Compare commits

...

7 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
116 changed files with 9216 additions and 687 deletions

View File

@@ -110,13 +110,21 @@ jobs:
cp -r web/dist "${ARCHIVE_NAME}/web"
cp server/config.example.yaml "${ARCHIVE_NAME}/"
cp deploy/install.sh "${ARCHIVE_NAME}/" 2>/dev/null || true
# v2.2+: 随发布包提供 Grafana dashboard 与 nginx.conf 模板
if [ -d deploy/grafana ]; then
cp -r deploy/grafana "${ARCHIVE_NAME}/grafana"
fi
cp deploy/nginx.conf "${ARCHIVE_NAME}/nginx.conf" 2>/dev/null || true
tar czf "${ARCHIVE_NAME}.tar.gz" "${ARCHIVE_NAME}"
cp "${ARCHIVE_NAME}.tar.gz" "backupx-${{ matrix.goos }}-${{ matrix.goarch }}.tar.gz"
- name: Upload to GitHub Release
uses: softprops/action-gh-release@v2
with:
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
# ─── Job 3: Docker 多架构 → Docker Hub ───

View File

@@ -62,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
```
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).
## Documentation

View File

@@ -62,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
```
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) 完成首次备份。
## 文档

View File

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

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

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

View File

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

View File

@@ -1,17 +1,25 @@
#!/bin/sh
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}"
ETC_DIR="${ETC_DIR:-/etc/backupx}"
SERVICE_NAME="backupx"
APP_USER="backupx"
APP_GROUP="backupx"
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}"
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}"
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}"
NGINX_SOURCE="${NGINX_SOURCE:-$PROJECT_ROOT/deploy/nginx.conf}"
if [ "$(id -u)" -ne 0 ]; then
echo "请使用 root 或 sudo 执行安装脚本。" >&2
@@ -20,13 +28,20 @@ fi
if [ ! -f "$BIN_SOURCE" ]; then
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
fi
if [ ! -d "$WEB_SOURCE" ]; then
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
fi
@@ -47,11 +62,34 @@ if [ ! -f "$ETC_DIR/config.yaml" ]; then
install -m 0640 "$CONFIG_TEMPLATE" "$ETC_DIR/config.yaml"
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 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"
if command -v nginx >/dev/null 2>&1; then
nginx -t

View File

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

View File

