Compare commits

..

11 Commits

Author SHA1 Message Date
Wu Qing
7a6ffd4ddd feat(BackupX): 修复跨节点备份恢复终态处理 (#60)
* feat(BackupX): 修复集群部署管理逻辑

* feat(BackupX): 修复节点池任务运行归属

* feat(BackupX): 修复跨节点恢复路由

* feat(BackupX): 修复跨节点备份恢复终态处理

* test(BackupX): 稳定安装流HTTP测试
2026-05-09 23:03:25 +08:00
Wu Qing
61709dd4c9 fix(cluster): support external master URL
- add server.external_url / BACKUPX_SERVER_EXTERNAL_URL for Agent install URL generation
- pass the configured external Master URL into install script and compose rendering
- document cluster deployment requirements for Docker, bare-metal, and multi-node setups

Fixes #55
2026-05-09 07:41:51 +08:00
Wu Qing
f6bd185b9f feat: improve agent install release layout support
- fix bare-metal Agent install config and executor path handling
- support release package layout in deploy/install.sh and release workflow
- add regression tests for Agent execution and deploy install script behavior
2026-05-09 00:00:53 +08:00
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
215 changed files with 22966 additions and 1003 deletions

View File

@@ -110,13 +110,21 @@ jobs:
cp -r web/dist "${ARCHIVE_NAME}/web" cp -r web/dist "${ARCHIVE_NAME}/web"
cp server/config.example.yaml "${ARCHIVE_NAME}/" cp server/config.example.yaml "${ARCHIVE_NAME}/"
cp deploy/install.sh "${ARCHIVE_NAME}/" 2>/dev/null || true 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}" tar czf "${ARCHIVE_NAME}.tar.gz" "${ARCHIVE_NAME}"
cp "${ARCHIVE_NAME}.tar.gz" "backupx-${{ matrix.goos }}-${{ matrix.goarch }}.tar.gz"
- name: Upload to GitHub Release - name: Upload to GitHub Release
uses: softprops/action-gh-release@v2 uses: softprops/action-gh-release@v2
with: with:
tag_name: ${{ env.VERSION }} tag_name: ${{ env.VERSION }}
files: backupx-${{ env.VERSION }}-${{ matrix.goos }}-${{ matrix.goarch }}.tar.gz files: |
backupx-${{ env.VERSION }}-${{ matrix.goos }}-${{ matrix.goarch }}.tar.gz
backupx-${{ matrix.goos }}-${{ matrix.goarch }}.tar.gz
generate_release_notes: true generate_release_notes: true
# ─── Job 3: Docker 多架构 → Docker Hub ─── # ─── Job 3: Docker 多架构 → Docker Hub ───

3
.gitignore vendored
View File

