Files
backup-master/internal/server/api.go
T
mrleo1nidandClaude Opus 5.5 97c7051021
CI / test (push) Successful in 1m36s
Release / release (push) Successful in 1m33s
Restore: перевод бэкапов на восстановленный сервер
Раньше восстановленная копия получала конфиг и таймер агента со старым токеном
(через категории etc и units), и бэкапы шли с двух серверов одновременно.

- конфиг и юниты агента больше не бэкапятся
- в конце restore спрашивает, сделать ли сервер основным: сервер бэкапов
  выпускает новый токен (POST /api/v1/restore/takeover), restore ставит
  агента; токен старого сервера отозван — его агент получает отказ
- --takeover / --no-takeover; без них — вопрос (с --yes — да)
- уведомление «Сервер восстановлен» в ntfy/Telegram

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 15:29:34 +03:00

462 lines
15 KiB
Go

package server
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
"os"
"path/filepath"
"regexp"
"slices"
"strconv"
"strings"
"time"
"gitea.hsrv.site/mrleo1nid/backup-master/internal/api"
"gitea.hsrv.site/mrleo1nid/backup-master/internal/catalog"
"gitea.hsrv.site/mrleo1nid/backup-master/internal/manifest"
"gitea.hsrv.site/mrleo1nid/backup-master/internal/version"
"gopkg.in/yaml.v3"
)
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
resp := AgentConfigResponse{Domain: c.Domain, ConfigYAML: c.ConfigYAML, HeavyDue: due, ServerVersion: s.cfg.Version}
agentVer := r.URL.Query().Get("version")
if agentVer != "" && agentVer != c.AgentVersion {
_ = s.st.SetAgentVersion(c.ID, agentVer)
}
// итоговый каталог: встроенный каталог сервера + переопределения клиента
if cat, err := s.clientCatalog(c); err != nil {
log.Printf("каталог %s: %v", c.Domain, err)
} else {
resp.CatalogYAML = cat
}
arch := r.URL.Query().Get("arch")
if c.AutoUpdate && agentVer != "" && version.ShouldUpdate(s.cfg.Version, agentVer) {
if p, ok := s.binaryPath(arch); ok {
if sum, err := s.binarySHA256(p); err == nil {
resp.Update = &api.AgentUpdate{Version: s.cfg.Version, Path: "/dl/backupctl-linux-" + arch, SHA256: sum}
} else {
log.Printf("sha256 %s: %v", p, err)
}
}
}
jsonResp(w, http.StatusOK, resp)
}
func (s *Server) clientCatalog(c *Client) (string, error) {
cat, err := catalog.Default()
if err != nil {
return "", err
}
if strings.TrimSpace(c.ConfigYAML) != "" {
over, err := catalog.Parse([]byte(c.ConfigYAML))
if err != nil {
return "", err
}
cat = catalog.Merge(cat, over)
}
if err := cat.Validate(); err != nil {
return "", err
}
data, err := yaml.Marshal(cat)
return string(data), err
}
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(notification{Title: "Бэкап готов: " + c.Domain, Msg: msg, Priority: "low", Tags: "white_check_mark", Link: s.clientLink(c.ID)})
}
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)
}
// apiTakeover делает восстановленный сервер основным: выпускает клиенту новый
// агентский токен. Старый токен перестаёт работать, поэтому агент старого сервера,
// если тот ещё жив, получает отказ и больше не шлёт бэкапы.
func (s *Server) apiTakeover(w http.ResponseWriter, r *http.Request) {
c := clientFrom(r)
tok := newToken()
if err := s.st.SetClientToken(c.ID, hashToken(tok)); err != nil {
jsonErr(w, http.StatusInternalServerError, err.Error())
return
}
log.Printf("%s: бэкапы переведены на восстановленный сервер, старый токен отозван", c.Domain)
s.notify(notification{
Title: "Сервер восстановлен: " + c.Domain,
Msg: "Бэкапы теперь присылает восстановленный сервер, токен старого отозван.",
Tags: "white_check_mark", Link: s.clientLink(c.ID),
})
jsonResp(w, http.StatusOK, map[string]string{"token": tok})
}