Один бинарник 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>
396 lines
12 KiB
Go
396 lines
12 KiB
Go
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)
|
|
}
|