Один бинарник 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>
162 lines
4.9 KiB
Go
162 lines
4.9 KiB
Go
// 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
|
||
}
|