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:
commit
1d44d067e9
53 files changed
+7278
No files matched your search
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user