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:
mrleo1nidandClaude Opus 5.5 committed 2026-10-06 21:52:40 +03:00
commit 1d44d067e9
53 files changed
+7278

No files matched your search

+395
View File
@@ -0,0 +1,395 @@
package server
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
"os"
"path/filepath"
"regexp"
"slices"
"strconv"
"time"
"gitea.hsrv.site/mrleo1nid/backup-master/internal/api"
"gitea.hsrv.site/mrleo1nid/backup-master/internal/manifest"
)
type ctxKey int
const clientKey ctxKey = 1
func clientFrom(r *http.Request) *Client { return r.Context().Value(clientKey).(*Client) }
func jsonResp(w http.ResponseWriter, code int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(v)
}
func jsonErr(w http.ResponseWriter, code int, msg string) {
jsonResp(w, code, map[string]string{"error": msg})
}
// agentAuth — upload-токен клиента. Даёт только запись своих бэкапов.
func (s *Server) agentAuth(h http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
tok := bearer(r)
if tok == "" {
jsonErr(w, http.StatusUnauthorized, "нужен токен")
return
}
c, err := s.st.ClientByToken(hashToken(tok))
if err != nil || c.Disabled {
jsonErr(w, http.StatusUnauthorized, "неверный токен или клиент отключён")
return
}
_ = s.st.TouchClient(c.ID)
h(w, r.WithContext(context.WithValue(r.Context(), clientKey, c)))
}
}
// restoreAuth — временный restore-токен, даёт чтение бэкапов одного клиента.
func (s *Server) restoreAuth(h http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
tok := bearer(r)
id, err := s.st.RestoreTokenClient(hashToken(tok))
if tok == "" || err != nil {
jsonErr(w, http.StatusUnauthorized, "неверный или просроченный restore-токен")
return
}
c, err := s.st.GetClient(id)
if err != nil {
jsonErr(w, http.StatusUnauthorized, "клиент удалён")
return
}
h(w, r.WithContext(context.WithValue(r.Context(), clientKey, c)))
}
}
type AgentConfigResponse = api.AgentConfig
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})
}
var partNameRe = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$`)
func (s *Server) apiCreateBackup(w http.ResponseWriter, r *http.Request) {
c := clientFrom(r)
var m manifest.Manifest
if err := json.NewDecoder(io.LimitReader(r.Body, 16<<20)).Decode(&m); err != nil {
jsonErr(w, http.StatusBadRequest, "манифест: "+err.Error())
return
}
if !slices.Contains(m.Parts, manifest.MainPart) {
jsonErr(w, http.StatusBadRequest, "в манифесте нет части main")
return
}
for _, p := range m.Parts {
if !partNameRe.MatchString(p) {
jsonErr(w, http.StatusBadRequest, "недопустимое имя части "+p)
return
}
}
m.Domain = c.Domain
now := time.Now()
if m.CreatedAt.IsZero() {
m.CreatedAt = now
}
// предыдущая незавершённая загрузка этого клиента уже не продолжится
if old, err := s.st.UploadingBefore(now.Unix()+1, c.ID); err == nil {
for _, b := range old {
if !s.isUploading(b.ID) {
s.failBackup(b, "прервана новым бэкапом")
}
}
}
raw, _ := json.Marshal(m)
id, err := s.st.CreateBackup(c.ID, now.Unix(), string(raw))
if err != nil {
jsonErr(w, http.StatusInternalServerError, err.Error())
return
}
dir := filepath.Join(c.Domain, fmt.Sprintf("%s-%d", now.Format("20060102-150405"), id))
if err := os.MkdirAll(filepath.Join(s.backupsDir(), dir), 0o700); err != nil {
jsonErr(w, http.StatusInternalServerError, err.Error())
return
}
if err := s.st.SetBackupDir(id, dir); err != nil {
jsonErr(w, http.StatusInternalServerError, err.Error())
return
}
log.Printf("бэкап #%d (%s) начат, части %v", id, c.Domain, m.Parts)
jsonResp(w, http.StatusCreated, map[string]int64{"id": id})
}
// ownBackup загружает бэкап из пути и проверяет, что он принадлежит клиенту.
func (s *Server) ownBackup(w http.ResponseWriter, r *http.Request) (*Backup, *manifest.Manifest, bool) {
c := clientFrom(r)
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
jsonErr(w, http.StatusBadRequest, "id")
return nil, nil, false
}
b, err := s.st.GetBackup(id)
if err != nil || b.ClientID != c.ID {
jsonErr(w, http.StatusNotFound, "бэкап не найден")
return nil, nil, false
}
var m manifest.Manifest
if err := json.Unmarshal([]byte(b.Manifest), &m); err != nil {
jsonErr(w, http.StatusInternalServerError, "манифест: "+err.Error())
return nil, nil, false
}
return b, &m, true
}
func (s *Server) isUploading(id int64) bool {
s.uploadMu.Lock()
defer s.uploadMu.Unlock()
return s.uploads[id]
}
func (s *Server) apiUploadPart(w http.ResponseWriter, r *http.Request) {
b, m, ok := s.ownBackup(w, r)
if !ok {
return
}
name := r.PathValue("name")
if b.Status != StatusUploading {
jsonErr(w, http.StatusConflict, "бэкап уже "+b.Status)
return
}
if !slices.Contains(m.Parts, name) {
jsonErr(w, http.StatusBadRequest, "части "+name+" нет в манифесте")
return
}
s.uploadMu.Lock()
if s.uploads[b.ID] {
s.uploadMu.Unlock()
jsonErr(w, http.StatusConflict, "загрузка уже идёт")
return
}
s.uploads[b.ID] = true
s.uploadMu.Unlock()
defer func() {
s.uploadMu.Lock()
delete(s.uploads, b.ID)
s.uploadMu.Unlock()
}()
tmp := s.partPath(b.Dir, name) + ".part"
f, err := os.OpenFile(tmp, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o600)
if err != nil {
jsonErr(w, http.StatusInternalServerError, err.Error())
return
}
h := sha256.New()
n, err := io.Copy(io.MultiWriter(f, h), http.MaxBytesReader(w, r.Body, s.cfg.MaxUpload))
if cerr := f.Close(); err == nil {
err = cerr
}
if err != nil {
os.Remove(tmp)
var mbe *http.MaxBytesError
if errors.As(err, &mbe) {
jsonErr(w, http.StatusRequestEntityTooLarge, "превышен размер части")
return
}
jsonErr(w, http.StatusBadRequest, "загрузка прервана: "+err.Error())
return
}
sum := hex.EncodeToString(h.Sum(nil))
if err := s.st.UpsertPart(Part{BackupID: b.ID, Name: name, Size: n, SHA256: sum, Status: PartUploaded}); err != nil {
jsonErr(w, http.StatusInternalServerError, err.Error())
return
}
jsonResp(w, http.StatusOK, PartInfo{Name: name, Size: n, SHA256: sum})
}
type PartInfo = api.PartInfo
type CompleteRequest = api.CompleteRequest
func (s *Server) apiComplete(w http.ResponseWriter, r *http.Request) {
c := clientFrom(r)
b, m, ok := s.ownBackup(w, r)
if !ok {
return
}
if b.Status != StatusUploading {
jsonErr(w, http.StatusConflict, "бэкап уже "+b.Status)
return
}
var req CompleteRequest
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&req); err != nil {
jsonErr(w, http.StatusBadRequest, err.Error())
return
}
claimed := map[string]PartInfo{}
for _, p := range req.Parts {
claimed[p.Name] = p
}
stored := map[string]Part{}
for _, p := range b.Parts {
stored[p.Name] = p
}
var total int64
for _, name := range m.Parts {
p, ok := stored[name]
cl := claimed[name]
if !ok {
jsonErr(w, http.StatusBadRequest, "часть "+name+" не загружена")
return
}
if cl.SHA256 != p.SHA256 || cl.Size != p.Size {
s.failBackup(b, fmt.Sprintf("часть %s: контрольная сумма не совпала", name))
jsonErr(w, http.StatusBadRequest, "контрольная сумма части "+name+" не совпала")
return
}
total += p.Size
}
for _, name := range m.Parts {
final := s.partPath(b.Dir, name)
if err := os.Rename(final+".part", final); err != nil {
jsonErr(w, http.StatusInternalServerError, err.Error())
return
}
if err := s.st.SetPartStatus(b.ID, name, PartComplete); err != nil {
jsonErr(w, http.StatusInternalServerError, err.Error())
return
}
}
if len(req.Warnings) > 0 {
m.Warnings = append(m.Warnings, req.Warnings...)
raw, _ := json.Marshal(m)
b.Manifest = string(raw)
if err := s.st.SetBackupManifest(b.ID, b.Manifest); err != nil {
log.Printf("backup #%d: %v", b.ID, err)
}
}
_ = os.WriteFile(filepath.Join(s.backupsDir(), b.Dir, manifest.ManifestFile), []byte(b.Manifest), 0o600)
if err := s.st.SetBackupStatus(b.ID, StatusComplete, "", total); err != nil {
jsonErr(w, http.StatusInternalServerError, err.Error())
return
}
heavy := len(m.Parts) > 1
if err := s.st.MarkClientSuccess(c.ID, b.CreatedAt, heavy); err != nil {
log.Printf("client %d: %v", c.ID, err)
}
log.Printf("бэкап #%d (%s) завершён: %s", b.ID, c.Domain, humanBytes(total))
if s.st.Setting(settingNotifySuccess) == "1" {
msg := fmt.Sprintf("%s: %s, частей %d", c.Domain, humanBytes(total), len(m.Parts))
if len(m.Warnings) > 0 {
msg += fmt.Sprintf(", предупреждений %d", len(m.Warnings))
}
s.notify("Бэкап готов: "+c.Domain, msg, "low", "white_check_mark")
}
if c2, err := s.st.GetClient(c.ID); err == nil {
if err := s.applyRetention(c2); err != nil {
log.Printf("ретеншн %s: %v", c.Domain, err)
}
}
jsonResp(w, http.StatusOK, map[string]any{"id": b.ID, "size": total})
}
func (s *Server) apiFail(w http.ResponseWriter, r *http.Request) {
b, _, ok := s.ownBackup(w, r)
if !ok {
return
}
var req struct {
Error string `json:"error"`
}
_ = json.NewDecoder(io.LimitReader(r.Body, 64<<10)).Decode(&req)
if req.Error == "" {
req.Error = "агент сообщил об ошибке"
}
if b.Status == StatusUploading {
s.failBackup(b, req.Error)
}
jsonResp(w, http.StatusOK, map[string]string{"status": StatusFailed})
}
type RestoreManifestResponse = api.RestoreManifest
func (s *Server) apiRestoreManifest(w http.ResponseWriter, r *http.Request) {
c := clientFrom(r)
var b *Backup
var err error
if v := r.URL.Query().Get("backup"); v != "" {
id, _ := strconv.ParseInt(v, 10, 64)
b, err = s.st.GetBackup(id)
if err == nil && (b.ClientID != c.ID || b.Status != StatusComplete) {
err = ErrNotFound
}
} else {
b, err = s.st.LatestComplete(c.ID)
}
if err != nil {
jsonErr(w, http.StatusNotFound, "нет подходящего бэкапа")
return
}
resp := RestoreManifestResponse{BackupID: b.ID, CreatedAt: time.Unix(b.CreatedAt, 0), Manifest: json.RawMessage(b.Manifest)}
for _, p := range b.Parts {
if p.Status == PartComplete {
resp.Parts = append(resp.Parts, PartInfo{Name: p.Name, Size: p.Size, SHA256: p.SHA256})
}
}
// heavy-части из более ранних бэкапов здесь не подмешиваются: restore может запросить
// их отдельно через ?backup=, если понадобится
jsonResp(w, http.StatusOK, resp)
}
func (s *Server) apiRestorePart(w http.ResponseWriter, r *http.Request) {
c := clientFrom(r)
id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64)
b, err := s.st.GetBackup(id)
if err != nil || b.ClientID != c.ID || b.Status != StatusComplete {
jsonErr(w, http.StatusNotFound, "бэкап не найден")
return
}
s.servePart(w, r, b, r.PathValue("name"))
}
func (s *Server) servePart(w http.ResponseWriter, r *http.Request, b *Backup, name string) {
found := false
for _, p := range b.Parts {
if p.Name == name && p.Status == PartComplete {
found = true
w.Header().Set("X-Content-Sha256", p.SHA256)
}
}
if !found {
http.Error(w, "часть не найдена", http.StatusNotFound)
return
}
f, err := os.Open(s.partPath(b.Dir, name))
if err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
defer f.Close()
fi, err := f.Stat()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/zstd")
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s-%d-%s.tar.zst"`,
filepath.Base(filepath.Dir(b.Dir)), b.ID, name))
http.ServeContent(w, r, "", fi.ModTime(), f)
}