Files
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

211 lines
7.1 KiB
Go
Raw Permalink 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"
"net/url"
"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"`
// CatalogYAML — итоговый каталог: встроенный каталог сервера + переопределения клиента.
// Агент берёт его вместо своего встроенного, поэтому новые категории приходят без обновления агента.
CatalogYAML string `json:"catalog_yaml,omitempty"`
ServerVersion string `json:"server_version,omitempty"`
Update *AgentUpdate `json:"update,omitempty"` // есть, если агенту пора обновиться
}
// AgentUpdate — новая версия агента. Path — путь на том же сервере (/dl/...), чужие адреса агент не принимает.
type AgentUpdate struct {
Version string `json:"version"`
Path string `json:"path"`
SHA256 string `json:"sha256"`
}
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)
}
// AgentConfig запрашивает настройки агента; arch и version нужны серверу, чтобы предложить обновление.
func (c *Client) AgentConfig(ctx context.Context, arch, version string) (*AgentConfig, error) {
var out AgentConfig
q := url.Values{}
if arch != "" {
q.Set("arch", arch)
}
if version != "" {
q.Set("version", version)
}
path := "/api/v1/agent/config"
if len(q) > 0 {
path += "?" + q.Encode()
}
return &out, c.do(ctx, http.MethodGet, path, nil, &out)
}
// Download открывает поток файла с этого же сервера (path вида /dl/...).
func (c *Client) Download(ctx context.Context, path string) (io.ReadCloser, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.Server+path, nil)
if err != nil {
return nil, err
}
resp, err := c.HTTP.Do(req)
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK {
resp.Body.Close()
return nil, &Error{Status: resp.StatusCode, Msg: resp.Status}
}
return resp.Body, nil
}
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
}
// Takeover делает восстановленный сервер основным и возвращает новый агентский токен.
func (c *Client) Takeover(ctx context.Context) (string, error) {
var out struct{ Token string }
err := c.do(ctx, http.MethodPost, "/api/v1/restore/takeover", nil, &out)
return out.Token, err
}