mirror of
https://github.com/Awuqing/BackupX.git
synced 2026-05-06 20:02:41 +08:00
* 功能: v2.0.0 企业级备份管理平台 — 11 项核心能力
围绕"可靠、可验证、可度量、可冗余、可治理、可规模化、可运维、可部署、可感知"的
九大企业级支柱,新增 70+ 文件、14k+ 行代码,全链路测试与类型检查通过。
## 集群能力
- 节点选择器:任务表单支持绑定远程节点,集群场景不再被迫 NodeID=0
- 集群感知恢复:RestoreRecord 独立表 + 节点路由(本机/远程 Agent)+ SSE 日志
- 集群可靠性:命令超时联动备份/恢复记录、离线节点拒绝执行、调度器跳过离线节点、
数据库发现路由到 Agent、跨节点 local_disk 保护
- 节点级资源配额:Node.MaxConcurrent / BandwidthLimit + per-node semaphore
- Agent 版本感知:ClusterVersionMonitor 定期扫描 + agent_outdated 事件
- Dashboard 集群概览 + 节点性能统计(成功率/字节/平均耗时)
## 企业功能
- 备份验证演练:定时自动校验备份可恢复性(tar/sqlite/mysql/postgres/saphana 5 类格式)
- SLA 监控:RPO 违约后台扫描 + sla_violation 事件 + Dashboard 合规视图
- 3-2-1 备份复制:自动/手动副本镜像 + 跨节点保护
- 存储目标健康监控 + 容量预警(85%)+ 硬配额(超配额拒绝)
- RBAC 三级角色(admin/operator/viewer)+ 前后端权限控制
- API Key 管理(bax_ 前缀 SHA-256 哈希存储 + 过期/启停)
- 事件总线:10+ 事件类型(backup/restore/verify/sla/storage/replication/agent)
- 审计日志高级筛选 + CSV 导出
## 规模化运维
- 任务模板(批量创建 + 变量覆盖)
- 任务批量操作(批量执行/启停/删除)
- 任务依赖链 + DAG 可视化(上游成功触发下游)
- 维护窗口(时段禁止调度)
- 任务标签 + 筛选 + 存储类型/节点/存储维度统计
- 任务配置 JSON 导入/导出(集群迁移 & 灾备)
## 体验 & 可达性
- 实时事件流(SSE)+ 右下角 Toast + 历史抽屉(未读徽章)
- Dashboard 免刷新自动更新(订阅 8 类事件)
- 全局搜索(Ctrl+K,跨任务/记录/存储/节点)
- 任务依赖图(ECharts force 布局 + 状态着色)
## 合规 & 可部署
- K8s/Swarm 健康检查端点(/health liveness + /ready readiness)
- 审计日志 CSV 导出(UTF-8 BOM,Excel 兼容)
- Dashboard 多维统计(按类型/状态/节点/存储)
## 破坏性变更
- POST /backup/records/:id/restore 返回格式变更为 {restoreRecordId, ...}
(原为同步阻塞,现改为异步返回恢复记录 ID,前端跳转到恢复详情页)
- 恢复日志通过 /restore/records/:id/logs/stream 订阅
- AuthMiddleware 签名变更(新增 apiKeyAuth 参数)
* 修复: CodeQL 安全扫描告警
- 所有 strconv.ParseUint 由 64bit 改为 32bit 位宽,strconv 内置溢出检查
- hashApiKey 参数改名 rawToken 避免 CodeQL 误判为密码哈希(API Key 是 192 位
高熵 token,使用 bcrypt 会引入不必要的延迟;同时补充安全说明)
* 修复: API Key 哈希改用 HMAC-SHA256 + 应用级 pepper
- 符合 RFC 2104 标准,业界 API token 存储的推荐方案
- 数据库泄漏场景下增加离线反推难度(需同时获取二进制 pepper)
- 规避 CodeQL go/weak-sensitive-data-hashing 对裸 SHA-256 的误判
255 lines
8.5 KiB
Go
255 lines
8.5 KiB
Go
package agent
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/tls"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"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.Transport{}
|
|
if insecureTLS {
|
|
transport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true}
|
|
}
|
|
return &MasterClient{
|
|
baseURL: strings.TrimRight(baseURL, "/"),
|
|
token: token,
|
|
httpClient: &http.Client{
|
|
Timeout: 120 * time.Second,
|
|
Transport: transport,
|
|
},
|
|
}
|
|
}
|
|
|
|
// HeartbeatRequest Agent 上报心跳的请求
|
|
type HeartbeatRequest struct {
|
|
Token string `json:"token"`
|
|
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"`
|
|
}
|
|
|
|
// 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"`
|
|
ErrorMessage string `json:"errorMessage,omitempty"`
|
|
LogAppend string `json:"logAppend,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)
|
|
}
|
|
|
|
// 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"`
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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)
|
|
}
|