backup-master: сервер бэкапов, агент и восстановление VPS
Один бинарник backupctl: - server: API для агентов и restore, веб-морда (один админ), SQLite, ретеншн GFS, heavy-части, уведомления ntfy, раздача agent.sh/restore.sh; - backup/plan: сбор по каталогу категорий (files/systemd/xui/compose), горячие дампы Postgres/Mongo/MySQL, снимок x-ui.db, docker-тома, digest образов, проверка crontab, потоковая отправка tar.zst; - restore: провижининг на основе vps_installer с теми же версиями пакетов, recipes 3x-ui/nebula/sing-box нужных версий, восстановление compose с заливкой дампов и закреплением образов по digest. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
1d44d067e9
53 files changed
+7278
No files matched your search
@@ -0,0 +1,186 @@
|
||||
package restore
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.hsrv.site/mrleo1nid/backup-master/internal/manifest"
|
||||
"gitea.hsrv.site/mrleo1nid/backup-master/internal/sysx"
|
||||
)
|
||||
|
||||
const pinFile = "compose.restore.yml"
|
||||
|
||||
// restoreCompose поднимает compose-проект: закрепляет образы по digest, создаёт
|
||||
// недостающие external-сети, заливает дампы БД и запускает проект.
|
||||
func (r *restorer) restoreCompose(c manifest.Category) error {
|
||||
info := c.Compose
|
||||
if info == nil {
|
||||
return fmt.Errorf("нет сведений о compose-проекте")
|
||||
}
|
||||
if !sysx.HasCommand("docker") {
|
||||
return fmt.Errorf("docker не установлен")
|
||||
}
|
||||
if len(info.ConfigFiles) == 0 {
|
||||
return fmt.Errorf("неизвестны compose-файлы проекта")
|
||||
}
|
||||
args := []string{"compose", "--project-directory", info.Dir, "-p", info.Project}
|
||||
for _, f := range info.ConfigFiles {
|
||||
args = append(args, "-f", f)
|
||||
}
|
||||
if pin, err := r.writePinFile(c); err != nil {
|
||||
return err
|
||||
} else if pin != "" {
|
||||
args = append(args, "-f", pin)
|
||||
r.todo = append(r.todo, fmt.Sprintf("%s: образы закреплены по digest в %s — добавляйте `-f %s` к docker compose, иначе подтянутся новые версии",
|
||||
c.Name, filepath.Join(info.Dir, pinFile), pinFile))
|
||||
}
|
||||
compose := func(extra ...string) error {
|
||||
return sysx.Run(r.ctx, "docker", append(slices.Clone(args), extra...)...)
|
||||
}
|
||||
|
||||
for _, n := range info.ExternalNetworks {
|
||||
if _, err := sysx.Output(r.ctx, "docker", "network", "inspect", n); err == nil {
|
||||
continue
|
||||
}
|
||||
if _, err := sysx.Output(r.ctx, "docker", "network", "create", n); err != nil {
|
||||
return fmt.Errorf("сеть %s: %w", n, err)
|
||||
}
|
||||
r.todo = append(r.todo, fmt.Sprintf("%s: external-сеть %s создана пустой — сервис, который её предоставлял, на этом сервере не восстановлен", c.Name, n))
|
||||
}
|
||||
|
||||
if err := compose("pull", "--ignore-buildable", "--quiet"); err != nil {
|
||||
r.warn("%s: pull: %v", c.Name, err)
|
||||
}
|
||||
if hasBuilt(info) {
|
||||
if err := compose("build"); err != nil {
|
||||
return fmt.Errorf("сборка образов: %w", err)
|
||||
}
|
||||
}
|
||||
if err := compose("create"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, d := range c.Dumps {
|
||||
if d.Service == "" {
|
||||
continue
|
||||
}
|
||||
if err := r.restoreDB(c, d, compose, args); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if info.Running {
|
||||
if err := compose("up", "-d"); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
r.warn("%s: на старом сервере проект был остановлен — не запускаю", c.Name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func hasBuilt(info *manifest.Compose) bool {
|
||||
for _, img := range info.Images {
|
||||
if img.Built {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// writePinFile пишет compose.restore.yml с образами по digest. Пустая строка — закреплять нечего.
|
||||
func (r *restorer) writePinFile(c manifest.Category) (string, error) {
|
||||
var services []string
|
||||
for name := range c.Compose.Images {
|
||||
services = append(services, name)
|
||||
}
|
||||
slices.Sort(services)
|
||||
var b strings.Builder
|
||||
b.WriteString("# Создано backup-master при восстановлении: образы закреплены по digest со старого сервера.\n")
|
||||
b.WriteString("services:\n")
|
||||
n := 0
|
||||
for _, svc := range services {
|
||||
img := c.Compose.Images[svc]
|
||||
if img.Built || img.Digest == "" {
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(&b, " %q:\n image: %q\n", svc, img.Digest)
|
||||
n++
|
||||
}
|
||||
if n == 0 {
|
||||
return "", nil
|
||||
}
|
||||
p := filepath.Join(c.Compose.Dir, pinFile)
|
||||
return p, os.WriteFile(p, []byte(b.String()), 0o644)
|
||||
}
|
||||
|
||||
var dbReady = map[string]string{
|
||||
"postgres": `pg_isready -q -h 127.0.0.1 -U "${POSTGRES_USER:-postgres}"`,
|
||||
"mongo": `(command -v mongosh >/dev/null && mongosh --quiet --host 127.0.0.1 --eval 'db.runCommand({ping:1}).ok') || mongo --quiet --host 127.0.0.1 --eval 'db.runCommand({ping:1}).ok'`,
|
||||
"mysql": `mysqladmin ping -h 127.0.0.1 -uroot -p"$MYSQL_ROOT_PASSWORD" --silent`,
|
||||
"mariadb": `A=$(command -v mariadb-admin || command -v mysqladmin); "$A" ping -h 127.0.0.1 -uroot -p"${MARIADB_ROOT_PASSWORD:-$MYSQL_ROOT_PASSWORD}" --silent`,
|
||||
}
|
||||
|
||||
var dbRestore = map[string]string{
|
||||
"postgres": `pg_restore -U "${POSTGRES_USER:-postgres}" -d "${POSTGRES_DB:-${POSTGRES_USER:-postgres}}" --clean --if-exists --no-owner`,
|
||||
"mongo": `if [ -n "$MONGO_INITDB_ROOT_USERNAME" ]; then exec mongorestore --quiet --archive --gzip --drop -u "$MONGO_INITDB_ROOT_USERNAME" -p "$MONGO_INITDB_ROOT_PASSWORD" --authenticationDatabase admin; else exec mongorestore --quiet --archive --gzip --drop; fi`,
|
||||
"mysql": `exec mysql -uroot -p"$MYSQL_ROOT_PASSWORD"`,
|
||||
"mariadb": `M=$(command -v mariadb || command -v mysql); exec "$M" -uroot -p"${MARIADB_ROOT_PASSWORD:-$MYSQL_ROOT_PASSWORD}"`,
|
||||
}
|
||||
|
||||
func (r *restorer) restoreDB(c manifest.Category, d manifest.Dump, compose func(...string) error, args []string) error {
|
||||
file := filepath.Join(r.staging, c.Name, "dumps", d.Name)
|
||||
if !exists(file) {
|
||||
return fmt.Errorf("дамп %s не найден в архиве", d.Name)
|
||||
}
|
||||
info("БД %s (%s): запуск", d.Service, d.Kind)
|
||||
if err := compose("up", "-d", d.Service); err != nil {
|
||||
return err
|
||||
}
|
||||
cid, err := sysx.Output(r.ctx, "docker", append(slices.Clone(args), "ps", "-q", d.Service)...)
|
||||
if err != nil || cid == "" {
|
||||
return fmt.Errorf("контейнер %s не найден: %v", d.Service, err)
|
||||
}
|
||||
cid = strings.Fields(cid)[0]
|
||||
if err := r.waitDB(cid, d.Kind); err != nil {
|
||||
return fmt.Errorf("БД %s не поднялась: %w", d.Service, err)
|
||||
}
|
||||
info("БД %s: заливка дампа %s", d.Service, d.Name)
|
||||
err = sysx.FromFile(r.ctx, file, "docker", "exec", "-i", cid, "sh", "-c", dbRestore[d.Kind])
|
||||
if err != nil {
|
||||
if d.Kind == "postgres" {
|
||||
// pg_restore возвращает 1 и на некритичных предупреждениях — данные обычно на месте
|
||||
r.warn("%s: pg_restore завершился с предупреждениями, проверьте вывод выше", c.Name)
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("заливка дампа %s: %w", d.Name, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// waitDB ждёт, пока БД стабильно принимает TCP-подключения: init-скрипты образов
|
||||
// поднимают временный сервер без сети, поэтому проверка через 127.0.0.1.
|
||||
func (r *restorer) waitDB(cid, kind string) error {
|
||||
probe := dbReady[kind]
|
||||
deadline := time.Now().Add(4 * time.Minute)
|
||||
okInRow := 0
|
||||
for time.Now().Before(deadline) {
|
||||
if _, err := sysx.Output(r.ctx, "docker", "exec", cid, "sh", "-c", probe); err == nil {
|
||||
if okInRow++; okInRow >= 3 {
|
||||
return nil
|
||||
}
|
||||
} else {
|
||||
okInRow = 0
|
||||
}
|
||||
select {
|
||||
case <-r.ctx.Done():
|
||||
return r.ctx.Err()
|
||||
case <-time.After(2 * time.Second):
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("таймаут ожидания")
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package restore
|
||||
|
||||
import "gitea.hsrv.site/mrleo1nid/backup-master/internal/manifest"
|
||||
|
||||
// order сортирует категории так, чтобы restore_after шли раньше зависящих от них.
|
||||
// Порядок манифеста сохраняется, отсутствующие зависимости игнорируются, при цикле
|
||||
// оставшиеся категории идут в исходном порядке.
|
||||
func order(cats []manifest.Category) []manifest.Category {
|
||||
idx := map[string]int{}
|
||||
for i, c := range cats {
|
||||
idx[c.Name] = i
|
||||
}
|
||||
done := make([]bool, len(cats))
|
||||
var out []manifest.Category
|
||||
for len(out) < len(cats) {
|
||||
progressed := false
|
||||
for i, c := range cats {
|
||||
if done[i] {
|
||||
continue
|
||||
}
|
||||
ready := true
|
||||
for _, dep := range c.RestoreAfter {
|
||||
if j, ok := idx[dep]; ok && !done[j] && j != i {
|
||||
ready = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if ready {
|
||||
done[i] = true
|
||||
out = append(out, c)
|
||||
progressed = true
|
||||
}
|
||||
}
|
||||
if !progressed {
|
||||
for i, c := range cats {
|
||||
if !done[i] {
|
||||
done[i] = true
|
||||
out = append(out, c)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Базовая подготовка нового сервера перед восстановлением из backup-master.
|
||||
# Основано на vps_installer/setup.sh (https://gitea.hsrv.site/mrleo1nid/vps_installer),
|
||||
# но рассчитано на запуск из `backupctl restore`:
|
||||
# - все параметры приходят через окружение (их выставляет restore по манифесту);
|
||||
# - интерактивны только NEW_USER (по умолчанию DEFAULT_USER) и NEW_PASS;
|
||||
# - apt-пакеты ставятся точных версий со старого сервера, если они есть в репозитории;
|
||||
# - 3x-ui, Nebula, sing-box ставит restore (нужной версии), здесь их нет;
|
||||
# - без перезагрузки — о ней напомнит restore в конце.
|
||||
#
|
||||
# Окружение:
|
||||
# NEW_USER, NEW_PASS, DEFAULT_USER, NEW_HOSTNAME,
|
||||
# INSTALL_BLOCKLIST, BLOCKLIST_URL, HARDEN_SSH (y/n),
|
||||
# INSTALL_CADDY, CADDY_VERSION, INSTALL_TELEGRAF, TELEGRAF_VERSION,
|
||||
# INSTALL_DOCKER, DOCKER_CE_VERSION, DOCKER_CLI_VERSION, COMPOSE_VERSION
|
||||
|
||||
set -Eeuo pipefail
|
||||
|
||||
REPO_RAW="https://gitea.hsrv.site/mrleo1nid/vps_installer/raw/branch/main"
|
||||
STATUSPAGE_DIR="/var/www/statuspage"
|
||||
LOG_FILE="/var/log/vps-setup.log"
|
||||
DEFAULT_BLOCKLIST_URL="https://raw.githubusercontent.com/jahlib/somebody-knocked-my-ssh/refs/heads/main/ban.txt"
|
||||
BAN_SCRIPT_URL="https://raw.githubusercontent.com/jahlib/somebody-knocked-my-ssh/refs/heads/main/ban.sh"
|
||||
# 00- — чтобы файл читался раньше 50-cloud-init.conf: sshd берёт ПЕРВОЕ найденное значение
|
||||
SSHD_HARDENING="/etc/ssh/sshd_config.d/00-hardening.conf"
|
||||
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
export NEEDRESTART_MODE=a
|
||||
|
||||
: "${INSTALL_BLOCKLIST:=y}" "${HARDEN_SSH:=y}" "${INSTALL_CADDY:=n}" "${INSTALL_TELEGRAF:=n}" "${INSTALL_DOCKER:=n}"
|
||||
: "${BLOCKLIST_URL:=$DEFAULT_BLOCKLIST_URL}"
|
||||
|
||||
# ---------- вывод ----------
|
||||
if [[ -t 1 ]]; then
|
||||
C_RED=$'\e[31m' C_GRN=$'\e[32m' C_YLW=$'\e[33m' C_BLU=$'\e[1;34m' C_RST=$'\e[0m'
|
||||
else
|
||||
C_RED='' C_GRN='' C_YLW='' C_BLU='' C_RST=''
|
||||
fi
|
||||
step() { echo; echo "${C_BLU}==> $*${C_RST}"; }
|
||||
info() { echo " $*"; }
|
||||
ok() { echo "${C_GRN} ✓ $*${C_RST}"; }
|
||||
warn() { echo "${C_YLW} ! $*${C_RST}"; }
|
||||
die() { echo "${C_RED}ОШИБКА: $*${C_RST}" >&2; exit 1; }
|
||||
|
||||
trap 'echo "${C_RED}Скрипт упал на строке $LINENO: $BASH_COMMAND${C_RST}" >&2' ERR
|
||||
|
||||
# ---------- ввод (через /dev/tty) ----------
|
||||
TTY=/dev/tty
|
||||
have_tty() { [[ -r $TTY && -w $TTY ]] && { : <"$TTY"; } 2>/dev/null; }
|
||||
|
||||
ask() { # ask VAR "вопрос" [default]
|
||||
local __var=$1 __q=$2 __def=${3:-} __ans
|
||||
[[ -n ${!__var:-} ]] && return 0
|
||||
have_tty || die "Нет терминала для вопроса «$__q». Задайте переменную $__var."
|
||||
if [[ -n $__def ]]; then
|
||||
read -r -p "$__q [$__def]: " __ans <"$TTY"
|
||||
__ans=${__ans:-$__def}
|
||||
else
|
||||
read -r -p "$__q: " __ans <"$TTY"
|
||||
fi
|
||||
printf -v "$__var" '%s' "$__ans"
|
||||
}
|
||||
|
||||
ask_password() { # ask_password VAR
|
||||
local __var=$1 p1 p2
|
||||
[[ -n ${!__var:-} ]] && return 0
|
||||
have_tty || die "Нет терминала для ввода пароля. Задайте переменную $__var."
|
||||
while true; do
|
||||
read -r -s -p "Пароль для $NEW_USER: " p1 <"$TTY"; echo >"$TTY"
|
||||
read -r -s -p "Повторите пароль: " p2 <"$TTY"; echo >"$TTY"
|
||||
if [[ -z $p1 ]]; then echo "Пароль не может быть пустым." >"$TTY"
|
||||
elif [[ $p1 != "$p2" ]]; then echo "Пароли не совпадают, ещё раз." >"$TTY"
|
||||
elif (( ${#p1} < 8 )); then echo "Слишком короткий, минимум 8 символов." >"$TTY"
|
||||
else break
|
||||
fi
|
||||
done
|
||||
printf -v "$__var" '%s' "$p1"
|
||||
}
|
||||
|
||||
apt_install() {
|
||||
apt-get install -y -q -o Dpkg::Options::=--force-confdef -o Dpkg::Options::=--force-confold "$@"
|
||||
}
|
||||
|
||||
# pkg_spec PKG VERSION — "pkg=version", если такая версия есть в репозитории, иначе "pkg"
|
||||
pkg_spec() {
|
||||
local pkg=$1 ver=${2:-}
|
||||
if [[ -n $ver ]] && apt-cache madison "$pkg" 2>/dev/null | awk -F'|' '{gsub(/ /,"",$2); print $2}' | grep -qxF "$ver"; then
|
||||
echo "$pkg=$ver"
|
||||
else
|
||||
[[ -n $ver ]] && warn "$pkg $ver нет в репозитории — будет установлена последняя версия" >&2
|
||||
echo "$pkg"
|
||||
fi
|
||||
}
|
||||
|
||||
# ---------- проверки ----------
|
||||
[[ $EUID -eq 0 ]] || die "Нужен root"
|
||||
. /etc/os-release
|
||||
[[ ${ID:-} == ubuntu ]] || die "Скрипт рассчитан на Ubuntu (обнаружено: ${PRETTY_NAME:-?})"
|
||||
[[ ${VERSION_ID:-} == 24.04 ]] || warn "Скрипт писался под Ubuntu 24.04, у вас ${PRETTY_NAME}. Продолжаю."
|
||||
ARCH=$(dpkg --print-architecture)
|
||||
|
||||
CLIENT_IP=""
|
||||
if [[ -n ${SSH_CLIENT:-} ]]; then CLIENT_IP=${SSH_CLIENT%% *}
|
||||
else CLIENT_IP=$(who am i 2>/dev/null | sed -nE 's/.*\(([0-9a-fA-F.:]+)\).*/\1/p' || true)
|
||||
fi
|
||||
|
||||
# ---------- параметры ----------
|
||||
step "Параметры подготовки"
|
||||
while true; do
|
||||
ask NEW_USER "Логин пользователя с sudo" "${DEFAULT_USER:-}"
|
||||
if [[ $NEW_USER =~ ^[a-z_][a-z0-9_-]{0,31}$ && $NEW_USER != root ]]; then break; fi
|
||||
echo "Недопустимый логин (строчные латинские буквы, цифры, _ и -; не root)."
|
||||
NEW_USER=""
|
||||
done
|
||||
USER_EXISTS=n
|
||||
if id "$NEW_USER" &>/dev/null; then
|
||||
USER_EXISTS=y
|
||||
info "Пользователь $NEW_USER уже есть — пароль не меняю (задайте NEW_PASS, чтобы сменить)"
|
||||
else
|
||||
ask_password NEW_PASS
|
||||
fi
|
||||
[[ -n ${NEW_HOSTNAME:-} ]] || ask NEW_HOSTNAME "Имя машины (hostname)" "$(hostname)"
|
||||
NEW_HOSTNAME=${NEW_HOSTNAME,,}
|
||||
|
||||
touch "$LOG_FILE" && chmod 600 "$LOG_FILE"
|
||||
exec > >(tee -a "$LOG_FILE") 2>&1
|
||||
echo "===== $(date '+%F %T') backup-master provision start =====" >>"$LOG_FILE"
|
||||
|
||||
# ---------- 1. Обновление системы ----------
|
||||
step "1. Обновление системы"
|
||||
apt-get update -q
|
||||
apt-get -y -q -o Dpkg::Options::=--force-confdef -o Dpkg::Options::=--force-confold upgrade
|
||||
apt_install unattended-upgrades curl ca-certificates gnupg
|
||||
cat >/etc/apt/apt.conf.d/20auto-upgrades <<'EOF'
|
||||
APT::Periodic::Update-Package-Lists "1";
|
||||
APT::Periodic::Unattended-Upgrade "1";
|
||||
EOF
|
||||
ok "Система обновлена, автообновления безопасности включены"
|
||||
|
||||
# ---------- 2. Пользователь с sudo ----------
|
||||
step "2. Пользователь $NEW_USER"
|
||||
if [[ $USER_EXISTS == n ]]; then
|
||||
useradd -m -s /bin/bash "$NEW_USER"
|
||||
ok "Пользователь создан"
|
||||
fi
|
||||
if [[ -n ${NEW_PASS:-} ]]; then
|
||||
echo "$NEW_USER:$NEW_PASS" | chpasswd
|
||||
unset NEW_PASS
|
||||
fi
|
||||
usermod -aG sudo "$NEW_USER"
|
||||
SUDOERS_FILE="/etc/sudoers.d/$NEW_USER"
|
||||
echo "$NEW_USER ALL=(ALL) NOPASSWD:ALL" >"$SUDOERS_FILE.tmp"
|
||||
chmod 440 "$SUDOERS_FILE.tmp"
|
||||
if visudo -c -f "$SUDOERS_FILE.tmp" >/dev/null; then
|
||||
mv "$SUDOERS_FILE.tmp" "$SUDOERS_FILE"
|
||||
ok "sudo без пароля ($SUDOERS_FILE)"
|
||||
else
|
||||
rm -f "$SUDOERS_FILE.tmp"
|
||||
die "visudo отверг файл sudoers"
|
||||
fi
|
||||
|
||||
# ---------- 3. Имя машины ----------
|
||||
step "3. Имя машины: $NEW_HOSTNAME"
|
||||
SHORT_HOSTNAME=${NEW_HOSTNAME%%.*}
|
||||
hostnamectl set-hostname "$NEW_HOSTNAME"
|
||||
HOSTS_LINE="127.0.1.1 $NEW_HOSTNAME"
|
||||
[[ $SHORT_HOSTNAME != "$NEW_HOSTNAME" ]] && HOSTS_LINE+=" $SHORT_HOSTNAME"
|
||||
if grep -q '^127\.0\.1\.1[[:space:]]' /etc/hosts; then
|
||||
sed -i "s/^127\.0\.1\.1[[:space:]].*/$HOSTS_LINE/" /etc/hosts
|
||||
else
|
||||
echo "$HOSTS_LINE" >>/etc/hosts
|
||||
fi
|
||||
if [[ -d /etc/cloud/cloud.cfg.d ]]; then
|
||||
echo "preserve_hostname: true" >/etc/cloud/cloud.cfg.d/99-preserve-hostname.cfg
|
||||
fi
|
||||
ok "hostname: $(hostname)"
|
||||
|
||||
# ---------- 4. iptables legacy ----------
|
||||
step "4. Переключение на iptables-legacy"
|
||||
apt_install iptables ipset
|
||||
for t in iptables ip6tables arptables ebtables; do
|
||||
if [[ -x /usr/sbin/$t-legacy ]] && update-alternatives --list "$t" &>/dev/null; then
|
||||
update-alternatives --set "$t" "/usr/sbin/$t-legacy" >/dev/null
|
||||
fi
|
||||
done
|
||||
iptables --version | grep -q legacy || die "iptables не переключился на legacy"
|
||||
ok "$(iptables --version)"
|
||||
|
||||
# ---------- 5. Fail2ban ----------
|
||||
step "5. Fail2ban"
|
||||
apt_install fail2ban python3-systemd
|
||||
IGNORE_IP="127.0.0.1/8 ::1 192.168.0.0/16 10.0.0.0/8"
|
||||
if [[ -n $CLIENT_IP ]]; then
|
||||
IGNORE_IP+=" $CLIENT_IP"
|
||||
info "Ваш текущий IP $CLIENT_IP добавлен в ignoreip"
|
||||
fi
|
||||
cat >/etc/fail2ban/jail.local <<EOF
|
||||
[DEFAULT]
|
||||
bantime = 1h
|
||||
findtime = 10m
|
||||
maxretry = 5
|
||||
# свои IP, чтобы не забанить себя
|
||||
ignoreip = $IGNORE_IP
|
||||
|
||||
[sshd]
|
||||
enabled = true
|
||||
backend = systemd
|
||||
EOF
|
||||
systemctl enable fail2ban >/dev/null 2>&1
|
||||
systemctl restart fail2ban
|
||||
ok "Fail2ban включён"
|
||||
|
||||
# ---------- 6. UFW ----------
|
||||
# правила со старого сервера восстановит категория system (ufw reload после распаковки)
|
||||
step "6. Firewall (UFW)"
|
||||
apt_install ufw
|
||||
ufw default deny incoming >/dev/null
|
||||
ufw default allow outgoing >/dev/null
|
||||
ufw allow OpenSSH >/dev/null
|
||||
mkdir -p /run/sshd
|
||||
SSH_PORT=$(sshd -T 2>/dev/null | awk '/^port /{print $2; exit}' || true)
|
||||
if [[ -n $SSH_PORT && $SSH_PORT != 22 ]]; then
|
||||
ufw allow "$SSH_PORT/tcp" >/dev/null
|
||||
fi
|
||||
ufw allow 80/tcp >/dev/null
|
||||
ufw allow 443/tcp >/dev/null
|
||||
ufw --force enable >/dev/null
|
||||
ok "UFW включён: SSH, 80, 443"
|
||||
|
||||
# ---------- 7. Блокировка IP по списку ----------
|
||||
if [[ $INSTALL_BLOCKLIST == y ]]; then
|
||||
step "7. Блокировка IP по внешнему списку"
|
||||
curl -fsSL "$BAN_SCRIPT_URL" -o /usr/local/sbin/ban.sh
|
||||
chmod +x /usr/local/sbin/ban.sh
|
||||
if [[ $BLOCKLIST_URL != "$DEFAULT_BLOCKLIST_URL" ]]; then
|
||||
ESCAPED_URL=$(printf '%s' "$BLOCKLIST_URL" | sed 's/[&|\\]/\\&/g')
|
||||
sed -i "s|^URL=.*|URL=\"$ESCAPED_URL\"|" /usr/local/sbin/ban.sh
|
||||
fi
|
||||
bash /usr/local/sbin/ban.sh || warn "ban.sh завершился с ошибкой"
|
||||
cat >/etc/cron.d/ban-blacklist <<'EOF'
|
||||
SHELL=/bin/bash
|
||||
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
||||
@reboot root sleep 60 && bash /usr/local/sbin/ban.sh >/dev/null 2>&1
|
||||
0 */6 * * * root bash /usr/local/sbin/ban.sh >/dev/null 2>&1
|
||||
EOF
|
||||
chmod 644 /etc/cron.d/ban-blacklist
|
||||
ok "ipset blacklist настроен"
|
||||
else
|
||||
step "7. Блокировка IP — пропущено"
|
||||
fi
|
||||
|
||||
# ---------- 8. Статус-страница ----------
|
||||
step "8. Статус-страница $STATUSPAGE_DIR"
|
||||
if [[ -f $STATUSPAGE_DIR/index.html ]]; then
|
||||
info "уже есть — не трогаю"
|
||||
else
|
||||
install -d -m 755 "$STATUSPAGE_DIR"
|
||||
curl -fsSL "$REPO_RAW/statuspage/index.html" -o "$STATUSPAGE_DIR/index.html" || warn "статус-страница не скачалась"
|
||||
chmod 644 "$STATUSPAGE_DIR/index.html" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# ---------- 9. Docker ----------
|
||||
if [[ $INSTALL_DOCKER == y ]]; then
|
||||
step "9. Docker + Docker Compose"
|
||||
for pkg in docker.io docker-doc docker-compose docker-compose-v2 podman-docker containerd runc; do
|
||||
dpkg -s "$pkg" &>/dev/null && apt-get remove -y -q "$pkg"
|
||||
done
|
||||
install -m 0755 -d /etc/apt/keyrings
|
||||
curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
|
||||
chmod a+r /etc/apt/keyrings/docker.asc
|
||||
echo "deb [arch=$ARCH signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu $VERSION_CODENAME stable" \
|
||||
>/etc/apt/sources.list.d/docker.list
|
||||
apt-get update -q
|
||||
apt_install --allow-downgrades \
|
||||
"$(pkg_spec docker-ce "${DOCKER_CE_VERSION:-}")" \
|
||||
"$(pkg_spec docker-ce-cli "${DOCKER_CLI_VERSION:-}")" \
|
||||
containerd.io docker-buildx-plugin \
|
||||
"$(pkg_spec docker-compose-plugin "${COMPOSE_VERSION:-}")"
|
||||
systemctl enable --now docker.service containerd.service >/dev/null 2>&1
|
||||
usermod -aG docker "$NEW_USER"
|
||||
ok "$(docker --version), $(docker compose version | head -n1)"
|
||||
else
|
||||
step "9. Docker — пропущено"
|
||||
fi
|
||||
|
||||
# ---------- 10. Telegraf ----------
|
||||
if [[ $INSTALL_TELEGRAF == y ]]; then
|
||||
step "10. Telegraf"
|
||||
mkdir -p /etc/apt/keyrings
|
||||
curl -fsSL https://repos.influxdata.com/influxdata-archive.key \
|
||||
| gpg --batch --yes --dearmor -o /etc/apt/keyrings/influxdata.gpg
|
||||
echo "deb [signed-by=/etc/apt/keyrings/influxdata.gpg] https://repos.influxdata.com/ubuntu stable main" \
|
||||
>/etc/apt/sources.list.d/influxdata.list
|
||||
apt-get update -q
|
||||
apt_install --allow-downgrades "$(pkg_spec telegraf "${TELEGRAF_VERSION:-}")" influxdata-archive-keyring
|
||||
# запуск — после восстановления конфига, по состоянию со старого сервера
|
||||
systemctl disable --now telegraf >/dev/null 2>&1 || true
|
||||
ok "$(telegraf --version) установлен"
|
||||
else
|
||||
step "10. Telegraf — пропущено"
|
||||
fi
|
||||
|
||||
# ---------- 11. Caddy ----------
|
||||
if [[ $INSTALL_CADDY == y ]]; then
|
||||
step "11. Caddy"
|
||||
apt_install debian-keyring debian-archive-keyring apt-transport-https
|
||||
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' \
|
||||
| gpg --batch --yes --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
|
||||
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' \
|
||||
>/etc/apt/sources.list.d/caddy-stable.list
|
||||
apt-get update -q
|
||||
apt_install --allow-downgrades "$(pkg_spec caddy "${CADDY_VERSION:-}")"
|
||||
ok "Caddy $(caddy version | awk '{print $1}') установлен"
|
||||
else
|
||||
step "11. Caddy — пропущено"
|
||||
fi
|
||||
|
||||
# ---------- 12. Отключение доступа root ----------
|
||||
if [[ $HARDEN_SSH == y ]]; then
|
||||
step "12. Отключение доступа root"
|
||||
cat >"$SSHD_HARDENING" <<'EOF'
|
||||
PermitRootLogin no
|
||||
PasswordAuthentication yes
|
||||
KbdInteractiveAuthentication no
|
||||
PubkeyAuthentication yes
|
||||
EOF
|
||||
mkdir -p /run/sshd
|
||||
sshd -t || { rm -f "$SSHD_HARDENING"; die "sshd -t нашёл ошибку в конфигурации"; }
|
||||
systemctl restart ssh
|
||||
passwd -l root >/dev/null
|
||||
ok "Вход root по SSH запрещён, пароль root заблокирован"
|
||||
else
|
||||
step "12. Отключение доступа root — пропущено"
|
||||
fi
|
||||
|
||||
ok "Базовая подготовка завершена"
|
||||
@@ -0,0 +1,240 @@
|
||||
package restore
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// recipe ставит бинарник нужной версии под архитектуру arch (amd64/arm64).
|
||||
type recipe func(ctx context.Context, version, arch string) error
|
||||
|
||||
var recipes = map[string]recipe{
|
||||
"nebula": installNebula,
|
||||
"xui": installXUI,
|
||||
"sing-box-extended": installSingBoxExtended,
|
||||
}
|
||||
|
||||
func installNebula(ctx context.Context, version, arch string) error {
|
||||
v := strings.TrimPrefix(version, "v")
|
||||
url := fmt.Sprintf("https://github.com/slackhq/nebula/releases/download/v%s/nebula-linux-%s.tar.gz", v, arch)
|
||||
return fetchTarGz(ctx, url, func(name string) (string, bool) {
|
||||
switch filepath.Base(name) {
|
||||
case "nebula", "nebula-cert":
|
||||
return "/usr/local/bin/" + filepath.Base(name), true
|
||||
}
|
||||
return "", false
|
||||
})
|
||||
}
|
||||
|
||||
// installXUI ставит 3x-ui из release-архива нужного тега — без интерактивного install.sh.
|
||||
// Настройки панели (порт, путь, логин) приходят из восстановленной x-ui.db.
|
||||
func installXUI(ctx context.Context, version, arch string) error {
|
||||
v := version
|
||||
if !strings.HasPrefix(v, "v") {
|
||||
v = "v" + v
|
||||
}
|
||||
url := fmt.Sprintf("https://github.com/MHSanaei/3x-ui/releases/download/%s/x-ui-linux-%s.tar.gz", v, arch)
|
||||
_ = os.RemoveAll("/usr/local/x-ui")
|
||||
if err := fetchTarGz(ctx, url, func(name string) (string, bool) {
|
||||
name = strings.TrimPrefix(name, "./")
|
||||
if !strings.HasPrefix(name, "x-ui/") && name != "x-ui" {
|
||||
return "", false
|
||||
}
|
||||
return "/usr/local/" + name, true
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, p := range []string{"/usr/local/x-ui/x-ui"} {
|
||||
_ = os.Chmod(p, 0o755)
|
||||
}
|
||||
if bins, _ := filepath.Glob("/usr/local/x-ui/bin/xray-linux-*"); len(bins) > 0 {
|
||||
for _, b := range bins {
|
||||
_ = os.Chmod(b, 0o755)
|
||||
}
|
||||
}
|
||||
// в разных версиях юнит называется по-разному
|
||||
var unit string
|
||||
for _, n := range []string{"x-ui.service.debian", "x-ui.service"} {
|
||||
if p := "/usr/local/x-ui/" + n; exists(p) {
|
||||
unit = p
|
||||
break
|
||||
}
|
||||
}
|
||||
if unit == "" {
|
||||
return errors.New("в архиве 3x-ui нет x-ui.service")
|
||||
}
|
||||
if err := copyFile(unit, "/etc/systemd/system/x-ui.service", 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
// консольная утилита x-ui того же тега (не критично)
|
||||
script := fmt.Sprintf("https://raw.githubusercontent.com/MHSanaei/3x-ui/%s/x-ui.sh", v)
|
||||
if err := download(ctx, script, "/usr/bin/x-ui", 0o755); err != nil {
|
||||
fmt.Printf(" ! x-ui.sh не скачан: %v\n", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func installSingBoxExtended(ctx context.Context, version, arch string) error {
|
||||
v := strings.TrimPrefix(version, "v")
|
||||
url := fmt.Sprintf("https://gitea.digitalforest.my/hesoyam/sing-box-extended-mirror/releases/download/v%s/sing-box-%s-linux-%s.tar.gz", v, v, arch)
|
||||
return fetchTarGz(ctx, url, func(name string) (string, bool) {
|
||||
if filepath.Base(name) == "sing-box" {
|
||||
return "/usr/local/bin/sing-box", true
|
||||
}
|
||||
return "", false
|
||||
})
|
||||
}
|
||||
|
||||
var httpClient = &http.Client{Timeout: 10 * time.Minute}
|
||||
|
||||
func download(ctx context.Context, url, dest string, mode os.FileMode) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("%s: %s", url, resp.Status)
|
||||
}
|
||||
return writeAtomic(dest, resp.Body, mode)
|
||||
}
|
||||
|
||||
// fetchTarGz скачивает tar.gz и распаковывает выбранные записи.
|
||||
func fetchTarGz(ctx context.Context, url string, route func(name string) (string, bool)) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("%s: %s", url, resp.Status)
|
||||
}
|
||||
gz, err := gzip.NewReader(resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: %w", url, err)
|
||||
}
|
||||
tr := tar.NewReader(gz)
|
||||
found := 0
|
||||
for {
|
||||
hdr, err := tr.Next()
|
||||
if errors.Is(err, io.EOF) {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: %w", url, err)
|
||||
}
|
||||
if strings.Contains(hdr.Name, "..") {
|
||||
continue
|
||||
}
|
||||
dest, ok := route(hdr.Name)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
switch hdr.Typeflag {
|
||||
case tar.TypeDir:
|
||||
if err := os.MkdirAll(dest, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
case tar.TypeReg:
|
||||
if err := writeAtomic(dest, tr, os.FileMode(hdr.Mode).Perm()|0o400); err != nil {
|
||||
return err
|
||||
}
|
||||
found++
|
||||
case tar.TypeSymlink:
|
||||
_ = os.Remove(dest)
|
||||
if err := os.Symlink(hdr.Linkname, dest); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
if found == 0 {
|
||||
return fmt.Errorf("%s: нужные файлы в архиве не найдены", url)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeAtomic(dest string, r io.Reader, mode os.FileMode) error {
|
||||
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := dest + ".bm-tmp"
|
||||
f, err := os.OpenFile(tmp, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, mode)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := io.Copy(f, r); err != nil {
|
||||
f.Close()
|
||||
os.Remove(tmp)
|
||||
return err
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
os.Remove(tmp)
|
||||
return err
|
||||
}
|
||||
if err := os.Chmod(tmp, mode); err != nil {
|
||||
os.Remove(tmp)
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp, dest)
|
||||
}
|
||||
|
||||
func copyFile(src, dest string, mode os.FileMode) error {
|
||||
f, err := os.Open(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
return writeAtomic(dest, f, mode)
|
||||
}
|
||||
|
||||
// copyTree копирует дерево src в dest с правами и симлинками (fallback-бинарники из бэкапа).
|
||||
func copyTree(src, dest string) error {
|
||||
return filepath.WalkDir(src, func(p string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rel, _ := filepath.Rel(src, p)
|
||||
target := filepath.Join(dest, rel)
|
||||
fi, err := os.Lstat(p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch {
|
||||
case fi.IsDir():
|
||||
return os.MkdirAll(target, fi.Mode().Perm()|0o700)
|
||||
case fi.Mode()&os.ModeSymlink != 0:
|
||||
link, err := os.Readlink(p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_ = os.Remove(target)
|
||||
return os.Symlink(link, target)
|
||||
case fi.Mode().IsRegular():
|
||||
return copyFile(p, target, fi.Mode().Perm())
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func exists(p string) bool {
|
||||
_, err := os.Lstat(p)
|
||||
return err == nil
|
||||
}
|
||||
@@ -0,0 +1,552 @@
|
||||
// Package restore восстанавливает сервер из бэкапа: базовая подготовка, установка
|
||||
// тех же версий софта, раскладка файлов и данных, запуск сервисов.
|
||||
package restore
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
_ "embed"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"hash"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.hsrv.site/mrleo1nid/backup-master/internal/api"
|
||||
"gitea.hsrv.site/mrleo1nid/backup-master/internal/archive"
|
||||
"gitea.hsrv.site/mrleo1nid/backup-master/internal/catalog"
|
||||
"gitea.hsrv.site/mrleo1nid/backup-master/internal/manifest"
|
||||
"gitea.hsrv.site/mrleo1nid/backup-master/internal/sysx"
|
||||
)
|
||||
|
||||
//go:embed provision.sh
|
||||
var provisionScript []byte
|
||||
|
||||
type Options struct {
|
||||
Server string
|
||||
Token string
|
||||
BackupID int64
|
||||
User string // пользователь с sudo на новом сервере
|
||||
SkipProvision bool
|
||||
SkipHeavy bool
|
||||
Yes bool // не спрашивать подтверждение
|
||||
StagingDir string
|
||||
KeepStaging bool
|
||||
}
|
||||
|
||||
type restorer struct {
|
||||
ctx context.Context
|
||||
opts Options
|
||||
client *api.Client
|
||||
m *manifest.Manifest
|
||||
backupID int64
|
||||
staging string
|
||||
arch string
|
||||
volumes map[string]string // имя тома → mountpoint
|
||||
warnings []string
|
||||
todo []string
|
||||
}
|
||||
|
||||
func (r *restorer) warn(format string, a ...any) {
|
||||
msg := fmt.Sprintf(format, a...)
|
||||
r.warnings = append(r.warnings, msg)
|
||||
fmt.Println(" ! " + msg)
|
||||
}
|
||||
|
||||
func step(format string, a ...any) { fmt.Printf("\n==> "+format+"\n", a...) }
|
||||
func info(format string, a ...any) { fmt.Printf(" "+format+"\n", a...) }
|
||||
|
||||
func Run(ctx context.Context, opts Options) error {
|
||||
if os.Geteuid() != 0 {
|
||||
return errors.New("restore нужно запускать от root")
|
||||
}
|
||||
if opts.Token == "" {
|
||||
return errors.New("нужен --token (команда восстановления выдаётся в веб-морде)")
|
||||
}
|
||||
r := &restorer{ctx: ctx, opts: opts, client: api.NewClient(opts.Server, opts.Token), volumes: map[string]string{}}
|
||||
|
||||
rm, err := r.client.RestoreManifest(ctx, opts.BackupID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("манифест: %w", err)
|
||||
}
|
||||
var m manifest.Manifest
|
||||
if err := json.Unmarshal(rm.Manifest, &m); err != nil {
|
||||
return fmt.Errorf("манифест: %w", err)
|
||||
}
|
||||
r.m, r.backupID = &m, rm.BackupID
|
||||
r.arch = sysx.DpkgArch(ctx)
|
||||
|
||||
r.summary(rm)
|
||||
if !opts.Yes && !confirm("Восстановить этот бэкап на ТЕКУЩИЙ сервер? Файлы будут перезаписаны") {
|
||||
return errors.New("отменено")
|
||||
}
|
||||
|
||||
r.staging = opts.StagingDir
|
||||
if r.staging == "" {
|
||||
r.staging = "/var/tmp/backup-restore"
|
||||
}
|
||||
r.staging = filepath.Join(r.staging, fmt.Sprint(rm.BackupID))
|
||||
if err := os.MkdirAll(r.staging, 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
if !opts.KeepStaging {
|
||||
defer os.RemoveAll(r.staging) // там дампы баз и ключи
|
||||
}
|
||||
|
||||
if !opts.SkipProvision {
|
||||
if err := r.provision(); err != nil {
|
||||
return fmt.Errorf("подготовка сервера: %w", err)
|
||||
}
|
||||
}
|
||||
if err := r.prepareVolumes(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
parts := map[string]api.PartInfo{}
|
||||
for _, p := range rm.Parts {
|
||||
parts[p.Name] = p
|
||||
}
|
||||
main, ok := parts[manifest.MainPart]
|
||||
if !ok {
|
||||
return errors.New("в бэкапе нет основной части")
|
||||
}
|
||||
step("Распаковка основной части (%s)", human(main.Size))
|
||||
if err := r.extractPart(main); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, name := range m.Parts {
|
||||
if name == manifest.MainPart {
|
||||
continue
|
||||
}
|
||||
p, ok := parts[name]
|
||||
switch {
|
||||
case !ok:
|
||||
r.warn("heavy-часть %s удалена ротацией — данные категории %s останутся пустыми", name, name)
|
||||
case opts.SkipHeavy:
|
||||
r.warn("heavy-часть %s пропущена (--skip-heavy)", name)
|
||||
default:
|
||||
step("Распаковка heavy-части %s (%s)", name, human(p.Size))
|
||||
if err := r.extractPart(p); err != nil {
|
||||
r.warn("heavy-часть %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
// heavy-категории, не попавшие в этот бэкап
|
||||
for _, c := range m.Categories {
|
||||
if c.HeavySkipped {
|
||||
r.warn("%s: heavy-данные (%s) в этот бэкап не входили — выберите бэкап с heavy-частью, если они нужны", c.Name, strings.Join(c.Heavy, ", "))
|
||||
}
|
||||
}
|
||||
|
||||
_ = sysx.Run(ctx, "systemctl", "daemon-reload")
|
||||
var failed []string
|
||||
for _, c := range order(m.Categories) {
|
||||
step("Категория %s (%s)", c.Name, c.Type)
|
||||
if err := r.restoreCategory(c); err != nil {
|
||||
r.warn("%s: %v", c.Name, err)
|
||||
failed = append(failed, c.Name)
|
||||
}
|
||||
}
|
||||
r.finish(failed)
|
||||
if len(failed) > 0 {
|
||||
return fmt.Errorf("с ошибками восстановлены категории: %s", strings.Join(failed, ", "))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *restorer) summary(rm *api.RestoreManifest) {
|
||||
m := r.m
|
||||
step("Бэкап #%d %s от %s", rm.BackupID, m.Domain, rm.CreatedAt.Local().Format("02.01.2006 15:04"))
|
||||
info("старый сервер: %s, %s (%s)", m.Hostname, m.OS.PrettyName, m.OS.Arch)
|
||||
osr := sysx.OSRelease()
|
||||
info("этот сервер: %s (%s)", osr["PRETTY_NAME"], r.arch)
|
||||
if m.OS.Arch != "" && r.arch != "" && m.OS.Arch != r.arch {
|
||||
r.warn("архитектура отличается (%s → %s): fallback-бинарники из бэкапа не подойдут", m.OS.Arch, r.arch)
|
||||
}
|
||||
if osr["VERSION_ID"] != "" && m.OS.VersionID != "" && osr["VERSION_ID"] != m.OS.VersionID {
|
||||
r.warn("версия ОС отличается: %s → %s", m.OS.VersionID, osr["VERSION_ID"])
|
||||
}
|
||||
for _, c := range m.Categories {
|
||||
line := c.Name + " (" + c.Type
|
||||
if c.Version != "" {
|
||||
line += " " + c.Version
|
||||
}
|
||||
line += ")"
|
||||
if c.Compose != nil {
|
||||
line += " " + c.Compose.Dir
|
||||
}
|
||||
info("• %s", line)
|
||||
}
|
||||
var parts []string
|
||||
for _, p := range rm.Parts {
|
||||
parts = append(parts, fmt.Sprintf("%s %s", p.Name, human(p.Size)))
|
||||
}
|
||||
info("части: %s", strings.Join(parts, ", "))
|
||||
}
|
||||
|
||||
func confirm(q string) bool {
|
||||
fmt.Printf("\n%s? [y/N]: ", q)
|
||||
line, _ := bufio.NewReader(os.Stdin).ReadString('\n')
|
||||
switch strings.ToLower(strings.TrimSpace(line)) {
|
||||
case "y", "yes", "д", "да":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (r *restorer) hasCategory(name string) bool {
|
||||
return slices.ContainsFunc(r.m.Categories, func(c manifest.Category) bool { return c.Name == name })
|
||||
}
|
||||
|
||||
func yn(b bool) string {
|
||||
if b {
|
||||
return "y"
|
||||
}
|
||||
return "n"
|
||||
}
|
||||
|
||||
func (r *restorer) provision() error {
|
||||
step("Базовая подготовка сервера")
|
||||
m := r.m
|
||||
hasCompose := slices.ContainsFunc(m.Categories, func(c manifest.Category) bool { return c.Type == catalog.TypeCompose })
|
||||
hostname := m.Hostname
|
||||
if hostname == "" || hostname == "localhost" {
|
||||
hostname = m.Domain
|
||||
}
|
||||
env := []string{
|
||||
"NEW_HOSTNAME=" + hostname,
|
||||
"INSTALL_BLOCKLIST=" + yn(m.Features.Blocklist),
|
||||
"HARDEN_SSH=" + yn(m.Features.SSHHardened),
|
||||
"INSTALL_DOCKER=" + yn(hasCompose || m.Packages["docker-ce"] != ""),
|
||||
"DOCKER_CE_VERSION=" + m.Packages["docker-ce"],
|
||||
"DOCKER_CLI_VERSION=" + m.Packages["docker-ce-cli"],
|
||||
"COMPOSE_VERSION=" + m.Packages["docker-compose-plugin"],
|
||||
"INSTALL_CADDY=" + yn(m.Packages["caddy"] != "" || r.hasCategory("caddy")),
|
||||
"CADDY_VERSION=" + m.Packages["caddy"],
|
||||
"INSTALL_TELEGRAF=" + yn(m.Packages["telegraf"] != "" || r.hasCategory("telegraf")),
|
||||
"TELEGRAF_VERSION=" + m.Packages["telegraf"],
|
||||
}
|
||||
if len(m.SudoUsers) > 0 {
|
||||
env = append(env, "DEFAULT_USER="+m.SudoUsers[0])
|
||||
}
|
||||
if r.opts.User != "" {
|
||||
env = append(env, "NEW_USER="+r.opts.User)
|
||||
}
|
||||
script := filepath.Join(r.staging, "provision.sh")
|
||||
if err := os.WriteFile(script, provisionScript, 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
cmd := exec.CommandContext(r.ctx, "bash", script)
|
||||
cmd.Env = append(os.Environ(), env...)
|
||||
cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
// prepareVolumes создаёт docker-тома заранее (с метками compose), чтобы распаковать данные прямо в них.
|
||||
func (r *restorer) prepareVolumes() error {
|
||||
var need bool
|
||||
for _, c := range r.m.Categories {
|
||||
if len(c.Volumes) > 0 {
|
||||
need = true
|
||||
}
|
||||
}
|
||||
if !need {
|
||||
return nil
|
||||
}
|
||||
if !sysx.HasCommand("docker") {
|
||||
return errors.New("в бэкапе есть docker-тома, но docker не установлен (запустите без --skip-provision)")
|
||||
}
|
||||
step("Docker-тома")
|
||||
for _, c := range r.m.Categories {
|
||||
for _, v := range c.Volumes {
|
||||
if _, err := sysx.Output(r.ctx, "docker", "volume", "inspect", v.Name); err != nil {
|
||||
args := []string{"volume", "create"}
|
||||
if v.Key != "" && c.Compose != nil {
|
||||
args = append(args, "--label", "com.docker.compose.project="+c.Compose.Project,
|
||||
"--label", "com.docker.compose.volume="+v.Key)
|
||||
}
|
||||
if _, err := sysx.Output(r.ctx, "docker", append(args, v.Name)...); err != nil {
|
||||
return fmt.Errorf("том %s: %w", v.Name, err)
|
||||
}
|
||||
}
|
||||
mp, err := sysx.Output(r.ctx, "docker", "volume", "inspect", "-f", "{{.Mountpoint}}", v.Name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("том %s: %w", v.Name, err)
|
||||
}
|
||||
r.volumes[v.Name] = mp
|
||||
info("%s → %s", v.Name, mp)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// route раскладывает записи архива: files → /, volumes → mountpoint тома, dumps и bin → staging.
|
||||
func (r *restorer) route(name string) (string, bool) {
|
||||
cat, rest, ok := strings.Cut(name, "/")
|
||||
if !ok || cat == "" {
|
||||
return "", false
|
||||
}
|
||||
kind, rest, ok := strings.Cut(rest, "/")
|
||||
if !ok || rest == "" {
|
||||
return "", false
|
||||
}
|
||||
switch kind {
|
||||
case "files":
|
||||
return "/" + rest, true
|
||||
case "bin", "dumps":
|
||||
return filepath.Join(r.staging, cat, kind, filepath.FromSlash(rest)), true
|
||||
case "volumes":
|
||||
vol, inner, _ := strings.Cut(rest, "/")
|
||||
mp, ok := r.volumes[vol]
|
||||
if !ok {
|
||||
return filepath.Join(r.staging, cat, "volumes", filepath.FromSlash(rest)), true
|
||||
}
|
||||
return filepath.Join(mp, filepath.FromSlash(inner)), true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func (r *restorer) extractPart(p api.PartInfo) error {
|
||||
body := &resumableReader{ctx: r.ctx, client: r.client, backupID: r.backupID, part: p.Name, h: sha256.New()}
|
||||
defer body.Close()
|
||||
ex := &archive.Extractor{Route: r.route}
|
||||
if err := ex.Extract(body); err != nil {
|
||||
return err
|
||||
}
|
||||
// дочитываем хвост (zstd мог не запросить последние байты), чтобы сверить сумму
|
||||
if _, err := io.Copy(io.Discard, body); err != nil {
|
||||
return err
|
||||
}
|
||||
if sum := hex.EncodeToString(body.h.Sum(nil)); sum != p.SHA256 {
|
||||
return fmt.Errorf("контрольная сумма части %s не совпала", p.Name)
|
||||
}
|
||||
for _, w := range ex.Warnings {
|
||||
r.warn("%s", w)
|
||||
}
|
||||
info("распаковано записей: %d", ex.Files)
|
||||
return nil
|
||||
}
|
||||
|
||||
// resumableReader читает часть с сервера и при обрыве продолжает с того же байта (Range).
|
||||
type resumableReader struct {
|
||||
ctx context.Context
|
||||
client *api.Client
|
||||
backupID int64
|
||||
part string
|
||||
body io.ReadCloser
|
||||
off int64
|
||||
retries int
|
||||
h hash.Hash
|
||||
}
|
||||
|
||||
func (rr *resumableReader) Read(p []byte) (int, error) {
|
||||
for {
|
||||
if rr.body == nil {
|
||||
b, err := rr.client.DownloadPart(rr.ctx, rr.backupID, rr.part, rr.off)
|
||||
if err != nil {
|
||||
if rr.retries++; rr.retries > 5 {
|
||||
return 0, err
|
||||
}
|
||||
time.Sleep(time.Duration(rr.retries) * 5 * time.Second)
|
||||
continue
|
||||
}
|
||||
rr.body = b
|
||||
}
|
||||
n, err := rr.body.Read(p)
|
||||
rr.off += int64(n)
|
||||
rr.h.Write(p[:n])
|
||||
if err == nil || errors.Is(err, io.EOF) {
|
||||
return n, err
|
||||
}
|
||||
rr.body.Close()
|
||||
rr.body = nil
|
||||
if rr.retries++; rr.retries > 5 {
|
||||
return n, err
|
||||
}
|
||||
fmt.Printf(" ! обрыв загрузки на %s, продолжаю: %v\n", human(rr.off), err)
|
||||
if n > 0 {
|
||||
return n, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (rr *resumableReader) Close() error {
|
||||
if rr.body != nil {
|
||||
return rr.body.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *restorer) restoreCategory(c manifest.Category) error {
|
||||
for _, d := range c.EnsureDirs {
|
||||
if err := os.MkdirAll(d, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
switch c.Type {
|
||||
case catalog.TypeSystemd, catalog.TypeXUI:
|
||||
if err := r.installBinary(c); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := r.restoreSQLite(c); err != nil {
|
||||
return err
|
||||
}
|
||||
case catalog.TypeCompose:
|
||||
if err := r.restoreCompose(c); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if c.Name == "system" {
|
||||
r.fixCrontabs()
|
||||
}
|
||||
for _, d := range c.Dumps {
|
||||
if d.Kind != "cmd" {
|
||||
continue
|
||||
}
|
||||
if d.RestoreCmd == "" {
|
||||
r.warn("%s: для дампа %s не задан restore_cmd — файл не применён", c.Name, d.Name)
|
||||
continue
|
||||
}
|
||||
file := filepath.Join(r.staging, c.Name, "dumps", d.Name)
|
||||
if err := sysx.Shell(r.ctx, d.RestoreCmd, "DUMP_FILE="+file); err != nil {
|
||||
return fmt.Errorf("дамп %s: %w", d.Name, err)
|
||||
}
|
||||
}
|
||||
for _, cmd := range c.PostRestore {
|
||||
if err := sysx.Shell(r.ctx, cmd); err != nil {
|
||||
r.warn("%s: post_restore %q: %v", c.Name, cmd, err)
|
||||
}
|
||||
}
|
||||
if c.Service != nil {
|
||||
return r.applyService(c.Service)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// installBinary ставит версию со старого сервера, при неудаче — копию бинарника из бэкапа.
|
||||
func (r *restorer) installBinary(c manifest.Category) error {
|
||||
if c.Install != "" && c.Version != "" {
|
||||
if rec, ok := recipes[c.Install]; ok {
|
||||
info("установка %s %s", c.Install, c.Version)
|
||||
err := rec(r.ctx, c.Version, r.arch)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
r.warn("%s: установка %s %s не удалась: %v", c.Name, c.Install, c.Version, err)
|
||||
} else {
|
||||
r.warn("%s: неизвестный recipe %q", c.Name, c.Install)
|
||||
}
|
||||
}
|
||||
if len(c.Binaries) == 0 {
|
||||
if c.Install != "" {
|
||||
return fmt.Errorf("бинарник не установлен и копии в бэкапе нет")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if r.m.OS.Arch != "" && r.arch != "" && r.m.OS.Arch != r.arch {
|
||||
return fmt.Errorf("копия бинарника из бэкапа под %s, а сервер %s", r.m.OS.Arch, r.arch)
|
||||
}
|
||||
info("использую копию бинарника из бэкапа")
|
||||
return copyTree(filepath.Join(r.staging, c.Name, "bin"), "/")
|
||||
}
|
||||
|
||||
func (r *restorer) restoreSQLite(c manifest.Category) error {
|
||||
for _, d := range c.Dumps {
|
||||
if d.Kind != "sqlite" || d.Target == "" {
|
||||
continue
|
||||
}
|
||||
if c.Service != nil {
|
||||
_ = sysx.Run(r.ctx, "systemctl", "stop", c.Service.Unit)
|
||||
}
|
||||
src := filepath.Join(r.staging, c.Name, "dumps", d.Name)
|
||||
for _, suffix := range []string{"-wal", "-shm"} {
|
||||
_ = os.Remove(d.Target + suffix)
|
||||
}
|
||||
if err := copyFile(src, d.Target, 0o600); err != nil {
|
||||
return fmt.Errorf("база %s: %w", d.Target, err)
|
||||
}
|
||||
info("база %s восстановлена", d.Target)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *restorer) applyService(s *manifest.ServiceState) error {
|
||||
_ = sysx.Run(r.ctx, "systemctl", "daemon-reload")
|
||||
if s.Enabled {
|
||||
if err := sysx.Run(r.ctx, "systemctl", "enable", s.Unit); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
_ = sysx.Run(r.ctx, "systemctl", "disable", s.Unit)
|
||||
}
|
||||
if s.Active {
|
||||
if err := sysx.Run(r.ctx, "systemctl", "restart", s.Unit); err != nil {
|
||||
return fmt.Errorf("%s не запустился (journalctl -u %s): %w", s.Unit, s.Unit, err)
|
||||
}
|
||||
info("%s запущен", s.Unit)
|
||||
} else {
|
||||
_ = sysx.Run(r.ctx, "systemctl", "stop", s.Unit)
|
||||
info("%s оставлен остановленным, как на старом сервере", s.Unit)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// fixCrontabs выставляет crontab-файлам владельца и права, которые требует cron.
|
||||
func (r *restorer) fixCrontabs() {
|
||||
files, _ := filepath.Glob("/var/spool/cron/crontabs/*")
|
||||
for _, f := range files {
|
||||
user := filepath.Base(f)
|
||||
if _, err := sysx.Output(r.ctx, "id", "-u", user); err != nil {
|
||||
r.warn("crontab пользователя %s восстановлен, но такого пользователя нет — cron его не выполнит", user)
|
||||
continue
|
||||
}
|
||||
_ = sysx.Run(r.ctx, "chown", user+":crontab", f)
|
||||
_ = os.Chmod(f, 0o600)
|
||||
}
|
||||
if len(files) > 0 {
|
||||
_ = sysx.Run(r.ctx, "systemctl", "restart", "cron")
|
||||
}
|
||||
}
|
||||
|
||||
func (r *restorer) finish(failed []string) {
|
||||
step("Итог")
|
||||
if len(failed) == 0 {
|
||||
info("Все категории восстановлены.")
|
||||
}
|
||||
if len(r.warnings) > 0 {
|
||||
info("Предупреждения:")
|
||||
for _, w := range r.warnings {
|
||||
info(" ! %s", w)
|
||||
}
|
||||
}
|
||||
r.todo = append(r.todo,
|
||||
fmt.Sprintf("Перевести DNS %s (и другие домены сервера) на IP этого сервера", r.m.Domain),
|
||||
"Если это lighthouse Nebula — обновить static_host_map на остальных узлах",
|
||||
"Перезагрузить сервер (iptables-legacy и обновление ядра): sudo reboot",
|
||||
)
|
||||
info("Осталось сделать:")
|
||||
for _, t := range r.todo {
|
||||
info(" • %s", t)
|
||||
}
|
||||
}
|
||||
|
||||
func human(n int64) string {
|
||||
const unit = 1024
|
||||
if n < unit {
|
||||
return fmt.Sprintf("%d Б", n)
|
||||
}
|
||||
div, exp := int64(unit), 0
|
||||
for m := n / unit; m >= unit; m /= unit {
|
||||
div *= unit
|
||||
exp++
|
||||
}
|
||||
return fmt.Sprintf("%.1f %cБ", float64(n)/float64(div), []rune("КМГТП")[exp])
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package restore
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"gitea.hsrv.site/mrleo1nid/backup-master/internal/manifest"
|
||||
)
|
||||
|
||||
func TestOrder(t *testing.T) {
|
||||
cats := []manifest.Category{
|
||||
{Name: "system"},
|
||||
{Name: "stoat-mbot", RestoreAfter: []string{"stoat", "psiphon"}},
|
||||
{Name: "pnvpanel"},
|
||||
{Name: "stoat"},
|
||||
}
|
||||
var names []string
|
||||
for _, c := range order(cats) {
|
||||
names = append(names, c.Name)
|
||||
}
|
||||
want := []string{"system", "pnvpanel", "stoat", "stoat-mbot"}
|
||||
for i := range want {
|
||||
if names[i] != want[i] {
|
||||
t.Fatalf("order = %v, want %v", names, want)
|
||||
}
|
||||
}
|
||||
// цикл не зависает
|
||||
cyc := order([]manifest.Category{{Name: "a", RestoreAfter: []string{"b"}}, {Name: "b", RestoreAfter: []string{"a"}}})
|
||||
if len(cyc) != 2 {
|
||||
t.Fatal("цикл потерял категории")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoute(t *testing.T) {
|
||||
r := &restorer{staging: "/stage", volumes: map[string]string{"pnvpanel_ticket_uploads": "/var/lib/docker/volumes/pnvpanel_ticket_uploads/_data"}}
|
||||
cases := map[string]string{
|
||||
"pnvpanel/files/opt/PnvPanel/.env": "/opt/PnvPanel/.env",
|
||||
"pnvpanel/dumps/db.pgdump": filepath.Join("/stage", "pnvpanel", "dumps", "db.pgdump"),
|
||||
"pnvpanel/volumes/pnvpanel_ticket_uploads/a/b.png": filepath.Join("/var/lib/docker/volumes/pnvpanel_ticket_uploads/_data", "a", "b.png"),
|
||||
"xui/bin/usr/local/x-ui/x-ui": filepath.Join("/stage", "xui", "bin", "usr", "local", "x-ui", "x-ui"),
|
||||
}
|
||||
for name, want := range cases {
|
||||
got, ok := r.route(name)
|
||||
if !ok || got != want {
|
||||
t.Errorf("route(%q) = %q, %v; want %q", name, got, ok, want)
|
||||
}
|
||||
}
|
||||
if _, ok := r.route("manifest.json"); ok {
|
||||
t.Error("manifest.json не должен распаковываться")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user