Один бинарник 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>
553 lines
17 KiB
Go
553 lines
17 KiB
Go
// 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])
|
|
}
|