mirror of
https://github.com/Awuqing/BackupX.git
synced 2026-08-15 17:34:15 +08:00
支持受限网络、正向代理、私有 CA 与 SSH 堡垒机部署 Agent。 加固 Docker、systemd、Nginx、安装器、Release 校验与可信代理边界,并完善命令队列索引、前端安装向导及中英文运维文档。
369 lines
13 KiB
Go
369 lines
13 KiB
Go
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)
|
||
}
|