backup-master: сервер бэкапов, агент и восстановление VPS
Один бинарник backupctl: - server: API для агентов и restore, веб-морда (один админ), SQLite, ретеншн GFS, heavy-части, уведомления ntfy, раздача agent.sh/restore.sh; - backup/plan: сбор по каталогу категорий (files/systemd/xui/compose), горячие дампы Postgres/Mongo/MySQL, снимок x-ui.db, docker-тома, digest образов, проверка crontab, потоковая отправка tar.zst; - restore: провижининг на основе vps_installer с теми же версиями пакетов, recipes 3x-ui/nebula/sing-box нужных версий, восстановление compose с заливкой дампов и закреплением образов по digest. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
1d44d067e9
53 files changed
+7278
No files matched your search
@@ -0,0 +1,255 @@
|
||||
// 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
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
cfg Config
|
||||
st *Store
|
||||
pages map[string]*pageTemplate
|
||||
login *loginLimiter
|
||||
|
||||
uploadMu sync.Mutex
|
||||
uploads map[int64]bool // бэкапы, в которые сейчас идёт загрузка
|
||||
}
|
||||
|
||||
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{}}, 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("Бэкап просрочен: "+c.Domain, msg, "high", "warning")
|
||||
_ = 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("Бэкап не удался: "+domain, msg, "high", "x")
|
||||
}
|
||||
|
||||
// --- токены ---
|
||||
|
||||
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))
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user