Files
backup-master/internal/server/server.go
T
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

262 lines
8.2 KiB
Go

// Package server — сервер бэкапов: API для агентов и restore, веб-морда, фоновые задачи.
package server
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"fmt"
"log"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"time"
)
type Config struct {
Listen string
DataDir string
PublicURL string // внешний адрес, подставляется в команды установки
DistDir string // бинарники backupctl-linux-<arch> для раздачи агентам
MaxUpload int64 // максимальный размер одной части, байт
// TrustProxy — брать IP клиента из X-Forwarded-For (за Caddy).
TrustProxy bool
// Version — версия сервера; до неё обновляются агенты с включённым автообновлением.
Version string
}
type Server struct {
cfg Config
st *Store
pages map[string]*pageTemplate
login *loginLimiter
uploadMu sync.Mutex
uploads map[int64]bool // бэкапы, в которые сейчас идёт загрузка
shaMu sync.Mutex
shaCache map[string]binarySum // sha256 раздаваемых бинарников
}
func New(cfg Config) (*Server, error) {
if cfg.MaxUpload == 0 {
cfg.MaxUpload = 200 << 30
}
cfg.PublicURL = strings.TrimRight(cfg.PublicURL, "/")
if err := os.MkdirAll(filepath.Join(cfg.DataDir, "backups"), 0o700); err != nil {
return nil, err
}
st, err := OpenStore(filepath.Join(cfg.DataDir, "backup-master.db"))
if err != nil {
return nil, err
}
pages, err := loadPages()
if err != nil {
st.Close()
return nil, err
}
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) Store() *Store { return s.st }
func (s *Server) backupsDir() string { return filepath.Join(s.cfg.DataDir, "backups") }
func (s *Server) partPath(dir, name string) string {
return filepath.Join(s.backupsDir(), dir, name+".tar.zst")
}
func (s *Server) Handler() http.Handler {
mux := http.NewServeMux()
// API агента
mux.HandleFunc("GET /api/v1/agent/config", s.agentAuth(s.apiAgentConfig))
mux.HandleFunc("POST /api/v1/backups", s.agentAuth(s.apiCreateBackup))
mux.HandleFunc("PUT /api/v1/backups/{id}/parts/{name}", s.agentAuth(s.apiUploadPart))
mux.HandleFunc("POST /api/v1/backups/{id}/complete", s.agentAuth(s.apiComplete))
mux.HandleFunc("POST /api/v1/backups/{id}/fail", s.agentAuth(s.apiFail))
// API восстановления
mux.HandleFunc("GET /api/v1/restore/manifest", s.restoreAuth(s.apiRestoreManifest))
mux.HandleFunc("GET /api/v1/restore/{id}/parts/{name}", s.restoreAuth(s.apiRestorePart))
// публичное
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { w.Write([]byte("ok\n")) })
mux.HandleFunc("GET /agent.sh", s.serveAgentScript)
mux.HandleFunc("GET /restore.sh", s.serveRestoreScript)
mux.HandleFunc("GET /dl/{file}", s.serveBinary)
mux.Handle("GET /static/", http.FileServerFS(staticFS))
// веб-морда
mux.HandleFunc("GET /login", s.webLoginPage)
mux.HandleFunc("POST /login", s.webLogin)
mux.HandleFunc("POST /logout", s.admin(s.webLogout))
mux.HandleFunc("GET /{$}", s.admin(s.webClients))
mux.HandleFunc("GET /clients/new", s.admin(s.webClientNew))
mux.HandleFunc("POST /clients", s.admin(s.webClientCreate))
mux.HandleFunc("GET /clients/{id}", s.admin(s.webClient))
mux.HandleFunc("POST /clients/{id}/settings", s.admin(s.webClientSettings))
mux.HandleFunc("POST /clients/{id}/token", s.admin(s.webClientRotateToken))
mux.HandleFunc("POST /clients/{id}/restore-token", s.admin(s.webClientRestoreToken))
mux.HandleFunc("POST /clients/{id}/delete", s.admin(s.webClientDelete))
mux.HandleFunc("GET /backups/{id}", s.admin(s.webBackup))
mux.HandleFunc("GET /backups/{id}/parts/{name}", s.admin(s.webBackupDownload))
mux.HandleFunc("POST /backups/{id}/delete", s.admin(s.webBackupDelete))
mux.HandleFunc("GET /settings", s.admin(s.webSettings))
mux.HandleFunc("POST /settings", s.admin(s.webSettingsSave))
return logRequests(mux)
}
// Run запускает HTTP-сервер и фоновые задачи до отмены ctx.
func (s *Server) Run(ctx context.Context) error {
if s.st.Setting(settingAdminHash) == "" {
log.Printf("ВНИМАНИЕ: админ не задан, выполните: backupctl admin-passwd <логин>")
}
srv := &http.Server{
Addr: s.cfg.Listen,
Handler: s.Handler(),
ReadHeaderTimeout: 15 * time.Second,
IdleTimeout: 2 * time.Minute,
}
go s.background(ctx)
errc := make(chan error, 1)
go func() {
log.Printf("backup-master слушает %s (public url %s)", s.cfg.Listen, s.cfg.PublicURL)
errc <- srv.ListenAndServe()
}()
select {
case err := <-errc:
return err
case <-ctx.Done():
shCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
return srv.Shutdown(shCtx)
}
}
func (s *Server) background(ctx context.Context) {
t := time.NewTicker(10 * time.Minute)
defer t.Stop()
for {
s.tick()
select {
case <-ctx.Done():
return
case <-t.C:
}
}
}
func (s *Server) tick() {
if err := s.st.Cleanup(); err != nil {
log.Printf("cleanup: %v", err)
}
// зависшие загрузки
stuck, err := s.st.UploadingBefore(time.Now().Add(-6*time.Hour).Unix(), 0)
if err != nil {
log.Printf("stuck uploads: %v", err)
}
for _, b := range stuck {
s.failBackup(b, "загрузка не завершилась за 6 часов")
}
// клиенты без свежих бэкапов
clients, err := s.st.ListClients()
if err != nil {
log.Printf("clients: %v", err)
return
}
now := time.Now()
for _, c := range clients {
if c.Disabled || c.StaleNotified || c.StaleHours <= 0 {
continue
}
ref := c.LastBackupAt
if ref == 0 {
ref = c.CreatedAt
}
if now.Sub(time.Unix(ref, 0)) > time.Duration(c.StaleHours)*time.Hour {
msg := fmt.Sprintf("%s: нет успешного бэкапа больше %d ч", c.Domain, c.StaleHours)
if c.LastBackupAt == 0 {
msg = fmt.Sprintf("%s: от клиента ещё не пришло ни одного бэкапа", c.Domain)
}
s.notify(notification{Title: "Бэкап просрочен: " + c.Domain, Msg: msg, Priority: "high", Tags: "warning", Link: s.clientLink(c.ID)})
_ = s.st.SetStaleNotified(c.ID)
}
}
}
func (s *Server) failBackup(b *Backup, msg string) {
if b.Dir != "" {
_ = os.RemoveAll(filepath.Join(s.backupsDir(), b.Dir))
}
if err := s.st.SetBackupStatus(b.ID, StatusFailed, msg, 0); err != nil {
log.Printf("backup #%d: %v", b.ID, err)
}
_ = s.st.MarkClientError(b.ClientID, msg)
domain := fmt.Sprintf("клиент #%d", b.ClientID)
if c, err := s.st.GetClient(b.ClientID); err == nil {
domain = c.Domain
}
log.Printf("бэкап #%d (%s) провален: %s", b.ID, domain, msg)
s.notify(notification{Title: "Бэкап не удался: " + domain, Msg: msg, Priority: "high", Tags: "x", Link: s.clientLink(b.ClientID)})
}
// --- токены ---
const tokenPrefix = "bm_"
func newToken() string {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
panic(err)
}
return tokenPrefix + base64.RawURLEncoding.EncodeToString(b)
}
func hashToken(t string) string {
h := sha256.Sum256([]byte(t))
return hex.EncodeToString(h[:])
}
func bearer(r *http.Request) string {
v, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ")
if !ok {
return ""
}
return strings.TrimSpace(v)
}
// --- логирование ---
type statusWriter struct {
http.ResponseWriter
status int
}
func (w *statusWriter) WriteHeader(code int) {
w.status = code
w.ResponseWriter.WriteHeader(code)
}
func (w *statusWriter) Unwrap() http.ResponseWriter { return w.ResponseWriter }
func logRequests(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
sw := &statusWriter{ResponseWriter: w, status: 200}
h.ServeHTTP(sw, r)
if strings.HasPrefix(r.URL.Path, "/static/") || r.URL.Path == "/healthz" {
return
}
log.Printf("%s %s %d %s", r.Method, r.URL.Path, sw.status, time.Since(start).Round(time.Millisecond))
})
}