diff --git a/README.md b/README.md index 188dcb9..f456b9a 100644 --- a/README.md +++ b/README.md @@ -99,7 +99,14 @@ sudo systemctl reload caddy 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-сборки обновлений не раздаёт, откатов на более старую версию не бывает. ### Уведомления diff --git a/cmd/backupctl/main.go b/cmd/backupctl/main.go index 2a85ba4..dd13d01 100644 --- a/cmd/backupctl/main.go +++ b/cmd/backupctl/main.go @@ -99,6 +99,7 @@ func runServer(ctx context.Context, args []string) error { maxGB := fs.Int64("max-upload-gb", 200, "максимальный размер одной части бэкапа, ГБ") _ = fs.Parse(args) cfg.MaxUpload = *maxGB << 30 + cfg.Version = version if cfg.PublicURL == "" { log.Printf("ВНИМАНИЕ: --public-url не задан, адрес в командах установки берётся из запроса") } @@ -176,6 +177,7 @@ func runBackup(ctx context.Context, args []string) error { fs := flag.NewFlagSet("backup", flag.ExitOnError) o := agentFlags(fs) fs.StringVar(&o.Output, "output", "", "записать части в каталог вместо отправки на сервер (отладка)") + fs.BoolVar(&o.NoUpdate, "no-update", false, "не обновлять агента, даже если сервер предлагает новую версию") _ = fs.Parse(args) return agent.Backup(ctx, *o) } diff --git a/internal/agent/reexec_other.go b/internal/agent/reexec_other.go new file mode 100644 index 0000000..f660bb0 --- /dev/null +++ b/internal/agent/reexec_other.go @@ -0,0 +1,9 @@ +//go:build !unix + +package agent + +import "errors" + +func reexec(string, []string, []string) error { + return errors.New("самообновление поддерживается только на Linux") +} diff --git a/internal/agent/reexec_unix.go b/internal/agent/reexec_unix.go new file mode 100644 index 0000000..e82b143 --- /dev/null +++ b/internal/agent/reexec_unix.go @@ -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) +} diff --git a/internal/agent/run.go b/internal/agent/run.go index c9c4f8d..eb491f0 100644 --- a/internal/agent/run.go +++ b/internal/agent/run.go @@ -11,6 +11,7 @@ import ( "log" "os" "path/filepath" + "runtime" "strings" "time" @@ -27,43 +28,73 @@ type Options struct { Output string // ForceHeavy — включить heavy-части независимо от расписания. ForceHeavy bool + // NoUpdate — не обновлять агента, даже если сервер предлагает. + NoUpdate bool } -// prepare загружает конфиг, получает переопределения с сервера и собирает план. -func prepare(ctx context.Context, opts Options, spool string) (*Plan, *api.Client, error) { +// session — подключение к серверу и его ответ для одного запуска агента. +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) if err != nil { - return nil, nil, err + return nil, err } client := api.NewClient(cfg.Server, cfg.Token) - remote, err := client.AgentConfig(ctx) + remote, err := client.AgentConfig(ctx, runtime.GOARCH, opts.AgentVersion) 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() if err != nil { - return nil, nil, err + return nil, warns, err } if strings.TrimSpace(remote.ConfigYAML) != "" { over, err := catalog.Parse([]byte(remote.ConfigYAML)) if err != nil { - return nil, nil, fmt.Errorf("конфиг клиента с сервера: %w", err) + return nil, warns, fmt.Errorf("конфиг клиента с сервера: %w", err) } cat = catalog.Merge(cat, over) } - if err := cat.Validate(); err != nil { - return nil, nil, err - } - if spool == "" { - spool = cfg.SpoolDir - } - plan, err := Collect(ctx, cat, spool, remote.HeavyDue || opts.ForceHeavy) + return cat, warns, cat.Validate() +} + +// prepare собирает план бэкапа по каталогу. +func (s *session) prepare(ctx context.Context, opts Options, spool string, warns []string) (*Plan, error) { + cat, catWarns, err := catalogFor(s.remote) 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 - return plan, client, nil + plan.Manifest.Warnings = append(append(warns, catWarns...), plan.Manifest.Warnings...) + return plan, nil } func newSpool(base string) (string, error) { @@ -73,20 +104,24 @@ func newSpool(base string) (string, error) { // PrintPlan выводит, что попадёт в бэкап. Дампы БД при этом реально выполняются (во временный каталог). func PrintPlan(ctx context.Context, opts Options, w io.Writer) error { - cfg, err := LoadConfig(opts.ConfigPath) + sess, err := connect(ctx, opts) if err != nil { return err } - spool, err := newSpool(cfg.SpoolDir) + spool, err := newSpool(sess.cfg.SpoolDir) if err != nil { return err } defer os.RemoveAll(spool) - plan, _, err := prepare(ctx, opts, spool) + plan, err := sess.prepare(ctx, opts, spool, nil) if err != nil { return err } 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) if len(m.Packages) > 0 { fmt.Fprintf(w, "Пакеты:") @@ -161,18 +196,32 @@ func Backup(ctx context.Context, opts Options) (err error) { } defer unlock() - cfg, err := LoadConfig(opts.ConfigPath) + sess, err := connect(ctx, opts) if err != nil { 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 { return err } defer os.RemoveAll(spool) start := time.Now() - plan, client, err := prepare(ctx, opts, spool) + plan, err := sess.prepare(ctx, opts, spool, warns) if err != nil { return err } diff --git a/internal/agent/update.go b/internal/agent/update.go new file mode 100644 index 0000000..6a58fc8 --- /dev/null +++ b/internal/agent/update.go @@ -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 +} diff --git a/internal/agent/update_test.go b/internal/agent/update_test.go new file mode 100644 index 0000000..be669bc --- /dev/null +++ b/internal/agent/update_test.go @@ -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) + } +} diff --git a/internal/api/api.go b/internal/api/api.go index c7b9a03..15e72ae 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -9,6 +9,7 @@ import ( "io" "net" "net/http" + "net/url" "strings" "time" @@ -17,8 +18,20 @@ import ( type AgentConfig struct { Domain string `json:"domain"` - ConfigYAML string `json:"config_yaml"` + ConfigYAML string `json:"config_yaml"` // только переопределения клиента (для старых агентов) 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 { @@ -100,9 +113,38 @@ func jsonBody(v any) io.Reader { 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 - 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) { diff --git a/internal/server/api.go b/internal/server/api.go index 05fe5d4..d90de8f 100644 --- a/internal/server/api.go +++ b/internal/server/api.go @@ -15,10 +15,14 @@ import ( "regexp" "slices" "strconv" + "strings" "time" "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/version" + "gopkg.in/yaml.v3" ) type ctxKey int @@ -79,7 +83,50 @@ func (s *Server) apiAgentConfig(w http.ResponseWriter, r *http.Request) { c := clientFrom(r) due := c.HeavyEveryDays >= 0 && 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}$`) diff --git a/internal/server/api_test.go b/internal/server/api_test.go index cc09bf0..cc13907 100644 --- a/internal/server/api_test.go +++ b/internal/server/api_test.go @@ -27,6 +27,13 @@ func newTestServer(t *testing.T) (*Server, *httptest.Server) { 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 { t.Helper() req, err := http.NewRequest(method, url, body) diff --git a/internal/server/clients_list.go b/internal/server/clients_list.go index 26c7245..c368a41 100644 --- a/internal/server/clients_list.go +++ b/internal/server/clients_list.go @@ -2,6 +2,8 @@ package server import ( "cmp" + + "gitea.hsrv.site/mrleo1nid/backup-master/internal/version" "net/http" "net/url" "slices" @@ -179,6 +181,9 @@ func (s *Server) webClients(w http.ResponseWriter, r *http.Request) { return } 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}) } diff --git a/internal/server/scripts.go b/internal/server/scripts.go index 1278d59..c961797 100644 --- a/internal/server/scripts.go +++ b/internal/server/scripts.go @@ -1,6 +1,9 @@ package server import ( + "crypto/sha256" + "encoding/hex" + "io" "net/http" "os" "path/filepath" @@ -8,6 +11,7 @@ import ( "runtime" "strings" "text/template" + "time" ) 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)$`) -// serveBinary раздаёт backupctl для агентов: из --dist, а для своей архитектуры — сам себя. -func (s *Server) serveBinary(w http.ResponseWriter, r *http.Request) { - name := r.PathValue("file") - m := binNameRe.FindStringSubmatch(name) - if m == nil { - http.NotFound(w, r) - return +// binaryPath возвращает файл backupctl для архитектуры: из --dist, а для своей — сам себя. +func (s *Server) binaryPath(arch string) (string, bool) { + name := "backupctl-linux-" + arch + if !binNameRe.MatchString(name) { + return "", false } if s.cfg.DistDir != "" { p := filepath.Join(s.cfg.DistDir, name) if _, err := os.Stat(p); err == nil { - w.Header().Set("Content-Type", "application/octet-stream") - http.ServeFile(w, r, p) - return + return p, true } } - 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") { - w.Header().Set("Content-Type", "application/octet-stream") - http.ServeFile(w, r, self) - return + return self, true } } - 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) } diff --git a/internal/server/server.go b/internal/server/server.go index 6e5cf59..74b0e60 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -25,6 +25,8 @@ type Config struct { MaxUpload int64 // максимальный размер одной части, байт // TrustProxy — брать IP клиента из X-Forwarded-For (за Caddy). TrustProxy bool + // Version — версия сервера; до неё обновляются агенты с включённым автообновлением. + Version string } type Server struct { @@ -35,6 +37,9 @@ type Server struct { uploadMu sync.Mutex uploads map[int64]bool // бэкапы, в которые сейчас идёт загрузка + + shaMu sync.Mutex + shaCache map[string]binarySum // sha256 раздаваемых бинарников } func New(cfg Config) (*Server, error) { @@ -54,7 +59,8 @@ func New(cfg Config) (*Server, error) { st.Close() 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() } diff --git a/internal/server/store.go b/internal/server/store.go index 6e78e50..3aab0cf 100644 --- a/internal/server/store.go +++ b/internal/server/store.go @@ -94,7 +94,38 @@ func OpenStore(path string) (*Store, error) { db.Close() 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() } @@ -119,17 +150,20 @@ type Client struct { LastErrorAt int64 StaleNotified bool Disabled bool + AutoUpdate bool // агент сам обновляется до версии сервера + AgentVersion string // версия агента при последнем обращении } 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, - 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) { var c Client 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.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) { return nil, ErrNotFound } @@ -172,9 +206,10 @@ func (s *Store) ListClients() ([]*Client, error) { func (s *Store) UpdateClientSettings(c *Client) error { _, 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.StaleHours, c.Disabled, c.ID) + c.StaleHours, c.Disabled, c.AutoUpdate, c.ID) return err } @@ -193,6 +228,11 @@ func (s *Store) TouchClient(id int64) error { 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 { q := `UPDATE clients SET last_backup_at = ?1, last_error = '', last_error_at = 0, stale_notified = 0` if heavy { diff --git a/internal/server/templates/client.html b/internal/server/templates/client.html index 03bf1a3..de5e968 100644 --- a/internal/server/templates/client.html +++ b/internal/server/templates/client.html @@ -24,6 +24,7 @@