Compare commits

...

8 Commits

Author SHA1 Message Date
Wu Qing
af0e8f5c1f fix: respect local timezone for scheduler (#54) 2026-05-01 14:39:16 +08:00
Wu Qing
63fde903d2 feat: add complete MFA support
Add complete MFA support with TOTP, recovery codes, WebAuthn, trusted-device cookie flow, and email/SMS OTP delivery via notification channels. Security follow-up: trusted device tokens are stored in HttpOnly cookies, and SMS OTP reuses the existing Webhook notifier to avoid introducing a new dynamic URL sink.
2026-04-25 22:14:50 +08:00
Wu Qing
67a42b09ba fix: make agent install command proxy independent (#50) 2026-04-25 13:43:30 +08:00
Wu Qing
bc8742977e 功能: v2.2 节点池调度 + Grafana Dashboard + 版本漂移 UI (#49)
节点池动态调度(企业集群核心需求):
- model.Node 新增 Labels CSV;Node.HasLabel / LabelSet 辅助方法
- model.BackupTask 新增 NodePoolTag;与 NodeID 互斥(校验层拒绝同时设置)
- BackupExecutionService.selectPoolNode:匹配标签的在线节点中选"运行中任务最少"
  并列按 ID 升序稳定;空池返回 NODE_POOL_EMPTY 让用户立即感知
- 选中节点仅写 BackupRecord,不回写 task.NodeID —— 每次执行重选实现真轮转均衡

Grafana Dashboard(v2.1 指标的可视化闭环):
- deploy/grafana/backupx-dashboard.json:11 个面板覆盖概览/时序/容量/集群
- deploy/grafana/README.md:Prometheus 抓取配置 + 告警建议
- release workflow 打包 grafana/ + nginx.conf 到 tar.gz

前端:
- 节点列表:Agent 版本 vs Master 不一致时橙红 Tag + Tooltip 提示升级
- 节点列表新增"标签/节点池"列,支持 CSV 编辑 + 并发/带宽一起改
- 任务表单新增 NodePoolTag 输入框,与节点选择器互斥禁用

测试:
- model/node_label_test.go:HasLabel / LabelSet / nil 安全
- service/node_pool_scheduler_test.go:负载最低优先 / 空池错误 / nil repo 降级
- go test ./... + npm run build 全绿
2026-04-21 14:05:48 +08:00
Wu Qing
1a699da8d6 修复: #46 Agent 一键安装脚本在 Debian dash 下执行失败 (#48)
* 修复: #46 Agent 一键安装脚本在 Debian dash 下执行失败

根因(多因素,任何一个都可能导致用户复现的 "sh: 2: Syntax error: newline unexpected"):
- Debian/Ubuntu 默认 /bin/sh → dash;pipe 方式下 shebang 被忽略
- Content-Type: text/x-shellscript 会触发部分 CDN/反向代理的脚本识别与改写
- 如果响应被改写为 HTML,sh 在第 2 行(<html>)即报此语法错误

修复:
1. 前端命令改为 `curl -fsSL URL | sudo bash`(避开 dash)
2. 命令面板增加"先下载再执行"备用命令(代理过滤场景兜底)
3. install handler Content-Type 改为 text/plain;加 nosniff / no-store /
   Content-Disposition 三头,减少中间层改写的概率
4. 脚本模板加 magic marker `BACKUPX_AGENT_INSTALL_V1`,用户可通过
   `head -3` 自查响应完整性;加 bash 自举段,文件执行时优先切到 bash

测试:
- installscript/issue46_test.go 断言 magic + bash-bootstrap 存在于三种模式
- install_flow_test.go 断言新 headers 与 marker
- go test ./... 全绿,前端 build 通过

* 修复: #46 用户截图证实 nginx SPA fallback 返回 index.html

用户反馈截图显示 curl 下载到的是 BackupX 前端 HTML,而非 shell 脚本——
说明 /install/:token 未被反向代理转发到后端,nginx 按 try_files fallback
到 /index.html,sh 读第 2 行 <html> 报语法错误。

真正的根因修复:
1. 后端 install 端点额外暴露 /api/install/:token 别名,让反向代理
   已有的 /api/ 转发规则自动接管
2. 节点创建时返回的 url/composeUrl 统一使用 /api/install/ 前缀
3. 更新 deploy/nginx.conf 模板:
   - 新增 location /install/ 转发(兼容旧版本生成的命令)
   - 新增 /health /ready /metrics 单独转发,避免 SPA fallback

测试:
- install_flow_test.go 新增 TestInstallScriptAliasUnderAPI 断言
  /api/install/:token 路径可用 + 新生成的 url 用 /api/install/ 前缀
2026-04-20 23:35:39 +08:00
Wu Qing
1b73f19eb1 功能: v2.1 可观测性与流控 (#47)
* 功能: v2.1 可观测性与流控 — Prometheus + 节点带宽 + 审计 Webhook

核心能力:
- Prometheus /metrics 端点:11 类指标(任务/存储/节点/SLA/验证/恢复/复制)
- 节点级带宽限速生效:model.Node.BandwidthLimit 覆盖全局默认
- 审计日志 Webhook 外输:HMAC-SHA256 签名,配合 SIEM 合规留档

实现:
- server/internal/metrics/  独立 Registry + 异步 Gauge Collector(30s)
- backup/restore/verify/replication 服务注入 metrics 钩子,nil 安全
- resolveProviderForNode() 按 task.NodeID 解析 BandwidthLimit
- AuditService.SetWebhook + 动态 settings 推送,无需重启

测试:
- metrics/registry_test.go: 注册/采集/nil safety/HTTP handler
- service/audit_service_webhook_test.go: 签名正确性/异步投递/禁用路径
- go test ./... 全部通过

* chore: 触发 CodeQL 扫描
2026-04-20 23:26:04 +08:00
Wu Qing
539e9e64c4 功能: v2.0.0 企业级备份管理平台 — 11 项核心能力 (#45)
* 功能: 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 的误判
2026-04-20 13:04:13 +08:00
Wu Qing
83bf5ec656 功能: 一键部署 Agent 向导 (#44) 2026-04-19 17:25:34 +08:00
196 changed files with 23517 additions and 941 deletions

View File

@@ -110,6 +110,11 @@ jobs:
cp -r web/dist "${ARCHIVE_NAME}/web"
cp server/config.example.yaml "${ARCHIVE_NAME}/"
cp deploy/install.sh "${ARCHIVE_NAME}/" 2>/dev/null || true
# v2.2+: 随发布包提供 Grafana dashboard 与 nginx.conf 模板
if [ -d deploy/grafana ]; then
cp -r deploy/grafana "${ARCHIVE_NAME}/grafana"
fi
cp deploy/nginx.conf "${ARCHIVE_NAME}/nginx.conf" 2>/dev/null || true
tar czf "${ARCHIVE_NAME}.tar.gz" "${ARCHIVE_NAME}"
- name: Upload to GitHub Release

3
.gitignore vendored
View File

@@ -1,3 +1,4 @@
web/node_modules/
web/dist/
server/bin/
server/bin/
.claude/

View File

@@ -46,6 +46,9 @@
| **Multi-Node Cluster** | Master-Agent mode via HTTP long-polling — Agents run tasks locally, upload straight to storage, no reverse connectivity required |
| **Security** | JWT + bcrypt + AES-256-GCM encrypted config + optional backup encryption + full audit log |
| **Notifications** | Email / Webhook / Telegram on success or failure |
| **Observability** | Prometheus `/metrics` endpoint + `/health` + `/ready` probes + SLA breach gauge |
| **Audit Webhook** | HMAC-SHA256 signed forwarding to SIEM / WORM storage for compliance (SOC2 / GDPR) |
| **Flow Control** | Per-node bandwidth cap + per-node concurrency limit — tune big/small nodes independently |
| **Deployment** | Single binary + embedded SQLite, Docker one-click, zero external dependencies |
## Quick Start

View File

@@ -46,6 +46,9 @@
| **多节点集群** | Master-Agent 模式,基于 HTTP 长轮询跨多台服务器管理备份。Agent 本地执行任务并直接上传到存储,无需反向连通性 |
| **安全** | JWT + bcrypt + AES-256-GCM 加密配置 + 可选备份文件加密 + 完整审计日志 |
| **通知** | 邮件 / Webhook / Telegram备份成功或失败时自动推送 |
| **可观测性** | Prometheus `/metrics` 端点 + `/health` + `/ready` 探针 + SLA 违约监控 |
| **审计外输** | HMAC-SHA256 签名 Webhook对接 SIEM / WORM 存储满足 SOC2 / GDPR 合规 |
| **流控** | 节点级带宽限速 + 节点级并发控制,大小节点分别配置,避免小内存 Agent 被挤爆 |
| **部署** | 单二进制 + 内嵌 SQLiteDocker 一键启动,零外部依赖 |
## 快速开始

View File

@@ -19,6 +19,25 @@ server {
proxy_read_timeout 3600s;
}
# Agent one-click install endpoints.
# Some external reverse proxies strip the /api prefix before reaching this
# container, so /install/ must be proxied here instead of falling through to
# the SPA index.html.
location /install/ {
proxy_pass http://127.0.0.1:8341/install/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffering off;
proxy_cache off;
}
location = /health { proxy_pass http://127.0.0.1:8341/health; }
location = /ready { proxy_pass http://127.0.0.1:8341/ready; }
location = /metrics { proxy_pass http://127.0.0.1:8341/metrics; }
# SPA fallback
location / {
try_files $uri $uri/ /index.html;

34
deploy/grafana/README.md Normal file
View File

@@ -0,0 +1,34 @@
# BackupX Grafana Dashboard
对接 BackupX v2.1+ 暴露的 Prometheus `/metrics` 端点。
## 导入步骤
1. 在 Grafana 配置 Prometheus 数据源指向你的 Prometheus例如 `http://prometheus:9090`
2. 在 Prometheus 配置抓取 BackupX
```yaml
scrape_configs:
- job_name: 'backupx'
scrape_interval: 30s
static_configs:
- targets: ['backupx-master:8340']
```
3. Grafana → Dashboards → Import → 上传 `backupx-dashboard.json` → 选 Prometheus 数据源 → Import
## 面板内容
- 当前运行任务数 / SLA 违约数 / 在线节点 / 24h 成功率 / 应用版本
- 任务执行速率(按 success/failed 堆叠)
- 任务耗时 P50/P95/P99按任务类型
- 任务产出字节速率
- 存储目标用量 TopN 柱状图
- 节点在线状态表(红/绿标色)
- 验证 / 恢复 / 复制的成功率时间线
## 自定义建议
-`backupx_sla_breach_tasks > 0` 配为 AlertManager 告警
- `sum(backupx_node_online) < N` 触发集群容量告警N 为你集群的最少节点数)
- P99 任务耗时突变可用于发现慢任务和资源压力

View File

@@ -0,0 +1,193 @@
{
"annotations": {
"list": [
{
"builtIn": 1,
"datasource": {"type": "grafana", "uid": "-- Grafana --"},
"enable": true,
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
"name": "Annotations & Alerts",
"type": "dashboard"
}
]
},
"description": "BackupX v2.1+ 核心指标面板。对接 /metrics 端点,抓取周期建议 30s与服务端 Gauge collector 同步)。",
"editable": true,
"fiscalYearStartMonth": 0,
"graphTooltip": 1,
"id": null,
"links": [
{
"title": "BackupX 文档",
"url": "https://awuqing.github.io/BackupX/",
"type": "link",
"targetBlank": true
}
],
"liveNow": false,
"panels": [
{
"type": "stat",
"title": "正在运行的任务",
"gridPos": {"h": 4, "w": 4, "x": 0, "y": 0},
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
"targets": [{"expr": "backupx_task_running", "refId": "A"}],
"fieldConfig": {
"defaults": {
"unit": "short",
"thresholds": {"mode": "absolute", "steps": [{"color": "green", "value": null}, {"color": "yellow", "value": 5}]}
}
},
"options": {"colorMode": "value", "graphMode": "area", "textMode": "auto"}
},
{
"type": "stat",
"title": "SLA 违约任务数",
"gridPos": {"h": 4, "w": 4, "x": 4, "y": 0},
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
"targets": [{"expr": "backupx_sla_breach_tasks", "refId": "A"}],
"fieldConfig": {
"defaults": {
"unit": "short",
"thresholds": {"mode": "absolute", "steps": [{"color": "green", "value": null}, {"color": "red", "value": 1}]}
}
}
},
{
"type": "stat",
"title": "在线节点",
"gridPos": {"h": 4, "w": 4, "x": 8, "y": 0},
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
"targets": [{"expr": "sum(backupx_node_online)", "refId": "A"}],
"fieldConfig": {
"defaults": {"unit": "short", "color": {"mode": "thresholds"}, "thresholds": {"steps": [{"color": "red", "value": null}, {"color": "green", "value": 1}]}}
}
},
{
"type": "stat",
"title": "24h 任务成功率",
"gridPos": {"h": 4, "w": 6, "x": 12, "y": 0},
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
"targets": [{
"expr": "sum(rate(backupx_task_run_total{status=\"success\"}[24h])) / sum(rate(backupx_task_run_total[24h])) * 100",
"refId": "A"
}],
"fieldConfig": {
"defaults": {
"unit": "percent", "decimals": 2,
"thresholds": {"mode": "absolute", "steps": [{"color": "red", "value": null}, {"color": "yellow", "value": 95}, {"color": "green", "value": 99}]}
}
}
},
{
"type": "stat",
"title": "应用版本",
"gridPos": {"h": 4, "w": 6, "x": 18, "y": 0},
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
"targets": [{"expr": "backupx_app_info", "refId": "A", "format": "table", "instant": true}],
"options": {"textMode": "value_and_name", "reduceOptions": {"calcs": ["last"], "fields": "/^version$/"}}
},
{
"type": "timeseries",
"title": "任务执行速率(按状态)",
"gridPos": {"h": 8, "w": 12, "x": 0, "y": 4},
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
"targets": [{
"expr": "sum by (status) (rate(backupx_task_run_total[5m]))",
"refId": "A",
"legendFormat": "{{status}}"
}],
"fieldConfig": {
"defaults": {
"unit": "ops",
"custom": {"drawStyle": "line", "lineInterpolation": "smooth", "fillOpacity": 10, "stacking": {"mode": "normal"}}
},
"overrides": [
{"matcher": {"id": "byName", "options": "success"}, "properties": [{"id": "color", "value": {"mode": "fixed", "fixedColor": "green"}}]},
{"matcher": {"id": "byName", "options": "failed"}, "properties": [{"id": "color", "value": {"mode": "fixed", "fixedColor": "red"}}]}
]
}
},
{
"type": "timeseries",
"title": "任务耗时 P50 / P95 / P99",
"gridPos": {"h": 8, "w": 12, "x": 12, "y": 4},
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
"targets": [
{"expr": "histogram_quantile(0.50, sum(rate(backupx_task_run_duration_seconds_bucket[10m])) by (le, task_type))", "refId": "A", "legendFormat": "P50 {{task_type}}"},
{"expr": "histogram_quantile(0.95, sum(rate(backupx_task_run_duration_seconds_bucket[10m])) by (le, task_type))", "refId": "B", "legendFormat": "P95 {{task_type}}"},
{"expr": "histogram_quantile(0.99, sum(rate(backupx_task_run_duration_seconds_bucket[10m])) by (le, task_type))", "refId": "C", "legendFormat": "P99 {{task_type}}"}
],
"fieldConfig": {"defaults": {"unit": "s"}}
},
{
"type": "timeseries",
"title": "任务产出字节速率",
"gridPos": {"h": 8, "w": 12, "x": 0, "y": 12},
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
"targets": [{"expr": "sum by (task_type) (rate(backupx_task_bytes_total[5m]))", "refId": "A", "legendFormat": "{{task_type}}"}],
"fieldConfig": {"defaults": {"unit": "Bps"}}
},
{
"type": "bargauge",
"title": "存储目标用量 TopN",
"gridPos": {"h": 8, "w": 12, "x": 12, "y": 12},
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
"targets": [{"expr": "topk(10, backupx_storage_used_bytes)", "refId": "A", "legendFormat": "{{target_name}} ({{target_type}})"}],
"fieldConfig": {"defaults": {"unit": "bytes"}},
"options": {"orientation": "horizontal", "displayMode": "gradient"}
},
{
"type": "table",
"title": "节点在线状态",
"gridPos": {"h": 8, "w": 12, "x": 0, "y": 20},
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
"targets": [{"expr": "backupx_node_online", "refId": "A", "format": "table", "instant": true}],
"transformations": [
{"id": "organize", "options": {"excludeByName": {"Time": true, "__name__": true, "job": true, "instance": true}, "indexByName": {"node_name": 0, "role": 1, "Value": 2}, "renameByName": {"Value": "online"}}}
],
"fieldConfig": {
"overrides": [{
"matcher": {"id": "byName", "options": "online"},
"properties": [{"id": "mappings", "value": [{"type": "value", "options": {"0": {"text": "离线", "color": "red"}, "1": {"text": "在线", "color": "green"}}}]}]
}]
}
},
{
"type": "timeseries",
"title": "验证 / 恢复 / 复制成功率",
"gridPos": {"h": 8, "w": 12, "x": 12, "y": 20},
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
"targets": [
{"expr": "sum by (status) (rate(backupx_verify_run_total[15m]))", "refId": "A", "legendFormat": "verify {{status}}"},
{"expr": "sum by (status) (rate(backupx_restore_run_total[15m]))", "refId": "B", "legendFormat": "restore {{status}}"},
{"expr": "sum by (status) (rate(backupx_replication_run_total[15m]))", "refId": "C", "legendFormat": "replication {{status}}"}
],
"fieldConfig": {"defaults": {"unit": "ops"}}
}
],
"refresh": "30s",
"schemaVersion": 39,
"tags": ["backupx", "backup", "sre"],
"templating": {
"list": [
{
"current": {"selected": false, "text": "Prometheus", "value": "Prometheus"},
"label": "Datasource",
"name": "DS_PROMETHEUS",
"query": "prometheus",
"refresh": 1,
"regex": "",
"type": "datasource"
}
]
},
"time": {"from": "now-6h", "to": "now"},
"timepicker": {},
"timezone": "",
"title": "BackupX Overview",
"uid": "backupx-overview",
"version": 1,
"weekStart": ""
}

View File

@@ -18,6 +18,22 @@ server {
proxy_read_timeout 3600s;
}
# Agent 一键安装脚本路径(兼容 v2.0 及之前生成的命令)。
# v2.1+ 新生成的命令走 /api/install/... 自动命中上面的 /api/ 代理。
location /install/ {
proxy_pass http://127.0.0.1:8340/install/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# 健康检查端点同样不走 SPA fallback。
location = /health { proxy_pass http://127.0.0.1:8340/health; }
location = /ready { proxy_pass http://127.0.0.1:8340/ready; }
location = /metrics { proxy_pass http://127.0.0.1:8340/metrics; }
location / {
try_files $uri $uri/ /index.html;
}

View File

@@ -28,45 +28,40 @@ BackupX supports Master-Agent mode: backup tasks can be routed to specific nodes
## Walkthrough
### 1. Create a node on Master
### 1. Open the install wizard
Web Console → **Node Management****Add Node**. A 64-byte hex token is shown **once** — keep it safe.
In the Web Console → **Node Management****Add Node**. You'll see a three-step wizard.
### 2. Deploy the Agent on a remote host
- **Step 1 — Node info.** Give the node a name, or switch to batch mode and paste multiple names (one per line, max 50).
- **Step 2 — Deploy options.** Pick install mode (`systemd` recommended, `docker`, or `foreground` for debugging), architecture (auto-detect by default), agent version (defaults to the master's version), TTL for the install link (5 min / 15 min / 1 h / 24 h), and download source (`github` direct, or the `ghproxy` mirror for mainland China).
- **Step 3 — Copy the command.** A one-line install command is shown with a live countdown. Click copy, paste into the target machine, and run with root privileges. The default command embeds the rendered installer, so the target host does not need to fetch `/api/install/:token` through your reverse proxy. The public install URL is still available as a fallback.
Upload the BackupX binary (same file as Master) to the target host, then start the Agent:
### 2. One-line install on the target host
**Option A: CLI flags**
Use the command generated by the Web Console. It writes the installer to a temporary file, validates the `BACKUPX_AGENT_INSTALL_V1` marker, then runs it with root privileges.
```bash
backupx agent --master http://master.example.com:8340 --token <token>
```
The script runs automatically and:
**Option B: config file**
1. Detects OS and architecture (`uname -m`)
2. Downloads the matching `backupx` binary from GitHub Release (or the ghproxy mirror)
3. Installs to `/opt/backupx-agent` and creates a `backupx` system user
4. Writes `/etc/systemd/system/backupx-agent.service` with the token baked into environment variables
5. Runs `systemctl enable --now backupx-agent`
6. Polls `/api/v1/agent/self` until the master confirms `status: online` (up to 30 s)
```yaml title="/etc/backupx/agent.yaml"
master: http://master.example.com:8340
token: <token>
heartbeatInterval: 15s
pollInterval: 5s
tempDir: /var/lib/backupx-agent
```
If you choose the URL-based fallback command and `curl` prints HTML or the shell reports `Syntax error: newline unexpected`, the install URL is being served by the web console instead of the backend. Ensure either `/api/install/` or `/install/` is forwarded to the BackupX backend, or use the embedded command generated by the console.
```bash
backupx agent --config /etc/backupx/agent.yaml
```
Reruns are idempotent — to upgrade or re-provision, simply generate a new install command and run it again. The one-time install link expires after its TTL or after first consumption, whichever is sooner.
**Option C: environment variables** (Docker / systemd friendly)
### 3. Rotate agent tokens at any time
```bash
BACKUPX_AGENT_MASTER=http://master.example.com:8340 \
BACKUPX_AGENT_TOKEN=<token> \
backupx agent
```
Go to the node's action menu (︙) → **Rotate Token**. The new token is shown once and the old token remains valid for 24 h, allowing rolling restarts without downtime. After 24 h, the old token is rejected.
Once connected, the node shows as **online** in the list.
### 4. Batch deployment
### 3. Route a task to the node
In Step 1 choose "Batch" and paste node names (one per line, max 50). Step 3 shows a table with one command per node plus a **Download .sh** button that bundles all commands into a shell script, convenient for SSH loops or Ansible tasks.
### 5. Route a task to the node
In the **Backup Tasks** page, pick the target node when creating the task. When the task runs:

View File

@@ -6,7 +6,7 @@ import type * as Preset from '@docusaurus/preset-classic';
// https://awuqing.github.io/BackupX/
const config: Config = {
title: 'BackupX',
tagline: 'Self-hosted server backup management — one binary, one command',
tagline: 'Self-hosted backup orchestration for servers, databases, storage targets and remote agents',
favicon: 'img/favicon.ico',
future: {
@@ -76,6 +76,16 @@ const config: Config = {
label: 'Downloads',
position: 'left',
},
{
to: '/community',
label: 'Community',
position: 'left',
},
{
to: '/sponsors',
label: 'Sponsors',
position: 'left',
},
{
type: 'localeDropdown',
position: 'right',
@@ -115,6 +125,22 @@ const config: Config = {
{label: 'Issues', href: 'https://github.com/Awuqing/BackupX/issues'},
],
},
{
title: 'Community',
items: [
{label: 'Contributors', href: 'https://github.com/Awuqing/BackupX/graphs/contributors'},
{label: 'Pull Requests', href: 'https://github.com/Awuqing/BackupX/pulls'},
{label: 'Sponsor', to: '/sponsors'},
],
},
{
title: 'Sponsors',
items: [
{label: 'Sponsor BackupX', href: 'https://github.com/sponsors/Awuqing'},
{label: 'Partnership', href: 'https://github.com/Awuqing/BackupX/issues/new/choose'},
{label: 'Sponsor tiers', to: '/sponsors'},
],
},
],
copyright: `Copyright © ${new Date().getFullYear()} BackupX · Apache License 2.0`,
},

View File

@@ -1,22 +1,22 @@
{
"home.badge": {
"message": "开源 · v1.6.0",
"message": "开源备份控制平面 · v2.2.1",
"description": "Version badge on the hero"
},
"home.title.part1": {
"message": "为每一台服务器提供",
"message": "面向自托管服务器",
"description": "Hero title, first line"
},
"home.title.part2": {
"message": "自托管备份管理。",
"message": "备份编排平台。",
"description": "Hero title accent second line"
},
"home.tagline": {
"message": "一个二进制,一条命令。文件 / 数据库 / SAP HANA 备份直送 70+ 存储后端。",
"message": "一个清爽控制台中管理文件、数据库、SAP HANA 和远程节点备份。控制平面自己掌握,存储后端灵活选择。",
"description": "Tagline on the home page"
},
"home.pageTitle": {
"message": "自托管备份管理",
"message": "面向自托管服务器的备份编排",
"description": "Page <title> element on the home page"
},
"home.getStarted": {
@@ -28,13 +28,26 @@
"description": "Hero metric label: storage backends"
},
"home.metric.backupTypes": {
"message": "备份类型",
"message": "远程执行",
"description": "Hero metric label: backup types"
},
"home.metric.license": {
"message": "开源协议",
"description": "Hero metric label: license"
},
"home.visual.eyebrow": {"message": "BackupX 控制台"},
"home.visual.title": {"message": "运维概览"},
"home.visual.status": {"message": "健康"},
"home.visual.success": {"message": "成功率"},
"home.visual.nodes": {"message": "活跃节点"},
"home.visual.targets": {"message": "存储目标"},
"home.visual.row1.title": {"message": "PostgreSQL 夜间备份"},
"home.visual.row1.desc": {"message": "加密归档已上传至 S3"},
"home.visual.row2.title": {"message": "SAP HANA 快照"},
"home.visual.row2.desc": {"message": "正在 agent-shanghai-02 上运行"},
"home.visual.row3.title": {"message": "保留策略清理"},
"home.visual.row3.desc": {"message": "下一次执行在 4 小时后"},
"home.command.title": {"message": "使用 Docker 启动"},
"section.features.tag": {
"message": "核心能力",
@@ -78,5 +91,70 @@
"showcase.storage.desc": {"message": "阿里云 OSS、腾讯云 COS、S3、Google Drive、WebDAV — 加上每一种 rclone 后端。测试连接、收藏、查看实时容量。"},
"showcase.nodes.title": {"message": "几分钟搭起 Master-Agent"},
"showcase.nodes.desc": {"message": "创建节点、复制令牌、在任意远程主机启动 Agent。路由到节点的任务在本地执行并直接上传到存储 — 无需反向连通性。"},
"showcase.cta": {"message": "开始阅读文档"}
"showcase.cta": {"message": "开始阅读文档"},
"community.tag": {"message": "社区"},
"community.pageTitle": {"message": "社区、赞助商与贡献者"},
"community.pageDescription": {"message": "赞助 BackupX了解贡献者并找到务实的参与方式。"},
"community.title": {"message": "开放协作,面向长期运维"},
"community.subtitle": {"message": "备份软件的信任来自透明发布、真实部署反馈,以及足够务实的贡献路径。"},
"community.sponsor.kicker": {"message": "赞助商"},
"community.sponsor.wallTitle": {"message": "赞助商"},
"community.sponsor.title": {"message": "支持你依赖的备份基础设施"},
"community.sponsor.cta": {"message": "赞助 BackupX"},
"community.sponsor.openSlot": {"message": "赞助席位开放"},
"community.sponsor.logo.project": {"message": "项目赞助"},
"community.sponsor.logo.cloud": {"message": "云服务伙伴"},
"community.sponsor.logo.object": {"message": "对象存储"},
"community.sponsor.logo.cdn": {"message": "CDN 伙伴"},
"community.sponsor.logo.database": {"message": "数据库伙伴"},
"community.sponsor.logo.security": {"message": "安全审计"},
"community.sponsor.logo.agent": {"message": "远程节点实验室"},
"community.sponsor.logo.docs": {"message": "文档赞助"},
"community.sponsor.logo.release": {"message": "发布赞助"},
"community.sponsor.logo.s3": {"message": "S3 兼容"},
"community.sponsor.logo.webdav": {"message": "WebDAV 伙伴"},
"community.sponsor.logo.sftp": {"message": "SFTP 伙伴"},
"community.sponsor.logo.docker": {"message": "容器伙伴"},
"community.sponsor.logo.mirror": {"message": "镜像伙伴"},
"community.sponsor.logo.restore": {"message": "恢复演练"},
"community.sponsor.logo.qa": {"message": "测试实验室"},
"community.sponsor.logo.oss": {"message": "开源支持"},
"community.sponsor.logo.open": {"message": "赞助席位开放"},
"community.sponsor.infrastructure.label": {"message": "基础设施"},
"community.sponsor.infrastructure.title": {"message": "云与存储生态伙伴"},
"community.sponsor.infrastructure.desc": {"message": "帮助 BackupX 覆盖对象存储、WebDAV、SFTP 以及区域云平台的真实验证。"},
"community.sponsor.security.label": {"message": "安全"},
"community.sponsor.security.title": {"message": "审计与可靠性支持者"},
"community.sponsor.security.desc": {"message": "支持加密、恢复演练、发布签名和运维检查等强化工作。"},
"community.sponsor.community.label": {"message": "社区"},
"community.sponsor.community.title": {"message": "开源支持者"},
"community.sponsor.community.desc": {"message": "支持文档、示例、平台测试和贡献者引导。"},
"community.sponsor.tier.backer.name": {"message": "Backer"},
"community.sponsor.tier.backer.amount": {"message": "适合个人与小团队"},
"community.sponsor.tier.backer.desc": {"message": "支持文档、Issue 分流、兼容性测试和小型体验改进。"},
"community.sponsor.tier.partner.name": {"message": "Partner"},
"community.sponsor.tier.partner.amount": {"message": "适合存储与基础设施厂商"},
"community.sponsor.tier.partner.desc": {"message": "支持 Provider 验证、部署示例、基准说明和集成指南。"},
"community.sponsor.tier.enterprise.name": {"message": "Enterprise"},
"community.sponsor.tier.enterprise.amount": {"message": "适合生产环境使用方"},
"community.sponsor.tier.enterprise.desc": {"message": "赞助恢复演练、发布加固、审计和长期维护等可靠性工作。"},
"community.contributor.kicker": {"message": "贡献者"},
"community.contributor.all": {"message": "查看全部"},
"community.contributor.source": {"message": "浏览器端通过 GitHub contributors API 获取。"},
"community.contributor.botRole": {"message": "自动化贡献者"},
"community.contributor.githubRole": {"message": "GitHub 贡献者"},
"community.contributor.contributions": {"message": "{count} 次贡献"},
"community.path.kicker": {"message": "贡献路径"},
"community.path.issues.title": {"message": "反馈生产问题"},
"community.path.issues.desc": {"message": "提交日志、部署拓扑和恢复预期。"},
"community.path.docs.title": {"message": "完善文档与示例"},
"community.path.docs.desc": {"message": "贡献存储、Agent 和数据库部署指南。"},
"community.path.code.title": {"message": "提交聚焦的 PR"},
"community.path.code.desc": {"message": "保持改动小而可测,并贴合现有架构。"},
"sponsors.pageTitle": {"message": "赞助商"},
"sponsors.pageDescription": {"message": "赞助 BackupX 的可靠性、文档、存储兼容性和长期维护。"},
"sponsors.tag": {"message": "赞助商"},
"sponsors.title": {"message": "赞助 BackupX 生态"},
"sponsors.subtitle": {"message": "赞助帮助 BackupX 更贴近真实运维:经过验证的存储 Provider、可靠发布、恢复信心和更完善的文档。"}
}

View File

@@ -26,47 +26,42 @@ BackupX 支持 Master-Agent 模式:备份任务可以指定在哪个节点执
- **执行** — Agent 复用 BackupRunnerfile / mysql / postgresql / sqlite / saphana并直接上传到存储
- **安全** — 每个节点独立 TokenAgent 不持有 Master 的 JWT 密钥或 AES-256 加密密钥
## 使用步骤
## 一键部署步骤
### 1. 在 Master 创建节点
### 1. 打开安装向导
Web 控制台 → **节点管理****添加节点**。界面会**一次性**显示 64 字节十六进制令牌,请妥善保存。
Web 控制台 → **节点管理****添加节点**,打开三步向导:
### 2. 在远程服务器部署 Agent
- **第一步 · 节点信息**:填写节点名称;或切换"批量创建"粘贴多行名称(每行一个,最多 50 个)
- **第二步 · 部署参数**:选择安装模式(`systemd` 推荐、`Docker``前台运行` 调试用、架构默认自动检测、Agent 版本(默认跟随 Master 版本、有效期5 分钟 / 15 分钟 / 1 小时 / 24 小时)、下载源(`GitHub` 直连或 `ghproxy` 镜像,国内服务器建议后者)
- **第三步 · 安装命令**:一条一键安装命令 + 实时倒计时。点击复制,粘贴到目标机以 root 权限执行。默认命令会嵌入已渲染的安装脚本,目标机无需再通过反向代理访问 `/api/install/:token`;公开安装 URL 仍作为备用路径保留。
把 BackupX 二进制上传到目标服务器(与 Master 同一个文件),然后用以下任一方式启动:
### 2. 目标机一条命令完成
**方式 ACLI 参数**
请直接使用 Web 控制台生成的命令。该命令会把安装脚本写入临时文件,校验 `BACKUPX_AGENT_INSTALL_V1` 魔数,再以 root 权限执行。
```bash
backupx agent --master http://master.example.com:8340 --token <token>
```
脚本会自动:
**方式 B配置文件**
1. 检测操作系统与架构(`uname -m`
2. 从 GitHub Release或 ghproxy 镜像)下载匹配的 `backupx` 二进制
3. 安装到 `/opt/backupx-agent`,创建系统用户 `backupx`
4. 写入 `/etc/systemd/system/backupx-agent.service`token 已烧入环境变量)
5. 执行 `systemctl enable --now backupx-agent`
6. 轮询 `/api/v1/agent/self`,直到 Master 确认 `status: online`(最多 30 秒)
```yaml title="/etc/backupx/agent.yaml"
master: http://master.example.com:8340
token: <token>
heartbeatInterval: 15s
pollInterval: 5s
tempDir: /var/lib/backupx-agent
```
如果使用 URL 备用命令时 `curl` 输出 HTML或 shell 报 `Syntax error: newline unexpected`,说明安装 URL 被 Web 控制台接管而不是转发到后端。需要确保 `/api/install/``/install/` 至少一个路径能转发到 BackupX 后端,或改用控制台生成的嵌入式命令。
```bash
backupx agent --config /etc/backupx/agent.yaml
```
脚本是幂等的:升级或重装只需重新生成一条安装命令再跑一次。一次性安装链接在 TTL 到期或被首次消费后立即作废。
**方式 C环境变量**(适合 Docker / systemd
### 3. 随时轮换 Agent Token
```bash
BACKUPX_AGENT_MASTER=http://master.example.com:8340 \
BACKUPX_AGENT_TOKEN=<token> \
backupx agent
```
节点操作列(︙)→ **重新生成 Token**。新 Token 一次性显示,旧 Token 24 小时内仍有效便于滚动替换无需停机。24 小时后旧 Token 被拒绝。
连接成功后节点在列表中显示为 **在线**。
### 4. 批量部署
### 3. 把任务路由到该节点
第一步选"批量创建"粘贴节点名(每行一个,最多 50 个)。第三步显示每个节点对应的命令表格,底部「导出 .sh」可打包为单个 shell 文件,方便 SSH 循环或 Ansible 任务。
### 5. 把任务路由到该节点
**备份任务** 页面新建任务时选择对应节点。任务触发时:

View File

@@ -2,6 +2,8 @@
"link.title.Docs": {"message": "文档"},
"link.title.Features": {"message": "功能"},
"link.title.More": {"message": "更多"},
"link.title.Community": {"message": "社区"},
"link.title.Sponsors": {"message": "赞助商"},
"link.item.label.Introduction": {"message": "简介"},
"link.item.label.Quick Start": {"message": "快速开始"},
"link.item.label.Installation": {"message": "安装"},
@@ -11,5 +13,11 @@
"link.item.label.GitHub": {"message": "GitHub"},
"link.item.label.Releases": {"message": "Releases"},
"link.item.label.Docker Hub": {"message": "Docker Hub"},
"link.item.label.Issues": {"message": "Issues"}
"link.item.label.Issues": {"message": "Issues"},
"link.item.label.Contributors": {"message": "贡献者"},
"link.item.label.Pull Requests": {"message": "Pull Requests"},
"link.item.label.Sponsor": {"message": "赞助"},
"link.item.label.Sponsor BackupX": {"message": "赞助 BackupX"},
"link.item.label.Partnership": {"message": "合作伙伴"},
"link.item.label.Sponsor tiers": {"message": "赞助层级"}
}

View File

@@ -7,6 +7,14 @@
"message": "下载",
"description": "Navbar item: Downloads"
},
"item.label.Community": {
"message": "社区",
"description": "Navbar item: Community"
},
"item.label.Sponsors": {
"message": "赞助商",
"description": "Navbar item: Sponsors"
},
"item.label.GitHub": {
"message": "GitHub",
"description": "Navbar item: GitHub"

View File

@@ -0,0 +1,329 @@
import type {ReactNode} from 'react';
import {useEffect, useState} from 'react';
import Heading from '@theme/Heading';
import Translate from '@docusaurus/Translate';
import Link from '@docusaurus/Link';
import styles from './styles.module.css';
type SponsorSlot = {
brand: ReactNode;
name: ReactNode;
href?: string;
};
type Contributor = {
login: string;
avatarUrl?: string;
contributions: number;
type: string;
href: string;
};
type GitHubContributor = {
login: string;
avatar_url?: string;
contributions?: number;
html_url?: string;
type?: string;
};
type CommunityPath = {
title: ReactNode;
description: ReactNode;
href: string;
};
const SPONSOR_SLOTS: SponsorSlot[] = [
{
brand: 'BackupX',
name: <Translate id="community.sponsor.logo.project">Project backer</Translate>,
href: 'https://github.com/sponsors/Awuqing',
},
{
brand: 'Cloud',
name: <Translate id="community.sponsor.logo.cloud">Cloud partner</Translate>,
},
{
brand: 'Object',
name: <Translate id="community.sponsor.logo.object">Object storage</Translate>,
},
{
brand: 'CDN',
name: <Translate id="community.sponsor.logo.cdn">CDN partner</Translate>,
},
{
brand: 'DB',
name: <Translate id="community.sponsor.logo.database">Database partner</Translate>,
},
{
brand: 'Security',
name: <Translate id="community.sponsor.logo.security">Security audit</Translate>,
},
{
brand: 'Agent',
name: <Translate id="community.sponsor.logo.agent">Remote node lab</Translate>,
},
{
brand: 'Docs',
name: <Translate id="community.sponsor.logo.docs">Docs sponsor</Translate>,
},
{
brand: 'Release',
name: <Translate id="community.sponsor.logo.release">Release sponsor</Translate>,
},
{
brand: 'S3',
name: <Translate id="community.sponsor.logo.s3">S3 compatible</Translate>,
},
{
brand: 'WebDAV',
name: <Translate id="community.sponsor.logo.webdav">WebDAV partner</Translate>,
},
{
brand: 'SFTP',
name: <Translate id="community.sponsor.logo.sftp">SFTP partner</Translate>,
},
{
brand: 'Docker',
name: <Translate id="community.sponsor.logo.docker">Container partner</Translate>,
},
{
brand: 'Mirror',
name: <Translate id="community.sponsor.logo.mirror">Mirror partner</Translate>,
},
{
brand: 'Restore',
name: <Translate id="community.sponsor.logo.restore">Restore drill</Translate>,
},
{
brand: 'QA',
name: <Translate id="community.sponsor.logo.qa">Test lab</Translate>,
},
{
brand: 'OSS',
name: <Translate id="community.sponsor.logo.oss">Open source</Translate>,
},
{
brand: 'Open Slot',
name: <Translate id="community.sponsor.logo.open">Sponsor slot open</Translate>,
},
];
const FALLBACK_CONTRIBUTORS: Contributor[] = [
{
login: 'Awuqing',
contributions: 0,
type: 'User',
href: 'https://github.com/Awuqing',
},
{
login: 'dependabot[bot]',
contributions: 0,
type: 'Bot',
href: 'https://github.com/dependabot',
},
];
const COMMUNITY_PATHS: CommunityPath[] = [
{
title: <Translate id="community.path.issues.title">Report production issues</Translate>,
description: <Translate id="community.path.issues.desc">Share logs, deployment topology and restore expectations.</Translate>,
href: 'https://github.com/Awuqing/BackupX/issues',
},
{
title: <Translate id="community.path.docs.title">Improve docs and examples</Translate>,
description: <Translate id="community.path.docs.desc">Contribute deployment guides for storage, agents and databases.</Translate>,
href: '/docs/development/contributing',
},
{
title: <Translate id="community.path.code.title">Ship focused PRs</Translate>,
description: <Translate id="community.path.code.desc">Keep changes small, tested and aligned with the existing architecture.</Translate>,
href: 'https://github.com/Awuqing/BackupX/pulls',
},
];
function SponsorLogoCard({brand, name, href}: SponsorSlot) {
return (
<Link className={styles.sponsorLogoTile} to={href ?? 'https://github.com/sponsors/Awuqing'}>
<span className={styles.sponsorLogoMark}>{brand}</span>
<span className={styles.sponsorLogoName}>{name}</span>
</Link>
);
}
function getInitials(login: string): string {
return login
.replace(/\[bot\]$/i, '')
.split(/[-_\s]/)
.filter(Boolean)
.slice(0, 2)
.map(part => part[0]?.toUpperCase())
.join('') || login.slice(0, 2).toUpperCase();
}
function normalizeContributor(contributor: GitHubContributor): Contributor | null {
if (!contributor.login) {
return null;
}
return {
login: contributor.login,
avatarUrl: contributor.avatar_url,
contributions: contributor.contributions ?? 0,
type: contributor.type ?? 'User',
href: contributor.html_url ?? `https://github.com/${contributor.login}`,
};
}
function useGitHubContributors(): Contributor[] {
const [contributors, setContributors] = useState<Contributor[]>(FALLBACK_CONTRIBUTORS);
useEffect(() => {
const controller = new AbortController();
fetch('https://api.github.com/repos/Awuqing/BackupX/contributors?per_page=12', {
signal: controller.signal,
headers: {
Accept: 'application/vnd.github+json',
},
})
.then(response => {
if (!response.ok) {
throw new Error(`GitHub contributors request failed: ${response.status}`);
}
return response.json() as Promise<GitHubContributor[]>;
})
.then(payload => {
const nextContributors = payload
.map(normalizeContributor)
.filter((contributor): contributor is Contributor => Boolean(contributor));
if (nextContributors.length > 0) {
setContributors(nextContributors);
}
})
.catch(error => {
if (error instanceof Error && error.name !== 'AbortError') {
console.warn(error.message);
}
});
return () => controller.abort();
}, []);
return contributors;
}
function ContributorCard({login, avatarUrl, contributions, type, href}: Contributor) {
return (
<Link className={styles.contributorCard} to={href}>
{avatarUrl ? (
<img className={styles.avatarImage} src={avatarUrl} alt="" loading="lazy" />
) : (
<span className={styles.avatar} aria-hidden="true">{getInitials(login)}</span>
)}
<span className={styles.contributorBody}>
<strong>{login}</strong>
<span>
{type === 'Bot' ? (
<Translate id="community.contributor.botRole">Automation contributor</Translate>
) : (
<Translate id="community.contributor.githubRole">GitHub contributor</Translate>
)}
</span>
<em>
<Translate id="community.contributor.contributions" values={{count: contributions}}>
{'{count} contributions'}
</Translate>
</em>
</span>
</Link>
);
}
export function HomepageSponsors(): ReactNode {
return (
<div className={styles.sponsorWall}>
<div className={styles.sponsorWallHeader}>
<Heading as="h3" className={styles.sponsorWallTitle}>
<Translate id="community.sponsor.wallTitle">Sponsors</Translate>
</Heading>
<Link className={styles.sponsorWallAction} to="https://github.com/sponsors/Awuqing">
<Translate id="community.sponsor.cta">Sponsor BackupX</Translate>
<span aria-hidden="true">-&gt;</span>
</Link>
</div>
<div className={styles.sponsorLogoGrid}>
{SPONSOR_SLOTS.map((slot, index) => (
<SponsorLogoCard key={index} {...slot} />
))}
</div>
</div>
);
}
export default function HomepageCommunity(): ReactNode {
const contributors = useGitHubContributors();
return (
<section id="community" className={styles.section}>
<div className="container">
<div className={styles.sectionHead}>
<div className={styles.sectionTag}>
<Translate id="community.tag">COMMUNITY</Translate>
</div>
<Heading as="h2" className={styles.sectionTitle}>
<Translate id="community.title">Built in the open, ready for long-term operators</Translate>
</Heading>
<p className={styles.sectionSubtitle}>
<Translate id="community.subtitle">
Backup software earns trust through transparent releases, real deployment feedback and a contributor path that stays practical.
</Translate>
</p>
</div>
<HomepageSponsors />
<div className={styles.communityGrid}>
<div className={styles.panel}>
<div className={styles.panelHeader}>
<span>
<Translate id="community.contributor.kicker">Contributors</Translate>
</span>
<Link to="https://github.com/Awuqing/BackupX/graphs/contributors">
<Translate id="community.contributor.all">View all</Translate>
</Link>
</div>
<div className={styles.panelNote}>
<Translate id="community.contributor.source">Loaded from GitHub contributors API in the browser.</Translate>
</div>
<div className={styles.contributorList}>
{contributors.map(contributor => (
<ContributorCard key={contributor.login} {...contributor} />
))}
</div>
</div>
<div className={styles.panel}>
<div className={styles.panelHeader}>
<span>
<Translate id="community.path.kicker">Contributor paths</Translate>
</span>
</div>
<div className={styles.pathList}>
{COMMUNITY_PATHS.map((path, index) => (
<Link key={index} className={styles.pathItem} to={path.href}>
<span className={styles.pathIndex}>{String(index + 1).padStart(2, '0')}</span>
<span>
<strong>{path.title}</strong>
<em>{path.description}</em>
</span>
</Link>
))}
</div>
</div>
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,429 @@
.section {
padding: 5.5rem 0 6rem;
background:
linear-gradient(180deg, rgba(245, 247, 250, 0) 0%, rgba(245, 247, 250, 0.86) 100%),
var(--ifm-background-color);
}
[data-theme='dark'] .section {
background:
linear-gradient(180deg, rgba(15, 17, 21, 0) 0%, rgba(255, 255, 255, 0.03) 100%),
var(--ifm-background-color);
}
.sectionHead {
max-width: 760px;
margin: 0 auto 2.5rem;
text-align: center;
}
.sectionTag {
display: inline-flex;
align-items: center;
min-height: 28px;
margin-bottom: 1rem;
padding: 4px 10px;
color: #00a870;
background: rgba(0, 180, 42, 0.1);
border: 1px solid rgba(0, 180, 42, 0.18);
border-radius: 8px;
font-size: 12px;
font-weight: 750;
letter-spacing: 0;
}
.sectionTitle {
margin: 0 0 1rem;
color: var(--ifm-heading-color);
font-size: 2.35rem;
font-weight: 750;
letter-spacing: 0;
line-height: 1.2;
}
.sectionSubtitle {
margin: 0;
color: var(--ifm-color-content-secondary);
font-size: 1.04rem;
line-height: 1.7;
}
.sponsorWall {
overflow: hidden;
margin-bottom: 1rem;
background: var(--ifm-background-color);
border: 1px solid var(--ifm-color-emphasis-200);
border-radius: 8px;
box-shadow: 0 12px 28px rgba(29, 33, 41, 0.06);
}
[data-theme='dark'] .sponsorWall {
background: rgba(255, 255, 255, 0.02);
border-color: rgba(255, 255, 255, 0.08);
box-shadow: none;
}
.sponsorWallHeader {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
min-height: 60px;
padding: 0 1.25rem;
border-bottom: 1px solid var(--ifm-color-emphasis-200);
}
[data-theme='dark'] .sponsorWallHeader {
border-bottom-color: rgba(255, 255, 255, 0.08);
}
.sponsorWallTitle {
position: relative;
margin: 0;
padding-left: 14px;
color: var(--ifm-heading-color);
font-size: 1.05rem;
font-weight: 750;
letter-spacing: 0;
}
.sponsorWallTitle::before {
position: absolute;
top: 50%;
left: 0;
width: 3px;
height: 18px;
content: "";
background: #52c41a;
border-radius: 3px;
transform: translateY(-50%);
}
.sponsorWallAction {
display: inline-flex;
align-items: center;
gap: 6px;
min-height: 36px;
padding: 0 12px;
color: #52c41a;
background: rgba(82, 196, 26, 0.08);
border: 1px solid rgba(82, 196, 26, 0.2);
border-radius: 8px;
font-size: 13px;
font-weight: 700;
text-decoration: none !important;
white-space: nowrap;
transition: background 0.2s ease, border-color 0.2s ease, transform 0.2s ease;
}
.sponsorWallAction:hover,
.sponsorWallAction:focus-visible {
color: #389e0d;
background: rgba(82, 196, 26, 0.14);
border-color: #52c41a;
transform: translateY(-1px);
}
.sponsorLogoGrid {
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
background: var(--ifm-color-emphasis-200);
gap: 1px;
padding: 1px;
}
[data-theme='dark'] .sponsorLogoGrid {
background: rgba(255, 255, 255, 0.08);
}
.sponsorLogoTile {
display: flex;
align-items: center;
justify-content: center;
min-width: 0;
min-height: 106px;
padding: 14px 10px;
flex-direction: column;
color: inherit;
background: var(--ifm-background-color);
text-align: center;
text-decoration: none !important;
transition: background 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease;
}
[data-theme='dark'] .sponsorLogoTile {
background: rgba(15, 17, 21, 0.78);
}
.sponsorLogoTile:hover,
.sponsorLogoTile:focus-visible {
z-index: 1;
color: inherit;
background: rgba(82, 196, 26, 0.04);
box-shadow: inset 0 0 0 1px rgba(82, 196, 26, 0.5);
transform: translateY(-1px);
}
.sponsorLogoMark {
display: block;
max-width: 100%;
overflow-wrap: anywhere;
color: var(--ifm-color-primary);
font-size: 1.45rem;
font-weight: 850;
letter-spacing: 0;
line-height: 1.1;
}
.sponsorLogoTile:nth-child(2n) .sponsorLogoMark {
color: #ff7d00;
}
.sponsorLogoTile:nth-child(3n) .sponsorLogoMark {
color: #14c9c9;
}
.sponsorLogoTile:nth-child(4n) .sponsorLogoMark {
color: #722ed1;
}
.sponsorLogoTile:nth-child(5n) .sponsorLogoMark {
color: #52c41a;
}
.sponsorLogoName {
display: block;
max-width: 100%;
margin-top: 10px;
color: var(--ifm-color-content-secondary);
font-size: 0.86rem;
font-weight: 600;
line-height: 1.35;
}
.panel {
background: var(--ifm-background-color);
border: 1px solid var(--ifm-color-emphasis-200);
border-radius: 8px;
box-shadow: 0 12px 28px rgba(29, 33, 41, 0.06);
}
[data-theme='dark'] .panel {
background: rgba(255, 255, 255, 0.02);
border-color: rgba(255, 255, 255, 0.08);
box-shadow: none;
}
.communityGrid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.panel {
min-width: 0;
padding: 1.25rem;
}
.panelHeader {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1rem;
color: var(--ifm-color-content-secondary);
font-size: 12px;
font-weight: 750;
letter-spacing: 0;
text-transform: uppercase;
}
.panelHeader a {
color: var(--ifm-color-primary);
text-decoration: none !important;
}
.panelNote {
margin: -0.35rem 0 1rem;
color: var(--ifm-color-content-secondary);
font-size: 0.82rem;
line-height: 1.5;
}
.contributorList,
.pathList {
display: grid;
gap: 10px;
}
.contributorCard,
.pathItem {
display: grid;
min-width: 0;
color: inherit;
background: var(--ifm-color-emphasis-100);
border: 1px solid var(--ifm-color-emphasis-200);
border-radius: 8px;
text-decoration: none !important;
transition: border-color 0.2s ease, transform 0.2s ease, background 0.2s ease;
}
.contributorCard:hover,
.contributorCard:focus-visible,
.pathItem:hover,
.pathItem:focus-visible {
color: inherit;
background: var(--ifm-background-color);
border-color: var(--ifm-color-primary);
transform: translateY(-1px);
}
[data-theme='dark'] .contributorCard,
[data-theme='dark'] .pathItem {
background: rgba(255, 255, 255, 0.03);
border-color: rgba(255, 255, 255, 0.08);
}
.contributorCard {
grid-template-columns: auto minmax(0, 1fr);
gap: 12px;
align-items: center;
padding: 12px;
}
.avatar {
display: inline-flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
color: #fff;
background: #165dff;
border-radius: 8px;
font-size: 13px;
font-weight: 800;
}
.avatarImage {
width: 44px;
height: 44px;
border: 1px solid var(--ifm-color-emphasis-200);
border-radius: 8px;
object-fit: cover;
}
.contributorCard:nth-child(2) .avatar {
background: #00a870;
}
.contributorCard:nth-child(3) .avatar {
background: #ff7d00;
}
.contributorBody {
display: grid;
min-width: 0;
gap: 2px;
}
.contributorBody strong {
color: var(--ifm-heading-color);
font-size: 0.98rem;
}
.contributorBody span {
color: var(--ifm-color-content);
font-size: 0.88rem;
}
.contributorBody em,
.pathItem em {
color: var(--ifm-color-content-secondary);
font-size: 0.82rem;
font-style: normal;
line-height: 1.45;
}
.pathItem {
grid-template-columns: auto minmax(0, 1fr);
gap: 12px;
padding: 14px;
}
.pathIndex {
color: var(--ifm-color-primary);
font-family: var(--ifm-font-family-monospace);
font-size: 0.86rem;
font-weight: 800;
}
.pathItem strong {
display: block;
margin-bottom: 4px;
color: var(--ifm-heading-color);
font-size: 0.96rem;
}
@media (max-width: 996px) {
.section {
padding: 4rem 0;
}
.sectionTitle {
font-size: 2rem;
}
.communityGrid {
grid-template-columns: 1fr;
}
.sponsorLogoGrid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.sponsorLogoTile {
min-height: 96px;
}
}
@media (max-width: 640px) {
.section {
padding: 3.25rem 0;
}
.sectionTitle {
font-size: 1.75rem;
}
.sponsorWallHeader {
display: grid;
min-height: auto;
padding: 1rem;
}
.sponsorWallAction {
justify-content: center;
width: 100%;
}
.sponsorLogoGrid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.sponsorLogoMark {
font-size: 1.15rem;
}
.panel {
padding: 1rem;
}
}
@media (prefers-reduced-motion: reduce) {
.sponsorWallAction,
.sponsorLogoTile,
.contributorCard,
.pathItem {
transition: none;
}
}

View File

@@ -129,7 +129,7 @@ function Feature({title, description, icon, link}: FeatureItem) {
{link && (
<span className={styles.featureLink}>
<Translate id="feat.learnMore">Learn more</Translate>
<span className={styles.featureArrow} aria-hidden="true"></span>
<span className={styles.featureArrow} aria-hidden="true">-&gt;</span>
</span>
)}
</>

View File

@@ -1,5 +1,6 @@
.section {
padding: 6rem 0 4rem;
padding: 5.5rem 0 4.25rem;
background: var(--ifm-background-color);
}
.sectionHead {
@@ -9,14 +10,17 @@
}
.sectionTag {
display: inline-block;
display: inline-flex;
align-items: center;
min-height: 28px;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.15em;
font-weight: 750;
letter-spacing: 0;
color: var(--ifm-color-primary);
padding: 4px 12px;
background: rgba(22, 93, 255, 0.08);
border-radius: 4px;
border: 1px solid rgba(22, 93, 255, 0.16);
border-radius: 8px;
margin-bottom: 1rem;
}
@@ -26,10 +30,10 @@
}
.sectionTitle {
font-size: clamp(1.8rem, 3vw, 2.5rem);
font-size: 2.35rem;
line-height: 1.2;
letter-spacing: -0.02em;
font-weight: 700;
letter-spacing: 0;
font-weight: 750;
margin: 0 0 1rem;
color: var(--ifm-heading-color);
}
@@ -51,6 +55,9 @@
.section {
padding: 3.5rem 0 2rem;
}
.sectionTitle {
font-size: 2rem;
}
.grid {
grid-template-columns: 1fr;
}
@@ -70,7 +77,7 @@
padding: 1.75rem;
background: var(--ifm-background-color);
border: 1px solid var(--ifm-color-emphasis-200);
border-radius: 12px;
border-radius: 8px;
transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease;
text-decoration: none !important;
color: inherit;
@@ -78,7 +85,7 @@
}
.featureCardLink:hover {
transform: translateY(-3px);
transform: translateY(-2px);
border-color: var(--ifm-color-primary);
box-shadow: 0 12px 30px -8px rgba(22, 93, 255, 0.18);
color: inherit;
@@ -99,26 +106,26 @@
.iconWrap {
width: 48px;
height: 48px;
border-radius: 10px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, rgba(22, 93, 255, 0.1) 0%, rgba(143, 75, 255, 0.08) 100%);
background: linear-gradient(135deg, rgba(22, 93, 255, 0.1) 0%, rgba(20, 201, 201, 0.12) 100%);
color: var(--ifm-color-primary);
margin-bottom: 1.25rem;
}
[data-theme='dark'] .iconWrap {
background: linear-gradient(135deg, rgba(96, 126, 255, 0.15) 0%, rgba(143, 75, 255, 0.12) 100%);
background: linear-gradient(135deg, rgba(96, 126, 255, 0.15) 0%, rgba(20, 201, 201, 0.12) 100%);
color: var(--ifm-color-primary-lighter);
}
.featureTitle {
font-size: 1.15rem;
font-weight: 600;
font-weight: 700;
margin: 0 0 0.6rem;
color: var(--ifm-heading-color);
letter-spacing: -0.01em;
letter-spacing: 0;
}
.featureDesc {
@@ -146,3 +153,17 @@
.featureCardLink:hover .featureArrow {
transform: translateX(4px);
}
@media (max-width: 640px) {
.sectionTitle {
font-size: 1.75rem;
}
}
@media (prefers-reduced-motion: reduce) {
.featureCard,
.featureCardLink,
.featureArrow {
transition: none;
}
}

View File

@@ -110,7 +110,7 @@ export default function HomepageShowcase(): ReactNode {
<p className={styles.captionDesc}>{current.description}</p>
<Link to="/docs/getting-started/quick-start" className={styles.captionLink}>
<Translate id="showcase.cta">Explore the docs</Translate>
<span aria-hidden="true"> </span>
<span aria-hidden="true"> -&gt;</span>
</Link>
</div>
</div>

View File

@@ -1,10 +1,14 @@
.section {
padding: 4rem 0 6rem;
background: linear-gradient(180deg, transparent 0%, rgba(22, 93, 255, 0.03) 100%);
padding: 4.5rem 0 5.5rem;
background:
linear-gradient(180deg, rgba(245, 247, 250, 0) 0%, rgba(245, 247, 250, 0.72) 100%),
var(--ifm-background-color);
}
[data-theme='dark'] .section {
background: linear-gradient(180deg, transparent 0%, rgba(64, 128, 255, 0.04) 100%);
background:
linear-gradient(180deg, rgba(15, 17, 21, 0) 0%, rgba(255, 255, 255, 0.03) 100%),
var(--ifm-background-color);
}
.sectionHead {
@@ -14,26 +18,30 @@
}
.sectionTag {
display: inline-block;
display: inline-flex;
align-items: center;
min-height: 28px;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.15em;
color: #8f4bff;
font-weight: 750;
letter-spacing: 0;
color: #0e7490;
padding: 4px 12px;
background: rgba(143, 75, 255, 0.08);
border-radius: 4px;
background: rgba(20, 201, 201, 0.1);
border: 1px solid rgba(20, 201, 201, 0.2);
border-radius: 8px;
margin-bottom: 1rem;
}
[data-theme='dark'] .sectionTag {
background: rgba(143, 75, 255, 0.18);
background: rgba(20, 201, 201, 0.16);
color: #67e8f9;
}
.sectionTitle {
font-size: clamp(1.8rem, 3vw, 2.5rem);
font-size: 2.35rem;
line-height: 1.2;
letter-spacing: -0.02em;
font-weight: 700;
letter-spacing: 0;
font-weight: 750;
margin: 0 0 1rem;
color: var(--ifm-heading-color);
}
@@ -49,34 +57,39 @@
.tabs {
display: flex;
justify-content: center;
gap: 8px;
gap: 6px;
margin-bottom: 2rem;
flex-wrap: wrap;
padding: 6px;
background: var(--ifm-color-emphasis-100);
border: 1px solid var(--ifm-color-emphasis-200);
border-radius: 8px;
}
.tabBtn {
min-height: 40px;
padding: 8px 18px;
background: transparent;
border: 1px solid var(--ifm-color-emphasis-300);
border-radius: 999px;
border: 1px solid transparent;
border-radius: 8px;
color: var(--ifm-color-content-secondary);
font-size: 14px;
font-weight: 500;
font-weight: 650;
cursor: pointer;
transition: all 0.2s ease;
transition: color 0.2s ease, border-color 0.2s ease, background 0.2s ease, box-shadow 0.2s ease;
}
.tabBtn:hover {
color: var(--ifm-color-primary);
border-color: var(--ifm-color-primary);
background: var(--ifm-background-color);
}
.tabBtnActive,
.tabBtnActive:hover {
background: linear-gradient(90deg, #165dff 0%, #4080ff 100%);
color: #fff !important;
border-color: transparent;
box-shadow: 0 4px 14px rgba(22, 93, 255, 0.3);
background: var(--ifm-background-color);
color: var(--ifm-color-primary) !important;
border-color: rgba(22, 93, 255, 0.18);
box-shadow: 0 6px 16px rgba(22, 93, 255, 0.12);
}
/* Stage */
@@ -96,10 +109,10 @@
.browser {
background: var(--ifm-background-color);
border-radius: 12px;
border-radius: 8px;
overflow: hidden;
box-shadow:
0 30px 60px -20px rgba(22, 93, 255, 0.25),
0 24px 58px -22px rgba(22, 93, 255, 0.28),
0 0 0 1px var(--ifm-color-emphasis-200);
}
@@ -137,7 +150,7 @@
margin: 0 auto;
padding: 3px 14px;
background: var(--ifm-background-color);
border-radius: 999px;
border-radius: 8px;
font-size: 12px;
color: var(--ifm-color-content-secondary);
font-family: 'SFMono-Regular', Menlo, monospace;
@@ -169,8 +182,8 @@
.captionTitle {
font-size: 1.7rem;
line-height: 1.2;
letter-spacing: -0.02em;
font-weight: 700;
letter-spacing: 0;
font-weight: 750;
margin: 0 0 1rem;
color: var(--ifm-heading-color);
}
@@ -186,11 +199,49 @@
display: inline-flex;
align-items: center;
gap: 4px;
font-weight: 500;
min-height: 40px;
padding: 0 12px;
border: 1px solid rgba(22, 93, 255, 0.18);
border-radius: 8px;
font-weight: 650;
color: var(--ifm-color-primary);
text-decoration: none !important;
transition: border-color 0.2s ease, background 0.2s ease;
}
.captionLink:hover {
color: var(--ifm-color-primary-dark);
background: rgba(22, 93, 255, 0.06);
border-color: var(--ifm-color-primary);
}
@media (max-width: 996px) {
.sectionTitle {
font-size: 2rem;
}
}
@media (max-width: 640px) {
.section {
padding: 3.25rem 0 4rem;
}
.sectionTitle {
font-size: 1.75rem;
}
.tabs {
justify-content: stretch;
}
.tabBtn {
flex: 1 1 130px;
}
}
@media (prefers-reduced-motion: reduce) {
.tabBtn,
.captionLink {
transition: none;
}
}

View File

@@ -16,14 +16,15 @@
/* Surfaces */
--ifm-background-color: #ffffff;
--ifm-background-surface-color: #ffffff;
--ifm-color-emphasis-100: #f7f9fc;
--ifm-color-emphasis-200: #eef1f6;
--ifm-color-emphasis-300: #dde3ec;
--ifm-color-emphasis-100: #f5f7fa;
--ifm-color-emphasis-200: #e5e6eb;
--ifm-color-emphasis-300: #c9cdd4;
--ifm-color-emphasis-400: #a9aeb8;
/* Typography */
--ifm-font-family-base: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
--ifm-font-family-monospace: 'SFMono-Regular', Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
--ifm-heading-font-weight: 600;
--ifm-heading-font-weight: 700;
--ifm-code-font-size: 92%;
--ifm-h1-font-size: 2.25rem;
--ifm-h2-font-size: 1.75rem;
@@ -33,10 +34,11 @@
--ifm-color-content: #1d2129;
--ifm-color-content-secondary: #4e5969;
--ifm-heading-color: #1d2129;
--ifm-global-radius: 8px;
/* Navbar */
--ifm-navbar-height: 64px;
--ifm-navbar-background-color: rgba(255, 255, 255, 0.82);
--ifm-navbar-background-color: rgba(255, 255, 255, 0.9);
--ifm-navbar-link-color: #4e5969;
--ifm-navbar-link-hover-color: var(--ifm-color-primary);
@@ -64,15 +66,16 @@
--ifm-background-color: #0f1115;
--ifm-background-surface-color: #16181d;
--ifm-color-emphasis-100: #1a1d23;
--ifm-color-emphasis-200: #23272f;
--ifm-color-emphasis-300: #2e343d;
--ifm-color-emphasis-100: #1d2129;
--ifm-color-emphasis-200: #272e3b;
--ifm-color-emphasis-300: #384252;
--ifm-color-emphasis-400: #4e5969;
--ifm-color-content: #e6e9ef;
--ifm-color-content-secondary: #9aa3b2;
--ifm-heading-color: #f0f2f5;
--ifm-navbar-background-color: rgba(15, 17, 21, 0.82);
--ifm-navbar-background-color: rgba(15, 17, 21, 0.9);
--ifm-navbar-link-color: #c9d1db;
--ifm-menu-color: #c9d1db;
@@ -97,7 +100,7 @@
.navbar__title {
font-weight: 700;
letter-spacing: -0.01em;
letter-spacing: 0;
}
.navbar__link {
@@ -105,10 +108,26 @@
font-size: 14px;
}
.navbar__link,
.button,
a {
transition: color 0.2s ease, background 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease;
}
.button {
border-radius: 8px;
font-weight: 650;
}
:focus-visible {
outline: 2px solid var(--ifm-color-primary);
outline-offset: 2px;
}
/* Sidebar tweaks */
.menu__link {
font-size: 14px;
border-radius: 6px;
border-radius: 8px;
padding: 6px 10px;
line-height: 1.4;
}
@@ -226,9 +245,20 @@ code {
}
::-webkit-scrollbar-thumb:hover {
background: var(--ifm-color-emphasis-400, #adb5bd);
background: var(--ifm-color-emphasis-400);
}
[data-theme='dark'] ::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.15);
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
scroll-behavior: auto !important;
transition-duration: 0.01ms !important;
}
}

View File

@@ -0,0 +1,19 @@
import type {ReactNode} from 'react';
import {translate} from '@docusaurus/Translate';
import Layout from '@theme/Layout';
import HomepageCommunity from '@site/src/components/HomepageCommunity';
export default function Community(): ReactNode {
return (
<Layout
title={translate({id: 'community.pageTitle', message: 'Community, sponsors and contributors'})}
description={translate({
id: 'community.pageDescription',
message: 'Sponsor BackupX, meet contributors, and find practical ways to contribute.',
})}>
<main>
<HomepageCommunity />
</main>
</Layout>
);
}

View File

@@ -1,48 +1,42 @@
/* ── Hero ───────────────────────────────────────────── */
/* Hero */
.hero {
position: relative;
padding: 7rem 0 6rem;
overflow: hidden;
background: var(--bx-hero-bg);
padding: 7rem 0 5.5rem;
background:
linear-gradient(180deg, rgba(22, 93, 255, 0.08) 0%, rgba(255, 255, 255, 0) 72%),
linear-gradient(90deg, rgba(20, 201, 201, 0.08) 0%, rgba(250, 173, 20, 0.08) 100%),
var(--ifm-background-color);
}
.heroBg {
.hero::before {
position: absolute;
inset: 0;
background:
radial-gradient(circle at 15% 20%, rgba(104, 127, 255, 0.18) 0%, transparent 45%),
radial-gradient(circle at 85% 70%, rgba(22, 93, 255, 0.15) 0%, transparent 50%),
linear-gradient(180deg, #f7f9ff 0%, #ffffff 100%);
z-index: 0;
content: "";
pointer-events: none;
background-image:
linear-gradient(rgba(22, 93, 255, 0.06) 1px, transparent 1px),
linear-gradient(90deg, rgba(22, 93, 255, 0.06) 1px, transparent 1px);
background-size: 44px 44px;
mask-image: linear-gradient(180deg, rgba(0, 0, 0, 0.75), transparent 82%);
}
[data-theme='dark'] .heroBg {
[data-theme='dark'] .hero {
background:
radial-gradient(circle at 15% 20%, rgba(96, 126, 255, 0.22) 0%, transparent 45%),
radial-gradient(circle at 85% 70%, rgba(118, 70, 255, 0.18) 0%, transparent 50%),
linear-gradient(180deg, #0f1115 0%, #0b0d10 100%);
linear-gradient(180deg, rgba(64, 128, 255, 0.16) 0%, rgba(15, 17, 21, 0) 72%),
linear-gradient(90deg, rgba(20, 201, 201, 0.1) 0%, rgba(250, 173, 20, 0.08) 100%),
var(--ifm-background-color);
}
.heroInner {
position: relative;
z-index: 1;
display: grid;
grid-template-columns: 1.1fr 1fr;
grid-template-columns: minmax(0, 1fr) minmax(420px, 0.9fr);
gap: 4rem;
align-items: center;
}
@media (max-width: 996px) {
.hero {
padding: 4rem 0 3rem;
}
.heroInner {
grid-template-columns: 1fr;
gap: 2.5rem;
text-align: left;
}
}
.heroContent {
display: flex;
flex-direction: column;
@@ -54,137 +48,144 @@
display: inline-flex;
align-items: center;
gap: 8px;
padding: 4px 14px;
background: rgba(22, 93, 255, 0.08);
border: 1px solid rgba(22, 93, 255, 0.15);
border-radius: 999px;
font-size: 13px;
min-height: 32px;
padding: 5px 12px;
color: var(--ifm-color-primary);
font-weight: 500;
background: rgba(22, 93, 255, 0.09);
border: 1px solid rgba(22, 93, 255, 0.2);
border-radius: 8px;
font-size: 13px;
font-weight: 600;
}
[data-theme='dark'] .badge {
background: rgba(96, 126, 255, 0.15);
border-color: rgba(96, 126, 255, 0.3);
background: rgba(64, 128, 255, 0.16);
border-color: rgba(64, 128, 255, 0.3);
color: var(--ifm-color-primary-lighter);
}
.badgeDot {
width: 6px;
height: 6px;
background: var(--ifm-color-primary);
width: 7px;
height: 7px;
background: #00b42a;
border-radius: 50%;
box-shadow: 0 0 0 4px rgba(22, 93, 255, 0.18);
animation: pulse 2s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
box-shadow: 0 0 0 4px rgba(0, 180, 42, 0.12);
}
.heroTitle {
font-size: clamp(2.25rem, 4vw, 3.4rem);
line-height: 1.15;
letter-spacing: -0.025em;
font-weight: 700;
margin: 0;
color: var(--ifm-heading-color);
font-size: 3.45rem;
font-weight: 750;
letter-spacing: 0;
line-height: 1.08;
}
.heroTitleAccent {
display: block;
background: linear-gradient(90deg, #4080ff 0%, #8f4bff 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
margin-top: 6px;
margin-top: 8px;
color: var(--ifm-color-primary);
}
.heroSubtitle {
font-size: 1.15rem;
line-height: 1.65;
color: var(--ifm-color-content-secondary);
max-width: 540px;
max-width: 640px;
margin: 0;
color: var(--ifm-color-content-secondary);
font-size: 1.15rem;
line-height: 1.72;
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-top: 8px;
flex-wrap: wrap;
}
.primaryBtn,
.secondaryBtn {
min-height: 46px;
border-radius: 8px;
transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease, background 0.2s ease;
}
.primaryBtn {
background: linear-gradient(90deg, #165dff 0%, #4080ff 100%);
border: none;
color: #fff;
display: inline-flex;
align-items: center;
gap: 6px;
font-weight: 600;
box-shadow: 0 6px 20px rgba(22, 93, 255, 0.3);
transition: transform 0.2s ease, box-shadow 0.2s ease;
gap: 8px;
color: #fff;
background: #165dff;
border: 1px solid #165dff;
box-shadow: 0 10px 24px rgba(22, 93, 255, 0.24);
font-weight: 650;
}
.primaryBtn:hover {
transform: translateY(-1px);
box-shadow: 0 10px 25px rgba(22, 93, 255, 0.4);
.primaryBtn:hover,
.primaryBtn:focus-visible {
color: #fff;
background: #0e4fe6;
border-color: #0e4fe6;
box-shadow: 0 14px 30px rgba(22, 93, 255, 0.3);
transform: translateY(-1px);
}
.btnArrow {
transition: transform 0.2s ease;
}
.primaryBtn:hover .btnArrow {
transform: translateX(4px);
.primaryBtn:hover .btnArrow,
.primaryBtn:focus-visible .btnArrow {
transform: translateX(3px);
}
.secondaryBtn {
background: var(--ifm-background-color);
border: 1px solid var(--ifm-color-emphasis-300);
color: var(--ifm-font-color-base);
display: inline-flex;
align-items: center;
font-weight: 500;
transition: all 0.2s ease;
color: var(--ifm-font-color-base);
background: var(--ifm-background-color);
border: 1px solid var(--ifm-color-emphasis-300);
font-weight: 600;
}
.secondaryBtn:hover {
border-color: var(--ifm-color-primary);
.secondaryBtn:hover,
.secondaryBtn:focus-visible {
color: var(--ifm-color-primary);
border-color: var(--ifm-color-primary);
background: var(--ifm-background-color);
transform: translateY(-1px);
}
.metrics {
display: flex;
align-items: center;
gap: 1.75rem;
padding-top: 1.5rem;
gap: 1.5rem;
margin-top: 0.5rem;
padding-top: 1.25rem;
}
.metric {
display: flex;
min-width: 0;
flex-direction: column;
gap: 2px;
gap: 4px;
}
.metricValue {
font-size: 1.6rem;
font-weight: 700;
color: var(--ifm-heading-color);
font-size: 1.35rem;
font-weight: 750;
letter-spacing: 0;
line-height: 1.1;
letter-spacing: -0.02em;
white-space: nowrap;
}
.metricLabel {
font-size: 12px;
color: var(--ifm-color-content-secondary);
font-size: 12px;
font-weight: 600;
letter-spacing: 0;
line-height: 1.35;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.metricDivider {
@@ -193,81 +194,277 @@
background: var(--ifm-color-emphasis-300);
}
/* ── Code window (macOS-style) ─────────────────────── */
.heroCode {
position: relative;
/* Product visual */
.heroVisual {
display: grid;
gap: 1rem;
}
.codeWindow {
background: #0f1622;
border-radius: 12px;
box-shadow:
0 20px 50px -10px rgba(15, 22, 34, 0.35),
0 0 0 1px rgba(255, 255, 255, 0.05);
.consolePanel {
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.06);
background: rgba(255, 255, 255, 0.92);
border: 1px solid rgba(22, 93, 255, 0.16);
border-radius: 8px;
box-shadow: 0 24px 60px rgba(29, 33, 41, 0.12);
}
[data-theme='light'] .codeWindow {
box-shadow: 0 20px 50px -10px rgba(22, 93, 255, 0.2), 0 0 0 1px rgba(22, 93, 255, 0.06);
[data-theme='dark'] .consolePanel {
background: rgba(22, 24, 29, 0.9);
border-color: rgba(255, 255, 255, 0.08);
box-shadow: 0 24px 60px rgba(0, 0, 0, 0.34);
}
.codeHeader {
.consoleHeader {
display: flex;
align-items: center;
gap: 6px;
padding: 10px 14px;
background: #161f2e;
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
padding: 1.25rem;
border-bottom: 1px solid var(--ifm-color-emphasis-200);
}
.codeDot {
width: 11px;
height: 11px;
border-radius: 50%;
[data-theme='dark'] .consoleHeader {
border-bottom-color: rgba(255, 255, 255, 0.08);
}
.codeDotRed { background: #ff5f56; }
.codeDotYellow { background: #ffbd2e; }
.codeDotGreen { background: #27c93f; }
.consoleHeader strong {
display: block;
margin-top: 4px;
color: var(--ifm-heading-color);
font-size: 1.2rem;
}
.codeTitle {
margin-left: auto;
font-size: 11px;
color: #7b8696;
letter-spacing: 0.05em;
.consoleEyebrow {
color: var(--ifm-color-content-secondary);
font-size: 12px;
font-weight: 650;
letter-spacing: 0;
text-transform: uppercase;
}
.codeBody {
margin: 0;
padding: 18px 20px;
font-family: 'SFMono-Regular', Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
font-size: 13px;
line-height: 1.65;
color: #e1e7ef;
background: transparent;
.consoleStatus {
display: inline-flex;
align-items: center;
min-height: 28px;
padding: 4px 10px;
color: #00a870;
background: rgba(0, 180, 42, 0.1);
border: 1px solid rgba(0, 180, 42, 0.2);
border-radius: 8px;
font-size: 12px;
font-weight: 700;
}
.consoleGrid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
border-bottom: 1px solid var(--ifm-color-emphasis-200);
}
[data-theme='dark'] .consoleGrid {
border-bottom-color: rgba(255, 255, 255, 0.08);
}
.consoleGrid > div {
min-width: 0;
padding: 1.1rem 1.25rem;
border-right: 1px solid var(--ifm-color-emphasis-200);
}
[data-theme='dark'] .consoleGrid > div {
border-right-color: rgba(255, 255, 255, 0.08);
}
.consoleGrid > div:last-child {
border-right: 0;
}
.consoleGrid strong {
display: block;
margin-top: 6px;
color: var(--ifm-heading-color);
font-size: 1.45rem;
line-height: 1.1;
}
.consoleLabel {
display: block;
color: var(--ifm-color-content-secondary);
font-size: 12px;
font-weight: 650;
}
.timeline {
display: grid;
}
.timelineRow {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
gap: 12px;
align-items: center;
padding: 1rem 1.25rem;
border-bottom: 1px solid var(--ifm-color-emphasis-200);
}
[data-theme='dark'] .timelineRow {
border-bottom-color: rgba(255, 255, 255, 0.08);
}
.timelineRow:last-child {
border-bottom: 0;
}
.timelineRow strong,
.timelineRow span {
display: block;
}
.timelineRow strong {
color: var(--ifm-heading-color);
font-size: 0.95rem;
font-weight: 700;
}
.timelineRow span {
color: var(--ifm-color-content-secondary);
font-size: 0.85rem;
line-height: 1.5;
}
.timelineRow em {
color: var(--ifm-color-content-secondary);
font-size: 0.8rem;
font-style: normal;
font-weight: 650;
white-space: nowrap;
}
.timelineDotOk,
.timelineDotInfo,
.timelineDotWarn {
width: 10px;
height: 10px;
border-radius: 50%;
}
.timelineDotOk {
background: #00b42a;
}
.timelineDotInfo {
background: #165dff;
}
.timelineDotWarn {
background: #ff7d00;
}
.commandCard {
display: grid;
gap: 8px;
padding: 1rem 1.1rem;
background: #111827;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 8px;
box-shadow: 0 16px 34px rgba(17, 24, 39, 0.18);
}
.commandTitle {
color: #9ca3af;
font-size: 12px;
font-weight: 650;
letter-spacing: 0;
text-transform: uppercase;
}
.commandCard code {
overflow-x: auto;
}
.codeBody code {
color: #e5e7eb;
background: transparent;
padding: 0;
border: 0;
color: inherit;
padding: 0;
font-size: 13px;
white-space: nowrap;
}
.codePrompt {
color: #4080ff;
margin-right: 6px;
user-select: none;
@media (max-width: 996px) {
.hero {
padding: 4.5rem 0 3.5rem;
}
.heroInner {
grid-template-columns: 1fr;
gap: 2.25rem;
}
.heroTitle {
font-size: 2.45rem;
}
}
.codeComment {
color: #6e7889;
font-style: italic;
@media (max-width: 640px) {
.hero {
padding: 3.75rem 0 2.75rem;
}
.heroTitle {
font-size: 2.05rem;
}
.heroSubtitle {
font-size: 1rem;
}
.actions {
width: 100%;
}
.primaryBtn,
.secondaryBtn {
width: 100%;
justify-content: center;
}
.metrics {
width: 100%;
align-items: stretch;
gap: 0.85rem;
flex-direction: column;
}
.metricDivider {
width: 100%;
height: 1px;
}
.consoleHeader,
.timelineRow {
padding: 1rem;
}
.consoleGrid {
grid-template-columns: 1fr;
}
.consoleGrid > div {
border-right: 0;
border-bottom: 1px solid var(--ifm-color-emphasis-200);
}
.consoleGrid > div:last-child {
border-bottom: 0;
}
[data-theme='dark'] .consoleGrid > div {
border-bottom-color: rgba(255, 255, 255, 0.08);
}
}
.codeString {
color: #82d1ff;
@media (prefers-reduced-motion: reduce) {
.primaryBtn,
.secondaryBtn,
.btnArrow {
transition: none;
}
}

View File

@@ -7,34 +7,34 @@ import Layout from '@theme/Layout';
import Heading from '@theme/Heading';
import HomepageFeatures from '@site/src/components/HomepageFeatures';
import HomepageShowcase from '@site/src/components/HomepageShowcase';
import HomepageCommunity from '@site/src/components/HomepageCommunity';
import styles from './index.module.css';
function HomepageHeader() {
return (
<header className={styles.hero}>
<div className={styles.heroBg} aria-hidden="true" />
<div className={clsx('container', styles.heroInner)}>
<div className={styles.heroContent}>
<div className={styles.badge}>
<span className={styles.badgeDot} />
<Translate id="home.badge">Open-source · v1.6.0</Translate>
<Translate id="home.badge">Open-source backup control plane · v2.2.1</Translate>
</div>
<Heading as="h1" className={styles.heroTitle}>
<Translate id="home.title.part1">Self-hosted backup management</Translate>
<Translate id="home.title.part1">Backup orchestration</Translate>
<span className={styles.heroTitleAccent}>
<Translate id="home.title.part2">for every server.</Translate>
<Translate id="home.title.part2">for self-hosted servers.</Translate>
</span>
</Heading>
<p className={styles.heroSubtitle}>
<Translate id="home.tagline">
One binary, one command. File / database / SAP HANA backups routed to 70+ storage backends.
Run file, database, SAP HANA and remote-node backups from one clean console. Keep the control plane yours, keep the storage flexible.
</Translate>
</p>
<div className={styles.actions}>
<Link className={clsx('button button--primary button--lg', styles.primaryBtn)} to="/docs/getting-started/quick-start">
<Translate id="home.getStarted">Get Started</Translate>
<span className={styles.btnArrow} aria-hidden="true"></span>
<span className={styles.btnArrow} aria-hidden="true">-&gt;</span>
</Link>
<Link className={clsx('button button--lg', styles.secondaryBtn)} to="https://github.com/Awuqing/BackupX">
<svg width="18" height="18" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true" style={{marginRight: 6}}>
@@ -52,9 +52,9 @@ function HomepageHeader() {
</div>
<div className={styles.metricDivider} />
<div className={styles.metric}>
<div className={styles.metricValue}>5</div>
<div className={styles.metricValue}>Agent</div>
<div className={styles.metricLabel}>
<Translate id="home.metric.backupTypes">Backup types</Translate>
<Translate id="home.metric.backupTypes">Remote execution</Translate>
</div>
</div>
<div className={styles.metricDivider} />
@@ -66,29 +66,85 @@ function HomepageHeader() {
</div>
</div>
</div>
<div className={styles.heroCode}>
<div className={styles.codeWindow}>
<div className={styles.codeHeader}>
<span className={clsx(styles.codeDot, styles.codeDotRed)} />
<span className={clsx(styles.codeDot, styles.codeDotYellow)} />
<span className={clsx(styles.codeDot, styles.codeDotGreen)} />
<span className={styles.codeTitle}>bash</span>
<div className={styles.heroVisual}>
<div className={styles.consolePanel}>
<div className={styles.consoleHeader}>
<div>
<span className={styles.consoleEyebrow}>
<Translate id="home.visual.eyebrow">BackupX Console</Translate>
</span>
<strong>
<Translate id="home.visual.title">Operations overview</Translate>
</strong>
</div>
<span className={styles.consoleStatus}>
<Translate id="home.visual.status">Healthy</Translate>
</span>
</div>
<pre className={styles.codeBody}>
<code>
<span className={styles.codeComment}># Docker one-liner</span>{'\n'}
<span className={styles.codePrompt}>$</span> docker run -d --name backupx \{'\n'}
{' '}-p 8340:8340 \{'\n'}
{' '}-v backupx-data:/app/data \{'\n'}
{' '}awuqing/backupx:latest{'\n'}
{'\n'}
<span className={styles.codeComment}># Open http://localhost:8340</span>{'\n'}
<span className={styles.codeComment}># Deploy an Agent on a remote host</span>{'\n'}
<span className={styles.codePrompt}>$</span> backupx agent \{'\n'}
{' '}--master <span className={styles.codeString}>http://master:8340</span> \{'\n'}
{' '}--token <span className={styles.codeString}>&lt;token&gt;</span>
</code>
</pre>
<div className={styles.consoleGrid}>
<div>
<span className={styles.consoleLabel}>
<Translate id="home.visual.success">Success rate</Translate>
</span>
<strong>99.4%</strong>
</div>
<div>
<span className={styles.consoleLabel}>
<Translate id="home.visual.nodes">Active nodes</Translate>
</span>
<strong>12</strong>
</div>
<div>
<span className={styles.consoleLabel}>
<Translate id="home.visual.targets">Storage targets</Translate>
</span>
<strong>8</strong>
</div>
</div>
<div className={styles.timeline}>
<div className={styles.timelineRow}>
<span className={styles.timelineDotOk} />
<div>
<strong>
<Translate id="home.visual.row1.title">PostgreSQL nightly</Translate>
</strong>
<span>
<Translate id="home.visual.row1.desc">Encrypted archive uploaded to S3</Translate>
</span>
</div>
<em>02:10</em>
</div>
<div className={styles.timelineRow}>
<span className={styles.timelineDotInfo} />
<div>
<strong>
<Translate id="home.visual.row2.title">SAP HANA snapshot</Translate>
</strong>
<span>
<Translate id="home.visual.row2.desc">Running on agent-shanghai-02</Translate>
</span>
</div>
<em>68%</em>
</div>
<div className={styles.timelineRow}>
<span className={styles.timelineDotWarn} />
<div>
<strong>
<Translate id="home.visual.row3.title">Retention cleanup</Translate>
</strong>
<span>
<Translate id="home.visual.row3.desc">Next run in 4 hours</Translate>
</span>
</div>
<em>queued</em>
</div>
</div>
</div>
<div className={styles.commandCard}>
<div className={styles.commandTitle}>
<Translate id="home.command.title">Start with Docker</Translate>
</div>
<code>docker run -d -p 8340:8340 awuqing/backupx:v2.2.1</code>
</div>
</div>
</div>
@@ -100,12 +156,13 @@ export default function Home(): ReactNode {
const {siteConfig} = useDocusaurusContext();
return (
<Layout
title={translate({id: 'home.pageTitle', message: 'Self-hosted backup management'})}
title={translate({id: 'home.pageTitle', message: 'Backup orchestration for self-hosted servers'})}
description={siteConfig.tagline}>
<HomepageHeader />
<main>
<HomepageFeatures />
<HomepageShowcase />
<HomepageCommunity />
</main>
</Layout>
);

View File

@@ -0,0 +1,39 @@
import type {ReactNode} from 'react';
import {translate} from '@docusaurus/Translate';
import Translate from '@docusaurus/Translate';
import Layout from '@theme/Layout';
import Heading from '@theme/Heading';
import {HomepageSponsors} from '@site/src/components/HomepageCommunity';
import styles from '@site/src/components/HomepageCommunity/styles.module.css';
export default function Sponsors(): ReactNode {
return (
<Layout
title={translate({id: 'sponsors.pageTitle', message: 'Sponsors'})}
description={translate({
id: 'sponsors.pageDescription',
message: 'Sponsor BackupX reliability, documentation, storage compatibility and long-term maintenance.',
})}>
<main>
<section className={styles.section}>
<div className="container">
<div className={styles.sectionHead}>
<div className={styles.sectionTag}>
<Translate id="sponsors.tag">SPONSORS</Translate>
</div>
<Heading as="h1" className={styles.sectionTitle}>
<Translate id="sponsors.title">Sponsor the BackupX ecosystem</Translate>
</Heading>
<p className={styles.sectionSubtitle}>
<Translate id="sponsors.subtitle">
Sponsorship helps keep BackupX practical for real operators: tested storage providers, reliable releases, restore confidence and better documentation.
</Translate>
</p>
</div>
<HomepageSponsors />
</div>
</section>
</main>
</Layout>
);
}

View File

@@ -7,6 +7,8 @@ require (
github.com/glebarez/sqlite v1.11.0
github.com/golang-jwt/jwt/v5 v5.3.0
github.com/natefinch/lumberjack v2.0.0+incompatible
github.com/prometheus/client_golang v1.23.2
github.com/pquerna/otp v1.5.0
github.com/rclone/rclone v1.73.3
github.com/robfig/cron/v3 v3.0.1
github.com/spf13/viper v1.20.0
@@ -180,8 +182,6 @@ require (
github.com/pkg/xattr v0.4.12 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
github.com/pquerna/otp v1.5.0 // indirect
github.com/prometheus/client_golang v1.23.2 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.67.2 // indirect
github.com/prometheus/procfs v0.19.2 // indirect

View File

@@ -11,6 +11,8 @@ import (
"strings"
"sync"
"time"
"backupx/server/internal/backup"
)
// Agent 是 Agent 进程的主控制器。
@@ -131,6 +133,12 @@ func (a *Agent) pollAndHandleOnce(ctx context.Context) {
a.handleRunTask(ctx, cmd)
case "list_dir":
a.handleListDir(ctx, cmd)
case "restore_record":
a.handleRestoreRecord(ctx, cmd)
case "discover_db":
a.handleDiscoverDB(ctx, cmd)
case "delete_storage_object":
a.handleDeleteStorageObject(ctx, cmd)
default:
msg := fmt.Sprintf("unknown command type: %s", cmd.Type)
log.Printf("[agent] %s", msg)
@@ -158,6 +166,83 @@ func (a *Agent) handleRunTask(ctx context.Context, cmd *CommandPayload) {
})
}
// handleRestoreRecord 处理 restore_record 命令
func (a *Agent) handleRestoreRecord(ctx context.Context, cmd *CommandPayload) {
var payload struct {
RestoreRecordID uint `json:"restoreRecordId"`
}
if err := json.Unmarshal(cmd.Payload, &payload); err != nil {
_ = a.client.SubmitCommandResult(ctx, cmd.ID, false, "invalid payload: "+err.Error(), nil)
return
}
if payload.RestoreRecordID == 0 {
_ = a.client.SubmitCommandResult(ctx, cmd.ID, false, "restoreRecordId is required", nil)
return
}
if err := a.executor.ExecuteRestore(ctx, payload.RestoreRecordID); err != nil {
_ = a.client.SubmitCommandResult(ctx, cmd.ID, false, err.Error(), nil)
return
}
_ = a.client.SubmitCommandResult(ctx, cmd.ID, true, "", map[string]any{
"restoreRecordId": payload.RestoreRecordID,
})
}
// handleDeleteStorageObject 处理 delete_storage_object 命令:在 Agent 侧删除指定存储对象。
// 用于跨节点 local_disk 场景下的远程备份文件清理。
func (a *Agent) handleDeleteStorageObject(ctx context.Context, cmd *CommandPayload) {
var payload struct {
TargetType string `json:"targetType"`
TargetConfig map[string]any `json:"targetConfig"`
StoragePath string `json:"storagePath"`
}
if err := json.Unmarshal(cmd.Payload, &payload); err != nil {
_ = a.client.SubmitCommandResult(ctx, cmd.ID, false, "invalid payload: "+err.Error(), nil)
return
}
if strings.TrimSpace(payload.StoragePath) == "" {
_ = a.client.SubmitCommandResult(ctx, cmd.ID, false, "storagePath is required", nil)
return
}
provider, err := a.executor.storageRegistry.Create(ctx, payload.TargetType, payload.TargetConfig)
if err != nil {
_ = a.client.SubmitCommandResult(ctx, cmd.ID, false, "create provider: "+err.Error(), nil)
return
}
if err := provider.Delete(ctx, payload.StoragePath); err != nil {
_ = a.client.SubmitCommandResult(ctx, cmd.ID, false, "delete object: "+err.Error(), nil)
return
}
_ = a.client.SubmitCommandResult(ctx, cmd.ID, true, "", map[string]any{"deleted": true})
}
// handleDiscoverDB 处理 discover_db 命令:在 Agent 本机执行 mysql/psql 列出数据库。
func (a *Agent) handleDiscoverDB(ctx context.Context, cmd *CommandPayload) {
var payload struct {
Type string `json:"type"`
Host string `json:"host"`
Port int `json:"port"`
User string `json:"user"`
Password string `json:"password"`
}
if err := json.Unmarshal(cmd.Payload, &payload); err != nil {
_ = a.client.SubmitCommandResult(ctx, cmd.ID, false, "invalid payload: "+err.Error(), nil)
return
}
databases, err := backup.DiscoverDatabases(ctx, backup.NewOSCommandExecutor(), backup.DiscoverRequest{
Type: payload.Type,
Host: payload.Host,
Port: payload.Port,
User: payload.User,
Password: payload.Password,
})
if err != nil {
_ = a.client.SubmitCommandResult(ctx, cmd.ID, false, err.Error(), nil)
return
}
_ = a.client.SubmitCommandResult(ctx, cmd.ID, true, "", map[string]any{"databases": databases})
}
// handleListDir 处理 list_dir 命令(阶段四实现)
func (a *Agent) handleListDir(ctx context.Context, cmd *CommandPayload) {
var payload struct {

View File

@@ -158,6 +158,52 @@ func (c *MasterClient) UpdateRecord(ctx context.Context, recordID uint, update R
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

View File

@@ -238,6 +238,180 @@ func (l *recordLogger) WriteLine(message string) {
_ = l.client.UpdateRecord(l.ctx, l.recordID, RecordUpdate{LogAppend: message + "\n"})
}
// restoreLogger 把 runner 日志回传到 Master 恢复记录。
type restoreLogger struct {
ctx context.Context
client *MasterClient
restoreID uint
}
func newRestoreLogger(ctx context.Context, client *MasterClient, restoreID uint) *restoreLogger {
return &restoreLogger{ctx: ctx, client: client, restoreID: restoreID}
}
func (l *restoreLogger) WriteLine(message string) {
_ = l.client.UpdateRestore(l.ctx, l.restoreID, RestoreUpdate{LogAppend: message + "\n"})
}
// DeleteStorageObject 在 Agent 本机上删除指定存储对象(供跨节点清理调用)。
func (e *Executor) DeleteStorageObject(ctx context.Context, targetType string, targetConfig map[string]any, storagePath string) error {
provider, err := e.storageRegistry.Create(ctx, targetType, targetConfig)
if err != nil {
return fmt.Errorf("create provider: %w", err)
}
return provider.Delete(ctx, storagePath)
}
// ExecuteRestore 处理 restore_record 命令:拉规格 → 下载 → 解压 → 执行 runner.Restore → 上报结果。
//
// 与 ExecuteRunTask 对称,但方向相反:
// - 下载:通过 spec.Storage 创建 provider → Download(spec.StoragePath)
// - 解密:当前 Agent 不支持加密恢复密钥未下发spec.Encrypt=true 会直接失败
// - 执行backup.Registry.Runner(spec.Type).Restore
// - 上报:通过 UpdateRestorestatus/logAppend
func (e *Executor) ExecuteRestore(ctx context.Context, restoreRecordID uint) error {
spec, err := e.client.GetRestoreSpec(ctx, restoreRecordID)
if err != nil {
e.reportRestoreFailure(ctx, restoreRecordID, fmt.Sprintf("拉取恢复规格失败: %v", err))
return err
}
if spec.Encrypt {
msg := "Agent 不支持加密恢复(加密密钥仅在 Master 端持有)"
e.reportRestoreFailure(ctx, restoreRecordID, msg)
return fmt.Errorf("%s", msg)
}
e.appendRestoreLog(ctx, restoreRecordID, fmt.Sprintf("[agent] 开始恢复 %s (type=%s)\n", spec.TaskName, spec.Type))
if err := os.MkdirAll(e.tempDir, 0o755); err != nil {
e.reportRestoreFailure(ctx, restoreRecordID, fmt.Sprintf("创建临时目录失败: %v", err))
return err
}
tmpDir, err := os.MkdirTemp(e.tempDir, "restore-*")
if err != nil {
e.reportRestoreFailure(ctx, restoreRecordID, fmt.Sprintf("创建恢复临时目录失败: %v", err))
return err
}
defer os.RemoveAll(tmpDir)
// 1) 创建 storage provider
var rawConfig map[string]any
if len(spec.Storage.Config) > 0 {
if err := jsonUnmarshalMap(spec.Storage.Config, &rawConfig); err != nil {
e.reportRestoreFailure(ctx, restoreRecordID, fmt.Sprintf("解析存储配置失败: %v", err))
return err
}
}
provider, err := e.storageRegistry.Create(ctx, spec.Storage.Type, rawConfig)
if err != nil {
e.reportRestoreFailure(ctx, restoreRecordID, fmt.Sprintf("创建存储客户端失败: %v", err))
return err
}
// 2) 下载
fileName := spec.FileName
if strings.TrimSpace(fileName) == "" {
fileName = filepath.Base(spec.StoragePath)
}
artifactPath := filepath.Join(tmpDir, filepath.Base(fileName))
e.appendRestoreLog(ctx, restoreRecordID, fmt.Sprintf("[agent] 下载备份文件 %s\n", spec.StoragePath))
reader, err := provider.Download(ctx, spec.StoragePath)
if err != nil {
e.reportRestoreFailure(ctx, restoreRecordID, fmt.Sprintf("下载备份失败: %v", err))
return err
}
if err := writeReaderToLocal(artifactPath, reader); err != nil {
e.reportRestoreFailure(ctx, restoreRecordID, fmt.Sprintf("写入备份文件失败: %v", err))
return err
}
// 3) 解压Agent 不支持加密,遇到 .enc 会直接失败)
preparedPath := artifactPath
if strings.HasSuffix(strings.ToLower(preparedPath), ".enc") {
msg := "检测到加密后缀Agent 不支持加密恢复"
e.reportRestoreFailure(ctx, restoreRecordID, msg)
return fmt.Errorf("%s", msg)
}
if strings.HasSuffix(strings.ToLower(preparedPath), ".gz") {
e.appendRestoreLog(ctx, restoreRecordID, "[agent] 解压 gzip 压缩\n")
decompressed, err := compress.GunzipFile(preparedPath)
if err != nil {
e.reportRestoreFailure(ctx, restoreRecordID, fmt.Sprintf("解压失败: %v", err))
return err
}
preparedPath = decompressed
}
// 4) 运行 runner.Restore
taskSpec := buildRestoreBackupTaskSpec(spec, time.Now().UTC(), tmpDir)
runner, err := e.backupRegistry.Runner(taskSpec.Type)
if err != nil {
e.reportRestoreFailure(ctx, restoreRecordID, fmt.Sprintf("不支持的备份类型: %v", err))
return err
}
logger := newRestoreLogger(ctx, e.client, restoreRecordID)
if err := runner.Restore(ctx, taskSpec, preparedPath, logger); err != nil {
e.reportRestoreFailure(ctx, restoreRecordID, err.Error())
return err
}
// 5) 上报成功
return e.client.UpdateRestore(ctx, restoreRecordID, RestoreUpdate{
Status: "success",
LogAppend: "[agent] 恢复执行完成\n",
})
}
func (e *Executor) appendRestoreLog(ctx context.Context, restoreID uint, line string) {
_ = e.client.UpdateRestore(ctx, restoreID, RestoreUpdate{LogAppend: line})
}
func (e *Executor) reportRestoreFailure(ctx context.Context, restoreID uint, msg string) {
_ = e.client.UpdateRestore(ctx, restoreID, RestoreUpdate{
Status: "failed",
ErrorMessage: msg,
LogAppend: fmt.Sprintf("[agent] 错误: %s\n", msg),
})
}
// buildRestoreBackupTaskSpec 把 RestoreSpec 转成 backup.TaskSpec。
func buildRestoreBackupTaskSpec(spec *RestoreSpec, startedAt time.Time, tempDir string) backup.TaskSpec {
return backup.TaskSpec{
ID: spec.TaskID,
Name: spec.TaskName,
Type: spec.Type,
SourcePath: spec.SourcePath,
SourcePaths: spec.SourcePaths,
ExcludePatterns: nil,
Database: backup.DatabaseSpec{
Host: spec.DBHost,
Port: spec.DBPort,
User: spec.DBUser,
Password: spec.DBPassword,
Path: spec.DBPath,
Names: splitCommaOrNewline(spec.DBName),
},
Compression: spec.Compression,
Encrypt: spec.Encrypt,
StartedAt: startedAt,
TempDir: tempDir,
}
}
// writeReaderToLocal 把 reader 写到本地文件Agent 侧工具函数)。
func writeReaderToLocal(targetPath string, reader io.ReadCloser) error {
defer reader.Close()
if err := os.MkdirAll(filepath.Dir(targetPath), 0o755); err != nil {
return err
}
file, err := os.Create(targetPath)
if err != nil {
return err
}
defer file.Close()
_, err = io.Copy(file, reader)
return err
}
// 辅助函数
func computeFileSHA256(path string) (string, error) {

View File

@@ -13,6 +13,7 @@ import (
"backupx/server/internal/database"
aphttp "backupx/server/internal/http"
"backupx/server/internal/logger"
"backupx/server/internal/metrics"
"backupx/server/internal/notify"
"backupx/server/internal/repository"
"backupx/server/internal/scheduler"
@@ -59,9 +60,9 @@ func New(ctx context.Context, cfg config.Config, version string) (*Application,
jwtManager := security.NewJWTManager(resolvedSecurity.JWTSecret, config.MustJWTDuration(cfg.Security))
rateLimiter := security.NewLoginRateLimiter(5, time.Minute)
authService := service.NewAuthService(userRepo, systemConfigRepo, jwtManager, rateLimiter)
systemService := service.NewSystemService(cfg, version, time.Now().UTC())
configCipher := codec.NewConfigCipher(resolvedSecurity.EncryptionKey)
authService := service.NewAuthService(userRepo, systemConfigRepo, jwtManager, rateLimiter, configCipher)
systemService := service.NewSystemService(cfg, version, time.Now().UTC())
storageRegistry := storage.NewRegistry(
storageRclone.NewLocalDiskFactory(),
storageRclone.NewS3Factory(),
@@ -80,11 +81,13 @@ func New(ctx context.Context, cfg config.Config, version string) (*Application,
storageTargetService.SetBackupRecordRepository(backupRecordRepo)
backupTaskService := service.NewBackupTaskService(backupTaskRepo, storageTargetRepo, configCipher)
backupTaskService.SetRecordsAndStorage(backupRecordRepo, storageRegistry)
// nodeRepo 在下方 Cluster 节点管理区块才实例化,这里延后注入
backupRunnerRegistry := backup.NewRegistry(backup.NewFileRunner(), backup.NewSQLiteRunner(), backup.NewMySQLRunner(nil), backup.NewPostgreSQLRunner(nil), backup.NewSAPHANARunner(nil))
logHub := backup.NewLogHub()
retentionService := backupretention.NewService(backupRecordRepo)
notifyRegistry := notify.NewRegistry(notify.NewEmailNotifier(), notify.NewWebhookNotifier(), notify.NewTelegramNotifier())
notificationService := service.NewNotificationService(notificationRepo, notifyRegistry, configCipher)
authService.SetNotificationService(notificationService)
// 初始化 rclone 传输配置(重试 + 带宽限制)
rcloneCtx := storageRclone.ConfiguredContext(ctx, storageRclone.TransferConfig{
LowLevelRetries: cfg.Backup.Retries,
@@ -97,6 +100,9 @@ func New(ctx context.Context, cfg config.Config, version string) (*Application,
backupTaskService.SetScheduler(schedulerService)
// 审计日志注入延迟到 auditService 创建后(见下方)
backupRecordService := service.NewBackupRecordService(backupRecordRepo, backupExecutionService, logHub)
// 恢复服务:使用独立 LogHub 避免恢复记录与备份记录 ID 命名空间冲突
restoreRecordRepo := repository.NewRestoreRecordRepository(db)
restoreLogHub := backup.NewLogHub()
dashboardService := service.NewDashboardService(backupTaskRepo, backupRecordRepo, storageTargetRepo)
settingsService := service.NewSettingsService(systemConfigRepo)
@@ -105,12 +111,16 @@ func New(ctx context.Context, cfg config.Config, version string) (*Application,
auditService := service.NewAuditService(auditLogRepo)
authService.SetAuditService(auditService)
schedulerService.SetAuditRecorder(auditService)
// 审计日志外输:启动时用当前 settings 初始化 webhook后续前端修改立即生效
settingsService.SetAuditWebhookConfigurer(ctx, auditService)
// Database discovery
// Database discovery(集群依赖在 agentService 创建后注入)
databaseDiscoveryService := service.NewDatabaseDiscoveryService(backup.NewOSCommandExecutor())
// Cluster: Node management
nodeRepo := repository.NewNodeRepository(db)
backupTaskService.SetNodeRepository(nodeRepo)
schedulerService.SetNodeRepository(nodeRepo)
nodeService := service.NewNodeService(nodeRepo, version)
nodeService.SetTaskRepository(backupTaskRepo)
if err := nodeService.EnsureLocalNode(ctx); err != nil {
@@ -122,32 +132,153 @@ func New(ctx context.Context, cfg config.Config, version string) (*Application,
// Agent 协议服务:命令队列 + 任务下发 + 记录上报
agentCmdRepo := repository.NewAgentCommandRepository(db)
agentService := service.NewAgentService(nodeRepo, backupTaskRepo, backupRecordRepo, storageTargetRepo, agentCmdRepo, configCipher)
agentService.SetRestoreRepository(restoreRecordRepo)
agentService.StartCommandTimeoutMonitor(ctx, 30*time.Second, 10*time.Minute)
// 一键部署install token service + 后台 GC
installTokenRepo := repository.NewAgentInstallTokenRepository(db)
installTokenService := service.NewInstallTokenService(installTokenRepo, nodeRepo)
installTokenService.StartGC(ctx, time.Hour)
// 把 Agent 下发能力注入到备份执行服务,实现多节点路由
backupExecutionService.SetClusterDependencies(nodeRepo, agentService)
// 启用远程目录浏览NodeService 通过 AgentService 做同步 RPC
nodeService.SetAgentRPC(agentService)
// 启用远程数据库发现:远程节点任务配置时 DatabasePicker 拿到的是节点视角的 DB 列表
databaseDiscoveryService.SetClusterDependencies(nodeRepo, agentService)
// 恢复服务:集群感知(本地/远程路由),依赖 agentService 入队
restoreService := service.NewRestoreService(
restoreRecordRepo,
backupRecordRepo,
backupTaskRepo,
storageTargetRepo,
nodeRepo,
storageRegistry,
backupRunnerRegistry,
restoreLogHub,
configCipher,
agentService,
cfg.Backup.TempDir,
cfg.Backup.MaxConcurrent,
)
// 验证服务:定期校验备份可恢复性(企业合规刚需)
verificationRecordRepo := repository.NewVerificationRecordRepository(db)
verifyLogHub := backup.NewLogHub()
verificationService := service.NewVerificationService(
verificationRecordRepo,
backupRecordRepo,
backupTaskRepo,
storageTargetRepo,
nodeRepo,
storageRegistry,
verifyLogHub,
configCipher,
cfg.Backup.TempDir,
cfg.Backup.MaxConcurrent,
)
// 验证失败通知:通过 NotificationService 的事件总线派发 verify_failed
verificationService.SetNotifier(service.NewVerificationEventNotifier(notificationService))
// 恢复完成/失败事件派发restore_success / restore_failed
restoreService.SetEventDispatcher(notificationService)
// 调度器接入验证演练 cron
schedulerService.SetVerifyRunner(verificationService)
// 用户管理与 API Key 服务(企业级 RBAC
userService := service.NewUserService(userRepo)
apiKeyRepo := repository.NewApiKeyRepository(db)
apiKeyService := service.NewApiKeyService(apiKeyRepo)
// SLA 后台扫描:每 15 分钟扫描违约任务,同任务 6 小时内不重复派发
dashboardService.StartSLAMonitor(ctx, notificationService, 15*time.Minute, 6*time.Hour)
// 存储目标健康扫描:每 5 分钟测试启用目标,掉线即告警
storageTargetService.StartHealthMonitor(ctx, notificationService, 5*time.Minute)
// 备份复制服务3-2-1 规则核心)
replicationRecordRepo := repository.NewReplicationRecordRepository(db)
replicationService := service.NewReplicationService(
replicationRecordRepo, backupRecordRepo, storageTargetRepo,
nodeRepo, storageRegistry, configCipher,
cfg.Backup.TempDir, cfg.Backup.MaxConcurrent,
)
replicationService.SetEventDispatcher(notificationService)
backupExecutionService.SetReplicationTrigger(replicationService)
// 备份成功后触发下游依赖任务(任务依赖链工作流)
backupExecutionService.SetDependentsResolver(backupTaskService)
// 任务模板(批量创建)
taskTemplateRepo := repository.NewTaskTemplateRepository(db)
taskTemplateService := service.NewTaskTemplateService(taskTemplateRepo, backupTaskService)
// 任务配置导入/导出JSON集群迁移 & 灾备)
taskExportService := service.NewTaskExportService(backupTaskService, backupTaskRepo, storageTargetRepo, nodeRepo)
// 全局搜索(跨任务/存储/节点/最近记录)
searchService := service.NewSearchService(backupTaskRepo, backupRecordRepo, storageTargetRepo, nodeRepo)
// 实时事件广播器SSE 推送给前端 Dashboard
// 注入 notification 后,每次 DispatchEvent 同时 broadcast 到所有 SSE 订阅者
eventBroadcaster := service.NewEventBroadcaster()
notificationService.SetBroadcaster(eventBroadcaster)
// 集群版本监控:每 30 分钟扫描,节点 24 小时内只告警一次
clusterVersionMonitor := service.NewClusterVersionMonitor(nodeRepo, version)
clusterVersionMonitor.SetEventDispatcher(notificationService)
clusterVersionMonitor.Start(ctx, 30*time.Minute, 24*time.Hour)
// Dashboard 集群概览依赖注入
dashboardService.SetClusterDependencies(nodeRepo, version)
// Prometheus 指标采集Counter/Histogram 由业务服务实时写入;
// Gauge 类存储用量、节点在线、SLA 违约)由 Collector 每 30s 异步刷新,
// 避免 /metrics 请求路径做慢 IO。
appMetrics := metrics.New(version)
backupExecutionService.SetMetrics(appMetrics)
restoreService.SetMetrics(appMetrics)
verificationService.SetMetrics(appMetrics)
replicationService.SetMetrics(appMetrics)
metricsCollector := metrics.NewCollector(
appMetrics,
metrics.NewRepoSource(storageTargetRepo, backupRecordRepo, nodeRepo, backupTaskRepo),
30*time.Second,
)
metricsCollector.Start(ctx)
router := aphttp.NewRouter(aphttp.RouterDependencies{
Config: cfg,
Version: version,
Logger: appLogger,
AuthService: authService,
SystemService: systemService,
StorageTargetService: storageTargetService,
BackupTaskService: backupTaskService,
BackupExecutionService: backupExecutionService,
BackupRecordService: backupRecordService,
NotificationService: notificationService,
DashboardService: dashboardService,
SettingsService: settingsService,
Context: ctx,
Config: cfg,
Version: version,
Logger: appLogger,
AuthService: authService,
SystemService: systemService,
StorageTargetService: storageTargetService,
BackupTaskService: backupTaskService,
BackupExecutionService: backupExecutionService,
BackupRecordService: backupRecordService,
RestoreService: restoreService,
VerificationService: verificationService,
ReplicationService: replicationService,
TaskTemplateService: taskTemplateService,
TaskExportService: taskExportService,
SearchService: searchService,
EventBroadcaster: eventBroadcaster,
UserService: userService,
ApiKeyService: apiKeyService,
NotificationService: notificationService,
DashboardService: dashboardService,
SettingsService: settingsService,
NodeService: nodeService,
AgentService: agentService,
DatabaseDiscoveryService: databaseDiscoveryService,
AuditService: auditService,
AuditService: auditService,
JWTManager: jwtManager,
UserRepository: userRepo,
SystemConfigRepo: systemConfigRepo,
UserRepository: userRepo,
SystemConfigRepo: systemConfigRepo,
InstallTokenService: installTokenService,
MasterExternalURL: "", // 如需覆盖 URL可扩展 cfg.Server 增字段;目前留空依赖 X-Forwarded-* / Request.Host
DB: db,
Metrics: appMetrics,
})
httpServer := &stdhttp.Server{

View File

@@ -0,0 +1,119 @@
package backup
import (
"bytes"
"context"
"fmt"
"strings"
"time"
)
// DiscoverRequest 数据库发现请求参数。
// Type 取 "mysql" 或 "postgresql"。
type DiscoverRequest struct {
Type string
Host string
Port int
User string
Password string
}
// DiscoverDatabases 通过本机 mysql/psql 客户端连接目标数据库并列出非系统库。
// 5 秒命令超时。调用方负责传入 CommandExecutorMaster 用 OSCommandExecutor
// Agent 同理)。此函数不依赖 service / apperror便于在 agent 包复用。
func DiscoverDatabases(ctx context.Context, executor CommandExecutor, req DiscoverRequest) ([]string, error) {
switch strings.TrimSpace(strings.ToLower(req.Type)) {
case "mysql":
return discoverMySQLDatabases(ctx, executor, req)
case "postgresql":
return discoverPostgreSQLDatabases(ctx, executor, req)
default:
return nil, fmt.Errorf("unsupported database type: %s", req.Type)
}
}
func discoverMySQLDatabases(ctx context.Context, executor CommandExecutor, req DiscoverRequest) ([]string, error) {
mysqlPath, err := executor.LookPath("mysql")
if err != nil {
return nil, fmt.Errorf("系统未安装 mysql 客户端")
}
timeout, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
var stdout, stderr bytes.Buffer
args := []string{
fmt.Sprintf("--host=%s", req.Host),
fmt.Sprintf("--port=%d", req.Port),
fmt.Sprintf("--user=%s", req.User),
"-e", "SHOW DATABASES",
"--skip-column-names",
}
env := []string{fmt.Sprintf("MYSQL_PWD=%s", req.Password)}
if err := executor.Run(timeout, mysqlPath, args, CommandOptions{
Stdout: &stdout,
Stderr: &stderr,
Env: env,
}); err != nil {
errMsg := strings.TrimSpace(stderr.String())
if errMsg == "" {
errMsg = err.Error()
}
return nil, fmt.Errorf("连接 MySQL 失败:%s", errMsg)
}
systemDBs := map[string]bool{
"information_schema": true,
"performance_schema": true,
"mysql": true,
"sys": true,
}
var databases []string
for _, line := range strings.Split(stdout.String(), "\n") {
db := strings.TrimSpace(line)
if db == "" || systemDBs[db] {
continue
}
databases = append(databases, db)
}
return databases, nil
}
func discoverPostgreSQLDatabases(ctx context.Context, executor CommandExecutor, req DiscoverRequest) ([]string, error) {
psqlPath, err := executor.LookPath("psql")
if err != nil {
return nil, fmt.Errorf("系统未安装 psql 客户端")
}
timeout, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
var stdout, stderr bytes.Buffer
args := []string{
"-h", req.Host,
"-p", fmt.Sprintf("%d", req.Port),
"-U", req.User,
"-d", "postgres",
"-t", "-A",
"-c", "SELECT datname FROM pg_database WHERE datistemplate = false ORDER BY datname",
}
env := []string{fmt.Sprintf("PGPASSWORD=%s", req.Password)}
if err := executor.Run(timeout, psqlPath, args, CommandOptions{
Stdout: &stdout,
Stderr: &stderr,
Env: env,
}); err != nil {
errMsg := strings.TrimSpace(stderr.String())
if errMsg == "" {
errMsg = err.Error()
}
return nil, fmt.Errorf("连接 PostgreSQL 失败:%s", errMsg)
}
skipDBs := map[string]bool{
"postgres": true,
}
var databases []string
for _, line := range strings.Split(stdout.String(), "\n") {
db := strings.TrimSpace(line)
if db == "" || skipDBs[db] || strings.HasPrefix(db, "template") {
continue
}
databases = append(databases, db)
}
return databases, nil
}

View File

@@ -0,0 +1,179 @@
package backup
import (
"archive/tar"
"bufio"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"os"
"strings"
)
// VerifyReport 是 quick 模式的验证结果摘要。
type VerifyReport struct {
TotalEntries int `json:"totalEntries,omitempty"`
FileBytes int64 `json:"fileBytes,omitempty"`
ChecksumOK bool `json:"checksumOk,omitempty"`
Detail string `json:"detail,omitempty"`
}
// VerifyTarArchive 遍历 tar 归档的每个 header + reader不写盘。
// 能检测归档截断、条目损坏、层级不对等常见问题。
// expectedChecksum 非空时额外对整个文件校验 SHA-256不做解压
func VerifyTarArchive(artifactPath string, expectedChecksum string) (*VerifyReport, error) {
file, err := os.Open(artifactPath)
if err != nil {
return nil, fmt.Errorf("open tar artifact: %w", err)
}
defer file.Close()
report := &VerifyReport{}
h := sha256.New()
reader := io.TeeReader(file, h)
tr := tar.NewReader(reader)
for {
header, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
return report, fmt.Errorf("read tar entry: %w", err)
}
report.TotalEntries++
// 读完条目数据以触发完整性校验tar 内部 CRC 不严格,但断流会报错)
if header.Typeflag == tar.TypeReg || header.Typeflag == tar.TypeRegA {
n, copyErr := io.Copy(io.Discard, tr)
if copyErr != nil {
return report, fmt.Errorf("read entry %s: %w", header.Name, copyErr)
}
report.FileBytes += n
}
}
// 读完 tar 后继续把剩余字节喂给 hashtar 结束后可能有零填充尾)
if _, err := io.Copy(io.Discard, reader); err != nil {
return report, fmt.Errorf("drain remainder: %w", err)
}
actual := hex.EncodeToString(h.Sum(nil))
if strings.TrimSpace(expectedChecksum) != "" {
report.ChecksumOK = strings.EqualFold(actual, expectedChecksum)
if !report.ChecksumOK {
return report, fmt.Errorf("checksum mismatch: expected %s, got %s", expectedChecksum, actual)
}
} else {
report.ChecksumOK = true
}
report.Detail = fmt.Sprintf("tar 包完整(%d 条目,有效字节 %d", report.TotalEntries, report.FileBytes)
return report, nil
}
// VerifySQLiteFile 校验 SQLite 文件头魔数。
// 官方格式:前 16 字节为 "SQLite format 3\000"。
func VerifySQLiteFile(artifactPath string) (*VerifyReport, error) {
file, err := os.Open(artifactPath)
if err != nil {
return nil, fmt.Errorf("open sqlite artifact: %w", err)
}
defer file.Close()
header := make([]byte, 16)
if _, err := io.ReadFull(file, header); err != nil {
return nil, fmt.Errorf("read sqlite header: %w", err)
}
const magic = "SQLite format 3\x00"
if string(header) != magic {
return &VerifyReport{Detail: "非法的 SQLite 文件头"}, fmt.Errorf("invalid sqlite magic header")
}
info, _ := file.Stat()
var size int64
if info != nil {
size = info.Size()
}
return &VerifyReport{
FileBytes: size,
Detail: fmt.Sprintf("SQLite 文件头合法(总大小 %d 字节)", size),
}, nil
}
// VerifyMySQLDump 校验 MySQL dump 文件头部是否为合法 mysqldump 输出。
// 头部 1024 字节包含以下任一关键字即通过:
// - "-- MySQL dump"
// - "-- Server version"
// - "-- MariaDB dump"
func VerifyMySQLDump(artifactPath string) (*VerifyReport, error) {
return verifyDumpHeader(artifactPath, []string{"-- MySQL dump", "-- Server version", "-- MariaDB dump"}, "MySQL/MariaDB")
}
// VerifyPostgreSQLDump 校验 PostgreSQL plain text dump 头部。
// 典型标记:"-- PostgreSQL database dump" 或 "-- Dumped from database version"。
func VerifyPostgreSQLDump(artifactPath string) (*VerifyReport, error) {
return verifyDumpHeader(artifactPath, []string{"-- PostgreSQL database dump", "-- Dumped from database version", "SET statement_timeout"}, "PostgreSQL")
}
func verifyDumpHeader(artifactPath string, markers []string, label string) (*VerifyReport, error) {
file, err := os.Open(artifactPath)
if err != nil {
return nil, fmt.Errorf("open dump artifact: %w", err)
}
defer file.Close()
reader := bufio.NewReader(file)
buf := make([]byte, 4096)
n, _ := io.ReadFull(reader, buf)
sample := string(buf[:n])
matched := ""
for _, m := range markers {
if strings.Contains(sample, m) {
matched = m
break
}
}
if matched == "" {
return &VerifyReport{Detail: fmt.Sprintf("未在前 %d 字节中发现 %s dump 特征", n, label)}, fmt.Errorf("no %s dump marker in header", label)
}
info, _ := file.Stat()
var size int64
if info != nil {
size = info.Size()
}
return &VerifyReport{
FileBytes: size,
Detail: fmt.Sprintf("%s dump 头部识别标志: %q文件 %d 字节)", label, matched, size),
}, nil
}
// VerifySAPHANAArchive 校验 SAP HANA 归档 tar 中是否包含 databackup/logbackup 标志文件。
func VerifySAPHANAArchive(artifactPath string) (*VerifyReport, error) {
file, err := os.Open(artifactPath)
if err != nil {
return nil, fmt.Errorf("open hana archive: %w", err)
}
defer file.Close()
tr := tar.NewReader(file)
report := &VerifyReport{}
var foundDataBackup bool
for {
header, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
return report, fmt.Errorf("read tar entry: %w", err)
}
report.TotalEntries++
name := strings.ToLower(header.Name)
if strings.Contains(name, "databackup") || strings.Contains(name, "logbackup") || strings.HasPrefix(name, "hana_") {
foundDataBackup = true
}
if header.Typeflag == tar.TypeReg || header.Typeflag == tar.TypeRegA {
n, copyErr := io.Copy(io.Discard, tr)
if copyErr != nil {
return report, fmt.Errorf("read entry %s: %w", header.Name, copyErr)
}
report.FileBytes += n
}
}
if !foundDataBackup {
return report, fmt.Errorf("HANA archive missing databackup/logbackup markers")
}
report.Detail = fmt.Sprintf("HANA 归档包含 %d 条目(%d 字节),已识别备份标志文件", report.TotalEntries, report.FileBytes)
return report, nil
}

View File

@@ -0,0 +1,121 @@
package backup
import (
"archive/tar"
"bytes"
"os"
"path/filepath"
"testing"
)
// 构造一个最小的 tar 归档文件供测试使用
func writeTestTar(t *testing.T, entries map[string][]byte) string {
t.Helper()
path := filepath.Join(t.TempDir(), "test.tar")
buf := new(bytes.Buffer)
tw := tar.NewWriter(buf)
for name, body := range entries {
header := &tar.Header{Name: name, Mode: 0o644, Size: int64(len(body)), Typeflag: tar.TypeReg}
if err := tw.WriteHeader(header); err != nil {
t.Fatalf("write tar header: %v", err)
}
if _, err := tw.Write(body); err != nil {
t.Fatalf("write tar body: %v", err)
}
}
_ = tw.Close()
if err := os.WriteFile(path, buf.Bytes(), 0o644); err != nil {
t.Fatalf("write tar file: %v", err)
}
return path
}
func TestVerifyTarArchive_Valid(t *testing.T) {
path := writeTestTar(t, map[string][]byte{
"readme.md": []byte("hello"),
"data.bin": []byte("world!!!"),
})
report, err := VerifyTarArchive(path, "")
if err != nil {
t.Fatalf("VerifyTarArchive returned error: %v", err)
}
if report.TotalEntries != 2 {
t.Fatalf("expected 2 entries, got %d", report.TotalEntries)
}
if report.FileBytes == 0 {
t.Fatalf("expected non-zero file bytes")
}
if !report.ChecksumOK {
t.Fatalf("checksumOK should be true when expected checksum empty")
}
}
func TestVerifyTarArchive_Truncated(t *testing.T) {
// 构造带多个大 entry 的 tar在 entry 数据中间截断,使 io.Copy 触发 UnexpectedEOF
path := filepath.Join(t.TempDir(), "big.tar")
buf := new(bytes.Buffer)
tw := tar.NewWriter(buf)
body := bytes.Repeat([]byte("x"), 4096)
_ = tw.WriteHeader(&tar.Header{Name: "big.bin", Mode: 0o644, Size: int64(len(body)), Typeflag: tar.TypeReg})
_, _ = tw.Write(body)
_ = tw.Close()
data := buf.Bytes()
// 保留 header 完整512破坏 body 中间使 tar.Reader 在 io.Copy 时遇到 EOF
truncated := data[:512+1024]
if err := os.WriteFile(path, truncated, 0o644); err != nil {
t.Fatalf("write truncated: %v", err)
}
if _, err := VerifyTarArchive(path, ""); err == nil {
t.Fatalf("expected error on truncated tar, got nil")
}
}
func TestVerifySQLiteFile_Valid(t *testing.T) {
path := filepath.Join(t.TempDir(), "ok.db")
content := []byte("SQLite format 3\x00" + string(make([]byte, 100)))
if err := os.WriteFile(path, content, 0o644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
report, err := VerifySQLiteFile(path)
if err != nil {
t.Fatalf("VerifySQLiteFile: %v", err)
}
if report.FileBytes == 0 {
t.Fatalf("expected non-zero size")
}
}
func TestVerifySQLiteFile_Invalid(t *testing.T) {
path := filepath.Join(t.TempDir(), "bad.db")
if err := os.WriteFile(path, []byte("not sqlite at all, some other text"), 0o644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
if _, err := VerifySQLiteFile(path); err == nil {
t.Fatalf("expected error on non-sqlite file")
}
}
func TestVerifyMySQLDump(t *testing.T) {
path := filepath.Join(t.TempDir(), "dump.sql")
content := "-- MySQL dump 10.13 Distrib 8.0.33\n-- Host: localhost\nINSERT INTO foo VALUES (1);\n"
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
report, err := VerifyMySQLDump(path)
if err != nil {
t.Fatalf("VerifyMySQLDump: %v", err)
}
if report.Detail == "" {
t.Fatalf("expected Detail in report")
}
}
func TestVerifyPostgreSQLDump_Invalid(t *testing.T) {
path := filepath.Join(t.TempDir(), "notpg.sql")
if err := os.WriteFile(path, []byte("some random text without header markers"), 0o644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
if _, err := VerifyPostgreSQLDump(path); err == nil {
t.Fatalf("expected error on non-pg dump")
}
}

View File

@@ -0,0 +1,180 @@
package backup
import (
"fmt"
"strconv"
"strings"
"time"
)
// MaintenanceWindow 描述一个允许执行备份的时段。
// 格式语义:
// - Days 为 "0..6" 的字符串集合0=周日6=周六);空 = 每天
// - StartMinutes / EndMinutes 为"午夜起计算的分钟数"0 ≤ v < 1440
// - 跨午夜窗口Start > End 表示跨夜(如 22:00-06:00
//
// 多个窗口是 OR 语义:只要 now 落入任一窗口即允许执行。
type MaintenanceWindow struct {
Days map[int]bool
StartMinutes int
EndMinutes int
}
// ParseMaintenanceWindows 解析用户配置CSV 每项形如 "days=mon,tue|time=22:00-06:00")。
// 简化语法:多个窗口以 ';' 分隔,每个窗口按 "[days=xxx;]time=HH:MM-HH:MM" 格式。
// Days 缺省 = 全周;若不合法,跳过该段而非抛错(让调用方尽力工作)。
// 示例:
// "time=01:00-05:00" 每天 1 点到 5 点
// "days=sat,sun;time=00:00-23:59" 仅周末全天
// "time=22:00-06:00" 每天跨夜
// "days=mon,tue,wed,thu,fri;time=22:00-06:00" 工作日跨夜
func ParseMaintenanceWindows(value string) []MaintenanceWindow {
v := strings.TrimSpace(value)
if v == "" {
return nil
}
segments := strings.Split(v, ";")
var windows []MaintenanceWindow
for _, segment := range segments {
segment = strings.TrimSpace(segment)
if segment == "" {
continue
}
window, ok := parseSingleWindow(segment)
if !ok {
continue
}
windows = append(windows, window)
}
return windows
}
func parseSingleWindow(segment string) (MaintenanceWindow, bool) {
// "days=xxx,time=HH:MM-HH:MM" 或 "time=..."
fields := strings.Split(segment, ",")
days := map[int]bool{}
var timeExpr string
for _, field := range fields {
field = strings.TrimSpace(field)
if field == "" {
continue
}
if strings.HasPrefix(field, "days=") {
daysPart := strings.TrimPrefix(field, "days=")
for _, day := range strings.Split(daysPart, "|") {
if idx := parseDayToken(strings.TrimSpace(day)); idx >= 0 {
days[idx] = true
}
}
} else if strings.HasPrefix(field, "time=") {
timeExpr = strings.TrimPrefix(field, "time=")
}
}
start, end, ok := parseTimeRange(strings.TrimSpace(timeExpr))
if !ok {
return MaintenanceWindow{}, false
}
return MaintenanceWindow{Days: days, StartMinutes: start, EndMinutes: end}, true
}
var dayTokens = map[string]int{
"sun": 0, "sunday": 0, "0": 0,
"mon": 1, "monday": 1, "1": 1,
"tue": 2, "tuesday": 2, "2": 2,
"wed": 3, "wednesday": 3, "3": 3,
"thu": 4, "thursday": 4, "4": 4,
"fri": 5, "friday": 5, "5": 5,
"sat": 6, "saturday": 6, "6": 6,
}
func parseDayToken(value string) int {
v := strings.ToLower(strings.TrimSpace(value))
if v == "" {
return -1
}
if idx, ok := dayTokens[v]; ok {
return idx
}
return -1
}
// parseTimeRange 解析 "HH:MM-HH:MM",返回起止分钟数。
func parseTimeRange(value string) (int, int, bool) {
parts := strings.SplitN(value, "-", 2)
if len(parts) != 2 {
return 0, 0, false
}
start, ok := parseHHMM(parts[0])
if !ok {
return 0, 0, false
}
end, ok := parseHHMM(parts[1])
if !ok {
return 0, 0, false
}
return start, end, true
}
func parseHHMM(value string) (int, bool) {
parts := strings.Split(strings.TrimSpace(value), ":")
if len(parts) != 2 {
return 0, false
}
h, err := strconv.Atoi(strings.TrimSpace(parts[0]))
if err != nil || h < 0 || h > 23 {
return 0, false
}
m, err := strconv.Atoi(strings.TrimSpace(parts[1]))
if err != nil || m < 0 || m > 59 {
return 0, false
}
return h*60 + m, true
}
// IsWithinWindow 判断 t 是否落入任一窗口。windows 为空或 nil 时总是返回 true不限制
func IsWithinWindow(t time.Time, windows []MaintenanceWindow) bool {
if len(windows) == 0 {
return true
}
minutes := t.Hour()*60 + t.Minute()
weekday := int(t.Weekday())
for _, w := range windows {
if len(w.Days) > 0 && !w.Days[weekday] {
continue
}
if w.StartMinutes == w.EndMinutes {
continue
}
if w.StartMinutes < w.EndMinutes {
// 同日窗口
if minutes >= w.StartMinutes && minutes < w.EndMinutes {
return true
}
} else {
// 跨午夜:[start, 1440) [0, end)
if minutes >= w.StartMinutes || minutes < w.EndMinutes {
return true
}
}
}
return false
}
// ValidateMaintenanceWindows 用户输入合法性校验(返回人可读的错误)。
func ValidateMaintenanceWindows(value string) error {
v := strings.TrimSpace(value)
if v == "" {
return nil
}
segments := strings.Split(v, ";")
for _, segment := range segments {
segment = strings.TrimSpace(segment)
if segment == "" {
continue
}
if _, ok := parseSingleWindow(segment); !ok {
return fmt.Errorf("无效的维护窗口配置: %q期望格式如 time=22:00-06:00 或 days=sat,sun,time=00:00-23:59", segment)
}
}
return nil
}

View File

@@ -0,0 +1,110 @@
package backup
import (
"testing"
"time"
)
func TestParseAndCheck_SingleSameDayWindow(t *testing.T) {
windows := ParseMaintenanceWindows("time=01:00-05:00")
if len(windows) != 1 {
t.Fatalf("expected 1 window, got %d", len(windows))
}
// 周一 03:00 UTC天数不限制
at := time.Date(2026, 4, 20, 3, 0, 0, 0, time.UTC)
if !IsWithinWindow(at, windows) {
t.Fatalf("expected 03:00 to be inside 01:00-05:00")
}
at = time.Date(2026, 4, 20, 6, 0, 0, 0, time.UTC)
if IsWithinWindow(at, windows) {
t.Fatalf("expected 06:00 to be outside 01:00-05:00")
}
}
func TestParseAndCheck_CrossMidnight(t *testing.T) {
windows := ParseMaintenanceWindows("time=22:00-06:00")
if len(windows) != 1 {
t.Fatalf("expected 1 window")
}
tests := []struct {
hour, minute int
inside bool
}{
{22, 30, true},
{23, 59, true},
{0, 0, true},
{3, 0, true},
{5, 59, true},
{6, 0, false},
{7, 0, false},
{21, 59, false},
}
base := time.Date(2026, 4, 20, 0, 0, 0, 0, time.UTC)
for _, tc := range tests {
at := base.Add(time.Duration(tc.hour)*time.Hour + time.Duration(tc.minute)*time.Minute)
if got := IsWithinWindow(at, windows); got != tc.inside {
t.Errorf("%02d:%02d expected inside=%v, got %v", tc.hour, tc.minute, tc.inside, got)
}
}
}
func TestParseAndCheck_DaysFilter(t *testing.T) {
// 周末全天
windows := ParseMaintenanceWindows("days=sat|sun,time=00:00-23:59")
if len(windows) != 1 {
t.Fatalf("expected 1 window")
}
sat := time.Date(2026, 4, 18, 12, 0, 0, 0, time.UTC) // Saturday
sun := time.Date(2026, 4, 19, 12, 0, 0, 0, time.UTC) // Sunday
mon := time.Date(2026, 4, 20, 12, 0, 0, 0, time.UTC) // Monday
if !IsWithinWindow(sat, windows) {
t.Fatalf("saturday should be inside")
}
if !IsWithinWindow(sun, windows) {
t.Fatalf("sunday should be inside")
}
if IsWithinWindow(mon, windows) {
t.Fatalf("monday should be outside")
}
}
func TestParseAndCheck_Multiple(t *testing.T) {
// 两段:工作日跨夜 + 周末全天
windows := ParseMaintenanceWindows("days=mon|tue|wed|thu|fri,time=22:00-06:00;days=sat|sun,time=00:00-23:59")
if len(windows) != 2 {
t.Fatalf("expected 2 windows, got %d", len(windows))
}
monAfternoon := time.Date(2026, 4, 20, 15, 0, 0, 0, time.UTC)
if IsWithinWindow(monAfternoon, windows) {
t.Fatalf("mon 15:00 should be outside both windows")
}
monNight := time.Date(2026, 4, 20, 23, 0, 0, 0, time.UTC)
if !IsWithinWindow(monNight, windows) {
t.Fatalf("mon 23:00 should be inside weekday-night window")
}
sunNoon := time.Date(2026, 4, 19, 12, 0, 0, 0, time.UTC)
if !IsWithinWindow(sunNoon, windows) {
t.Fatalf("sun 12:00 should be inside weekend window")
}
}
func TestValidateMaintenanceWindows(t *testing.T) {
if err := ValidateMaintenanceWindows(""); err != nil {
t.Fatalf("empty should be valid, got %v", err)
}
if err := ValidateMaintenanceWindows("time=01:00-05:00"); err != nil {
t.Fatalf("valid format rejected: %v", err)
}
if err := ValidateMaintenanceWindows("bad-input"); err == nil {
t.Fatalf("invalid format should return error")
}
if err := ValidateMaintenanceWindows("time=25:00-30:00"); err == nil {
t.Fatalf("invalid hour should return error")
}
}
func TestIsWithinWindow_NoWindows(t *testing.T) {
if !IsWithinWindow(time.Now(), nil) {
t.Fatalf("no windows should always be inside")
}
}

View File

@@ -23,7 +23,7 @@ func Open(cfg config.DatabaseConfig, logger *zap.Logger) (*gorm.DB, error) {
return nil, fmt.Errorf("open sqlite: %w", err)
}
if err := db.AutoMigrate(&model.User{}, &model.SystemConfig{}, &model.StorageTarget{}, &model.OAuthSession{}, &model.BackupTask{}, &model.BackupRecord{}, &model.Notification{}, &model.Node{}, &model.BackupTaskStorageTarget{}, &model.AuditLog{}, &model.AgentCommand{}); err != nil {
if err := db.AutoMigrate(&model.User{}, &model.SystemConfig{}, &model.StorageTarget{}, &model.OAuthSession{}, &model.BackupTask{}, &model.BackupRecord{}, &model.Notification{}, &model.Node{}, &model.BackupTaskStorageTarget{}, &model.AuditLog{}, &model.AgentCommand{}, &model.AgentInstallToken{}, &model.RestoreRecord{}, &model.VerificationRecord{}, &model.ApiKey{}, &model.ReplicationRecord{}, &model.TaskTemplate{}); err != nil {
return nil, fmt.Errorf("migrate schema: %w", err)
}

View File

@@ -14,12 +14,13 @@ import (
// AgentHandler 实现 Agent 调用 Master 的 HTTP API。
// 全部端点通过 X-Agent-Token 头做节点认证,不使用 JWT。
type AgentHandler struct {
agentService *service.AgentService
nodeService *service.NodeService
agentService *service.AgentService
nodeService *service.NodeService
restoreService *service.RestoreService
}
func NewAgentHandler(agentService *service.AgentService, nodeService *service.NodeService) *AgentHandler {
return &AgentHandler{agentService: agentService, nodeService: nodeService}
func NewAgentHandler(agentService *service.AgentService, nodeService *service.NodeService, restoreService *service.RestoreService) *AgentHandler {
return &AgentHandler{agentService: agentService, nodeService: nodeService, restoreService: restoreService}
}
// extractToken 从请求头或 JSON body 中提取 Agent Token。
@@ -154,3 +155,70 @@ func (h *AgentHandler) UpdateRecord(c *gin.Context) {
}
response.Success(c, gin.H{"status": "ok"})
}
// GetRestoreSpec Agent 拉取恢复规格。
func (h *AgentHandler) GetRestoreSpec(c *gin.Context) {
if h.restoreService == nil {
c.JSON(stdhttp.StatusServiceUnavailable, gin.H{"code": "RESTORE_SERVICE_DISABLED", "message": "restore service is not enabled"})
return
}
node, err := h.agentService.AuthenticatedNode(c.Request.Context(), extractToken(c))
if err != nil {
response.Error(c, err)
return
}
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
response.Error(c, err)
return
}
spec, err := h.restoreService.GetAgentRestoreSpec(c.Request.Context(), node, uint(id))
if err != nil {
response.Error(c, err)
return
}
response.Success(c, spec)
}
// UpdateRestore Agent 上报恢复记录的状态/日志。
func (h *AgentHandler) UpdateRestore(c *gin.Context) {
if h.restoreService == nil {
c.JSON(stdhttp.StatusServiceUnavailable, gin.H{"code": "RESTORE_SERVICE_DISABLED", "message": "restore service is not enabled"})
return
}
node, err := h.agentService.AuthenticatedNode(c.Request.Context(), extractToken(c))
if err != nil {
response.Error(c, err)
return
}
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
response.Error(c, err)
return
}
var input service.AgentRestoreUpdate
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(stdhttp.StatusBadRequest, gin.H{"code": "INVALID_INPUT", "message": err.Error()})
return
}
if err := h.restoreService.UpdateAgentRestore(c.Request.Context(), node, uint(id), input); err != nil {
response.Error(c, err)
return
}
response.Success(c, gin.H{"status": "ok"})
}
// Self 返回当前 Agent token 所属节点的状态,供安装脚本末尾探活。
func (h *AgentHandler) Self(c *gin.Context) {
node, err := h.agentService.AuthenticatedNode(c.Request.Context(), extractToken(c))
if err != nil {
response.Error(c, err)
return
}
status, err := h.agentService.SelfStatus(c.Request.Context(), node)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, status)
}

View File

@@ -0,0 +1,93 @@
package http
import (
"fmt"
"backupx/server/internal/apperror"
"backupx/server/internal/service"
"backupx/server/pkg/response"
"github.com/gin-gonic/gin"
)
// ApiKeyHandler 管理 API Keyadmin 专属)。
type ApiKeyHandler struct {
service *service.ApiKeyService
auditService *service.AuditService
}
func NewApiKeyHandler(apiKeyService *service.ApiKeyService, auditService *service.AuditService) *ApiKeyHandler {
return &ApiKeyHandler{service: apiKeyService, auditService: auditService}
}
func (h *ApiKeyHandler) List(c *gin.Context) {
items, err := h.service.List(c.Request.Context())
if err != nil {
response.Error(c, err)
return
}
response.Success(c, items)
}
func (h *ApiKeyHandler) Create(c *gin.Context) {
var input service.ApiKeyCreateInput
if err := c.ShouldBindJSON(&input); err != nil {
response.Error(c, apperror.BadRequest("API_KEY_INVALID", "API Key 参数不合法", err))
return
}
creator := ""
if username, exists := c.Get(contextUsernameKey); exists {
if v, ok := username.(string); ok {
creator = v
}
}
result, err := h.service.Create(c.Request.Context(), creator, input)
if err != nil {
response.Error(c, err)
return
}
recordAudit(c, h.auditService, "api_key", "create", "api_key", fmt.Sprintf("%d", result.ApiKey.ID), result.ApiKey.Name,
fmt.Sprintf("创建 API Key: %s (角色: %s)", result.ApiKey.Name, result.ApiKey.Role))
response.Success(c, result)
}
func (h *ApiKeyHandler) Revoke(c *gin.Context) {
id, ok := parseUintParam(c, "id")
if !ok {
return
}
if err := h.service.Revoke(c.Request.Context(), id); err != nil {
response.Error(c, err)
return
}
recordAudit(c, h.auditService, "api_key", "revoke", "api_key", fmt.Sprintf("%d", id), "",
fmt.Sprintf("撤销 API Key (ID: %d)", id))
response.Success(c, gin.H{"revoked": true})
}
func (h *ApiKeyHandler) Toggle(c *gin.Context) {
id, ok := parseUintParam(c, "id")
if !ok {
return
}
var input struct {
Disabled bool `json:"disabled"`
}
if err := c.ShouldBindJSON(&input); err != nil {
response.Error(c, apperror.BadRequest("API_KEY_INVALID", "参数不合法", err))
return
}
if err := h.service.ToggleDisabled(c.Request.Context(), id, input.Disabled); err != nil {
response.Error(c, err)
return
}
action := "enable"
label := "启用"
if input.Disabled {
action = "disable"
label = "停用"
}
recordAudit(c, h.auditService, "api_key", action, "api_key", fmt.Sprintf("%d", id), "",
fmt.Sprintf("%s API Key (ID: %d)", label, id))
response.Success(c, gin.H{"disabled": input.Disabled})
}

View File

@@ -1,11 +1,18 @@
package http
import (
"encoding/csv"
"fmt"
stdhttp "net/http"
"strconv"
"strings"
"time"
"backupx/server/internal/apperror"
"backupx/server/internal/repository"
"backupx/server/internal/service"
"backupx/server/pkg/response"
"github.com/gin-gonic/gin"
)
@@ -17,24 +24,97 @@ func NewAuditHandler(auditService *service.AuditService) *AuditHandler {
return &AuditHandler{auditService: auditService}
}
// List 多字段筛选分页查询审计日志。
// 支持参数category, action, username, targetId, keyword, dateFrom, dateTo, limit, offset。
// 向后兼容:若仅传 category + limit + offset行为与旧版一致。
func (h *AuditHandler) List(c *gin.Context) {
category := strings.TrimSpace(c.Query("category"))
limit := 50
offset := 0
if v := strings.TrimSpace(c.Query("limit")); v != "" {
if parsed, err := strconv.Atoi(v); err == nil && parsed > 0 {
limit = parsed
}
opts, err := parseAuditFilter(c)
if err != nil {
response.Error(c, err)
return
}
if v := strings.TrimSpace(c.Query("offset")); v != "" {
if parsed, err := strconv.Atoi(v); err == nil && parsed >= 0 {
offset = parsed
}
}
result, err := h.auditService.List(c.Request.Context(), category, limit, offset)
result, err := h.auditService.ListAdvanced(c.Request.Context(), opts)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, result)
}
// Export 导出 CSV。同筛选参数最多 10000 行。
// 文件名带时间戳避免浏览器缓存覆盖。
func (h *AuditHandler) Export(c *gin.Context) {
opts, err := parseAuditFilter(c)
if err != nil {
response.Error(c, err)
return
}
// 导出不分页:覆盖掉 List 的默认 limit
opts.Limit = 0
opts.Offset = 0
items, err := h.auditService.ExportAll(c.Request.Context(), opts)
if err != nil {
response.Error(c, err)
return
}
filename := fmt.Sprintf("backupx-audit-%s.csv", time.Now().UTC().Format("20060102-150405"))
c.Header("Content-Type", "text/csv; charset=utf-8")
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename))
// UTF-8 BOM 让 Excel 正确识别中文
_, _ = c.Writer.Write([]byte{0xEF, 0xBB, 0xBF})
writer := csv.NewWriter(c.Writer)
_ = writer.Write([]string{"时间", "用户", "类别", "动作", "目标类型", "目标 ID", "目标名", "详情", "客户端 IP"})
for _, item := range items {
_ = writer.Write([]string{
item.CreatedAt.UTC().Format(time.RFC3339),
item.Username,
item.Category,
item.Action,
item.TargetType,
item.TargetID,
item.TargetName,
item.Detail,
item.ClientIP,
})
}
writer.Flush()
if err := writer.Error(); err != nil {
c.Writer.WriteHeader(stdhttp.StatusInternalServerError)
}
}
// parseAuditFilter 解析查询参数为 repository 选项。
func parseAuditFilter(c *gin.Context) (repository.AuditLogListOptions, error) {
opts := repository.AuditLogListOptions{
Category: strings.TrimSpace(c.Query("category")),
Action: strings.TrimSpace(c.Query("action")),
Username: strings.TrimSpace(c.Query("username")),
TargetID: strings.TrimSpace(c.Query("targetId")),
Keyword: strings.TrimSpace(c.Query("keyword")),
}
if v := strings.TrimSpace(c.Query("limit")); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
opts.Limit = n
}
}
if v := strings.TrimSpace(c.Query("offset")); v != "" {
if n, err := strconv.Atoi(v); err == nil && n >= 0 {
opts.Offset = n
}
}
if v := strings.TrimSpace(c.Query("dateFrom")); v != "" {
parsed, err := time.Parse(time.RFC3339, v)
if err != nil {
return opts, apperror.BadRequest("AUDIT_FILTER_INVALID", "dateFrom 必须为 RFC3339 时间格式", err)
}
opts.DateFrom = &parsed
}
if v := strings.TrimSpace(c.Query("dateTo")); v != "" {
parsed, err := time.Parse(time.RFC3339, v)
if err != nil {
return opts, apperror.BadRequest("AUDIT_FILTER_INVALID", "dateTo 必须为 RFC3339 时间格式", err)
}
opts.DateTo = &parsed
}
return opts, nil
}

View File

@@ -1,12 +1,23 @@
package http
import (
"net"
stdhttp "net/http"
"strings"
"time"
"backupx/server/internal/apperror"
"backupx/server/internal/service"
"backupx/server/pkg/response"
"github.com/gin-gonic/gin"
)
const (
trustedDeviceCookieName = "backupx_trusted_device"
trustedDeviceCookiePath = "/api/auth"
trustedDeviceCookieMaxAge = int((30 * 24 * time.Hour) / time.Second)
)
type AuthHandler struct {
authService *service.AuthService
}
@@ -44,11 +55,18 @@ func (h *AuthHandler) Login(c *gin.Context) {
response.Error(c, apperror.BadRequest("AUTH_LOGIN_INVALID", "登录参数不合法", err))
return
}
if strings.TrimSpace(input.TrustedDeviceToken) == "" {
input.TrustedDeviceToken = trustedDeviceCookieValue(c)
}
payload, err := h.authService.Login(c.Request.Context(), input, ClientKey(c))
if err != nil {
response.Error(c, err)
return
}
if payload.TrustedDeviceToken != "" {
setTrustedDeviceCookie(c, payload.TrustedDeviceToken)
payload.TrustedDeviceToken = ""
}
response.Success(c, payload)
}
@@ -83,9 +101,315 @@ func (h *AuthHandler) ChangePassword(c *gin.Context) {
response.Error(c, err)
return
}
clearTrustedDeviceCookie(c)
response.Success(c, gin.H{"changed": true})
}
func (h *AuthHandler) PrepareTwoFactor(c *gin.Context) {
subjectValue, _ := c.Get(contextUserSubjectKey)
subject, err := service.SubjectFromContextValue(subjectValue)
if err != nil {
response.Error(c, apperror.Unauthorized("AUTH_INVALID_SUBJECT", "无效登录态", err))
return
}
var input service.TwoFactorSetupInput
if err := c.ShouldBindJSON(&input); err != nil {
response.Error(c, apperror.BadRequest("AUTH_2FA_INVALID", "参数不合法", err))
return
}
payload, err := h.authService.PrepareTwoFactor(c.Request.Context(), subject, input)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, payload)
}
func (h *AuthHandler) EnableTwoFactor(c *gin.Context) {
subjectValue, _ := c.Get(contextUserSubjectKey)
subject, err := service.SubjectFromContextValue(subjectValue)
if err != nil {
response.Error(c, apperror.Unauthorized("AUTH_INVALID_SUBJECT", "无效登录态", err))
return
}
var input service.EnableTwoFactorInput
if err := c.ShouldBindJSON(&input); err != nil {
response.Error(c, apperror.BadRequest("AUTH_2FA_INVALID", "参数不合法", err))
return
}
user, err := h.authService.EnableTwoFactor(c.Request.Context(), subject, input)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, user)
}
func (h *AuthHandler) DisableTwoFactor(c *gin.Context) {
subjectValue, _ := c.Get(contextUserSubjectKey)
subject, err := service.SubjectFromContextValue(subjectValue)
if err != nil {
response.Error(c, apperror.Unauthorized("AUTH_INVALID_SUBJECT", "无效登录态", err))
return
}
var input service.DisableTwoFactorInput
if err := c.ShouldBindJSON(&input); err != nil {
response.Error(c, apperror.BadRequest("AUTH_2FA_INVALID", "参数不合法", err))
return
}
user, err := h.authService.DisableTwoFactor(c.Request.Context(), subject, input)
if err != nil {
response.Error(c, err)
return
}
if !user.MFAEnabled {
clearTrustedDeviceCookie(c)
}
response.Success(c, user)
}
func (h *AuthHandler) RegenerateRecoveryCodes(c *gin.Context) {
subjectValue, _ := c.Get(contextUserSubjectKey)
subject, err := service.SubjectFromContextValue(subjectValue)
if err != nil {
response.Error(c, apperror.Unauthorized("AUTH_INVALID_SUBJECT", "无效登录态", err))
return
}
var input service.RegenerateRecoveryCodesInput
if err := c.ShouldBindJSON(&input); err != nil {
response.Error(c, apperror.BadRequest("AUTH_2FA_INVALID", "参数不合法", err))
return
}
payload, err := h.authService.RegenerateRecoveryCodes(c.Request.Context(), subject, input)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, payload)
}
func (h *AuthHandler) ConfigureOTP(c *gin.Context) {
subjectValue, _ := c.Get(contextUserSubjectKey)
subject, err := service.SubjectFromContextValue(subjectValue)
if err != nil {
response.Error(c, apperror.Unauthorized("AUTH_INVALID_SUBJECT", "无效登录态", err))
return
}
var input service.OTPConfigInput
if err := c.ShouldBindJSON(&input); err != nil {
response.Error(c, apperror.BadRequest("AUTH_OTP_INVALID", "参数不合法", err))
return
}
user, err := h.authService.ConfigureOutOfBandOTP(c.Request.Context(), subject, input)
if err != nil {
response.Error(c, err)
return
}
if !user.MFAEnabled {
clearTrustedDeviceCookie(c)
}
response.Success(c, user)
}
func (h *AuthHandler) SendLoginOTP(c *gin.Context) {
var input service.LoginOTPInput
if err := c.ShouldBindJSON(&input); err != nil {
response.Error(c, apperror.BadRequest("AUTH_OTP_INVALID", "参数不合法", err))
return
}
if err := h.authService.SendLoginOTP(c.Request.Context(), input, ClientKey(c)); err != nil {
response.Error(c, err)
return
}
response.Success(c, gin.H{"sent": true})
}
func (h *AuthHandler) BeginWebAuthnRegistration(c *gin.Context) {
subjectValue, _ := c.Get(contextUserSubjectKey)
subject, err := service.SubjectFromContextValue(subjectValue)
if err != nil {
response.Error(c, apperror.Unauthorized("AUTH_INVALID_SUBJECT", "无效登录态", err))
return
}
var input service.WebAuthnRegistrationOptionsInput
if err := c.ShouldBindJSON(&input); err != nil {
response.Error(c, apperror.BadRequest("AUTH_WEBAUTHN_INVALID", "参数不合法", err))
return
}
options, err := h.authService.BeginWebAuthnRegistration(c.Request.Context(), subject, input, webAuthnRequestContext(c))
if err != nil {
response.Error(c, err)
return
}
response.Success(c, options)
}
func (h *AuthHandler) FinishWebAuthnRegistration(c *gin.Context) {
subjectValue, _ := c.Get(contextUserSubjectKey)
subject, err := service.SubjectFromContextValue(subjectValue)
if err != nil {
response.Error(c, apperror.Unauthorized("AUTH_INVALID_SUBJECT", "无效登录态", err))
return
}
var input service.WebAuthnRegistrationFinishInput
if err := c.ShouldBindJSON(&input); err != nil {
response.Error(c, apperror.BadRequest("AUTH_WEBAUTHN_INVALID", "参数不合法", err))
return
}
user, err := h.authService.FinishWebAuthnRegistration(c.Request.Context(), subject, input)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, user)
}
func (h *AuthHandler) BeginWebAuthnLogin(c *gin.Context) {
var input service.WebAuthnLoginOptionsInput
if err := c.ShouldBindJSON(&input); err != nil {
response.Error(c, apperror.BadRequest("AUTH_WEBAUTHN_INVALID", "参数不合法", err))
return
}
options, err := h.authService.BeginWebAuthnLogin(c.Request.Context(), input, webAuthnRequestContext(c), ClientKey(c))
if err != nil {
response.Error(c, err)
return
}
response.Success(c, options)
}
func (h *AuthHandler) ListWebAuthnCredentials(c *gin.Context) {
subjectValue, _ := c.Get(contextUserSubjectKey)
subject, err := service.SubjectFromContextValue(subjectValue)
if err != nil {
response.Error(c, apperror.Unauthorized("AUTH_INVALID_SUBJECT", "无效登录态", err))
return
}
items, err := h.authService.ListWebAuthnCredentials(c.Request.Context(), subject)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, items)
}
func (h *AuthHandler) DeleteWebAuthnCredential(c *gin.Context) {
subjectValue, _ := c.Get(contextUserSubjectKey)
subject, err := service.SubjectFromContextValue(subjectValue)
if err != nil {
response.Error(c, apperror.Unauthorized("AUTH_INVALID_SUBJECT", "无效登录态", err))
return
}
var input service.WebAuthnCredentialDeleteInput
if err := c.ShouldBindJSON(&input); err != nil {
response.Error(c, apperror.BadRequest("AUTH_WEBAUTHN_INVALID", "参数不合法", err))
return
}
user, err := h.authService.DeleteWebAuthnCredential(c.Request.Context(), subject, c.Param("id"), input)
if err != nil {
response.Error(c, err)
return
}
if !user.MFAEnabled {
clearTrustedDeviceCookie(c)
}
response.Success(c, user)
}
func (h *AuthHandler) ListTrustedDevices(c *gin.Context) {
subjectValue, _ := c.Get(contextUserSubjectKey)
subject, err := service.SubjectFromContextValue(subjectValue)
if err != nil {
response.Error(c, apperror.Unauthorized("AUTH_INVALID_SUBJECT", "无效登录态", err))
return
}
items, err := h.authService.ListTrustedDevices(c.Request.Context(), subject)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, items)
}
func (h *AuthHandler) RevokeTrustedDevice(c *gin.Context) {
subjectValue, _ := c.Get(contextUserSubjectKey)
subject, err := service.SubjectFromContextValue(subjectValue)
if err != nil {
response.Error(c, apperror.Unauthorized("AUTH_INVALID_SUBJECT", "无效登录态", err))
return
}
var input service.TrustedDeviceRevokeInput
if err := c.ShouldBindJSON(&input); err != nil {
response.Error(c, apperror.BadRequest("AUTH_TRUSTED_DEVICE_INVALID", "参数不合法", err))
return
}
if err := h.authService.RevokeTrustedDevice(c.Request.Context(), subject, c.Param("id"), input); err != nil {
response.Error(c, err)
return
}
clearTrustedDeviceCookie(c)
response.Success(c, gin.H{"deleted": true})
}
func (h *AuthHandler) Logout(c *gin.Context) {
response.Success(c, gin.H{"loggedOut": true})
}
func webAuthnRequestContext(c *gin.Context) service.WebAuthnRequestContext {
host := firstForwardedValue(c.Request.Host)
if forwardedHost := firstForwardedValue(c.GetHeader("X-Forwarded-Host")); forwardedHost != "" {
host = forwardedHost
}
rpID := host
if parsedHost, _, err := net.SplitHostPort(host); err == nil {
rpID = parsedHost
}
scheme := "http"
if c.Request.TLS != nil {
scheme = "https"
}
if forwardedProto := firstForwardedValue(c.GetHeader("X-Forwarded-Proto")); forwardedProto != "" {
scheme = forwardedProto
}
origin := strings.TrimSpace(c.GetHeader("Origin"))
if origin == "" {
origin = scheme + "://" + host
}
return service.WebAuthnRequestContext{RPID: rpID, Origin: origin}
}
func firstForwardedValue(value string) string {
parts := strings.Split(value, ",")
if len(parts) == 0 {
return ""
}
return strings.TrimSpace(parts[0])
}
func trustedDeviceCookieValue(c *gin.Context) string {
token, err := c.Cookie(trustedDeviceCookieName)
if err != nil {
return ""
}
return strings.TrimSpace(token)
}
func setTrustedDeviceCookie(c *gin.Context, token string) {
writeTrustedDeviceCookie(c, strings.TrimSpace(token), trustedDeviceCookieMaxAge)
}
func clearTrustedDeviceCookie(c *gin.Context) {
writeTrustedDeviceCookie(c, "", -1)
}
func writeTrustedDeviceCookie(c *gin.Context, value string, maxAge int) {
c.SetSameSite(stdhttp.SameSiteLaxMode)
c.SetCookie(trustedDeviceCookieName, value, maxAge, trustedDeviceCookiePath, "", requestIsSecure(c), true)
}
func requestIsSecure(c *gin.Context) bool {
if c.Request.TLS != nil {
return true
}
return strings.EqualFold(firstForwardedValue(c.GetHeader("X-Forwarded-Proto")), "https")
}

View File

@@ -16,12 +16,13 @@ import (
)
type BackupRecordHandler struct {
service *service.BackupRecordService
auditService *service.AuditService
service *service.BackupRecordService
restoreService *service.RestoreService
auditService *service.AuditService
}
func NewBackupRecordHandler(recordService *service.BackupRecordService, auditService *service.AuditService) *BackupRecordHandler {
return &BackupRecordHandler{service: recordService, auditService: auditService}
func NewBackupRecordHandler(recordService *service.BackupRecordService, restoreService *service.RestoreService, auditService *service.AuditService) *BackupRecordHandler {
return &BackupRecordHandler{service: recordService, restoreService: restoreService, auditService: auditService}
}
func (h *BackupRecordHandler) List(c *gin.Context) {
@@ -121,18 +122,29 @@ func (h *BackupRecordHandler) Download(c *gin.Context) {
_, _ = io.Copy(c.Writer, result.Reader)
}
// Restore 启动一次异步恢复并返回 restoreRecordId实际执行路由由 RestoreService
// 根据 task.NodeID 决定(本地 Master or 远程 Agent
func (h *BackupRecordHandler) Restore(c *gin.Context) {
id, ok := parseUintParam(c, "id")
if !ok {
return
}
if err := h.service.Restore(c.Request.Context(), id); err != nil {
if h.restoreService == nil {
response.Error(c, apperror.Internal("RESTORE_SERVICE_DISABLED", "恢复服务未启用", nil))
return
}
triggeredBy := ""
if subject, exists := c.Get(contextUserSubjectKey); exists {
triggeredBy = strings.TrimSpace(fmt.Sprintf("%v", subject))
}
detail, err := h.restoreService.Start(c.Request.Context(), id, triggeredBy)
if err != nil {
response.Error(c, err)
return
}
recordAudit(c, h.auditService, "backup_record", "restore", "backup_record", fmt.Sprintf("%d", id), "",
fmt.Sprintf("恢复备份记录 (ID: %d)", id))
response.Success(c, gin.H{"restored": true})
fmt.Sprintf("启动恢复 (备份记录 ID: %d, 恢复记录 ID: %d)", id, detail.ID))
response.Success(c, detail)
}
func (h *BackupRecordHandler) Delete(c *gin.Context) {

View File

@@ -3,6 +3,7 @@ package http
import (
"fmt"
"backupx/server/internal/apperror"
"backupx/server/internal/service"
"backupx/server/pkg/response"
"github.com/gin-gonic/gin"
@@ -30,3 +31,37 @@ func (h *BackupRunHandler) Run(c *gin.Context) {
recordAudit(c, h.auditService, "backup_task", "run", "backup_task", fmt.Sprintf("%d", id), "", "手动触发备份")
response.Success(c, record)
}
// BatchRun 批量触发备份任务。best-effort单个失败不影响其他。
// Body: {"ids": [1,2,3]}
func (h *BackupRunHandler) BatchRun(c *gin.Context) {
var input struct {
IDs []uint `json:"ids" binding:"required,min=1"`
}
if err := c.ShouldBindJSON(&input); err != nil {
response.Error(c, apperror.BadRequest("BACKUP_TASK_BATCH_INVALID", "批量执行参数不合法", err))
return
}
results := make([]service.BatchResult, 0, len(input.IDs))
succ := 0
for _, id := range input.IDs {
if id == 0 {
continue
}
_, err := h.service.RunTaskByID(c.Request.Context(), id)
item := service.BatchResult{ID: id, Success: err == nil}
if err != nil {
if appErr, ok := err.(*apperror.AppError); ok {
item.Error = appErr.Message
} else {
item.Error = err.Error()
}
} else {
succ++
}
results = append(results, item)
}
recordAudit(c, h.auditService, "backup_task", "batch_run", "backup_task", "", "",
fmt.Sprintf("批量触发备份 %d/%d", succ, len(results)))
response.Success(c, results)
}

View File

@@ -40,6 +40,16 @@ func (h *BackupTaskHandler) List(c *gin.Context) {
response.Success(c, items)
}
// ListTags 返回系统内所有任务用过的唯一标签列表,供前端标签选择器的建议词。
func (h *BackupTaskHandler) ListTags(c *gin.Context) {
tags, err := h.service.ListTags(c.Request.Context())
if err != nil {
response.Error(c, err)
return
}
response.Success(c, tags)
}
func (h *BackupTaskHandler) Get(c *gin.Context) {
id, ok := parseUintParam(c, "id")
if !ok {
@@ -106,6 +116,55 @@ func (h *BackupTaskHandler) Delete(c *gin.Context) {
response.Success(c, gin.H{"deleted": true})
}
// BatchToggle / BatchDelete 批量操作。
// Body: {"ids": [1,2,3], "enabled": true} (enabled 仅 toggle 用)
func (h *BackupTaskHandler) BatchToggle(c *gin.Context) {
var input struct {
IDs []uint `json:"ids" binding:"required,min=1"`
Enabled bool `json:"enabled"`
}
if err := c.ShouldBindJSON(&input); err != nil {
response.Error(c, apperror.BadRequest("BACKUP_TASK_BATCH_INVALID", "批量操作参数不合法", err))
return
}
results := h.service.BatchToggle(c.Request.Context(), input.IDs, input.Enabled)
succ := 0
for _, r := range results {
if r.Success {
succ++
}
}
action := "batch_enable"
label := "启用"
if !input.Enabled {
action = "batch_disable"
label = "停用"
}
recordAudit(c, h.auditService, "backup_task", action, "backup_task", "", "",
fmt.Sprintf("批量%s %d/%d 个任务", label, succ, len(results)))
response.Success(c, results)
}
func (h *BackupTaskHandler) BatchDelete(c *gin.Context) {
var input struct {
IDs []uint `json:"ids" binding:"required,min=1"`
}
if err := c.ShouldBindJSON(&input); err != nil {
response.Error(c, apperror.BadRequest("BACKUP_TASK_BATCH_INVALID", "批量删除参数不合法", err))
return
}
results := h.service.BatchDeleteTasks(c.Request.Context(), input.IDs)
succ := 0
for _, r := range results {
if r.Success {
succ++
}
}
recordAudit(c, h.auditService, "backup_task", "batch_delete", "backup_task", "", "",
fmt.Sprintf("批量删除 %d/%d 个任务", succ, len(results)))
response.Success(c, results)
}
func (h *BackupTaskHandler) Toggle(c *gin.Context) {
id, ok := parseUintParam(c, "id")
if !ok {

View File

@@ -1,3 +1,9 @@
package http
const contextUserSubjectKey = "userSubject"
const (
contextUserSubjectKey = "userSubject"
contextUserRoleKey = "userRole"
contextUsernameKey = "username"
// contextAuthSubjectKey 标识认证主体来源user | api_key便于审计追踪。
contextAuthSubjectKey = "authSubject"
)

View File

@@ -27,6 +27,58 @@ func (h *DashboardHandler) Stats(c *gin.Context) {
response.Success(c, payload)
}
// SLA 返回所有启用任务的 SLA 合规视图。用于 Dashboard 企业合规卡片。
func (h *DashboardHandler) SLA(c *gin.Context) {
payload, err := h.service.SLACompliance(c.Request.Context())
if err != nil {
response.Error(c, err)
return
}
response.Success(c, payload)
}
// Cluster 返回集群节点概览(在线/离线/过期 Agent 等),用于 Dashboard 卡片。
func (h *DashboardHandler) Cluster(c *gin.Context) {
payload, err := h.service.ClusterOverview(c.Request.Context())
if err != nil {
response.Error(c, err)
return
}
response.Success(c, payload)
}
// NodePerformance 返回各节点近 N 天的执行表现(成功率/字节数/平均耗时)。
func (h *DashboardHandler) NodePerformance(c *gin.Context) {
days := 30
if v := strings.TrimSpace(c.Query("days")); v != "" {
if parsed, err := strconv.Atoi(v); err == nil && parsed > 0 {
days = parsed
}
}
payload, err := h.service.NodePerformance(c.Request.Context(), days)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, payload)
}
// Breakdown 返回按类型/状态/节点/存储分组的统计。
func (h *DashboardHandler) Breakdown(c *gin.Context) {
days := 30
if v := strings.TrimSpace(c.Query("days")); v != "" {
if parsed, err := strconv.Atoi(v); err == nil && parsed > 0 {
days = parsed
}
}
payload, err := h.service.Breakdown(c.Request.Context(), days)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, payload)
}
func (h *DashboardHandler) Timeline(c *gin.Context) {
days := 30
if value := strings.TrimSpace(c.Query("days")); value != "" {

View File

@@ -0,0 +1,81 @@
package http
import (
"encoding/json"
"fmt"
"io"
"time"
"backupx/server/internal/apperror"
"backupx/server/internal/service"
"backupx/server/pkg/response"
"github.com/gin-gonic/gin"
)
// EventsHandler 实时事件推送SSE
// 前端通过 EventSource 订阅 /api/events/stream实时接收系统事件
// 用于 Dashboard 免刷新更新 / 桌面 Toast / 实时告警。
type EventsHandler struct {
broadcaster *service.EventBroadcaster
}
func NewEventsHandler(broadcaster *service.EventBroadcaster) *EventsHandler {
return &EventsHandler{broadcaster: broadcaster}
}
// Stream SSE 长连接。JWT/API Key 中间件之后。
// 心跳:每 25s 发一条 comment 行(: keepalive保持连接不被代理断开。
func (h *EventsHandler) Stream(c *gin.Context) {
if h.broadcaster == nil {
response.Error(c, apperror.Internal("EVENTS_DISABLED", "事件广播器未启用", nil))
return
}
c.Writer.Header().Set("Content-Type", "text/event-stream")
c.Writer.Header().Set("Cache-Control", "no-cache")
c.Writer.Header().Set("Connection", "keep-alive")
c.Writer.Header().Set("X-Accel-Buffering", "no") // 禁用 nginx 缓冲
flusher, ok := c.Writer.(interface{ Flush() })
if !ok {
response.Error(c, apperror.Internal("EVENTS_STREAM_UNSUPPORTED", "当前连接不支持 SSE", nil))
return
}
// 首先发送一次 hello 让客户端确认连通
_, _ = fmt.Fprintf(c.Writer, ": connected %d\n\n", time.Now().Unix())
flusher.Flush()
ch, cancel := h.broadcaster.Subscribe(32)
defer cancel()
heartbeat := time.NewTicker(25 * time.Second)
defer heartbeat.Stop()
for {
select {
case <-c.Request.Context().Done():
return
case <-heartbeat.C:
if _, err := fmt.Fprintf(c.Writer, ": heartbeat %d\n\n", time.Now().Unix()); err != nil {
return
}
flusher.Flush()
case envelope, ok := <-ch:
if !ok {
return
}
if err := writeEventEnvelope(c.Writer, envelope); err != nil {
return
}
flusher.Flush()
}
}
}
func writeEventEnvelope(writer io.Writer, envelope service.EventEnvelope) error {
data, err := json.Marshal(envelope)
if err != nil {
return err
}
_, err = fmt.Fprintf(writer, "event: %s\ndata: %s\n\n", envelope.Type, data)
return err
}

View File

@@ -0,0 +1,75 @@
package http
import (
stdhttp "net/http"
"time"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// HealthHandler 提供 K8s/Swarm 风格的健康检查端点。
//
// - /health liveness 探针。进程存活即 200不检查任何依赖
// - /ready readiness 探针。检查数据库连通,不通则返回 503。
//
// 两者均为公开端点(无认证中间件),供外部编排系统探测。
// 输出最少信息,避免泄露内部结构。
type HealthHandler struct {
db *gorm.DB
startedAt time.Time
version string
}
func NewHealthHandler(db *gorm.DB, version string) *HealthHandler {
return &HealthHandler{db: db, startedAt: time.Now().UTC(), version: version}
}
// Live 用于 liveness只要进程能响应就返回 200。
func (h *HealthHandler) Live(c *gin.Context) {
c.JSON(stdhttp.StatusOK, gin.H{
"status": "live",
"version": h.version,
"uptime": int(time.Since(h.startedAt).Seconds()),
"timestamp": time.Now().UTC().Format(time.RFC3339),
})
}
// Ready 用于 readiness依赖数据库不可用时返回 503。
// 新实例启动或数据库短暂失联时,编排系统据此停止转发流量。
func (h *HealthHandler) Ready(c *gin.Context) {
checks := map[string]string{}
overallOK := true
if h.db != nil {
sqlDB, err := h.db.DB()
if err != nil {
checks["database"] = "error: " + err.Error()
overallOK = false
} else {
ctx, cancel := c.Request.Context(), func() {}
_ = cancel
if err := sqlDB.PingContext(ctx); err != nil {
checks["database"] = "ping failed: " + err.Error()
overallOK = false
} else {
checks["database"] = "ok"
}
}
} else {
checks["database"] = "not configured"
overallOK = false
}
status := stdhttp.StatusOK
state := "ready"
if !overallOK {
status = stdhttp.StatusServiceUnavailable
state = "not_ready"
}
c.JSON(status, gin.H{
"status": state,
"version": h.version,
"uptime": int(time.Since(h.startedAt).Seconds()),
"checks": checks,
"timestamp": time.Now().UTC().Format(time.RFC3339),
})
}

View File

@@ -0,0 +1,444 @@
package http
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
"backupx/server/internal/config"
"backupx/server/internal/database"
"backupx/server/internal/logger"
"backupx/server/internal/repository"
"backupx/server/internal/security"
"backupx/server/internal/service"
"backupx/server/internal/storage/codec"
)
// setupInstallFlowRouter 构造一个 Node + Agent + InstallToken 全量依赖的 router
// 并返回已登录管理员 JWT。
func setupInstallFlowRouter(t *testing.T) (http.Handler, string) {
t.Helper()
tempDir := t.TempDir()
cfg := config.Config{
Server: config.ServerConfig{Host: "127.0.0.1", Port: 8340, Mode: "test"},
Database: config.DatabaseConfig{Path: filepath.Join(tempDir, "backupx.db")},
Security: config.SecurityConfig{JWTExpire: "24h"},
Log: config.LogConfig{Level: "error"},
}
log, err := logger.New(cfg.Log)
if err != nil {
t.Fatalf("logger: %v", err)
}
db, err := database.Open(cfg.Database, log)
if err != nil {
t.Fatalf("db: %v", err)
}
sqlDB, err := db.DB()
if err != nil {
t.Fatalf("sql db: %v", err)
}
t.Cleanup(func() {
_ = sqlDB.Close()
})
userRepo := repository.NewUserRepository(db)
systemConfigRepo := repository.NewSystemConfigRepository(db)
resolved, err := service.ResolveSecurity(context.Background(), cfg.Security, systemConfigRepo)
if err != nil {
t.Fatalf("security: %v", err)
}
jwtMgr := security.NewJWTManager(resolved.JWTSecret, time.Hour)
authSvc := service.NewAuthService(userRepo, systemConfigRepo, jwtMgr, security.NewLoginRateLimiter(5, time.Minute), codec.NewConfigCipher(resolved.EncryptionKey))
systemSvc := service.NewSystemService(cfg, "test", time.Now().UTC())
nodeRepo := repository.NewNodeRepository(db)
nodeSvc := service.NewNodeService(nodeRepo, "test")
if err := nodeSvc.EnsureLocalNode(context.Background()); err != nil {
t.Fatalf("ensure local: %v", err)
}
installTokenRepo := repository.NewAgentInstallTokenRepository(db)
installTokenSvc := service.NewInstallTokenService(installTokenRepo, nodeRepo)
auditLogRepo := repository.NewAuditLogRepository(db)
auditSvc := service.NewAuditService(auditLogRepo)
// 用 cancelable ctx测试结束时停掉 handler 启动的后台 GC 协程,
// 避免 goroutine 持有 map 导致 tempdir 清理失败。
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
router := NewRouter(RouterDependencies{
Context: ctx,
Config: cfg,
Version: "test",
Logger: log,
AuthService: authSvc,
SystemService: systemSvc,
NodeService: nodeSvc,
InstallTokenService: installTokenSvc,
AuditService: auditSvc,
JWTManager: jwtMgr,
UserRepository: userRepo,
SystemConfigRepo: systemConfigRepo,
})
// setup 管理员并登录拿 JWT
setupBody, _ := json.Marshal(map[string]string{
"username": "admin", "password": "password-123", "displayName": "admin",
})
setupReq := httptest.NewRequest(http.MethodPost, "/api/auth/setup", bytes.NewBuffer(setupBody))
setupReq.Header.Set("Content-Type", "application/json")
setupRec := httptest.NewRecorder()
router.ServeHTTP(setupRec, setupReq)
if setupRec.Code != 200 {
t.Fatalf("setup failed: %d %s", setupRec.Code, setupRec.Body.String())
}
var setupResp struct {
Data struct {
Token string `json:"token"`
} `json:"data"`
}
if err := json.Unmarshal(setupRec.Body.Bytes(), &setupResp); err != nil {
t.Fatalf("unmarshal setup: %v", err)
}
return router, setupResp.Data.Token
}
func TestOneClickInstallFlow(t *testing.T) {
router, jwt := setupInstallFlowRouter(t)
// 1. 批量创建
batchBody, _ := json.Marshal(map[string][]string{"names": {"prod-a", "prod-b"}})
batchReq := httptest.NewRequest(http.MethodPost, "/api/nodes/batch", bytes.NewBuffer(batchBody))
batchReq.Header.Set("Content-Type", "application/json")
batchReq.Header.Set("Authorization", "Bearer "+jwt)
batchRec := httptest.NewRecorder()
router.ServeHTTP(batchRec, batchReq)
if batchRec.Code != 200 {
t.Fatalf("batch create failed: %d %s", batchRec.Code, batchRec.Body.String())
}
var batchResp struct {
Data []struct {
ID uint `json:"id"`
Name string `json:"name"`
} `json:"data"`
}
if err := json.Unmarshal(batchRec.Body.Bytes(), &batchResp); err != nil {
t.Fatalf("unmarshal batch: %v", err)
}
if len(batchResp.Data) != 2 {
t.Fatalf("expected 2 nodes, got %d", len(batchResp.Data))
}
nodeID := batchResp.Data[0].ID
// 2. 生成 install token
genBody, _ := json.Marshal(map[string]any{
"mode": "systemd",
"arch": "auto",
"agentVersion": "v1.7.0",
"downloadSrc": "github",
"ttlSeconds": 900,
})
genReq := httptest.NewRequest(http.MethodPost,
"/api/nodes/"+formatUint(nodeID)+"/install-tokens", bytes.NewBuffer(genBody))
genReq.Header.Set("Content-Type", "application/json")
genReq.Header.Set("Authorization", "Bearer "+jwt)
genRec := httptest.NewRecorder()
router.ServeHTTP(genRec, genReq)
if genRec.Code != 200 {
t.Fatalf("install-tokens failed: %d %s", genRec.Code, genRec.Body.String())
}
var genResp struct {
Data struct {
InstallToken string `json:"installToken"`
URL string `json:"url"`
FallbackURL string `json:"fallbackUrl"`
ScriptBase64 string `json:"scriptBase64"`
} `json:"data"`
}
if err := json.Unmarshal(genRec.Body.Bytes(), &genResp); err != nil {
t.Fatalf("unmarshal gen: %v", err)
}
if genResp.Data.InstallToken == "" {
t.Fatalf("missing installToken")
}
if !strings.Contains(genResp.Data.FallbackURL, "/install/") {
t.Fatalf("missing fallback install URL, got %q", genResp.Data.FallbackURL)
}
decodedScript, err := base64.StdEncoding.DecodeString(genResp.Data.ScriptBase64)
if err != nil {
t.Fatalf("scriptBase64 should be valid base64: %v", err)
}
if !strings.Contains(string(decodedScript), "BACKUPX_AGENT_INSTALL_V1") {
t.Fatalf("scriptBase64 should contain rendered install script")
}
// 3. 公开端点消费
scriptReq := httptest.NewRequest(http.MethodGet, "/install/"+genResp.Data.InstallToken, nil)
scriptRec := httptest.NewRecorder()
router.ServeHTTP(scriptRec, scriptReq)
if scriptRec.Code != 200 {
t.Fatalf("script fetch failed: %d %s", scriptRec.Code, scriptRec.Body.String())
}
if !strings.Contains(scriptRec.Body.String(), "systemctl enable --now backupx-agent") {
t.Fatalf("script missing systemctl enable:\n%s", scriptRec.Body.String())
}
// Issue #46 防嗅探 headerstext/plain + nosniff + no-store + Content-Disposition
if ct := scriptRec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/plain") {
t.Errorf("script Content-Type should be text/plain*, got %q", ct)
}
if nosniff := scriptRec.Header().Get("X-Content-Type-Options"); nosniff != "nosniff" {
t.Errorf("missing X-Content-Type-Options: nosniff (got %q)", nosniff)
}
if cc := scriptRec.Header().Get("Cache-Control"); !strings.Contains(cc, "no-store") {
t.Errorf("missing Cache-Control: no-store (got %q)", cc)
}
if cd := scriptRec.Header().Get("Content-Disposition"); !strings.Contains(cd, "backupx-agent-install.sh") {
t.Errorf("Content-Disposition should name the script file (got %q)", cd)
}
if !strings.Contains(scriptRec.Body.String(), "BACKUPX_AGENT_INSTALL_V1") {
t.Errorf("script missing magic marker BACKUPX_AGENT_INSTALL_V1")
}
// 4. 再次消费应 410
scriptReq2 := httptest.NewRequest(http.MethodGet, "/install/"+genResp.Data.InstallToken, nil)
scriptRec2 := httptest.NewRecorder()
router.ServeHTTP(scriptRec2, scriptReq2)
if scriptRec2.Code != http.StatusGone {
t.Fatalf("second consume should be 410, got %d: %s", scriptRec2.Code, scriptRec2.Body.String())
}
}
// TestInstallScriptAliasUnderAPI 验证 /api/install/:token 别名路径可用,
// 这是 Issue #46 的根本修复:让 install 端点自动命中反向代理的 /api/ 转发规则,
// 避免 nginx SPA fallback 把请求当前端路由返回 index.html。
func TestInstallScriptAliasUnderAPI(t *testing.T) {
router, token := setupInstallFlowRouter(t)
// 1. 创建一个节点,生成 install token
batchBody, _ := json.Marshal(map[string][]string{"names": {"alias-node"}})
batchReq := httptest.NewRequest(http.MethodPost, "/api/nodes/batch", bytes.NewReader(batchBody))
batchReq.Header.Set("Content-Type", "application/json")
batchReq.Header.Set("Authorization", "Bearer "+token)
batchRec := httptest.NewRecorder()
router.ServeHTTP(batchRec, batchReq)
if batchRec.Code != 200 {
t.Fatalf("batch create failed: %d %s", batchRec.Code, batchRec.Body.String())
}
var batchResp struct {
Data []struct {
ID uint `json:"id"`
} `json:"data"`
}
_ = json.Unmarshal(batchRec.Body.Bytes(), &batchResp)
if len(batchResp.Data) == 0 {
t.Fatalf("batch create returned no nodes: %s", batchRec.Body.String())
}
nodeID := batchResp.Data[0].ID
genBody, _ := json.Marshal(map[string]any{
"mode": "systemd", "arch": "auto", "agentVersion": "v1.7.0", "downloadSrc": "github", "ttlSeconds": 600,
})
genReq := httptest.NewRequest(http.MethodPost,
"/api/nodes/"+strconv.FormatUint(uint64(nodeID), 10)+"/install-tokens", bytes.NewReader(genBody))
genReq.Header.Set("Content-Type", "application/json")
genReq.Header.Set("Authorization", "Bearer "+token)
genRec := httptest.NewRecorder()
router.ServeHTTP(genRec, genReq)
if genRec.Code != 200 {
t.Fatalf("gen install token failed: %d %s", genRec.Code, genRec.Body.String())
}
var genResp struct {
Data struct {
InstallToken string `json:"installToken"`
URL string `json:"url"`
FallbackURL string `json:"fallbackUrl"`
ScriptBase64 string `json:"scriptBase64"`
} `json:"data"`
}
_ = json.Unmarshal(genRec.Body.Bytes(), &genResp)
// 2. 新生成的 url 应指向 /api/install/... —— 让反向代理的 /api/ 转发规则自动接管
if !strings.Contains(genResp.Data.URL, "/api/install/") {
t.Errorf("new install URL should use /api/install/ prefix, got %s", genResp.Data.URL)
}
if !strings.Contains(genResp.Data.FallbackURL, "/install/") {
t.Errorf("fallback install URL should use /install/ prefix, got %s", genResp.Data.FallbackURL)
}
if genResp.Data.ScriptBase64 == "" {
t.Errorf("new install response should include scriptBase64 for proxy-independent commands")
}
// 3. /api/install/:token 必须可消费(与 /install/:token 等价)
aliasReq := httptest.NewRequest(http.MethodGet, "/api/install/"+genResp.Data.InstallToken, nil)
aliasRec := httptest.NewRecorder()
router.ServeHTTP(aliasRec, aliasReq)
if aliasRec.Code != 200 {
t.Fatalf("/api/install alias failed: %d %s", aliasRec.Code, aliasRec.Body.String())
}
if !strings.Contains(aliasRec.Body.String(), "systemctl enable --now backupx-agent") {
t.Errorf("alias should return rendered script, got:\n%s", aliasRec.Body.String())
}
if ct := aliasRec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/plain") {
t.Errorf("alias Content-Type should be text/plain*, got %q", ct)
}
}
func TestInstallTokenRateLimit(t *testing.T) {
router, jwt := setupInstallFlowRouter(t)
batchBody, _ := json.Marshal(map[string][]string{"names": {"rl-test"}})
batchReq := httptest.NewRequest(http.MethodPost, "/api/nodes/batch", bytes.NewBuffer(batchBody))
batchReq.Header.Set("Content-Type", "application/json")
batchReq.Header.Set("Authorization", "Bearer "+jwt)
batchRec := httptest.NewRecorder()
router.ServeHTTP(batchRec, batchReq)
if batchRec.Code != 200 {
t.Fatalf("batch: %d %s", batchRec.Code, batchRec.Body.String())
}
var batchResp struct {
Data []struct {
ID uint `json:"id"`
} `json:"data"`
}
_ = json.Unmarshal(batchRec.Body.Bytes(), &batchResp)
nodeID := batchResp.Data[0].ID
body, _ := json.Marshal(map[string]any{
"mode": "systemd", "arch": "auto", "agentVersion": "v1",
"downloadSrc": "github", "ttlSeconds": 300,
})
for i := 0; i < 5; i++ {
req := httptest.NewRequest(http.MethodPost,
"/api/nodes/"+formatUint(nodeID)+"/install-tokens", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+jwt)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != 200 {
t.Fatalf("iter %d expected 200, got %d: %s", i, rec.Code, rec.Body.String())
}
}
req := httptest.NewRequest(http.MethodPost,
"/api/nodes/"+formatUint(nodeID)+"/install-tokens", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+jwt)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusTooManyRequests {
t.Fatalf("expected 429, got %d: %s", rec.Code, rec.Body.String())
}
}
func TestRotateTokenFlow(t *testing.T) {
router, jwt := setupInstallFlowRouter(t)
batchBody, _ := json.Marshal(map[string][]string{"names": {"rot-x"}})
batchReq := httptest.NewRequest(http.MethodPost, "/api/nodes/batch", bytes.NewBuffer(batchBody))
batchReq.Header.Set("Content-Type", "application/json")
batchReq.Header.Set("Authorization", "Bearer "+jwt)
batchRec := httptest.NewRecorder()
router.ServeHTTP(batchRec, batchReq)
var batchResp struct {
Data []struct {
ID uint `json:"id"`
} `json:"data"`
}
_ = json.Unmarshal(batchRec.Body.Bytes(), &batchResp)
nodeID := batchResp.Data[0].ID
rotReq := httptest.NewRequest(http.MethodPost,
"/api/nodes/"+formatUint(nodeID)+"/rotate-token", nil)
rotReq.Header.Set("Authorization", "Bearer "+jwt)
rotRec := httptest.NewRecorder()
router.ServeHTTP(rotRec, rotReq)
if rotRec.Code != 200 {
t.Fatalf("rotate failed: %d %s", rotRec.Code, rotRec.Body.String())
}
var rotResp struct {
Data struct {
NewToken string `json:"newToken"`
} `json:"data"`
}
_ = json.Unmarshal(rotRec.Body.Bytes(), &rotResp)
if len(rotResp.Data.NewToken) != 64 {
t.Fatalf("new token wrong length: %s", rotResp.Data.NewToken)
}
}
func TestInstallFlowComposeModeMismatch(t *testing.T) {
router, jwt := setupInstallFlowRouter(t)
batchBody, _ := json.Marshal(map[string][]string{"names": {"cm"}})
batchReq := httptest.NewRequest(http.MethodPost, "/api/nodes/batch", bytes.NewBuffer(batchBody))
batchReq.Header.Set("Content-Type", "application/json")
batchReq.Header.Set("Authorization", "Bearer "+jwt)
batchRec := httptest.NewRecorder()
router.ServeHTTP(batchRec, batchReq)
var batchResp struct {
Data []struct {
ID uint `json:"id"`
} `json:"data"`
}
_ = json.Unmarshal(batchRec.Body.Bytes(), &batchResp)
nodeID := batchResp.Data[0].ID
// 生成 systemd 模式的 token
genBody, _ := json.Marshal(map[string]any{
"mode": "systemd", "arch": "auto", "agentVersion": "v1",
"downloadSrc": "github", "ttlSeconds": 300,
})
genReq := httptest.NewRequest(http.MethodPost,
"/api/nodes/"+formatUint(nodeID)+"/install-tokens", bytes.NewBuffer(genBody))
genReq.Header.Set("Content-Type", "application/json")
genReq.Header.Set("Authorization", "Bearer "+jwt)
genRec := httptest.NewRecorder()
router.ServeHTTP(genRec, genReq)
var genResp struct {
Data struct {
InstallToken string `json:"installToken"`
} `json:"data"`
}
_ = json.Unmarshal(genRec.Body.Bytes(), &genResp)
// 访问 compose.yml 应 400
req := httptest.NewRequest(http.MethodGet,
"/install/"+genResp.Data.InstallToken+"/compose.yml", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected 400 for mode mismatch, got %d: %s", rec.Code, rec.Body.String())
}
// systemd token 未被消费Peek 不消费)→ 应仍可通过 /install/:token 消费成功
req2 := httptest.NewRequest(http.MethodGet, "/install/"+genResp.Data.InstallToken, nil)
rec2 := httptest.NewRecorder()
router.ServeHTTP(rec2, req2)
if rec2.Code != 200 {
t.Fatalf("original script fetch should still work: %d %s", rec2.Code, rec2.Body.String())
}
}
// formatUint 小工具uint → 十进制字符串(无需引入 strconv
func formatUint(u uint) string {
if u == 0 {
return "0"
}
var buf [20]byte
i := len(buf)
for u > 0 {
i--
buf[i] = byte('0' + u%10)
u /= 10
}
return string(buf[i:])
}

View File

@@ -0,0 +1,235 @@
package http
import (
"context"
stdhttp "net/http"
"strconv"
"strings"
"sync"
"time"
"backupx/server/internal/installscript"
"backupx/server/internal/model"
"backupx/server/internal/service"
"github.com/gin-gonic/gin"
)
// InstallHandler 公开路由(不走 JWT 中间件):/install/:token 与 /install/:token/compose.yml。
type InstallHandler struct {
tokenService *service.InstallTokenService
auditService *service.AuditService
externalURL string
limiter *ipLimiter
}
// NewInstallHandler 构造 handler 并启动限流器的后台 GC 协程。
// gcCtx 控制 GC 协程生命周期,建议传入 app context。
func NewInstallHandler(gcCtx context.Context, tokenService *service.InstallTokenService, auditService *service.AuditService, externalURL string) *InstallHandler {
limiter := newIPLimiter(20, time.Minute)
limiter.startGC(gcCtx)
return &InstallHandler{
tokenService: tokenService,
auditService: auditService,
externalURL: externalURL,
limiter: limiter,
}
}
// Script 消费 install token 并返回 shell 脚本Mode 由 token 存储决定systemd/docker/foreground 均返回 shell
//
// 响应头策略issue #46 教训):
// - Content-Type 用 text/plain 而非 text/x-shellscript避免 Cloudflare/反向代理把
// 脚本内容按特殊类型识别并触发 minify/HTML rewrite导致 `curl | sh` 收到非脚本内容
// - X-Content-Type-Options: nosniff禁止浏览器/中间层按内容嗅探改写 MIME
// - Cache-Control: no-storetoken 一次性消费,禁止任何缓存层留存旧脚本
// - Content-Disposition: inline; filename=...:部分代理会跳过带文件名的响应
func (h *InstallHandler) Script(c *gin.Context) {
if !h.limiter.allow(c.ClientIP()) {
c.String(stdhttp.StatusTooManyRequests, "请求过于频繁,请稍后再试\n")
return
}
token := strings.TrimSpace(c.Param("token"))
consumed, err := h.tokenService.Consume(c.Request.Context(), token)
if err != nil {
c.String(stdhttp.StatusInternalServerError, "server error\n")
return
}
if consumed == nil {
c.String(stdhttp.StatusGone, "install token 不存在、已过期或已消费\n")
return
}
h.recordConsumeAudit(c, consumed, "script")
script, err := renderInstallScript(resolveMasterURL(c, h.externalURL), consumed.Node, consumed.Record)
if err != nil {
c.String(stdhttp.StatusInternalServerError, "render error\n")
return
}
c.Header("X-Content-Type-Options", "nosniff")
c.Header("Cache-Control", "no-store")
c.Header("Content-Disposition", `inline; filename="backupx-agent-install.sh"`)
c.Data(stdhttp.StatusOK, "text/plain; charset=utf-8", []byte(script))
}
// Compose 消费 install token 并返回 docker-compose YAML仅 Mode=docker 有效。
// 注意:/install/:token 与 /install/:token/compose.yml 共享同一 token 的消费状态,任一首次命中即消费。
func (h *InstallHandler) Compose(c *gin.Context) {
if !h.limiter.allow(c.ClientIP()) {
c.String(stdhttp.StatusTooManyRequests, "请求过于频繁,请稍后再试\n")
return
}
token := strings.TrimSpace(c.Param("token"))
// 先 Peek 看 Mode不消费若非 docker 直接 400
record, err := h.tokenService.Peek(c.Request.Context(), token)
if err != nil {
c.String(stdhttp.StatusInternalServerError, "server error\n")
return
}
if record == nil {
c.String(stdhttp.StatusGone, "install token 不存在\n")
return
}
if record.Mode != model.InstallModeDocker {
c.String(stdhttp.StatusBadRequest, "该 install token 的模式不是 docker\n")
return
}
// 消费
consumed, err := h.tokenService.Consume(c.Request.Context(), token)
if err != nil {
c.String(stdhttp.StatusInternalServerError, "server error\n")
return
}
if consumed == nil {
c.String(stdhttp.StatusGone, "install token 已过期或已消费\n")
return
}
h.recordConsumeAudit(c, consumed, "compose")
yaml, err := installscript.RenderComposeYaml(installscript.Context{
MasterURL: resolveMasterURL(c, h.externalURL),
AgentToken: consumed.Node.Token,
AgentVersion: consumed.Record.AgentVer,
Mode: model.InstallModeDocker,
NodeID: consumed.Node.ID,
})
if err != nil {
c.String(stdhttp.StatusInternalServerError, "render error\n")
return
}
c.Data(stdhttp.StatusOK, "text/yaml; charset=utf-8", []byte(yaml))
}
func (h *InstallHandler) recordConsumeAudit(c *gin.Context, consumed *service.ConsumedInstallToken, kind string) {
if h.auditService == nil {
return
}
h.auditService.Record(service.AuditEntry{
Category: "install_token",
Action: "consume",
TargetType: "node",
TargetID: strconv.FormatUint(uint64(consumed.Node.ID), 10),
TargetName: consumed.Node.Name,
Detail: "install token 消费 (" + kind + ")",
ClientIP: c.ClientIP(),
})
}
func renderInstallScript(masterURL string, node *model.Node, record *model.AgentInstallToken) (string, error) {
return installscript.RenderScript(installscript.Context{
MasterURL: masterURL,
AgentToken: node.Token,
AgentVersion: record.AgentVer,
Mode: record.Mode,
Arch: record.Arch,
DownloadBase: installscript.DownloadBaseFor(record.DownloadSrc),
InstallPrefix: "/opt/backupx-agent",
NodeID: node.ID,
})
}
// resolveMasterURL 按优先级推导 Master URL外部配置 > X-Forwarded-* > Request.Host。
// 此为包级 helper供 install_handler 和 node_handler 共用。
func resolveMasterURL(c *gin.Context, externalURL string) string {
if strings.TrimSpace(externalURL) != "" {
return strings.TrimRight(externalURL, "/")
}
scheme := strings.TrimSpace(c.GetHeader("X-Forwarded-Proto"))
if scheme == "" {
if c.Request.TLS != nil {
scheme = "https"
} else {
scheme = "http"
}
}
host := strings.TrimSpace(c.GetHeader("X-Forwarded-Host"))
if host == "" {
host = c.Request.Host
}
return scheme + "://" + host
}
// ipLimiter 简单内存滑动窗口限流,按 client IP 维度。
type ipLimiter struct {
mu sync.Mutex
events map[string][]time.Time
limit int
window time.Duration
}
func newIPLimiter(limit int, window time.Duration) *ipLimiter {
return &ipLimiter{events: make(map[string][]time.Time), limit: limit, window: window}
}
func (l *ipLimiter) allow(ip string) bool {
l.mu.Lock()
defer l.mu.Unlock()
now := time.Now()
cutoff := now.Add(-l.window)
keep := l.events[ip][:0]
for _, t := range l.events[ip] {
if t.After(cutoff) {
keep = append(keep, t)
}
}
if len(keep) >= l.limit {
l.events[ip] = keep
return false
}
l.events[ip] = append(keep, now)
return true
}
// gc 清理窗口外所有过期的 IP 条目,防止公网扫描导致 map 无界增长。
// 由后台 goroutine 周期性调用。
func (l *ipLimiter) gc(now time.Time) {
l.mu.Lock()
defer l.mu.Unlock()
cutoff := now.Add(-l.window)
for k, v := range l.events {
stale := true
for _, t := range v {
if t.After(cutoff) {
stale = false
break
}
}
if stale {
delete(l.events, k)
}
}
}
// startGC 启动后台清理协程,每 window 周期清扫一次 map。
// ctx 取消时协程退出。
func (l *ipLimiter) startGC(ctx context.Context) {
go func() {
ticker := time.NewTicker(l.window)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case t := <-ticker.C:
l.gc(t)
}
}
}()
}

View File

@@ -1,6 +1,7 @@
package http
import (
"context"
stdhttp "net/http"
"strings"
@@ -26,28 +27,94 @@ func CORSMiddleware() gin.HandlerFunc {
}
}
func AuthMiddleware(jwtManager *security.JWTManager) gin.HandlerFunc {
// ApiKeyAuthenticator 抽象 API Key 验证能力,避免 middleware 直接依赖 service 包。
// 实现方service.ApiKeyService。未注入时 AuthMiddleware 仍然支持 JWT。
type ApiKeyAuthenticator interface {
Authenticate(ctx context.Context, rawKey string) (subject string, role string, err error)
}
// AuthMiddleware 支持两种认证方式:
// - JWT (Authorization: Bearer <jwt>):交互式用户
// - API Key (Authorization: Bearer bax_xxx 或 X-Api-Key: bax_xxx):第三方脚本
//
// JWT 会在 context 中写入 userSubject / userRole / username
// API Key 会写入 authSubject=api_key:<id> / userRole=<key role>。
func AuthMiddleware(jwtManager *security.JWTManager, apiKeyAuth ApiKeyAuthenticator) gin.HandlerFunc {
return func(c *gin.Context) {
header := strings.TrimSpace(c.GetHeader("Authorization"))
if !strings.HasPrefix(header, "Bearer ") {
rawToken := extractAuthToken(c)
if rawToken == "" {
response.Error(c, apperror.Unauthorized("AUTH_REQUIRED", "请先登录", nil))
c.Abort()
return
}
tokenString := strings.TrimSpace(strings.TrimPrefix(header, "Bearer "))
claims, err := jwtManager.Parse(tokenString)
if apiKeyAuth != nil && strings.HasPrefix(rawToken, "bax_") {
subject, role, err := apiKeyAuth.Authenticate(c.Request.Context(), rawToken)
if err != nil {
response.Error(c, err)
c.Abort()
return
}
c.Set(contextAuthSubjectKey, subject)
c.Set(contextUserRoleKey, role)
c.Set(contextUserSubjectKey, subject)
c.Set(contextUsernameKey, subject)
c.Next()
return
}
claims, err := jwtManager.Parse(rawToken)
if err != nil {
response.Error(c, apperror.Unauthorized("AUTH_INVALID_TOKEN", "登录状态已失效,请重新登录", err))
c.Abort()
return
}
c.Set(contextUserSubjectKey, claims.Subject)
c.Set(contextUserRoleKey, claims.Role)
c.Set(contextUsernameKey, claims.Username)
c.Set(contextAuthSubjectKey, "user:"+claims.Subject)
c.Next()
}
}
// extractAuthToken 从 Authorization: Bearer 或 X-Api-Key 中提取原始 token。
func extractAuthToken(c *gin.Context) string {
header := strings.TrimSpace(c.GetHeader("Authorization"))
if strings.HasPrefix(header, "Bearer ") {
return strings.TrimSpace(strings.TrimPrefix(header, "Bearer "))
}
if key := strings.TrimSpace(c.GetHeader("X-Api-Key")); key != "" {
return key
}
return ""
}
// RequireRole 仅放行指定角色,否则返回 403。
// 必须用在 AuthMiddleware 之后。viewer 只读保护、admin 管理端都靠它。
func RequireRole(roles ...string) gin.HandlerFunc {
allowed := make(map[string]bool, len(roles))
for _, r := range roles {
allowed[strings.ToLower(r)] = true
}
return func(c *gin.Context) {
role, _ := c.Get(contextUserRoleKey)
roleStr := ""
if v, ok := role.(string); ok {
roleStr = strings.ToLower(v)
}
if !allowed[roleStr] {
response.Error(c, apperror.New(403, "AUTH_FORBIDDEN", "当前角色无权执行此操作", nil))
c.Abort()
return
}
c.Next()
}
}
// RequireNotViewer 是 RequireRole(admin, operator) 的快捷方式,
// 用于任何"写入/变更"类端点,禁止 viewer 触发。
func RequireNotViewer() gin.HandlerFunc {
return RequireRole("admin", "operator")
}
func ClientKey(c *gin.Context) string {
ip := strings.TrimSpace(c.ClientIP())
if ip == "" {

View File

@@ -1,22 +1,64 @@
package http
import (
"encoding/base64"
"fmt"
stdhttp "net/http"
"strconv"
"backupx/server/internal/apperror"
"backupx/server/internal/installscript"
"backupx/server/internal/repository"
"backupx/server/internal/service"
"backupx/server/pkg/response"
"github.com/gin-gonic/gin"
)
type NodeHandler struct {
service *service.NodeService
auditService *service.AuditService
service *service.NodeService
auditService *service.AuditService
installTokenSvc *service.InstallTokenService
userRepo repository.UserRepository
externalURL string
}
func NewNodeHandler(service *service.NodeService, auditService *service.AuditService) *NodeHandler {
return &NodeHandler{service: service, auditService: auditService}
// NewNodeHandler 构造 handler。
// userRepo 用于把 JWT subject用户名解析为 user.ID填入 install_token.created_by_id 做审计追溯;
// 传 nil 时 created_by_id 记为 0仍可用不阻断
func NewNodeHandler(
nodeService *service.NodeService,
auditService *service.AuditService,
installTokenSvc *service.InstallTokenService,
userRepo repository.UserRepository,
externalURL string,
) *NodeHandler {
return &NodeHandler{
service: nodeService,
auditService: auditService,
installTokenSvc: installTokenSvc,
userRepo: userRepo,
externalURL: externalURL,
}
}
// resolveCurrentUserID 从 JWT subject 解析出 user.ID失败返回 0。
func (h *NodeHandler) resolveCurrentUserID(c *gin.Context) uint {
if h.userRepo == nil {
return 0
}
subjectValue, ok := c.Get(contextUserSubjectKey)
if !ok {
return 0
}
subject, err := service.SubjectFromContextValue(subjectValue)
if err != nil || subject == "" {
return 0
}
user, err := h.userRepo.FindByUsername(c.Request.Context(), subject)
if err != nil || user == nil {
return 0
}
return user.ID
}
func (h *NodeHandler) List(c *gin.Context) {
@@ -128,3 +170,149 @@ func (h *NodeHandler) Heartbeat(c *gin.Context) {
}
response.Success(c, gin.H{"status": "ok"})
}
// BatchCreate 批量创建远程节点。
func (h *NodeHandler) BatchCreate(c *gin.Context) {
var input struct {
Names []string `json:"names" binding:"required"`
}
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(stdhttp.StatusBadRequest, gin.H{"code": "INVALID_INPUT", "message": err.Error()})
return
}
results, err := h.service.BatchCreate(c.Request.Context(), input.Names)
if err != nil {
response.Error(c, err)
return
}
recordAudit(c, h.auditService, "node", "batch_create", "node", "",
fmt.Sprintf("%d", len(results)), fmt.Sprintf("批量创建 %d 个节点", len(results)))
response.Success(c, results)
}
// RotateToken 轮换节点的 agent token。
func (h *NodeHandler) RotateToken(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
response.Error(c, err)
return
}
tok, err := h.service.RotateToken(c.Request.Context(), uint(id))
if err != nil {
response.Error(c, err)
return
}
recordAudit(c, h.auditService, "node", "rotate_token", "node",
fmt.Sprintf("%d", id), "",
fmt.Sprintf("轮换节点 Token (ID: %d)", id))
response.Success(c, gin.H{"newToken": tok})
}
// CreateInstallToken 生成一次性安装令牌。
func (h *NodeHandler) CreateInstallToken(c *gin.Context) {
if h.installTokenSvc == nil {
response.Error(c, apperror.New(stdhttp.StatusServiceUnavailable,
"INSTALL_TOKEN_DISABLED", "一键部署未启用", nil))
return
}
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
response.Error(c, err)
return
}
var input struct {
Mode string `json:"mode"`
Arch string `json:"arch"`
AgentVersion string `json:"agentVersion"`
DownloadSrc string `json:"downloadSrc"`
TTLSeconds int `json:"ttlSeconds"`
}
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(stdhttp.StatusBadRequest, gin.H{"code": "INVALID_INPUT", "message": err.Error()})
return
}
// 默认值
if input.Mode == "" {
input.Mode = "systemd"
}
if input.Arch == "" {
input.Arch = "auto"
}
if input.DownloadSrc == "" {
input.DownloadSrc = "github"
}
if input.TTLSeconds == 0 {
input.TTLSeconds = 900
}
out, err := h.installTokenSvc.Create(c.Request.Context(), service.InstallTokenInput{
NodeID: uint(id),
Mode: input.Mode,
Arch: input.Arch,
AgentVersion: input.AgentVersion,
DownloadSrc: input.DownloadSrc,
TTLSeconds: input.TTLSeconds,
CreatedByID: h.resolveCurrentUserID(c),
})
if err != nil {
response.Error(c, err)
return
}
recordAudit(c, h.auditService, "install_token", "create", "node",
fmt.Sprintf("%d", id), out.Node.Name,
fmt.Sprintf("生成 %s/%s install token TTL=%ds", input.Mode, input.Arch, input.TTLSeconds))
masterURL := resolveMasterURL(c, h.externalURL)
script, err := renderInstallScript(masterURL, out.Node, out.Record)
if err != nil {
response.Error(c, err)
return
}
// 使用 /api/install/... 而非 /install/... —— 让反向代理的 /api/ 转发规则
// 自动接管,避免 SPA fallback 把请求当成前端路由返回 index.htmlissue #46
// 同时返回 /install/... 备用地址,兼容会剥离 /api 前缀的外层反向代理。
// scriptBase64 让前端可以生成不依赖公开下载路径的嵌入式命令,解决 Lucky 等代理
// 把 /api/install/* 也 fallback 到 index.html 的场景。
body := gin.H{
"installToken": out.Token,
"expiresAt": out.ExpiresAt,
"url": masterURL + "/api/install/" + out.Token,
"fallbackUrl": masterURL + "/install/" + out.Token,
"scriptBase64": base64.StdEncoding.EncodeToString([]byte(script)),
"composeUrl": "",
"fallbackComposeUrl": "",
}
if input.Mode == "docker" {
body["composeUrl"] = masterURL + "/api/install/" + out.Token + "/compose.yml"
body["fallbackComposeUrl"] = masterURL + "/install/" + out.Token + "/compose.yml"
}
response.Success(c, body)
}
// PreviewScript 预览安装脚本token 字段用 <AGENT_TOKEN> 占位,不消费 install token
// 用于 UI Step 3 展开"脚本预览"。
func (h *NodeHandler) PreviewScript(c *gin.Context) {
mode := c.DefaultQuery("mode", "systemd")
arch := c.DefaultQuery("arch", "auto")
ver := c.Query("agentVersion")
if ver == "" {
c.JSON(stdhttp.StatusBadRequest, gin.H{"code": "INVALID_INPUT", "message": "agentVersion required"})
return
}
src := c.DefaultQuery("downloadSrc", "github")
ctx := installscript.Context{
MasterURL: resolveMasterURL(c, h.externalURL),
AgentToken: "<AGENT_TOKEN>",
AgentVersion: ver,
Mode: mode,
Arch: arch,
DownloadBase: installscript.DownloadBaseFor(src),
InstallPrefix: "/opt/backupx-agent",
}
script, err := installscript.RenderScript(ctx)
if err != nil {
response.Error(c, err)
return
}
c.Data(stdhttp.StatusOK, "text/x-shellscript; charset=utf-8", []byte(script))
}

View File

@@ -0,0 +1,128 @@
package http
import (
"fmt"
"strconv"
"strings"
"time"
"backupx/server/internal/apperror"
"backupx/server/internal/service"
"backupx/server/pkg/response"
"github.com/gin-gonic/gin"
)
// ReplicationHandler 管理备份复制记录列表 + 手动触发。
type ReplicationHandler struct {
service *service.ReplicationService
auditService *service.AuditService
}
func NewReplicationHandler(replicationService *service.ReplicationService, auditService *service.AuditService) *ReplicationHandler {
return &ReplicationHandler{service: replicationService, auditService: auditService}
}
// TriggerByRecord 手动触发:从备份记录复制到指定目标存储。
// Body: {"destTargetId": 12}
func (h *ReplicationHandler) TriggerByRecord(c *gin.Context) {
recordID, ok := parseUintParam(c, "id")
if !ok {
return
}
var input struct {
DestTargetID uint `json:"destTargetId" binding:"required,min=1"`
}
if err := c.ShouldBindJSON(&input); err != nil {
response.Error(c, apperror.BadRequest("REPLICATION_INVALID", "复制参数不合法", err))
return
}
triggeredBy := ""
if subject, exists := c.Get(contextUsernameKey); exists {
if v, ok := subject.(string); ok {
triggeredBy = v
}
}
if triggeredBy == "" {
triggeredBy = "manual"
}
result, err := h.service.Start(c.Request.Context(), recordID, input.DestTargetID, triggeredBy)
if err != nil {
response.Error(c, err)
return
}
recordAudit(c, h.auditService, "replication", "manual_run", "backup_record", fmt.Sprintf("%d", recordID), "",
fmt.Sprintf("手动触发复制(备份记录 #%d → 存储 #%d, 复制记录 #%d", recordID, input.DestTargetID, result.ID))
response.Success(c, result)
}
func (h *ReplicationHandler) List(c *gin.Context) {
filter, err := buildReplicationFilter(c)
if err != nil {
response.Error(c, err)
return
}
items, err := h.service.List(c.Request.Context(), filter)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, items)
}
func (h *ReplicationHandler) Get(c *gin.Context) {
id, ok := parseUintParam(c, "id")
if !ok {
return
}
item, err := h.service.Get(c.Request.Context(), id)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, item)
}
func buildReplicationFilter(c *gin.Context) (service.ReplicationRecordListInput, error) {
var filter service.ReplicationRecordListInput
if v := strings.TrimSpace(c.Query("taskId")); v != "" {
parsed, err := strconv.ParseUint(v, 10, 32)
if err != nil {
return filter, apperror.BadRequest("REPLICATION_FILTER_INVALID", "taskId 不合法", err)
}
id := uint(parsed)
filter.TaskID = &id
}
if v := strings.TrimSpace(c.Query("backupRecordId")); v != "" {
parsed, err := strconv.ParseUint(v, 10, 32)
if err != nil {
return filter, apperror.BadRequest("REPLICATION_FILTER_INVALID", "backupRecordId 不合法", err)
}
id := uint(parsed)
filter.BackupRecordID = &id
}
if v := strings.TrimSpace(c.Query("destTargetId")); v != "" {
parsed, err := strconv.ParseUint(v, 10, 32)
if err != nil {
return filter, apperror.BadRequest("REPLICATION_FILTER_INVALID", "destTargetId 不合法", err)
}
id := uint(parsed)
filter.DestTargetID = &id
}
filter.Status = strings.TrimSpace(c.Query("status"))
if v := strings.TrimSpace(c.Query("dateFrom")); v != "" {
parsed, err := time.Parse(time.RFC3339, v)
if err != nil {
return filter, apperror.BadRequest("REPLICATION_FILTER_INVALID", "dateFrom 必须为 RFC3339", err)
}
filter.DateFrom = &parsed
}
if v := strings.TrimSpace(c.Query("dateTo")); v != "" {
parsed, err := time.Parse(time.RFC3339, v)
if err != nil {
return filter, apperror.BadRequest("REPLICATION_FILTER_INVALID", "dateTo 必须为 RFC3339", err)
}
filter.DateTo = &parsed
}
return filter, nil
}

View File

@@ -0,0 +1,162 @@
package http
import (
"encoding/json"
"fmt"
"io"
"strconv"
"strings"
"time"
"backupx/server/internal/apperror"
"backupx/server/internal/backup"
"backupx/server/internal/service"
"backupx/server/pkg/response"
"github.com/gin-gonic/gin"
)
// RestoreRecordHandler 提供恢复记录列表/详情/实时日志端点。
// 创建恢复由 BackupRecordHandler.Restore 代理到 RestoreService.Start。
type RestoreRecordHandler struct {
service *service.RestoreService
auditService *service.AuditService
}
func NewRestoreRecordHandler(restoreService *service.RestoreService, auditService *service.AuditService) *RestoreRecordHandler {
return &RestoreRecordHandler{service: restoreService, auditService: auditService}
}
func (h *RestoreRecordHandler) List(c *gin.Context) {
filter, err := buildRestoreFilter(c)
if err != nil {
response.Error(c, err)
return
}
items, err := h.service.List(c.Request.Context(), filter)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, items)
}
func (h *RestoreRecordHandler) Get(c *gin.Context) {
id, ok := parseUintParam(c, "id")
if !ok {
return
}
item, err := h.service.Get(c.Request.Context(), id)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, item)
}
func (h *RestoreRecordHandler) StreamLogs(c *gin.Context) {
id, ok := parseUintParam(c, "id")
if !ok {
return
}
detail, err := h.service.Get(c.Request.Context(), id)
if err != nil {
response.Error(c, err)
return
}
events := detail.LogEvents
completed := detail.Status != "running"
channel, cancel, err := h.service.SubscribeLogs(c.Request.Context(), id, 64)
if err != nil {
response.Error(c, err)
return
}
defer cancel()
c.Writer.Header().Set("Content-Type", "text/event-stream")
c.Writer.Header().Set("Cache-Control", "no-cache")
c.Writer.Header().Set("Connection", "keep-alive")
flusher, ok := c.Writer.(interface{ Flush() })
if !ok {
response.Error(c, apperror.Internal("RESTORE_STREAM_UNSUPPORTED", "当前连接不支持日志流", nil))
return
}
for _, event := range events {
if err := writeRestoreSSEEvent(c.Writer, event); err != nil {
return
}
flusher.Flush()
}
if completed {
return
}
for {
select {
case <-c.Request.Context().Done():
return
case event, ok := <-channel:
if !ok {
return
}
if err := writeRestoreSSEEvent(c.Writer, event); err != nil {
return
}
flusher.Flush()
if event.Completed {
return
}
}
}
}
func buildRestoreFilter(c *gin.Context) (service.RestoreRecordListInput, error) {
var filter service.RestoreRecordListInput
if taskIDValue := strings.TrimSpace(c.Query("taskId")); taskIDValue != "" {
parsed, err := strconv.ParseUint(taskIDValue, 10, 32)
if err != nil {
return filter, apperror.BadRequest("RESTORE_RECORD_FILTER_INVALID", "taskId 不合法", err)
}
v := uint(parsed)
filter.TaskID = &v
}
if backupValue := strings.TrimSpace(c.Query("backupRecordId")); backupValue != "" {
parsed, err := strconv.ParseUint(backupValue, 10, 32)
if err != nil {
return filter, apperror.BadRequest("RESTORE_RECORD_FILTER_INVALID", "backupRecordId 不合法", err)
}
v := uint(parsed)
filter.BackupRecordID = &v
}
if nodeValue := strings.TrimSpace(c.Query("nodeId")); nodeValue != "" {
parsed, err := strconv.ParseUint(nodeValue, 10, 32)
if err != nil {
return filter, apperror.BadRequest("RESTORE_RECORD_FILTER_INVALID", "nodeId 不合法", err)
}
v := uint(parsed)
filter.NodeID = &v
}
filter.Status = strings.TrimSpace(c.Query("status"))
if dateFrom := strings.TrimSpace(c.Query("dateFrom")); dateFrom != "" {
parsed, err := time.Parse(time.RFC3339, dateFrom)
if err != nil {
return filter, apperror.BadRequest("RESTORE_RECORD_FILTER_INVALID", "dateFrom 必须为 RFC3339 时间格式", err)
}
filter.DateFrom = &parsed
}
if dateTo := strings.TrimSpace(c.Query("dateTo")); dateTo != "" {
parsed, err := time.Parse(time.RFC3339, dateTo)
if err != nil {
return filter, apperror.BadRequest("RESTORE_RECORD_FILTER_INVALID", "dateTo 必须为 RFC3339 时间格式", err)
}
filter.DateTo = &parsed
}
return filter, nil
}
func writeRestoreSSEEvent(writer io.Writer, event backup.LogEvent) error {
payload, err := json.Marshal(event)
if err != nil {
return err
}
_, err = fmt.Fprintf(writer, "event: log\ndata: %s\n\n", payload)
return err
}

View File

@@ -1,20 +1,26 @@
package http
import (
"context"
"errors"
stdhttp "net/http"
"backupx/server/internal/apperror"
"backupx/server/internal/config"
"backupx/server/internal/metrics"
"backupx/server/internal/repository"
"backupx/server/internal/security"
"backupx/server/internal/service"
"backupx/server/pkg/response"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
"gorm.io/gorm"
)
type RouterDependencies struct {
// Context 控制 handler 启动的后台协程(如 ipLimiter GC的生命周期。
// app 应传入随进程退出可取消的 ctx若为 nil 则退化为 context.Background()。
Context context.Context
Config config.Config
Version string
Logger *zap.Logger
@@ -24,6 +30,15 @@ type RouterDependencies struct {
BackupTaskService *service.BackupTaskService
BackupExecutionService *service.BackupExecutionService
BackupRecordService *service.BackupRecordService
RestoreService *service.RestoreService
VerificationService *service.VerificationService
ReplicationService *service.ReplicationService
TaskTemplateService *service.TaskTemplateService
TaskExportService *service.TaskExportService
SearchService *service.SearchService
EventBroadcaster *service.EventBroadcaster
UserService *service.UserService
ApiKeyService *service.ApiKeyService
NotificationService *service.NotificationService
DashboardService *service.DashboardService
SettingsService *service.SettingsService
@@ -34,6 +49,12 @@ type RouterDependencies struct {
JWTManager *security.JWTManager
UserRepository repository.UserRepository
SystemConfigRepo repository.SystemConfigRepository
InstallTokenService *service.InstallTokenService
MasterExternalURL string
// DB 注入给健康检查端点做 liveness/readiness 探测。
DB *gorm.DB
// Metrics 注入给 /metrics 端点;为 nil 时端点返回 503。
Metrics *metrics.Metrics
}
func NewRouter(deps RouterDependencies) *gin.Engine {
@@ -48,7 +69,19 @@ func NewRouter(deps RouterDependencies) *gin.Engine {
storageTargetHandler := NewStorageTargetHandler(deps.StorageTargetService, deps.AuditService)
backupTaskHandler := NewBackupTaskHandler(deps.BackupTaskService, deps.AuditService)
backupRunHandler := NewBackupRunHandler(deps.BackupExecutionService, deps.AuditService)
backupRecordHandler := NewBackupRecordHandler(deps.BackupRecordService, deps.AuditService)
backupRecordHandler := NewBackupRecordHandler(deps.BackupRecordService, deps.RestoreService, deps.AuditService)
restoreRecordHandler := NewRestoreRecordHandler(deps.RestoreService, deps.AuditService)
verificationHandler := NewVerificationHandler(deps.VerificationService, deps.AuditService)
replicationHandler := NewReplicationHandler(deps.ReplicationService, deps.AuditService)
taskTemplateHandler := NewTaskTemplateHandler(deps.TaskTemplateService, deps.AuditService)
userHandler := NewUserHandler(deps.UserService, deps.AuditService)
apiKeyHandler := NewApiKeyHandler(deps.ApiKeyService, deps.AuditService)
// apiKeyAuth给 AuthMiddleware 注入 API Key 验证能力。
// 为 nil 时中间件仅支持 JWT不影响向后兼容。
var apiKeyAuth ApiKeyAuthenticator
if deps.ApiKeyService != nil {
apiKeyAuth = deps.ApiKeyService
}
notificationHandler := NewNotificationHandler(deps.NotificationService)
dashboardHandler := NewDashboardHandler(deps.DashboardService)
settingsHandler := NewSettingsHandler(deps.SettingsService, deps.AuditService)
@@ -61,111 +94,265 @@ func NewRouter(deps RouterDependencies) *gin.Engine {
auth.GET("/setup/status", authHandler.SetupStatus)
auth.POST("/setup", authHandler.Setup)
auth.POST("/login", authHandler.Login)
auth.POST("/logout", AuthMiddleware(deps.JWTManager), authHandler.Logout)
auth.GET("/profile", AuthMiddleware(deps.JWTManager), authHandler.Profile)
auth.PUT("/password", AuthMiddleware(deps.JWTManager), authHandler.ChangePassword)
auth.POST("/otp/send", authHandler.SendLoginOTP)
auth.POST("/webauthn/login/options", authHandler.BeginWebAuthnLogin)
auth.POST("/logout", AuthMiddleware(deps.JWTManager, apiKeyAuth), authHandler.Logout)
auth.GET("/profile", AuthMiddleware(deps.JWTManager, apiKeyAuth), authHandler.Profile)
auth.PUT("/password", AuthMiddleware(deps.JWTManager, apiKeyAuth), authHandler.ChangePassword)
auth.POST("/2fa/setup", AuthMiddleware(deps.JWTManager, apiKeyAuth), authHandler.PrepareTwoFactor)
auth.POST("/2fa/enable", AuthMiddleware(deps.JWTManager, apiKeyAuth), authHandler.EnableTwoFactor)
auth.POST("/2fa/recovery-codes", AuthMiddleware(deps.JWTManager, apiKeyAuth), authHandler.RegenerateRecoveryCodes)
auth.DELETE("/2fa", AuthMiddleware(deps.JWTManager, apiKeyAuth), authHandler.DisableTwoFactor)
auth.PUT("/otp/config", AuthMiddleware(deps.JWTManager, apiKeyAuth), authHandler.ConfigureOTP)
auth.POST("/webauthn/register/options", AuthMiddleware(deps.JWTManager, apiKeyAuth), authHandler.BeginWebAuthnRegistration)
auth.POST("/webauthn/register/finish", AuthMiddleware(deps.JWTManager, apiKeyAuth), authHandler.FinishWebAuthnRegistration)
auth.GET("/webauthn/credentials", AuthMiddleware(deps.JWTManager, apiKeyAuth), authHandler.ListWebAuthnCredentials)
auth.DELETE("/webauthn/credentials/:id", AuthMiddleware(deps.JWTManager, apiKeyAuth), authHandler.DeleteWebAuthnCredential)
auth.GET("/trusted-devices", AuthMiddleware(deps.JWTManager, apiKeyAuth), authHandler.ListTrustedDevices)
auth.DELETE("/trusted-devices/:id", AuthMiddleware(deps.JWTManager, apiKeyAuth), authHandler.RevokeTrustedDevice)
}
system := api.Group("/system")
system.Use(AuthMiddleware(deps.JWTManager))
system.Use(AuthMiddleware(deps.JWTManager, apiKeyAuth))
system.GET("/info", systemHandler.Info)
system.GET("/update-check", systemHandler.CheckUpdate)
storageTargets := api.Group("/storage-targets")
storageTargets.Use(AuthMiddleware(deps.JWTManager))
storageTargets.Use(AuthMiddleware(deps.JWTManager, apiKeyAuth))
// 静态路由必须在参数路由 /:id 之前注册,避免 Gin 路由冲突
storageTargets.GET("", storageTargetHandler.List)
storageTargets.POST("", storageTargetHandler.Create)
storageTargets.POST("/test", storageTargetHandler.TestConnection)
storageTargets.POST("/google-drive/auth-url", storageTargetHandler.StartGoogleDriveOAuth)
storageTargets.POST("/google-drive/complete", storageTargetHandler.CompleteGoogleDriveOAuth)
storageTargets.POST("", RequireNotViewer(), storageTargetHandler.Create)
storageTargets.POST("/test", RequireNotViewer(), storageTargetHandler.TestConnection)
storageTargets.POST("/google-drive/auth-url", RequireNotViewer(), storageTargetHandler.StartGoogleDriveOAuth)
storageTargets.POST("/google-drive/complete", RequireNotViewer(), storageTargetHandler.CompleteGoogleDriveOAuth)
storageTargets.GET("/google-drive/callback", storageTargetHandler.HandleGoogleDriveCallback)
rcloneHandler := NewRcloneHandler()
storageTargets.GET("/rclone/backends", rcloneHandler.ListBackends)
// 参数路由
storageTargets.GET("/:id", storageTargetHandler.Get)
storageTargets.PUT("/:id", storageTargetHandler.Update)
storageTargets.DELETE("/:id", storageTargetHandler.Delete)
storageTargets.PUT("/:id/star", storageTargetHandler.ToggleStar)
storageTargets.POST("/:id/test", storageTargetHandler.TestSavedConnection)
storageTargets.PUT("/:id", RequireNotViewer(), storageTargetHandler.Update)
storageTargets.DELETE("/:id", RequireNotViewer(), storageTargetHandler.Delete)
storageTargets.PUT("/:id/star", RequireNotViewer(), storageTargetHandler.ToggleStar)
storageTargets.POST("/:id/test", RequireNotViewer(), storageTargetHandler.TestSavedConnection)
storageTargets.GET("/:id/usage", storageTargetHandler.GetUsage)
storageTargets.GET("/:id/google-drive/profile", storageTargetHandler.GoogleDriveProfile)
backupTasks := api.Group("/backup/tasks")
backupTasks.Use(AuthMiddleware(deps.JWTManager))
backupTasks.Use(AuthMiddleware(deps.JWTManager, apiKeyAuth))
backupTasks.GET("", backupTaskHandler.List)
backupTasks.GET("/tags", backupTaskHandler.ListTags)
backupTasks.GET("/:id", backupTaskHandler.Get)
backupTasks.POST("", backupTaskHandler.Create)
backupTasks.PUT("/:id", backupTaskHandler.Update)
backupTasks.DELETE("/:id", backupTaskHandler.Delete)
backupTasks.PUT("/:id/toggle", backupTaskHandler.Toggle)
backupTasks.POST("/:id/run", backupRunHandler.Run)
backupTasks.POST("", RequireNotViewer(), backupTaskHandler.Create)
backupTasks.PUT("/:id", RequireNotViewer(), backupTaskHandler.Update)
backupTasks.DELETE("/:id", RequireNotViewer(), backupTaskHandler.Delete)
backupTasks.PUT("/:id/toggle", RequireNotViewer(), backupTaskHandler.Toggle)
backupTasks.POST("/:id/run", RequireNotViewer(), backupRunHandler.Run)
backupTasks.POST("/batch/toggle", RequireNotViewer(), backupTaskHandler.BatchToggle)
backupTasks.POST("/batch/delete", RequireNotViewer(), backupTaskHandler.BatchDelete)
backupTasks.POST("/batch/run", RequireNotViewer(), backupRunHandler.BatchRun)
// 任务配置导入/导出(集群迁移 & 灾备)
if deps.TaskExportService != nil {
taskExportHandler := NewTaskExportHandler(deps.TaskExportService, deps.AuditService)
backupTasks.GET("/export", taskExportHandler.Export)
backupTasks.POST("/import", RequireNotViewer(), taskExportHandler.Import)
}
if deps.VerificationService != nil {
backupTasks.POST("/:id/verify", RequireNotViewer(), verificationHandler.TriggerByTask)
}
backupRecords := api.Group("/backup/records")
backupRecords.Use(AuthMiddleware(deps.JWTManager))
backupRecords.Use(AuthMiddleware(deps.JWTManager, apiKeyAuth))
backupRecords.GET("", backupRecordHandler.List)
backupRecords.GET("/:id", backupRecordHandler.Get)
backupRecords.GET("/:id/logs/stream", backupRecordHandler.StreamLogs)
backupRecords.GET("/:id/download", backupRecordHandler.Download)
backupRecords.POST("/:id/restore", backupRecordHandler.Restore)
backupRecords.POST("/batch-delete", backupRecordHandler.BatchDelete)
backupRecords.DELETE("/:id", backupRecordHandler.Delete)
backupRecords.POST("/:id/restore", RequireNotViewer(), backupRecordHandler.Restore)
backupRecords.POST("/batch-delete", RequireNotViewer(), backupRecordHandler.BatchDelete)
backupRecords.DELETE("/:id", RequireNotViewer(), backupRecordHandler.Delete)
// 恢复记录独立命名空间:列表/详情/SSE 日志流。
// 创建恢复仍然走 POST /backup/records/:id/restore以源备份记录为触发点
if deps.RestoreService != nil {
restoreRecords := api.Group("/restore/records")
restoreRecords.Use(AuthMiddleware(deps.JWTManager, apiKeyAuth))
restoreRecords.GET("", restoreRecordHandler.List)
restoreRecords.GET("/:id", restoreRecordHandler.Get)
restoreRecords.GET("/:id/logs/stream", restoreRecordHandler.StreamLogs)
}
// 备份复制记录3-2-1 规则)
if deps.ReplicationService != nil {
replicationRecords := api.Group("/replication/records")
replicationRecords.Use(AuthMiddleware(deps.JWTManager, apiKeyAuth))
replicationRecords.GET("", replicationHandler.List)
replicationRecords.GET("/:id", replicationHandler.Get)
backupRecords.POST("/:id/replicate", RequireNotViewer(), replicationHandler.TriggerByRecord)
}
// 任务模板(批量创建)
if deps.TaskTemplateService != nil {
templates := api.Group("/task-templates")
templates.Use(AuthMiddleware(deps.JWTManager, apiKeyAuth))
templates.GET("", taskTemplateHandler.List)
templates.GET("/:id", taskTemplateHandler.Get)
templates.POST("", RequireNotViewer(), taskTemplateHandler.Create)
templates.PUT("/:id", RequireNotViewer(), taskTemplateHandler.Update)
templates.DELETE("/:id", RequireNotViewer(), taskTemplateHandler.Delete)
templates.POST("/:id/apply", RequireNotViewer(), taskTemplateHandler.Apply)
}
// 备份验证/演练记录
if deps.VerificationService != nil {
verifyRecords := api.Group("/verify/records")
verifyRecords.Use(AuthMiddleware(deps.JWTManager, apiKeyAuth))
verifyRecords.GET("", verificationHandler.List)
verifyRecords.GET("/:id", verificationHandler.Get)
verifyRecords.GET("/:id/logs/stream", verificationHandler.StreamLogs)
// 基于备份记录的验证入口:与 restore 对称
backupRecords.POST("/:id/verify", RequireNotViewer(), verificationHandler.TriggerByRecord)
}
dashboard := api.Group("/dashboard")
dashboard.Use(AuthMiddleware(deps.JWTManager))
dashboard.Use(AuthMiddleware(deps.JWTManager, apiKeyAuth))
dashboard.GET("/stats", dashboardHandler.Stats)
dashboard.GET("/timeline", dashboardHandler.Timeline)
dashboard.GET("/sla", dashboardHandler.SLA)
dashboard.GET("/cluster", dashboardHandler.Cluster)
dashboard.GET("/breakdown", dashboardHandler.Breakdown)
dashboard.GET("/node-performance", dashboardHandler.NodePerformance)
notifications := api.Group("/notifications")
notifications.Use(AuthMiddleware(deps.JWTManager))
notifications.Use(AuthMiddleware(deps.JWTManager, apiKeyAuth))
notifications.GET("", notificationHandler.List)
notifications.GET("/:id", notificationHandler.Get)
notifications.POST("", notificationHandler.Create)
notifications.PUT("/:id", notificationHandler.Update)
notifications.DELETE("/:id", notificationHandler.Delete)
notifications.POST("/test", notificationHandler.Test)
notifications.POST("/:id/test", notificationHandler.TestSaved)
notifications.POST("", RequireNotViewer(), notificationHandler.Create)
notifications.PUT("/:id", RequireNotViewer(), notificationHandler.Update)
notifications.DELETE("/:id", RequireNotViewer(), notificationHandler.Delete)
notifications.POST("/test", RequireNotViewer(), notificationHandler.Test)
notifications.POST("/:id/test", RequireNotViewer(), notificationHandler.TestSaved)
settings := api.Group("/settings")
settings.Use(AuthMiddleware(deps.JWTManager))
settings.Use(AuthMiddleware(deps.JWTManager, apiKeyAuth))
settings.GET("", settingsHandler.Get)
settings.PUT("", settingsHandler.Update)
settings.PUT("", RequireRole("admin"), settingsHandler.Update)
// 用户管理admin 专属)
if deps.UserService != nil {
users := api.Group("/users")
users.Use(AuthMiddleware(deps.JWTManager, apiKeyAuth), RequireRole("admin"))
users.GET("", userHandler.List)
users.POST("", userHandler.Create)
users.PUT("/:id", userHandler.Update)
users.POST("/:id/2fa/reset", userHandler.ResetTwoFactor)
users.DELETE("/:id", userHandler.Delete)
}
// API Key 管理admin 专属)
if deps.ApiKeyService != nil {
apiKeys := api.Group("/api-keys")
apiKeys.Use(AuthMiddleware(deps.JWTManager, apiKeyAuth), RequireRole("admin"))
apiKeys.GET("", apiKeyHandler.List)
apiKeys.POST("", apiKeyHandler.Create)
apiKeys.PUT("/:id/toggle", apiKeyHandler.Toggle)
apiKeys.DELETE("/:id", apiKeyHandler.Revoke)
}
auditLogs := api.Group("/audit-logs")
auditLogs.Use(AuthMiddleware(deps.JWTManager))
auditLogs.Use(AuthMiddleware(deps.JWTManager, apiKeyAuth))
auditLogs.GET("", auditHandler.List)
auditLogs.GET("/export", auditHandler.Export)
// 实时事件 SSE 流Dashboard 自刷新、桌面告警)
if deps.EventBroadcaster != nil {
eventsHandler := NewEventsHandler(deps.EventBroadcaster)
events := api.Group("/events")
events.Use(AuthMiddleware(deps.JWTManager, apiKeyAuth))
events.GET("/stream", eventsHandler.Stream)
}
// 全局搜索
if deps.SearchService != nil {
searchHandler := NewSearchHandler(deps.SearchService)
searchGroup := api.Group("/search")
searchGroup.Use(AuthMiddleware(deps.JWTManager, apiKeyAuth))
searchGroup.GET("", searchHandler.Search)
}
if deps.DatabaseDiscoveryService != nil {
databaseHandler := NewDatabaseHandler(deps.DatabaseDiscoveryService)
database := api.Group("/database")
database.Use(AuthMiddleware(deps.JWTManager))
database.Use(AuthMiddleware(deps.JWTManager, apiKeyAuth))
database.POST("/discover", databaseHandler.Discover)
}
nodeHandler := NewNodeHandler(deps.NodeService, deps.AuditService)
nodeHandler := NewNodeHandler(deps.NodeService, deps.AuditService, deps.InstallTokenService, deps.UserRepository, deps.MasterExternalURL)
nodes := api.Group("/nodes")
nodes.Use(AuthMiddleware(deps.JWTManager))
nodes.Use(AuthMiddleware(deps.JWTManager, apiKeyAuth))
nodes.GET("", nodeHandler.List)
nodes.GET("/:id", nodeHandler.Get)
nodes.POST("", nodeHandler.Create)
nodes.PUT("/:id", nodeHandler.Update)
nodes.DELETE("/:id", nodeHandler.Delete)
nodes.POST("", RequireRole("admin"), nodeHandler.Create)
nodes.PUT("/:id", RequireRole("admin"), nodeHandler.Update)
nodes.DELETE("/:id", RequireRole("admin"), nodeHandler.Delete)
nodes.GET("/:id/fs/list", nodeHandler.ListDirectory)
nodes.POST("/batch", RequireRole("admin"), nodeHandler.BatchCreate)
nodes.POST("/:id/install-tokens", RequireRole("admin"), nodeHandler.CreateInstallToken)
nodes.POST("/:id/rotate-token", RequireRole("admin"), nodeHandler.RotateToken)
nodes.GET("/:id/install-script-preview", RequireRole("admin"), nodeHandler.PreviewScript)
// Agent APItoken 认证,无需 JWT
if deps.AgentService != nil {
agentHandler := NewAgentHandler(deps.AgentService, deps.NodeService)
agentHandler := NewAgentHandler(deps.AgentService, deps.NodeService, deps.RestoreService)
agent := api.Group("/agent")
agent.POST("/heartbeat", agentHandler.Heartbeat)
agent.POST("/commands/poll", agentHandler.Poll)
agent.POST("/commands/:id/result", agentHandler.SubmitCommandResult)
agent.GET("/tasks/:id", agentHandler.GetTaskSpec)
agent.POST("/records/:id", agentHandler.UpdateRecord)
agent.GET("/restores/:id/spec", agentHandler.GetRestoreSpec)
agent.POST("/restores/:id", agentHandler.UpdateRestore)
// Agent v1安装脚本探活用仅 Self 端点
v1Agent := api.Group("/v1/agent")
v1Agent.GET("/self", agentHandler.Self)
} else {
// 未启用 Agent 服务时,保留原有 heartbeat 端点以兼容
api.POST("/agent/heartbeat", nodeHandler.Heartbeat)
}
}
// 健康检查端点(公开、无认证、低开销)
// K8s/Swarm/Nomad 等编排系统使用这些端点做 liveness/readiness 探测。
healthHandler := NewHealthHandler(deps.DB, deps.Version)
engine.GET("/health", healthHandler.Live)
engine.GET("/ready", healthHandler.Ready)
// 在 /api 下也暴露一份,方便反向代理按 path 前缀统一路由
engine.GET("/api/health", healthHandler.Live)
engine.GET("/api/ready", healthHandler.Ready)
// Prometheus /metrics 端点(公开、无认证;内网/反向代理授权即可)。
// 业内通行做法:/metrics 通常由 Prometheus pull 抓取,不走 API Key。
if deps.Metrics != nil {
engine.GET("/metrics", gin.WrapH(deps.Metrics.Handler()))
}
// 公开安装路由(不走 JWT 中间件)。
// 同时注册到 / 和 /api 前缀下:
// - /install/:token 保留历史 URL兼容旧 nginx 部署
// - /api/install/:token 新 URL自动走反向代理的 /api/ 转发规则
//
// Issue #46用户的 nginx 只转发 /api//install/* 被 SPA fallback 到 index.html
// 返回 HTML 被 sh 解释成 "Syntax error"。使用 /api/install/ 可避开此问题。
if deps.InstallTokenService != nil {
gcCtx := deps.Context
if gcCtx == nil {
gcCtx = context.Background()
}
installHandler := NewInstallHandler(gcCtx, deps.InstallTokenService, deps.AuditService, deps.MasterExternalURL)
engine.GET("/install/:token", installHandler.Script)
engine.GET("/install/:token/compose.yml", installHandler.Compose)
engine.GET("/api/install/:token", installHandler.Script)
engine.GET("/api/install/:token/compose.yml", installHandler.Compose)
}
engine.NoRoute(func(c *gin.Context) {
response.Error(c, apperror.New(stdhttp.StatusNotFound, "NOT_FOUND", "接口不存在", errors.New("route not found")))
})

View File

@@ -16,50 +16,17 @@ import (
"backupx/server/internal/repository"
"backupx/server/internal/security"
"backupx/server/internal/service"
"backupx/server/internal/storage/codec"
"github.com/pquerna/otp/totp"
)
func TestSetupLoginAndProfileFlow(t *testing.T) {
tempDir := t.TempDir()
cfg := config.Config{
Server: config.ServerConfig{Host: "127.0.0.1", Port: 8340, Mode: "test"},
Database: config.DatabaseConfig{Path: filepath.Join(tempDir, "backupx.db")},
Security: config.SecurityConfig{JWTExpire: "24h"},
Log: config.LogConfig{Level: "error"},
}
log, err := logger.New(cfg.Log)
if err != nil {
t.Fatalf("logger.New error: %v", err)
}
db, err := database.Open(cfg.Database, log)
if err != nil {
t.Fatalf("database.Open error: %v", err)
}
userRepo := repository.NewUserRepository(db)
systemConfigRepo := repository.NewSystemConfigRepository(db)
resolved, err := service.ResolveSecurity(context.Background(), cfg.Security, systemConfigRepo)
if err != nil {
t.Fatalf("ResolveSecurity error: %v", err)
}
jwtManager := security.NewJWTManager(resolved.JWTSecret, time.Hour)
authService := service.NewAuthService(userRepo, systemConfigRepo, jwtManager, security.NewLoginRateLimiter(5, time.Minute))
systemService := service.NewSystemService(cfg, "test", time.Now().UTC())
router := NewRouter(RouterDependencies{
Config: cfg,
Version: "test",
Logger: log,
AuthService: authService,
SystemService: systemService,
JWTManager: jwtManager,
UserRepository: userRepo,
SystemConfigRepo: systemConfigRepo,
})
router, _ := newTestHTTPRouter(t)
setupBody, _ := json.Marshal(map[string]string{
"username": "admin",
"password": "password-123",
"username": "admin",
"password": "password-123",
"displayName": "Admin",
})
setupRequest := httptest.NewRequest(http.MethodPost, "/api/auth/setup", bytes.NewBuffer(setupBody))
@@ -92,3 +59,143 @@ func TestSetupLoginAndProfileFlow(t *testing.T) {
t.Fatalf("expected profile 200, got %d", profileRecorder.Code)
}
}
func TestTrustedDeviceCookieSkipsMFA(t *testing.T) {
router, authService := newTestHTTPRouter(t)
if _, err := authService.Setup(context.Background(), service.SetupInput{
Username: "admin", Password: "password-123", DisplayName: "Admin",
}); err != nil {
t.Fatalf("Setup error: %v", err)
}
totpSetup, err := authService.PrepareTwoFactor(context.Background(), "1", service.TwoFactorSetupInput{
CurrentPassword: "password-123",
})
if err != nil {
t.Fatalf("PrepareTwoFactor error: %v", err)
}
enableCode, err := totp.GenerateCode(totpSetup.Secret, time.Now().UTC())
if err != nil {
t.Fatalf("GenerateCode error: %v", err)
}
if _, err := authService.EnableTwoFactor(context.Background(), "1", service.EnableTwoFactorInput{Code: enableCode}); err != nil {
t.Fatalf("EnableTwoFactor error: %v", err)
}
loginCode, err := totp.GenerateCode(totpSetup.Secret, time.Now().UTC())
if err != nil {
t.Fatalf("GenerateCode login error: %v", err)
}
loginBody, _ := json.Marshal(map[string]any{
"username": "admin",
"password": "password-123",
"twoFactorCode": loginCode,
"rememberDevice": true,
"trustedDeviceName": "test browser",
})
loginRequest := httptest.NewRequest(http.MethodPost, "/api/auth/login", bytes.NewBuffer(loginBody))
loginRequest.Header.Set("Content-Type", "application/json")
loginRecorder := httptest.NewRecorder()
router.ServeHTTP(loginRecorder, loginRequest)
if loginRecorder.Code != http.StatusOK {
t.Fatalf("expected login 200, got %d: %s", loginRecorder.Code, loginRecorder.Body.String())
}
trustedCookie := findCookie(loginRecorder.Result().Cookies(), trustedDeviceCookieName)
if trustedCookie == nil {
t.Fatalf("expected trusted device cookie")
}
if !trustedCookie.HttpOnly {
t.Fatalf("expected trusted device cookie to be HttpOnly")
}
if trustedCookie.Path != trustedDeviceCookiePath {
t.Fatalf("expected trusted device cookie path %q, got %q", trustedDeviceCookiePath, trustedCookie.Path)
}
var loginResponse struct {
Data struct {
Token string `json:"token"`
TrustedDeviceToken string `json:"trustedDeviceToken"`
TrustedDevice *service.TrustedDeviceOutput `json:"trustedDevice"`
} `json:"data"`
}
if err := json.Unmarshal(loginRecorder.Body.Bytes(), &loginResponse); err != nil {
t.Fatalf("unmarshal login response: %v", err)
}
if loginResponse.Data.Token == "" || loginResponse.Data.TrustedDevice == nil {
t.Fatalf("expected login token and trusted device metadata")
}
if loginResponse.Data.TrustedDeviceToken != "" {
t.Fatalf("trusted device token should not be exposed in response body")
}
secondBody, _ := json.Marshal(map[string]string{
"username": "admin",
"password": "password-123",
})
secondRequest := httptest.NewRequest(http.MethodPost, "/api/auth/login", bytes.NewBuffer(secondBody))
secondRequest.Header.Set("Content-Type", "application/json")
secondRequest.AddCookie(trustedCookie)
secondRecorder := httptest.NewRecorder()
router.ServeHTTP(secondRecorder, secondRequest)
if secondRecorder.Code != http.StatusOK {
t.Fatalf("expected trusted device login 200, got %d: %s", secondRecorder.Code, secondRecorder.Body.String())
}
}
func newTestHTTPRouter(t *testing.T) (http.Handler, *service.AuthService) {
t.Helper()
tempDir := t.TempDir()
cfg := config.Config{
Server: config.ServerConfig{Host: "127.0.0.1", Port: 8340, Mode: "test"},
Database: config.DatabaseConfig{Path: filepath.Join(tempDir, "backupx.db")},
Security: config.SecurityConfig{JWTExpire: "24h"},
Log: config.LogConfig{Level: "error"},
}
log, err := logger.New(cfg.Log)
if err != nil {
t.Fatalf("logger.New error: %v", err)
}
db, err := database.Open(cfg.Database, log)
if err != nil {
t.Fatalf("database.Open error: %v", err)
}
sqlDB, err := db.DB()
if err != nil {
t.Fatalf("db.DB error: %v", err)
}
t.Cleanup(func() {
_ = sqlDB.Close()
})
userRepo := repository.NewUserRepository(db)
systemConfigRepo := repository.NewSystemConfigRepository(db)
resolved, err := service.ResolveSecurity(context.Background(), cfg.Security, systemConfigRepo)
if err != nil {
t.Fatalf("ResolveSecurity error: %v", err)
}
jwtManager := security.NewJWTManager(resolved.JWTSecret, time.Hour)
authService := service.NewAuthService(userRepo, systemConfigRepo, jwtManager, security.NewLoginRateLimiter(5, time.Minute), codec.NewConfigCipher(resolved.EncryptionKey))
systemService := service.NewSystemService(cfg, "test", time.Now().UTC())
router := NewRouter(RouterDependencies{
Config: cfg,
Version: "test",
Logger: log,
AuthService: authService,
SystemService: systemService,
JWTManager: jwtManager,
UserRepository: userRepo,
SystemConfigRepo: systemConfigRepo,
})
return router, authService
}
func findCookie(cookies []*http.Cookie, name string) *http.Cookie {
for _, cookie := range cookies {
if cookie.Name == name {
return cookie
}
}
return nil
}

View File

@@ -0,0 +1,28 @@
package http
import (
"backupx/server/internal/service"
"backupx/server/pkg/response"
"github.com/gin-gonic/gin"
)
// SearchHandler 全局搜索。
type SearchHandler struct {
service *service.SearchService
}
func NewSearchHandler(s *service.SearchService) *SearchHandler {
return &SearchHandler{service: s}
}
// Search GET /search?q=关键字
func (h *SearchHandler) Search(c *gin.Context) {
query := c.Query("q")
result, err := h.service.Search(c.Request.Context(), query)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, result)
}

View File

@@ -0,0 +1,101 @@
package http
import (
"encoding/json"
"fmt"
"io"
stdhttp "net/http"
"strconv"
"strings"
"time"
"backupx/server/internal/apperror"
"backupx/server/internal/service"
"backupx/server/pkg/response"
"github.com/gin-gonic/gin"
)
// TaskExportHandler 提供任务配置 JSON 导入/导出。
type TaskExportHandler struct {
service *service.TaskExportService
auditService *service.AuditService
}
func NewTaskExportHandler(s *service.TaskExportService, audit *service.AuditService) *TaskExportHandler {
return &TaskExportHandler{service: s, auditService: audit}
}
// Export GET /api/backup/tasks/export?ids=1,2,3
// 无 ids 参数时导出全部任务。返回 application/json + Content-Disposition。
func (h *TaskExportHandler) Export(c *gin.Context) {
var taskIDs []uint
if v := strings.TrimSpace(c.Query("ids")); v != "" {
for _, part := range strings.Split(v, ",") {
if id, err := strconv.ParseUint(strings.TrimSpace(part), 10, 32); err == nil {
taskIDs = append(taskIDs, uint(id))
}
}
}
payload, err := h.service.Export(c.Request.Context(), taskIDs)
if err != nil {
response.Error(c, err)
return
}
data, err := json.MarshalIndent(payload, "", " ")
if err != nil {
response.Error(c, apperror.Internal("TASK_EXPORT_MARSHAL_FAILED", "无法序列化导出内容", err))
return
}
filename := fmt.Sprintf("backupx-tasks-%s.json", time.Now().UTC().Format("20060102-150405"))
c.Header("Content-Type", "application/json; charset=utf-8")
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename))
_, _ = c.Writer.Write(data)
recordAudit(c, h.auditService, "backup_task", "export", "backup_task", "", "",
fmt.Sprintf("导出 %d 个任务的配置为 JSON", payload.TaskCount))
}
// Import POST /api/backup/tasks/import
// Body: ExportPayload JSON。返回每个任务的创建/跳过结果。
func (h *TaskExportHandler) Import(c *gin.Context) {
body, err := io.ReadAll(c.Request.Body)
if err != nil {
response.Error(c, apperror.BadRequest("TASK_IMPORT_INVALID", "无法读取请求体", err))
return
}
if len(body) == 0 {
response.Error(c, apperror.BadRequest("TASK_IMPORT_INVALID", "请求体为空", nil))
return
}
if len(body) > 1024*1024 { // 1MB 上限
c.Writer.WriteHeader(stdhttp.StatusRequestEntityTooLarge)
response.Error(c, apperror.BadRequest("TASK_IMPORT_TOO_LARGE", "导入文件过大(上限 1MB", nil))
return
}
var payload service.ExportPayload
if err := json.Unmarshal(body, &payload); err != nil {
response.Error(c, apperror.BadRequest("TASK_IMPORT_INVALID", "JSON 格式不合法", err))
return
}
if len(payload.Tasks) == 0 {
response.Error(c, apperror.BadRequest("TASK_IMPORT_INVALID", "文件中未包含任何任务", nil))
return
}
results, err := h.service.Import(c.Request.Context(), payload)
if err != nil {
response.Error(c, err)
return
}
succ := 0
skipped := 0
for _, r := range results {
if r.Success && !r.Skipped {
succ++
} else if r.Skipped {
skipped++
}
}
recordAudit(c, h.auditService, "backup_task", "import", "backup_task", "", "",
fmt.Sprintf("从 JSON 导入任务:创建 %d / 跳过 %d / 失败 %d", succ, skipped, len(results)-succ-skipped))
response.Success(c, results)
}

View File

@@ -0,0 +1,125 @@
package http
import (
"fmt"
"backupx/server/internal/apperror"
"backupx/server/internal/service"
"backupx/server/pkg/response"
"github.com/gin-gonic/gin"
)
type TaskTemplateHandler struct {
service *service.TaskTemplateService
auditService *service.AuditService
}
func NewTaskTemplateHandler(templateService *service.TaskTemplateService, auditService *service.AuditService) *TaskTemplateHandler {
return &TaskTemplateHandler{service: templateService, auditService: auditService}
}
func (h *TaskTemplateHandler) List(c *gin.Context) {
items, err := h.service.List(c.Request.Context())
if err != nil {
response.Error(c, err)
return
}
response.Success(c, items)
}
func (h *TaskTemplateHandler) Get(c *gin.Context) {
id, ok := parseUintParam(c, "id")
if !ok {
return
}
item, err := h.service.Get(c.Request.Context(), id)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, item)
}
func (h *TaskTemplateHandler) Create(c *gin.Context) {
var input service.TaskTemplateUpsertInput
if err := c.ShouldBindJSON(&input); err != nil {
response.Error(c, apperror.BadRequest("TASK_TEMPLATE_INVALID", "模板参数不合法", err))
return
}
creator := ""
if v, ok := c.Get(contextUsernameKey); ok {
if s, ok := v.(string); ok {
creator = s
}
}
item, err := h.service.Create(c.Request.Context(), creator, input)
if err != nil {
response.Error(c, err)
return
}
recordAudit(c, h.auditService, "task_template", "create", "task_template", fmt.Sprintf("%d", item.ID), item.Name,
fmt.Sprintf("创建任务模板: %s (类型: %s)", item.Name, item.TaskType))
response.Success(c, item)
}
func (h *TaskTemplateHandler) Update(c *gin.Context) {
id, ok := parseUintParam(c, "id")
if !ok {
return
}
var input service.TaskTemplateUpsertInput
if err := c.ShouldBindJSON(&input); err != nil {
response.Error(c, apperror.BadRequest("TASK_TEMPLATE_INVALID", "模板参数不合法", err))
return
}
item, err := h.service.Update(c.Request.Context(), id, input)
if err != nil {
response.Error(c, err)
return
}
recordAudit(c, h.auditService, "task_template", "update", "task_template", fmt.Sprintf("%d", item.ID), item.Name,
fmt.Sprintf("更新任务模板: %s", item.Name))
response.Success(c, item)
}
func (h *TaskTemplateHandler) Delete(c *gin.Context) {
id, ok := parseUintParam(c, "id")
if !ok {
return
}
if err := h.service.Delete(c.Request.Context(), id); err != nil {
response.Error(c, err)
return
}
recordAudit(c, h.auditService, "task_template", "delete", "task_template", fmt.Sprintf("%d", id), "",
fmt.Sprintf("删除任务模板 (ID: %d)", id))
response.Success(c, gin.H{"deleted": true})
}
// Apply 一键批量创建任务。Body: {variables: [{name, sourcePath, ...}, ...]}
func (h *TaskTemplateHandler) Apply(c *gin.Context) {
id, ok := parseUintParam(c, "id")
if !ok {
return
}
var input service.TaskTemplateApplyInput
if err := c.ShouldBindJSON(&input); err != nil {
response.Error(c, apperror.BadRequest("TASK_TEMPLATE_INVALID", "应用参数不合法", err))
return
}
results, err := h.service.Apply(c.Request.Context(), id, input)
if err != nil {
response.Error(c, err)
return
}
successCount := 0
for _, r := range results {
if r.Success {
successCount++
}
}
recordAudit(c, h.auditService, "task_template", "apply", "task_template", fmt.Sprintf("%d", id), "",
fmt.Sprintf("应用模板批量创建任务(成功 %d/%d", successCount, len(results)))
response.Success(c, results)
}

View File

@@ -0,0 +1,95 @@
package http
import (
"fmt"
"backupx/server/internal/apperror"
"backupx/server/internal/service"
"backupx/server/pkg/response"
"github.com/gin-gonic/gin"
)
// UserHandler 管理账号(仅 admin 可访问)。
type UserHandler struct {
service *service.UserService
auditService *service.AuditService
}
func NewUserHandler(userService *service.UserService, auditService *service.AuditService) *UserHandler {
return &UserHandler{service: userService, auditService: auditService}
}
func (h *UserHandler) List(c *gin.Context) {
items, err := h.service.List(c.Request.Context())
if err != nil {
response.Error(c, err)
return
}
response.Success(c, items)
}
func (h *UserHandler) Create(c *gin.Context) {
var input service.UserUpsertInput
if err := c.ShouldBindJSON(&input); err != nil {
response.Error(c, apperror.BadRequest("USER_INVALID", "用户参数不合法", err))
return
}
item, err := h.service.Create(c.Request.Context(), input)
if err != nil {
response.Error(c, err)
return
}
recordAudit(c, h.auditService, "user", "create", "user", fmt.Sprintf("%d", item.ID), item.Username,
fmt.Sprintf("创建用户 %s (角色: %s)", item.Username, item.Role))
response.Success(c, item)
}
func (h *UserHandler) Update(c *gin.Context) {
id, ok := parseUintParam(c, "id")
if !ok {
return
}
var input service.UserUpsertInput
if err := c.ShouldBindJSON(&input); err != nil {
response.Error(c, apperror.BadRequest("USER_INVALID", "用户参数不合法", err))
return
}
item, err := h.service.Update(c.Request.Context(), id, input)
if err != nil {
response.Error(c, err)
return
}
recordAudit(c, h.auditService, "user", "update", "user", fmt.Sprintf("%d", id), item.Username,
fmt.Sprintf("更新用户 %s (角色: %s, 停用: %v)", item.Username, item.Role, item.Disabled))
response.Success(c, item)
}
func (h *UserHandler) Delete(c *gin.Context) {
id, ok := parseUintParam(c, "id")
if !ok {
return
}
if err := h.service.Delete(c.Request.Context(), id); err != nil {
response.Error(c, err)
return
}
recordAudit(c, h.auditService, "user", "delete", "user", fmt.Sprintf("%d", id), "",
fmt.Sprintf("删除用户 (ID: %d)", id))
response.Success(c, gin.H{"deleted": true})
}
func (h *UserHandler) ResetTwoFactor(c *gin.Context) {
id, ok := parseUintParam(c, "id")
if !ok {
return
}
item, err := h.service.ResetTwoFactor(c.Request.Context(), id)
if err != nil {
response.Error(c, err)
return
}
recordAudit(c, h.auditService, "user", "reset_two_factor", "user", fmt.Sprintf("%d", id), item.Username,
fmt.Sprintf("重置用户 %s 的 MFA", item.Username))
response.Success(c, item)
}

View File

@@ -0,0 +1,207 @@
package http
import (
"encoding/json"
"fmt"
"io"
"strconv"
"strings"
"time"
"backupx/server/internal/apperror"
"backupx/server/internal/backup"
"backupx/server/internal/service"
"backupx/server/pkg/response"
"github.com/gin-gonic/gin"
)
// VerificationHandler 提供验证记录列表/详情/SSE以及手动触发入口。
type VerificationHandler struct {
service *service.VerificationService
auditService *service.AuditService
}
func NewVerificationHandler(verifyService *service.VerificationService, auditService *service.AuditService) *VerificationHandler {
return &VerificationHandler{service: verifyService, auditService: auditService}
}
// TriggerByTask 接收任务级手动触发。使用最新成功备份为源。
func (h *VerificationHandler) TriggerByTask(c *gin.Context) {
taskID, ok := parseUintParam(c, "id")
if !ok {
return
}
var input struct {
Mode string `json:"mode"`
}
_ = c.ShouldBindJSON(&input)
triggeredBy := ""
if subject, exists := c.Get(contextUserSubjectKey); exists {
triggeredBy = strings.TrimSpace(fmt.Sprintf("%v", subject))
}
if triggeredBy == "" {
triggeredBy = "manual"
}
detail, err := h.service.StartByTask(c.Request.Context(), taskID, input.Mode, triggeredBy)
if err != nil {
response.Error(c, err)
return
}
recordAudit(c, h.auditService, "backup_verify", "manual_run", "backup_task", fmt.Sprintf("%d", taskID), "",
fmt.Sprintf("手动触发验证(任务 ID: %d, 验证记录 ID: %d, 模式: %s", taskID, detail.ID, detail.Mode))
response.Success(c, detail)
}
// TriggerByRecord 基于指定备份记录触发验证(允许验证历史备份)。
func (h *VerificationHandler) TriggerByRecord(c *gin.Context) {
recordID, ok := parseUintParam(c, "id")
if !ok {
return
}
var input struct {
Mode string `json:"mode"`
}
_ = c.ShouldBindJSON(&input)
triggeredBy := ""
if subject, exists := c.Get(contextUserSubjectKey); exists {
triggeredBy = strings.TrimSpace(fmt.Sprintf("%v", subject))
}
if triggeredBy == "" {
triggeredBy = "manual"
}
detail, err := h.service.Start(c.Request.Context(), recordID, input.Mode, triggeredBy)
if err != nil {
response.Error(c, err)
return
}
recordAudit(c, h.auditService, "backup_verify", "manual_run", "backup_record", fmt.Sprintf("%d", recordID), "",
fmt.Sprintf("手动触发验证(备份记录 ID: %d, 验证记录 ID: %d, 模式: %s", recordID, detail.ID, detail.Mode))
response.Success(c, detail)
}
func (h *VerificationHandler) List(c *gin.Context) {
filter, err := buildVerifyFilter(c)
if err != nil {
response.Error(c, err)
return
}
items, err := h.service.List(c.Request.Context(), filter)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, items)
}
func (h *VerificationHandler) Get(c *gin.Context) {
id, ok := parseUintParam(c, "id")
if !ok {
return
}
item, err := h.service.Get(c.Request.Context(), id)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, item)
}
func (h *VerificationHandler) StreamLogs(c *gin.Context) {
id, ok := parseUintParam(c, "id")
if !ok {
return
}
detail, err := h.service.Get(c.Request.Context(), id)
if err != nil {
response.Error(c, err)
return
}
events := detail.LogEvents
completed := detail.Status != "running"
channel, cancel, err := h.service.SubscribeLogs(c.Request.Context(), id, 64)
if err != nil {
response.Error(c, err)
return
}
defer cancel()
c.Writer.Header().Set("Content-Type", "text/event-stream")
c.Writer.Header().Set("Cache-Control", "no-cache")
c.Writer.Header().Set("Connection", "keep-alive")
flusher, ok := c.Writer.(interface{ Flush() })
if !ok {
response.Error(c, apperror.Internal("VERIFY_STREAM_UNSUPPORTED", "当前连接不支持日志流", nil))
return
}
for _, event := range events {
if err := writeVerifySSEEvent(c.Writer, event); err != nil {
return
}
flusher.Flush()
}
if completed {
return
}
for {
select {
case <-c.Request.Context().Done():
return
case event, ok := <-channel:
if !ok {
return
}
if err := writeVerifySSEEvent(c.Writer, event); err != nil {
return
}
flusher.Flush()
if event.Completed {
return
}
}
}
}
func buildVerifyFilter(c *gin.Context) (service.VerificationRecordListInput, error) {
var filter service.VerificationRecordListInput
if value := strings.TrimSpace(c.Query("taskId")); value != "" {
parsed, err := strconv.ParseUint(value, 10, 32)
if err != nil {
return filter, apperror.BadRequest("VERIFY_RECORD_FILTER_INVALID", "taskId 不合法", err)
}
v := uint(parsed)
filter.TaskID = &v
}
if value := strings.TrimSpace(c.Query("backupRecordId")); value != "" {
parsed, err := strconv.ParseUint(value, 10, 32)
if err != nil {
return filter, apperror.BadRequest("VERIFY_RECORD_FILTER_INVALID", "backupRecordId 不合法", err)
}
v := uint(parsed)
filter.BackupRecordID = &v
}
filter.Status = strings.TrimSpace(c.Query("status"))
if dateFrom := strings.TrimSpace(c.Query("dateFrom")); dateFrom != "" {
parsed, err := time.Parse(time.RFC3339, dateFrom)
if err != nil {
return filter, apperror.BadRequest("VERIFY_RECORD_FILTER_INVALID", "dateFrom 必须为 RFC3339 时间格式", err)
}
filter.DateFrom = &parsed
}
if dateTo := strings.TrimSpace(c.Query("dateTo")); dateTo != "" {
parsed, err := time.Parse(time.RFC3339, dateTo)
if err != nil {
return filter, apperror.BadRequest("VERIFY_RECORD_FILTER_INVALID", "dateTo 必须为 RFC3339 时间格式", err)
}
filter.DateTo = &parsed
}
return filter, nil
}
func writeVerifySSEEvent(writer io.Writer, event backup.LogEvent) error {
payload, err := json.Marshal(event)
if err != nil {
return err
}
_, err = fmt.Fprintf(writer, "event: log\ndata: %s\n\n", payload)
return err
}

View File

@@ -0,0 +1,55 @@
package installscript
import (
"strings"
"testing"
"backupx/server/internal/model"
)
// TestRenderScriptIncludesMagicMarker 渲染脚本必须包含 Issue #46 引入的魔数注释,
// 方便用户通过 `head -3 脚本` 自查是否被中间层改写。
func TestRenderScriptIncludesMagicMarker(t *testing.T) {
for _, mode := range []string{model.InstallModeSystemd, model.InstallModeDocker, model.InstallModeForeground} {
ctx := testCtx
ctx.Mode = mode
got, err := RenderScript(ctx)
if err != nil {
t.Fatalf("render err (%s): %v", mode, err)
}
if !strings.Contains(got, "BACKUPX_AGENT_INSTALL_V1") {
t.Errorf("mode=%s: script missing magic marker:\n%s", mode, got)
}
}
}
// TestRenderScriptBashBootstrap 脚本顶部必须有 bash 自举段,文件执行时跳到 bash。
func TestRenderScriptBashBootstrap(t *testing.T) {
got, err := RenderScript(testCtx)
if err != nil {
t.Fatalf("render err: %v", err)
}
if !strings.Contains(got, `[ -z "${BASH_VERSION:-}" ]`) {
t.Errorf("script missing bash bootstrap guard:\n%s", got)
}
if !strings.Contains(got, `exec bash "$0" "$@"`) {
t.Errorf("script missing exec bash fallback:\n%s", got)
}
}
func TestRenderScriptCreatesBackupXUserAndGroup(t *testing.T) {
got, err := RenderScript(testCtx)
if err != nil {
t.Fatalf("render err: %v", err)
}
for _, want := range []string{
"getent group backupx",
"groupadd --system backupx",
"useradd --system --gid backupx",
"Group=backupx",
} {
if !strings.Contains(got, want) {
t.Errorf("script missing %q:\n%s", want, got)
}
}
}

View File

@@ -0,0 +1,170 @@
// Package installscript 负责把一次性安装令牌 + 节点配置渲染为可执行 shell 脚本或 docker-compose YAML。
//
// 模板文件通过 go:embed 嵌入二进制,避免运行时依赖外部资源。
package installscript
import (
"bytes"
_ "embed"
"fmt"
"net/url"
"strings"
"text/template"
"backupx/server/internal/model"
)
//go:embed templates/agent-install.sh.tmpl
var installScriptTmpl string
//go:embed templates/agent-compose.yml.tmpl
var composeYamlTmpl string
// Context 是模板渲染输入。
type Context struct {
MasterURL string
AgentToken string
AgentVersion string
Mode string // systemd|docker|foreground
Arch string // amd64|arm64|auto
DownloadBase string
InstallPrefix string
NodeID uint
}
// DownloadBaseFor 将下载源枚举转换为具体 URL 前缀。
func DownloadBaseFor(src string) string {
switch src {
case model.InstallSourceGhproxy:
return "https://ghproxy.com/https://github.com/Awuqing/BackupX/releases/download"
default:
return "https://github.com/Awuqing/BackupX/releases/download"
}
}
// RenderScript 渲染目标机安装脚本。
func RenderScript(ctx Context) (string, error) {
ctx = withDefaults(ctx)
if err := validateContext(ctx); err != nil {
return "", err
}
tmpl, err := template.New("install").Parse(installScriptTmpl)
if err != nil {
return "", fmt.Errorf("parse template: %w", err)
}
var buf bytes.Buffer
if err := tmpl.Execute(&buf, ctx); err != nil {
return "", fmt.Errorf("execute template: %w", err)
}
return buf.String(), nil
}
// RenderComposeYaml 渲染 docker-compose.yml 片段。
func RenderComposeYaml(ctx Context) (string, error) {
ctx = withDefaults(ctx)
if err := validateContext(ctx); err != nil {
return "", err
}
tmpl, err := template.New("compose").Parse(composeYamlTmpl)
if err != nil {
return "", fmt.Errorf("parse template: %w", err)
}
var buf bytes.Buffer
if err := tmpl.Execute(&buf, ctx); err != nil {
return "", fmt.Errorf("execute template: %w", err)
}
return buf.String(), nil
}
// validateContext 对模板变量做安全校验,防止 YAML/shell 注入。
// - MasterURL必须是合法 http(s) URL无控制字符
// - AgentToken仅允许 hex 字符,最长 128
// - AgentVersion仅允许 tag 常见字符(字母数字、点、连字符、下划线、加号)
//
// 这些字段被直接写入 shell 双引号字符串和 YAML 双引号值;不做校验会带来
// 注入风险(如 MasterURL 含 `"\nCOMMAND:` 可逃逸 YAML 结构)。
func validateContext(ctx Context) error {
if err := validateMasterURL(ctx.MasterURL); err != nil {
return err
}
if err := validateAgentToken(ctx.AgentToken); err != nil {
return err
}
if err := validateAgentVersion(ctx.AgentVersion); err != nil {
return err
}
return nil
}
func validateMasterURL(raw string) error {
raw = strings.TrimSpace(raw)
if raw == "" {
return fmt.Errorf("master URL empty")
}
if strings.ContainsAny(raw, " \t\r\n\"'`$\\") {
return fmt.Errorf("master URL contains illegal characters")
}
u, err := url.Parse(raw)
if err != nil {
return fmt.Errorf("invalid master URL: %w", err)
}
if u.Scheme != "http" && u.Scheme != "https" {
return fmt.Errorf("master URL scheme must be http or https, got %q", u.Scheme)
}
if u.Host == "" {
return fmt.Errorf("master URL missing host")
}
return nil
}
// validateAgentToken 允许占位符 <AGENT_TOKEN>PreviewScript 使用),
// 或 32 字节 hex64 字符)+ 小幅兼容16-128 hex 字符)
func validateAgentToken(tok string) error {
if tok == "<AGENT_TOKEN>" {
return nil
}
if len(tok) < 8 || len(tok) > 128 {
return fmt.Errorf("agent token length out of range")
}
for _, c := range tok {
switch {
case c >= '0' && c <= '9':
case c >= 'a' && c <= 'f':
case c >= 'A' && c <= 'F':
default:
return fmt.Errorf("agent token must be hex")
}
}
return nil
}
func validateAgentVersion(v string) error {
v = strings.TrimSpace(v)
if v == "" {
return fmt.Errorf("agent version empty")
}
if len(v) > 64 {
return fmt.Errorf("agent version too long")
}
for _, c := range v {
switch {
case c >= '0' && c <= '9':
case c >= 'a' && c <= 'z':
case c >= 'A' && c <= 'Z':
case c == '.' || c == '-' || c == '_' || c == '+':
default:
return fmt.Errorf("agent version contains illegal char %q", c)
}
}
return nil
}
func withDefaults(ctx Context) Context {
if ctx.InstallPrefix == "" {
ctx.InstallPrefix = "/opt/backupx-agent"
}
if ctx.DownloadBase == "" {
ctx.DownloadBase = DownloadBaseFor(model.InstallSourceGitHub)
}
return ctx
}

View File

@@ -0,0 +1,176 @@
package installscript
import (
"strings"
"testing"
"backupx/server/internal/model"
)
// 使用合法 hex token32 字节 = 64 字符)以通过 validateAgentToken 校验
var testCtx = Context{
MasterURL: "https://master.example.com",
AgentToken: "deadbeefcafebabe0123456789abcdef0123456789abcdef0123456789abcdef",
AgentVersion: "v1.7.0",
Mode: model.InstallModeSystemd,
Arch: model.InstallArchAuto,
DownloadBase: "https://github.com/Awuqing/BackupX/releases/download",
InstallPrefix: "/opt/backupx-agent",
NodeID: 42,
}
func TestRenderScriptSystemd(t *testing.T) {
got, err := RenderScript(testCtx)
if err != nil {
t.Fatalf("render err: %v", err)
}
mustContain := []string{
"BACKUPX_AGENT_MASTER=${MASTER_URL}",
`Environment="BACKUPX_AGENT_TOKEN=${AGENT_TOKEN}"`,
"systemctl daemon-reload",
"systemctl enable --now backupx-agent",
"X-Agent-Token: ${AGENT_TOKEN}",
"MASTER_URL=\"https://master.example.com\"",
"AGENT_TOKEN=\"deadbeefcafebabe0123456789abcdef0123456789abcdef0123456789abcdef\"",
}
for _, s := range mustContain {
if !strings.Contains(got, s) {
t.Errorf("systemd script missing %q", s)
}
}
mustNotContain := []string{"docker run", `exec "${INSTALL_PREFIX}/backupx" agent --temp-dir`}
for _, s := range mustNotContain {
if strings.Contains(got, s) {
t.Errorf("systemd script unexpectedly contains %q", s)
}
}
}
func TestRenderScriptForeground(t *testing.T) {
ctx := testCtx
ctx.Mode = model.InstallModeForeground
got, err := RenderScript(ctx)
if err != nil {
t.Fatalf("render err: %v", err)
}
if !strings.Contains(got, `exec "${INSTALL_PREFIX}/backupx" agent`) {
t.Errorf("foreground script missing exec line:\n%s", got)
}
if strings.Contains(got, "systemctl daemon-reload") {
t.Errorf("foreground script should not reference systemctl:\n%s", got)
}
if strings.Contains(got, "docker run") {
t.Errorf("foreground script should not reference docker:\n%s", got)
}
}
func TestRenderScriptDocker(t *testing.T) {
ctx := testCtx
ctx.Mode = model.InstallModeDocker
got, err := RenderScript(ctx)
if err != nil {
t.Fatalf("render err: %v", err)
}
if !strings.Contains(got, "docker run") {
t.Errorf("docker script missing `docker run`:\n%s", got)
}
if !strings.Contains(got, "awuqing/backupx:${AGENT_VERSION}") {
t.Errorf("docker script missing image tag reference:\n%s", got)
}
if strings.Contains(got, "systemctl daemon-reload") {
t.Errorf("docker script should not reference systemctl:\n%s", got)
}
}
func TestRenderComposeYaml(t *testing.T) {
ctx := testCtx
ctx.Mode = model.InstallModeDocker
got, err := RenderComposeYaml(ctx)
if err != nil {
t.Fatalf("render err: %v", err)
}
if !strings.Contains(got, "image: awuqing/backupx:v1.7.0") {
t.Errorf("compose missing image:\n%s", got)
}
if !strings.Contains(got, `BACKUPX_AGENT_TOKEN: "deadbeefcafebabe0123456789abcdef0123456789abcdef0123456789abcdef"`) {
t.Errorf("compose missing token env:\n%s", got)
}
}
func TestRenderScriptRejectsInjectedMasterURL(t *testing.T) {
bad := []string{
"https://example.com\" other: inject", // 含引号和空格
"javascript:alert(1)", // scheme 非法
"https://example.com\n- privileged", // 含换行YAML 注入经典 payload
"", // 空
}
for _, u := range bad {
ctx := testCtx
ctx.MasterURL = u
if _, err := RenderScript(ctx); err == nil {
t.Errorf("RenderScript should reject MasterURL %q", u)
}
}
}
func TestRenderComposeYamlRejectsInjectedMasterURL(t *testing.T) {
ctx := testCtx
ctx.Mode = model.InstallModeDocker
ctx.MasterURL = "https://example.com\n- privileged: true"
if _, err := RenderComposeYaml(ctx); err == nil {
t.Errorf("RenderComposeYaml should reject injected MasterURL")
}
}
func TestRenderScriptRejectsBadToken(t *testing.T) {
ctx := testCtx
ctx.AgentToken = "not-hex-token" // 非 hex
if _, err := RenderScript(ctx); err == nil {
t.Errorf("should reject non-hex agent token")
}
}
func TestRenderScriptAcceptsPlaceholderToken(t *testing.T) {
ctx := testCtx
ctx.AgentToken = "<AGENT_TOKEN>" // Preview 占位符
if _, err := RenderScript(ctx); err != nil {
t.Errorf("should accept placeholder token: %v", err)
}
}
func TestRenderScriptRejectsBadVersion(t *testing.T) {
ctx := testCtx
ctx.AgentVersion = "v1.7 && rm -rf /" // 含非法字符
if _, err := RenderScript(ctx); err == nil {
t.Errorf("should reject version with shell metacharacters")
}
}
func TestDownloadBaseMapping(t *testing.T) {
cases := map[string]string{
model.InstallSourceGitHub: "https://github.com/Awuqing/BackupX/releases/download",
model.InstallSourceGhproxy: "https://ghproxy.com/https://github.com/Awuqing/BackupX/releases/download",
}
for src, want := range cases {
got := DownloadBaseFor(src)
if got != want {
t.Errorf("src=%s want=%s got=%s", src, want, got)
}
}
}
func TestRenderScriptDefaultsApplied(t *testing.T) {
ctx := testCtx
ctx.InstallPrefix = "" // 应被默认为 /opt/backupx-agent
ctx.DownloadBase = "" // 应被默认为 github
got, err := RenderScript(ctx)
if err != nil {
t.Fatalf("render err: %v", err)
}
if !strings.Contains(got, "INSTALL_PREFIX=\"/opt/backupx-agent\"") {
t.Errorf("default InstallPrefix not applied:\n%s", got)
}
if !strings.Contains(got, "DOWNLOAD_BASE=\"https://github.com/Awuqing/BackupX/releases/download\"") {
t.Errorf("default DownloadBase not applied:\n%s", got)
}
}

View File

@@ -0,0 +1,13 @@
# BackupX Agent docker-compose 片段
# 生成于 {{.MasterURL}} · 节点 ID {{.NodeID}}
version: "3.8"
services:
backupx-agent:
image: awuqing/backupx:{{.AgentVersion}}
command: ["agent"]
restart: unless-stopped
environment:
BACKUPX_AGENT_MASTER: "{{.MasterURL}}"
BACKUPX_AGENT_TOKEN: "{{.AgentToken}}"
volumes:
- /var/lib/backupx-agent:/tmp/backupx-agent

View File

@@ -0,0 +1,137 @@
#!/bin/sh
# BackupX Agent 一键安装脚本(由 Master 动态渲染)
# Magic: BACKUPX_AGENT_INSTALL_V1 —— 若 `head -3 脚本` 看不到此行,说明反向代理/CDN 改写了响应
# 模式: {{.Mode}} | 架构: {{.Arch}} | 版本: {{.AgentVersion}}
set -eu
# 自举到 bash文件执行模式下生效管道模式 $0 不是文件exec 会静默失败,继续用 sh
# 动机:部分 Debian/Ubuntu 用户通过 `curl | sudo sh` 触发时dash 对本脚本报语法错误;
# 若目标机装有 bash优先切换到 bash 获得更一致的行为。
if [ -z "${BASH_VERSION:-}" ] && command -v bash >/dev/null 2>&1 && [ -f "$0" ]; then
exec bash "$0" "$@"
fi
MASTER_URL="{{.MasterURL}}"
AGENT_TOKEN="{{.AgentToken}}"
AGENT_VERSION="{{.AgentVersion}}"
DOWNLOAD_BASE="{{.DownloadBase}}"
INSTALL_PREFIX="{{.InstallPrefix}}"
ARCH="{{.Arch}}"
# 1. 前置检查
[ "$(id -u)" -eq 0 ] || { echo "请使用 root 或 sudo 执行" >&2; exit 1; }
command -v curl >/dev/null || command -v wget >/dev/null \
|| { echo "需要 curl 或 wget" >&2; exit 1; }
{{if eq .Mode "systemd"}}command -v systemctl >/dev/null || { echo "不支持非 systemd 系统" >&2; exit 1; }
{{end}}{{if eq .Mode "docker"}}command -v docker >/dev/null || { echo "需要先安装 docker" >&2; exit 1; }
{{end}}
# 2. 架构检测
if [ "$ARCH" = "auto" ]; then
case "$(uname -m)" in
x86_64|amd64) ARCH=amd64 ;;
aarch64|arm64) ARCH=arm64 ;;
*) echo "不支持的架构: $(uname -m)" >&2; exit 1 ;;
esac
fi
{{if ne .Mode "docker"}}
# 3. 下载二进制systemd / foreground 模式)
ARCHIVE="backupx-${AGENT_VERSION}-linux-${ARCH}.tar.gz"
URL="${DOWNLOAD_BASE}/${AGENT_VERSION}/${ARCHIVE}"
TMPDIR="$(mktemp -d)"; trap 'rm -rf "$TMPDIR"' EXIT
echo "[1/4] 下载 ${URL}"
if command -v curl >/dev/null; then
curl -fsSL "$URL" -o "$TMPDIR/pkg.tar.gz"
else
wget -qO "$TMPDIR/pkg.tar.gz" "$URL"
fi
tar xzf "$TMPDIR/pkg.tar.gz" -C "$TMPDIR"
# 4. 安装二进制 + 用户
echo "[2/4] 安装到 ${INSTALL_PREFIX}"
if ! getent group backupx >/dev/null 2>&1; then
if command -v groupadd >/dev/null 2>&1; then
groupadd --system backupx
elif command -v addgroup >/dev/null 2>&1; then
addgroup --system backupx
else
echo "需要 groupadd 或 addgroup 来创建 backupx 组" >&2
exit 1
fi
fi
if ! id backupx >/dev/null 2>&1; then
if command -v useradd >/dev/null 2>&1; then
useradd --system --gid backupx --home-dir "$INSTALL_PREFIX" --shell /usr/sbin/nologin backupx
elif command -v adduser >/dev/null 2>&1; then
adduser --system --ingroup backupx --home "$INSTALL_PREFIX" --shell /usr/sbin/nologin backupx
else
echo "需要 useradd 或 adduser 来创建 backupx 用户" >&2
exit 1
fi
fi
id backupx >/dev/null 2>&1 || { echo "backupx 用户创建失败" >&2; exit 1; }
install -d -o backupx -g backupx "$INSTALL_PREFIX" /var/lib/backupx-agent
install -m 0755 "$TMPDIR/backupx-${AGENT_VERSION}-linux-${ARCH}/backupx" "$INSTALL_PREFIX/backupx"
{{end}}
{{if eq .Mode "systemd"}}
# 5. systemd unit
echo "[3/4] 配置 systemd"
cat > /etc/systemd/system/backupx-agent.service <<UNIT
[Unit]
Description=BackupX Agent
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=backupx
Group=backupx
Environment="BACKUPX_AGENT_MASTER=${MASTER_URL}"
Environment="BACKUPX_AGENT_TOKEN=${AGENT_TOKEN}"
ExecStart=${INSTALL_PREFIX}/backupx agent --temp-dir /var/lib/backupx-agent
Restart=on-failure
RestartSec=10s
NoNewPrivileges=true
[Install]
WantedBy=multi-user.target
UNIT
systemctl daemon-reload
systemctl enable --now backupx-agent
# 6. 等待上线
echo "[4/4] 等待节点上线"
for i in $(seq 1 15); do
sleep 2
if curl -fsSL -H "X-Agent-Token: ${AGENT_TOKEN}" "${MASTER_URL}/api/v1/agent/self" 2>/dev/null \
| grep -q '"status":"online"'; then
echo "✓ 节点已上线"
exit 0
fi
done
echo "⚠ 30s 内未收到上线心跳,请检查防火墙或 journalctl -u backupx-agent"
exit 2
{{end}}
{{if eq .Mode "foreground"}}
# 5. 前台运行
echo "[3/3] 前台启动 agentCtrl+C 退出)"
export BACKUPX_AGENT_MASTER="${MASTER_URL}"
export BACKUPX_AGENT_TOKEN="${AGENT_TOKEN}"
exec "${INSTALL_PREFIX}/backupx" agent --temp-dir /var/lib/backupx-agent
{{end}}
{{if eq .Mode "docker"}}
# Docker 模式:直接用镜像启动容器
echo "[1/2] 拉取镜像 awuqing/backupx:${AGENT_VERSION}"
docker pull "awuqing/backupx:${AGENT_VERSION}"
echo "[2/2] 启动容器 backupx-agent"
docker rm -f backupx-agent >/dev/null 2>&1 || true
docker run -d --name backupx-agent --restart=unless-stopped \
-e "BACKUPX_AGENT_MASTER=${MASTER_URL}" \
-e "BACKUPX_AGENT_TOKEN=${AGENT_TOKEN}" \
-v /var/lib/backupx-agent:/tmp/backupx-agent \
"awuqing/backupx:${AGENT_VERSION}" agent
echo "✓ 容器已启动"
{{end}}

View File

@@ -0,0 +1,152 @@
package metrics
import (
"context"
"time"
"backupx/server/internal/model"
"backupx/server/internal/repository"
)
// SampleSource 抽象 Collector 需要的仓储访问,便于单测替换。
type SampleSource interface {
ListStorageTargets(ctx context.Context) ([]model.StorageTarget, error)
StorageUsage(ctx context.Context) ([]repository.BackupStorageUsageItem, error)
ListNodes(ctx context.Context) ([]model.Node, error)
CountSLABreach(ctx context.Context) (int, error)
}
// repoSource 把 repository 适配到 SampleSource。
type repoSource struct {
targets repository.StorageTargetRepository
records repository.BackupRecordRepository
nodes repository.NodeRepository
tasks repository.BackupTaskRepository
now func() time.Time
}
// NewRepoSource 用仓储实例构造 SampleSource。
func NewRepoSource(
targets repository.StorageTargetRepository,
records repository.BackupRecordRepository,
nodes repository.NodeRepository,
tasks repository.BackupTaskRepository,
) SampleSource {
return &repoSource{
targets: targets,
records: records,
nodes: nodes,
tasks: tasks,
now: func() time.Time { return time.Now().UTC() },
}
}
func (s *repoSource) ListStorageTargets(ctx context.Context) ([]model.StorageTarget, error) {
return s.targets.List(ctx)
}
func (s *repoSource) StorageUsage(ctx context.Context) ([]repository.BackupStorageUsageItem, error) {
return s.records.StorageUsage(ctx)
}
func (s *repoSource) ListNodes(ctx context.Context) ([]model.Node, error) {
return s.nodes.List(ctx)
}
// CountSLABreach 统计当前违反 RPO 的任务:
// - 任务启用且配置了 SLAHoursRPO > 0
// - 最近一次成功备份距今超出 SLA 时间窗,或从未成功过
func (s *repoSource) CountSLABreach(ctx context.Context) (int, error) {
tasks, err := s.tasks.List(ctx, repository.BackupTaskListOptions{})
if err != nil {
return 0, err
}
now := s.now()
count := 0
for i := range tasks {
task := &tasks[i]
if task.SLAHoursRPO <= 0 || !task.Enabled {
continue
}
threshold := now.Add(-time.Duration(task.SLAHoursRPO) * time.Hour)
if task.LastRunAt == nil || task.LastRunAt.Before(threshold) {
count++
}
}
return count, nil
}
// Collector 周期性采集 gauge 类指标存储用量、节点在线、SLA 违约)。
// 用后台 goroutine 驱动,避免在 /metrics 请求路径做慢 IO。
type Collector struct {
metrics *Metrics
source SampleSource
interval time.Duration
}
// NewCollector 创建周期采集器。interval=0 走默认 30s。
func NewCollector(m *Metrics, source SampleSource, interval time.Duration) *Collector {
if interval <= 0 {
interval = 30 * time.Second
}
return &Collector{metrics: m, source: source, interval: interval}
}
// Start 在后台运行采集循环;随 ctx 取消而终止。
// 启动时立即采一次,之后按 interval 轮询。
func (c *Collector) Start(ctx context.Context) {
if c == nil || c.metrics == nil || c.source == nil {
return
}
go func() {
c.collect(ctx)
ticker := time.NewTicker(c.interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
c.collect(ctx)
}
}
}()
}
// collect 执行一次采样;单轮失败不影响下次。
func (c *Collector) collect(ctx context.Context) {
// 存储用量:按 StorageTargetID 聚合 file_size对应 target name/type
if targets, err := c.source.ListStorageTargets(ctx); err == nil {
nameByID := make(map[uint]string, len(targets))
typeByID := make(map[uint]string, len(targets))
for i := range targets {
nameByID[targets[i].ID] = targets[i].Name
typeByID[targets[i].ID] = targets[i].Type
}
if usage, uerr := c.source.StorageUsage(ctx); uerr == nil {
c.metrics.ResetStorageUsed()
for _, item := range usage {
name := nameByID[item.StorageTargetID]
if name == "" {
continue
}
c.metrics.SetStorageUsed(name, typeByID[item.StorageTargetID], item.TotalSize)
}
}
}
// 节点在线状态role 约定为 master / agent
if nodes, err := c.source.ListNodes(ctx); err == nil {
c.metrics.ResetNodeOnline()
for i := range nodes {
n := &nodes[i]
role := "agent"
if n.IsLocal {
role = "master"
}
c.metrics.SetNodeOnline(n.Name, role, n.Status == model.NodeStatusOnline)
}
}
if breach, err := c.source.CountSLABreach(ctx); err == nil {
c.metrics.SetSLABreach(breach)
}
}

View File

@@ -0,0 +1,225 @@
// Package metrics 暴露 BackupX 的 Prometheus 采集器。
//
// 设计要点:
// - 使用独立 Registry避免与 default registry 中的 Go runtime metrics 混淆
// - Counter/Gauge/Histogram 全部以 backupx_ 为前缀,遵循 Prometheus 命名规范
// - 所有指标都支持零值:未注入时调用方法是 no-op不会 panic
// - 组件只依赖本包,不反向引用 service/repository避免循环
package metrics
import (
"net/http"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/collectors"
"github.com/prometheus/client_golang/prometheus/promhttp"
)
// Metrics 聚合所有采集器,由 app 层组装一次并按需注入到 service。
type Metrics struct {
registry *prometheus.Registry
// 任务执行计数labels: status, task_type
TaskRunTotal *prometheus.CounterVec
// 任务耗时分布labels: task_type
TaskRunDuration *prometheus.HistogramVec
// 任务产出字节数labels: task_type
TaskBytesTotal *prometheus.CounterVec
// 正在运行的任务数
TaskRunningGauge prometheus.Gauge
// 存储目标用量labels: target_name, target_type
StorageUsedBytes *prometheus.GaugeVec
// 节点在线状态labels: node_name, rolevalue: 0/1
NodeOnline *prometheus.GaugeVec
// 验证演练结果labels: status
VerifyRunTotal *prometheus.CounterVec
// 恢复操作结果labels: status
RestoreRunTotal *prometheus.CounterVec
// 副本复制结果labels: status
ReplicationRunTotal *prometheus.CounterVec
// SLA 违约数gauge
SLABreachGauge prometheus.Gauge
// 应用信息label: version
AppInfo *prometheus.GaugeVec
}
// New 构造并注册所有采集器。
// 失败时 panic采集器注册失败属于启动期编程错误没有合理 fallback。
func New(version string) *Metrics {
reg := prometheus.NewRegistry()
// 注入标准 Go runtime + process 指标
reg.MustRegister(collectors.NewGoCollector())
reg.MustRegister(collectors.NewProcessCollector(collectors.ProcessCollectorOpts{}))
m := &Metrics{
registry: reg,
TaskRunTotal: prometheus.NewCounterVec(prometheus.CounterOpts{
Name: "backupx_task_run_total",
Help: "备份任务执行总数,按状态和任务类型细分",
}, []string{"status", "task_type"}),
TaskRunDuration: prometheus.NewHistogramVec(prometheus.HistogramOpts{
Name: "backupx_task_run_duration_seconds",
Help: "备份任务耗时分布",
Buckets: []float64{1, 5, 15, 30, 60, 120, 300, 600, 1800, 3600, 7200},
}, []string{"task_type"}),
TaskBytesTotal: prometheus.NewCounterVec(prometheus.CounterOpts{
Name: "backupx_task_bytes_total",
Help: "备份任务累计产出字节数",
}, []string{"task_type"}),
TaskRunningGauge: prometheus.NewGauge(prometheus.GaugeOpts{
Name: "backupx_task_running",
Help: "当前正在执行的备份任务数",
}),
StorageUsedBytes: prometheus.NewGaugeVec(prometheus.GaugeOpts{
Name: "backupx_storage_used_bytes",
Help: "存储目标已用字节数",
}, []string{"target_name", "target_type"}),
NodeOnline: prometheus.NewGaugeVec(prometheus.GaugeOpts{
Name: "backupx_node_online",
Help: "集群节点在线状态1 在线 / 0 离线)",
}, []string{"node_name", "role"}),
VerifyRunTotal: prometheus.NewCounterVec(prometheus.CounterOpts{
Name: "backupx_verify_run_total",
Help: "备份验证演练执行总数",
}, []string{"status"}),
RestoreRunTotal: prometheus.NewCounterVec(prometheus.CounterOpts{
Name: "backupx_restore_run_total",
Help: "恢复操作执行总数",
}, []string{"status"}),
ReplicationRunTotal: prometheus.NewCounterVec(prometheus.CounterOpts{
Name: "backupx_replication_run_total",
Help: "备份副本复制执行总数",
}, []string{"status"}),
SLABreachGauge: prometheus.NewGauge(prometheus.GaugeOpts{
Name: "backupx_sla_breach_tasks",
Help: "当前违反 SLA/RPO 的任务数",
}),
AppInfo: prometheus.NewGaugeVec(prometheus.GaugeOpts{
Name: "backupx_app_info",
Help: "BackupX 应用元信息(恒为 1通过 label 暴露版本号)",
}, []string{"version"}),
}
reg.MustRegister(
m.TaskRunTotal,
m.TaskRunDuration,
m.TaskBytesTotal,
m.TaskRunningGauge,
m.StorageUsedBytes,
m.NodeOnline,
m.VerifyRunTotal,
m.RestoreRunTotal,
m.ReplicationRunTotal,
m.SLABreachGauge,
m.AppInfo,
)
m.AppInfo.WithLabelValues(version).Set(1)
return m
}
// Handler 返回 /metrics 的 HTTP handler。
// 使用本包专属 registry避免混入其他组件的默认 metrics。
func (m *Metrics) Handler() http.Handler {
if m == nil {
return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
http.Error(w, "metrics disabled", http.StatusServiceUnavailable)
})
}
return promhttp.HandlerFor(m.registry, promhttp.HandlerOpts{
EnableOpenMetrics: false,
})
}
// ObserveTaskRun 记录一次任务执行结果。
// status 常用值success / failed / cancelled。nil 接收器安全。
func (m *Metrics) ObserveTaskRun(taskType, status string, durationSec float64, bytes int64) {
if m == nil {
return
}
m.TaskRunTotal.WithLabelValues(status, taskType).Inc()
m.TaskRunDuration.WithLabelValues(taskType).Observe(durationSec)
if bytes > 0 {
m.TaskBytesTotal.WithLabelValues(taskType).Add(float64(bytes))
}
}
// IncTaskRunning / DecTaskRunning 配套使用,反映并发中任务数。
func (m *Metrics) IncTaskRunning() {
if m == nil {
return
}
m.TaskRunningGauge.Inc()
}
func (m *Metrics) DecTaskRunning() {
if m == nil {
return
}
m.TaskRunningGauge.Dec()
}
// ObserveRestore / ObserveVerify / ObserveReplication 记录子动作结果。
// 所有方法对 nil 接收器安全:未注入 Metrics 时静默降级,不 panic。
func (m *Metrics) ObserveRestore(status string) {
if m == nil {
return
}
m.RestoreRunTotal.WithLabelValues(status).Inc()
}
func (m *Metrics) ObserveVerify(status string) {
if m == nil {
return
}
m.VerifyRunTotal.WithLabelValues(status).Inc()
}
func (m *Metrics) ObserveReplication(status string) {
if m == nil {
return
}
m.ReplicationRunTotal.WithLabelValues(status).Inc()
}
// SetStorageUsed 刷新某存储目标的用量。调用方负责周期采集。
func (m *Metrics) SetStorageUsed(name, targetType string, bytes int64) {
if m == nil {
return
}
m.StorageUsedBytes.WithLabelValues(name, targetType).Set(float64(bytes))
}
// SetNodeOnline 刷新节点在线状态。
func (m *Metrics) SetNodeOnline(name, role string, online bool) {
if m == nil {
return
}
val := 0.0
if online {
val = 1
}
m.NodeOnline.WithLabelValues(name, role).Set(val)
}
// ResetNodeOnline 清空节点 gauge当节点被删除时避免残留指标
func (m *Metrics) ResetNodeOnline() {
if m == nil {
return
}
m.NodeOnline.Reset()
}
// ResetStorageUsed 清空存储目标 gauge。
func (m *Metrics) ResetStorageUsed() {
if m == nil {
return
}
m.StorageUsedBytes.Reset()
}
// SetSLABreach 刷新 SLA 违约任务数。
func (m *Metrics) SetSLABreach(count int) {
if m == nil {
return
}
m.SLABreachGauge.Set(float64(count))
}

View File

@@ -0,0 +1,76 @@
package metrics
import (
"io"
"net/http/httptest"
"strings"
"testing"
"github.com/prometheus/client_golang/prometheus/testutil"
)
func TestNew_AppInfoVersionLabel(t *testing.T) {
m := New("2.1.0")
if got := testutil.ToFloat64(m.AppInfo.WithLabelValues("2.1.0")); got != 1 {
t.Fatalf("app_info(version=2.1.0) expected 1, got %v", got)
}
}
func TestObserveTaskRun_IncrementsCounterAndHistogram(t *testing.T) {
m := New("test")
m.ObserveTaskRun("mysql", "success", 12.5, 1024)
m.ObserveTaskRun("mysql", "failed", 3.0, 0)
if got := testutil.ToFloat64(m.TaskRunTotal.WithLabelValues("success", "mysql")); got != 1 {
t.Fatalf("task_run_total{status=success,task_type=mysql}: expected 1, got %v", got)
}
if got := testutil.ToFloat64(m.TaskRunTotal.WithLabelValues("failed", "mysql")); got != 1 {
t.Fatalf("task_run_total{status=failed,task_type=mysql}: expected 1, got %v", got)
}
if got := testutil.ToFloat64(m.TaskBytesTotal.WithLabelValues("mysql")); got != 1024 {
t.Fatalf("task_bytes_total{task_type=mysql}: expected 1024, got %v", got)
}
}
func TestObserveTaskRun_NilReceiverIsSafe(t *testing.T) {
var m *Metrics // nil
m.ObserveTaskRun("file", "success", 1, 1)
m.ObserveRestore("success")
m.ObserveVerify("failed")
m.ObserveReplication("success")
m.IncTaskRunning()
m.DecTaskRunning()
m.SetStorageUsed("a", "s3", 1)
m.SetNodeOnline("n1", "master", true)
m.SetSLABreach(3)
m.ResetNodeOnline()
m.ResetStorageUsed()
// no panic -> pass
}
func TestHandler_ExposesBackupxMetrics(t *testing.T) {
m := New("0.0.0-test")
m.ObserveTaskRun("file", "success", 1.0, 2048)
m.SetNodeOnline("n1", "master", true)
m.SetSLABreach(1)
recorder := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/metrics", nil)
m.Handler().ServeHTTP(recorder, req)
body, err := io.ReadAll(recorder.Result().Body)
if err != nil {
t.Fatalf("read body: %v", err)
}
content := string(body)
for _, keyword := range []string{
"backupx_task_run_total",
"backupx_task_run_duration_seconds",
"backupx_node_online",
"backupx_sla_breach_tasks",
"backupx_app_info",
} {
if !strings.Contains(content, keyword) {
t.Errorf("expected /metrics to contain %q", keyword)
}
}
}

View File

@@ -20,6 +20,19 @@ const (
// Payload: {"path": "/var/log"}
// Result: {"entries": [{"name":"...", "path":"...", "isDir":true, "size":0}]}
AgentCommandTypeListDir = "list_dir"
// AgentCommandTypeRestoreRecord 在 Agent 节点上恢复指定备份记录
// Payload: {"restoreRecordId": 789}
// Agent 拉 /api/agent/restores/:id/spec 获取完整规格后执行恢复
AgentCommandTypeRestoreRecord = "restore_record"
// AgentCommandTypeDiscoverDB 在 Agent 节点上发现数据库列表
// Payload: {"type": "mysql", "host": "...", "port": 3306, "user": "...", "password": "..."}
// Result: {"databases": ["db1", "db2"]}
AgentCommandTypeDiscoverDB = "discover_db"
// AgentCommandTypeDeleteStorageObject 在 Agent 节点上删除指定存储对象
// Payload: {"targetType": "local_disk", "targetConfig": {...}, "storagePath": "tasks/1/x.tar.gz"}
// 用于跨节点 local_disk 场景Master 删记录时请求 Agent 清理其本地备份文件。
// Agent 需具备对应存储 provider 的执行能力。best-effort失败仅影响 Agent 侧文件残留。
AgentCommandTypeDeleteStorageObject = "delete_storage_object"
)
// AgentCommand 代表 Master 发给某个 Agent 节点的待执行命令。

View File

@@ -0,0 +1,36 @@
package model
import "time"
// AgentInstallToken 一次性安装令牌,用于 /install/:token 公开端点。
//
// 生命周期:创建 → 消费ConsumedAt 非空即作废)→ 超过 ExpiresAt 后被 GC 硬删除。
type AgentInstallToken struct {
ID uint `gorm:"primaryKey" json:"id"`
Token string `gorm:"size:64;uniqueIndex;not null" json:"token"`
NodeID uint `gorm:"not null;index" json:"nodeId"`
Mode string `gorm:"size:16;not null" json:"mode"` // systemd|docker|foreground
Arch string `gorm:"size:16;not null" json:"arch"` // amd64|arm64|auto
AgentVer string `gorm:"size:32;not null" json:"agentVersion"`
DownloadSrc string `gorm:"size:16;not null;default:'github'" json:"downloadSrc"`
ExpiresAt time.Time `gorm:"not null;index" json:"expiresAt"`
ConsumedAt *time.Time `json:"consumedAt,omitempty"`
CreatedByID uint `gorm:"not null" json:"createdById"`
CreatedAt time.Time `json:"createdAt"`
}
func (AgentInstallToken) TableName() string { return "agent_install_tokens" }
// 合法模式/架构/下载源常量
const (
InstallModeSystemd = "systemd"
InstallModeDocker = "docker"
InstallModeForeground = "foreground"
InstallArchAmd64 = "amd64"
InstallArchArm64 = "arm64"
InstallArchAuto = "auto"
InstallSourceGitHub = "github"
InstallSourceGhproxy = "ghproxy"
)

View File

@@ -0,0 +1,24 @@
package model
import "time"
// ApiKey 用于 CI/CD、监控脚本等非交互式场景通过 HTTP API 访问 BackupX。
// 明文 Key 仅在创建时返回一次,数据库存储 SHA-256 哈希。
// 认证中间件:当 Authorization: Bearer 值以 "bax_" 前缀开头时走 API Key 验证。
type ApiKey struct {
ID uint `gorm:"primaryKey" json:"id"`
Name string `gorm:"size:128;not null" json:"name"`
Role string `gorm:"size:32;not null;default:viewer" json:"role"`
KeyHash string `gorm:"column:key_hash;size:128;uniqueIndex;not null" json:"-"`
Prefix string `gorm:"size:32;not null" json:"prefix"`
CreatedBy string `gorm:"column:created_by;size:128" json:"createdBy"`
LastUsedAt *time.Time `gorm:"column:last_used_at" json:"lastUsedAt,omitempty"`
ExpiresAt *time.Time `gorm:"column:expires_at" json:"expiresAt,omitempty"`
Disabled bool `gorm:"not null;default:false" json:"disabled"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (ApiKey) TableName() string {
return "api_keys"
}

View File

@@ -14,6 +14,9 @@ type BackupRecord struct {
Task BackupTask `json:"task,omitempty"`
StorageTargetID uint `gorm:"column:storage_target_id;index;not null" json:"storageTargetId"`
StorageTarget StorageTarget `json:"storageTarget,omitempty"`
// NodeID 执行该次备份的节点0 = 本机 Master。用于集群中识别 local_disk 类型
// 存储的归属节点,避免 Master 端试图跨节点访问远程 Agent 的本地存储。
NodeID uint `gorm:"column:node_id;index;default:0" json:"nodeId"`
Status string `gorm:"size:20;index;not null" json:"status"`
FileName string `gorm:"column:file_name;size:255" json:"fileName"`
FileSize int64 `gorm:"column:file_size;not null;default:0" json:"fileSize"`

View File

@@ -39,6 +39,10 @@ type BackupTask struct {
StorageTargets []StorageTarget `gorm:"many2many:backup_task_storage_targets" json:"storageTargets,omitempty"`
NodeID uint `gorm:"column:node_id;index;default:0" json:"nodeId"`
Node Node `json:"node,omitempty"`
// NodePoolTag 节点池标签(可选)。非空且 NodeID=0 时,调度器会从 Node.Labels 包含该 tag
// 的在线节点中动态挑选一台执行(按运行中任务数最少原则),失败会 best-effort 切换到下一个候选。
// 典型场景NodePoolTag="db" 让 MySQL 备份任务在任意标有 "db" 的数据库节点执行。
NodePoolTag string `gorm:"column:node_pool_tag;size:64;index" json:"nodePoolTag"`
Tags string `gorm:"column:tags;size:500" json:"tags"`
RetentionDays int `gorm:"column:retention_days;not null;default:30" json:"retentionDays"`
Compression string `gorm:"size:10;not null;default:'gzip'" json:"compression"`
@@ -46,6 +50,25 @@ type BackupTask struct {
MaxBackups int `gorm:"column:max_backups;not null;default:10" json:"maxBackups"`
LastRunAt *time.Time `gorm:"column:last_run_at" json:"lastRunAt,omitempty"`
LastStatus string `gorm:"column:last_status;size:20;not null;default:'idle'" json:"lastStatus"`
// 验证(恢复演练)配置 — 定期自动校验备份可恢复性
VerifyEnabled bool `gorm:"column:verify_enabled;not null;default:false" json:"verifyEnabled"`
VerifyCronExpr string `gorm:"column:verify_cron_expr;size:64" json:"verifyCronExpr"`
VerifyMode string `gorm:"column:verify_mode;size:20;not null;default:'quick'" json:"verifyMode"`
// SLA 配置 — RPO期望最长未备份间隔与告警阈值
SLAHoursRPO int `gorm:"column:sla_hours_rpo;not null;default:0" json:"slaHoursRpo"`
AlertOnConsecutiveFails int `gorm:"column:alert_on_consecutive_fails;not null;default:1" json:"alertOnConsecutiveFails"`
// ReplicationTargetIDs 备份复制目标存储 ID 列表CSV
// 备份完成后系统将自动把成果从任务主存储StorageTargets 的第一个)复制到这些目标。
// 满足 3-2-1 规则:至少 2 份副本,且至少 1 份异地(不同 provider/region
ReplicationTargetIDs string `gorm:"column:replication_target_ids;size:500" json:"replicationTargetIds"`
// MaintenanceWindows 允许执行备份的时段(格式详见 backup/window.go
// 空 = 不限制。非空时调度器在非窗口跳过,手动执行返回友好错误。
MaintenanceWindows string `gorm:"column:maintenance_windows;size:500" json:"maintenanceWindows"`
// DependsOnTaskIDs 依赖的上游任务 ID 列表CSV
// 语义:上游任务成功后自动触发本任务,形成工作流(如 DB 备份完成 → 归档压缩)。
// 调度器继续按本任务自己的 cron 触发,仅"自动触发"路径响应依赖完成事件。
// 循环依赖检查在 service 层完成,避免配置阶段即出错。
DependsOnTaskIDs string `gorm:"column:depends_on_task_ids;size:500" json:"dependsOnTaskIds"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}

View File

@@ -1,6 +1,9 @@
package model
import "time"
import (
"strings"
"time"
)
const (
NodeStatusOnline = "online"
@@ -19,12 +22,54 @@ type Node struct {
IsLocal bool `gorm:"not null;default:false" json:"isLocal"`
OS string `gorm:"size:64" json:"os"`
Arch string `gorm:"size:32" json:"arch"`
AgentVer string `gorm:"column:agent_version;size:32" json:"agentVersion"`
LastSeen time.Time `gorm:"column:last_seen" json:"lastSeen"`
AgentVer string `gorm:"column:agent_version;size:32" json:"agentVersion"`
LastSeen time.Time `gorm:"column:last_seen" json:"lastSeen"`
PrevToken string `gorm:"size:128;index" json:"-"`
PrevTokenExpires *time.Time `gorm:"column:prev_token_expires" json:"-"`
// MaxConcurrent 该节点允许的最大并发任务数0=不限制,沿用全局 cfg.Backup.MaxConcurrent
// 用于大集群中限制单节点资源占用:例如小内存 Agent 节点可配 1避免多个大备份同时跑挤爆。
MaxConcurrent int `gorm:"column:max_concurrent;not null;default:0" json:"maxConcurrent"`
// BandwidthLimit 该节点上传带宽上限rclone 可识别格式10M / 1G / 0=不限)。
// 对集群感知的上传场景有效Master 本地与 Agent 运行时均会应用)。
BandwidthLimit string `gorm:"column:bandwidth_limit;size:32" json:"bandwidthLimit"`
// Labels 节点标签CSV如 "prod,db-host,high-mem")。
// 用于任务调度的节点池选择:任务配置 NodePoolTag 时,调度器会从 Labels 包含该 tag 的
// 在线节点中自动挑选一台执行(按当前运行中任务数升序)。单节点可属多个池。
Labels string `gorm:"column:labels;size:500" json:"labels"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
// LabelSet 把 CSV Labels 解析为 set便于做成员判定。
// 空白与空 token 自动忽略。
func (n *Node) LabelSet() map[string]struct{} {
if n == nil {
return nil
}
out := make(map[string]struct{})
for _, raw := range strings.Split(n.Labels, ",") {
label := strings.TrimSpace(raw)
if label != "" {
out[label] = struct{}{}
}
}
return out
}
// HasLabel 判断节点是否属于指定池。nil/空 tag 返回 false。
func (n *Node) HasLabel(tag string) bool {
tag = strings.TrimSpace(tag)
if n == nil || tag == "" {
return false
}
for _, raw := range strings.Split(n.Labels, ",") {
if strings.TrimSpace(raw) == tag {
return true
}
}
return false
}
func (Node) TableName() string {
return "nodes"
}

View File

@@ -0,0 +1,47 @@
package model
import "testing"
func TestNodeHasLabel(t *testing.T) {
cases := []struct {
labels string
tag string
want bool
}{
{"prod,db,high-mem", "prod", true},
{"prod,db,high-mem", "db", true},
{"prod,db,high-mem", "backup", false},
{" prod , db ", "db", true}, // trim 空白
{"", "prod", false},
{"prod", "", false}, // 空 tag 不匹配
}
for _, c := range cases {
n := &Node{Labels: c.labels}
if got := n.HasLabel(c.tag); got != c.want {
t.Errorf("labels=%q tag=%q want %v got %v", c.labels, c.tag, c.want, got)
}
}
}
func TestNodeLabelSet(t *testing.T) {
n := &Node{Labels: "prod, db ,,high-mem,prod"}
set := n.LabelSet()
for _, want := range []string{"prod", "db", "high-mem"} {
if _, ok := set[want]; !ok {
t.Errorf("expected label %q in set", want)
}
}
if len(set) != 3 {
t.Errorf("duplicates not deduped, got %v", set)
}
}
func TestNilNodeHasLabelSafe(t *testing.T) {
var n *Node
if n.HasLabel("anything") {
t.Error("nil node should never match any label")
}
if s := n.LabelSet(); s != nil {
t.Errorf("nil node LabelSet should be nil, got %v", s)
}
}

View File

@@ -2,6 +2,26 @@ package model
import "time"
// 通知事件类型(企业级事件总线)。
// 任一 Notification 可订阅多个事件EventTypes 字段存 CSV。
// 空 EventTypes + OnSuccess/OnFailure=true 时沿用旧语义(仅备份成功/失败)。
const (
NotificationEventBackupSuccess = "backup_success"
NotificationEventBackupFailed = "backup_failed"
NotificationEventRestoreSuccess = "restore_success"
NotificationEventRestoreFailed = "restore_failed"
NotificationEventVerifyFailed = "verify_failed"
NotificationEventSLAViolation = "sla_violation"
// NotificationEventStorageUnhealthy 存储目标连接失败(后台健康扫描触发)。
NotificationEventStorageUnhealthy = "storage_unhealthy"
// NotificationEventReplicationFailed 备份复制失败。
NotificationEventReplicationFailed = "replication_failed"
// NotificationEventAgentOutdated Agent 版本落后 Master建议升级。
NotificationEventAgentOutdated = "agent_outdated"
// NotificationEventStorageCapacity 存储目标使用率超过预警阈值85%)。
NotificationEventStorageCapacity = "storage_capacity_warning"
)
type Notification struct {
ID uint `gorm:"primaryKey" json:"id"`
Type string `gorm:"size:20;index;not null" json:"type"`
@@ -10,8 +30,11 @@ type Notification struct {
Enabled bool `gorm:"not null;default:true" json:"enabled"`
OnSuccess bool `gorm:"column:on_success;not null;default:false" json:"onSuccess"`
OnFailure bool `gorm:"column:on_failure;not null;default:true" json:"onFailure"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
// EventTypes 逗号分隔,订阅的事件类型。
// 空 = 仅监听备份成功/失败(兼容旧配置);非空则严格按订阅触发。
EventTypes string `gorm:"column:event_types;size:500" json:"eventTypes"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (Notification) TableName() string {

View File

@@ -0,0 +1,44 @@
package model
import "time"
// ReplicationRecord 记录一次备份复制的执行。
// 触发方式:
// - 自动:备份成功后,根据 task.ReplicationTargetIDs 自动派发
// - 手动:从备份记录详情页手动触发
//
// 核心语义:把源存储上的备份对象 mirror 到目标存储,保留 StoragePath。
// 3-2-1 规则核心:每份备份至少存在于两个独立存储目标,且至少一份异地。
const (
ReplicationStatusRunning = "running"
ReplicationStatusSuccess = "success"
ReplicationStatusFailed = "failed"
)
type ReplicationRecord struct {
ID uint `gorm:"primaryKey" json:"id"`
BackupRecordID uint `gorm:"column:backup_record_id;index;not null" json:"backupRecordId"`
BackupRecord BackupRecord `json:"backupRecord,omitempty"`
TaskID uint `gorm:"column:task_id;index;not null" json:"taskId"`
// SourceTargetID 源存储目标(备份已存在于此)
SourceTargetID uint `gorm:"column:source_target_id;index;not null" json:"sourceTargetId"`
SourceTarget StorageTarget `gorm:"foreignKey:SourceTargetID;references:ID" json:"sourceTarget,omitempty"`
// DestTargetID 目标存储(复制过去)
DestTargetID uint `gorm:"column:dest_target_id;index;not null" json:"destTargetId"`
DestTarget StorageTarget `gorm:"foreignKey:DestTargetID;references:ID" json:"destTarget,omitempty"`
Status string `gorm:"size:20;index;not null" json:"status"`
StoragePath string `gorm:"column:storage_path;size:500" json:"storagePath"`
FileSize int64 `gorm:"column:file_size;not null;default:0" json:"fileSize"`
Checksum string `gorm:"column:checksum;size:64" json:"checksum"`
ErrorMessage string `gorm:"column:error_message;size:2000" json:"errorMessage"`
DurationSeconds int `gorm:"column:duration_seconds;not null;default:0" json:"durationSeconds"`
TriggeredBy string `gorm:"column:triggered_by;size:100" json:"triggeredBy"`
StartedAt time.Time `gorm:"column:started_at;index;not null" json:"startedAt"`
CompletedAt *time.Time `gorm:"column:completed_at;index" json:"completedAt,omitempty"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (ReplicationRecord) TableName() string {
return "replication_records"
}

View File

@@ -0,0 +1,33 @@
package model
import "time"
// RestoreRecord 代表一次恢复执行,用于审计、实时日志与列表页。
// 每次从 BackupRecord 触发恢复都会产生独立 RestoreRecord与 BackupRecord 一对多。
const (
RestoreRecordStatusRunning = "running"
RestoreRecordStatusSuccess = "success"
RestoreRecordStatusFailed = "failed"
)
type RestoreRecord struct {
ID uint `gorm:"primaryKey" json:"id"`
BackupRecordID uint `gorm:"column:backup_record_id;index;not null" json:"backupRecordId"`
BackupRecord BackupRecord `json:"backupRecord,omitempty"`
TaskID uint `gorm:"column:task_id;index;not null" json:"taskId"`
Task BackupTask `json:"task,omitempty"`
NodeID uint `gorm:"column:node_id;index;default:0" json:"nodeId"`
Status string `gorm:"size:20;index;not null" json:"status"`
ErrorMessage string `gorm:"column:error_message;size:2000" json:"errorMessage"`
LogContent string `gorm:"column:log_content;type:text" json:"logContent"`
DurationSeconds int `gorm:"column:duration_seconds;not null;default:0" json:"durationSeconds"`
StartedAt time.Time `gorm:"column:started_at;index;not null" json:"startedAt"`
CompletedAt *time.Time `gorm:"column:completed_at;index" json:"completedAt,omitempty"`
TriggeredBy string `gorm:"column:triggered_by;size:100" json:"triggeredBy"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (RestoreRecord) TableName() string {
return "restore_records"
}

View File

@@ -14,8 +14,12 @@ type StorageTarget struct {
LastTestedAt *time.Time `gorm:"column:last_tested_at" json:"lastTestedAt,omitempty"`
LastTestStatus string `gorm:"column:last_test_status;size:32;not null;default:'unknown'" json:"lastTestStatus"`
LastTestMessage string `gorm:"column:last_test_message;size:512" json:"lastTestMessage"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
// QuotaBytes 软限额字节。0 = 不限制。
// 备份执行前检查:该目标上已累计字节数 + 本次文件大小 > QuotaBytes 时拒绝上传。
// 比容量预警85% 通知)更严格,作为企业治理"防超用"的硬性闸门。
QuotaBytes int64 `gorm:"column:quota_bytes;not null;default:0" json:"quotaBytes"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (StorageTarget) TableName() string {

View File

@@ -0,0 +1,27 @@
package model
import "time"
// TaskTemplate 是批量创建任务的模板。
// 用途大规模场景100+ 任务)下保存一份参数预设,
// 再通过"应用模板"接口一次性创建多个任务(变量替换 Name/SourcePath 等)。
//
// 参数存 JSONPayload结构与 service.BackupTaskUpsertInput 基本一致,
// 仅以下字段在应用时可被变量覆盖:
// - name
// - sourcePath / sourcePaths 中的 {{.Host}} / {{.Env}} 等占位符
type TaskTemplate struct {
ID uint `gorm:"primaryKey" json:"id"`
Name string `gorm:"size:128;uniqueIndex;not null" json:"name"`
Description string `gorm:"size:500" json:"description"`
TaskType string `gorm:"column:task_type;size:20;not null" json:"taskType"`
// Payload JSON存完整 BackupTaskUpsertInput 的序列化
Payload string `gorm:"type:text;not null" json:"payload"`
CreatedBy string `gorm:"column:created_by;size:128" json:"createdBy"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (TaskTemplate) TableName() string {
return "task_templates"
}

View File

@@ -2,15 +2,49 @@ package model
import "time"
// 用户角色常量。RBAC 策略:
// - admin系统全权创建用户、管理 API Key、删除数据、改设置
// - operator日常运维创建/编辑/执行任务、触发恢复与验证、管理存储目标与通知)
// - viewer只读查看仪表盘、任务、记录、日志不能触发或改变状态
const (
UserRoleAdmin = "admin"
UserRoleOperator = "operator"
UserRoleViewer = "viewer"
)
// IsValidRole 校验角色字符串合法。
func IsValidRole(role string) bool {
switch role {
case UserRoleAdmin, UserRoleOperator, UserRoleViewer:
return true
}
return false
}
type User struct {
ID uint `gorm:"primaryKey" json:"id"`
Username string `gorm:"size:64;uniqueIndex;not null" json:"username"`
PasswordHash string `gorm:"column:password_hash;not null" json:"-"`
DisplayName string `gorm:"size:128;not null" json:"displayName"`
Email string `gorm:"size:255" json:"email"`
Role string `gorm:"size:32;not null;default:admin" json:"role"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
ID uint `gorm:"primaryKey" json:"id"`
Username string `gorm:"size:64;uniqueIndex;not null" json:"username"`
PasswordHash string `gorm:"column:password_hash;not null" json:"-"`
DisplayName string `gorm:"size:128;not null" json:"displayName"`
Email string `gorm:"size:255" json:"email"`
Phone string `gorm:"size:64" json:"phone"`
Role string `gorm:"size:32;not null;default:admin" json:"role"`
// TwoFactorSecretCiphertext 保存 TOTP 密钥密文;未启用时可作为待确认密钥。
TwoFactorEnabled bool `gorm:"column:two_factor_enabled;not null;default:false" json:"twoFactorEnabled"`
TwoFactorSecretCiphertext string `gorm:"column:two_factor_secret_ciphertext;type:text" json:"-"`
// TwoFactorRecoveryCodeHashes 保存一次性恢复码哈希的 JSON 数组。
TwoFactorRecoveryCodeHashes string `gorm:"column:two_factor_recovery_code_hashes;type:text" json:"-"`
// WebAuthnCredentials 保存通行密钥公钥元数据 JSON不包含私钥或明文密钥。
WebAuthnCredentials string `gorm:"column:webauthn_credentials;type:text" json:"-"`
WebAuthnChallengeCiphertext string `gorm:"column:webauthn_challenge_ciphertext;type:text" json:"-"`
TrustedDevices string `gorm:"column:trusted_devices;type:text" json:"-"`
EmailOTPEnabled bool `gorm:"column:email_otp_enabled;not null;default:false" json:"emailOtpEnabled"`
SMSOTPEnabled bool `gorm:"column:sms_otp_enabled;not null;default:false" json:"smsOtpEnabled"`
OutOfBandOTPCiphertext string `gorm:"column:out_of_band_otp_ciphertext;type:text" json:"-"`
// Disabled 禁用账号(不删除保留审计)。禁用后无法登录。
Disabled bool `gorm:"not null;default:false" json:"disabled"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (User) TableName() string {

View File

@@ -0,0 +1,43 @@
package model
import "time"
// VerificationRecord 记录一次备份验证(或演练)的执行。
// 验证目标:从指定 BackupRecord 读取归档 → 在沙箱内执行只读校验
// (解压/格式检查/完整性校验),不改动源数据。
const (
VerificationRecordStatusRunning = "running"
VerificationRecordStatusSuccess = "success"
VerificationRecordStatusFailed = "failed"
// VerificationModeQuick 仅做格式与完整性校验tar header、SHA-256、DB dump 头)。
// 耗时短,不占用目标系统资源,适合每日调度。
VerificationModeQuick = "quick"
// VerificationModeDeep 真正恢复到隔离沙箱(临时库或解压目录),验证可读。
// 耗时较长,适合每周/每月。当前版本保留接口不实现。
VerificationModeDeep = "deep"
)
type VerificationRecord struct {
ID uint `gorm:"primaryKey" json:"id"`
BackupRecordID uint `gorm:"column:backup_record_id;index;not null" json:"backupRecordId"`
BackupRecord BackupRecord `json:"backupRecord,omitempty"`
TaskID uint `gorm:"column:task_id;index;not null" json:"taskId"`
Task BackupTask `json:"task,omitempty"`
NodeID uint `gorm:"column:node_id;index;default:0" json:"nodeId"`
Mode string `gorm:"size:20;not null;default:'quick'" json:"mode"`
Status string `gorm:"size:20;index;not null" json:"status"`
Summary string `gorm:"size:500" json:"summary"`
ErrorMessage string `gorm:"column:error_message;size:2000" json:"errorMessage"`
LogContent string `gorm:"column:log_content;type:text" json:"logContent"`
DurationSeconds int `gorm:"column:duration_seconds;not null;default:0" json:"durationSeconds"`
StartedAt time.Time `gorm:"column:started_at;index;not null" json:"startedAt"`
CompletedAt *time.Time `gorm:"column:completed_at;index" json:"completedAt,omitempty"`
TriggeredBy string `gorm:"column:triggered_by;size:100" json:"triggeredBy"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (VerificationRecord) TableName() string {
return "verification_records"
}

View File

@@ -18,7 +18,14 @@ type AgentCommandRepository interface {
ClaimPending(ctx context.Context, nodeID uint) (*model.AgentCommand, error)
Update(ctx context.Context, cmd *model.AgentCommand) error
// MarkStaleTimeout 把 dispatched 状态但超时未完成的命令标记为 timeout。
// 返回被标记的行数。不返回具体命令(供背景监控简单调用)。
MarkStaleTimeout(ctx context.Context, threshold time.Time) (int64, error)
// ListStaleDispatched 列出 dispatched 但已超时、尚未被标记的命令。
// 调用方需要把它们逐一标记 timeout 并联动关联记录状态。
ListStaleDispatched(ctx context.Context, threshold time.Time) ([]model.AgentCommand, error)
// ListPendingByNode 列出某节点下的所有 pending/dispatched 命令。
// 用于删除节点或节点离线时的清理。
ListPendingByNode(ctx context.Context, nodeID uint) ([]model.AgentCommand, error)
}
type GormAgentCommandRepository struct {
@@ -99,3 +106,30 @@ func (r *GormAgentCommandRepository) MarkStaleTimeout(ctx context.Context, thres
}
return result.RowsAffected, nil
}
// ListStaleDispatched 列出 dispatched 但 dispatched_at 早于 threshold 的命令。
func (r *GormAgentCommandRepository) ListStaleDispatched(ctx context.Context, threshold time.Time) ([]model.AgentCommand, error) {
var items []model.AgentCommand
if err := r.db.WithContext(ctx).
Where("status = ? AND dispatched_at < ?", model.AgentCommandStatusDispatched, threshold).
Order("id asc").
Find(&items).Error; err != nil {
return nil, err
}
return items, nil
}
// ListPendingByNode 列出某节点下所有待执行pending 或 dispatched命令。
func (r *GormAgentCommandRepository) ListPendingByNode(ctx context.Context, nodeID uint) ([]model.AgentCommand, error) {
var items []model.AgentCommand
if err := r.db.WithContext(ctx).
Where("node_id = ? AND status IN ?", nodeID, []string{
model.AgentCommandStatusPending,
model.AgentCommandStatusDispatched,
}).
Order("id asc").
Find(&items).Error; err != nil {
return nil, err
}
return items, nil
}

View File

@@ -0,0 +1,107 @@
package repository
import (
"context"
"errors"
"time"
"backupx/server/internal/model"
"gorm.io/gorm"
)
// AgentInstallTokenRepository 一次性安装令牌仓储。
type AgentInstallTokenRepository interface {
Create(ctx context.Context, t *model.AgentInstallToken) error
// FindByToken 按 token 字符串查询(不过滤状态),用于管理工具或审计场景。
FindByToken(ctx context.Context, token string) (*model.AgentInstallToken, error)
// FindValidByToken 查询且要求 consumed_at IS NULL 且 expires_at > now
// 适用于 compose 端点预检 Mode 等"只读不消费但需有效"的场景。
FindValidByToken(ctx context.Context, token string) (*model.AgentInstallToken, error)
// ConsumeByToken 原子消费:仅当 token 存在、未过期、未消费时成功,返回消费后的记录。
// 其它情况(不存在/已过期/已消费)一律返回 (nil, nil)。
ConsumeByToken(ctx context.Context, token string) (*model.AgentInstallToken, error)
// DeleteExpiredBefore 硬删除 ExpiresAt < threshold 的记录。
DeleteExpiredBefore(ctx context.Context, threshold time.Time) (int64, error)
// CountCreatedSince 统计 node 在 since 之后创建的数量(用于节点级限流)。
CountCreatedSince(ctx context.Context, nodeID uint, since time.Time) (int64, error)
}
type GormAgentInstallTokenRepository struct {
db *gorm.DB
}
func NewAgentInstallTokenRepository(db *gorm.DB) *GormAgentInstallTokenRepository {
return &GormAgentInstallTokenRepository{db: db}
}
func (r *GormAgentInstallTokenRepository) Create(ctx context.Context, t *model.AgentInstallToken) error {
return r.db.WithContext(ctx).Create(t).Error
}
func (r *GormAgentInstallTokenRepository) FindByToken(ctx context.Context, token string) (*model.AgentInstallToken, error) {
var item model.AgentInstallToken
if err := r.db.WithContext(ctx).Where("token = ?", token).First(&item).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &item, nil
}
// FindValidByToken 仅返回未消费且未过期的记录,过滤条件与 ConsumeByToken 对齐。
func (r *GormAgentInstallTokenRepository) FindValidByToken(ctx context.Context, token string) (*model.AgentInstallToken, error) {
var item model.AgentInstallToken
now := time.Now().UTC()
err := r.db.WithContext(ctx).
Where("token = ? AND consumed_at IS NULL AND expires_at > ?", token, now).
First(&item).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &item, nil
}
// ConsumeByToken 使用条件 UPDATE + RowsAffected 实现原子消费。
// SQLite 不支持 SELECT FOR UPDATE但 UPDATE 本身在 SQLite 中是原子的。
func (r *GormAgentInstallTokenRepository) ConsumeByToken(ctx context.Context, token string) (*model.AgentInstallToken, error) {
var consumed *model.AgentInstallToken
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
now := time.Now().UTC()
result := tx.Model(&model.AgentInstallToken{}).
Where("token = ? AND consumed_at IS NULL AND expires_at > ?", token, now).
Update("consumed_at", &now)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return nil
}
var item model.AgentInstallToken
if err := tx.Where("token = ?", token).First(&item).Error; err != nil {
return err
}
consumed = &item
return nil
})
if err != nil {
return nil, err
}
return consumed, nil
}
func (r *GormAgentInstallTokenRepository) DeleteExpiredBefore(ctx context.Context, threshold time.Time) (int64, error) {
result := r.db.WithContext(ctx).Where("expires_at < ?", threshold).Delete(&model.AgentInstallToken{})
return result.RowsAffected, result.Error
}
func (r *GormAgentInstallTokenRepository) CountCreatedSince(ctx context.Context, nodeID uint, since time.Time) (int64, error) {
var n int64
err := r.db.WithContext(ctx).Model(&model.AgentInstallToken{}).
Where("node_id = ? AND created_at >= ?", nodeID, since).
Count(&n).Error
return n, err
}

View File

@@ -0,0 +1,151 @@
package repository
import (
"context"
"path/filepath"
"testing"
"time"
"backupx/server/internal/model"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
gormlogger "gorm.io/gorm/logger"
)
func openTestInstallTokenDB(t *testing.T) *gorm.DB {
t.Helper()
path := filepath.Join(t.TempDir(), "install.db")
db, err := gorm.Open(sqlite.Open(path), &gorm.Config{Logger: gormlogger.Default.LogMode(gormlogger.Silent)})
if err != nil {
t.Fatalf("open: %v", err)
}
if err := db.AutoMigrate(&model.AgentInstallToken{}); err != nil {
t.Fatalf("migrate: %v", err)
}
return db
}
func TestInstallTokenConsumeOnce(t *testing.T) {
db := openTestInstallTokenDB(t)
repo := NewAgentInstallTokenRepository(db)
ctx := context.Background()
tok := &model.AgentInstallToken{
Token: "abc", NodeID: 1, Mode: model.InstallModeSystemd,
Arch: model.InstallArchAuto, AgentVer: "v1.7.0",
DownloadSrc: model.InstallSourceGitHub,
ExpiresAt: time.Now().UTC().Add(15 * time.Minute),
CreatedByID: 1,
}
if err := repo.Create(ctx, tok); err != nil {
t.Fatalf("create: %v", err)
}
got, err := repo.ConsumeByToken(ctx, "abc")
if err != nil {
t.Fatalf("consume err: %v", err)
}
if got == nil || got.ConsumedAt == nil {
t.Fatalf("expected consumed token, got %+v", got)
}
got, err = repo.ConsumeByToken(ctx, "abc")
if err != nil {
t.Fatalf("second consume err: %v", err)
}
if got != nil {
t.Fatalf("expected nil on second consume, got %+v", got)
}
}
func TestInstallTokenConsumeExpired(t *testing.T) {
db := openTestInstallTokenDB(t)
repo := NewAgentInstallTokenRepository(db)
ctx := context.Background()
tok := &model.AgentInstallToken{
Token: "stale", NodeID: 1, Mode: model.InstallModeSystemd,
Arch: model.InstallArchAuto, AgentVer: "v1.7.0",
DownloadSrc: model.InstallSourceGitHub,
ExpiresAt: time.Now().UTC().Add(-time.Minute),
CreatedByID: 1,
}
if err := repo.Create(ctx, tok); err != nil {
t.Fatalf("create: %v", err)
}
got, err := repo.ConsumeByToken(ctx, "stale")
if err != nil {
t.Fatalf("consume err: %v", err)
}
if got != nil {
t.Fatalf("expected nil on expired, got %+v", got)
}
}
func TestInstallTokenGC(t *testing.T) {
db := openTestInstallTokenDB(t)
repo := NewAgentInstallTokenRepository(db)
ctx := context.Background()
old := &model.AgentInstallToken{
Token: "old", NodeID: 1, Mode: model.InstallModeSystemd,
Arch: model.InstallArchAuto, AgentVer: "v1.7.0",
DownloadSrc: model.InstallSourceGitHub,
ExpiresAt: time.Now().UTC().Add(-8 * 24 * time.Hour),
CreatedByID: 1,
}
if err := repo.Create(ctx, old); err != nil {
t.Fatalf("create old: %v", err)
}
fresh := &model.AgentInstallToken{
Token: "fresh", NodeID: 1, Mode: model.InstallModeSystemd,
Arch: model.InstallArchAuto, AgentVer: "v1.7.0",
DownloadSrc: model.InstallSourceGitHub,
ExpiresAt: time.Now().UTC().Add(-1 * time.Hour),
CreatedByID: 1,
}
if err := repo.Create(ctx, fresh); err != nil {
t.Fatalf("create fresh: %v", err)
}
n, err := repo.DeleteExpiredBefore(ctx, time.Now().UTC().Add(-7*24*time.Hour))
if err != nil {
t.Fatalf("gc err: %v", err)
}
if n != 1 {
t.Fatalf("expected 1 deleted, got %d", n)
}
}
func TestInstallTokenCountCreatedSince(t *testing.T) {
db := openTestInstallTokenDB(t)
repo := NewAgentInstallTokenRepository(db)
ctx := context.Background()
// 同一节点 3 条
for i := 0; i < 3; i++ {
_ = repo.Create(ctx, &model.AgentInstallToken{
Token: "t" + string(rune('a'+i)), NodeID: 1, Mode: "systemd", Arch: "auto",
AgentVer: "v1", DownloadSrc: "github",
ExpiresAt: time.Now().UTC().Add(time.Minute), CreatedByID: 1,
})
}
// 另一节点 2 条(不计入)
for i := 0; i < 2; i++ {
_ = repo.Create(ctx, &model.AgentInstallToken{
Token: "n2_" + string(rune('a'+i)), NodeID: 2, Mode: "systemd", Arch: "auto",
AgentVer: "v1", DownloadSrc: "github",
ExpiresAt: time.Now().UTC().Add(time.Minute), CreatedByID: 1,
})
}
n, err := repo.CountCreatedSince(ctx, 1, time.Now().UTC().Add(-time.Minute))
if err != nil {
t.Fatalf("count err: %v", err)
}
if n != 3 {
t.Fatalf("expected 3, got %d", n)
}
}

View File

@@ -0,0 +1,78 @@
package repository
import (
"context"
"errors"
"time"
"backupx/server/internal/model"
"gorm.io/gorm"
)
type ApiKeyRepository interface {
Create(ctx context.Context, key *model.ApiKey) error
Update(ctx context.Context, key *model.ApiKey) error
Delete(ctx context.Context, id uint) error
FindByID(ctx context.Context, id uint) (*model.ApiKey, error)
FindByHash(ctx context.Context, hash string) (*model.ApiKey, error)
List(ctx context.Context) ([]model.ApiKey, error)
MarkUsed(ctx context.Context, id uint, at time.Time) error
}
type GormApiKeyRepository struct {
db *gorm.DB
}
func NewApiKeyRepository(db *gorm.DB) *GormApiKeyRepository {
return &GormApiKeyRepository{db: db}
}
func (r *GormApiKeyRepository) Create(ctx context.Context, key *model.ApiKey) error {
return r.db.WithContext(ctx).Create(key).Error
}
func (r *GormApiKeyRepository) Update(ctx context.Context, key *model.ApiKey) error {
return r.db.WithContext(ctx).Save(key).Error
}
func (r *GormApiKeyRepository) Delete(ctx context.Context, id uint) error {
return r.db.WithContext(ctx).Delete(&model.ApiKey{}, id).Error
}
func (r *GormApiKeyRepository) FindByID(ctx context.Context, id uint) (*model.ApiKey, error) {
var item model.ApiKey
if err := r.db.WithContext(ctx).First(&item, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &item, nil
}
func (r *GormApiKeyRepository) FindByHash(ctx context.Context, hash string) (*model.ApiKey, error) {
var item model.ApiKey
if err := r.db.WithContext(ctx).Where("key_hash = ?", hash).First(&item).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &item, nil
}
func (r *GormApiKeyRepository) List(ctx context.Context) ([]model.ApiKey, error) {
var items []model.ApiKey
if err := r.db.WithContext(ctx).Order("created_at desc").Find(&items).Error; err != nil {
return nil, err
}
return items, nil
}
// MarkUsed 更新最近使用时间。写入失败不应阻断认证主流程,调用方需忽略错误。
func (r *GormApiKeyRepository) MarkUsed(ctx context.Context, id uint, at time.Time) error {
return r.db.WithContext(ctx).
Model(&model.ApiKey{}).
Where("id = ?", id).
Update("last_used_at", at).Error
}

View File

@@ -2,6 +2,7 @@ package repository
import (
"context"
"time"
"backupx/server/internal/model"
"gorm.io/gorm"
@@ -9,6 +10,12 @@ import (
type AuditLogListOptions struct {
Category string
Action string
Username string
TargetID string
Keyword string // 模糊匹配 detail / target_name
DateFrom *time.Time
DateTo *time.Time
Limit int
Offset int
}
@@ -21,6 +28,7 @@ type AuditLogListResult struct {
type AuditLogRepository interface {
Create(ctx context.Context, log *model.AuditLog) error
List(ctx context.Context, opts AuditLogListOptions) (*AuditLogListResult, error)
ListAll(ctx context.Context, opts AuditLogListOptions) ([]model.AuditLog, error)
}
type gormAuditLogRepository struct {
@@ -36,10 +44,7 @@ func (r *gormAuditLogRepository) Create(_ context.Context, log *model.AuditLog)
}
func (r *gormAuditLogRepository) List(_ context.Context, opts AuditLogListOptions) (*AuditLogListResult, error) {
query := r.db.Model(&model.AuditLog{})
if opts.Category != "" {
query = query.Where("category = ?", opts.Category)
}
query := r.buildQuery(opts)
var total int64
if err := query.Count(&total).Error; err != nil {
return nil, err
@@ -54,3 +59,42 @@ func (r *gormAuditLogRepository) List(_ context.Context, opts AuditLogListOption
}
return &AuditLogListResult{Items: items, Total: total}, nil
}
// ListAll 导出专用:不分页返回所有匹配记录(上限 10k 防爆)。
func (r *gormAuditLogRepository) ListAll(_ context.Context, opts AuditLogListOptions) ([]model.AuditLog, error) {
query := r.buildQuery(opts)
const maxExportRows = 10000
var items []model.AuditLog
if err := query.Order("created_at DESC").Limit(maxExportRows).Find(&items).Error; err != nil {
return nil, err
}
return items, nil
}
// buildQuery 统一构造带筛选条件的查询。
func (r *gormAuditLogRepository) buildQuery(opts AuditLogListOptions) *gorm.DB {
query := r.db.Model(&model.AuditLog{})
if opts.Category != "" {
query = query.Where("category = ?", opts.Category)
}
if opts.Action != "" {
query = query.Where("action = ?", opts.Action)
}
if opts.Username != "" {
query = query.Where("username = ?", opts.Username)
}
if opts.TargetID != "" {
query = query.Where("target_id = ?", opts.TargetID)
}
if opts.Keyword != "" {
pattern := "%" + opts.Keyword + "%"
query = query.Where("detail LIKE ? OR target_name LIKE ?", pattern, pattern)
}
if opts.DateFrom != nil {
query = query.Where("created_at >= ?", opts.DateFrom.UTC())
}
if opts.DateTo != nil {
query = query.Where("created_at <= ?", opts.DateTo.UTC())
}
return query
}

View File

@@ -18,11 +18,13 @@ type BackupTaskRepository interface {
FindByID(context.Context, uint) (*model.BackupTask, error)
FindByName(context.Context, string) (*model.BackupTask, error)
ListSchedulable(context.Context) ([]model.BackupTask, error)
ListVerifySchedulable(context.Context) ([]model.BackupTask, error)
Count(context.Context) (int64, error)
CountEnabled(context.Context) (int64, error)
CountByStorageTargetID(context.Context, uint) (int64, error)
CountByNodeID(context.Context, uint) (int64, error)
ListByNodeID(context.Context, uint) ([]model.BackupTask, error)
DistinctTags(context.Context) ([]string, error)
Create(context.Context, *model.BackupTask) error
Update(context.Context, *model.BackupTask) error
Delete(context.Context, uint) error
@@ -37,7 +39,7 @@ func NewBackupTaskRepository(db *gorm.DB) *GormBackupTaskRepository {
}
func (r *GormBackupTaskRepository) List(ctx context.Context, options BackupTaskListOptions) ([]model.BackupTask, error) {
query := r.db.WithContext(ctx).Model(&model.BackupTask{}).Preload("StorageTarget").Preload("StorageTargets").Order("updated_at desc")
query := r.db.WithContext(ctx).Model(&model.BackupTask{}).Preload("StorageTarget").Preload("StorageTargets").Preload("Node").Order("updated_at desc")
if options.Type != "" {
query = query.Where("type = ?", options.Type)
}
@@ -53,7 +55,7 @@ func (r *GormBackupTaskRepository) List(ctx context.Context, options BackupTaskL
func (r *GormBackupTaskRepository) FindByID(ctx context.Context, id uint) (*model.BackupTask, error) {
var item model.BackupTask
if err := r.db.WithContext(ctx).Preload("StorageTarget").Preload("StorageTargets").First(&item, id).Error; err != nil {
if err := r.db.WithContext(ctx).Preload("StorageTarget").Preload("StorageTargets").Preload("Node").First(&item, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
@@ -75,12 +77,105 @@ func (r *GormBackupTaskRepository) FindByName(ctx context.Context, name string)
func (r *GormBackupTaskRepository) ListSchedulable(ctx context.Context) ([]model.BackupTask, error) {
var items []model.BackupTask
if err := r.db.WithContext(ctx).Preload("StorageTarget").Preload("StorageTargets").Where("enabled = ? AND cron_expr <> ''", true).Order("id asc").Find(&items).Error; err != nil {
if err := r.db.WithContext(ctx).Preload("StorageTarget").Preload("StorageTargets").Preload("Node").Where("enabled = ? AND cron_expr <> ''", true).Order("id asc").Find(&items).Error; err != nil {
return nil, err
}
return items, nil
}
// ListVerifySchedulable 列出所有启用且配置了验证 cron 的任务。
// 与 ListSchedulable 的区别:即使任务本身没有备份 cron只要配置了 verify_cron_expr
// 也会被调度(验证是独立的定时动作)。
func (r *GormBackupTaskRepository) ListVerifySchedulable(ctx context.Context) ([]model.BackupTask, error) {
var items []model.BackupTask
if err := r.db.WithContext(ctx).
Preload("StorageTarget").
Preload("StorageTargets").
Preload("Node").
Where("enabled = ? AND verify_enabled = ? AND verify_cron_expr <> ''", true, true).
Order("id asc").
Find(&items).Error; err != nil {
return nil, err
}
return items, nil
}
// DistinctTags 返回系统中所有任务使用过的唯一标签(用于 UI 建议)。
// tags 字段是逗号分隔字符串,此方法会扁平化后去重。
func (r *GormBackupTaskRepository) DistinctTags(ctx context.Context) ([]string, error) {
var rows []struct {
Tags string
}
if err := r.db.WithContext(ctx).
Model(&model.BackupTask{}).
Select("tags").
Where("tags <> ''").
Scan(&rows).Error; err != nil {
return nil, err
}
seen := map[string]bool{}
result := []string{}
for _, row := range rows {
for _, raw := range splitTags(row.Tags) {
if !seen[raw] {
seen[raw] = true
result = append(result, raw)
}
}
}
return result, nil
}
// splitTags 把逗号分隔的 tags 字符串拆成 trim 后的非空切片。
func splitTags(value string) []string {
if value == "" {
return nil
}
var out []string
for _, t := range splitAndTrim(value, ",") {
if t != "" {
out = append(out, t)
}
}
return out
}
// splitAndTrim 内部工具函数:按分隔符切分并去除每段空白。
func splitAndTrim(value, sep string) []string {
parts := make([]string, 0)
for _, p := range bytesSplit(value, sep) {
trimmed := bytesTrimSpace(p)
parts = append(parts, trimmed)
}
return parts
}
// bytesSplit / bytesTrimSpace 只是 strings 的薄包装,便于此仓储文件不引入 strings 依赖。
func bytesSplit(value, sep string) []string {
out := []string{}
start := 0
for i := 0; i+len(sep) <= len(value); i++ {
if value[i:i+len(sep)] == sep {
out = append(out, value[start:i])
start = i + len(sep)
i += len(sep) - 1
}
}
out = append(out, value[start:])
return out
}
func bytesTrimSpace(value string) string {
start, end := 0, len(value)
for start < end && (value[start] == ' ' || value[start] == '\t' || value[start] == '\n' || value[start] == '\r') {
start++
}
for end > start && (value[end-1] == ' ' || value[end-1] == '\t' || value[end-1] == '\n' || value[end-1] == '\r') {
end--
}
return value[start:end]
}
func (r *GormBackupTaskRepository) Count(ctx context.Context) (int64, error) {
var count int64
if err := r.db.WithContext(ctx).Model(&model.BackupTask{}).Count(&count).Error; err != nil {
@@ -117,7 +212,7 @@ func (r *GormBackupTaskRepository) CountByNodeID(ctx context.Context, nodeID uin
// ListByNodeID 列出绑定到指定节点的任务。用于 Agent 拉取本节点待执行任务。
func (r *GormBackupTaskRepository) ListByNodeID(ctx context.Context, nodeID uint) ([]model.BackupTask, error) {
var items []model.BackupTask
if err := r.db.WithContext(ctx).Preload("StorageTarget").Preload("StorageTargets").Where("node_id = ?", nodeID).Order("id asc").Find(&items).Error; err != nil {
if err := r.db.WithContext(ctx).Preload("StorageTarget").Preload("StorageTargets").Preload("Node").Where("node_id = ?", nodeID).Order("id asc").Find(&items).Error; err != nil {
return nil, err
}
return items, nil

View File

@@ -15,6 +15,8 @@ type NodeRepository interface {
FindByToken(context.Context, string) (*model.Node, error)
FindLocal(context.Context) (*model.Node, error)
Create(context.Context, *model.Node) error
// BatchCreate 在单一事务内批量创建节点,任一失败即全部回滚。
BatchCreate(ctx context.Context, nodes []*model.Node) error
Update(context.Context, *model.Node) error
Delete(context.Context, uint) error
MarkStaleOffline(ctx context.Context, threshold time.Time) (int64, error)
@@ -49,7 +51,20 @@ func (r *GormNodeRepository) FindByID(ctx context.Context, id uint) (*model.Node
func (r *GormNodeRepository) FindByToken(ctx context.Context, token string) (*model.Node, error) {
var item model.Node
if err := r.db.WithContext(ctx).Where("token = ?", token).First(&item).Error; err != nil {
// 主 token 查询
err := r.db.WithContext(ctx).Where("token = ?", token).First(&item).Error
if err == nil {
return &item, nil
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err
}
// 回退prev_token 且未过期
now := time.Now().UTC()
err = r.db.WithContext(ctx).
Where("prev_token = ? AND prev_token_expires IS NOT NULL AND prev_token_expires > ?", token, now).
First(&item).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
@@ -73,6 +88,22 @@ func (r *GormNodeRepository) Create(ctx context.Context, item *model.Node) error
return r.db.WithContext(ctx).Create(item).Error
}
// BatchCreate 在单一事务中批量创建节点。任一记录失败即事务回滚。
// 节点 ID 在事务提交后回填到入参切片元素上。
func (r *GormNodeRepository) BatchCreate(ctx context.Context, nodes []*model.Node) error {
if len(nodes) == 0 {
return nil
}
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
for _, n := range nodes {
if err := tx.Create(n).Error; err != nil {
return err
}
}
return nil
})
}
func (r *GormNodeRepository) Update(ctx context.Context, item *model.Node) error {
return r.db.WithContext(ctx).Save(item).Error
}

View File

@@ -0,0 +1,76 @@
package repository
import (
"context"
"path/filepath"
"testing"
"time"
"backupx/server/internal/model"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
gormlogger "gorm.io/gorm/logger"
)
func openTestNodeDB(t *testing.T) *gorm.DB {
t.Helper()
path := filepath.Join(t.TempDir(), "nodes.db")
db, err := gorm.Open(sqlite.Open(path), &gorm.Config{Logger: gormlogger.Default.LogMode(gormlogger.Silent)})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := db.AutoMigrate(&model.Node{}); err != nil {
t.Fatalf("migrate: %v", err)
}
return db
}
func TestFindByTokenFallsBackToPrevToken(t *testing.T) {
db := openTestNodeDB(t)
repo := NewNodeRepository(db)
ctx := context.Background()
future := time.Now().UTC().Add(24 * time.Hour)
node := &model.Node{
Name: "test", Token: "new-token",
PrevToken: "old-token", PrevTokenExpires: &future,
}
if err := repo.Create(ctx, node); err != nil {
t.Fatalf("create: %v", err)
}
// 新 token 能查到
got, err := repo.FindByToken(ctx, "new-token")
if err != nil || got == nil || got.ID != node.ID {
t.Fatalf("new token lookup failed: err=%v got=%v", err, got)
}
// 旧 token 也能查到(未过期)
got, err = repo.FindByToken(ctx, "old-token")
if err != nil || got == nil || got.ID != node.ID {
t.Fatalf("prev_token lookup failed: err=%v got=%v", err, got)
}
}
func TestFindByTokenRejectsExpiredPrevToken(t *testing.T) {
db := openTestNodeDB(t)
repo := NewNodeRepository(db)
ctx := context.Background()
past := time.Now().UTC().Add(-1 * time.Hour)
node := &model.Node{
Name: "test", Token: "new-token",
PrevToken: "stale", PrevTokenExpires: &past,
}
if err := repo.Create(ctx, node); err != nil {
t.Fatalf("create: %v", err)
}
got, err := repo.FindByToken(ctx, "stale")
if err != nil {
t.Fatalf("err=%v", err)
}
if got != nil {
t.Fatalf("expected stale prev_token rejected, got %v", got)
}
}

View File

@@ -0,0 +1,106 @@
package repository
import (
"context"
"errors"
"time"
"backupx/server/internal/model"
"gorm.io/gorm"
)
type ReplicationRecordListOptions struct {
TaskID *uint
BackupRecordID *uint
DestTargetID *uint
Status string
DateFrom *time.Time
DateTo *time.Time
Limit int
Offset int
}
type ReplicationRecordRepository interface {
Create(ctx context.Context, record *model.ReplicationRecord) error
Update(ctx context.Context, record *model.ReplicationRecord) error
FindByID(ctx context.Context, id uint) (*model.ReplicationRecord, error)
List(ctx context.Context, opts ReplicationRecordListOptions) ([]model.ReplicationRecord, error)
Count(ctx context.Context) (int64, error)
}
type GormReplicationRecordRepository struct {
db *gorm.DB
}
func NewReplicationRecordRepository(db *gorm.DB) *GormReplicationRecordRepository {
return &GormReplicationRecordRepository{db: db}
}
func (r *GormReplicationRecordRepository) Create(ctx context.Context, item *model.ReplicationRecord) error {
return r.db.WithContext(ctx).Create(item).Error
}
func (r *GormReplicationRecordRepository) Update(ctx context.Context, item *model.ReplicationRecord) error {
return r.db.WithContext(ctx).Save(item).Error
}
func (r *GormReplicationRecordRepository) FindByID(ctx context.Context, id uint) (*model.ReplicationRecord, error) {
var item model.ReplicationRecord
if err := r.db.WithContext(ctx).
Preload("BackupRecord").
Preload("SourceTarget").
Preload("DestTarget").
First(&item, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &item, nil
}
func (r *GormReplicationRecordRepository) List(ctx context.Context, opts ReplicationRecordListOptions) ([]model.ReplicationRecord, error) {
query := r.db.WithContext(ctx).
Model(&model.ReplicationRecord{}).
Preload("BackupRecord").
Preload("SourceTarget").
Preload("DestTarget").
Order("started_at desc")
if opts.TaskID != nil {
query = query.Where("task_id = ?", *opts.TaskID)
}
if opts.BackupRecordID != nil {
query = query.Where("backup_record_id = ?", *opts.BackupRecordID)
}
if opts.DestTargetID != nil {
query = query.Where("dest_target_id = ?", *opts.DestTargetID)
}
if opts.Status != "" {
query = query.Where("status = ?", opts.Status)
}
if opts.DateFrom != nil {
query = query.Where("started_at >= ?", opts.DateFrom.UTC())
}
if opts.DateTo != nil {
query = query.Where("started_at <= ?", opts.DateTo.UTC())
}
if opts.Limit > 0 {
query = query.Limit(opts.Limit)
}
if opts.Offset > 0 {
query = query.Offset(opts.Offset)
}
var items []model.ReplicationRecord
if err := query.Find(&items).Error; err != nil {
return nil, err
}
return items, nil
}
func (r *GormReplicationRecordRepository) Count(ctx context.Context) (int64, error) {
var count int64
if err := r.db.WithContext(ctx).Model(&model.ReplicationRecord{}).Count(&count).Error; err != nil {
return 0, err
}
return count, nil
}

View File

@@ -0,0 +1,111 @@
package repository
import (
"context"
"errors"
"time"
"backupx/server/internal/model"
"gorm.io/gorm"
)
// RestoreRecordListOptions 恢复记录列表筛选条件。
type RestoreRecordListOptions struct {
TaskID *uint
BackupRecordID *uint
NodeID *uint
Status string
DateFrom *time.Time
DateTo *time.Time
Limit int
Offset int
}
// RestoreRecordRepository 恢复记录仓储接口。
type RestoreRecordRepository interface {
Create(ctx context.Context, item *model.RestoreRecord) error
Update(ctx context.Context, item *model.RestoreRecord) error
Delete(ctx context.Context, id uint) error
FindByID(ctx context.Context, id uint) (*model.RestoreRecord, error)
List(ctx context.Context, options RestoreRecordListOptions) ([]model.RestoreRecord, error)
Count(ctx context.Context) (int64, error)
}
type GormRestoreRecordRepository struct {
db *gorm.DB
}
func NewRestoreRecordRepository(db *gorm.DB) *GormRestoreRecordRepository {
return &GormRestoreRecordRepository{db: db}
}
func (r *GormRestoreRecordRepository) Create(ctx context.Context, item *model.RestoreRecord) error {
return r.db.WithContext(ctx).Create(item).Error
}
func (r *GormRestoreRecordRepository) Update(ctx context.Context, item *model.RestoreRecord) error {
return r.db.WithContext(ctx).Save(item).Error
}
func (r *GormRestoreRecordRepository) Delete(ctx context.Context, id uint) error {
return r.db.WithContext(ctx).Delete(&model.RestoreRecord{}, id).Error
}
func (r *GormRestoreRecordRepository) FindByID(ctx context.Context, id uint) (*model.RestoreRecord, error) {
var item model.RestoreRecord
if err := r.db.WithContext(ctx).
Preload("Task").
Preload("BackupRecord").
First(&item, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &item, nil
}
func (r *GormRestoreRecordRepository) List(ctx context.Context, options RestoreRecordListOptions) ([]model.RestoreRecord, error) {
query := r.db.WithContext(ctx).
Model(&model.RestoreRecord{}).
Preload("Task").
Preload("BackupRecord").
Order("started_at desc")
if options.TaskID != nil {
query = query.Where("task_id = ?", *options.TaskID)
}
if options.BackupRecordID != nil {
query = query.Where("backup_record_id = ?", *options.BackupRecordID)
}
if options.NodeID != nil {
query = query.Where("node_id = ?", *options.NodeID)
}
if options.Status != "" {
query = query.Where("status = ?", options.Status)
}
if options.DateFrom != nil {
query = query.Where("started_at >= ?", options.DateFrom.UTC())
}
if options.DateTo != nil {
query = query.Where("started_at <= ?", options.DateTo.UTC())
}
if options.Limit > 0 {
query = query.Limit(options.Limit)
}
if options.Offset > 0 {
query = query.Offset(options.Offset)
}
var items []model.RestoreRecord
if err := query.Find(&items).Error; err != nil {
return nil, err
}
return items, nil
}
func (r *GormRestoreRecordRepository) Count(ctx context.Context) (int64, error) {
var count int64
if err := r.db.WithContext(ctx).Model(&model.RestoreRecord{}).Count(&count).Error; err != nil {
return 0, err
}
return count, nil
}

View File

@@ -0,0 +1,126 @@
package repository
import (
"context"
"path/filepath"
"testing"
"time"
"backupx/server/internal/config"
"backupx/server/internal/database"
"backupx/server/internal/logger"
"backupx/server/internal/model"
)
func newRestoreRecordTestRepository(t *testing.T) (*GormRestoreRecordRepository, uint) {
t.Helper()
log, err := logger.New(config.LogConfig{Level: "error"})
if err != nil {
t.Fatalf("logger.New returned error: %v", err)
}
db, err := database.Open(config.DatabaseConfig{Path: filepath.Join(t.TempDir(), "backupx.db")}, log)
if err != nil {
t.Fatalf("database.Open returned error: %v", err)
}
storageTarget := &model.StorageTarget{Name: "local", Type: "local_disk", Enabled: true, ConfigCiphertext: "{}", ConfigVersion: 1, LastTestStatus: "unknown"}
if err := db.Create(storageTarget).Error; err != nil {
t.Fatalf("seed storage target error: %v", err)
}
task := &model.BackupTask{Name: "website", Type: "file", Enabled: true, SourcePath: "/srv/www", StorageTargetID: storageTarget.ID, RetentionDays: 30, Compression: "gzip", MaxBackups: 10, LastStatus: "idle"}
if err := db.Create(task).Error; err != nil {
t.Fatalf("seed backup task error: %v", err)
}
now := time.Now().UTC()
completedAt := now.Add(time.Minute)
backupRecord := &model.BackupRecord{TaskID: task.ID, StorageTargetID: storageTarget.ID, Status: model.BackupRecordStatusSuccess, FileName: "website.tar.gz", FileSize: 1024, StoragePath: "tasks/1/website.tar.gz", StartedAt: now, CompletedAt: &completedAt}
if err := db.Create(backupRecord).Error; err != nil {
t.Fatalf("seed backup record error: %v", err)
}
return NewRestoreRecordRepository(db), backupRecord.ID
}
func TestRestoreRecordRepositoryCRUD(t *testing.T) {
ctx := context.Background()
repo, backupRecordID := newRestoreRecordTestRepository(t)
startedAt := time.Now().UTC()
restore := &model.RestoreRecord{
BackupRecordID: backupRecordID,
TaskID: 1,
NodeID: 0,
Status: model.RestoreRecordStatusRunning,
StartedAt: startedAt,
TriggeredBy: "admin",
}
if err := repo.Create(ctx, restore); err != nil {
t.Fatalf("Create returned error: %v", err)
}
if restore.ID == 0 {
t.Fatalf("expected generated restore ID, got 0")
}
found, err := repo.FindByID(ctx, restore.ID)
if err != nil {
t.Fatalf("FindByID returned error: %v", err)
}
if found == nil || found.TriggeredBy != "admin" || found.Status != model.RestoreRecordStatusRunning {
t.Fatalf("unexpected restore record: %#v", found)
}
if found.BackupRecord.ID != backupRecordID {
t.Fatalf("expected BackupRecord preload, got %#v", found.BackupRecord)
}
completedAt := startedAt.Add(30 * time.Second)
found.Status = model.RestoreRecordStatusSuccess
found.DurationSeconds = 30
found.CompletedAt = &completedAt
if err := repo.Update(ctx, found); err != nil {
t.Fatalf("Update returned error: %v", err)
}
runningFilter := model.RestoreRecordStatusRunning
list, err := repo.List(ctx, RestoreRecordListOptions{Status: runningFilter})
if err != nil {
t.Fatalf("List returned error: %v", err)
}
if len(list) != 0 {
t.Fatalf("expected no running restores after update, got %d", len(list))
}
successFilter := model.RestoreRecordStatusSuccess
successList, err := repo.List(ctx, RestoreRecordListOptions{Status: successFilter})
if err != nil {
t.Fatalf("List success returned error: %v", err)
}
if len(successList) != 1 {
t.Fatalf("expected 1 success restore, got %d", len(successList))
}
brID := backupRecordID
byBackup, err := repo.List(ctx, RestoreRecordListOptions{BackupRecordID: &brID})
if err != nil {
t.Fatalf("List byBackup returned error: %v", err)
}
if len(byBackup) != 1 {
t.Fatalf("expected 1 restore for backup record, got %d", len(byBackup))
}
total, err := repo.Count(ctx)
if err != nil {
t.Fatalf("Count returned error: %v", err)
}
if total != 1 {
t.Fatalf("expected 1 total, got %d", total)
}
if err := repo.Delete(ctx, restore.ID); err != nil {
t.Fatalf("Delete returned error: %v", err)
}
afterDel, err := repo.FindByID(ctx, restore.ID)
if err != nil {
t.Fatalf("FindByID after delete returned error: %v", err)
}
if afterDel != nil {
t.Fatalf("expected nil after delete, got %#v", afterDel)
}
}

View File

@@ -0,0 +1,68 @@
package repository
import (
"context"
"errors"
"backupx/server/internal/model"
"gorm.io/gorm"
)
type TaskTemplateRepository interface {
Create(ctx context.Context, template *model.TaskTemplate) error
Update(ctx context.Context, template *model.TaskTemplate) error
Delete(ctx context.Context, id uint) error
FindByID(ctx context.Context, id uint) (*model.TaskTemplate, error)
FindByName(ctx context.Context, name string) (*model.TaskTemplate, error)
List(ctx context.Context) ([]model.TaskTemplate, error)
}
type GormTaskTemplateRepository struct {
db *gorm.DB
}
func NewTaskTemplateRepository(db *gorm.DB) *GormTaskTemplateRepository {
return &GormTaskTemplateRepository{db: db}
}
func (r *GormTaskTemplateRepository) Create(ctx context.Context, t *model.TaskTemplate) error {
return r.db.WithContext(ctx).Create(t).Error
}
func (r *GormTaskTemplateRepository) Update(ctx context.Context, t *model.TaskTemplate) error {
return r.db.WithContext(ctx).Save(t).Error
}
func (r *GormTaskTemplateRepository) Delete(ctx context.Context, id uint) error {
return r.db.WithContext(ctx).Delete(&model.TaskTemplate{}, id).Error
}
func (r *GormTaskTemplateRepository) FindByID(ctx context.Context, id uint) (*model.TaskTemplate, error) {
var item model.TaskTemplate
if err := r.db.WithContext(ctx).First(&item, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &item, nil
}
func (r *GormTaskTemplateRepository) FindByName(ctx context.Context, name string) (*model.TaskTemplate, error) {
var item model.TaskTemplate
if err := r.db.WithContext(ctx).Where("name = ?", name).First(&item).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &item, nil
}
func (r *GormTaskTemplateRepository) List(ctx context.Context) ([]model.TaskTemplate, error) {
var items []model.TaskTemplate
if err := r.db.WithContext(ctx).Order("name asc").Find(&items).Error; err != nil {
return nil, err
}
return items, nil
}

View File

@@ -10,8 +10,11 @@ import (
type UserRepository interface {
Count(context.Context) (int64, error)
CountByRole(context.Context, string) (int64, error)
Create(context.Context, *model.User) error
Update(context.Context, *model.User) error
Delete(context.Context, uint) error
List(context.Context) ([]model.User, error)
FindByUsername(context.Context, string) (*model.User, error)
FindByID(context.Context, uint) (*model.User, error)
}
@@ -32,6 +35,31 @@ func (r *GormUserRepository) Count(ctx context.Context) (int64, error) {
return count, nil
}
// CountByRole 按角色统计启用(非 disabled用户数。用于防止删除最后一个 admin。
func (r *GormUserRepository) CountByRole(ctx context.Context, role string) (int64, error) {
var count int64
if err := r.db.WithContext(ctx).Model(&model.User{}).
Where("role = ? AND disabled = ?", role, false).
Count(&count).Error; err != nil {
return 0, err
}
return count, nil
}
// List 按创建时间升序返回所有用户。
func (r *GormUserRepository) List(ctx context.Context) ([]model.User, error) {
var items []model.User
if err := r.db.WithContext(ctx).Order("created_at asc").Find(&items).Error; err != nil {
return nil, err
}
return items, nil
}
// Delete 物理删除用户。调用方应先在 service 层检查最后 admin。
func (r *GormUserRepository) Delete(ctx context.Context, id uint) error {
return r.db.WithContext(ctx).Delete(&model.User{}, id).Error
}
func (r *GormUserRepository) Create(ctx context.Context, user *model.User) error {
return r.db.WithContext(ctx).Create(user).Error
}

View File

@@ -0,0 +1,121 @@
package repository
import (
"context"
"errors"
"time"
"backupx/server/internal/model"
"gorm.io/gorm"
)
// VerificationRecordListOptions 验证记录列表筛选条件。
type VerificationRecordListOptions struct {
TaskID *uint
BackupRecordID *uint
Status string
DateFrom *time.Time
DateTo *time.Time
Limit int
Offset int
}
type VerificationRecordRepository interface {
Create(ctx context.Context, item *model.VerificationRecord) error
Update(ctx context.Context, item *model.VerificationRecord) error
Delete(ctx context.Context, id uint) error
FindByID(ctx context.Context, id uint) (*model.VerificationRecord, error)
List(ctx context.Context, options VerificationRecordListOptions) ([]model.VerificationRecord, error)
FindLatestByTask(ctx context.Context, taskID uint) (*model.VerificationRecord, error)
Count(ctx context.Context) (int64, error)
}
type GormVerificationRecordRepository struct {
db *gorm.DB
}
func NewVerificationRecordRepository(db *gorm.DB) *GormVerificationRecordRepository {
return &GormVerificationRecordRepository{db: db}
}
func (r *GormVerificationRecordRepository) Create(ctx context.Context, item *model.VerificationRecord) error {
return r.db.WithContext(ctx).Create(item).Error
}
func (r *GormVerificationRecordRepository) Update(ctx context.Context, item *model.VerificationRecord) error {
return r.db.WithContext(ctx).Save(item).Error
}
func (r *GormVerificationRecordRepository) Delete(ctx context.Context, id uint) error {
return r.db.WithContext(ctx).Delete(&model.VerificationRecord{}, id).Error
}
func (r *GormVerificationRecordRepository) FindByID(ctx context.Context, id uint) (*model.VerificationRecord, error) {
var item model.VerificationRecord
if err := r.db.WithContext(ctx).
Preload("Task").
Preload("BackupRecord").
First(&item, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &item, nil
}
func (r *GormVerificationRecordRepository) List(ctx context.Context, options VerificationRecordListOptions) ([]model.VerificationRecord, error) {
query := r.db.WithContext(ctx).
Model(&model.VerificationRecord{}).
Preload("Task").
Preload("BackupRecord").
Order("started_at desc")
if options.TaskID != nil {
query = query.Where("task_id = ?", *options.TaskID)
}
if options.BackupRecordID != nil {
query = query.Where("backup_record_id = ?", *options.BackupRecordID)
}
if options.Status != "" {
query = query.Where("status = ?", options.Status)
}
if options.DateFrom != nil {
query = query.Where("started_at >= ?", options.DateFrom.UTC())
}
if options.DateTo != nil {
query = query.Where("started_at <= ?", options.DateTo.UTC())
}
if options.Limit > 0 {
query = query.Limit(options.Limit)
}
if options.Offset > 0 {
query = query.Offset(options.Offset)
}
var items []model.VerificationRecord
if err := query.Find(&items).Error; err != nil {
return nil, err
}
return items, nil
}
func (r *GormVerificationRecordRepository) FindLatestByTask(ctx context.Context, taskID uint) (*model.VerificationRecord, error) {
var item model.VerificationRecord
if err := r.db.WithContext(ctx).
Where("task_id = ?", taskID).
Order("started_at desc").
First(&item).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &item, nil
}
func (r *GormVerificationRecordRepository) Count(ctx context.Context) (int64, error) {
var count int64
if err := r.db.WithContext(ctx).Model(&model.VerificationRecord{}).Count(&count).Error; err != nil {
return 0, err
}
return count, nil
}

View File

@@ -6,6 +6,7 @@ import (
"sync"
"time"
"backupx/server/internal/backup"
"backupx/server/internal/model"
"backupx/server/internal/repository"
servicepkg "backupx/server/internal/service"
@@ -17,28 +18,59 @@ type TaskRunner interface {
RunTaskByID(context.Context, uint) (*servicepkg.BackupRecordDetail, error)
}
// VerifyRunner 供调度器触发验证演练。
// 使用最新成功备份作为源taskID 对应的任务须配置 VerifyEnabled=true。
type VerifyRunner interface {
StartByTask(ctx context.Context, taskID uint, mode, triggeredBy string) (*servicepkg.VerificationRecordDetail, error)
}
// AuditRecorder 记录审计日志(可选依赖)
type AuditRecorder interface {
Record(servicepkg.AuditEntry)
}
type Service struct {
mu sync.Mutex
cron *cron.Cron
tasks repository.BackupTaskRepository
runner TaskRunner
logger *zap.Logger
audit AuditRecorder
entries map[uint]cron.EntryID
mu sync.Mutex
cron *cron.Cron
tasks repository.BackupTaskRepository
nodes repository.NodeRepository
runner TaskRunner
verifyRunner VerifyRunner
logger *zap.Logger
audit AuditRecorder
entries map[uint]cron.EntryID // 备份 cron 条目
verifyEntries map[uint]cron.EntryID // 验证 cron 条目
}
func NewService(tasks repository.BackupTaskRepository, runner TaskRunner, logger *zap.Logger) *Service {
parser := cron.NewParser(cron.SecondOptional | cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow | cron.Descriptor)
return &Service{cron: cron.New(cron.WithParser(parser), cron.WithLocation(time.UTC)), tasks: tasks, runner: runner, logger: logger, entries: make(map[uint]cron.EntryID)}
return &Service{
cron: cron.New(cron.WithParser(parser), cron.WithLocation(time.Local)),
tasks: tasks,
runner: runner,
logger: logger,
entries: make(map[uint]cron.EntryID),
verifyEntries: make(map[uint]cron.EntryID),
}
}
// SetVerifyRunner 注入验证调度器。可选注入:未注入时不处理验证 cron。
func (s *Service) SetVerifyRunner(runner VerifyRunner) {
s.mu.Lock()
defer s.mu.Unlock()
s.verifyRunner = runner
}
func (s *Service) SetAuditRecorder(audit AuditRecorder) { s.audit = audit }
// SetNodeRepository 注入节点仓储用于调度前的健康检查。
// 可选注入:未注入时调度器无条件触发任务(单节点场景)。
func (s *Service) SetNodeRepository(nodes repository.NodeRepository) {
s.mu.Lock()
defer s.mu.Unlock()
s.nodes = nodes
}
func (s *Service) Start(ctx context.Context) error {
if err := s.Reload(ctx); err != nil {
return err
@@ -62,25 +94,43 @@ func (s *Service) Reload(ctx context.Context) error {
if err != nil {
return err
}
// 验证调度单独扫描(启用验证的任务可能未启用备份 cron反之亦然
verifyItems, err := s.tasks.ListVerifySchedulable(ctx)
if err != nil {
return err
}
s.mu.Lock()
defer s.mu.Unlock()
for taskID, entryID := range s.entries {
s.cron.Remove(entryID)
delete(s.entries, taskID)
}
for taskID, entryID := range s.verifyEntries {
s.cron.Remove(entryID)
delete(s.verifyEntries, taskID)
}
for _, item := range items {
item := item
if err := s.syncTaskLocked(&item); err != nil {
return err
}
}
for _, item := range verifyItems {
item := item
if err := s.syncVerifyTaskLocked(&item); err != nil {
return err
}
}
return nil
}
func (s *Service) SyncTask(_ context.Context, task *model.BackupTask) error {
s.mu.Lock()
defer s.mu.Unlock()
return s.syncTaskLocked(task)
if err := s.syncTaskLocked(task); err != nil {
return err
}
return s.syncVerifyTaskLocked(task)
}
func (s *Service) RemoveTask(_ context.Context, taskID uint) error {
@@ -90,6 +140,10 @@ func (s *Service) RemoveTask(_ context.Context, taskID uint) error {
s.cron.Remove(entryID)
delete(s.entries, taskID)
}
if entryID, ok := s.verifyEntries[taskID]; ok {
s.cron.Remove(entryID)
delete(s.verifyEntries, taskID)
}
return nil
}
@@ -106,13 +160,56 @@ func (s *Service) syncTaskLocked(task *model.BackupTask) error {
}
taskID := task.ID
taskName := task.Name
entryID, err := s.cron.AddFunc(task.CronExpr, func() {
taskNodeID := task.NodeID
cronExpr := task.CronExpr
maintenanceWindows := task.MaintenanceWindows
entryID, err := s.cron.AddFunc(cronExpr, func() {
// 集群感知:若任务绑定了离线的远程节点,跳过本轮触发避免堆积 failed 记录
if taskNodeID > 0 && s.nodes != nil {
node, err := s.nodes.FindByID(context.Background(), taskNodeID)
if err == nil && node != nil && !node.IsLocal && node.Status != model.NodeStatusOnline {
if s.logger != nil {
s.logger.Warn("skip scheduled run: target node offline",
zap.Uint("task_id", taskID), zap.String("task_name", taskName),
zap.Uint("node_id", taskNodeID), zap.String("node_name", node.Name))
}
if s.audit != nil {
s.audit.Record(servicepkg.AuditEntry{
Username: "system", Category: "backup_task", Action: "scheduled_skip",
TargetType: "backup_task", TargetID: fmt.Sprintf("%d", taskID),
TargetName: taskName,
Detail: fmt.Sprintf("跳过调度触发:节点 %s 离线 (task: %s, cron: %s)", node.Name, taskName, cronExpr),
})
}
return
}
}
// 维护窗口校验非窗口时间跳过。Windows 为空则不限制。
if maintenanceWindows != "" {
windows := backup.ParseMaintenanceWindows(maintenanceWindows)
if len(windows) > 0 && !backup.IsWithinWindow(time.Now(), windows) {
if s.logger != nil {
s.logger.Info("skip scheduled run: outside maintenance window",
zap.Uint("task_id", taskID), zap.String("task_name", taskName),
zap.String("windows", maintenanceWindows))
}
if s.audit != nil {
s.audit.Record(servicepkg.AuditEntry{
Username: "system", Category: "backup_task", Action: "scheduled_skip",
TargetType: "backup_task", TargetID: fmt.Sprintf("%d", taskID),
TargetName: taskName,
Detail: fmt.Sprintf("跳过调度触发:非维护窗口 (task: %s, windows: %s)", taskName, maintenanceWindows),
})
}
return
}
}
// 自动调度任务记录审计日志
if s.audit != nil {
s.audit.Record(servicepkg.AuditEntry{
Username: "system", Category: "backup_task", Action: "scheduled_run",
TargetType: "backup_task", TargetID: fmt.Sprintf("%d", taskID),
TargetName: taskName, Detail: fmt.Sprintf("定时调度触发备份任务: %s (cron: %s)", taskName, task.CronExpr),
TargetName: taskName, Detail: fmt.Sprintf("定时调度触发备份任务: %s (cron: %s)", taskName, cronExpr),
})
}
if _, runErr := s.runner.RunTaskByID(context.Background(), taskID); runErr != nil && s.logger != nil {
@@ -125,3 +222,43 @@ func (s *Service) syncTaskLocked(task *model.BackupTask) error {
s.entries[task.ID] = entryID
return nil
}
// syncVerifyTaskLocked 同步任务的验证演练 cron 条目。
// 调度时间到 → 拉取最新成功备份 → 触发 Verify 快速校验。
// 若未注入 verifyRunner直接返回单节点+无验证场景)。
func (s *Service) syncVerifyTaskLocked(task *model.BackupTask) error {
if task == nil {
return fmt.Errorf("task is required")
}
if entryID, ok := s.verifyEntries[task.ID]; ok {
s.cron.Remove(entryID)
delete(s.verifyEntries, task.ID)
}
if s.verifyRunner == nil {
return nil
}
if !task.Enabled || !task.VerifyEnabled || task.VerifyCronExpr == "" {
return nil
}
taskID := task.ID
taskName := task.Name
mode := task.VerifyMode
verifyCron := task.VerifyCronExpr
entryID, err := s.cron.AddFunc(verifyCron, func() {
if s.audit != nil {
s.audit.Record(servicepkg.AuditEntry{
Username: "system", Category: "backup_verify", Action: "scheduled_run",
TargetType: "backup_task", TargetID: fmt.Sprintf("%d", taskID),
TargetName: taskName, Detail: fmt.Sprintf("定时验证演练: %s (cron: %s, mode: %s)", taskName, verifyCron, mode),
})
}
if _, runErr := s.verifyRunner.StartByTask(context.Background(), taskID, mode, "system"); runErr != nil && s.logger != nil {
s.logger.Warn("scheduled verify run failed", zap.Uint("task_id", taskID), zap.Error(runErr))
}
})
if err != nil {
return err
}
s.verifyEntries[task.ID] = entryID
return nil
}

View File

@@ -26,6 +26,12 @@ func (r *fakeTaskRepository) FindByName(context.Context, string) (*model.BackupT
func (r *fakeTaskRepository) ListSchedulable(context.Context) ([]model.BackupTask, error) {
return r.items, nil
}
func (r *fakeTaskRepository) ListVerifySchedulable(context.Context) ([]model.BackupTask, error) {
return nil, nil
}
func (r *fakeTaskRepository) DistinctTags(context.Context) ([]string, error) {
return nil, nil
}
func (r *fakeTaskRepository) Count(context.Context) (int64, error) { return 0, nil }
func (r *fakeTaskRepository) CountEnabled(context.Context) (int64, error) { return 0, nil }
func (r *fakeTaskRepository) CountByStorageTargetID(context.Context, uint) (int64, error) {
@@ -62,3 +68,37 @@ func TestServiceSyncTaskAndTrigger(t *testing.T) {
t.Fatalf("expected scheduled runner to be triggered")
}
}
func TestServiceSchedulesTasksInLocalTimezone(t *testing.T) {
location, err := time.LoadLocation("Asia/Shanghai")
if err != nil {
t.Fatalf("LoadLocation returned error: %v", err)
}
originalLocal := time.Local
time.Local = location
t.Cleanup(func() {
time.Local = originalLocal
})
service := NewService(&fakeTaskRepository{}, &fakeRunner{}, nil)
if got := service.cron.Location(); got != location {
t.Fatalf("cron location = %v, want %v", got, location)
}
task := &model.BackupTask{ID: 1, Enabled: true, CronExpr: "0 5 * * *"}
if err := service.SyncTask(context.Background(), task); err != nil {
t.Fatalf("SyncTask returned error: %v", err)
}
entryID, ok := service.entries[task.ID]
if !ok {
t.Fatalf("expected cron entry for task %d", task.ID)
}
entry := service.cron.Entry(entryID)
now := time.Date(2026, 4, 30, 4, 0, 0, 0, location)
got := entry.Schedule.Next(now)
want := time.Date(2026, 4, 30, 5, 0, 0, 0, location)
if !got.Equal(want) {
t.Fatalf("next run = %s, want %s", got, want)
}
}

View File

@@ -0,0 +1,23 @@
package security
import (
"crypto/rand"
"fmt"
"math/big"
"strings"
)
const LoginOTPDigits = 6
func GenerateNumericOTP() (string, error) {
limit := big.NewInt(1_000_000)
value, err := rand.Int(rand.Reader, limit)
if err != nil {
return "", err
}
return fmt.Sprintf("%0*d", LoginOTPDigits, value.Int64()), nil
}
func NormalizeNumericOTP(code string) string {
return strings.TrimSpace(code)
}

View File

@@ -0,0 +1,49 @@
package security
import (
"crypto/rand"
"encoding/hex"
"fmt"
"strings"
"unicode"
)
const RecoveryCodeCount = 10
func GenerateRecoveryCodes(count int) ([]string, error) {
if count <= 0 {
count = RecoveryCodeCount
}
codes := make([]string, 0, count)
for i := 0; i < count; i++ {
raw := make([]byte, 8)
if _, err := rand.Read(raw); err != nil {
return nil, fmt.Errorf("generate recovery code: %w", err)
}
encoded := strings.ToUpper(hex.EncodeToString(raw))
codes = append(codes, encoded[0:4]+"-"+encoded[4:8]+"-"+encoded[8:12]+"-"+encoded[12:16])
}
return codes, nil
}
func NormalizeRecoveryCode(code string) string {
return strings.Map(func(r rune) rune {
if unicode.IsSpace(r) || r == '-' {
return -1
}
return unicode.ToUpper(r)
}, strings.TrimSpace(code))
}
func IsRecoveryCodeCandidate(code string) bool {
normalized := NormalizeRecoveryCode(code)
if len(normalized) != 16 {
return false
}
for _, r := range normalized {
if !('0' <= r && r <= '9') && !('A' <= r && r <= 'F') {
return false
}
}
return true
}

Some files were not shown because too many files have changed in this diff Show More