// 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 }