2 Commits
Author SHA1 Message Date
mrleo1nidandClaude Opus 5.5 97c7051021 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>
2026-10-17 15:29:34 +03:00
mrleo1nidandClaude Opus 5.5 488abc2d34 Агенты подхватывают изменения с сервера: каталог и самообновление
CI / test (push) Successful in 1m29s
- сервер отдаёт агенту итоговый каталог (встроенный каталог сервера +
  переопределения клиента); если каталог не подходит версии агента —
  встроенный каталог агента и предупреждение
- самообновление перед бэкапом: версия сервера новее → скачивание с того же
  сервера (только /dl/...), проверка sha256 и запуска новой версии, подмена
  с откатом, перезапуск через exec; при ошибке бэкап идёт текущей версией
- галочка «Автообновление» у клиента (по умолчанию включена), версия агента
  в списке и карточке, отставшие подсвечены; версия сервера в шапке
- миграция существующей базы: колонки auto_update и agent_version
- backupctl backup --no-update

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 15:27:31 +03:00
26 changed files with 872 additions and 58 deletions

No files matched your search

+15 -2
View File
@@ -99,7 +99,14 @@ sudo systemctl reload caddy
sudo -u backup-master BM_DATA=/var/lib/backup-master backupctl admin-passwd новый_логин sudo -u backup-master BM_DATA=/var/lib/backup-master backupctl admin-passwd новый_логин
``` ```
**Обновление сервера:** снова выполните блок «бинарники», затем `sudo systemctl restart backup-master`. Агенты обновляются командой `curl -fsSL https://backup.ВАШ-ДОМЕН/agent.sh | sudo bash` без `--token`, конфиг при этом сохраняется. **Обновление.** Достаточно обновить сервер: снова выполните блок «бинарники», затем `sudo systemctl restart backup-master`. Агенты подтянутся сами при следующем бэкапе:
- **каталог** агент каждый раз берёт с сервера — встроенный каталог сервера плюс переопределения клиента. Новые категории начинают работать без обновления агента;
- **бинарник** — перед бэкапом агент сравнивает свою версию с версией сервера. Если серверная новее, он скачивает свой бинарник с того же сервера, сверяет sha256, проверяет, что новая версия запускается, подменяет файл и перезапускается. Если что-то не так, бэкап выполняет текущая версия, а в карточке клиента появляется предупреждение.
Отставшие агенты подсвечены в списке клиентов. Автообновление можно выключить в карточке клиента — тогда агент обновляется вручную командой `curl -fsSL https://backup.ВАШ-ДОМЕН/agent.sh | sudo bash` без `--token`, конфиг при этом сохраняется. Разовый бэкап без обновления: `backupctl backup --no-update`.
Сервер dev-сборки обновлений не раздаёт, откатов на более старую версию не бывает.
### Уведомления ### Уведомления
@@ -160,12 +167,18 @@ curl -fsSL https://backup.example.com/restore.sh | sudo bash -s -- --token bm_..
- systemd-сервисы включаются и запускаются так, как было на старом сервере; - systemd-сервисы включаются и запускаются так, как было на старом сервере;
- sshd проверяется (`sshd -t`) и перезапускается со старым конфигом и ключами хоста; - sshd проверяется (`sshd -t`) и перезапускается со старым конфигом и ключами хоста;
- свои systemd-юниты включаются и запускаются как на старом сервере. - свои systemd-юниты включаются и запускаются как на старом сервере.
6. В конце выводятся итог и список того, что осталось сделать: DNS, lighthouse, перезагрузка. Перезагрузка нужна в том числе для того, чтобы все сервисы перечитали восстановленные конфиги. 6. Restore спрашивает, сделать ли новый сервер **основным для бэкапов**:
- **да** (по умолчанию) — сервер бэкапов выпускает клиенту новый токен, на новый сервер ставится агент. Токен старого сервера отзывается: если тот ещё жив, его агент получает отказ, и бэкапы шлёт только новый;
- **нет** (`--no-takeover`) — пробное восстановление: агент не ставится, основным остаётся старый сервер.
Конфиг и юниты самого агента в бэкап не попадают, поэтому восстановленная копия не начнёт слать бэкапы со старым токеном.
7. В конце выводятся итог и список того, что осталось сделать: DNS, lighthouse, перезагрузка. Перезагрузка нужна в том числе для того, чтобы все сервисы перечитали восстановленные конфиги.
Флаги: Флаги:
- `--backup ID` — конкретный бэкап; - `--backup ID` — конкретный бэкап;
- `--skip-heavy` — без MinIO Stoat; - `--skip-heavy` — без MinIO Stoat;
- `--skip-provision` — повторный прогон без базовой подготовки; - `--skip-provision` — повторный прогон без базовой подготовки;
- `--takeover` / `--no-takeover` — сделать сервер основным для бэкапов или нет, не спрашивая;
- `--user NAME`, `--yes`, `--keep-staging`. - `--user NAME`, `--yes`, `--keep-staging`.
## Что бэкапится ## Что бэкапится
+13 -1
View File
@@ -34,7 +34,7 @@ const usage = `backupctl %s
backupctl plan [--config ...] показать, что попадёт в бэкап 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 version
Флаги подкоманды: backupctl <команда> -h Флаги подкоманды: backupctl <команда> -h
@@ -99,6 +99,7 @@ func runServer(ctx context.Context, args []string) error {
maxGB := fs.Int64("max-upload-gb", 200, "максимальный размер одной части бэкапа, ГБ") maxGB := fs.Int64("max-upload-gb", 200, "максимальный размер одной части бэкапа, ГБ")
_ = fs.Parse(args) _ = fs.Parse(args)
cfg.MaxUpload = *maxGB << 30 cfg.MaxUpload = *maxGB << 30
cfg.Version = version
if cfg.PublicURL == "" { if cfg.PublicURL == "" {
log.Printf("ВНИМАНИЕ: --public-url не задан, адрес в командах установки берётся из запроса") log.Printf("ВНИМАНИЕ: --public-url не задан, адрес в командах установки берётся из запроса")
} }
@@ -176,6 +177,7 @@ func runBackup(ctx context.Context, args []string) error {
fs := flag.NewFlagSet("backup", flag.ExitOnError) fs := flag.NewFlagSet("backup", flag.ExitOnError)
o := agentFlags(fs) o := agentFlags(fs)
fs.StringVar(&o.Output, "output", "", "записать части в каталог вместо отправки на сервер (отладка)") fs.StringVar(&o.Output, "output", "", "записать части в каталог вместо отправки на сервер (отладка)")
fs.BoolVar(&o.NoUpdate, "no-update", false, "не обновлять агента, даже если сервер предлагает новую версию")
_ = fs.Parse(args) _ = fs.Parse(args)
return agent.Backup(ctx, *o) return agent.Backup(ctx, *o)
} }
@@ -199,7 +201,17 @@ func runRestore(ctx context.Context, args []string) error {
fs.BoolVar(&o.Yes, "yes", false, "не спрашивать подтверждение") fs.BoolVar(&o.Yes, "yes", false, "не спрашивать подтверждение")
fs.StringVar(&o.StagingDir, "staging", "", "каталог для временных файлов (по умолчанию /var/tmp/backup-restore)") fs.StringVar(&o.StagingDir, "staging", "", "каталог для временных файлов (по умолчанию /var/tmp/backup-restore)")
fs.BoolVar(&o.KeepStaging, "keep-staging", false, "не удалять временные файлы (дампы) после восстановления") fs.BoolVar(&o.KeepStaging, "keep-staging", false, "не удалять временные файлы (дампы) после восстановления")
takeover := fs.Bool("takeover", false, "сделать этот сервер основным для бэкапов, не спрашивая (токен старого отзывается)")
noTakeover := fs.Bool("no-takeover", false, "пробное восстановление: агента не ставить, бэкапы продолжает слать старый сервер")
_ = fs.Parse(args) _ = 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 == "" { if o.Server == "" {
return errors.New("нужен --server") return errors.New("нужен --server")
} }
+1
View File
@@ -84,6 +84,7 @@ func TestEtcCategoryExcludes(t *testing.T) {
"/etc/nginx/sites-available/site": false, "/etc/nginx/sites-available/site": false,
"/etc/security/limits.conf": false, "/etc/security/limits.conf": false,
"/etc/systemd/journald.conf": false, "/etc/systemd/journald.conf": false,
"/etc/backup-agent/agent.yaml": true,
} { } {
if got := skip.Match(p); got != want { if got := skip.Match(p); got != want {
t.Errorf("etc exclude(%s) = %v, want %v", p, got, want) t.Errorf("etc exclude(%s) = %v, want %v", p, got, want)
+9
View File
@@ -0,0 +1,9 @@
//go:build !unix
package agent
import "errors"
func reexec(string, []string, []string) error {
return errors.New("самообновление поддерживается только на Linux")
}
+10
View File
@@ -0,0 +1,10 @@
//go:build unix
package agent
import "syscall"
// reexec заменяет текущий процесс новым бинарником (тот же PID, systemd не замечает).
func reexec(exe string, args, env []string) error {
return syscall.Exec(exe, args, env)
}
+72 -23
View File
@@ -11,6 +11,7 @@ import (
"log" "log"
"os" "os"
"path/filepath" "path/filepath"
"runtime"
"strings" "strings"
"time" "time"
@@ -27,43 +28,73 @@ type Options struct {
Output string Output string
// ForceHeavy — включить heavy-части независимо от расписания. // ForceHeavy — включить heavy-части независимо от расписания.
ForceHeavy bool ForceHeavy bool
// NoUpdate — не обновлять агента, даже если сервер предлагает.
NoUpdate bool
} }
// prepare загружает конфиг, получает переопределения с сервера и собирает план. // session — подключение к серверу и его ответ для одного запуска агента.
func prepare(ctx context.Context, opts Options, spool string) (*Plan, *api.Client, error) { type session struct {
cfg *Config
client *api.Client
remote *api.AgentConfig
}
// connect читает конфиг и спрашивает у сервера настройки (каталог, обновление).
func connect(ctx context.Context, opts Options) (*session, error) {
cfg, err := LoadConfig(opts.ConfigPath) cfg, err := LoadConfig(opts.ConfigPath)
if err != nil { if err != nil {
return nil, nil, err return nil, err
} }
client := api.NewClient(cfg.Server, cfg.Token) client := api.NewClient(cfg.Server, cfg.Token)
remote, err := client.AgentConfig(ctx) remote, err := client.AgentConfig(ctx, runtime.GOARCH, opts.AgentVersion)
if err != nil { if err != nil {
return nil, nil, fmt.Errorf("конфиг с сервера: %w", err) return nil, fmt.Errorf("конфиг с сервера: %w", err)
}
return &session{cfg: cfg, client: client, remote: remote}, nil
}
// catalogFor выбирает каталог: итоговый с сервера, а если его нет (старый сервер)
// или он не читается этой версией агента — встроенный + переопределения клиента.
func catalogFor(remote *api.AgentConfig) (*catalog.Config, []string, error) {
var warns []string
if strings.TrimSpace(remote.CatalogYAML) != "" {
cat, err := catalog.Parse([]byte(remote.CatalogYAML))
if err == nil {
err = cat.Validate()
}
if err == nil {
return cat, nil, nil
}
warns = append(warns, fmt.Sprintf("каталог с сервера не подошёл этой версии агента (%v) — использую встроенный", err))
} }
cat, err := catalog.Default() cat, err := catalog.Default()
if err != nil { if err != nil {
return nil, nil, err return nil, warns, err
} }
if strings.TrimSpace(remote.ConfigYAML) != "" { if strings.TrimSpace(remote.ConfigYAML) != "" {
over, err := catalog.Parse([]byte(remote.ConfigYAML)) over, err := catalog.Parse([]byte(remote.ConfigYAML))
if err != nil { if err != nil {
return nil, nil, fmt.Errorf("конфиг клиента с сервера: %w", err) return nil, warns, fmt.Errorf("конфиг клиента с сервера: %w", err)
} }
cat = catalog.Merge(cat, over) cat = catalog.Merge(cat, over)
} }
if err := cat.Validate(); err != nil { return cat, warns, cat.Validate()
return nil, nil, err }
}
if spool == "" { // prepare собирает план бэкапа по каталогу.
spool = cfg.SpoolDir func (s *session) prepare(ctx context.Context, opts Options, spool string, warns []string) (*Plan, error) {
} cat, catWarns, err := catalogFor(s.remote)
plan, err := Collect(ctx, cat, spool, remote.HeavyDue || opts.ForceHeavy)
if err != nil { if err != nil {
return nil, nil, err return nil, err
} }
plan.Manifest.Domain = remote.Domain plan, err := Collect(ctx, cat, spool, s.remote.HeavyDue || opts.ForceHeavy)
if err != nil {
return nil, err
}
plan.Manifest.Domain = s.remote.Domain
plan.Manifest.AgentVersion = opts.AgentVersion plan.Manifest.AgentVersion = opts.AgentVersion
return plan, client, nil plan.Manifest.Warnings = append(append(warns, catWarns...), plan.Manifest.Warnings...)
return plan, nil
} }
func newSpool(base string) (string, error) { func newSpool(base string) (string, error) {
@@ -73,20 +104,24 @@ func newSpool(base string) (string, error) {
// PrintPlan выводит, что попадёт в бэкап. Дампы БД при этом реально выполняются (во временный каталог). // PrintPlan выводит, что попадёт в бэкап. Дампы БД при этом реально выполняются (во временный каталог).
func PrintPlan(ctx context.Context, opts Options, w io.Writer) error { func PrintPlan(ctx context.Context, opts Options, w io.Writer) error {
cfg, err := LoadConfig(opts.ConfigPath) sess, err := connect(ctx, opts)
if err != nil { if err != nil {
return err return err
} }
spool, err := newSpool(cfg.SpoolDir) spool, err := newSpool(sess.cfg.SpoolDir)
if err != nil { if err != nil {
return err return err
} }
defer os.RemoveAll(spool) defer os.RemoveAll(spool)
plan, _, err := prepare(ctx, opts, spool) plan, err := sess.prepare(ctx, opts, spool, nil)
if err != nil { if err != nil {
return err return err
} }
m := &plan.Manifest m := &plan.Manifest
fmt.Fprintf(w, "Агент %s, сервер %s\n", opts.AgentVersion, sess.remote.ServerVersion)
if u := sess.remote.Update; u != nil {
fmt.Fprintf(w, "Доступно обновление агента до %s — установится при следующем бэкапе\n", u.Version)
}
fmt.Fprintf(w, "Домен: %s, hostname: %s, %s (%s)\n", m.Domain, m.Hostname, m.OS.PrettyName, m.OS.Arch) fmt.Fprintf(w, "Домен: %s, hostname: %s, %s (%s)\n", m.Domain, m.Hostname, m.OS.PrettyName, m.OS.Arch)
if len(m.Packages) > 0 { if len(m.Packages) > 0 {
fmt.Fprintf(w, "Пакеты:") fmt.Fprintf(w, "Пакеты:")
@@ -161,18 +196,32 @@ func Backup(ctx context.Context, opts Options) (err error) {
} }
defer unlock() defer unlock()
cfg, err := LoadConfig(opts.ConfigPath) sess, err := connect(ctx, opts)
if err != nil { if err != nil {
return err return err
} }
spool, err := newSpool(cfg.SpoolDir) // Самообновление — до сбора: при успехе процесс перезапускается новой версией
// (exec не возвращается), при ошибке бэкап идёт текущей версией с предупреждением.
var warns []string
if u := sess.remote.Update; u != nil && !opts.NoUpdate && os.Getenv(updatedEnv) == "" {
log.Printf("обновление агента %s → %s", opts.AgentVersion, u.Version)
if err := selfUpdate(ctx, sess.client, u, opts.AgentVersion); err != nil {
log.Printf("! обновление не удалось: %v", err)
warns = append(warns, fmt.Sprintf("обновление агента до %s не удалось: %v", u.Version, err))
}
}
if from := os.Getenv(updatedEnv); from != "" {
log.Printf("агент обновлён: %s → %s", from, opts.AgentVersion)
}
client := sess.client
spool, err := newSpool(sess.cfg.SpoolDir)
if err != nil { if err != nil {
return err return err
} }
defer os.RemoveAll(spool) defer os.RemoveAll(spool)
start := time.Now() start := time.Now()
plan, client, err := prepare(ctx, opts, spool) plan, err := sess.prepare(ctx, opts, spool, warns)
if err != nil { if err != nil {
return err return err
} }
+3 -1
View File
@@ -30,7 +30,9 @@ func (c *collector) collectUnits(s catalog.Spec) *manifest.Category {
for _, f := range c.unowned(s.Unowned) { for _, f := range c.unowned(s.Unowned) {
unit := filepath.Base(f) unit := filepath.Base(f)
name := strings.TrimSuffix(strings.TrimSuffix(unit, ".service"), ".timer") 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 continue
} }
mc.Paths = append(mc.Paths, f) mc.Paths = append(mc.Paths, f)
+106
View File
@@ -0,0 +1,106 @@
package agent
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"gitea.hsrv.site/mrleo1nid/backup-master/internal/api"
)
// updatedEnv выставляется перезапущенному процессу: не обновляться повторно в том же запуске.
const updatedEnv = "BM_UPDATED_FROM"
// selfUpdate скачивает новую версию агента с того же сервера, сверяет sha256,
// подменяет бинарник (старый сохраняется для отката) и перезапускает процесс.
// При успехе не возвращается.
func selfUpdate(ctx context.Context, client *api.Client, u *api.AgentUpdate, current string) error {
// только путь на своём сервере: чужой адрес от сервера не принимаем
if !strings.HasPrefix(u.Path, "/dl/") || strings.Contains(u.Path, "..") || strings.Contains(u.Path, "://") {
return fmt.Errorf("недопустимый путь обновления %q", u.Path)
}
if len(u.SHA256) != 64 {
return errors.New("сервер не прислал sha256")
}
exe, err := os.Executable()
if err != nil {
return err
}
if exe, err = filepath.EvalSymlinks(exe); err != nil {
return err
}
dctx, cancel := context.WithTimeout(ctx, 5*time.Minute)
defer cancel()
body, err := client.Download(dctx, u.Path)
if err != nil {
return fmt.Errorf("скачивание: %w", err)
}
defer body.Close()
tmp := exe + ".new"
if err := writeVerified(tmp, body, u.SHA256); err != nil {
os.Remove(tmp)
return err
}
// новый бинарник должен запускаться и сообщать ожидаемую версию
if err := checkVersion(ctx, tmp, u.Version); err != nil {
os.Remove(tmp)
return err
}
old := exe + ".old"
if err := os.Rename(exe, old); err != nil {
os.Remove(tmp)
return err
}
if err := os.Rename(tmp, exe); err != nil {
_ = os.Rename(old, exe)
return err
}
env := append(os.Environ(), updatedEnv+"="+current)
err = reexec(exe, os.Args, env)
// сюда попадаем, только если exec не удался — возвращаем старую версию
_ = os.Rename(old, exe)
return fmt.Errorf("перезапуск: %w", err)
}
func writeVerified(path string, r io.Reader, want string) error {
f, err := os.OpenFile(path, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o755)
if err != nil {
return err
}
h := sha256.New()
if _, err := io.Copy(io.MultiWriter(f, h), r); err != nil {
f.Close()
return err
}
if err := f.Close(); err != nil {
return err
}
if got := hex.EncodeToString(h.Sum(nil)); got != want {
return fmt.Errorf("sha256 не совпал: %s, ожидался %s", got, want)
}
return os.Chmod(path, 0o755)
}
func checkVersion(ctx context.Context, bin, want string) error {
cctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
out, err := exec.CommandContext(cctx, bin, "version").Output()
if err != nil {
return fmt.Errorf("новый бинарник не запускается: %w", err)
}
if !strings.Contains(string(out), want) {
return fmt.Errorf("новый бинарник сообщает версию %q, ожидалась %s", strings.TrimSpace(string(out)), want)
}
return nil
}
+55
View File
@@ -0,0 +1,55 @@
package agent
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"gitea.hsrv.site/mrleo1nid/backup-master/internal/api"
)
func TestSelfUpdateRejectsForeignPath(t *testing.T) {
for _, p := range []string{"https://evil.example/x", "/dl/../etc/passwd", "/api/v1/x"} {
err := selfUpdate(context.Background(), api.NewClient("http://127.0.0.1:1", "t"),
&api.AgentUpdate{Version: "v1.0.0", Path: p, SHA256: strings.Repeat("a", 64)}, "v0.1.0")
if err == nil || !strings.Contains(err.Error(), "недопустимый путь") {
t.Errorf("%s: %v", p, err)
}
}
}
func TestWriteVerified(t *testing.T) {
p := filepath.Join(t.TempDir(), "bin")
// sha256("abc")
good := "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
if err := writeVerified(p, strings.NewReader("abc"), good); err != nil {
t.Fatal(err)
}
if err := writeVerified(p, strings.NewReader("abd"), good); err == nil {
t.Fatal("подменённый бинарник принят")
}
if _, err := os.Stat(p); err != nil {
t.Fatal(err)
}
}
func TestCatalogFor(t *testing.T) {
cat, warns, err := catalogFor(&api.AgentConfig{CatalogYAML: "categories:\n - {name: only, type: files, paths: [/x]}\n"})
if err != nil || len(warns) != 0 || cat.Find("only") == nil || cat.Find("xui") != nil {
t.Fatalf("каталог с сервера не применён: %v %v", err, warns)
}
// каталог, который эта версия не понимает, — встроенный + переопределения и предупреждение
cat, warns, err = catalogFor(&api.AgentConfig{
CatalogYAML: "categories:\n - {name: x, type: future-type}\n",
ConfigYAML: "categories:\n - {name: scripts, disabled: true}\n",
})
if err != nil || len(warns) != 1 || cat.Find("xui") == nil || !cat.Find("scripts").Disabled {
t.Fatalf("запасной каталог: %v %v", err, warns)
}
// старый сервер без catalog_yaml
if cat, _, err := catalogFor(&api.AgentConfig{}); err != nil || cat.Find("xui") == nil {
t.Fatalf("встроенный каталог: %v", err)
}
}
+52 -3
View File
@@ -9,6 +9,7 @@ import (
"io" "io"
"net" "net"
"net/http" "net/http"
"net/url"
"strings" "strings"
"time" "time"
@@ -17,8 +18,20 @@ import (
type AgentConfig struct { type AgentConfig struct {
Domain string `json:"domain"` Domain string `json:"domain"`
ConfigYAML string `json:"config_yaml"` ConfigYAML string `json:"config_yaml"` // только переопределения клиента (для старых агентов)
HeavyDue bool `json:"heavy_due"` HeavyDue bool `json:"heavy_due"`
// CatalogYAML — итоговый каталог: встроенный каталог сервера + переопределения клиента.
// Агент берёт его вместо своего встроенного, поэтому новые категории приходят без обновления агента.
CatalogYAML string `json:"catalog_yaml,omitempty"`
ServerVersion string `json:"server_version,omitempty"`
Update *AgentUpdate `json:"update,omitempty"` // есть, если агенту пора обновиться
}
// AgentUpdate — новая версия агента. Path — путь на том же сервере (/dl/...), чужие адреса агент не принимает.
type AgentUpdate struct {
Version string `json:"version"`
Path string `json:"path"`
SHA256 string `json:"sha256"`
} }
type PartInfo struct { type PartInfo struct {
@@ -100,9 +113,38 @@ func jsonBody(v any) io.Reader {
return bytes.NewReader(data) return bytes.NewReader(data)
} }
func (c *Client) AgentConfig(ctx context.Context) (*AgentConfig, error) { // AgentConfig запрашивает настройки агента; arch и version нужны серверу, чтобы предложить обновление.
func (c *Client) AgentConfig(ctx context.Context, arch, version string) (*AgentConfig, error) {
var out AgentConfig var out AgentConfig
return &out, c.do(ctx, http.MethodGet, "/api/v1/agent/config", nil, &out) q := url.Values{}
if arch != "" {
q.Set("arch", arch)
}
if version != "" {
q.Set("version", version)
}
path := "/api/v1/agent/config"
if len(q) > 0 {
path += "?" + q.Encode()
}
return &out, c.do(ctx, http.MethodGet, path, nil, &out)
}
// Download открывает поток файла с этого же сервера (path вида /dl/...).
func (c *Client) Download(ctx context.Context, path string) (io.ReadCloser, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.Server+path, nil)
if err != nil {
return nil, err
}
resp, err := c.HTTP.Do(req)
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK {
resp.Body.Close()
return nil, &Error{Status: resp.StatusCode, Msg: resp.Status}
}
return resp.Body, nil
} }
func (c *Client) CreateBackup(ctx context.Context, m *manifest.Manifest) (int64, error) { func (c *Client) CreateBackup(ctx context.Context, m *manifest.Manifest) (int64, error) {
@@ -159,3 +201,10 @@ func (c *Client) DownloadPart(ctx context.Context, id int64, name string, offset
} }
return resp.Body, nil 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/machine-id
- /etc/hostname - /etc/hostname
- /etc/resolv.conf - /etc/resolv.conf
# свой агент не бэкапим: на новый сервер его ставит restore с новым токеном
- /etc/backup-agent
- name: pnvpanel - name: pnvpanel
type: compose type: compose
+13
View File
@@ -40,6 +40,7 @@ type Options struct {
Yes bool // не спрашивать подтверждение Yes bool // не спрашивать подтверждение
StagingDir string StagingDir string
KeepStaging bool KeepStaging bool
Takeover string // TakeoverAsk / TakeoverYes / TakeoverNo
} }
type restorer struct { type restorer struct {
@@ -156,6 +157,7 @@ func Run(ctx context.Context, opts Options) error {
failed = append(failed, c.Name) failed = append(failed, c.Name)
} }
} }
r.takeover()
r.finish(failed) r.finish(failed)
if len(failed) > 0 { if len(failed) > 0 {
return fmt.Errorf("с ошибками восстановлены категории: %s", strings.Join(failed, ", ")) return fmt.Errorf("с ошибками восстановлены категории: %s", strings.Join(failed, ", "))
@@ -193,6 +195,17 @@ func (r *restorer) summary(rm *api.RestoreManifest) {
info("части: %s", strings.Join(parts, ", ")) 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 { func confirm(q string) bool {
fmt.Printf("\n%s? [y/N]: ", q) fmt.Printf("\n%s? [y/N]: ", q)
line, _ := bufio.NewReader(os.Stdin).ReadString('\n') 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")
}
+67 -1
View File
@@ -15,10 +15,14 @@ import (
"regexp" "regexp"
"slices" "slices"
"strconv" "strconv"
"strings"
"time" "time"
"gitea.hsrv.site/mrleo1nid/backup-master/internal/api" "gitea.hsrv.site/mrleo1nid/backup-master/internal/api"
"gitea.hsrv.site/mrleo1nid/backup-master/internal/catalog"
"gitea.hsrv.site/mrleo1nid/backup-master/internal/manifest" "gitea.hsrv.site/mrleo1nid/backup-master/internal/manifest"
"gitea.hsrv.site/mrleo1nid/backup-master/internal/version"
"gopkg.in/yaml.v3"
) )
type ctxKey int type ctxKey int
@@ -79,7 +83,50 @@ func (s *Server) apiAgentConfig(w http.ResponseWriter, r *http.Request) {
c := clientFrom(r) c := clientFrom(r)
due := c.HeavyEveryDays >= 0 && due := c.HeavyEveryDays >= 0 &&
time.Since(time.Unix(c.LastHeavyAt, 0)) >= time.Duration(c.HeavyEveryDays)*24*time.Hour-time.Hour time.Since(time.Unix(c.LastHeavyAt, 0)) >= time.Duration(c.HeavyEveryDays)*24*time.Hour-time.Hour
jsonResp(w, http.StatusOK, AgentConfigResponse{Domain: c.Domain, ConfigYAML: c.ConfigYAML, HeavyDue: due}) resp := AgentConfigResponse{Domain: c.Domain, ConfigYAML: c.ConfigYAML, HeavyDue: due, ServerVersion: s.cfg.Version}
agentVer := r.URL.Query().Get("version")
if agentVer != "" && agentVer != c.AgentVersion {
_ = s.st.SetAgentVersion(c.ID, agentVer)
}
// итоговый каталог: встроенный каталог сервера + переопределения клиента
if cat, err := s.clientCatalog(c); err != nil {
log.Printf("каталог %s: %v", c.Domain, err)
} else {
resp.CatalogYAML = cat
}
arch := r.URL.Query().Get("arch")
if c.AutoUpdate && agentVer != "" && version.ShouldUpdate(s.cfg.Version, agentVer) {
if p, ok := s.binaryPath(arch); ok {
if sum, err := s.binarySHA256(p); err == nil {
resp.Update = &api.AgentUpdate{Version: s.cfg.Version, Path: "/dl/backupctl-linux-" + arch, SHA256: sum}
} else {
log.Printf("sha256 %s: %v", p, err)
}
}
}
jsonResp(w, http.StatusOK, resp)
}
func (s *Server) clientCatalog(c *Client) (string, error) {
cat, err := catalog.Default()
if err != nil {
return "", err
}
if strings.TrimSpace(c.ConfigYAML) != "" {
over, err := catalog.Parse([]byte(c.ConfigYAML))
if err != nil {
return "", err
}
cat = catalog.Merge(cat, over)
}
if err := cat.Validate(); err != nil {
return "", err
}
data, err := yaml.Marshal(cat)
return string(data), err
} }
var partNameRe = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$`) var partNameRe = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$`)
@@ -393,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)) filepath.Base(filepath.Dir(b.Dir)), b.ID, name))
http.ServeContent(w, r, "", fi.ModTime(), f) 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})
}
+7
View File
@@ -27,6 +27,13 @@ func newTestServer(t *testing.T) (*Server, *httptest.Server) {
return s, ts return s, ts
} }
func newTestHTTP(t *testing.T, s *Server) string {
t.Helper()
ts := httptest.NewServer(s.Handler())
t.Cleanup(ts.Close)
return ts.URL
}
func call(t *testing.T, method, url, token string, body io.Reader) *http.Response { func call(t *testing.T, method, url, token string, body io.Reader) *http.Response {
t.Helper() t.Helper()
req, err := http.NewRequest(method, url, body) req, err := http.NewRequest(method, url, body)
+5
View File
@@ -2,6 +2,8 @@ package server
import ( import (
"cmp" "cmp"
"gitea.hsrv.site/mrleo1nid/backup-master/internal/version"
"net/http" "net/http"
"net/url" "net/url"
"slices" "slices"
@@ -179,6 +181,9 @@ func (s *Server) webClients(w http.ResponseWriter, r *http.Request) {
return return
} }
v := buildClientsView(q, clients, sizes) v := buildClientsView(q, clients, sizes)
for i := range v.Rows {
v.Rows[i].Outdated = version.ShouldUpdate(s.cfg.Version, v.Rows[i].AgentVersion) && v.Rows[i].AgentVersion != ""
}
s.render(w, r, "clients", map[string]any{"V": v}) s.render(w, r, "clients", map[string]any{"V": v})
} }
+66 -18
View File
@@ -1,6 +1,9 @@
package server package server
import ( import (
"crypto/sha256"
"encoding/hex"
"io"
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
@@ -8,6 +11,7 @@ import (
"runtime" "runtime"
"strings" "strings"
"text/template" "text/template"
"time"
) )
var agentScript = template.Must(template.New("agent.sh").Parse(`#!/usr/bin/env bash var agentScript = template.Must(template.New("agent.sh").Parse(`#!/usr/bin/env bash
@@ -143,28 +147,72 @@ func (s *Server) serveRestoreScript(w http.ResponseWriter, r *http.Request) {
var binNameRe = regexp.MustCompile(`^backupctl-linux-(amd64|arm64)$`) var binNameRe = regexp.MustCompile(`^backupctl-linux-(amd64|arm64)$`)
// serveBinary раздаёт backupctl для агентов: из --dist, а для своей архитектуры — сам себя. // binaryPath возвращает файл backupctl для архитектуры: из --dist, а для своей — сам себя.
func (s *Server) serveBinary(w http.ResponseWriter, r *http.Request) { func (s *Server) binaryPath(arch string) (string, bool) {
name := r.PathValue("file") name := "backupctl-linux-" + arch
m := binNameRe.FindStringSubmatch(name) if !binNameRe.MatchString(name) {
if m == nil { return "", false
http.NotFound(w, r)
return
} }
if s.cfg.DistDir != "" { if s.cfg.DistDir != "" {
p := filepath.Join(s.cfg.DistDir, name) p := filepath.Join(s.cfg.DistDir, name)
if _, err := os.Stat(p); err == nil { if _, err := os.Stat(p); err == nil {
return p, true
}
}
if runtime.GOOS == "linux" && runtime.GOARCH == arch {
if self, err := os.Executable(); err == nil && !strings.HasSuffix(self, ".test") {
return self, true
}
}
return "", false
}
type binarySum struct {
size int64
modTime time.Time
sum string
}
// binarySHA256 считает sha256 файла с кэшем по размеру и времени изменения.
func (s *Server) binarySHA256(p string) (string, error) {
fi, err := os.Stat(p)
if err != nil {
return "", err
}
s.shaMu.Lock()
c, ok := s.shaCache[p]
s.shaMu.Unlock()
if ok && c.size == fi.Size() && c.modTime.Equal(fi.ModTime()) {
return c.sum, nil
}
f, err := os.Open(p)
if err != nil {
return "", err
}
defer f.Close()
h := sha256.New()
if _, err := io.Copy(h, f); err != nil {
return "", err
}
sum := hex.EncodeToString(h.Sum(nil))
s.shaMu.Lock()
s.shaCache[p] = binarySum{fi.Size(), fi.ModTime(), sum}
s.shaMu.Unlock()
return sum, nil
}
// serveBinary раздаёт backupctl для агентов.
func (s *Server) serveBinary(w http.ResponseWriter, r *http.Request) {
m := binNameRe.FindStringSubmatch(r.PathValue("file"))
if m == nil {
http.NotFound(w, r)
return
}
p, ok := s.binaryPath(m[1])
if !ok {
http.Error(w, "бинарник "+m[0]+" не найден: положите его в --dist", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/octet-stream") w.Header().Set("Content-Type", "application/octet-stream")
http.ServeFile(w, r, p) http.ServeFile(w, r, p)
return
}
}
if runtime.GOOS == "linux" && runtime.GOARCH == m[1] {
if self, err := os.Executable(); err == nil && !strings.HasSuffix(self, ".test") {
w.Header().Set("Content-Type", "application/octet-stream")
http.ServeFile(w, r, self)
return
}
}
http.Error(w, "бинарник "+name+" не найден: положите его в --dist", http.StatusNotFound)
} }
+8 -1
View File
@@ -25,6 +25,8 @@ type Config struct {
MaxUpload int64 // максимальный размер одной части, байт MaxUpload int64 // максимальный размер одной части, байт
// TrustProxy — брать IP клиента из X-Forwarded-For (за Caddy). // TrustProxy — брать IP клиента из X-Forwarded-For (за Caddy).
TrustProxy bool TrustProxy bool
// Version — версия сервера; до неё обновляются агенты с включённым автообновлением.
Version string
} }
type Server struct { type Server struct {
@@ -35,6 +37,9 @@ type Server struct {
uploadMu sync.Mutex uploadMu sync.Mutex
uploads map[int64]bool // бэкапы, в которые сейчас идёт загрузка uploads map[int64]bool // бэкапы, в которые сейчас идёт загрузка
shaMu sync.Mutex
shaCache map[string]binarySum // sha256 раздаваемых бинарников
} }
func New(cfg Config) (*Server, error) { func New(cfg Config) (*Server, error) {
@@ -54,7 +59,8 @@ func New(cfg Config) (*Server, error) {
st.Close() st.Close()
return nil, err return nil, err
} }
return &Server{cfg: cfg, st: st, pages: pages, login: newLoginLimiter(), uploads: map[int64]bool{}}, nil return &Server{cfg: cfg, st: st, pages: pages, login: newLoginLimiter(), uploads: map[int64]bool{},
shaCache: map[string]binarySum{}}, nil
} }
func (s *Server) Close() error { return s.st.Close() } func (s *Server) Close() error { return s.st.Close() }
@@ -80,6 +86,7 @@ func (s *Server) Handler() http.Handler {
// API восстановления // API восстановления
mux.HandleFunc("GET /api/v1/restore/manifest", s.restoreAuth(s.apiRestoreManifest)) 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("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")) }) mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { w.Write([]byte("ok\n")) })
+45 -5
View File
@@ -94,7 +94,38 @@ func OpenStore(path string) (*Store, error) {
db.Close() db.Close()
return nil, fmt.Errorf("миграция: %w", err) return nil, fmt.Errorf("миграция: %w", err)
} }
return &Store{db: db}, nil st := &Store{db: db}
// колонки, добавленные после первых релизов: у существующих баз их нет
for _, col := range []struct{ table, name, def string }{
{"clients", "auto_update", "INTEGER NOT NULL DEFAULT 1"},
{"clients", "agent_version", "TEXT NOT NULL DEFAULT ''"},
} {
if err := st.ensureColumn(col.table, col.name, col.def); err != nil {
db.Close()
return nil, fmt.Errorf("миграция %s.%s: %w", col.table, col.name, err)
}
}
return st, nil
}
func (s *Store) ensureColumn(table, name, def string) error {
rows, err := s.db.Query(`SELECT name FROM pragma_table_info(?)`, table)
if err != nil {
return err
}
found := false
for rows.Next() {
var n string
if rows.Scan(&n) == nil && n == name {
found = true
}
}
rows.Close()
if found {
return nil
}
_, err = s.db.Exec(fmt.Sprintf(`ALTER TABLE %s ADD COLUMN %s %s`, table, name, def))
return err
} }
func (s *Store) Close() error { return s.db.Close() } func (s *Store) Close() error { return s.db.Close() }
@@ -119,17 +150,20 @@ type Client struct {
LastErrorAt int64 LastErrorAt int64
StaleNotified bool StaleNotified bool
Disabled bool Disabled bool
AutoUpdate bool // агент сам обновляется до версии сервера
AgentVersion string // версия агента при последнем обращении
} }
const clientCols = `id, name, domain, token_hash, config_yaml, keep_daily, keep_weekly, keep_monthly, const clientCols = `id, name, domain, token_hash, config_yaml, keep_daily, keep_weekly, keep_monthly,
heavy_keep, heavy_every_days, stale_hours, created_at, last_backup_at, last_heavy_at, last_seen_at, heavy_keep, heavy_every_days, stale_hours, created_at, last_backup_at, last_heavy_at, last_seen_at,
last_error, last_error_at, stale_notified, disabled` last_error, last_error_at, stale_notified, disabled, auto_update, agent_version`
func scanClient(row interface{ Scan(...any) error }) (*Client, error) { func scanClient(row interface{ Scan(...any) error }) (*Client, error) {
var c Client var c Client
err := row.Scan(&c.ID, &c.Name, &c.Domain, &c.TokenHash, &c.ConfigYAML, &c.KeepDaily, &c.KeepWeekly, err := row.Scan(&c.ID, &c.Name, &c.Domain, &c.TokenHash, &c.ConfigYAML, &c.KeepDaily, &c.KeepWeekly,
&c.KeepMonthly, &c.HeavyKeep, &c.HeavyEveryDays, &c.StaleHours, &c.CreatedAt, &c.LastBackupAt, &c.KeepMonthly, &c.HeavyKeep, &c.HeavyEveryDays, &c.StaleHours, &c.CreatedAt, &c.LastBackupAt,
&c.LastHeavyAt, &c.LastSeenAt, &c.LastError, &c.LastErrorAt, &c.StaleNotified, &c.Disabled) &c.LastHeavyAt, &c.LastSeenAt, &c.LastError, &c.LastErrorAt, &c.StaleNotified, &c.Disabled,
&c.AutoUpdate, &c.AgentVersion)
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound return nil, ErrNotFound
} }
@@ -172,9 +206,10 @@ func (s *Store) ListClients() ([]*Client, error) {
func (s *Store) UpdateClientSettings(c *Client) error { func (s *Store) UpdateClientSettings(c *Client) error {
_, err := s.db.Exec(`UPDATE clients SET name = ?, config_yaml = ?, keep_daily = ?, keep_weekly = ?, _, err := s.db.Exec(`UPDATE clients SET name = ?, config_yaml = ?, keep_daily = ?, keep_weekly = ?,
keep_monthly = ?, heavy_keep = ?, heavy_every_days = ?, stale_hours = ?, disabled = ? WHERE id = ?`, keep_monthly = ?, heavy_keep = ?, heavy_every_days = ?, stale_hours = ?, disabled = ?, auto_update = ?
WHERE id = ?`,
c.Name, c.ConfigYAML, c.KeepDaily, c.KeepWeekly, c.KeepMonthly, c.HeavyKeep, c.HeavyEveryDays, c.Name, c.ConfigYAML, c.KeepDaily, c.KeepWeekly, c.KeepMonthly, c.HeavyKeep, c.HeavyEveryDays,
c.StaleHours, c.Disabled, c.ID) c.StaleHours, c.Disabled, c.AutoUpdate, c.ID)
return err return err
} }
@@ -193,6 +228,11 @@ func (s *Store) TouchClient(id int64) error {
return err return err
} }
func (s *Store) SetAgentVersion(id int64, version string) error {
_, err := s.db.Exec(`UPDATE clients SET agent_version = ? WHERE id = ?`, version, id)
return err
}
func (s *Store) MarkClientSuccess(id int64, at int64, heavy bool) error { func (s *Store) MarkClientSuccess(id int64, at int64, heavy bool) error {
q := `UPDATE clients SET last_backup_at = ?1, last_error = '', last_error_at = 0, stale_notified = 0` q := `UPDATE clients SET last_backup_at = ?1, last_error = '', last_error_at = 0, stale_notified = 0`
if heavy { if heavy {
+2
View File
@@ -24,6 +24,7 @@
<div><dt>Последний бэкап</dt><dd title="{{ts .Client.LastBackupAt}}">{{ago .Client.LastBackupAt}}</dd></div> <div><dt>Последний бэкап</dt><dd title="{{ts .Client.LastBackupAt}}">{{ago .Client.LastBackupAt}}</dd></div>
<div><dt>Heavy-части</dt><dd title="{{ts .Client.LastHeavyAt}}">{{ago .Client.LastHeavyAt}}</dd></div> <div><dt>Heavy-части</dt><dd title="{{ts .Client.LastHeavyAt}}">{{ago .Client.LastHeavyAt}}</dd></div>
<div><dt>Агент на связи</dt><dd title="{{ts .Client.LastSeenAt}}">{{ago .Client.LastSeenAt}}</dd></div> <div><dt>Агент на связи</dt><dd title="{{ts .Client.LastSeenAt}}">{{ago .Client.LastSeenAt}}</dd></div>
<div><dt>Версия агента</dt><dd>{{with .Client.AgentVersion}}{{.}}{{else}}—{{end}}{{if .AgentOutdated}} <span class="badge stale" title="Сервер {{.ServerVersion}}">{{if .Client.AutoUpdate}}обновится при следующем бэкапе{{else}}устарел, автообновление выключено{{end}}</span>{{end}}</dd></div>
{{with .Latest}}<div><dt>Система</dt><dd>{{.Hostname}}{{with .OS.PrettyName}} · {{.}}{{end}}{{with .OS.Arch}} · {{.}}{{end}}</dd></div>{{end}} {{with .Latest}}<div><dt>Система</dt><dd>{{.Hostname}}{{with .OS.PrettyName}} · {{.}}{{end}}{{with .OS.Arch}} · {{.}}{{end}}</dd></div>{{end}}
</dl> </dl>
@@ -105,6 +106,7 @@
<label>Heavy раз в, дней <input type="number" min="0" name="heavy_every_days" value="{{.Client.HeavyEveryDays}}"></label> <label>Heavy раз в, дней <input type="number" min="0" name="heavy_every_days" value="{{.Client.HeavyEveryDays}}"></label>
<label>Хранить heavy, шт <input type="number" min="0" name="heavy_keep" value="{{.Client.HeavyKeep}}"></label> <label>Хранить heavy, шт <input type="number" min="0" name="heavy_keep" value="{{.Client.HeavyKeep}}"></label>
</div> </div>
<label class="check"><input type="checkbox" name="auto_update" value="1" {{if .Client.AutoUpdate}}checked{{end}}> Автообновление агента до версии сервера (перед бэкапом)</label>
<label class="check"><input type="checkbox" name="disabled" value="1" {{if .Client.Disabled}}checked{{end}}> Клиент отключён (агент получает отказ)</label> <label class="check"><input type="checkbox" name="disabled" value="1" {{if .Client.Disabled}}checked{{end}}> Клиент отключён (агент получает отказ)</label>
<label>Переопределения каталога (YAML) <label>Переопределения каталога (YAML)
<textarea name="config_yaml" rows="12" spellcheck="false" placeholder="categories: <textarea name="config_yaml" rows="12" spellcheck="false" placeholder="categories:
+2
View File
@@ -31,6 +31,7 @@
<th><a href="{{$q.SortURL "last_backup"}}" {{if eq $q.Sort "last_backup"}}class="sorted"{{end}}>Последний бэкап {{$q.SortMark "last_backup"}}</a></th> <th><a href="{{$q.SortURL "last_backup"}}" {{if eq $q.Sort "last_backup"}}class="sorted"{{end}}>Последний бэкап {{$q.SortMark "last_backup"}}</a></th>
<th class="num"><a href="{{$q.SortURL "size"}}" {{if eq $q.Sort "size"}}class="sorted"{{end}}>Размер {{$q.SortMark "size"}}</a></th> <th class="num"><a href="{{$q.SortURL "size"}}" {{if eq $q.Sort "size"}}class="sorted"{{end}}>Размер {{$q.SortMark "size"}}</a></th>
<th><a href="{{$q.SortURL "last_seen"}}" {{if eq $q.Sort "last_seen"}}class="sorted"{{end}}>Агент на связи {{$q.SortMark "last_seen"}}</a></th> <th><a href="{{$q.SortURL "last_seen"}}" {{if eq $q.Sort "last_seen"}}class="sorted"{{end}}>Агент на связи {{$q.SortMark "last_seen"}}</a></th>
<th>Версия</th>
</tr></thead> </tr></thead>
<tbody> <tbody>
{{range $v.Rows}} {{range $v.Rows}}
@@ -41,6 +42,7 @@
<td title="{{ts .LastBackupAt}}">{{ago .LastBackupAt}}</td> <td title="{{ts .LastBackupAt}}">{{ago .LastBackupAt}}</td>
<td class="num">{{if .LastSize}}{{bytes .LastSize}}{{end}}</td> <td class="num">{{if .LastSize}}{{bytes .LastSize}}{{end}}</td>
<td title="{{ts .LastSeenAt}}">{{ago .LastSeenAt}}</td> <td title="{{ts .LastSeenAt}}">{{ago .LastSeenAt}}</td>
<td>{{if .Outdated}}<span class="badge stale" title="{{if .AutoUpdate}}обновится при следующем бэкапе{{else}}автообновление выключено{{end}}">{{.AgentVersion}}</span>{{else}}<span class="muted small">{{.AgentVersion}}</span>{{end}}</td>
</tr> </tr>
{{end}} {{end}}
</tbody> </tbody>
+1 -1
View File
@@ -10,7 +10,7 @@
{{if ne .Page "login"}} {{if ne .Page "login"}}
<header class="top"> <header class="top">
<div class="wrap"> <div class="wrap">
<a class="brand" href="/">backup-master</a> <a class="brand" href="/">backup-master{{with .ServerVersion}} <span class="muted small">{{.}}</span>{{end}}</a>
<nav> <nav>
<a href="/" {{if or (eq .Page "clients") (eq .Page "client") (eq .Page "backup")}}class="active"{{end}}>Клиенты</a> <a href="/" {{if or (eq .Page "clients") (eq .Page "client") (eq .Page "backup")}}class="active"{{end}}>Клиенты</a>
<a href="/settings" {{if eq .Page "settings"}}class="active"{{end}}>Настройки</a> <a href="/settings" {{if eq .Page "settings"}}class="active"{{end}}>Настройки</a>
+144
View File
@@ -0,0 +1,144 @@
package server
import (
"crypto/sha256"
"database/sql"
"encoding/hex"
"os"
"path/filepath"
"testing"
"time"
"gitea.hsrv.site/mrleo1nid/backup-master/internal/catalog"
)
func TestAgentConfigCatalogAndUpdate(t *testing.T) {
dist := t.TempDir()
bin := []byte("fake-backupctl-binary")
if err := os.WriteFile(filepath.Join(dist, "backupctl-linux-amd64"), bin, 0o755); err != nil {
t.Fatal(err)
}
s, err := New(Config{DataDir: t.TempDir(), DistDir: dist, Version: "v0.6.0"})
if err != nil {
t.Fatal(err)
}
defer s.Close()
tok := newToken()
id, err := s.st.CreateClient("x", "x.example.com", hashToken(tok))
if err != nil {
t.Fatal(err)
}
c, _ := s.st.GetClient(id)
if !c.AutoUpdate {
t.Fatal("автообновление должно быть включено по умолчанию")
}
c.ConfigYAML = "categories:\n - {name: scripts, disabled: true}\n"
if err := s.st.UpdateClientSettings(c); err != nil {
t.Fatal(err)
}
ts := newTestHTTP(t, s)
var cfg AgentConfigResponse
decode(t, call(t, "GET", ts+"/api/v1/agent/config?arch=amd64&version=v0.5.0", tok, nil), &cfg)
cat, err := catalog.Parse([]byte(cfg.CatalogYAML))
if err != nil || cat.Validate() != nil {
t.Fatalf("каталог не читается: %v", err)
}
if cat.Find("xui") == nil || !cat.Find("scripts").Disabled {
t.Fatal("в каталоге нет встроенных категорий или переопределений клиента")
}
sum := sha256.Sum256(bin)
if cfg.Update == nil || cfg.Update.Version != "v0.6.0" || cfg.Update.Path != "/dl/backupctl-linux-amd64" || cfg.Update.SHA256 != hex.EncodeToString(sum[:]) {
t.Fatalf("update: %+v", cfg.Update)
}
if c, _ := s.st.GetClient(id); c.AgentVersion != "v0.5.0" {
t.Fatalf("версия агента не записана: %q", c.AgentVersion)
}
// актуальная версия, неизвестная архитектура, выключенное автообновление — без обновления
for _, q := range []string{"arch=amd64&version=v0.6.0", "arch=riscv64&version=v0.5.0"} {
cfg = AgentConfigResponse{}
decode(t, call(t, "GET", ts+"/api/v1/agent/config?"+q, tok, nil), &cfg)
if cfg.Update != nil {
t.Fatalf("%s: обновление не ожидалось", q)
}
}
c, _ = s.st.GetClient(id)
c.AutoUpdate = false
_ = s.st.UpdateClientSettings(c)
cfg = AgentConfigResponse{}
decode(t, call(t, "GET", ts+"/api/v1/agent/config?arch=amd64&version=v0.5.0", tok, nil), &cfg)
if cfg.Update != nil {
t.Fatal("автообновление выключено, а обновление предложено")
}
}
// Базы первых релизов без новых колонок мигрируются при открытии.
func TestStoreMigration(t *testing.T) {
path := filepath.Join(t.TempDir(), "old.db")
db, err := sql.Open("sqlite", "file:"+path)
if err != nil {
t.Fatal(err)
}
_, err = db.Exec(`CREATE TABLE clients (id INTEGER PRIMARY KEY, name TEXT NOT NULL, domain TEXT NOT NULL UNIQUE,
token_hash TEXT NOT NULL UNIQUE, config_yaml TEXT NOT NULL DEFAULT '', keep_daily INTEGER NOT NULL DEFAULT 7,
keep_weekly INTEGER NOT NULL DEFAULT 4, keep_monthly INTEGER NOT NULL DEFAULT 6, heavy_keep INTEGER NOT NULL DEFAULT 1,
heavy_every_days INTEGER NOT NULL DEFAULT 7, stale_hours INTEGER NOT NULL DEFAULT 26, created_at INTEGER NOT NULL,
last_backup_at INTEGER NOT NULL DEFAULT 0, last_heavy_at INTEGER NOT NULL DEFAULT 0, last_seen_at INTEGER NOT NULL DEFAULT 0,
last_error TEXT NOT NULL DEFAULT '', last_error_at INTEGER NOT NULL DEFAULT 0, stale_notified INTEGER NOT NULL DEFAULT 0,
disabled INTEGER NOT NULL DEFAULT 0);
INSERT INTO clients (name, domain, token_hash, created_at) VALUES ('a', 'a.example.com', 'h', 1);`)
if err != nil {
t.Fatal(err)
}
db.Close()
st, err := OpenStore(path)
if err != nil {
t.Fatal(err)
}
defer st.Close()
cs, err := st.ListClients()
if err != nil || len(cs) != 1 || !cs[0].AutoUpdate {
t.Fatalf("после миграции: %v %+v", err, cs)
}
// повторное открытие не падает на уже добавленных колонках
st.Close()
if st2, err := OpenStore(path); err != nil {
t.Fatal(err)
} else {
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)
}
}
+7 -2
View File
@@ -16,6 +16,7 @@ import (
"gitea.hsrv.site/mrleo1nid/backup-master/internal/catalog" "gitea.hsrv.site/mrleo1nid/backup-master/internal/catalog"
"gitea.hsrv.site/mrleo1nid/backup-master/internal/manifest" "gitea.hsrv.site/mrleo1nid/backup-master/internal/manifest"
"gitea.hsrv.site/mrleo1nid/backup-master/internal/version"
) )
//go:embed templates/*.html //go:embed templates/*.html
@@ -95,6 +96,7 @@ func (s *Server) render(w http.ResponseWriter, r *http.Request, page string, dat
data["CSRF"] = csrfFrom(r) data["CSRF"] = csrfFrom(r)
data["Page"] = page data["Page"] = page
data["PublicURL"] = s.cfg.PublicURL data["PublicURL"] = s.cfg.PublicURL
data["ServerVersion"] = s.cfg.Version
var buf bytes.Buffer var buf bytes.Buffer
if err := t.Execute(&buf, data); err != nil { if err := t.Execute(&buf, data); err != nil {
log.Printf("render %s: %v", page, err) log.Printf("render %s: %v", page, err)
@@ -127,6 +129,7 @@ type clientRow struct {
*Client *Client
StatusClass, StatusText string StatusClass, StatusText string
LastSize int64 LastSize int64
Outdated bool // агент старее сервера
} }
func (s *Server) webClientNew(w http.ResponseWriter, r *http.Request) { func (s *Server) webClientNew(w http.ResponseWriter, r *http.Request) {
@@ -196,7 +199,8 @@ func (s *Server) renderClient(w http.ResponseWriter, r *http.Request, c *Client,
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
return return
} }
data := map[string]any{"Client": c, "Backups": backups, "Error": formErr} data := map[string]any{"Client": c, "Backups": backups, "Error": formErr,
"AgentOutdated": c.AgentVersion != "" && version.ShouldUpdate(s.cfg.Version, c.AgentVersion)}
data["StatusClass"], data["StatusText"] = clientStatus(c) data["StatusClass"], data["StatusText"] = clientStatus(c)
if latest, err := s.st.LatestComplete(c.ID); err == nil { if latest, err := s.st.LatestComplete(c.ID); err == nil {
var m manifest.Manifest var m manifest.Manifest
@@ -230,6 +234,7 @@ func (s *Server) webClientSettings(w http.ResponseWriter, r *http.Request) {
c.HeavyEveryDays = formInt(r, "heavy_every_days", c.HeavyEveryDays) c.HeavyEveryDays = formInt(r, "heavy_every_days", c.HeavyEveryDays)
c.StaleHours = formInt(r, "stale_hours", c.StaleHours) c.StaleHours = formInt(r, "stale_hours", c.StaleHours)
c.Disabled = r.FormValue("disabled") == "1" c.Disabled = r.FormValue("disabled") == "1"
c.AutoUpdate = r.FormValue("auto_update") == "1"
if strings.TrimSpace(c.ConfigYAML) != "" { if strings.TrimSpace(c.ConfigYAML) != "" {
base, err := catalog.Default() base, err := catalog.Default()
if err == nil { if err == nil {
@@ -286,7 +291,7 @@ func (s *Server) webClientRestoreToken(w http.ResponseWriter, r *http.Request) {
s.render(w, r, "token", map[string]any{ s.render(w, r, "token", map[string]any{
"Client": c, "Client": c,
"Title": "Восстановление " + c.Domain, "Title": "Восстановление " + c.Domain,
"Note": "Токен действует 24 часа и даёт только чтение бэкапов этого клиента. Выполните на новом сервере (Ubuntu 24.04, от root или пользователя с sudo):", "Note": "Токен действует 24 часа и даёт только чтение бэкапов этого клиента. Выполните на новом сервере (Ubuntu 24.04, от root или пользователя с sudo). В конце restore спросит, сделать ли новый сервер основным: тогда токен старого отзывается и бэкапы шлёт только новый. Для пробного восстановления добавьте --no-takeover.",
"Cmd": cmd, "Cmd": cmd,
}) })
} }
+44
View File
@@ -0,0 +1,44 @@
// Package version сравнивает версии backupctl вида v1.2.3.
package version
import (
"strconv"
"strings"
)
// Parse разбирает "v1.2.3" или "1.2.3". ok=false для dev-сборок ("dev", "main-abc123").
func Parse(v string) (parts [3]int, ok bool) {
v = strings.TrimPrefix(strings.TrimSpace(v), "v")
v, _, _ = strings.Cut(v, "-") // v1.2.3-rc1 → 1.2.3
f := strings.Split(v, ".")
if len(f) != 3 {
return parts, false
}
for i, s := range f {
n, err := strconv.Atoi(s)
if err != nil || n < 0 {
return parts, false
}
parts[i] = n
}
return parts, true
}
// ShouldUpdate решает, обновлять ли агента до версии сервера. Сервер dev-сборки
// обновления не раздаёт; агент dev-сборки обновляется до релиза; откат вниз не делается.
func ShouldUpdate(server, agent string) bool {
s, ok := Parse(server)
if !ok {
return false
}
a, ok := Parse(agent)
if !ok {
return true
}
for i := range s {
if s[i] != a[i] {
return s[i] > a[i]
}
}
return false
}
+25
View File
@@ -0,0 +1,25 @@
package version
import "testing"
func TestShouldUpdate(t *testing.T) {
cases := []struct {
server, agent string
want bool
}{
{"v0.5.0", "v0.4.0", true},
{"v0.5.0", "v0.5.0", false},
{"v0.4.0", "v0.5.0", false}, // без отката
{"v0.10.0", "v0.9.3", true}, // числа, а не строки
{"v1.0.0", "dev", true},
{"v1.0.0", "main-abc123", true},
{"dev", "v0.1.0", false}, // dev-сервер не раздаёт обновления
{"main-abc", "dev", false},
{"v0.5.1-rc1", "v0.5.0", true},
}
for _, c := range cases {
if got := ShouldUpdate(c.server, c.agent); got != c.want {
t.Errorf("ShouldUpdate(%q, %q) = %v, want %v", c.server, c.agent, got, c.want)
}
}
}