// 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- для раздачи агентам 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("POST /api/v1/restore/takeover", s.restoreAuth(s.apiTakeover)) // публичное mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { w.Write([]byte("ok\n")) }) mux.HandleFunc("GET /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)) }) }