From 97c70510218f83e2aa1b996d16139c913ca7f4c6 Mon Sep 17 00:00:00 2001 From: mrleo1nid <43583385+mrleo1nid@users.noreply.github.com> Date: Sat, 17 Oct 2026 15:29:34 +0300 Subject: [PATCH] =?UTF-8?q?Restore:=20=D0=BF=D0=B5=D1=80=D0=B5=D0=B2=D0=BE?= =?UTF-8?q?=D0=B4=20=D0=B1=D1=8D=D0=BA=D0=B0=D0=BF=D0=BE=D0=B2=20=D0=BD?= =?UTF-8?q?=D0=B0=20=D0=B2=D0=BE=D1=81=D1=81=D1=82=D0=B0=D0=BD=D0=BE=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D0=BD=D1=8B=D0=B9=20=D1=81=D0=B5=D1=80=D0=B2?= =?UTF-8?q?=D0=B5=D1=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Раньше восстановленная копия получала конфиг и таймер агента со старым токеном (через категории etc и units), и бэкапы шли с двух серверов одновременно. - конфиг и юниты агента больше не бэкапятся - в конце restore спрашивает, сделать ли сервер основным: сервер бэкапов выпускает новый токен (POST /api/v1/restore/takeover), restore ставит агента; токен старого сервера отозван — его агент получает отказ - --takeover / --no-takeover; без них — вопрос (с --yes — да) - уведомление «Сервер восстановлен» в ntfy/Telegram Co-Authored-By: Claude Opus 5.5 --- README.md | 8 ++- cmd/backupctl/main.go | 12 ++++- internal/agent/collect_test.go | 1 + internal/agent/units.go | 4 +- internal/api/api.go | 7 +++ internal/catalog/default.yaml | 2 + internal/restore/restore.go | 13 +++++ internal/restore/takeover.go | 98 ++++++++++++++++++++++++++++++++++ internal/server/api.go | 19 +++++++ internal/server/server.go | 1 + internal/server/update_test.go | 32 +++++++++++ internal/server/web.go | 2 +- 12 files changed, 195 insertions(+), 4 deletions(-) create mode 100644 internal/restore/takeover.go diff --git a/README.md b/README.md index f456b9a..54ba862 100644 --- a/README.md +++ b/README.md @@ -167,12 +167,18 @@ curl -fsSL https://backup.example.com/restore.sh | sudo bash -s -- --token bm_.. - systemd-сервисы включаются и запускаются так, как было на старом сервере; - sshd проверяется (`sshd -t`) и перезапускается со старым конфигом и ключами хоста; - свои systemd-юниты включаются и запускаются как на старом сервере. -6. В конце выводятся итог и список того, что осталось сделать: DNS, lighthouse, перезагрузка. Перезагрузка нужна в том числе для того, чтобы все сервисы перечитали восстановленные конфиги. +6. Restore спрашивает, сделать ли новый сервер **основным для бэкапов**: + - **да** (по умолчанию) — сервер бэкапов выпускает клиенту новый токен, на новый сервер ставится агент. Токен старого сервера отзывается: если тот ещё жив, его агент получает отказ, и бэкапы шлёт только новый; + - **нет** (`--no-takeover`) — пробное восстановление: агент не ставится, основным остаётся старый сервер. + + Конфиг и юниты самого агента в бэкап не попадают, поэтому восстановленная копия не начнёт слать бэкапы со старым токеном. +7. В конце выводятся итог и список того, что осталось сделать: DNS, lighthouse, перезагрузка. Перезагрузка нужна в том числе для того, чтобы все сервисы перечитали восстановленные конфиги. Флаги: - `--backup ID` — конкретный бэкап; - `--skip-heavy` — без MinIO Stoat; - `--skip-provision` — повторный прогон без базовой подготовки; +- `--takeover` / `--no-takeover` — сделать сервер основным для бэкапов или нет, не спрашивая; - `--user NAME`, `--yes`, `--keep-staging`. ## Что бэкапится diff --git a/cmd/backupctl/main.go b/cmd/backupctl/main.go index dd13d01..b7f9d9c 100644 --- a/cmd/backupctl/main.go +++ b/cmd/backupctl/main.go @@ -34,7 +34,7 @@ const usage = `backupctl %s backupctl plan [--config ...] показать, что попадёт в бэкап Новый сервер: - backupctl restore --server URL --token TOKEN [--backup ID] [--skip-heavy] [--skip-provision] + backupctl restore --server URL --token TOKEN [--backup ID] [--skip-heavy] [--skip-provision] [--takeover|--no-takeover] backupctl version Флаги подкоманды: backupctl <команда> -h @@ -201,7 +201,17 @@ func runRestore(ctx context.Context, args []string) error { fs.BoolVar(&o.Yes, "yes", false, "не спрашивать подтверждение") fs.StringVar(&o.StagingDir, "staging", "", "каталог для временных файлов (по умолчанию /var/tmp/backup-restore)") fs.BoolVar(&o.KeepStaging, "keep-staging", false, "не удалять временные файлы (дампы) после восстановления") + takeover := fs.Bool("takeover", false, "сделать этот сервер основным для бэкапов, не спрашивая (токен старого отзывается)") + noTakeover := fs.Bool("no-takeover", false, "пробное восстановление: агента не ставить, бэкапы продолжает слать старый сервер") _ = fs.Parse(args) + switch { + case *takeover && *noTakeover: + return errors.New("--takeover и --no-takeover вместе не указываются") + case *takeover: + o.Takeover = restore.TakeoverYes + case *noTakeover: + o.Takeover = restore.TakeoverNo + } if o.Server == "" { return errors.New("нужен --server") } diff --git a/internal/agent/collect_test.go b/internal/agent/collect_test.go index 8f6908a..d6f786e 100644 --- a/internal/agent/collect_test.go +++ b/internal/agent/collect_test.go @@ -84,6 +84,7 @@ func TestEtcCategoryExcludes(t *testing.T) { "/etc/nginx/sites-available/site": false, "/etc/security/limits.conf": false, "/etc/systemd/journald.conf": false, + "/etc/backup-agent/agent.yaml": true, } { if got := skip.Match(p); got != want { t.Errorf("etc exclude(%s) = %v, want %v", p, got, want) diff --git a/internal/agent/units.go b/internal/agent/units.go index e72e4b1..7249976 100644 --- a/internal/agent/units.go +++ b/internal/agent/units.go @@ -30,7 +30,9 @@ func (c *collector) collectUnits(s catalog.Spec) *manifest.Category { for _, f := range c.unowned(s.Unowned) { unit := filepath.Base(f) name := strings.TrimSuffix(strings.TrimSuffix(unit, ".service"), ".timer") - if archive.Covers(covered, filepath.ToSlash(f)) || services[name] || services[unit] { + // юниты самого агента не бэкапим: на новый сервер его ставит restore с новым токеном, + // иначе восстановленная копия слала бы бэкапы со старым токеном параллельно старому серверу + if strings.HasPrefix(unit, "backup-agent.") || archive.Covers(covered, filepath.ToSlash(f)) || services[name] || services[unit] { continue } mc.Paths = append(mc.Paths, f) diff --git a/internal/api/api.go b/internal/api/api.go index 15e72ae..697f53a 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -201,3 +201,10 @@ func (c *Client) DownloadPart(ctx context.Context, id int64, name string, offset } return resp.Body, nil } + +// Takeover делает восстановленный сервер основным и возвращает новый агентский токен. +func (c *Client) Takeover(ctx context.Context) (string, error) { + var out struct{ Token string } + err := c.do(ctx, http.MethodPost, "/api/v1/restore/takeover", nil, &out) + return out.Token, err +} diff --git a/internal/catalog/default.yaml b/internal/catalog/default.yaml index fe7c247..45b1dc9 100644 --- a/internal/catalog/default.yaml +++ b/internal/catalog/default.yaml @@ -168,6 +168,8 @@ categories: - /etc/machine-id - /etc/hostname - /etc/resolv.conf + # свой агент не бэкапим: на новый сервер его ставит restore с новым токеном + - /etc/backup-agent - name: pnvpanel type: compose diff --git a/internal/restore/restore.go b/internal/restore/restore.go index 5d58483..4834d45 100644 --- a/internal/restore/restore.go +++ b/internal/restore/restore.go @@ -40,6 +40,7 @@ type Options struct { Yes bool // не спрашивать подтверждение StagingDir string KeepStaging bool + Takeover string // TakeoverAsk / TakeoverYes / TakeoverNo } type restorer struct { @@ -156,6 +157,7 @@ func Run(ctx context.Context, opts Options) error { failed = append(failed, c.Name) } } + r.takeover() r.finish(failed) if len(failed) > 0 { return fmt.Errorf("с ошибками восстановлены категории: %s", strings.Join(failed, ", ")) @@ -193,6 +195,17 @@ func (r *restorer) summary(rm *api.RestoreManifest) { 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') diff --git a/internal/restore/takeover.go b/internal/restore/takeover.go new file mode 100644 index 0000000..da61e9f --- /dev/null +++ b/internal/restore/takeover.go @@ -0,0 +1,98 @@ +package restore + +import ( + "context" + "fmt" + "os" + "strings" + + "gitea.hsrv.site/mrleo1nid/backup-master/internal/sysx" +) + +const ( + agentConfigPath = "/etc/backup-agent/agent.yaml" + + agentService = `[Unit] +Description=backup-master agent +Wants=network-online.target +After=network-online.target docker.service + +[Service] +Type=oneshot +ExecStart=/usr/local/bin/backupctl backup +Nice=10 +IOSchedulingClass=idle +TimeoutStartSec=6h +` + agentTimer = `[Unit] +Description=Ежедневный бэкап backup-master + +[Timer] +OnCalendar=*-*-* 03:00:00 +RandomizedDelaySec=30m +Persistent=true + +[Install] +WantedBy=timers.target +` +) + +// Takeover — делать ли восстановленный сервер основным для бэкапов. +const ( + TakeoverAsk = "" + TakeoverYes = "yes" + TakeoverNo = "no" +) + +// takeover переводит бэкапы клиента на этот сервер: сервер бэкапов выпускает новый +// токен (старый отзывается — агент старого сервера получит отказ) и здесь ставится агент. +func (r *restorer) takeover() { + mode := r.opts.Takeover + if mode == TakeoverAsk { + mode = TakeoverNo + if r.opts.Yes || confirmDefaultYes("Сделать этот сервер основным для бэкапов? Агент старого сервера перестанет их присылать") { + mode = TakeoverYes + } + } + step("Бэкапы этого сервера") + if mode != TakeoverYes { + info("агент не установлен — бэкапы по-прежнему присылает старый сервер (пробное восстановление)") + r.todo = append(r.todo, "Чтобы этот сервер стал основным: в карточке клиента «Новый токен агента» и выполнить выданную команду здесь") + return + } + token, err := r.client.Takeover(r.ctx) + if err != nil { + r.warn("не удалось перевести бэкапы на этот сервер: %v — установите агента вручную (карточка клиента → «Новый токен агента»)", err) + return + } + if err := installAgent(r.ctx, r.opts.Server, token); err != nil { + r.warn("агент не установлен: %v — токен уже перевыпущен, установите агента вручную (карточка клиента → «Новый токен агента»)", err) + return + } + info("агент установлен, бэкапы теперь присылает этот сервер; токен старого сервера отозван") + r.todo = append(r.todo, + "Старый сервер больше не может слать бэкапы (токен отозван). При выводе из работы: sudo systemctl disable --now backup-agent.timer", + "Первый бэкап с нового сервера сразу: sudo systemctl start backup-agent") +} + +func installAgent(ctx context.Context, server, token string) error { + if err := os.MkdirAll("/etc/backup-agent", 0o700); err != nil { + return err + } + cfg := fmt.Sprintf("server: %s\ntoken: %s\nspool_dir: /var/tmp/backup-agent\n", strings.TrimRight(server, "/"), token) + if err := os.WriteFile(agentConfigPath, []byte(cfg), 0o600); err != nil { + return err + } + for path, body := range map[string]string{ + "/etc/systemd/system/backup-agent.service": agentService, + "/etc/systemd/system/backup-agent.timer": agentTimer, + } { + if err := os.WriteFile(path, []byte(body), 0o644); err != nil { + return err + } + } + if err := sysx.Run(ctx, "systemctl", "daemon-reload"); err != nil { + return err + } + return sysx.Run(ctx, "systemctl", "enable", "--now", "backup-agent.timer") +} diff --git a/internal/server/api.go b/internal/server/api.go index d90de8f..e114b23 100644 --- a/internal/server/api.go +++ b/internal/server/api.go @@ -440,3 +440,22 @@ func (s *Server) servePart(w http.ResponseWriter, r *http.Request, b *Backup, na filepath.Base(filepath.Dir(b.Dir)), b.ID, name)) http.ServeContent(w, r, "", fi.ModTime(), f) } + +// apiTakeover делает восстановленный сервер основным: выпускает клиенту новый +// агентский токен. Старый токен перестаёт работать, поэтому агент старого сервера, +// если тот ещё жив, получает отказ и больше не шлёт бэкапы. +func (s *Server) apiTakeover(w http.ResponseWriter, r *http.Request) { + c := clientFrom(r) + tok := newToken() + if err := s.st.SetClientToken(c.ID, hashToken(tok)); err != nil { + jsonErr(w, http.StatusInternalServerError, err.Error()) + return + } + log.Printf("%s: бэкапы переведены на восстановленный сервер, старый токен отозван", c.Domain) + s.notify(notification{ + Title: "Сервер восстановлен: " + c.Domain, + Msg: "Бэкапы теперь присылает восстановленный сервер, токен старого отозван.", + Tags: "white_check_mark", Link: s.clientLink(c.ID), + }) + jsonResp(w, http.StatusOK, map[string]string{"token": tok}) +} diff --git a/internal/server/server.go b/internal/server/server.go index 74b0e60..2c89ae9 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -86,6 +86,7 @@ func (s *Server) Handler() http.Handler { // API восстановления mux.HandleFunc("GET /api/v1/restore/manifest", s.restoreAuth(s.apiRestoreManifest)) mux.HandleFunc("GET /api/v1/restore/{id}/parts/{name}", s.restoreAuth(s.apiRestorePart)) + mux.HandleFunc("POST /api/v1/restore/takeover", s.restoreAuth(s.apiTakeover)) // публичное mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { w.Write([]byte("ok\n")) }) diff --git a/internal/server/update_test.go b/internal/server/update_test.go index c394d49..33eaf0f 100644 --- a/internal/server/update_test.go +++ b/internal/server/update_test.go @@ -7,6 +7,7 @@ import ( "os" "path/filepath" "testing" + "time" "gitea.hsrv.site/mrleo1nid/backup-master/internal/catalog" ) @@ -110,3 +111,34 @@ func TestStoreMigration(t *testing.T) { st2.Close() } } + +func TestTakeoverRevokesOldAgent(t *testing.T) { + s, ts := newTestServer(t) + oldTok := newToken() + id, err := s.st.CreateClient("x", "x.example.com", hashToken(oldTok)) + if err != nil { + t.Fatal(err) + } + if r := call(t, "GET", ts.URL+"/api/v1/agent/config", oldTok, nil); r.StatusCode != 200 { + t.Fatalf("старый токен до takeover: %d", r.StatusCode) + } + // агентский токен takeover не делает + if r := call(t, "POST", ts.URL+"/api/v1/restore/takeover", oldTok, nil); r.StatusCode != 401 { + t.Fatalf("takeover агентским токеном: %d", r.StatusCode) + } + rt := newToken() + if err := s.st.CreateRestoreToken(hashToken(rt), id, time.Hour); err != nil { + t.Fatal(err) + } + var out struct{ Token string } + decode(t, call(t, "POST", ts.URL+"/api/v1/restore/takeover", rt, nil), &out) + if out.Token == "" || out.Token == oldTok { + t.Fatalf("новый токен: %q", out.Token) + } + if r := call(t, "GET", ts.URL+"/api/v1/agent/config", oldTok, nil); r.StatusCode != 401 { + t.Fatalf("старый сервер после takeover всё ещё принят: %d", r.StatusCode) + } + if r := call(t, "GET", ts.URL+"/api/v1/agent/config", out.Token, nil); r.StatusCode != 200 { + t.Fatalf("новый токен: %d", r.StatusCode) + } +} diff --git a/internal/server/web.go b/internal/server/web.go index dbe7f6e..90165ea 100644 --- a/internal/server/web.go +++ b/internal/server/web.go @@ -291,7 +291,7 @@ func (s *Server) webClientRestoreToken(w http.ResponseWriter, r *http.Request) { s.render(w, r, "token", map[string]any{ "Client": c, "Title": "Восстановление " + c.Domain, - "Note": "Токен действует 24 часа и даёт только чтение бэкапов этого клиента. Выполните на новом сервере (Ubuntu 24.04, от root или пользователя с sudo):", + "Note": "Токен действует 24 часа и даёт только чтение бэкапов этого клиента. Выполните на новом сервере (Ubuntu 24.04, от root или пользователя с sudo). В конце restore спросит, сделать ли новый сервер основным: тогда токен старого отзывается и бэкапы шлёт только новый. Для пробного восстановления добавьте --no-takeover.", "Cmd": cmd, }) }