Агенты подхватывают изменения с сервера: каталог и самообновление
CI / test (push) Successful in 1m29s
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>
This commit is contained in:
1 parent
1947b26913
commit
488abc2d34
21 files changed
+674
-51
No files matched your search
@@ -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-сборки обновлений не раздаёт, откатов на более старую версию не бывает.
|
||||||
|
|
||||||
### Уведомления
|
### Уведомления
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
//go:build !unix
|
||||||
|
|
||||||
|
package agent
|
||||||
|
|
||||||
|
import "errors"
|
||||||
|
|
||||||
|
func reexec(string, []string, []string) error {
|
||||||
|
return errors.New("самообновление поддерживается только на Linux")
|
||||||
|
}
|
||||||
@@ -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
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+45
-3
@@ -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) {
|
||||||
|
|||||||
+48
-1
@@ -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}$`)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+63
-15
@@ -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 {
|
||||||
w.Header().Set("Content-Type", "application/octet-stream")
|
return p, true
|
||||||
http.ServeFile(w, r, p)
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if runtime.GOOS == "linux" && runtime.GOARCH == m[1] {
|
if runtime.GOOS == "linux" && runtime.GOARCH == arch {
|
||||||
if self, err := os.Executable(); err == nil && !strings.HasSuffix(self, ".test") {
|
if self, err := os.Executable(); err == nil && !strings.HasSuffix(self, ".test") {
|
||||||
w.Header().Set("Content-Type", "application/octet-stream")
|
return self, true
|
||||||
http.ServeFile(w, r, self)
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
http.Error(w, "бинарник "+name+" не найден: положите его в --dist", http.StatusNotFound)
|
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")
|
||||||
|
http.ServeFile(w, r, p)
|
||||||
}
|
}
|
||||||
@@ -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() }
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/hex"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user