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

+161
View File
@@ -0,0 +1,161 @@
// Package api — типы протокола сервер↔агент/restore и HTTP-клиент к нему.
package api
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"strings"
"time"
"gitea.hsrv.site/mrleo1nid/backup-master/internal/manifest"
)
type AgentConfig struct {
Domain string `json:"domain"`
ConfigYAML string `json:"config_yaml"`
HeavyDue bool `json:"heavy_due"`
}
type PartInfo struct {
Name string `json:"name"`
Size int64 `json:"size"`
SHA256 string `json:"sha256"`
}
type CompleteRequest struct {
Parts []PartInfo `json:"parts"`
// Warnings — предупреждения, возникшие при архивации (после отправки манифеста).
Warnings []string `json:"warnings,omitempty"`
}
type RestoreManifest struct {
BackupID int64 `json:"backup_id"`
CreatedAt time.Time `json:"created_at"`
Manifest json.RawMessage `json:"manifest"`
Parts []PartInfo `json:"parts"` // только доступные (heavy могли удалить ротацией)
}
// Client — HTTP-клиент с Bearer-токеном.
type Client struct {
Server string
Token string
HTTP *http.Client
}
func NewClient(server, token string) *Client {
tr := &http.Transport{
Proxy: http.ProxyFromEnvironment,
DialContext: (&net.Dialer{Timeout: 30 * time.Second, KeepAlive: 30 * time.Second}).DialContext,
TLSHandshakeTimeout: 30 * time.Second,
ResponseHeaderTimeout: 10 * time.Minute, // complete на сервере может считать ретеншн
ForceAttemptHTTP2: true,
}
return &Client{Server: strings.TrimRight(server, "/"), Token: token, HTTP: &http.Client{Transport: tr}}
}
type Error struct {
Status int
Msg string
}
func (e *Error) Error() string {
return fmt.Sprintf("сервер ответил %d: %s", e.Status, e.Msg)
}
func (c *Client) do(ctx context.Context, method, path string, body io.Reader, out any) error {
req, err := http.NewRequestWithContext(ctx, method, c.Server+path, body)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+c.Token)
if body != nil && method != http.MethodPut {
req.Header.Set("Content-Type", "application/json")
}
resp, err := c.HTTP.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
var e struct{ Error string }
data, _ := io.ReadAll(io.LimitReader(resp.Body, 64<<10))
if json.Unmarshal(data, &e) != nil || e.Error == "" {
e.Error = strings.TrimSpace(string(data))
}
return &Error{Status: resp.StatusCode, Msg: e.Error}
}
if out != nil {
return json.NewDecoder(resp.Body).Decode(out)
}
return nil
}
func jsonBody(v any) io.Reader {
data, _ := json.Marshal(v)
return bytes.NewReader(data)
}
func (c *Client) AgentConfig(ctx context.Context) (*AgentConfig, error) {
var out AgentConfig
return &out, c.do(ctx, http.MethodGet, "/api/v1/agent/config", nil, &out)
}
func (c *Client) CreateBackup(ctx context.Context, m *manifest.Manifest) (int64, error) {
var out struct{ ID int64 }
err := c.do(ctx, http.MethodPost, "/api/v1/backups", jsonBody(m), &out)
return out.ID, err
}
func (c *Client) UploadPart(ctx context.Context, id int64, name string, body io.Reader) (*PartInfo, error) {
var out PartInfo
err := c.do(ctx, http.MethodPut, fmt.Sprintf("/api/v1/backups/%d/parts/%s", id, name), body, &out)
return &out, err
}
func (c *Client) Complete(ctx context.Context, id int64, req CompleteRequest) error {
return c.do(ctx, http.MethodPost, fmt.Sprintf("/api/v1/backups/%d/complete", id), jsonBody(req), nil)
}
func (c *Client) Fail(ctx context.Context, id int64, msg string) error {
return c.do(ctx, http.MethodPost, fmt.Sprintf("/api/v1/backups/%d/fail", id), jsonBody(map[string]string{"error": msg}), nil)
}
func (c *Client) RestoreManifest(ctx context.Context, backupID int64) (*RestoreManifest, error) {
path := "/api/v1/restore/manifest"
if backupID != 0 {
path += fmt.Sprintf("?backup=%d", backupID)
}
var out RestoreManifest
return &out, c.do(ctx, http.MethodGet, path, nil, &out)
}
// DownloadPart открывает поток части начиная с offset (для докачки).
func (c *Client) DownloadPart(ctx context.Context, id int64, name string, offset int64) (io.ReadCloser, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, fmt.Sprintf("%s/api/v1/restore/%d/parts/%s", c.Server, id, name), nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+c.Token)
if offset > 0 {
req.Header.Set("Range", fmt.Sprintf("bytes=%d-", offset))
}
resp, err := c.HTTP.Do(req)
if err != nil {
return nil, err
}
want := http.StatusOK
if offset > 0 {
want = http.StatusPartialContent
}
if resp.StatusCode != want {
data, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
resp.Body.Close()
return nil, &Error{Status: resp.StatusCode, Msg: strings.TrimSpace(string(data))}
}
return resp.Body, nil
}