Files
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

107 lines
3.3 KiB
Go

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
}