@@ -1,3 +1,4 @@
web/node_modules/ web/node_modules/
web/dist/ 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 | | **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 | | **Security** | JWT + bcrypt + AES-256-GCM encrypted config + optional backup encryption + full audit log |
| **Notifications** | Email / Webhook / Telegram on success or failure | | **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 | | **Deployment** | Single binary + embedded SQLite, Docker one-click, zero external dependencies |
## Quick Start ## Quick Start
@@ -59,6 +62,8 @@ curl -LO https://github.com/Awuqing/BackupX/releases/latest/download/backupx-lin
tar xzf backupx-*.tar.gz && cd backupx-* && sudo ./install.sh tar xzf backupx-*.tar.gz && cd backupx-* && sudo ./install.sh
``` ```
For ARM64 hosts, use `backupx-linux-arm64.tar.gz`. The archive contains `backupx`, `web/`, `config.example.yaml`, and `install.sh`; run `install.sh` from the extracted directory.
Open `http://your-server:8340`, create the admin account, then follow the [5-minute Quick Start](https://awuqing.github.io/BackupX/docs/getting-started/quick-start). Open `http://your-server:8340`, create the admin account, then follow the [5-minute Quick Start](https://awuqing.github.io/BackupX/docs/getting-started/quick-start).
## Documentation ## Documentation

View File

@@ -46,6 +46,9 @@
| **多节点集群** | Master-Agent 模式,基于 HTTP 长轮询跨多台服务器管理备份。Agent 本地执行任务并直接上传到存储,无需反向连通性 | | **多节点集群** | Master-Agent 模式,基于 HTTP 长轮询跨多台服务器管理备份。Agent 本地执行任务并直接上传到存储,无需反向连通性 |
| **安全** | JWT + bcrypt + AES-256-GCM 加密配置 + 可选备份文件加密 + 完整审计日志 | | **安全** | JWT + bcrypt + AES-256-GCM 加密配置 + 可选备份文件加密 + 完整审计日志 |
| **通知** | 邮件 / Webhook / Telegram备份成功或失败时自动推送 | | **通知** | 邮件 / Webhook / Telegram备份成功或失败时自动推送 |
| **可观测性** | Prometheus `/metrics` 端点 + `/health` + `/ready` 探针 + SLA 违约监控 |
| **审计外输** | HMAC-SHA256 签名 Webhook对接 SIEM / WORM 存储满足 SOC2 / GDPR 合规 |
| **流控** | 节点级带宽限速 + 节点级并发控制,大小节点分别配置,避免小内存 Agent 被挤爆 |
| **部署** | 单二进制 + 内嵌 SQLiteDocker 一键启动,零外部依赖 | | **部署** | 单二进制 + 内嵌 SQLiteDocker 一键启动,零外部依赖 |
## 快速开始 ## 快速开始
@@ -59,6 +62,8 @@ curl -LO https://github.com/Awuqing/BackupX/releases/latest/download/backupx-lin
tar xzf backupx-*.tar.gz && cd backupx-* && sudo ./install.sh tar xzf backupx-*.tar.gz && cd backupx-* && sudo ./install.sh
``` ```
ARM64 主机请下载 `backupx-linux-arm64.tar.gz`。预编译包内包含 `backupx``web/``config.example.yaml``install.sh`,请在解压后的目录内执行 `install.sh`
打开 `http://your-server:8340`,创建管理员账户,按 [5 分钟快速开始](https://awuqing.github.io/BackupX/zh-Hans/docs/getting-started/quick-start) 完成首次备份。 打开 `http://your-server:8340`,创建管理员账户,按 [5 分钟快速开始](https://awuqing.github.io/BackupX/zh-Hans/docs/getting-started/quick-start) 完成首次备份。
## 文档 ## 文档

View File

@@ -19,6 +19,25 @@ server {
proxy_read_timeout 3600s; 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 # SPA fallback
location / { location / {
try_files $uri $uri/ /index.html; 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

@@ -1,17 +1,25 @@
#!/bin/sh #!/bin/sh
set -eu set -eu
PROJECT_ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
PROJECT_ROOT=$(CDPATH= cd -- "$SCRIPT_DIR/.." && pwd)
PREFIX="${PREFIX:-/opt/backupx}" PREFIX="${PREFIX:-/opt/backupx}"
ETC_DIR="${ETC_DIR:-/etc/backupx}" ETC_DIR="${ETC_DIR:-/etc/backupx}"
SERVICE_NAME="backupx" SERVICE_NAME="backupx"
APP_USER="backupx" APP_USER="backupx"
APP_GROUP="backupx" APP_GROUP="backupx"
BIN_SOURCE="${BIN_SOURCE:-$PROJECT_ROOT/server/backupx}" if [ -f "$SCRIPT_DIR/backupx" ] && [ -d "$SCRIPT_DIR/web" ]; then
WEB_SOURCE="${WEB_SOURCE:-$PROJECT_ROOT/web/dist}" BIN_SOURCE="${BIN_SOURCE:-$SCRIPT_DIR/backupx}"
CONFIG_TEMPLATE="${CONFIG_TEMPLATE:-$PROJECT_ROOT/server/config.example.yaml}" WEB_SOURCE="${WEB_SOURCE:-$SCRIPT_DIR/web}"
CONFIG_TEMPLATE="${CONFIG_TEMPLATE:-$SCRIPT_DIR/config.example.yaml}"
NGINX_SOURCE="${NGINX_SOURCE:-$SCRIPT_DIR/nginx.conf}"
else
BIN_SOURCE="${BIN_SOURCE:-$PROJECT_ROOT/server/backupx}"
WEB_SOURCE="${WEB_SOURCE:-$PROJECT_ROOT/web/dist}"
CONFIG_TEMPLATE="${CONFIG_TEMPLATE:-$PROJECT_ROOT/server/config.example.yaml}"
NGINX_SOURCE="${NGINX_SOURCE:-$PROJECT_ROOT/deploy/nginx.conf}"
fi
SERVICE_SOURCE="${SERVICE_SOURCE:-$PROJECT_ROOT/deploy/backupx.service}" SERVICE_SOURCE="${SERVICE_SOURCE:-$PROJECT_ROOT/deploy/backupx.service}"
NGINX_SOURCE="${NGINX_SOURCE:-$PROJECT_ROOT/deploy/nginx.conf}"
if [ "$(id -u)" -ne 0 ]; then if [ "$(id -u)" -ne 0 ]; then
echo "请使用 root 或 sudo 执行安装脚本。" >&2 echo "请使用 root 或 sudo 执行安装脚本。" >&2
@@ -20,13 +28,20 @@ fi
if [ ! -f "$BIN_SOURCE" ]; then if [ ! -f "$BIN_SOURCE" ]; then
echo "未找到后端二进制:$BIN_SOURCE" >&2 echo "未找到后端二进制:$BIN_SOURCE" >&2
echo "请先执行cd \"$PROJECT_ROOT/server\" && go build -o backupx ./cmd/backupx" >&2 echo "源码树安装请先执行cd \"$PROJECT_ROOT/server\" && go build -o backupx ./cmd/backupx" >&2
echo "发布包安装请确认当前目录包含 ./backupx、./web 和 ./install.sh。" >&2
exit 1 exit 1
fi fi
if [ ! -d "$WEB_SOURCE" ]; then if [ ! -d "$WEB_SOURCE" ]; then
echo "未找到前端构建产物:$WEB_SOURCE" >&2 echo "未找到前端构建产物:$WEB_SOURCE" >&2
echo "请先执行cd \"$PROJECT_ROOT/web\" && npm run build" >&2 echo "源码树安装请先执行cd \"$PROJECT_ROOT/web\" && npm run build" >&2
echo "发布包安装请确认当前目录包含 ./web。" >&2
exit 1
fi
if [ ! -f "$CONFIG_TEMPLATE" ]; then
echo "未找到配置模板:$CONFIG_TEMPLATE" >&2
exit 1 exit 1
fi fi
@@ -47,11 +62,34 @@ if [ ! -f "$ETC_DIR/config.yaml" ]; then
install -m 0640 "$CONFIG_TEMPLATE" "$ETC_DIR/config.yaml" install -m 0640 "$CONFIG_TEMPLATE" "$ETC_DIR/config.yaml"
fi fi
install -m 0644 "$SERVICE_SOURCE" "/etc/systemd/system/$SERVICE_NAME.service" if [ -f "$SERVICE_SOURCE" ]; then
install -m 0644 "$SERVICE_SOURCE" "/etc/systemd/system/$SERVICE_NAME.service"
else
cat > "/etc/systemd/system/$SERVICE_NAME.service" <<UNIT
[Unit]
Description=BackupX API Service
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=$APP_USER
Group=$APP_GROUP
WorkingDirectory=$PREFIX
ExecStart=$PREFIX/bin/backupx -config $ETC_DIR/config.yaml
Restart=on-failure
RestartSec=5
NoNewPrivileges=true
LimitNOFILE=65535
[Install]
WantedBy=multi-user.target
UNIT
fi
systemctl daemon-reload systemctl daemon-reload
systemctl enable --now "$SERVICE_NAME" systemctl enable --now "$SERVICE_NAME"
if [ -d "/etc/nginx/conf.d" ]; then if [ -d "/etc/nginx/conf.d" ] && [ -f "$NGINX_SOURCE" ]; then
install -m 0644 "$NGINX_SOURCE" "/etc/nginx/conf.d/$SERVICE_NAME.conf" install -m 0644 "$NGINX_SOURCE" "/etc/nginx/conf.d/$SERVICE_NAME.conf"
if command -v nginx >/dev/null 2>&1; then if command -v nginx >/dev/null 2>&1; then
nginx -t nginx -t

View File

@@ -18,6 +18,22 @@ server {
proxy_read_timeout 3600s; 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 / { location / {
try_files $uri $uri/ /index.html; try_files $uri $uri/ /index.html;
} }

View File

@@ -22,6 +22,8 @@ services:
# - /home/user/data:/mnt/data:ro # - /home/user/data:/mnt/data:ro
environment: environment:
- TZ=Asia/Shanghai - TZ=Asia/Shanghai
# 远程 Agent 需要通过公网或可路由地址连接 Master 时,取消注释并改成真实 URL
# - BACKUPX_SERVER_EXTERNAL_URL=https://backup.example.com
# 通过 BACKUPX_ 前缀环境变量覆盖配置: # 通过 BACKUPX_ 前缀环境变量覆盖配置:
# - BACKUPX_LOG_LEVEL=debug # - BACKUPX_LOG_LEVEL=debug
# - BACKUPX_BACKUP_MAX_CONCURRENT=4 # - BACKUPX_BACKUP_MAX_CONCURRENT=4

View File

@@ -25,6 +25,19 @@ The installer performs these steps automatically:
4. Installs `backupx.service` (systemd), enabled at boot 4. Installs `backupx.service` (systemd), enabled at boot
5. (Optional) installs an Nginx site file — see [Nginx Reverse Proxy](./nginx) 5. (Optional) installs an Nginx site file — see [Nginx Reverse Proxy](./nginx)
For multi-node clusters, edit `/etc/backupx/config.yaml` after installation and set the Master URL that remote Agents can reach:
```yaml
server:
external_url: "https://backup.example.com"
```
Restart BackupX after changing it:
```bash
sudo systemctl restart backupx
```
## From source ## From source
```bash ```bash

View File

@@ -15,13 +15,14 @@ server:
host: "0.0.0.0" # BACKUPX_SERVER_HOST host: "0.0.0.0" # BACKUPX_SERVER_HOST
port: 8340 # BACKUPX_SERVER_PORT port: 8340 # BACKUPX_SERVER_PORT
mode: "release" # release | debug mode: "release" # release | debug
external_url: "" # BACKUPX_SERVER_EXTERNAL_URL — public Master URL for Agent install scripts
database: database:
path: "./data/backupx.db" # BACKUPX_DATABASE_PATH — embedded SQLite path: "./data/backupx.db" # BACKUPX_DATABASE_PATH — embedded SQLite
security: security:
jwt_secret: "" # BACKUPX_SECURITY_JWT_SECRET — auto-generated if empty jwt_secret: "" # BACKUPX_SECURITY_JWT_SECRET — auto-generated if empty
jwt_expires_in: "24h" jwt_expire: "24h" # BACKUPX_SECURITY_JWT_EXPIRE
encryption_key: "" # AES-256-GCM key for storage config encryption encryption_key: "" # AES-256-GCM key for storage config encryption
backup: backup:
@@ -46,7 +47,20 @@ The environment wins when both file and env are set. All dot-paths become unders
| Config key | Env variable | | Config key | Env variable |
|------------|--------------| |------------|--------------|
| `server.port` | `BACKUPX_SERVER_PORT` | | `server.port` | `BACKUPX_SERVER_PORT` |
| `server.external_url` | `BACKUPX_SERVER_EXTERNAL_URL` |
| `security.jwt_expire` | `BACKUPX_SECURITY_JWT_EXPIRE` |
| `log.level` | `BACKUPX_LOG_LEVEL` | | `log.level` | `BACKUPX_LOG_LEVEL` |
| `backup.max_concurrent` | `BACKUPX_BACKUP_MAX_CONCURRENT` | | `backup.max_concurrent` | `BACKUPX_BACKUP_MAX_CONCURRENT` |
| `backup.temp_dir` | `BACKUPX_BACKUP_TEMP_DIR` | | `backup.temp_dir` | `BACKUPX_BACKUP_TEMP_DIR` |
| `backup.bandwidth_limit` | `BACKUPX_BACKUP_BANDWIDTH_LIMIT` | | `backup.bandwidth_limit` | `BACKUPX_BACKUP_BANDWIDTH_LIMIT` |
## Master external URL
Set `server.external_url` when BackupX is behind Docker, Nginx, a load balancer, or any reverse proxy whose internal Host is not reachable by remote Agents:
```yaml
server:
external_url: "https://backup.example.com"
```
This value is used when BackupX renders one-click Agent install scripts and docker-compose snippets. It must be reachable from every Agent host. Leave it empty only when `X-Forwarded-Proto` / `X-Forwarded-Host` are reliable and point to the same URL that Agents can access.

View File

@@ -25,6 +25,8 @@ services:
- /etc/nginx:/mnt/nginx-conf:ro - /etc/nginx:/mnt/nginx-conf:ro
environment: environment:
- TZ=Asia/Shanghai - TZ=Asia/Shanghai
# Required when remote Agents must connect through a public or routed URL:
# - BACKUPX_SERVER_EXTERNAL_URL=https://backup.example.com
- BACKUPX_LOG_LEVEL=info - BACKUPX_LOG_LEVEL=info
- BACKUPX_BACKUP_MAX_CONCURRENT=2 - BACKUPX_BACKUP_MAX_CONCURRENT=2
@@ -42,6 +44,17 @@ docker compose up -d
To back up files from the host, mount them into the container. When creating a file-type task in the web UI, point the source path at the mount location (e.g. `/mnt/www`). Make sure the directory is visible inside the container. To back up files from the host, mount them into the container. When creating a file-type task in the web UI, point the source path at the mount location (e.g. `/mnt/www`). Make sure the directory is visible inside the container.
## Multi-node clusters
When deploying Agents on other machines, set `BACKUPX_SERVER_EXTERNAL_URL` on the Master container to the URL that those Agents can reach:
```yaml
environment:
- BACKUPX_SERVER_EXTERNAL_URL=https://backup.example.com
```
Use an HTTPS URL if Agents cross untrusted networks. The generated one-click install scripts and docker-compose snippets use this value as `BACKUPX_AGENT_MASTER`.
## Environment variables ## Environment variables
All configuration keys can be overridden with the `BACKUPX_` prefix: All configuration keys can be overridden with the `BACKUPX_` prefix:

View File

@@ -28,21 +28,30 @@ BackupX supports Master-Agent mode: backup tasks can be routed to specific nodes
## Walkthrough ## Walkthrough
### 0. Set the Master URL for production clusters
Before generating Agent install commands, make sure the Master URL shown to Agents is stable and reachable from every target host.
If BackupX runs behind Docker, Nginx, a load balancer, or an outer reverse proxy, configure `server.external_url` or `BACKUPX_SERVER_EXTERNAL_URL` on the Master:
```yaml title="config.yaml"
server:
external_url: "https://backup.example.com"
```
This URL is baked into systemd units, foreground commands, and docker-compose snippets. If it is wrong, Agents will install successfully but stay offline because they keep polling an internal or browser-only address.
### 1. Open the install wizard ### 1. Open the install wizard
In the Web Console → **Node Management** → **Add Node**. You'll see a three-step wizard. In the Web Console → **Node Management** → **Add Node**. You'll see a three-step wizard.
- **Step 1 — Node info.** Give the node a name, or switch to batch mode and paste multiple names (one per line, max 50). - **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 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 single `curl ... | sudo sh` line is shown with a live countdown. Click copy, paste into the target machine, and run with root privileges. - **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.
### 2. One-line install on the target host ### 2. One-line install on the target host
Example (systemd mode): 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
curl -fsSL https://master.example.com/install/Xk3p9...vM | sudo sh
```
The script runs automatically and: The script runs automatically and:
@@ -53,6 +62,8 @@ The script runs automatically and:
5. Runs `systemctl enable --now backupx-agent` 5. Runs `systemctl enable --now backupx-agent`
6. Polls `/api/v1/agent/self` until the master confirms `status: online` (up to 30 s) 6. Polls `/api/v1/agent/self` until the master confirms `status: online` (up to 30 s)
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.
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. 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.
### 3. Rotate agent tokens at any time ### 3. Rotate agent tokens at any time

View File

@@ -6,7 +6,7 @@ import type * as Preset from '@docusaurus/preset-classic';
// https://awuqing.github.io/BackupX/ // https://awuqing.github.io/BackupX/
const config: Config = { const config: Config = {
title: 'BackupX', 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', favicon: 'img/favicon.ico',
future: { future: {
@@ -76,6 +76,16 @@ const config: Config = {
label: 'Downloads', label: 'Downloads',
position: 'left', position: 'left',
}, },
{
to: '/community',
label: 'Community',
position: 'left',
},
{
to: '/sponsors',
label: 'Sponsors',
position: 'left',
},
{ {
type: 'localeDropdown', type: 'localeDropdown',
position: 'right', position: 'right',
@@ -115,6 +125,22 @@ const config: Config = {
{label: 'Issues', href: 'https://github.com/Awuqing/BackupX/issues'}, {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`, copyright: `Copyright © ${new Date().getFullYear()} BackupX · Apache License 2.0`,
}, },

View File

@@ -1,22 +1,22 @@
{ {
"home.badge": { "home.badge": {
"message": "开源 · v1.6.0", "message": "开源备份控制平面 · v2.2.1",
"description": "Version badge on the hero" "description": "Version badge on the hero"
}, },
"home.title.part1": { "home.title.part1": {
"message": "为每一台服务器提供", "message": "面向自托管服务器",
"description": "Hero title, first line" "description": "Hero title, first line"
}, },
"home.title.part2": { "home.title.part2": {
"message": "自托管备份管理。", "message": "备份编排平台。",
"description": "Hero title accent second line" "description": "Hero title accent second line"
}, },
"home.tagline": { "home.tagline": {
"message": "一个二进制,一条命令。文件 / 数据库 / SAP HANA 备份直送 70+ 存储后端。", "message": "一个清爽控制台中管理文件、数据库、SAP HANA 和远程节点备份。控制平面自己掌握,存储后端灵活选择。",
"description": "Tagline on the home page" "description": "Tagline on the home page"
}, },
"home.pageTitle": { "home.pageTitle": {
"message": "自托管备份管理", "message": "面向自托管服务器的备份编排",
"description": "Page <title> element on the home page" "description": "Page <title> element on the home page"
}, },
"home.getStarted": { "home.getStarted": {
@@ -28,13 +28,26 @@
"description": "Hero metric label: storage backends" "description": "Hero metric label: storage backends"
}, },
"home.metric.backupTypes": { "home.metric.backupTypes": {
"message": "备份类型", "message": "远程执行",
"description": "Hero metric label: backup types" "description": "Hero metric label: backup types"
}, },
"home.metric.license": { "home.metric.license": {
"message": "开源协议", "message": "开源协议",
"description": "Hero metric label: license" "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": { "section.features.tag": {
"message": "核心能力", "message": "核心能力",
@@ -78,5 +91,70 @@
"showcase.storage.desc": {"message": "阿里云 OSS、腾讯云 COS、S3、Google Drive、WebDAV — 加上每一种 rclone 后端。测试连接、收藏、查看实时容量。"}, "showcase.storage.desc": {"message": "阿里云 OSS、腾讯云 COS、S3、Google Drive、WebDAV — 加上每一种 rclone 后端。测试连接、收藏、查看实时容量。"},
"showcase.nodes.title": {"message": "几分钟搭起 Master-Agent"}, "showcase.nodes.title": {"message": "几分钟搭起 Master-Agent"},
"showcase.nodes.desc": {"message": "创建节点、复制令牌、在任意远程主机启动 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

@@ -25,6 +25,19 @@ sudo ./install.sh
4. 安装并启用 `backupx.service` systemd 单元 4. 安装并启用 `backupx.service` systemd 单元
5. (可选)生成 Nginx 站点配置 — 参见 [Nginx 反向代理](./nginx) 5. (可选)生成 Nginx 站点配置 — 参见 [Nginx 反向代理](./nginx)
如果要部署多节点集群,安装后请编辑 `/etc/backupx/config.yaml`,设置远程 Agent 可访问到的 Master URL
```yaml
server:
external_url: "https://backup.example.com"
```
修改后重启 BackupX
```bash
sudo systemctl restart backupx
```
## 从源码构建 ## 从源码构建
```bash ```bash

View File

@@ -15,13 +15,14 @@ server:
host: "0.0.0.0" # BACKUPX_SERVER_HOST host: "0.0.0.0" # BACKUPX_SERVER_HOST
port: 8340 # BACKUPX_SERVER_PORT port: 8340 # BACKUPX_SERVER_PORT
mode: "release" # release | debug mode: "release" # release | debug
external_url: "" # BACKUPX_SERVER_EXTERNAL_URL — Agent 安装脚本使用的 Master 对外 URL
database: database:
path: "./data/backupx.db" # BACKUPX_DATABASE_PATH — 内嵌 SQLite path: "./data/backupx.db" # BACKUPX_DATABASE_PATH — 内嵌 SQLite
security: security:
jwt_secret: "" # BACKUPX_SECURITY_JWT_SECRET — 留空自动生成 jwt_secret: "" # BACKUPX_SECURITY_JWT_SECRET — 留空自动生成
jwt_expires_in: "24h" jwt_expire: "24h" # BACKUPX_SECURITY_JWT_EXPIRE
encryption_key: "" # 用于加密存储配置的 AES-256-GCM 密钥 encryption_key: "" # 用于加密存储配置的 AES-256-GCM 密钥
backup: backup:
@@ -46,7 +47,20 @@ log:
| 配置项 | 环境变量 | | 配置项 | 环境变量 |
|--------|----------| |--------|----------|
| `server.port` | `BACKUPX_SERVER_PORT` | | `server.port` | `BACKUPX_SERVER_PORT` |
| `server.external_url` | `BACKUPX_SERVER_EXTERNAL_URL` |
| `security.jwt_expire` | `BACKUPX_SECURITY_JWT_EXPIRE` |
| `log.level` | `BACKUPX_LOG_LEVEL` | | `log.level` | `BACKUPX_LOG_LEVEL` |
| `backup.max_concurrent` | `BACKUPX_BACKUP_MAX_CONCURRENT` | | `backup.max_concurrent` | `BACKUPX_BACKUP_MAX_CONCURRENT` |
| `backup.temp_dir` | `BACKUPX_BACKUP_TEMP_DIR` | | `backup.temp_dir` | `BACKUPX_BACKUP_TEMP_DIR` |
| `backup.bandwidth_limit` | `BACKUPX_BACKUP_BANDWIDTH_LIMIT` | | `backup.bandwidth_limit` | `BACKUPX_BACKUP_BANDWIDTH_LIMIT` |
## Master 对外 URL
当 BackupX 部署在 Docker、Nginx、负载均衡或多层反向代理后面且后端收到的内部 Host 不是远程 Agent 可访问地址时,请配置 `server.external_url`
```yaml
server:
external_url: "https://backup.example.com"
```
BackupX 会用这个地址渲染一键 Agent 安装脚本和 docker-compose 片段。该地址必须能被所有 Agent 主机访问。只有在 `X-Forwarded-Proto` / `X-Forwarded-Host` 可靠且正好指向 Agent 可访问地址时,才建议留空。

View File

@@ -25,6 +25,8 @@ services:
- /etc/nginx:/mnt/nginx-conf:ro - /etc/nginx:/mnt/nginx-conf:ro
environment: environment:
- TZ=Asia/Shanghai - TZ=Asia/Shanghai
# 远程 Agent 需要通过公网或可路由地址连接 Master 时必须配置:
# - BACKUPX_SERVER_EXTERNAL_URL=https://backup.example.com
- BACKUPX_LOG_LEVEL=info - BACKUPX_LOG_LEVEL=info
- BACKUPX_BACKUP_MAX_CONCURRENT=2 - BACKUPX_BACKUP_MAX_CONCURRENT=2
@@ -42,6 +44,17 @@ docker compose up -d
想备份宿主机上的文件,需要将对应路径挂载进容器。在 Web UI 创建文件类型任务时,把源路径指向挂载后的容器内路径(如 `/mnt/www`)。 想备份宿主机上的文件,需要将对应路径挂载进容器。在 Web UI 创建文件类型任务时,把源路径指向挂载后的容器内路径(如 `/mnt/www`)。
## 多节点集群
如果要在其他机器部署 Agent请在 Master 容器上设置 `BACKUPX_SERVER_EXTERNAL_URL`,值为所有 Agent 都能访问到的 URL
```yaml
environment:
- BACKUPX_SERVER_EXTERNAL_URL=https://backup.example.com
```
Agent 跨不可信网络访问时建议使用 HTTPS。控制台生成的一键安装脚本和 docker-compose 片段会把这个值写成 `BACKUPX_AGENT_MASTER`。
## 环境变量 ## 环境变量
所有配置项都可以通过 `BACKUPX_` 前缀环境变量覆盖: 所有配置项都可以通过 `BACKUPX_` 前缀环境变量覆盖:

View File

@@ -28,21 +28,30 @@ BackupX 支持 Master-Agent 模式:备份任务可以指定在哪个节点执
## 一键部署步骤 ## 一键部署步骤
### 0. 为生产集群设置 Master 对外 URL
生成 Agent 安装命令前,请先确认 Master URL 对所有目标主机稳定可达。
如果 BackupX 部署在 Docker、Nginx、负载均衡或外层反向代理后面请在 Master 配置 `server.external_url` 或环境变量 `BACKUPX_SERVER_EXTERNAL_URL`
```yaml title="config.yaml"
server:
external_url: "https://backup.example.com"
```
该 URL 会写入 systemd 单元、前台运行命令和 docker-compose 片段。如果地址不正确Agent 可能安装成功但始终离线,因为它会持续轮询一个内网地址或仅浏览器可访问的地址。
### 1. 打开安装向导 ### 1. 打开安装向导
Web 控制台 → **节点管理** → **添加节点**,打开三步向导: Web 控制台 → **节点管理** → **添加节点**,打开三步向导:
- **第一步 · 节点信息**:填写节点名称;或切换"批量创建"粘贴多行名称(每行一个,最多 50 个) - **第一步 · 节点信息**:填写节点名称;或切换"批量创建"粘贴多行名称(每行一个,最多 50 个)
- **第二步 · 部署参数**:选择安装模式(`systemd` 推荐、`Docker`、`前台运行` 调试用、架构默认自动检测、Agent 版本(默认跟随 Master 版本、有效期5 分钟 / 15 分钟 / 1 小时 / 24 小时)、下载源(`GitHub` 直连或 `ghproxy` 镜像,国内服务器建议后者) - **第二步 · 部署参数**:选择安装模式(`systemd` 推荐、`Docker`、`前台运行` 调试用、架构默认自动检测、Agent 版本(默认跟随 Master 版本、有效期5 分钟 / 15 分钟 / 1 小时 / 24 小时)、下载源(`GitHub` 直连或 `ghproxy` 镜像,国内服务器建议后者)
- **第三步 · 安装命令**:一`curl ... | sudo sh` 命令 + 实时倒计时。点击复制,粘贴到目标机以 root 权限执行 - **第三步 · 安装命令**:一条一键安装命令 + 实时倒计时。点击复制,粘贴到目标机以 root 权限执行。默认命令会嵌入已渲染的安装脚本,目标机无需再通过反向代理访问 `/api/install/:token`;公开安装 URL 仍作为备用路径保留。
### 2. 目标机一条命令完成 ### 2. 目标机一条命令完成
示例systemd 模式): 请直接使用 Web 控制台生成的命令。该命令会把安装脚本写入临时文件,校验 `BACKUPX_AGENT_INSTALL_V1` 魔数,再以 root 权限执行。
```bash
curl -fsSL https://master.example.com/install/Xk3p9...vM | sudo sh
```
脚本会自动: 脚本会自动:
@@ -53,6 +62,8 @@ curl -fsSL https://master.example.com/install/Xk3p9...vM | sudo sh
5. 执行 `systemctl enable --now backupx-agent` 5. 执行 `systemctl enable --now backupx-agent`
6. 轮询 `/api/v1/agent/self`,直到 Master 确认 `status: online`(最多 30 秒) 6. 轮询 `/api/v1/agent/self`,直到 Master 确认 `status: online`(最多 30 秒)
如果使用 URL 备用命令时 `curl` 输出 HTML或 shell 报 `Syntax error: newline unexpected`,说明安装 URL 被 Web 控制台接管而不是转发到后端。需要确保 `/api/install/` 或 `/install/` 至少一个路径能转发到 BackupX 后端,或改用控制台生成的嵌入式命令。
脚本是幂等的:升级或重装只需重新生成一条安装命令再跑一次。一次性安装链接在 TTL 到期或被首次消费后立即作废。 脚本是幂等的:升级或重装只需重新生成一条安装命令再跑一次。一次性安装链接在 TTL 到期或被首次消费后立即作废。
### 3. 随时轮换 Agent Token ### 3. 随时轮换 Agent Token

View File

@@ -2,6 +2,8 @@
"link.title.Docs": {"message": "文档"}, "link.title.Docs": {"message": "文档"},
"link.title.Features": {"message": "功能"}, "link.title.Features": {"message": "功能"},
"link.title.More": {"message": "更多"}, "link.title.More": {"message": "更多"},
"link.title.Community": {"message": "社区"},
"link.title.Sponsors": {"message": "赞助商"},
"link.item.label.Introduction": {"message": "简介"}, "link.item.label.Introduction": {"message": "简介"},
"link.item.label.Quick Start": {"message": "快速开始"}, "link.item.label.Quick Start": {"message": "快速开始"},
"link.item.label.Installation": {"message": "安装"}, "link.item.label.Installation": {"message": "安装"},
@@ -11,5 +13,11 @@
"link.item.label.GitHub": {"message": "GitHub"}, "link.item.label.GitHub": {"message": "GitHub"},
"link.item.label.Releases": {"message": "Releases"}, "link.item.label.Releases": {"message": "Releases"},
"link.item.label.Docker Hub": {"message": "Docker Hub"}, "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": "下载", "message": "下载",
"description": "Navbar item: Downloads" "description": "Navbar item: Downloads"
}, },
"item.label.Community": {
"message": "社区",
"description": "Navbar item: Community"
},
"item.label.Sponsors": {
"message": "赞助商",
"description": "Navbar item: Sponsors"
},
"item.label.GitHub": { "item.label.GitHub": {
"message": "GitHub", "message": "GitHub",
"description": "Navbar item: 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 && ( {link && (
<span className={styles.featureLink}> <span className={styles.featureLink}>
<Translate id="feat.learnMore">Learn more</Translate> <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> </span>
)} )}
</> </>

View File

@@ -1,5 +1,6 @@
.section { .section {
padding: 6rem 0 4rem; padding: 5.5rem 0 4.25rem;
background: var(--ifm-background-color);
} }
.sectionHead { .sectionHead {
@@ -9,14 +10,17 @@
} }
.sectionTag { .sectionTag {
display: inline-block; display: inline-flex;
align-items: center;
min-height: 28px;
font-size: 12px; font-size: 12px;
font-weight: 600; font-weight: 750;
letter-spacing: 0.15em; letter-spacing: 0;
color: var(--ifm-color-primary); color: var(--ifm-color-primary);
padding: 4px 12px; padding: 4px 12px;
background: rgba(22, 93, 255, 0.08); 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; margin-bottom: 1rem;
} }
@@ -26,10 +30,10 @@
} }
.sectionTitle { .sectionTitle {
font-size: clamp(1.8rem, 3vw, 2.5rem); font-size: 2.35rem;
line-height: 1.2; line-height: 1.2;
letter-spacing: -0.02em; letter-spacing: 0;
font-weight: 700; font-weight: 750;
margin: 0 0 1rem; margin: 0 0 1rem;
color: var(--ifm-heading-color); color: var(--ifm-heading-color);
} }
@@ -51,6 +55,9 @@
.section { .section {
padding: 3.5rem 0 2rem; padding: 3.5rem 0 2rem;
} }
.sectionTitle {
font-size: 2rem;
}
.grid { .grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
@@ -70,7 +77,7 @@
padding: 1.75rem; padding: 1.75rem;
background: var(--ifm-background-color); background: var(--ifm-background-color);
border: 1px solid var(--ifm-color-emphasis-200); 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; transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease;
text-decoration: none !important; text-decoration: none !important;
color: inherit; color: inherit;
@@ -78,7 +85,7 @@
} }
.featureCardLink:hover { .featureCardLink:hover {
transform: translateY(-3px); transform: translateY(-2px);
border-color: var(--ifm-color-primary); border-color: var(--ifm-color-primary);
box-shadow: 0 12px 30px -8px rgba(22, 93, 255, 0.18); box-shadow: 0 12px 30px -8px rgba(22, 93, 255, 0.18);
color: inherit; color: inherit;
@@ -99,26 +106,26 @@
.iconWrap { .iconWrap {
width: 48px; width: 48px;
height: 48px; height: 48px;
border-radius: 10px; border-radius: 8px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: 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); color: var(--ifm-color-primary);
margin-bottom: 1.25rem; margin-bottom: 1.25rem;
} }
[data-theme='dark'] .iconWrap { [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); color: var(--ifm-color-primary-lighter);
} }
.featureTitle { .featureTitle {
font-size: 1.15rem; font-size: 1.15rem;
font-weight: 600; font-weight: 700;
margin: 0 0 0.6rem; margin: 0 0 0.6rem;
color: var(--ifm-heading-color); color: var(--ifm-heading-color);
letter-spacing: -0.01em; letter-spacing: 0;
} }
.featureDesc { .featureDesc {
@@ -146,3 +153,17 @@
.featureCardLink:hover .featureArrow { .featureCardLink:hover .featureArrow {
transform: translateX(4px); 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> <p className={styles.captionDesc}>{current.description}</p>
<Link to="/docs/getting-started/quick-start" className={styles.captionLink}> <Link to="/docs/getting-started/quick-start" className={styles.captionLink}>
<Translate id="showcase.cta">Explore the docs</Translate> <Translate id="showcase.cta">Explore the docs</Translate>
<span aria-hidden="true"> </span> <span aria-hidden="true"> -&gt;</span>
</Link> </Link>
</div> </div>
</div> </div>

View File

@@ -1,10 +1,14 @@
.section { .section {
padding: 4rem 0 6rem; padding: 4.5rem 0 5.5rem;
background: linear-gradient(180deg, transparent 0%, rgba(22, 93, 255, 0.03) 100%); 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 { [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 { .sectionHead {
@@ -14,26 +18,30 @@
} }
.sectionTag { .sectionTag {
display: inline-block; display: inline-flex;
align-items: center;
min-height: 28px;
font-size: 12px; font-size: 12px;
font-weight: 600; font-weight: 750;
letter-spacing: 0.15em; letter-spacing: 0;
color: #8f4bff; color: #0e7490;
padding: 4px 12px; padding: 4px 12px;
background: rgba(143, 75, 255, 0.08); background: rgba(20, 201, 201, 0.1);
border-radius: 4px; border: 1px solid rgba(20, 201, 201, 0.2);
border-radius: 8px;
margin-bottom: 1rem; margin-bottom: 1rem;
} }
[data-theme='dark'] .sectionTag { [data-theme='dark'] .sectionTag {
background: rgba(143, 75, 255, 0.18); background: rgba(20, 201, 201, 0.16);
color: #67e8f9;
} }
.sectionTitle { .sectionTitle {
font-size: clamp(1.8rem, 3vw, 2.5rem); font-size: 2.35rem;
line-height: 1.2; line-height: 1.2;
letter-spacing: -0.02em; letter-spacing: 0;
font-weight: 700; font-weight: 750;
margin: 0 0 1rem; margin: 0 0 1rem;
color: var(--ifm-heading-color); color: var(--ifm-heading-color);
} }
@@ -49,34 +57,39 @@
.tabs { .tabs {
display: flex; display: flex;
justify-content: center; justify-content: center;
gap: 8px; gap: 6px;
margin-bottom: 2rem; margin-bottom: 2rem;
flex-wrap: wrap; flex-wrap: wrap;
padding: 6px;
background: var(--ifm-color-emphasis-100);
border: 1px solid var(--ifm-color-emphasis-200);
border-radius: 8px;
} }
.tabBtn { .tabBtn {
min-height: 40px;
padding: 8px 18px; padding: 8px 18px;
background: transparent; background: transparent;
border: 1px solid var(--ifm-color-emphasis-300); border: 1px solid transparent;
border-radius: 999px; border-radius: 8px;
color: var(--ifm-color-content-secondary); color: var(--ifm-color-content-secondary);
font-size: 14px; font-size: 14px;
font-weight: 500; font-weight: 650;
cursor: pointer; 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 { .tabBtn:hover {
color: var(--ifm-color-primary); color: var(--ifm-color-primary);
border-color: var(--ifm-color-primary); background: var(--ifm-background-color);
} }
.tabBtnActive, .tabBtnActive,
.tabBtnActive:hover { .tabBtnActive:hover {
background: linear-gradient(90deg, #165dff 0%, #4080ff 100%); background: var(--ifm-background-color);
color: #fff !important; color: var(--ifm-color-primary) !important;
border-color: transparent; border-color: rgba(22, 93, 255, 0.18);
box-shadow: 0 4px 14px rgba(22, 93, 255, 0.3); box-shadow: 0 6px 16px rgba(22, 93, 255, 0.12);
} }
/* Stage */ /* Stage */
@@ -96,10 +109,10 @@
.browser { .browser {
background: var(--ifm-background-color); background: var(--ifm-background-color);
border-radius: 12px; border-radius: 8px;
overflow: hidden; overflow: hidden;
box-shadow: 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); 0 0 0 1px var(--ifm-color-emphasis-200);
} }
@@ -137,7 +150,7 @@
margin: 0 auto; margin: 0 auto;
padding: 3px 14px; padding: 3px 14px;
background: var(--ifm-background-color); background: var(--ifm-background-color);
border-radius: 999px; border-radius: 8px;
font-size: 12px; font-size: 12px;
color: var(--ifm-color-content-secondary); color: var(--ifm-color-content-secondary);
font-family: 'SFMono-Regular', Menlo, monospace; font-family: 'SFMono-Regular', Menlo, monospace;
@@ -169,8 +182,8 @@
.captionTitle { .captionTitle {
font-size: 1.7rem; font-size: 1.7rem;
line-height: 1.2; line-height: 1.2;
letter-spacing: -0.02em; letter-spacing: 0;
font-weight: 700; font-weight: 750;
margin: 0 0 1rem; margin: 0 0 1rem;
color: var(--ifm-heading-color); color: var(--ifm-heading-color);
} }
@@ -186,11 +199,49 @@
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 4px; 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); color: var(--ifm-color-primary);
text-decoration: none !important; text-decoration: none !important;
transition: border-color 0.2s ease, background 0.2s ease;
} }
.captionLink:hover { .captionLink:hover {
color: var(--ifm-color-primary-dark); 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 */ /* Surfaces */
--ifm-background-color: #ffffff; --ifm-background-color: #ffffff;
--ifm-background-surface-color: #ffffff; --ifm-background-surface-color: #ffffff;
--ifm-color-emphasis-100: #f7f9fc; --ifm-color-emphasis-100: #f5f7fa;
--ifm-color-emphasis-200: #eef1f6; --ifm-color-emphasis-200: #e5e6eb;
--ifm-color-emphasis-300: #dde3ec; --ifm-color-emphasis-300: #c9cdd4;
--ifm-color-emphasis-400: #a9aeb8;
/* Typography */ /* Typography */
--ifm-font-family-base: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif; --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-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-code-font-size: 92%;
--ifm-h1-font-size: 2.25rem; --ifm-h1-font-size: 2.25rem;
--ifm-h2-font-size: 1.75rem; --ifm-h2-font-size: 1.75rem;
@@ -33,10 +34,11 @@
--ifm-color-content: #1d2129; --ifm-color-content: #1d2129;
--ifm-color-content-secondary: #4e5969; --ifm-color-content-secondary: #4e5969;
--ifm-heading-color: #1d2129; --ifm-heading-color: #1d2129;
--ifm-global-radius: 8px;
/* Navbar */ /* Navbar */
--ifm-navbar-height: 64px; --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-color: #4e5969;
--ifm-navbar-link-hover-color: var(--ifm-color-primary); --ifm-navbar-link-hover-color: var(--ifm-color-primary);
@@ -64,15 +66,16 @@
--ifm-background-color: #0f1115; --ifm-background-color: #0f1115;
--ifm-background-surface-color: #16181d; --ifm-background-surface-color: #16181d;
--ifm-color-emphasis-100: #1a1d23; --ifm-color-emphasis-100: #1d2129;
--ifm-color-emphasis-200: #23272f; --ifm-color-emphasis-200: #272e3b;
--ifm-color-emphasis-300: #2e343d; --ifm-color-emphasis-300: #384252;
--ifm-color-emphasis-400: #4e5969;
--ifm-color-content: #e6e9ef; --ifm-color-content: #e6e9ef;
--ifm-color-content-secondary: #9aa3b2; --ifm-color-content-secondary: #9aa3b2;
--ifm-heading-color: #f0f2f5; --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-navbar-link-color: #c9d1db;
--ifm-menu-color: #c9d1db; --ifm-menu-color: #c9d1db;
@@ -97,7 +100,7 @@
.navbar__title { .navbar__title {
font-weight: 700; font-weight: 700;
letter-spacing: -0.01em; letter-spacing: 0;
} }
.navbar__link { .navbar__link {
@@ -105,10 +108,26 @@
font-size: 14px; 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 */ /* Sidebar tweaks */
.menu__link { .menu__link {
font-size: 14px; font-size: 14px;
border-radius: 6px; border-radius: 8px;
padding: 6px 10px; padding: 6px 10px;
line-height: 1.4; line-height: 1.4;
} }
@@ -226,9 +245,20 @@ code {
} }
::-webkit-scrollbar-thumb:hover { ::-webkit-scrollbar-thumb:hover {
background: var(--ifm-color-emphasis-400, #adb5bd); background: var(--ifm-color-emphasis-400);
} }
[data-theme='dark'] ::-webkit-scrollbar-thumb { [data-theme='dark'] ::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.15); 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 { .hero {
position: relative; position: relative;
padding: 7rem 0 6rem;
overflow: hidden; 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; position: absolute;
inset: 0; inset: 0;
background: content: "";
radial-gradient(circle at 15% 20%, rgba(104, 127, 255, 0.18) 0%, transparent 45%), pointer-events: none;
radial-gradient(circle at 85% 70%, rgba(22, 93, 255, 0.15) 0%, transparent 50%), background-image:
linear-gradient(180deg, #f7f9ff 0%, #ffffff 100%); linear-gradient(rgba(22, 93, 255, 0.06) 1px, transparent 1px),
z-index: 0; 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: background:
radial-gradient(circle at 15% 20%, rgba(96, 126, 255, 0.22) 0%, transparent 45%), linear-gradient(180deg, rgba(64, 128, 255, 0.16) 0%, rgba(15, 17, 21, 0) 72%),
radial-gradient(circle at 85% 70%, rgba(118, 70, 255, 0.18) 0%, transparent 50%), linear-gradient(90deg, rgba(20, 201, 201, 0.1) 0%, rgba(250, 173, 20, 0.08) 100%),
linear-gradient(180deg, #0f1115 0%, #0b0d10 100%); var(--ifm-background-color);
} }
.heroInner { .heroInner {
position: relative; position: relative;
z-index: 1; z-index: 1;
display: grid; display: grid;
grid-template-columns: 1.1fr 1fr; grid-template-columns: minmax(0, 1fr) minmax(420px, 0.9fr);
gap: 4rem; gap: 4rem;
align-items: center; align-items: center;
} }
@media (max-width: 996px) {
.hero {
padding: 4rem 0 3rem;
}
.heroInner {
grid-template-columns: 1fr;
gap: 2.5rem;
text-align: left;
}
}
.heroContent { .heroContent {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -54,137 +48,144 @@
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
padding: 4px 14px; min-height: 32px;
background: rgba(22, 93, 255, 0.08); padding: 5px 12px;
border: 1px solid rgba(22, 93, 255, 0.15);
border-radius: 999px;
font-size: 13px;
color: var(--ifm-color-primary); 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 { [data-theme='dark'] .badge {
background: rgba(96, 126, 255, 0.15); background: rgba(64, 128, 255, 0.16);
border-color: rgba(96, 126, 255, 0.3); border-color: rgba(64, 128, 255, 0.3);
color: var(--ifm-color-primary-lighter); color: var(--ifm-color-primary-lighter);
} }
.badgeDot { .badgeDot {
width: 6px; width: 7px;
height: 6px; height: 7px;
background: var(--ifm-color-primary); background: #00b42a;
border-radius: 50%; border-radius: 50%;
box-shadow: 0 0 0 4px rgba(22, 93, 255, 0.18); box-shadow: 0 0 0 4px rgba(0, 180, 42, 0.12);
animation: pulse 2s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
} }
.heroTitle { .heroTitle {
font-size: clamp(2.25rem, 4vw, 3.4rem);
line-height: 1.15;
letter-spacing: -0.025em;
font-weight: 700;
margin: 0; margin: 0;
color: var(--ifm-heading-color); color: var(--ifm-heading-color);
font-size: 3.45rem;
font-weight: 750;
letter-spacing: 0;
line-height: 1.08;
} }
.heroTitleAccent { .heroTitleAccent {
display: block; display: block;
background: linear-gradient(90deg, #4080ff 0%, #8f4bff 100%); margin-top: 8px;
-webkit-background-clip: text; color: var(--ifm-color-primary);
-webkit-text-fill-color: transparent;
background-clip: text;
margin-top: 6px;
} }
.heroSubtitle { .heroSubtitle {
font-size: 1.15rem; max-width: 640px;
line-height: 1.65;
color: var(--ifm-color-content-secondary);
max-width: 540px;
margin: 0; margin: 0;
color: var(--ifm-color-content-secondary);
font-size: 1.15rem;
line-height: 1.72;
} }
.actions { .actions {
display: flex; display: flex;
flex-wrap: wrap;
gap: 12px; gap: 12px;
margin-top: 8px; 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 { .primaryBtn {
background: linear-gradient(90deg, #165dff 0%, #4080ff 100%);
border: none;
color: #fff;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 6px; gap: 8px;
font-weight: 600; color: #fff;
box-shadow: 0 6px 20px rgba(22, 93, 255, 0.3); background: #165dff;
transition: transform 0.2s ease, box-shadow 0.2s ease; border: 1px solid #165dff;
box-shadow: 0 10px 24px rgba(22, 93, 255, 0.24);
font-weight: 650;
} }
.primaryBtn:hover { .primaryBtn:hover,
transform: translateY(-1px); .primaryBtn:focus-visible {
box-shadow: 0 10px 25px rgba(22, 93, 255, 0.4);
color: #fff; color: #fff;
background: #0e4fe6;
border-color: #0e4fe6;
box-shadow: 0 14px 30px rgba(22, 93, 255, 0.3);
transform: translateY(-1px);
} }
.btnArrow { .btnArrow {
transition: transform 0.2s ease; transition: transform 0.2s ease;
} }
.primaryBtn:hover .btnArrow { .primaryBtn:hover .btnArrow,
transform: translateX(4px); .primaryBtn:focus-visible .btnArrow {
transform: translateX(3px);
} }
.secondaryBtn { .secondaryBtn {
background: var(--ifm-background-color);
border: 1px solid var(--ifm-color-emphasis-300);
color: var(--ifm-font-color-base);
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
font-weight: 500; color: var(--ifm-font-color-base);
transition: all 0.2s ease; background: var(--ifm-background-color);
border: 1px solid var(--ifm-color-emphasis-300);
font-weight: 600;
} }
.secondaryBtn:hover { .secondaryBtn:hover,
border-color: var(--ifm-color-primary); .secondaryBtn:focus-visible {
color: var(--ifm-color-primary); color: var(--ifm-color-primary);
border-color: var(--ifm-color-primary);
background: var(--ifm-background-color); background: var(--ifm-background-color);
transform: translateY(-1px);
} }
.metrics { .metrics {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 1.75rem; gap: 1.5rem;
padding-top: 1.5rem;
margin-top: 0.5rem; margin-top: 0.5rem;
padding-top: 1.25rem;
} }
.metric { .metric {
display: flex; display: flex;
min-width: 0;
flex-direction: column; flex-direction: column;
gap: 2px; gap: 4px;
} }
.metricValue { .metricValue {
font-size: 1.6rem;
font-weight: 700;
color: var(--ifm-heading-color); color: var(--ifm-heading-color);
font-size: 1.35rem;
font-weight: 750;
letter-spacing: 0;
line-height: 1.1; line-height: 1.1;
letter-spacing: -0.02em; white-space: nowrap;
} }
.metricLabel { .metricLabel {
font-size: 12px;
color: var(--ifm-color-content-secondary); color: var(--ifm-color-content-secondary);
font-size: 12px;
font-weight: 600;
letter-spacing: 0;
line-height: 1.35;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.05em;
} }
.metricDivider { .metricDivider {
@@ -193,81 +194,277 @@
background: var(--ifm-color-emphasis-300); background: var(--ifm-color-emphasis-300);
} }
/* ── Code window (macOS-style) ─────────────────────── */ /* Product visual */
.heroCode { .heroVisual {
position: relative; display: grid;
gap: 1rem;
} }
.codeWindow { .consolePanel {
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);
overflow: hidden; 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 { [data-theme='dark'] .consolePanel {
box-shadow: 0 20px 50px -10px rgba(22, 93, 255, 0.2), 0 0 0 1px rgba(22, 93, 255, 0.06); 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; display: flex;
align-items: center; align-items: flex-start;
gap: 6px; justify-content: space-between;
padding: 10px 14px; gap: 1rem;
background: #161f2e; padding: 1.25rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.04); border-bottom: 1px solid var(--ifm-color-emphasis-200);
} }
.codeDot { [data-theme='dark'] .consoleHeader {
width: 11px; border-bottom-color: rgba(255, 255, 255, 0.08);
height: 11px;
border-radius: 50%;
} }
.codeDotRed { background: #ff5f56; } .consoleHeader strong {
.codeDotYellow { background: #ffbd2e; } display: block;
.codeDotGreen { background: #27c93f; } margin-top: 4px;
color: var(--ifm-heading-color);
font-size: 1.2rem;
}
.codeTitle { .consoleEyebrow {
margin-left: auto; color: var(--ifm-color-content-secondary);
font-size: 11px; font-size: 12px;
color: #7b8696; font-weight: 650;
letter-spacing: 0.05em; letter-spacing: 0;
text-transform: uppercase; text-transform: uppercase;
} }
.codeBody { .consoleStatus {
margin: 0; display: inline-flex;
padding: 18px 20px; align-items: center;
font-family: 'SFMono-Regular', Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace; min-height: 28px;
font-size: 13px; padding: 4px 10px;
line-height: 1.65; color: #00a870;
color: #e1e7ef; background: rgba(0, 180, 42, 0.1);
background: transparent; 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; overflow-x: auto;
} color: #e5e7eb;
.codeBody code {
background: transparent; background: transparent;
padding: 0;
border: 0; border: 0;
color: inherit; padding: 0;
font-size: 13px;
white-space: nowrap;
} }
.codePrompt { @media (max-width: 996px) {
color: #4080ff; .hero {
margin-right: 6px; padding: 4.5rem 0 3.5rem;
user-select: none; }
.heroInner {
grid-template-columns: 1fr;
gap: 2.25rem;
}
.heroTitle {
font-size: 2.45rem;
}
} }
.codeComment { @media (max-width: 640px) {
color: #6e7889; .hero {
font-style: italic; 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 { @media (prefers-reduced-motion: reduce) {
color: #82d1ff; .primaryBtn,
.secondaryBtn,
.btnArrow {
transition: none;
}
} }

View File

@@ -7,34 +7,34 @@ import Layout from '@theme/Layout';
import Heading from '@theme/Heading'; import Heading from '@theme/Heading';
import HomepageFeatures from '@site/src/components/HomepageFeatures'; import HomepageFeatures from '@site/src/components/HomepageFeatures';
import HomepageShowcase from '@site/src/components/HomepageShowcase'; import HomepageShowcase from '@site/src/components/HomepageShowcase';
import HomepageCommunity from '@site/src/components/HomepageCommunity';
import styles from './index.module.css'; import styles from './index.module.css';
function HomepageHeader() { function HomepageHeader() {
return ( return (
<header className={styles.hero}> <header className={styles.hero}>
<div className={styles.heroBg} aria-hidden="true" />
<div className={clsx('container', styles.heroInner)}> <div className={clsx('container', styles.heroInner)}>
<div className={styles.heroContent}> <div className={styles.heroContent}>
<div className={styles.badge}> <div className={styles.badge}>
<span className={styles.badgeDot} /> <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> </div>
<Heading as="h1" className={styles.heroTitle}> <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}> <span className={styles.heroTitleAccent}>
<Translate id="home.title.part2">for every server.</Translate> <Translate id="home.title.part2">for self-hosted servers.</Translate>
</span> </span>
</Heading> </Heading>
<p className={styles.heroSubtitle}> <p className={styles.heroSubtitle}>
<Translate id="home.tagline"> <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> </Translate>
</p> </p>
<div className={styles.actions}> <div className={styles.actions}>
<Link className={clsx('button button--primary button--lg', styles.primaryBtn)} to="/docs/getting-started/quick-start"> <Link className={clsx('button button--primary button--lg', styles.primaryBtn)} to="/docs/getting-started/quick-start">
<Translate id="home.getStarted">Get Started</Translate> <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>
<Link className={clsx('button button--lg', styles.secondaryBtn)} to="https://github.com/Awuqing/BackupX"> <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}}> <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>
<div className={styles.metricDivider} /> <div className={styles.metricDivider} />
<div className={styles.metric}> <div className={styles.metric}>
<div className={styles.metricValue}>5</div> <div className={styles.metricValue}>Agent</div>
<div className={styles.metricLabel}> <div className={styles.metricLabel}>
<Translate id="home.metric.backupTypes">Backup types</Translate> <Translate id="home.metric.backupTypes">Remote execution</Translate>
</div> </div>
</div> </div>
<div className={styles.metricDivider} /> <div className={styles.metricDivider} />
@@ -66,29 +66,85 @@ function HomepageHeader() {
</div> </div>
</div> </div>
</div> </div>
<div className={styles.heroCode}> <div className={styles.heroVisual}>
<div className={styles.codeWindow}> <div className={styles.consolePanel}>
<div className={styles.codeHeader}> <div className={styles.consoleHeader}>
<span className={clsx(styles.codeDot, styles.codeDotRed)} /> <div>
<span className={clsx(styles.codeDot, styles.codeDotYellow)} /> <span className={styles.consoleEyebrow}>
<span className={clsx(styles.codeDot, styles.codeDotGreen)} /> <Translate id="home.visual.eyebrow">BackupX Console</Translate>
<span className={styles.codeTitle}>bash</span> </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> </div>
<pre className={styles.codeBody}> <div className={styles.consoleGrid}>
<code> <div>
<span className={styles.codeComment}># Docker one-liner</span>{'\n'} <span className={styles.consoleLabel}>
<span className={styles.codePrompt}>$</span> docker run -d --name backupx \{'\n'} <Translate id="home.visual.success">Success rate</Translate>
{' '}-p 8340:8340 \{'\n'} </span>
{' '}-v backupx-data:/app/data \{'\n'} <strong>99.4%</strong>
{' '}awuqing/backupx:latest{'\n'} </div>
{'\n'} <div>
<span className={styles.codeComment}># Open http://localhost:8340</span>{'\n'} <span className={styles.consoleLabel}>
<span className={styles.codeComment}># Deploy an Agent on a remote host</span>{'\n'} <Translate id="home.visual.nodes">Active nodes</Translate>
<span className={styles.codePrompt}>$</span> backupx agent \{'\n'} </span>
{' '}--master <span className={styles.codeString}>http://master:8340</span> \{'\n'} <strong>12</strong>
{' '}--token <span className={styles.codeString}>&lt;token&gt;</span> </div>
</code> <div>
</pre> <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> </div>
</div> </div>
@@ -100,12 +156,13 @@ export default function Home(): ReactNode {
const {siteConfig} = useDocusaurusContext(); const {siteConfig} = useDocusaurusContext();
return ( return (
<Layout <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}> description={siteConfig.tagline}>
<HomepageHeader /> <HomepageHeader />
<main> <main>
<HomepageFeatures /> <HomepageFeatures />
<HomepageShowcase /> <HomepageShowcase />
<HomepageCommunity />
</main> </main>
</Layout> </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

@@ -3,6 +3,7 @@ server:
host: "0.0.0.0" host: "0.0.0.0"
port: 8340 port: 8340
mode: "release" # debug | release mode: "release" # debug | release
external_url: "" # 可选Master 对 Agent 可达的 URL例如 https://backup.example.com
database: database:
path: "./data/backupx.db" # SQLite 数据库路径 path: "./data/backupx.db" # SQLite 数据库路径

View File

@@ -7,6 +7,8 @@ require (
github.com/glebarez/sqlite v1.11.0 github.com/glebarez/sqlite v1.11.0
github.com/golang-jwt/jwt/v5 v5.3.0 github.com/golang-jwt/jwt/v5 v5.3.0
github.com/natefinch/lumberjack v2.0.0+incompatible 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/rclone/rclone v1.73.3
github.com/robfig/cron/v3 v3.0.1 github.com/robfig/cron/v3 v3.0.1
github.com/spf13/viper v1.20.0 github.com/spf13/viper v1.20.0
@@ -180,8 +182,6 @@ require (
github.com/pkg/xattr v0.4.12 // indirect github.com/pkg/xattr v0.4.12 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // 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/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/client_model v0.6.2 // indirect
github.com/prometheus/common v0.67.2 // indirect github.com/prometheus/common v0.67.2 // indirect
github.com/prometheus/procfs v0.19.2 // indirect github.com/prometheus/procfs v0.19.2 // indirect

View File

@@ -11,6 +11,8 @@ import (
"strings" "strings"
"sync" "sync"
"time" "time"
"backupx/server/internal/backup"
) )
// Agent 是 Agent 进程的主控制器。 // Agent 是 Agent 进程的主控制器。
@@ -131,6 +133,12 @@ func (a *Agent) pollAndHandleOnce(ctx context.Context) {
a.handleRunTask(ctx, cmd) a.handleRunTask(ctx, cmd)
case "list_dir": case "list_dir":
a.handleListDir(ctx, cmd) 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: default:
msg := fmt.Sprintf("unknown command type: %s", cmd.Type) msg := fmt.Sprintf("unknown command type: %s", cmd.Type)
log.Printf("[agent] %s", msg) 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 命令(阶段四实现) // handleListDir 处理 list_dir 命令(阶段四实现)
func (a *Agent) handleListDir(ctx context.Context, cmd *CommandPayload) { func (a *Agent) handleListDir(ctx context.Context, cmd *CommandPayload) {
var payload struct { 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) 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。 // do 是通用 HTTP 调用。所有 Agent API 都统一走 JSON + X-Agent-Token。
func (c *MasterClient) do(ctx context.Context, method, path string, body any, out any) error { func (c *MasterClient) do(ctx context.Context, method, path string, body any, out any) error {
var reqBody io.Reader var reqBody io.Reader

View File

@@ -26,7 +26,7 @@ type Config struct {
HeartbeatInterval string `yaml:"heartbeatInterval"` HeartbeatInterval string `yaml:"heartbeatInterval"`
// PollInterval 命令轮询间隔,默认 5s // PollInterval 命令轮询间隔,默认 5s
PollInterval string `yaml:"pollInterval"` PollInterval string `yaml:"pollInterval"`
// TempDir 备份临时目录,默认 /tmp/backupx-agent // TempDir 备份临时目录,默认 /var/lib/backupx-agent/tmp
TempDir string `yaml:"tempDir"` TempDir string `yaml:"tempDir"`
// InsecureSkipTLSVerify 测试环境允许跳过 TLS 证书校验 // InsecureSkipTLSVerify 测试环境允许跳过 TLS 证书校验
InsecureSkipTLSVerify bool `yaml:"insecureSkipTlsVerify"` InsecureSkipTLSVerify bool `yaml:"insecureSkipTlsVerify"`
@@ -98,7 +98,7 @@ func applyConfigDefaults(cfg *Config) (*Config, error) {
cfg.PollInterval = "5s" cfg.PollInterval = "5s"
} }
if cfg.TempDir == "" { if cfg.TempDir == "" {
cfg.TempDir = "/tmp/backupx-agent" cfg.TempDir = "/var/lib/backupx-agent/tmp"
} }
cfg.Master = strings.TrimRight(strings.TrimSpace(cfg.Master), "/") cfg.Master = strings.TrimRight(strings.TrimSpace(cfg.Master), "/")
return cfg, nil return cfg, nil

View File

@@ -50,7 +50,7 @@ func TestLoadConfigDefaults(t *testing.T) {
if cfg.HeartbeatInterval != "15s" || cfg.PollInterval != "5s" { if cfg.HeartbeatInterval != "15s" || cfg.PollInterval != "5s" {
t.Errorf("default intervals not applied: %+v", cfg) t.Errorf("default intervals not applied: %+v", cfg)
} }
if cfg.TempDir != "/tmp/backupx-agent" { if cfg.TempDir != "/var/lib/backupx-agent/tmp" {
t.Errorf("default tempdir: %q", cfg.TempDir) t.Errorf("default tempdir: %q", cfg.TempDir)
} }
} }

View File

@@ -4,6 +4,7 @@ import (
"context" "context"
"crypto/sha256" "crypto/sha256"
"encoding/hex" "encoding/hex"
"encoding/json"
"fmt" "fmt"
"io" "io"
"os" "os"
@@ -19,10 +20,10 @@ import (
// Executor 负责在 Agent 本地执行命令。 // Executor 负责在 Agent 本地执行命令。
type Executor struct { type Executor struct {
client *MasterClient client *MasterClient
tempDir string tempDir string
backupRegistry *backup.Registry backupRegistry *backup.Registry
storageRegistry *storage.Registry storageRegistry *storage.Registry
} }
// NewExecutor 构造执行器。预先初始化 backup runner 与 storage registry。 // NewExecutor 构造执行器。预先初始化 backup runner 与 storage registry。
@@ -59,6 +60,11 @@ func NewExecutor(client *MasterClient, tempDir string) *Executor {
// 注意Agent 当前不支持 Encrypt=true加密密钥不下发到 Agent避免密钥扩散 // 注意Agent 当前不支持 Encrypt=true加密密钥不下发到 Agent避免密钥扩散
// 遇到启用加密的任务会向 Master 上报失败并返回错误。 // 遇到启用加密的任务会向 Master 上报失败并返回错误。
func (e *Executor) ExecuteRunTask(ctx context.Context, taskID, recordID uint) error { func (e *Executor) ExecuteRunTask(ctx context.Context, taskID, recordID uint) error {
if err := e.ensureTempDir(); err != nil {
e.reportRecordFailure(ctx, recordID, err.Error())
return err
}
// 1) 拉取任务规格 // 1) 拉取任务规格
spec, err := e.client.GetTaskSpec(ctx, taskID) spec, err := e.client.GetTaskSpec(ctx, taskID)
if err != nil { if err != nil {
@@ -74,10 +80,6 @@ func (e *Executor) ExecuteRunTask(ctx context.Context, taskID, recordID uint) er
// 2) 构造 backup.TaskSpec 并找对应 runner // 2) 构造 backup.TaskSpec 并找对应 runner
startedAt := time.Now().UTC() startedAt := time.Now().UTC()
if err := os.MkdirAll(e.tempDir, 0o755); err != nil {
e.reportRecordFailure(ctx, recordID, fmt.Sprintf("创建临时目录失败: %v", err))
return err
}
backupSpec := buildBackupTaskSpec(spec, startedAt, e.tempDir) backupSpec := buildBackupTaskSpec(spec, startedAt, e.tempDir)
runner, err := e.backupRegistry.Runner(backupSpec.Type) runner, err := e.backupRegistry.Runner(backupSpec.Type)
if err != nil { if err != nil {
@@ -184,22 +186,8 @@ func (e *Executor) reportRecordFailure(ctx context.Context, recordID uint, msg s
// buildBackupTaskSpec 把 AgentTaskSpec 转换为 backup.TaskSpec。 // buildBackupTaskSpec 把 AgentTaskSpec 转换为 backup.TaskSpec。
func buildBackupTaskSpec(spec *TaskSpec, startedAt time.Time, tempDir string) backup.TaskSpec { func buildBackupTaskSpec(spec *TaskSpec, startedAt time.Time, tempDir string) backup.TaskSpec {
var sourcePaths []string sourcePaths := parseStringListField(spec.SourcePaths)
if strings.TrimSpace(spec.SourcePaths) != "" { excludes := parseStringListField(spec.ExcludePatterns)
for _, p := range strings.Split(spec.SourcePaths, "\n") {
if p = strings.TrimSpace(p); p != "" {
sourcePaths = append(sourcePaths, p)
}
}
}
var excludes []string
if strings.TrimSpace(spec.ExcludePatterns) != "" {
for _, p := range strings.Split(spec.ExcludePatterns, "\n") {
if p = strings.TrimSpace(p); p != "" {
excludes = append(excludes, p)
}
}
}
return backup.TaskSpec{ return backup.TaskSpec{
ID: spec.TaskID, ID: spec.TaskID,
Name: spec.Name, Name: spec.Name,
@@ -222,6 +210,37 @@ func buildBackupTaskSpec(spec *TaskSpec, startedAt time.Time, tempDir string) ba
} }
} }
func (e *Executor) ensureTempDir() error {
if err := os.MkdirAll(e.tempDir, 0o755); err != nil {
return fmt.Errorf("create agent temp dir: %w", err)
}
return nil
}
func parseStringListField(value string) []string {
trimmed := strings.TrimSpace(value)
if trimmed == "" || trimmed == "[]" {
return nil
}
var jsonItems []string
if err := json.Unmarshal([]byte(trimmed), &jsonItems); err == nil {
return compactStringList(jsonItems)
}
return compactStringList(strings.FieldsFunc(trimmed, func(r rune) bool {
return r == '\n' || r == '\r'
}))
}
func compactStringList(items []string) []string {
result := make([]string, 0, len(items))
for _, item := range items {
if trimmed := strings.TrimSpace(item); trimmed != "" {
result = append(result, trimmed)
}
}
return result
}
// recordLogger 把 runner 日志回传到 Master 记录。 // recordLogger 把 runner 日志回传到 Master 记录。
// 实现 backup.LogWriter每条日志追加到 record.log_content。 // 实现 backup.LogWriter每条日志追加到 record.log_content。
type recordLogger struct { type recordLogger struct {
@@ -238,6 +257,181 @@ func (l *recordLogger) WriteLine(message string) {
_ = l.client.UpdateRecord(l.ctx, l.recordID, RecordUpdate{LogAppend: message + "\n"}) _ = 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 {
if err := e.ensureTempDir(); err != nil {
e.reportRestoreFailure(ctx, restoreRecordID, err.Error())
return err
}
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))
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) { func computeFileSHA256(path string) (string, error) {

View File

@@ -0,0 +1,34 @@
package agent
import (
"reflect"
"testing"
"time"
)
func TestBuildBackupTaskSpecParsesJSONSourcePaths(t *testing.T) {
spec := &TaskSpec{
TaskID: 7,
Name: "root-files",
Type: "file",
SourcePaths: `["/root","/etc"]`,
ExcludePatterns: `["*.log","tmp"]`,
}
got := buildBackupTaskSpec(spec, time.Unix(0, 0), "/var/lib/backupx-agent/tmp")
if !reflect.DeepEqual(got.SourcePaths, []string{"/root", "/etc"}) {
t.Fatalf("source paths = %#v", got.SourcePaths)
}
if !reflect.DeepEqual(got.ExcludePatterns, []string{"*.log", "tmp"}) {
t.Fatalf("exclude patterns = %#v", got.ExcludePatterns)
}
}
func TestParseStringListFieldKeepsLegacyLineFormat(t *testing.T) {
got := parseStringListField("/root\n /etc \n")
want := []string{"/root", "/etc"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("paths = %#v, want %#v", got, want)
}
}

View File

@@ -5,6 +5,7 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"sort" "sort"
"strings"
) )
// DirEntry Agent 返回给 Master 的目录项。 // DirEntry Agent 返回给 Master 的目录项。
@@ -17,8 +18,8 @@ type DirEntry struct {
// listLocalDir 列出 Agent 所在机器的指定路径。 // listLocalDir 列出 Agent 所在机器的指定路径。
func listLocalDir(path string) ([]DirEntry, error) { func listLocalDir(path string) ([]DirEntry, error) {
cleaned := filepath.Clean(path) cleaned := filepath.Clean(strings.TrimSpace(path))
if cleaned == "" { if strings.TrimSpace(path) == "" || cleaned == "." {
cleaned = "/" cleaned = "/"
} }
entries, err := os.ReadDir(cleaned) entries, err := os.ReadDir(cleaned)

View File

@@ -36,6 +36,21 @@ func TestListLocalDir(t *testing.T) {
} }
} }
func TestListLocalDirEmptyPathUsesRoot(t *testing.T) {
entries, err := listLocalDir("")
if err != nil {
t.Fatalf("list root: %v", err)
}
if len(entries) == 0 {
t.Fatalf("expected root entries")
}
for _, entry := range entries {
if !filepath.IsAbs(entry.Path) {
t.Fatalf("entry path should be absolute: %+v", entry)
}
}
}
func TestSplitCommaOrNewline(t *testing.T) { func TestSplitCommaOrNewline(t *testing.T) {
cases := []struct { cases := []struct {
in string in string

View File

@@ -13,6 +13,7 @@ import (
"backupx/server/internal/database" "backupx/server/internal/database"
aphttp "backupx/server/internal/http" aphttp "backupx/server/internal/http"
"backupx/server/internal/logger" "backupx/server/internal/logger"
"backupx/server/internal/metrics"
"backupx/server/internal/notify" "backupx/server/internal/notify"
"backupx/server/internal/repository" "backupx/server/internal/repository"
"backupx/server/internal/scheduler" "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)) jwtManager := security.NewJWTManager(resolvedSecurity.JWTSecret, config.MustJWTDuration(cfg.Security))
rateLimiter := security.NewLoginRateLimiter(5, time.Minute) 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) configCipher := codec.NewConfigCipher(resolvedSecurity.EncryptionKey)
authService := service.NewAuthService(userRepo, systemConfigRepo, jwtManager, rateLimiter, configCipher)
systemService := service.NewSystemService(cfg, version, time.Now().UTC())
storageRegistry := storage.NewRegistry( storageRegistry := storage.NewRegistry(
storageRclone.NewLocalDiskFactory(), storageRclone.NewLocalDiskFactory(),
storageRclone.NewS3Factory(), storageRclone.NewS3Factory(),
@@ -80,11 +81,13 @@ func New(ctx context.Context, cfg config.Config, version string) (*Application,
storageTargetService.SetBackupRecordRepository(backupRecordRepo) storageTargetService.SetBackupRecordRepository(backupRecordRepo)
backupTaskService := service.NewBackupTaskService(backupTaskRepo, storageTargetRepo, configCipher) backupTaskService := service.NewBackupTaskService(backupTaskRepo, storageTargetRepo, configCipher)
backupTaskService.SetRecordsAndStorage(backupRecordRepo, storageRegistry) backupTaskService.SetRecordsAndStorage(backupRecordRepo, storageRegistry)
// nodeRepo 在下方 Cluster 节点管理区块才实例化,这里延后注入
backupRunnerRegistry := backup.NewRegistry(backup.NewFileRunner(), backup.NewSQLiteRunner(), backup.NewMySQLRunner(nil), backup.NewPostgreSQLRunner(nil), backup.NewSAPHANARunner(nil)) backupRunnerRegistry := backup.NewRegistry(backup.NewFileRunner(), backup.NewSQLiteRunner(), backup.NewMySQLRunner(nil), backup.NewPostgreSQLRunner(nil), backup.NewSAPHANARunner(nil))
logHub := backup.NewLogHub() logHub := backup.NewLogHub()
retentionService := backupretention.NewService(backupRecordRepo) retentionService := backupretention.NewService(backupRecordRepo)
notifyRegistry := notify.NewRegistry(notify.NewEmailNotifier(), notify.NewWebhookNotifier(), notify.NewTelegramNotifier()) notifyRegistry := notify.NewRegistry(notify.NewEmailNotifier(), notify.NewWebhookNotifier(), notify.NewTelegramNotifier())
notificationService := service.NewNotificationService(notificationRepo, notifyRegistry, configCipher) notificationService := service.NewNotificationService(notificationRepo, notifyRegistry, configCipher)
authService.SetNotificationService(notificationService)
// 初始化 rclone 传输配置(重试 + 带宽限制) // 初始化 rclone 传输配置(重试 + 带宽限制)
rcloneCtx := storageRclone.ConfiguredContext(ctx, storageRclone.TransferConfig{ rcloneCtx := storageRclone.ConfiguredContext(ctx, storageRclone.TransferConfig{
LowLevelRetries: cfg.Backup.Retries, LowLevelRetries: cfg.Backup.Retries,
@@ -97,6 +100,9 @@ func New(ctx context.Context, cfg config.Config, version string) (*Application,
backupTaskService.SetScheduler(schedulerService) backupTaskService.SetScheduler(schedulerService)
// 审计日志注入延迟到 auditService 创建后(见下方) // 审计日志注入延迟到 auditService 创建后(见下方)
backupRecordService := service.NewBackupRecordService(backupRecordRepo, backupExecutionService, logHub) backupRecordService := service.NewBackupRecordService(backupRecordRepo, backupExecutionService, logHub)
// 恢复服务:使用独立 LogHub 避免恢复记录与备份记录 ID 命名空间冲突
restoreRecordRepo := repository.NewRestoreRecordRepository(db)
restoreLogHub := backup.NewLogHub()
dashboardService := service.NewDashboardService(backupTaskRepo, backupRecordRepo, storageTargetRepo) dashboardService := service.NewDashboardService(backupTaskRepo, backupRecordRepo, storageTargetRepo)
settingsService := service.NewSettingsService(systemConfigRepo) settingsService := service.NewSettingsService(systemConfigRepo)
@@ -105,12 +111,16 @@ func New(ctx context.Context, cfg config.Config, version string) (*Application,
auditService := service.NewAuditService(auditLogRepo) auditService := service.NewAuditService(auditLogRepo)
authService.SetAuditService(auditService) authService.SetAuditService(auditService)
schedulerService.SetAuditRecorder(auditService) schedulerService.SetAuditRecorder(auditService)
// 审计日志外输:启动时用当前 settings 初始化 webhook后续前端修改立即生效
settingsService.SetAuditWebhookConfigurer(ctx, auditService)
// Database discovery // Database discovery(集群依赖在 agentService 创建后注入)
databaseDiscoveryService := service.NewDatabaseDiscoveryService(backup.NewOSCommandExecutor()) databaseDiscoveryService := service.NewDatabaseDiscoveryService(backup.NewOSCommandExecutor())
// Cluster: Node management // Cluster: Node management
nodeRepo := repository.NewNodeRepository(db) nodeRepo := repository.NewNodeRepository(db)
backupTaskService.SetNodeRepository(nodeRepo)
schedulerService.SetNodeRepository(nodeRepo)
nodeService := service.NewNodeService(nodeRepo, version) nodeService := service.NewNodeService(nodeRepo, version)
nodeService.SetTaskRepository(backupTaskRepo) nodeService.SetTaskRepository(backupTaskRepo)
if err := nodeService.EnsureLocalNode(ctx); err != nil { if err := nodeService.EnsureLocalNode(ctx); err != nil {
@@ -122,6 +132,7 @@ func New(ctx context.Context, cfg config.Config, version string) (*Application,
// Agent 协议服务:命令队列 + 任务下发 + 记录上报 // Agent 协议服务:命令队列 + 任务下发 + 记录上报
agentCmdRepo := repository.NewAgentCommandRepository(db) agentCmdRepo := repository.NewAgentCommandRepository(db)
agentService := service.NewAgentService(nodeRepo, backupTaskRepo, backupRecordRepo, storageTargetRepo, agentCmdRepo, configCipher) agentService := service.NewAgentService(nodeRepo, backupTaskRepo, backupRecordRepo, storageTargetRepo, agentCmdRepo, configCipher)
agentService.SetRestoreRepository(restoreRecordRepo)
agentService.StartCommandTimeoutMonitor(ctx, 30*time.Second, 10*time.Minute) agentService.StartCommandTimeoutMonitor(ctx, 30*time.Second, 10*time.Minute)
// 一键部署install token service + 后台 GC // 一键部署install token service + 后台 GC
@@ -133,30 +144,141 @@ func New(ctx context.Context, cfg config.Config, version string) (*Application,
backupExecutionService.SetClusterDependencies(nodeRepo, agentService) backupExecutionService.SetClusterDependencies(nodeRepo, agentService)
// 启用远程目录浏览NodeService 通过 AgentService 做同步 RPC // 启用远程目录浏览NodeService 通过 AgentService 做同步 RPC
nodeService.SetAgentRPC(agentService) 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{ router := aphttp.NewRouter(aphttp.RouterDependencies{
Context: ctx, Context: ctx,
Config: cfg, Config: cfg,
Version: version, Version: version,
Logger: appLogger, Logger: appLogger,
AuthService: authService, AuthService: authService,
SystemService: systemService, SystemService: systemService,
StorageTargetService: storageTargetService, StorageTargetService: storageTargetService,
BackupTaskService: backupTaskService, BackupTaskService: backupTaskService,
BackupExecutionService: backupExecutionService, BackupExecutionService: backupExecutionService,
BackupRecordService: backupRecordService, BackupRecordService: backupRecordService,
NotificationService: notificationService, RestoreService: restoreService,
DashboardService: dashboardService, VerificationService: verificationService,
SettingsService: settingsService, ReplicationService: replicationService,
TaskTemplateService: taskTemplateService,
TaskExportService: taskExportService,
SearchService: searchService,
EventBroadcaster: eventBroadcaster,
UserService: userService,
ApiKeyService: apiKeyService,
NotificationService: notificationService,
DashboardService: dashboardService,
SettingsService: settingsService,
NodeService: nodeService, NodeService: nodeService,
AgentService: agentService, AgentService: agentService,
DatabaseDiscoveryService: databaseDiscoveryService, DatabaseDiscoveryService: databaseDiscoveryService,
AuditService: auditService, AuditService: auditService,
JWTManager: jwtManager, JWTManager: jwtManager,
UserRepository: userRepo, UserRepository: userRepo,
SystemConfigRepo: systemConfigRepo, SystemConfigRepo: systemConfigRepo,
InstallTokenService: installTokenService, InstallTokenService: installTokenService,
MasterExternalURL: "", // 如需覆盖 URL可扩展 cfg.Server 增字段;目前留空依赖 X-Forwarded-* / Request.Host MasterExternalURL: cfg.Server.ExternalURL,
DB: db,
Metrics: appMetrics,
}) })
httpServer := &stdhttp.Server{ 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

@@ -24,6 +24,9 @@ func (r *fakeRecordRepository) List(context.Context, repository.BackupRecordList
func (r *fakeRecordRepository) FindByID(context.Context, uint) (*model.BackupRecord, error) { func (r *fakeRecordRepository) FindByID(context.Context, uint) (*model.BackupRecord, error) {
return nil, nil return nil, nil
} }
func (r *fakeRecordRepository) FindRunningByTaskAndNode(context.Context, uint, uint) (*model.BackupRecord, error) {
return nil, nil
}
func (r *fakeRecordRepository) Create(context.Context, *model.BackupRecord) error { return nil } func (r *fakeRecordRepository) Create(context.Context, *model.BackupRecord) error { return nil }
func (r *fakeRecordRepository) Update(context.Context, *model.BackupRecord) error { return nil } func (r *fakeRecordRepository) Update(context.Context, *model.BackupRecord) error { return nil }
func (r *fakeRecordRepository) Delete(_ context.Context, id uint) error { func (r *fakeRecordRepository) Delete(_ context.Context, id uint) error {

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

@@ -17,9 +17,10 @@ type Config struct {
} }
type ServerConfig struct { type ServerConfig struct {
Host string `mapstructure:"host"` Host string `mapstructure:"host"`
Port int `mapstructure:"port"` Port int `mapstructure:"port"`
Mode string `mapstructure:"mode"` Mode string `mapstructure:"mode"`
ExternalURL string `mapstructure:"external_url"`
} }
type DatabaseConfig struct { type DatabaseConfig struct {
@@ -136,6 +137,7 @@ func applyDefaults(v *viper.Viper) {
v.SetDefault("server.host", "0.0.0.0") v.SetDefault("server.host", "0.0.0.0")
v.SetDefault("server.port", 8340) v.SetDefault("server.port", 8340)
v.SetDefault("server.mode", "release") v.SetDefault("server.mode", "release")
v.SetDefault("server.external_url", "")
v.SetDefault("database.path", "./data/backupx.db") v.SetDefault("database.path", "./data/backupx.db")
v.SetDefault("security.jwt_expire", "24h") v.SetDefault("security.jwt_expire", "24h")
v.SetDefault("backup.temp_dir", "/tmp/backupx") v.SetDefault("backup.temp_dir", "/tmp/backupx")

View File

@@ -1,6 +1,10 @@
package config package config
import "testing" import (
"os"
"path/filepath"
"testing"
)
func TestLoadUsesDefaultsWithoutConfigFile(t *testing.T) { func TestLoadUsesDefaultsWithoutConfigFile(t *testing.T) {
cfg, err := Load("") cfg, err := Load("")
@@ -18,3 +22,33 @@ func TestLoadUsesDefaultsWithoutConfigFile(t *testing.T) {
t.Fatalf("expected default database path, got %s", cfg.Database.Path) t.Fatalf("expected default database path, got %s", cfg.Database.Path)
} }
} }
func TestLoadReadsServerExternalURLFromFile(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.yaml")
content := []byte("server:\n external_url: \"https://backup.example.com\"\n")
if err := os.WriteFile(configPath, content, 0o600); err != nil {
t.Fatalf("write config: %v", err)
}
cfg, err := Load(configPath)
if err != nil {
t.Fatalf("Load returned error: %v", err)
}
if cfg.Server.ExternalURL != "https://backup.example.com" {
t.Fatalf("expected external URL from config, got %q", cfg.Server.ExternalURL)
}
}
func TestLoadReadsServerExternalURLFromEnv(t *testing.T) {
t.Setenv("BACKUPX_SERVER_EXTERNAL_URL", "https://env-backup.example.com")
cfg, err := Load("")
if err != nil {
t.Fatalf("Load returned error: %v", err)
}
if cfg.Server.ExternalURL != "https://env-backup.example.com" {
t.Fatalf("expected external URL from env, got %q", cfg.Server.ExternalURL)
}
}

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) 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{}, &model.AgentInstallToken{}); 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) return nil, fmt.Errorf("migrate schema: %w", err)
} }

View File

@@ -14,12 +14,13 @@ import (
// AgentHandler 实现 Agent 调用 Master 的 HTTP API。 // AgentHandler 实现 Agent 调用 Master 的 HTTP API。
// 全部端点通过 X-Agent-Token 头做节点认证,不使用 JWT。 // 全部端点通过 X-Agent-Token 头做节点认证,不使用 JWT。
type AgentHandler struct { type AgentHandler struct {
agentService *service.AgentService agentService *service.AgentService
nodeService *service.NodeService nodeService *service.NodeService
restoreService *service.RestoreService
} }
func NewAgentHandler(agentService *service.AgentService, nodeService *service.NodeService) *AgentHandler { func NewAgentHandler(agentService *service.AgentService, nodeService *service.NodeService, restoreService *service.RestoreService) *AgentHandler {
return &AgentHandler{agentService: agentService, nodeService: nodeService} return &AgentHandler{agentService: agentService, nodeService: nodeService, restoreService: restoreService}
} }
// extractToken 从请求头或 JSON body 中提取 Agent Token。 // extractToken 从请求头或 JSON body 中提取 Agent Token。
@@ -155,6 +156,58 @@ func (h *AgentHandler) UpdateRecord(c *gin.Context) {
response.Success(c, gin.H{"status": "ok"}) 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 所属节点的状态,供安装脚本末尾探活。 // Self 返回当前 Agent token 所属节点的状态,供安装脚本末尾探活。
func (h *AgentHandler) Self(c *gin.Context) { func (h *AgentHandler) Self(c *gin.Context) {
node, err := h.agentService.AuthenticatedNode(c.Request.Context(), extractToken(c)) node, err := h.agentService.AuthenticatedNode(c.Request.Context(), extractToken(c))

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 package http
import ( import (
"encoding/csv"
"fmt"
stdhttp "net/http"
"strconv" "strconv"
"strings" "strings"
"time"
"backupx/server/internal/apperror"
"backupx/server/internal/repository"
"backupx/server/internal/service" "backupx/server/internal/service"
"backupx/server/pkg/response" "backupx/server/pkg/response"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
@@ -17,24 +24,97 @@ func NewAuditHandler(auditService *service.AuditService) *AuditHandler {
return &AuditHandler{auditService: auditService} return &AuditHandler{auditService: auditService}
} }
// List 多字段筛选分页查询审计日志。
// 支持参数category, action, username, targetId, keyword, dateFrom, dateTo, limit, offset。
// 向后兼容:若仅传 category + limit + offset行为与旧版一致。
func (h *AuditHandler) List(c *gin.Context) { func (h *AuditHandler) List(c *gin.Context) {
category := strings.TrimSpace(c.Query("category")) opts, err := parseAuditFilter(c)
limit := 50 if err != nil {
offset := 0 response.Error(c, err)
if v := strings.TrimSpace(c.Query("limit")); v != "" { return
if parsed, err := strconv.Atoi(v); err == nil && parsed > 0 {
limit = parsed
}
} }
if v := strings.TrimSpace(c.Query("offset")); v != "" { result, err := h.auditService.ListAdvanced(c.Request.Context(), opts)
if parsed, err := strconv.Atoi(v); err == nil && parsed >= 0 {
offset = parsed
}
}
result, err := h.auditService.List(c.Request.Context(), category, limit, offset)
if err != nil { if err != nil {
response.Error(c, err) response.Error(c, err)
return return
} }
response.Success(c, result) 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 package http
import ( import (
"net"
stdhttp "net/http"
"strings"
"time"
"backupx/server/internal/apperror" "backupx/server/internal/apperror"
"backupx/server/internal/service" "backupx/server/internal/service"
"backupx/server/pkg/response" "backupx/server/pkg/response"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
const (
trustedDeviceCookieName = "backupx_trusted_device"
trustedDeviceCookiePath = "/api/auth"
trustedDeviceCookieMaxAge = int((30 * 24 * time.Hour) / time.Second)
)
type AuthHandler struct { type AuthHandler struct {
authService *service.AuthService authService *service.AuthService
} }
@@ -44,11 +55,18 @@ func (h *AuthHandler) Login(c *gin.Context) {
response.Error(c, apperror.BadRequest("AUTH_LOGIN_INVALID", "登录参数不合法", err)) response.Error(c, apperror.BadRequest("AUTH_LOGIN_INVALID", "登录参数不合法", err))
return return
} }
if strings.TrimSpace(input.TrustedDeviceToken) == "" {
input.TrustedDeviceToken = trustedDeviceCookieValue(c)
}
payload, err := h.authService.Login(c.Request.Context(), input, ClientKey(c)) payload, err := h.authService.Login(c.Request.Context(), input, ClientKey(c))
if err != nil { if err != nil {
response.Error(c, err) response.Error(c, err)
return return
} }
if payload.TrustedDeviceToken != "" {
setTrustedDeviceCookie(c, payload.TrustedDeviceToken)
payload.TrustedDeviceToken = ""
}
response.Success(c, payload) response.Success(c, payload)
} }
@@ -83,9 +101,315 @@ func (h *AuthHandler) ChangePassword(c *gin.Context) {
response.Error(c, err) response.Error(c, err)
return return
} }
clearTrustedDeviceCookie(c)
response.Success(c, gin.H{"changed": true}) 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) { func (h *AuthHandler) Logout(c *gin.Context) {
response.Success(c, gin.H{"loggedOut": true}) 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 { type BackupRecordHandler struct {
service *service.BackupRecordService service *service.BackupRecordService
auditService *service.AuditService restoreService *service.RestoreService
auditService *service.AuditService
} }
func NewBackupRecordHandler(recordService *service.BackupRecordService, auditService *service.AuditService) *BackupRecordHandler { func NewBackupRecordHandler(recordService *service.BackupRecordService, restoreService *service.RestoreService, auditService *service.AuditService) *BackupRecordHandler {
return &BackupRecordHandler{service: recordService, auditService: auditService} return &BackupRecordHandler{service: recordService, restoreService: restoreService, auditService: auditService}
} }
func (h *BackupRecordHandler) List(c *gin.Context) { func (h *BackupRecordHandler) List(c *gin.Context) {
@@ -121,18 +122,29 @@ func (h *BackupRecordHandler) Download(c *gin.Context) {
_, _ = io.Copy(c.Writer, result.Reader) _, _ = io.Copy(c.Writer, result.Reader)
} }
// Restore 启动一次异步恢复并返回 restoreRecordId实际执行路由由 RestoreService
// 根据 task.NodeID 决定(本地 Master or 远程 Agent
func (h *BackupRecordHandler) Restore(c *gin.Context) { func (h *BackupRecordHandler) Restore(c *gin.Context) {
id, ok := parseUintParam(c, "id") id, ok := parseUintParam(c, "id")
if !ok { if !ok {
return 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) response.Error(c, err)
return return
} }
recordAudit(c, h.auditService, "backup_record", "restore", "backup_record", fmt.Sprintf("%d", id), "", recordAudit(c, h.auditService, "backup_record", "restore", "backup_record", fmt.Sprintf("%d", id), "",
fmt.Sprintf("恢复备份记录 (ID: %d)", id)) fmt.Sprintf("启动恢复 (备份记录 ID: %d, 恢复记录 ID: %d)", id, detail.ID))
response.Success(c, gin.H{"restored": true}) response.Success(c, detail)
} }
func (h *BackupRecordHandler) Delete(c *gin.Context) { func (h *BackupRecordHandler) Delete(c *gin.Context) {

View File

@@ -3,6 +3,7 @@ package http
import ( import (
"fmt" "fmt"
"backupx/server/internal/apperror"
"backupx/server/internal/service" "backupx/server/internal/service"
"backupx/server/pkg/response" "backupx/server/pkg/response"
"github.com/gin-gonic/gin" "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), "", "手动触发备份") recordAudit(c, h.auditService, "backup_task", "run", "backup_task", fmt.Sprintf("%d", id), "", "手动触发备份")
response.Success(c, record) 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) 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) { func (h *BackupTaskHandler) Get(c *gin.Context) {
id, ok := parseUintParam(c, "id") id, ok := parseUintParam(c, "id")
if !ok { if !ok {
@@ -106,6 +116,55 @@ func (h *BackupTaskHandler) Delete(c *gin.Context) {
response.Success(c, gin.H{"deleted": true}) 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) { func (h *BackupTaskHandler) Toggle(c *gin.Context) {
id, ok := parseUintParam(c, "id") id, ok := parseUintParam(c, "id")
if !ok { if !ok {

View File

@@ -1,3 +1,9 @@
package http 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) 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) { func (h *DashboardHandler) Timeline(c *gin.Context) {
days := 30 days := 30
if value := strings.TrimSpace(c.Query("days")); value != "" { 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

@@ -3,10 +3,12 @@ package http
import ( import (
"bytes" "bytes"
"context" "context"
"encoding/base64"
"encoding/json" "encoding/json"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"path/filepath" "path/filepath"
"strconv"
"strings" "strings"
"testing" "testing"
"time" "time"
@@ -17,15 +19,20 @@ import (
"backupx/server/internal/repository" "backupx/server/internal/repository"
"backupx/server/internal/security" "backupx/server/internal/security"
"backupx/server/internal/service" "backupx/server/internal/service"
"backupx/server/internal/storage/codec"
) )
// setupInstallFlowRouter 构造一个 Node + Agent + InstallToken 全量依赖的 router // setupInstallFlowRouter 构造一个 Node + Agent + InstallToken 全量依赖的 router
// 并返回已登录管理员 JWT。 // 并返回已登录管理员 JWT。
func setupInstallFlowRouter(t *testing.T) (http.Handler, string) { func setupInstallFlowRouter(t *testing.T) (http.Handler, string) {
return setupInstallFlowRouterWithExternalURL(t, "")
}
func setupInstallFlowRouterWithExternalURL(t *testing.T, externalURL string) (http.Handler, string) {
t.Helper() t.Helper()
tempDir := t.TempDir() tempDir := t.TempDir()
cfg := config.Config{ cfg := config.Config{
Server: config.ServerConfig{Host: "127.0.0.1", Port: 8340, Mode: "test"}, Server: config.ServerConfig{Host: "127.0.0.1", Port: 8340, Mode: "test", ExternalURL: externalURL},
Database: config.DatabaseConfig{Path: filepath.Join(tempDir, "backupx.db")}, Database: config.DatabaseConfig{Path: filepath.Join(tempDir, "backupx.db")},
Security: config.SecurityConfig{JWTExpire: "24h"}, Security: config.SecurityConfig{JWTExpire: "24h"},
Log: config.LogConfig{Level: "error"}, Log: config.LogConfig{Level: "error"},
@@ -38,6 +45,13 @@ func setupInstallFlowRouter(t *testing.T) (http.Handler, string) {
if err != nil { if err != nil {
t.Fatalf("db: %v", err) 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) userRepo := repository.NewUserRepository(db)
systemConfigRepo := repository.NewSystemConfigRepository(db) systemConfigRepo := repository.NewSystemConfigRepository(db)
@@ -46,7 +60,7 @@ func setupInstallFlowRouter(t *testing.T) (http.Handler, string) {
t.Fatalf("security: %v", err) t.Fatalf("security: %v", err)
} }
jwtMgr := security.NewJWTManager(resolved.JWTSecret, time.Hour) jwtMgr := security.NewJWTManager(resolved.JWTSecret, time.Hour)
authSvc := service.NewAuthService(userRepo, systemConfigRepo, jwtMgr, security.NewLoginRateLimiter(5, time.Minute)) authSvc := service.NewAuthService(userRepo, systemConfigRepo, jwtMgr, security.NewLoginRateLimiter(5, time.Minute), codec.NewConfigCipher(resolved.EncryptionKey))
systemSvc := service.NewSystemService(cfg, "test", time.Now().UTC()) systemSvc := service.NewSystemService(cfg, "test", time.Now().UTC())
nodeRepo := repository.NewNodeRepository(db) nodeRepo := repository.NewNodeRepository(db)
@@ -58,9 +72,6 @@ func setupInstallFlowRouter(t *testing.T) (http.Handler, string) {
installTokenRepo := repository.NewAgentInstallTokenRepository(db) installTokenRepo := repository.NewAgentInstallTokenRepository(db)
installTokenSvc := service.NewInstallTokenService(installTokenRepo, nodeRepo) installTokenSvc := service.NewInstallTokenService(installTokenRepo, nodeRepo)
auditLogRepo := repository.NewAuditLogRepository(db)
auditSvc := service.NewAuditService(auditLogRepo)
// 用 cancelable ctx测试结束时停掉 handler 启动的后台 GC 协程, // 用 cancelable ctx测试结束时停掉 handler 启动的后台 GC 协程,
// 避免 goroutine 持有 map 导致 tempdir 清理失败。 // 避免 goroutine 持有 map 导致 tempdir 清理失败。
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
@@ -75,7 +86,7 @@ func setupInstallFlowRouter(t *testing.T) (http.Handler, string) {
SystemService: systemSvc, SystemService: systemSvc,
NodeService: nodeSvc, NodeService: nodeSvc,
InstallTokenService: installTokenSvc, InstallTokenService: installTokenSvc,
AuditService: auditSvc, MasterExternalURL: cfg.Server.ExternalURL,
JWTManager: jwtMgr, JWTManager: jwtMgr,
UserRepository: userRepo, UserRepository: userRepo,
SystemConfigRepo: systemConfigRepo, SystemConfigRepo: systemConfigRepo,
@@ -104,6 +115,73 @@ func setupInstallFlowRouter(t *testing.T) (http.Handler, string) {
return router, setupResp.Data.Token return router, setupResp.Data.Token
} }
func TestInstallTokenUsesConfiguredExternalURL(t *testing.T) {
const externalURL = "https://public.example.com/base"
router, jwt := setupInstallFlowRouterWithExternalURL(t, externalURL)
batchBody, _ := json.Marshal(map[string][]string{"names": {"external-url-node"}})
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"`
} `json:"data"`
}
if err := json.Unmarshal(batchRec.Body.Bytes(), &batchResp); err != nil {
t.Fatalf("unmarshal batch: %v", err)
}
if len(batchResp.Data) != 1 {
t.Fatalf("expected 1 node, got %d", len(batchResp.Data))
}
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(batchResp.Data[0].ID)+"/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.URL != externalURL+"/api/install/"+genResp.Data.InstallToken {
t.Fatalf("url should use external URL, got %q", genResp.Data.URL)
}
if genResp.Data.FallbackURL != externalURL+"/install/"+genResp.Data.InstallToken {
t.Fatalf("fallbackUrl should use external 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), `MASTER_URL="`+externalURL+`"`) {
t.Fatalf("script should use external MASTER_URL:\n%s", string(decodedScript))
}
}
func TestOneClickInstallFlow(t *testing.T) { func TestOneClickInstallFlow(t *testing.T) {
router, jwt := setupInstallFlowRouter(t) router, jwt := setupInstallFlowRouter(t)
@@ -152,6 +230,8 @@ func TestOneClickInstallFlow(t *testing.T) {
Data struct { Data struct {
InstallToken string `json:"installToken"` InstallToken string `json:"installToken"`
URL string `json:"url"` URL string `json:"url"`
FallbackURL string `json:"fallbackUrl"`
ScriptBase64 string `json:"scriptBase64"`
} `json:"data"` } `json:"data"`
} }
if err := json.Unmarshal(genRec.Body.Bytes(), &genResp); err != nil { if err := json.Unmarshal(genRec.Body.Bytes(), &genResp); err != nil {
@@ -160,6 +240,16 @@ func TestOneClickInstallFlow(t *testing.T) {
if genResp.Data.InstallToken == "" { if genResp.Data.InstallToken == "" {
t.Fatalf("missing 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. 公开端点消费 // 3. 公开端点消费
scriptReq := httptest.NewRequest(http.MethodGet, "/install/"+genResp.Data.InstallToken, nil) scriptReq := httptest.NewRequest(http.MethodGet, "/install/"+genResp.Data.InstallToken, nil)
@@ -171,6 +261,22 @@ func TestOneClickInstallFlow(t *testing.T) {
if !strings.Contains(scriptRec.Body.String(), "systemctl enable --now backupx-agent") { if !strings.Contains(scriptRec.Body.String(), "systemctl enable --now backupx-agent") {
t.Fatalf("script missing systemctl enable:\n%s", scriptRec.Body.String()) 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 // 4. 再次消费应 410
scriptReq2 := httptest.NewRequest(http.MethodGet, "/install/"+genResp.Data.InstallToken, nil) scriptReq2 := httptest.NewRequest(http.MethodGet, "/install/"+genResp.Data.InstallToken, nil)
@@ -181,6 +287,81 @@ func TestOneClickInstallFlow(t *testing.T) {
} }
} }
// 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) { func TestInstallTokenRateLimit(t *testing.T) {
router, jwt := setupInstallFlowRouter(t) router, jwt := setupInstallFlowRouter(t)
@@ -315,6 +496,76 @@ func TestInstallFlowComposeModeMismatch(t *testing.T) {
} }
} }
func TestInstallFlowComposeSuccessConsumesToken(t *testing.T) {
router, jwt := setupInstallFlowRouter(t)
batchBody, _ := json.Marshal(map[string][]string{"names": {"compose-ok"}})
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"`
} `json:"data"`
}
if err := json.Unmarshal(batchRec.Body.Bytes(), &batchResp); err != nil {
t.Fatalf("unmarshal batch: %v", err)
}
if len(batchResp.Data) != 1 {
t.Fatalf("expected 1 node, got %d", len(batchResp.Data))
}
genBody, _ := json.Marshal(map[string]any{
"mode": "docker",
"arch": "auto",
"agentVersion": "v1.7.0",
"downloadSrc": "github",
"ttlSeconds": 900,
})
genReq := httptest.NewRequest(http.MethodPost,
"/api/nodes/"+formatUint(batchResp.Data[0].ID)+"/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"`
} `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")
}
composeReq := httptest.NewRequest(http.MethodGet, "/api/install/"+genResp.Data.InstallToken+"/compose.yml", nil)
composeRec := httptest.NewRecorder()
router.ServeHTTP(composeRec, composeReq)
if composeRec.Code != 200 {
t.Fatalf("compose fetch failed: %d %s", composeRec.Code, composeRec.Body.String())
}
if !strings.Contains(composeRec.Body.String(), "BACKUPX_AGENT_TOKEN") {
t.Fatalf("compose missing token env:\n%s", composeRec.Body.String())
}
scriptReq := httptest.NewRequest(http.MethodGet, "/api/install/"+genResp.Data.InstallToken, nil)
scriptRec := httptest.NewRecorder()
router.ServeHTTP(scriptRec, scriptReq)
if scriptRec.Code != http.StatusGone {
t.Fatalf("script after compose should be 410, got %d: %s", scriptRec.Code, scriptRec.Body.String())
}
}
// formatUint 小工具uint → 十进制字符串(无需引入 strconv // formatUint 小工具uint → 十进制字符串(无需引入 strconv
func formatUint(u uint) string { func formatUint(u uint) string {
if u == 0 { if u == 0 {

View File

@@ -36,6 +36,13 @@ func NewInstallHandler(gcCtx context.Context, tokenService *service.InstallToken
} }
// Script 消费 install token 并返回 shell 脚本Mode 由 token 存储决定systemd/docker/foreground 均返回 shell // 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) { func (h *InstallHandler) Script(c *gin.Context) {
if !h.limiter.allow(c.ClientIP()) { if !h.limiter.allow(c.ClientIP()) {
c.String(stdhttp.StatusTooManyRequests, "请求过于频繁,请稍后再试\n") c.String(stdhttp.StatusTooManyRequests, "请求过于频繁,请稍后再试\n")
@@ -52,21 +59,15 @@ func (h *InstallHandler) Script(c *gin.Context) {
return return
} }
h.recordConsumeAudit(c, consumed, "script") h.recordConsumeAudit(c, consumed, "script")
script, err := installscript.RenderScript(installscript.Context{ script, err := renderInstallScript(resolveMasterURL(c, h.externalURL), consumed.Node, consumed.Record)
MasterURL: resolveMasterURL(c, h.externalURL),
AgentToken: consumed.Node.Token,
AgentVersion: consumed.Record.AgentVer,
Mode: consumed.Record.Mode,
Arch: consumed.Record.Arch,
DownloadBase: installscript.DownloadBaseFor(consumed.Record.DownloadSrc),
InstallPrefix: "/opt/backupx-agent",
NodeID: consumed.Node.ID,
})
if err != nil { if err != nil {
c.String(stdhttp.StatusInternalServerError, "render error\n") c.String(stdhttp.StatusInternalServerError, "render error\n")
return return
} }
c.Data(stdhttp.StatusOK, "text/x-shellscript; charset=utf-8", []byte(script)) 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 有效。 // Compose 消费 install token 并返回 docker-compose YAML仅 Mode=docker 有效。
@@ -131,6 +132,19 @@ func (h *InstallHandler) recordConsumeAudit(c *gin.Context, consumed *service.Co
}) })
} }
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。 // resolveMasterURL 按优先级推导 Master URL外部配置 > X-Forwarded-* > Request.Host。
// 此为包级 helper供 install_handler 和 node_handler 共用。 // 此为包级 helper供 install_handler 和 node_handler 共用。
func resolveMasterURL(c *gin.Context, externalURL string) string { func resolveMasterURL(c *gin.Context, externalURL string) string {

View File

@@ -1,6 +1,7 @@
package http package http
import ( import (
"context"
stdhttp "net/http" stdhttp "net/http"
"strings" "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) { return func(c *gin.Context) {
header := strings.TrimSpace(c.GetHeader("Authorization")) rawToken := extractAuthToken(c)
if !strings.HasPrefix(header, "Bearer ") { if rawToken == "" {
response.Error(c, apperror.Unauthorized("AUTH_REQUIRED", "请先登录", nil)) response.Error(c, apperror.Unauthorized("AUTH_REQUIRED", "请先登录", nil))
c.Abort() c.Abort()
return return
} }
if apiKeyAuth != nil && strings.HasPrefix(rawToken, "bax_") {
tokenString := strings.TrimSpace(strings.TrimPrefix(header, "Bearer ")) subject, role, err := apiKeyAuth.Authenticate(c.Request.Context(), rawToken)
claims, err := jwtManager.Parse(tokenString) 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 { if err != nil {
response.Error(c, apperror.Unauthorized("AUTH_INVALID_TOKEN", "登录状态已失效,请重新登录", err)) response.Error(c, apperror.Unauthorized("AUTH_INVALID_TOKEN", "登录状态已失效,请重新登录", err))
c.Abort() c.Abort()
return return
} }
c.Set(contextUserSubjectKey, claims.Subject) c.Set(contextUserSubjectKey, claims.Subject)
c.Set(contextUserRoleKey, claims.Role)
c.Set(contextUsernameKey, claims.Username)
c.Set(contextAuthSubjectKey, "user:"+claims.Subject)
c.Next() 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 { func ClientKey(c *gin.Context) string {
ip := strings.TrimSpace(c.ClientIP()) ip := strings.TrimSpace(c.ClientIP())
if ip == "" { if ip == "" {

View File

@@ -244,14 +244,17 @@ func (h *NodeHandler) CreateInstallToken(c *gin.Context) {
input.TTLSeconds = 900 input.TTLSeconds = 900
} }
out, err := h.installTokenSvc.Create(c.Request.Context(), service.InstallTokenInput{ out, err := h.installTokenSvc.CreateCommand(c.Request.Context(), service.InstallCommandInput{
NodeID: uint(id), InstallTokenInput: service.InstallTokenInput{
Mode: input.Mode, NodeID: uint(id),
Arch: input.Arch, Mode: input.Mode,
AgentVersion: input.AgentVersion, Arch: input.Arch,
DownloadSrc: input.DownloadSrc, AgentVersion: input.AgentVersion,
TTLSeconds: input.TTLSeconds, DownloadSrc: input.DownloadSrc,
CreatedByID: h.resolveCurrentUserID(c), TTLSeconds: input.TTLSeconds,
CreatedByID: h.resolveCurrentUserID(c),
},
MasterURL: resolveMasterURL(c, h.externalURL),
}) })
if err != nil { if err != nil {
response.Error(c, err) response.Error(c, err)
@@ -261,15 +264,19 @@ func (h *NodeHandler) CreateInstallToken(c *gin.Context) {
fmt.Sprintf("%d", id), out.Node.Name, fmt.Sprintf("%d", id), out.Node.Name,
fmt.Sprintf("生成 %s/%s install token TTL=%ds", input.Mode, input.Arch, input.TTLSeconds)) fmt.Sprintf("生成 %s/%s install token TTL=%ds", input.Mode, input.Arch, input.TTLSeconds))
masterURL := resolveMasterURL(c, h.externalURL) // 使用 /api/install/... 而非 /install/... —— 让反向代理的 /api/ 转发规则
// 自动接管,避免 SPA fallback 把请求当成前端路由返回 index.htmlissue #46
// 同时返回 /install/... 备用地址,兼容会剥离 /api 前缀的外层反向代理。
// scriptBase64 让前端可以生成不依赖公开下载路径的嵌入式命令,解决 Lucky 等代理
// 把 /api/install/* 也 fallback 到 index.html 的场景。
body := gin.H{ body := gin.H{
"installToken": out.Token, "installToken": out.Token,
"expiresAt": out.ExpiresAt, "expiresAt": out.ExpiresAt,
"url": masterURL + "/install/" + out.Token, "url": out.URL,
"composeUrl": "", "fallbackUrl": out.FallbackURL,
} "scriptBase64": out.ScriptBase64,
if input.Mode == "docker" { "composeUrl": out.ComposeURL,
body["composeUrl"] = masterURL + "/install/" + out.Token + "/compose.yml" "fallbackComposeUrl": out.FallbackComposeURL,
} }
response.Success(c, body) response.Success(c, body)
} }

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

@@ -7,12 +7,14 @@ import (
"backupx/server/internal/apperror" "backupx/server/internal/apperror"
"backupx/server/internal/config" "backupx/server/internal/config"
"backupx/server/internal/metrics"
"backupx/server/internal/repository" "backupx/server/internal/repository"
"backupx/server/internal/security" "backupx/server/internal/security"
"backupx/server/internal/service" "backupx/server/internal/service"
"backupx/server/pkg/response" "backupx/server/pkg/response"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"go.uber.org/zap" "go.uber.org/zap"
"gorm.io/gorm"
) )
type RouterDependencies struct { type RouterDependencies struct {
@@ -28,6 +30,15 @@ type RouterDependencies struct {
BackupTaskService *service.BackupTaskService BackupTaskService *service.BackupTaskService
BackupExecutionService *service.BackupExecutionService BackupExecutionService *service.BackupExecutionService
BackupRecordService *service.BackupRecordService 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 NotificationService *service.NotificationService
DashboardService *service.DashboardService DashboardService *service.DashboardService
SettingsService *service.SettingsService SettingsService *service.SettingsService
@@ -40,6 +51,10 @@ type RouterDependencies struct {
SystemConfigRepo repository.SystemConfigRepository SystemConfigRepo repository.SystemConfigRepository
InstallTokenService *service.InstallTokenService InstallTokenService *service.InstallTokenService
MasterExternalURL string MasterExternalURL string
// DB 注入给健康检查端点做 liveness/readiness 探测。
DB *gorm.DB
// Metrics 注入给 /metrics 端点;为 nil 时端点返回 503。
Metrics *metrics.Metrics
} }
func NewRouter(deps RouterDependencies) *gin.Engine { func NewRouter(deps RouterDependencies) *gin.Engine {
@@ -54,7 +69,19 @@ func NewRouter(deps RouterDependencies) *gin.Engine {
storageTargetHandler := NewStorageTargetHandler(deps.StorageTargetService, deps.AuditService) storageTargetHandler := NewStorageTargetHandler(deps.StorageTargetService, deps.AuditService)
backupTaskHandler := NewBackupTaskHandler(deps.BackupTaskService, deps.AuditService) backupTaskHandler := NewBackupTaskHandler(deps.BackupTaskService, deps.AuditService)
backupRunHandler := NewBackupRunHandler(deps.BackupExecutionService, 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) notificationHandler := NewNotificationHandler(deps.NotificationService)
dashboardHandler := NewDashboardHandler(deps.DashboardService) dashboardHandler := NewDashboardHandler(deps.DashboardService)
settingsHandler := NewSettingsHandler(deps.SettingsService, deps.AuditService) settingsHandler := NewSettingsHandler(deps.SettingsService, deps.AuditService)
@@ -67,109 +94,221 @@ func NewRouter(deps RouterDependencies) *gin.Engine {
auth.GET("/setup/status", authHandler.SetupStatus) auth.GET("/setup/status", authHandler.SetupStatus)
auth.POST("/setup", authHandler.Setup) auth.POST("/setup", authHandler.Setup)
auth.POST("/login", authHandler.Login) auth.POST("/login", authHandler.Login)
auth.POST("/logout", AuthMiddleware(deps.JWTManager), authHandler.Logout) auth.POST("/otp/send", authHandler.SendLoginOTP)
auth.GET("/profile", AuthMiddleware(deps.JWTManager), authHandler.Profile) auth.POST("/webauthn/login/options", authHandler.BeginWebAuthnLogin)
auth.PUT("/password", AuthMiddleware(deps.JWTManager), authHandler.ChangePassword) 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 := api.Group("/system")
system.Use(AuthMiddleware(deps.JWTManager)) system.Use(AuthMiddleware(deps.JWTManager, apiKeyAuth))
system.GET("/info", systemHandler.Info) system.GET("/info", systemHandler.Info)
system.GET("/update-check", systemHandler.CheckUpdate) system.GET("/update-check", systemHandler.CheckUpdate)
storageTargets := api.Group("/storage-targets") storageTargets := api.Group("/storage-targets")
storageTargets.Use(AuthMiddleware(deps.JWTManager)) storageTargets.Use(AuthMiddleware(deps.JWTManager, apiKeyAuth))
// 静态路由必须在参数路由 /:id 之前注册,避免 Gin 路由冲突 // 静态路由必须在参数路由 /:id 之前注册,避免 Gin 路由冲突
storageTargets.GET("", storageTargetHandler.List) storageTargets.GET("", storageTargetHandler.List)
storageTargets.POST("", storageTargetHandler.Create) storageTargets.POST("", RequireNotViewer(), storageTargetHandler.Create)
storageTargets.POST("/test", storageTargetHandler.TestConnection) storageTargets.POST("/test", RequireNotViewer(), storageTargetHandler.TestConnection)
storageTargets.POST("/google-drive/auth-url", storageTargetHandler.StartGoogleDriveOAuth) storageTargets.POST("/google-drive/auth-url", RequireNotViewer(), storageTargetHandler.StartGoogleDriveOAuth)
storageTargets.POST("/google-drive/complete", storageTargetHandler.CompleteGoogleDriveOAuth) storageTargets.POST("/google-drive/complete", RequireNotViewer(), storageTargetHandler.CompleteGoogleDriveOAuth)
storageTargets.GET("/google-drive/callback", storageTargetHandler.HandleGoogleDriveCallback) storageTargets.GET("/google-drive/callback", storageTargetHandler.HandleGoogleDriveCallback)
rcloneHandler := NewRcloneHandler() rcloneHandler := NewRcloneHandler()
storageTargets.GET("/rclone/backends", rcloneHandler.ListBackends) storageTargets.GET("/rclone/backends", rcloneHandler.ListBackends)
// 参数路由 // 参数路由
storageTargets.GET("/:id", storageTargetHandler.Get) storageTargets.GET("/:id", storageTargetHandler.Get)
storageTargets.PUT("/:id", storageTargetHandler.Update) storageTargets.PUT("/:id", RequireNotViewer(), storageTargetHandler.Update)
storageTargets.DELETE("/:id", storageTargetHandler.Delete) storageTargets.DELETE("/:id", RequireNotViewer(), storageTargetHandler.Delete)
storageTargets.PUT("/:id/star", storageTargetHandler.ToggleStar) storageTargets.PUT("/:id/star", RequireNotViewer(), storageTargetHandler.ToggleStar)
storageTargets.POST("/:id/test", storageTargetHandler.TestSavedConnection) storageTargets.POST("/:id/test", RequireNotViewer(), storageTargetHandler.TestSavedConnection)
storageTargets.GET("/:id/usage", storageTargetHandler.GetUsage) storageTargets.GET("/:id/usage", storageTargetHandler.GetUsage)
storageTargets.GET("/:id/google-drive/profile", storageTargetHandler.GoogleDriveProfile) storageTargets.GET("/:id/google-drive/profile", storageTargetHandler.GoogleDriveProfile)
backupTasks := api.Group("/backup/tasks") backupTasks := api.Group("/backup/tasks")
backupTasks.Use(AuthMiddleware(deps.JWTManager)) backupTasks.Use(AuthMiddleware(deps.JWTManager, apiKeyAuth))
backupTasks.GET("", backupTaskHandler.List) backupTasks.GET("", backupTaskHandler.List)
backupTasks.GET("/tags", backupTaskHandler.ListTags)
backupTasks.GET("/:id", backupTaskHandler.Get) backupTasks.GET("/:id", backupTaskHandler.Get)
backupTasks.POST("", backupTaskHandler.Create) backupTasks.POST("", RequireNotViewer(), backupTaskHandler.Create)
backupTasks.PUT("/:id", backupTaskHandler.Update) backupTasks.PUT("/:id", RequireNotViewer(), backupTaskHandler.Update)
backupTasks.DELETE("/:id", backupTaskHandler.Delete) backupTasks.DELETE("/:id", RequireNotViewer(), backupTaskHandler.Delete)
backupTasks.PUT("/:id/toggle", backupTaskHandler.Toggle) backupTasks.PUT("/:id/toggle", RequireNotViewer(), backupTaskHandler.Toggle)
backupTasks.POST("/:id/run", backupRunHandler.Run) 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 := api.Group("/backup/records")
backupRecords.Use(AuthMiddleware(deps.JWTManager)) backupRecords.Use(AuthMiddleware(deps.JWTManager, apiKeyAuth))
backupRecords.GET("", backupRecordHandler.List) backupRecords.GET("", backupRecordHandler.List)
backupRecords.GET("/:id", backupRecordHandler.Get) backupRecords.GET("/:id", backupRecordHandler.Get)
backupRecords.GET("/:id/logs/stream", backupRecordHandler.StreamLogs) backupRecords.GET("/:id/logs/stream", backupRecordHandler.StreamLogs)
backupRecords.GET("/:id/download", backupRecordHandler.Download) backupRecords.GET("/:id/download", backupRecordHandler.Download)
backupRecords.POST("/:id/restore", backupRecordHandler.Restore) backupRecords.POST("/:id/restore", RequireNotViewer(), backupRecordHandler.Restore)
backupRecords.POST("/batch-delete", backupRecordHandler.BatchDelete) backupRecords.POST("/batch-delete", RequireNotViewer(), backupRecordHandler.BatchDelete)
backupRecords.DELETE("/:id", backupRecordHandler.Delete) 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 := api.Group("/dashboard")
dashboard.Use(AuthMiddleware(deps.JWTManager)) dashboard.Use(AuthMiddleware(deps.JWTManager, apiKeyAuth))
dashboard.GET("/stats", dashboardHandler.Stats) dashboard.GET("/stats", dashboardHandler.Stats)
dashboard.GET("/timeline", dashboardHandler.Timeline) 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 := api.Group("/notifications")
notifications.Use(AuthMiddleware(deps.JWTManager)) notifications.Use(AuthMiddleware(deps.JWTManager, apiKeyAuth))
notifications.GET("", notificationHandler.List) notifications.GET("", notificationHandler.List)
notifications.GET("/:id", notificationHandler.Get) notifications.GET("/:id", notificationHandler.Get)
notifications.POST("", notificationHandler.Create) notifications.POST("", RequireNotViewer(), notificationHandler.Create)
notifications.PUT("/:id", notificationHandler.Update) notifications.PUT("/:id", RequireNotViewer(), notificationHandler.Update)
notifications.DELETE("/:id", notificationHandler.Delete) notifications.DELETE("/:id", RequireNotViewer(), notificationHandler.Delete)
notifications.POST("/test", notificationHandler.Test) notifications.POST("/test", RequireNotViewer(), notificationHandler.Test)
notifications.POST("/:id/test", notificationHandler.TestSaved) notifications.POST("/:id/test", RequireNotViewer(), notificationHandler.TestSaved)
settings := api.Group("/settings") settings := api.Group("/settings")
settings.Use(AuthMiddleware(deps.JWTManager)) settings.Use(AuthMiddleware(deps.JWTManager, apiKeyAuth))
settings.GET("", settingsHandler.Get) 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 := api.Group("/audit-logs")
auditLogs.Use(AuthMiddleware(deps.JWTManager)) auditLogs.Use(AuthMiddleware(deps.JWTManager, apiKeyAuth))
auditLogs.GET("", auditHandler.List) 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 { if deps.DatabaseDiscoveryService != nil {
databaseHandler := NewDatabaseHandler(deps.DatabaseDiscoveryService) databaseHandler := NewDatabaseHandler(deps.DatabaseDiscoveryService)
database := api.Group("/database") database := api.Group("/database")
database.Use(AuthMiddleware(deps.JWTManager)) database.Use(AuthMiddleware(deps.JWTManager, apiKeyAuth))
database.POST("/discover", databaseHandler.Discover) database.POST("/discover", databaseHandler.Discover)
} }
nodeHandler := NewNodeHandler(deps.NodeService, deps.AuditService, deps.InstallTokenService, deps.UserRepository, deps.MasterExternalURL) nodeHandler := NewNodeHandler(deps.NodeService, deps.AuditService, deps.InstallTokenService, deps.UserRepository, deps.MasterExternalURL)
nodes := api.Group("/nodes") nodes := api.Group("/nodes")
nodes.Use(AuthMiddleware(deps.JWTManager)) nodes.Use(AuthMiddleware(deps.JWTManager, apiKeyAuth))
nodes.GET("", nodeHandler.List) nodes.GET("", nodeHandler.List)
nodes.GET("/:id", nodeHandler.Get) nodes.GET("/:id", nodeHandler.Get)
nodes.POST("", nodeHandler.Create) nodes.POST("", RequireRole("admin"), nodeHandler.Create)
nodes.PUT("/:id", nodeHandler.Update) nodes.PUT("/:id", RequireRole("admin"), nodeHandler.Update)
nodes.DELETE("/:id", nodeHandler.Delete) nodes.DELETE("/:id", RequireRole("admin"), nodeHandler.Delete)
nodes.GET("/:id/fs/list", nodeHandler.ListDirectory) nodes.GET("/:id/fs/list", nodeHandler.ListDirectory)
nodes.POST("/batch", nodeHandler.BatchCreate) nodes.POST("/batch", RequireRole("admin"), nodeHandler.BatchCreate)
nodes.POST("/:id/install-tokens", nodeHandler.CreateInstallToken) nodes.POST("/:id/install-tokens", RequireRole("admin"), nodeHandler.CreateInstallToken)
nodes.POST("/:id/rotate-token", nodeHandler.RotateToken) nodes.POST("/:id/rotate-token", RequireRole("admin"), nodeHandler.RotateToken)
nodes.GET("/:id/install-script-preview", nodeHandler.PreviewScript) nodes.GET("/:id/install-script-preview", RequireRole("admin"), nodeHandler.PreviewScript)
// Agent APItoken 认证,无需 JWT // Agent APItoken 认证,无需 JWT
if deps.AgentService != nil { if deps.AgentService != nil {
agentHandler := NewAgentHandler(deps.AgentService, deps.NodeService) agentHandler := NewAgentHandler(deps.AgentService, deps.NodeService, deps.RestoreService)
agent := api.Group("/agent") agent := api.Group("/agent")
agent.POST("/heartbeat", agentHandler.Heartbeat) agent.POST("/heartbeat", agentHandler.Heartbeat)
agent.POST("/commands/poll", agentHandler.Poll) agent.POST("/commands/poll", agentHandler.Poll)
agent.POST("/commands/:id/result", agentHandler.SubmitCommandResult) agent.POST("/commands/:id/result", agentHandler.SubmitCommandResult)
agent.GET("/tasks/:id", agentHandler.GetTaskSpec) agent.GET("/tasks/:id", agentHandler.GetTaskSpec)
agent.POST("/records/:id", agentHandler.UpdateRecord) agent.POST("/records/:id", agentHandler.UpdateRecord)
agent.GET("/restores/:id/spec", agentHandler.GetRestoreSpec)
agent.POST("/restores/:id", agentHandler.UpdateRestore)
// Agent v1安装脚本探活用仅 Self 端点 // Agent v1安装脚本探活用仅 Self 端点
v1Agent := api.Group("/v1/agent") v1Agent := api.Group("/v1/agent")
@@ -180,7 +319,28 @@ func NewRouter(deps RouterDependencies) *gin.Engine {
} }
} }
// 公开安装路由(不走 JWT 中间件 // 健康检查端点(公开、无认证、低开销
// 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 { if deps.InstallTokenService != nil {
gcCtx := deps.Context gcCtx := deps.Context
if gcCtx == nil { if gcCtx == nil {
@@ -189,6 +349,8 @@ func NewRouter(deps RouterDependencies) *gin.Engine {
installHandler := NewInstallHandler(gcCtx, deps.InstallTokenService, deps.AuditService, deps.MasterExternalURL) installHandler := NewInstallHandler(gcCtx, deps.InstallTokenService, deps.AuditService, deps.MasterExternalURL)
engine.GET("/install/:token", installHandler.Script) engine.GET("/install/:token", installHandler.Script)
engine.GET("/install/:token/compose.yml", installHandler.Compose) 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) { engine.NoRoute(func(c *gin.Context) {

View File

@@ -16,50 +16,17 @@ import (
"backupx/server/internal/repository" "backupx/server/internal/repository"
"backupx/server/internal/security" "backupx/server/internal/security"
"backupx/server/internal/service" "backupx/server/internal/service"
"backupx/server/internal/storage/codec"
"github.com/pquerna/otp/totp"
) )
func TestSetupLoginAndProfileFlow(t *testing.T) { func TestSetupLoginAndProfileFlow(t *testing.T) {
tempDir := t.TempDir() router, _ := newTestHTTPRouter(t)
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,
})
setupBody, _ := json.Marshal(map[string]string{ setupBody, _ := json.Marshal(map[string]string{
"username": "admin", "username": "admin",
"password": "password-123", "password": "password-123",
"displayName": "Admin", "displayName": "Admin",
}) })
setupRequest := httptest.NewRequest(http.MethodPost, "/api/auth/setup", bytes.NewBuffer(setupBody)) 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) 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,41 @@
package installscript
import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
func TestDeployInstallScriptSyntax(t *testing.T) {
scriptPath := filepath.Join("..", "..", "..", "deploy", "install.sh")
cmd := exec.Command("sh", "-n", scriptPath)
output, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("install.sh syntax invalid: %v\n%s", err, output)
}
}
func TestDeployInstallScriptSupportsReleasePackageLayout(t *testing.T) {
scriptPath := filepath.Join("..", "..", "..", "deploy", "install.sh")
data, err := os.ReadFile(scriptPath)
if err != nil {
t.Fatal(err)
}
script := string(data)
for _, want := range []string{
`SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)`,
`if [ -f "$SCRIPT_DIR/backupx" ] && [ -d "$SCRIPT_DIR/web" ]; then`,
`BIN_SOURCE="${BIN_SOURCE:-$SCRIPT_DIR/backupx}"`,
`WEB_SOURCE="${WEB_SOURCE:-$SCRIPT_DIR/web}"`,
`CONFIG_TEMPLATE="${CONFIG_TEMPLATE:-$SCRIPT_DIR/config.example.yaml}"`,
`发布包安装请确认当前目录包含 ./backupx、./web 和 ./install.sh。`,
`cat > "/etc/systemd/system/$SERVICE_NAME.service" <<UNIT`,
`if [ -d "/etc/nginx/conf.d" ] && [ -f "$NGINX_SOURCE" ]; then`,
} {
if !strings.Contains(script, want) {
t.Fatalf("install.sh missing %q", want)
}
}
}

View File

@@ -0,0 +1,58 @@
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 TestRenderScriptUsesRootForBareMetalBackups(t *testing.T) {
got, err := RenderScript(testCtx)
if err != nil {
t.Fatalf("render err: %v", err)
}
for _, want := range []string{
"/var/lib/backupx-agent/tmp",
"install -d -m 0700 /var/lib/backupx-agent /var/lib/backupx-agent/tmp",
} {
if !strings.Contains(got, want) {
t.Errorf("script missing %q:\n%s", want, got)
}
}
for _, forbidden := range []string{"User=backupx", "Group=backupx", "NoNewPrivileges=true"} {
if strings.Contains(got, forbidden) {
t.Errorf("script should not contain %q for bare-metal backups:\n%s", forbidden, got)
}
}
}

View File

@@ -27,8 +27,10 @@ func TestRenderScriptSystemd(t *testing.T) {
mustContain := []string{ mustContain := []string{
"BACKUPX_AGENT_MASTER=${MASTER_URL}", "BACKUPX_AGENT_MASTER=${MASTER_URL}",
`Environment="BACKUPX_AGENT_TOKEN=${AGENT_TOKEN}"`, `Environment="BACKUPX_AGENT_TOKEN=${AGENT_TOKEN}"`,
"/var/lib/backupx-agent/tmp",
"systemctl daemon-reload", "systemctl daemon-reload",
"systemctl enable --now backupx-agent", "systemctl enable --now backupx-agent",
"systemctl status backupx-agent",
"X-Agent-Token: ${AGENT_TOKEN}", "X-Agent-Token: ${AGENT_TOKEN}",
"MASTER_URL=\"https://master.example.com\"", "MASTER_URL=\"https://master.example.com\"",
"AGENT_TOKEN=\"deadbeefcafebabe0123456789abcdef0123456789abcdef0123456789abcdef\"", "AGENT_TOKEN=\"deadbeefcafebabe0123456789abcdef0123456789abcdef0123456789abcdef\"",
@@ -56,6 +58,9 @@ func TestRenderScriptForeground(t *testing.T) {
if !strings.Contains(got, `exec "${INSTALL_PREFIX}/backupx" agent`) { if !strings.Contains(got, `exec "${INSTALL_PREFIX}/backupx" agent`) {
t.Errorf("foreground script missing exec line:\n%s", got) t.Errorf("foreground script missing exec line:\n%s", got)
} }
if !strings.Contains(got, "/var/lib/backupx-agent/tmp") {
t.Errorf("foreground script missing dedicated temp dir:\n%s", got)
}
if strings.Contains(got, "systemctl daemon-reload") { if strings.Contains(got, "systemctl daemon-reload") {
t.Errorf("foreground script should not reference systemctl:\n%s", got) t.Errorf("foreground script should not reference systemctl:\n%s", got)
} }
@@ -74,6 +79,9 @@ func TestRenderScriptDocker(t *testing.T) {
if !strings.Contains(got, "docker run") { if !strings.Contains(got, "docker run") {
t.Errorf("docker script missing `docker run`:\n%s", got) t.Errorf("docker script missing `docker run`:\n%s", got)
} }
if !strings.Contains(got, "/var/lib/backupx-agent:/var/lib/backupx-agent") {
t.Errorf("docker script missing agent data volume:\n%s", got)
}
if !strings.Contains(got, "awuqing/backupx:${AGENT_VERSION}") { if !strings.Contains(got, "awuqing/backupx:${AGENT_VERSION}") {
t.Errorf("docker script missing image tag reference:\n%s", got) t.Errorf("docker script missing image tag reference:\n%s", got)
} }
@@ -95,14 +103,17 @@ func TestRenderComposeYaml(t *testing.T) {
if !strings.Contains(got, `BACKUPX_AGENT_TOKEN: "deadbeefcafebabe0123456789abcdef0123456789abcdef0123456789abcdef"`) { if !strings.Contains(got, `BACKUPX_AGENT_TOKEN: "deadbeefcafebabe0123456789abcdef0123456789abcdef0123456789abcdef"`) {
t.Errorf("compose missing token env:\n%s", got) t.Errorf("compose missing token env:\n%s", got)
} }
if !strings.Contains(got, "/var/lib/backupx-agent:/var/lib/backupx-agent") {
t.Errorf("compose missing agent data volume:\n%s", got)
}
} }
func TestRenderScriptRejectsInjectedMasterURL(t *testing.T) { func TestRenderScriptRejectsInjectedMasterURL(t *testing.T) {
bad := []string{ bad := []string{
"https://example.com\" other: inject", // 含引号和空格 "https://example.com\" other: inject", // 含引号和空格
"javascript:alert(1)", // scheme 非法 "javascript:alert(1)", // scheme 非法
"https://example.com\n- privileged", // 含换行YAML 注入经典 payload "https://example.com\n- privileged", // 含换行YAML 注入经典 payload
"", // 空 "", // 空
} }
for _, u := range bad { for _, u := range bad {
ctx := testCtx ctx := testCtx
@@ -161,8 +172,8 @@ func TestDownloadBaseMapping(t *testing.T) {
func TestRenderScriptDefaultsApplied(t *testing.T) { func TestRenderScriptDefaultsApplied(t *testing.T) {
ctx := testCtx ctx := testCtx
ctx.InstallPrefix = "" // 应被默认为 /opt/backupx-agent ctx.InstallPrefix = "" // 应被默认为 /opt/backupx-agent
ctx.DownloadBase = "" // 应被默认为 github ctx.DownloadBase = "" // 应被默认为 github
got, err := RenderScript(ctx) got, err := RenderScript(ctx)
if err != nil { if err != nil {
t.Fatalf("render err: %v", err) t.Fatalf("render err: %v", err)

View File

@@ -10,4 +10,4 @@ services:
BACKUPX_AGENT_MASTER: "{{.MasterURL}}" BACKUPX_AGENT_MASTER: "{{.MasterURL}}"
BACKUPX_AGENT_TOKEN: "{{.AgentToken}}" BACKUPX_AGENT_TOKEN: "{{.AgentToken}}"
volumes: volumes:
- /var/lib/backupx-agent:/tmp/backupx-agent - /var/lib/backupx-agent:/var/lib/backupx-agent

View File

@@ -1,8 +1,16 @@
#!/bin/sh #!/bin/sh
# BackupX Agent 一键安装脚本(由 Master 动态渲染) # BackupX Agent 一键安装脚本(由 Master 动态渲染)
# Magic: BACKUPX_AGENT_INSTALL_V1 —— 若 `head -3 脚本` 看不到此行,说明反向代理/CDN 改写了响应
# 模式: {{.Mode}} | 架构: {{.Arch}} | 版本: {{.AgentVersion}} # 模式: {{.Mode}} | 架构: {{.Arch}} | 版本: {{.AgentVersion}}
set -eu 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}}" MASTER_URL="{{.MasterURL}}"
AGENT_TOKEN="{{.AgentToken}}" AGENT_TOKEN="{{.AgentToken}}"
AGENT_VERSION="{{.AgentVersion}}" AGENT_VERSION="{{.AgentVersion}}"
@@ -39,10 +47,10 @@ else
fi fi
tar xzf "$TMPDIR/pkg.tar.gz" -C "$TMPDIR" tar xzf "$TMPDIR/pkg.tar.gz" -C "$TMPDIR"
# 4. 安装二进制 + 用户 # 4. 安装二进制 + 数据目录
echo "[2/4] 安装到 ${INSTALL_PREFIX}" echo "[2/4] 安装到 ${INSTALL_PREFIX}"
id backupx >/dev/null 2>&1 || useradd --system --home-dir "$INSTALL_PREFIX" --shell /usr/sbin/nologin backupx install -d -m 0755 "$INSTALL_PREFIX"
install -d -o backupx -g backupx "$INSTALL_PREFIX" /var/lib/backupx-agent install -d -m 0700 /var/lib/backupx-agent /var/lib/backupx-agent/tmp
install -m 0755 "$TMPDIR/backupx-${AGENT_VERSION}-linux-${ARCH}/backupx" "$INSTALL_PREFIX/backupx" install -m 0755 "$TMPDIR/backupx-${AGENT_VERSION}-linux-${ARCH}/backupx" "$INSTALL_PREFIX/backupx"
{{end}} {{end}}
@@ -57,13 +65,11 @@ Wants=network-online.target
[Service] [Service]
Type=simple Type=simple
User=backupx
Environment="BACKUPX_AGENT_MASTER=${MASTER_URL}" Environment="BACKUPX_AGENT_MASTER=${MASTER_URL}"
Environment="BACKUPX_AGENT_TOKEN=${AGENT_TOKEN}" Environment="BACKUPX_AGENT_TOKEN=${AGENT_TOKEN}"
ExecStart=${INSTALL_PREFIX}/backupx agent --temp-dir /var/lib/backupx-agent ExecStart=${INSTALL_PREFIX}/backupx agent --temp-dir /var/lib/backupx-agent/tmp
Restart=on-failure Restart=on-failure
RestartSec=10s RestartSec=10s
NoNewPrivileges=true
[Install] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target
@@ -82,6 +88,7 @@ for i in $(seq 1 15); do
fi fi
done done
echo "⚠ 30s 内未收到上线心跳,请检查防火墙或 journalctl -u backupx-agent" echo "⚠ 30s 内未收到上线心跳,请检查防火墙或 journalctl -u backupx-agent"
echo "提示systemd 服务名是 backupx-agent可执行 systemctl status backupx-agent 查看状态。"
exit 2 exit 2
{{end}} {{end}}
@@ -90,7 +97,7 @@ exit 2
echo "[3/3] 前台启动 agentCtrl+C 退出)" echo "[3/3] 前台启动 agentCtrl+C 退出)"
export BACKUPX_AGENT_MASTER="${MASTER_URL}" export BACKUPX_AGENT_MASTER="${MASTER_URL}"
export BACKUPX_AGENT_TOKEN="${AGENT_TOKEN}" export BACKUPX_AGENT_TOKEN="${AGENT_TOKEN}"
exec "${INSTALL_PREFIX}/backupx" agent --temp-dir /var/lib/backupx-agent exec "${INSTALL_PREFIX}/backupx" agent --temp-dir /var/lib/backupx-agent/tmp
{{end}} {{end}}
{{if eq .Mode "docker"}} {{if eq .Mode "docker"}}
@@ -102,7 +109,7 @@ docker rm -f backupx-agent >/dev/null 2>&1 || true
docker run -d --name backupx-agent --restart=unless-stopped \ docker run -d --name backupx-agent --restart=unless-stopped \
-e "BACKUPX_AGENT_MASTER=${MASTER_URL}" \ -e "BACKUPX_AGENT_MASTER=${MASTER_URL}" \
-e "BACKUPX_AGENT_TOKEN=${AGENT_TOKEN}" \ -e "BACKUPX_AGENT_TOKEN=${AGENT_TOKEN}" \
-v /var/lib/backupx-agent:/tmp/backupx-agent \ -v /var/lib/backupx-agent:/var/lib/backupx-agent \
"awuqing/backupx:${AGENT_VERSION}" agent "awuqing/backupx:${AGENT_VERSION}" agent
echo "✓ 容器已启动" echo "✓ 容器已启动"
{{end}} {{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"} // Payload: {"path": "/var/log"}
// Result: {"entries": [{"name":"...", "path":"...", "isDir":true, "size":0}]} // Result: {"entries": [{"name":"...", "path":"...", "isDir":true, "size":0}]}
AgentCommandTypeListDir = "list_dir" 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 节点的待执行命令。 // AgentCommand 代表 Master 发给某个 Agent 节点的待执行命令。

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"` Task BackupTask `json:"task,omitempty"`
StorageTargetID uint `gorm:"column:storage_target_id;index;not null" json:"storageTargetId"` StorageTargetID uint `gorm:"column:storage_target_id;index;not null" json:"storageTargetId"`
StorageTarget StorageTarget `json:"storageTarget,omitempty"` 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"` Status string `gorm:"size:20;index;not null" json:"status"`
FileName string `gorm:"column:file_name;size:255" json:"fileName"` FileName string `gorm:"column:file_name;size:255" json:"fileName"`
FileSize int64 `gorm:"column:file_size;not null;default:0" json:"fileSize"` 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"` StorageTargets []StorageTarget `gorm:"many2many:backup_task_storage_targets" json:"storageTargets,omitempty"`
NodeID uint `gorm:"column:node_id;index;default:0" json:"nodeId"` NodeID uint `gorm:"column:node_id;index;default:0" json:"nodeId"`
Node Node `json:"node,omitempty"` 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"` Tags string `gorm:"column:tags;size:500" json:"tags"`
RetentionDays int `gorm:"column:retention_days;not null;default:30" json:"retentionDays"` RetentionDays int `gorm:"column:retention_days;not null;default:30" json:"retentionDays"`
Compression string `gorm:"size:10;not null;default:'gzip'" json:"compression"` 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"` MaxBackups int `gorm:"column:max_backups;not null;default:10" json:"maxBackups"`
LastRunAt *time.Time `gorm:"column:last_run_at" json:"lastRunAt,omitempty"` 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"` 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"` CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"` UpdatedAt time.Time `json:"updatedAt"`
} }

View File

@@ -1,6 +1,9 @@
package model package model
import "time" import (
"strings"
"time"
)
const ( const (
NodeStatusOnline = "online" NodeStatusOnline = "online"
@@ -23,8 +26,48 @@ type Node struct {
LastSeen time.Time `gorm:"column:last_seen" json:"lastSeen"` LastSeen time.Time `gorm:"column:last_seen" json:"lastSeen"`
PrevToken string `gorm:"size:128;index" json:"-"` PrevToken string `gorm:"size:128;index" json:"-"`
PrevTokenExpires *time.Time `gorm:"column:prev_token_expires" json:"-"` PrevTokenExpires *time.Time `gorm:"column:prev_token_expires" json:"-"`
CreatedAt time.Time `json:"createdAt"` // MaxConcurrent 该节点允许的最大并发任务数0=不限制,沿用全局 cfg.Backup.MaxConcurrent
UpdatedAt time.Time `json:"updatedAt"` // 用于大集群中限制单节点资源占用:例如小内存 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 { func (Node) TableName() string {

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" 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 { type Notification struct {
ID uint `gorm:"primaryKey" json:"id"` ID uint `gorm:"primaryKey" json:"id"`
Type string `gorm:"size:20;index;not null" json:"type"` 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"` Enabled bool `gorm:"not null;default:true" json:"enabled"`
OnSuccess bool `gorm:"column:on_success;not null;default:false" json:"onSuccess"` OnSuccess bool `gorm:"column:on_success;not null;default:false" json:"onSuccess"`
OnFailure bool `gorm:"column:on_failure;not null;default:true" json:"onFailure"` OnFailure bool `gorm:"column:on_failure;not null;default:true" json:"onFailure"`
CreatedAt time.Time `json:"createdAt"` // EventTypes 逗号分隔,订阅的事件类型。
UpdatedAt time.Time `json:"updatedAt"` // 空 = 仅监听备份成功/失败(兼容旧配置);非空则严格按订阅触发。
EventTypes string `gorm:"column:event_types;size:500" json:"eventTypes"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
} }
func (Notification) TableName() string { 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"` 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"` 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"` LastTestMessage string `gorm:"column:last_test_message;size:512" json:"lastTestMessage"`
CreatedAt time.Time `json:"createdAt"` // QuotaBytes 软限额字节。0 = 不限制。
UpdatedAt time.Time `json:"updatedAt"` // 备份执行前检查:该目标上已累计字节数 + 本次文件大小 > 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 { 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" 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 { type User struct {
ID uint `gorm:"primaryKey" json:"id"` ID uint `gorm:"primaryKey" json:"id"`
Username string `gorm:"size:64;uniqueIndex;not null" json:"username"` Username string `gorm:"size:64;uniqueIndex;not null" json:"username"`
PasswordHash string `gorm:"column:password_hash;not null" json:"-"` PasswordHash string `gorm:"column:password_hash;not null" json:"-"`
DisplayName string `gorm:"size:128;not null" json:"displayName"` DisplayName string `gorm:"size:128;not null" json:"displayName"`
Email string `gorm:"size:255" json:"email"` Email string `gorm:"size:255" json:"email"`
Role string `gorm:"size:32;not null;default:admin" json:"role"` Phone string `gorm:"size:64" json:"phone"`
CreatedAt time.Time `json:"createdAt"` Role string `gorm:"size:32;not null;default:admin" json:"role"`
UpdatedAt time.Time `json:"updatedAt"` // 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 { 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

@@ -17,8 +17,24 @@ type AgentCommandRepository interface {
// 并返回领取到的命令。无命令时返回 (nil, nil)。 // 并返回领取到的命令。无命令时返回 (nil, nil)。
ClaimPending(ctx context.Context, nodeID uint) (*model.AgentCommand, error) ClaimPending(ctx context.Context, nodeID uint) (*model.AgentCommand, error)
Update(ctx context.Context, cmd *model.AgentCommand) error Update(ctx context.Context, cmd *model.AgentCommand) error
// CompleteDispatched 只在命令仍处于 dispatched 时写入终态。
// 返回 false 表示命令已被超时监控或其它流程终结,调用方不应覆盖。
CompleteDispatched(ctx context.Context, cmd *model.AgentCommand) (bool, error)
// MarkStaleTimeout 把 dispatched 状态但超时未完成的命令标记为 timeout。 // MarkStaleTimeout 把 dispatched 状态但超时未完成的命令标记为 timeout。
// 返回被标记的行数。不返回具体命令(供背景监控简单调用)。
MarkStaleTimeout(ctx context.Context, threshold time.Time) (int64, error) MarkStaleTimeout(ctx context.Context, threshold time.Time) (int64, error)
// TimeoutActive 只在命令仍处于 pending/dispatched 时写入 timeout。
// 返回 false 表示命令已被 Agent 回写为终态,调用方不应覆盖。
TimeoutActive(ctx context.Context, cmd *model.AgentCommand) (bool, error)
// ListStaleDispatched 列出 dispatched 但已超时、尚未被标记的命令。
// 调用方需要把它们逐一标记 timeout 并联动关联记录状态。
ListStaleDispatched(ctx context.Context, threshold time.Time) ([]model.AgentCommand, error)
// ListStaleActive 列出 pending/dispatched 但已超时、尚未完成的命令。
// pending 使用 created_at 判定dispatched 使用 dispatched_at 判定。
ListStaleActive(ctx context.Context, threshold time.Time) ([]model.AgentCommand, error)
// ListPendingByNode 列出某节点下的所有 pending/dispatched 命令。
// 用于删除节点或节点离线时的清理。
ListPendingByNode(ctx context.Context, nodeID uint) ([]model.AgentCommand, error)
} }
type GormAgentCommandRepository struct { type GormAgentCommandRepository struct {
@@ -87,6 +103,21 @@ func (r *GormAgentCommandRepository) Update(ctx context.Context, cmd *model.Agen
return r.db.WithContext(ctx).Save(cmd).Error return r.db.WithContext(ctx).Save(cmd).Error
} }
func (r *GormAgentCommandRepository) CompleteDispatched(ctx context.Context, cmd *model.AgentCommand) (bool, error) {
result := r.db.WithContext(ctx).Model(&model.AgentCommand{}).
Where("id = ? AND node_id = ? AND status = ?", cmd.ID, cmd.NodeID, model.AgentCommandStatusDispatched).
Updates(map[string]any{
"status": cmd.Status,
"error_message": cmd.ErrorMessage,
"result": cmd.Result,
"completed_at": cmd.CompletedAt,
})
if result.Error != nil {
return false, result.Error
}
return result.RowsAffected > 0, nil
}
func (r *GormAgentCommandRepository) MarkStaleTimeout(ctx context.Context, threshold time.Time) (int64, error) { func (r *GormAgentCommandRepository) MarkStaleTimeout(ctx context.Context, threshold time.Time) (int64, error) {
result := r.db.WithContext(ctx).Model(&model.AgentCommand{}). result := r.db.WithContext(ctx).Model(&model.AgentCommand{}).
Where("status = ? AND dispatched_at < ?", model.AgentCommandStatusDispatched, threshold). Where("status = ? AND dispatched_at < ?", model.AgentCommandStatusDispatched, threshold).
@@ -99,3 +130,59 @@ func (r *GormAgentCommandRepository) MarkStaleTimeout(ctx context.Context, thres
} }
return result.RowsAffected, nil return result.RowsAffected, nil
} }
func (r *GormAgentCommandRepository) TimeoutActive(ctx context.Context, cmd *model.AgentCommand) (bool, error) {
result := r.db.WithContext(ctx).Model(&model.AgentCommand{}).
Where("id = ? AND status IN ?", cmd.ID, []string{model.AgentCommandStatusPending, model.AgentCommandStatusDispatched}).
Updates(map[string]any{
"status": model.AgentCommandStatusTimeout,
"error_message": cmd.ErrorMessage,
"completed_at": cmd.CompletedAt,
})
if result.Error != nil {
return false, result.Error
}
return result.RowsAffected > 0, 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
}
func (r *GormAgentCommandRepository) ListStaleActive(ctx context.Context, threshold time.Time) ([]model.AgentCommand, error) {
var items []model.AgentCommand
if err := r.db.WithContext(ctx).
Where(
"(status = ? AND created_at < ?) OR (status = ? AND dispatched_at < ?)",
model.AgentCommandStatusPending, threshold,
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

@@ -90,6 +90,78 @@ func TestAgentCommandRepository_Update(t *testing.T) {
} }
} }
func TestAgentCommandRepository_CompleteDispatchedOnlyUpdatesDispatchedCommand(t *testing.T) {
db := newTestDB(t)
repo := NewAgentCommandRepository(db)
ctx := context.Background()
dispatched := &model.AgentCommand{NodeID: 1, Type: "run_task", Status: model.AgentCommandStatusDispatched}
timeout := &model.AgentCommand{NodeID: 1, Type: "run_task", Status: model.AgentCommandStatusTimeout, ErrorMessage: "timeout"}
if err := repo.Create(ctx, dispatched); err != nil {
t.Fatalf("Create dispatched returned error: %v", err)
}
if err := repo.Create(ctx, timeout); err != nil {
t.Fatalf("Create timeout returned error: %v", err)
}
now := time.Now().UTC()
dispatched.Status = model.AgentCommandStatusSucceeded
dispatched.Result = `{"ok":true}`
dispatched.CompletedAt = &now
updated, err := repo.CompleteDispatched(ctx, dispatched)
if err != nil {
t.Fatalf("CompleteDispatched returned error: %v", err)
}
if !updated {
t.Fatal("expected dispatched command to be updated")
}
timeout.Status = model.AgentCommandStatusSucceeded
timeout.Result = `{"late":true}`
timeout.CompletedAt = &now
updated, err = repo.CompleteDispatched(ctx, timeout)
if err != nil {
t.Fatalf("CompleteDispatched terminal returned error: %v", err)
}
if updated {
t.Fatal("expected terminal command not to be updated")
}
gotTimeout, err := repo.FindByID(ctx, timeout.ID)
if err != nil {
t.Fatalf("FindByID timeout returned error: %v", err)
}
if gotTimeout.Status != model.AgentCommandStatusTimeout || gotTimeout.Result != "" {
t.Fatalf("expected timeout command unchanged, got %#v", gotTimeout)
}
}
func TestAgentCommandRepository_TimeoutActiveDoesNotOverwriteTerminalCommand(t *testing.T) {
db := newTestDB(t)
repo := NewAgentCommandRepository(db)
ctx := context.Background()
succeeded := &model.AgentCommand{NodeID: 1, Type: "run_task", Status: model.AgentCommandStatusSucceeded, Result: `{"ok":true}`}
if err := repo.Create(ctx, succeeded); err != nil {
t.Fatalf("Create succeeded returned error: %v", err)
}
now := time.Now().UTC()
succeeded.ErrorMessage = "timeout"
succeeded.CompletedAt = &now
updated, err := repo.TimeoutActive(ctx, succeeded)
if err != nil {
t.Fatalf("TimeoutActive returned error: %v", err)
}
if updated {
t.Fatal("expected terminal command not to be timed out")
}
got, err := repo.FindByID(ctx, succeeded.ID)
if err != nil {
t.Fatalf("FindByID returned error: %v", err)
}
if got.Status != model.AgentCommandStatusSucceeded || got.ErrorMessage != "" || got.Result != `{"ok":true}` {
t.Fatalf("expected succeeded command unchanged, got %#v", got)
}
}
func TestAgentCommandRepository_MarkStaleTimeout(t *testing.T) { func TestAgentCommandRepository_MarkStaleTimeout(t *testing.T) {
db := newTestDB(t) db := newTestDB(t)
repo := NewAgentCommandRepository(db) repo := NewAgentCommandRepository(db)
@@ -118,3 +190,31 @@ func TestAgentCommandRepository_MarkStaleTimeout(t *testing.T) {
t.Errorf("new should stay dispatched: %+v", newGot) t.Errorf("new should stay dispatched: %+v", newGot)
} }
} }
func TestAgentCommandRepository_ListStaleActiveIncludesPendingAndDispatched(t *testing.T) {
db := newTestDB(t)
repo := NewAgentCommandRepository(db)
ctx := context.Background()
old := time.Now().Add(-time.Hour)
recent := time.Now()
oldPending := &model.AgentCommand{NodeID: 1, Type: "run_task", Status: model.AgentCommandStatusPending, CreatedAt: old}
oldDispatched := &model.AgentCommand{NodeID: 1, Type: "restore_record", Status: model.AgentCommandStatusDispatched, DispatchedAt: &old}
recentPending := &model.AgentCommand{NodeID: 1, Type: "run_task", Status: model.AgentCommandStatusPending, CreatedAt: recent}
succeeded := &model.AgentCommand{NodeID: 1, Type: "run_task", Status: model.AgentCommandStatusSucceeded, CreatedAt: old}
for _, cmd := range []*model.AgentCommand{oldPending, oldDispatched, recentPending, succeeded} {
if err := repo.Create(ctx, cmd); err != nil {
t.Fatalf("Create returned error: %v", err)
}
}
items, err := repo.ListStaleActive(ctx, time.Now().Add(-30*time.Minute))
if err != nil {
t.Fatalf("ListStaleActive returned error: %v", err)
}
if len(items) != 2 {
t.Fatalf("expected 2 stale active commands, got %#v", items)
}
if items[0].ID != oldPending.ID || items[1].ID != oldDispatched.ID {
t.Fatalf("unexpected stale active order/items: %#v", items)
}
}

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