Раньше восстановленная копия получала конфиг и таймер агента со старым токеном (через категории 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>
211 lines
7.1 KiB
Go
211 lines
7.1 KiB
Go
// 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
|
||
}
|