@@ -15,13 +15,14 @@ server:
host: "0.0.0.0" # BACKUPX_SERVER_HOST
port: 8340 # BACKUPX_SERVER_PORT
mode: "release" # release | debug
external_url: "" # BACKUPX_SERVER_EXTERNAL_URL — public Master URL for Agent install scripts
database:
path: "./data/backupx.db" # BACKUPX_DATABASE_PATH — embedded SQLite
security:
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
backup:
@@ -46,7 +47,20 @@ The environment wins when both file and env are set. All dot-paths become unders
| Config key | Env variable |
|------------|--------------|
| `server.port` | `BACKUPX_SERVER_PORT` |
| `server.external_url` | `BACKUPX_SERVER_EXTERNAL_URL` |
| `security.jwt_expire` | `BACKUPX_SECURITY_JWT_EXPIRE` |
| `log.level` | `BACKUPX_LOG_LEVEL` |
| `backup.max_concurrent` | `BACKUPX_BACKUP_MAX_CONCURRENT` |
| `backup.temp_dir` | `BACKUPX_BACKUP_TEMP_DIR` |
| `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
environment:
- 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_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.
## 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
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
### 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
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 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
Example (systemd mode):
```bash
curl -fsSL https://master.example.com/install/Xk3p9...vM | sudo sh
```
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.
The script runs automatically and:
@@ -53,6 +62,8 @@ The script runs automatically and:
5. Runs `systemctl enable --now backupx-agent`
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.
### 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/
const config: Config = {
title: 'BackupX',
tagline: 'Self-hosted server backup management — one binary, one command',
tagline: 'Self-hosted backup orchestration for servers, databases, storage targets and remote agents',
favicon: 'img/favicon.ico',
future: {
@@ -76,6 +76,16 @@ const config: Config = {
label: 'Downloads',
position: 'left',
},
{
to: '/community',
label: 'Community',
position: 'left',
},
{
to: '/sponsors',
label: 'Sponsors',
position: 'left',
},
{
type: 'localeDropdown',
position: 'right',
@@ -115,6 +125,22 @@ const config: Config = {
{label: 'Issues', href: 'https://github.com/Awuqing/BackupX/issues'},
],
},
{
title: 'Community',
items: [
{label: 'Contributors', href: 'https://github.com/Awuqing/BackupX/graphs/contributors'},
{label: 'Pull Requests', href: 'https://github.com/Awuqing/BackupX/pulls'},
{label: 'Sponsor', to: '/sponsors'},
],
},
{
title: 'Sponsors',
items: [
{label: 'Sponsor BackupX', href: 'https://github.com/sponsors/Awuqing'},
{label: 'Partnership', href: 'https://github.com/Awuqing/BackupX/issues/new/choose'},
{label: 'Sponsor tiers', to: '/sponsors'},
],
},
],
copyright: `Copyright © ${new Date().getFullYear()} BackupX · Apache License 2.0`,
},

View File

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

View File

@@ -25,6 +25,19 @@ sudo ./install.sh
4. 安装并启用 `backupx.service` systemd 单元
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

View File

@@ -15,13 +15,14 @@ server:
host: "0.0.0.0" # BACKUPX_SERVER_HOST
port: 8340 # BACKUPX_SERVER_PORT
mode: "release" # release | debug
external_url: "" # BACKUPX_SERVER_EXTERNAL_URL — Agent 安装脚本使用的 Master 对外 URL
database:
path: "./data/backupx.db" # BACKUPX_DATABASE_PATH — 内嵌 SQLite
security:
jwt_secret: "" # BACKUPX_SECURITY_JWT_SECRET — 留空自动生成
jwt_expires_in: "24h"
jwt_expire: "24h" # BACKUPX_SECURITY_JWT_EXPIRE
encryption_key: "" # 用于加密存储配置的 AES-256-GCM 密钥
backup:
@@ -46,7 +47,20 @@ log:
| 配置项 | 环境变量 |
|--------|----------|
| `server.port` | `BACKUPX_SERVER_PORT` |
| `server.external_url` | `BACKUPX_SERVER_EXTERNAL_URL` |
| `security.jwt_expire` | `BACKUPX_SECURITY_JWT_EXPIRE` |
| `log.level` | `BACKUPX_LOG_LEVEL` |
| `backup.max_concurrent` | `BACKUPX_BACKUP_MAX_CONCURRENT` |
| `backup.temp_dir` | `BACKUPX_BACKUP_TEMP_DIR` |
| `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
environment:
- TZ=Asia/Shanghai
# 远程 Agent 需要通过公网或可路由地址连接 Master 时必须配置:
# - BACKUPX_SERVER_EXTERNAL_URL=https://backup.example.com
- BACKUPX_LOG_LEVEL=info
- BACKUPX_BACKUP_MAX_CONCURRENT=2
@@ -42,6 +44,17 @@ docker compose up -d
想备份宿主机上的文件,需要将对应路径挂载进容器。在 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_` 前缀环境变量覆盖:

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. 打开安装向导
Web 控制台 → **节点管理** → **添加节点**,打开三步向导:
- **第一步 · 节点信息**:填写节点名称;或切换"批量创建"粘贴多行名称(每行一个,最多 50 个)
- **第二步 · 部署参数**:选择安装模式(`systemd` 推荐、`Docker`、`前台运行` 调试用、架构默认自动检测、Agent 版本(默认跟随 Master 版本、有效期5 分钟 / 15 分钟 / 1 小时 / 24 小时)、下载源(`GitHub` 直连或 `ghproxy` 镜像,国内服务器建议后者)
- **第三步 · 安装命令**:一`curl ... | sudo sh` 命令 + 实时倒计时。点击复制,粘贴到目标机以 root 权限执行
- **第三步 · 安装命令**:一条一键安装命令 + 实时倒计时。点击复制,粘贴到目标机以 root 权限执行。默认命令会嵌入已渲染的安装脚本,目标机无需再通过反向代理访问 `/api/install/:token`;公开安装 URL 仍作为备用路径保留。
### 2. 目标机一条命令完成
示例systemd 模式):
```bash
curl -fsSL https://master.example.com/install/Xk3p9...vM | sudo sh
```
请直接使用 Web 控制台生成的命令。该命令会把安装脚本写入临时文件,校验 `BACKUPX_AGENT_INSTALL_V1` 魔数,再以 root 权限执行。
脚本会自动:
@@ -53,6 +62,8 @@ curl -fsSL https://master.example.com/install/Xk3p9...vM | sudo sh
5. 执行 `systemctl enable --now backupx-agent`
6. 轮询 `/api/v1/agent/self`,直到 Master 确认 `status: online`(最多 30 秒)
如果使用 URL 备用命令时 `curl` 输出 HTML或 shell 报 `Syntax error: newline unexpected`,说明安装 URL 被 Web 控制台接管而不是转发到后端。需要确保 `/api/install/` 或 `/install/` 至少一个路径能转发到 BackupX 后端,或改用控制台生成的嵌入式命令。
脚本是幂等的:升级或重装只需重新生成一条安装命令再跑一次。一次性安装链接在 TTL 到期或被首次消费后立即作废。
### 3. 随时轮换 Agent Token

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -50,7 +50,7 @@ func TestLoadConfigDefaults(t *testing.T) {
if cfg.HeartbeatInterval != "15s" || cfg.PollInterval != "5s" {
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)
}
}

View File

@@ -4,6 +4,7 @@ import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"os"
@@ -19,10 +20,10 @@ import (
// Executor 负责在 Agent 本地执行命令。
type Executor struct {
client *MasterClient
tempDir string
backupRegistry *backup.Registry
storageRegistry *storage.Registry
client *MasterClient
tempDir string
backupRegistry *backup.Registry
storageRegistry *storage.Registry
}
// NewExecutor 构造执行器。预先初始化 backup runner 与 storage registry。
@@ -59,6 +60,11 @@ func NewExecutor(client *MasterClient, tempDir string) *Executor {
// 注意Agent 当前不支持 Encrypt=true加密密钥不下发到 Agent避免密钥扩散
// 遇到启用加密的任务会向 Master 上报失败并返回错误。
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) 拉取任务规格
spec, err := e.client.GetTaskSpec(ctx, taskID)
if err != nil {
@@ -74,10 +80,6 @@ func (e *Executor) ExecuteRunTask(ctx context.Context, taskID, recordID uint) er
// 2) 构造 backup.TaskSpec 并找对应 runner
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)
runner, err := e.backupRegistry.Runner(backupSpec.Type)
if err != nil {
@@ -184,22 +186,8 @@ func (e *Executor) reportRecordFailure(ctx context.Context, recordID uint, msg s
// buildBackupTaskSpec 把 AgentTaskSpec 转换为 backup.TaskSpec。
func buildBackupTaskSpec(spec *TaskSpec, startedAt time.Time, tempDir string) backup.TaskSpec {
var sourcePaths []string
if strings.TrimSpace(spec.SourcePaths) != "" {
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)
}
}
}
sourcePaths := parseStringListField(spec.SourcePaths)
excludes := parseStringListField(spec.ExcludePatterns)
return backup.TaskSpec{
ID: spec.TaskID,
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 记录。
// 实现 backup.LogWriter每条日志追加到 record.log_content。
type recordLogger struct {
@@ -240,8 +259,8 @@ func (l *recordLogger) WriteLine(message string) {
// restoreLogger 把 runner 日志回传到 Master 恢复记录。
type restoreLogger struct {
ctx context.Context
client *MasterClient
ctx context.Context
client *MasterClient
restoreID uint
}
@@ -270,6 +289,11 @@ func (e *Executor) DeleteStorageObject(ctx context.Context, targetType string, t
// - 执行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))
@@ -282,10 +306,6 @@ func (e *Executor) ExecuteRestore(ctx context.Context, restoreRecordID uint) err
}
e.appendRestoreLog(ctx, restoreRecordID, fmt.Sprintf("[agent] 开始恢复 %s (type=%s)\n", spec.TaskName, spec.Type))
if err := os.MkdirAll(e.tempDir, 0o755); err != nil {
e.reportRestoreFailure(ctx, restoreRecordID, fmt.Sprintf("创建临时目录失败: %v", err))
return err
}
tmpDir, err := os.MkdirTemp(e.tempDir, "restore-*")
if err != nil {
e.reportRestoreFailure(ctx, restoreRecordID, fmt.Sprintf("创建恢复临时目录失败: %v", err))

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"
"path/filepath"
"sort"
"strings"
)
// DirEntry Agent 返回给 Master 的目录项。
@@ -17,8 +18,8 @@ type DirEntry struct {
// listLocalDir 列出 Agent 所在机器的指定路径。
func listLocalDir(path string) ([]DirEntry, error) {
cleaned := filepath.Clean(path)
if cleaned == "" {
cleaned := filepath.Clean(strings.TrimSpace(path))
if strings.TrimSpace(path) == "" || cleaned == "." {
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) {
cases := []struct {
in string

View File

@@ -60,9 +60,9 @@ func New(ctx context.Context, cfg config.Config, version string) (*Application,
jwtManager := security.NewJWTManager(resolvedSecurity.JWTSecret, config.MustJWTDuration(cfg.Security))
rateLimiter := security.NewLoginRateLimiter(5, time.Minute)
authService := service.NewAuthService(userRepo, systemConfigRepo, jwtManager, rateLimiter)
systemService := service.NewSystemService(cfg, version, time.Now().UTC())
configCipher := codec.NewConfigCipher(resolvedSecurity.EncryptionKey)
authService := service.NewAuthService(userRepo, systemConfigRepo, jwtManager, rateLimiter, configCipher)
systemService := service.NewSystemService(cfg, version, time.Now().UTC())
storageRegistry := storage.NewRegistry(
storageRclone.NewLocalDiskFactory(),
storageRclone.NewS3Factory(),
@@ -87,6 +87,7 @@ func New(ctx context.Context, cfg config.Config, version string) (*Application,
retentionService := backupretention.NewService(backupRecordRepo)
notifyRegistry := notify.NewRegistry(notify.NewEmailNotifier(), notify.NewWebhookNotifier(), notify.NewTelegramNotifier())
notificationService := service.NewNotificationService(notificationRepo, notifyRegistry, configCipher)
authService.SetNotificationService(notificationService)
// 初始化 rclone 传输配置(重试 + 带宽限制)
rcloneCtx := storageRclone.ConfiguredContext(ctx, storageRclone.TransferConfig{
LowLevelRetries: cfg.Backup.Retries,
@@ -245,37 +246,37 @@ func New(ctx context.Context, cfg config.Config, version string) (*Application,
metricsCollector.Start(ctx)
router := aphttp.NewRouter(aphttp.RouterDependencies{
Context: ctx,
Config: cfg,
Version: version,
Logger: appLogger,
AuthService: authService,
SystemService: systemService,
StorageTargetService: storageTargetService,
BackupTaskService: backupTaskService,
BackupExecutionService: backupExecutionService,
BackupRecordService: backupRecordService,
RestoreService: restoreService,
VerificationService: verificationService,
ReplicationService: replicationService,
TaskTemplateService: taskTemplateService,
TaskExportService: taskExportService,
SearchService: searchService,
EventBroadcaster: eventBroadcaster,
UserService: userService,
ApiKeyService: apiKeyService,
NotificationService: notificationService,
DashboardService: dashboardService,
SettingsService: settingsService,
Context: ctx,
Config: cfg,
Version: version,
Logger: appLogger,
AuthService: authService,
SystemService: systemService,
StorageTargetService: storageTargetService,
BackupTaskService: backupTaskService,
BackupExecutionService: backupExecutionService,
BackupRecordService: backupRecordService,
RestoreService: restoreService,
VerificationService: verificationService,
ReplicationService: replicationService,
TaskTemplateService: taskTemplateService,
TaskExportService: taskExportService,
SearchService: searchService,
EventBroadcaster: eventBroadcaster,
UserService: userService,
ApiKeyService: apiKeyService,
NotificationService: notificationService,
DashboardService: dashboardService,
SettingsService: settingsService,
NodeService: nodeService,
AgentService: agentService,
DatabaseDiscoveryService: databaseDiscoveryService,
AuditService: auditService,
AuditService: auditService,
JWTManager: jwtManager,
UserRepository: userRepo,
SystemConfigRepo: systemConfigRepo,
InstallTokenService: installTokenService,
MasterExternalURL: "", // 如需覆盖 URL可扩展 cfg.Server 增字段;目前留空依赖 X-Forwarded-* / Request.Host
MasterExternalURL: cfg.Server.ExternalURL,
DB: db,
Metrics: appMetrics,
})

View File

@@ -24,6 +24,9 @@ func (r *fakeRecordRepository) List(context.Context, repository.BackupRecordList
func (r *fakeRecordRepository) FindByID(context.Context, uint) (*model.BackupRecord, error) {
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) Update(context.Context, *model.BackupRecord) error { return nil }
func (r *fakeRecordRepository) Delete(_ context.Context, id uint) error {

View File

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

View File

@@ -1,6 +1,10 @@
package config
import "testing"
import (
"os"
"path/filepath"
"testing"
)
func TestLoadUsesDefaultsWithoutConfigFile(t *testing.T) {
cfg, err := Load("")
@@ -18,3 +22,33 @@ func TestLoadUsesDefaultsWithoutConfigFile(t *testing.T) {
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

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

View File

@@ -3,6 +3,7 @@ package http
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"net/http"
"net/http/httptest"
@@ -18,15 +19,20 @@ import (
"backupx/server/internal/repository"
"backupx/server/internal/security"
"backupx/server/internal/service"
"backupx/server/internal/storage/codec"
)
// setupInstallFlowRouter 构造一个 Node + Agent + InstallToken 全量依赖的 router
// 并返回已登录管理员 JWT。
func setupInstallFlowRouter(t *testing.T) (http.Handler, string) {
return setupInstallFlowRouterWithExternalURL(t, "")
}
func setupInstallFlowRouterWithExternalURL(t *testing.T, externalURL string) (http.Handler, string) {
t.Helper()
tempDir := t.TempDir()
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")},
Security: config.SecurityConfig{JWTExpire: "24h"},
Log: config.LogConfig{Level: "error"},
@@ -39,6 +45,13 @@ func setupInstallFlowRouter(t *testing.T) (http.Handler, string) {
if err != nil {
t.Fatalf("db: %v", err)
}
sqlDB, err := db.DB()
if err != nil {
t.Fatalf("sql db: %v", err)
}
t.Cleanup(func() {
_ = sqlDB.Close()
})
userRepo := repository.NewUserRepository(db)
systemConfigRepo := repository.NewSystemConfigRepository(db)
@@ -47,7 +60,7 @@ func setupInstallFlowRouter(t *testing.T) (http.Handler, string) {
t.Fatalf("security: %v", err)
}
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())
nodeRepo := repository.NewNodeRepository(db)
@@ -59,9 +72,6 @@ func setupInstallFlowRouter(t *testing.T) (http.Handler, string) {
installTokenRepo := repository.NewAgentInstallTokenRepository(db)
installTokenSvc := service.NewInstallTokenService(installTokenRepo, nodeRepo)
auditLogRepo := repository.NewAuditLogRepository(db)
auditSvc := service.NewAuditService(auditLogRepo)
// 用 cancelable ctx测试结束时停掉 handler 启动的后台 GC 协程,
// 避免 goroutine 持有 map 导致 tempdir 清理失败。
ctx, cancel := context.WithCancel(context.Background())
@@ -76,7 +86,7 @@ func setupInstallFlowRouter(t *testing.T) (http.Handler, string) {
SystemService: systemSvc,
NodeService: nodeSvc,
InstallTokenService: installTokenSvc,
AuditService: auditSvc,
MasterExternalURL: cfg.Server.ExternalURL,
JWTManager: jwtMgr,
UserRepository: userRepo,
SystemConfigRepo: systemConfigRepo,
@@ -105,6 +115,73 @@ func setupInstallFlowRouter(t *testing.T) (http.Handler, string) {
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) {
router, jwt := setupInstallFlowRouter(t)
@@ -153,6 +230,8 @@ func TestOneClickInstallFlow(t *testing.T) {
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 {
@@ -161,6 +240,16 @@ func TestOneClickInstallFlow(t *testing.T) {
if genResp.Data.InstallToken == "" {
t.Fatalf("missing installToken")
}
if !strings.Contains(genResp.Data.FallbackURL, "/install/") {
t.Fatalf("missing fallback install URL, got %q", genResp.Data.FallbackURL)
}
decodedScript, err := base64.StdEncoding.DecodeString(genResp.Data.ScriptBase64)
if err != nil {
t.Fatalf("scriptBase64 should be valid base64: %v", err)
}
if !strings.Contains(string(decodedScript), "BACKUPX_AGENT_INSTALL_V1") {
t.Fatalf("scriptBase64 should contain rendered install script")
}
// 3. 公开端点消费
scriptReq := httptest.NewRequest(http.MethodGet, "/install/"+genResp.Data.InstallToken, nil)
@@ -241,6 +330,8 @@ func TestInstallScriptAliasUnderAPI(t *testing.T) {
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)
@@ -249,6 +340,12 @@ func TestInstallScriptAliasUnderAPI(t *testing.T) {
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)
@@ -399,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
func formatUint(u uint) string {
if u == 0 {

View File

@@ -59,16 +59,7 @@ func (h *InstallHandler) Script(c *gin.Context) {
return
}
h.recordConsumeAudit(c, consumed, "script")
script, err := installscript.RenderScript(installscript.Context{
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,
})
script, err := renderInstallScript(resolveMasterURL(c, h.externalURL), consumed.Node, consumed.Record)
if err != nil {
c.String(stdhttp.StatusInternalServerError, "render error\n")
return
@@ -141,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。
// 此为包级 helper供 install_handler 和 node_handler 共用。
func resolveMasterURL(c *gin.Context, externalURL string) string {

View File

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

View File

@@ -94,9 +94,22 @@ func NewRouter(deps RouterDependencies) *gin.Engine {
auth.GET("/setup/status", authHandler.SetupStatus)
auth.POST("/setup", authHandler.Setup)
auth.POST("/login", authHandler.Login)
auth.POST("/otp/send", authHandler.SendLoginOTP)
auth.POST("/webauthn/login/options", authHandler.BeginWebAuthnLogin)
auth.POST("/logout", AuthMiddleware(deps.JWTManager, apiKeyAuth), authHandler.Logout)
auth.GET("/profile", AuthMiddleware(deps.JWTManager, apiKeyAuth), authHandler.Profile)
auth.PUT("/password", AuthMiddleware(deps.JWTManager, apiKeyAuth), authHandler.ChangePassword)
auth.POST("/2fa/setup", AuthMiddleware(deps.JWTManager, apiKeyAuth), authHandler.PrepareTwoFactor)
auth.POST("/2fa/enable", AuthMiddleware(deps.JWTManager, apiKeyAuth), authHandler.EnableTwoFactor)
auth.POST("/2fa/recovery-codes", AuthMiddleware(deps.JWTManager, apiKeyAuth), authHandler.RegenerateRecoveryCodes)
auth.DELETE("/2fa", AuthMiddleware(deps.JWTManager, apiKeyAuth), authHandler.DisableTwoFactor)
auth.PUT("/otp/config", AuthMiddleware(deps.JWTManager, apiKeyAuth), authHandler.ConfigureOTP)
auth.POST("/webauthn/register/options", AuthMiddleware(deps.JWTManager, apiKeyAuth), authHandler.BeginWebAuthnRegistration)
auth.POST("/webauthn/register/finish", AuthMiddleware(deps.JWTManager, apiKeyAuth), authHandler.FinishWebAuthnRegistration)
auth.GET("/webauthn/credentials", AuthMiddleware(deps.JWTManager, apiKeyAuth), authHandler.ListWebAuthnCredentials)
auth.DELETE("/webauthn/credentials/:id", AuthMiddleware(deps.JWTManager, apiKeyAuth), authHandler.DeleteWebAuthnCredential)
auth.GET("/trusted-devices", AuthMiddleware(deps.JWTManager, apiKeyAuth), authHandler.ListTrustedDevices)
auth.DELETE("/trusted-devices/:id", AuthMiddleware(deps.JWTManager, apiKeyAuth), authHandler.RevokeTrustedDevice)
}
system := api.Group("/system")
@@ -229,6 +242,7 @@ func NewRouter(deps RouterDependencies) *gin.Engine {
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)
}
@@ -279,10 +293,10 @@ func NewRouter(deps RouterDependencies) *gin.Engine {
nodes.PUT("/:id", RequireRole("admin"), nodeHandler.Update)
nodes.DELETE("/:id", RequireRole("admin"), nodeHandler.Delete)
nodes.GET("/:id/fs/list", nodeHandler.ListDirectory)
nodes.POST("/batch", RequireRole("admin"), nodeHandler.BatchCreate)
nodes.POST("/:id/install-tokens", RequireRole("admin"), nodeHandler.CreateInstallToken)
nodes.POST("/:id/rotate-token", RequireRole("admin"), nodeHandler.RotateToken)
nodes.GET("/:id/install-script-preview", RequireRole("admin"), nodeHandler.PreviewScript)
nodes.POST("/batch", RequireRole("admin"), nodeHandler.BatchCreate)
nodes.POST("/:id/install-tokens", RequireRole("admin"), nodeHandler.CreateInstallToken)
nodes.POST("/:id/rotate-token", RequireRole("admin"), nodeHandler.RotateToken)
nodes.GET("/:id/install-script-preview", RequireRole("admin"), nodeHandler.PreviewScript)
// Agent APItoken 认证,无需 JWT
if deps.AgentService != nil {

View File

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

View File

@@ -78,3 +78,18 @@ func (h *UserHandler) Delete(c *gin.Context) {
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,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

@@ -36,3 +36,23 @@ func TestRenderScriptBashBootstrap(t *testing.T) {
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{
"BACKUPX_AGENT_MASTER=${MASTER_URL}",
`Environment="BACKUPX_AGENT_TOKEN=${AGENT_TOKEN}"`,
"/var/lib/backupx-agent/tmp",
"systemctl daemon-reload",
"systemctl enable --now backupx-agent",
"systemctl status backupx-agent",
"X-Agent-Token: ${AGENT_TOKEN}",
"MASTER_URL=\"https://master.example.com\"",
"AGENT_TOKEN=\"deadbeefcafebabe0123456789abcdef0123456789abcdef0123456789abcdef\"",
@@ -56,6 +58,9 @@ func TestRenderScriptForeground(t *testing.T) {
if !strings.Contains(got, `exec "${INSTALL_PREFIX}/backupx" agent`) {
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") {
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") {
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}") {
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"`) {
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) {
bad := []string{
"https://example.com\" other: inject", // 含引号和空格
"javascript:alert(1)", // scheme 非法
"https://example.com\n- privileged", // 含换行YAML 注入经典 payload
"", // 空
"javascript:alert(1)", // scheme 非法
"https://example.com\n- privileged", // 含换行YAML 注入经典 payload
"", // 空
}
for _, u := range bad {
ctx := testCtx
@@ -161,8 +172,8 @@ func TestDownloadBaseMapping(t *testing.T) {
func TestRenderScriptDefaultsApplied(t *testing.T) {
ctx := testCtx
ctx.InstallPrefix = "" // 应被默认为 /opt/backupx-agent
ctx.DownloadBase = "" // 应被默认为 github
ctx.InstallPrefix = "" // 应被默认为 /opt/backupx-agent
ctx.DownloadBase = "" // 应被默认为 github
got, err := RenderScript(ctx)
if err != nil {
t.Fatalf("render err: %v", err)

View File

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

View File

@@ -47,10 +47,10 @@ else
fi
tar xzf "$TMPDIR/pkg.tar.gz" -C "$TMPDIR"
# 4. 安装二进制 + 用户
# 4. 安装二进制 + 数据目录
echo "[2/4] 安装到 ${INSTALL_PREFIX}"
id backupx >/dev/null 2>&1 || useradd --system --home-dir "$INSTALL_PREFIX" --shell /usr/sbin/nologin backupx
install -d -o backupx -g backupx "$INSTALL_PREFIX" /var/lib/backupx-agent
install -d -m 0755 "$INSTALL_PREFIX"
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"
{{end}}
@@ -65,13 +65,11 @@ Wants=network-online.target
[Service]
Type=simple
User=backupx
Environment="BACKUPX_AGENT_MASTER=${MASTER_URL}"
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
RestartSec=10s
NoNewPrivileges=true
[Install]
WantedBy=multi-user.target
@@ -90,6 +88,7 @@ for i in $(seq 1 15); do
fi
done
echo "⚠ 30s 内未收到上线心跳,请检查防火墙或 journalctl -u backupx-agent"
echo "提示systemd 服务名是 backupx-agent可执行 systemctl status backupx-agent 查看状态。"
exit 2
{{end}}
@@ -98,7 +97,7 @@ exit 2
echo "[3/3] 前台启动 agentCtrl+C 退出)"
export BACKUPX_AGENT_MASTER="${MASTER_URL}"
export BACKUPX_AGENT_TOKEN="${AGENT_TOKEN}"
exec "${INSTALL_PREFIX}/backupx" agent --temp-dir /var/lib/backupx-agent
exec "${INSTALL_PREFIX}/backupx" agent --temp-dir /var/lib/backupx-agent/tmp
{{end}}
{{if eq .Mode "docker"}}
@@ -110,7 +109,7 @@ docker rm -f backupx-agent >/dev/null 2>&1 || true
docker run -d --name backupx-agent --restart=unless-stopped \
-e "BACKUPX_AGENT_MASTER=${MASTER_URL}" \
-e "BACKUPX_AGENT_TOKEN=${AGENT_TOKEN}" \
-v /var/lib/backupx-agent:/tmp/backupx-agent \
-v /var/lib/backupx-agent:/var/lib/backupx-agent \
"awuqing/backupx:${AGENT_VERSION}" agent
echo "✓ 容器已启动"
{{end}}

View File

@@ -39,6 +39,10 @@ type BackupTask struct {
StorageTargets []StorageTarget `gorm:"many2many:backup_task_storage_targets" json:"storageTargets,omitempty"`
NodeID uint `gorm:"column:node_id;index;default:0" json:"nodeId"`
Node Node `json:"node,omitempty"`
// NodePoolTag 节点池标签(可选)。非空且 NodeID=0 时,调度器会从 Node.Labels 包含该 tag
// 的在线节点中动态挑选一台执行(按运行中任务数最少原则),失败会 best-effort 切换到下一个候选。
// 典型场景NodePoolTag="db" 让 MySQL 备份任务在任意标有 "db" 的数据库节点执行。
NodePoolTag string `gorm:"column:node_pool_tag;size:64;index" json:"nodePoolTag"`
Tags string `gorm:"column:tags;size:500" json:"tags"`
RetentionDays int `gorm:"column:retention_days;not null;default:30" json:"retentionDays"`
Compression string `gorm:"size:10;not null;default:'gzip'" json:"compression"`

View File

@@ -1,6 +1,9 @@
package model
import "time"
import (
"strings"
"time"
)
const (
NodeStatusOnline = "online"
@@ -29,8 +32,42 @@ type Node struct {
// BandwidthLimit 该节点上传带宽上限rclone 可识别格式10M / 1G / 0=不限)。
// 对集群感知的上传场景有效Master 本地与 Agent 运行时均会应用)。
BandwidthLimit string `gorm:"column:bandwidth_limit;size:32" json:"bandwidthLimit"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
// 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 {

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

@@ -22,12 +22,25 @@ func IsValidRole(role string) bool {
}
type User struct {
ID uint `gorm:"primaryKey" json:"id"`
Username string `gorm:"size:64;uniqueIndex;not null" json:"username"`
PasswordHash string `gorm:"column:password_hash;not null" json:"-"`
DisplayName string `gorm:"size:128;not null" json:"displayName"`
Email string `gorm:"size:255" json:"email"`
Role string `gorm:"size:32;not null;default:admin" json:"role"`
ID uint `gorm:"primaryKey" json:"id"`
Username string `gorm:"size:64;uniqueIndex;not null" json:"username"`
PasswordHash string `gorm:"column:password_hash;not null" json:"-"`
DisplayName string `gorm:"size:128;not null" json:"displayName"`
Email string `gorm:"size:255" json:"email"`
Phone string `gorm:"size:64" json:"phone"`
Role string `gorm:"size:32;not null;default:admin" json:"role"`
// TwoFactorSecretCiphertext 保存 TOTP 密钥密文;未启用时可作为待确认密钥。
TwoFactorEnabled bool `gorm:"column:two_factor_enabled;not null;default:false" json:"twoFactorEnabled"`
TwoFactorSecretCiphertext string `gorm:"column:two_factor_secret_ciphertext;type:text" json:"-"`
// TwoFactorRecoveryCodeHashes 保存一次性恢复码哈希的 JSON 数组。
TwoFactorRecoveryCodeHashes string `gorm:"column:two_factor_recovery_code_hashes;type:text" json:"-"`
// WebAuthnCredentials 保存通行密钥公钥元数据 JSON不包含私钥或明文密钥。
WebAuthnCredentials string `gorm:"column:webauthn_credentials;type:text" json:"-"`
WebAuthnChallengeCiphertext string `gorm:"column:webauthn_challenge_ciphertext;type:text" json:"-"`
TrustedDevices string `gorm:"column:trusted_devices;type:text" json:"-"`
EmailOTPEnabled bool `gorm:"column:email_otp_enabled;not null;default:false" json:"emailOtpEnabled"`
SMSOTPEnabled bool `gorm:"column:sms_otp_enabled;not null;default:false" json:"smsOtpEnabled"`
OutOfBandOTPCiphertext string `gorm:"column:out_of_band_otp_ciphertext;type:text" json:"-"`
// Disabled 禁用账号(不删除保留审计)。禁用后无法登录。
Disabled bool `gorm:"not null;default:false" json:"disabled"`
CreatedAt time.Time `json:"createdAt"`

View File

@@ -17,12 +17,21 @@ type AgentCommandRepository interface {
// 并返回领取到的命令。无命令时返回 (nil, nil)。
ClaimPending(ctx context.Context, nodeID uint) (*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(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)
@@ -94,6 +103,21 @@ func (r *GormAgentCommandRepository) Update(ctx context.Context, cmd *model.Agen
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) {
result := r.db.WithContext(ctx).Model(&model.AgentCommand{}).
Where("status = ? AND dispatched_at < ?", model.AgentCommandStatusDispatched, threshold).
@@ -107,6 +131,20 @@ func (r *GormAgentCommandRepository) MarkStaleTimeout(ctx context.Context, thres
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
@@ -119,6 +157,21 @@ func (r *GormAgentCommandRepository) ListStaleDispatched(ctx context.Context, th
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

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) {
db := newTestDB(t)
repo := NewAgentCommandRepository(db)
@@ -118,3 +190,31 @@ func TestAgentCommandRepository_MarkStaleTimeout(t *testing.T) {
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)
}
}

View File

@@ -3,6 +3,7 @@ package repository
import (
"context"
"path/filepath"
"sync"
"testing"
"time"
@@ -83,6 +84,59 @@ func TestInstallTokenConsumeExpired(t *testing.T) {
}
}
func TestInstallTokenConsumeConcurrentOnlyOneWins(t *testing.T) {
db := openTestInstallTokenDB(t)
repo := NewAgentInstallTokenRepository(db)
ctx := context.Background()
tok := &model.AgentInstallToken{
Token: "concurrent", NodeID: 1, Mode: model.InstallModeSystemd,
Arch: model.InstallArchAuto, AgentVer: "v1.7.0",
DownloadSrc: model.InstallSourceGitHub,
ExpiresAt: time.Now().UTC().Add(15 * time.Minute),
CreatedByID: 1,
}
if err := repo.Create(ctx, tok); err != nil {
t.Fatalf("create: %v", err)
}
const workers = 8
var wg sync.WaitGroup
start := make(chan struct{})
results := make(chan *model.AgentInstallToken, workers)
errs := make(chan error, workers)
for i := 0; i < workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
<-start
got, err := repo.ConsumeByToken(ctx, "concurrent")
if err != nil {
errs <- err
return
}
results <- got
}()
}
close(start)
wg.Wait()
close(results)
close(errs)
for err := range errs {
t.Fatalf("consume err: %v", err)
}
success := 0
for got := range results {
if got != nil {
success++
}
}
if success != 1 {
t.Fatalf("expected exactly one successful consume, got %d", success)
}
}
func TestInstallTokenGC(t *testing.T) {
db := openTestInstallTokenDB(t)
repo := NewAgentInstallTokenRepository(db)

View File

@@ -33,6 +33,7 @@ type BackupStorageUsageItem struct {
type BackupRecordRepository interface {
List(context.Context, BackupRecordListOptions) ([]model.BackupRecord, error)
FindByID(context.Context, uint) (*model.BackupRecord, error)
FindRunningByTaskAndNode(context.Context, uint, uint) (*model.BackupRecord, error)
Create(context.Context, *model.BackupRecord) error
Update(context.Context, *model.BackupRecord) error
Delete(context.Context, uint) error
@@ -93,6 +94,20 @@ func (r *GormBackupRecordRepository) FindByID(ctx context.Context, id uint) (*mo
return &item, nil
}
func (r *GormBackupRecordRepository) FindRunningByTaskAndNode(ctx context.Context, taskID uint, nodeID uint) (*model.BackupRecord, error) {
var item model.BackupRecord
if err := r.db.WithContext(ctx).
Where("task_id = ? AND node_id = ? AND status = ?", taskID, nodeID, model.BackupRecordStatusRunning).
Order("id desc").
First(&item).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &item, nil
}
func (r *GormBackupRecordRepository) Create(ctx context.Context, item *model.BackupRecord) error {
return r.db.WithContext(ctx).Create(item).Error
}

View File

@@ -226,7 +226,7 @@ func (r *GormBackupTaskRepository) Create(ctx context.Context, item *model.Backu
}
func (r *GormBackupTaskRepository) Update(ctx context.Context, item *model.BackupTask) error {
if err := r.db.WithContext(ctx).Save(item).Error; err != nil {
if err := r.db.WithContext(ctx).Omit("StorageTarget", "StorageTargets", "Node").Save(item).Error; err != nil {
return err
}
if len(item.StorageTargets) > 0 {

View File

@@ -92,3 +92,49 @@ func TestBackupTaskRepositoryCRUD(t *testing.T) {
t.Fatalf("expected task deleted, got %#v", deleted)
}
}
func TestBackupTaskRepositoryUpdateCanClearNodeIDAfterPreload(t *testing.T) {
ctx := context.Background()
repo := newBackupTaskTestRepository(t)
remoteNode := &model.Node{Name: "edge-1", Token: "edge-token", Status: model.NodeStatusOnline, IsLocal: false}
if err := repo.db.WithContext(ctx).Create(remoteNode).Error; err != nil {
t.Fatalf("create node: %v", err)
}
task := &model.BackupTask{
Name: "pooled-source",
Type: "file",
Enabled: true,
SourcePath: "/srv/www/site",
StorageTargetID: 1,
NodeID: remoteNode.ID,
RetentionDays: 30,
Compression: "gzip",
MaxBackups: 10,
LastStatus: "idle",
}
if err := repo.Create(ctx, task); err != nil {
t.Fatalf("Create returned error: %v", err)
}
loaded, err := repo.FindByID(ctx, task.ID)
if err != nil {
t.Fatalf("FindByID returned error: %v", err)
}
if loaded == nil || loaded.Node.ID != remoteNode.ID {
t.Fatalf("expected preloaded node %d, got %#v", remoteNode.ID, loaded)
}
loaded.NodeID = 0
loaded.NodePoolTag = "db"
if err := repo.Update(ctx, loaded); err != nil {
t.Fatalf("Update returned error: %v", err)
}
stored, err := repo.FindByID(ctx, task.ID)
if err != nil {
t.Fatalf("FindByID after update returned error: %v", err)
}
if stored.NodeID != 0 {
t.Fatalf("expected NodeID to be cleared, got %d", stored.NodeID)
}
if stored.NodePoolTag != "db" {
t.Fatalf("expected NodePoolTag db, got %q", stored.NodePoolTag)
}
}

View File

@@ -45,7 +45,7 @@ type Service struct {
func NewService(tasks repository.BackupTaskRepository, runner TaskRunner, logger *zap.Logger) *Service {
parser := cron.NewParser(cron.SecondOptional | cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow | cron.Descriptor)
return &Service{
cron: cron.New(cron.WithParser(parser), cron.WithLocation(time.UTC)),
cron: cron.New(cron.WithParser(parser), cron.WithLocation(time.Local)),
tasks: tasks,
runner: runner,
logger: logger,

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,68 @@
package security
import (
"bytes"
"encoding/base64"
"image/png"
"strings"
"time"
"unicode"
"github.com/pquerna/otp"
"github.com/pquerna/otp/totp"
)
const TOTPIssuer = "BackupX"
type TOTPEnrollment struct {
Secret string
OTPAuthURL string
QRCodeDataURL string
}
func GenerateTOTPEnrollment(accountName string) (*TOTPEnrollment, error) {
key, err := totp.Generate(totp.GenerateOpts{
Issuer: TOTPIssuer,
AccountName: accountName,
Period: 30,
SecretSize: 20,
Digits: otp.DigitsSix,
Algorithm: otp.AlgorithmSHA1,
})
if err != nil {
return nil, err
}
image, err := key.Image(220, 220)
if err != nil {
return nil, err
}
var buf bytes.Buffer
if err := png.Encode(&buf, image); err != nil {
return nil, err
}
return &TOTPEnrollment{
Secret: key.Secret(),
OTPAuthURL: key.URL(),
QRCodeDataURL: "data:image/png;base64," + base64.StdEncoding.EncodeToString(buf.Bytes()),
}, nil
}
func ValidateTOTPCode(secret string, code string) (bool, error) {
return totp.ValidateCustom(NormalizeTOTPCode(code), secret, time.Now().UTC(), totp.ValidateOpts{
Period: 30,
Skew: 1,
Digits: otp.DigitsSix,
Algorithm: otp.AlgorithmSHA1,
})
}
func NormalizeTOTPCode(code string) string {
return strings.Map(func(r rune) rune {
if unicode.IsSpace(r) {
return -1
}
return r
}, strings.TrimSpace(code))
}

View File

@@ -0,0 +1,447 @@
package security
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/binary"
"encoding/json"
"errors"
"fmt"
"math/big"
"strings"
)
const (
WebAuthnChallengeBytes = 32
)
type WebAuthnCredentialMaterial struct {
CredentialID string
PublicKeyX string
PublicKeyY string
SignCount uint32
}
type WebAuthnParsedCredential struct {
CredentialID string
PublicKeyX string
PublicKeyY string
SignCount uint32
}
type WebAuthnClientData struct {
Type string `json:"type"`
Challenge string `json:"challenge"`
Origin string `json:"origin"`
}
type WebAuthnAttestationResponse struct {
ClientDataJSON string `json:"clientDataJSON"`
AttestationObject string `json:"attestationObject"`
}
type WebAuthnRegistrationResponse struct {
ID string `json:"id"`
RawID string `json:"rawId"`
Type string `json:"type"`
Response WebAuthnAttestationResponse `json:"response"`
}
type WebAuthnAssertionResponse struct {
ClientDataJSON string `json:"clientDataJSON"`
AuthenticatorData string `json:"authenticatorData"`
Signature string `json:"signature"`
UserHandle string `json:"userHandle,omitempty"`
}
type WebAuthnLoginAssertion struct {
ID string `json:"id"`
RawID string `json:"rawId"`
Type string `json:"type"`
Response WebAuthnAssertionResponse `json:"response"`
}
func GenerateWebAuthnChallenge() (string, error) {
buf := make([]byte, WebAuthnChallengeBytes)
if _, err := rand.Read(buf); err != nil {
return "", err
}
return EncodeBase64URL(buf), nil
}
func EncodeBase64URL(data []byte) string {
return base64.RawURLEncoding.EncodeToString(data)
}
func DecodeBase64URL(value string) ([]byte, error) {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return nil, errors.New("empty base64url value")
}
if decoded, err := base64.RawURLEncoding.DecodeString(trimmed); err == nil {
return decoded, nil
}
return base64.URLEncoding.DecodeString(trimmed)
}
func VerifyWebAuthnRegistration(input WebAuthnRegistrationResponse, challenge string, rpID string, expectedOrigin string) (*WebAuthnParsedCredential, error) {
if input.Type != "public-key" {
return nil, fmt.Errorf("unexpected credential type: %s", input.Type)
}
clientDataRaw, err := DecodeBase64URL(input.Response.ClientDataJSON)
if err != nil {
return nil, fmt.Errorf("decode client data: %w", err)
}
if err := validateWebAuthnClientData(clientDataRaw, "webauthn.create", challenge, expectedOrigin); err != nil {
return nil, err
}
attestationObject, err := DecodeBase64URL(input.Response.AttestationObject)
if err != nil {
return nil, fmt.Errorf("decode attestation object: %w", err)
}
parsed, err := parseCBORExact(attestationObject)
if err != nil {
return nil, fmt.Errorf("parse attestation object: %w", err)
}
attestationMap, ok := parsed.(map[any]any)
if !ok {
return nil, errors.New("attestation object is not a map")
}
authData, ok := attestationMap["authData"].([]byte)
if !ok {
return nil, errors.New("attestation authData is missing")
}
credential, err := parseAttestedCredentialData(authData, rpID)
if err != nil {
return nil, err
}
rawID := strings.TrimSpace(input.RawID)
if rawID == "" {
rawID = strings.TrimSpace(input.ID)
}
if rawID != "" && rawID != credential.CredentialID {
return nil, errors.New("credential raw id does not match attested credential id")
}
return credential, nil
}
func VerifyWebAuthnAssertion(input WebAuthnLoginAssertion, challenge string, rpID string, expectedOrigin string, credential WebAuthnCredentialMaterial) (uint32, error) {
if input.Type != "public-key" {
return 0, fmt.Errorf("unexpected credential type: %s", input.Type)
}
rawID := strings.TrimSpace(input.RawID)
if rawID == "" {
rawID = strings.TrimSpace(input.ID)
}
if rawID != credential.CredentialID {
return 0, errors.New("credential id does not match")
}
clientDataRaw, err := DecodeBase64URL(input.Response.ClientDataJSON)
if err != nil {
return 0, fmt.Errorf("decode client data: %w", err)
}
if err := validateWebAuthnClientData(clientDataRaw, "webauthn.get", challenge, expectedOrigin); err != nil {
return 0, err
}
authData, err := DecodeBase64URL(input.Response.AuthenticatorData)
if err != nil {
return 0, fmt.Errorf("decode authenticator data: %w", err)
}
signature, err := DecodeBase64URL(input.Response.Signature)
if err != nil {
return 0, fmt.Errorf("decode signature: %w", err)
}
signCount, err := parseAssertionAuthenticatorData(authData, rpID, credential.SignCount)
if err != nil {
return 0, err
}
xBytes, err := DecodeBase64URL(credential.PublicKeyX)
if err != nil {
return 0, fmt.Errorf("decode public key x: %w", err)
}
yBytes, err := DecodeBase64URL(credential.PublicKeyY)
if err != nil {
return 0, fmt.Errorf("decode public key y: %w", err)
}
publicKey := ecdsa.PublicKey{Curve: elliptic.P256(), X: new(big.Int).SetBytes(xBytes), Y: new(big.Int).SetBytes(yBytes)}
if !publicKey.Curve.IsOnCurve(publicKey.X, publicKey.Y) {
return 0, errors.New("webauthn public key is not on P-256 curve")
}
clientDataHash := sha256.Sum256(clientDataRaw)
verifyData := append(append([]byte{}, authData...), clientDataHash[:]...)
digest := sha256.Sum256(verifyData)
if !ecdsa.VerifyASN1(&publicKey, digest[:], signature) {
return 0, errors.New("invalid webauthn signature")
}
return signCount, nil
}
func validateWebAuthnClientData(raw []byte, expectedType string, challenge string, expectedOrigin string) error {
var clientData WebAuthnClientData
if err := json.Unmarshal(raw, &clientData); err != nil {
return fmt.Errorf("parse client data: %w", err)
}
if clientData.Type != expectedType {
return fmt.Errorf("unexpected webauthn client data type: %s", clientData.Type)
}
if clientData.Challenge != challenge {
return errors.New("webauthn challenge mismatch")
}
if expectedOrigin != "" && clientData.Origin != expectedOrigin {
return fmt.Errorf("webauthn origin mismatch: %s", clientData.Origin)
}
return nil
}
func parseAttestedCredentialData(authData []byte, rpID string) (*WebAuthnParsedCredential, error) {
signCount, credentialData, err := parseAuthenticatorDataHeader(authData, rpID, true, 0)
if err != nil {
return nil, err
}
if len(credentialData) < 18 {
return nil, errors.New("attested credential data is too short")
}
offset := 16
credentialIDLength := int(binary.BigEndian.Uint16(credentialData[offset : offset+2]))
offset += 2
if credentialIDLength <= 0 || len(credentialData) < offset+credentialIDLength {
return nil, errors.New("invalid credential id length")
}
credentialID := credentialData[offset : offset+credentialIDLength]
offset += credentialIDLength
publicKeyRaw := credentialData[offset:]
publicKey, err := parseCBOR(publicKeyRaw)
if err != nil {
return nil, fmt.Errorf("parse credential public key: %w", err)
}
publicKeyMap, ok := publicKey.(map[any]any)
if !ok {
return nil, errors.New("credential public key is not a map")
}
kty, err := coseInt(publicKeyMap, 1)
if err != nil {
return nil, err
}
alg, err := coseInt(publicKeyMap, 3)
if err != nil {
return nil, err
}
crv, err := coseInt(publicKeyMap, -1)
if err != nil {
return nil, err
}
if kty != 2 || alg != -7 || crv != 1 {
return nil, fmt.Errorf("unsupported COSE key: kty=%d alg=%d crv=%d", kty, alg, crv)
}
x, err := coseBytes(publicKeyMap, -2)
if err != nil {
return nil, err
}
y, err := coseBytes(publicKeyMap, -3)
if err != nil {
return nil, err
}
if !elliptic.P256().IsOnCurve(new(big.Int).SetBytes(x), new(big.Int).SetBytes(y)) {
return nil, errors.New("credential public key is not on P-256 curve")
}
return &WebAuthnParsedCredential{
CredentialID: EncodeBase64URL(credentialID),
PublicKeyX: EncodeBase64URL(x),
PublicKeyY: EncodeBase64URL(y),
SignCount: signCount,
}, nil
}
func parseAssertionAuthenticatorData(authData []byte, rpID string, previousSignCount uint32) (uint32, error) {
signCount, _, err := parseAuthenticatorDataHeader(authData, rpID, false, previousSignCount)
if err != nil {
return 0, err
}
return signCount, nil
}
func parseAuthenticatorDataHeader(authData []byte, rpID string, requireAttestedData bool, previousSignCount uint32) (uint32, []byte, error) {
if len(authData) < 37 {
return 0, nil, errors.New("authenticator data is too short")
}
expectedRPIDHash := sha256.Sum256([]byte(rpID))
if string(authData[:32]) != string(expectedRPIDHash[:]) {
return 0, nil, errors.New("rp id hash mismatch")
}
flags := authData[32]
if flags&0x01 == 0 {
return 0, nil, errors.New("user presence flag is missing")
}
signCount := binary.BigEndian.Uint32(authData[33:37])
if previousSignCount > 0 && signCount > 0 && signCount <= previousSignCount {
return 0, nil, errors.New("authenticator sign count did not increase")
}
if requireAttestedData && flags&0x40 == 0 {
return 0, nil, errors.New("attested credential data flag is missing")
}
return signCount, authData[37:], nil
}
func coseInt(m map[any]any, key int64) (int64, error) {
value, ok := m[key]
if !ok {
return 0, fmt.Errorf("missing COSE key %d", key)
}
intValue, ok := value.(int64)
if !ok {
return 0, fmt.Errorf("invalid COSE key %d", key)
}
return intValue, nil
}
func coseBytes(m map[any]any, key int64) ([]byte, error) {
value, ok := m[key]
if !ok {
return nil, fmt.Errorf("missing COSE key %d", key)
}
bytesValue, ok := value.([]byte)
if !ok || len(bytesValue) == 0 {
return nil, fmt.Errorf("invalid COSE key %d", key)
}
return bytesValue, nil
}
func parseCBOR(data []byte) (any, error) {
reader := cborReader{data: data}
value, err := reader.read()
if err != nil {
return nil, err
}
return value, nil
}
func parseCBORExact(data []byte) (any, error) {
reader := cborReader{data: data}
value, err := reader.read()
if err != nil {
return nil, err
}
if reader.pos != len(data) {
return nil, errors.New("trailing cbor data")
}
return value, nil
}
type cborReader struct {
data []byte
pos int
}
func (r *cborReader) read() (any, error) {
if r.pos >= len(r.data) {
return nil, errors.New("unexpected cbor eof")
}
initial := r.data[r.pos]
r.pos++
major := initial >> 5
additional := initial & 0x1f
length, err := r.readLength(additional)
if err != nil {
return nil, err
}
switch major {
case 0:
return int64(length), nil
case 1:
return -1 - int64(length), nil
case 2:
return r.readBytes(length)
case 3:
raw, err := r.readBytes(length)
if err != nil {
return nil, err
}
return string(raw), nil
case 4:
out := make([]any, 0, length)
for i := uint64(0); i < length; i++ {
item, err := r.read()
if err != nil {
return nil, err
}
out = append(out, item)
}
return out, nil
case 5:
out := make(map[any]any, length)
for i := uint64(0); i < length; i++ {
key, err := r.read()
if err != nil {
return nil, err
}
value, err := r.read()
if err != nil {
return nil, err
}
out[key] = value
}
return out, nil
case 7:
switch additional {
case 20:
return false, nil
case 21:
return true, nil
case 22, 23:
return nil, nil
default:
return nil, fmt.Errorf("unsupported cbor simple value: %d", additional)
}
default:
return nil, fmt.Errorf("unsupported cbor major type: %d", major)
}
}
func (r *cborReader) readLength(additional byte) (uint64, error) {
switch {
case additional < 24:
return uint64(additional), nil
case additional == 24:
if r.pos+1 > len(r.data) {
return 0, errors.New("unexpected cbor eof")
}
value := r.data[r.pos]
r.pos++
return uint64(value), nil
case additional == 25:
if r.pos+2 > len(r.data) {
return 0, errors.New("unexpected cbor eof")
}
value := binary.BigEndian.Uint16(r.data[r.pos : r.pos+2])
r.pos += 2
return uint64(value), nil
case additional == 26:
if r.pos+4 > len(r.data) {
return 0, errors.New("unexpected cbor eof")
}
value := binary.BigEndian.Uint32(r.data[r.pos : r.pos+4])
r.pos += 4
return uint64(value), nil
case additional == 27:
if r.pos+8 > len(r.data) {
return 0, errors.New("unexpected cbor eof")
}
value := binary.BigEndian.Uint64(r.data[r.pos : r.pos+8])
r.pos += 8
return value, nil
default:
return 0, fmt.Errorf("unsupported cbor additional info: %d", additional)
}
}
func (r *cborReader) readBytes(length uint64) ([]byte, error) {
if length > uint64(len(r.data)-r.pos) {
return nil, errors.New("unexpected cbor eof")
}
out := r.data[r.pos : r.pos+int(length)]
r.pos += int(length)
return out, nil
}

View File

@@ -118,7 +118,8 @@ func (s *AgentService) SubmitCommandResult(ctx context.Context, node *model.Node
cmd.Result = string(result.Result)
}
cmd.CompletedAt = &now
return s.cmdRepo.Update(ctx, cmd)
_, err = s.cmdRepo.CompleteDispatched(ctx, cmd)
return err
}
// AgentTaskSpec 给 Agent 返回的任务规格,包含解密后的存储配置,供 Agent 直接执行。
@@ -159,8 +160,8 @@ func (s *AgentService) GetTaskSpec(ctx context.Context, node *model.Node, taskID
if task == nil {
return nil, apperror.New(404, "BACKUP_TASK_NOT_FOUND", "任务不存在", nil)
}
if task.NodeID != node.ID {
return nil, apperror.Unauthorized("BACKUP_TASK_FORBIDDEN", "任务不属于当前节点", nil)
if err := s.ensureTaskSpecAccess(ctx, node, task); err != nil {
return nil, err
}
// 解密数据库密码(若有)
dbPassword := ""
@@ -213,6 +214,20 @@ func (s *AgentService) GetTaskSpec(ctx context.Context, node *model.Node, taskID
}, nil
}
func (s *AgentService) ensureTaskSpecAccess(ctx context.Context, node *model.Node, task *model.BackupTask) error {
if task.NodeID == node.ID {
return nil
}
record, err := s.recordRepo.FindRunningByTaskAndNode(ctx, task.ID, node.ID)
if err != nil {
return err
}
if record == nil {
return apperror.Unauthorized("BACKUP_TASK_FORBIDDEN", "任务不属于当前节点", nil)
}
return nil
}
// AgentRecordUpdate Agent 上报备份记录的最终状态。
type AgentRecordUpdate struct {
Status string `json:"status"` // running | success | failed
@@ -233,14 +248,16 @@ func (s *AgentService) UpdateRecord(ctx context.Context, node *model.Node, recor
if record == nil {
return apperror.New(404, "BACKUP_RECORD_NOT_FOUND", "记录不存在", nil)
}
// 通过 task.NodeID 判断是否属于当前 agent
task, err := s.taskRepo.FindByID(ctx, record.TaskID)
if err != nil {
return err
}
if task == nil || task.NodeID != node.ID {
if task == nil || !recordBelongsToNode(record, task, node.ID) {
return apperror.Unauthorized("BACKUP_RECORD_FORBIDDEN", "记录不属于当前节点", nil)
}
if isBackupRecordTerminal(record.Status) {
return nil
}
if update.Status != "" {
record.Status = update.Status
}
@@ -282,6 +299,17 @@ func (s *AgentService) UpdateRecord(ctx context.Context, node *model.Node, recor
return nil
}
func recordBelongsToNode(record *model.BackupRecord, task *model.BackupTask, nodeID uint) bool {
if record.NodeID != 0 {
return record.NodeID == nodeID
}
return task.NodeID == nodeID
}
func isBackupRecordTerminal(status string) bool {
return status == model.BackupRecordStatusSuccess || status == model.BackupRecordStatusFailed
}
// EnqueueCommand Master 端调用:给指定节点插入一条待执行命令。
// 返回命令 ID。
func (s *AgentService) EnqueueCommand(ctx context.Context, nodeID uint, cmdType string, payload any) (uint, error) {
@@ -356,25 +384,84 @@ func (s *AgentService) StartCommandTimeoutMonitor(ctx context.Context, interval
}()
}
// processStaleCommands 扫描已超时的 dispatched 命令并联动关联记录。
// 流程:先取超时候选 → 对每条联动 backup/restore 记录 → 把命令置为 timeout
// processStaleCommands 扫描已超时的 pending/dispatched 命令并联动关联记录。
// 流程:先取超时候选 → 条件式把命令置为 timeout → 对抢到的命令联动 backup/restore 记录。
// 单条失败不影响后续处理。
func (s *AgentService) processStaleCommands(ctx context.Context, threshold time.Time) {
commands, err := s.cmdRepo.ListStaleDispatched(ctx, threshold)
commands, err := s.cmdRepo.ListStaleActive(ctx, threshold)
if err != nil || len(commands) == 0 {
return
}
for i := range commands {
cmd := commands[i]
s.failLinkedRecord(ctx, &cmd)
if s.commandStillActive(ctx, &cmd, threshold) {
continue
}
now := time.Now().UTC()
cmd.Status = model.AgentCommandStatusTimeout
cmd.ErrorMessage = "agent did not report result before timeout"
cmd.CompletedAt = &now
_ = s.cmdRepo.Update(ctx, &cmd)
timedOut, err := s.cmdRepo.TimeoutActive(ctx, &cmd)
if err != nil || !timedOut {
continue
}
s.failLinkedRecord(ctx, &cmd)
}
}
// commandStillActive 用关联记录状态、记录更新时间和节点心跳作为长任务续租信号。
// 仅 run_task / restore_record 允许续租,避免短 RPC 命令被在线节点长期保留。
func (s *AgentService) commandStillActive(ctx context.Context, cmd *model.AgentCommand, threshold time.Time) bool {
if cmd.Status != model.AgentCommandStatusDispatched {
return false
}
switch cmd.Type {
case model.AgentCommandTypeRunTask:
var payload struct {
RecordID uint `json:"recordId"`
}
if err := json.Unmarshal([]byte(cmd.Payload), &payload); err != nil || payload.RecordID == 0 {
return false
}
record, err := s.recordRepo.FindByID(ctx, payload.RecordID)
if err != nil || record == nil || record.Status != model.BackupRecordStatusRunning {
return false
}
if s.nodeRecentlySeen(ctx, cmd.NodeID, threshold) {
return true
}
return record.UpdatedAt.After(threshold)
case model.AgentCommandTypeRestoreRecord:
if s.restoreRepo == nil {
return false
}
var payload struct {
RestoreRecordID uint `json:"restoreRecordId"`
}
if err := json.Unmarshal([]byte(cmd.Payload), &payload); err != nil || payload.RestoreRecordID == 0 {
return false
}
restore, err := s.restoreRepo.FindByID(ctx, payload.RestoreRecordID)
if err != nil || restore == nil || restore.Status != model.RestoreRecordStatusRunning {
return false
}
if s.nodeRecentlySeen(ctx, cmd.NodeID, threshold) {
return true
}
return restore.UpdatedAt.After(threshold)
default:
return false
}
}
func (s *AgentService) nodeRecentlySeen(ctx context.Context, nodeID uint, threshold time.Time) bool {
node, err := s.nodeRepo.FindByID(ctx, nodeID)
if err != nil || node == nil {
return false
}
return node.Status == model.NodeStatusOnline && node.LastSeen.After(threshold)
}
// failLinkedRecord 根据命令类型把关联记录标记为 failed。
// 只对仍然处于 running 状态的记录生效,避免覆盖已完成的结果。
func (s *AgentService) failLinkedRecord(ctx context.Context, cmd *model.AgentCommand) {

View File

@@ -0,0 +1,589 @@
package service
import (
"context"
"path/filepath"
"testing"
"time"
"backupx/server/internal/config"
"backupx/server/internal/database"
"backupx/server/internal/logger"
"backupx/server/internal/model"
"backupx/server/internal/repository"
"backupx/server/internal/storage/codec"
"gorm.io/gorm"
)
func newAgentServicePoolTestHarness(t *testing.T) (*AgentService, *gorm.DB, repository.BackupRecordRepository, repository.AgentCommandRepository, *model.Node, *model.Node) {
t.Helper()
log, err := logger.New(config.LogConfig{Level: "error"})
if err != nil {
t.Fatalf("logger.New returned error: %v", err)
}
db, err := database.Open(config.DatabaseConfig{Path: filepath.Join(t.TempDir(), "backupx.db")}, log)
if err != nil {
t.Fatalf("database.Open returned error: %v", err)
}
cipher := codec.NewConfigCipher("agent-service-secret")
nodeRepo := repository.NewNodeRepository(db)
taskRepo := repository.NewBackupTaskRepository(db)
recordRepo := repository.NewBackupRecordRepository(db)
storageRepo := repository.NewStorageTargetRepository(db)
cmdRepo := repository.NewAgentCommandRepository(db)
owner := &model.Node{Name: "edge-owner", Token: "owner-token", Status: model.NodeStatusOnline, IsLocal: false, LastSeen: time.Now().UTC()}
other := &model.Node{Name: "edge-other", Token: "other-token", Status: model.NodeStatusOnline, IsLocal: false, LastSeen: time.Now().UTC()}
if err := nodeRepo.Create(context.Background(), owner); err != nil {
t.Fatalf("create owner node: %v", err)
}
if err := nodeRepo.Create(context.Background(), other); err != nil {
t.Fatalf("create other node: %v", err)
}
targetConfig, err := cipher.EncryptJSON(map[string]any{"basePath": t.TempDir()})
if err != nil {
t.Fatalf("EncryptJSON returned error: %v", err)
}
target := &model.StorageTarget{Name: "local", Type: "local_disk", Enabled: true, ConfigCiphertext: targetConfig, ConfigVersion: 1, LastTestStatus: "unknown"}
if err := storageRepo.Create(context.Background(), target); err != nil {
t.Fatalf("create storage target: %v", err)
}
task := &model.BackupTask{
Name: "pooled-task",
Type: "file",
Enabled: true,
SourcePath: "/srv/data",
StorageTargetID: target.ID,
NodeID: 0,
NodePoolTag: "db",
RetentionDays: 30,
Compression: "gzip",
MaxBackups: 10,
LastStatus: "running",
}
if err := taskRepo.Create(context.Background(), task); err != nil {
t.Fatalf("create task: %v", err)
}
record := &model.BackupRecord{
TaskID: task.ID,
StorageTargetID: target.ID,
NodeID: owner.ID,
Status: model.BackupRecordStatusRunning,
StartedAt: time.Now().UTC(),
}
if err := recordRepo.Create(context.Background(), record); err != nil {
t.Fatalf("create record: %v", err)
}
return NewAgentService(nodeRepo, taskRepo, recordRepo, storageRepo, cmdRepo, cipher), db, recordRepo, cmdRepo, owner, other
}
func TestAgentServicePooledTaskUsesRecordNodeForSpecAndRecordUpdates(t *testing.T) {
svc, _, records, _, owner, other := newAgentServicePoolTestHarness(t)
ctx := context.Background()
spec, err := svc.GetTaskSpec(ctx, owner, 1)
if err != nil {
t.Fatalf("owner GetTaskSpec returned error: %v", err)
}
if spec.TaskID != 1 || len(spec.StorageTargets) != 1 {
t.Fatalf("unexpected spec: %#v", spec)
}
if _, err := svc.GetTaskSpec(ctx, other, 1); err == nil {
t.Fatal("expected non-owner node to be forbidden from pooled task spec")
}
if err := svc.UpdateRecord(ctx, owner, 1, AgentRecordUpdate{
Status: model.BackupRecordStatusSuccess,
FileName: "backup.tar.gz",
FileSize: 123,
StoragePath: "tasks/1/backup.tar.gz",
}); err != nil {
t.Fatalf("owner UpdateRecord returned error: %v", err)
}
updated, err := records.FindByID(ctx, 1)
if err != nil {
t.Fatalf("FindByID returned error: %v", err)
}
if updated.Status != model.BackupRecordStatusSuccess || updated.NodeID != owner.ID {
t.Fatalf("unexpected updated record: %#v", updated)
}
if err := svc.UpdateRecord(ctx, other, 1, AgentRecordUpdate{LogAppend: "bad"}); err == nil {
t.Fatal("expected non-owner node to be forbidden from record update")
}
}
func TestAgentServiceProcessStaleCommandsFailsPendingRunTaskRecord(t *testing.T) {
svc, _, records, commands, owner, _ := newAgentServicePoolTestHarness(t)
ctx := context.Background()
oldCommand := &model.AgentCommand{
NodeID: owner.ID,
Type: model.AgentCommandTypeRunTask,
Status: model.AgentCommandStatusPending,
Payload: `{"recordId":1}`,
CreatedAt: time.Now().UTC().Add(-time.Hour),
}
if err := commands.Create(ctx, oldCommand); err != nil {
t.Fatalf("Create command returned error: %v", err)
}
svc.processStaleCommands(ctx, time.Now().UTC().Add(-30*time.Minute))
updatedCommand, err := commands.FindByID(ctx, oldCommand.ID)
if err != nil {
t.Fatalf("FindByID command returned error: %v", err)
}
if updatedCommand.Status != model.AgentCommandStatusTimeout {
t.Fatalf("expected command timeout, got %#v", updatedCommand)
}
updatedRecord, err := records.FindByID(ctx, 1)
if err != nil {
t.Fatalf("FindByID record returned error: %v", err)
}
if updatedRecord.Status != model.BackupRecordStatusFailed {
t.Fatalf("expected record failed, got %#v", updatedRecord)
}
if updatedRecord.CompletedAt == nil {
t.Fatal("expected failed record completedAt to be set")
}
}
func TestAgentServiceProcessStaleCommandsFailsPendingRestoreRecord(t *testing.T) {
svc, db, _, commands, owner, _ := newAgentServicePoolTestHarness(t)
ctx := context.Background()
restoreRepo := repository.NewRestoreRecordRepository(db)
restore := &model.RestoreRecord{
BackupRecordID: 1,
TaskID: 1,
NodeID: owner.ID,
Status: model.RestoreRecordStatusRunning,
StartedAt: time.Now().UTC().Add(-time.Hour),
}
if err := restoreRepo.Create(ctx, restore); err != nil {
t.Fatalf("Create restore returned error: %v", err)
}
svc.SetRestoreRepository(restoreRepo)
oldCommand := &model.AgentCommand{
NodeID: owner.ID,
Type: model.AgentCommandTypeRestoreRecord,
Status: model.AgentCommandStatusPending,
Payload: `{"restoreRecordId":1}`,
CreatedAt: time.Now().UTC().Add(-time.Hour),
}
if err := commands.Create(ctx, oldCommand); err != nil {
t.Fatalf("Create command returned error: %v", err)
}
svc.processStaleCommands(ctx, time.Now().UTC().Add(-30*time.Minute))
updatedCommand, err := commands.FindByID(ctx, oldCommand.ID)
if err != nil {
t.Fatalf("FindByID command returned error: %v", err)
}
if updatedCommand.Status != model.AgentCommandStatusTimeout {
t.Fatalf("expected command timeout, got %#v", updatedCommand)
}
updatedRestore, err := restoreRepo.FindByID(ctx, restore.ID)
if err != nil {
t.Fatalf("FindByID restore returned error: %v", err)
}
if updatedRestore.Status != model.RestoreRecordStatusFailed {
t.Fatalf("expected restore failed, got %#v", updatedRestore)
}
if updatedRestore.CompletedAt == nil {
t.Fatal("expected failed restore completedAt to be set")
}
}
func TestAgentServiceProcessStaleCommandsKeepsActiveDispatchedRunTaskRecord(t *testing.T) {
svc, _, records, commands, owner, _ := newAgentServicePoolTestHarness(t)
ctx := context.Background()
dispatchedAt := time.Now().UTC().Add(-time.Hour)
oldCommand := &model.AgentCommand{
NodeID: owner.ID,
Type: model.AgentCommandTypeRunTask,
Status: model.AgentCommandStatusDispatched,
Payload: `{"recordId":1}`,
CreatedAt: dispatchedAt,
DispatchedAt: &dispatchedAt,
}
if err := commands.Create(ctx, oldCommand); err != nil {
t.Fatalf("Create command returned error: %v", err)
}
svc.processStaleCommands(ctx, time.Now().UTC().Add(-30*time.Minute))
updatedCommand, err := commands.FindByID(ctx, oldCommand.ID)
if err != nil {
t.Fatalf("FindByID command returned error: %v", err)
}
if updatedCommand.Status != model.AgentCommandStatusDispatched {
t.Fatalf("expected active command to remain dispatched, got %#v", updatedCommand)
}
updatedRecord, err := records.FindByID(ctx, 1)
if err != nil {
t.Fatalf("FindByID record returned error: %v", err)
}
if updatedRecord.Status != model.BackupRecordStatusRunning {
t.Fatalf("expected active record to remain running, got %#v", updatedRecord)
}
}
func TestAgentServiceProcessStaleCommandsKeepsDispatchedRunTaskWhenNodeHeartbeatIsFresh(t *testing.T) {
svc, db, records, commands, owner, _ := newAgentServicePoolTestHarness(t)
ctx := context.Background()
dispatchedAt := time.Now().UTC().Add(-time.Hour)
if err := setBackupRecordUpdatedAt(db, 1, dispatchedAt); err != nil {
t.Fatalf("set backup record updated_at: %v", err)
}
if err := db.Model(&model.Node{}).Where("id = ?", owner.ID).UpdateColumn("last_seen", time.Now().UTC()).Error; err != nil {
t.Fatalf("set owner last_seen: %v", err)
}
oldCommand := &model.AgentCommand{
NodeID: owner.ID,
Type: model.AgentCommandTypeRunTask,
Status: model.AgentCommandStatusDispatched,
Payload: `{"recordId":1}`,
CreatedAt: dispatchedAt,
DispatchedAt: &dispatchedAt,
}
if err := commands.Create(ctx, oldCommand); err != nil {
t.Fatalf("Create command returned error: %v", err)
}
svc.processStaleCommands(ctx, time.Now().UTC().Add(-30*time.Minute))
updatedCommand, err := commands.FindByID(ctx, oldCommand.ID)
if err != nil {
t.Fatalf("FindByID command returned error: %v", err)
}
if updatedCommand.Status != model.AgentCommandStatusDispatched {
t.Fatalf("expected command to remain dispatched while node heartbeat is fresh, got %#v", updatedCommand)
}
updatedRecord, err := records.FindByID(ctx, 1)
if err != nil {
t.Fatalf("FindByID record returned error: %v", err)
}
if updatedRecord.Status != model.BackupRecordStatusRunning {
t.Fatalf("expected record to remain running while node heartbeat is fresh, got %#v", updatedRecord)
}
}
func TestAgentServiceProcessStaleCommandsTimesOutShortCommandEvenWhenNodeHeartbeatIsFresh(t *testing.T) {
svc, db, _, commands, owner, _ := newAgentServicePoolTestHarness(t)
ctx := context.Background()
dispatchedAt := time.Now().UTC().Add(-time.Hour)
if err := db.Model(&model.Node{}).Where("id = ?", owner.ID).UpdateColumn("last_seen", time.Now().UTC()).Error; err != nil {
t.Fatalf("set owner last_seen: %v", err)
}
oldCommand := &model.AgentCommand{
NodeID: owner.ID,
Type: model.AgentCommandTypeListDir,
Status: model.AgentCommandStatusDispatched,
Payload: `{"path":"/srv"}`,
CreatedAt: dispatchedAt,
DispatchedAt: &dispatchedAt,
}
if err := commands.Create(ctx, oldCommand); err != nil {
t.Fatalf("Create command returned error: %v", err)
}
svc.processStaleCommands(ctx, time.Now().UTC().Add(-30*time.Minute))
updatedCommand, err := commands.FindByID(ctx, oldCommand.ID)
if err != nil {
t.Fatalf("FindByID command returned error: %v", err)
}
if updatedCommand.Status != model.AgentCommandStatusTimeout {
t.Fatalf("expected stale short command timeout, got %#v", updatedCommand)
}
}
func TestAgentServiceProcessStaleCommandsTimesOutDispatchedRunTaskWhenRecordIsTerminalEvenWithFreshHeartbeat(t *testing.T) {
svc, db, records, commands, owner, _ := newAgentServicePoolTestHarness(t)
ctx := context.Background()
dispatchedAt := time.Now().UTC().Add(-time.Hour)
if err := db.Model(&model.Node{}).Where("id = ?", owner.ID).UpdateColumn("last_seen", time.Now().UTC()).Error; err != nil {
t.Fatalf("set owner last_seen: %v", err)
}
record, err := records.FindByID(ctx, 1)
if err != nil {
t.Fatalf("FindByID record returned error: %v", err)
}
completedAt := time.Now().UTC().Add(-time.Minute)
record.Status = model.BackupRecordStatusFailed
record.CompletedAt = &completedAt
if err := records.Update(ctx, record); err != nil {
t.Fatalf("Update terminal record returned error: %v", err)
}
oldCommand := &model.AgentCommand{
NodeID: owner.ID,
Type: model.AgentCommandTypeRunTask,
Status: model.AgentCommandStatusDispatched,
Payload: `{"recordId":1}`,
CreatedAt: dispatchedAt,
DispatchedAt: &dispatchedAt,
}
if err := commands.Create(ctx, oldCommand); err != nil {
t.Fatalf("Create command returned error: %v", err)
}
svc.processStaleCommands(ctx, time.Now().UTC().Add(-30*time.Minute))
updatedCommand, err := commands.FindByID(ctx, oldCommand.ID)
if err != nil {
t.Fatalf("FindByID command returned error: %v", err)
}
if updatedCommand.Status != model.AgentCommandStatusTimeout {
t.Fatalf("expected command timeout when linked record is terminal, got %#v", updatedCommand)
}
}
func TestAgentServiceProcessStaleCommandsTimesOutInactiveDispatchedRunTaskRecord(t *testing.T) {
svc, db, records, commands, owner, _ := newAgentServicePoolTestHarness(t)
ctx := context.Background()
dispatchedAt := time.Now().UTC().Add(-time.Hour)
if err := setBackupRecordUpdatedAt(db, 1, dispatchedAt); err != nil {
t.Fatalf("set backup record updated_at: %v", err)
}
if err := db.Model(&model.Node{}).Where("id = ?", owner.ID).UpdateColumn("last_seen", dispatchedAt).Error; err != nil {
t.Fatalf("set owner last_seen: %v", err)
}
oldCommand := &model.AgentCommand{
NodeID: owner.ID,
Type: model.AgentCommandTypeRunTask,
Status: model.AgentCommandStatusDispatched,
Payload: `{"recordId":1}`,
CreatedAt: dispatchedAt,
DispatchedAt: &dispatchedAt,
}
if err := commands.Create(ctx, oldCommand); err != nil {
t.Fatalf("Create command returned error: %v", err)
}
svc.processStaleCommands(ctx, time.Now().UTC().Add(-30*time.Minute))
updatedCommand, err := commands.FindByID(ctx, oldCommand.ID)
if err != nil {
t.Fatalf("FindByID command returned error: %v", err)
}
if updatedCommand.Status != model.AgentCommandStatusTimeout {
t.Fatalf("expected inactive command timeout, got %#v", updatedCommand)
}
updatedRecord, err := records.FindByID(ctx, 1)
if err != nil {
t.Fatalf("FindByID record returned error: %v", err)
}
if updatedRecord.Status != model.BackupRecordStatusFailed {
t.Fatalf("expected inactive record failed, got %#v", updatedRecord)
}
}
func TestAgentServiceProcessStaleCommandsKeepsActiveDispatchedRestoreRecord(t *testing.T) {
svc, db, _, commands, owner, _ := newAgentServicePoolTestHarness(t)
ctx := context.Background()
restoreRepo := repository.NewRestoreRecordRepository(db)
restore := createAgentServiceRestoreRecord(t, restoreRepo, owner.ID)
svc.SetRestoreRepository(restoreRepo)
dispatchedAt := time.Now().UTC().Add(-time.Hour)
oldCommand := &model.AgentCommand{
NodeID: owner.ID,
Type: model.AgentCommandTypeRestoreRecord,
Status: model.AgentCommandStatusDispatched,
Payload: `{"restoreRecordId":1}`,
CreatedAt: dispatchedAt,
DispatchedAt: &dispatchedAt,
}
if err := commands.Create(ctx, oldCommand); err != nil {
t.Fatalf("Create command returned error: %v", err)
}
svc.processStaleCommands(ctx, time.Now().UTC().Add(-30*time.Minute))
updatedCommand, err := commands.FindByID(ctx, oldCommand.ID)
if err != nil {
t.Fatalf("FindByID command returned error: %v", err)
}
if updatedCommand.Status != model.AgentCommandStatusDispatched {
t.Fatalf("expected active restore command to remain dispatched, got %#v", updatedCommand)
}
updatedRestore, err := restoreRepo.FindByID(ctx, restore.ID)
if err != nil {
t.Fatalf("FindByID restore returned error: %v", err)
}
if updatedRestore.Status != model.RestoreRecordStatusRunning {
t.Fatalf("expected active restore to remain running, got %#v", updatedRestore)
}
}
func TestAgentServiceProcessStaleCommandsKeepsDispatchedRestoreWhenNodeHeartbeatIsFresh(t *testing.T) {
svc, db, _, commands, owner, _ := newAgentServicePoolTestHarness(t)
ctx := context.Background()
restoreRepo := repository.NewRestoreRecordRepository(db)
restore := createAgentServiceRestoreRecord(t, restoreRepo, owner.ID)
svc.SetRestoreRepository(restoreRepo)
dispatchedAt := time.Now().UTC().Add(-time.Hour)
if err := setRestoreRecordUpdatedAt(db, restore.ID, dispatchedAt); err != nil {
t.Fatalf("set restore record updated_at: %v", err)
}
if err := db.Model(&model.Node{}).Where("id = ?", owner.ID).UpdateColumn("last_seen", time.Now().UTC()).Error; err != nil {
t.Fatalf("set owner last_seen: %v", err)
}
oldCommand := &model.AgentCommand{
NodeID: owner.ID,
Type: model.AgentCommandTypeRestoreRecord,
Status: model.AgentCommandStatusDispatched,
Payload: `{"restoreRecordId":1}`,
CreatedAt: dispatchedAt,
DispatchedAt: &dispatchedAt,
}
if err := commands.Create(ctx, oldCommand); err != nil {
t.Fatalf("Create command returned error: %v", err)
}
svc.processStaleCommands(ctx, time.Now().UTC().Add(-30*time.Minute))
updatedCommand, err := commands.FindByID(ctx, oldCommand.ID)
if err != nil {
t.Fatalf("FindByID command returned error: %v", err)
}
if updatedCommand.Status != model.AgentCommandStatusDispatched {
t.Fatalf("expected restore command to remain dispatched while node heartbeat is fresh, got %#v", updatedCommand)
}
}
func TestAgentServiceProcessStaleCommandsTimesOutInactiveDispatchedRestoreRecord(t *testing.T) {
svc, db, _, commands, owner, _ := newAgentServicePoolTestHarness(t)
ctx := context.Background()
restoreRepo := repository.NewRestoreRecordRepository(db)
restore := createAgentServiceRestoreRecord(t, restoreRepo, owner.ID)
svc.SetRestoreRepository(restoreRepo)
dispatchedAt := time.Now().UTC().Add(-time.Hour)
if err := setRestoreRecordUpdatedAt(db, restore.ID, dispatchedAt); err != nil {
t.Fatalf("set restore record updated_at: %v", err)
}
if err := db.Model(&model.Node{}).Where("id = ?", owner.ID).UpdateColumn("last_seen", dispatchedAt).Error; err != nil {
t.Fatalf("set owner last_seen: %v", err)
}
oldCommand := &model.AgentCommand{
NodeID: owner.ID,
Type: model.AgentCommandTypeRestoreRecord,
Status: model.AgentCommandStatusDispatched,
Payload: `{"restoreRecordId":1}`,
CreatedAt: dispatchedAt,
DispatchedAt: &dispatchedAt,
}
if err := commands.Create(ctx, oldCommand); err != nil {
t.Fatalf("Create command returned error: %v", err)
}
svc.processStaleCommands(ctx, time.Now().UTC().Add(-30*time.Minute))
updatedCommand, err := commands.FindByID(ctx, oldCommand.ID)
if err != nil {
t.Fatalf("FindByID command returned error: %v", err)
}
if updatedCommand.Status != model.AgentCommandStatusTimeout {
t.Fatalf("expected inactive restore command timeout, got %#v", updatedCommand)
}
updatedRestore, err := restoreRepo.FindByID(ctx, restore.ID)
if err != nil {
t.Fatalf("FindByID restore returned error: %v", err)
}
if updatedRestore.Status != model.RestoreRecordStatusFailed {
t.Fatalf("expected inactive restore failed, got %#v", updatedRestore)
}
}
func TestAgentServiceSubmitCommandResultDoesNotOverwriteTerminalCommand(t *testing.T) {
svc, _, _, commands, owner, _ := newAgentServicePoolTestHarness(t)
ctx := context.Background()
completedAt := time.Now().UTC().Add(-time.Minute)
command := &model.AgentCommand{
NodeID: owner.ID,
Type: model.AgentCommandTypeRunTask,
Status: model.AgentCommandStatusTimeout,
Payload: `{"recordId":1}`,
ErrorMessage: "timeout",
CompletedAt: &completedAt,
}
if err := commands.Create(ctx, command); err != nil {
t.Fatalf("Create command returned error: %v", err)
}
if err := svc.SubmitCommandResult(ctx, owner, command.ID, AgentCommandResult{Success: true, Result: []byte(`{"ok":true}`)}); err != nil {
t.Fatalf("SubmitCommandResult returned error: %v", err)
}
updatedCommand, err := commands.FindByID(ctx, command.ID)
if err != nil {
t.Fatalf("FindByID command returned error: %v", err)
}
if updatedCommand.Status != model.AgentCommandStatusTimeout {
t.Fatalf("expected terminal command status to remain timeout, got %#v", updatedCommand)
}
if updatedCommand.Result != "" {
t.Fatalf("expected terminal command result to remain empty, got %q", updatedCommand.Result)
}
}
func TestAgentServiceUpdateRecordDoesNotOverwriteTerminalRecord(t *testing.T) {
svc, _, records, _, owner, _ := newAgentServicePoolTestHarness(t)
ctx := context.Background()
record, err := records.FindByID(ctx, 1)
if err != nil {
t.Fatalf("FindByID record returned error: %v", err)
}
completedAt := time.Now().UTC().Add(-time.Minute)
record.Status = model.BackupRecordStatusFailed
record.ErrorMessage = "timeout"
record.CompletedAt = &completedAt
if err := records.Update(ctx, record); err != nil {
t.Fatalf("Update record returned error: %v", err)
}
if err := svc.UpdateRecord(ctx, owner, record.ID, AgentRecordUpdate{
Status: model.BackupRecordStatusSuccess,
FileName: "late.tar.gz",
FileSize: 42,
Checksum: "late",
StoragePath: "late/path",
ErrorMessage: "late success",
LogAppend: "late log\n",
}); err != nil {
t.Fatalf("UpdateRecord returned error: %v", err)
}
updatedRecord, err := records.FindByID(ctx, record.ID)
if err != nil {
t.Fatalf("FindByID updated record returned error: %v", err)
}
if updatedRecord.Status != model.BackupRecordStatusFailed {
t.Fatalf("expected terminal record status to remain failed, got %#v", updatedRecord)
}
if updatedRecord.FileName != "" || updatedRecord.StoragePath != "" || updatedRecord.ErrorMessage != "timeout" {
t.Fatalf("expected terminal record fields to remain unchanged, got %#v", updatedRecord)
}
}
func createAgentServiceRestoreRecord(t *testing.T, repo repository.RestoreRecordRepository, nodeID uint) *model.RestoreRecord {
t.Helper()
restore := &model.RestoreRecord{
BackupRecordID: 1,
TaskID: 1,
NodeID: nodeID,
Status: model.RestoreRecordStatusRunning,
StartedAt: time.Now().UTC().Add(-time.Hour),
}
if err := repo.Create(context.Background(), restore); err != nil {
t.Fatalf("Create restore returned error: %v", err)
}
return restore
}
func setBackupRecordUpdatedAt(db *gorm.DB, id uint, updatedAt time.Time) error {
return db.Model(&model.BackupRecord{}).Where("id = ?", id).UpdateColumn("updated_at", updatedAt).Error
}
func setRestoreRecordUpdatedAt(db *gorm.DB, id uint, updatedAt time.Time) error {
return db.Model(&model.RestoreRecord{}).Where("id = ?", id).UpdateColumn("updated_at", updatedAt).Error
}

View File

@@ -0,0 +1,179 @@
package service
import (
"encoding/json"
"strings"
"time"
"backupx/server/internal/model"
)
const (
mfaChallengeTTL = 5 * time.Minute
trustedDeviceTTL = 30 * 24 * time.Hour
maxTrustedDeviceName = 128
maxTrustedDevices = 10
)
type WebAuthnCredentialRecord struct {
ID string `json:"id"`
Name string `json:"name"`
CredentialID string `json:"credentialId"`
PublicKeyX string `json:"publicKeyX"`
PublicKeyY string `json:"publicKeyY"`
SignCount uint32 `json:"signCount"`
CreatedAt string `json:"createdAt"`
LastUsedAt string `json:"lastUsedAt,omitempty"`
}
type WebAuthnCredentialOutput struct {
ID string `json:"id"`
Name string `json:"name"`
CreatedAt string `json:"createdAt"`
LastUsedAt string `json:"lastUsedAt,omitempty"`
}
type webAuthnChallengeState struct {
Type string `json:"type"`
Challenge string `json:"challenge"`
RPID string `json:"rpId"`
Origin string `json:"origin"`
ExpiresAt time.Time `json:"expiresAt"`
}
type TrustedDeviceRecord struct {
ID string `json:"id"`
Name string `json:"name"`
TokenHash string `json:"tokenHash"`
CreatedAt time.Time `json:"createdAt"`
LastUsedAt time.Time `json:"lastUsedAt"`
ExpiresAt time.Time `json:"expiresAt"`
LastIP string `json:"lastIp"`
}
type TrustedDeviceOutput struct {
ID string `json:"id"`
Name string `json:"name"`
CreatedAt string `json:"createdAt"`
LastUsedAt string `json:"lastUsedAt"`
ExpiresAt string `json:"expiresAt"`
LastIP string `json:"lastIp"`
}
type pendingOutOfBandOTP struct {
Channel string `json:"channel"`
CodeHash string `json:"codeHash"`
ExpiresAt time.Time `json:"expiresAt"`
}
func userMFAEnabled(user *model.User) bool {
if user == nil {
return false
}
return user.TwoFactorEnabled ||
strings.TrimSpace(user.WebAuthnCredentials) != "" ||
user.EmailOTPEnabled ||
user.SMSOTPEnabled
}
func clearTrustedDevicesIfMFAOff(user *model.User) {
if user == nil || userMFAEnabled(user) {
return
}
user.TrustedDevices = ""
user.OutOfBandOTPCiphertext = ""
user.WebAuthnChallengeCiphertext = ""
}
func parseWebAuthnCredentials(value string) ([]WebAuthnCredentialRecord, error) {
if strings.TrimSpace(value) == "" {
return nil, nil
}
var credentials []WebAuthnCredentialRecord
if err := json.Unmarshal([]byte(value), &credentials); err != nil {
return nil, err
}
return credentials, nil
}
func encodeWebAuthnCredentials(credentials []WebAuthnCredentialRecord) (string, error) {
if len(credentials) == 0 {
return "", nil
}
encoded, err := json.Marshal(credentials)
if err != nil {
return "", err
}
return string(encoded), nil
}
func webAuthnCredentialCount(user *model.User) int {
if user == nil {
return 0
}
credentials, err := parseWebAuthnCredentials(user.WebAuthnCredentials)
if err != nil {
return 0
}
return len(credentials)
}
func parseTrustedDevices(value string) ([]TrustedDeviceRecord, error) {
if strings.TrimSpace(value) == "" {
return nil, nil
}
var devices []TrustedDeviceRecord
if err := json.Unmarshal([]byte(value), &devices); err != nil {
return nil, err
}
return devices, nil
}
func encodeTrustedDevices(devices []TrustedDeviceRecord) (string, error) {
if len(devices) == 0 {
return "", nil
}
encoded, err := json.Marshal(devices)
if err != nil {
return "", err
}
return string(encoded), nil
}
func trustedDeviceCount(user *model.User) int {
if user == nil {
return 0
}
devices, err := parseTrustedDevices(user.TrustedDevices)
if err != nil {
return 0
}
now := time.Now().UTC()
count := 0
for _, device := range devices {
if device.ExpiresAt.After(now) {
count++
}
}
return count
}
func toWebAuthnCredentialOutput(record WebAuthnCredentialRecord) WebAuthnCredentialOutput {
return WebAuthnCredentialOutput{
ID: record.ID,
Name: record.Name,
CreatedAt: record.CreatedAt,
LastUsedAt: record.LastUsedAt,
}
}
func toTrustedDeviceOutput(record TrustedDeviceRecord) TrustedDeviceOutput {
return TrustedDeviceOutput{
ID: record.ID,
Name: record.Name,
CreatedAt: record.CreatedAt.Format(time.RFC3339),
LastUsedAt: record.LastUsedAt.Format(time.RFC3339),
ExpiresAt: record.ExpiresAt.Format(time.RFC3339),
LastIP: record.LastIP,
}
}

View File

@@ -0,0 +1,252 @@
package service
import (
"context"
"fmt"
"strings"
"time"
"backupx/server/internal/apperror"
"backupx/server/internal/model"
"backupx/server/internal/security"
)
type OTPConfigInput struct {
CurrentPassword string `json:"currentPassword" binding:"required,min=8,max=128"`
Channel string `json:"channel" binding:"required,oneof=email sms"`
Enabled bool `json:"enabled"`
Email string `json:"email" binding:"omitempty,max=255"`
Phone string `json:"phone" binding:"omitempty,max=64"`
}
type LoginOTPInput struct {
Username string `json:"username" binding:"required,min=3,max=64"`
Password string `json:"password" binding:"required,min=8,max=128"`
Channel string `json:"channel" binding:"required,oneof=email sms"`
}
func (s *AuthService) ConfigureOutOfBandOTP(ctx context.Context, subject string, input OTPConfigInput) (*UserOutput, error) {
user, err := s.userBySubject(ctx, subject)
if err != nil {
return nil, err
}
if err := security.ComparePassword(user.PasswordHash, input.CurrentPassword); err != nil {
return nil, apperror.BadRequest("AUTH_WRONG_PASSWORD", "当前密码不正确", err)
}
channel := strings.TrimSpace(input.Channel)
previousEmail := strings.TrimSpace(user.Email)
previousPhone := strings.TrimSpace(user.Phone)
contactChanged := false
switch channel {
case "email":
email := strings.TrimSpace(input.Email)
if email != "" {
user.Email = email
}
contactChanged = previousEmail != strings.TrimSpace(user.Email)
if input.Enabled && strings.TrimSpace(user.Email) == "" {
return nil, apperror.BadRequest("AUTH_EMAIL_REQUIRED", "请先在用户资料中设置邮箱", nil)
}
user.EmailOTPEnabled = input.Enabled
case "sms":
phone := strings.TrimSpace(input.Phone)
if phone != "" {
user.Phone = phone
}
contactChanged = previousPhone != strings.TrimSpace(user.Phone)
if input.Enabled && strings.TrimSpace(user.Phone) == "" {
return nil, apperror.BadRequest("AUTH_PHONE_REQUIRED", "请先设置手机号", nil)
}
user.SMSOTPEnabled = input.Enabled
default:
return nil, apperror.BadRequest("AUTH_OTP_CHANNEL_INVALID", "验证码渠道不支持", nil)
}
if s.shouldClearPendingOTP(user, channel, contactChanged) {
user.OutOfBandOTPCiphertext = ""
}
clearTrustedDevicesIfMFAOff(user)
if err := s.users.Update(ctx, user); err != nil {
return nil, apperror.Internal("AUTH_OTP_CONFIG_FAILED", "无法更新 OTP 配置", err)
}
if s.auditService != nil {
action := "otp_disable"
if input.Enabled {
action = "otp_enable"
}
s.auditService.Record(AuditEntry{
UserID: user.ID, Username: user.Username,
Category: "auth", Action: action,
TargetType: "otp", TargetID: channel,
Detail: fmt.Sprintf("%s %s OTP", map[bool]string{true: "启用", false: "关闭"}[input.Enabled], channel),
})
}
return ToUserOutput(user), nil
}
func (s *AuthService) SendLoginOTP(ctx context.Context, input LoginOTPInput, clientKey string) error {
user, err := s.verifyPasswordForMFAStart(ctx, input.Username, input.Password, clientKey)
if err != nil {
return err
}
channel := strings.TrimSpace(input.Channel)
if channel == "email" && !user.EmailOTPEnabled {
return apperror.BadRequest("AUTH_EMAIL_OTP_DISABLED", "当前账号未启用邮件验证码", nil)
}
if channel == "sms" && !user.SMSOTPEnabled {
return apperror.BadRequest("AUTH_SMS_OTP_DISABLED", "当前账号未启用短信验证码", nil)
}
code, err := security.GenerateNumericOTP()
if err != nil {
return apperror.Internal("AUTH_OTP_GENERATE_FAILED", "无法生成登录验证码", err)
}
hash, err := security.HashPassword(code)
if err != nil {
return apperror.Internal("AUTH_OTP_GENERATE_FAILED", "无法处理登录验证码", err)
}
pending := pendingOutOfBandOTP{
Channel: channel,
CodeHash: hash,
ExpiresAt: time.Now().UTC().Add(mfaChallengeTTL),
}
ciphertext, err := s.twoFactorCipher.EncryptJSON(pending)
if err != nil {
return apperror.Internal("AUTH_OTP_SAVE_FAILED", "无法保存登录验证码状态", err)
}
user.OutOfBandOTPCiphertext = ciphertext
if err := s.users.Update(ctx, user); err != nil {
return apperror.Internal("AUTH_OTP_SAVE_FAILED", "无法保存登录验证码状态", err)
}
if err := s.deliverLoginOTP(ctx, user, channel, code); err != nil {
user.OutOfBandOTPCiphertext = ""
if updateErr := s.users.Update(ctx, user); updateErr != nil {
return apperror.Internal("AUTH_OTP_SAVE_FAILED", "登录验证码发送失败,且无法回滚验证码状态", updateErr)
}
return apperror.BadRequest("AUTH_OTP_DELIVERY_FAILED", "登录验证码发送失败", err)
}
if s.auditService != nil {
s.auditService.Record(AuditEntry{
UserID: user.ID, Username: user.Username,
Category: "auth", Action: "otp_send",
TargetType: "otp", TargetID: channel,
Detail: "发送登录 OTP", ClientIP: clientKey,
})
}
return nil
}
func (s *AuthService) consumeOutOfBandOTP(ctx context.Context, user *model.User, code string, clientKey string) (bool, error) {
if strings.TrimSpace(user.OutOfBandOTPCiphertext) == "" {
return false, nil
}
var pending pendingOutOfBandOTP
if err := s.twoFactorCipher.DecryptJSON(user.OutOfBandOTPCiphertext, &pending); err != nil {
return false, apperror.Internal("AUTH_OTP_INVALID", "登录验证码状态异常", err)
}
if pending.ExpiresAt.Before(time.Now().UTC()) {
user.OutOfBandOTPCiphertext = ""
if err := s.users.Update(ctx, user); err != nil {
return false, apperror.Internal("AUTH_OTP_CONSUME_FAILED", "无法更新登录验证码状态", err)
}
return false, nil
}
if !outOfBandOTPChannelEnabled(user, pending.Channel) {
user.OutOfBandOTPCiphertext = ""
if err := s.users.Update(ctx, user); err != nil {
return false, apperror.Internal("AUTH_OTP_CONSUME_FAILED", "无法更新登录验证码状态", err)
}
return false, nil
}
if security.ComparePassword(pending.CodeHash, security.NormalizeNumericOTP(code)) != nil {
return false, nil
}
user.OutOfBandOTPCiphertext = ""
if err := s.users.Update(ctx, user); err != nil {
return false, apperror.Internal("AUTH_OTP_CONSUME_FAILED", "无法使用登录验证码", err)
}
if s.auditService != nil {
s.auditService.Record(AuditEntry{
UserID: user.ID, Username: user.Username,
Category: "auth", Action: "otp_used",
TargetType: "otp", TargetID: pending.Channel,
Detail: "使用登录 OTP 完成登录", ClientIP: clientKey,
})
}
return true, nil
}
func (s *AuthService) deliverLoginOTP(ctx context.Context, user *model.User, channel string, code string) error {
if s.notificationService == nil {
return fmt.Errorf("notification service is not configured")
}
switch channel {
case "email":
email := strings.TrimSpace(user.Email)
if email == "" {
return fmt.Errorf("user email is empty")
}
return s.notificationService.SendAuthEmailOTP(ctx, email, code)
case "sms":
phone := strings.TrimSpace(user.Phone)
if phone == "" {
return fmt.Errorf("user phone is empty")
}
return s.notificationService.SendAuthSMSOTP(ctx, phone, code)
default:
return fmt.Errorf("unsupported otp channel: %s", channel)
}
}
func (s *AuthService) verifyPasswordForMFAStart(ctx context.Context, username string, password string, clientKey string) (*model.User, error) {
if clientKey == "" {
clientKey = "unknown"
}
if !s.rateLimiter.Allow(clientKey) {
return nil, apperror.TooManyRequests("AUTH_RATE_LIMITED", "登录尝试过于频繁,请稍后再试", nil)
}
user, err := s.users.FindByUsername(ctx, strings.TrimSpace(username))
if err != nil {
return nil, apperror.Internal("AUTH_LOOKUP_FAILED", "无法执行登录校验", err)
}
if user == nil || user.Disabled {
return nil, apperror.Unauthorized("AUTH_INVALID_CREDENTIALS", "用户名或密码错误", nil)
}
if err := security.ComparePassword(user.PasswordHash, password); err != nil {
if s.auditService != nil {
s.auditService.Record(AuditEntry{
UserID: user.ID, Username: user.Username,
Category: "auth", Action: "login_failed",
Detail: "密码错误", ClientIP: clientKey,
})
}
return nil, apperror.Unauthorized("AUTH_INVALID_CREDENTIALS", "用户名或密码错误", err)
}
if !userMFAEnabled(user) {
return nil, apperror.BadRequest("AUTH_MFA_NOT_ENABLED", "当前账号未启用多因素验证", nil)
}
return user, nil
}
func outOfBandOTPChannelEnabled(user *model.User, channel string) bool {
switch channel {
case "email":
return user.EmailOTPEnabled
case "sms":
return user.SMSOTPEnabled
default:
return false
}
}
func (s *AuthService) shouldClearPendingOTP(user *model.User, changedChannel string, contactChanged bool) bool {
if !user.EmailOTPEnabled && !user.SMSOTPEnabled {
return true
}
if strings.TrimSpace(user.OutOfBandOTPCiphertext) == "" {
return false
}
var pending pendingOutOfBandOTP
if err := s.twoFactorCipher.DecryptJSON(user.OutOfBandOTPCiphertext, &pending); err != nil {
return true
}
return pending.Channel == changedChannel && (contactChanged || !outOfBandOTPChannelEnabled(user, changedChannel))
}

View File

@@ -2,6 +2,7 @@ package service
import (
"context"
"encoding/json"
"errors"
"fmt"
"strconv"
@@ -11,6 +12,7 @@ import (
"backupx/server/internal/model"
"backupx/server/internal/repository"
"backupx/server/internal/security"
"backupx/server/internal/storage/codec"
)
type SetupInput struct {
@@ -20,28 +22,47 @@ type SetupInput struct {
}
type LoginInput struct {
Username string `json:"username" binding:"required,min=3,max=64"`
Password string `json:"password" binding:"required,min=8,max=128"`
Username string `json:"username" binding:"required,min=3,max=64"`
Password string `json:"password" binding:"required,min=8,max=128"`
TwoFactorCode string `json:"twoFactorCode" binding:"omitempty,min=6,max=32"`
WebAuthnAssertion *security.WebAuthnLoginAssertion `json:"webAuthnAssertion"`
TrustedDeviceToken string `json:"trustedDeviceToken"`
RememberDevice bool `json:"rememberDevice"`
TrustedDeviceName string `json:"trustedDeviceName" binding:"omitempty,max=128"`
}
type AuthPayload struct {
Token string `json:"token"`
User *UserOutput `json:"user"`
Token string `json:"token"`
User *UserOutput `json:"user"`
TrustedDeviceToken string `json:"trustedDeviceToken,omitempty"`
TrustedDevice *TrustedDeviceOutput `json:"trustedDevice,omitempty"`
}
type UserOutput struct {
ID uint `json:"id"`
Username string `json:"username"`
DisplayName string `json:"displayName"`
Role string `json:"role"`
ID uint `json:"id"`
Username string `json:"username"`
DisplayName string `json:"displayName"`
Email string `json:"email"`
Phone string `json:"phone"`
Role string `json:"role"`
MFAEnabled bool `json:"mfaEnabled"`
TwoFactorEnabled bool `json:"twoFactorEnabled"`
TwoFactorRecoveryCodesRemaining int `json:"twoFactorRecoveryCodesRemaining"`
WebAuthnEnabled bool `json:"webAuthnEnabled"`
WebAuthnCredentialCount int `json:"webAuthnCredentialCount"`
TrustedDeviceCount int `json:"trustedDeviceCount"`
EmailOTPEnabled bool `json:"emailOtpEnabled"`
SMSOTPEnabled bool `json:"smsOtpEnabled"`
}
type AuthService struct {
users repository.UserRepository
configs repository.SystemConfigRepository
jwtManager *security.JWTManager
rateLimiter *security.LoginRateLimiter
auditService *AuditService
users repository.UserRepository
configs repository.SystemConfigRepository
jwtManager *security.JWTManager
rateLimiter *security.LoginRateLimiter
twoFactorCipher *codec.ConfigCipher
auditService *AuditService
notificationService *NotificationService
}
func NewAuthService(
@@ -49,14 +70,25 @@ func NewAuthService(
configs repository.SystemConfigRepository,
jwtManager *security.JWTManager,
rateLimiter *security.LoginRateLimiter,
twoFactorCipher *codec.ConfigCipher,
) *AuthService {
return &AuthService{users: users, configs: configs, jwtManager: jwtManager, rateLimiter: rateLimiter}
return &AuthService{
users: users,
configs: configs,
jwtManager: jwtManager,
rateLimiter: rateLimiter,
twoFactorCipher: twoFactorCipher,
}
}
func (s *AuthService) SetAuditService(auditService *AuditService) {
s.auditService = auditService
}
func (s *AuthService) SetNotificationService(notificationService *NotificationService) {
s.notificationService = notificationService
}
func (s *AuthService) SetupStatus(ctx context.Context) (bool, error) {
count, err := s.users.Count(ctx)
if err != nil {
@@ -130,7 +162,7 @@ func (s *AuthService) Login(ctx context.Context, input LoginInput, clientKey str
if s.auditService != nil {
s.auditService.Record(AuditEntry{
Category: "auth", Action: "login_failed",
Detail: fmt.Sprintf("用户名不存在: %s", strings.TrimSpace(input.Username)),
Detail: fmt.Sprintf("用户名不存在: %s", strings.TrimSpace(input.Username)),
ClientIP: clientKey,
})
}
@@ -156,6 +188,20 @@ func (s *AuthService) Login(ctx context.Context, input LoginInput, clientKey str
}
return nil, apperror.Unauthorized("AUTH_INVALID_CREDENTIALS", "用户名或密码错误", err)
}
mfaRequired := userMFAEnabled(user)
trustedDeviceUsed := false
if mfaRequired {
trusted, err := s.verifyTrustedDevice(ctx, user, input.TrustedDeviceToken, clientKey)
if err != nil {
return nil, err
}
trustedDeviceUsed = trusted
if !trusted {
if err := s.verifyLoginMFA(ctx, user, input, clientKey); err != nil {
return nil, err
}
}
}
s.rateLimiter.Reset(clientKey)
token, err := s.jwtManager.Generate(user)
@@ -163,6 +209,16 @@ func (s *AuthService) Login(ctx context.Context, input LoginInput, clientKey str
return nil, apperror.Internal("AUTH_TOKEN_FAILED", "无法生成访问令牌", err)
}
payload := &AuthPayload{Token: token, User: ToUserOutput(user)}
if mfaRequired && !trustedDeviceUsed && input.RememberDevice {
deviceToken, device, err := s.issueTrustedDevice(ctx, user, input.TrustedDeviceName, clientKey)
if err != nil {
return nil, err
}
payload.TrustedDeviceToken = deviceToken
payload.TrustedDevice = device
}
if s.auditService != nil {
s.auditService.Record(AuditEntry{
UserID: user.ID, Username: user.Username,
@@ -171,10 +227,72 @@ func (s *AuthService) Login(ctx context.Context, input LoginInput, clientKey str
})
}
return &AuthPayload{Token: token, User: ToUserOutput(user)}, nil
return payload, nil
}
func (s *AuthService) GetCurrentUser(ctx context.Context, subject string) (*UserOutput, error) {
func (s *AuthService) verifyLoginMFA(ctx context.Context, user *model.User, input LoginInput, clientKey string) error {
if input.WebAuthnAssertion != nil {
if err := s.VerifyWebAuthnLogin(ctx, user, *input.WebAuthnAssertion, clientKey); err != nil {
if s.auditService != nil {
s.auditService.Record(AuditEntry{
UserID: user.ID, Username: user.Username,
Category: "auth", Action: "login_failed",
Detail: "通行密钥校验失败", ClientIP: clientKey,
})
}
return err
}
return nil
}
code := strings.TrimSpace(input.TwoFactorCode)
if code == "" {
if s.auditService != nil {
s.auditService.Record(AuditEntry{
UserID: user.ID, Username: user.Username,
Category: "auth", Action: "two_factor_required",
Detail: "登录需要多因素验证", ClientIP: clientKey,
})
}
return apperror.Unauthorized("AUTH_2FA_REQUIRED", "请输入验证码、恢复码或使用通行密钥", nil)
}
if user.TwoFactorEnabled {
secret, err := s.decryptTwoFactorSecret(user.TwoFactorSecretCiphertext)
if err != nil {
return apperror.Internal("AUTH_2FA_SECRET_INVALID", "TOTP 配置异常", err)
}
ok, err := security.ValidateTOTPCode(secret, code)
if err == nil && ok {
return nil
}
if consumed, err := s.consumeRecoveryCode(ctx, user, code); err != nil {
return err
} else if consumed {
if s.auditService != nil {
s.auditService.Record(AuditEntry{
UserID: user.ID, Username: user.Username,
Category: "auth", Action: "two_factor_recovery_code_used",
Detail: "使用恢复码完成登录", ClientIP: clientKey,
})
}
return nil
}
}
if consumed, err := s.consumeOutOfBandOTP(ctx, user, code, clientKey); err != nil {
return err
} else if consumed {
return nil
}
if s.auditService != nil {
s.auditService.Record(AuditEntry{
UserID: user.ID, Username: user.Username,
Category: "auth", Action: "login_failed",
Detail: "多因素验证码错误", ClientIP: clientKey,
})
}
return apperror.Unauthorized("AUTH_2FA_INVALID", "验证码、恢复码或通行密钥错误", nil)
}
func (s *AuthService) userBySubject(ctx context.Context, subject string) (*model.User, error) {
userID, err := strconv.ParseUint(subject, 10, 64)
if err != nil {
return nil, apperror.Unauthorized("AUTH_INVALID_SUBJECT", "无效用户身份", err)
@@ -186,6 +304,14 @@ func (s *AuthService) GetCurrentUser(ctx context.Context, subject string) (*User
if user == nil {
return nil, apperror.Unauthorized("AUTH_USER_NOT_FOUND", "当前用户不存在", errors.New("user not found"))
}
return user, nil
}
func (s *AuthService) GetCurrentUser(ctx context.Context, subject string) (*UserOutput, error) {
user, err := s.userBySubject(ctx, subject)
if err != nil {
return nil, err
}
return ToUserOutput(user), nil
}
@@ -195,16 +321,9 @@ type ChangePasswordInput struct {
}
func (s *AuthService) ChangePassword(ctx context.Context, subject string, input ChangePasswordInput) error {
userID, err := strconv.ParseUint(subject, 10, 64)
user, err := s.userBySubject(ctx, subject)
if err != nil {
return apperror.Unauthorized("AUTH_INVALID_SUBJECT", "无效用户身份", err)
}
user, err := s.users.FindByID(ctx, uint(userID))
if err != nil {
return apperror.Internal("AUTH_LOOKUP_FAILED", "无法获取当前用户", err)
}
if user == nil {
return apperror.Unauthorized("AUTH_USER_NOT_FOUND", "当前用户不存在", errors.New("user not found"))
return err
}
if err := security.ComparePassword(user.PasswordHash, input.OldPassword); err != nil {
return apperror.BadRequest("AUTH_WRONG_PASSWORD", "旧密码不正确", err)
@@ -214,6 +333,9 @@ func (s *AuthService) ChangePassword(ctx context.Context, subject string, input
return apperror.Internal("AUTH_HASH_FAILED", "无法处理密码", err)
}
user.PasswordHash = hash
user.TrustedDevices = ""
user.OutOfBandOTPCiphertext = ""
user.WebAuthnChallengeCiphertext = ""
if err := s.users.Update(ctx, user); err != nil {
return apperror.Internal("AUTH_UPDATE_FAILED", "密码修改失败", err)
}
@@ -229,15 +351,338 @@ func (s *AuthService) ChangePassword(ctx context.Context, subject string, input
return nil
}
type TwoFactorSetupInput struct {
CurrentPassword string `json:"currentPassword" binding:"required,min=8,max=128"`
}
type TwoFactorSetupOutput struct {
Secret string `json:"secret"`
OTPAuthURL string `json:"otpAuthUrl"`
QRCodeDataURL string `json:"qrCodeDataUrl"`
TwoFactorEnabled bool `json:"twoFactorEnabled"`
TwoFactorConfirmed bool `json:"twoFactorConfirmed"`
}
type EnableTwoFactorInput struct {
Code string `json:"code" binding:"required,min=6,max=10"`
}
type EnableTwoFactorOutput struct {
User *UserOutput `json:"user"`
RecoveryCodes []string `json:"recoveryCodes"`
}
type DisableTwoFactorInput struct {
CurrentPassword string `json:"currentPassword" binding:"required,min=8,max=128"`
Code string `json:"code" binding:"required,min=6,max=32"`
}
type RegenerateRecoveryCodesInput struct {
CurrentPassword string `json:"currentPassword" binding:"required,min=8,max=128"`
Code string `json:"code" binding:"required,min=6,max=10"`
}
type RecoveryCodesOutput struct {
User *UserOutput `json:"user"`
RecoveryCodes []string `json:"recoveryCodes"`
}
func (s *AuthService) PrepareTwoFactor(ctx context.Context, subject string, input TwoFactorSetupInput) (*TwoFactorSetupOutput, error) {
user, err := s.userBySubject(ctx, subject)
if err != nil {
return nil, err
}
if user.TwoFactorEnabled {
return nil, apperror.Conflict("AUTH_2FA_ALREADY_ENABLED", "TOTP 已启用", nil)
}
if err := security.ComparePassword(user.PasswordHash, input.CurrentPassword); err != nil {
return nil, apperror.BadRequest("AUTH_WRONG_PASSWORD", "当前密码不正确", err)
}
enrollment, err := security.GenerateTOTPEnrollment(user.Username)
if err != nil {
return nil, apperror.Internal("AUTH_2FA_SETUP_FAILED", "无法生成 TOTP 密钥", err)
}
ciphertext, err := s.encryptTwoFactorSecret(enrollment.Secret)
if err != nil {
return nil, apperror.Internal("AUTH_2FA_SAVE_FAILED", "无法保存 TOTP 密钥", err)
}
user.TwoFactorSecretCiphertext = ciphertext
user.TwoFactorEnabled = false
if err := s.users.Update(ctx, user); err != nil {
return nil, apperror.Internal("AUTH_2FA_SAVE_FAILED", "无法保存 TOTP 密钥", err)
}
if s.auditService != nil {
s.auditService.Record(AuditEntry{
UserID: user.ID, Username: user.Username,
Category: "auth", Action: "two_factor_setup",
TargetType: "user", TargetID: fmt.Sprintf("%d", user.ID), TargetName: user.Username,
Detail: "生成 TOTP 密钥",
})
}
return &TwoFactorSetupOutput{
Secret: enrollment.Secret,
OTPAuthURL: enrollment.OTPAuthURL,
QRCodeDataURL: enrollment.QRCodeDataURL,
TwoFactorEnabled: false,
TwoFactorConfirmed: false,
}, nil
}
func (s *AuthService) EnableTwoFactor(ctx context.Context, subject string, input EnableTwoFactorInput) (*EnableTwoFactorOutput, error) {
user, err := s.userBySubject(ctx, subject)
if err != nil {
return nil, err
}
if user.TwoFactorEnabled {
return nil, apperror.Conflict("AUTH_2FA_ALREADY_ENABLED", "TOTP 已启用", nil)
}
if strings.TrimSpace(user.TwoFactorSecretCiphertext) == "" {
return nil, apperror.BadRequest("AUTH_2FA_NOT_PREPARED", "请先生成 TOTP 密钥", nil)
}
secret, err := s.decryptTwoFactorSecret(user.TwoFactorSecretCiphertext)
if err != nil {
return nil, apperror.Internal("AUTH_2FA_SECRET_INVALID", "TOTP 配置异常", err)
}
ok, err := security.ValidateTOTPCode(secret, input.Code)
if err != nil {
return nil, apperror.BadRequest("AUTH_2FA_INVALID", "TOTP 验证码格式不正确", err)
}
if !ok {
return nil, apperror.BadRequest("AUTH_2FA_INVALID", "TOTP 验证码错误", nil)
}
recoveryCodes, recoveryHashes, err := s.generateRecoveryCodeHashes()
if err != nil {
return nil, apperror.Internal("AUTH_2FA_RECOVERY_FAILED", "无法生成恢复码", err)
}
user.TwoFactorEnabled = true
user.TwoFactorRecoveryCodeHashes = recoveryHashes
if err := s.users.Update(ctx, user); err != nil {
return nil, apperror.Internal("AUTH_2FA_ENABLE_FAILED", "无法启用 TOTP", err)
}
if s.auditService != nil {
s.auditService.Record(AuditEntry{
UserID: user.ID, Username: user.Username,
Category: "auth", Action: "two_factor_enable",
TargetType: "user", TargetID: fmt.Sprintf("%d", user.ID), TargetName: user.Username,
Detail: "启用 TOTP",
})
}
return &EnableTwoFactorOutput{User: ToUserOutput(user), RecoveryCodes: recoveryCodes}, nil
}
func (s *AuthService) DisableTwoFactor(ctx context.Context, subject string, input DisableTwoFactorInput) (*UserOutput, error) {
user, err := s.userBySubject(ctx, subject)
if err != nil {
return nil, err
}
if !user.TwoFactorEnabled {
return nil, apperror.BadRequest("AUTH_2FA_NOT_ENABLED", "TOTP 未启用", nil)
}
if err := security.ComparePassword(user.PasswordHash, input.CurrentPassword); err != nil {
return nil, apperror.BadRequest("AUTH_WRONG_PASSWORD", "当前密码不正确", err)
}
secret, err := s.decryptTwoFactorSecret(user.TwoFactorSecretCiphertext)
if err != nil {
return nil, apperror.Internal("AUTH_2FA_SECRET_INVALID", "TOTP 配置异常", err)
}
ok, err := security.ValidateTOTPCode(secret, input.Code)
if err != nil {
return nil, apperror.BadRequest("AUTH_2FA_INVALID", "TOTP 验证码格式不正确", err)
}
if !ok {
return nil, apperror.BadRequest("AUTH_2FA_INVALID", "TOTP 验证码错误", nil)
}
user.TwoFactorEnabled = false
user.TwoFactorSecretCiphertext = ""
user.TwoFactorRecoveryCodeHashes = ""
clearTrustedDevicesIfMFAOff(user)
if err := s.users.Update(ctx, user); err != nil {
return nil, apperror.Internal("AUTH_2FA_DISABLE_FAILED", "无法关闭 TOTP", err)
}
if s.auditService != nil {
s.auditService.Record(AuditEntry{
UserID: user.ID, Username: user.Username,
Category: "auth", Action: "two_factor_disable",
TargetType: "user", TargetID: fmt.Sprintf("%d", user.ID), TargetName: user.Username,
Detail: "关闭 TOTP",
})
}
return ToUserOutput(user), nil
}
func (s *AuthService) verifyCurrentTOTP(user *model.User, code string) error {
secret, err := s.decryptTwoFactorSecret(user.TwoFactorSecretCiphertext)
if err != nil {
return apperror.Internal("AUTH_2FA_SECRET_INVALID", "TOTP 配置异常", err)
}
ok, err := security.ValidateTOTPCode(secret, code)
if err != nil {
return apperror.BadRequest("AUTH_2FA_INVALID", "TOTP 验证码格式不正确", err)
}
if !ok {
return apperror.BadRequest("AUTH_2FA_INVALID", "TOTP 验证码错误", nil)
}
return nil
}
func (s *AuthService) RegenerateRecoveryCodes(ctx context.Context, subject string, input RegenerateRecoveryCodesInput) (*RecoveryCodesOutput, error) {
user, err := s.userBySubject(ctx, subject)
if err != nil {
return nil, err
}
if !user.TwoFactorEnabled {
return nil, apperror.BadRequest("AUTH_2FA_NOT_ENABLED", "TOTP 未启用", nil)
}
if err := security.ComparePassword(user.PasswordHash, input.CurrentPassword); err != nil {
return nil, apperror.BadRequest("AUTH_WRONG_PASSWORD", "当前密码不正确", err)
}
if err := s.verifyCurrentTOTP(user, input.Code); err != nil {
return nil, err
}
recoveryCodes, recoveryHashes, err := s.generateRecoveryCodeHashes()
if err != nil {
return nil, apperror.Internal("AUTH_2FA_RECOVERY_FAILED", "无法生成恢复码", err)
}
user.TwoFactorRecoveryCodeHashes = recoveryHashes
if err := s.users.Update(ctx, user); err != nil {
return nil, apperror.Internal("AUTH_2FA_RECOVERY_FAILED", "无法更新恢复码", err)
}
if s.auditService != nil {
s.auditService.Record(AuditEntry{
UserID: user.ID, Username: user.Username,
Category: "auth", Action: "two_factor_recovery_codes_regenerate",
TargetType: "user", TargetID: fmt.Sprintf("%d", user.ID), TargetName: user.Username,
Detail: "重新生成 TOTP 恢复码",
})
}
return &RecoveryCodesOutput{User: ToUserOutput(user), RecoveryCodes: recoveryCodes}, nil
}
func (s *AuthService) generateRecoveryCodeHashes() ([]string, string, error) {
codes, err := security.GenerateRecoveryCodes(security.RecoveryCodeCount)
if err != nil {
return nil, "", err
}
hashes := make([]string, 0, len(codes))
for _, code := range codes {
hash, err := security.HashPassword(security.NormalizeRecoveryCode(code))
if err != nil {
return nil, "", err
}
hashes = append(hashes, hash)
}
encoded, err := encodeRecoveryCodeHashes(hashes)
if err != nil {
return nil, "", err
}
return codes, encoded, nil
}
func (s *AuthService) consumeRecoveryCode(ctx context.Context, user *model.User, code string) (bool, error) {
if !security.IsRecoveryCodeCandidate(code) {
return false, nil
}
hashes, err := parseRecoveryCodeHashes(user.TwoFactorRecoveryCodeHashes)
if err != nil {
return false, apperror.Internal("AUTH_2FA_RECOVERY_INVALID", "恢复码配置异常", err)
}
if len(hashes) == 0 {
return false, nil
}
normalized := security.NormalizeRecoveryCode(code)
for i, hash := range hashes {
if security.ComparePassword(hash, normalized) != nil {
continue
}
hashes = append(hashes[:i], hashes[i+1:]...)
encoded, err := encodeRecoveryCodeHashes(hashes)
if err != nil {
return false, apperror.Internal("AUTH_2FA_RECOVERY_INVALID", "恢复码配置异常", err)
}
user.TwoFactorRecoveryCodeHashes = encoded
if err := s.users.Update(ctx, user); err != nil {
return false, apperror.Internal("AUTH_2FA_RECOVERY_CONSUME_FAILED", "无法使用恢复码", err)
}
return true, nil
}
return false, nil
}
func (s *AuthService) encryptTwoFactorSecret(secret string) (string, error) {
if s.twoFactorCipher == nil {
return "", errors.New("two-factor cipher is not configured")
}
return s.twoFactorCipher.Encrypt([]byte(strings.TrimSpace(secret)))
}
func (s *AuthService) decryptTwoFactorSecret(ciphertext string) (string, error) {
if s.twoFactorCipher == nil {
return "", errors.New("two-factor cipher is not configured")
}
raw, err := s.twoFactorCipher.Decrypt(strings.TrimSpace(ciphertext))
if err != nil {
return "", err
}
return strings.TrimSpace(string(raw)), nil
}
func parseRecoveryCodeHashes(encoded string) ([]string, error) {
if strings.TrimSpace(encoded) == "" {
return nil, nil
}
var hashes []string
if err := json.Unmarshal([]byte(encoded), &hashes); err != nil {
return nil, err
}
return hashes, nil
}
func encodeRecoveryCodeHashes(hashes []string) (string, error) {
if len(hashes) == 0 {
return "", nil
}
encoded, err := json.Marshal(hashes)
if err != nil {
return "", err
}
return string(encoded), nil
}
func recoveryCodeRemainingCount(user *model.User) int {
if user == nil {
return 0
}
hashes, err := parseRecoveryCodeHashes(user.TwoFactorRecoveryCodeHashes)
if err != nil {
return 0
}
return len(hashes)
}
func ToUserOutput(user *model.User) *UserOutput {
if user == nil {
return nil
}
return &UserOutput{
ID: user.ID,
Username: user.Username,
DisplayName: user.DisplayName,
Role: user.Role,
ID: user.ID,
Username: user.Username,
DisplayName: user.DisplayName,
Email: user.Email,
Phone: user.Phone,
Role: user.Role,
MFAEnabled: userMFAEnabled(user),
TwoFactorEnabled: user.TwoFactorEnabled,
TwoFactorRecoveryCodesRemaining: recoveryCodeRemainingCount(user),
WebAuthnEnabled: webAuthnCredentialCount(user) > 0,
WebAuthnCredentialCount: webAuthnCredentialCount(user),
TrustedDeviceCount: trustedDeviceCount(user),
EmailOTPEnabled: user.EmailOTPEnabled,
SMSOTPEnabled: user.SMSOTPEnabled,
}
}

View File

@@ -5,8 +5,11 @@ import (
"testing"
"time"
"backupx/server/internal/apperror"
"backupx/server/internal/model"
"backupx/server/internal/security"
"backupx/server/internal/storage/codec"
"github.com/pquerna/otp/totp"
)
type fakeUserRepository struct {
@@ -100,6 +103,7 @@ func TestAuthServiceSetupAndLogin(t *testing.T) {
&fakeSystemConfigRepository{},
security.NewJWTManager("test-secret", time.Hour),
security.NewLoginRateLimiter(5, time.Minute),
codec.NewConfigCipher("test-encryption-secret"),
)
setupResult, err := service.Setup(context.Background(), SetupInput{
@@ -133,6 +137,7 @@ func newTestAuthService() (*AuthService, *fakeUserRepository) {
&fakeSystemConfigRepository{},
security.NewJWTManager("test-secret", time.Hour),
security.NewLoginRateLimiter(5, time.Minute),
codec.NewConfigCipher("test-encryption-secret"),
)
return svc, users
}
@@ -188,3 +193,425 @@ func TestChangePasswordWrongOld(t *testing.T) {
t.Fatalf("expected ChangePassword with wrong old password to fail")
}
}
func TestAuthServiceLoginRequiresTwoFactorWhenEnabled(t *testing.T) {
svc, _ := newTestAuthService()
_, err := svc.Setup(context.Background(), SetupInput{
Username: "admin", Password: "password-123", DisplayName: "Admin",
})
if err != nil {
t.Fatalf("Setup: %v", err)
}
setup, err := svc.PrepareTwoFactor(context.Background(), "1", TwoFactorSetupInput{
CurrentPassword: "password-123",
})
if err != nil {
t.Fatalf("PrepareTwoFactor: %v", err)
}
if setup.Secret == "" || setup.QRCodeDataURL == "" || setup.OTPAuthURL == "" {
t.Fatalf("expected populated 2FA enrollment, got %#v", setup)
}
code, err := totp.GenerateCode(setup.Secret, time.Now().UTC())
if err != nil {
t.Fatalf("GenerateCode: %v", err)
}
enabledUser, err := svc.EnableTwoFactor(context.Background(), "1", EnableTwoFactorInput{Code: code})
if err != nil {
t.Fatalf("EnableTwoFactor: %v", err)
}
if !enabledUser.User.TwoFactorEnabled {
t.Fatalf("expected 2FA enabled")
}
if len(enabledUser.RecoveryCodes) != security.RecoveryCodeCount {
t.Fatalf("expected %d recovery codes, got %d", security.RecoveryCodeCount, len(enabledUser.RecoveryCodes))
}
_, err = svc.Login(context.Background(), LoginInput{
Username: "admin", Password: "password-123",
}, "127.0.0.1")
if appErr, ok := err.(*apperror.AppError); !ok || appErr.Code != "AUTH_2FA_REQUIRED" {
t.Fatalf("expected AUTH_2FA_REQUIRED, got %v", err)
}
loginCode, err := totp.GenerateCode(setup.Secret, time.Now().UTC())
if err != nil {
t.Fatalf("GenerateCode login: %v", err)
}
loginResult, err := svc.Login(context.Background(), LoginInput{
Username: "admin", Password: "password-123", TwoFactorCode: loginCode,
}, "127.0.0.1")
if err != nil {
t.Fatalf("Login with 2FA: %v", err)
}
if loginResult.Token == "" {
t.Fatalf("expected non-empty token")
}
}
func TestAuthServiceDisableTwoFactor(t *testing.T) {
svc, _ := newTestAuthService()
_, err := svc.Setup(context.Background(), SetupInput{
Username: "admin", Password: "password-123", DisplayName: "Admin",
})
if err != nil {
t.Fatalf("Setup: %v", err)
}
setup, err := svc.PrepareTwoFactor(context.Background(), "1", TwoFactorSetupInput{
CurrentPassword: "password-123",
})
if err != nil {
t.Fatalf("PrepareTwoFactor: %v", err)
}
code, err := totp.GenerateCode(setup.Secret, time.Now().UTC())
if err != nil {
t.Fatalf("GenerateCode: %v", err)
}
if _, err := svc.EnableTwoFactor(context.Background(), "1", EnableTwoFactorInput{Code: code}); err != nil {
t.Fatalf("EnableTwoFactor: %v", err)
}
disableCode, err := totp.GenerateCode(setup.Secret, time.Now().UTC())
if err != nil {
t.Fatalf("GenerateCode disable: %v", err)
}
user, err := svc.DisableTwoFactor(context.Background(), "1", DisableTwoFactorInput{
CurrentPassword: "password-123",
Code: disableCode,
})
if err != nil {
t.Fatalf("DisableTwoFactor: %v", err)
}
if user.TwoFactorEnabled {
t.Fatalf("expected 2FA disabled")
}
loginResult, err := svc.Login(context.Background(), LoginInput{
Username: "admin", Password: "password-123",
}, "127.0.0.1")
if err != nil {
t.Fatalf("Login after disable: %v", err)
}
if loginResult.Token == "" {
t.Fatalf("expected non-empty token")
}
}
func TestAuthServiceRecoveryCodeLoginConsumesCode(t *testing.T) {
svc, _ := newTestAuthService()
_, err := svc.Setup(context.Background(), SetupInput{
Username: "admin", Password: "password-123", DisplayName: "Admin",
})
if err != nil {
t.Fatalf("Setup: %v", err)
}
setup, err := svc.PrepareTwoFactor(context.Background(), "1", TwoFactorSetupInput{
CurrentPassword: "password-123",
})
if err != nil {
t.Fatalf("PrepareTwoFactor: %v", err)
}
code, err := totp.GenerateCode(setup.Secret, time.Now().UTC())
if err != nil {
t.Fatalf("GenerateCode: %v", err)
}
enabled, err := svc.EnableTwoFactor(context.Background(), "1", EnableTwoFactorInput{Code: code})
if err != nil {
t.Fatalf("EnableTwoFactor: %v", err)
}
recoveryCode := enabled.RecoveryCodes[0]
loginResult, err := svc.Login(context.Background(), LoginInput{
Username: "admin", Password: "password-123", TwoFactorCode: recoveryCode,
}, "127.0.0.1")
if err != nil {
t.Fatalf("Login with recovery code: %v", err)
}
if loginResult.User.TwoFactorRecoveryCodesRemaining != security.RecoveryCodeCount-1 {
t.Fatalf("expected one recovery code consumed, got remaining=%d", loginResult.User.TwoFactorRecoveryCodesRemaining)
}
_, err = svc.Login(context.Background(), LoginInput{
Username: "admin", Password: "password-123", TwoFactorCode: recoveryCode,
}, "127.0.0.1")
if appErr, ok := err.(*apperror.AppError); !ok || appErr.Code != "AUTH_2FA_INVALID" {
t.Fatalf("expected consumed recovery code to fail, got %v", err)
}
}
func TestAuthServiceRegenerateRecoveryCodesInvalidatesOldCodes(t *testing.T) {
svc, _ := newTestAuthService()
_, err := svc.Setup(context.Background(), SetupInput{
Username: "admin", Password: "password-123", DisplayName: "Admin",
})
if err != nil {
t.Fatalf("Setup: %v", err)
}
setup, err := svc.PrepareTwoFactor(context.Background(), "1", TwoFactorSetupInput{
CurrentPassword: "password-123",
})
if err != nil {
t.Fatalf("PrepareTwoFactor: %v", err)
}
code, err := totp.GenerateCode(setup.Secret, time.Now().UTC())
if err != nil {
t.Fatalf("GenerateCode: %v", err)
}
enabled, err := svc.EnableTwoFactor(context.Background(), "1", EnableTwoFactorInput{Code: code})
if err != nil {
t.Fatalf("EnableTwoFactor: %v", err)
}
oldRecoveryCode := enabled.RecoveryCodes[0]
regenerateCode, err := totp.GenerateCode(setup.Secret, time.Now().UTC())
if err != nil {
t.Fatalf("GenerateCode regenerate: %v", err)
}
regenerated, err := svc.RegenerateRecoveryCodes(context.Background(), "1", RegenerateRecoveryCodesInput{
CurrentPassword: "password-123",
Code: regenerateCode,
})
if err != nil {
t.Fatalf("RegenerateRecoveryCodes: %v", err)
}
if len(regenerated.RecoveryCodes) != security.RecoveryCodeCount {
t.Fatalf("expected %d recovery codes, got %d", security.RecoveryCodeCount, len(regenerated.RecoveryCodes))
}
_, err = svc.Login(context.Background(), LoginInput{
Username: "admin", Password: "password-123", TwoFactorCode: oldRecoveryCode,
}, "127.0.0.1")
if appErr, ok := err.(*apperror.AppError); !ok || appErr.Code != "AUTH_2FA_INVALID" {
t.Fatalf("expected old recovery code to fail, got %v", err)
}
}
func TestAuthServiceTrustedDeviceSkipsMFA(t *testing.T) {
svc, repo := newTestAuthService()
_, err := svc.Setup(context.Background(), SetupInput{
Username: "admin", Password: "password-123", DisplayName: "Admin",
})
if err != nil {
t.Fatalf("Setup: %v", err)
}
setup, err := svc.PrepareTwoFactor(context.Background(), "1", TwoFactorSetupInput{
CurrentPassword: "password-123",
})
if err != nil {
t.Fatalf("PrepareTwoFactor: %v", err)
}
code, err := totp.GenerateCode(setup.Secret, time.Now().UTC())
if err != nil {
t.Fatalf("GenerateCode: %v", err)
}
if _, err := svc.EnableTwoFactor(context.Background(), "1", EnableTwoFactorInput{Code: code}); err != nil {
t.Fatalf("EnableTwoFactor: %v", err)
}
loginCode, err := totp.GenerateCode(setup.Secret, time.Now().UTC())
if err != nil {
t.Fatalf("GenerateCode login: %v", err)
}
firstLogin, err := svc.Login(context.Background(), LoginInput{
Username: "admin", Password: "password-123", TwoFactorCode: loginCode,
RememberDevice: true, TrustedDeviceName: "test browser",
}, "127.0.0.1")
if err != nil {
t.Fatalf("Login with 2FA: %v", err)
}
if firstLogin.TrustedDeviceToken == "" || firstLogin.TrustedDevice == nil {
t.Fatalf("expected trusted device token")
}
secondLogin, err := svc.Login(context.Background(), LoginInput{
Username: "admin", Password: "password-123", TrustedDeviceToken: firstLogin.TrustedDeviceToken,
}, "127.0.0.1")
if err != nil {
t.Fatalf("Login with trusted device: %v", err)
}
if secondLogin.Token == "" {
t.Fatalf("expected token")
}
disableCode, err := totp.GenerateCode(setup.Secret, time.Now().UTC())
if err != nil {
t.Fatalf("GenerateCode disable: %v", err)
}
if _, err := svc.DisableTwoFactor(context.Background(), "1", DisableTwoFactorInput{
CurrentPassword: "password-123",
Code: disableCode,
}); err != nil {
t.Fatalf("DisableTwoFactor: %v", err)
}
if repo.users[0].TrustedDevices != "" {
t.Fatalf("expected trusted devices cleared after disabling last MFA method")
}
}
func TestAuthServiceOutOfBandOTPLoginConsumesCode(t *testing.T) {
svc, repo := newTestAuthService()
_, err := svc.Setup(context.Background(), SetupInput{
Username: "admin", Password: "password-123", DisplayName: "Admin",
})
if err != nil {
t.Fatalf("Setup: %v", err)
}
user := repo.users[0]
user.Email = "admin@example.com"
user.EmailOTPEnabled = true
hash, err := security.HashPassword("123456")
if err != nil {
t.Fatalf("HashPassword: %v", err)
}
ciphertext, err := svc.twoFactorCipher.EncryptJSON(pendingOutOfBandOTP{
Channel: "email", CodeHash: hash, ExpiresAt: time.Now().UTC().Add(time.Minute),
})
if err != nil {
t.Fatalf("EncryptJSON: %v", err)
}
user.OutOfBandOTPCiphertext = ciphertext
if err := repo.Update(context.Background(), user); err != nil {
t.Fatalf("Update: %v", err)
}
loginResult, err := svc.Login(context.Background(), LoginInput{
Username: "admin", Password: "password-123", TwoFactorCode: "123456",
}, "127.0.0.1")
if err != nil {
t.Fatalf("Login with email OTP: %v", err)
}
if loginResult.Token == "" {
t.Fatalf("expected token")
}
if repo.users[0].OutOfBandOTPCiphertext != "" {
t.Fatalf("expected OTP to be consumed")
}
_, err = svc.Login(context.Background(), LoginInput{
Username: "admin", Password: "password-123", TwoFactorCode: "123456",
}, "127.0.0.1")
if appErr, ok := err.(*apperror.AppError); !ok || appErr.Code != "AUTH_2FA_INVALID" {
t.Fatalf("expected consumed OTP to fail, got %v", err)
}
}
func TestAuthServiceMFAStartIsRateLimited(t *testing.T) {
svc, repo := newTestAuthService()
_, err := svc.Setup(context.Background(), SetupInput{
Username: "admin", Password: "password-123", DisplayName: "Admin",
})
if err != nil {
t.Fatalf("Setup: %v", err)
}
repo.users[0].Email = "admin@example.com"
repo.users[0].EmailOTPEnabled = true
for i := 0; i < 5; i++ {
_ = svc.SendLoginOTP(context.Background(), LoginOTPInput{
Username: "admin", Password: "wrong-password", Channel: "email",
}, "127.0.0.1")
}
err = svc.SendLoginOTP(context.Background(), LoginOTPInput{
Username: "admin", Password: "wrong-password", Channel: "email",
}, "127.0.0.1")
if appErr, ok := err.(*apperror.AppError); !ok || appErr.Code != "AUTH_RATE_LIMITED" {
t.Fatalf("expected AUTH_RATE_LIMITED, got %v", err)
}
}
func TestAuthServiceDisabledOTPChannelCannotConsumePendingCode(t *testing.T) {
svc, repo := newTestAuthService()
_, err := svc.Setup(context.Background(), SetupInput{
Username: "admin", Password: "password-123", DisplayName: "Admin",
})
if err != nil {
t.Fatalf("Setup: %v", err)
}
user := repo.users[0]
user.Email = "admin@example.com"
user.EmailOTPEnabled = false
user.SMSOTPEnabled = true
hash, err := security.HashPassword("123456")
if err != nil {
t.Fatalf("HashPassword: %v", err)
}
ciphertext, err := svc.twoFactorCipher.EncryptJSON(pendingOutOfBandOTP{
Channel: "email", CodeHash: hash, ExpiresAt: time.Now().UTC().Add(time.Minute),
})
if err != nil {
t.Fatalf("EncryptJSON: %v", err)
}
user.OutOfBandOTPCiphertext = ciphertext
if err := repo.Update(context.Background(), user); err != nil {
t.Fatalf("Update: %v", err)
}
_, err = svc.Login(context.Background(), LoginInput{
Username: "admin", Password: "password-123", TwoFactorCode: "123456",
}, "127.0.0.1")
if appErr, ok := err.(*apperror.AppError); !ok || appErr.Code != "AUTH_2FA_INVALID" {
t.Fatalf("expected disabled OTP channel to fail, got %v", err)
}
if repo.users[0].OutOfBandOTPCiphertext != "" {
t.Fatalf("expected disabled channel OTP to be cleared")
}
}
func TestAuthServiceChangingOTPRecipientClearsPendingCode(t *testing.T) {
svc, repo := newTestAuthService()
_, err := svc.Setup(context.Background(), SetupInput{
Username: "admin", Password: "password-123", DisplayName: "Admin",
})
if err != nil {
t.Fatalf("Setup: %v", err)
}
user := repo.users[0]
user.Email = "old@example.com"
user.EmailOTPEnabled = true
hash, err := security.HashPassword("123456")
if err != nil {
t.Fatalf("HashPassword: %v", err)
}
ciphertext, err := svc.twoFactorCipher.EncryptJSON(pendingOutOfBandOTP{
Channel: "email", CodeHash: hash, ExpiresAt: time.Now().UTC().Add(time.Minute),
})
if err != nil {
t.Fatalf("EncryptJSON: %v", err)
}
user.OutOfBandOTPCiphertext = ciphertext
if err := repo.Update(context.Background(), user); err != nil {
t.Fatalf("Update: %v", err)
}
updated, err := svc.ConfigureOutOfBandOTP(context.Background(), "1", OTPConfigInput{
CurrentPassword: "password-123",
Channel: "email",
Enabled: true,
Email: "new@example.com",
})
if err != nil {
t.Fatalf("ConfigureOutOfBandOTP: %v", err)
}
if updated.Email != "new@example.com" {
t.Fatalf("expected email updated, got %q", updated.Email)
}
if repo.users[0].OutOfBandOTPCiphertext != "" {
t.Fatalf("expected pending email OTP to be cleared after recipient change")
}
}
func TestAuthServiceCorruptWebAuthnCredentialsStillRequireMFA(t *testing.T) {
svc, repo := newTestAuthService()
_, err := svc.Setup(context.Background(), SetupInput{
Username: "admin", Password: "password-123", DisplayName: "Admin",
})
if err != nil {
t.Fatalf("Setup: %v", err)
}
repo.users[0].WebAuthnCredentials = "{invalid-json"
_, err = svc.Login(context.Background(), LoginInput{
Username: "admin", Password: "password-123",
}, "127.0.0.1")
if appErr, ok := err.(*apperror.AppError); !ok || appErr.Code != "AUTH_2FA_REQUIRED" {
t.Fatalf("expected corrupt WebAuthn credentials to require MFA, got %v", err)
}
}

View File

@@ -0,0 +1,221 @@
package service
import (
"context"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"fmt"
"strings"
"time"
"backupx/server/internal/apperror"
"backupx/server/internal/model"
"backupx/server/internal/security"
)
func (s *AuthService) ListTrustedDevices(ctx context.Context, subject string) ([]TrustedDeviceOutput, error) {
user, err := s.userBySubject(ctx, subject)
if err != nil {
return nil, err
}
devices, err := parseTrustedDevices(user.TrustedDevices)
if err != nil {
return nil, apperror.Internal("AUTH_TRUSTED_DEVICE_INVALID", "可信设备配置异常", err)
}
now := time.Now().UTC()
output := make([]TrustedDeviceOutput, 0, len(devices))
for _, device := range devices {
if device.ExpiresAt.Before(now) {
continue
}
output = append(output, toTrustedDeviceOutput(device))
}
return output, nil
}
type TrustedDeviceRevokeInput struct {
CurrentPassword string `json:"currentPassword" binding:"required,min=8,max=128"`
}
func (s *AuthService) RevokeTrustedDevice(ctx context.Context, subject string, id string, input TrustedDeviceRevokeInput) error {
user, err := s.userBySubject(ctx, subject)
if err != nil {
return err
}
if err := security.ComparePassword(user.PasswordHash, input.CurrentPassword); err != nil {
return apperror.BadRequest("AUTH_WRONG_PASSWORD", "当前密码不正确", err)
}
devices, err := parseTrustedDevices(user.TrustedDevices)
if err != nil {
return apperror.Internal("AUTH_TRUSTED_DEVICE_INVALID", "可信设备配置异常", err)
}
found := false
filtered := make([]TrustedDeviceRecord, 0, len(devices))
for _, device := range devices {
if device.ID == strings.TrimSpace(id) {
found = true
} else {
filtered = append(filtered, device)
}
}
if !found {
return apperror.New(404, "AUTH_TRUSTED_DEVICE_NOT_FOUND", "可信设备不存在", nil)
}
encoded, err := encodeTrustedDevices(filtered)
if err != nil {
return apperror.Internal("AUTH_TRUSTED_DEVICE_INVALID", "可信设备配置异常", err)
}
user.TrustedDevices = encoded
if err := s.users.Update(ctx, user); err != nil {
return apperror.Internal("AUTH_TRUSTED_DEVICE_REVOKE_FAILED", "无法移除可信设备", err)
}
if s.auditService != nil {
s.auditService.Record(AuditEntry{
UserID: user.ID, Username: user.Username,
Category: "auth", Action: "trusted_device_revoke",
TargetType: "trusted_device", TargetID: strings.TrimSpace(id),
Detail: "移除可信设备",
})
}
return nil
}
func (s *AuthService) verifyTrustedDevice(ctx context.Context, user *model.User, token string, clientKey string) (bool, error) {
token = strings.TrimSpace(token)
if token == "" {
return false, nil
}
devices, err := parseTrustedDevices(user.TrustedDevices)
if err != nil {
return false, apperror.Internal("AUTH_TRUSTED_DEVICE_INVALID", "可信设备配置异常", err)
}
now := time.Now().UTC()
hash := trustedDeviceTokenHash(token)
changed := false
for i := range devices {
device := &devices[i]
if device.ExpiresAt.Before(now) {
changed = true
continue
}
if subtle.ConstantTimeCompare([]byte(device.TokenHash), []byte(hash)) != 1 {
continue
}
device.LastUsedAt = now
device.LastIP = clientKey
changed = true
encoded, err := encodeTrustedDevices(filterActiveTrustedDevices(devices, now))
if err != nil {
return false, apperror.Internal("AUTH_TRUSTED_DEVICE_INVALID", "可信设备配置异常", err)
}
user.TrustedDevices = encoded
if err := s.users.Update(ctx, user); err != nil {
return false, apperror.Internal("AUTH_TRUSTED_DEVICE_UPDATE_FAILED", "无法更新可信设备", err)
}
if s.auditService != nil {
s.auditService.Record(AuditEntry{
UserID: user.ID, Username: user.Username,
Category: "auth", Action: "trusted_device_used",
TargetType: "trusted_device", TargetID: device.ID, TargetName: device.Name,
Detail: "使用可信设备跳过多因素验证", ClientIP: clientKey,
})
}
return true, nil
}
if changed {
encoded, err := encodeTrustedDevices(filterActiveTrustedDevices(devices, now))
if err != nil {
return false, apperror.Internal("AUTH_TRUSTED_DEVICE_INVALID", "可信设备配置异常", err)
}
user.TrustedDevices = encoded
if err := s.users.Update(ctx, user); err != nil {
return false, apperror.Internal("AUTH_TRUSTED_DEVICE_UPDATE_FAILED", "无法更新可信设备", err)
}
}
return false, nil
}
func (s *AuthService) issueTrustedDevice(ctx context.Context, user *model.User, name string, clientKey string) (string, *TrustedDeviceOutput, error) {
token, err := randomURLToken(32)
if err != nil {
return "", nil, apperror.Internal("AUTH_TRUSTED_DEVICE_CREATE_FAILED", "无法生成可信设备令牌", err)
}
id, err := randomURLToken(16)
if err != nil {
return "", nil, apperror.Internal("AUTH_TRUSTED_DEVICE_CREATE_FAILED", "无法生成可信设备编号", err)
}
now := time.Now().UTC()
deviceName := normalizeTrustedDeviceName(name)
device := TrustedDeviceRecord{
ID: id,
Name: deviceName,
TokenHash: trustedDeviceTokenHash(token),
CreatedAt: now,
LastUsedAt: now,
ExpiresAt: now.Add(trustedDeviceTTL),
LastIP: clientKey,
}
devices, err := parseTrustedDevices(user.TrustedDevices)
if err != nil {
return "", nil, apperror.Internal("AUTH_TRUSTED_DEVICE_INVALID", "可信设备配置异常", err)
}
devices = append(filterActiveTrustedDevices(devices, now), device)
if len(devices) > maxTrustedDevices {
devices = devices[len(devices)-maxTrustedDevices:]
}
encoded, err := encodeTrustedDevices(devices)
if err != nil {
return "", nil, apperror.Internal("AUTH_TRUSTED_DEVICE_INVALID", "可信设备配置异常", err)
}
user.TrustedDevices = encoded
if err := s.users.Update(ctx, user); err != nil {
return "", nil, apperror.Internal("AUTH_TRUSTED_DEVICE_CREATE_FAILED", "无法保存可信设备", err)
}
output := toTrustedDeviceOutput(device)
if s.auditService != nil {
s.auditService.Record(AuditEntry{
UserID: user.ID, Username: user.Username,
Category: "auth", Action: "trusted_device_create",
TargetType: "trusted_device", TargetID: device.ID, TargetName: device.Name,
Detail: fmt.Sprintf("添加可信设备,有效期至 %s", device.ExpiresAt.Format(time.RFC3339)), ClientIP: clientKey,
})
}
return token, &output, nil
}
func filterActiveTrustedDevices(devices []TrustedDeviceRecord, now time.Time) []TrustedDeviceRecord {
active := make([]TrustedDeviceRecord, 0, len(devices))
for _, device := range devices {
if device.ExpiresAt.After(now) {
active = append(active, device)
}
}
return active
}
func trustedDeviceTokenHash(token string) string {
sum := sha256.Sum256([]byte(strings.TrimSpace(token)))
return base64.RawURLEncoding.EncodeToString(sum[:])
}
func randomURLToken(size int) (string, error) {
buf := make([]byte, size)
if _, err := rand.Read(buf); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(buf), nil
}
func normalizeTrustedDeviceName(name string) string {
trimmed := strings.TrimSpace(name)
if trimmed == "" {
return "当前设备"
}
if len([]rune(trimmed)) <= maxTrustedDeviceName {
return trimmed
}
runes := []rune(trimmed)
return string(runes[:maxTrustedDeviceName])
}

View File

@@ -0,0 +1,366 @@
package service
import (
"context"
"fmt"
"strings"
"time"
"backupx/server/internal/apperror"
"backupx/server/internal/model"
"backupx/server/internal/security"
)
type WebAuthnRequestContext struct {
RPID string
Origin string
}
type WebAuthnRegistrationOptionsInput struct {
CurrentPassword string `json:"currentPassword" binding:"required,min=8,max=128"`
}
type WebAuthnRegistrationFinishInput struct {
Name string `json:"name" binding:"omitempty,max=128"`
Credential security.WebAuthnRegistrationResponse `json:"credential" binding:"required"`
}
type WebAuthnCredentialDeleteInput struct {
CurrentPassword string `json:"currentPassword" binding:"required,min=8,max=128"`
}
type WebAuthnLoginOptionsInput struct {
Username string `json:"username" binding:"required,min=3,max=64"`
Password string `json:"password" binding:"required,min=8,max=128"`
}
type webAuthnPublicKeyCredentialParam struct {
Type string `json:"type"`
Alg int `json:"alg"`
}
type webAuthnRelyingParty struct {
Name string `json:"name"`
ID string `json:"id"`
}
type webAuthnUserEntity struct {
ID string `json:"id"`
Name string `json:"name"`
DisplayName string `json:"displayName"`
}
type webAuthnCredentialDescriptor struct {
Type string `json:"type"`
ID string `json:"id"`
}
type webAuthnAuthenticatorSelection struct {
UserVerification string `json:"userVerification"`
}
type WebAuthnRegistrationOptions struct {
Challenge string `json:"challenge"`
RP webAuthnRelyingParty `json:"rp"`
User webAuthnUserEntity `json:"user"`
PubKeyCredParams []webAuthnPublicKeyCredentialParam `json:"pubKeyCredParams"`
Timeout int `json:"timeout"`
Attestation string `json:"attestation"`
AuthenticatorSelection webAuthnAuthenticatorSelection `json:"authenticatorSelection"`
ExcludeCredentials []webAuthnCredentialDescriptor `json:"excludeCredentials"`
}
type WebAuthnLoginOptions struct {
Challenge string `json:"challenge"`
RPID string `json:"rpId"`
Timeout int `json:"timeout"`
UserVerification string `json:"userVerification"`
AllowCredentials []webAuthnCredentialDescriptor `json:"allowCredentials"`
}
func (s *AuthService) BeginWebAuthnRegistration(ctx context.Context, subject string, input WebAuthnRegistrationOptionsInput, request WebAuthnRequestContext) (*WebAuthnRegistrationOptions, error) {
user, err := s.userBySubject(ctx, subject)
if err != nil {
return nil, err
}
if err := security.ComparePassword(user.PasswordHash, input.CurrentPassword); err != nil {
return nil, apperror.BadRequest("AUTH_WRONG_PASSWORD", "当前密码不正确", err)
}
credentials, err := parseWebAuthnCredentials(user.WebAuthnCredentials)
if err != nil {
return nil, apperror.Internal("AUTH_WEBAUTHN_INVALID", "通行密钥配置异常", err)
}
challenge, err := security.GenerateWebAuthnChallenge()
if err != nil {
return nil, apperror.Internal("AUTH_WEBAUTHN_CHALLENGE_FAILED", "无法生成通行密钥挑战", err)
}
state := webAuthnChallengeState{
Type: "register",
Challenge: challenge,
RPID: request.RPID,
Origin: request.Origin,
ExpiresAt: time.Now().UTC().Add(mfaChallengeTTL),
}
if err := s.saveWebAuthnChallenge(ctx, user, state); err != nil {
return nil, err
}
exclude := make([]webAuthnCredentialDescriptor, 0, len(credentials))
for _, credential := range credentials {
exclude = append(exclude, webAuthnCredentialDescriptor{Type: "public-key", ID: credential.CredentialID})
}
return &WebAuthnRegistrationOptions{
Challenge: challenge,
RP: webAuthnRelyingParty{Name: "BackupX", ID: request.RPID},
User: webAuthnUserEntity{
ID: security.EncodeBase64URL([]byte(fmt.Sprintf("%d", user.ID))),
Name: user.Username,
DisplayName: user.DisplayName,
},
PubKeyCredParams: []webAuthnPublicKeyCredentialParam{
{Type: "public-key", Alg: -7},
},
Timeout: int(mfaChallengeTTL / time.Millisecond),
Attestation: "none",
AuthenticatorSelection: webAuthnAuthenticatorSelection{UserVerification: "preferred"},
ExcludeCredentials: exclude,
}, nil
}
func (s *AuthService) FinishWebAuthnRegistration(ctx context.Context, subject string, input WebAuthnRegistrationFinishInput) (*UserOutput, error) {
user, err := s.userBySubject(ctx, subject)
if err != nil {
return nil, err
}
state, err := s.loadWebAuthnChallenge(user, "register")
if err != nil {
return nil, err
}
parsed, err := security.VerifyWebAuthnRegistration(input.Credential, state.Challenge, state.RPID, state.Origin)
if err != nil {
return nil, apperror.BadRequest("AUTH_WEBAUTHN_VERIFY_FAILED", "通行密钥注册校验失败", err)
}
credentials, err := parseWebAuthnCredentials(user.WebAuthnCredentials)
if err != nil {
return nil, apperror.Internal("AUTH_WEBAUTHN_INVALID", "通行密钥配置异常", err)
}
for _, credential := range credentials {
if credential.CredentialID == parsed.CredentialID {
return nil, apperror.Conflict("AUTH_WEBAUTHN_EXISTS", "该通行密钥已注册", nil)
}
}
id, err := randomURLToken(16)
if err != nil {
return nil, apperror.Internal("AUTH_WEBAUTHN_SAVE_FAILED", "无法生成通行密钥编号", err)
}
now := time.Now().UTC().Format(time.RFC3339)
name := strings.TrimSpace(input.Name)
if name == "" {
name = "通行密钥"
}
credentials = append(credentials, WebAuthnCredentialRecord{
ID: id,
Name: normalizeTrustedDeviceName(name),
CredentialID: parsed.CredentialID,
PublicKeyX: parsed.PublicKeyX,
PublicKeyY: parsed.PublicKeyY,
SignCount: parsed.SignCount,
CreatedAt: now,
})
encoded, err := encodeWebAuthnCredentials(credentials)
if err != nil {
return nil, apperror.Internal("AUTH_WEBAUTHN_SAVE_FAILED", "无法保存通行密钥", err)
}
user.WebAuthnCredentials = encoded
user.WebAuthnChallengeCiphertext = ""
if err := s.users.Update(ctx, user); err != nil {
return nil, apperror.Internal("AUTH_WEBAUTHN_SAVE_FAILED", "无法保存通行密钥", err)
}
if s.auditService != nil {
s.auditService.Record(AuditEntry{
UserID: user.ID, Username: user.Username,
Category: "auth", Action: "webauthn_register",
TargetType: "webauthn_credential", TargetID: id, TargetName: name,
Detail: "注册通行密钥",
})
}
return ToUserOutput(user), nil
}
func (s *AuthService) BeginWebAuthnLogin(ctx context.Context, input WebAuthnLoginOptionsInput, request WebAuthnRequestContext, clientKey string) (*WebAuthnLoginOptions, error) {
user, err := s.verifyPasswordForMFAStart(ctx, input.Username, input.Password, clientKey)
if err != nil {
return nil, err
}
credentials, err := parseWebAuthnCredentials(user.WebAuthnCredentials)
if err != nil {
return nil, apperror.Internal("AUTH_WEBAUTHN_INVALID", "通行密钥配置异常", err)
}
if len(credentials) == 0 {
return nil, apperror.BadRequest("AUTH_WEBAUTHN_NOT_ENABLED", "当前账号未注册通行密钥", nil)
}
challenge, err := security.GenerateWebAuthnChallenge()
if err != nil {
return nil, apperror.Internal("AUTH_WEBAUTHN_CHALLENGE_FAILED", "无法生成通行密钥挑战", err)
}
state := webAuthnChallengeState{
Type: "login",
Challenge: challenge,
RPID: request.RPID,
Origin: request.Origin,
ExpiresAt: time.Now().UTC().Add(mfaChallengeTTL),
}
if err := s.saveWebAuthnChallenge(ctx, user, state); err != nil {
return nil, err
}
allowed := make([]webAuthnCredentialDescriptor, 0, len(credentials))
for _, credential := range credentials {
allowed = append(allowed, webAuthnCredentialDescriptor{Type: "public-key", ID: credential.CredentialID})
}
return &WebAuthnLoginOptions{
Challenge: challenge,
RPID: request.RPID,
Timeout: int(mfaChallengeTTL / time.Millisecond),
UserVerification: "preferred",
AllowCredentials: allowed,
}, nil
}
func (s *AuthService) VerifyWebAuthnLogin(ctx context.Context, user *model.User, assertion security.WebAuthnLoginAssertion, clientKey string) error {
state, err := s.loadWebAuthnChallenge(user, "login")
if err != nil {
return err
}
credentials, err := parseWebAuthnCredentials(user.WebAuthnCredentials)
if err != nil {
return apperror.Internal("AUTH_WEBAUTHN_INVALID", "通行密钥配置异常", err)
}
rawID := strings.TrimSpace(assertion.RawID)
if rawID == "" {
rawID = strings.TrimSpace(assertion.ID)
}
for i := range credentials {
credential := &credentials[i]
if credential.CredentialID != rawID {
continue
}
nextSignCount, err := security.VerifyWebAuthnAssertion(assertion, state.Challenge, state.RPID, state.Origin, security.WebAuthnCredentialMaterial{
CredentialID: credential.CredentialID,
PublicKeyX: credential.PublicKeyX,
PublicKeyY: credential.PublicKeyY,
SignCount: credential.SignCount,
})
if err != nil {
return apperror.Unauthorized("AUTH_WEBAUTHN_INVALID", "通行密钥校验失败", err)
}
credential.SignCount = nextSignCount
credential.LastUsedAt = time.Now().UTC().Format(time.RFC3339)
encoded, err := encodeWebAuthnCredentials(credentials)
if err != nil {
return apperror.Internal("AUTH_WEBAUTHN_SAVE_FAILED", "无法更新通行密钥", err)
}
user.WebAuthnCredentials = encoded
user.WebAuthnChallengeCiphertext = ""
if err := s.users.Update(ctx, user); err != nil {
return apperror.Internal("AUTH_WEBAUTHN_SAVE_FAILED", "无法更新通行密钥", err)
}
if s.auditService != nil {
s.auditService.Record(AuditEntry{
UserID: user.ID, Username: user.Username,
Category: "auth", Action: "webauthn_used",
TargetType: "webauthn_credential", TargetID: credential.ID, TargetName: credential.Name,
Detail: "使用通行密钥完成多因素验证", ClientIP: clientKey,
})
}
return nil
}
return apperror.Unauthorized("AUTH_WEBAUTHN_INVALID", "通行密钥不存在", nil)
}
func (s *AuthService) ListWebAuthnCredentials(ctx context.Context, subject string) ([]WebAuthnCredentialOutput, error) {
user, err := s.userBySubject(ctx, subject)
if err != nil {
return nil, err
}
credentials, err := parseWebAuthnCredentials(user.WebAuthnCredentials)
if err != nil {
return nil, apperror.Internal("AUTH_WEBAUTHN_INVALID", "通行密钥配置异常", err)
}
output := make([]WebAuthnCredentialOutput, 0, len(credentials))
for _, credential := range credentials {
output = append(output, toWebAuthnCredentialOutput(credential))
}
return output, nil
}
func (s *AuthService) DeleteWebAuthnCredential(ctx context.Context, subject string, id string, input WebAuthnCredentialDeleteInput) (*UserOutput, error) {
user, err := s.userBySubject(ctx, subject)
if err != nil {
return nil, err
}
if err := security.ComparePassword(user.PasswordHash, input.CurrentPassword); err != nil {
return nil, apperror.BadRequest("AUTH_WRONG_PASSWORD", "当前密码不正确", err)
}
credentials, err := parseWebAuthnCredentials(user.WebAuthnCredentials)
if err != nil {
return nil, apperror.Internal("AUTH_WEBAUTHN_INVALID", "通行密钥配置异常", err)
}
found := false
filtered := make([]WebAuthnCredentialRecord, 0, len(credentials))
for _, credential := range credentials {
if credential.ID == strings.TrimSpace(id) {
found = true
} else {
filtered = append(filtered, credential)
}
}
if !found {
return nil, apperror.New(404, "AUTH_WEBAUTHN_NOT_FOUND", "通行密钥不存在", nil)
}
encoded, err := encodeWebAuthnCredentials(filtered)
if err != nil {
return nil, apperror.Internal("AUTH_WEBAUTHN_SAVE_FAILED", "无法更新通行密钥", err)
}
user.WebAuthnCredentials = encoded
clearTrustedDevicesIfMFAOff(user)
if err := s.users.Update(ctx, user); err != nil {
return nil, apperror.Internal("AUTH_WEBAUTHN_DELETE_FAILED", "无法删除通行密钥", err)
}
if s.auditService != nil {
s.auditService.Record(AuditEntry{
UserID: user.ID, Username: user.Username,
Category: "auth", Action: "webauthn_delete",
TargetType: "webauthn_credential", TargetID: strings.TrimSpace(id),
Detail: "删除通行密钥",
})
}
return ToUserOutput(user), nil
}
func (s *AuthService) saveWebAuthnChallenge(ctx context.Context, user *model.User, state webAuthnChallengeState) error {
ciphertext, err := s.twoFactorCipher.EncryptJSON(state)
if err != nil {
return apperror.Internal("AUTH_WEBAUTHN_CHALLENGE_FAILED", "无法保存通行密钥挑战", err)
}
user.WebAuthnChallengeCiphertext = ciphertext
if err := s.users.Update(ctx, user); err != nil {
return apperror.Internal("AUTH_WEBAUTHN_CHALLENGE_FAILED", "无法保存通行密钥挑战", err)
}
return nil
}
func (s *AuthService) loadWebAuthnChallenge(user *model.User, challengeType string) (*webAuthnChallengeState, error) {
if strings.TrimSpace(user.WebAuthnChallengeCiphertext) == "" {
return nil, apperror.BadRequest("AUTH_WEBAUTHN_CHALLENGE_MISSING", "请先发起通行密钥验证", nil)
}
var state webAuthnChallengeState
if err := s.twoFactorCipher.DecryptJSON(user.WebAuthnChallengeCiphertext, &state); err != nil {
return nil, apperror.Internal("AUTH_WEBAUTHN_CHALLENGE_INVALID", "通行密钥挑战状态异常", err)
}
if state.Type != challengeType {
return nil, apperror.BadRequest("AUTH_WEBAUTHN_CHALLENGE_INVALID", "通行密钥挑战类型不匹配", nil)
}
if state.ExpiresAt.Before(time.Now().UTC()) {
return nil, apperror.BadRequest("AUTH_WEBAUTHN_CHALLENGE_EXPIRED", "通行密钥挑战已过期", nil)
}
return &state, nil
}

View File

@@ -73,28 +73,28 @@ func collectTargetIDs(task *model.BackupTask) []uint {
}
type BackupExecutionService struct {
tasks repository.BackupTaskRepository
records repository.BackupRecordRepository
targets repository.StorageTargetRepository
nodeRepo repository.NodeRepository
storageRegistry *storage.Registry
runnerRegistry *backup.Registry
logHub *backup.LogHub
retention *backupretention.Service
cipher *codec.ConfigCipher
tasks repository.BackupTaskRepository
records repository.BackupRecordRepository
targets repository.StorageTargetRepository
nodeRepo repository.NodeRepository
storageRegistry *storage.Registry
runnerRegistry *backup.Registry
logHub *backup.LogHub
retention *backupretention.Service
cipher *codec.ConfigCipher
notifier BackupResultNotifier
agentDispatcher AgentDispatcher
replicationHook ReplicationTrigger
dependentsResolver DependentsResolver
async func(func())
now func() time.Time
tempDir string
semaphore chan struct{}
async func(func())
now func() time.Time
tempDir string
semaphore chan struct{}
// nodeSemaphores 节点级并发限制(按 NodeID 映射)。
// 没命中的 NodeID 走全局 semaphore节点配置 MaxConcurrent>0 时按该节点独立排队。
nodeSemaphores sync.Map
retries int // rclone 底层重试次数
bandwidthLimit string // rclone 带宽限制(全局默认,节点配置可覆盖)
retries int // rclone 底层重试次数
bandwidthLimit string // rclone 带宽限制(全局默认,节点配置可覆盖)
metrics *metrics.Metrics
}
@@ -270,11 +270,9 @@ func (s *BackupExecutionService) DeleteRecord(ctx context.Context, recordID uint
if record == nil {
return apperror.New(404, "BACKUP_RECORD_NOT_FOUND", "备份记录不存在", fmt.Errorf("backup record %d not found", recordID))
}
// 集群场景保护:跨节点 local_disk 文件 Master 无法远程删除,拒绝操作以避免存储泄漏的错觉
if err := s.validateClusterAccessible(ctx, record); err != nil {
if remote, err := s.deleteRemoteLocalDiskObject(ctx, record); err != nil {
return err
}
if strings.TrimSpace(record.StoragePath) != "" {
} else if !remote && strings.TrimSpace(record.StoragePath) != "" {
provider, err := s.resolveProvider(ctx, record.StorageTargetID)
if err != nil {
return err
@@ -289,6 +287,40 @@ func (s *BackupExecutionService) DeleteRecord(ctx context.Context, recordID uint
return nil
}
func (s *BackupExecutionService) deleteRemoteLocalDiskObject(ctx context.Context, record *model.BackupRecord) (bool, error) {
if strings.TrimSpace(record.StoragePath) == "" || s.nodeRepo == nil {
return false, nil
}
node, err := s.nodeRepo.FindByID(ctx, record.NodeID)
if err != nil || node == nil || node.IsLocal {
return false, nil
}
target, err := s.targets.FindByID(ctx, record.StorageTargetID)
if err != nil {
return false, apperror.Internal("BACKUP_STORAGE_TARGET_GET_FAILED", "无法获取存储目标详情", err)
}
if target == nil || !strings.EqualFold(target.Type, "local_disk") {
return false, nil
}
if s.agentDispatcher == nil {
return true, apperror.BadRequest("BACKUP_RECORD_CROSS_NODE_LOCAL_DISK",
fmt.Sprintf("该备份位于节点 %s 的本地磁盘local_diskMaster 无法跨节点删除。请确保 Agent 在线后再操作。", node.Name),
nil)
}
configMap := map[string]any{}
if err := s.cipher.DecryptJSON(target.ConfigCiphertext, &configMap); err != nil {
return true, apperror.Internal("BACKUP_STORAGE_TARGET_DECRYPT_FAILED", "无法解密存储目标配置", err)
}
if _, err := s.agentDispatcher.EnqueueCommand(ctx, record.NodeID, model.AgentCommandTypeDeleteStorageObject, map[string]any{
"targetType": target.Type,
"targetConfig": configMap,
"storagePath": record.StoragePath,
}); err != nil {
return true, apperror.Internal("AGENT_COMMAND_ENQUEUE_FAILED", "无法下发远程备份文件删除命令", err)
}
return true, nil
}
// validateClusterAccessible 在跨节点 + local_disk 场景下拒绝 Master 端直接访问。
// 场景说明:远程 Agent 把备份写到其本机磁盘local_disk basePathMaster 的
// provider 指向的是 Master 本机的同名路径,访问会静默取错文件或 404。明确拒绝
@@ -335,16 +367,29 @@ func (s *BackupExecutionService) startTask(ctx context.Context, id uint, async b
nil)
}
}
// 节点池动态选择task.NodeID=0 且 NodePoolTag 非空时,从匹配的在线节点中挑一台。
// 选择策略:正在运行任务数最少者优先;并列时按 ID 升序稳定。
// 选中节点仅影响本次运行task.NodeID 不持久化改动),保证任务在池内轮转。
resolvedNodeID := task.NodeID
if task.NodeID == 0 && strings.TrimSpace(task.NodePoolTag) != "" {
if pooled, perr := s.selectPoolNode(ctx, task.NodePoolTag); perr == nil && pooled != nil {
resolvedNodeID = pooled.ID
} else if perr != nil {
return nil, perr
}
}
startedAt := s.now()
// 取第一个存储目标 ID 做兼容
primaryTargetID := task.StorageTargetID
if tids := collectTargetIDs(task); len(tids) > 0 {
primaryTargetID = tids[0]
}
record := &model.BackupRecord{TaskID: task.ID, StorageTargetID: primaryTargetID, NodeID: task.NodeID, Status: "running", StartedAt: startedAt}
record := &model.BackupRecord{TaskID: task.ID, StorageTargetID: primaryTargetID, NodeID: resolvedNodeID, Status: "running", StartedAt: startedAt}
if err := s.records.Create(ctx, record); err != nil {
return nil, apperror.Internal("BACKUP_RECORD_CREATE_FAILED", "无法创建备份记录", err)
}
runTask := *task
runTask.NodeID = resolvedNodeID
task.LastRunAt = &startedAt
task.LastStatus = "running"
if err := s.tasks.Update(ctx, task); err != nil {
@@ -352,27 +397,27 @@ func (s *BackupExecutionService) startTask(ctx context.Context, id uint, async b
}
// 多节点路由task.NodeID 指向远程节点时,把执行任务入队给 Agent
// NodeID=0 或本机节点时由 Master 直接执行。
if remoteNode := s.resolveRemoteNode(ctx, task.NodeID); remoteNode != nil {
if remoteNode := s.resolveRemoteNode(ctx, resolvedNodeID); remoteNode != nil {
// 节点离线 → 立即把刚创建的 running 记录标记 failed返回明确错误
if remoteNode.Status != model.NodeStatusOnline {
offlineMsg := fmt.Sprintf("节点 %s 当前离线,无法执行备份任务", remoteNode.Name)
_ = s.finalizeRecord(ctx, task, record.ID, startedAt, model.BackupRecordStatusFailed,
offlineMsg, "", "", 0, "", "")
_ = s.finalizeRecord(ctx, &runTask, record.ID, startedAt, model.BackupRecordStatusFailed,
offlineMsg, "", "", 0, "", "", primaryTargetID)
return nil, apperror.BadRequest("NODE_OFFLINE", offlineMsg, nil)
}
if _, enqueueErr := s.agentDispatcher.EnqueueCommand(ctx, task.NodeID, model.AgentCommandTypeRunTask, map[string]any{
if _, enqueueErr := s.agentDispatcher.EnqueueCommand(ctx, resolvedNodeID, model.AgentCommandTypeRunTask, map[string]any{
"taskId": task.ID,
"recordId": record.ID,
}); enqueueErr != nil {
// 入队失败 → 在记录中标记失败,继续返回详情
_ = s.finalizeRecord(ctx, task, record.ID, startedAt, model.BackupRecordStatusFailed,
"无法下发任务到远程节点: "+enqueueErr.Error(), "", "", 0, "", "")
_ = s.finalizeRecord(ctx, &runTask, record.ID, startedAt, model.BackupRecordStatusFailed,
"无法下发任务到远程节点: "+enqueueErr.Error(), "", "", 0, "", "", primaryTargetID)
return nil, apperror.Internal("AGENT_COMMAND_ENQUEUE_FAILED", "无法下发任务到远程节点", enqueueErr)
}
return s.getRecordDetail(ctx, record.ID)
}
run := func() {
s.executeTask(context.Background(), task, record.ID, startedAt)
s.executeTask(context.Background(), &runTask, record.ID, startedAt)
}
if async {
s.async(run)
@@ -414,6 +459,64 @@ func (s *BackupExecutionService) shouldNotify(ctx context.Context, task *model.B
return true
}
// selectPoolNode 从所有 Labels 包含 poolTag 的在线节点中选择"当前运行中任务最少"的一台。
// 返回 (nil, error) 表示硬错误(仓储访问失败);(nil, nil) 表示没有匹配节点(退化走本机 Master
// 本方法不修改任何持久化状态,仅做选择。
func (s *BackupExecutionService) selectPoolNode(ctx context.Context, poolTag string) (*model.Node, error) {
if s.nodeRepo == nil {
// 没接入集群依赖时,降级为让调用方走本机 Master
return nil, nil
}
nodes, err := s.nodeRepo.List(ctx)
if err != nil {
return nil, apperror.Internal("NODE_LIST_FAILED", "无法枚举节点池", err)
}
candidates := make([]*model.Node, 0)
for i := range nodes {
n := &nodes[i]
if n.Status != model.NodeStatusOnline {
continue
}
if !n.HasLabel(poolTag) {
continue
}
candidates = append(candidates, n)
}
if len(candidates) == 0 {
return nil, apperror.BadRequest("NODE_POOL_EMPTY",
fmt.Sprintf("节点池 %q 下无在线节点,任务无法调度", poolTag), nil)
}
// 运行中记录数越少越优先。并列按 ID 升序(稳定、可预期)。
best := candidates[0]
bestLoad := s.countRunningOnNode(ctx, best.ID)
for _, n := range candidates[1:] {
load := s.countRunningOnNode(ctx, n.ID)
if load < bestLoad || (load == bestLoad && n.ID < best.ID) {
best = n
bestLoad = load
}
}
return best, nil
}
// countRunningOnNode 近似返回节点当前 running 记录数。失败按 0 处理(不影响功能,仅退化调度精度)。
func (s *BackupExecutionService) countRunningOnNode(ctx context.Context, nodeID uint) int {
if s.records == nil {
return 0
}
items, err := s.records.List(ctx, repository.BackupRecordListOptions{Status: model.BackupRecordStatusRunning})
if err != nil {
return 0
}
count := 0
for i := range items {
if items[i].NodeID == nodeID {
count++
}
}
return count
}
// effectiveBandwidth 返回当前上下文应用的带宽限速字符串。
// 优先级Node.BandwidthLimit非空 > 全局 s.bandwidthLimit。
func (s *BackupExecutionService) effectiveBandwidth(ctx context.Context, nodeID uint) string {
@@ -490,9 +593,10 @@ func (s *BackupExecutionService) executeTask(ctx context.Context, task *model.Ba
var fileSize int64
var checksum string
var storagePath string
selectedStorageTargetID := task.StorageTargetID
var uploadResults []StorageUploadResultItem
completeRecord := func() {
if finalizeErr := s.finalizeRecord(ctx, task, recordID, startedAt, status, errMessage, logger.String(), fileName, fileSize, checksum, storagePath); finalizeErr != nil {
if finalizeErr := s.finalizeRecord(ctx, task, recordID, startedAt, status, errMessage, logger.String(), fileName, fileSize, checksum, storagePath, selectedStorageTargetID); finalizeErr != nil {
logger.Errorf("写回备份记录失败:%v", finalizeErr)
}
// 采集任务执行结果到 Prometheus耗时 + 产出字节 + 状态计数)
@@ -688,6 +792,9 @@ func (s *BackupExecutionService) executeTask(ctx context.Context, task *model.Ba
for _, r := range uploadResults {
if r.Status == "success" {
anySuccess = true
if selectedStorageTargetID == task.StorageTargetID {
selectedStorageTargetID = r.StorageTargetID
}
} else if r.Error != "" {
failedMessages = append(failedMessages, fmt.Sprintf("%s: %s", r.StorageTargetName, r.Error))
}
@@ -720,7 +827,7 @@ func (s *BackupExecutionService) executeTask(ctx context.Context, task *model.Ba
record := &model.BackupRecord{
ID: recordID,
TaskID: task.ID,
StorageTargetID: task.StorageTargetID,
StorageTargetID: selectedStorageTargetID,
NodeID: task.NodeID,
Status: "success",
FileName: fileName,
@@ -745,7 +852,7 @@ func (s *BackupExecutionService) executeTask(ctx context.Context, task *model.Ba
}
}
func (s *BackupExecutionService) finalizeRecord(ctx context.Context, task *model.BackupTask, recordID uint, startedAt time.Time, status string, errorMessage string, logContent string, fileName string, fileSize int64, checksum string, storagePath string) error {
func (s *BackupExecutionService) finalizeRecord(ctx context.Context, task *model.BackupTask, recordID uint, startedAt time.Time, status string, errorMessage string, logContent string, fileName string, fileSize int64, checksum string, storagePath string, storageTargetID uint) error {
record, err := s.records.FindByID(ctx, recordID)
if err != nil {
return err
@@ -755,6 +862,9 @@ func (s *BackupExecutionService) finalizeRecord(ctx context.Context, task *model
}
completedAt := s.now()
record.Status = status
if storageTargetID > 0 {
record.StorageTargetID = storageTargetID
}
record.FileName = fileName
record.FileSize = fileSize
record.Checksum = checksum
@@ -886,6 +996,9 @@ func (s *BackupExecutionService) loadRecordProvider(ctx context.Context, recordI
if record == nil {
return nil, nil, apperror.New(404, "BACKUP_RECORD_NOT_FOUND", "备份记录不存在", fmt.Errorf("backup record %d not found", recordID))
}
if err := s.validateClusterAccessible(ctx, record); err != nil {
return nil, nil, err
}
provider, err := s.resolveProvider(ctx, record.StorageTargetID)
if err != nil {
return nil, nil, err

View File

@@ -2,9 +2,13 @@ package service
import (
"context"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"testing"
"time"
"backupx/server/internal/backup"
backupretention "backupx/server/internal/backup/retention"
@@ -18,6 +22,62 @@ import (
storageRclone "backupx/server/internal/storage/rclone"
)
type testStorageFactory struct {
providers map[string]*testStorageProvider
}
func (f *testStorageFactory) Type() storage.ProviderType {
return "test_storage"
}
func (f *testStorageFactory) New(_ context.Context, config map[string]any) (storage.StorageProvider, error) {
name, _ := config["name"].(string)
provider := f.providers[name]
if provider == nil {
return nil, fmt.Errorf("unknown provider %q", name)
}
return provider, nil
}
type testStorageProvider struct {
name string
failUpload bool
objects map[string][]byte
}
func (p *testStorageProvider) Type() storage.ProviderType { return "test_storage" }
func (p *testStorageProvider) TestConnection(context.Context) error {
return nil
}
func (p *testStorageProvider) Upload(_ context.Context, objectKey string, reader io.Reader, _ int64, _ map[string]string) error {
if p.failUpload {
return fmt.Errorf("upload failed for %s", p.name)
}
data, err := io.ReadAll(reader)
if err != nil {
return err
}
if p.objects == nil {
p.objects = map[string][]byte{}
}
p.objects[objectKey] = data
return nil
}
func (p *testStorageProvider) Download(_ context.Context, objectKey string) (io.ReadCloser, error) {
data, ok := p.objects[objectKey]
if !ok {
return nil, fmt.Errorf("object %s not found", objectKey)
}
return io.NopCloser(strings.NewReader(string(data))), nil
}
func (p *testStorageProvider) Delete(_ context.Context, objectKey string) error {
delete(p.objects, objectKey)
return nil
}
func (p *testStorageProvider) List(context.Context, string) ([]storage.ObjectInfo, error) {
return nil, nil
}
func newExecutionTestServices(t *testing.T) (*BackupExecutionService, *BackupRecordService, repository.BackupTaskRepository, repository.StorageTargetRepository, repository.BackupRecordRepository, string, string) {
t.Helper()
baseDir := t.TempDir()
@@ -85,6 +145,195 @@ func TestBackupExecutionServiceRunTaskByIDSync(t *testing.T) {
}
}
func TestBackupExecutionServiceNodePoolSelectionDoesNotPersistTaskNodeID(t *testing.T) {
executionService, _, tasks, _, records, _, _ := newExecutionTestServices(t)
ctx := context.Background()
nodeRepo := &nodeRepoStub{nodes: []model.Node{
{ID: 10, Name: "edge-a", Token: "edge-a-token", Status: model.NodeStatusOnline, Labels: "prod,db"},
{ID: 11, Name: "edge-b", Token: "edge-b-token", Status: model.NodeStatusOnline, Labels: "prod,db"},
}}
dispatcher := &fakeDispatcher{}
executionService.SetClusterDependencies(nodeRepo, dispatcher)
task, err := tasks.FindByID(ctx, 1)
if err != nil {
t.Fatalf("FindByID returned error: %v", err)
}
task.NodeID = 0
task.NodePoolTag = "db"
if err := tasks.Update(ctx, task); err != nil {
t.Fatalf("Update task returned error: %v", err)
}
detail, err := executionService.RunTaskByID(ctx, 1)
if err != nil {
t.Fatalf("RunTaskByID returned error: %v", err)
}
storedTask, err := tasks.FindByID(ctx, 1)
if err != nil {
t.Fatalf("FindByID after run returned error: %v", err)
}
if storedTask.NodeID != 0 {
t.Fatalf("expected pooled task NodeID to remain 0, got %d", storedTask.NodeID)
}
if storedTask.NodePoolTag != "db" {
t.Fatalf("expected pooled task tag to remain db, got %q", storedTask.NodePoolTag)
}
storedRecord, err := records.FindByID(ctx, detail.ID)
if err != nil {
t.Fatalf("FindByID record returned error: %v", err)
}
if storedRecord == nil || storedRecord.NodeID != 10 {
t.Fatalf("expected record to keep selected node 10, got %#v", storedRecord)
}
calls := dispatcher.snapshot()
if len(calls) != 1 || calls[0].NodeID != 10 || calls[0].CmdType != model.AgentCommandTypeRunTask {
t.Fatalf("unexpected dispatcher calls: %#v", calls)
}
}
func TestBackupExecutionServiceDeleteRecordDispatchesRemoteLocalDiskCleanup(t *testing.T) {
executionService, _, tasks, _, records, _, _ := newExecutionTestServices(t)
ctx := context.Background()
nodeRepo := &nodeRepoStub{nodes: []model.Node{
{ID: 10, Name: "edge-a", Token: "edge-a-token", Status: model.NodeStatusOnline},
}}
dispatcher := &fakeDispatcher{}
executionService.SetClusterDependencies(nodeRepo, dispatcher)
task, err := tasks.FindByID(ctx, 1)
if err != nil {
t.Fatalf("FindByID task returned error: %v", err)
}
completedAt := time.Now().UTC()
record := &model.BackupRecord{
TaskID: task.ID,
StorageTargetID: task.StorageTargetID,
NodeID: 10,
Status: model.BackupRecordStatusSuccess,
FileName: "remote.tar.gz",
StoragePath: "file/2026/05/09/remote.tar.gz",
StartedAt: completedAt.Add(-time.Second),
CompletedAt: &completedAt,
}
if err := records.Create(ctx, record); err != nil {
t.Fatalf("Create record returned error: %v", err)
}
if err := executionService.DeleteRecord(ctx, record.ID); err != nil {
t.Fatalf("DeleteRecord returned error: %v", err)
}
deleted, err := records.FindByID(ctx, record.ID)
if err != nil {
t.Fatalf("FindByID record returned error: %v", err)
}
if deleted != nil {
t.Fatalf("expected record deleted, got %#v", deleted)
}
calls := dispatcher.snapshot()
if len(calls) != 1 {
t.Fatalf("expected one dispatcher call, got %#v", calls)
}
if calls[0].NodeID != 10 || calls[0].CmdType != model.AgentCommandTypeDeleteStorageObject {
t.Fatalf("unexpected dispatcher call: %#v", calls[0])
}
if calls[0].Payload["storagePath"] != record.StoragePath {
t.Fatalf("expected storagePath %q, got %#v", record.StoragePath, calls[0].Payload)
}
if calls[0].Payload["targetType"] != string(storage.ProviderTypeLocalDisk) {
t.Fatalf("expected local_disk targetType, got %#v", calls[0].Payload)
}
if _, ok := calls[0].Payload["targetConfig"].(map[string]any); !ok {
t.Fatalf("expected targetConfig map, got %#v", calls[0].Payload["targetConfig"])
}
}
func TestBackupExecutionServiceRestoreRecordRejectsRemoteLocalDisk(t *testing.T) {
executionService, _, tasks, _, records, _, _ := newExecutionTestServices(t)
ctx := context.Background()
executionService.SetClusterDependencies(&nodeRepoStub{nodes: []model.Node{
{ID: 10, Name: "edge-a", Token: "edge-a-token", Status: model.NodeStatusOnline},
}}, &fakeDispatcher{})
task, err := tasks.FindByID(ctx, 1)
if err != nil {
t.Fatalf("FindByID task returned error: %v", err)
}
completedAt := time.Now().UTC()
record := &model.BackupRecord{
TaskID: task.ID,
StorageTargetID: task.StorageTargetID,
NodeID: 10,
Status: model.BackupRecordStatusSuccess,
FileName: "remote.tar.gz",
StoragePath: "file/2026/05/09/remote.tar.gz",
StartedAt: completedAt.Add(-time.Second),
CompletedAt: &completedAt,
}
if err := records.Create(ctx, record); err != nil {
t.Fatalf("Create record returned error: %v", err)
}
err = executionService.RestoreRecord(ctx, record.ID)
if err == nil {
t.Fatal("expected remote local_disk restore to be rejected")
}
if !strings.Contains(err.Error(), "Master 无法跨节点访问") {
t.Fatalf("expected cross-node local_disk error, got %v", err)
}
}
func TestBackupExecutionServiceRecordsFirstSuccessfulStorageTarget(t *testing.T) {
executionService, _, tasks, targets, records, _, _ := newExecutionTestServices(t)
ctx := context.Background()
second := &testStorageProvider{name: "second", objects: map[string][]byte{}}
executionService.storageRegistry = storage.NewRegistry(&testStorageFactory{providers: map[string]*testStorageProvider{
"second": second,
}})
cipher := codec.NewConfigCipher("execution-secret")
firstConfig, err := cipher.EncryptJSON(map[string]any{"name": "missing"})
if err != nil {
t.Fatalf("EncryptJSON first returned error: %v", err)
}
secondConfig, err := cipher.EncryptJSON(map[string]any{"name": "second"})
if err != nil {
t.Fatalf("EncryptJSON second returned error: %v", err)
}
if err := targets.Create(ctx, &model.StorageTarget{Name: "first", Type: "test_storage", Enabled: true, ConfigCiphertext: firstConfig, ConfigVersion: 1, LastTestStatus: "unknown"}); err != nil {
t.Fatalf("Create first target returned error: %v", err)
}
if err := targets.Create(ctx, &model.StorageTarget{Name: "second", Type: "test_storage", Enabled: true, ConfigCiphertext: secondConfig, ConfigVersion: 1, LastTestStatus: "unknown"}); err != nil {
t.Fatalf("Create second target returned error: %v", err)
}
task, err := tasks.FindByID(ctx, 1)
if err != nil {
t.Fatalf("FindByID task returned error: %v", err)
}
task.StorageTargetID = 2
task.StorageTargets = []model.StorageTarget{{ID: 2}, {ID: 3}}
if err := tasks.Update(ctx, task); err != nil {
t.Fatalf("Update task returned error: %v", err)
}
detail, err := executionService.RunTaskByIDSync(ctx, 1)
if err != nil {
t.Fatalf("RunTaskByIDSync returned error: %v", err)
}
if detail.Status != model.BackupRecordStatusSuccess {
t.Fatalf("expected success, got %#v", detail)
}
storedRecord, err := records.FindByID(ctx, detail.ID)
if err != nil {
t.Fatalf("FindByID record returned error: %v", err)
}
if storedRecord.StorageTargetID != 3 {
t.Fatalf("expected record StorageTargetID to point at successful target 3, got %d", storedRecord.StorageTargetID)
}
if _, ok := second.objects[storedRecord.StoragePath]; !ok {
t.Fatalf("expected object in successful provider at %q", storedRecord.StoragePath)
}
}
func TestBackupRecordServiceRestore(t *testing.T) {
executionService, recordService, _, _, _, sourceDir, _ := newExecutionTestServices(t)
detail, err := executionService.RunTaskByIDSync(context.Background(), 1)

View File

@@ -35,7 +35,9 @@ type BackupTaskUpsertInput struct {
DBPath string `json:"dbPath" binding:"max=500"`
StorageTargetID uint `json:"storageTargetId"` // deprecated: 向后兼容
StorageTargetIDs []uint `json:"storageTargetIds"` // 新增:多存储目标
NodeID uint `json:"nodeId"` // 执行节点0 = 本机 Master
NodeID uint `json:"nodeId"` // 执行节点0 = 本机 Master 或节点池
// NodePoolTag 节点池标签。NodeID=0 且本字段非空时,调度器动态从 Labels 命中的在线节点中选负载最低者。
NodePoolTag string `json:"nodePoolTag" binding:"max=64"`
Tags string `json:"tags" binding:"max=500"` // 逗号分隔标签
RetentionDays int `json:"retentionDays"`
Compression string `json:"compression" binding:"omitempty,oneof=gzip none"`
@@ -74,6 +76,7 @@ type BackupTaskSummary struct {
StorageTargetNames []string `json:"storageTargetNames"`
NodeID uint `json:"nodeId"`
NodeName string `json:"nodeName,omitempty"`
NodePoolTag string `json:"nodePoolTag,omitempty"`
Tags string `json:"tags"`
RetentionDays int `json:"retentionDays"`
Compression string `json:"compression"`
@@ -494,6 +497,11 @@ func (s *BackupTaskService) validateInput(ctx context.Context, existing *model.B
return apperror.BadRequest("BACKUP_TASK_INVALID", "所选执行节点不存在", nil)
}
}
// 节点池与固定节点互斥:固定节点已确定执行位置,不再动态调度
if input.NodeID > 0 && strings.TrimSpace(input.NodePoolTag) != "" {
return apperror.BadRequest("BACKUP_TASK_INVALID",
"固定执行节点与节点池标签只能选其一", nil)
}
if input.RetentionDays < 0 {
return apperror.BadRequest("BACKUP_TASK_INVALID", "保留天数不能小于 0", nil)
}
@@ -648,6 +656,7 @@ func (s *BackupTaskService) buildTask(existing *model.BackupTask, input BackupTa
StorageTargetID: primaryTargetID,
StorageTargets: storageTargets,
NodeID: input.NodeID,
NodePoolTag: strings.TrimSpace(input.NodePoolTag),
Tags: strings.TrimSpace(input.Tags),
RetentionDays: input.RetentionDays,
Compression: compression,
@@ -738,6 +747,7 @@ func toBackupTaskSummary(item *model.BackupTask) BackupTaskSummary {
StorageTargetNames: targetNames,
NodeID: item.NodeID,
NodeName: item.Node.Name,
NodePoolTag: item.NodePoolTag,
Tags: item.Tags,
RetentionDays: item.RetentionDays,
Compression: item.Compression,

View File

@@ -3,12 +3,14 @@ package service
import (
"context"
"crypto/rand"
"encoding/base64"
"encoding/hex"
"fmt"
"strings"
"time"
"backupx/server/internal/apperror"
"backupx/server/internal/installscript"
"backupx/server/internal/model"
"backupx/server/internal/repository"
)
@@ -42,6 +44,25 @@ type InstallTokenOutput struct {
Record *model.AgentInstallToken
}
// InstallCommandInput 生成可展示安装命令所需的完整业务输入。
type InstallCommandInput struct {
InstallTokenInput
MasterURL string
}
// InstallCommandOutput 是 UI 生成安装命令所需的完整业务输出。
type InstallCommandOutput struct {
Token string
ExpiresAt time.Time
Node *model.Node
Record *model.AgentInstallToken
URL string
FallbackURL string
ComposeURL string
FallbackComposeURL string
ScriptBase64 string
}
// ConsumedInstallToken 消费成功后返回给 handler 的组合体。
type ConsumedInstallToken struct {
Record *model.AgentInstallToken
@@ -106,6 +127,67 @@ func (s *InstallTokenService) Create(ctx context.Context, in InstallTokenInput)
return &InstallTokenOutput{Token: token, ExpiresAt: expiresAt, Node: node, Record: record}, nil
}
// CreateCommand 创建 install token并返回 UI 展示安装命令所需的 URL 与嵌入式脚本。
func (s *InstallTokenService) CreateCommand(ctx context.Context, in InstallCommandInput) (*InstallCommandOutput, error) {
masterURL := strings.TrimRight(strings.TrimSpace(in.MasterURL), "/")
if masterURL == "" {
return nil, apperror.BadRequest("INSTALL_TOKEN_INVALID", "masterURL 必填", nil)
}
if err := s.validate(in.InstallTokenInput); err != nil {
return nil, err
}
node, err := s.nodeRepo.FindByID(ctx, in.NodeID)
if err != nil {
return nil, err
}
if node == nil {
return nil, apperror.New(404, "NODE_NOT_FOUND", "节点不存在", nil)
}
if _, err := renderInstallCommandScript(masterURL, node, &model.AgentInstallToken{
Mode: in.Mode,
Arch: in.Arch,
AgentVer: in.AgentVersion,
DownloadSrc: in.DownloadSrc,
}); err != nil {
return nil, err
}
out, err := s.Create(ctx, in.InstallTokenInput)
if err != nil {
return nil, err
}
script, err := renderInstallCommandScript(masterURL, out.Node, out.Record)
if err != nil {
return nil, err
}
result := &InstallCommandOutput{
Token: out.Token,
ExpiresAt: out.ExpiresAt,
Node: out.Node,
Record: out.Record,
URL: masterURL + "/api/install/" + out.Token,
FallbackURL: masterURL + "/install/" + out.Token,
ScriptBase64: base64.StdEncoding.EncodeToString([]byte(script)),
}
if out.Record.Mode == model.InstallModeDocker {
result.ComposeURL = masterURL + "/api/install/" + out.Token + "/compose.yml"
result.FallbackComposeURL = masterURL + "/install/" + out.Token + "/compose.yml"
}
return result, nil
}
func renderInstallCommandScript(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,
})
}
// Consume 原子消费令牌。未命中/已过期/已消费均返回 (nil, nil)。
func (s *InstallTokenService) Consume(ctx context.Context, token string) (*ConsumedInstallToken, error) {
if strings.TrimSpace(token) == "" {
@@ -170,8 +252,8 @@ func (s *InstallTokenService) validate(in InstallTokenInput) error {
if !validInstallSources[in.DownloadSrc] {
return apperror.BadRequest("INSTALL_TOKEN_INVALID", "downloadSrc 非法", nil)
}
if strings.TrimSpace(in.AgentVersion) == "" {
return apperror.BadRequest("INSTALL_TOKEN_INVALID", "agentVersion 必填", nil)
if err := validateInstallAgentVersion(in.AgentVersion); err != nil {
return err
}
if in.TTLSeconds < InstallTokenMinTTL || in.TTLSeconds > InstallTokenMaxTTL {
return apperror.BadRequest("INSTALL_TOKEN_INVALID",
@@ -180,6 +262,27 @@ func (s *InstallTokenService) validate(in InstallTokenInput) error {
return nil
}
func validateInstallAgentVersion(v string) error {
v = strings.TrimSpace(v)
if v == "" {
return apperror.BadRequest("INSTALL_TOKEN_INVALID", "agentVersion 必填", nil)
}
if len(v) > 64 {
return apperror.BadRequest("INSTALL_TOKEN_INVALID", "agentVersion 不能超过 64 字符", nil)
}
for _, c := range v {
switch {
case c >= '0' && c <= '9':
case c >= 'a' && c <= 'z':
case c >= 'A' && c <= 'Z':
case c == '.' || c == '-' || c == '_' || c == '+':
default:
return apperror.BadRequest("INSTALL_TOKEN_INVALID", "agentVersion 包含非法字符", nil)
}
}
return nil
}
func generateInstallToken() (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {

View File

@@ -131,6 +131,79 @@ func TestInstallTokenServiceValidatesInput(t *testing.T) {
}
}
func TestInstallTokenServiceRejectsInvalidAgentVersionBeforeCreate(t *testing.T) {
db := openInstallTokenTestDB(t)
nodeRepo := repository.NewNodeRepository(db)
node := &model.Node{Name: "invalid-version", Token: "feedface"}
if err := nodeRepo.Create(context.Background(), node); err != nil {
t.Fatalf("create node: %v", err)
}
tokenRepo := repository.NewAgentInstallTokenRepository(db)
svc := NewInstallTokenService(tokenRepo, nodeRepo)
_, err := svc.Create(context.Background(), InstallTokenInput{
NodeID: node.ID,
Mode: model.InstallModeSystemd,
Arch: model.InstallArchAuto,
AgentVersion: "v1 && rm -rf /",
DownloadSrc: model.InstallSourceGitHub,
TTLSeconds: 900,
CreatedByID: 1,
})
if err == nil {
t.Fatalf("expected invalid version error")
}
count, err := tokenRepo.CountCreatedSince(context.Background(), node.ID, time.Now().UTC().Add(-time.Hour))
if err != nil {
t.Fatalf("count: %v", err)
}
if count != 0 {
t.Fatalf("invalid request created %d token records", count)
}
}
func TestInstallTokenServiceCreateCommandBuildsURLsAndScript(t *testing.T) {
db := openInstallTokenTestDB(t)
nodeRepo := repository.NewNodeRepository(db)
node := &model.Node{
Name: "command-node",
Token: "deadbeefcafebabe0123456789abcdef0123456789abcdef0123456789abcdef",
}
if err := nodeRepo.Create(context.Background(), node); err != nil {
t.Fatalf("create node: %v", err)
}
tokenRepo := repository.NewAgentInstallTokenRepository(db)
svc := NewInstallTokenService(tokenRepo, nodeRepo)
out, err := svc.CreateCommand(context.Background(), InstallCommandInput{
InstallTokenInput: InstallTokenInput{
NodeID: node.ID,
Mode: model.InstallModeDocker,
Arch: model.InstallArchAuto,
AgentVersion: "v1.7.0",
DownloadSrc: model.InstallSourceGitHub,
TTLSeconds: 900,
CreatedByID: 1,
},
MasterURL: "https://public.example.com/base",
})
if err != nil {
t.Fatalf("create command: %v", err)
}
if out.Token == "" || out.ScriptBase64 == "" {
t.Fatalf("missing token or script: %+v", out)
}
if out.URL != "https://public.example.com/base/api/install/"+out.Token {
t.Fatalf("bad url: %s", out.URL)
}
if out.FallbackURL != "https://public.example.com/base/install/"+out.Token {
t.Fatalf("bad fallback url: %s", out.FallbackURL)
}
if out.ComposeURL != "https://public.example.com/base/api/install/"+out.Token+"/compose.yml" {
t.Fatalf("bad compose url: %s", out.ComposeURL)
}
}
func TestInstallTokenServiceRateLimit(t *testing.T) {
db := openInstallTokenTestDB(t)
nodeRepo := repository.NewNodeRepository(db)

View File

@@ -0,0 +1,83 @@
package service
import (
"context"
"errors"
"testing"
"time"
"backupx/server/internal/apperror"
"backupx/server/internal/model"
)
// nodeRepoStub 返回预设节点切片;仅关注 List/FindByID。
// 其余方法返回零值,避免在调度路径被调用到。
type nodeRepoStub struct {
nodes []model.Node
}
func (s *nodeRepoStub) List(context.Context) ([]model.Node, error) { return s.nodes, nil }
func (s *nodeRepoStub) FindByID(_ context.Context, id uint) (*model.Node, error) {
for i := range s.nodes {
if s.nodes[i].ID == id {
return &s.nodes[i], nil
}
}
return nil, nil
}
func (s *nodeRepoStub) FindByToken(context.Context, string) (*model.Node, error) { return nil, nil }
func (s *nodeRepoStub) FindLocal(context.Context) (*model.Node, error) { return nil, nil }
func (s *nodeRepoStub) Create(context.Context, *model.Node) error { return nil }
func (s *nodeRepoStub) BatchCreate(context.Context, []*model.Node) error { return nil }
func (s *nodeRepoStub) Update(context.Context, *model.Node) error { return nil }
func (s *nodeRepoStub) Delete(context.Context, uint) error { return nil }
func (s *nodeRepoStub) MarkStaleOffline(context.Context, time.Time) (int64, error) {
return 0, nil
}
func TestSelectPoolNode_PicksLeastLoaded(t *testing.T) {
nodes := []model.Node{
{ID: 1, Name: "node-a", Status: model.NodeStatusOnline, Labels: "prod,db"},
{ID: 2, Name: "node-b", Status: model.NodeStatusOnline, Labels: "prod,db"},
{ID: 3, Name: "node-offline", Status: model.NodeStatusOffline, Labels: "prod,db"},
{ID: 4, Name: "node-other-pool", Status: model.NodeStatusOnline, Labels: "staging"},
}
svc := &BackupExecutionService{
nodeRepo: &nodeRepoStub{nodes: nodes},
records: nil, // 触发 countRunningOnNode 返回 0节点并列时按 ID 升序
}
chosen, err := svc.selectPoolNode(context.Background(), "db")
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if chosen == nil || chosen.ID != 1 {
t.Fatalf("expected node-a (ID=1), got %#v", chosen)
}
}
func TestSelectPoolNode_EmptyPoolReturnsError(t *testing.T) {
svc := &BackupExecutionService{
nodeRepo: &nodeRepoStub{nodes: []model.Node{
{ID: 1, Status: model.NodeStatusOnline, Labels: "prod"},
}},
}
_, err := svc.selectPoolNode(context.Background(), "missing-pool")
if err == nil {
t.Fatal("expected empty-pool error")
}
var apperr *apperror.AppError
if !errors.As(err, &apperr) || apperr.Code != "NODE_POOL_EMPTY" {
t.Errorf("expected NODE_POOL_EMPTY, got %v", err)
}
}
func TestSelectPoolNode_NilRepoDegradesGracefully(t *testing.T) {
svc := &BackupExecutionService{}
got, err := svc.selectPoolNode(context.Background(), "any")
if err != nil {
t.Errorf("nil repo should degrade silently, got err %v", err)
}
if got != nil {
t.Errorf("nil repo should return nil node, got %v", got)
}
}

View File

@@ -34,6 +34,7 @@ type NodeSummary struct {
LastSeen time.Time `json:"lastSeen"`
MaxConcurrent int `json:"maxConcurrent"`
BandwidthLimit string `json:"bandwidthLimit"`
Labels string `json:"labels"`
CreatedAt time.Time `json:"createdAt"`
}
@@ -47,6 +48,8 @@ type NodeUpdateInput struct {
Name string `json:"name" binding:"required"`
MaxConcurrent int `json:"maxConcurrent"`
BandwidthLimit string `json:"bandwidthLimit" binding:"max=32"`
// Labels CSV同时作为调度器的节点池标签task.NodePoolTag 对齐的值)。
Labels string `json:"labels" binding:"max=500"`
}
// NodeService manages the cluster nodes.
@@ -132,6 +135,7 @@ func (s *NodeService) List(ctx context.Context) ([]NodeSummary, error) {
LastSeen: n.LastSeen,
MaxConcurrent: n.MaxConcurrent,
BandwidthLimit: n.BandwidthLimit,
Labels: n.Labels,
CreatedAt: n.CreatedAt,
}
}
@@ -159,6 +163,7 @@ func (s *NodeService) Get(ctx context.Context, id uint) (*NodeSummary, error) {
LastSeen: node.LastSeen,
MaxConcurrent: node.MaxConcurrent,
BandwidthLimit: node.BandwidthLimit,
Labels: node.Labels,
CreatedAt: node.CreatedAt,
}, nil
}
@@ -320,12 +325,32 @@ func (s *NodeService) Update(ctx context.Context, id uint, input NodeUpdateInput
}
node.MaxConcurrent = input.MaxConcurrent
node.BandwidthLimit = strings.TrimSpace(input.BandwidthLimit)
node.Labels = normalizeLabels(input.Labels)
if err := s.repo.Update(ctx, node); err != nil {
return nil, err
}
return s.Get(ctx, id)
}
// normalizeLabels 规整 CSV labels去空白、去空 token、去重、保持首次出现顺序。
// 输入 " prod, db , prod ,high-mem " → "prod,db,high-mem"
func normalizeLabels(raw string) string {
seen := make(map[string]struct{})
out := make([]string, 0)
for _, token := range strings.Split(raw, ",") {
label := strings.TrimSpace(token)
if label == "" {
continue
}
if _, dup := seen[label]; dup {
continue
}
seen[label] = struct{}{}
out = append(out, label)
}
return strings.Join(out, ",")
}
// DirEntry represents a file or directory in a node's file system.
type DirEntry struct {
Name string `json:"name"`

View File

@@ -16,11 +16,11 @@ import (
)
type NotificationUpsertInput struct {
Name string `json:"name" binding:"required,min=1,max=100"`
Type string `json:"type" binding:"required,oneof=email webhook telegram"`
Enabled bool `json:"enabled"`
OnSuccess bool `json:"onSuccess"`
OnFailure bool `json:"onFailure"`
Name string `json:"name" binding:"required,min=1,max=100"`
Type string `json:"type" binding:"required,oneof=email webhook telegram"`
Enabled bool `json:"enabled"`
OnSuccess bool `json:"onSuccess"`
OnFailure bool `json:"onFailure"`
// EventTypes 订阅的扩展事件列表。与 OnSuccess/OnFailure 并存:
// - 两者均空时,订阅"备份成功/失败"对应原有语义(兼容)。
// - EventTypes 显式指定时优先按清单匹配。
@@ -186,8 +186,8 @@ func (s *NotificationService) NotifyBackupResult(ctx context.Context, event Back
// - eventType 对应 model.NotificationEvent* 常量,用于订阅匹配
//
// 订阅匹配规则:
// 1) notification.EventTypes 非空:必须包含 eventType
// 2) notification.EventTypes 为空:沿用 OnSuccess/OnFailure 开关(仅 backup_* 事件)
// 1. notification.EventTypes 非空:必须包含 eventType
// 2. notification.EventTypes 为空:沿用 OnSuccess/OnFailure 开关(仅 backup_* 事件)
func (s *NotificationService) DispatchEvent(ctx context.Context, eventType string, title string, body string, fields map[string]any) error {
// 同步广播到 SSE 订阅者(前端 Dashboard 实时推送)。
// 非阻塞:即便广播器未注入或订阅者已满也不影响 Notification 持久渠道。
@@ -209,6 +209,49 @@ func (s *NotificationService) DispatchEvent(ctx context.Context, eventType strin
return s.deliver(ctx, items, message)
}
func (s *NotificationService) SendAuthEmailOTP(ctx context.Context, to string, code string) error {
return s.sendFirstByType(ctx, "email", map[string]any{"to": strings.TrimSpace(to)}, notify.Message{
Title: "BackupX 登录验证码",
Body: fmt.Sprintf("您的 BackupX 登录验证码为:%s\n验证码 5 分钟内有效。若非本人操作,请立即检查账号安全。", code),
Fields: map[string]any{
"purpose": "login_otp",
},
})
}
func (s *NotificationService) SendAuthSMSOTP(ctx context.Context, phone string, code string) error {
return s.sendFirstByType(ctx, "webhook", nil, notify.Message{
Title: "BackupX 登录验证码",
Body: fmt.Sprintf("BackupX 登录验证码:%s5 分钟内有效。", code),
Fields: map[string]any{
"phone": strings.TrimSpace(phone),
"code": code,
"purpose": "login_otp",
},
})
}
func (s *NotificationService) sendFirstByType(ctx context.Context, notificationType string, override map[string]any, message notify.Message) error {
items, err := s.notifications.List(ctx)
if err != nil {
return err
}
for _, item := range items {
if !item.Enabled || item.Type != notificationType {
continue
}
configMap := map[string]any{}
if err := s.cipher.DecryptJSON(item.ConfigCiphertext, &configMap); err != nil {
return fmt.Errorf("decrypt notification %d config: %w", item.ID, err)
}
for key, value := range override {
configMap[key] = value
}
return s.registry.Send(ctx, item.Type, configMap, message)
}
return fmt.Errorf("no enabled %s notification configured", notificationType)
}
// collectSubscribers 按事件类型收集启用的订阅者。
// 列出启用通知后按事件类型再过滤(避免引入新 repository 方法)。
func (s *NotificationService) collectSubscribers(ctx context.Context, eventType string, fallbackSuccess bool) ([]model.Notification, error) {

View File

@@ -141,10 +141,11 @@ func (s *RestoreService) Start(ctx context.Context, backupRecordID uint, trigger
}
startedAt := s.now()
restoreNodeID := s.resolveRestoreNodeID(record, task)
restore := &model.RestoreRecord{
BackupRecordID: backupRecordID,
TaskID: record.TaskID,
NodeID: task.NodeID,
NodeID: restoreNodeID,
Status: model.RestoreRecordStatusRunning,
StartedAt: startedAt,
TriggeredBy: strings.TrimSpace(triggeredBy),
@@ -154,7 +155,7 @@ func (s *RestoreService) Start(ctx context.Context, backupRecordID uint, trigger
}
// 远程节点路由
if remoteNode := s.resolveRemoteNode(ctx, task.NodeID); remoteNode != nil {
if remoteNode := s.resolveRemoteNode(ctx, restoreNodeID); remoteNode != nil {
if s.dispatcher == nil {
return nil, apperror.Internal("RESTORE_DISPATCH_UNAVAILABLE", "Agent 下发通道未就绪", nil)
}
@@ -166,14 +167,14 @@ func (s *RestoreService) Start(ctx context.Context, backupRecordID uint, trigger
s.logHub.Complete(restore.ID, model.RestoreRecordStatusFailed)
return nil, apperror.BadRequest("NODE_OFFLINE", offlineMsg, nil)
}
if _, dispatchErr := s.dispatcher.EnqueueCommand(ctx, task.NodeID, model.AgentCommandTypeRestoreRecord, map[string]any{
if _, dispatchErr := s.dispatcher.EnqueueCommand(ctx, restoreNodeID, model.AgentCommandTypeRestoreRecord, map[string]any{
"restoreRecordId": restore.ID,
}); dispatchErr != nil {
_ = s.finalize(ctx, restore.ID, model.RestoreRecordStatusFailed,
"下发恢复任务到远程节点失败: "+dispatchErr.Error())
return nil, apperror.Internal("AGENT_COMMAND_ENQUEUE_FAILED", "无法下发恢复任务到远程节点", dispatchErr)
}
s.logHub.Append(restore.ID, "info", fmt.Sprintf("已下发恢复任务到节点 %s#%d等待 Agent 执行", remoteNode.Name, task.NodeID))
s.logHub.Append(restore.ID, "info", fmt.Sprintf("已下发恢复任务到节点 %s#%d等待 Agent 执行", remoteNode.Name, restoreNodeID))
return s.getDetail(ctx, restore.ID)
}
@@ -185,6 +186,16 @@ func (s *RestoreService) Start(ctx context.Context, backupRecordID uint, trigger
return s.getDetail(ctx, restore.ID)
}
func (s *RestoreService) resolveRestoreNodeID(record *model.BackupRecord, task *model.BackupTask) uint {
if record != nil && record.NodeID != 0 {
return record.NodeID
}
if task != nil {
return task.NodeID
}
return 0
}
// isRemoteNode 判断 NodeID 是否指向有效的远程节点。
func (s *RestoreService) isRemoteNode(ctx context.Context, nodeID uint) bool {
return s.resolveRemoteNode(ctx, nodeID) != nil
@@ -629,6 +640,9 @@ func (s *RestoreService) UpdateAgentRestore(ctx context.Context, node *model.Nod
if restore.NodeID != node.ID {
return apperror.Unauthorized("RESTORE_RECORD_FORBIDDEN", "恢复记录不属于当前节点", nil)
}
if isRestoreRecordTerminal(restore.Status) {
return nil
}
// 追加日志到 LogHub + DB
if strings.TrimSpace(update.LogAppend) != "" {
for _, line := range strings.Split(update.LogAppend, "\n") {
@@ -667,6 +681,10 @@ func (s *RestoreService) UpdateAgentRestore(ctx context.Context, node *model.Nod
return nil
}
func isRestoreRecordTerminal(status string) bool {
return status == model.RestoreRecordStatusSuccess || status == model.RestoreRecordStatusFailed
}
// --- 内部辅助 ---
func (s *RestoreService) getDetail(ctx context.Context, restoreID uint) (*RestoreRecordDetail, error) {

View File

@@ -51,15 +51,15 @@ func (f *fakeDispatcher) snapshot() []dispatcherCall {
}
type restoreTestHarness struct {
service *RestoreService
execution *BackupExecutionService
records repository.BackupRecordRepository
restores repository.RestoreRecordRepository
tasks repository.BackupTaskRepository
nodes repository.NodeRepository
dispatcher *fakeDispatcher
sourceDir string
storageDir string
service *RestoreService
execution *BackupExecutionService
records repository.BackupRecordRepository
restores repository.RestoreRecordRepository
tasks repository.BackupTaskRepository
nodes repository.NodeRepository
dispatcher *fakeDispatcher
sourceDir string
storageDir string
}
func newRestoreTestHarness(t *testing.T, remoteNode bool) *restoreTestHarness {
@@ -228,6 +228,179 @@ func TestRestoreServiceStart_RemoteNodeEnqueuesCommand(t *testing.T) {
}
}
func TestRestoreServiceStart_UsesBackupRecordNodeForPooledTask(t *testing.T) {
h := newRestoreTestHarness(t, true)
ctx := context.Background()
task, err := h.tasks.FindByID(ctx, 1)
if err != nil {
t.Fatalf("FindByID task: %v", err)
}
remoteNodeID := task.NodeID
task.NodeID = 0
task.NodePoolTag = "db"
if err := h.tasks.Update(ctx, task); err != nil {
t.Fatalf("Update task: %v", err)
}
storedTask, err := h.tasks.FindByID(ctx, task.ID)
if err != nil {
t.Fatalf("FindByID stored task: %v", err)
}
if storedTask.NodeID != 0 {
t.Fatalf("expected stored task NodeID to be reset to 0, got %d", storedTask.NodeID)
}
startedAt := time.Now().UTC()
completedAt := startedAt.Add(time.Second)
backupRecord := &model.BackupRecord{
TaskID: task.ID,
StorageTargetID: task.StorageTargetID,
NodeID: remoteNodeID,
Status: model.BackupRecordStatusSuccess,
FileName: "pooled.tar.gz",
StoragePath: "file/2026/05/09/pooled.tar.gz",
StartedAt: startedAt,
CompletedAt: &completedAt,
}
if err := h.records.Create(ctx, backupRecord); err != nil {
t.Fatalf("Create backup record: %v", err)
}
detail, err := h.service.Start(ctx, backupRecord.ID, "tester-pool")
if err != nil {
t.Fatalf("Start: %v", err)
}
if detail.NodeID != remoteNodeID {
t.Fatalf("expected restore node %d, got %d", remoteNodeID, detail.NodeID)
}
calls := h.dispatcher.snapshot()
if len(calls) != 1 {
t.Fatalf("expected exactly 1 dispatcher call, got %d", len(calls))
}
if calls[0].NodeID != remoteNodeID {
t.Fatalf("expected dispatch to node %d, got %d", remoteNodeID, calls[0].NodeID)
}
}
func TestRestoreServiceAgentRestoreAccessUsesRestoreRecordNode(t *testing.T) {
h := newRestoreTestHarness(t, true)
ctx := context.Background()
task, err := h.tasks.FindByID(ctx, 1)
if err != nil {
t.Fatalf("FindByID task: %v", err)
}
owner, err := h.nodes.FindByID(ctx, task.NodeID)
if err != nil {
t.Fatalf("FindByID owner node: %v", err)
}
other := &model.Node{Name: "edge-2", Token: "other-token", Status: model.NodeStatusOnline, IsLocal: false, LastSeen: time.Now().UTC()}
if err := h.nodes.Create(ctx, other); err != nil {
t.Fatalf("Create other node: %v", err)
}
startedAt := time.Now().UTC()
completedAt := startedAt.Add(time.Second)
backupRecord := &model.BackupRecord{
TaskID: task.ID,
StorageTargetID: task.StorageTargetID,
NodeID: owner.ID,
Status: model.BackupRecordStatusSuccess,
FileName: "remote.tar.gz",
StoragePath: "file/2026/05/09/remote.tar.gz",
StartedAt: startedAt,
CompletedAt: &completedAt,
}
if err := h.records.Create(ctx, backupRecord); err != nil {
t.Fatalf("Create backup record: %v", err)
}
restore := &model.RestoreRecord{
BackupRecordID: backupRecord.ID,
TaskID: task.ID,
NodeID: owner.ID,
Status: model.RestoreRecordStatusRunning,
StartedAt: startedAt,
TriggeredBy: "agent-test",
}
if err := h.restores.Create(ctx, restore); err != nil {
t.Fatalf("Create restore record: %v", err)
}
spec, err := h.service.GetAgentRestoreSpec(ctx, owner, restore.ID)
if err != nil {
t.Fatalf("owner GetAgentRestoreSpec returned error: %v", err)
}
if spec.RestoreRecordID != restore.ID || spec.StoragePath != backupRecord.StoragePath {
t.Fatalf("unexpected restore spec: %#v", spec)
}
if _, err := h.service.GetAgentRestoreSpec(ctx, other, restore.ID); err == nil {
t.Fatal("expected non-owner node to be forbidden from restore spec")
}
if err := h.service.UpdateAgentRestore(ctx, owner, restore.ID, AgentRestoreUpdate{
Status: model.RestoreRecordStatusSuccess,
LogAppend: "done\n",
}); err != nil {
t.Fatalf("owner UpdateAgentRestore returned error: %v", err)
}
updated, err := h.restores.FindByID(ctx, restore.ID)
if err != nil {
t.Fatalf("FindByID restore returned error: %v", err)
}
if updated.Status != model.RestoreRecordStatusSuccess || updated.NodeID != owner.ID {
t.Fatalf("unexpected updated restore record: %#v", updated)
}
if err := h.service.UpdateAgentRestore(ctx, other, restore.ID, AgentRestoreUpdate{LogAppend: "bad\n"}); err == nil {
t.Fatal("expected non-owner node to be forbidden from restore update")
}
}
func TestRestoreServiceUpdateAgentRestoreDoesNotOverwriteTerminalRecord(t *testing.T) {
h := newRestoreTestHarness(t, true)
ctx := context.Background()
task, err := h.tasks.FindByID(ctx, 1)
if err != nil {
t.Fatalf("FindByID task: %v", err)
}
owner, err := h.nodes.FindByID(ctx, task.NodeID)
if err != nil {
t.Fatalf("FindByID owner node: %v", err)
}
startedAt := time.Now().UTC().Add(-time.Hour)
completedAt := time.Now().UTC().Add(-time.Minute)
restore := &model.RestoreRecord{
BackupRecordID: 1,
TaskID: task.ID,
NodeID: owner.ID,
Status: model.RestoreRecordStatusFailed,
ErrorMessage: "timeout",
StartedAt: startedAt,
CompletedAt: &completedAt,
TriggeredBy: "agent-test",
}
if err := h.restores.Create(ctx, restore); err != nil {
t.Fatalf("Create restore record: %v", err)
}
if err := h.service.UpdateAgentRestore(ctx, owner, restore.ID, AgentRestoreUpdate{
Status: model.RestoreRecordStatusSuccess,
ErrorMessage: "late success",
LogAppend: "late log\n",
}); err != nil {
t.Fatalf("UpdateAgentRestore returned error: %v", err)
}
updated, err := h.restores.FindByID(ctx, restore.ID)
if err != nil {
t.Fatalf("FindByID restore returned error: %v", err)
}
if updated.Status != model.RestoreRecordStatusFailed {
t.Fatalf("expected terminal restore status to remain failed, got %#v", updated)
}
if updated.ErrorMessage != "timeout" {
t.Fatalf("expected terminal restore error to remain unchanged, got %q", updated.ErrorMessage)
}
}
func TestRestoreServiceStart_FailsOnNonSuccessBackup(t *testing.T) {
h := newRestoreTestHarness(t, false)
ctx := context.Background()

View File

@@ -22,13 +22,22 @@ func NewUserService(users repository.UserRepository) *UserService {
// UserSummary 用户列表项(不含密码哈希)。
type UserSummary struct {
ID uint `json:"id"`
Username string `json:"username"`
DisplayName string `json:"displayName"`
Email string `json:"email"`
Role string `json:"role"`
Disabled bool `json:"disabled"`
CreatedAt string `json:"createdAt"`
ID uint `json:"id"`
Username string `json:"username"`
DisplayName string `json:"displayName"`
Email string `json:"email"`
Phone string `json:"phone"`
Role string `json:"role"`
Disabled bool `json:"disabled"`
MFAEnabled bool `json:"mfaEnabled"`
TwoFactorEnabled bool `json:"twoFactorEnabled"`
TwoFactorRecoveryCodesRemaining int `json:"twoFactorRecoveryCodesRemaining"`
WebAuthnEnabled bool `json:"webAuthnEnabled"`
WebAuthnCredentialCount int `json:"webAuthnCredentialCount"`
TrustedDeviceCount int `json:"trustedDeviceCount"`
EmailOTPEnabled bool `json:"emailOtpEnabled"`
SMSOTPEnabled bool `json:"smsOtpEnabled"`
CreatedAt string `json:"createdAt"`
}
// UserUpsertInput 创建/更新用户的输入。
@@ -37,6 +46,7 @@ type UserUpsertInput struct {
Password string `json:"password" binding:"omitempty,min=8,max=128"`
DisplayName string `json:"displayName" binding:"required,min=1,max=128"`
Email string `json:"email" binding:"omitempty,max=255"`
Phone string `json:"phone" binding:"omitempty,max=64"`
Role string `json:"role" binding:"required,oneof=admin operator viewer"`
Disabled bool `json:"disabled"`
}
@@ -76,6 +86,7 @@ func (s *UserService) Create(ctx context.Context, input UserUpsertInput) (*UserS
PasswordHash: hash,
DisplayName: strings.TrimSpace(input.DisplayName),
Email: strings.TrimSpace(input.Email),
Phone: strings.TrimSpace(input.Phone),
Role: input.Role,
Disabled: input.Disabled,
}
@@ -107,18 +118,43 @@ func (s *UserService) Update(ctx context.Context, id uint, input UserUpsertInput
return nil, apperror.Conflict("USER_USERNAME_EXISTS", "用户名已存在", nil)
}
}
passwordChanged := strings.TrimSpace(input.Password) != ""
disabledChanged := input.Disabled && !existing.Disabled
emailChanged := strings.TrimSpace(input.Email) != strings.TrimSpace(existing.Email)
phoneChanged := strings.TrimSpace(input.Phone) != strings.TrimSpace(existing.Phone)
existing.Username = strings.TrimSpace(input.Username)
existing.DisplayName = strings.TrimSpace(input.DisplayName)
existing.Email = strings.TrimSpace(input.Email)
existing.Phone = strings.TrimSpace(input.Phone)
existing.Role = input.Role
existing.Disabled = input.Disabled
if strings.TrimSpace(input.Password) != "" {
if passwordChanged {
hash, err := security.HashPassword(input.Password)
if err != nil {
return nil, apperror.Internal("USER_HASH_FAILED", "无法处理密码", err)
}
existing.PasswordHash = hash
existing.TrustedDevices = ""
existing.OutOfBandOTPCiphertext = ""
existing.WebAuthnChallengeCiphertext = ""
}
if strings.TrimSpace(existing.Email) == "" && existing.EmailOTPEnabled {
existing.EmailOTPEnabled = false
existing.OutOfBandOTPCiphertext = ""
}
if strings.TrimSpace(existing.Phone) == "" && existing.SMSOTPEnabled {
existing.SMSOTPEnabled = false
existing.OutOfBandOTPCiphertext = ""
}
if emailChanged || phoneChanged {
existing.OutOfBandOTPCiphertext = ""
}
if disabledChanged {
existing.TrustedDevices = ""
existing.OutOfBandOTPCiphertext = ""
existing.WebAuthnChallengeCiphertext = ""
}
clearTrustedDevicesIfMFAOff(existing)
if err := s.users.Update(ctx, existing); err != nil {
return nil, apperror.Internal("USER_UPDATE_FAILED", "无法更新用户", err)
}
@@ -147,14 +183,47 @@ func (s *UserService) Delete(ctx context.Context, id uint) error {
return s.users.Delete(ctx, id)
}
func (s *UserService) ResetTwoFactor(ctx context.Context, id uint) (*UserSummary, error) {
existing, err := s.users.FindByID(ctx, id)
if err != nil {
return nil, apperror.Internal("USER_GET_FAILED", "无法获取用户", err)
}
if existing == nil {
return nil, apperror.New(404, "USER_NOT_FOUND", "用户不存在", nil)
}
existing.TwoFactorEnabled = false
existing.TwoFactorSecretCiphertext = ""
existing.TwoFactorRecoveryCodeHashes = ""
existing.WebAuthnCredentials = ""
existing.WebAuthnChallengeCiphertext = ""
existing.TrustedDevices = ""
existing.EmailOTPEnabled = false
existing.SMSOTPEnabled = false
existing.OutOfBandOTPCiphertext = ""
if err := s.users.Update(ctx, existing); err != nil {
return nil, apperror.Internal("USER_2FA_RESET_FAILED", "无法重置 MFA", err)
}
summary := toUserSummary(existing)
return &summary, nil
}
func toUserSummary(u *model.User) UserSummary {
return UserSummary{
ID: u.ID,
Username: u.Username,
DisplayName: u.DisplayName,
Email: u.Email,
Role: u.Role,
Disabled: u.Disabled,
CreatedAt: u.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
ID: u.ID,
Username: u.Username,
DisplayName: u.DisplayName,
Email: u.Email,
Phone: u.Phone,
Role: u.Role,
Disabled: u.Disabled,
MFAEnabled: userMFAEnabled(u),
TwoFactorEnabled: u.TwoFactorEnabled,
TwoFactorRecoveryCodesRemaining: recoveryCodeRemainingCount(u),
WebAuthnEnabled: webAuthnCredentialCount(u) > 0,
WebAuthnCredentialCount: webAuthnCredentialCount(u),
TrustedDeviceCount: trustedDeviceCount(u),
EmailOTPEnabled: u.EmailOTPEnabled,
SMSOTPEnabled: u.SMSOTPEnabled,
CreatedAt: u.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
}
}

View File

@@ -0,0 +1,124 @@
package service
import (
"context"
"testing"
"backupx/server/internal/model"
"backupx/server/internal/security"
)
func TestUserServiceUpdatePasswordClearsTrustedDeviceState(t *testing.T) {
hash, err := security.HashPassword("old-password")
if err != nil {
t.Fatalf("HashPassword: %v", err)
}
repo := &fakeUserRepository{users: []*model.User{{
ID: 1,
Username: "admin",
PasswordHash: hash,
DisplayName: "Admin",
Email: "admin@example.com",
Role: model.UserRoleAdmin,
TwoFactorEnabled: true,
TrustedDevices: `[{"id":"device"}]`,
OutOfBandOTPCiphertext: "pending",
WebAuthnChallengeCiphertext: "challenge",
}}}
svc := NewUserService(repo)
if _, err := svc.Update(context.Background(), 1, UserUpsertInput{
Username: "admin",
Password: "new-password",
DisplayName: "Admin",
Email: "admin@example.com",
Role: model.UserRoleAdmin,
}); err != nil {
t.Fatalf("Update: %v", err)
}
updated := repo.users[0]
if security.ComparePassword(updated.PasswordHash, "new-password") != nil {
t.Fatalf("expected password hash to be updated")
}
if updated.TrustedDevices != "" || updated.OutOfBandOTPCiphertext != "" || updated.WebAuthnChallengeCiphertext != "" {
t.Fatalf("expected password update to clear trusted device state, got trusted=%q otp=%q challenge=%q", updated.TrustedDevices, updated.OutOfBandOTPCiphertext, updated.WebAuthnChallengeCiphertext)
}
}
func TestUserServiceUpdateContactClearsUnavailableOTP(t *testing.T) {
hash, err := security.HashPassword("password-123")
if err != nil {
t.Fatalf("HashPassword: %v", err)
}
repo := &fakeUserRepository{users: []*model.User{{
ID: 1,
Username: "admin",
PasswordHash: hash,
DisplayName: "Admin",
Email: "admin@example.com",
Phone: "+15550000000",
Role: model.UserRoleAdmin,
EmailOTPEnabled: true,
SMSOTPEnabled: true,
TrustedDevices: `[{"id":"device"}]`,
OutOfBandOTPCiphertext: "pending",
}}}
svc := NewUserService(repo)
summary, err := svc.Update(context.Background(), 1, UserUpsertInput{
Username: "admin",
DisplayName: "Admin",
Role: model.UserRoleAdmin,
})
if err != nil {
t.Fatalf("Update: %v", err)
}
updated := repo.users[0]
if updated.EmailOTPEnabled || updated.SMSOTPEnabled || summary.MFAEnabled {
t.Fatalf("expected unavailable OTP channels to be disabled")
}
if updated.TrustedDevices != "" || updated.OutOfBandOTPCiphertext != "" || updated.WebAuthnChallengeCiphertext != "" {
t.Fatalf("expected last MFA removal to clear temporary state")
}
}
func TestUserServiceUpdateContactChangeClearsPendingOTP(t *testing.T) {
hash, err := security.HashPassword("password-123")
if err != nil {
t.Fatalf("HashPassword: %v", err)
}
repo := &fakeUserRepository{users: []*model.User{{
ID: 1,
Username: "admin",
PasswordHash: hash,
DisplayName: "Admin",
Email: "old@example.com",
Role: model.UserRoleAdmin,
EmailOTPEnabled: true,
OutOfBandOTPCiphertext: "pending",
}}}
svc := NewUserService(repo)
summary, err := svc.Update(context.Background(), 1, UserUpsertInput{
Username: "admin",
DisplayName: "Admin",
Email: "new@example.com",
Role: model.UserRoleAdmin,
})
if err != nil {
t.Fatalf("Update: %v", err)
}
updated := repo.users[0]
if updated.Email != "new@example.com" || summary.Email != "new@example.com" {
t.Fatalf("expected email to be updated")
}
if !updated.EmailOTPEnabled {
t.Fatalf("expected email OTP to remain enabled")
}
if updated.OutOfBandOTPCiphertext != "" {
t.Fatalf("expected contact change to clear pending OTP")
}
}

View File

@@ -59,6 +59,7 @@ function createEmptyDraft(storageTargets?: StorageTargetSummary[]): BackupTaskPa
storageTargetId: defaultIds[0] ?? 0,
storageTargetIds: defaultIds,
nodeId: 0,
nodePoolTag: '',
tags: '',
retentionDays: 30,
compression: 'gzip',
@@ -127,6 +128,7 @@ export function BackupTaskFormDrawer({ visible, loading, initialValue, storageTa
storageTargetId: editTargetIds[0] ?? 0,
storageTargetIds: editTargetIds,
nodeId: (initialValue as any).nodeId ?? 0,
nodePoolTag: (initialValue as any).nodePoolTag ?? '',
tags: initialValue.tags ?? '',
retentionDays: initialValue.retentionDays,
compression: initialValue.compression,
@@ -297,12 +299,28 @@ export function BackupTaskFormDrawer({ visible, loading, initialValue, storageTa
<Select
value={draft.nodeId ?? 0}
options={nodeOptions}
onChange={(value) => updateDraft({ nodeId: Number(value ?? 0) })}
onChange={(value) => {
const nodeId = Number(value ?? 0)
// 固定节点与节点池互斥:切到固定节点时清空 NodePoolTag
updateDraft(nodeId > 0 ? { nodeId, nodePoolTag: '' } : { nodeId })
}}
/>
<Typography.Paragraph type="secondary" style={{ marginBottom: 0, marginTop: 4 }}>
/"节点管理" Agent
</Typography.Paragraph>
</div>
<div>
<Typography.Text></Typography.Text>
<Input
placeholder="填写标签后从节点池动态调度(与固定节点互斥)"
value={draft.nodePoolTag ?? ''}
disabled={(draft.nodeId ?? 0) > 0}
onChange={(value) => updateDraft({ nodePoolTag: value })}
/>
<Typography.Paragraph type="secondary" style={{ marginBottom: 0, marginTop: 4 }}>
"本机 / 未指定" Labels tag 线
</Typography.Paragraph>
</div>
<div>
<Typography.Text>Cron </Typography.Text>
<CronInput value={draft.cronExpr} onChange={(value) => updateDraft({ cronExpr: value })} />

View File

@@ -5,6 +5,7 @@ describe('notification field config', () => {
it('returns readable type labels', () => {
expect(getNotificationTypeLabel('email')).toBe('Email')
expect(getNotificationTypeLabel('telegram')).toBe('Telegram')
expect(getNotificationTypeLabel('webhook')).toBe('Webhook')
})
it('returns required fields for each notification type', () => {

View File

@@ -1,4 +1,4 @@
import { Avatar, Button, Dropdown, Layout, Menu, Message, Modal, Form, Input, Space, Typography } from '@arco-design/web-react'
import { Alert, Avatar, Button, Divider, Dropdown, Layout, Menu, Message, Modal, Form, Input, Space, Tag, Typography } from '@arco-design/web-react'
import {
IconDashboard,
IconStorage,
@@ -23,7 +23,27 @@ import {
} from '@arco-design/web-react/icon'
import { useState } from 'react'
import { Outlet, useLocation, useNavigate } from 'react-router-dom'
import { changePassword, type ChangePasswordPayload } from '../services/auth'
import {
changePassword,
beginWebAuthnRegistration,
clearTrustedDeviceToken,
configureOtp,
deleteWebAuthnCredential,
disableTwoFactor,
enableTwoFactor,
finishWebAuthnRegistration,
listTrustedDevices,
listWebAuthnCredentials,
prepareTwoFactor,
regenerateRecoveryCodes,
revokeTrustedDevice,
type ChangePasswordPayload,
type TrustedDevice,
type UserInfo,
type WebAuthnCredential,
type TwoFactorSetupResult,
} from '../services/auth'
import { createWebAuthnCredential } from '../utils/webauthn'
import { useAuthStore } from '../stores/auth'
import { resolveErrorMessage } from '../utils/error'
import { isAdmin, roleLabel } from '../utils/permissions'
@@ -105,11 +125,27 @@ export function AppLayout() {
const [collapsed, setCollapsed] = useState(false)
const [pwdVisible, setPwdVisible] = useState(false)
const [pwdLoading, setPwdLoading] = useState(false)
const [twoFactorVisible, setTwoFactorVisible] = useState(false)
const [twoFactorLoading, setTwoFactorLoading] = useState(false)
const [twoFactorSetup, setTwoFactorSetup] = useState<TwoFactorSetupResult | null>(null)
const [recoveryCodes, setRecoveryCodes] = useState<string[]>([])
const [webAuthnCredentials, setWebAuthnCredentials] = useState<WebAuthnCredential[]>([])
const [trustedDevices, setTrustedDevices] = useState<TrustedDevice[]>([])
const [securityDetailsLoading, setSecurityDetailsLoading] = useState(false)
const [pwdForm] = Form.useForm<ChangePasswordPayload & { confirmPassword: string }>()
const [twoFactorForm] = Form.useForm<{ currentPassword: string; code: string; email: string; phone: string }>()
const location = useLocation()
const navigate = useNavigate()
const user = useAuthStore((state) => state.user)
const logout = useAuthStore((state) => state.logout)
const setUser = useAuthStore((state) => state.setUser)
function applySecurityUserUpdate(updated: UserInfo) {
setUser(updated)
if (!updated.mfaEnabled) {
clearTrustedDeviceToken(updated.username)
}
}
async function handleChangePassword() {
try {
@@ -120,6 +156,7 @@ export function AppLayout() {
}
setPwdLoading(true)
await changePassword({ oldPassword: values.oldPassword, newPassword: values.newPassword })
clearTrustedDeviceToken(user?.username)
Message.success('密码修改成功')
setPwdVisible(false)
pwdForm.resetFields()
@@ -132,15 +169,227 @@ export function AppLayout() {
}
}
function closeTwoFactorModal() {
setTwoFactorVisible(false)
setTwoFactorSetup(null)
setRecoveryCodes([])
setWebAuthnCredentials([])
setTrustedDevices([])
twoFactorForm.resetFields()
}
async function openSecurityModal() {
setTwoFactorVisible(true)
twoFactorForm.setFieldValue('email', user?.email ?? '')
twoFactorForm.setFieldValue('phone', user?.phone ?? '')
await loadSecurityDetails()
}
async function loadSecurityDetails() {
setSecurityDetailsLoading(true)
try {
const [credentials, devices] = await Promise.all([listWebAuthnCredentials(), listTrustedDevices()])
setWebAuthnCredentials(credentials)
setTrustedDevices(devices)
} catch (err) {
Message.error(resolveErrorMessage(err, '加载安全配置失败'))
} finally {
setSecurityDetailsLoading(false)
}
}
async function copyRecoveryCodes() {
if (recoveryCodes.length === 0) return
try {
await navigator.clipboard.writeText(recoveryCodes.join('\n'))
Message.success('已复制到剪贴板')
} catch {
Message.info('请手动选择文本复制')
}
}
async function handleTwoFactorSetupAction() {
try {
const values = await twoFactorForm.validate()
setTwoFactorLoading(true)
if (!twoFactorSetup) {
const setup = await prepareTwoFactor({ currentPassword: values.currentPassword })
setTwoFactorSetup(setup)
Message.success('TOTP 密钥已生成')
return
}
const result = await enableTwoFactor({ code: values.code })
setUser(result.user)
setRecoveryCodes(result.recoveryCodes)
Message.success('TOTP 已启用')
} catch (err) {
if (err) {
Message.error(resolveErrorMessage(err, 'TOTP 操作失败'))
}
} finally {
setTwoFactorLoading(false)
}
}
async function handleRegenerateRecoveryCodes() {
try {
const values = await twoFactorForm.validate()
setTwoFactorLoading(true)
const result = await regenerateRecoveryCodes({
currentPassword: values.currentPassword,
code: values.code,
})
setUser(result.user)
setRecoveryCodes(result.recoveryCodes)
twoFactorForm.resetFields()
Message.success('恢复码已重新生成')
} catch (err) {
if (err) {
Message.error(resolveErrorMessage(err, '恢复码生成失败'))
}
} finally {
setTwoFactorLoading(false)
}
}
async function handleDisableTwoFactor() {
try {
const values = await twoFactorForm.validate()
setTwoFactorLoading(true)
const updated = await disableTwoFactor({
currentPassword: values.currentPassword,
code: values.code,
})
applySecurityUserUpdate(updated)
Message.success('TOTP 已关闭')
closeTwoFactorModal()
} catch (err) {
if (err) {
Message.error(resolveErrorMessage(err, '关闭 TOTP 失败'))
}
} finally {
setTwoFactorLoading(false)
}
}
function readCurrentPassword() {
const currentPassword = String(twoFactorForm.getFieldValue('currentPassword') ?? '')
if (currentPassword.trim().length < 8) {
Message.error('请输入当前密码')
return ''
}
return currentPassword
}
async function handleRegisterWebAuthn() {
const currentPassword = readCurrentPassword()
if (!currentPassword) return
try {
setTwoFactorLoading(true)
const options = await beginWebAuthnRegistration({ currentPassword })
const credential = await createWebAuthnCredential(options)
const updated = await finishWebAuthnRegistration({ name: navigator.userAgent.slice(0, 120), credential })
applySecurityUserUpdate(updated)
await loadSecurityDetails()
Message.success('通行密钥已注册')
} catch (err) {
Message.error(resolveErrorMessage(err, '通行密钥注册失败'))
} finally {
setTwoFactorLoading(false)
}
}
async function handleDeleteWebAuthnCredential(id: string) {
const currentPassword = readCurrentPassword()
if (!currentPassword) return
try {
setTwoFactorLoading(true)
const updated = await deleteWebAuthnCredential(id, { currentPassword })
applySecurityUserUpdate(updated)
await loadSecurityDetails()
Message.success('通行密钥已删除')
} catch (err) {
Message.error(resolveErrorMessage(err, '删除通行密钥失败'))
} finally {
setTwoFactorLoading(false)
}
}
async function handleConfigureOtp(channel: 'email' | 'sms', enabled: boolean) {
const currentPassword = readCurrentPassword()
if (!currentPassword) return
const email = String(twoFactorForm.getFieldValue('email') ?? '')
const phone = String(twoFactorForm.getFieldValue('phone') ?? '')
try {
setTwoFactorLoading(true)
const updated = await configureOtp({ currentPassword, channel, enabled, email, phone })
applySecurityUserUpdate(updated)
twoFactorForm.setFieldValue('email', updated.email ?? '')
twoFactorForm.setFieldValue('phone', updated.phone ?? '')
Message.success(enabled ? 'OTP 已启用' : 'OTP 已关闭')
} catch (err) {
Message.error(resolveErrorMessage(err, 'OTP 配置失败'))
} finally {
setTwoFactorLoading(false)
}
}
async function handleRevokeTrustedDevice(id: string) {
const currentPassword = readCurrentPassword()
if (!currentPassword) return
try {
setTwoFactorLoading(true)
await revokeTrustedDevice(id, { currentPassword })
clearTrustedDeviceToken(user?.username)
await loadSecurityDetails()
Message.success('可信设备已移除')
} catch (err) {
Message.error(resolveErrorMessage(err, '移除可信设备失败'))
} finally {
setTwoFactorLoading(false)
}
}
function renderTwoFactorFooter() {
if (recoveryCodes.length > 0) {
return (
<Space>
<Button onClick={() => void copyRecoveryCodes()}></Button>
<Button type="primary" onClick={closeTwoFactorModal}></Button>
</Space>
)
}
if (user?.twoFactorEnabled) {
return (
<Space>
<Button onClick={closeTwoFactorModal}></Button>
<Button loading={twoFactorLoading} onClick={() => void handleRegenerateRecoveryCodes()}></Button>
<Button status="danger" loading={twoFactorLoading} onClick={() => void handleDisableTwoFactor()}> TOTP</Button>
</Space>
)
}
return (
<Space>
<Button onClick={closeTwoFactorModal}></Button>
<Button type="primary" loading={twoFactorLoading} onClick={() => void handleTwoFactorSetupAction()}>
{twoFactorSetup ? '启用 TOTP' : '生成 TOTP 二维码'}
</Button>
</Space>
)
}
const userDroplist = (
<Menu onClickMenuItem={(key) => {
if (key === 'password') {
setPwdVisible(true)
} else if (key === 'two-factor') {
void openSecurityModal()
} else if (key === 'logout') {
logout()
}
}}>
<Menu.Item key="password"><IconLock style={{ marginRight: 8 }} /></Menu.Item>
<Menu.Item key="two-factor"><IconSafe style={{ marginRight: 8 }} /></Menu.Item>
<Menu.Item key="logout"><IconPoweroff style={{ marginRight: 8 }} />退</Menu.Item>
</Menu>
)
@@ -217,6 +466,138 @@ export function AppLayout() {
</Form.Item>
</Form>
</Modal>
<Modal
title="多因素认证"
visible={twoFactorVisible}
onCancel={closeTwoFactorModal}
footer={renderTwoFactorFooter()}
unmountOnExit
>
{recoveryCodes.length > 0 ? (
<Space direction="vertical" size="medium" style={{ width: '100%' }}>
<Alert type="warning" content="恢复码只会显示一次。请立即保存;每个恢复码只能使用一次。" />
<Input.TextArea value={recoveryCodes.join('\n')} autoSize readOnly />
</Space>
) : (
<Form form={twoFactorForm} layout="vertical">
{user?.twoFactorEnabled ? (
<>
<Alert
type="success"
content={`当前账号已启用 TOTP恢复码剩余 ${user.twoFactorRecoveryCodesRemaining ?? 0} 个。`}
style={{ marginBottom: 16 }}
/>
<Form.Item field="currentPassword" label="当前密码" rules={[{ required: true, minLength: 8 }]}>
<Input.Password placeholder="请输入当前密码" />
</Form.Item>
<Form.Item field="code" label="TOTP 验证码" rules={[{ required: true, minLength: 6, maxLength: 10 }]}>
<Input placeholder="请输入 6 位验证码" maxLength={10} />
</Form.Item>
</>
) : (
<>
{!twoFactorSetup ? (
<>
<Alert type="info" content="启用前需要验证当前密码。" style={{ marginBottom: 16 }} />
<Form.Item field="currentPassword" label="当前密码" rules={[{ required: true, minLength: 8 }]}>
<Input.Password placeholder="请输入当前密码" />
</Form.Item>
</>
) : (
<>
<Alert type="warning" content="密钥仅在本次启用流程中显示。启用后会生成一次性恢复码。" style={{ marginBottom: 16 }} />
<div style={{ display: 'flex', gap: 20, alignItems: 'center', marginBottom: 16 }}>
<img
src={twoFactorSetup.qrCodeDataUrl}
alt="TOTP 二维码"
style={{ width: 160, height: 160, border: '1px solid var(--color-border)', borderRadius: 8 }}
/>
<Space direction="vertical" size={8} style={{ flex: 1, minWidth: 0 }}>
<Typography.Text type="secondary"></Typography.Text>
<Input value={twoFactorSetup.secret} readOnly />
</Space>
</div>
<Form.Item field="code" label="TOTP 验证码" rules={[{ required: true, minLength: 6, maxLength: 10 }]}>
<Input placeholder="请输入 6 位验证码" maxLength={10} />
</Form.Item>
</>
)}
</>
)}
<Divider />
<Space direction="vertical" size="medium" style={{ width: '100%' }}>
<Space style={{ justifyContent: 'space-between', width: '100%' }}>
<Typography.Title heading={6} style={{ margin: 0 }}></Typography.Title>
<Tag color={webAuthnCredentials.length > 0 ? 'green' : 'gray'} bordered>
{webAuthnCredentials.length > 0 ? `${webAuthnCredentials.length}` : '未注册'}
</Tag>
</Space>
<Typography.Paragraph type="secondary" style={{ margin: 0 }}>
Passkey
</Typography.Paragraph>
<Button loading={twoFactorLoading} onClick={() => void handleRegisterWebAuthn()}></Button>
<Space direction="vertical" size={8} style={{ width: '100%' }}>
{securityDetailsLoading ? <Typography.Text type="secondary">...</Typography.Text> : null}
{webAuthnCredentials.map((item) => (
<div key={item.id} style={{ display: 'flex', justifyContent: 'space-between', gap: 12, alignItems: 'center', padding: '8px 0', borderTop: '1px solid var(--color-border)' }}>
<Space direction="vertical" size={2}>
<Typography.Text>{item.name}</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>{item.lastUsedAt ? `最近使用 ${item.lastUsedAt}` : `创建于 ${item.createdAt}`}</Typography.Text>
</Space>
<Button size="small" status="danger" onClick={() => void handleDeleteWebAuthnCredential(item.id)}></Button>
</div>
))}
</Space>
</Space>
<Divider />
<Space direction="vertical" size="medium" style={{ width: '100%' }}>
<Typography.Title heading={6} style={{ margin: 0 }}> / OTP</Typography.Title>
<Alert type="info" content="邮件 OTP 使用已启用的 Email 通知配置发送;短信 OTP 使用 Webhook 通知配置发送payload 会包含 phone/code/purpose 字段。" />
<Space wrap>
<Tag color={user?.emailOtpEnabled ? 'green' : 'gray'} bordered> OTP {user?.emailOtpEnabled ? '已启用' : '未启用'}</Tag>
<Tag color={user?.smsOtpEnabled ? 'green' : 'gray'} bordered> OTP {user?.smsOtpEnabled ? '已启用' : '未启用'}</Tag>
</Space>
<Form.Item field="email" label="邮箱">
<Input placeholder="启用邮件 OTP 时填写" />
</Form.Item>
<Form.Item field="phone" label="手机号">
<Input placeholder="启用短信 OTP 时填写" />
</Form.Item>
<Space wrap>
<Button loading={twoFactorLoading} onClick={() => void handleConfigureOtp('email', !user?.emailOtpEnabled)}>
{user?.emailOtpEnabled ? '关闭邮件 OTP' : '启用邮件 OTP'}
</Button>
<Button loading={twoFactorLoading} onClick={() => void handleConfigureOtp('sms', !user?.smsOtpEnabled)}>
{user?.smsOtpEnabled ? '关闭短信 OTP' : '启用短信 OTP'}
</Button>
</Space>
</Space>
<Divider />
<Space direction="vertical" size="medium" style={{ width: '100%' }}>
<Space style={{ justifyContent: 'space-between', width: '100%' }}>
<Typography.Title heading={6} style={{ margin: 0 }}></Typography.Title>
<Tag color={trustedDevices.length > 0 ? 'green' : 'gray'} bordered>{trustedDevices.length} </Tag>
</Space>
<Typography.Paragraph type="secondary" style={{ margin: 0 }}>
30
</Typography.Paragraph>
<Space direction="vertical" size={8} style={{ width: '100%' }}>
{trustedDevices.map((item) => (
<div key={item.id} style={{ display: 'flex', justifyContent: 'space-between', gap: 12, alignItems: 'center', padding: '8px 0', borderTop: '1px solid var(--color-border)' }}>
<Space direction="vertical" size={2}>
<Typography.Text>{item.name}</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>使 {item.lastUsedAt || '-'} {item.expiresAt}</Typography.Text>
</Space>
<Button size="small" status="danger" onClick={() => void handleRevokeTrustedDevice(item.id)}></Button>
</div>
))}
{!securityDetailsLoading && trustedDevices.length === 0 ? <Typography.Text type="secondary"></Typography.Text> : null}
</Space>
</Space>
</Form>
)}
</Modal>
</Layout>
)
}

View File

@@ -1,6 +1,7 @@
import { Alert, Button, Card, Empty, Form, Input, Message, Modal, Select, Space, Switch, Table, Tag, Typography } from '@arco-design/web-react'
import { useCallback, useEffect, useState } from 'react'
import { createUser, deleteUser, listUsers, updateUser, type UserRole, type UserSummary, type UserUpsertPayload } from '../../services/users'
import { createUser, deleteUser, listUsers, resetUserTwoFactor, updateUser, type UserRole, type UserSummary, type UserUpsertPayload } from '../../services/users'
import { clearTrustedDeviceToken } from '../../services/auth'
import { useAuthStore } from '../../stores/auth'
import { resolveErrorMessage } from '../../utils/error'
import { isAdmin, roleLabel } from '../../utils/permissions'
@@ -12,12 +13,13 @@ const roleOptions = [
]
function createEmpty(): UserUpsertPayload {
return { username: '', password: '', displayName: '', email: '', role: 'operator', disabled: false }
return { username: '', password: '', displayName: '', email: '', phone: '', role: 'operator', disabled: false }
}
// UsersPage admin 用户管理。非 admin 角色进入路由会被路由守卫拦截。
export function UsersPage() {
const user = useAuthStore((s) => s.user)
const setUser = useAuthStore((s) => s.setUser)
const [items, setItems] = useState<UserSummary[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
@@ -55,6 +57,7 @@ export function UsersPage() {
password: '',
displayName: item.displayName,
email: item.email,
phone: item.phone,
role: item.role,
disabled: item.disabled,
})
@@ -73,7 +76,13 @@ export function UsersPage() {
setSubmitting(true)
try {
if (editing) {
await updateUser(editing.id, draft)
const updated = await updateUser(editing.id, draft)
if (updated.id === user?.id) {
if (draft.password?.trim()) {
clearTrustedDeviceToken(updated.username)
}
setUser(updated)
}
Message.success('用户已更新')
} else {
await createUser(draft)
@@ -99,6 +108,21 @@ export function UsersPage() {
}
}
async function handleResetTwoFactor(item: UserSummary) {
if (!window.confirm(`确定重置用户「${item.username}」的全部 MFA 配置吗?该用户之后可仅凭密码登录。`)) return
try {
const updated = await resetUserTwoFactor(item.id)
if (updated.id === user?.id) {
clearTrustedDeviceToken(updated.username)
setUser(updated)
}
Message.success('MFA 已重置')
await load()
} catch (e) {
Message.error(resolveErrorMessage(e, '重置 MFA 失败'))
}
}
if (!isAdmin(user)) {
return <Alert type="warning" content="当前账号无权访问用户管理(仅 admin" />
}
@@ -132,12 +156,27 @@ export function UsersPage() {
</Space>
) },
{ title: '角色', dataIndex: 'role', render: (value: string) => <Tag color="arcoblue" bordered>{roleLabel(value)}</Tag> },
{ title: '邮箱', dataIndex: 'email', render: (v: string) => v || '-' },
{ title: '邮箱 / 手机', dataIndex: 'email', render: (_: string, row: UserSummary) => (
<Space direction="vertical" size={2}>
<Typography.Text>{row.email || '-'}</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>{row.phone || '-'}</Typography.Text>
</Space>
) },
{ title: '状态', dataIndex: 'disabled', render: (disabled: boolean) => disabled ? <Tag color="red" bordered></Tag> : <Tag color="green" bordered></Tag> },
{ title: 'MFA', dataIndex: 'mfaEnabled', render: (_: boolean, row: UserSummary) => row.mfaEnabled ? (
<Space wrap size={4}>
{row.twoFactorEnabled ? <Tag color="green" bordered>TOTP</Tag> : null}
{row.webAuthnEnabled ? <Tag color="arcoblue" bordered>Passkey {row.webAuthnCredentialCount}</Tag> : null}
{row.emailOtpEnabled ? <Tag color="purple" bordered></Tag> : null}
{row.smsOtpEnabled ? <Tag color="orange" bordered></Tag> : null}
{row.twoFactorEnabled ? <Typography.Text type="secondary" style={{ fontSize: 12 }}> {row.twoFactorRecoveryCodesRemaining}</Typography.Text> : null}
</Space>
) : <Tag bordered></Tag> },
{ title: '创建时间', dataIndex: 'createdAt' },
{ title: '操作', width: 180, render: (_: unknown, row: UserSummary) => (
{ title: '操作', width: 260, render: (_: unknown, row: UserSummary) => (
<Space>
<Button size="small" type="text" onClick={() => openEdit(row)}></Button>
{row.mfaEnabled && <Button size="small" type="text" onClick={() => void handleResetTwoFactor(row)}> MFA</Button>}
<Button size="small" type="text" status="danger" onClick={() => void handleDelete(row)} disabled={row.id === user?.id}></Button>
</Space>
) },
@@ -163,6 +202,9 @@ export function UsersPage() {
<Form.Item label="邮箱">
<Input value={draft.email} onChange={(v) => setDraft({ ...draft, email: v })} />
</Form.Item>
<Form.Item label="手机号">
<Input value={draft.phone} onChange={(v) => setDraft({ ...draft, phone: v })} />
</Form.Item>
<Form.Item label={editing ? '新密码(留空不修改)' : '初始密码'} required={!editing}>
<Input.Password value={draft.password} onChange={(v) => setDraft({ ...draft, password: v })} />
</Form.Item>

View File

@@ -26,6 +26,23 @@ const categoryLabels: Record<string, string> = {
const actionLabels: Record<string, string> = {
login_success: '登录成功',
login_failed: '登录失败',
two_factor_required: '需要 MFA',
two_factor_setup: '生成 TOTP',
two_factor_enable: '启用 TOTP',
two_factor_disable: '关闭 TOTP',
two_factor_recovery_code_used: '使用恢复码',
two_factor_recovery_codes_regenerate: '重建恢复码',
webauthn_register: '注册通行密钥',
webauthn_used: '使用通行密钥',
webauthn_delete: '删除通行密钥',
trusted_device_create: '信任设备',
trusted_device_used: '使用可信设备',
trusted_device_revoke: '移除可信设备',
otp_enable: '启用 OTP',
otp_disable: '关闭 OTP',
otp_send: '发送 OTP',
otp_used: '使用 OTP',
reset_two_factor: '重置 MFA',
setup: '系统初始化',
change_password: '修改密码',
create: '创建',

View File

@@ -1,10 +1,11 @@
import { Alert, Button, Card, Form, Input, Space, Typography, Message } from '@arco-design/web-react'
import { IconCloud, IconLock, IconUser } from '@arco-design/web-react/icon'
import { Button, Checkbox, Form, Input, Space, Typography, Message } from '@arco-design/web-react'
import { IconCloud, IconLock, IconSafe, IconUser } from '@arco-design/web-react/icon'
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import axios from 'axios'
import { fetchSetupStatus } from '../../services/auth'
import { beginWebAuthnLogin, fetchSetupStatus, sendLoginOtp } from '../../services/auth'
import { useAuthStore } from '../../stores/auth'
import { getWebAuthnAssertion } from '../../utils/webauthn'
interface SetupFormValues {
username: string
@@ -15,12 +16,17 @@ interface SetupFormValues {
interface LoginFormValues {
username: string
password: string
twoFactorCode?: string
rememberDevice?: boolean
}
function resolveErrorMessage(error: unknown) {
if (axios.isAxiosError(error)) {
return error.response?.data?.message ?? '请求失败,请稍后重试'
}
if (error instanceof Error) {
return error.message
}
return '请求失败,请稍后重试'
}
@@ -29,8 +35,20 @@ export function LoginPage() {
const authStatus = useAuthStore((state) => state.status)
const doLogin = useAuthStore((state) => state.login)
const doSetup = useAuthStore((state) => state.setup)
const [loginForm] = Form.useForm<LoginFormValues>()
const [initialized, setInitialized] = useState<boolean | null>(null)
const [loading, setLoading] = useState(false)
const [mfaActionLoading, setMfaActionLoading] = useState('')
const [twoFactorRequired, setTwoFactorRequired] = useState(false)
function resetTwoFactorPrompt() {
if (!twoFactorRequired) {
return
}
setTwoFactorRequired(false)
loginForm.setFieldValue('twoFactorCode', undefined)
loginForm.setFieldValue('rememberDevice', false)
}
useEffect(() => {
if (authStatus === 'authenticated') {
@@ -73,13 +91,77 @@ export function LoginPage() {
const handleLogin = async (values: LoginFormValues) => {
setLoading(true)
try {
await doLogin(values)
await doLogin({
...values,
trustedDeviceName: values.rememberDevice ? navigator.userAgent.slice(0, 120) : undefined,
})
setTwoFactorRequired(false)
Message.success('登录成功')
navigate('/dashboard', { replace: true })
} catch (error) {
if (axios.isAxiosError(error)) {
const code = error.response?.data?.code
if (code === 'AUTH_2FA_REQUIRED' || code === 'AUTH_2FA_INVALID') {
setTwoFactorRequired(true)
Message.error(resolveErrorMessage(error))
return
}
}
Message.error(resolveErrorMessage(error))
} finally {
setLoading(false)
}
}
function readLoginCredentials(): (LoginFormValues & { username: string; password: string }) | null {
const values = loginForm.getFieldsValue()
if (!values.username?.trim() || !values.password?.trim()) {
Message.error('请先输入用户名和密码')
return null
}
return {
...values,
username: values.username,
password: values.password,
}
}
async function handleSendOTP(channel: 'email' | 'sms') {
const values = readLoginCredentials()
if (!values) return
setMfaActionLoading(channel)
try {
await sendLoginOtp({ username: values.username, password: values.password, channel })
Message.success(channel === 'email' ? '邮件验证码已发送' : '短信验证码已发送')
} catch (error) {
Message.error(resolveErrorMessage(error))
} finally {
setMfaActionLoading('')
}
}
async function handleWebAuthnLogin() {
const values = readLoginCredentials()
if (!values) return
setMfaActionLoading('webauthn')
try {
const options = await beginWebAuthnLogin({ username: values.username, password: values.password })
const assertion = await getWebAuthnAssertion(options)
await doLogin({
username: values.username,
password: values.password,
webAuthnAssertion: assertion,
trustedDeviceToken: '',
rememberDevice: values.rememberDevice,
trustedDeviceName: navigator.userAgent.slice(0, 120),
})
setTwoFactorRequired(false)
Message.success('登录成功')
navigate('/dashboard', { replace: true })
} catch (error) {
Message.error(resolveErrorMessage(error))
} finally {
setLoading(false)
setMfaActionLoading('')
}
}
@@ -181,15 +263,30 @@ export function LoginPage() {
</Button>
</Form>
) : (
<Form<LoginFormValues> layout="vertical" onSubmit={handleLogin}>
<Form<LoginFormValues> form={loginForm} layout="vertical" onSubmit={handleLogin}>
<Form.Item field="username" label="用户名" rules={[{ required: true, minLength: 3 }]}>
<Input placeholder="请输入用户名" prefix={<IconUser />} size="large" />
<Input placeholder="请输入用户名" prefix={<IconUser />} size="large" onChange={resetTwoFactorPrompt} />
</Form.Item>
<Form.Item field="password" label="密码" rules={[{ required: true, minLength: 8 }]}>
<Input.Password placeholder="请输入密码" prefix={<IconLock />} size="large" />
<Input.Password placeholder="请输入密码" prefix={<IconLock />} size="large" onChange={resetTwoFactorPrompt} />
</Form.Item>
{twoFactorRequired && (
<>
<Form.Item field="twoFactorCode" label="验证码或恢复码" rules={[{ required: true, minLength: 6, maxLength: 32 }]}>
<Input placeholder="请输入 TOTP、恢复码、邮件或短信验证码" prefix={<IconSafe />} size="large" maxLength={32} />
</Form.Item>
<Space wrap style={{ marginTop: -8, marginBottom: 8 }}>
<Button loading={mfaActionLoading === 'email'} onClick={() => void handleSendOTP('email')}></Button>
<Button loading={mfaActionLoading === 'sms'} onClick={() => void handleSendOTP('sms')}></Button>
<Button loading={mfaActionLoading === 'webauthn'} onClick={() => void handleWebAuthnLogin()}>使</Button>
</Space>
<Form.Item field="rememberDevice" triggerPropName="checked">
<Checkbox> 30 </Checkbox>
</Form.Item>
</>
)}
<Button long type="primary" htmlType="submit" loading={loading} size="large" style={{ borderRadius: 8, height: 44, marginTop: 16 }}>
{twoFactorRequired ? '验证并登录' : '登录'}
</Button>
</Form>
)}

View File

@@ -1,11 +1,11 @@
import React, { useEffect, useRef, useState } from 'react'
import { Modal, Steps, Button, Space, Message, Spin, Progress } from '@arco-design/web-react'
import { Modal, Steps, Button, Space, Message, Spin } from '@arco-design/web-react'
import { Step1NodeName, type Mode } from './wizard/Step1NodeName'
import { Step2DeployOptions, type DeployOptions } from './wizard/Step2DeployOptions'
import { Step3CommandPreview } from './wizard/Step3CommandPreview'
import { BatchCommandTable, type BatchCommandRow } from './BatchCommandTable'
import { batchCreateNodes, createInstallToken } from '../../services/nodes'
import type { InstallTokenResult } from '../../types/nodes'
import { useAgentDeployFlow, type AgentDeployRow } from './useAgentDeployFlow'
const Step = Steps.Step
@@ -24,9 +24,7 @@ export function AgentInstallWizard({ visible, onClose, onSuccess, masterVersion,
const [mode, setMode] = useState<Mode>('single')
const [singleName, setSingleName] = useState('')
const [batchText, setBatchText] = useState('')
// 批量进度(已生成 / 总数)
const [batchProgress, setBatchProgress] = useState<{ done: number; total: number } | null>(null)
const deployFlow = useAgentDeployFlow()
const [deploy, setDeploy] = useState<DeployOptions>({
mode: 'systemd',
@@ -65,7 +63,6 @@ export function AgentInstallWizard({ visible, onClose, onSuccess, masterVersion,
setSingleToken(null)
setSingleNodeInfo(null)
setBatchRows([])
setBatchProgress(null)
}
const handleClose = () => {
@@ -101,71 +98,21 @@ export function AgentInstallWizard({ visible, onClose, onSuccess, masterVersion,
Message.warning('请填写 Agent 版本号(形如 v1.7.0')
return
}
// 步骤 1 的批次内去重在前端先提示一次,再由后端最终校验
if (mode === 'batch' && !fixedNode) {
const names = parseBatchNames()
const seen = new Set<string>()
const dups: string[] = []
for (const n of names) {
if (seen.has(n)) dups.push(n)
seen.add(n)
}
if (dups.length > 0) {
Message.warning(`批次内有重复节点名:${Array.from(new Set(dups)).join(', ')}`)
return
}
}
setSubmitting(true)
try {
if (fixedNode) {
const tok = await createInstallToken(fixedNode.id, {
mode: deploy.mode,
arch: deploy.arch,
agentVersion: deploy.agentVersion,
downloadSrc: deploy.downloadSrc,
ttlSeconds: deploy.ttlSeconds,
})
setSingleNodeInfo(fixedNode)
setSingleToken(tok)
const result = await deployFlow.submitExistingNode(fixedNode, deploy)
applySingleOrTableResult(result.rows, fixedNode)
} else if (mode === 'single') {
const created = await batchCreateNodes([singleName.trim()])
const one = created[0]
const tok = await createInstallToken(one.id, {
mode: deploy.mode,
arch: deploy.arch,
agentVersion: deploy.agentVersion,
downloadSrc: deploy.downloadSrc,
ttlSeconds: deploy.ttlSeconds,
})
setSingleNodeInfo({ id: one.id, name: one.name })
setSingleToken(tok)
const result = await deployFlow.submitNewNodes([singleName.trim()], deploy)
applySingleOrTableResult(result.rows)
} else {
const names = parseBatchNames()
const created = await batchCreateNodes(names)
setBatchProgress({ done: 0, total: created.length })
// 并发生成 install tokenPromise.all每完成一个递增 done 计数
let done = 0
const tokens = await Promise.all(
created.map(async (c) => {
const tok = await createInstallToken(c.id, {
mode: deploy.mode,
arch: deploy.arch,
agentVersion: deploy.agentVersion,
downloadSrc: deploy.downloadSrc,
ttlSeconds: deploy.ttlSeconds,
})
done += 1
if (mountedRef.current) setBatchProgress({ done, total: created.length })
return { c, tok }
}),
)
const rows: BatchCommandRow[] = tokens.map(({ c, tok }) => ({
nodeId: c.id,
nodeName: c.name,
command: `curl -fsSL ${tok.url} | sudo bash`,
expiresAt: tok.expiresAt,
}))
if (mountedRef.current) setBatchRows(rows)
const result = await deployFlow.submitNewNodes(names, deploy)
if (mountedRef.current) setBatchRows(toBatchRows(result.rows))
if (result.status === 'partialFailed') {
Message.warning('部分节点安装命令生成失败,可在结果表中查看')
}
}
setStep(2)
onSuccess()
@@ -180,14 +127,12 @@ export function AgentInstallWizard({ visible, onClose, onSuccess, masterVersion,
if (!singleNodeInfo) return
setSubmitting(true)
try {
const tok = await createInstallToken(singleNodeInfo.id, {
mode: deploy.mode,
arch: deploy.arch,
agentVersion: deploy.agentVersion,
downloadSrc: deploy.downloadSrc,
ttlSeconds: deploy.ttlSeconds,
})
setSingleToken(tok)
const row = await deployFlow.regenerateNode(singleNodeInfo, deploy)
if (row.status === 'ready' && row.installToken) {
setSingleToken(row.installToken)
} else {
Message.error(row.errorMessage || '重新生成失败')
}
} catch (e: any) {
Message.error(e?.message || '重新生成失败')
} finally {
@@ -195,6 +140,25 @@ export function AgentInstallWizard({ visible, onClose, onSuccess, masterVersion,
}
}
const retryBatchNode = async (row: BatchCommandRow) => {
setSubmitting(true)
try {
const next = await deployFlow.regenerateNode({ id: row.nodeId, name: row.nodeName }, deploy)
setBatchRows((rows) => rows.map((item) => (
item.nodeId === row.nodeId ? toBatchRows([next])[0] : item
)))
if (next.status === 'ready') {
Message.success(`节点「${row.nodeName}」安装命令已重新生成`)
} else {
Message.error(next.errorMessage || '重试失败')
}
} catch (e: any) {
Message.error(e?.message || '重试失败')
} finally {
setSubmitting(false)
}
}
const previewParams = {
mode: deploy.mode,
arch: deploy.arch,
@@ -224,17 +188,6 @@ export function AgentInstallWizard({ visible, onClose, onSuccess, masterVersion,
{submitting && (
<div style={{ textAlign: 'center', padding: 32 }}>
<Spin />
{batchProgress && (
<div style={{ marginTop: 16, maxWidth: 360, marginLeft: 'auto', marginRight: 'auto' }}>
<div style={{ fontSize: 13, marginBottom: 6 }}>
{batchProgress.done} / {batchProgress.total}
</div>
<Progress
percent={Math.round((batchProgress.done / batchProgress.total) * 100)}
showText
/>
</div>
)}
</div>
)}
@@ -288,7 +241,7 @@ export function AgentInstallWizard({ visible, onClose, onSuccess, masterVersion,
onRegenerate={regenerateSingle}
/>
)}
{batchRows.length > 0 && <BatchCommandTable rows={batchRows} />}
{batchRows.length > 0 && <BatchCommandTable rows={batchRows} onRetryNode={retryBatchNode} />}
<div style={{ marginTop: 24, textAlign: 'right' }}>
<Button type="primary" onClick={handleClose}>
@@ -298,4 +251,31 @@ export function AgentInstallWizard({ visible, onClose, onSuccess, masterVersion,
)}
</Modal>
)
function applySingleOrTableResult(rows: AgentDeployRow[], fallbackNode?: { id: number; name: string }) {
const row = rows[0]
if (!row) return
if (row.status === 'ready' && row.installToken) {
setSingleNodeInfo({ id: row.nodeId || fallbackNode?.id || 0, name: row.nodeName || fallbackNode?.name || '' })
setSingleToken(row.installToken)
setBatchRows([])
return
}
setSingleNodeInfo(null)
setSingleToken(null)
setBatchRows(toBatchRows(rows))
Message.error(row.errorMessage || '安装命令生成失败')
}
}
function toBatchRows(rows: AgentDeployRow[]): BatchCommandRow[] {
return rows.map((row) => ({
nodeId: row.nodeId,
nodeName: row.nodeName,
status: row.status,
command: row.command,
expiresAt: row.expiresAt,
errorMessage: row.errorMessage,
embeddedCommand: row.embeddedCommand,
}))
}

View File

@@ -0,0 +1,30 @@
import { describe, expect, it, vi } from 'vitest'
import type { BatchCommandRow } from './BatchCommandTable'
import { getExportableBatchRows } from './BatchCommandTable'
function row(patch: Partial<BatchCommandRow>): BatchCommandRow {
return {
nodeId: 1,
nodeName: 'prod-a',
status: 'ready',
command: 'curl install',
expiresAt: '2099-01-01T00:00:00Z',
...patch,
}
}
describe('getExportableBatchRows', () => {
it('excludes failed and expired commands from batch export', () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-05-09T00:00:00Z'))
const rows = [
row({ nodeId: 1, nodeName: 'ready', expiresAt: '2026-05-09T00:05:00Z' }),
row({ nodeId: 2, nodeName: 'failed', status: 'failed', errorMessage: 'failed' }),
row({ nodeId: 3, nodeName: 'expired', expiresAt: '2026-05-08T23:59:59Z' }),
]
expect(getExportableBatchRows(rows).map((item) => item.nodeName)).toEqual(['ready'])
vi.useRealTimers()
})
})

View File

@@ -1,29 +1,32 @@
import React, { useEffect, useState } from 'react'
import { Table, Button, Space, Message, Typography } from '@arco-design/web-react'
import { IconCopy, IconDownload } from '@arco-design/web-react/icon'
import { Table, Button, Space, Message, Typography, Tag } from '@arco-design/web-react'
import { IconCopy, IconDownload, IconRefresh } from '@arco-design/web-react/icon'
const { Text } = Typography
export interface BatchCommandRow {
nodeId: number
nodeName: string
status: 'ready' | 'failed'
command: string
expiresAt: string
errorMessage?: string
embeddedCommand?: string
}
interface Props {
rows: BatchCommandRow[]
onRetryNode?: (row: BatchCommandRow) => void
}
export function BatchCommandTable({ rows }: Props) {
export function BatchCommandTable({ rows, onRetryNode }: Props) {
const [remaining, setRemaining] = useState<Record<number, number>>({})
useEffect(() => {
const tick = () => {
const next: Record<number, number> = {}
rows.forEach((r) => {
const exp = new Date(r.expiresAt).getTime()
next[r.nodeId] = Math.max(0, Math.floor((exp - Date.now()) / 1000))
next[r.nodeId] = secondsLeft(r.expiresAt)
})
setRemaining(next)
}
@@ -38,12 +41,13 @@ export function BatchCommandTable({ rows }: Props) {
}
const exportAll = () => {
const exportRows = getExportableBatchRows(rows)
const content = [
'#!/bin/sh',
'# BackupX Agent 批量部署脚本',
'# 使用方法:在目标机逐个执行下面对应节点命令',
'',
...rows.map((r) => `# --- ${r.nodeName} ---\n${r.command}`),
...exportRows.map((r) => `# --- ${r.nodeName} ---\n${r.command}`),
].join('\n\n')
const blob = new Blob([content], { type: 'text/x-shellscript' })
const url = URL.createObjectURL(blob)
@@ -61,11 +65,20 @@ export function BatchCommandTable({ rows }: Props) {
pagination={false}
columns={[
{ title: '节点', dataIndex: 'nodeName', width: 140 },
{
title: '状态', dataIndex: 'status', width: 90,
render: (status: BatchCommandRow['status']) => (
status === 'ready' ? <Tag color="green"></Tag> : <Tag color="red"></Tag>
),
},
{
title: '安装命令',
dataIndex: 'command',
render: (cmd: unknown, row: BatchCommandRow) => {
const left = remaining[row.nodeId] ?? 0
if (row.status === 'failed') {
return <Text type="error" style={{ fontSize: 12 }}>{row.errorMessage || '生成安装命令失败'}</Text>
}
return (
<Text style={{
fontFamily: 'monospace', fontSize: 12, wordBreak: 'break-all',
@@ -80,6 +93,9 @@ export function BatchCommandTable({ rows }: Props) {
title: '剩余', dataIndex: 'expiresAt', width: 90,
render: (_v: unknown, row: BatchCommandRow) => {
const left = remaining[row.nodeId] ?? 0
if (row.status === 'failed') {
return <Text type="secondary" style={{ fontSize: 12 }}>-</Text>
}
return (
<Text type={left === 0 ? 'secondary' : 'primary'} style={{ fontSize: 12 }}>
{left === 0 ? '已过期' : `${Math.floor(left / 60)}:${String(left % 60).padStart(2, '0')}`}
@@ -88,10 +104,17 @@ export function BatchCommandTable({ rows }: Props) {
},
},
{
title: '操作', width: 80,
title: '操作', width: 110,
render: (_v: unknown, row: BatchCommandRow) => (
<Button size="small" icon={<IconCopy />} onClick={() => copy(row.command)}
disabled={(remaining[row.nodeId] ?? 0) === 0}></Button>
<Space>
{row.status === 'ready' && (
<Button size="small" icon={<IconCopy />} onClick={() => copy(row.command)}
disabled={(remaining[row.nodeId] ?? 0) === 0}></Button>
)}
{row.status === 'failed' && onRetryNode && (
<Button size="small" icon={<IconRefresh />} onClick={() => onRetryNode(row)}></Button>
)}
</Space>
),
},
]}
@@ -100,9 +123,22 @@ export function BatchCommandTable({ rows }: Props) {
/>
<div style={{ marginTop: 12, textAlign: 'right' }}>
<Space>
<Button icon={<IconDownload />} onClick={exportAll}> .sh</Button>
<Button icon={<IconDownload />} onClick={exportAll}
disabled={getExportableBatchRows(rows).length === 0}> .sh</Button>
</Space>
</div>
</div>
)
}
function secondsLeft(expiresAt: string) {
if (!expiresAt) {
return 0
}
const exp = new Date(expiresAt).getTime()
return Math.max(0, Math.floor((exp - Date.now()) / 1000))
}
export function getExportableBatchRows(rows: BatchCommandRow[]) {
return rows.filter((row) => row.status === 'ready' && secondsLeft(row.expiresAt) > 0)
}

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