// 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 Takeover string // TakeoverAsk / TakeoverYes / TakeoverNo } 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) } } r.restoreSystem() 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.takeover() 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, ", ")) } // confirmDefaultYes — вопрос, где пустой ответ означает «да». func confirmDefaultYes(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 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 } case catalog.TypeUnits: _ = sysx.Run(r.ctx, "systemctl", "daemon-reload") for i := range c.Units { if err := r.applyService(&c.Units[i]); err != nil { r.warn("%s: %v", c.Name, 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]) }