mirror of
https://github.com/Awuqing/BackupX.git
synced 2026-06-25 11:33:42 +08:00
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7a6ffd4ddd | ||
|
|
61709dd4c9 | ||
|
|
f6bd185b9f | ||
|
|
af0e8f5c1f | ||
|
|
63fde903d2 | ||
|
|
67a42b09ba | ||
|
|
bc8742977e | ||
|
|
1a699da8d6 | ||
|
|
1b73f19eb1 |
10
.github/workflows/release.yml
vendored
10
.github/workflows/release.yml
vendored
@@ -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 ───
|
||||
|
||||
@@ -46,6 +46,9 @@
|
||||
| **Multi-Node Cluster** | Master-Agent mode via HTTP long-polling — Agents run tasks locally, upload straight to storage, no reverse connectivity required |
|
||||
| **Security** | JWT + bcrypt + AES-256-GCM encrypted config + optional backup encryption + full audit log |
|
||||
| **Notifications** | Email / Webhook / Telegram on success or failure |
|
||||
| **Observability** | Prometheus `/metrics` endpoint + `/health` + `/ready` probes + SLA breach gauge |
|
||||
| **Audit Webhook** | HMAC-SHA256 signed forwarding to SIEM / WORM storage for compliance (SOC2 / GDPR) |
|
||||
| **Flow Control** | Per-node bandwidth cap + per-node concurrency limit — tune big/small nodes independently |
|
||||
| **Deployment** | Single binary + embedded SQLite, Docker one-click, zero external dependencies |
|
||||
|
||||
## Quick Start
|
||||
@@ -59,6 +62,8 @@ curl -LO https://github.com/Awuqing/BackupX/releases/latest/download/backupx-lin
|
||||
tar xzf backupx-*.tar.gz && cd backupx-* && sudo ./install.sh
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
@@ -46,6 +46,9 @@
|
||||
| **多节点集群** | Master-Agent 模式,基于 HTTP 长轮询跨多台服务器管理备份。Agent 本地执行任务并直接上传到存储,无需反向连通性 |
|
||||
| **安全** | JWT + bcrypt + AES-256-GCM 加密配置 + 可选备份文件加密 + 完整审计日志 |
|
||||
| **通知** | 邮件 / Webhook / Telegram,备份成功或失败时自动推送 |
|
||||
| **可观测性** | Prometheus `/metrics` 端点 + `/health` + `/ready` 探针 + SLA 违约监控 |
|
||||
| **审计外输** | HMAC-SHA256 签名 Webhook,对接 SIEM / WORM 存储满足 SOC2 / GDPR 合规 |
|
||||
| **流控** | 节点级带宽限速 + 节点级并发控制,大小节点分别配置,避免小内存 Agent 被挤爆 |
|
||||
| **部署** | 单二进制 + 内嵌 SQLite,Docker 一键启动,零外部依赖 |
|
||||
|
||||
## 快速开始
|
||||
@@ -59,6 +62,8 @@ curl -LO https://github.com/Awuqing/BackupX/releases/latest/download/backupx-lin
|
||||
tar xzf backupx-*.tar.gz && cd backupx-* && sudo ./install.sh
|
||||
```
|
||||
|
||||
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) 完成首次备份。
|
||||
|
||||
## 文档
|
||||
|
||||
@@ -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
34
deploy/grafana/README.md
Normal 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 任务耗时突变可用于发现慢任务和资源压力
|
||||
193
deploy/grafana/backupx-dashboard.json
Normal file
193
deploy/grafana/backupx-dashboard.json
Normal 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": ""
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -18,6 +18,22 @@ server {
|
||||
proxy_read_timeout 3600s;
|
||||
}
|
||||
|
||||
# Agent 一键安装脚本路径(兼容 v2.0 及之前生成的命令)。
|
||||
# v2.1+ 新生成的命令走 /api/install/... 自动命中上面的 /api/ 代理。
|
||||
location /install/ {
|
||||
proxy_pass http://127.0.0.1:8340/install/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# 健康检查端点同样不走 SPA fallback。
|
||||
location = /health { proxy_pass http://127.0.0.1:8340/health; }
|
||||
location = /ready { proxy_pass http://127.0.0.1:8340/ready; }
|
||||
location = /metrics { proxy_pass http://127.0.0.1:8340/metrics; }
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`,
|
||||
},
|
||||
|
||||
@@ -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、可靠发布、恢复信心和更完善的文档。"}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 可访问地址时,才建议留空。
|
||||
|
||||
@@ -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_` 前缀环境变量覆盖:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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": "赞助层级"}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
329
docs-site/src/components/HomepageCommunity/index.tsx
Normal file
329
docs-site/src/components/HomepageCommunity/index.tsx
Normal 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">-></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>
|
||||
);
|
||||
}
|
||||
429
docs-site/src/components/HomepageCommunity/styles.module.css
Normal file
429
docs-site/src/components/HomepageCommunity/styles.module.css
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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">-></span>
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"> -></span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
19
docs-site/src/pages/community.tsx
Normal file
19
docs-site/src/pages/community.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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">-></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}><token></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>
|
||||
);
|
||||
|
||||
39
docs-site/src/pages/sponsors.tsx
Normal file
39
docs-site/src/pages/sponsors.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 数据库路径
|
||||
|
||||
@@ -7,6 +7,8 @@ require (
|
||||
github.com/glebarez/sqlite v1.11.0
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0
|
||||
github.com/natefinch/lumberjack v2.0.0+incompatible
|
||||
github.com/prometheus/client_golang v1.23.2
|
||||
github.com/pquerna/otp v1.5.0
|
||||
github.com/rclone/rclone v1.73.3
|
||||
github.com/robfig/cron/v3 v3.0.1
|
||||
github.com/spf13/viper v1.20.0
|
||||
@@ -180,8 +182,6 @@ require (
|
||||
github.com/pkg/xattr v0.4.12 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
|
||||
github.com/pquerna/otp v1.5.0 // indirect
|
||||
github.com/prometheus/client_golang v1.23.2 // indirect
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
github.com/prometheus/common v0.67.2 // indirect
|
||||
github.com/prometheus/procfs v0.19.2 // indirect
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
// - 上报:通过 UpdateRestore(status/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))
|
||||
|
||||
34
server/internal/agent/executor_test.go
Normal file
34
server/internal/agent/executor_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"backupx/server/internal/database"
|
||||
aphttp "backupx/server/internal/http"
|
||||
"backupx/server/internal/logger"
|
||||
"backupx/server/internal/metrics"
|
||||
"backupx/server/internal/notify"
|
||||
"backupx/server/internal/repository"
|
||||
"backupx/server/internal/scheduler"
|
||||
@@ -59,9 +60,9 @@ func New(ctx context.Context, cfg config.Config, version string) (*Application,
|
||||
|
||||
jwtManager := security.NewJWTManager(resolvedSecurity.JWTSecret, config.MustJWTDuration(cfg.Security))
|
||||
rateLimiter := security.NewLoginRateLimiter(5, time.Minute)
|
||||
authService := service.NewAuthService(userRepo, systemConfigRepo, jwtManager, rateLimiter)
|
||||
systemService := service.NewSystemService(cfg, version, time.Now().UTC())
|
||||
configCipher := codec.NewConfigCipher(resolvedSecurity.EncryptionKey)
|
||||
authService := service.NewAuthService(userRepo, systemConfigRepo, jwtManager, rateLimiter, configCipher)
|
||||
systemService := service.NewSystemService(cfg, version, time.Now().UTC())
|
||||
storageRegistry := storage.NewRegistry(
|
||||
storageRclone.NewLocalDiskFactory(),
|
||||
storageRclone.NewS3Factory(),
|
||||
@@ -86,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,
|
||||
@@ -109,6 +111,8 @@ func New(ctx context.Context, cfg config.Config, version string) (*Application,
|
||||
auditService := service.NewAuditService(auditLogRepo)
|
||||
authService.SetAuditService(auditService)
|
||||
schedulerService.SetAuditRecorder(auditService)
|
||||
// 审计日志外输:启动时用当前 settings 初始化 webhook,后续前端修改立即生效
|
||||
settingsService.SetAuditWebhookConfigurer(ctx, auditService)
|
||||
|
||||
// Database discovery(集群依赖在 agentService 创建后注入)
|
||||
databaseDiscoveryService := service.NewDatabaseDiscoveryService(backup.NewOSCommandExecutor())
|
||||
@@ -226,39 +230,55 @@ func New(ctx context.Context, cfg config.Config, version string) (*Application,
|
||||
// Dashboard 集群概览依赖注入
|
||||
dashboardService.SetClusterDependencies(nodeRepo, version)
|
||||
|
||||
// Prometheus 指标采集:Counter/Histogram 由业务服务实时写入;
|
||||
// Gauge 类(存储用量、节点在线、SLA 违约)由 Collector 每 30s 异步刷新,
|
||||
// 避免 /metrics 请求路径做慢 IO。
|
||||
appMetrics := metrics.New(version)
|
||||
backupExecutionService.SetMetrics(appMetrics)
|
||||
restoreService.SetMetrics(appMetrics)
|
||||
verificationService.SetMetrics(appMetrics)
|
||||
replicationService.SetMetrics(appMetrics)
|
||||
metricsCollector := metrics.NewCollector(
|
||||
appMetrics,
|
||||
metrics.NewRepoSource(storageTargetRepo, backupRecordRepo, nodeRepo, backupTaskRepo),
|
||||
30*time.Second,
|
||||
)
|
||||
metricsCollector.Start(ctx)
|
||||
|
||||
router := aphttp.NewRouter(aphttp.RouterDependencies{
|
||||
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,
|
||||
})
|
||||
|
||||
httpServer := &stdhttp.Server{
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -3,10 +3,12 @@ package http
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -17,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"},
|
||||
@@ -38,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)
|
||||
@@ -46,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)
|
||||
@@ -58,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())
|
||||
@@ -75,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,
|
||||
@@ -104,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)
|
||||
|
||||
@@ -152,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 {
|
||||
@@ -160,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)
|
||||
@@ -171,6 +261,22 @@ func TestOneClickInstallFlow(t *testing.T) {
|
||||
if !strings.Contains(scriptRec.Body.String(), "systemctl enable --now backupx-agent") {
|
||||
t.Fatalf("script missing systemctl enable:\n%s", scriptRec.Body.String())
|
||||
}
|
||||
// Issue #46 防嗅探 headers:text/plain + nosniff + no-store + Content-Disposition
|
||||
if ct := scriptRec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/plain") {
|
||||
t.Errorf("script Content-Type should be text/plain*, got %q", ct)
|
||||
}
|
||||
if nosniff := scriptRec.Header().Get("X-Content-Type-Options"); nosniff != "nosniff" {
|
||||
t.Errorf("missing X-Content-Type-Options: nosniff (got %q)", nosniff)
|
||||
}
|
||||
if cc := scriptRec.Header().Get("Cache-Control"); !strings.Contains(cc, "no-store") {
|
||||
t.Errorf("missing Cache-Control: no-store (got %q)", cc)
|
||||
}
|
||||
if cd := scriptRec.Header().Get("Content-Disposition"); !strings.Contains(cd, "backupx-agent-install.sh") {
|
||||
t.Errorf("Content-Disposition should name the script file (got %q)", cd)
|
||||
}
|
||||
if !strings.Contains(scriptRec.Body.String(), "BACKUPX_AGENT_INSTALL_V1") {
|
||||
t.Errorf("script missing magic marker BACKUPX_AGENT_INSTALL_V1")
|
||||
}
|
||||
|
||||
// 4. 再次消费应 410
|
||||
scriptReq2 := httptest.NewRequest(http.MethodGet, "/install/"+genResp.Data.InstallToken, nil)
|
||||
@@ -181,6 +287,81 @@ func TestOneClickInstallFlow(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestInstallScriptAliasUnderAPI 验证 /api/install/:token 别名路径可用,
|
||||
// 这是 Issue #46 的根本修复:让 install 端点自动命中反向代理的 /api/ 转发规则,
|
||||
// 避免 nginx SPA fallback 把请求当前端路由返回 index.html。
|
||||
func TestInstallScriptAliasUnderAPI(t *testing.T) {
|
||||
router, token := setupInstallFlowRouter(t)
|
||||
|
||||
// 1. 创建一个节点,生成 install token
|
||||
batchBody, _ := json.Marshal(map[string][]string{"names": {"alias-node"}})
|
||||
batchReq := httptest.NewRequest(http.MethodPost, "/api/nodes/batch", bytes.NewReader(batchBody))
|
||||
batchReq.Header.Set("Content-Type", "application/json")
|
||||
batchReq.Header.Set("Authorization", "Bearer "+token)
|
||||
batchRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(batchRec, batchReq)
|
||||
if batchRec.Code != 200 {
|
||||
t.Fatalf("batch create failed: %d %s", batchRec.Code, batchRec.Body.String())
|
||||
}
|
||||
var batchResp struct {
|
||||
Data []struct {
|
||||
ID uint `json:"id"`
|
||||
} `json:"data"`
|
||||
}
|
||||
_ = json.Unmarshal(batchRec.Body.Bytes(), &batchResp)
|
||||
if len(batchResp.Data) == 0 {
|
||||
t.Fatalf("batch create returned no nodes: %s", batchRec.Body.String())
|
||||
}
|
||||
nodeID := batchResp.Data[0].ID
|
||||
|
||||
genBody, _ := json.Marshal(map[string]any{
|
||||
"mode": "systemd", "arch": "auto", "agentVersion": "v1.7.0", "downloadSrc": "github", "ttlSeconds": 600,
|
||||
})
|
||||
genReq := httptest.NewRequest(http.MethodPost,
|
||||
"/api/nodes/"+strconv.FormatUint(uint64(nodeID), 10)+"/install-tokens", bytes.NewReader(genBody))
|
||||
genReq.Header.Set("Content-Type", "application/json")
|
||||
genReq.Header.Set("Authorization", "Bearer "+token)
|
||||
genRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(genRec, genReq)
|
||||
if genRec.Code != 200 {
|
||||
t.Fatalf("gen install token failed: %d %s", genRec.Code, genRec.Body.String())
|
||||
}
|
||||
var genResp struct {
|
||||
Data struct {
|
||||
InstallToken string `json:"installToken"`
|
||||
URL string `json:"url"`
|
||||
FallbackURL string `json:"fallbackUrl"`
|
||||
ScriptBase64 string `json:"scriptBase64"`
|
||||
} `json:"data"`
|
||||
}
|
||||
_ = json.Unmarshal(genRec.Body.Bytes(), &genResp)
|
||||
|
||||
// 2. 新生成的 url 应指向 /api/install/... —— 让反向代理的 /api/ 转发规则自动接管
|
||||
if !strings.Contains(genResp.Data.URL, "/api/install/") {
|
||||
t.Errorf("new install URL should use /api/install/ prefix, got %s", genResp.Data.URL)
|
||||
}
|
||||
if !strings.Contains(genResp.Data.FallbackURL, "/install/") {
|
||||
t.Errorf("fallback install URL should use /install/ prefix, got %s", genResp.Data.FallbackURL)
|
||||
}
|
||||
if genResp.Data.ScriptBase64 == "" {
|
||||
t.Errorf("new install response should include scriptBase64 for proxy-independent commands")
|
||||
}
|
||||
|
||||
// 3. /api/install/:token 必须可消费(与 /install/:token 等价)
|
||||
aliasReq := httptest.NewRequest(http.MethodGet, "/api/install/"+genResp.Data.InstallToken, nil)
|
||||
aliasRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(aliasRec, aliasReq)
|
||||
if aliasRec.Code != 200 {
|
||||
t.Fatalf("/api/install alias failed: %d %s", aliasRec.Code, aliasRec.Body.String())
|
||||
}
|
||||
if !strings.Contains(aliasRec.Body.String(), "systemctl enable --now backupx-agent") {
|
||||
t.Errorf("alias should return rendered script, got:\n%s", aliasRec.Body.String())
|
||||
}
|
||||
if ct := aliasRec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/plain") {
|
||||
t.Errorf("alias Content-Type should be text/plain*, got %q", ct)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallTokenRateLimit(t *testing.T) {
|
||||
router, jwt := setupInstallFlowRouter(t)
|
||||
|
||||
@@ -315,6 +496,76 @@ func TestInstallFlowComposeModeMismatch(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallFlowComposeSuccessConsumesToken(t *testing.T) {
|
||||
router, jwt := setupInstallFlowRouter(t)
|
||||
|
||||
batchBody, _ := json.Marshal(map[string][]string{"names": {"compose-ok"}})
|
||||
batchReq := httptest.NewRequest(http.MethodPost, "/api/nodes/batch", bytes.NewBuffer(batchBody))
|
||||
batchReq.Header.Set("Content-Type", "application/json")
|
||||
batchReq.Header.Set("Authorization", "Bearer "+jwt)
|
||||
batchRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(batchRec, batchReq)
|
||||
if batchRec.Code != 200 {
|
||||
t.Fatalf("batch create failed: %d %s", batchRec.Code, batchRec.Body.String())
|
||||
}
|
||||
var batchResp struct {
|
||||
Data []struct {
|
||||
ID uint `json:"id"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(batchRec.Body.Bytes(), &batchResp); err != nil {
|
||||
t.Fatalf("unmarshal batch: %v", err)
|
||||
}
|
||||
if len(batchResp.Data) != 1 {
|
||||
t.Fatalf("expected 1 node, got %d", len(batchResp.Data))
|
||||
}
|
||||
|
||||
genBody, _ := json.Marshal(map[string]any{
|
||||
"mode": "docker",
|
||||
"arch": "auto",
|
||||
"agentVersion": "v1.7.0",
|
||||
"downloadSrc": "github",
|
||||
"ttlSeconds": 900,
|
||||
})
|
||||
genReq := httptest.NewRequest(http.MethodPost,
|
||||
"/api/nodes/"+formatUint(batchResp.Data[0].ID)+"/install-tokens", bytes.NewBuffer(genBody))
|
||||
genReq.Header.Set("Content-Type", "application/json")
|
||||
genReq.Header.Set("Authorization", "Bearer "+jwt)
|
||||
genRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(genRec, genReq)
|
||||
if genRec.Code != 200 {
|
||||
t.Fatalf("install-tokens failed: %d %s", genRec.Code, genRec.Body.String())
|
||||
}
|
||||
var genResp struct {
|
||||
Data struct {
|
||||
InstallToken string `json:"installToken"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(genRec.Body.Bytes(), &genResp); err != nil {
|
||||
t.Fatalf("unmarshal gen: %v", err)
|
||||
}
|
||||
if genResp.Data.InstallToken == "" {
|
||||
t.Fatalf("missing installToken")
|
||||
}
|
||||
|
||||
composeReq := httptest.NewRequest(http.MethodGet, "/api/install/"+genResp.Data.InstallToken+"/compose.yml", nil)
|
||||
composeRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(composeRec, composeReq)
|
||||
if composeRec.Code != 200 {
|
||||
t.Fatalf("compose fetch failed: %d %s", composeRec.Code, composeRec.Body.String())
|
||||
}
|
||||
if !strings.Contains(composeRec.Body.String(), "BACKUPX_AGENT_TOKEN") {
|
||||
t.Fatalf("compose missing token env:\n%s", composeRec.Body.String())
|
||||
}
|
||||
|
||||
scriptReq := httptest.NewRequest(http.MethodGet, "/api/install/"+genResp.Data.InstallToken, nil)
|
||||
scriptRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(scriptRec, scriptReq)
|
||||
if scriptRec.Code != http.StatusGone {
|
||||
t.Fatalf("script after compose should be 410, got %d: %s", scriptRec.Code, scriptRec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// formatUint 小工具:uint → 十进制字符串(无需引入 strconv)。
|
||||
func formatUint(u uint) string {
|
||||
if u == 0 {
|
||||
|
||||
@@ -36,6 +36,13 @@ func NewInstallHandler(gcCtx context.Context, tokenService *service.InstallToken
|
||||
}
|
||||
|
||||
// Script 消费 install token 并返回 shell 脚本;Mode 由 token 存储决定(systemd/docker/foreground 均返回 shell)。
|
||||
//
|
||||
// 响应头策略(issue #46 教训):
|
||||
// - Content-Type 用 text/plain 而非 text/x-shellscript:避免 Cloudflare/反向代理把
|
||||
// 脚本内容按特殊类型识别并触发 minify/HTML rewrite,导致 `curl | sh` 收到非脚本内容
|
||||
// - X-Content-Type-Options: nosniff:禁止浏览器/中间层按内容嗅探改写 MIME
|
||||
// - Cache-Control: no-store:token 一次性消费,禁止任何缓存层留存旧脚本
|
||||
// - Content-Disposition: inline; filename=...:部分代理会跳过带文件名的响应
|
||||
func (h *InstallHandler) Script(c *gin.Context) {
|
||||
if !h.limiter.allow(c.ClientIP()) {
|
||||
c.String(stdhttp.StatusTooManyRequests, "请求过于频繁,请稍后再试\n")
|
||||
@@ -52,21 +59,15 @@ 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
|
||||
}
|
||||
c.Data(stdhttp.StatusOK, "text/x-shellscript; charset=utf-8", []byte(script))
|
||||
c.Header("X-Content-Type-Options", "nosniff")
|
||||
c.Header("Cache-Control", "no-store")
|
||||
c.Header("Content-Disposition", `inline; filename="backupx-agent-install.sh"`)
|
||||
c.Data(stdhttp.StatusOK, "text/plain; charset=utf-8", []byte(script))
|
||||
}
|
||||
|
||||
// Compose 消费 install token 并返回 docker-compose YAML,仅 Mode=docker 有效。
|
||||
@@ -131,6 +132,19 @@ func (h *InstallHandler) recordConsumeAudit(c *gin.Context, consumed *service.Co
|
||||
})
|
||||
}
|
||||
|
||||
func renderInstallScript(masterURL string, node *model.Node, record *model.AgentInstallToken) (string, error) {
|
||||
return installscript.RenderScript(installscript.Context{
|
||||
MasterURL: masterURL,
|
||||
AgentToken: node.Token,
|
||||
AgentVersion: record.AgentVer,
|
||||
Mode: record.Mode,
|
||||
Arch: record.Arch,
|
||||
DownloadBase: installscript.DownloadBaseFor(record.DownloadSrc),
|
||||
InstallPrefix: "/opt/backupx-agent",
|
||||
NodeID: node.ID,
|
||||
})
|
||||
}
|
||||
|
||||
// resolveMasterURL 按优先级推导 Master URL:外部配置 > X-Forwarded-* > Request.Host。
|
||||
// 此为包级 helper,供 install_handler 和 node_handler 共用。
|
||||
func resolveMasterURL(c *gin.Context, externalURL string) string {
|
||||
|
||||
@@ -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,15 +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.html(issue #46)。
|
||||
// 同时返回 /install/... 备用地址,兼容会剥离 /api 前缀的外层反向代理。
|
||||
// scriptBase64 让前端可以生成不依赖公开下载路径的嵌入式命令,解决 Lucky 等代理
|
||||
// 把 /api/install/* 也 fallback 到 index.html 的场景。
|
||||
body := gin.H{
|
||||
"installToken": out.Token,
|
||||
"expiresAt": out.ExpiresAt,
|
||||
"url": masterURL + "/install/" + out.Token,
|
||||
"composeUrl": "",
|
||||
}
|
||||
if input.Mode == "docker" {
|
||||
body["composeUrl"] = masterURL + "/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)
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
|
||||
"backupx/server/internal/apperror"
|
||||
"backupx/server/internal/config"
|
||||
"backupx/server/internal/metrics"
|
||||
"backupx/server/internal/repository"
|
||||
"backupx/server/internal/security"
|
||||
"backupx/server/internal/service"
|
||||
@@ -52,6 +53,8 @@ type RouterDependencies struct {
|
||||
MasterExternalURL string
|
||||
// DB 注入给健康检查端点做 liveness/readiness 探测。
|
||||
DB *gorm.DB
|
||||
// Metrics 注入给 /metrics 端点;为 nil 时端点返回 503。
|
||||
Metrics *metrics.Metrics
|
||||
}
|
||||
|
||||
func NewRouter(deps RouterDependencies) *gin.Engine {
|
||||
@@ -91,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")
|
||||
@@ -226,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)
|
||||
}
|
||||
|
||||
@@ -276,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 API(token 认证,无需 JWT)
|
||||
if deps.AgentService != nil {
|
||||
@@ -311,7 +328,19 @@ func NewRouter(deps RouterDependencies) *gin.Engine {
|
||||
engine.GET("/api/health", healthHandler.Live)
|
||||
engine.GET("/api/ready", healthHandler.Ready)
|
||||
|
||||
// 公开安装路由(不走 JWT 中间件)
|
||||
// Prometheus /metrics 端点(公开、无认证;内网/反向代理授权即可)。
|
||||
// 业内通行做法:/metrics 通常由 Prometheus pull 抓取,不走 API Key。
|
||||
if deps.Metrics != nil {
|
||||
engine.GET("/metrics", gin.WrapH(deps.Metrics.Handler()))
|
||||
}
|
||||
|
||||
// 公开安装路由(不走 JWT 中间件)。
|
||||
// 同时注册到 / 和 /api 前缀下:
|
||||
// - /install/:token 保留历史 URL,兼容旧 nginx 部署
|
||||
// - /api/install/:token 新 URL,自动走反向代理的 /api/ 转发规则
|
||||
//
|
||||
// Issue #46:用户的 nginx 只转发 /api/,/install/* 被 SPA fallback 到 index.html,
|
||||
// 返回 HTML 被 sh 解释成 "Syntax error"。使用 /api/install/ 可避开此问题。
|
||||
if deps.InstallTokenService != nil {
|
||||
gcCtx := deps.Context
|
||||
if gcCtx == nil {
|
||||
@@ -320,6 +349,8 @@ func NewRouter(deps RouterDependencies) *gin.Engine {
|
||||
installHandler := NewInstallHandler(gcCtx, deps.InstallTokenService, deps.AuditService, deps.MasterExternalURL)
|
||||
engine.GET("/install/:token", installHandler.Script)
|
||||
engine.GET("/install/:token/compose.yml", installHandler.Compose)
|
||||
engine.GET("/api/install/:token", installHandler.Script)
|
||||
engine.GET("/api/install/:token/compose.yml", installHandler.Compose)
|
||||
}
|
||||
|
||||
engine.NoRoute(func(c *gin.Context) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
41
server/internal/installscript/deploy_install_test.go
Normal file
41
server/internal/installscript/deploy_install_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
58
server/internal/installscript/issue46_test.go
Normal file
58
server/internal/installscript/issue46_test.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package installscript
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"backupx/server/internal/model"
|
||||
)
|
||||
|
||||
// TestRenderScriptIncludesMagicMarker 渲染脚本必须包含 Issue #46 引入的魔数注释,
|
||||
// 方便用户通过 `head -3 脚本` 自查是否被中间层改写。
|
||||
func TestRenderScriptIncludesMagicMarker(t *testing.T) {
|
||||
for _, mode := range []string{model.InstallModeSystemd, model.InstallModeDocker, model.InstallModeForeground} {
|
||||
ctx := testCtx
|
||||
ctx.Mode = mode
|
||||
got, err := RenderScript(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("render err (%s): %v", mode, err)
|
||||
}
|
||||
if !strings.Contains(got, "BACKUPX_AGENT_INSTALL_V1") {
|
||||
t.Errorf("mode=%s: script missing magic marker:\n%s", mode, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRenderScriptBashBootstrap 脚本顶部必须有 bash 自举段,文件执行时跳到 bash。
|
||||
func TestRenderScriptBashBootstrap(t *testing.T) {
|
||||
got, err := RenderScript(testCtx)
|
||||
if err != nil {
|
||||
t.Fatalf("render err: %v", err)
|
||||
}
|
||||
if !strings.Contains(got, `[ -z "${BASH_VERSION:-}" ]`) {
|
||||
t.Errorf("script missing bash bootstrap guard:\n%s", got)
|
||||
}
|
||||
if !strings.Contains(got, `exec bash "$0" "$@"`) {
|
||||
t.Errorf("script missing exec bash fallback:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderScriptUsesRootForBareMetalBackups(t *testing.T) {
|
||||
got, err := RenderScript(testCtx)
|
||||
if err != nil {
|
||||
t.Fatalf("render err: %v", err)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"/var/lib/backupx-agent/tmp",
|
||||
"install -d -m 0700 /var/lib/backupx-agent /var/lib/backupx-agent/tmp",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("script missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
for _, forbidden := range []string{"User=backupx", "Group=backupx", "NoNewPrivileges=true"} {
|
||||
if strings.Contains(got, forbidden) {
|
||||
t.Errorf("script should not contain %q for bare-metal backups:\n%s", forbidden, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
#!/bin/sh
|
||||
# BackupX Agent 一键安装脚本(由 Master 动态渲染)
|
||||
# Magic: BACKUPX_AGENT_INSTALL_V1 —— 若 `head -3 脚本` 看不到此行,说明反向代理/CDN 改写了响应
|
||||
# 模式: {{.Mode}} | 架构: {{.Arch}} | 版本: {{.AgentVersion}}
|
||||
set -eu
|
||||
|
||||
# 自举到 bash(文件执行模式下生效;管道模式 $0 不是文件,exec 会静默失败,继续用 sh)。
|
||||
# 动机:部分 Debian/Ubuntu 用户通过 `curl | sudo sh` 触发时,dash 对本脚本报语法错误;
|
||||
# 若目标机装有 bash,优先切换到 bash 获得更一致的行为。
|
||||
if [ -z "${BASH_VERSION:-}" ] && command -v bash >/dev/null 2>&1 && [ -f "$0" ]; then
|
||||
exec bash "$0" "$@"
|
||||
fi
|
||||
|
||||
MASTER_URL="{{.MasterURL}}"
|
||||
AGENT_TOKEN="{{.AgentToken}}"
|
||||
AGENT_VERSION="{{.AgentVersion}}"
|
||||
@@ -39,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}}
|
||||
|
||||
@@ -57,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
|
||||
@@ -82,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}}
|
||||
|
||||
@@ -90,7 +97,7 @@ exit 2
|
||||
echo "[3/3] 前台启动 agent(Ctrl+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"}}
|
||||
@@ -102,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}}
|
||||
|
||||
152
server/internal/metrics/collector.go
Normal file
152
server/internal/metrics/collector.go
Normal file
@@ -0,0 +1,152 @@
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"backupx/server/internal/model"
|
||||
"backupx/server/internal/repository"
|
||||
)
|
||||
|
||||
// SampleSource 抽象 Collector 需要的仓储访问,便于单测替换。
|
||||
type SampleSource interface {
|
||||
ListStorageTargets(ctx context.Context) ([]model.StorageTarget, error)
|
||||
StorageUsage(ctx context.Context) ([]repository.BackupStorageUsageItem, error)
|
||||
ListNodes(ctx context.Context) ([]model.Node, error)
|
||||
CountSLABreach(ctx context.Context) (int, error)
|
||||
}
|
||||
|
||||
// repoSource 把 repository 适配到 SampleSource。
|
||||
type repoSource struct {
|
||||
targets repository.StorageTargetRepository
|
||||
records repository.BackupRecordRepository
|
||||
nodes repository.NodeRepository
|
||||
tasks repository.BackupTaskRepository
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// NewRepoSource 用仓储实例构造 SampleSource。
|
||||
func NewRepoSource(
|
||||
targets repository.StorageTargetRepository,
|
||||
records repository.BackupRecordRepository,
|
||||
nodes repository.NodeRepository,
|
||||
tasks repository.BackupTaskRepository,
|
||||
) SampleSource {
|
||||
return &repoSource{
|
||||
targets: targets,
|
||||
records: records,
|
||||
nodes: nodes,
|
||||
tasks: tasks,
|
||||
now: func() time.Time { return time.Now().UTC() },
|
||||
}
|
||||
}
|
||||
|
||||
func (s *repoSource) ListStorageTargets(ctx context.Context) ([]model.StorageTarget, error) {
|
||||
return s.targets.List(ctx)
|
||||
}
|
||||
|
||||
func (s *repoSource) StorageUsage(ctx context.Context) ([]repository.BackupStorageUsageItem, error) {
|
||||
return s.records.StorageUsage(ctx)
|
||||
}
|
||||
|
||||
func (s *repoSource) ListNodes(ctx context.Context) ([]model.Node, error) {
|
||||
return s.nodes.List(ctx)
|
||||
}
|
||||
|
||||
// CountSLABreach 统计当前违反 RPO 的任务:
|
||||
// - 任务启用且配置了 SLAHoursRPO > 0
|
||||
// - 最近一次成功备份距今超出 SLA 时间窗,或从未成功过
|
||||
func (s *repoSource) CountSLABreach(ctx context.Context) (int, error) {
|
||||
tasks, err := s.tasks.List(ctx, repository.BackupTaskListOptions{})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
now := s.now()
|
||||
count := 0
|
||||
for i := range tasks {
|
||||
task := &tasks[i]
|
||||
if task.SLAHoursRPO <= 0 || !task.Enabled {
|
||||
continue
|
||||
}
|
||||
threshold := now.Add(-time.Duration(task.SLAHoursRPO) * time.Hour)
|
||||
if task.LastRunAt == nil || task.LastRunAt.Before(threshold) {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// Collector 周期性采集 gauge 类指标(存储用量、节点在线、SLA 违约)。
|
||||
// 用后台 goroutine 驱动,避免在 /metrics 请求路径做慢 IO。
|
||||
type Collector struct {
|
||||
metrics *Metrics
|
||||
source SampleSource
|
||||
interval time.Duration
|
||||
}
|
||||
|
||||
// NewCollector 创建周期采集器。interval=0 走默认 30s。
|
||||
func NewCollector(m *Metrics, source SampleSource, interval time.Duration) *Collector {
|
||||
if interval <= 0 {
|
||||
interval = 30 * time.Second
|
||||
}
|
||||
return &Collector{metrics: m, source: source, interval: interval}
|
||||
}
|
||||
|
||||
// Start 在后台运行采集循环;随 ctx 取消而终止。
|
||||
// 启动时立即采一次,之后按 interval 轮询。
|
||||
func (c *Collector) Start(ctx context.Context) {
|
||||
if c == nil || c.metrics == nil || c.source == nil {
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
c.collect(ctx)
|
||||
ticker := time.NewTicker(c.interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
c.collect(ctx)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// collect 执行一次采样;单轮失败不影响下次。
|
||||
func (c *Collector) collect(ctx context.Context) {
|
||||
// 存储用量:按 StorageTargetID 聚合 file_size,对应 target name/type
|
||||
if targets, err := c.source.ListStorageTargets(ctx); err == nil {
|
||||
nameByID := make(map[uint]string, len(targets))
|
||||
typeByID := make(map[uint]string, len(targets))
|
||||
for i := range targets {
|
||||
nameByID[targets[i].ID] = targets[i].Name
|
||||
typeByID[targets[i].ID] = targets[i].Type
|
||||
}
|
||||
if usage, uerr := c.source.StorageUsage(ctx); uerr == nil {
|
||||
c.metrics.ResetStorageUsed()
|
||||
for _, item := range usage {
|
||||
name := nameByID[item.StorageTargetID]
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
c.metrics.SetStorageUsed(name, typeByID[item.StorageTargetID], item.TotalSize)
|
||||
}
|
||||
}
|
||||
}
|
||||
// 节点在线状态:role 约定为 master / agent
|
||||
if nodes, err := c.source.ListNodes(ctx); err == nil {
|
||||
c.metrics.ResetNodeOnline()
|
||||
for i := range nodes {
|
||||
n := &nodes[i]
|
||||
role := "agent"
|
||||
if n.IsLocal {
|
||||
role = "master"
|
||||
}
|
||||
c.metrics.SetNodeOnline(n.Name, role, n.Status == model.NodeStatusOnline)
|
||||
}
|
||||
}
|
||||
if breach, err := c.source.CountSLABreach(ctx); err == nil {
|
||||
c.metrics.SetSLABreach(breach)
|
||||
}
|
||||
}
|
||||
225
server/internal/metrics/registry.go
Normal file
225
server/internal/metrics/registry.go
Normal file
@@ -0,0 +1,225 @@
|
||||
// Package metrics 暴露 BackupX 的 Prometheus 采集器。
|
||||
//
|
||||
// 设计要点:
|
||||
// - 使用独立 Registry,避免与 default registry 中的 Go runtime metrics 混淆
|
||||
// - Counter/Gauge/Histogram 全部以 backupx_ 为前缀,遵循 Prometheus 命名规范
|
||||
// - 所有指标都支持零值:未注入时调用方法是 no-op,不会 panic
|
||||
// - 组件只依赖本包,不反向引用 service/repository,避免循环
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/collectors"
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
)
|
||||
|
||||
// Metrics 聚合所有采集器,由 app 层组装一次并按需注入到 service。
|
||||
type Metrics struct {
|
||||
registry *prometheus.Registry
|
||||
|
||||
// 任务执行计数(labels: status, task_type)
|
||||
TaskRunTotal *prometheus.CounterVec
|
||||
// 任务耗时分布(labels: task_type)
|
||||
TaskRunDuration *prometheus.HistogramVec
|
||||
// 任务产出字节数(labels: task_type)
|
||||
TaskBytesTotal *prometheus.CounterVec
|
||||
// 正在运行的任务数
|
||||
TaskRunningGauge prometheus.Gauge
|
||||
// 存储目标用量(labels: target_name, target_type)
|
||||
StorageUsedBytes *prometheus.GaugeVec
|
||||
// 节点在线状态(labels: node_name, role;value: 0/1)
|
||||
NodeOnline *prometheus.GaugeVec
|
||||
// 验证演练结果(labels: status)
|
||||
VerifyRunTotal *prometheus.CounterVec
|
||||
// 恢复操作结果(labels: status)
|
||||
RestoreRunTotal *prometheus.CounterVec
|
||||
// 副本复制结果(labels: status)
|
||||
ReplicationRunTotal *prometheus.CounterVec
|
||||
// SLA 违约数(gauge)
|
||||
SLABreachGauge prometheus.Gauge
|
||||
// 应用信息(label: version)
|
||||
AppInfo *prometheus.GaugeVec
|
||||
}
|
||||
|
||||
// New 构造并注册所有采集器。
|
||||
// 失败时 panic:采集器注册失败属于启动期编程错误,没有合理 fallback。
|
||||
func New(version string) *Metrics {
|
||||
reg := prometheus.NewRegistry()
|
||||
// 注入标准 Go runtime + process 指标
|
||||
reg.MustRegister(collectors.NewGoCollector())
|
||||
reg.MustRegister(collectors.NewProcessCollector(collectors.ProcessCollectorOpts{}))
|
||||
|
||||
m := &Metrics{
|
||||
registry: reg,
|
||||
TaskRunTotal: prometheus.NewCounterVec(prometheus.CounterOpts{
|
||||
Name: "backupx_task_run_total",
|
||||
Help: "备份任务执行总数,按状态和任务类型细分",
|
||||
}, []string{"status", "task_type"}),
|
||||
TaskRunDuration: prometheus.NewHistogramVec(prometheus.HistogramOpts{
|
||||
Name: "backupx_task_run_duration_seconds",
|
||||
Help: "备份任务耗时分布",
|
||||
Buckets: []float64{1, 5, 15, 30, 60, 120, 300, 600, 1800, 3600, 7200},
|
||||
}, []string{"task_type"}),
|
||||
TaskBytesTotal: prometheus.NewCounterVec(prometheus.CounterOpts{
|
||||
Name: "backupx_task_bytes_total",
|
||||
Help: "备份任务累计产出字节数",
|
||||
}, []string{"task_type"}),
|
||||
TaskRunningGauge: prometheus.NewGauge(prometheus.GaugeOpts{
|
||||
Name: "backupx_task_running",
|
||||
Help: "当前正在执行的备份任务数",
|
||||
}),
|
||||
StorageUsedBytes: prometheus.NewGaugeVec(prometheus.GaugeOpts{
|
||||
Name: "backupx_storage_used_bytes",
|
||||
Help: "存储目标已用字节数",
|
||||
}, []string{"target_name", "target_type"}),
|
||||
NodeOnline: prometheus.NewGaugeVec(prometheus.GaugeOpts{
|
||||
Name: "backupx_node_online",
|
||||
Help: "集群节点在线状态(1 在线 / 0 离线)",
|
||||
}, []string{"node_name", "role"}),
|
||||
VerifyRunTotal: prometheus.NewCounterVec(prometheus.CounterOpts{
|
||||
Name: "backupx_verify_run_total",
|
||||
Help: "备份验证演练执行总数",
|
||||
}, []string{"status"}),
|
||||
RestoreRunTotal: prometheus.NewCounterVec(prometheus.CounterOpts{
|
||||
Name: "backupx_restore_run_total",
|
||||
Help: "恢复操作执行总数",
|
||||
}, []string{"status"}),
|
||||
ReplicationRunTotal: prometheus.NewCounterVec(prometheus.CounterOpts{
|
||||
Name: "backupx_replication_run_total",
|
||||
Help: "备份副本复制执行总数",
|
||||
}, []string{"status"}),
|
||||
SLABreachGauge: prometheus.NewGauge(prometheus.GaugeOpts{
|
||||
Name: "backupx_sla_breach_tasks",
|
||||
Help: "当前违反 SLA/RPO 的任务数",
|
||||
}),
|
||||
AppInfo: prometheus.NewGaugeVec(prometheus.GaugeOpts{
|
||||
Name: "backupx_app_info",
|
||||
Help: "BackupX 应用元信息(恒为 1,通过 label 暴露版本号)",
|
||||
}, []string{"version"}),
|
||||
}
|
||||
reg.MustRegister(
|
||||
m.TaskRunTotal,
|
||||
m.TaskRunDuration,
|
||||
m.TaskBytesTotal,
|
||||
m.TaskRunningGauge,
|
||||
m.StorageUsedBytes,
|
||||
m.NodeOnline,
|
||||
m.VerifyRunTotal,
|
||||
m.RestoreRunTotal,
|
||||
m.ReplicationRunTotal,
|
||||
m.SLABreachGauge,
|
||||
m.AppInfo,
|
||||
)
|
||||
m.AppInfo.WithLabelValues(version).Set(1)
|
||||
return m
|
||||
}
|
||||
|
||||
// Handler 返回 /metrics 的 HTTP handler。
|
||||
// 使用本包专属 registry,避免混入其他组件的默认 metrics。
|
||||
func (m *Metrics) Handler() http.Handler {
|
||||
if m == nil {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
http.Error(w, "metrics disabled", http.StatusServiceUnavailable)
|
||||
})
|
||||
}
|
||||
return promhttp.HandlerFor(m.registry, promhttp.HandlerOpts{
|
||||
EnableOpenMetrics: false,
|
||||
})
|
||||
}
|
||||
|
||||
// ObserveTaskRun 记录一次任务执行结果。
|
||||
// status 常用值:success / failed / cancelled。nil 接收器安全。
|
||||
func (m *Metrics) ObserveTaskRun(taskType, status string, durationSec float64, bytes int64) {
|
||||
if m == nil {
|
||||
return
|
||||
}
|
||||
m.TaskRunTotal.WithLabelValues(status, taskType).Inc()
|
||||
m.TaskRunDuration.WithLabelValues(taskType).Observe(durationSec)
|
||||
if bytes > 0 {
|
||||
m.TaskBytesTotal.WithLabelValues(taskType).Add(float64(bytes))
|
||||
}
|
||||
}
|
||||
|
||||
// IncTaskRunning / DecTaskRunning 配套使用,反映并发中任务数。
|
||||
func (m *Metrics) IncTaskRunning() {
|
||||
if m == nil {
|
||||
return
|
||||
}
|
||||
m.TaskRunningGauge.Inc()
|
||||
}
|
||||
|
||||
func (m *Metrics) DecTaskRunning() {
|
||||
if m == nil {
|
||||
return
|
||||
}
|
||||
m.TaskRunningGauge.Dec()
|
||||
}
|
||||
|
||||
// ObserveRestore / ObserveVerify / ObserveReplication 记录子动作结果。
|
||||
// 所有方法对 nil 接收器安全:未注入 Metrics 时静默降级,不 panic。
|
||||
func (m *Metrics) ObserveRestore(status string) {
|
||||
if m == nil {
|
||||
return
|
||||
}
|
||||
m.RestoreRunTotal.WithLabelValues(status).Inc()
|
||||
}
|
||||
|
||||
func (m *Metrics) ObserveVerify(status string) {
|
||||
if m == nil {
|
||||
return
|
||||
}
|
||||
m.VerifyRunTotal.WithLabelValues(status).Inc()
|
||||
}
|
||||
|
||||
func (m *Metrics) ObserveReplication(status string) {
|
||||
if m == nil {
|
||||
return
|
||||
}
|
||||
m.ReplicationRunTotal.WithLabelValues(status).Inc()
|
||||
}
|
||||
|
||||
// SetStorageUsed 刷新某存储目标的用量。调用方负责周期采集。
|
||||
func (m *Metrics) SetStorageUsed(name, targetType string, bytes int64) {
|
||||
if m == nil {
|
||||
return
|
||||
}
|
||||
m.StorageUsedBytes.WithLabelValues(name, targetType).Set(float64(bytes))
|
||||
}
|
||||
|
||||
// SetNodeOnline 刷新节点在线状态。
|
||||
func (m *Metrics) SetNodeOnline(name, role string, online bool) {
|
||||
if m == nil {
|
||||
return
|
||||
}
|
||||
val := 0.0
|
||||
if online {
|
||||
val = 1
|
||||
}
|
||||
m.NodeOnline.WithLabelValues(name, role).Set(val)
|
||||
}
|
||||
|
||||
// ResetNodeOnline 清空节点 gauge(当节点被删除时避免残留指标)。
|
||||
func (m *Metrics) ResetNodeOnline() {
|
||||
if m == nil {
|
||||
return
|
||||
}
|
||||
m.NodeOnline.Reset()
|
||||
}
|
||||
|
||||
// ResetStorageUsed 清空存储目标 gauge。
|
||||
func (m *Metrics) ResetStorageUsed() {
|
||||
if m == nil {
|
||||
return
|
||||
}
|
||||
m.StorageUsedBytes.Reset()
|
||||
}
|
||||
|
||||
// SetSLABreach 刷新 SLA 违约任务数。
|
||||
func (m *Metrics) SetSLABreach(count int) {
|
||||
if m == nil {
|
||||
return
|
||||
}
|
||||
m.SLABreachGauge.Set(float64(count))
|
||||
}
|
||||
76
server/internal/metrics/registry_test.go
Normal file
76
server/internal/metrics/registry_test.go
Normal file
@@ -0,0 +1,76 @@
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus/testutil"
|
||||
)
|
||||
|
||||
func TestNew_AppInfoVersionLabel(t *testing.T) {
|
||||
m := New("2.1.0")
|
||||
if got := testutil.ToFloat64(m.AppInfo.WithLabelValues("2.1.0")); got != 1 {
|
||||
t.Fatalf("app_info(version=2.1.0) expected 1, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestObserveTaskRun_IncrementsCounterAndHistogram(t *testing.T) {
|
||||
m := New("test")
|
||||
m.ObserveTaskRun("mysql", "success", 12.5, 1024)
|
||||
m.ObserveTaskRun("mysql", "failed", 3.0, 0)
|
||||
if got := testutil.ToFloat64(m.TaskRunTotal.WithLabelValues("success", "mysql")); got != 1 {
|
||||
t.Fatalf("task_run_total{status=success,task_type=mysql}: expected 1, got %v", got)
|
||||
}
|
||||
if got := testutil.ToFloat64(m.TaskRunTotal.WithLabelValues("failed", "mysql")); got != 1 {
|
||||
t.Fatalf("task_run_total{status=failed,task_type=mysql}: expected 1, got %v", got)
|
||||
}
|
||||
if got := testutil.ToFloat64(m.TaskBytesTotal.WithLabelValues("mysql")); got != 1024 {
|
||||
t.Fatalf("task_bytes_total{task_type=mysql}: expected 1024, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestObserveTaskRun_NilReceiverIsSafe(t *testing.T) {
|
||||
var m *Metrics // nil
|
||||
m.ObserveTaskRun("file", "success", 1, 1)
|
||||
m.ObserveRestore("success")
|
||||
m.ObserveVerify("failed")
|
||||
m.ObserveReplication("success")
|
||||
m.IncTaskRunning()
|
||||
m.DecTaskRunning()
|
||||
m.SetStorageUsed("a", "s3", 1)
|
||||
m.SetNodeOnline("n1", "master", true)
|
||||
m.SetSLABreach(3)
|
||||
m.ResetNodeOnline()
|
||||
m.ResetStorageUsed()
|
||||
// no panic -> pass
|
||||
}
|
||||
|
||||
func TestHandler_ExposesBackupxMetrics(t *testing.T) {
|
||||
m := New("0.0.0-test")
|
||||
m.ObserveTaskRun("file", "success", 1.0, 2048)
|
||||
m.SetNodeOnline("n1", "master", true)
|
||||
m.SetSLABreach(1)
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", "/metrics", nil)
|
||||
m.Handler().ServeHTTP(recorder, req)
|
||||
|
||||
body, err := io.ReadAll(recorder.Result().Body)
|
||||
if err != nil {
|
||||
t.Fatalf("read body: %v", err)
|
||||
}
|
||||
content := string(body)
|
||||
for _, keyword := range []string{
|
||||
"backupx_task_run_total",
|
||||
"backupx_task_run_duration_seconds",
|
||||
"backupx_node_online",
|
||||
"backupx_sla_breach_tasks",
|
||||
"backupx_app_info",
|
||||
} {
|
||||
if !strings.Contains(content, keyword) {
|
||||
t.Errorf("expected /metrics to contain %q", keyword)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
|
||||
@@ -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 {
|
||||
|
||||
47
server/internal/model/node_label_test.go
Normal file
47
server/internal/model/node_label_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
23
server/internal/security/otp_code.go
Normal file
23
server/internal/security/otp_code.go
Normal 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)
|
||||
}
|
||||
49
server/internal/security/recovery_code.go
Normal file
49
server/internal/security/recovery_code.go
Normal 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
|
||||
}
|
||||
68
server/internal/security/totp.go
Normal file
68
server/internal/security/totp.go
Normal 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))
|
||||
}
|
||||
447
server/internal/security/webauthn.go
Normal file
447
server/internal/security/webauthn.go
Normal 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
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
589
server/internal/service/agent_service_test.go
Normal file
589
server/internal/service/agent_service_test.go
Normal 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
|
||||
}
|
||||
@@ -1,9 +1,18 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"backupx/server/internal/apperror"
|
||||
"backupx/server/internal/model"
|
||||
@@ -25,10 +34,39 @@ type AuditEntry struct {
|
||||
|
||||
type AuditService struct {
|
||||
repo repository.AuditLogRepository
|
||||
|
||||
// webhook 外输配置(可选)
|
||||
webhookMu sync.RWMutex
|
||||
webhookURL string
|
||||
webhookSecret string
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
func NewAuditService(repo repository.AuditLogRepository) *AuditService {
|
||||
return &AuditService{repo: repo}
|
||||
return &AuditService{
|
||||
repo: repo,
|
||||
httpClient: &http.Client{
|
||||
Timeout: 3 * time.Second, // 短超时:审计 webhook 不应拖慢业务
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// SetWebhook 动态配置审计事件转发 URL 与签名密钥。
|
||||
// - url 为空字符串时禁用转发
|
||||
// - secret 非空时对 payload 计算 HMAC-SHA256,作为 X-BackupX-Signature header
|
||||
//
|
||||
// 适用场景:
|
||||
// - 企业 SIEM 集成(Splunk HEC、ELK、Loki)
|
||||
// - 安全审计留痕到第三方 WORM 存储
|
||||
// - 合规日志归档(GDPR / SOC2)
|
||||
func (s *AuditService) SetWebhook(url, secret string) {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
s.webhookMu.Lock()
|
||||
defer s.webhookMu.Unlock()
|
||||
s.webhookURL = strings.TrimSpace(url)
|
||||
s.webhookSecret = strings.TrimSpace(secret)
|
||||
}
|
||||
|
||||
// Record 异步 fire-and-forget 写入审计日志,不阻塞业务逻辑
|
||||
@@ -51,9 +89,65 @@ func (s *AuditService) Record(entry AuditEntry) {
|
||||
if err := s.repo.Create(context.Background(), record); err != nil {
|
||||
log.Printf("[audit] failed to write audit log: %v", err)
|
||||
}
|
||||
s.fireWebhook(record)
|
||||
}()
|
||||
}
|
||||
|
||||
// fireWebhook 异步向外部系统转发审计事件。失败降级到本地日志,永不影响主流程。
|
||||
func (s *AuditService) fireWebhook(record *model.AuditLog) {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
s.webhookMu.RLock()
|
||||
url := s.webhookURL
|
||||
secret := s.webhookSecret
|
||||
s.webhookMu.RUnlock()
|
||||
if url == "" {
|
||||
return
|
||||
}
|
||||
payload := map[string]any{
|
||||
"eventType": "audit.log",
|
||||
"occurredAt": record.CreatedAt.UTC().Format(time.RFC3339),
|
||||
"actor": map[string]any{
|
||||
"userId": record.UserID,
|
||||
"username": record.Username,
|
||||
},
|
||||
"category": record.Category,
|
||||
"action": record.Action,
|
||||
"targetType": record.TargetType,
|
||||
"targetId": record.TargetID,
|
||||
"targetName": record.TargetName,
|
||||
"detail": record.Detail,
|
||||
"clientIp": record.ClientIP,
|
||||
}
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
log.Printf("[audit] webhook marshal failed: %v", err)
|
||||
return
|
||||
}
|
||||
req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, url, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
log.Printf("[audit] webhook build request failed: %v", err)
|
||||
return
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("User-Agent", "BackupX-Audit/1.0")
|
||||
if secret != "" {
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
mac.Write(body)
|
||||
req.Header.Set("X-BackupX-Signature", "sha256="+hex.EncodeToString(mac.Sum(nil)))
|
||||
}
|
||||
resp, err := s.httpClient.Do(req)
|
||||
if err != nil {
|
||||
log.Printf("[audit] webhook POST failed: %v", err)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 400 {
|
||||
log.Printf("[audit] webhook returned status %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// List 分页查询审计日志
|
||||
func (s *AuditService) List(ctx context.Context, category string, limit, offset int) (*repository.AuditLogListResult, error) {
|
||||
result, err := s.repo.List(ctx, repository.AuditLogListOptions{
|
||||
|
||||
129
server/internal/service/audit_service_webhook_test.go
Normal file
129
server/internal/service/audit_service_webhook_test.go
Normal file
@@ -0,0 +1,129 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"backupx/server/internal/model"
|
||||
"backupx/server/internal/repository"
|
||||
)
|
||||
|
||||
// fakeAuditRepo 用通道同步等待异步写入,避免 sleep。
|
||||
type fakeAuditRepo struct {
|
||||
mu sync.Mutex
|
||||
logs []model.AuditLog
|
||||
created chan struct{}
|
||||
}
|
||||
|
||||
func newFakeAuditRepo() *fakeAuditRepo {
|
||||
return &fakeAuditRepo{created: make(chan struct{}, 4)}
|
||||
}
|
||||
|
||||
func (r *fakeAuditRepo) Create(_ context.Context, log *model.AuditLog) error {
|
||||
r.mu.Lock()
|
||||
log.CreatedAt = time.Now().UTC()
|
||||
r.logs = append(r.logs, *log)
|
||||
r.mu.Unlock()
|
||||
r.created <- struct{}{}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *fakeAuditRepo) List(context.Context, repository.AuditLogListOptions) (*repository.AuditLogListResult, error) {
|
||||
return &repository.AuditLogListResult{}, nil
|
||||
}
|
||||
|
||||
func (r *fakeAuditRepo) ListAll(context.Context, repository.AuditLogListOptions) ([]model.AuditLog, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func TestAuditService_WebhookDeliversSignedPayload(t *testing.T) {
|
||||
var hits atomic.Int32
|
||||
var got struct {
|
||||
sig string
|
||||
payload map[string]any
|
||||
received chan struct{}
|
||||
}
|
||||
got.received = make(chan struct{}, 1)
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
hits.Add(1)
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
got.sig = r.Header.Get("X-BackupX-Signature")
|
||||
_ = json.Unmarshal(body, &got.payload)
|
||||
|
||||
// 验证 HMAC 正确
|
||||
mac := hmac.New(sha256.New, []byte("s3cret"))
|
||||
mac.Write(body)
|
||||
expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))
|
||||
if got.sig != expected {
|
||||
t.Errorf("signature mismatch: expected %s, got %s", expected, got.sig)
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
got.received <- struct{}{}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
repo := newFakeAuditRepo()
|
||||
svc := NewAuditService(repo)
|
||||
svc.SetWebhook(server.URL, "s3cret")
|
||||
|
||||
svc.Record(AuditEntry{
|
||||
Username: "alice",
|
||||
Category: "auth",
|
||||
Action: "login_success",
|
||||
ClientIP: "10.0.0.1",
|
||||
Detail: "admin login",
|
||||
})
|
||||
|
||||
// 等待异步写入 + webhook
|
||||
select {
|
||||
case <-repo.created:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("audit log not written within 1s")
|
||||
}
|
||||
select {
|
||||
case <-got.received:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("webhook not invoked within 1s")
|
||||
}
|
||||
|
||||
if hits.Load() != 1 {
|
||||
t.Fatalf("expected 1 webhook hit, got %d", hits.Load())
|
||||
}
|
||||
if got.payload["eventType"] != "audit.log" {
|
||||
t.Errorf("eventType wrong: %v", got.payload["eventType"])
|
||||
}
|
||||
actor, ok := got.payload["actor"].(map[string]any)
|
||||
if !ok || actor["username"] != "alice" {
|
||||
t.Errorf("actor.username mismatch: %v", got.payload["actor"])
|
||||
}
|
||||
if got.payload["action"] != "login_success" {
|
||||
t.Errorf("action mismatch: %v", got.payload["action"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuditService_WebhookDisabledWhenURLEmpty(t *testing.T) {
|
||||
repo := newFakeAuditRepo()
|
||||
svc := NewAuditService(repo)
|
||||
// 不调用 SetWebhook:应该不发送任何请求
|
||||
svc.Record(AuditEntry{Username: "bob", Action: "logout"})
|
||||
|
||||
select {
|
||||
case <-repo.created:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("audit log not written within 1s")
|
||||
}
|
||||
// 给 webhook 一些时间(即便它不会被调用)
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
// 无显式断言:能不 panic 即算通过
|
||||
}
|
||||
179
server/internal/service/auth_methods.go
Normal file
179
server/internal/service/auth_methods.go
Normal 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,
|
||||
}
|
||||
}
|
||||
252
server/internal/service/auth_otp.go
Normal file
252
server/internal/service/auth_otp.go
Normal 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))
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
221
server/internal/service/auth_trusted_device.go
Normal file
221
server/internal/service/auth_trusted_device.go
Normal 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])
|
||||
}
|
||||
366
server/internal/service/auth_webauthn.go
Normal file
366
server/internal/service/auth_webauthn.go
Normal 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
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"backupx/server/internal/apperror"
|
||||
"backupx/server/internal/backup"
|
||||
backupretention "backupx/server/internal/backup/retention"
|
||||
"backupx/server/internal/metrics"
|
||||
"backupx/server/internal/model"
|
||||
"backupx/server/internal/repository"
|
||||
"backupx/server/internal/storage"
|
||||
@@ -72,28 +73,34 @@ 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 带宽限制
|
||||
bandwidthLimit string // rclone 带宽限制(全局默认,节点配置可覆盖)
|
||||
metrics *metrics.Metrics
|
||||
}
|
||||
|
||||
// SetMetrics 注入 Prometheus 采集器。nil 时所有埋点退化为 no-op。
|
||||
func (s *BackupExecutionService) SetMetrics(m *metrics.Metrics) {
|
||||
s.metrics = m
|
||||
}
|
||||
|
||||
// ReplicationTrigger 抽象备份成功后的副本派发(实现者:ReplicationService)。
|
||||
@@ -263,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
|
||||
@@ -282,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_disk),Master 无法跨节点删除。请确保 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 basePath)时,Master 的
|
||||
// provider 指向的是 Master 本机的同名路径,访问会静默取错文件或 404。明确拒绝
|
||||
@@ -328,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 {
|
||||
@@ -345,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)
|
||||
@@ -407,6 +459,80 @@ 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 {
|
||||
if nodeID == 0 || s.nodeRepo == nil {
|
||||
return s.bandwidthLimit
|
||||
}
|
||||
node, err := s.nodeRepo.FindByID(ctx, nodeID)
|
||||
if err != nil || node == nil {
|
||||
return s.bandwidthLimit
|
||||
}
|
||||
if strings.TrimSpace(node.BandwidthLimit) != "" {
|
||||
return node.BandwidthLimit
|
||||
}
|
||||
return s.bandwidthLimit
|
||||
}
|
||||
|
||||
// acquireNodeSemaphore 返回节点级并发通道。懒初始化:第一次为某节点排队时创建。
|
||||
// 如果节点未配置 MaxConcurrent 或 nodeRepo 未注入,返回 nil(调用方走全局 semaphore)。
|
||||
// 节点容量仅在首次创建时采用,后续变更需重启服务才生效(避免运行时 resize 通道的复杂度)。
|
||||
@@ -456,6 +582,10 @@ func (s *BackupExecutionService) executeTask(ctx context.Context, task *model.Ba
|
||||
s.semaphore <- struct{}{}
|
||||
defer func() { <-s.semaphore }()
|
||||
|
||||
// Prometheus: running gauge + 完成时 observe 耗时/字节/状态
|
||||
s.metrics.IncTaskRunning()
|
||||
defer s.metrics.DecTaskRunning()
|
||||
|
||||
logger := backup.NewExecutionLogger(recordID, s.logHub)
|
||||
status := "failed"
|
||||
errMessage := ""
|
||||
@@ -463,11 +593,14 @@ 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(耗时 + 产出字节 + 状态计数)
|
||||
s.metrics.ObserveTaskRun(task.Type, status, time.Since(startedAt).Seconds(), fileSize)
|
||||
// 写入多目标上传结果
|
||||
if len(uploadResults) > 0 {
|
||||
if resultsJSON, marshalErr := json.Marshal(uploadResults); marshalErr == nil {
|
||||
@@ -559,7 +692,8 @@ func (s *BackupExecutionService) executeTask(ctx context.Context, task *model.Ba
|
||||
if findErr == nil && target != nil {
|
||||
targetName = target.Name
|
||||
}
|
||||
provider, resolveErr := s.resolveProvider(ctx, targetID)
|
||||
// 节点级带宽覆盖:若 task 绑定节点并配置了 BandwidthLimit,覆盖全局限速
|
||||
provider, resolveErr := s.resolveProviderForNode(ctx, targetID, task.NodeID)
|
||||
if resolveErr != nil {
|
||||
uploadResults[index] = StorageUploadResultItem{StorageTargetID: targetID, StorageTargetName: targetName, Status: "failed", Error: resolveErr.Error()}
|
||||
logger.Warnf("存储目标 %s 创建客户端失败:%v", targetName, resolveErr)
|
||||
@@ -658,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))
|
||||
}
|
||||
@@ -690,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,
|
||||
@@ -715,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
|
||||
@@ -725,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
|
||||
@@ -742,10 +882,17 @@ func (s *BackupExecutionService) finalizeRecord(ctx context.Context, task *model
|
||||
}
|
||||
|
||||
func (s *BackupExecutionService) resolveProvider(ctx context.Context, targetID uint) (storage.StorageProvider, error) {
|
||||
// 注入 rclone 传输配置(重试、带宽限制)
|
||||
return s.resolveProviderForNode(ctx, targetID, 0)
|
||||
}
|
||||
|
||||
// resolveProviderForNode 根据节点的 BandwidthLimit 覆盖全局默认。
|
||||
// nodeID=0 或节点未配置时退化为全局默认。
|
||||
// 仅在 Master 本地执行生效;Agent 会收到自身 Node 配置,并在独立 runtime 中应用。
|
||||
func (s *BackupExecutionService) resolveProviderForNode(ctx context.Context, targetID uint, nodeID uint) (storage.StorageProvider, error) {
|
||||
// 注入 rclone 传输配置(重试、节点级带宽覆盖全局)
|
||||
ctx = rclone.ConfiguredContext(ctx, rclone.TransferConfig{
|
||||
LowLevelRetries: s.retries,
|
||||
BandwidthLimit: s.bandwidthLimit,
|
||||
BandwidthLimit: s.effectiveBandwidth(ctx, nodeID),
|
||||
})
|
||||
target, err := s.targets.FindByID(ctx, targetID)
|
||||
if err != nil {
|
||||
@@ -849,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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
83
server/internal/service/node_pool_scheduler_test.go
Normal file
83
server/internal/service/node_pool_scheduler_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
|
||||
@@ -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 登录验证码:%s,5 分钟内有效。", 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) {
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"time"
|
||||
|
||||
"backupx/server/internal/apperror"
|
||||
"backupx/server/internal/metrics"
|
||||
"backupx/server/internal/model"
|
||||
"backupx/server/internal/repository"
|
||||
"backupx/server/internal/storage"
|
||||
@@ -37,6 +38,12 @@ type ReplicationService struct {
|
||||
semaphore chan struct{}
|
||||
async func(func())
|
||||
now func() time.Time
|
||||
metrics *metrics.Metrics
|
||||
}
|
||||
|
||||
// SetMetrics 注入 Prometheus 采集器。
|
||||
func (s *ReplicationService) SetMetrics(m *metrics.Metrics) {
|
||||
s.metrics = m
|
||||
}
|
||||
|
||||
func NewReplicationService(
|
||||
@@ -193,6 +200,7 @@ func (s *ReplicationService) executeReplication(ctx context.Context, repID uint)
|
||||
rep.DurationSeconds = int(completedAt.Sub(rep.StartedAt).Seconds())
|
||||
rep.CompletedAt = &completedAt
|
||||
_ = s.replications.Update(ctx, rep)
|
||||
s.metrics.ObserveReplication(status)
|
||||
if status == model.ReplicationStatusFailed {
|
||||
s.dispatchFailed(ctx, rep, errMessage)
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
|
||||
"backupx/server/internal/apperror"
|
||||
"backupx/server/internal/backup"
|
||||
"backupx/server/internal/metrics"
|
||||
"backupx/server/internal/model"
|
||||
"backupx/server/internal/repository"
|
||||
"backupx/server/internal/storage"
|
||||
@@ -41,6 +42,12 @@ type RestoreService struct {
|
||||
semaphore chan struct{}
|
||||
async func(func())
|
||||
now func() time.Time
|
||||
metrics *metrics.Metrics
|
||||
}
|
||||
|
||||
// SetMetrics 注入 Prometheus 采集器。
|
||||
func (s *RestoreService) SetMetrics(m *metrics.Metrics) {
|
||||
s.metrics = m
|
||||
}
|
||||
|
||||
// NewRestoreService 构造恢复服务。maxConcurrent 控制本地并发恢复数。
|
||||
@@ -134,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),
|
||||
@@ -147,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)
|
||||
}
|
||||
@@ -159,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)
|
||||
}
|
||||
|
||||
@@ -178,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
|
||||
@@ -432,6 +450,7 @@ func (s *RestoreService) finalizeWithLog(ctx context.Context, restoreID uint, st
|
||||
}
|
||||
record.DurationSeconds = int(completedAt.Sub(record.StartedAt).Seconds())
|
||||
record.CompletedAt = &completedAt
|
||||
s.metrics.ObserveRestore(status)
|
||||
return s.restores.Update(ctx, record)
|
||||
}
|
||||
|
||||
@@ -621,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") {
|
||||
@@ -659,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) {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -8,21 +8,55 @@ import (
|
||||
"backupx/server/internal/repository"
|
||||
)
|
||||
|
||||
// AuditWebhookConfigurer 抽象审计 webhook 配置接口,由 AuditService 实现。
|
||||
// 用接口解耦避免 settings_service 直接依赖 AuditService 具体类型。
|
||||
type AuditWebhookConfigurer interface {
|
||||
SetWebhook(url, secret string)
|
||||
}
|
||||
|
||||
type SettingsService struct {
|
||||
configs repository.SystemConfigRepository
|
||||
configs repository.SystemConfigRepository
|
||||
auditWebhook AuditWebhookConfigurer
|
||||
}
|
||||
|
||||
func NewSettingsService(configs repository.SystemConfigRepository) *SettingsService {
|
||||
return &SettingsService{configs: configs}
|
||||
}
|
||||
|
||||
// settingsKeys lists all user-editable setting keys.
|
||||
// SetAuditWebhookConfigurer 注入 audit webhook 配置接收方。
|
||||
// 启动时立即用当前 DB 中的设置调用一次,后续每次 Update 变更 webhook key 时同步推送。
|
||||
func (s *SettingsService) SetAuditWebhookConfigurer(ctx context.Context, configurer AuditWebhookConfigurer) {
|
||||
if s == nil || configurer == nil {
|
||||
return
|
||||
}
|
||||
s.auditWebhook = configurer
|
||||
// 启动时同步一次,保证重启后配置不丢失
|
||||
all, err := s.GetAll(ctx)
|
||||
if err == nil {
|
||||
configurer.SetWebhook(all[SettingKeyAuditWebhookURL], all[SettingKeyAuditWebhookSecret])
|
||||
}
|
||||
}
|
||||
|
||||
// 可被前端写入的系统设置键。新增键必须同步加入此清单,
|
||||
// 否则 Update 会忽略(安全原则:显式 allow-list)。
|
||||
const (
|
||||
SettingKeySiteName = "site_name"
|
||||
SettingKeyLanguage = "language"
|
||||
SettingKeyTimezone = "timezone"
|
||||
SettingKeyBackupNotificationEnabled = "backup_notification_enabled"
|
||||
SettingKeyBandwidthLimit = "bandwidth_limit"
|
||||
SettingKeyAuditWebhookURL = "audit_webhook_url"
|
||||
SettingKeyAuditWebhookSecret = "audit_webhook_secret"
|
||||
)
|
||||
|
||||
var settingsKeys = []string{
|
||||
"site_name",
|
||||
"language",
|
||||
"timezone",
|
||||
"backup_notification_enabled",
|
||||
"bandwidth_limit",
|
||||
SettingKeySiteName,
|
||||
SettingKeyLanguage,
|
||||
SettingKeyTimezone,
|
||||
SettingKeyBackupNotificationEnabled,
|
||||
SettingKeyBandwidthLimit,
|
||||
SettingKeyAuditWebhookURL,
|
||||
SettingKeyAuditWebhookSecret,
|
||||
}
|
||||
|
||||
func (s *SettingsService) GetAll(ctx context.Context) (map[string]string, error) {
|
||||
@@ -42,6 +76,7 @@ func (s *SettingsService) Update(ctx context.Context, settings map[string]string
|
||||
for _, key := range settingsKeys {
|
||||
allowed[key] = true
|
||||
}
|
||||
auditWebhookTouched := false
|
||||
for key, value := range settings {
|
||||
if !allowed[key] {
|
||||
continue
|
||||
@@ -50,6 +85,14 @@ func (s *SettingsService) Update(ctx context.Context, settings map[string]string
|
||||
if err := s.configs.Upsert(ctx, item); err != nil {
|
||||
return nil, apperror.Internal("SETTINGS_UPDATE_FAILED", "无法更新系统设置", err)
|
||||
}
|
||||
if key == SettingKeyAuditWebhookURL || key == SettingKeyAuditWebhookSecret {
|
||||
auditWebhookTouched = true
|
||||
}
|
||||
}
|
||||
// audit webhook 配置变化:立即同步到 AuditService,避免重启才生效
|
||||
if auditWebhookTouched && s.auditWebhook != nil {
|
||||
all, _ := s.GetAll(ctx)
|
||||
s.auditWebhook.SetWebhook(all[SettingKeyAuditWebhookURL], all[SettingKeyAuditWebhookSecret])
|
||||
}
|
||||
return s.GetAll(ctx)
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
}
|
||||
}
|
||||
|
||||
124
server/internal/service/user_service_test.go
Normal file
124
server/internal/service/user_service_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
|
||||
"backupx/server/internal/apperror"
|
||||
"backupx/server/internal/backup"
|
||||
"backupx/server/internal/metrics"
|
||||
"backupx/server/internal/model"
|
||||
"backupx/server/internal/repository"
|
||||
"backupx/server/internal/storage"
|
||||
@@ -42,6 +43,12 @@ type VerificationService struct {
|
||||
semaphore chan struct{}
|
||||
async func(func())
|
||||
now func() time.Time
|
||||
metrics *metrics.Metrics
|
||||
}
|
||||
|
||||
// SetMetrics 注入 Prometheus 采集器。
|
||||
func (s *VerificationService) SetMetrics(m *metrics.Metrics) {
|
||||
s.metrics = m
|
||||
}
|
||||
|
||||
// VerificationNotifier 给用户推送验证完成/失败通知。
|
||||
@@ -413,6 +420,7 @@ func (s *VerificationService) finalize(ctx context.Context, verID uint, status,
|
||||
}
|
||||
record.DurationSeconds = int(completedAt.Sub(record.StartedAt).Seconds())
|
||||
record.CompletedAt = &completedAt
|
||||
s.metrics.ObserveVerify(status)
|
||||
return s.verifications.Update(ctx, record)
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user