Restore: перевод бэкапов на восстановленный сервер
CI / test (push) Successful in 1m36s
Release / release (push) Successful in 1m33s

Раньше восстановленная копия получала конфиг и таймер агента со старым токеном
(через категории etc и units), и бэкапы шли с двух серверов одновременно.

- конфиг и юниты агента больше не бэкапятся
- в конце restore спрашивает, сделать ли сервер основным: сервер бэкапов
  выпускает новый токен (POST /api/v1/restore/takeover), restore ставит
  агента; токен старого сервера отозван — его агент получает отказ
- --takeover / --no-takeover; без них — вопрос (с --yes — да)
- уведомление «Сервер восстановлен» в ntfy/Telegram

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mrleo1nidandClaude Opus 5.5 committed 2026-10-17 15:29:34 +03:00
1 parent 488abc2d34
commit 97c7051021
12 files changed
+195 -4

No files matched your search

+7 -1
View File
@@ -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`.
## Что бэкапится
+11 -1
View File
@@ -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")
}
+1
View File
@@ -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)
+3 -1
View File
@@ -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)
+7
View File
@@ -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
}
+2
View File
@@ -168,6 +168,8 @@ categories:
- /etc/machine-id
- /etc/hostname
- /etc/resolv.conf
# свой агент не бэкапим: на новый сервер его ставит restore с новым токеном
- /etc/backup-agent
- name: pnvpanel
type: compose
+13
View File
@@ -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')
+98
View File
@@ -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")
}
+19
View File
@@ -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})
}
+1
View File
@@ -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")) })
+32
View File
@@ -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)
}
}
+1 -1
View File
@@ -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,
})
}