Files
backup-master/internal/api/api.go
T
mrleo1nidandClaude Opus 5.5 1d44d067e9 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>
2026-10-06 21:52:40 +03:00

162 lines
4.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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
}