package agent import ( "bytes" "context" "crypto/tls" "crypto/x509" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "os" "strings" "time" ) // MasterClient 是 Agent 调用 Master HTTP API 的封装。 type MasterClient struct { baseURL string token string httpClient *http.Client } // NewMasterClient 构造 Master 客户端。 func NewMasterClient(baseURL, token string, insecureTLS bool) *MasterClient { transport := http.DefaultTransport.(*http.Transport).Clone() tlsConfig := &tls.Config{MinVersion: tls.VersionTLS12} if transport.TLSClientConfig != nil { tlsConfig = transport.TLSClientConfig.Clone() tlsConfig.MinVersion = tls.VersionTLS12 } // 仅用于用户显式开启的测试模式。生产环境应配置受信 CA。 tlsConfig.InsecureSkipVerify = insecureTLS // #nosec G402 transport.TLSClientConfig = tlsConfig return &MasterClient{ baseURL: strings.TrimRight(baseURL, "/"), token: token, httpClient: &http.Client{ Timeout: 120 * time.Second, Transport: transport, // Agent Token 是自定义认证头。禁止自动重定向,避免代理或错误 // 配置把它转发到另一个主机;Master URL 必须直接指向 API。 CheckRedirect: func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse }, }, } } // ConfigureTransport 应用显式代理和私有 CA。默认 Transport 已保留 // ProxyFromEnvironment,因此 ProxyURL 留空时 HTTP_PROXY/HTTPS_PROXY/NO_PROXY 生效。 func (c *MasterClient) ConfigureTransport(proxyURL, caCertFile string) error { transport, ok := c.httpClient.Transport.(*http.Transport) if !ok { return errors.New("agent http transport has unexpected type") } if strings.TrimSpace(proxyURL) != "" { parsedProxy, err := url.Parse(strings.TrimSpace(proxyURL)) if err != nil { return fmt.Errorf("parse proxy URL: %w", err) } transport.Proxy = http.ProxyURL(parsedProxy) } if strings.TrimSpace(caCertFile) == "" { return nil } pemData, err := os.ReadFile(strings.TrimSpace(caCertFile)) if err != nil { return fmt.Errorf("read CA certificate: %w", err) } roots, err := x509.SystemCertPool() if err != nil || roots == nil { roots = x509.NewCertPool() } if !roots.AppendCertsFromPEM(pemData) { return errors.New("CA certificate file does not contain a valid PEM certificate") } transport.TLSClientConfig.RootCAs = roots return nil } // HeartbeatRequest Agent 上报心跳的请求 type HeartbeatRequest struct { Hostname string `json:"hostname,omitempty"` IPAddress string `json:"ipAddress,omitempty"` AgentVersion string `json:"agentVersion,omitempty"` OS string `json:"os,omitempty"` Arch string `json:"arch,omitempty"` } // HeartbeatResponse Master 返回的心跳响应 type HeartbeatResponse struct { Status string `json:"status"` NodeID uint `json:"nodeId"` Name string `json:"name"` } // Heartbeat 上报心跳并获取节点元信息 func (c *MasterClient) Heartbeat(ctx context.Context, req HeartbeatRequest) (*HeartbeatResponse, error) { var resp HeartbeatResponse if err := c.do(ctx, http.MethodPost, "/api/agent/heartbeat", req, &resp); err != nil { return nil, err } return &resp, nil } // CommandPayload 与 service.AgentCommandPayload 对齐 type CommandPayload struct { ID uint `json:"id"` Type string `json:"type"` Payload json.RawMessage `json:"payload,omitempty"` } // PollCommandResponse 轮询响应:无命令时 Command 为 nil type PollCommandResponse struct { Command *CommandPayload `json:"command"` } // PollCommand 拉取下一条待执行命令 func (c *MasterClient) PollCommand(ctx context.Context) (*CommandPayload, error) { var resp PollCommandResponse if err := c.do(ctx, http.MethodPost, "/api/agent/commands/poll", nil, &resp); err != nil { return nil, err } return resp.Command, nil } // SubmitCommandResult 上报命令执行结果 func (c *MasterClient) SubmitCommandResult(ctx context.Context, cmdID uint, success bool, errorMsg string, result any) error { var resultJSON json.RawMessage if result != nil { data, err := json.Marshal(result) if err != nil { return fmt.Errorf("marshal result: %w", err) } resultJSON = data } payload := map[string]any{ "success": success, "errorMessage": errorMsg, } if resultJSON != nil { payload["result"] = resultJSON } path := fmt.Sprintf("/api/agent/commands/%d/result", cmdID) return c.do(ctx, http.MethodPost, path, payload, nil) } // TaskSpec 与 service.AgentTaskSpec 对齐 type TaskSpec struct { TaskID uint `json:"taskId"` Name string `json:"name"` Type string `json:"type"` SourcePath string `json:"sourcePath"` SourcePaths string `json:"sourcePaths"` ExcludePatterns string `json:"excludePatterns"` DBHost string `json:"dbHost"` DBPort int `json:"dbPort"` DBUser string `json:"dbUser"` DBPassword string `json:"dbPassword"` DBName string `json:"dbName"` DBPath string `json:"dbPath"` ExtraConfig string `json:"extraConfig"` Compression string `json:"compression"` Encrypt bool `json:"encrypt"` StorageTargets []StorageTargetConfig `json:"storageTargets"` } // StorageTargetConfig 与 service.AgentStorageTargetConfig 对齐 type StorageTargetConfig struct { ID uint `json:"id"` Type string `json:"type"` Name string `json:"name"` Config json.RawMessage `json:"config"` TransferMode string `json:"transferMode"` } // GetTaskSpec 拉取任务规格 func (c *MasterClient) GetTaskSpec(ctx context.Context, taskID uint) (*TaskSpec, error) { var spec TaskSpec path := fmt.Sprintf("/api/agent/tasks/%d", taskID) if err := c.do(ctx, http.MethodGet, path, nil, &spec); err != nil { return nil, err } return &spec, nil } // RecordUpdate 与 service.AgentRecordUpdate 对齐 type RecordUpdate struct { Status string `json:"status,omitempty"` FileName string `json:"fileName,omitempty"` FileSize int64 `json:"fileSize,omitempty"` Checksum string `json:"checksum,omitempty"` StoragePath string `json:"storagePath,omitempty"` StorageTargetID uint `json:"storageTargetId,omitempty"` StorageTransferMode string `json:"storageTransferMode,omitempty"` StorageUploadResults []StorageResultItem `json:"storageUploadResults,omitempty"` ErrorMessage string `json:"errorMessage,omitempty"` LogAppend string `json:"logAppend,omitempty"` } type StorageResultItem struct { StorageTargetID uint `json:"storageTargetId"` StorageTargetName string `json:"storageTargetName"` Status string `json:"status"` StoragePath string `json:"storagePath,omitempty"` FileSize int64 `json:"fileSize,omitempty"` TransferMode string `json:"transferMode,omitempty"` Error string `json:"error,omitempty"` } // UpdateRecord 上报备份记录的状态/日志 func (c *MasterClient) UpdateRecord(ctx context.Context, recordID uint, update RecordUpdate) error { path := fmt.Sprintf("/api/agent/records/%d", recordID) return c.do(ctx, http.MethodPost, path, update, nil) } // UploadArtifact streams an artifact through the Master for storage targets // that are not directly reachable from the Agent. func (c *MasterClient) UploadArtifact(ctx context.Context, recordID, targetID uint, objectKey string, size int64, checksum string, reader io.Reader) error { path := fmt.Sprintf("/api/agent/records/%d/artifacts/%d", recordID, targetID) req, err := http.NewRequestWithContext(ctx, http.MethodPut, c.baseURL+path, reader) if err != nil { return err } // The executor owns and closes the artifact file. Prevent net/http from // closing that underlying reader when it finishes the request body. req.Body = io.NopCloser(reader) req.ContentLength = size req.Header.Set("Content-Type", "application/octet-stream") req.Header.Set("X-Agent-Token", c.token) req.Header.Set("X-BackupX-Object-Key", objectKey) req.Header.Set("X-BackupX-SHA256", checksum) client := *c.httpClient client.Timeout = 0 resp, err := client.Do(req) if err != nil { return fmt.Errorf("relay artifact to Master: %w", err) } data, readErr := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) closeErr := resp.Body.Close() if readErr != nil || closeErr != nil { return errors.Join(readErr, closeErr) } if resp.StatusCode < 200 || resp.StatusCode >= 300 { return fmt.Errorf("relay artifact to Master: http %d: %s", resp.StatusCode, string(data)) } return nil } // RestoreSpec 与 service.AgentRestoreSpec 对齐 type RestoreSpec struct { RestoreRecordID uint `json:"restoreRecordId"` BackupRecordID uint `json:"backupRecordId"` TaskID uint `json:"taskId"` TaskName string `json:"taskName"` Type string `json:"type"` SourcePath string `json:"sourcePath,omitempty"` SourcePaths []string `json:"sourcePaths,omitempty"` DBHost string `json:"dbHost,omitempty"` DBPort int `json:"dbPort,omitempty"` DBUser string `json:"dbUser,omitempty"` DBPassword string `json:"dbPassword,omitempty"` DBName string `json:"dbName,omitempty"` DBPath string `json:"dbPath,omitempty"` ExtraConfig string `json:"extraConfig,omitempty"` Compression string `json:"compression"` Encrypt bool `json:"encrypt"` Storage StorageTargetConfig `json:"storage"` StoragePath string `json:"storagePath"` FileName string `json:"fileName"` Checksum string `json:"checksum,omitempty"` } // RestoreUpdate 与 service.AgentRestoreUpdate 对齐 type RestoreUpdate struct { Status string `json:"status,omitempty"` ErrorMessage string `json:"errorMessage,omitempty"` LogAppend string `json:"logAppend,omitempty"` } // GetRestoreSpec 拉取恢复规格 func (c *MasterClient) GetRestoreSpec(ctx context.Context, restoreRecordID uint) (*RestoreSpec, error) { var spec RestoreSpec path := fmt.Sprintf("/api/agent/restores/%d/spec", restoreRecordID) if err := c.do(ctx, http.MethodGet, path, nil, &spec); err != nil { return nil, err } return &spec, nil } func (c *MasterClient) DownloadRestoreArtifact(ctx context.Context, restoreRecordID uint) (io.ReadCloser, error) { path := fmt.Sprintf("/api/agent/restores/%d/artifact", restoreRecordID) req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+path, nil) if err != nil { return nil, err } req.Header.Set("X-Agent-Token", c.token) client := *c.httpClient client.Timeout = 0 resp, err := client.Do(req) if err != nil { return nil, fmt.Errorf("download relayed artifact from Master: %w", err) } if resp.StatusCode >= 200 && resp.StatusCode < 300 { return resp.Body, nil } data, readErr := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) closeErr := resp.Body.Close() return nil, errors.Join(fmt.Errorf("download relayed artifact from Master: http %d: %s", resp.StatusCode, string(data)), readErr, closeErr) } // UpdateRestore 上报恢复记录的状态/日志 func (c *MasterClient) UpdateRestore(ctx context.Context, restoreRecordID uint, update RestoreUpdate) error { path := fmt.Sprintf("/api/agent/restores/%d", restoreRecordID) return c.do(ctx, http.MethodPost, path, update, nil) } // do 是通用 HTTP 调用。所有 Agent API 都统一走 JSON + X-Agent-Token。 func (c *MasterClient) do(ctx context.Context, method, path string, body any, out any) error { var reqBody io.Reader if body != nil { data, err := json.Marshal(body) if err != nil { return fmt.Errorf("marshal request: %w", err) } reqBody = bytes.NewReader(data) } req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, reqBody) if err != nil { return err } req.Header.Set("X-Agent-Token", c.token) if body != nil { req.Header.Set("Content-Type", "application/json") } resp, err := c.httpClient.Do(req) if err != nil { return fmt.Errorf("%s %s: %w", method, path, err) } defer resp.Body.Close() data, err := io.ReadAll(resp.Body) if err != nil { return fmt.Errorf("read response: %w", err) } if resp.StatusCode < 200 || resp.StatusCode >= 300 { return fmt.Errorf("%s %s: http %d: %s", method, path, resp.StatusCode, string(data)) } if out == nil { return nil } // BackupX API 统一封装成 {code, data, message} 形式,需要解出 data 字段 var envelope struct { Code string `json:"code"` Data json.RawMessage `json:"data"` Message string `json:"message"` } if err := json.Unmarshal(data, &envelope); err == nil && envelope.Data != nil { if err := json.Unmarshal(envelope.Data, out); err != nil { return fmt.Errorf("decode data: %w", err) } return nil } // 兼容直接返回对象的情况 return json.Unmarshal(data, out) }