mirror of
https://github.com/Awuqing/BackupX.git
synced 2026-06-25 19:43:41 +08:00
Compare commits
22 Commits
v2.3.2
...
feat/one-c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
34a9bc82be | ||
|
|
5c896bb96c | ||
|
|
e28f2d3454 | ||
|
|
5b03485f15 | ||
|
|
42b7e2013a | ||
|
|
275a466e6a | ||
|
|
ae71d952b1 | ||
|
|
46de6cf03a | ||
|
|
37440e583d | ||
|
|
3487dfcee8 | ||
|
|
60fda5a5e4 | ||
|
|
65ee70f6d3 | ||
|
|
c0db0e0eac | ||
|
|
e1a70624bc | ||
|
|
665a88a0a7 | ||
|
|
8d52be23b0 | ||
|
|
b7ace8e3a5 | ||
|
|
f53fa661c8 | ||
|
|
0d26bc3d91 | ||
|
|
c8b5095b2f | ||
|
|
fb043627bb | ||
|
|
65cbbea3b6 |
5
.github/workflows/release.yml
vendored
5
.github/workflows/release.yml
vendored
@@ -110,11 +110,6 @@ jobs:
|
|||||||
cp -r web/dist "${ARCHIVE_NAME}/web"
|
cp -r web/dist "${ARCHIVE_NAME}/web"
|
||||||
cp server/config.example.yaml "${ARCHIVE_NAME}/"
|
cp server/config.example.yaml "${ARCHIVE_NAME}/"
|
||||||
cp deploy/install.sh "${ARCHIVE_NAME}/" 2>/dev/null || true
|
cp deploy/install.sh "${ARCHIVE_NAME}/" 2>/dev/null || true
|
||||||
# v2.2+: 随发布包提供 Grafana dashboard 与 nginx.conf 模板
|
|
||||||
if [ -d deploy/grafana ]; then
|
|
||||||
cp -r deploy/grafana "${ARCHIVE_NAME}/grafana"
|
|
||||||
fi
|
|
||||||
cp deploy/nginx.conf "${ARCHIVE_NAME}/nginx.conf" 2>/dev/null || true
|
|
||||||
tar czf "${ARCHIVE_NAME}.tar.gz" "${ARCHIVE_NAME}"
|
tar czf "${ARCHIVE_NAME}.tar.gz" "${ARCHIVE_NAME}"
|
||||||
|
|
||||||
- name: Upload to GitHub Release
|
- name: Upload to GitHub Release
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,4 +1,3 @@
|
|||||||
web/node_modules/
|
web/node_modules/
|
||||||
web/dist/
|
web/dist/
|
||||||
server/bin/
|
server/bin/
|
||||||
.claude/
|
|
||||||
@@ -46,9 +46,6 @@
|
|||||||
| **Multi-Node Cluster** | Master-Agent mode via HTTP long-polling — Agents run tasks locally, upload straight to storage, no reverse connectivity required |
|
| **Multi-Node Cluster** | Master-Agent mode via HTTP long-polling — Agents run tasks locally, upload straight to storage, no reverse connectivity required |
|
||||||
| **Security** | JWT + bcrypt + AES-256-GCM encrypted config + optional backup encryption + full audit log |
|
| **Security** | JWT + bcrypt + AES-256-GCM encrypted config + optional backup encryption + full audit log |
|
||||||
| **Notifications** | Email / Webhook / Telegram on success or failure |
|
| **Notifications** | Email / Webhook / Telegram on success or failure |
|
||||||
| **Observability** | Prometheus `/metrics` endpoint + `/health` + `/ready` probes + SLA breach gauge |
|
|
||||||
| **Audit Webhook** | HMAC-SHA256 signed forwarding to SIEM / WORM storage for compliance (SOC2 / GDPR) |
|
|
||||||
| **Flow Control** | Per-node bandwidth cap + per-node concurrency limit — tune big/small nodes independently |
|
|
||||||
| **Deployment** | Single binary + embedded SQLite, Docker one-click, zero external dependencies |
|
| **Deployment** | Single binary + embedded SQLite, Docker one-click, zero external dependencies |
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|||||||
@@ -46,9 +46,6 @@
|
|||||||
| **多节点集群** | Master-Agent 模式,基于 HTTP 长轮询跨多台服务器管理备份。Agent 本地执行任务并直接上传到存储,无需反向连通性 |
|
| **多节点集群** | Master-Agent 模式,基于 HTTP 长轮询跨多台服务器管理备份。Agent 本地执行任务并直接上传到存储,无需反向连通性 |
|
||||||
| **安全** | JWT + bcrypt + AES-256-GCM 加密配置 + 可选备份文件加密 + 完整审计日志 |
|
| **安全** | JWT + bcrypt + AES-256-GCM 加密配置 + 可选备份文件加密 + 完整审计日志 |
|
||||||
| **通知** | 邮件 / Webhook / Telegram,备份成功或失败时自动推送 |
|
| **通知** | 邮件 / Webhook / Telegram,备份成功或失败时自动推送 |
|
||||||
| **可观测性** | Prometheus `/metrics` 端点 + `/health` + `/ready` 探针 + SLA 违约监控 |
|
|
||||||
| **审计外输** | HMAC-SHA256 签名 Webhook,对接 SIEM / WORM 存储满足 SOC2 / GDPR 合规 |
|
|
||||||
| **流控** | 节点级带宽限速 + 节点级并发控制,大小节点分别配置,避免小内存 Agent 被挤爆 |
|
|
||||||
| **部署** | 单二进制 + 内嵌 SQLite,Docker 一键启动,零外部依赖 |
|
| **部署** | 单二进制 + 内嵌 SQLite,Docker 一键启动,零外部依赖 |
|
||||||
|
|
||||||
## 快速开始
|
## 快速开始
|
||||||
|
|||||||
@@ -19,25 +19,6 @@ server {
|
|||||||
proxy_read_timeout 3600s;
|
proxy_read_timeout 3600s;
|
||||||
}
|
}
|
||||||
|
|
||||||
# Agent one-click install endpoints.
|
|
||||||
# Some external reverse proxies strip the /api prefix before reaching this
|
|
||||||
# container, so /install/ must be proxied here instead of falling through to
|
|
||||||
# the SPA index.html.
|
|
||||||
location /install/ {
|
|
||||||
proxy_pass http://127.0.0.1:8341/install/;
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
proxy_buffering off;
|
|
||||||
proxy_cache off;
|
|
||||||
}
|
|
||||||
|
|
||||||
location = /health { proxy_pass http://127.0.0.1:8341/health; }
|
|
||||||
location = /ready { proxy_pass http://127.0.0.1:8341/ready; }
|
|
||||||
location = /metrics { proxy_pass http://127.0.0.1:8341/metrics; }
|
|
||||||
|
|
||||||
# SPA fallback
|
# SPA fallback
|
||||||
location / {
|
location / {
|
||||||
try_files $uri $uri/ /index.html;
|
try_files $uri $uri/ /index.html;
|
||||||
|
|||||||
@@ -1,34 +0,0 @@
|
|||||||
# 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 任务耗时突变可用于发现慢任务和资源压力
|
|
||||||
@@ -1,193 +0,0 @@
|
|||||||
{
|
|
||||||
"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": ""
|
|
||||||
}
|
|
||||||
@@ -18,22 +18,6 @@ server {
|
|||||||
proxy_read_timeout 3600s;
|
proxy_read_timeout 3600s;
|
||||||
}
|
}
|
||||||
|
|
||||||
# Agent 一键安装脚本路径(兼容 v2.0 及之前生成的命令)。
|
|
||||||
# v2.1+ 新生成的命令走 /api/install/... 自动命中上面的 /api/ 代理。
|
|
||||||
location /install/ {
|
|
||||||
proxy_pass http://127.0.0.1:8340/install/;
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
}
|
|
||||||
|
|
||||||
# 健康检查端点同样不走 SPA fallback。
|
|
||||||
location = /health { proxy_pass http://127.0.0.1:8340/health; }
|
|
||||||
location = /ready { proxy_pass http://127.0.0.1:8340/ready; }
|
|
||||||
location = /metrics { proxy_pass http://127.0.0.1:8340/metrics; }
|
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
try_files $uri $uri/ /index.html;
|
try_files $uri $uri/ /index.html;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,11 +34,15 @@ In the Web Console → **Node Management** → **Add Node**. You'll see a three-
|
|||||||
|
|
||||||
- **Step 1 — Node info.** Give the node a name, or switch to batch mode and paste multiple names (one per line, max 50).
|
- **Step 1 — Node info.** Give the node a name, or switch to batch mode and paste multiple names (one per line, max 50).
|
||||||
- **Step 2 — Deploy options.** Pick install mode (`systemd` recommended, `docker`, or `foreground` for debugging), architecture (auto-detect by default), agent version (defaults to the master's version), TTL for the install link (5 min / 15 min / 1 h / 24 h), and download source (`github` direct, or the `ghproxy` mirror for mainland China).
|
- **Step 2 — Deploy options.** Pick install mode (`systemd` recommended, `docker`, or `foreground` for debugging), architecture (auto-detect by default), agent version (defaults to the master's version), TTL for the install link (5 min / 15 min / 1 h / 24 h), and download source (`github` direct, or the `ghproxy` mirror for mainland China).
|
||||||
- **Step 3 — Copy the command.** A 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.
|
- **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.
|
||||||
|
|
||||||
### 2. One-line install on the target host
|
### 2. One-line install on the target host
|
||||||
|
|
||||||
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.
|
Example (systemd mode):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsSL https://master.example.com/install/Xk3p9...vM | sudo sh
|
||||||
|
```
|
||||||
|
|
||||||
The script runs automatically and:
|
The script runs automatically and:
|
||||||
|
|
||||||
@@ -49,8 +53,6 @@ The script runs automatically and:
|
|||||||
5. Runs `systemctl enable --now backupx-agent`
|
5. Runs `systemctl enable --now backupx-agent`
|
||||||
6. Polls `/api/v1/agent/self` until the master confirms `status: online` (up to 30 s)
|
6. Polls `/api/v1/agent/self` until the master confirms `status: online` (up to 30 s)
|
||||||
|
|
||||||
If you choose the URL-based fallback command and `curl` prints HTML or the shell reports `Syntax error: newline unexpected`, the install URL is being served by the web console instead of the backend. Ensure either `/api/install/` or `/install/` is forwarded to the BackupX backend, or use the embedded command generated by the console.
|
|
||||||
|
|
||||||
Reruns are idempotent — to upgrade or re-provision, simply generate a new install command and run it again. The one-time install link expires after its TTL or after first consumption, whichever is sooner.
|
Reruns are idempotent — to upgrade or re-provision, simply generate a new install command and run it again. The one-time install link expires after its TTL or after first consumption, whichever is sooner.
|
||||||
|
|
||||||
### 3. Rotate agent tokens at any time
|
### 3. Rotate agent tokens at any time
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import type * as Preset from '@docusaurus/preset-classic';
|
|||||||
// https://awuqing.github.io/BackupX/
|
// https://awuqing.github.io/BackupX/
|
||||||
const config: Config = {
|
const config: Config = {
|
||||||
title: 'BackupX',
|
title: 'BackupX',
|
||||||
tagline: 'Self-hosted backup orchestration for servers, databases, storage targets and remote agents',
|
tagline: 'Self-hosted server backup management — one binary, one command',
|
||||||
favicon: 'img/favicon.ico',
|
favicon: 'img/favicon.ico',
|
||||||
|
|
||||||
future: {
|
future: {
|
||||||
@@ -76,16 +76,6 @@ const config: Config = {
|
|||||||
label: 'Downloads',
|
label: 'Downloads',
|
||||||
position: 'left',
|
position: 'left',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
to: '/community',
|
|
||||||
label: 'Community',
|
|
||||||
position: 'left',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
to: '/sponsors',
|
|
||||||
label: 'Sponsors',
|
|
||||||
position: 'left',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
type: 'localeDropdown',
|
type: 'localeDropdown',
|
||||||
position: 'right',
|
position: 'right',
|
||||||
@@ -125,22 +115,6 @@ const config: Config = {
|
|||||||
{label: 'Issues', href: 'https://github.com/Awuqing/BackupX/issues'},
|
{label: 'Issues', href: 'https://github.com/Awuqing/BackupX/issues'},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
|
||||||
title: 'Community',
|
|
||||||
items: [
|
|
||||||
{label: 'Contributors', href: 'https://github.com/Awuqing/BackupX/graphs/contributors'},
|
|
||||||
{label: 'Pull Requests', href: 'https://github.com/Awuqing/BackupX/pulls'},
|
|
||||||
{label: 'Sponsor', to: '/sponsors'},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Sponsors',
|
|
||||||
items: [
|
|
||||||
{label: 'Sponsor BackupX', href: 'https://github.com/sponsors/Awuqing'},
|
|
||||||
{label: 'Partnership', href: 'https://github.com/Awuqing/BackupX/issues/new/choose'},
|
|
||||||
{label: 'Sponsor tiers', to: '/sponsors'},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
copyright: `Copyright © ${new Date().getFullYear()} BackupX · Apache License 2.0`,
|
copyright: `Copyright © ${new Date().getFullYear()} BackupX · Apache License 2.0`,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,22 +1,22 @@
|
|||||||
{
|
{
|
||||||
"home.badge": {
|
"home.badge": {
|
||||||
"message": "开源备份控制平面 · v2.2.1",
|
"message": "开源 · v1.6.0",
|
||||||
"description": "Version badge on the hero"
|
"description": "Version badge on the hero"
|
||||||
},
|
},
|
||||||
"home.title.part1": {
|
"home.title.part1": {
|
||||||
"message": "面向自托管服务器的",
|
"message": "为每一台服务器提供",
|
||||||
"description": "Hero title, first line"
|
"description": "Hero title, first line"
|
||||||
},
|
},
|
||||||
"home.title.part2": {
|
"home.title.part2": {
|
||||||
"message": "备份编排平台。",
|
"message": "自托管备份管理。",
|
||||||
"description": "Hero title accent second line"
|
"description": "Hero title accent second line"
|
||||||
},
|
},
|
||||||
"home.tagline": {
|
"home.tagline": {
|
||||||
"message": "在一个清爽控制台中管理文件、数据库、SAP HANA 和远程节点备份。控制平面自己掌握,存储后端灵活选择。",
|
"message": "一个二进制,一条命令。文件 / 数据库 / SAP HANA 备份直送 70+ 存储后端。",
|
||||||
"description": "Tagline on the home page"
|
"description": "Tagline on the home page"
|
||||||
},
|
},
|
||||||
"home.pageTitle": {
|
"home.pageTitle": {
|
||||||
"message": "面向自托管服务器的备份编排",
|
"message": "自托管备份管理",
|
||||||
"description": "Page <title> element on the home page"
|
"description": "Page <title> element on the home page"
|
||||||
},
|
},
|
||||||
"home.getStarted": {
|
"home.getStarted": {
|
||||||
@@ -28,26 +28,13 @@
|
|||||||
"description": "Hero metric label: storage backends"
|
"description": "Hero metric label: storage backends"
|
||||||
},
|
},
|
||||||
"home.metric.backupTypes": {
|
"home.metric.backupTypes": {
|
||||||
"message": "远程执行",
|
"message": "备份类型",
|
||||||
"description": "Hero metric label: backup types"
|
"description": "Hero metric label: backup types"
|
||||||
},
|
},
|
||||||
"home.metric.license": {
|
"home.metric.license": {
|
||||||
"message": "开源协议",
|
"message": "开源协议",
|
||||||
"description": "Hero metric label: license"
|
"description": "Hero metric label: license"
|
||||||
},
|
},
|
||||||
"home.visual.eyebrow": {"message": "BackupX 控制台"},
|
|
||||||
"home.visual.title": {"message": "运维概览"},
|
|
||||||
"home.visual.status": {"message": "健康"},
|
|
||||||
"home.visual.success": {"message": "成功率"},
|
|
||||||
"home.visual.nodes": {"message": "活跃节点"},
|
|
||||||
"home.visual.targets": {"message": "存储目标"},
|
|
||||||
"home.visual.row1.title": {"message": "PostgreSQL 夜间备份"},
|
|
||||||
"home.visual.row1.desc": {"message": "加密归档已上传至 S3"},
|
|
||||||
"home.visual.row2.title": {"message": "SAP HANA 快照"},
|
|
||||||
"home.visual.row2.desc": {"message": "正在 agent-shanghai-02 上运行"},
|
|
||||||
"home.visual.row3.title": {"message": "保留策略清理"},
|
|
||||||
"home.visual.row3.desc": {"message": "下一次执行在 4 小时后"},
|
|
||||||
"home.command.title": {"message": "使用 Docker 启动"},
|
|
||||||
|
|
||||||
"section.features.tag": {
|
"section.features.tag": {
|
||||||
"message": "核心能力",
|
"message": "核心能力",
|
||||||
@@ -91,70 +78,5 @@
|
|||||||
"showcase.storage.desc": {"message": "阿里云 OSS、腾讯云 COS、S3、Google Drive、WebDAV — 加上每一种 rclone 后端。测试连接、收藏、查看实时容量。"},
|
"showcase.storage.desc": {"message": "阿里云 OSS、腾讯云 COS、S3、Google Drive、WebDAV — 加上每一种 rclone 后端。测试连接、收藏、查看实时容量。"},
|
||||||
"showcase.nodes.title": {"message": "几分钟搭起 Master-Agent"},
|
"showcase.nodes.title": {"message": "几分钟搭起 Master-Agent"},
|
||||||
"showcase.nodes.desc": {"message": "创建节点、复制令牌、在任意远程主机启动 Agent。路由到节点的任务在本地执行并直接上传到存储 — 无需反向连通性。"},
|
"showcase.nodes.desc": {"message": "创建节点、复制令牌、在任意远程主机启动 Agent。路由到节点的任务在本地执行并直接上传到存储 — 无需反向连通性。"},
|
||||||
"showcase.cta": {"message": "开始阅读文档"},
|
"showcase.cta": {"message": "开始阅读文档"}
|
||||||
|
|
||||||
"community.tag": {"message": "社区"},
|
|
||||||
"community.pageTitle": {"message": "社区、赞助商与贡献者"},
|
|
||||||
"community.pageDescription": {"message": "赞助 BackupX,了解贡献者,并找到务实的参与方式。"},
|
|
||||||
"community.title": {"message": "开放协作,面向长期运维"},
|
|
||||||
"community.subtitle": {"message": "备份软件的信任来自透明发布、真实部署反馈,以及足够务实的贡献路径。"},
|
|
||||||
"community.sponsor.kicker": {"message": "赞助商"},
|
|
||||||
"community.sponsor.wallTitle": {"message": "赞助商"},
|
|
||||||
"community.sponsor.title": {"message": "支持你依赖的备份基础设施"},
|
|
||||||
"community.sponsor.cta": {"message": "赞助 BackupX"},
|
|
||||||
"community.sponsor.openSlot": {"message": "赞助席位开放"},
|
|
||||||
"community.sponsor.logo.project": {"message": "项目赞助"},
|
|
||||||
"community.sponsor.logo.cloud": {"message": "云服务伙伴"},
|
|
||||||
"community.sponsor.logo.object": {"message": "对象存储"},
|
|
||||||
"community.sponsor.logo.cdn": {"message": "CDN 伙伴"},
|
|
||||||
"community.sponsor.logo.database": {"message": "数据库伙伴"},
|
|
||||||
"community.sponsor.logo.security": {"message": "安全审计"},
|
|
||||||
"community.sponsor.logo.agent": {"message": "远程节点实验室"},
|
|
||||||
"community.sponsor.logo.docs": {"message": "文档赞助"},
|
|
||||||
"community.sponsor.logo.release": {"message": "发布赞助"},
|
|
||||||
"community.sponsor.logo.s3": {"message": "S3 兼容"},
|
|
||||||
"community.sponsor.logo.webdav": {"message": "WebDAV 伙伴"},
|
|
||||||
"community.sponsor.logo.sftp": {"message": "SFTP 伙伴"},
|
|
||||||
"community.sponsor.logo.docker": {"message": "容器伙伴"},
|
|
||||||
"community.sponsor.logo.mirror": {"message": "镜像伙伴"},
|
|
||||||
"community.sponsor.logo.restore": {"message": "恢复演练"},
|
|
||||||
"community.sponsor.logo.qa": {"message": "测试实验室"},
|
|
||||||
"community.sponsor.logo.oss": {"message": "开源支持"},
|
|
||||||
"community.sponsor.logo.open": {"message": "赞助席位开放"},
|
|
||||||
"community.sponsor.infrastructure.label": {"message": "基础设施"},
|
|
||||||
"community.sponsor.infrastructure.title": {"message": "云与存储生态伙伴"},
|
|
||||||
"community.sponsor.infrastructure.desc": {"message": "帮助 BackupX 覆盖对象存储、WebDAV、SFTP 以及区域云平台的真实验证。"},
|
|
||||||
"community.sponsor.security.label": {"message": "安全"},
|
|
||||||
"community.sponsor.security.title": {"message": "审计与可靠性支持者"},
|
|
||||||
"community.sponsor.security.desc": {"message": "支持加密、恢复演练、发布签名和运维检查等强化工作。"},
|
|
||||||
"community.sponsor.community.label": {"message": "社区"},
|
|
||||||
"community.sponsor.community.title": {"message": "开源支持者"},
|
|
||||||
"community.sponsor.community.desc": {"message": "支持文档、示例、平台测试和贡献者引导。"},
|
|
||||||
"community.sponsor.tier.backer.name": {"message": "Backer"},
|
|
||||||
"community.sponsor.tier.backer.amount": {"message": "适合个人与小团队"},
|
|
||||||
"community.sponsor.tier.backer.desc": {"message": "支持文档、Issue 分流、兼容性测试和小型体验改进。"},
|
|
||||||
"community.sponsor.tier.partner.name": {"message": "Partner"},
|
|
||||||
"community.sponsor.tier.partner.amount": {"message": "适合存储与基础设施厂商"},
|
|
||||||
"community.sponsor.tier.partner.desc": {"message": "支持 Provider 验证、部署示例、基准说明和集成指南。"},
|
|
||||||
"community.sponsor.tier.enterprise.name": {"message": "Enterprise"},
|
|
||||||
"community.sponsor.tier.enterprise.amount": {"message": "适合生产环境使用方"},
|
|
||||||
"community.sponsor.tier.enterprise.desc": {"message": "赞助恢复演练、发布加固、审计和长期维护等可靠性工作。"},
|
|
||||||
"community.contributor.kicker": {"message": "贡献者"},
|
|
||||||
"community.contributor.all": {"message": "查看全部"},
|
|
||||||
"community.contributor.source": {"message": "浏览器端通过 GitHub contributors API 获取。"},
|
|
||||||
"community.contributor.botRole": {"message": "自动化贡献者"},
|
|
||||||
"community.contributor.githubRole": {"message": "GitHub 贡献者"},
|
|
||||||
"community.contributor.contributions": {"message": "{count} 次贡献"},
|
|
||||||
"community.path.kicker": {"message": "贡献路径"},
|
|
||||||
"community.path.issues.title": {"message": "反馈生产问题"},
|
|
||||||
"community.path.issues.desc": {"message": "提交日志、部署拓扑和恢复预期。"},
|
|
||||||
"community.path.docs.title": {"message": "完善文档与示例"},
|
|
||||||
"community.path.docs.desc": {"message": "贡献存储、Agent 和数据库部署指南。"},
|
|
||||||
"community.path.code.title": {"message": "提交聚焦的 PR"},
|
|
||||||
"community.path.code.desc": {"message": "保持改动小而可测,并贴合现有架构。"},
|
|
||||||
"sponsors.pageTitle": {"message": "赞助商"},
|
|
||||||
"sponsors.pageDescription": {"message": "赞助 BackupX 的可靠性、文档、存储兼容性和长期维护。"},
|
|
||||||
"sponsors.tag": {"message": "赞助商"},
|
|
||||||
"sponsors.title": {"message": "赞助 BackupX 生态"},
|
|
||||||
"sponsors.subtitle": {"message": "赞助帮助 BackupX 更贴近真实运维:经过验证的存储 Provider、可靠发布、恢复信心和更完善的文档。"}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,11 +34,15 @@ Web 控制台 → **节点管理** → **添加节点**,打开三步向导:
|
|||||||
|
|
||||||
- **第一步 · 节点信息**:填写节点名称;或切换"批量创建"粘贴多行名称(每行一个,最多 50 个)
|
- **第一步 · 节点信息**:填写节点名称;或切换"批量创建"粘贴多行名称(每行一个,最多 50 个)
|
||||||
- **第二步 · 部署参数**:选择安装模式(`systemd` 推荐、`Docker`、`前台运行` 调试用)、架构(默认自动检测)、Agent 版本(默认跟随 Master 版本)、有效期(5 分钟 / 15 分钟 / 1 小时 / 24 小时)、下载源(`GitHub` 直连或 `ghproxy` 镜像,国内服务器建议后者)
|
- **第二步 · 部署参数**:选择安装模式(`systemd` 推荐、`Docker`、`前台运行` 调试用)、架构(默认自动检测)、Agent 版本(默认跟随 Master 版本)、有效期(5 分钟 / 15 分钟 / 1 小时 / 24 小时)、下载源(`GitHub` 直连或 `ghproxy` 镜像,国内服务器建议后者)
|
||||||
- **第三步 · 安装命令**:一条一键安装命令 + 实时倒计时。点击复制,粘贴到目标机以 root 权限执行。默认命令会嵌入已渲染的安装脚本,目标机无需再通过反向代理访问 `/api/install/:token`;公开安装 URL 仍作为备用路径保留。
|
- **第三步 · 安装命令**:一行 `curl ... | sudo sh` 命令 + 实时倒计时。点击复制,粘贴到目标机以 root 权限执行
|
||||||
|
|
||||||
### 2. 目标机一条命令完成
|
### 2. 目标机一条命令完成
|
||||||
|
|
||||||
请直接使用 Web 控制台生成的命令。该命令会把安装脚本写入临时文件,校验 `BACKUPX_AGENT_INSTALL_V1` 魔数,再以 root 权限执行。
|
示例(systemd 模式):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsSL https://master.example.com/install/Xk3p9...vM | sudo sh
|
||||||
|
```
|
||||||
|
|
||||||
脚本会自动:
|
脚本会自动:
|
||||||
|
|
||||||
@@ -49,8 +53,6 @@ Web 控制台 → **节点管理** → **添加节点**,打开三步向导:
|
|||||||
5. 执行 `systemctl enable --now backupx-agent`
|
5. 执行 `systemctl enable --now backupx-agent`
|
||||||
6. 轮询 `/api/v1/agent/self`,直到 Master 确认 `status: online`(最多 30 秒)
|
6. 轮询 `/api/v1/agent/self`,直到 Master 确认 `status: online`(最多 30 秒)
|
||||||
|
|
||||||
如果使用 URL 备用命令时 `curl` 输出 HTML,或 shell 报 `Syntax error: newline unexpected`,说明安装 URL 被 Web 控制台接管而不是转发到后端。需要确保 `/api/install/` 或 `/install/` 至少一个路径能转发到 BackupX 后端,或改用控制台生成的嵌入式命令。
|
|
||||||
|
|
||||||
脚本是幂等的:升级或重装只需重新生成一条安装命令再跑一次。一次性安装链接在 TTL 到期或被首次消费后立即作废。
|
脚本是幂等的:升级或重装只需重新生成一条安装命令再跑一次。一次性安装链接在 TTL 到期或被首次消费后立即作废。
|
||||||
|
|
||||||
### 3. 随时轮换 Agent Token
|
### 3. 随时轮换 Agent Token
|
||||||
|
|||||||
@@ -2,8 +2,6 @@
|
|||||||
"link.title.Docs": {"message": "文档"},
|
"link.title.Docs": {"message": "文档"},
|
||||||
"link.title.Features": {"message": "功能"},
|
"link.title.Features": {"message": "功能"},
|
||||||
"link.title.More": {"message": "更多"},
|
"link.title.More": {"message": "更多"},
|
||||||
"link.title.Community": {"message": "社区"},
|
|
||||||
"link.title.Sponsors": {"message": "赞助商"},
|
|
||||||
"link.item.label.Introduction": {"message": "简介"},
|
"link.item.label.Introduction": {"message": "简介"},
|
||||||
"link.item.label.Quick Start": {"message": "快速开始"},
|
"link.item.label.Quick Start": {"message": "快速开始"},
|
||||||
"link.item.label.Installation": {"message": "安装"},
|
"link.item.label.Installation": {"message": "安装"},
|
||||||
@@ -13,11 +11,5 @@
|
|||||||
"link.item.label.GitHub": {"message": "GitHub"},
|
"link.item.label.GitHub": {"message": "GitHub"},
|
||||||
"link.item.label.Releases": {"message": "Releases"},
|
"link.item.label.Releases": {"message": "Releases"},
|
||||||
"link.item.label.Docker Hub": {"message": "Docker Hub"},
|
"link.item.label.Docker Hub": {"message": "Docker Hub"},
|
||||||
"link.item.label.Issues": {"message": "Issues"},
|
"link.item.label.Issues": {"message": "Issues"}
|
||||||
"link.item.label.Contributors": {"message": "贡献者"},
|
|
||||||
"link.item.label.Pull Requests": {"message": "Pull Requests"},
|
|
||||||
"link.item.label.Sponsor": {"message": "赞助"},
|
|
||||||
"link.item.label.Sponsor BackupX": {"message": "赞助 BackupX"},
|
|
||||||
"link.item.label.Partnership": {"message": "合作伙伴"},
|
|
||||||
"link.item.label.Sponsor tiers": {"message": "赞助层级"}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,14 +7,6 @@
|
|||||||
"message": "下载",
|
"message": "下载",
|
||||||
"description": "Navbar item: Downloads"
|
"description": "Navbar item: Downloads"
|
||||||
},
|
},
|
||||||
"item.label.Community": {
|
|
||||||
"message": "社区",
|
|
||||||
"description": "Navbar item: Community"
|
|
||||||
},
|
|
||||||
"item.label.Sponsors": {
|
|
||||||
"message": "赞助商",
|
|
||||||
"description": "Navbar item: Sponsors"
|
|
||||||
},
|
|
||||||
"item.label.GitHub": {
|
"item.label.GitHub": {
|
||||||
"message": "GitHub",
|
"message": "GitHub",
|
||||||
"description": "Navbar item: GitHub"
|
"description": "Navbar item: GitHub"
|
||||||
|
|||||||
@@ -1,329 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,429 +0,0 @@
|
|||||||
.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 && (
|
{link && (
|
||||||
<span className={styles.featureLink}>
|
<span className={styles.featureLink}>
|
||||||
<Translate id="feat.learnMore">Learn more</Translate>
|
<Translate id="feat.learnMore">Learn more</Translate>
|
||||||
<span className={styles.featureArrow} aria-hidden="true">-></span>
|
<span className={styles.featureArrow} aria-hidden="true">→</span>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
.section {
|
.section {
|
||||||
padding: 5.5rem 0 4.25rem;
|
padding: 6rem 0 4rem;
|
||||||
background: var(--ifm-background-color);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.sectionHead {
|
.sectionHead {
|
||||||
@@ -10,17 +9,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.sectionTag {
|
.sectionTag {
|
||||||
display: inline-flex;
|
display: inline-block;
|
||||||
align-items: center;
|
|
||||||
min-height: 28px;
|
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 750;
|
font-weight: 600;
|
||||||
letter-spacing: 0;
|
letter-spacing: 0.15em;
|
||||||
color: var(--ifm-color-primary);
|
color: var(--ifm-color-primary);
|
||||||
padding: 4px 12px;
|
padding: 4px 12px;
|
||||||
background: rgba(22, 93, 255, 0.08);
|
background: rgba(22, 93, 255, 0.08);
|
||||||
border: 1px solid rgba(22, 93, 255, 0.16);
|
border-radius: 4px;
|
||||||
border-radius: 8px;
|
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,10 +26,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.sectionTitle {
|
.sectionTitle {
|
||||||
font-size: 2.35rem;
|
font-size: clamp(1.8rem, 3vw, 2.5rem);
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
letter-spacing: 0;
|
letter-spacing: -0.02em;
|
||||||
font-weight: 750;
|
font-weight: 700;
|
||||||
margin: 0 0 1rem;
|
margin: 0 0 1rem;
|
||||||
color: var(--ifm-heading-color);
|
color: var(--ifm-heading-color);
|
||||||
}
|
}
|
||||||
@@ -55,9 +51,6 @@
|
|||||||
.section {
|
.section {
|
||||||
padding: 3.5rem 0 2rem;
|
padding: 3.5rem 0 2rem;
|
||||||
}
|
}
|
||||||
.sectionTitle {
|
|
||||||
font-size: 2rem;
|
|
||||||
}
|
|
||||||
.grid {
|
.grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
@@ -77,7 +70,7 @@
|
|||||||
padding: 1.75rem;
|
padding: 1.75rem;
|
||||||
background: var(--ifm-background-color);
|
background: var(--ifm-background-color);
|
||||||
border: 1px solid var(--ifm-color-emphasis-200);
|
border: 1px solid var(--ifm-color-emphasis-200);
|
||||||
border-radius: 8px;
|
border-radius: 12px;
|
||||||
transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease;
|
transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease;
|
||||||
text-decoration: none !important;
|
text-decoration: none !important;
|
||||||
color: inherit;
|
color: inherit;
|
||||||
@@ -85,7 +78,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.featureCardLink:hover {
|
.featureCardLink:hover {
|
||||||
transform: translateY(-2px);
|
transform: translateY(-3px);
|
||||||
border-color: var(--ifm-color-primary);
|
border-color: var(--ifm-color-primary);
|
||||||
box-shadow: 0 12px 30px -8px rgba(22, 93, 255, 0.18);
|
box-shadow: 0 12px 30px -8px rgba(22, 93, 255, 0.18);
|
||||||
color: inherit;
|
color: inherit;
|
||||||
@@ -106,26 +99,26 @@
|
|||||||
.iconWrap {
|
.iconWrap {
|
||||||
width: 48px;
|
width: 48px;
|
||||||
height: 48px;
|
height: 48px;
|
||||||
border-radius: 8px;
|
border-radius: 10px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
background: linear-gradient(135deg, rgba(22, 93, 255, 0.1) 0%, rgba(20, 201, 201, 0.12) 100%);
|
background: linear-gradient(135deg, rgba(22, 93, 255, 0.1) 0%, rgba(143, 75, 255, 0.08) 100%);
|
||||||
color: var(--ifm-color-primary);
|
color: var(--ifm-color-primary);
|
||||||
margin-bottom: 1.25rem;
|
margin-bottom: 1.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme='dark'] .iconWrap {
|
[data-theme='dark'] .iconWrap {
|
||||||
background: linear-gradient(135deg, rgba(96, 126, 255, 0.15) 0%, rgba(20, 201, 201, 0.12) 100%);
|
background: linear-gradient(135deg, rgba(96, 126, 255, 0.15) 0%, rgba(143, 75, 255, 0.12) 100%);
|
||||||
color: var(--ifm-color-primary-lighter);
|
color: var(--ifm-color-primary-lighter);
|
||||||
}
|
}
|
||||||
|
|
||||||
.featureTitle {
|
.featureTitle {
|
||||||
font-size: 1.15rem;
|
font-size: 1.15rem;
|
||||||
font-weight: 700;
|
font-weight: 600;
|
||||||
margin: 0 0 0.6rem;
|
margin: 0 0 0.6rem;
|
||||||
color: var(--ifm-heading-color);
|
color: var(--ifm-heading-color);
|
||||||
letter-spacing: 0;
|
letter-spacing: -0.01em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.featureDesc {
|
.featureDesc {
|
||||||
@@ -153,17 +146,3 @@
|
|||||||
.featureCardLink:hover .featureArrow {
|
.featureCardLink:hover .featureArrow {
|
||||||
transform: translateX(4px);
|
transform: translateX(4px);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
|
||||||
.sectionTitle {
|
|
||||||
font-size: 1.75rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
|
||||||
.featureCard,
|
|
||||||
.featureCardLink,
|
|
||||||
.featureArrow {
|
|
||||||
transition: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ export default function HomepageShowcase(): ReactNode {
|
|||||||
<p className={styles.captionDesc}>{current.description}</p>
|
<p className={styles.captionDesc}>{current.description}</p>
|
||||||
<Link to="/docs/getting-started/quick-start" className={styles.captionLink}>
|
<Link to="/docs/getting-started/quick-start" className={styles.captionLink}>
|
||||||
<Translate id="showcase.cta">Explore the docs</Translate>
|
<Translate id="showcase.cta">Explore the docs</Translate>
|
||||||
<span aria-hidden="true"> -></span>
|
<span aria-hidden="true"> →</span>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,14 +1,10 @@
|
|||||||
.section {
|
.section {
|
||||||
padding: 4.5rem 0 5.5rem;
|
padding: 4rem 0 6rem;
|
||||||
background:
|
background: linear-gradient(180deg, transparent 0%, rgba(22, 93, 255, 0.03) 100%);
|
||||||
linear-gradient(180deg, rgba(245, 247, 250, 0) 0%, rgba(245, 247, 250, 0.72) 100%),
|
|
||||||
var(--ifm-background-color);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme='dark'] .section {
|
[data-theme='dark'] .section {
|
||||||
background:
|
background: linear-gradient(180deg, transparent 0%, rgba(64, 128, 255, 0.04) 100%);
|
||||||
linear-gradient(180deg, rgba(15, 17, 21, 0) 0%, rgba(255, 255, 255, 0.03) 100%),
|
|
||||||
var(--ifm-background-color);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.sectionHead {
|
.sectionHead {
|
||||||
@@ -18,30 +14,26 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.sectionTag {
|
.sectionTag {
|
||||||
display: inline-flex;
|
display: inline-block;
|
||||||
align-items: center;
|
|
||||||
min-height: 28px;
|
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 750;
|
font-weight: 600;
|
||||||
letter-spacing: 0;
|
letter-spacing: 0.15em;
|
||||||
color: #0e7490;
|
color: #8f4bff;
|
||||||
padding: 4px 12px;
|
padding: 4px 12px;
|
||||||
background: rgba(20, 201, 201, 0.1);
|
background: rgba(143, 75, 255, 0.08);
|
||||||
border: 1px solid rgba(20, 201, 201, 0.2);
|
border-radius: 4px;
|
||||||
border-radius: 8px;
|
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme='dark'] .sectionTag {
|
[data-theme='dark'] .sectionTag {
|
||||||
background: rgba(20, 201, 201, 0.16);
|
background: rgba(143, 75, 255, 0.18);
|
||||||
color: #67e8f9;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.sectionTitle {
|
.sectionTitle {
|
||||||
font-size: 2.35rem;
|
font-size: clamp(1.8rem, 3vw, 2.5rem);
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
letter-spacing: 0;
|
letter-spacing: -0.02em;
|
||||||
font-weight: 750;
|
font-weight: 700;
|
||||||
margin: 0 0 1rem;
|
margin: 0 0 1rem;
|
||||||
color: var(--ifm-heading-color);
|
color: var(--ifm-heading-color);
|
||||||
}
|
}
|
||||||
@@ -57,39 +49,34 @@
|
|||||||
.tabs {
|
.tabs {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 6px;
|
gap: 8px;
|
||||||
margin-bottom: 2rem;
|
margin-bottom: 2rem;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
padding: 6px;
|
|
||||||
background: var(--ifm-color-emphasis-100);
|
|
||||||
border: 1px solid var(--ifm-color-emphasis-200);
|
|
||||||
border-radius: 8px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.tabBtn {
|
.tabBtn {
|
||||||
min-height: 40px;
|
|
||||||
padding: 8px 18px;
|
padding: 8px 18px;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: 1px solid transparent;
|
border: 1px solid var(--ifm-color-emphasis-300);
|
||||||
border-radius: 8px;
|
border-radius: 999px;
|
||||||
color: var(--ifm-color-content-secondary);
|
color: var(--ifm-color-content-secondary);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 650;
|
font-weight: 500;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: color 0.2s ease, border-color 0.2s ease, background 0.2s ease, box-shadow 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tabBtn:hover {
|
.tabBtn:hover {
|
||||||
color: var(--ifm-color-primary);
|
color: var(--ifm-color-primary);
|
||||||
background: var(--ifm-background-color);
|
border-color: var(--ifm-color-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tabBtnActive,
|
.tabBtnActive,
|
||||||
.tabBtnActive:hover {
|
.tabBtnActive:hover {
|
||||||
background: var(--ifm-background-color);
|
background: linear-gradient(90deg, #165dff 0%, #4080ff 100%);
|
||||||
color: var(--ifm-color-primary) !important;
|
color: #fff !important;
|
||||||
border-color: rgba(22, 93, 255, 0.18);
|
border-color: transparent;
|
||||||
box-shadow: 0 6px 16px rgba(22, 93, 255, 0.12);
|
box-shadow: 0 4px 14px rgba(22, 93, 255, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Stage */
|
/* Stage */
|
||||||
@@ -109,10 +96,10 @@
|
|||||||
|
|
||||||
.browser {
|
.browser {
|
||||||
background: var(--ifm-background-color);
|
background: var(--ifm-background-color);
|
||||||
border-radius: 8px;
|
border-radius: 12px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 24px 58px -22px rgba(22, 93, 255, 0.28),
|
0 30px 60px -20px rgba(22, 93, 255, 0.25),
|
||||||
0 0 0 1px var(--ifm-color-emphasis-200);
|
0 0 0 1px var(--ifm-color-emphasis-200);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,7 +137,7 @@
|
|||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 3px 14px;
|
padding: 3px 14px;
|
||||||
background: var(--ifm-background-color);
|
background: var(--ifm-background-color);
|
||||||
border-radius: 8px;
|
border-radius: 999px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--ifm-color-content-secondary);
|
color: var(--ifm-color-content-secondary);
|
||||||
font-family: 'SFMono-Regular', Menlo, monospace;
|
font-family: 'SFMono-Regular', Menlo, monospace;
|
||||||
@@ -182,8 +169,8 @@
|
|||||||
.captionTitle {
|
.captionTitle {
|
||||||
font-size: 1.7rem;
|
font-size: 1.7rem;
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
letter-spacing: 0;
|
letter-spacing: -0.02em;
|
||||||
font-weight: 750;
|
font-weight: 700;
|
||||||
margin: 0 0 1rem;
|
margin: 0 0 1rem;
|
||||||
color: var(--ifm-heading-color);
|
color: var(--ifm-heading-color);
|
||||||
}
|
}
|
||||||
@@ -199,49 +186,11 @@
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
min-height: 40px;
|
font-weight: 500;
|
||||||
padding: 0 12px;
|
|
||||||
border: 1px solid rgba(22, 93, 255, 0.18);
|
|
||||||
border-radius: 8px;
|
|
||||||
font-weight: 650;
|
|
||||||
color: var(--ifm-color-primary);
|
color: var(--ifm-color-primary);
|
||||||
text-decoration: none !important;
|
text-decoration: none !important;
|
||||||
transition: border-color 0.2s ease, background 0.2s ease;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.captionLink:hover {
|
.captionLink:hover {
|
||||||
color: var(--ifm-color-primary-dark);
|
color: var(--ifm-color-primary-dark);
|
||||||
background: rgba(22, 93, 255, 0.06);
|
|
||||||
border-color: var(--ifm-color-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 996px) {
|
|
||||||
.sectionTitle {
|
|
||||||
font-size: 2rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
|
||||||
.section {
|
|
||||||
padding: 3.25rem 0 4rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sectionTitle {
|
|
||||||
font-size: 1.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tabs {
|
|
||||||
justify-content: stretch;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tabBtn {
|
|
||||||
flex: 1 1 130px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
|
||||||
.tabBtn,
|
|
||||||
.captionLink {
|
|
||||||
transition: none;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,15 +16,14 @@
|
|||||||
/* Surfaces */
|
/* Surfaces */
|
||||||
--ifm-background-color: #ffffff;
|
--ifm-background-color: #ffffff;
|
||||||
--ifm-background-surface-color: #ffffff;
|
--ifm-background-surface-color: #ffffff;
|
||||||
--ifm-color-emphasis-100: #f5f7fa;
|
--ifm-color-emphasis-100: #f7f9fc;
|
||||||
--ifm-color-emphasis-200: #e5e6eb;
|
--ifm-color-emphasis-200: #eef1f6;
|
||||||
--ifm-color-emphasis-300: #c9cdd4;
|
--ifm-color-emphasis-300: #dde3ec;
|
||||||
--ifm-color-emphasis-400: #a9aeb8;
|
|
||||||
|
|
||||||
/* Typography */
|
/* Typography */
|
||||||
--ifm-font-family-base: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
|
--ifm-font-family-base: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
|
||||||
--ifm-font-family-monospace: 'SFMono-Regular', Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
|
--ifm-font-family-monospace: 'SFMono-Regular', Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
|
||||||
--ifm-heading-font-weight: 700;
|
--ifm-heading-font-weight: 600;
|
||||||
--ifm-code-font-size: 92%;
|
--ifm-code-font-size: 92%;
|
||||||
--ifm-h1-font-size: 2.25rem;
|
--ifm-h1-font-size: 2.25rem;
|
||||||
--ifm-h2-font-size: 1.75rem;
|
--ifm-h2-font-size: 1.75rem;
|
||||||
@@ -34,11 +33,10 @@
|
|||||||
--ifm-color-content: #1d2129;
|
--ifm-color-content: #1d2129;
|
||||||
--ifm-color-content-secondary: #4e5969;
|
--ifm-color-content-secondary: #4e5969;
|
||||||
--ifm-heading-color: #1d2129;
|
--ifm-heading-color: #1d2129;
|
||||||
--ifm-global-radius: 8px;
|
|
||||||
|
|
||||||
/* Navbar */
|
/* Navbar */
|
||||||
--ifm-navbar-height: 64px;
|
--ifm-navbar-height: 64px;
|
||||||
--ifm-navbar-background-color: rgba(255, 255, 255, 0.9);
|
--ifm-navbar-background-color: rgba(255, 255, 255, 0.82);
|
||||||
--ifm-navbar-link-color: #4e5969;
|
--ifm-navbar-link-color: #4e5969;
|
||||||
--ifm-navbar-link-hover-color: var(--ifm-color-primary);
|
--ifm-navbar-link-hover-color: var(--ifm-color-primary);
|
||||||
|
|
||||||
@@ -66,16 +64,15 @@
|
|||||||
|
|
||||||
--ifm-background-color: #0f1115;
|
--ifm-background-color: #0f1115;
|
||||||
--ifm-background-surface-color: #16181d;
|
--ifm-background-surface-color: #16181d;
|
||||||
--ifm-color-emphasis-100: #1d2129;
|
--ifm-color-emphasis-100: #1a1d23;
|
||||||
--ifm-color-emphasis-200: #272e3b;
|
--ifm-color-emphasis-200: #23272f;
|
||||||
--ifm-color-emphasis-300: #384252;
|
--ifm-color-emphasis-300: #2e343d;
|
||||||
--ifm-color-emphasis-400: #4e5969;
|
|
||||||
|
|
||||||
--ifm-color-content: #e6e9ef;
|
--ifm-color-content: #e6e9ef;
|
||||||
--ifm-color-content-secondary: #9aa3b2;
|
--ifm-color-content-secondary: #9aa3b2;
|
||||||
--ifm-heading-color: #f0f2f5;
|
--ifm-heading-color: #f0f2f5;
|
||||||
|
|
||||||
--ifm-navbar-background-color: rgba(15, 17, 21, 0.9);
|
--ifm-navbar-background-color: rgba(15, 17, 21, 0.82);
|
||||||
--ifm-navbar-link-color: #c9d1db;
|
--ifm-navbar-link-color: #c9d1db;
|
||||||
|
|
||||||
--ifm-menu-color: #c9d1db;
|
--ifm-menu-color: #c9d1db;
|
||||||
@@ -100,7 +97,7 @@
|
|||||||
|
|
||||||
.navbar__title {
|
.navbar__title {
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 0;
|
letter-spacing: -0.01em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.navbar__link {
|
.navbar__link {
|
||||||
@@ -108,26 +105,10 @@
|
|||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.navbar__link,
|
|
||||||
.button,
|
|
||||||
a {
|
|
||||||
transition: color 0.2s ease, background 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.button {
|
|
||||||
border-radius: 8px;
|
|
||||||
font-weight: 650;
|
|
||||||
}
|
|
||||||
|
|
||||||
:focus-visible {
|
|
||||||
outline: 2px solid var(--ifm-color-primary);
|
|
||||||
outline-offset: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Sidebar tweaks */
|
/* Sidebar tweaks */
|
||||||
.menu__link {
|
.menu__link {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
border-radius: 8px;
|
border-radius: 6px;
|
||||||
padding: 6px 10px;
|
padding: 6px 10px;
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
@@ -245,20 +226,9 @@ code {
|
|||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb:hover {
|
::-webkit-scrollbar-thumb:hover {
|
||||||
background: var(--ifm-color-emphasis-400);
|
background: var(--ifm-color-emphasis-400, #adb5bd);
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme='dark'] ::-webkit-scrollbar-thumb {
|
[data-theme='dark'] ::-webkit-scrollbar-thumb {
|
||||||
background: rgba(255, 255, 255, 0.15);
|
background: rgba(255, 255, 255, 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
|
||||||
*,
|
|
||||||
*::before,
|
|
||||||
*::after {
|
|
||||||
animation-duration: 0.01ms !important;
|
|
||||||
animation-iteration-count: 1 !important;
|
|
||||||
scroll-behavior: auto !important;
|
|
||||||
transition-duration: 0.01ms !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
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,42 +1,48 @@
|
|||||||
/* Hero */
|
/* ── Hero ───────────────────────────────────────────── */
|
||||||
.hero {
|
.hero {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
padding: 7rem 0 6rem;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
padding: 7rem 0 5.5rem;
|
background: var(--bx-hero-bg);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero::before {
|
.heroBg {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
content: "";
|
background:
|
||||||
pointer-events: none;
|
radial-gradient(circle at 15% 20%, rgba(104, 127, 255, 0.18) 0%, transparent 45%),
|
||||||
background-image:
|
radial-gradient(circle at 85% 70%, rgba(22, 93, 255, 0.15) 0%, transparent 50%),
|
||||||
linear-gradient(rgba(22, 93, 255, 0.06) 1px, transparent 1px),
|
linear-gradient(180deg, #f7f9ff 0%, #ffffff 100%);
|
||||||
linear-gradient(90deg, rgba(22, 93, 255, 0.06) 1px, transparent 1px);
|
z-index: 0;
|
||||||
background-size: 44px 44px;
|
|
||||||
mask-image: linear-gradient(180deg, rgba(0, 0, 0, 0.75), transparent 82%);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme='dark'] .hero {
|
[data-theme='dark'] .heroBg {
|
||||||
background:
|
background:
|
||||||
linear-gradient(180deg, rgba(64, 128, 255, 0.16) 0%, rgba(15, 17, 21, 0) 72%),
|
radial-gradient(circle at 15% 20%, rgba(96, 126, 255, 0.22) 0%, transparent 45%),
|
||||||
linear-gradient(90deg, rgba(20, 201, 201, 0.1) 0%, rgba(250, 173, 20, 0.08) 100%),
|
radial-gradient(circle at 85% 70%, rgba(118, 70, 255, 0.18) 0%, transparent 50%),
|
||||||
var(--ifm-background-color);
|
linear-gradient(180deg, #0f1115 0%, #0b0d10 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.heroInner {
|
.heroInner {
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr) minmax(420px, 0.9fr);
|
grid-template-columns: 1.1fr 1fr;
|
||||||
gap: 4rem;
|
gap: 4rem;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 996px) {
|
||||||
|
.hero {
|
||||||
|
padding: 4rem 0 3rem;
|
||||||
|
}
|
||||||
|
.heroInner {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 2.5rem;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.heroContent {
|
.heroContent {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -48,144 +54,137 @@
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
min-height: 32px;
|
padding: 4px 14px;
|
||||||
padding: 5px 12px;
|
background: rgba(22, 93, 255, 0.08);
|
||||||
color: var(--ifm-color-primary);
|
border: 1px solid rgba(22, 93, 255, 0.15);
|
||||||
background: rgba(22, 93, 255, 0.09);
|
border-radius: 999px;
|
||||||
border: 1px solid rgba(22, 93, 255, 0.2);
|
|
||||||
border-radius: 8px;
|
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 600;
|
color: var(--ifm-color-primary);
|
||||||
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme='dark'] .badge {
|
[data-theme='dark'] .badge {
|
||||||
background: rgba(64, 128, 255, 0.16);
|
background: rgba(96, 126, 255, 0.15);
|
||||||
border-color: rgba(64, 128, 255, 0.3);
|
border-color: rgba(96, 126, 255, 0.3);
|
||||||
color: var(--ifm-color-primary-lighter);
|
color: var(--ifm-color-primary-lighter);
|
||||||
}
|
}
|
||||||
|
|
||||||
.badgeDot {
|
.badgeDot {
|
||||||
width: 7px;
|
width: 6px;
|
||||||
height: 7px;
|
height: 6px;
|
||||||
background: #00b42a;
|
background: var(--ifm-color-primary);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
box-shadow: 0 0 0 4px rgba(0, 180, 42, 0.12);
|
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; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.heroTitle {
|
.heroTitle {
|
||||||
|
font-size: clamp(2.25rem, 4vw, 3.4rem);
|
||||||
|
line-height: 1.15;
|
||||||
|
letter-spacing: -0.025em;
|
||||||
|
font-weight: 700;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: var(--ifm-heading-color);
|
color: var(--ifm-heading-color);
|
||||||
font-size: 3.45rem;
|
|
||||||
font-weight: 750;
|
|
||||||
letter-spacing: 0;
|
|
||||||
line-height: 1.08;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.heroTitleAccent {
|
.heroTitleAccent {
|
||||||
display: block;
|
display: block;
|
||||||
margin-top: 8px;
|
background: linear-gradient(90deg, #4080ff 0%, #8f4bff 100%);
|
||||||
color: var(--ifm-color-primary);
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
margin-top: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.heroSubtitle {
|
.heroSubtitle {
|
||||||
max-width: 640px;
|
|
||||||
margin: 0;
|
|
||||||
color: var(--ifm-color-content-secondary);
|
|
||||||
font-size: 1.15rem;
|
font-size: 1.15rem;
|
||||||
line-height: 1.72;
|
line-height: 1.65;
|
||||||
|
color: var(--ifm-color-content-secondary);
|
||||||
|
max-width: 540px;
|
||||||
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.actions {
|
.actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
}
|
flex-wrap: wrap;
|
||||||
|
|
||||||
.primaryBtn,
|
|
||||||
.secondaryBtn {
|
|
||||||
min-height: 46px;
|
|
||||||
border-radius: 8px;
|
|
||||||
transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease, background 0.2s ease;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.primaryBtn {
|
.primaryBtn {
|
||||||
|
background: linear-gradient(90deg, #165dff 0%, #4080ff 100%);
|
||||||
|
border: none;
|
||||||
|
color: #fff;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 6px;
|
||||||
color: #fff;
|
font-weight: 600;
|
||||||
background: #165dff;
|
box-shadow: 0 6px 20px rgba(22, 93, 255, 0.3);
|
||||||
border: 1px solid #165dff;
|
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||||
box-shadow: 0 10px 24px rgba(22, 93, 255, 0.24);
|
|
||||||
font-weight: 650;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.primaryBtn:hover,
|
.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);
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 10px 25px rgba(22, 93, 255, 0.4);
|
||||||
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btnArrow {
|
.btnArrow {
|
||||||
transition: transform 0.2s ease;
|
transition: transform 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.primaryBtn:hover .btnArrow,
|
.primaryBtn:hover .btnArrow {
|
||||||
.primaryBtn:focus-visible .btnArrow {
|
transform: translateX(4px);
|
||||||
transform: translateX(3px);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.secondaryBtn {
|
.secondaryBtn {
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
color: var(--ifm-font-color-base);
|
|
||||||
background: var(--ifm-background-color);
|
background: var(--ifm-background-color);
|
||||||
border: 1px solid var(--ifm-color-emphasis-300);
|
border: 1px solid var(--ifm-color-emphasis-300);
|
||||||
font-weight: 600;
|
color: var(--ifm-font-color-base);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.secondaryBtn:hover,
|
.secondaryBtn:hover {
|
||||||
.secondaryBtn:focus-visible {
|
|
||||||
color: var(--ifm-color-primary);
|
|
||||||
border-color: var(--ifm-color-primary);
|
border-color: var(--ifm-color-primary);
|
||||||
|
color: var(--ifm-color-primary);
|
||||||
background: var(--ifm-background-color);
|
background: var(--ifm-background-color);
|
||||||
transform: translateY(-1px);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.metrics {
|
.metrics {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 1.5rem;
|
gap: 1.75rem;
|
||||||
|
padding-top: 1.5rem;
|
||||||
margin-top: 0.5rem;
|
margin-top: 0.5rem;
|
||||||
padding-top: 1.25rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.metric {
|
.metric {
|
||||||
display: flex;
|
display: flex;
|
||||||
min-width: 0;
|
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 4px;
|
gap: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.metricValue {
|
.metricValue {
|
||||||
|
font-size: 1.6rem;
|
||||||
|
font-weight: 700;
|
||||||
color: var(--ifm-heading-color);
|
color: var(--ifm-heading-color);
|
||||||
font-size: 1.35rem;
|
|
||||||
font-weight: 750;
|
|
||||||
letter-spacing: 0;
|
|
||||||
line-height: 1.1;
|
line-height: 1.1;
|
||||||
white-space: nowrap;
|
letter-spacing: -0.02em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.metricLabel {
|
.metricLabel {
|
||||||
color: var(--ifm-color-content-secondary);
|
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
color: var(--ifm-color-content-secondary);
|
||||||
letter-spacing: 0;
|
|
||||||
line-height: 1.35;
|
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.metricDivider {
|
.metricDivider {
|
||||||
@@ -194,277 +193,81 @@
|
|||||||
background: var(--ifm-color-emphasis-300);
|
background: var(--ifm-color-emphasis-300);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Product visual */
|
/* ── Code window (macOS-style) ─────────────────────── */
|
||||||
.heroVisual {
|
.heroCode {
|
||||||
display: grid;
|
position: relative;
|
||||||
gap: 1rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.consolePanel {
|
.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);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: rgba(255, 255, 255, 0.92);
|
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
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='dark'] .consolePanel {
|
[data-theme='light'] .codeWindow {
|
||||||
background: rgba(22, 24, 29, 0.9);
|
box-shadow: 0 20px 50px -10px rgba(22, 93, 255, 0.2), 0 0 0 1px rgba(22, 93, 255, 0.06);
|
||||||
border-color: rgba(255, 255, 255, 0.08);
|
|
||||||
box-shadow: 0 24px 60px rgba(0, 0, 0, 0.34);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.consoleHeader {
|
.codeHeader {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 1rem;
|
|
||||||
padding: 1.25rem;
|
|
||||||
border-bottom: 1px solid var(--ifm-color-emphasis-200);
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-theme='dark'] .consoleHeader {
|
|
||||||
border-bottom-color: rgba(255, 255, 255, 0.08);
|
|
||||||
}
|
|
||||||
|
|
||||||
.consoleHeader strong {
|
|
||||||
display: block;
|
|
||||||
margin-top: 4px;
|
|
||||||
color: var(--ifm-heading-color);
|
|
||||||
font-size: 1.2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.consoleEyebrow {
|
|
||||||
color: var(--ifm-color-content-secondary);
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 650;
|
|
||||||
letter-spacing: 0;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.consoleStatus {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
min-height: 28px;
|
gap: 6px;
|
||||||
padding: 4px 10px;
|
padding: 10px 14px;
|
||||||
color: #00a870;
|
background: #161f2e;
|
||||||
background: rgba(0, 180, 42, 0.1);
|
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
|
||||||
border: 1px solid rgba(0, 180, 42, 0.2);
|
|
||||||
border-radius: 8px;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.consoleGrid {
|
.codeDot {
|
||||||
display: grid;
|
width: 11px;
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
height: 11px;
|
||||||
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%;
|
border-radius: 50%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.timelineDotOk {
|
.codeDotRed { background: #ff5f56; }
|
||||||
background: #00b42a;
|
.codeDotYellow { background: #ffbd2e; }
|
||||||
}
|
.codeDotGreen { background: #27c93f; }
|
||||||
|
|
||||||
.timelineDotInfo {
|
.codeTitle {
|
||||||
background: #165dff;
|
margin-left: auto;
|
||||||
}
|
font-size: 11px;
|
||||||
|
color: #7b8696;
|
||||||
.timelineDotWarn {
|
letter-spacing: 0.05em;
|
||||||
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;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
.commandCard code {
|
.codeBody {
|
||||||
overflow-x: auto;
|
margin: 0;
|
||||||
color: #e5e7eb;
|
padding: 18px 20px;
|
||||||
background: transparent;
|
font-family: 'SFMono-Regular', Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
|
||||||
border: 0;
|
|
||||||
padding: 0;
|
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
white-space: nowrap;
|
line-height: 1.65;
|
||||||
|
color: #e1e7ef;
|
||||||
|
background: transparent;
|
||||||
|
overflow-x: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 996px) {
|
.codeBody code {
|
||||||
.hero {
|
background: transparent;
|
||||||
padding: 4.5rem 0 3.5rem;
|
padding: 0;
|
||||||
}
|
border: 0;
|
||||||
|
color: inherit;
|
||||||
.heroInner {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
gap: 2.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.heroTitle {
|
|
||||||
font-size: 2.45rem;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
.codePrompt {
|
||||||
.hero {
|
color: #4080ff;
|
||||||
padding: 3.75rem 0 2.75rem;
|
margin-right: 6px;
|
||||||
}
|
user-select: none;
|
||||||
|
|
||||||
.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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
.codeComment {
|
||||||
.primaryBtn,
|
color: #6e7889;
|
||||||
.secondaryBtn,
|
font-style: italic;
|
||||||
.btnArrow {
|
}
|
||||||
transition: none;
|
|
||||||
}
|
.codeString {
|
||||||
|
color: #82d1ff;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,34 +7,34 @@ import Layout from '@theme/Layout';
|
|||||||
import Heading from '@theme/Heading';
|
import Heading from '@theme/Heading';
|
||||||
import HomepageFeatures from '@site/src/components/HomepageFeatures';
|
import HomepageFeatures from '@site/src/components/HomepageFeatures';
|
||||||
import HomepageShowcase from '@site/src/components/HomepageShowcase';
|
import HomepageShowcase from '@site/src/components/HomepageShowcase';
|
||||||
import HomepageCommunity from '@site/src/components/HomepageCommunity';
|
|
||||||
|
|
||||||
import styles from './index.module.css';
|
import styles from './index.module.css';
|
||||||
|
|
||||||
function HomepageHeader() {
|
function HomepageHeader() {
|
||||||
return (
|
return (
|
||||||
<header className={styles.hero}>
|
<header className={styles.hero}>
|
||||||
|
<div className={styles.heroBg} aria-hidden="true" />
|
||||||
<div className={clsx('container', styles.heroInner)}>
|
<div className={clsx('container', styles.heroInner)}>
|
||||||
<div className={styles.heroContent}>
|
<div className={styles.heroContent}>
|
||||||
<div className={styles.badge}>
|
<div className={styles.badge}>
|
||||||
<span className={styles.badgeDot} />
|
<span className={styles.badgeDot} />
|
||||||
<Translate id="home.badge">Open-source backup control plane · v2.2.1</Translate>
|
<Translate id="home.badge">Open-source · v1.6.0</Translate>
|
||||||
</div>
|
</div>
|
||||||
<Heading as="h1" className={styles.heroTitle}>
|
<Heading as="h1" className={styles.heroTitle}>
|
||||||
<Translate id="home.title.part1">Backup orchestration</Translate>
|
<Translate id="home.title.part1">Self-hosted backup management</Translate>
|
||||||
<span className={styles.heroTitleAccent}>
|
<span className={styles.heroTitleAccent}>
|
||||||
<Translate id="home.title.part2">for self-hosted servers.</Translate>
|
<Translate id="home.title.part2">for every server.</Translate>
|
||||||
</span>
|
</span>
|
||||||
</Heading>
|
</Heading>
|
||||||
<p className={styles.heroSubtitle}>
|
<p className={styles.heroSubtitle}>
|
||||||
<Translate id="home.tagline">
|
<Translate id="home.tagline">
|
||||||
Run file, database, SAP HANA and remote-node backups from one clean console. Keep the control plane yours, keep the storage flexible.
|
One binary, one command. File / database / SAP HANA backups routed to 70+ storage backends.
|
||||||
</Translate>
|
</Translate>
|
||||||
</p>
|
</p>
|
||||||
<div className={styles.actions}>
|
<div className={styles.actions}>
|
||||||
<Link className={clsx('button button--primary button--lg', styles.primaryBtn)} to="/docs/getting-started/quick-start">
|
<Link className={clsx('button button--primary button--lg', styles.primaryBtn)} to="/docs/getting-started/quick-start">
|
||||||
<Translate id="home.getStarted">Get Started</Translate>
|
<Translate id="home.getStarted">Get Started</Translate>
|
||||||
<span className={styles.btnArrow} aria-hidden="true">-></span>
|
<span className={styles.btnArrow} aria-hidden="true">→</span>
|
||||||
</Link>
|
</Link>
|
||||||
<Link className={clsx('button button--lg', styles.secondaryBtn)} to="https://github.com/Awuqing/BackupX">
|
<Link className={clsx('button button--lg', styles.secondaryBtn)} to="https://github.com/Awuqing/BackupX">
|
||||||
<svg width="18" height="18" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true" style={{marginRight: 6}}>
|
<svg width="18" height="18" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true" style={{marginRight: 6}}>
|
||||||
@@ -52,9 +52,9 @@ function HomepageHeader() {
|
|||||||
</div>
|
</div>
|
||||||
<div className={styles.metricDivider} />
|
<div className={styles.metricDivider} />
|
||||||
<div className={styles.metric}>
|
<div className={styles.metric}>
|
||||||
<div className={styles.metricValue}>Agent</div>
|
<div className={styles.metricValue}>5</div>
|
||||||
<div className={styles.metricLabel}>
|
<div className={styles.metricLabel}>
|
||||||
<Translate id="home.metric.backupTypes">Remote execution</Translate>
|
<Translate id="home.metric.backupTypes">Backup types</Translate>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.metricDivider} />
|
<div className={styles.metricDivider} />
|
||||||
@@ -66,85 +66,29 @@ function HomepageHeader() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.heroVisual}>
|
<div className={styles.heroCode}>
|
||||||
<div className={styles.consolePanel}>
|
<div className={styles.codeWindow}>
|
||||||
<div className={styles.consoleHeader}>
|
<div className={styles.codeHeader}>
|
||||||
<div>
|
<span className={clsx(styles.codeDot, styles.codeDotRed)} />
|
||||||
<span className={styles.consoleEyebrow}>
|
<span className={clsx(styles.codeDot, styles.codeDotYellow)} />
|
||||||
<Translate id="home.visual.eyebrow">BackupX Console</Translate>
|
<span className={clsx(styles.codeDot, styles.codeDotGreen)} />
|
||||||
</span>
|
<span className={styles.codeTitle}>bash</span>
|
||||||
<strong>
|
|
||||||
<Translate id="home.visual.title">Operations overview</Translate>
|
|
||||||
</strong>
|
|
||||||
</div>
|
|
||||||
<span className={styles.consoleStatus}>
|
|
||||||
<Translate id="home.visual.status">Healthy</Translate>
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.consoleGrid}>
|
<pre className={styles.codeBody}>
|
||||||
<div>
|
<code>
|
||||||
<span className={styles.consoleLabel}>
|
<span className={styles.codeComment}># Docker one-liner</span>{'\n'}
|
||||||
<Translate id="home.visual.success">Success rate</Translate>
|
<span className={styles.codePrompt}>$</span> docker run -d --name backupx \{'\n'}
|
||||||
</span>
|
{' '}-p 8340:8340 \{'\n'}
|
||||||
<strong>99.4%</strong>
|
{' '}-v backupx-data:/app/data \{'\n'}
|
||||||
</div>
|
{' '}awuqing/backupx:latest{'\n'}
|
||||||
<div>
|
{'\n'}
|
||||||
<span className={styles.consoleLabel}>
|
<span className={styles.codeComment}># Open http://localhost:8340</span>{'\n'}
|
||||||
<Translate id="home.visual.nodes">Active nodes</Translate>
|
<span className={styles.codeComment}># Deploy an Agent on a remote host</span>{'\n'}
|
||||||
</span>
|
<span className={styles.codePrompt}>$</span> backupx agent \{'\n'}
|
||||||
<strong>12</strong>
|
{' '}--master <span className={styles.codeString}>http://master:8340</span> \{'\n'}
|
||||||
</div>
|
{' '}--token <span className={styles.codeString}><token></span>
|
||||||
<div>
|
</code>
|
||||||
<span className={styles.consoleLabel}>
|
</pre>
|
||||||
<Translate id="home.visual.targets">Storage targets</Translate>
|
|
||||||
</span>
|
|
||||||
<strong>8</strong>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className={styles.timeline}>
|
|
||||||
<div className={styles.timelineRow}>
|
|
||||||
<span className={styles.timelineDotOk} />
|
|
||||||
<div>
|
|
||||||
<strong>
|
|
||||||
<Translate id="home.visual.row1.title">PostgreSQL nightly</Translate>
|
|
||||||
</strong>
|
|
||||||
<span>
|
|
||||||
<Translate id="home.visual.row1.desc">Encrypted archive uploaded to S3</Translate>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<em>02:10</em>
|
|
||||||
</div>
|
|
||||||
<div className={styles.timelineRow}>
|
|
||||||
<span className={styles.timelineDotInfo} />
|
|
||||||
<div>
|
|
||||||
<strong>
|
|
||||||
<Translate id="home.visual.row2.title">SAP HANA snapshot</Translate>
|
|
||||||
</strong>
|
|
||||||
<span>
|
|
||||||
<Translate id="home.visual.row2.desc">Running on agent-shanghai-02</Translate>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<em>68%</em>
|
|
||||||
</div>
|
|
||||||
<div className={styles.timelineRow}>
|
|
||||||
<span className={styles.timelineDotWarn} />
|
|
||||||
<div>
|
|
||||||
<strong>
|
|
||||||
<Translate id="home.visual.row3.title">Retention cleanup</Translate>
|
|
||||||
</strong>
|
|
||||||
<span>
|
|
||||||
<Translate id="home.visual.row3.desc">Next run in 4 hours</Translate>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<em>queued</em>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className={styles.commandCard}>
|
|
||||||
<div className={styles.commandTitle}>
|
|
||||||
<Translate id="home.command.title">Start with Docker</Translate>
|
|
||||||
</div>
|
|
||||||
<code>docker run -d -p 8340:8340 awuqing/backupx:v2.2.1</code>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -156,13 +100,12 @@ export default function Home(): ReactNode {
|
|||||||
const {siteConfig} = useDocusaurusContext();
|
const {siteConfig} = useDocusaurusContext();
|
||||||
return (
|
return (
|
||||||
<Layout
|
<Layout
|
||||||
title={translate({id: 'home.pageTitle', message: 'Backup orchestration for self-hosted servers'})}
|
title={translate({id: 'home.pageTitle', message: 'Self-hosted backup management'})}
|
||||||
description={siteConfig.tagline}>
|
description={siteConfig.tagline}>
|
||||||
<HomepageHeader />
|
<HomepageHeader />
|
||||||
<main>
|
<main>
|
||||||
<HomepageFeatures />
|
<HomepageFeatures />
|
||||||
<HomepageShowcase />
|
<HomepageShowcase />
|
||||||
<HomepageCommunity />
|
|
||||||
</main>
|
</main>
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,39 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -7,8 +7,6 @@ require (
|
|||||||
github.com/glebarez/sqlite v1.11.0
|
github.com/glebarez/sqlite v1.11.0
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.0
|
github.com/golang-jwt/jwt/v5 v5.3.0
|
||||||
github.com/natefinch/lumberjack v2.0.0+incompatible
|
github.com/natefinch/lumberjack v2.0.0+incompatible
|
||||||
github.com/prometheus/client_golang v1.23.2
|
|
||||||
github.com/pquerna/otp v1.5.0
|
|
||||||
github.com/rclone/rclone v1.73.3
|
github.com/rclone/rclone v1.73.3
|
||||||
github.com/robfig/cron/v3 v3.0.1
|
github.com/robfig/cron/v3 v3.0.1
|
||||||
github.com/spf13/viper v1.20.0
|
github.com/spf13/viper v1.20.0
|
||||||
@@ -182,6 +180,8 @@ require (
|
|||||||
github.com/pkg/xattr v0.4.12 // indirect
|
github.com/pkg/xattr v0.4.12 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
|
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
|
||||||
|
github.com/pquerna/otp v1.5.0 // indirect
|
||||||
|
github.com/prometheus/client_golang v1.23.2 // indirect
|
||||||
github.com/prometheus/client_model v0.6.2 // indirect
|
github.com/prometheus/client_model v0.6.2 // indirect
|
||||||
github.com/prometheus/common v0.67.2 // indirect
|
github.com/prometheus/common v0.67.2 // indirect
|
||||||
github.com/prometheus/procfs v0.19.2 // indirect
|
github.com/prometheus/procfs v0.19.2 // indirect
|
||||||
|
|||||||
@@ -11,8 +11,6 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"backupx/server/internal/backup"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Agent 是 Agent 进程的主控制器。
|
// Agent 是 Agent 进程的主控制器。
|
||||||
@@ -133,12 +131,6 @@ func (a *Agent) pollAndHandleOnce(ctx context.Context) {
|
|||||||
a.handleRunTask(ctx, cmd)
|
a.handleRunTask(ctx, cmd)
|
||||||
case "list_dir":
|
case "list_dir":
|
||||||
a.handleListDir(ctx, cmd)
|
a.handleListDir(ctx, cmd)
|
||||||
case "restore_record":
|
|
||||||
a.handleRestoreRecord(ctx, cmd)
|
|
||||||
case "discover_db":
|
|
||||||
a.handleDiscoverDB(ctx, cmd)
|
|
||||||
case "delete_storage_object":
|
|
||||||
a.handleDeleteStorageObject(ctx, cmd)
|
|
||||||
default:
|
default:
|
||||||
msg := fmt.Sprintf("unknown command type: %s", cmd.Type)
|
msg := fmt.Sprintf("unknown command type: %s", cmd.Type)
|
||||||
log.Printf("[agent] %s", msg)
|
log.Printf("[agent] %s", msg)
|
||||||
@@ -166,83 +158,6 @@ func (a *Agent) handleRunTask(ctx context.Context, cmd *CommandPayload) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleRestoreRecord 处理 restore_record 命令
|
|
||||||
func (a *Agent) handleRestoreRecord(ctx context.Context, cmd *CommandPayload) {
|
|
||||||
var payload struct {
|
|
||||||
RestoreRecordID uint `json:"restoreRecordId"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(cmd.Payload, &payload); err != nil {
|
|
||||||
_ = a.client.SubmitCommandResult(ctx, cmd.ID, false, "invalid payload: "+err.Error(), nil)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if payload.RestoreRecordID == 0 {
|
|
||||||
_ = a.client.SubmitCommandResult(ctx, cmd.ID, false, "restoreRecordId is required", nil)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := a.executor.ExecuteRestore(ctx, payload.RestoreRecordID); err != nil {
|
|
||||||
_ = a.client.SubmitCommandResult(ctx, cmd.ID, false, err.Error(), nil)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
_ = a.client.SubmitCommandResult(ctx, cmd.ID, true, "", map[string]any{
|
|
||||||
"restoreRecordId": payload.RestoreRecordID,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleDeleteStorageObject 处理 delete_storage_object 命令:在 Agent 侧删除指定存储对象。
|
|
||||||
// 用于跨节点 local_disk 场景下的远程备份文件清理。
|
|
||||||
func (a *Agent) handleDeleteStorageObject(ctx context.Context, cmd *CommandPayload) {
|
|
||||||
var payload struct {
|
|
||||||
TargetType string `json:"targetType"`
|
|
||||||
TargetConfig map[string]any `json:"targetConfig"`
|
|
||||||
StoragePath string `json:"storagePath"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(cmd.Payload, &payload); err != nil {
|
|
||||||
_ = a.client.SubmitCommandResult(ctx, cmd.ID, false, "invalid payload: "+err.Error(), nil)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(payload.StoragePath) == "" {
|
|
||||||
_ = a.client.SubmitCommandResult(ctx, cmd.ID, false, "storagePath is required", nil)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
provider, err := a.executor.storageRegistry.Create(ctx, payload.TargetType, payload.TargetConfig)
|
|
||||||
if err != nil {
|
|
||||||
_ = a.client.SubmitCommandResult(ctx, cmd.ID, false, "create provider: "+err.Error(), nil)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := provider.Delete(ctx, payload.StoragePath); err != nil {
|
|
||||||
_ = a.client.SubmitCommandResult(ctx, cmd.ID, false, "delete object: "+err.Error(), nil)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
_ = a.client.SubmitCommandResult(ctx, cmd.ID, true, "", map[string]any{"deleted": true})
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleDiscoverDB 处理 discover_db 命令:在 Agent 本机执行 mysql/psql 列出数据库。
|
|
||||||
func (a *Agent) handleDiscoverDB(ctx context.Context, cmd *CommandPayload) {
|
|
||||||
var payload struct {
|
|
||||||
Type string `json:"type"`
|
|
||||||
Host string `json:"host"`
|
|
||||||
Port int `json:"port"`
|
|
||||||
User string `json:"user"`
|
|
||||||
Password string `json:"password"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(cmd.Payload, &payload); err != nil {
|
|
||||||
_ = a.client.SubmitCommandResult(ctx, cmd.ID, false, "invalid payload: "+err.Error(), nil)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
databases, err := backup.DiscoverDatabases(ctx, backup.NewOSCommandExecutor(), backup.DiscoverRequest{
|
|
||||||
Type: payload.Type,
|
|
||||||
Host: payload.Host,
|
|
||||||
Port: payload.Port,
|
|
||||||
User: payload.User,
|
|
||||||
Password: payload.Password,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
_ = a.client.SubmitCommandResult(ctx, cmd.ID, false, err.Error(), nil)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
_ = a.client.SubmitCommandResult(ctx, cmd.ID, true, "", map[string]any{"databases": databases})
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleListDir 处理 list_dir 命令(阶段四实现)
|
// handleListDir 处理 list_dir 命令(阶段四实现)
|
||||||
func (a *Agent) handleListDir(ctx context.Context, cmd *CommandPayload) {
|
func (a *Agent) handleListDir(ctx context.Context, cmd *CommandPayload) {
|
||||||
var payload struct {
|
var payload struct {
|
||||||
|
|||||||
@@ -158,52 +158,6 @@ func (c *MasterClient) UpdateRecord(ctx context.Context, recordID uint, update R
|
|||||||
return c.do(ctx, http.MethodPost, path, update, nil)
|
return c.do(ctx, http.MethodPost, path, update, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// RestoreSpec 与 service.AgentRestoreSpec 对齐
|
|
||||||
type RestoreSpec struct {
|
|
||||||
RestoreRecordID uint `json:"restoreRecordId"`
|
|
||||||
BackupRecordID uint `json:"backupRecordId"`
|
|
||||||
TaskID uint `json:"taskId"`
|
|
||||||
TaskName string `json:"taskName"`
|
|
||||||
Type string `json:"type"`
|
|
||||||
SourcePath string `json:"sourcePath,omitempty"`
|
|
||||||
SourcePaths []string `json:"sourcePaths,omitempty"`
|
|
||||||
DBHost string `json:"dbHost,omitempty"`
|
|
||||||
DBPort int `json:"dbPort,omitempty"`
|
|
||||||
DBUser string `json:"dbUser,omitempty"`
|
|
||||||
DBPassword string `json:"dbPassword,omitempty"`
|
|
||||||
DBName string `json:"dbName,omitempty"`
|
|
||||||
DBPath string `json:"dbPath,omitempty"`
|
|
||||||
ExtraConfig string `json:"extraConfig,omitempty"`
|
|
||||||
Compression string `json:"compression"`
|
|
||||||
Encrypt bool `json:"encrypt"`
|
|
||||||
Storage StorageTargetConfig `json:"storage"`
|
|
||||||
StoragePath string `json:"storagePath"`
|
|
||||||
FileName string `json:"fileName"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// RestoreUpdate 与 service.AgentRestoreUpdate 对齐
|
|
||||||
type RestoreUpdate struct {
|
|
||||||
Status string `json:"status,omitempty"`
|
|
||||||
ErrorMessage string `json:"errorMessage,omitempty"`
|
|
||||||
LogAppend string `json:"logAppend,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetRestoreSpec 拉取恢复规格
|
|
||||||
func (c *MasterClient) GetRestoreSpec(ctx context.Context, restoreRecordID uint) (*RestoreSpec, error) {
|
|
||||||
var spec RestoreSpec
|
|
||||||
path := fmt.Sprintf("/api/agent/restores/%d/spec", restoreRecordID)
|
|
||||||
if err := c.do(ctx, http.MethodGet, path, nil, &spec); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &spec, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpdateRestore 上报恢复记录的状态/日志
|
|
||||||
func (c *MasterClient) UpdateRestore(ctx context.Context, restoreRecordID uint, update RestoreUpdate) error {
|
|
||||||
path := fmt.Sprintf("/api/agent/restores/%d", restoreRecordID)
|
|
||||||
return c.do(ctx, http.MethodPost, path, update, nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
// do 是通用 HTTP 调用。所有 Agent API 都统一走 JSON + X-Agent-Token。
|
// do 是通用 HTTP 调用。所有 Agent API 都统一走 JSON + X-Agent-Token。
|
||||||
func (c *MasterClient) do(ctx context.Context, method, path string, body any, out any) error {
|
func (c *MasterClient) do(ctx context.Context, method, path string, body any, out any) error {
|
||||||
var reqBody io.Reader
|
var reqBody io.Reader
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ type Config struct {
|
|||||||
HeartbeatInterval string `yaml:"heartbeatInterval"`
|
HeartbeatInterval string `yaml:"heartbeatInterval"`
|
||||||
// PollInterval 命令轮询间隔,默认 5s
|
// PollInterval 命令轮询间隔,默认 5s
|
||||||
PollInterval string `yaml:"pollInterval"`
|
PollInterval string `yaml:"pollInterval"`
|
||||||
// TempDir 备份临时目录,默认 /var/lib/backupx-agent/tmp
|
// TempDir 备份临时目录,默认 /tmp/backupx-agent
|
||||||
TempDir string `yaml:"tempDir"`
|
TempDir string `yaml:"tempDir"`
|
||||||
// InsecureSkipTLSVerify 测试环境允许跳过 TLS 证书校验
|
// InsecureSkipTLSVerify 测试环境允许跳过 TLS 证书校验
|
||||||
InsecureSkipTLSVerify bool `yaml:"insecureSkipTlsVerify"`
|
InsecureSkipTLSVerify bool `yaml:"insecureSkipTlsVerify"`
|
||||||
@@ -98,7 +98,7 @@ func applyConfigDefaults(cfg *Config) (*Config, error) {
|
|||||||
cfg.PollInterval = "5s"
|
cfg.PollInterval = "5s"
|
||||||
}
|
}
|
||||||
if cfg.TempDir == "" {
|
if cfg.TempDir == "" {
|
||||||
cfg.TempDir = "/var/lib/backupx-agent/tmp"
|
cfg.TempDir = "/tmp/backupx-agent"
|
||||||
}
|
}
|
||||||
cfg.Master = strings.TrimRight(strings.TrimSpace(cfg.Master), "/")
|
cfg.Master = strings.TrimRight(strings.TrimSpace(cfg.Master), "/")
|
||||||
return cfg, nil
|
return cfg, nil
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ func TestLoadConfigDefaults(t *testing.T) {
|
|||||||
if cfg.HeartbeatInterval != "15s" || cfg.PollInterval != "5s" {
|
if cfg.HeartbeatInterval != "15s" || cfg.PollInterval != "5s" {
|
||||||
t.Errorf("default intervals not applied: %+v", cfg)
|
t.Errorf("default intervals not applied: %+v", cfg)
|
||||||
}
|
}
|
||||||
if cfg.TempDir != "/var/lib/backupx-agent/tmp" {
|
if cfg.TempDir != "/tmp/backupx-agent" {
|
||||||
t.Errorf("default tempdir: %q", cfg.TempDir)
|
t.Errorf("default tempdir: %q", cfg.TempDir)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
@@ -20,10 +19,10 @@ import (
|
|||||||
|
|
||||||
// Executor 负责在 Agent 本地执行命令。
|
// Executor 负责在 Agent 本地执行命令。
|
||||||
type Executor struct {
|
type Executor struct {
|
||||||
client *MasterClient
|
client *MasterClient
|
||||||
tempDir string
|
tempDir string
|
||||||
backupRegistry *backup.Registry
|
backupRegistry *backup.Registry
|
||||||
storageRegistry *storage.Registry
|
storageRegistry *storage.Registry
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewExecutor 构造执行器。预先初始化 backup runner 与 storage registry。
|
// NewExecutor 构造执行器。预先初始化 backup runner 与 storage registry。
|
||||||
@@ -60,11 +59,6 @@ func NewExecutor(client *MasterClient, tempDir string) *Executor {
|
|||||||
// 注意:Agent 当前不支持 Encrypt=true(加密密钥不下发到 Agent,避免密钥扩散)。
|
// 注意:Agent 当前不支持 Encrypt=true(加密密钥不下发到 Agent,避免密钥扩散)。
|
||||||
// 遇到启用加密的任务会向 Master 上报失败并返回错误。
|
// 遇到启用加密的任务会向 Master 上报失败并返回错误。
|
||||||
func (e *Executor) ExecuteRunTask(ctx context.Context, taskID, recordID uint) error {
|
func (e *Executor) ExecuteRunTask(ctx context.Context, taskID, recordID uint) error {
|
||||||
if err := e.ensureTempDir(); err != nil {
|
|
||||||
e.reportRecordFailure(ctx, recordID, err.Error())
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1) 拉取任务规格
|
// 1) 拉取任务规格
|
||||||
spec, err := e.client.GetTaskSpec(ctx, taskID)
|
spec, err := e.client.GetTaskSpec(ctx, taskID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -80,6 +74,10 @@ func (e *Executor) ExecuteRunTask(ctx context.Context, taskID, recordID uint) er
|
|||||||
|
|
||||||
// 2) 构造 backup.TaskSpec 并找对应 runner
|
// 2) 构造 backup.TaskSpec 并找对应 runner
|
||||||
startedAt := time.Now().UTC()
|
startedAt := time.Now().UTC()
|
||||||
|
if err := os.MkdirAll(e.tempDir, 0o755); err != nil {
|
||||||
|
e.reportRecordFailure(ctx, recordID, fmt.Sprintf("创建临时目录失败: %v", err))
|
||||||
|
return err
|
||||||
|
}
|
||||||
backupSpec := buildBackupTaskSpec(spec, startedAt, e.tempDir)
|
backupSpec := buildBackupTaskSpec(spec, startedAt, e.tempDir)
|
||||||
runner, err := e.backupRegistry.Runner(backupSpec.Type)
|
runner, err := e.backupRegistry.Runner(backupSpec.Type)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -186,8 +184,22 @@ func (e *Executor) reportRecordFailure(ctx context.Context, recordID uint, msg s
|
|||||||
|
|
||||||
// buildBackupTaskSpec 把 AgentTaskSpec 转换为 backup.TaskSpec。
|
// buildBackupTaskSpec 把 AgentTaskSpec 转换为 backup.TaskSpec。
|
||||||
func buildBackupTaskSpec(spec *TaskSpec, startedAt time.Time, tempDir string) backup.TaskSpec {
|
func buildBackupTaskSpec(spec *TaskSpec, startedAt time.Time, tempDir string) backup.TaskSpec {
|
||||||
sourcePaths := parseStringListField(spec.SourcePaths)
|
var sourcePaths []string
|
||||||
excludes := parseStringListField(spec.ExcludePatterns)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
return backup.TaskSpec{
|
return backup.TaskSpec{
|
||||||
ID: spec.TaskID,
|
ID: spec.TaskID,
|
||||||
Name: spec.Name,
|
Name: spec.Name,
|
||||||
@@ -210,37 +222,6 @@ func buildBackupTaskSpec(spec *TaskSpec, startedAt time.Time, tempDir string) ba
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *Executor) ensureTempDir() error {
|
|
||||||
if err := os.MkdirAll(e.tempDir, 0o755); err != nil {
|
|
||||||
return fmt.Errorf("create agent temp dir: %w", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseStringListField(value string) []string {
|
|
||||||
trimmed := strings.TrimSpace(value)
|
|
||||||
if trimmed == "" || trimmed == "[]" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
var jsonItems []string
|
|
||||||
if err := json.Unmarshal([]byte(trimmed), &jsonItems); err == nil {
|
|
||||||
return compactStringList(jsonItems)
|
|
||||||
}
|
|
||||||
return compactStringList(strings.FieldsFunc(trimmed, func(r rune) bool {
|
|
||||||
return r == '\n' || r == '\r'
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
func compactStringList(items []string) []string {
|
|
||||||
result := make([]string, 0, len(items))
|
|
||||||
for _, item := range items {
|
|
||||||
if trimmed := strings.TrimSpace(item); trimmed != "" {
|
|
||||||
result = append(result, trimmed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
// recordLogger 把 runner 日志回传到 Master 记录。
|
// recordLogger 把 runner 日志回传到 Master 记录。
|
||||||
// 实现 backup.LogWriter,每条日志追加到 record.log_content。
|
// 实现 backup.LogWriter,每条日志追加到 record.log_content。
|
||||||
type recordLogger struct {
|
type recordLogger struct {
|
||||||
@@ -257,181 +238,6 @@ func (l *recordLogger) WriteLine(message string) {
|
|||||||
_ = l.client.UpdateRecord(l.ctx, l.recordID, RecordUpdate{LogAppend: message + "\n"})
|
_ = l.client.UpdateRecord(l.ctx, l.recordID, RecordUpdate{LogAppend: message + "\n"})
|
||||||
}
|
}
|
||||||
|
|
||||||
// restoreLogger 把 runner 日志回传到 Master 恢复记录。
|
|
||||||
type restoreLogger struct {
|
|
||||||
ctx context.Context
|
|
||||||
client *MasterClient
|
|
||||||
restoreID uint
|
|
||||||
}
|
|
||||||
|
|
||||||
func newRestoreLogger(ctx context.Context, client *MasterClient, restoreID uint) *restoreLogger {
|
|
||||||
return &restoreLogger{ctx: ctx, client: client, restoreID: restoreID}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (l *restoreLogger) WriteLine(message string) {
|
|
||||||
_ = l.client.UpdateRestore(l.ctx, l.restoreID, RestoreUpdate{LogAppend: message + "\n"})
|
|
||||||
}
|
|
||||||
|
|
||||||
// DeleteStorageObject 在 Agent 本机上删除指定存储对象(供跨节点清理调用)。
|
|
||||||
func (e *Executor) DeleteStorageObject(ctx context.Context, targetType string, targetConfig map[string]any, storagePath string) error {
|
|
||||||
provider, err := e.storageRegistry.Create(ctx, targetType, targetConfig)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("create provider: %w", err)
|
|
||||||
}
|
|
||||||
return provider.Delete(ctx, storagePath)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ExecuteRestore 处理 restore_record 命令:拉规格 → 下载 → 解压 → 执行 runner.Restore → 上报结果。
|
|
||||||
//
|
|
||||||
// 与 ExecuteRunTask 对称,但方向相反:
|
|
||||||
// - 下载:通过 spec.Storage 创建 provider → Download(spec.StoragePath)
|
|
||||||
// - 解密:当前 Agent 不支持加密恢复(密钥未下发),spec.Encrypt=true 会直接失败
|
|
||||||
// - 执行:backup.Registry.Runner(spec.Type).Restore
|
|
||||||
// - 上报:通过 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))
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if spec.Encrypt {
|
|
||||||
msg := "Agent 不支持加密恢复(加密密钥仅在 Master 端持有)"
|
|
||||||
e.reportRestoreFailure(ctx, restoreRecordID, msg)
|
|
||||||
return fmt.Errorf("%s", msg)
|
|
||||||
}
|
|
||||||
e.appendRestoreLog(ctx, restoreRecordID, fmt.Sprintf("[agent] 开始恢复 %s (type=%s)\n", spec.TaskName, spec.Type))
|
|
||||||
|
|
||||||
tmpDir, err := os.MkdirTemp(e.tempDir, "restore-*")
|
|
||||||
if err != nil {
|
|
||||||
e.reportRestoreFailure(ctx, restoreRecordID, fmt.Sprintf("创建恢复临时目录失败: %v", err))
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer os.RemoveAll(tmpDir)
|
|
||||||
|
|
||||||
// 1) 创建 storage provider
|
|
||||||
var rawConfig map[string]any
|
|
||||||
if len(spec.Storage.Config) > 0 {
|
|
||||||
if err := jsonUnmarshalMap(spec.Storage.Config, &rawConfig); err != nil {
|
|
||||||
e.reportRestoreFailure(ctx, restoreRecordID, fmt.Sprintf("解析存储配置失败: %v", err))
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
provider, err := e.storageRegistry.Create(ctx, spec.Storage.Type, rawConfig)
|
|
||||||
if err != nil {
|
|
||||||
e.reportRestoreFailure(ctx, restoreRecordID, fmt.Sprintf("创建存储客户端失败: %v", err))
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2) 下载
|
|
||||||
fileName := spec.FileName
|
|
||||||
if strings.TrimSpace(fileName) == "" {
|
|
||||||
fileName = filepath.Base(spec.StoragePath)
|
|
||||||
}
|
|
||||||
artifactPath := filepath.Join(tmpDir, filepath.Base(fileName))
|
|
||||||
e.appendRestoreLog(ctx, restoreRecordID, fmt.Sprintf("[agent] 下载备份文件 %s\n", spec.StoragePath))
|
|
||||||
reader, err := provider.Download(ctx, spec.StoragePath)
|
|
||||||
if err != nil {
|
|
||||||
e.reportRestoreFailure(ctx, restoreRecordID, fmt.Sprintf("下载备份失败: %v", err))
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := writeReaderToLocal(artifactPath, reader); err != nil {
|
|
||||||
e.reportRestoreFailure(ctx, restoreRecordID, fmt.Sprintf("写入备份文件失败: %v", err))
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3) 解压(Agent 不支持加密,遇到 .enc 会直接失败)
|
|
||||||
preparedPath := artifactPath
|
|
||||||
if strings.HasSuffix(strings.ToLower(preparedPath), ".enc") {
|
|
||||||
msg := "检测到加密后缀,Agent 不支持加密恢复"
|
|
||||||
e.reportRestoreFailure(ctx, restoreRecordID, msg)
|
|
||||||
return fmt.Errorf("%s", msg)
|
|
||||||
}
|
|
||||||
if strings.HasSuffix(strings.ToLower(preparedPath), ".gz") {
|
|
||||||
e.appendRestoreLog(ctx, restoreRecordID, "[agent] 解压 gzip 压缩\n")
|
|
||||||
decompressed, err := compress.GunzipFile(preparedPath)
|
|
||||||
if err != nil {
|
|
||||||
e.reportRestoreFailure(ctx, restoreRecordID, fmt.Sprintf("解压失败: %v", err))
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
preparedPath = decompressed
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4) 运行 runner.Restore
|
|
||||||
taskSpec := buildRestoreBackupTaskSpec(spec, time.Now().UTC(), tmpDir)
|
|
||||||
runner, err := e.backupRegistry.Runner(taskSpec.Type)
|
|
||||||
if err != nil {
|
|
||||||
e.reportRestoreFailure(ctx, restoreRecordID, fmt.Sprintf("不支持的备份类型: %v", err))
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
logger := newRestoreLogger(ctx, e.client, restoreRecordID)
|
|
||||||
if err := runner.Restore(ctx, taskSpec, preparedPath, logger); err != nil {
|
|
||||||
e.reportRestoreFailure(ctx, restoreRecordID, err.Error())
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5) 上报成功
|
|
||||||
return e.client.UpdateRestore(ctx, restoreRecordID, RestoreUpdate{
|
|
||||||
Status: "success",
|
|
||||||
LogAppend: "[agent] 恢复执行完成\n",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *Executor) appendRestoreLog(ctx context.Context, restoreID uint, line string) {
|
|
||||||
_ = e.client.UpdateRestore(ctx, restoreID, RestoreUpdate{LogAppend: line})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *Executor) reportRestoreFailure(ctx context.Context, restoreID uint, msg string) {
|
|
||||||
_ = e.client.UpdateRestore(ctx, restoreID, RestoreUpdate{
|
|
||||||
Status: "failed",
|
|
||||||
ErrorMessage: msg,
|
|
||||||
LogAppend: fmt.Sprintf("[agent] 错误: %s\n", msg),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// buildRestoreBackupTaskSpec 把 RestoreSpec 转成 backup.TaskSpec。
|
|
||||||
func buildRestoreBackupTaskSpec(spec *RestoreSpec, startedAt time.Time, tempDir string) backup.TaskSpec {
|
|
||||||
return backup.TaskSpec{
|
|
||||||
ID: spec.TaskID,
|
|
||||||
Name: spec.TaskName,
|
|
||||||
Type: spec.Type,
|
|
||||||
SourcePath: spec.SourcePath,
|
|
||||||
SourcePaths: spec.SourcePaths,
|
|
||||||
ExcludePatterns: nil,
|
|
||||||
Database: backup.DatabaseSpec{
|
|
||||||
Host: spec.DBHost,
|
|
||||||
Port: spec.DBPort,
|
|
||||||
User: spec.DBUser,
|
|
||||||
Password: spec.DBPassword,
|
|
||||||
Path: spec.DBPath,
|
|
||||||
Names: splitCommaOrNewline(spec.DBName),
|
|
||||||
},
|
|
||||||
Compression: spec.Compression,
|
|
||||||
Encrypt: spec.Encrypt,
|
|
||||||
StartedAt: startedAt,
|
|
||||||
TempDir: tempDir,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// writeReaderToLocal 把 reader 写到本地文件(Agent 侧工具函数)。
|
|
||||||
func writeReaderToLocal(targetPath string, reader io.ReadCloser) error {
|
|
||||||
defer reader.Close()
|
|
||||||
if err := os.MkdirAll(filepath.Dir(targetPath), 0o755); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
file, err := os.Create(targetPath)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer file.Close()
|
|
||||||
_, err = io.Copy(file, reader)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// 辅助函数
|
// 辅助函数
|
||||||
|
|
||||||
func computeFileSHA256(path string) (string, error) {
|
func computeFileSHA256(path string) (string, error) {
|
||||||
|
|||||||
@@ -1,34 +0,0 @@
|
|||||||
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,7 +5,6 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// DirEntry Agent 返回给 Master 的目录项。
|
// DirEntry Agent 返回给 Master 的目录项。
|
||||||
@@ -18,8 +17,8 @@ type DirEntry struct {
|
|||||||
|
|
||||||
// listLocalDir 列出 Agent 所在机器的指定路径。
|
// listLocalDir 列出 Agent 所在机器的指定路径。
|
||||||
func listLocalDir(path string) ([]DirEntry, error) {
|
func listLocalDir(path string) ([]DirEntry, error) {
|
||||||
cleaned := filepath.Clean(strings.TrimSpace(path))
|
cleaned := filepath.Clean(path)
|
||||||
if strings.TrimSpace(path) == "" || cleaned == "." {
|
if cleaned == "" {
|
||||||
cleaned = "/"
|
cleaned = "/"
|
||||||
}
|
}
|
||||||
entries, err := os.ReadDir(cleaned)
|
entries, err := os.ReadDir(cleaned)
|
||||||
|
|||||||
@@ -36,21 +36,6 @@ func TestListLocalDir(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestListLocalDirEmptyPathUsesRoot(t *testing.T) {
|
|
||||||
entries, err := listLocalDir("")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("list root: %v", err)
|
|
||||||
}
|
|
||||||
if len(entries) == 0 {
|
|
||||||
t.Fatalf("expected root entries")
|
|
||||||
}
|
|
||||||
for _, entry := range entries {
|
|
||||||
if !filepath.IsAbs(entry.Path) {
|
|
||||||
t.Fatalf("entry path should be absolute: %+v", entry)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSplitCommaOrNewline(t *testing.T) {
|
func TestSplitCommaOrNewline(t *testing.T) {
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
in string
|
in string
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import (
|
|||||||
"backupx/server/internal/database"
|
"backupx/server/internal/database"
|
||||||
aphttp "backupx/server/internal/http"
|
aphttp "backupx/server/internal/http"
|
||||||
"backupx/server/internal/logger"
|
"backupx/server/internal/logger"
|
||||||
"backupx/server/internal/metrics"
|
|
||||||
"backupx/server/internal/notify"
|
"backupx/server/internal/notify"
|
||||||
"backupx/server/internal/repository"
|
"backupx/server/internal/repository"
|
||||||
"backupx/server/internal/scheduler"
|
"backupx/server/internal/scheduler"
|
||||||
@@ -60,9 +59,9 @@ func New(ctx context.Context, cfg config.Config, version string) (*Application,
|
|||||||
|
|
||||||
jwtManager := security.NewJWTManager(resolvedSecurity.JWTSecret, config.MustJWTDuration(cfg.Security))
|
jwtManager := security.NewJWTManager(resolvedSecurity.JWTSecret, config.MustJWTDuration(cfg.Security))
|
||||||
rateLimiter := security.NewLoginRateLimiter(5, time.Minute)
|
rateLimiter := security.NewLoginRateLimiter(5, time.Minute)
|
||||||
configCipher := codec.NewConfigCipher(resolvedSecurity.EncryptionKey)
|
authService := service.NewAuthService(userRepo, systemConfigRepo, jwtManager, rateLimiter)
|
||||||
authService := service.NewAuthService(userRepo, systemConfigRepo, jwtManager, rateLimiter, configCipher)
|
|
||||||
systemService := service.NewSystemService(cfg, version, time.Now().UTC())
|
systemService := service.NewSystemService(cfg, version, time.Now().UTC())
|
||||||
|
configCipher := codec.NewConfigCipher(resolvedSecurity.EncryptionKey)
|
||||||
storageRegistry := storage.NewRegistry(
|
storageRegistry := storage.NewRegistry(
|
||||||
storageRclone.NewLocalDiskFactory(),
|
storageRclone.NewLocalDiskFactory(),
|
||||||
storageRclone.NewS3Factory(),
|
storageRclone.NewS3Factory(),
|
||||||
@@ -81,13 +80,11 @@ func New(ctx context.Context, cfg config.Config, version string) (*Application,
|
|||||||
storageTargetService.SetBackupRecordRepository(backupRecordRepo)
|
storageTargetService.SetBackupRecordRepository(backupRecordRepo)
|
||||||
backupTaskService := service.NewBackupTaskService(backupTaskRepo, storageTargetRepo, configCipher)
|
backupTaskService := service.NewBackupTaskService(backupTaskRepo, storageTargetRepo, configCipher)
|
||||||
backupTaskService.SetRecordsAndStorage(backupRecordRepo, storageRegistry)
|
backupTaskService.SetRecordsAndStorage(backupRecordRepo, storageRegistry)
|
||||||
// nodeRepo 在下方 Cluster 节点管理区块才实例化,这里延后注入
|
|
||||||
backupRunnerRegistry := backup.NewRegistry(backup.NewFileRunner(), backup.NewSQLiteRunner(), backup.NewMySQLRunner(nil), backup.NewPostgreSQLRunner(nil), backup.NewSAPHANARunner(nil))
|
backupRunnerRegistry := backup.NewRegistry(backup.NewFileRunner(), backup.NewSQLiteRunner(), backup.NewMySQLRunner(nil), backup.NewPostgreSQLRunner(nil), backup.NewSAPHANARunner(nil))
|
||||||
logHub := backup.NewLogHub()
|
logHub := backup.NewLogHub()
|
||||||
retentionService := backupretention.NewService(backupRecordRepo)
|
retentionService := backupretention.NewService(backupRecordRepo)
|
||||||
notifyRegistry := notify.NewRegistry(notify.NewEmailNotifier(), notify.NewWebhookNotifier(), notify.NewTelegramNotifier())
|
notifyRegistry := notify.NewRegistry(notify.NewEmailNotifier(), notify.NewWebhookNotifier(), notify.NewTelegramNotifier())
|
||||||
notificationService := service.NewNotificationService(notificationRepo, notifyRegistry, configCipher)
|
notificationService := service.NewNotificationService(notificationRepo, notifyRegistry, configCipher)
|
||||||
authService.SetNotificationService(notificationService)
|
|
||||||
// 初始化 rclone 传输配置(重试 + 带宽限制)
|
// 初始化 rclone 传输配置(重试 + 带宽限制)
|
||||||
rcloneCtx := storageRclone.ConfiguredContext(ctx, storageRclone.TransferConfig{
|
rcloneCtx := storageRclone.ConfiguredContext(ctx, storageRclone.TransferConfig{
|
||||||
LowLevelRetries: cfg.Backup.Retries,
|
LowLevelRetries: cfg.Backup.Retries,
|
||||||
@@ -100,9 +97,6 @@ func New(ctx context.Context, cfg config.Config, version string) (*Application,
|
|||||||
backupTaskService.SetScheduler(schedulerService)
|
backupTaskService.SetScheduler(schedulerService)
|
||||||
// 审计日志注入延迟到 auditService 创建后(见下方)
|
// 审计日志注入延迟到 auditService 创建后(见下方)
|
||||||
backupRecordService := service.NewBackupRecordService(backupRecordRepo, backupExecutionService, logHub)
|
backupRecordService := service.NewBackupRecordService(backupRecordRepo, backupExecutionService, logHub)
|
||||||
// 恢复服务:使用独立 LogHub 避免恢复记录与备份记录 ID 命名空间冲突
|
|
||||||
restoreRecordRepo := repository.NewRestoreRecordRepository(db)
|
|
||||||
restoreLogHub := backup.NewLogHub()
|
|
||||||
dashboardService := service.NewDashboardService(backupTaskRepo, backupRecordRepo, storageTargetRepo)
|
dashboardService := service.NewDashboardService(backupTaskRepo, backupRecordRepo, storageTargetRepo)
|
||||||
settingsService := service.NewSettingsService(systemConfigRepo)
|
settingsService := service.NewSettingsService(systemConfigRepo)
|
||||||
|
|
||||||
@@ -111,16 +105,12 @@ func New(ctx context.Context, cfg config.Config, version string) (*Application,
|
|||||||
auditService := service.NewAuditService(auditLogRepo)
|
auditService := service.NewAuditService(auditLogRepo)
|
||||||
authService.SetAuditService(auditService)
|
authService.SetAuditService(auditService)
|
||||||
schedulerService.SetAuditRecorder(auditService)
|
schedulerService.SetAuditRecorder(auditService)
|
||||||
// 审计日志外输:启动时用当前 settings 初始化 webhook,后续前端修改立即生效
|
|
||||||
settingsService.SetAuditWebhookConfigurer(ctx, auditService)
|
|
||||||
|
|
||||||
// Database discovery(集群依赖在 agentService 创建后注入)
|
// Database discovery
|
||||||
databaseDiscoveryService := service.NewDatabaseDiscoveryService(backup.NewOSCommandExecutor())
|
databaseDiscoveryService := service.NewDatabaseDiscoveryService(backup.NewOSCommandExecutor())
|
||||||
|
|
||||||
// Cluster: Node management
|
// Cluster: Node management
|
||||||
nodeRepo := repository.NewNodeRepository(db)
|
nodeRepo := repository.NewNodeRepository(db)
|
||||||
backupTaskService.SetNodeRepository(nodeRepo)
|
|
||||||
schedulerService.SetNodeRepository(nodeRepo)
|
|
||||||
nodeService := service.NewNodeService(nodeRepo, version)
|
nodeService := service.NewNodeService(nodeRepo, version)
|
||||||
nodeService.SetTaskRepository(backupTaskRepo)
|
nodeService.SetTaskRepository(backupTaskRepo)
|
||||||
if err := nodeService.EnsureLocalNode(ctx); err != nil {
|
if err := nodeService.EnsureLocalNode(ctx); err != nil {
|
||||||
@@ -132,7 +122,6 @@ func New(ctx context.Context, cfg config.Config, version string) (*Application,
|
|||||||
// Agent 协议服务:命令队列 + 任务下发 + 记录上报
|
// Agent 协议服务:命令队列 + 任务下发 + 记录上报
|
||||||
agentCmdRepo := repository.NewAgentCommandRepository(db)
|
agentCmdRepo := repository.NewAgentCommandRepository(db)
|
||||||
agentService := service.NewAgentService(nodeRepo, backupTaskRepo, backupRecordRepo, storageTargetRepo, agentCmdRepo, configCipher)
|
agentService := service.NewAgentService(nodeRepo, backupTaskRepo, backupRecordRepo, storageTargetRepo, agentCmdRepo, configCipher)
|
||||||
agentService.SetRestoreRepository(restoreRecordRepo)
|
|
||||||
agentService.StartCommandTimeoutMonitor(ctx, 30*time.Second, 10*time.Minute)
|
agentService.StartCommandTimeoutMonitor(ctx, 30*time.Second, 10*time.Minute)
|
||||||
|
|
||||||
// 一键部署:install token service + 后台 GC
|
// 一键部署:install token service + 后台 GC
|
||||||
@@ -144,141 +133,30 @@ func New(ctx context.Context, cfg config.Config, version string) (*Application,
|
|||||||
backupExecutionService.SetClusterDependencies(nodeRepo, agentService)
|
backupExecutionService.SetClusterDependencies(nodeRepo, agentService)
|
||||||
// 启用远程目录浏览:NodeService 通过 AgentService 做同步 RPC
|
// 启用远程目录浏览:NodeService 通过 AgentService 做同步 RPC
|
||||||
nodeService.SetAgentRPC(agentService)
|
nodeService.SetAgentRPC(agentService)
|
||||||
// 启用远程数据库发现:远程节点任务配置时 DatabasePicker 拿到的是节点视角的 DB 列表
|
|
||||||
databaseDiscoveryService.SetClusterDependencies(nodeRepo, agentService)
|
|
||||||
|
|
||||||
// 恢复服务:集群感知(本地/远程路由),依赖 agentService 入队
|
|
||||||
restoreService := service.NewRestoreService(
|
|
||||||
restoreRecordRepo,
|
|
||||||
backupRecordRepo,
|
|
||||||
backupTaskRepo,
|
|
||||||
storageTargetRepo,
|
|
||||||
nodeRepo,
|
|
||||||
storageRegistry,
|
|
||||||
backupRunnerRegistry,
|
|
||||||
restoreLogHub,
|
|
||||||
configCipher,
|
|
||||||
agentService,
|
|
||||||
cfg.Backup.TempDir,
|
|
||||||
cfg.Backup.MaxConcurrent,
|
|
||||||
)
|
|
||||||
|
|
||||||
// 验证服务:定期校验备份可恢复性(企业合规刚需)
|
|
||||||
verificationRecordRepo := repository.NewVerificationRecordRepository(db)
|
|
||||||
verifyLogHub := backup.NewLogHub()
|
|
||||||
verificationService := service.NewVerificationService(
|
|
||||||
verificationRecordRepo,
|
|
||||||
backupRecordRepo,
|
|
||||||
backupTaskRepo,
|
|
||||||
storageTargetRepo,
|
|
||||||
nodeRepo,
|
|
||||||
storageRegistry,
|
|
||||||
verifyLogHub,
|
|
||||||
configCipher,
|
|
||||||
cfg.Backup.TempDir,
|
|
||||||
cfg.Backup.MaxConcurrent,
|
|
||||||
)
|
|
||||||
// 验证失败通知:通过 NotificationService 的事件总线派发 verify_failed
|
|
||||||
verificationService.SetNotifier(service.NewVerificationEventNotifier(notificationService))
|
|
||||||
// 恢复完成/失败事件派发(restore_success / restore_failed)
|
|
||||||
restoreService.SetEventDispatcher(notificationService)
|
|
||||||
// 调度器接入验证演练 cron
|
|
||||||
schedulerService.SetVerifyRunner(verificationService)
|
|
||||||
|
|
||||||
// 用户管理与 API Key 服务(企业级 RBAC)
|
|
||||||
userService := service.NewUserService(userRepo)
|
|
||||||
apiKeyRepo := repository.NewApiKeyRepository(db)
|
|
||||||
apiKeyService := service.NewApiKeyService(apiKeyRepo)
|
|
||||||
|
|
||||||
// SLA 后台扫描:每 15 分钟扫描违约任务,同任务 6 小时内不重复派发
|
|
||||||
dashboardService.StartSLAMonitor(ctx, notificationService, 15*time.Minute, 6*time.Hour)
|
|
||||||
// 存储目标健康扫描:每 5 分钟测试启用目标,掉线即告警
|
|
||||||
storageTargetService.StartHealthMonitor(ctx, notificationService, 5*time.Minute)
|
|
||||||
|
|
||||||
// 备份复制服务(3-2-1 规则核心)
|
|
||||||
replicationRecordRepo := repository.NewReplicationRecordRepository(db)
|
|
||||||
replicationService := service.NewReplicationService(
|
|
||||||
replicationRecordRepo, backupRecordRepo, storageTargetRepo,
|
|
||||||
nodeRepo, storageRegistry, configCipher,
|
|
||||||
cfg.Backup.TempDir, cfg.Backup.MaxConcurrent,
|
|
||||||
)
|
|
||||||
replicationService.SetEventDispatcher(notificationService)
|
|
||||||
backupExecutionService.SetReplicationTrigger(replicationService)
|
|
||||||
// 备份成功后触发下游依赖任务(任务依赖链工作流)
|
|
||||||
backupExecutionService.SetDependentsResolver(backupTaskService)
|
|
||||||
|
|
||||||
// 任务模板(批量创建)
|
|
||||||
taskTemplateRepo := repository.NewTaskTemplateRepository(db)
|
|
||||||
taskTemplateService := service.NewTaskTemplateService(taskTemplateRepo, backupTaskService)
|
|
||||||
|
|
||||||
// 任务配置导入/导出(JSON,集群迁移 & 灾备)
|
|
||||||
taskExportService := service.NewTaskExportService(backupTaskService, backupTaskRepo, storageTargetRepo, nodeRepo)
|
|
||||||
|
|
||||||
// 全局搜索(跨任务/存储/节点/最近记录)
|
|
||||||
searchService := service.NewSearchService(backupTaskRepo, backupRecordRepo, storageTargetRepo, nodeRepo)
|
|
||||||
|
|
||||||
// 实时事件广播器(SSE 推送给前端 Dashboard)
|
|
||||||
// 注入 notification 后,每次 DispatchEvent 同时 broadcast 到所有 SSE 订阅者
|
|
||||||
eventBroadcaster := service.NewEventBroadcaster()
|
|
||||||
notificationService.SetBroadcaster(eventBroadcaster)
|
|
||||||
|
|
||||||
// 集群版本监控:每 30 分钟扫描,节点 24 小时内只告警一次
|
|
||||||
clusterVersionMonitor := service.NewClusterVersionMonitor(nodeRepo, version)
|
|
||||||
clusterVersionMonitor.SetEventDispatcher(notificationService)
|
|
||||||
clusterVersionMonitor.Start(ctx, 30*time.Minute, 24*time.Hour)
|
|
||||||
|
|
||||||
// Dashboard 集群概览依赖注入
|
|
||||||
dashboardService.SetClusterDependencies(nodeRepo, version)
|
|
||||||
|
|
||||||
// Prometheus 指标采集:Counter/Histogram 由业务服务实时写入;
|
|
||||||
// Gauge 类(存储用量、节点在线、SLA 违约)由 Collector 每 30s 异步刷新,
|
|
||||||
// 避免 /metrics 请求路径做慢 IO。
|
|
||||||
appMetrics := metrics.New(version)
|
|
||||||
backupExecutionService.SetMetrics(appMetrics)
|
|
||||||
restoreService.SetMetrics(appMetrics)
|
|
||||||
verificationService.SetMetrics(appMetrics)
|
|
||||||
replicationService.SetMetrics(appMetrics)
|
|
||||||
metricsCollector := metrics.NewCollector(
|
|
||||||
appMetrics,
|
|
||||||
metrics.NewRepoSource(storageTargetRepo, backupRecordRepo, nodeRepo, backupTaskRepo),
|
|
||||||
30*time.Second,
|
|
||||||
)
|
|
||||||
metricsCollector.Start(ctx)
|
|
||||||
|
|
||||||
router := aphttp.NewRouter(aphttp.RouterDependencies{
|
router := aphttp.NewRouter(aphttp.RouterDependencies{
|
||||||
Context: ctx,
|
Context: ctx,
|
||||||
Config: cfg,
|
Config: cfg,
|
||||||
Version: version,
|
Version: version,
|
||||||
Logger: appLogger,
|
Logger: appLogger,
|
||||||
AuthService: authService,
|
AuthService: authService,
|
||||||
SystemService: systemService,
|
SystemService: systemService,
|
||||||
StorageTargetService: storageTargetService,
|
StorageTargetService: storageTargetService,
|
||||||
BackupTaskService: backupTaskService,
|
BackupTaskService: backupTaskService,
|
||||||
BackupExecutionService: backupExecutionService,
|
BackupExecutionService: backupExecutionService,
|
||||||
BackupRecordService: backupRecordService,
|
BackupRecordService: backupRecordService,
|
||||||
RestoreService: restoreService,
|
NotificationService: notificationService,
|
||||||
VerificationService: verificationService,
|
DashboardService: dashboardService,
|
||||||
ReplicationService: replicationService,
|
SettingsService: settingsService,
|
||||||
TaskTemplateService: taskTemplateService,
|
|
||||||
TaskExportService: taskExportService,
|
|
||||||
SearchService: searchService,
|
|
||||||
EventBroadcaster: eventBroadcaster,
|
|
||||||
UserService: userService,
|
|
||||||
ApiKeyService: apiKeyService,
|
|
||||||
NotificationService: notificationService,
|
|
||||||
DashboardService: dashboardService,
|
|
||||||
SettingsService: settingsService,
|
|
||||||
NodeService: nodeService,
|
NodeService: nodeService,
|
||||||
AgentService: agentService,
|
AgentService: agentService,
|
||||||
DatabaseDiscoveryService: databaseDiscoveryService,
|
DatabaseDiscoveryService: databaseDiscoveryService,
|
||||||
AuditService: auditService,
|
AuditService: auditService,
|
||||||
JWTManager: jwtManager,
|
JWTManager: jwtManager,
|
||||||
UserRepository: userRepo,
|
UserRepository: userRepo,
|
||||||
SystemConfigRepo: systemConfigRepo,
|
SystemConfigRepo: systemConfigRepo,
|
||||||
InstallTokenService: installTokenService,
|
InstallTokenService: installTokenService,
|
||||||
MasterExternalURL: "", // 如需覆盖 URL,可扩展 cfg.Server 增字段;目前留空依赖 X-Forwarded-* / Request.Host
|
MasterExternalURL: "", // 如需覆盖 URL,可扩展 cfg.Server 增字段;目前留空依赖 X-Forwarded-* / Request.Host
|
||||||
DB: db,
|
|
||||||
Metrics: appMetrics,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
httpServer := &stdhttp.Server{
|
httpServer := &stdhttp.Server{
|
||||||
|
|||||||
@@ -1,119 +0,0 @@
|
|||||||
package backup
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// DiscoverRequest 数据库发现请求参数。
|
|
||||||
// Type 取 "mysql" 或 "postgresql"。
|
|
||||||
type DiscoverRequest struct {
|
|
||||||
Type string
|
|
||||||
Host string
|
|
||||||
Port int
|
|
||||||
User string
|
|
||||||
Password string
|
|
||||||
}
|
|
||||||
|
|
||||||
// DiscoverDatabases 通过本机 mysql/psql 客户端连接目标数据库并列出非系统库。
|
|
||||||
// 5 秒命令超时。调用方负责传入 CommandExecutor(Master 用 OSCommandExecutor,
|
|
||||||
// Agent 同理)。此函数不依赖 service / apperror,便于在 agent 包复用。
|
|
||||||
func DiscoverDatabases(ctx context.Context, executor CommandExecutor, req DiscoverRequest) ([]string, error) {
|
|
||||||
switch strings.TrimSpace(strings.ToLower(req.Type)) {
|
|
||||||
case "mysql":
|
|
||||||
return discoverMySQLDatabases(ctx, executor, req)
|
|
||||||
case "postgresql":
|
|
||||||
return discoverPostgreSQLDatabases(ctx, executor, req)
|
|
||||||
default:
|
|
||||||
return nil, fmt.Errorf("unsupported database type: %s", req.Type)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func discoverMySQLDatabases(ctx context.Context, executor CommandExecutor, req DiscoverRequest) ([]string, error) {
|
|
||||||
mysqlPath, err := executor.LookPath("mysql")
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("系统未安装 mysql 客户端")
|
|
||||||
}
|
|
||||||
timeout, cancel := context.WithTimeout(ctx, 5*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
var stdout, stderr bytes.Buffer
|
|
||||||
args := []string{
|
|
||||||
fmt.Sprintf("--host=%s", req.Host),
|
|
||||||
fmt.Sprintf("--port=%d", req.Port),
|
|
||||||
fmt.Sprintf("--user=%s", req.User),
|
|
||||||
"-e", "SHOW DATABASES",
|
|
||||||
"--skip-column-names",
|
|
||||||
}
|
|
||||||
env := []string{fmt.Sprintf("MYSQL_PWD=%s", req.Password)}
|
|
||||||
if err := executor.Run(timeout, mysqlPath, args, CommandOptions{
|
|
||||||
Stdout: &stdout,
|
|
||||||
Stderr: &stderr,
|
|
||||||
Env: env,
|
|
||||||
}); err != nil {
|
|
||||||
errMsg := strings.TrimSpace(stderr.String())
|
|
||||||
if errMsg == "" {
|
|
||||||
errMsg = err.Error()
|
|
||||||
}
|
|
||||||
return nil, fmt.Errorf("连接 MySQL 失败:%s", errMsg)
|
|
||||||
}
|
|
||||||
systemDBs := map[string]bool{
|
|
||||||
"information_schema": true,
|
|
||||||
"performance_schema": true,
|
|
||||||
"mysql": true,
|
|
||||||
"sys": true,
|
|
||||||
}
|
|
||||||
var databases []string
|
|
||||||
for _, line := range strings.Split(stdout.String(), "\n") {
|
|
||||||
db := strings.TrimSpace(line)
|
|
||||||
if db == "" || systemDBs[db] {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
databases = append(databases, db)
|
|
||||||
}
|
|
||||||
return databases, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func discoverPostgreSQLDatabases(ctx context.Context, executor CommandExecutor, req DiscoverRequest) ([]string, error) {
|
|
||||||
psqlPath, err := executor.LookPath("psql")
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("系统未安装 psql 客户端")
|
|
||||||
}
|
|
||||||
timeout, cancel := context.WithTimeout(ctx, 5*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
var stdout, stderr bytes.Buffer
|
|
||||||
args := []string{
|
|
||||||
"-h", req.Host,
|
|
||||||
"-p", fmt.Sprintf("%d", req.Port),
|
|
||||||
"-U", req.User,
|
|
||||||
"-d", "postgres",
|
|
||||||
"-t", "-A",
|
|
||||||
"-c", "SELECT datname FROM pg_database WHERE datistemplate = false ORDER BY datname",
|
|
||||||
}
|
|
||||||
env := []string{fmt.Sprintf("PGPASSWORD=%s", req.Password)}
|
|
||||||
if err := executor.Run(timeout, psqlPath, args, CommandOptions{
|
|
||||||
Stdout: &stdout,
|
|
||||||
Stderr: &stderr,
|
|
||||||
Env: env,
|
|
||||||
}); err != nil {
|
|
||||||
errMsg := strings.TrimSpace(stderr.String())
|
|
||||||
if errMsg == "" {
|
|
||||||
errMsg = err.Error()
|
|
||||||
}
|
|
||||||
return nil, fmt.Errorf("连接 PostgreSQL 失败:%s", errMsg)
|
|
||||||
}
|
|
||||||
skipDBs := map[string]bool{
|
|
||||||
"postgres": true,
|
|
||||||
}
|
|
||||||
var databases []string
|
|
||||||
for _, line := range strings.Split(stdout.String(), "\n") {
|
|
||||||
db := strings.TrimSpace(line)
|
|
||||||
if db == "" || skipDBs[db] || strings.HasPrefix(db, "template") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
databases = append(databases, db)
|
|
||||||
}
|
|
||||||
return databases, nil
|
|
||||||
}
|
|
||||||
@@ -1,179 +0,0 @@
|
|||||||
package backup
|
|
||||||
|
|
||||||
import (
|
|
||||||
"archive/tar"
|
|
||||||
"bufio"
|
|
||||||
"crypto/sha256"
|
|
||||||
"encoding/hex"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// VerifyReport 是 quick 模式的验证结果摘要。
|
|
||||||
type VerifyReport struct {
|
|
||||||
TotalEntries int `json:"totalEntries,omitempty"`
|
|
||||||
FileBytes int64 `json:"fileBytes,omitempty"`
|
|
||||||
ChecksumOK bool `json:"checksumOk,omitempty"`
|
|
||||||
Detail string `json:"detail,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// VerifyTarArchive 遍历 tar 归档的每个 header + reader,不写盘。
|
|
||||||
// 能检测归档截断、条目损坏、层级不对等常见问题。
|
|
||||||
// expectedChecksum 非空时额外对整个文件校验 SHA-256(不做解压)。
|
|
||||||
func VerifyTarArchive(artifactPath string, expectedChecksum string) (*VerifyReport, error) {
|
|
||||||
file, err := os.Open(artifactPath)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("open tar artifact: %w", err)
|
|
||||||
}
|
|
||||||
defer file.Close()
|
|
||||||
report := &VerifyReport{}
|
|
||||||
h := sha256.New()
|
|
||||||
reader := io.TeeReader(file, h)
|
|
||||||
tr := tar.NewReader(reader)
|
|
||||||
for {
|
|
||||||
header, err := tr.Next()
|
|
||||||
if err == io.EOF {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return report, fmt.Errorf("read tar entry: %w", err)
|
|
||||||
}
|
|
||||||
report.TotalEntries++
|
|
||||||
// 读完条目数据以触发完整性校验(tar 内部 CRC 不严格,但断流会报错)
|
|
||||||
if header.Typeflag == tar.TypeReg || header.Typeflag == tar.TypeRegA {
|
|
||||||
n, copyErr := io.Copy(io.Discard, tr)
|
|
||||||
if copyErr != nil {
|
|
||||||
return report, fmt.Errorf("read entry %s: %w", header.Name, copyErr)
|
|
||||||
}
|
|
||||||
report.FileBytes += n
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 读完 tar 后继续把剩余字节喂给 hash(tar 结束后可能有零填充尾)
|
|
||||||
if _, err := io.Copy(io.Discard, reader); err != nil {
|
|
||||||
return report, fmt.Errorf("drain remainder: %w", err)
|
|
||||||
}
|
|
||||||
actual := hex.EncodeToString(h.Sum(nil))
|
|
||||||
if strings.TrimSpace(expectedChecksum) != "" {
|
|
||||||
report.ChecksumOK = strings.EqualFold(actual, expectedChecksum)
|
|
||||||
if !report.ChecksumOK {
|
|
||||||
return report, fmt.Errorf("checksum mismatch: expected %s, got %s", expectedChecksum, actual)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
report.ChecksumOK = true
|
|
||||||
}
|
|
||||||
report.Detail = fmt.Sprintf("tar 包完整(%d 条目,有效字节 %d)", report.TotalEntries, report.FileBytes)
|
|
||||||
return report, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// VerifySQLiteFile 校验 SQLite 文件头魔数。
|
|
||||||
// 官方格式:前 16 字节为 "SQLite format 3\000"。
|
|
||||||
func VerifySQLiteFile(artifactPath string) (*VerifyReport, error) {
|
|
||||||
file, err := os.Open(artifactPath)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("open sqlite artifact: %w", err)
|
|
||||||
}
|
|
||||||
defer file.Close()
|
|
||||||
header := make([]byte, 16)
|
|
||||||
if _, err := io.ReadFull(file, header); err != nil {
|
|
||||||
return nil, fmt.Errorf("read sqlite header: %w", err)
|
|
||||||
}
|
|
||||||
const magic = "SQLite format 3\x00"
|
|
||||||
if string(header) != magic {
|
|
||||||
return &VerifyReport{Detail: "非法的 SQLite 文件头"}, fmt.Errorf("invalid sqlite magic header")
|
|
||||||
}
|
|
||||||
info, _ := file.Stat()
|
|
||||||
var size int64
|
|
||||||
if info != nil {
|
|
||||||
size = info.Size()
|
|
||||||
}
|
|
||||||
return &VerifyReport{
|
|
||||||
FileBytes: size,
|
|
||||||
Detail: fmt.Sprintf("SQLite 文件头合法(总大小 %d 字节)", size),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// VerifyMySQLDump 校验 MySQL dump 文件头部是否为合法 mysqldump 输出。
|
|
||||||
// 头部 1024 字节包含以下任一关键字即通过:
|
|
||||||
// - "-- MySQL dump"
|
|
||||||
// - "-- Server version"
|
|
||||||
// - "-- MariaDB dump"
|
|
||||||
func VerifyMySQLDump(artifactPath string) (*VerifyReport, error) {
|
|
||||||
return verifyDumpHeader(artifactPath, []string{"-- MySQL dump", "-- Server version", "-- MariaDB dump"}, "MySQL/MariaDB")
|
|
||||||
}
|
|
||||||
|
|
||||||
// VerifyPostgreSQLDump 校验 PostgreSQL plain text dump 头部。
|
|
||||||
// 典型标记:"-- PostgreSQL database dump" 或 "-- Dumped from database version"。
|
|
||||||
func VerifyPostgreSQLDump(artifactPath string) (*VerifyReport, error) {
|
|
||||||
return verifyDumpHeader(artifactPath, []string{"-- PostgreSQL database dump", "-- Dumped from database version", "SET statement_timeout"}, "PostgreSQL")
|
|
||||||
}
|
|
||||||
|
|
||||||
func verifyDumpHeader(artifactPath string, markers []string, label string) (*VerifyReport, error) {
|
|
||||||
file, err := os.Open(artifactPath)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("open dump artifact: %w", err)
|
|
||||||
}
|
|
||||||
defer file.Close()
|
|
||||||
reader := bufio.NewReader(file)
|
|
||||||
buf := make([]byte, 4096)
|
|
||||||
n, _ := io.ReadFull(reader, buf)
|
|
||||||
sample := string(buf[:n])
|
|
||||||
matched := ""
|
|
||||||
for _, m := range markers {
|
|
||||||
if strings.Contains(sample, m) {
|
|
||||||
matched = m
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if matched == "" {
|
|
||||||
return &VerifyReport{Detail: fmt.Sprintf("未在前 %d 字节中发现 %s dump 特征", n, label)}, fmt.Errorf("no %s dump marker in header", label)
|
|
||||||
}
|
|
||||||
info, _ := file.Stat()
|
|
||||||
var size int64
|
|
||||||
if info != nil {
|
|
||||||
size = info.Size()
|
|
||||||
}
|
|
||||||
return &VerifyReport{
|
|
||||||
FileBytes: size,
|
|
||||||
Detail: fmt.Sprintf("%s dump 头部识别标志: %q(文件 %d 字节)", label, matched, size),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// VerifySAPHANAArchive 校验 SAP HANA 归档 tar 中是否包含 databackup/logbackup 标志文件。
|
|
||||||
func VerifySAPHANAArchive(artifactPath string) (*VerifyReport, error) {
|
|
||||||
file, err := os.Open(artifactPath)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("open hana archive: %w", err)
|
|
||||||
}
|
|
||||||
defer file.Close()
|
|
||||||
tr := tar.NewReader(file)
|
|
||||||
report := &VerifyReport{}
|
|
||||||
var foundDataBackup bool
|
|
||||||
for {
|
|
||||||
header, err := tr.Next()
|
|
||||||
if err == io.EOF {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return report, fmt.Errorf("read tar entry: %w", err)
|
|
||||||
}
|
|
||||||
report.TotalEntries++
|
|
||||||
name := strings.ToLower(header.Name)
|
|
||||||
if strings.Contains(name, "databackup") || strings.Contains(name, "logbackup") || strings.HasPrefix(name, "hana_") {
|
|
||||||
foundDataBackup = true
|
|
||||||
}
|
|
||||||
if header.Typeflag == tar.TypeReg || header.Typeflag == tar.TypeRegA {
|
|
||||||
n, copyErr := io.Copy(io.Discard, tr)
|
|
||||||
if copyErr != nil {
|
|
||||||
return report, fmt.Errorf("read entry %s: %w", header.Name, copyErr)
|
|
||||||
}
|
|
||||||
report.FileBytes += n
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !foundDataBackup {
|
|
||||||
return report, fmt.Errorf("HANA archive missing databackup/logbackup markers")
|
|
||||||
}
|
|
||||||
report.Detail = fmt.Sprintf("HANA 归档包含 %d 条目(%d 字节),已识别备份标志文件", report.TotalEntries, report.FileBytes)
|
|
||||||
return report, nil
|
|
||||||
}
|
|
||||||
@@ -1,121 +0,0 @@
|
|||||||
package backup
|
|
||||||
|
|
||||||
import (
|
|
||||||
"archive/tar"
|
|
||||||
"bytes"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
// 构造一个最小的 tar 归档文件供测试使用
|
|
||||||
func writeTestTar(t *testing.T, entries map[string][]byte) string {
|
|
||||||
t.Helper()
|
|
||||||
path := filepath.Join(t.TempDir(), "test.tar")
|
|
||||||
buf := new(bytes.Buffer)
|
|
||||||
tw := tar.NewWriter(buf)
|
|
||||||
for name, body := range entries {
|
|
||||||
header := &tar.Header{Name: name, Mode: 0o644, Size: int64(len(body)), Typeflag: tar.TypeReg}
|
|
||||||
if err := tw.WriteHeader(header); err != nil {
|
|
||||||
t.Fatalf("write tar header: %v", err)
|
|
||||||
}
|
|
||||||
if _, err := tw.Write(body); err != nil {
|
|
||||||
t.Fatalf("write tar body: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ = tw.Close()
|
|
||||||
if err := os.WriteFile(path, buf.Bytes(), 0o644); err != nil {
|
|
||||||
t.Fatalf("write tar file: %v", err)
|
|
||||||
}
|
|
||||||
return path
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestVerifyTarArchive_Valid(t *testing.T) {
|
|
||||||
path := writeTestTar(t, map[string][]byte{
|
|
||||||
"readme.md": []byte("hello"),
|
|
||||||
"data.bin": []byte("world!!!"),
|
|
||||||
})
|
|
||||||
report, err := VerifyTarArchive(path, "")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("VerifyTarArchive returned error: %v", err)
|
|
||||||
}
|
|
||||||
if report.TotalEntries != 2 {
|
|
||||||
t.Fatalf("expected 2 entries, got %d", report.TotalEntries)
|
|
||||||
}
|
|
||||||
if report.FileBytes == 0 {
|
|
||||||
t.Fatalf("expected non-zero file bytes")
|
|
||||||
}
|
|
||||||
if !report.ChecksumOK {
|
|
||||||
t.Fatalf("checksumOK should be true when expected checksum empty")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestVerifyTarArchive_Truncated(t *testing.T) {
|
|
||||||
// 构造带多个大 entry 的 tar,在 entry 数据中间截断,使 io.Copy 触发 UnexpectedEOF
|
|
||||||
path := filepath.Join(t.TempDir(), "big.tar")
|
|
||||||
buf := new(bytes.Buffer)
|
|
||||||
tw := tar.NewWriter(buf)
|
|
||||||
body := bytes.Repeat([]byte("x"), 4096)
|
|
||||||
_ = tw.WriteHeader(&tar.Header{Name: "big.bin", Mode: 0o644, Size: int64(len(body)), Typeflag: tar.TypeReg})
|
|
||||||
_, _ = tw.Write(body)
|
|
||||||
_ = tw.Close()
|
|
||||||
data := buf.Bytes()
|
|
||||||
// 保留 header 完整(512),破坏 body 中间使 tar.Reader 在 io.Copy 时遇到 EOF
|
|
||||||
truncated := data[:512+1024]
|
|
||||||
if err := os.WriteFile(path, truncated, 0o644); err != nil {
|
|
||||||
t.Fatalf("write truncated: %v", err)
|
|
||||||
}
|
|
||||||
if _, err := VerifyTarArchive(path, ""); err == nil {
|
|
||||||
t.Fatalf("expected error on truncated tar, got nil")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestVerifySQLiteFile_Valid(t *testing.T) {
|
|
||||||
path := filepath.Join(t.TempDir(), "ok.db")
|
|
||||||
content := []byte("SQLite format 3\x00" + string(make([]byte, 100)))
|
|
||||||
if err := os.WriteFile(path, content, 0o644); err != nil {
|
|
||||||
t.Fatalf("WriteFile: %v", err)
|
|
||||||
}
|
|
||||||
report, err := VerifySQLiteFile(path)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("VerifySQLiteFile: %v", err)
|
|
||||||
}
|
|
||||||
if report.FileBytes == 0 {
|
|
||||||
t.Fatalf("expected non-zero size")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestVerifySQLiteFile_Invalid(t *testing.T) {
|
|
||||||
path := filepath.Join(t.TempDir(), "bad.db")
|
|
||||||
if err := os.WriteFile(path, []byte("not sqlite at all, some other text"), 0o644); err != nil {
|
|
||||||
t.Fatalf("WriteFile: %v", err)
|
|
||||||
}
|
|
||||||
if _, err := VerifySQLiteFile(path); err == nil {
|
|
||||||
t.Fatalf("expected error on non-sqlite file")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestVerifyMySQLDump(t *testing.T) {
|
|
||||||
path := filepath.Join(t.TempDir(), "dump.sql")
|
|
||||||
content := "-- MySQL dump 10.13 Distrib 8.0.33\n-- Host: localhost\nINSERT INTO foo VALUES (1);\n"
|
|
||||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
|
||||||
t.Fatalf("WriteFile: %v", err)
|
|
||||||
}
|
|
||||||
report, err := VerifyMySQLDump(path)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("VerifyMySQLDump: %v", err)
|
|
||||||
}
|
|
||||||
if report.Detail == "" {
|
|
||||||
t.Fatalf("expected Detail in report")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestVerifyPostgreSQLDump_Invalid(t *testing.T) {
|
|
||||||
path := filepath.Join(t.TempDir(), "notpg.sql")
|
|
||||||
if err := os.WriteFile(path, []byte("some random text without header markers"), 0o644); err != nil {
|
|
||||||
t.Fatalf("WriteFile: %v", err)
|
|
||||||
}
|
|
||||||
if _, err := VerifyPostgreSQLDump(path); err == nil {
|
|
||||||
t.Fatalf("expected error on non-pg dump")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,180 +0,0 @@
|
|||||||
package backup
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// MaintenanceWindow 描述一个允许执行备份的时段。
|
|
||||||
// 格式语义:
|
|
||||||
// - Days 为 "0..6" 的字符串集合(0=周日,6=周六);空 = 每天
|
|
||||||
// - StartMinutes / EndMinutes 为"午夜起计算的分钟数",0 ≤ v < 1440
|
|
||||||
// - 跨午夜窗口:Start > End 表示跨夜(如 22:00-06:00)
|
|
||||||
//
|
|
||||||
// 多个窗口是 OR 语义:只要 now 落入任一窗口即允许执行。
|
|
||||||
type MaintenanceWindow struct {
|
|
||||||
Days map[int]bool
|
|
||||||
StartMinutes int
|
|
||||||
EndMinutes int
|
|
||||||
}
|
|
||||||
|
|
||||||
// ParseMaintenanceWindows 解析用户配置(CSV 每项形如 "days=mon,tue|time=22:00-06:00")。
|
|
||||||
// 简化语法:多个窗口以 ';' 分隔,每个窗口按 "[days=xxx;]time=HH:MM-HH:MM" 格式。
|
|
||||||
// Days 缺省 = 全周;若不合法,跳过该段而非抛错(让调用方尽力工作)。
|
|
||||||
// 示例:
|
|
||||||
// "time=01:00-05:00" 每天 1 点到 5 点
|
|
||||||
// "days=sat,sun;time=00:00-23:59" 仅周末全天
|
|
||||||
// "time=22:00-06:00" 每天跨夜
|
|
||||||
// "days=mon,tue,wed,thu,fri;time=22:00-06:00" 工作日跨夜
|
|
||||||
func ParseMaintenanceWindows(value string) []MaintenanceWindow {
|
|
||||||
v := strings.TrimSpace(value)
|
|
||||||
if v == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
segments := strings.Split(v, ";")
|
|
||||||
var windows []MaintenanceWindow
|
|
||||||
for _, segment := range segments {
|
|
||||||
segment = strings.TrimSpace(segment)
|
|
||||||
if segment == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
window, ok := parseSingleWindow(segment)
|
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
windows = append(windows, window)
|
|
||||||
}
|
|
||||||
return windows
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseSingleWindow(segment string) (MaintenanceWindow, bool) {
|
|
||||||
// "days=xxx,time=HH:MM-HH:MM" 或 "time=..."
|
|
||||||
fields := strings.Split(segment, ",")
|
|
||||||
days := map[int]bool{}
|
|
||||||
var timeExpr string
|
|
||||||
for _, field := range fields {
|
|
||||||
field = strings.TrimSpace(field)
|
|
||||||
if field == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if strings.HasPrefix(field, "days=") {
|
|
||||||
daysPart := strings.TrimPrefix(field, "days=")
|
|
||||||
for _, day := range strings.Split(daysPart, "|") {
|
|
||||||
if idx := parseDayToken(strings.TrimSpace(day)); idx >= 0 {
|
|
||||||
days[idx] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if strings.HasPrefix(field, "time=") {
|
|
||||||
timeExpr = strings.TrimPrefix(field, "time=")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
start, end, ok := parseTimeRange(strings.TrimSpace(timeExpr))
|
|
||||||
if !ok {
|
|
||||||
return MaintenanceWindow{}, false
|
|
||||||
}
|
|
||||||
return MaintenanceWindow{Days: days, StartMinutes: start, EndMinutes: end}, true
|
|
||||||
}
|
|
||||||
|
|
||||||
var dayTokens = map[string]int{
|
|
||||||
"sun": 0, "sunday": 0, "0": 0,
|
|
||||||
"mon": 1, "monday": 1, "1": 1,
|
|
||||||
"tue": 2, "tuesday": 2, "2": 2,
|
|
||||||
"wed": 3, "wednesday": 3, "3": 3,
|
|
||||||
"thu": 4, "thursday": 4, "4": 4,
|
|
||||||
"fri": 5, "friday": 5, "5": 5,
|
|
||||||
"sat": 6, "saturday": 6, "6": 6,
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseDayToken(value string) int {
|
|
||||||
v := strings.ToLower(strings.TrimSpace(value))
|
|
||||||
if v == "" {
|
|
||||||
return -1
|
|
||||||
}
|
|
||||||
if idx, ok := dayTokens[v]; ok {
|
|
||||||
return idx
|
|
||||||
}
|
|
||||||
return -1
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseTimeRange 解析 "HH:MM-HH:MM",返回起止分钟数。
|
|
||||||
func parseTimeRange(value string) (int, int, bool) {
|
|
||||||
parts := strings.SplitN(value, "-", 2)
|
|
||||||
if len(parts) != 2 {
|
|
||||||
return 0, 0, false
|
|
||||||
}
|
|
||||||
start, ok := parseHHMM(parts[0])
|
|
||||||
if !ok {
|
|
||||||
return 0, 0, false
|
|
||||||
}
|
|
||||||
end, ok := parseHHMM(parts[1])
|
|
||||||
if !ok {
|
|
||||||
return 0, 0, false
|
|
||||||
}
|
|
||||||
return start, end, true
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseHHMM(value string) (int, bool) {
|
|
||||||
parts := strings.Split(strings.TrimSpace(value), ":")
|
|
||||||
if len(parts) != 2 {
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
h, err := strconv.Atoi(strings.TrimSpace(parts[0]))
|
|
||||||
if err != nil || h < 0 || h > 23 {
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
m, err := strconv.Atoi(strings.TrimSpace(parts[1]))
|
|
||||||
if err != nil || m < 0 || m > 59 {
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
return h*60 + m, true
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsWithinWindow 判断 t 是否落入任一窗口。windows 为空或 nil 时总是返回 true(不限制)。
|
|
||||||
func IsWithinWindow(t time.Time, windows []MaintenanceWindow) bool {
|
|
||||||
if len(windows) == 0 {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
minutes := t.Hour()*60 + t.Minute()
|
|
||||||
weekday := int(t.Weekday())
|
|
||||||
for _, w := range windows {
|
|
||||||
if len(w.Days) > 0 && !w.Days[weekday] {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if w.StartMinutes == w.EndMinutes {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if w.StartMinutes < w.EndMinutes {
|
|
||||||
// 同日窗口
|
|
||||||
if minutes >= w.StartMinutes && minutes < w.EndMinutes {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// 跨午夜:[start, 1440) ∪ [0, end)
|
|
||||||
if minutes >= w.StartMinutes || minutes < w.EndMinutes {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// ValidateMaintenanceWindows 用户输入合法性校验(返回人可读的错误)。
|
|
||||||
func ValidateMaintenanceWindows(value string) error {
|
|
||||||
v := strings.TrimSpace(value)
|
|
||||||
if v == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
segments := strings.Split(v, ";")
|
|
||||||
for _, segment := range segments {
|
|
||||||
segment = strings.TrimSpace(segment)
|
|
||||||
if segment == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if _, ok := parseSingleWindow(segment); !ok {
|
|
||||||
return fmt.Errorf("无效的维护窗口配置: %q(期望格式如 time=22:00-06:00 或 days=sat,sun,time=00:00-23:59)", segment)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,110 +0,0 @@
|
|||||||
package backup
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestParseAndCheck_SingleSameDayWindow(t *testing.T) {
|
|
||||||
windows := ParseMaintenanceWindows("time=01:00-05:00")
|
|
||||||
if len(windows) != 1 {
|
|
||||||
t.Fatalf("expected 1 window, got %d", len(windows))
|
|
||||||
}
|
|
||||||
// 周一 03:00 UTC(天数不限制)
|
|
||||||
at := time.Date(2026, 4, 20, 3, 0, 0, 0, time.UTC)
|
|
||||||
if !IsWithinWindow(at, windows) {
|
|
||||||
t.Fatalf("expected 03:00 to be inside 01:00-05:00")
|
|
||||||
}
|
|
||||||
at = time.Date(2026, 4, 20, 6, 0, 0, 0, time.UTC)
|
|
||||||
if IsWithinWindow(at, windows) {
|
|
||||||
t.Fatalf("expected 06:00 to be outside 01:00-05:00")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseAndCheck_CrossMidnight(t *testing.T) {
|
|
||||||
windows := ParseMaintenanceWindows("time=22:00-06:00")
|
|
||||||
if len(windows) != 1 {
|
|
||||||
t.Fatalf("expected 1 window")
|
|
||||||
}
|
|
||||||
tests := []struct {
|
|
||||||
hour, minute int
|
|
||||||
inside bool
|
|
||||||
}{
|
|
||||||
{22, 30, true},
|
|
||||||
{23, 59, true},
|
|
||||||
{0, 0, true},
|
|
||||||
{3, 0, true},
|
|
||||||
{5, 59, true},
|
|
||||||
{6, 0, false},
|
|
||||||
{7, 0, false},
|
|
||||||
{21, 59, false},
|
|
||||||
}
|
|
||||||
base := time.Date(2026, 4, 20, 0, 0, 0, 0, time.UTC)
|
|
||||||
for _, tc := range tests {
|
|
||||||
at := base.Add(time.Duration(tc.hour)*time.Hour + time.Duration(tc.minute)*time.Minute)
|
|
||||||
if got := IsWithinWindow(at, windows); got != tc.inside {
|
|
||||||
t.Errorf("%02d:%02d expected inside=%v, got %v", tc.hour, tc.minute, tc.inside, got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseAndCheck_DaysFilter(t *testing.T) {
|
|
||||||
// 周末全天
|
|
||||||
windows := ParseMaintenanceWindows("days=sat|sun,time=00:00-23:59")
|
|
||||||
if len(windows) != 1 {
|
|
||||||
t.Fatalf("expected 1 window")
|
|
||||||
}
|
|
||||||
sat := time.Date(2026, 4, 18, 12, 0, 0, 0, time.UTC) // Saturday
|
|
||||||
sun := time.Date(2026, 4, 19, 12, 0, 0, 0, time.UTC) // Sunday
|
|
||||||
mon := time.Date(2026, 4, 20, 12, 0, 0, 0, time.UTC) // Monday
|
|
||||||
if !IsWithinWindow(sat, windows) {
|
|
||||||
t.Fatalf("saturday should be inside")
|
|
||||||
}
|
|
||||||
if !IsWithinWindow(sun, windows) {
|
|
||||||
t.Fatalf("sunday should be inside")
|
|
||||||
}
|
|
||||||
if IsWithinWindow(mon, windows) {
|
|
||||||
t.Fatalf("monday should be outside")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseAndCheck_Multiple(t *testing.T) {
|
|
||||||
// 两段:工作日跨夜 + 周末全天
|
|
||||||
windows := ParseMaintenanceWindows("days=mon|tue|wed|thu|fri,time=22:00-06:00;days=sat|sun,time=00:00-23:59")
|
|
||||||
if len(windows) != 2 {
|
|
||||||
t.Fatalf("expected 2 windows, got %d", len(windows))
|
|
||||||
}
|
|
||||||
monAfternoon := time.Date(2026, 4, 20, 15, 0, 0, 0, time.UTC)
|
|
||||||
if IsWithinWindow(monAfternoon, windows) {
|
|
||||||
t.Fatalf("mon 15:00 should be outside both windows")
|
|
||||||
}
|
|
||||||
monNight := time.Date(2026, 4, 20, 23, 0, 0, 0, time.UTC)
|
|
||||||
if !IsWithinWindow(monNight, windows) {
|
|
||||||
t.Fatalf("mon 23:00 should be inside weekday-night window")
|
|
||||||
}
|
|
||||||
sunNoon := time.Date(2026, 4, 19, 12, 0, 0, 0, time.UTC)
|
|
||||||
if !IsWithinWindow(sunNoon, windows) {
|
|
||||||
t.Fatalf("sun 12:00 should be inside weekend window")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestValidateMaintenanceWindows(t *testing.T) {
|
|
||||||
if err := ValidateMaintenanceWindows(""); err != nil {
|
|
||||||
t.Fatalf("empty should be valid, got %v", err)
|
|
||||||
}
|
|
||||||
if err := ValidateMaintenanceWindows("time=01:00-05:00"); err != nil {
|
|
||||||
t.Fatalf("valid format rejected: %v", err)
|
|
||||||
}
|
|
||||||
if err := ValidateMaintenanceWindows("bad-input"); err == nil {
|
|
||||||
t.Fatalf("invalid format should return error")
|
|
||||||
}
|
|
||||||
if err := ValidateMaintenanceWindows("time=25:00-30:00"); err == nil {
|
|
||||||
t.Fatalf("invalid hour should return error")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestIsWithinWindow_NoWindows(t *testing.T) {
|
|
||||||
if !IsWithinWindow(time.Now(), nil) {
|
|
||||||
t.Fatalf("no windows should always be inside")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -23,7 +23,7 @@ func Open(cfg config.DatabaseConfig, logger *zap.Logger) (*gorm.DB, error) {
|
|||||||
return nil, fmt.Errorf("open sqlite: %w", err)
|
return nil, fmt.Errorf("open sqlite: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := db.AutoMigrate(&model.User{}, &model.SystemConfig{}, &model.StorageTarget{}, &model.OAuthSession{}, &model.BackupTask{}, &model.BackupRecord{}, &model.Notification{}, &model.Node{}, &model.BackupTaskStorageTarget{}, &model.AuditLog{}, &model.AgentCommand{}, &model.AgentInstallToken{}, &model.RestoreRecord{}, &model.VerificationRecord{}, &model.ApiKey{}, &model.ReplicationRecord{}, &model.TaskTemplate{}); err != nil {
|
if err := db.AutoMigrate(&model.User{}, &model.SystemConfig{}, &model.StorageTarget{}, &model.OAuthSession{}, &model.BackupTask{}, &model.BackupRecord{}, &model.Notification{}, &model.Node{}, &model.BackupTaskStorageTarget{}, &model.AuditLog{}, &model.AgentCommand{}, &model.AgentInstallToken{}); err != nil {
|
||||||
return nil, fmt.Errorf("migrate schema: %w", err)
|
return nil, fmt.Errorf("migrate schema: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,13 +14,12 @@ import (
|
|||||||
// AgentHandler 实现 Agent 调用 Master 的 HTTP API。
|
// AgentHandler 实现 Agent 调用 Master 的 HTTP API。
|
||||||
// 全部端点通过 X-Agent-Token 头做节点认证,不使用 JWT。
|
// 全部端点通过 X-Agent-Token 头做节点认证,不使用 JWT。
|
||||||
type AgentHandler struct {
|
type AgentHandler struct {
|
||||||
agentService *service.AgentService
|
agentService *service.AgentService
|
||||||
nodeService *service.NodeService
|
nodeService *service.NodeService
|
||||||
restoreService *service.RestoreService
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewAgentHandler(agentService *service.AgentService, nodeService *service.NodeService, restoreService *service.RestoreService) *AgentHandler {
|
func NewAgentHandler(agentService *service.AgentService, nodeService *service.NodeService) *AgentHandler {
|
||||||
return &AgentHandler{agentService: agentService, nodeService: nodeService, restoreService: restoreService}
|
return &AgentHandler{agentService: agentService, nodeService: nodeService}
|
||||||
}
|
}
|
||||||
|
|
||||||
// extractToken 从请求头或 JSON body 中提取 Agent Token。
|
// extractToken 从请求头或 JSON body 中提取 Agent Token。
|
||||||
@@ -156,58 +155,6 @@ func (h *AgentHandler) UpdateRecord(c *gin.Context) {
|
|||||||
response.Success(c, gin.H{"status": "ok"})
|
response.Success(c, gin.H{"status": "ok"})
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetRestoreSpec Agent 拉取恢复规格。
|
|
||||||
func (h *AgentHandler) GetRestoreSpec(c *gin.Context) {
|
|
||||||
if h.restoreService == nil {
|
|
||||||
c.JSON(stdhttp.StatusServiceUnavailable, gin.H{"code": "RESTORE_SERVICE_DISABLED", "message": "restore service is not enabled"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
node, err := h.agentService.AuthenticatedNode(c.Request.Context(), extractToken(c))
|
|
||||||
if err != nil {
|
|
||||||
response.Error(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
|
||||||
if err != nil {
|
|
||||||
response.Error(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
spec, err := h.restoreService.GetAgentRestoreSpec(c.Request.Context(), node, uint(id))
|
|
||||||
if err != nil {
|
|
||||||
response.Error(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
response.Success(c, spec)
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpdateRestore Agent 上报恢复记录的状态/日志。
|
|
||||||
func (h *AgentHandler) UpdateRestore(c *gin.Context) {
|
|
||||||
if h.restoreService == nil {
|
|
||||||
c.JSON(stdhttp.StatusServiceUnavailable, gin.H{"code": "RESTORE_SERVICE_DISABLED", "message": "restore service is not enabled"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
node, err := h.agentService.AuthenticatedNode(c.Request.Context(), extractToken(c))
|
|
||||||
if err != nil {
|
|
||||||
response.Error(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
|
||||||
if err != nil {
|
|
||||||
response.Error(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var input service.AgentRestoreUpdate
|
|
||||||
if err := c.ShouldBindJSON(&input); err != nil {
|
|
||||||
c.JSON(stdhttp.StatusBadRequest, gin.H{"code": "INVALID_INPUT", "message": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := h.restoreService.UpdateAgentRestore(c.Request.Context(), node, uint(id), input); err != nil {
|
|
||||||
response.Error(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
response.Success(c, gin.H{"status": "ok"})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Self 返回当前 Agent token 所属节点的状态,供安装脚本末尾探活。
|
// Self 返回当前 Agent token 所属节点的状态,供安装脚本末尾探活。
|
||||||
func (h *AgentHandler) Self(c *gin.Context) {
|
func (h *AgentHandler) Self(c *gin.Context) {
|
||||||
node, err := h.agentService.AuthenticatedNode(c.Request.Context(), extractToken(c))
|
node, err := h.agentService.AuthenticatedNode(c.Request.Context(), extractToken(c))
|
||||||
|
|||||||
@@ -1,93 +0,0 @@
|
|||||||
package http
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"backupx/server/internal/apperror"
|
|
||||||
"backupx/server/internal/service"
|
|
||||||
"backupx/server/pkg/response"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ApiKeyHandler 管理 API Key(admin 专属)。
|
|
||||||
type ApiKeyHandler struct {
|
|
||||||
service *service.ApiKeyService
|
|
||||||
auditService *service.AuditService
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewApiKeyHandler(apiKeyService *service.ApiKeyService, auditService *service.AuditService) *ApiKeyHandler {
|
|
||||||
return &ApiKeyHandler{service: apiKeyService, auditService: auditService}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *ApiKeyHandler) List(c *gin.Context) {
|
|
||||||
items, err := h.service.List(c.Request.Context())
|
|
||||||
if err != nil {
|
|
||||||
response.Error(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
response.Success(c, items)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *ApiKeyHandler) Create(c *gin.Context) {
|
|
||||||
var input service.ApiKeyCreateInput
|
|
||||||
if err := c.ShouldBindJSON(&input); err != nil {
|
|
||||||
response.Error(c, apperror.BadRequest("API_KEY_INVALID", "API Key 参数不合法", err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
creator := ""
|
|
||||||
if username, exists := c.Get(contextUsernameKey); exists {
|
|
||||||
if v, ok := username.(string); ok {
|
|
||||||
creator = v
|
|
||||||
}
|
|
||||||
}
|
|
||||||
result, err := h.service.Create(c.Request.Context(), creator, input)
|
|
||||||
if err != nil {
|
|
||||||
response.Error(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
recordAudit(c, h.auditService, "api_key", "create", "api_key", fmt.Sprintf("%d", result.ApiKey.ID), result.ApiKey.Name,
|
|
||||||
fmt.Sprintf("创建 API Key: %s (角色: %s)", result.ApiKey.Name, result.ApiKey.Role))
|
|
||||||
response.Success(c, result)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *ApiKeyHandler) Revoke(c *gin.Context) {
|
|
||||||
id, ok := parseUintParam(c, "id")
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := h.service.Revoke(c.Request.Context(), id); err != nil {
|
|
||||||
response.Error(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
recordAudit(c, h.auditService, "api_key", "revoke", "api_key", fmt.Sprintf("%d", id), "",
|
|
||||||
fmt.Sprintf("撤销 API Key (ID: %d)", id))
|
|
||||||
response.Success(c, gin.H{"revoked": true})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *ApiKeyHandler) Toggle(c *gin.Context) {
|
|
||||||
id, ok := parseUintParam(c, "id")
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var input struct {
|
|
||||||
Disabled bool `json:"disabled"`
|
|
||||||
}
|
|
||||||
if err := c.ShouldBindJSON(&input); err != nil {
|
|
||||||
response.Error(c, apperror.BadRequest("API_KEY_INVALID", "参数不合法", err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := h.service.ToggleDisabled(c.Request.Context(), id, input.Disabled); err != nil {
|
|
||||||
response.Error(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
action := "enable"
|
|
||||||
label := "启用"
|
|
||||||
if input.Disabled {
|
|
||||||
action = "disable"
|
|
||||||
label = "停用"
|
|
||||||
}
|
|
||||||
recordAudit(c, h.auditService, "api_key", action, "api_key", fmt.Sprintf("%d", id), "",
|
|
||||||
fmt.Sprintf("%s API Key (ID: %d)", label, id))
|
|
||||||
response.Success(c, gin.H{"disabled": input.Disabled})
|
|
||||||
}
|
|
||||||
@@ -1,18 +1,11 @@
|
|||||||
package http
|
package http
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/csv"
|
|
||||||
"fmt"
|
|
||||||
stdhttp "net/http"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
|
||||||
|
|
||||||
"backupx/server/internal/apperror"
|
|
||||||
"backupx/server/internal/repository"
|
|
||||||
"backupx/server/internal/service"
|
"backupx/server/internal/service"
|
||||||
"backupx/server/pkg/response"
|
"backupx/server/pkg/response"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -24,97 +17,24 @@ func NewAuditHandler(auditService *service.AuditService) *AuditHandler {
|
|||||||
return &AuditHandler{auditService: auditService}
|
return &AuditHandler{auditService: auditService}
|
||||||
}
|
}
|
||||||
|
|
||||||
// List 多字段筛选分页查询审计日志。
|
|
||||||
// 支持参数:category, action, username, targetId, keyword, dateFrom, dateTo, limit, offset。
|
|
||||||
// 向后兼容:若仅传 category + limit + offset,行为与旧版一致。
|
|
||||||
func (h *AuditHandler) List(c *gin.Context) {
|
func (h *AuditHandler) List(c *gin.Context) {
|
||||||
opts, err := parseAuditFilter(c)
|
category := strings.TrimSpace(c.Query("category"))
|
||||||
if err != nil {
|
limit := 50
|
||||||
response.Error(c, err)
|
offset := 0
|
||||||
return
|
if v := strings.TrimSpace(c.Query("limit")); v != "" {
|
||||||
|
if parsed, err := strconv.Atoi(v); err == nil && parsed > 0 {
|
||||||
|
limit = parsed
|
||||||
|
}
|
||||||
}
|
}
|
||||||
result, err := h.auditService.ListAdvanced(c.Request.Context(), opts)
|
if v := strings.TrimSpace(c.Query("offset")); v != "" {
|
||||||
|
if parsed, err := strconv.Atoi(v); err == nil && parsed >= 0 {
|
||||||
|
offset = parsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result, err := h.auditService.List(c.Request.Context(), category, limit, offset)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.Error(c, err)
|
response.Error(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
response.Success(c, result)
|
response.Success(c, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Export 导出 CSV。同筛选参数,最多 10000 行。
|
|
||||||
// 文件名带时间戳避免浏览器缓存覆盖。
|
|
||||||
func (h *AuditHandler) Export(c *gin.Context) {
|
|
||||||
opts, err := parseAuditFilter(c)
|
|
||||||
if err != nil {
|
|
||||||
response.Error(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// 导出不分页:覆盖掉 List 的默认 limit
|
|
||||||
opts.Limit = 0
|
|
||||||
opts.Offset = 0
|
|
||||||
items, err := h.auditService.ExportAll(c.Request.Context(), opts)
|
|
||||||
if err != nil {
|
|
||||||
response.Error(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
filename := fmt.Sprintf("backupx-audit-%s.csv", time.Now().UTC().Format("20060102-150405"))
|
|
||||||
c.Header("Content-Type", "text/csv; charset=utf-8")
|
|
||||||
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename))
|
|
||||||
// UTF-8 BOM 让 Excel 正确识别中文
|
|
||||||
_, _ = c.Writer.Write([]byte{0xEF, 0xBB, 0xBF})
|
|
||||||
writer := csv.NewWriter(c.Writer)
|
|
||||||
_ = writer.Write([]string{"时间", "用户", "类别", "动作", "目标类型", "目标 ID", "目标名", "详情", "客户端 IP"})
|
|
||||||
for _, item := range items {
|
|
||||||
_ = writer.Write([]string{
|
|
||||||
item.CreatedAt.UTC().Format(time.RFC3339),
|
|
||||||
item.Username,
|
|
||||||
item.Category,
|
|
||||||
item.Action,
|
|
||||||
item.TargetType,
|
|
||||||
item.TargetID,
|
|
||||||
item.TargetName,
|
|
||||||
item.Detail,
|
|
||||||
item.ClientIP,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
writer.Flush()
|
|
||||||
if err := writer.Error(); err != nil {
|
|
||||||
c.Writer.WriteHeader(stdhttp.StatusInternalServerError)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseAuditFilter 解析查询参数为 repository 选项。
|
|
||||||
func parseAuditFilter(c *gin.Context) (repository.AuditLogListOptions, error) {
|
|
||||||
opts := repository.AuditLogListOptions{
|
|
||||||
Category: strings.TrimSpace(c.Query("category")),
|
|
||||||
Action: strings.TrimSpace(c.Query("action")),
|
|
||||||
Username: strings.TrimSpace(c.Query("username")),
|
|
||||||
TargetID: strings.TrimSpace(c.Query("targetId")),
|
|
||||||
Keyword: strings.TrimSpace(c.Query("keyword")),
|
|
||||||
}
|
|
||||||
if v := strings.TrimSpace(c.Query("limit")); v != "" {
|
|
||||||
if n, err := strconv.Atoi(v); err == nil && n > 0 {
|
|
||||||
opts.Limit = n
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if v := strings.TrimSpace(c.Query("offset")); v != "" {
|
|
||||||
if n, err := strconv.Atoi(v); err == nil && n >= 0 {
|
|
||||||
opts.Offset = n
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if v := strings.TrimSpace(c.Query("dateFrom")); v != "" {
|
|
||||||
parsed, err := time.Parse(time.RFC3339, v)
|
|
||||||
if err != nil {
|
|
||||||
return opts, apperror.BadRequest("AUDIT_FILTER_INVALID", "dateFrom 必须为 RFC3339 时间格式", err)
|
|
||||||
}
|
|
||||||
opts.DateFrom = &parsed
|
|
||||||
}
|
|
||||||
if v := strings.TrimSpace(c.Query("dateTo")); v != "" {
|
|
||||||
parsed, err := time.Parse(time.RFC3339, v)
|
|
||||||
if err != nil {
|
|
||||||
return opts, apperror.BadRequest("AUDIT_FILTER_INVALID", "dateTo 必须为 RFC3339 时间格式", err)
|
|
||||||
}
|
|
||||||
opts.DateTo = &parsed
|
|
||||||
}
|
|
||||||
return opts, nil
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,23 +1,12 @@
|
|||||||
package http
|
package http
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net"
|
|
||||||
stdhttp "net/http"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"backupx/server/internal/apperror"
|
"backupx/server/internal/apperror"
|
||||||
"backupx/server/internal/service"
|
"backupx/server/internal/service"
|
||||||
"backupx/server/pkg/response"
|
"backupx/server/pkg/response"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
|
||||||
trustedDeviceCookieName = "backupx_trusted_device"
|
|
||||||
trustedDeviceCookiePath = "/api/auth"
|
|
||||||
trustedDeviceCookieMaxAge = int((30 * 24 * time.Hour) / time.Second)
|
|
||||||
)
|
|
||||||
|
|
||||||
type AuthHandler struct {
|
type AuthHandler struct {
|
||||||
authService *service.AuthService
|
authService *service.AuthService
|
||||||
}
|
}
|
||||||
@@ -55,18 +44,11 @@ func (h *AuthHandler) Login(c *gin.Context) {
|
|||||||
response.Error(c, apperror.BadRequest("AUTH_LOGIN_INVALID", "登录参数不合法", err))
|
response.Error(c, apperror.BadRequest("AUTH_LOGIN_INVALID", "登录参数不合法", err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(input.TrustedDeviceToken) == "" {
|
|
||||||
input.TrustedDeviceToken = trustedDeviceCookieValue(c)
|
|
||||||
}
|
|
||||||
payload, err := h.authService.Login(c.Request.Context(), input, ClientKey(c))
|
payload, err := h.authService.Login(c.Request.Context(), input, ClientKey(c))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.Error(c, err)
|
response.Error(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if payload.TrustedDeviceToken != "" {
|
|
||||||
setTrustedDeviceCookie(c, payload.TrustedDeviceToken)
|
|
||||||
payload.TrustedDeviceToken = ""
|
|
||||||
}
|
|
||||||
response.Success(c, payload)
|
response.Success(c, payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,315 +83,9 @@ func (h *AuthHandler) ChangePassword(c *gin.Context) {
|
|||||||
response.Error(c, err)
|
response.Error(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
clearTrustedDeviceCookie(c)
|
|
||||||
response.Success(c, gin.H{"changed": true})
|
response.Success(c, gin.H{"changed": true})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *AuthHandler) PrepareTwoFactor(c *gin.Context) {
|
|
||||||
subjectValue, _ := c.Get(contextUserSubjectKey)
|
|
||||||
subject, err := service.SubjectFromContextValue(subjectValue)
|
|
||||||
if err != nil {
|
|
||||||
response.Error(c, apperror.Unauthorized("AUTH_INVALID_SUBJECT", "无效登录态", err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var input service.TwoFactorSetupInput
|
|
||||||
if err := c.ShouldBindJSON(&input); err != nil {
|
|
||||||
response.Error(c, apperror.BadRequest("AUTH_2FA_INVALID", "参数不合法", err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
payload, err := h.authService.PrepareTwoFactor(c.Request.Context(), subject, input)
|
|
||||||
if err != nil {
|
|
||||||
response.Error(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
response.Success(c, payload)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *AuthHandler) EnableTwoFactor(c *gin.Context) {
|
|
||||||
subjectValue, _ := c.Get(contextUserSubjectKey)
|
|
||||||
subject, err := service.SubjectFromContextValue(subjectValue)
|
|
||||||
if err != nil {
|
|
||||||
response.Error(c, apperror.Unauthorized("AUTH_INVALID_SUBJECT", "无效登录态", err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var input service.EnableTwoFactorInput
|
|
||||||
if err := c.ShouldBindJSON(&input); err != nil {
|
|
||||||
response.Error(c, apperror.BadRequest("AUTH_2FA_INVALID", "参数不合法", err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
user, err := h.authService.EnableTwoFactor(c.Request.Context(), subject, input)
|
|
||||||
if err != nil {
|
|
||||||
response.Error(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
response.Success(c, user)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *AuthHandler) DisableTwoFactor(c *gin.Context) {
|
|
||||||
subjectValue, _ := c.Get(contextUserSubjectKey)
|
|
||||||
subject, err := service.SubjectFromContextValue(subjectValue)
|
|
||||||
if err != nil {
|
|
||||||
response.Error(c, apperror.Unauthorized("AUTH_INVALID_SUBJECT", "无效登录态", err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var input service.DisableTwoFactorInput
|
|
||||||
if err := c.ShouldBindJSON(&input); err != nil {
|
|
||||||
response.Error(c, apperror.BadRequest("AUTH_2FA_INVALID", "参数不合法", err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
user, err := h.authService.DisableTwoFactor(c.Request.Context(), subject, input)
|
|
||||||
if err != nil {
|
|
||||||
response.Error(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !user.MFAEnabled {
|
|
||||||
clearTrustedDeviceCookie(c)
|
|
||||||
}
|
|
||||||
response.Success(c, user)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *AuthHandler) RegenerateRecoveryCodes(c *gin.Context) {
|
|
||||||
subjectValue, _ := c.Get(contextUserSubjectKey)
|
|
||||||
subject, err := service.SubjectFromContextValue(subjectValue)
|
|
||||||
if err != nil {
|
|
||||||
response.Error(c, apperror.Unauthorized("AUTH_INVALID_SUBJECT", "无效登录态", err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var input service.RegenerateRecoveryCodesInput
|
|
||||||
if err := c.ShouldBindJSON(&input); err != nil {
|
|
||||||
response.Error(c, apperror.BadRequest("AUTH_2FA_INVALID", "参数不合法", err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
payload, err := h.authService.RegenerateRecoveryCodes(c.Request.Context(), subject, input)
|
|
||||||
if err != nil {
|
|
||||||
response.Error(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
response.Success(c, payload)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *AuthHandler) ConfigureOTP(c *gin.Context) {
|
|
||||||
subjectValue, _ := c.Get(contextUserSubjectKey)
|
|
||||||
subject, err := service.SubjectFromContextValue(subjectValue)
|
|
||||||
if err != nil {
|
|
||||||
response.Error(c, apperror.Unauthorized("AUTH_INVALID_SUBJECT", "无效登录态", err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var input service.OTPConfigInput
|
|
||||||
if err := c.ShouldBindJSON(&input); err != nil {
|
|
||||||
response.Error(c, apperror.BadRequest("AUTH_OTP_INVALID", "参数不合法", err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
user, err := h.authService.ConfigureOutOfBandOTP(c.Request.Context(), subject, input)
|
|
||||||
if err != nil {
|
|
||||||
response.Error(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !user.MFAEnabled {
|
|
||||||
clearTrustedDeviceCookie(c)
|
|
||||||
}
|
|
||||||
response.Success(c, user)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *AuthHandler) SendLoginOTP(c *gin.Context) {
|
|
||||||
var input service.LoginOTPInput
|
|
||||||
if err := c.ShouldBindJSON(&input); err != nil {
|
|
||||||
response.Error(c, apperror.BadRequest("AUTH_OTP_INVALID", "参数不合法", err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := h.authService.SendLoginOTP(c.Request.Context(), input, ClientKey(c)); err != nil {
|
|
||||||
response.Error(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
response.Success(c, gin.H{"sent": true})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *AuthHandler) BeginWebAuthnRegistration(c *gin.Context) {
|
|
||||||
subjectValue, _ := c.Get(contextUserSubjectKey)
|
|
||||||
subject, err := service.SubjectFromContextValue(subjectValue)
|
|
||||||
if err != nil {
|
|
||||||
response.Error(c, apperror.Unauthorized("AUTH_INVALID_SUBJECT", "无效登录态", err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var input service.WebAuthnRegistrationOptionsInput
|
|
||||||
if err := c.ShouldBindJSON(&input); err != nil {
|
|
||||||
response.Error(c, apperror.BadRequest("AUTH_WEBAUTHN_INVALID", "参数不合法", err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
options, err := h.authService.BeginWebAuthnRegistration(c.Request.Context(), subject, input, webAuthnRequestContext(c))
|
|
||||||
if err != nil {
|
|
||||||
response.Error(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
response.Success(c, options)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *AuthHandler) FinishWebAuthnRegistration(c *gin.Context) {
|
|
||||||
subjectValue, _ := c.Get(contextUserSubjectKey)
|
|
||||||
subject, err := service.SubjectFromContextValue(subjectValue)
|
|
||||||
if err != nil {
|
|
||||||
response.Error(c, apperror.Unauthorized("AUTH_INVALID_SUBJECT", "无效登录态", err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var input service.WebAuthnRegistrationFinishInput
|
|
||||||
if err := c.ShouldBindJSON(&input); err != nil {
|
|
||||||
response.Error(c, apperror.BadRequest("AUTH_WEBAUTHN_INVALID", "参数不合法", err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
user, err := h.authService.FinishWebAuthnRegistration(c.Request.Context(), subject, input)
|
|
||||||
if err != nil {
|
|
||||||
response.Error(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
response.Success(c, user)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *AuthHandler) BeginWebAuthnLogin(c *gin.Context) {
|
|
||||||
var input service.WebAuthnLoginOptionsInput
|
|
||||||
if err := c.ShouldBindJSON(&input); err != nil {
|
|
||||||
response.Error(c, apperror.BadRequest("AUTH_WEBAUTHN_INVALID", "参数不合法", err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
options, err := h.authService.BeginWebAuthnLogin(c.Request.Context(), input, webAuthnRequestContext(c), ClientKey(c))
|
|
||||||
if err != nil {
|
|
||||||
response.Error(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
response.Success(c, options)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *AuthHandler) ListWebAuthnCredentials(c *gin.Context) {
|
|
||||||
subjectValue, _ := c.Get(contextUserSubjectKey)
|
|
||||||
subject, err := service.SubjectFromContextValue(subjectValue)
|
|
||||||
if err != nil {
|
|
||||||
response.Error(c, apperror.Unauthorized("AUTH_INVALID_SUBJECT", "无效登录态", err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
items, err := h.authService.ListWebAuthnCredentials(c.Request.Context(), subject)
|
|
||||||
if err != nil {
|
|
||||||
response.Error(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
response.Success(c, items)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *AuthHandler) DeleteWebAuthnCredential(c *gin.Context) {
|
|
||||||
subjectValue, _ := c.Get(contextUserSubjectKey)
|
|
||||||
subject, err := service.SubjectFromContextValue(subjectValue)
|
|
||||||
if err != nil {
|
|
||||||
response.Error(c, apperror.Unauthorized("AUTH_INVALID_SUBJECT", "无效登录态", err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var input service.WebAuthnCredentialDeleteInput
|
|
||||||
if err := c.ShouldBindJSON(&input); err != nil {
|
|
||||||
response.Error(c, apperror.BadRequest("AUTH_WEBAUTHN_INVALID", "参数不合法", err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
user, err := h.authService.DeleteWebAuthnCredential(c.Request.Context(), subject, c.Param("id"), input)
|
|
||||||
if err != nil {
|
|
||||||
response.Error(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !user.MFAEnabled {
|
|
||||||
clearTrustedDeviceCookie(c)
|
|
||||||
}
|
|
||||||
response.Success(c, user)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *AuthHandler) ListTrustedDevices(c *gin.Context) {
|
|
||||||
subjectValue, _ := c.Get(contextUserSubjectKey)
|
|
||||||
subject, err := service.SubjectFromContextValue(subjectValue)
|
|
||||||
if err != nil {
|
|
||||||
response.Error(c, apperror.Unauthorized("AUTH_INVALID_SUBJECT", "无效登录态", err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
items, err := h.authService.ListTrustedDevices(c.Request.Context(), subject)
|
|
||||||
if err != nil {
|
|
||||||
response.Error(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
response.Success(c, items)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *AuthHandler) RevokeTrustedDevice(c *gin.Context) {
|
|
||||||
subjectValue, _ := c.Get(contextUserSubjectKey)
|
|
||||||
subject, err := service.SubjectFromContextValue(subjectValue)
|
|
||||||
if err != nil {
|
|
||||||
response.Error(c, apperror.Unauthorized("AUTH_INVALID_SUBJECT", "无效登录态", err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var input service.TrustedDeviceRevokeInput
|
|
||||||
if err := c.ShouldBindJSON(&input); err != nil {
|
|
||||||
response.Error(c, apperror.BadRequest("AUTH_TRUSTED_DEVICE_INVALID", "参数不合法", err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := h.authService.RevokeTrustedDevice(c.Request.Context(), subject, c.Param("id"), input); err != nil {
|
|
||||||
response.Error(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
clearTrustedDeviceCookie(c)
|
|
||||||
response.Success(c, gin.H{"deleted": true})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *AuthHandler) Logout(c *gin.Context) {
|
func (h *AuthHandler) Logout(c *gin.Context) {
|
||||||
response.Success(c, gin.H{"loggedOut": true})
|
response.Success(c, gin.H{"loggedOut": true})
|
||||||
}
|
}
|
||||||
|
|
||||||
func webAuthnRequestContext(c *gin.Context) service.WebAuthnRequestContext {
|
|
||||||
host := firstForwardedValue(c.Request.Host)
|
|
||||||
if forwardedHost := firstForwardedValue(c.GetHeader("X-Forwarded-Host")); forwardedHost != "" {
|
|
||||||
host = forwardedHost
|
|
||||||
}
|
|
||||||
rpID := host
|
|
||||||
if parsedHost, _, err := net.SplitHostPort(host); err == nil {
|
|
||||||
rpID = parsedHost
|
|
||||||
}
|
|
||||||
scheme := "http"
|
|
||||||
if c.Request.TLS != nil {
|
|
||||||
scheme = "https"
|
|
||||||
}
|
|
||||||
if forwardedProto := firstForwardedValue(c.GetHeader("X-Forwarded-Proto")); forwardedProto != "" {
|
|
||||||
scheme = forwardedProto
|
|
||||||
}
|
|
||||||
origin := strings.TrimSpace(c.GetHeader("Origin"))
|
|
||||||
if origin == "" {
|
|
||||||
origin = scheme + "://" + host
|
|
||||||
}
|
|
||||||
return service.WebAuthnRequestContext{RPID: rpID, Origin: origin}
|
|
||||||
}
|
|
||||||
|
|
||||||
func firstForwardedValue(value string) string {
|
|
||||||
parts := strings.Split(value, ",")
|
|
||||||
if len(parts) == 0 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return strings.TrimSpace(parts[0])
|
|
||||||
}
|
|
||||||
|
|
||||||
func trustedDeviceCookieValue(c *gin.Context) string {
|
|
||||||
token, err := c.Cookie(trustedDeviceCookieName)
|
|
||||||
if err != nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return strings.TrimSpace(token)
|
|
||||||
}
|
|
||||||
|
|
||||||
func setTrustedDeviceCookie(c *gin.Context, token string) {
|
|
||||||
writeTrustedDeviceCookie(c, strings.TrimSpace(token), trustedDeviceCookieMaxAge)
|
|
||||||
}
|
|
||||||
|
|
||||||
func clearTrustedDeviceCookie(c *gin.Context) {
|
|
||||||
writeTrustedDeviceCookie(c, "", -1)
|
|
||||||
}
|
|
||||||
|
|
||||||
func writeTrustedDeviceCookie(c *gin.Context, value string, maxAge int) {
|
|
||||||
c.SetSameSite(stdhttp.SameSiteLaxMode)
|
|
||||||
c.SetCookie(trustedDeviceCookieName, value, maxAge, trustedDeviceCookiePath, "", requestIsSecure(c), true)
|
|
||||||
}
|
|
||||||
|
|
||||||
func requestIsSecure(c *gin.Context) bool {
|
|
||||||
if c.Request.TLS != nil {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return strings.EqualFold(firstForwardedValue(c.GetHeader("X-Forwarded-Proto")), "https")
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -16,13 +16,12 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type BackupRecordHandler struct {
|
type BackupRecordHandler struct {
|
||||||
service *service.BackupRecordService
|
service *service.BackupRecordService
|
||||||
restoreService *service.RestoreService
|
auditService *service.AuditService
|
||||||
auditService *service.AuditService
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewBackupRecordHandler(recordService *service.BackupRecordService, restoreService *service.RestoreService, auditService *service.AuditService) *BackupRecordHandler {
|
func NewBackupRecordHandler(recordService *service.BackupRecordService, auditService *service.AuditService) *BackupRecordHandler {
|
||||||
return &BackupRecordHandler{service: recordService, restoreService: restoreService, auditService: auditService}
|
return &BackupRecordHandler{service: recordService, auditService: auditService}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *BackupRecordHandler) List(c *gin.Context) {
|
func (h *BackupRecordHandler) List(c *gin.Context) {
|
||||||
@@ -122,29 +121,18 @@ func (h *BackupRecordHandler) Download(c *gin.Context) {
|
|||||||
_, _ = io.Copy(c.Writer, result.Reader)
|
_, _ = io.Copy(c.Writer, result.Reader)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Restore 启动一次异步恢复并返回 restoreRecordId;实际执行路由由 RestoreService
|
|
||||||
// 根据 task.NodeID 决定(本地 Master or 远程 Agent)。
|
|
||||||
func (h *BackupRecordHandler) Restore(c *gin.Context) {
|
func (h *BackupRecordHandler) Restore(c *gin.Context) {
|
||||||
id, ok := parseUintParam(c, "id")
|
id, ok := parseUintParam(c, "id")
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if h.restoreService == nil {
|
if err := h.service.Restore(c.Request.Context(), id); err != nil {
|
||||||
response.Error(c, apperror.Internal("RESTORE_SERVICE_DISABLED", "恢复服务未启用", nil))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
triggeredBy := ""
|
|
||||||
if subject, exists := c.Get(contextUserSubjectKey); exists {
|
|
||||||
triggeredBy = strings.TrimSpace(fmt.Sprintf("%v", subject))
|
|
||||||
}
|
|
||||||
detail, err := h.restoreService.Start(c.Request.Context(), id, triggeredBy)
|
|
||||||
if err != nil {
|
|
||||||
response.Error(c, err)
|
response.Error(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
recordAudit(c, h.auditService, "backup_record", "restore", "backup_record", fmt.Sprintf("%d", id), "",
|
recordAudit(c, h.auditService, "backup_record", "restore", "backup_record", fmt.Sprintf("%d", id), "",
|
||||||
fmt.Sprintf("启动恢复 (备份记录 ID: %d, 恢复记录 ID: %d)", id, detail.ID))
|
fmt.Sprintf("恢复备份记录 (ID: %d)", id))
|
||||||
response.Success(c, detail)
|
response.Success(c, gin.H{"restored": true})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *BackupRecordHandler) Delete(c *gin.Context) {
|
func (h *BackupRecordHandler) Delete(c *gin.Context) {
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package http
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"backupx/server/internal/apperror"
|
|
||||||
"backupx/server/internal/service"
|
"backupx/server/internal/service"
|
||||||
"backupx/server/pkg/response"
|
"backupx/server/pkg/response"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -31,37 +30,3 @@ func (h *BackupRunHandler) Run(c *gin.Context) {
|
|||||||
recordAudit(c, h.auditService, "backup_task", "run", "backup_task", fmt.Sprintf("%d", id), "", "手动触发备份")
|
recordAudit(c, h.auditService, "backup_task", "run", "backup_task", fmt.Sprintf("%d", id), "", "手动触发备份")
|
||||||
response.Success(c, record)
|
response.Success(c, record)
|
||||||
}
|
}
|
||||||
|
|
||||||
// BatchRun 批量触发备份任务。best-effort:单个失败不影响其他。
|
|
||||||
// Body: {"ids": [1,2,3]}
|
|
||||||
func (h *BackupRunHandler) BatchRun(c *gin.Context) {
|
|
||||||
var input struct {
|
|
||||||
IDs []uint `json:"ids" binding:"required,min=1"`
|
|
||||||
}
|
|
||||||
if err := c.ShouldBindJSON(&input); err != nil {
|
|
||||||
response.Error(c, apperror.BadRequest("BACKUP_TASK_BATCH_INVALID", "批量执行参数不合法", err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
results := make([]service.BatchResult, 0, len(input.IDs))
|
|
||||||
succ := 0
|
|
||||||
for _, id := range input.IDs {
|
|
||||||
if id == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
_, err := h.service.RunTaskByID(c.Request.Context(), id)
|
|
||||||
item := service.BatchResult{ID: id, Success: err == nil}
|
|
||||||
if err != nil {
|
|
||||||
if appErr, ok := err.(*apperror.AppError); ok {
|
|
||||||
item.Error = appErr.Message
|
|
||||||
} else {
|
|
||||||
item.Error = err.Error()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
succ++
|
|
||||||
}
|
|
||||||
results = append(results, item)
|
|
||||||
}
|
|
||||||
recordAudit(c, h.auditService, "backup_task", "batch_run", "backup_task", "", "",
|
|
||||||
fmt.Sprintf("批量触发备份 %d/%d", succ, len(results)))
|
|
||||||
response.Success(c, results)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -40,16 +40,6 @@ func (h *BackupTaskHandler) List(c *gin.Context) {
|
|||||||
response.Success(c, items)
|
response.Success(c, items)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListTags 返回系统内所有任务用过的唯一标签列表,供前端标签选择器的建议词。
|
|
||||||
func (h *BackupTaskHandler) ListTags(c *gin.Context) {
|
|
||||||
tags, err := h.service.ListTags(c.Request.Context())
|
|
||||||
if err != nil {
|
|
||||||
response.Error(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
response.Success(c, tags)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *BackupTaskHandler) Get(c *gin.Context) {
|
func (h *BackupTaskHandler) Get(c *gin.Context) {
|
||||||
id, ok := parseUintParam(c, "id")
|
id, ok := parseUintParam(c, "id")
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -116,55 +106,6 @@ func (h *BackupTaskHandler) Delete(c *gin.Context) {
|
|||||||
response.Success(c, gin.H{"deleted": true})
|
response.Success(c, gin.H{"deleted": true})
|
||||||
}
|
}
|
||||||
|
|
||||||
// BatchToggle / BatchDelete 批量操作。
|
|
||||||
// Body: {"ids": [1,2,3], "enabled": true} (enabled 仅 toggle 用)
|
|
||||||
func (h *BackupTaskHandler) BatchToggle(c *gin.Context) {
|
|
||||||
var input struct {
|
|
||||||
IDs []uint `json:"ids" binding:"required,min=1"`
|
|
||||||
Enabled bool `json:"enabled"`
|
|
||||||
}
|
|
||||||
if err := c.ShouldBindJSON(&input); err != nil {
|
|
||||||
response.Error(c, apperror.BadRequest("BACKUP_TASK_BATCH_INVALID", "批量操作参数不合法", err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
results := h.service.BatchToggle(c.Request.Context(), input.IDs, input.Enabled)
|
|
||||||
succ := 0
|
|
||||||
for _, r := range results {
|
|
||||||
if r.Success {
|
|
||||||
succ++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
action := "batch_enable"
|
|
||||||
label := "启用"
|
|
||||||
if !input.Enabled {
|
|
||||||
action = "batch_disable"
|
|
||||||
label = "停用"
|
|
||||||
}
|
|
||||||
recordAudit(c, h.auditService, "backup_task", action, "backup_task", "", "",
|
|
||||||
fmt.Sprintf("批量%s %d/%d 个任务", label, succ, len(results)))
|
|
||||||
response.Success(c, results)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *BackupTaskHandler) BatchDelete(c *gin.Context) {
|
|
||||||
var input struct {
|
|
||||||
IDs []uint `json:"ids" binding:"required,min=1"`
|
|
||||||
}
|
|
||||||
if err := c.ShouldBindJSON(&input); err != nil {
|
|
||||||
response.Error(c, apperror.BadRequest("BACKUP_TASK_BATCH_INVALID", "批量删除参数不合法", err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
results := h.service.BatchDeleteTasks(c.Request.Context(), input.IDs)
|
|
||||||
succ := 0
|
|
||||||
for _, r := range results {
|
|
||||||
if r.Success {
|
|
||||||
succ++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
recordAudit(c, h.auditService, "backup_task", "batch_delete", "backup_task", "", "",
|
|
||||||
fmt.Sprintf("批量删除 %d/%d 个任务", succ, len(results)))
|
|
||||||
response.Success(c, results)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *BackupTaskHandler) Toggle(c *gin.Context) {
|
func (h *BackupTaskHandler) Toggle(c *gin.Context) {
|
||||||
id, ok := parseUintParam(c, "id")
|
id, ok := parseUintParam(c, "id")
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|||||||
@@ -1,9 +1,3 @@
|
|||||||
package http
|
package http
|
||||||
|
|
||||||
const (
|
const contextUserSubjectKey = "userSubject"
|
||||||
contextUserSubjectKey = "userSubject"
|
|
||||||
contextUserRoleKey = "userRole"
|
|
||||||
contextUsernameKey = "username"
|
|
||||||
// contextAuthSubjectKey 标识认证主体来源(user | api_key),便于审计追踪。
|
|
||||||
contextAuthSubjectKey = "authSubject"
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -27,58 +27,6 @@ func (h *DashboardHandler) Stats(c *gin.Context) {
|
|||||||
response.Success(c, payload)
|
response.Success(c, payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SLA 返回所有启用任务的 SLA 合规视图。用于 Dashboard 企业合规卡片。
|
|
||||||
func (h *DashboardHandler) SLA(c *gin.Context) {
|
|
||||||
payload, err := h.service.SLACompliance(c.Request.Context())
|
|
||||||
if err != nil {
|
|
||||||
response.Error(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
response.Success(c, payload)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cluster 返回集群节点概览(在线/离线/过期 Agent 等),用于 Dashboard 卡片。
|
|
||||||
func (h *DashboardHandler) Cluster(c *gin.Context) {
|
|
||||||
payload, err := h.service.ClusterOverview(c.Request.Context())
|
|
||||||
if err != nil {
|
|
||||||
response.Error(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
response.Success(c, payload)
|
|
||||||
}
|
|
||||||
|
|
||||||
// NodePerformance 返回各节点近 N 天的执行表现(成功率/字节数/平均耗时)。
|
|
||||||
func (h *DashboardHandler) NodePerformance(c *gin.Context) {
|
|
||||||
days := 30
|
|
||||||
if v := strings.TrimSpace(c.Query("days")); v != "" {
|
|
||||||
if parsed, err := strconv.Atoi(v); err == nil && parsed > 0 {
|
|
||||||
days = parsed
|
|
||||||
}
|
|
||||||
}
|
|
||||||
payload, err := h.service.NodePerformance(c.Request.Context(), days)
|
|
||||||
if err != nil {
|
|
||||||
response.Error(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
response.Success(c, payload)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Breakdown 返回按类型/状态/节点/存储分组的统计。
|
|
||||||
func (h *DashboardHandler) Breakdown(c *gin.Context) {
|
|
||||||
days := 30
|
|
||||||
if v := strings.TrimSpace(c.Query("days")); v != "" {
|
|
||||||
if parsed, err := strconv.Atoi(v); err == nil && parsed > 0 {
|
|
||||||
days = parsed
|
|
||||||
}
|
|
||||||
}
|
|
||||||
payload, err := h.service.Breakdown(c.Request.Context(), days)
|
|
||||||
if err != nil {
|
|
||||||
response.Error(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
response.Success(c, payload)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *DashboardHandler) Timeline(c *gin.Context) {
|
func (h *DashboardHandler) Timeline(c *gin.Context) {
|
||||||
days := 30
|
days := 30
|
||||||
if value := strings.TrimSpace(c.Query("days")); value != "" {
|
if value := strings.TrimSpace(c.Query("days")); value != "" {
|
||||||
|
|||||||
@@ -1,81 +0,0 @@
|
|||||||
package http
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"backupx/server/internal/apperror"
|
|
||||||
"backupx/server/internal/service"
|
|
||||||
"backupx/server/pkg/response"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
)
|
|
||||||
|
|
||||||
// EventsHandler 实时事件推送(SSE)。
|
|
||||||
// 前端通过 EventSource 订阅 /api/events/stream,实时接收系统事件,
|
|
||||||
// 用于 Dashboard 免刷新更新 / 桌面 Toast / 实时告警。
|
|
||||||
type EventsHandler struct {
|
|
||||||
broadcaster *service.EventBroadcaster
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewEventsHandler(broadcaster *service.EventBroadcaster) *EventsHandler {
|
|
||||||
return &EventsHandler{broadcaster: broadcaster}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stream SSE 长连接。JWT/API Key 中间件之后。
|
|
||||||
// 心跳:每 25s 发一条 comment 行(: keepalive)保持连接不被代理断开。
|
|
||||||
func (h *EventsHandler) Stream(c *gin.Context) {
|
|
||||||
if h.broadcaster == nil {
|
|
||||||
response.Error(c, apperror.Internal("EVENTS_DISABLED", "事件广播器未启用", nil))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.Writer.Header().Set("Content-Type", "text/event-stream")
|
|
||||||
c.Writer.Header().Set("Cache-Control", "no-cache")
|
|
||||||
c.Writer.Header().Set("Connection", "keep-alive")
|
|
||||||
c.Writer.Header().Set("X-Accel-Buffering", "no") // 禁用 nginx 缓冲
|
|
||||||
flusher, ok := c.Writer.(interface{ Flush() })
|
|
||||||
if !ok {
|
|
||||||
response.Error(c, apperror.Internal("EVENTS_STREAM_UNSUPPORTED", "当前连接不支持 SSE", nil))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// 首先发送一次 hello 让客户端确认连通
|
|
||||||
_, _ = fmt.Fprintf(c.Writer, ": connected %d\n\n", time.Now().Unix())
|
|
||||||
flusher.Flush()
|
|
||||||
|
|
||||||
ch, cancel := h.broadcaster.Subscribe(32)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
heartbeat := time.NewTicker(25 * time.Second)
|
|
||||||
defer heartbeat.Stop()
|
|
||||||
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-c.Request.Context().Done():
|
|
||||||
return
|
|
||||||
case <-heartbeat.C:
|
|
||||||
if _, err := fmt.Fprintf(c.Writer, ": heartbeat %d\n\n", time.Now().Unix()); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
flusher.Flush()
|
|
||||||
case envelope, ok := <-ch:
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := writeEventEnvelope(c.Writer, envelope); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
flusher.Flush()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func writeEventEnvelope(writer io.Writer, envelope service.EventEnvelope) error {
|
|
||||||
data, err := json.Marshal(envelope)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
_, err = fmt.Fprintf(writer, "event: %s\ndata: %s\n\n", envelope.Type, data)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
package http
|
|
||||||
|
|
||||||
import (
|
|
||||||
stdhttp "net/http"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
|
||||||
|
|
||||||
// HealthHandler 提供 K8s/Swarm 风格的健康检查端点。
|
|
||||||
//
|
|
||||||
// - /health :liveness 探针。进程存活即 200(不检查任何依赖)。
|
|
||||||
// - /ready :readiness 探针。检查数据库连通,不通则返回 503。
|
|
||||||
//
|
|
||||||
// 两者均为公开端点(无认证中间件),供外部编排系统探测。
|
|
||||||
// 输出最少信息,避免泄露内部结构。
|
|
||||||
type HealthHandler struct {
|
|
||||||
db *gorm.DB
|
|
||||||
startedAt time.Time
|
|
||||||
version string
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewHealthHandler(db *gorm.DB, version string) *HealthHandler {
|
|
||||||
return &HealthHandler{db: db, startedAt: time.Now().UTC(), version: version}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Live 用于 liveness:只要进程能响应就返回 200。
|
|
||||||
func (h *HealthHandler) Live(c *gin.Context) {
|
|
||||||
c.JSON(stdhttp.StatusOK, gin.H{
|
|
||||||
"status": "live",
|
|
||||||
"version": h.version,
|
|
||||||
"uptime": int(time.Since(h.startedAt).Seconds()),
|
|
||||||
"timestamp": time.Now().UTC().Format(time.RFC3339),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ready 用于 readiness:依赖(数据库)不可用时返回 503。
|
|
||||||
// 新实例启动或数据库短暂失联时,编排系统据此停止转发流量。
|
|
||||||
func (h *HealthHandler) Ready(c *gin.Context) {
|
|
||||||
checks := map[string]string{}
|
|
||||||
overallOK := true
|
|
||||||
if h.db != nil {
|
|
||||||
sqlDB, err := h.db.DB()
|
|
||||||
if err != nil {
|
|
||||||
checks["database"] = "error: " + err.Error()
|
|
||||||
overallOK = false
|
|
||||||
} else {
|
|
||||||
ctx, cancel := c.Request.Context(), func() {}
|
|
||||||
_ = cancel
|
|
||||||
if err := sqlDB.PingContext(ctx); err != nil {
|
|
||||||
checks["database"] = "ping failed: " + err.Error()
|
|
||||||
overallOK = false
|
|
||||||
} else {
|
|
||||||
checks["database"] = "ok"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
checks["database"] = "not configured"
|
|
||||||
overallOK = false
|
|
||||||
}
|
|
||||||
status := stdhttp.StatusOK
|
|
||||||
state := "ready"
|
|
||||||
if !overallOK {
|
|
||||||
status = stdhttp.StatusServiceUnavailable
|
|
||||||
state = "not_ready"
|
|
||||||
}
|
|
||||||
c.JSON(status, gin.H{
|
|
||||||
"status": state,
|
|
||||||
"version": h.version,
|
|
||||||
"uptime": int(time.Since(h.startedAt).Seconds()),
|
|
||||||
"checks": checks,
|
|
||||||
"timestamp": time.Now().UTC().Format(time.RFC3339),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -3,12 +3,10 @@ package http
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/base64"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -19,7 +17,6 @@ import (
|
|||||||
"backupx/server/internal/repository"
|
"backupx/server/internal/repository"
|
||||||
"backupx/server/internal/security"
|
"backupx/server/internal/security"
|
||||||
"backupx/server/internal/service"
|
"backupx/server/internal/service"
|
||||||
"backupx/server/internal/storage/codec"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// setupInstallFlowRouter 构造一个 Node + Agent + InstallToken 全量依赖的 router,
|
// setupInstallFlowRouter 构造一个 Node + Agent + InstallToken 全量依赖的 router,
|
||||||
@@ -41,13 +38,6 @@ func setupInstallFlowRouter(t *testing.T) (http.Handler, string) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("db: %v", err)
|
t.Fatalf("db: %v", err)
|
||||||
}
|
}
|
||||||
sqlDB, err := db.DB()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("sql db: %v", err)
|
|
||||||
}
|
|
||||||
t.Cleanup(func() {
|
|
||||||
_ = sqlDB.Close()
|
|
||||||
})
|
|
||||||
|
|
||||||
userRepo := repository.NewUserRepository(db)
|
userRepo := repository.NewUserRepository(db)
|
||||||
systemConfigRepo := repository.NewSystemConfigRepository(db)
|
systemConfigRepo := repository.NewSystemConfigRepository(db)
|
||||||
@@ -56,7 +46,7 @@ func setupInstallFlowRouter(t *testing.T) (http.Handler, string) {
|
|||||||
t.Fatalf("security: %v", err)
|
t.Fatalf("security: %v", err)
|
||||||
}
|
}
|
||||||
jwtMgr := security.NewJWTManager(resolved.JWTSecret, time.Hour)
|
jwtMgr := security.NewJWTManager(resolved.JWTSecret, time.Hour)
|
||||||
authSvc := service.NewAuthService(userRepo, systemConfigRepo, jwtMgr, security.NewLoginRateLimiter(5, time.Minute), codec.NewConfigCipher(resolved.EncryptionKey))
|
authSvc := service.NewAuthService(userRepo, systemConfigRepo, jwtMgr, security.NewLoginRateLimiter(5, time.Minute))
|
||||||
systemSvc := service.NewSystemService(cfg, "test", time.Now().UTC())
|
systemSvc := service.NewSystemService(cfg, "test", time.Now().UTC())
|
||||||
|
|
||||||
nodeRepo := repository.NewNodeRepository(db)
|
nodeRepo := repository.NewNodeRepository(db)
|
||||||
@@ -162,8 +152,6 @@ func TestOneClickInstallFlow(t *testing.T) {
|
|||||||
Data struct {
|
Data struct {
|
||||||
InstallToken string `json:"installToken"`
|
InstallToken string `json:"installToken"`
|
||||||
URL string `json:"url"`
|
URL string `json:"url"`
|
||||||
FallbackURL string `json:"fallbackUrl"`
|
|
||||||
ScriptBase64 string `json:"scriptBase64"`
|
|
||||||
} `json:"data"`
|
} `json:"data"`
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(genRec.Body.Bytes(), &genResp); err != nil {
|
if err := json.Unmarshal(genRec.Body.Bytes(), &genResp); err != nil {
|
||||||
@@ -172,16 +160,6 @@ func TestOneClickInstallFlow(t *testing.T) {
|
|||||||
if genResp.Data.InstallToken == "" {
|
if genResp.Data.InstallToken == "" {
|
||||||
t.Fatalf("missing installToken")
|
t.Fatalf("missing installToken")
|
||||||
}
|
}
|
||||||
if !strings.Contains(genResp.Data.FallbackURL, "/install/") {
|
|
||||||
t.Fatalf("missing fallback install URL, got %q", genResp.Data.FallbackURL)
|
|
||||||
}
|
|
||||||
decodedScript, err := base64.StdEncoding.DecodeString(genResp.Data.ScriptBase64)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("scriptBase64 should be valid base64: %v", err)
|
|
||||||
}
|
|
||||||
if !strings.Contains(string(decodedScript), "BACKUPX_AGENT_INSTALL_V1") {
|
|
||||||
t.Fatalf("scriptBase64 should contain rendered install script")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. 公开端点消费
|
// 3. 公开端点消费
|
||||||
scriptReq := httptest.NewRequest(http.MethodGet, "/install/"+genResp.Data.InstallToken, nil)
|
scriptReq := httptest.NewRequest(http.MethodGet, "/install/"+genResp.Data.InstallToken, nil)
|
||||||
@@ -193,22 +171,6 @@ func TestOneClickInstallFlow(t *testing.T) {
|
|||||||
if !strings.Contains(scriptRec.Body.String(), "systemctl enable --now backupx-agent") {
|
if !strings.Contains(scriptRec.Body.String(), "systemctl enable --now backupx-agent") {
|
||||||
t.Fatalf("script missing systemctl enable:\n%s", scriptRec.Body.String())
|
t.Fatalf("script missing systemctl enable:\n%s", scriptRec.Body.String())
|
||||||
}
|
}
|
||||||
// Issue #46 防嗅探 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
|
// 4. 再次消费应 410
|
||||||
scriptReq2 := httptest.NewRequest(http.MethodGet, "/install/"+genResp.Data.InstallToken, nil)
|
scriptReq2 := httptest.NewRequest(http.MethodGet, "/install/"+genResp.Data.InstallToken, nil)
|
||||||
@@ -219,81 +181,6 @@ func TestOneClickInstallFlow(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestInstallScriptAliasUnderAPI 验证 /api/install/:token 别名路径可用,
|
|
||||||
// 这是 Issue #46 的根本修复:让 install 端点自动命中反向代理的 /api/ 转发规则,
|
|
||||||
// 避免 nginx SPA fallback 把请求当前端路由返回 index.html。
|
|
||||||
func TestInstallScriptAliasUnderAPI(t *testing.T) {
|
|
||||||
router, token := setupInstallFlowRouter(t)
|
|
||||||
|
|
||||||
// 1. 创建一个节点,生成 install token
|
|
||||||
batchBody, _ := json.Marshal(map[string][]string{"names": {"alias-node"}})
|
|
||||||
batchReq := httptest.NewRequest(http.MethodPost, "/api/nodes/batch", bytes.NewReader(batchBody))
|
|
||||||
batchReq.Header.Set("Content-Type", "application/json")
|
|
||||||
batchReq.Header.Set("Authorization", "Bearer "+token)
|
|
||||||
batchRec := httptest.NewRecorder()
|
|
||||||
router.ServeHTTP(batchRec, batchReq)
|
|
||||||
if batchRec.Code != 200 {
|
|
||||||
t.Fatalf("batch create failed: %d %s", batchRec.Code, batchRec.Body.String())
|
|
||||||
}
|
|
||||||
var batchResp struct {
|
|
||||||
Data []struct {
|
|
||||||
ID uint `json:"id"`
|
|
||||||
} `json:"data"`
|
|
||||||
}
|
|
||||||
_ = json.Unmarshal(batchRec.Body.Bytes(), &batchResp)
|
|
||||||
if len(batchResp.Data) == 0 {
|
|
||||||
t.Fatalf("batch create returned no nodes: %s", batchRec.Body.String())
|
|
||||||
}
|
|
||||||
nodeID := batchResp.Data[0].ID
|
|
||||||
|
|
||||||
genBody, _ := json.Marshal(map[string]any{
|
|
||||||
"mode": "systemd", "arch": "auto", "agentVersion": "v1.7.0", "downloadSrc": "github", "ttlSeconds": 600,
|
|
||||||
})
|
|
||||||
genReq := httptest.NewRequest(http.MethodPost,
|
|
||||||
"/api/nodes/"+strconv.FormatUint(uint64(nodeID), 10)+"/install-tokens", bytes.NewReader(genBody))
|
|
||||||
genReq.Header.Set("Content-Type", "application/json")
|
|
||||||
genReq.Header.Set("Authorization", "Bearer "+token)
|
|
||||||
genRec := httptest.NewRecorder()
|
|
||||||
router.ServeHTTP(genRec, genReq)
|
|
||||||
if genRec.Code != 200 {
|
|
||||||
t.Fatalf("gen install token failed: %d %s", genRec.Code, genRec.Body.String())
|
|
||||||
}
|
|
||||||
var genResp struct {
|
|
||||||
Data struct {
|
|
||||||
InstallToken string `json:"installToken"`
|
|
||||||
URL string `json:"url"`
|
|
||||||
FallbackURL string `json:"fallbackUrl"`
|
|
||||||
ScriptBase64 string `json:"scriptBase64"`
|
|
||||||
} `json:"data"`
|
|
||||||
}
|
|
||||||
_ = json.Unmarshal(genRec.Body.Bytes(), &genResp)
|
|
||||||
|
|
||||||
// 2. 新生成的 url 应指向 /api/install/... —— 让反向代理的 /api/ 转发规则自动接管
|
|
||||||
if !strings.Contains(genResp.Data.URL, "/api/install/") {
|
|
||||||
t.Errorf("new install URL should use /api/install/ prefix, got %s", genResp.Data.URL)
|
|
||||||
}
|
|
||||||
if !strings.Contains(genResp.Data.FallbackURL, "/install/") {
|
|
||||||
t.Errorf("fallback install URL should use /install/ prefix, got %s", genResp.Data.FallbackURL)
|
|
||||||
}
|
|
||||||
if genResp.Data.ScriptBase64 == "" {
|
|
||||||
t.Errorf("new install response should include scriptBase64 for proxy-independent commands")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. /api/install/:token 必须可消费(与 /install/:token 等价)
|
|
||||||
aliasReq := httptest.NewRequest(http.MethodGet, "/api/install/"+genResp.Data.InstallToken, nil)
|
|
||||||
aliasRec := httptest.NewRecorder()
|
|
||||||
router.ServeHTTP(aliasRec, aliasReq)
|
|
||||||
if aliasRec.Code != 200 {
|
|
||||||
t.Fatalf("/api/install alias failed: %d %s", aliasRec.Code, aliasRec.Body.String())
|
|
||||||
}
|
|
||||||
if !strings.Contains(aliasRec.Body.String(), "systemctl enable --now backupx-agent") {
|
|
||||||
t.Errorf("alias should return rendered script, got:\n%s", aliasRec.Body.String())
|
|
||||||
}
|
|
||||||
if ct := aliasRec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/plain") {
|
|
||||||
t.Errorf("alias Content-Type should be text/plain*, got %q", ct)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestInstallTokenRateLimit(t *testing.T) {
|
func TestInstallTokenRateLimit(t *testing.T) {
|
||||||
router, jwt := setupInstallFlowRouter(t)
|
router, jwt := setupInstallFlowRouter(t)
|
||||||
|
|
||||||
|
|||||||
@@ -36,13 +36,6 @@ func NewInstallHandler(gcCtx context.Context, tokenService *service.InstallToken
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Script 消费 install token 并返回 shell 脚本;Mode 由 token 存储决定(systemd/docker/foreground 均返回 shell)。
|
// Script 消费 install token 并返回 shell 脚本;Mode 由 token 存储决定(systemd/docker/foreground 均返回 shell)。
|
||||||
//
|
|
||||||
// 响应头策略(issue #46 教训):
|
|
||||||
// - Content-Type 用 text/plain 而非 text/x-shellscript:避免 Cloudflare/反向代理把
|
|
||||||
// 脚本内容按特殊类型识别并触发 minify/HTML rewrite,导致 `curl | sh` 收到非脚本内容
|
|
||||||
// - X-Content-Type-Options: nosniff:禁止浏览器/中间层按内容嗅探改写 MIME
|
|
||||||
// - Cache-Control: no-store:token 一次性消费,禁止任何缓存层留存旧脚本
|
|
||||||
// - Content-Disposition: inline; filename=...:部分代理会跳过带文件名的响应
|
|
||||||
func (h *InstallHandler) Script(c *gin.Context) {
|
func (h *InstallHandler) Script(c *gin.Context) {
|
||||||
if !h.limiter.allow(c.ClientIP()) {
|
if !h.limiter.allow(c.ClientIP()) {
|
||||||
c.String(stdhttp.StatusTooManyRequests, "请求过于频繁,请稍后再试\n")
|
c.String(stdhttp.StatusTooManyRequests, "请求过于频繁,请稍后再试\n")
|
||||||
@@ -59,15 +52,21 @@ func (h *InstallHandler) Script(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
h.recordConsumeAudit(c, consumed, "script")
|
h.recordConsumeAudit(c, consumed, "script")
|
||||||
script, err := renderInstallScript(resolveMasterURL(c, h.externalURL), consumed.Node, consumed.Record)
|
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,
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.String(stdhttp.StatusInternalServerError, "render error\n")
|
c.String(stdhttp.StatusInternalServerError, "render error\n")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.Header("X-Content-Type-Options", "nosniff")
|
c.Data(stdhttp.StatusOK, "text/x-shellscript; charset=utf-8", []byte(script))
|
||||||
c.Header("Cache-Control", "no-store")
|
|
||||||
c.Header("Content-Disposition", `inline; filename="backupx-agent-install.sh"`)
|
|
||||||
c.Data(stdhttp.StatusOK, "text/plain; charset=utf-8", []byte(script))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compose 消费 install token 并返回 docker-compose YAML,仅 Mode=docker 有效。
|
// Compose 消费 install token 并返回 docker-compose YAML,仅 Mode=docker 有效。
|
||||||
@@ -132,19 +131,6 @@ func (h *InstallHandler) recordConsumeAudit(c *gin.Context, consumed *service.Co
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func renderInstallScript(masterURL string, node *model.Node, record *model.AgentInstallToken) (string, error) {
|
|
||||||
return installscript.RenderScript(installscript.Context{
|
|
||||||
MasterURL: masterURL,
|
|
||||||
AgentToken: node.Token,
|
|
||||||
AgentVersion: record.AgentVer,
|
|
||||||
Mode: record.Mode,
|
|
||||||
Arch: record.Arch,
|
|
||||||
DownloadBase: installscript.DownloadBaseFor(record.DownloadSrc),
|
|
||||||
InstallPrefix: "/opt/backupx-agent",
|
|
||||||
NodeID: node.ID,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// resolveMasterURL 按优先级推导 Master URL:外部配置 > X-Forwarded-* > Request.Host。
|
// resolveMasterURL 按优先级推导 Master URL:外部配置 > X-Forwarded-* > Request.Host。
|
||||||
// 此为包级 helper,供 install_handler 和 node_handler 共用。
|
// 此为包级 helper,供 install_handler 和 node_handler 共用。
|
||||||
func resolveMasterURL(c *gin.Context, externalURL string) string {
|
func resolveMasterURL(c *gin.Context, externalURL string) string {
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package http
|
package http
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
stdhttp "net/http"
|
stdhttp "net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -27,94 +26,28 @@ func CORSMiddleware() gin.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ApiKeyAuthenticator 抽象 API Key 验证能力,避免 middleware 直接依赖 service 包。
|
func AuthMiddleware(jwtManager *security.JWTManager) gin.HandlerFunc {
|
||||||
// 实现方:service.ApiKeyService。未注入时 AuthMiddleware 仍然支持 JWT。
|
|
||||||
type ApiKeyAuthenticator interface {
|
|
||||||
Authenticate(ctx context.Context, rawKey string) (subject string, role string, err error)
|
|
||||||
}
|
|
||||||
|
|
||||||
// AuthMiddleware 支持两种认证方式:
|
|
||||||
// - JWT (Authorization: Bearer <jwt>):交互式用户
|
|
||||||
// - API Key (Authorization: Bearer bax_xxx 或 X-Api-Key: bax_xxx):第三方脚本
|
|
||||||
//
|
|
||||||
// JWT 会在 context 中写入 userSubject / userRole / username;
|
|
||||||
// API Key 会写入 authSubject=api_key:<id> / userRole=<key role>。
|
|
||||||
func AuthMiddleware(jwtManager *security.JWTManager, apiKeyAuth ApiKeyAuthenticator) gin.HandlerFunc {
|
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
rawToken := extractAuthToken(c)
|
header := strings.TrimSpace(c.GetHeader("Authorization"))
|
||||||
if rawToken == "" {
|
if !strings.HasPrefix(header, "Bearer ") {
|
||||||
response.Error(c, apperror.Unauthorized("AUTH_REQUIRED", "请先登录", nil))
|
response.Error(c, apperror.Unauthorized("AUTH_REQUIRED", "请先登录", nil))
|
||||||
c.Abort()
|
c.Abort()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if apiKeyAuth != nil && strings.HasPrefix(rawToken, "bax_") {
|
|
||||||
subject, role, err := apiKeyAuth.Authenticate(c.Request.Context(), rawToken)
|
tokenString := strings.TrimSpace(strings.TrimPrefix(header, "Bearer "))
|
||||||
if err != nil {
|
claims, err := jwtManager.Parse(tokenString)
|
||||||
response.Error(c, err)
|
|
||||||
c.Abort()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.Set(contextAuthSubjectKey, subject)
|
|
||||||
c.Set(contextUserRoleKey, role)
|
|
||||||
c.Set(contextUserSubjectKey, subject)
|
|
||||||
c.Set(contextUsernameKey, subject)
|
|
||||||
c.Next()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
claims, err := jwtManager.Parse(rawToken)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.Error(c, apperror.Unauthorized("AUTH_INVALID_TOKEN", "登录状态已失效,请重新登录", err))
|
response.Error(c, apperror.Unauthorized("AUTH_INVALID_TOKEN", "登录状态已失效,请重新登录", err))
|
||||||
c.Abort()
|
c.Abort()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
c.Set(contextUserSubjectKey, claims.Subject)
|
c.Set(contextUserSubjectKey, claims.Subject)
|
||||||
c.Set(contextUserRoleKey, claims.Role)
|
|
||||||
c.Set(contextUsernameKey, claims.Username)
|
|
||||||
c.Set(contextAuthSubjectKey, "user:"+claims.Subject)
|
|
||||||
c.Next()
|
c.Next()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// extractAuthToken 从 Authorization: Bearer 或 X-Api-Key 中提取原始 token。
|
|
||||||
func extractAuthToken(c *gin.Context) string {
|
|
||||||
header := strings.TrimSpace(c.GetHeader("Authorization"))
|
|
||||||
if strings.HasPrefix(header, "Bearer ") {
|
|
||||||
return strings.TrimSpace(strings.TrimPrefix(header, "Bearer "))
|
|
||||||
}
|
|
||||||
if key := strings.TrimSpace(c.GetHeader("X-Api-Key")); key != "" {
|
|
||||||
return key
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// RequireRole 仅放行指定角色,否则返回 403。
|
|
||||||
// 必须用在 AuthMiddleware 之后。viewer 只读保护、admin 管理端都靠它。
|
|
||||||
func RequireRole(roles ...string) gin.HandlerFunc {
|
|
||||||
allowed := make(map[string]bool, len(roles))
|
|
||||||
for _, r := range roles {
|
|
||||||
allowed[strings.ToLower(r)] = true
|
|
||||||
}
|
|
||||||
return func(c *gin.Context) {
|
|
||||||
role, _ := c.Get(contextUserRoleKey)
|
|
||||||
roleStr := ""
|
|
||||||
if v, ok := role.(string); ok {
|
|
||||||
roleStr = strings.ToLower(v)
|
|
||||||
}
|
|
||||||
if !allowed[roleStr] {
|
|
||||||
response.Error(c, apperror.New(403, "AUTH_FORBIDDEN", "当前角色无权执行此操作", nil))
|
|
||||||
c.Abort()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.Next()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// RequireNotViewer 是 RequireRole(admin, operator) 的快捷方式,
|
|
||||||
// 用于任何"写入/变更"类端点,禁止 viewer 触发。
|
|
||||||
func RequireNotViewer() gin.HandlerFunc {
|
|
||||||
return RequireRole("admin", "operator")
|
|
||||||
}
|
|
||||||
|
|
||||||
func ClientKey(c *gin.Context) string {
|
func ClientKey(c *gin.Context) string {
|
||||||
ip := strings.TrimSpace(c.ClientIP())
|
ip := strings.TrimSpace(c.ClientIP())
|
||||||
if ip == "" {
|
if ip == "" {
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package http
|
package http
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/base64"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
stdhttp "net/http"
|
stdhttp "net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -263,28 +262,14 @@ func (h *NodeHandler) CreateInstallToken(c *gin.Context) {
|
|||||||
fmt.Sprintf("生成 %s/%s install token TTL=%ds", input.Mode, input.Arch, input.TTLSeconds))
|
fmt.Sprintf("生成 %s/%s install token TTL=%ds", input.Mode, input.Arch, input.TTLSeconds))
|
||||||
|
|
||||||
masterURL := resolveMasterURL(c, h.externalURL)
|
masterURL := resolveMasterURL(c, h.externalURL)
|
||||||
script, err := renderInstallScript(masterURL, out.Node, out.Record)
|
|
||||||
if err != nil {
|
|
||||||
response.Error(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// 使用 /api/install/... 而非 /install/... —— 让反向代理的 /api/ 转发规则
|
|
||||||
// 自动接管,避免 SPA fallback 把请求当成前端路由返回 index.html(issue #46)。
|
|
||||||
// 同时返回 /install/... 备用地址,兼容会剥离 /api 前缀的外层反向代理。
|
|
||||||
// scriptBase64 让前端可以生成不依赖公开下载路径的嵌入式命令,解决 Lucky 等代理
|
|
||||||
// 把 /api/install/* 也 fallback 到 index.html 的场景。
|
|
||||||
body := gin.H{
|
body := gin.H{
|
||||||
"installToken": out.Token,
|
"installToken": out.Token,
|
||||||
"expiresAt": out.ExpiresAt,
|
"expiresAt": out.ExpiresAt,
|
||||||
"url": masterURL + "/api/install/" + out.Token,
|
"url": masterURL + "/install/" + out.Token,
|
||||||
"fallbackUrl": masterURL + "/install/" + out.Token,
|
"composeUrl": "",
|
||||||
"scriptBase64": base64.StdEncoding.EncodeToString([]byte(script)),
|
|
||||||
"composeUrl": "",
|
|
||||||
"fallbackComposeUrl": "",
|
|
||||||
}
|
}
|
||||||
if input.Mode == "docker" {
|
if input.Mode == "docker" {
|
||||||
body["composeUrl"] = masterURL + "/api/install/" + out.Token + "/compose.yml"
|
body["composeUrl"] = masterURL + "/install/" + out.Token + "/compose.yml"
|
||||||
body["fallbackComposeUrl"] = masterURL + "/install/" + out.Token + "/compose.yml"
|
|
||||||
}
|
}
|
||||||
response.Success(c, body)
|
response.Success(c, body)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,128 +0,0 @@
|
|||||||
package http
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"backupx/server/internal/apperror"
|
|
||||||
"backupx/server/internal/service"
|
|
||||||
"backupx/server/pkg/response"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ReplicationHandler 管理备份复制记录列表 + 手动触发。
|
|
||||||
type ReplicationHandler struct {
|
|
||||||
service *service.ReplicationService
|
|
||||||
auditService *service.AuditService
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewReplicationHandler(replicationService *service.ReplicationService, auditService *service.AuditService) *ReplicationHandler {
|
|
||||||
return &ReplicationHandler{service: replicationService, auditService: auditService}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TriggerByRecord 手动触发:从备份记录复制到指定目标存储。
|
|
||||||
// Body: {"destTargetId": 12}
|
|
||||||
func (h *ReplicationHandler) TriggerByRecord(c *gin.Context) {
|
|
||||||
recordID, ok := parseUintParam(c, "id")
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var input struct {
|
|
||||||
DestTargetID uint `json:"destTargetId" binding:"required,min=1"`
|
|
||||||
}
|
|
||||||
if err := c.ShouldBindJSON(&input); err != nil {
|
|
||||||
response.Error(c, apperror.BadRequest("REPLICATION_INVALID", "复制参数不合法", err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
triggeredBy := ""
|
|
||||||
if subject, exists := c.Get(contextUsernameKey); exists {
|
|
||||||
if v, ok := subject.(string); ok {
|
|
||||||
triggeredBy = v
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if triggeredBy == "" {
|
|
||||||
triggeredBy = "manual"
|
|
||||||
}
|
|
||||||
result, err := h.service.Start(c.Request.Context(), recordID, input.DestTargetID, triggeredBy)
|
|
||||||
if err != nil {
|
|
||||||
response.Error(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
recordAudit(c, h.auditService, "replication", "manual_run", "backup_record", fmt.Sprintf("%d", recordID), "",
|
|
||||||
fmt.Sprintf("手动触发复制(备份记录 #%d → 存储 #%d, 复制记录 #%d)", recordID, input.DestTargetID, result.ID))
|
|
||||||
response.Success(c, result)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *ReplicationHandler) List(c *gin.Context) {
|
|
||||||
filter, err := buildReplicationFilter(c)
|
|
||||||
if err != nil {
|
|
||||||
response.Error(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
items, err := h.service.List(c.Request.Context(), filter)
|
|
||||||
if err != nil {
|
|
||||||
response.Error(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
response.Success(c, items)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *ReplicationHandler) Get(c *gin.Context) {
|
|
||||||
id, ok := parseUintParam(c, "id")
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
item, err := h.service.Get(c.Request.Context(), id)
|
|
||||||
if err != nil {
|
|
||||||
response.Error(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
response.Success(c, item)
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildReplicationFilter(c *gin.Context) (service.ReplicationRecordListInput, error) {
|
|
||||||
var filter service.ReplicationRecordListInput
|
|
||||||
if v := strings.TrimSpace(c.Query("taskId")); v != "" {
|
|
||||||
parsed, err := strconv.ParseUint(v, 10, 32)
|
|
||||||
if err != nil {
|
|
||||||
return filter, apperror.BadRequest("REPLICATION_FILTER_INVALID", "taskId 不合法", err)
|
|
||||||
}
|
|
||||||
id := uint(parsed)
|
|
||||||
filter.TaskID = &id
|
|
||||||
}
|
|
||||||
if v := strings.TrimSpace(c.Query("backupRecordId")); v != "" {
|
|
||||||
parsed, err := strconv.ParseUint(v, 10, 32)
|
|
||||||
if err != nil {
|
|
||||||
return filter, apperror.BadRequest("REPLICATION_FILTER_INVALID", "backupRecordId 不合法", err)
|
|
||||||
}
|
|
||||||
id := uint(parsed)
|
|
||||||
filter.BackupRecordID = &id
|
|
||||||
}
|
|
||||||
if v := strings.TrimSpace(c.Query("destTargetId")); v != "" {
|
|
||||||
parsed, err := strconv.ParseUint(v, 10, 32)
|
|
||||||
if err != nil {
|
|
||||||
return filter, apperror.BadRequest("REPLICATION_FILTER_INVALID", "destTargetId 不合法", err)
|
|
||||||
}
|
|
||||||
id := uint(parsed)
|
|
||||||
filter.DestTargetID = &id
|
|
||||||
}
|
|
||||||
filter.Status = strings.TrimSpace(c.Query("status"))
|
|
||||||
if v := strings.TrimSpace(c.Query("dateFrom")); v != "" {
|
|
||||||
parsed, err := time.Parse(time.RFC3339, v)
|
|
||||||
if err != nil {
|
|
||||||
return filter, apperror.BadRequest("REPLICATION_FILTER_INVALID", "dateFrom 必须为 RFC3339", err)
|
|
||||||
}
|
|
||||||
filter.DateFrom = &parsed
|
|
||||||
}
|
|
||||||
if v := strings.TrimSpace(c.Query("dateTo")); v != "" {
|
|
||||||
parsed, err := time.Parse(time.RFC3339, v)
|
|
||||||
if err != nil {
|
|
||||||
return filter, apperror.BadRequest("REPLICATION_FILTER_INVALID", "dateTo 必须为 RFC3339", err)
|
|
||||||
}
|
|
||||||
filter.DateTo = &parsed
|
|
||||||
}
|
|
||||||
return filter, nil
|
|
||||||
}
|
|
||||||
@@ -1,162 +0,0 @@
|
|||||||
package http
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"backupx/server/internal/apperror"
|
|
||||||
"backupx/server/internal/backup"
|
|
||||||
"backupx/server/internal/service"
|
|
||||||
"backupx/server/pkg/response"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
)
|
|
||||||
|
|
||||||
// RestoreRecordHandler 提供恢复记录列表/详情/实时日志端点。
|
|
||||||
// 创建恢复由 BackupRecordHandler.Restore 代理到 RestoreService.Start。
|
|
||||||
type RestoreRecordHandler struct {
|
|
||||||
service *service.RestoreService
|
|
||||||
auditService *service.AuditService
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewRestoreRecordHandler(restoreService *service.RestoreService, auditService *service.AuditService) *RestoreRecordHandler {
|
|
||||||
return &RestoreRecordHandler{service: restoreService, auditService: auditService}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *RestoreRecordHandler) List(c *gin.Context) {
|
|
||||||
filter, err := buildRestoreFilter(c)
|
|
||||||
if err != nil {
|
|
||||||
response.Error(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
items, err := h.service.List(c.Request.Context(), filter)
|
|
||||||
if err != nil {
|
|
||||||
response.Error(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
response.Success(c, items)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *RestoreRecordHandler) Get(c *gin.Context) {
|
|
||||||
id, ok := parseUintParam(c, "id")
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
item, err := h.service.Get(c.Request.Context(), id)
|
|
||||||
if err != nil {
|
|
||||||
response.Error(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
response.Success(c, item)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *RestoreRecordHandler) StreamLogs(c *gin.Context) {
|
|
||||||
id, ok := parseUintParam(c, "id")
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
detail, err := h.service.Get(c.Request.Context(), id)
|
|
||||||
if err != nil {
|
|
||||||
response.Error(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
events := detail.LogEvents
|
|
||||||
completed := detail.Status != "running"
|
|
||||||
channel, cancel, err := h.service.SubscribeLogs(c.Request.Context(), id, 64)
|
|
||||||
if err != nil {
|
|
||||||
response.Error(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer cancel()
|
|
||||||
c.Writer.Header().Set("Content-Type", "text/event-stream")
|
|
||||||
c.Writer.Header().Set("Cache-Control", "no-cache")
|
|
||||||
c.Writer.Header().Set("Connection", "keep-alive")
|
|
||||||
flusher, ok := c.Writer.(interface{ Flush() })
|
|
||||||
if !ok {
|
|
||||||
response.Error(c, apperror.Internal("RESTORE_STREAM_UNSUPPORTED", "当前连接不支持日志流", nil))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
for _, event := range events {
|
|
||||||
if err := writeRestoreSSEEvent(c.Writer, event); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
flusher.Flush()
|
|
||||||
}
|
|
||||||
if completed {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-c.Request.Context().Done():
|
|
||||||
return
|
|
||||||
case event, ok := <-channel:
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := writeRestoreSSEEvent(c.Writer, event); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
flusher.Flush()
|
|
||||||
if event.Completed {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildRestoreFilter(c *gin.Context) (service.RestoreRecordListInput, error) {
|
|
||||||
var filter service.RestoreRecordListInput
|
|
||||||
if taskIDValue := strings.TrimSpace(c.Query("taskId")); taskIDValue != "" {
|
|
||||||
parsed, err := strconv.ParseUint(taskIDValue, 10, 32)
|
|
||||||
if err != nil {
|
|
||||||
return filter, apperror.BadRequest("RESTORE_RECORD_FILTER_INVALID", "taskId 不合法", err)
|
|
||||||
}
|
|
||||||
v := uint(parsed)
|
|
||||||
filter.TaskID = &v
|
|
||||||
}
|
|
||||||
if backupValue := strings.TrimSpace(c.Query("backupRecordId")); backupValue != "" {
|
|
||||||
parsed, err := strconv.ParseUint(backupValue, 10, 32)
|
|
||||||
if err != nil {
|
|
||||||
return filter, apperror.BadRequest("RESTORE_RECORD_FILTER_INVALID", "backupRecordId 不合法", err)
|
|
||||||
}
|
|
||||||
v := uint(parsed)
|
|
||||||
filter.BackupRecordID = &v
|
|
||||||
}
|
|
||||||
if nodeValue := strings.TrimSpace(c.Query("nodeId")); nodeValue != "" {
|
|
||||||
parsed, err := strconv.ParseUint(nodeValue, 10, 32)
|
|
||||||
if err != nil {
|
|
||||||
return filter, apperror.BadRequest("RESTORE_RECORD_FILTER_INVALID", "nodeId 不合法", err)
|
|
||||||
}
|
|
||||||
v := uint(parsed)
|
|
||||||
filter.NodeID = &v
|
|
||||||
}
|
|
||||||
filter.Status = strings.TrimSpace(c.Query("status"))
|
|
||||||
if dateFrom := strings.TrimSpace(c.Query("dateFrom")); dateFrom != "" {
|
|
||||||
parsed, err := time.Parse(time.RFC3339, dateFrom)
|
|
||||||
if err != nil {
|
|
||||||
return filter, apperror.BadRequest("RESTORE_RECORD_FILTER_INVALID", "dateFrom 必须为 RFC3339 时间格式", err)
|
|
||||||
}
|
|
||||||
filter.DateFrom = &parsed
|
|
||||||
}
|
|
||||||
if dateTo := strings.TrimSpace(c.Query("dateTo")); dateTo != "" {
|
|
||||||
parsed, err := time.Parse(time.RFC3339, dateTo)
|
|
||||||
if err != nil {
|
|
||||||
return filter, apperror.BadRequest("RESTORE_RECORD_FILTER_INVALID", "dateTo 必须为 RFC3339 时间格式", err)
|
|
||||||
}
|
|
||||||
filter.DateTo = &parsed
|
|
||||||
}
|
|
||||||
return filter, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func writeRestoreSSEEvent(writer io.Writer, event backup.LogEvent) error {
|
|
||||||
payload, err := json.Marshal(event)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
_, err = fmt.Fprintf(writer, "event: log\ndata: %s\n\n", payload)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
@@ -7,14 +7,12 @@ import (
|
|||||||
|
|
||||||
"backupx/server/internal/apperror"
|
"backupx/server/internal/apperror"
|
||||||
"backupx/server/internal/config"
|
"backupx/server/internal/config"
|
||||||
"backupx/server/internal/metrics"
|
|
||||||
"backupx/server/internal/repository"
|
"backupx/server/internal/repository"
|
||||||
"backupx/server/internal/security"
|
"backupx/server/internal/security"
|
||||||
"backupx/server/internal/service"
|
"backupx/server/internal/service"
|
||||||
"backupx/server/pkg/response"
|
"backupx/server/pkg/response"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type RouterDependencies struct {
|
type RouterDependencies struct {
|
||||||
@@ -30,15 +28,6 @@ type RouterDependencies struct {
|
|||||||
BackupTaskService *service.BackupTaskService
|
BackupTaskService *service.BackupTaskService
|
||||||
BackupExecutionService *service.BackupExecutionService
|
BackupExecutionService *service.BackupExecutionService
|
||||||
BackupRecordService *service.BackupRecordService
|
BackupRecordService *service.BackupRecordService
|
||||||
RestoreService *service.RestoreService
|
|
||||||
VerificationService *service.VerificationService
|
|
||||||
ReplicationService *service.ReplicationService
|
|
||||||
TaskTemplateService *service.TaskTemplateService
|
|
||||||
TaskExportService *service.TaskExportService
|
|
||||||
SearchService *service.SearchService
|
|
||||||
EventBroadcaster *service.EventBroadcaster
|
|
||||||
UserService *service.UserService
|
|
||||||
ApiKeyService *service.ApiKeyService
|
|
||||||
NotificationService *service.NotificationService
|
NotificationService *service.NotificationService
|
||||||
DashboardService *service.DashboardService
|
DashboardService *service.DashboardService
|
||||||
SettingsService *service.SettingsService
|
SettingsService *service.SettingsService
|
||||||
@@ -51,10 +40,6 @@ type RouterDependencies struct {
|
|||||||
SystemConfigRepo repository.SystemConfigRepository
|
SystemConfigRepo repository.SystemConfigRepository
|
||||||
InstallTokenService *service.InstallTokenService
|
InstallTokenService *service.InstallTokenService
|
||||||
MasterExternalURL string
|
MasterExternalURL string
|
||||||
// DB 注入给健康检查端点做 liveness/readiness 探测。
|
|
||||||
DB *gorm.DB
|
|
||||||
// Metrics 注入给 /metrics 端点;为 nil 时端点返回 503。
|
|
||||||
Metrics *metrics.Metrics
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewRouter(deps RouterDependencies) *gin.Engine {
|
func NewRouter(deps RouterDependencies) *gin.Engine {
|
||||||
@@ -69,19 +54,7 @@ func NewRouter(deps RouterDependencies) *gin.Engine {
|
|||||||
storageTargetHandler := NewStorageTargetHandler(deps.StorageTargetService, deps.AuditService)
|
storageTargetHandler := NewStorageTargetHandler(deps.StorageTargetService, deps.AuditService)
|
||||||
backupTaskHandler := NewBackupTaskHandler(deps.BackupTaskService, deps.AuditService)
|
backupTaskHandler := NewBackupTaskHandler(deps.BackupTaskService, deps.AuditService)
|
||||||
backupRunHandler := NewBackupRunHandler(deps.BackupExecutionService, deps.AuditService)
|
backupRunHandler := NewBackupRunHandler(deps.BackupExecutionService, deps.AuditService)
|
||||||
backupRecordHandler := NewBackupRecordHandler(deps.BackupRecordService, deps.RestoreService, deps.AuditService)
|
backupRecordHandler := NewBackupRecordHandler(deps.BackupRecordService, deps.AuditService)
|
||||||
restoreRecordHandler := NewRestoreRecordHandler(deps.RestoreService, deps.AuditService)
|
|
||||||
verificationHandler := NewVerificationHandler(deps.VerificationService, deps.AuditService)
|
|
||||||
replicationHandler := NewReplicationHandler(deps.ReplicationService, deps.AuditService)
|
|
||||||
taskTemplateHandler := NewTaskTemplateHandler(deps.TaskTemplateService, deps.AuditService)
|
|
||||||
userHandler := NewUserHandler(deps.UserService, deps.AuditService)
|
|
||||||
apiKeyHandler := NewApiKeyHandler(deps.ApiKeyService, deps.AuditService)
|
|
||||||
// apiKeyAuth:给 AuthMiddleware 注入 API Key 验证能力。
|
|
||||||
// 为 nil 时中间件仅支持 JWT,不影响向后兼容。
|
|
||||||
var apiKeyAuth ApiKeyAuthenticator
|
|
||||||
if deps.ApiKeyService != nil {
|
|
||||||
apiKeyAuth = deps.ApiKeyService
|
|
||||||
}
|
|
||||||
notificationHandler := NewNotificationHandler(deps.NotificationService)
|
notificationHandler := NewNotificationHandler(deps.NotificationService)
|
||||||
dashboardHandler := NewDashboardHandler(deps.DashboardService)
|
dashboardHandler := NewDashboardHandler(deps.DashboardService)
|
||||||
settingsHandler := NewSettingsHandler(deps.SettingsService, deps.AuditService)
|
settingsHandler := NewSettingsHandler(deps.SettingsService, deps.AuditService)
|
||||||
@@ -94,221 +67,109 @@ func NewRouter(deps RouterDependencies) *gin.Engine {
|
|||||||
auth.GET("/setup/status", authHandler.SetupStatus)
|
auth.GET("/setup/status", authHandler.SetupStatus)
|
||||||
auth.POST("/setup", authHandler.Setup)
|
auth.POST("/setup", authHandler.Setup)
|
||||||
auth.POST("/login", authHandler.Login)
|
auth.POST("/login", authHandler.Login)
|
||||||
auth.POST("/otp/send", authHandler.SendLoginOTP)
|
auth.POST("/logout", AuthMiddleware(deps.JWTManager), authHandler.Logout)
|
||||||
auth.POST("/webauthn/login/options", authHandler.BeginWebAuthnLogin)
|
auth.GET("/profile", AuthMiddleware(deps.JWTManager), authHandler.Profile)
|
||||||
auth.POST("/logout", AuthMiddleware(deps.JWTManager, apiKeyAuth), authHandler.Logout)
|
auth.PUT("/password", AuthMiddleware(deps.JWTManager), authHandler.ChangePassword)
|
||||||
auth.GET("/profile", AuthMiddleware(deps.JWTManager, apiKeyAuth), authHandler.Profile)
|
|
||||||
auth.PUT("/password", AuthMiddleware(deps.JWTManager, apiKeyAuth), authHandler.ChangePassword)
|
|
||||||
auth.POST("/2fa/setup", AuthMiddleware(deps.JWTManager, apiKeyAuth), authHandler.PrepareTwoFactor)
|
|
||||||
auth.POST("/2fa/enable", AuthMiddleware(deps.JWTManager, apiKeyAuth), authHandler.EnableTwoFactor)
|
|
||||||
auth.POST("/2fa/recovery-codes", AuthMiddleware(deps.JWTManager, apiKeyAuth), authHandler.RegenerateRecoveryCodes)
|
|
||||||
auth.DELETE("/2fa", AuthMiddleware(deps.JWTManager, apiKeyAuth), authHandler.DisableTwoFactor)
|
|
||||||
auth.PUT("/otp/config", AuthMiddleware(deps.JWTManager, apiKeyAuth), authHandler.ConfigureOTP)
|
|
||||||
auth.POST("/webauthn/register/options", AuthMiddleware(deps.JWTManager, apiKeyAuth), authHandler.BeginWebAuthnRegistration)
|
|
||||||
auth.POST("/webauthn/register/finish", AuthMiddleware(deps.JWTManager, apiKeyAuth), authHandler.FinishWebAuthnRegistration)
|
|
||||||
auth.GET("/webauthn/credentials", AuthMiddleware(deps.JWTManager, apiKeyAuth), authHandler.ListWebAuthnCredentials)
|
|
||||||
auth.DELETE("/webauthn/credentials/:id", AuthMiddleware(deps.JWTManager, apiKeyAuth), authHandler.DeleteWebAuthnCredential)
|
|
||||||
auth.GET("/trusted-devices", AuthMiddleware(deps.JWTManager, apiKeyAuth), authHandler.ListTrustedDevices)
|
|
||||||
auth.DELETE("/trusted-devices/:id", AuthMiddleware(deps.JWTManager, apiKeyAuth), authHandler.RevokeTrustedDevice)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
system := api.Group("/system")
|
system := api.Group("/system")
|
||||||
system.Use(AuthMiddleware(deps.JWTManager, apiKeyAuth))
|
system.Use(AuthMiddleware(deps.JWTManager))
|
||||||
system.GET("/info", systemHandler.Info)
|
system.GET("/info", systemHandler.Info)
|
||||||
system.GET("/update-check", systemHandler.CheckUpdate)
|
system.GET("/update-check", systemHandler.CheckUpdate)
|
||||||
|
|
||||||
storageTargets := api.Group("/storage-targets")
|
storageTargets := api.Group("/storage-targets")
|
||||||
storageTargets.Use(AuthMiddleware(deps.JWTManager, apiKeyAuth))
|
storageTargets.Use(AuthMiddleware(deps.JWTManager))
|
||||||
// 静态路由必须在参数路由 /:id 之前注册,避免 Gin 路由冲突
|
// 静态路由必须在参数路由 /:id 之前注册,避免 Gin 路由冲突
|
||||||
storageTargets.GET("", storageTargetHandler.List)
|
storageTargets.GET("", storageTargetHandler.List)
|
||||||
storageTargets.POST("", RequireNotViewer(), storageTargetHandler.Create)
|
storageTargets.POST("", storageTargetHandler.Create)
|
||||||
storageTargets.POST("/test", RequireNotViewer(), storageTargetHandler.TestConnection)
|
storageTargets.POST("/test", storageTargetHandler.TestConnection)
|
||||||
storageTargets.POST("/google-drive/auth-url", RequireNotViewer(), storageTargetHandler.StartGoogleDriveOAuth)
|
storageTargets.POST("/google-drive/auth-url", storageTargetHandler.StartGoogleDriveOAuth)
|
||||||
storageTargets.POST("/google-drive/complete", RequireNotViewer(), storageTargetHandler.CompleteGoogleDriveOAuth)
|
storageTargets.POST("/google-drive/complete", storageTargetHandler.CompleteGoogleDriveOAuth)
|
||||||
storageTargets.GET("/google-drive/callback", storageTargetHandler.HandleGoogleDriveCallback)
|
storageTargets.GET("/google-drive/callback", storageTargetHandler.HandleGoogleDriveCallback)
|
||||||
rcloneHandler := NewRcloneHandler()
|
rcloneHandler := NewRcloneHandler()
|
||||||
storageTargets.GET("/rclone/backends", rcloneHandler.ListBackends)
|
storageTargets.GET("/rclone/backends", rcloneHandler.ListBackends)
|
||||||
// 参数路由
|
// 参数路由
|
||||||
storageTargets.GET("/:id", storageTargetHandler.Get)
|
storageTargets.GET("/:id", storageTargetHandler.Get)
|
||||||
storageTargets.PUT("/:id", RequireNotViewer(), storageTargetHandler.Update)
|
storageTargets.PUT("/:id", storageTargetHandler.Update)
|
||||||
storageTargets.DELETE("/:id", RequireNotViewer(), storageTargetHandler.Delete)
|
storageTargets.DELETE("/:id", storageTargetHandler.Delete)
|
||||||
storageTargets.PUT("/:id/star", RequireNotViewer(), storageTargetHandler.ToggleStar)
|
storageTargets.PUT("/:id/star", storageTargetHandler.ToggleStar)
|
||||||
storageTargets.POST("/:id/test", RequireNotViewer(), storageTargetHandler.TestSavedConnection)
|
storageTargets.POST("/:id/test", storageTargetHandler.TestSavedConnection)
|
||||||
storageTargets.GET("/:id/usage", storageTargetHandler.GetUsage)
|
storageTargets.GET("/:id/usage", storageTargetHandler.GetUsage)
|
||||||
storageTargets.GET("/:id/google-drive/profile", storageTargetHandler.GoogleDriveProfile)
|
storageTargets.GET("/:id/google-drive/profile", storageTargetHandler.GoogleDriveProfile)
|
||||||
|
|
||||||
backupTasks := api.Group("/backup/tasks")
|
backupTasks := api.Group("/backup/tasks")
|
||||||
backupTasks.Use(AuthMiddleware(deps.JWTManager, apiKeyAuth))
|
backupTasks.Use(AuthMiddleware(deps.JWTManager))
|
||||||
backupTasks.GET("", backupTaskHandler.List)
|
backupTasks.GET("", backupTaskHandler.List)
|
||||||
backupTasks.GET("/tags", backupTaskHandler.ListTags)
|
|
||||||
backupTasks.GET("/:id", backupTaskHandler.Get)
|
backupTasks.GET("/:id", backupTaskHandler.Get)
|
||||||
backupTasks.POST("", RequireNotViewer(), backupTaskHandler.Create)
|
backupTasks.POST("", backupTaskHandler.Create)
|
||||||
backupTasks.PUT("/:id", RequireNotViewer(), backupTaskHandler.Update)
|
backupTasks.PUT("/:id", backupTaskHandler.Update)
|
||||||
backupTasks.DELETE("/:id", RequireNotViewer(), backupTaskHandler.Delete)
|
backupTasks.DELETE("/:id", backupTaskHandler.Delete)
|
||||||
backupTasks.PUT("/:id/toggle", RequireNotViewer(), backupTaskHandler.Toggle)
|
backupTasks.PUT("/:id/toggle", backupTaskHandler.Toggle)
|
||||||
backupTasks.POST("/:id/run", RequireNotViewer(), backupRunHandler.Run)
|
backupTasks.POST("/:id/run", backupRunHandler.Run)
|
||||||
backupTasks.POST("/batch/toggle", RequireNotViewer(), backupTaskHandler.BatchToggle)
|
|
||||||
backupTasks.POST("/batch/delete", RequireNotViewer(), backupTaskHandler.BatchDelete)
|
|
||||||
backupTasks.POST("/batch/run", RequireNotViewer(), backupRunHandler.BatchRun)
|
|
||||||
// 任务配置导入/导出(集群迁移 & 灾备)
|
|
||||||
if deps.TaskExportService != nil {
|
|
||||||
taskExportHandler := NewTaskExportHandler(deps.TaskExportService, deps.AuditService)
|
|
||||||
backupTasks.GET("/export", taskExportHandler.Export)
|
|
||||||
backupTasks.POST("/import", RequireNotViewer(), taskExportHandler.Import)
|
|
||||||
}
|
|
||||||
if deps.VerificationService != nil {
|
|
||||||
backupTasks.POST("/:id/verify", RequireNotViewer(), verificationHandler.TriggerByTask)
|
|
||||||
}
|
|
||||||
|
|
||||||
backupRecords := api.Group("/backup/records")
|
backupRecords := api.Group("/backup/records")
|
||||||
backupRecords.Use(AuthMiddleware(deps.JWTManager, apiKeyAuth))
|
backupRecords.Use(AuthMiddleware(deps.JWTManager))
|
||||||
backupRecords.GET("", backupRecordHandler.List)
|
backupRecords.GET("", backupRecordHandler.List)
|
||||||
backupRecords.GET("/:id", backupRecordHandler.Get)
|
backupRecords.GET("/:id", backupRecordHandler.Get)
|
||||||
backupRecords.GET("/:id/logs/stream", backupRecordHandler.StreamLogs)
|
backupRecords.GET("/:id/logs/stream", backupRecordHandler.StreamLogs)
|
||||||
backupRecords.GET("/:id/download", backupRecordHandler.Download)
|
backupRecords.GET("/:id/download", backupRecordHandler.Download)
|
||||||
backupRecords.POST("/:id/restore", RequireNotViewer(), backupRecordHandler.Restore)
|
backupRecords.POST("/:id/restore", backupRecordHandler.Restore)
|
||||||
backupRecords.POST("/batch-delete", RequireNotViewer(), backupRecordHandler.BatchDelete)
|
backupRecords.POST("/batch-delete", backupRecordHandler.BatchDelete)
|
||||||
backupRecords.DELETE("/:id", RequireNotViewer(), backupRecordHandler.Delete)
|
backupRecords.DELETE("/:id", backupRecordHandler.Delete)
|
||||||
|
|
||||||
// 恢复记录独立命名空间:列表/详情/SSE 日志流。
|
|
||||||
// 创建恢复仍然走 POST /backup/records/:id/restore(以源备份记录为触发点)。
|
|
||||||
if deps.RestoreService != nil {
|
|
||||||
restoreRecords := api.Group("/restore/records")
|
|
||||||
restoreRecords.Use(AuthMiddleware(deps.JWTManager, apiKeyAuth))
|
|
||||||
restoreRecords.GET("", restoreRecordHandler.List)
|
|
||||||
restoreRecords.GET("/:id", restoreRecordHandler.Get)
|
|
||||||
restoreRecords.GET("/:id/logs/stream", restoreRecordHandler.StreamLogs)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 备份复制记录(3-2-1 规则)
|
|
||||||
if deps.ReplicationService != nil {
|
|
||||||
replicationRecords := api.Group("/replication/records")
|
|
||||||
replicationRecords.Use(AuthMiddleware(deps.JWTManager, apiKeyAuth))
|
|
||||||
replicationRecords.GET("", replicationHandler.List)
|
|
||||||
replicationRecords.GET("/:id", replicationHandler.Get)
|
|
||||||
backupRecords.POST("/:id/replicate", RequireNotViewer(), replicationHandler.TriggerByRecord)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 任务模板(批量创建)
|
|
||||||
if deps.TaskTemplateService != nil {
|
|
||||||
templates := api.Group("/task-templates")
|
|
||||||
templates.Use(AuthMiddleware(deps.JWTManager, apiKeyAuth))
|
|
||||||
templates.GET("", taskTemplateHandler.List)
|
|
||||||
templates.GET("/:id", taskTemplateHandler.Get)
|
|
||||||
templates.POST("", RequireNotViewer(), taskTemplateHandler.Create)
|
|
||||||
templates.PUT("/:id", RequireNotViewer(), taskTemplateHandler.Update)
|
|
||||||
templates.DELETE("/:id", RequireNotViewer(), taskTemplateHandler.Delete)
|
|
||||||
templates.POST("/:id/apply", RequireNotViewer(), taskTemplateHandler.Apply)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 备份验证/演练记录
|
|
||||||
if deps.VerificationService != nil {
|
|
||||||
verifyRecords := api.Group("/verify/records")
|
|
||||||
verifyRecords.Use(AuthMiddleware(deps.JWTManager, apiKeyAuth))
|
|
||||||
verifyRecords.GET("", verificationHandler.List)
|
|
||||||
verifyRecords.GET("/:id", verificationHandler.Get)
|
|
||||||
verifyRecords.GET("/:id/logs/stream", verificationHandler.StreamLogs)
|
|
||||||
// 基于备份记录的验证入口:与 restore 对称
|
|
||||||
backupRecords.POST("/:id/verify", RequireNotViewer(), verificationHandler.TriggerByRecord)
|
|
||||||
}
|
|
||||||
dashboard := api.Group("/dashboard")
|
dashboard := api.Group("/dashboard")
|
||||||
dashboard.Use(AuthMiddleware(deps.JWTManager, apiKeyAuth))
|
dashboard.Use(AuthMiddleware(deps.JWTManager))
|
||||||
dashboard.GET("/stats", dashboardHandler.Stats)
|
dashboard.GET("/stats", dashboardHandler.Stats)
|
||||||
dashboard.GET("/timeline", dashboardHandler.Timeline)
|
dashboard.GET("/timeline", dashboardHandler.Timeline)
|
||||||
dashboard.GET("/sla", dashboardHandler.SLA)
|
|
||||||
dashboard.GET("/cluster", dashboardHandler.Cluster)
|
|
||||||
dashboard.GET("/breakdown", dashboardHandler.Breakdown)
|
|
||||||
dashboard.GET("/node-performance", dashboardHandler.NodePerformance)
|
|
||||||
|
|
||||||
notifications := api.Group("/notifications")
|
notifications := api.Group("/notifications")
|
||||||
notifications.Use(AuthMiddleware(deps.JWTManager, apiKeyAuth))
|
notifications.Use(AuthMiddleware(deps.JWTManager))
|
||||||
notifications.GET("", notificationHandler.List)
|
notifications.GET("", notificationHandler.List)
|
||||||
notifications.GET("/:id", notificationHandler.Get)
|
notifications.GET("/:id", notificationHandler.Get)
|
||||||
notifications.POST("", RequireNotViewer(), notificationHandler.Create)
|
notifications.POST("", notificationHandler.Create)
|
||||||
notifications.PUT("/:id", RequireNotViewer(), notificationHandler.Update)
|
notifications.PUT("/:id", notificationHandler.Update)
|
||||||
notifications.DELETE("/:id", RequireNotViewer(), notificationHandler.Delete)
|
notifications.DELETE("/:id", notificationHandler.Delete)
|
||||||
notifications.POST("/test", RequireNotViewer(), notificationHandler.Test)
|
notifications.POST("/test", notificationHandler.Test)
|
||||||
notifications.POST("/:id/test", RequireNotViewer(), notificationHandler.TestSaved)
|
notifications.POST("/:id/test", notificationHandler.TestSaved)
|
||||||
|
|
||||||
settings := api.Group("/settings")
|
settings := api.Group("/settings")
|
||||||
settings.Use(AuthMiddleware(deps.JWTManager, apiKeyAuth))
|
settings.Use(AuthMiddleware(deps.JWTManager))
|
||||||
settings.GET("", settingsHandler.Get)
|
settings.GET("", settingsHandler.Get)
|
||||||
settings.PUT("", RequireRole("admin"), settingsHandler.Update)
|
settings.PUT("", settingsHandler.Update)
|
||||||
|
|
||||||
// 用户管理(admin 专属)
|
|
||||||
if deps.UserService != nil {
|
|
||||||
users := api.Group("/users")
|
|
||||||
users.Use(AuthMiddleware(deps.JWTManager, apiKeyAuth), RequireRole("admin"))
|
|
||||||
users.GET("", userHandler.List)
|
|
||||||
users.POST("", userHandler.Create)
|
|
||||||
users.PUT("/:id", userHandler.Update)
|
|
||||||
users.POST("/:id/2fa/reset", userHandler.ResetTwoFactor)
|
|
||||||
users.DELETE("/:id", userHandler.Delete)
|
|
||||||
}
|
|
||||||
|
|
||||||
// API Key 管理(admin 专属)
|
|
||||||
if deps.ApiKeyService != nil {
|
|
||||||
apiKeys := api.Group("/api-keys")
|
|
||||||
apiKeys.Use(AuthMiddleware(deps.JWTManager, apiKeyAuth), RequireRole("admin"))
|
|
||||||
apiKeys.GET("", apiKeyHandler.List)
|
|
||||||
apiKeys.POST("", apiKeyHandler.Create)
|
|
||||||
apiKeys.PUT("/:id/toggle", apiKeyHandler.Toggle)
|
|
||||||
apiKeys.DELETE("/:id", apiKeyHandler.Revoke)
|
|
||||||
}
|
|
||||||
|
|
||||||
auditLogs := api.Group("/audit-logs")
|
auditLogs := api.Group("/audit-logs")
|
||||||
auditLogs.Use(AuthMiddleware(deps.JWTManager, apiKeyAuth))
|
auditLogs.Use(AuthMiddleware(deps.JWTManager))
|
||||||
auditLogs.GET("", auditHandler.List)
|
auditLogs.GET("", auditHandler.List)
|
||||||
auditLogs.GET("/export", auditHandler.Export)
|
|
||||||
|
|
||||||
// 实时事件 SSE 流(Dashboard 自刷新、桌面告警)
|
|
||||||
if deps.EventBroadcaster != nil {
|
|
||||||
eventsHandler := NewEventsHandler(deps.EventBroadcaster)
|
|
||||||
events := api.Group("/events")
|
|
||||||
events.Use(AuthMiddleware(deps.JWTManager, apiKeyAuth))
|
|
||||||
events.GET("/stream", eventsHandler.Stream)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 全局搜索
|
|
||||||
if deps.SearchService != nil {
|
|
||||||
searchHandler := NewSearchHandler(deps.SearchService)
|
|
||||||
searchGroup := api.Group("/search")
|
|
||||||
searchGroup.Use(AuthMiddleware(deps.JWTManager, apiKeyAuth))
|
|
||||||
searchGroup.GET("", searchHandler.Search)
|
|
||||||
}
|
|
||||||
|
|
||||||
if deps.DatabaseDiscoveryService != nil {
|
if deps.DatabaseDiscoveryService != nil {
|
||||||
databaseHandler := NewDatabaseHandler(deps.DatabaseDiscoveryService)
|
databaseHandler := NewDatabaseHandler(deps.DatabaseDiscoveryService)
|
||||||
database := api.Group("/database")
|
database := api.Group("/database")
|
||||||
database.Use(AuthMiddleware(deps.JWTManager, apiKeyAuth))
|
database.Use(AuthMiddleware(deps.JWTManager))
|
||||||
database.POST("/discover", databaseHandler.Discover)
|
database.POST("/discover", databaseHandler.Discover)
|
||||||
}
|
}
|
||||||
|
|
||||||
nodeHandler := NewNodeHandler(deps.NodeService, deps.AuditService, deps.InstallTokenService, deps.UserRepository, deps.MasterExternalURL)
|
nodeHandler := NewNodeHandler(deps.NodeService, deps.AuditService, deps.InstallTokenService, deps.UserRepository, deps.MasterExternalURL)
|
||||||
nodes := api.Group("/nodes")
|
nodes := api.Group("/nodes")
|
||||||
nodes.Use(AuthMiddleware(deps.JWTManager, apiKeyAuth))
|
nodes.Use(AuthMiddleware(deps.JWTManager))
|
||||||
nodes.GET("", nodeHandler.List)
|
nodes.GET("", nodeHandler.List)
|
||||||
nodes.GET("/:id", nodeHandler.Get)
|
nodes.GET("/:id", nodeHandler.Get)
|
||||||
nodes.POST("", RequireRole("admin"), nodeHandler.Create)
|
nodes.POST("", nodeHandler.Create)
|
||||||
nodes.PUT("/:id", RequireRole("admin"), nodeHandler.Update)
|
nodes.PUT("/:id", nodeHandler.Update)
|
||||||
nodes.DELETE("/:id", RequireRole("admin"), nodeHandler.Delete)
|
nodes.DELETE("/:id", nodeHandler.Delete)
|
||||||
nodes.GET("/:id/fs/list", nodeHandler.ListDirectory)
|
nodes.GET("/:id/fs/list", nodeHandler.ListDirectory)
|
||||||
nodes.POST("/batch", RequireRole("admin"), nodeHandler.BatchCreate)
|
nodes.POST("/batch", nodeHandler.BatchCreate)
|
||||||
nodes.POST("/:id/install-tokens", RequireRole("admin"), nodeHandler.CreateInstallToken)
|
nodes.POST("/:id/install-tokens", nodeHandler.CreateInstallToken)
|
||||||
nodes.POST("/:id/rotate-token", RequireRole("admin"), nodeHandler.RotateToken)
|
nodes.POST("/:id/rotate-token", nodeHandler.RotateToken)
|
||||||
nodes.GET("/:id/install-script-preview", RequireRole("admin"), nodeHandler.PreviewScript)
|
nodes.GET("/:id/install-script-preview", nodeHandler.PreviewScript)
|
||||||
|
|
||||||
// Agent API(token 认证,无需 JWT)
|
// Agent API(token 认证,无需 JWT)
|
||||||
if deps.AgentService != nil {
|
if deps.AgentService != nil {
|
||||||
agentHandler := NewAgentHandler(deps.AgentService, deps.NodeService, deps.RestoreService)
|
agentHandler := NewAgentHandler(deps.AgentService, deps.NodeService)
|
||||||
agent := api.Group("/agent")
|
agent := api.Group("/agent")
|
||||||
agent.POST("/heartbeat", agentHandler.Heartbeat)
|
agent.POST("/heartbeat", agentHandler.Heartbeat)
|
||||||
agent.POST("/commands/poll", agentHandler.Poll)
|
agent.POST("/commands/poll", agentHandler.Poll)
|
||||||
agent.POST("/commands/:id/result", agentHandler.SubmitCommandResult)
|
agent.POST("/commands/:id/result", agentHandler.SubmitCommandResult)
|
||||||
agent.GET("/tasks/:id", agentHandler.GetTaskSpec)
|
agent.GET("/tasks/:id", agentHandler.GetTaskSpec)
|
||||||
agent.POST("/records/:id", agentHandler.UpdateRecord)
|
agent.POST("/records/:id", agentHandler.UpdateRecord)
|
||||||
agent.GET("/restores/:id/spec", agentHandler.GetRestoreSpec)
|
|
||||||
agent.POST("/restores/:id", agentHandler.UpdateRestore)
|
|
||||||
|
|
||||||
// Agent v1(安装脚本探活用),仅 Self 端点
|
// Agent v1(安装脚本探活用),仅 Self 端点
|
||||||
v1Agent := api.Group("/v1/agent")
|
v1Agent := api.Group("/v1/agent")
|
||||||
@@ -319,28 +180,7 @@ func NewRouter(deps RouterDependencies) *gin.Engine {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 健康检查端点(公开、无认证、低开销)
|
// 公开安装路由(不走 JWT 中间件)
|
||||||
// K8s/Swarm/Nomad 等编排系统使用这些端点做 liveness/readiness 探测。
|
|
||||||
healthHandler := NewHealthHandler(deps.DB, deps.Version)
|
|
||||||
engine.GET("/health", healthHandler.Live)
|
|
||||||
engine.GET("/ready", healthHandler.Ready)
|
|
||||||
// 在 /api 下也暴露一份,方便反向代理按 path 前缀统一路由
|
|
||||||
engine.GET("/api/health", healthHandler.Live)
|
|
||||||
engine.GET("/api/ready", healthHandler.Ready)
|
|
||||||
|
|
||||||
// Prometheus /metrics 端点(公开、无认证;内网/反向代理授权即可)。
|
|
||||||
// 业内通行做法:/metrics 通常由 Prometheus pull 抓取,不走 API Key。
|
|
||||||
if deps.Metrics != nil {
|
|
||||||
engine.GET("/metrics", gin.WrapH(deps.Metrics.Handler()))
|
|
||||||
}
|
|
||||||
|
|
||||||
// 公开安装路由(不走 JWT 中间件)。
|
|
||||||
// 同时注册到 / 和 /api 前缀下:
|
|
||||||
// - /install/:token 保留历史 URL,兼容旧 nginx 部署
|
|
||||||
// - /api/install/:token 新 URL,自动走反向代理的 /api/ 转发规则
|
|
||||||
//
|
|
||||||
// Issue #46:用户的 nginx 只转发 /api/,/install/* 被 SPA fallback 到 index.html,
|
|
||||||
// 返回 HTML 被 sh 解释成 "Syntax error"。使用 /api/install/ 可避开此问题。
|
|
||||||
if deps.InstallTokenService != nil {
|
if deps.InstallTokenService != nil {
|
||||||
gcCtx := deps.Context
|
gcCtx := deps.Context
|
||||||
if gcCtx == nil {
|
if gcCtx == nil {
|
||||||
@@ -349,8 +189,6 @@ func NewRouter(deps RouterDependencies) *gin.Engine {
|
|||||||
installHandler := NewInstallHandler(gcCtx, deps.InstallTokenService, deps.AuditService, deps.MasterExternalURL)
|
installHandler := NewInstallHandler(gcCtx, deps.InstallTokenService, deps.AuditService, deps.MasterExternalURL)
|
||||||
engine.GET("/install/:token", installHandler.Script)
|
engine.GET("/install/:token", installHandler.Script)
|
||||||
engine.GET("/install/:token/compose.yml", installHandler.Compose)
|
engine.GET("/install/:token/compose.yml", installHandler.Compose)
|
||||||
engine.GET("/api/install/:token", installHandler.Script)
|
|
||||||
engine.GET("/api/install/:token/compose.yml", installHandler.Compose)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
engine.NoRoute(func(c *gin.Context) {
|
engine.NoRoute(func(c *gin.Context) {
|
||||||
|
|||||||
@@ -16,17 +16,50 @@ import (
|
|||||||
"backupx/server/internal/repository"
|
"backupx/server/internal/repository"
|
||||||
"backupx/server/internal/security"
|
"backupx/server/internal/security"
|
||||||
"backupx/server/internal/service"
|
"backupx/server/internal/service"
|
||||||
"backupx/server/internal/storage/codec"
|
|
||||||
|
|
||||||
"github.com/pquerna/otp/totp"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestSetupLoginAndProfileFlow(t *testing.T) {
|
func TestSetupLoginAndProfileFlow(t *testing.T) {
|
||||||
router, _ := newTestHTTPRouter(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,
|
||||||
|
})
|
||||||
|
|
||||||
setupBody, _ := json.Marshal(map[string]string{
|
setupBody, _ := json.Marshal(map[string]string{
|
||||||
"username": "admin",
|
"username": "admin",
|
||||||
"password": "password-123",
|
"password": "password-123",
|
||||||
"displayName": "Admin",
|
"displayName": "Admin",
|
||||||
})
|
})
|
||||||
setupRequest := httptest.NewRequest(http.MethodPost, "/api/auth/setup", bytes.NewBuffer(setupBody))
|
setupRequest := httptest.NewRequest(http.MethodPost, "/api/auth/setup", bytes.NewBuffer(setupBody))
|
||||||
@@ -59,143 +92,3 @@ func TestSetupLoginAndProfileFlow(t *testing.T) {
|
|||||||
t.Fatalf("expected profile 200, got %d", profileRecorder.Code)
|
t.Fatalf("expected profile 200, got %d", profileRecorder.Code)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTrustedDeviceCookieSkipsMFA(t *testing.T) {
|
|
||||||
router, authService := newTestHTTPRouter(t)
|
|
||||||
if _, err := authService.Setup(context.Background(), service.SetupInput{
|
|
||||||
Username: "admin", Password: "password-123", DisplayName: "Admin",
|
|
||||||
}); err != nil {
|
|
||||||
t.Fatalf("Setup error: %v", err)
|
|
||||||
}
|
|
||||||
totpSetup, err := authService.PrepareTwoFactor(context.Background(), "1", service.TwoFactorSetupInput{
|
|
||||||
CurrentPassword: "password-123",
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("PrepareTwoFactor error: %v", err)
|
|
||||||
}
|
|
||||||
enableCode, err := totp.GenerateCode(totpSetup.Secret, time.Now().UTC())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("GenerateCode error: %v", err)
|
|
||||||
}
|
|
||||||
if _, err := authService.EnableTwoFactor(context.Background(), "1", service.EnableTwoFactorInput{Code: enableCode}); err != nil {
|
|
||||||
t.Fatalf("EnableTwoFactor error: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
loginCode, err := totp.GenerateCode(totpSetup.Secret, time.Now().UTC())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("GenerateCode login error: %v", err)
|
|
||||||
}
|
|
||||||
loginBody, _ := json.Marshal(map[string]any{
|
|
||||||
"username": "admin",
|
|
||||||
"password": "password-123",
|
|
||||||
"twoFactorCode": loginCode,
|
|
||||||
"rememberDevice": true,
|
|
||||||
"trustedDeviceName": "test browser",
|
|
||||||
})
|
|
||||||
loginRequest := httptest.NewRequest(http.MethodPost, "/api/auth/login", bytes.NewBuffer(loginBody))
|
|
||||||
loginRequest.Header.Set("Content-Type", "application/json")
|
|
||||||
loginRecorder := httptest.NewRecorder()
|
|
||||||
router.ServeHTTP(loginRecorder, loginRequest)
|
|
||||||
|
|
||||||
if loginRecorder.Code != http.StatusOK {
|
|
||||||
t.Fatalf("expected login 200, got %d: %s", loginRecorder.Code, loginRecorder.Body.String())
|
|
||||||
}
|
|
||||||
trustedCookie := findCookie(loginRecorder.Result().Cookies(), trustedDeviceCookieName)
|
|
||||||
if trustedCookie == nil {
|
|
||||||
t.Fatalf("expected trusted device cookie")
|
|
||||||
}
|
|
||||||
if !trustedCookie.HttpOnly {
|
|
||||||
t.Fatalf("expected trusted device cookie to be HttpOnly")
|
|
||||||
}
|
|
||||||
if trustedCookie.Path != trustedDeviceCookiePath {
|
|
||||||
t.Fatalf("expected trusted device cookie path %q, got %q", trustedDeviceCookiePath, trustedCookie.Path)
|
|
||||||
}
|
|
||||||
var loginResponse struct {
|
|
||||||
Data struct {
|
|
||||||
Token string `json:"token"`
|
|
||||||
TrustedDeviceToken string `json:"trustedDeviceToken"`
|
|
||||||
TrustedDevice *service.TrustedDeviceOutput `json:"trustedDevice"`
|
|
||||||
} `json:"data"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(loginRecorder.Body.Bytes(), &loginResponse); err != nil {
|
|
||||||
t.Fatalf("unmarshal login response: %v", err)
|
|
||||||
}
|
|
||||||
if loginResponse.Data.Token == "" || loginResponse.Data.TrustedDevice == nil {
|
|
||||||
t.Fatalf("expected login token and trusted device metadata")
|
|
||||||
}
|
|
||||||
if loginResponse.Data.TrustedDeviceToken != "" {
|
|
||||||
t.Fatalf("trusted device token should not be exposed in response body")
|
|
||||||
}
|
|
||||||
|
|
||||||
secondBody, _ := json.Marshal(map[string]string{
|
|
||||||
"username": "admin",
|
|
||||||
"password": "password-123",
|
|
||||||
})
|
|
||||||
secondRequest := httptest.NewRequest(http.MethodPost, "/api/auth/login", bytes.NewBuffer(secondBody))
|
|
||||||
secondRequest.Header.Set("Content-Type", "application/json")
|
|
||||||
secondRequest.AddCookie(trustedCookie)
|
|
||||||
secondRecorder := httptest.NewRecorder()
|
|
||||||
router.ServeHTTP(secondRecorder, secondRequest)
|
|
||||||
|
|
||||||
if secondRecorder.Code != http.StatusOK {
|
|
||||||
t.Fatalf("expected trusted device login 200, got %d: %s", secondRecorder.Code, secondRecorder.Body.String())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func newTestHTTPRouter(t *testing.T) (http.Handler, *service.AuthService) {
|
|
||||||
t.Helper()
|
|
||||||
tempDir := t.TempDir()
|
|
||||||
cfg := config.Config{
|
|
||||||
Server: config.ServerConfig{Host: "127.0.0.1", Port: 8340, Mode: "test"},
|
|
||||||
Database: config.DatabaseConfig{Path: filepath.Join(tempDir, "backupx.db")},
|
|
||||||
Security: config.SecurityConfig{JWTExpire: "24h"},
|
|
||||||
Log: config.LogConfig{Level: "error"},
|
|
||||||
}
|
|
||||||
|
|
||||||
log, err := logger.New(cfg.Log)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("logger.New error: %v", err)
|
|
||||||
}
|
|
||||||
db, err := database.Open(cfg.Database, log)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("database.Open error: %v", err)
|
|
||||||
}
|
|
||||||
sqlDB, err := db.DB()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("db.DB error: %v", err)
|
|
||||||
}
|
|
||||||
t.Cleanup(func() {
|
|
||||||
_ = sqlDB.Close()
|
|
||||||
})
|
|
||||||
|
|
||||||
userRepo := repository.NewUserRepository(db)
|
|
||||||
systemConfigRepo := repository.NewSystemConfigRepository(db)
|
|
||||||
resolved, err := service.ResolveSecurity(context.Background(), cfg.Security, systemConfigRepo)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("ResolveSecurity error: %v", err)
|
|
||||||
}
|
|
||||||
jwtManager := security.NewJWTManager(resolved.JWTSecret, time.Hour)
|
|
||||||
authService := service.NewAuthService(userRepo, systemConfigRepo, jwtManager, security.NewLoginRateLimiter(5, time.Minute), codec.NewConfigCipher(resolved.EncryptionKey))
|
|
||||||
systemService := service.NewSystemService(cfg, "test", time.Now().UTC())
|
|
||||||
|
|
||||||
router := NewRouter(RouterDependencies{
|
|
||||||
Config: cfg,
|
|
||||||
Version: "test",
|
|
||||||
Logger: log,
|
|
||||||
AuthService: authService,
|
|
||||||
SystemService: systemService,
|
|
||||||
JWTManager: jwtManager,
|
|
||||||
UserRepository: userRepo,
|
|
||||||
SystemConfigRepo: systemConfigRepo,
|
|
||||||
})
|
|
||||||
return router, authService
|
|
||||||
}
|
|
||||||
|
|
||||||
func findCookie(cookies []*http.Cookie, name string) *http.Cookie {
|
|
||||||
for _, cookie := range cookies {
|
|
||||||
if cookie.Name == name {
|
|
||||||
return cookie
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
package http
|
|
||||||
|
|
||||||
import (
|
|
||||||
"backupx/server/internal/service"
|
|
||||||
"backupx/server/pkg/response"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
)
|
|
||||||
|
|
||||||
// SearchHandler 全局搜索。
|
|
||||||
type SearchHandler struct {
|
|
||||||
service *service.SearchService
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewSearchHandler(s *service.SearchService) *SearchHandler {
|
|
||||||
return &SearchHandler{service: s}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Search GET /search?q=关键字
|
|
||||||
func (h *SearchHandler) Search(c *gin.Context) {
|
|
||||||
query := c.Query("q")
|
|
||||||
result, err := h.service.Search(c.Request.Context(), query)
|
|
||||||
if err != nil {
|
|
||||||
response.Error(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
response.Success(c, result)
|
|
||||||
}
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
package http
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
stdhttp "net/http"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"backupx/server/internal/apperror"
|
|
||||||
"backupx/server/internal/service"
|
|
||||||
"backupx/server/pkg/response"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TaskExportHandler 提供任务配置 JSON 导入/导出。
|
|
||||||
type TaskExportHandler struct {
|
|
||||||
service *service.TaskExportService
|
|
||||||
auditService *service.AuditService
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewTaskExportHandler(s *service.TaskExportService, audit *service.AuditService) *TaskExportHandler {
|
|
||||||
return &TaskExportHandler{service: s, auditService: audit}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Export GET /api/backup/tasks/export?ids=1,2,3
|
|
||||||
// 无 ids 参数时导出全部任务。返回 application/json + Content-Disposition。
|
|
||||||
func (h *TaskExportHandler) Export(c *gin.Context) {
|
|
||||||
var taskIDs []uint
|
|
||||||
if v := strings.TrimSpace(c.Query("ids")); v != "" {
|
|
||||||
for _, part := range strings.Split(v, ",") {
|
|
||||||
if id, err := strconv.ParseUint(strings.TrimSpace(part), 10, 32); err == nil {
|
|
||||||
taskIDs = append(taskIDs, uint(id))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
payload, err := h.service.Export(c.Request.Context(), taskIDs)
|
|
||||||
if err != nil {
|
|
||||||
response.Error(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
data, err := json.MarshalIndent(payload, "", " ")
|
|
||||||
if err != nil {
|
|
||||||
response.Error(c, apperror.Internal("TASK_EXPORT_MARSHAL_FAILED", "无法序列化导出内容", err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
filename := fmt.Sprintf("backupx-tasks-%s.json", time.Now().UTC().Format("20060102-150405"))
|
|
||||||
c.Header("Content-Type", "application/json; charset=utf-8")
|
|
||||||
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename))
|
|
||||||
_, _ = c.Writer.Write(data)
|
|
||||||
recordAudit(c, h.auditService, "backup_task", "export", "backup_task", "", "",
|
|
||||||
fmt.Sprintf("导出 %d 个任务的配置为 JSON", payload.TaskCount))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Import POST /api/backup/tasks/import
|
|
||||||
// Body: ExportPayload JSON。返回每个任务的创建/跳过结果。
|
|
||||||
func (h *TaskExportHandler) Import(c *gin.Context) {
|
|
||||||
body, err := io.ReadAll(c.Request.Body)
|
|
||||||
if err != nil {
|
|
||||||
response.Error(c, apperror.BadRequest("TASK_IMPORT_INVALID", "无法读取请求体", err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if len(body) == 0 {
|
|
||||||
response.Error(c, apperror.BadRequest("TASK_IMPORT_INVALID", "请求体为空", nil))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if len(body) > 1024*1024 { // 1MB 上限
|
|
||||||
c.Writer.WriteHeader(stdhttp.StatusRequestEntityTooLarge)
|
|
||||||
response.Error(c, apperror.BadRequest("TASK_IMPORT_TOO_LARGE", "导入文件过大(上限 1MB)", nil))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var payload service.ExportPayload
|
|
||||||
if err := json.Unmarshal(body, &payload); err != nil {
|
|
||||||
response.Error(c, apperror.BadRequest("TASK_IMPORT_INVALID", "JSON 格式不合法", err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if len(payload.Tasks) == 0 {
|
|
||||||
response.Error(c, apperror.BadRequest("TASK_IMPORT_INVALID", "文件中未包含任何任务", nil))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
results, err := h.service.Import(c.Request.Context(), payload)
|
|
||||||
if err != nil {
|
|
||||||
response.Error(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
succ := 0
|
|
||||||
skipped := 0
|
|
||||||
for _, r := range results {
|
|
||||||
if r.Success && !r.Skipped {
|
|
||||||
succ++
|
|
||||||
} else if r.Skipped {
|
|
||||||
skipped++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
recordAudit(c, h.auditService, "backup_task", "import", "backup_task", "", "",
|
|
||||||
fmt.Sprintf("从 JSON 导入任务:创建 %d / 跳过 %d / 失败 %d", succ, skipped, len(results)-succ-skipped))
|
|
||||||
response.Success(c, results)
|
|
||||||
}
|
|
||||||
@@ -1,125 +0,0 @@
|
|||||||
package http
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"backupx/server/internal/apperror"
|
|
||||||
"backupx/server/internal/service"
|
|
||||||
"backupx/server/pkg/response"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
)
|
|
||||||
|
|
||||||
type TaskTemplateHandler struct {
|
|
||||||
service *service.TaskTemplateService
|
|
||||||
auditService *service.AuditService
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewTaskTemplateHandler(templateService *service.TaskTemplateService, auditService *service.AuditService) *TaskTemplateHandler {
|
|
||||||
return &TaskTemplateHandler{service: templateService, auditService: auditService}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *TaskTemplateHandler) List(c *gin.Context) {
|
|
||||||
items, err := h.service.List(c.Request.Context())
|
|
||||||
if err != nil {
|
|
||||||
response.Error(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
response.Success(c, items)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *TaskTemplateHandler) Get(c *gin.Context) {
|
|
||||||
id, ok := parseUintParam(c, "id")
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
item, err := h.service.Get(c.Request.Context(), id)
|
|
||||||
if err != nil {
|
|
||||||
response.Error(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
response.Success(c, item)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *TaskTemplateHandler) Create(c *gin.Context) {
|
|
||||||
var input service.TaskTemplateUpsertInput
|
|
||||||
if err := c.ShouldBindJSON(&input); err != nil {
|
|
||||||
response.Error(c, apperror.BadRequest("TASK_TEMPLATE_INVALID", "模板参数不合法", err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
creator := ""
|
|
||||||
if v, ok := c.Get(contextUsernameKey); ok {
|
|
||||||
if s, ok := v.(string); ok {
|
|
||||||
creator = s
|
|
||||||
}
|
|
||||||
}
|
|
||||||
item, err := h.service.Create(c.Request.Context(), creator, input)
|
|
||||||
if err != nil {
|
|
||||||
response.Error(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
recordAudit(c, h.auditService, "task_template", "create", "task_template", fmt.Sprintf("%d", item.ID), item.Name,
|
|
||||||
fmt.Sprintf("创建任务模板: %s (类型: %s)", item.Name, item.TaskType))
|
|
||||||
response.Success(c, item)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *TaskTemplateHandler) Update(c *gin.Context) {
|
|
||||||
id, ok := parseUintParam(c, "id")
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var input service.TaskTemplateUpsertInput
|
|
||||||
if err := c.ShouldBindJSON(&input); err != nil {
|
|
||||||
response.Error(c, apperror.BadRequest("TASK_TEMPLATE_INVALID", "模板参数不合法", err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
item, err := h.service.Update(c.Request.Context(), id, input)
|
|
||||||
if err != nil {
|
|
||||||
response.Error(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
recordAudit(c, h.auditService, "task_template", "update", "task_template", fmt.Sprintf("%d", item.ID), item.Name,
|
|
||||||
fmt.Sprintf("更新任务模板: %s", item.Name))
|
|
||||||
response.Success(c, item)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *TaskTemplateHandler) Delete(c *gin.Context) {
|
|
||||||
id, ok := parseUintParam(c, "id")
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := h.service.Delete(c.Request.Context(), id); err != nil {
|
|
||||||
response.Error(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
recordAudit(c, h.auditService, "task_template", "delete", "task_template", fmt.Sprintf("%d", id), "",
|
|
||||||
fmt.Sprintf("删除任务模板 (ID: %d)", id))
|
|
||||||
response.Success(c, gin.H{"deleted": true})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply 一键批量创建任务。Body: {variables: [{name, sourcePath, ...}, ...]}
|
|
||||||
func (h *TaskTemplateHandler) Apply(c *gin.Context) {
|
|
||||||
id, ok := parseUintParam(c, "id")
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var input service.TaskTemplateApplyInput
|
|
||||||
if err := c.ShouldBindJSON(&input); err != nil {
|
|
||||||
response.Error(c, apperror.BadRequest("TASK_TEMPLATE_INVALID", "应用参数不合法", err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
results, err := h.service.Apply(c.Request.Context(), id, input)
|
|
||||||
if err != nil {
|
|
||||||
response.Error(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
successCount := 0
|
|
||||||
for _, r := range results {
|
|
||||||
if r.Success {
|
|
||||||
successCount++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
recordAudit(c, h.auditService, "task_template", "apply", "task_template", fmt.Sprintf("%d", id), "",
|
|
||||||
fmt.Sprintf("应用模板批量创建任务(成功 %d/%d)", successCount, len(results)))
|
|
||||||
response.Success(c, results)
|
|
||||||
}
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
package http
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"backupx/server/internal/apperror"
|
|
||||||
"backupx/server/internal/service"
|
|
||||||
"backupx/server/pkg/response"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
)
|
|
||||||
|
|
||||||
// UserHandler 管理账号(仅 admin 可访问)。
|
|
||||||
type UserHandler struct {
|
|
||||||
service *service.UserService
|
|
||||||
auditService *service.AuditService
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewUserHandler(userService *service.UserService, auditService *service.AuditService) *UserHandler {
|
|
||||||
return &UserHandler{service: userService, auditService: auditService}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *UserHandler) List(c *gin.Context) {
|
|
||||||
items, err := h.service.List(c.Request.Context())
|
|
||||||
if err != nil {
|
|
||||||
response.Error(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
response.Success(c, items)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *UserHandler) Create(c *gin.Context) {
|
|
||||||
var input service.UserUpsertInput
|
|
||||||
if err := c.ShouldBindJSON(&input); err != nil {
|
|
||||||
response.Error(c, apperror.BadRequest("USER_INVALID", "用户参数不合法", err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
item, err := h.service.Create(c.Request.Context(), input)
|
|
||||||
if err != nil {
|
|
||||||
response.Error(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
recordAudit(c, h.auditService, "user", "create", "user", fmt.Sprintf("%d", item.ID), item.Username,
|
|
||||||
fmt.Sprintf("创建用户 %s (角色: %s)", item.Username, item.Role))
|
|
||||||
response.Success(c, item)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *UserHandler) Update(c *gin.Context) {
|
|
||||||
id, ok := parseUintParam(c, "id")
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var input service.UserUpsertInput
|
|
||||||
if err := c.ShouldBindJSON(&input); err != nil {
|
|
||||||
response.Error(c, apperror.BadRequest("USER_INVALID", "用户参数不合法", err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
item, err := h.service.Update(c.Request.Context(), id, input)
|
|
||||||
if err != nil {
|
|
||||||
response.Error(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
recordAudit(c, h.auditService, "user", "update", "user", fmt.Sprintf("%d", id), item.Username,
|
|
||||||
fmt.Sprintf("更新用户 %s (角色: %s, 停用: %v)", item.Username, item.Role, item.Disabled))
|
|
||||||
response.Success(c, item)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *UserHandler) Delete(c *gin.Context) {
|
|
||||||
id, ok := parseUintParam(c, "id")
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := h.service.Delete(c.Request.Context(), id); err != nil {
|
|
||||||
response.Error(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
recordAudit(c, h.auditService, "user", "delete", "user", fmt.Sprintf("%d", id), "",
|
|
||||||
fmt.Sprintf("删除用户 (ID: %d)", id))
|
|
||||||
response.Success(c, gin.H{"deleted": true})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *UserHandler) ResetTwoFactor(c *gin.Context) {
|
|
||||||
id, ok := parseUintParam(c, "id")
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
item, err := h.service.ResetTwoFactor(c.Request.Context(), id)
|
|
||||||
if err != nil {
|
|
||||||
response.Error(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
recordAudit(c, h.auditService, "user", "reset_two_factor", "user", fmt.Sprintf("%d", id), item.Username,
|
|
||||||
fmt.Sprintf("重置用户 %s 的 MFA", item.Username))
|
|
||||||
response.Success(c, item)
|
|
||||||
}
|
|
||||||
@@ -1,207 +0,0 @@
|
|||||||
package http
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"backupx/server/internal/apperror"
|
|
||||||
"backupx/server/internal/backup"
|
|
||||||
"backupx/server/internal/service"
|
|
||||||
"backupx/server/pkg/response"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
)
|
|
||||||
|
|
||||||
// VerificationHandler 提供验证记录列表/详情/SSE,以及手动触发入口。
|
|
||||||
type VerificationHandler struct {
|
|
||||||
service *service.VerificationService
|
|
||||||
auditService *service.AuditService
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewVerificationHandler(verifyService *service.VerificationService, auditService *service.AuditService) *VerificationHandler {
|
|
||||||
return &VerificationHandler{service: verifyService, auditService: auditService}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TriggerByTask 接收任务级手动触发。使用最新成功备份为源。
|
|
||||||
func (h *VerificationHandler) TriggerByTask(c *gin.Context) {
|
|
||||||
taskID, ok := parseUintParam(c, "id")
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var input struct {
|
|
||||||
Mode string `json:"mode"`
|
|
||||||
}
|
|
||||||
_ = c.ShouldBindJSON(&input)
|
|
||||||
triggeredBy := ""
|
|
||||||
if subject, exists := c.Get(contextUserSubjectKey); exists {
|
|
||||||
triggeredBy = strings.TrimSpace(fmt.Sprintf("%v", subject))
|
|
||||||
}
|
|
||||||
if triggeredBy == "" {
|
|
||||||
triggeredBy = "manual"
|
|
||||||
}
|
|
||||||
detail, err := h.service.StartByTask(c.Request.Context(), taskID, input.Mode, triggeredBy)
|
|
||||||
if err != nil {
|
|
||||||
response.Error(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
recordAudit(c, h.auditService, "backup_verify", "manual_run", "backup_task", fmt.Sprintf("%d", taskID), "",
|
|
||||||
fmt.Sprintf("手动触发验证(任务 ID: %d, 验证记录 ID: %d, 模式: %s)", taskID, detail.ID, detail.Mode))
|
|
||||||
response.Success(c, detail)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TriggerByRecord 基于指定备份记录触发验证(允许验证历史备份)。
|
|
||||||
func (h *VerificationHandler) TriggerByRecord(c *gin.Context) {
|
|
||||||
recordID, ok := parseUintParam(c, "id")
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var input struct {
|
|
||||||
Mode string `json:"mode"`
|
|
||||||
}
|
|
||||||
_ = c.ShouldBindJSON(&input)
|
|
||||||
triggeredBy := ""
|
|
||||||
if subject, exists := c.Get(contextUserSubjectKey); exists {
|
|
||||||
triggeredBy = strings.TrimSpace(fmt.Sprintf("%v", subject))
|
|
||||||
}
|
|
||||||
if triggeredBy == "" {
|
|
||||||
triggeredBy = "manual"
|
|
||||||
}
|
|
||||||
detail, err := h.service.Start(c.Request.Context(), recordID, input.Mode, triggeredBy)
|
|
||||||
if err != nil {
|
|
||||||
response.Error(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
recordAudit(c, h.auditService, "backup_verify", "manual_run", "backup_record", fmt.Sprintf("%d", recordID), "",
|
|
||||||
fmt.Sprintf("手动触发验证(备份记录 ID: %d, 验证记录 ID: %d, 模式: %s)", recordID, detail.ID, detail.Mode))
|
|
||||||
response.Success(c, detail)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *VerificationHandler) List(c *gin.Context) {
|
|
||||||
filter, err := buildVerifyFilter(c)
|
|
||||||
if err != nil {
|
|
||||||
response.Error(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
items, err := h.service.List(c.Request.Context(), filter)
|
|
||||||
if err != nil {
|
|
||||||
response.Error(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
response.Success(c, items)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *VerificationHandler) Get(c *gin.Context) {
|
|
||||||
id, ok := parseUintParam(c, "id")
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
item, err := h.service.Get(c.Request.Context(), id)
|
|
||||||
if err != nil {
|
|
||||||
response.Error(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
response.Success(c, item)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *VerificationHandler) StreamLogs(c *gin.Context) {
|
|
||||||
id, ok := parseUintParam(c, "id")
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
detail, err := h.service.Get(c.Request.Context(), id)
|
|
||||||
if err != nil {
|
|
||||||
response.Error(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
events := detail.LogEvents
|
|
||||||
completed := detail.Status != "running"
|
|
||||||
channel, cancel, err := h.service.SubscribeLogs(c.Request.Context(), id, 64)
|
|
||||||
if err != nil {
|
|
||||||
response.Error(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer cancel()
|
|
||||||
c.Writer.Header().Set("Content-Type", "text/event-stream")
|
|
||||||
c.Writer.Header().Set("Cache-Control", "no-cache")
|
|
||||||
c.Writer.Header().Set("Connection", "keep-alive")
|
|
||||||
flusher, ok := c.Writer.(interface{ Flush() })
|
|
||||||
if !ok {
|
|
||||||
response.Error(c, apperror.Internal("VERIFY_STREAM_UNSUPPORTED", "当前连接不支持日志流", nil))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
for _, event := range events {
|
|
||||||
if err := writeVerifySSEEvent(c.Writer, event); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
flusher.Flush()
|
|
||||||
}
|
|
||||||
if completed {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-c.Request.Context().Done():
|
|
||||||
return
|
|
||||||
case event, ok := <-channel:
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := writeVerifySSEEvent(c.Writer, event); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
flusher.Flush()
|
|
||||||
if event.Completed {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildVerifyFilter(c *gin.Context) (service.VerificationRecordListInput, error) {
|
|
||||||
var filter service.VerificationRecordListInput
|
|
||||||
if value := strings.TrimSpace(c.Query("taskId")); value != "" {
|
|
||||||
parsed, err := strconv.ParseUint(value, 10, 32)
|
|
||||||
if err != nil {
|
|
||||||
return filter, apperror.BadRequest("VERIFY_RECORD_FILTER_INVALID", "taskId 不合法", err)
|
|
||||||
}
|
|
||||||
v := uint(parsed)
|
|
||||||
filter.TaskID = &v
|
|
||||||
}
|
|
||||||
if value := strings.TrimSpace(c.Query("backupRecordId")); value != "" {
|
|
||||||
parsed, err := strconv.ParseUint(value, 10, 32)
|
|
||||||
if err != nil {
|
|
||||||
return filter, apperror.BadRequest("VERIFY_RECORD_FILTER_INVALID", "backupRecordId 不合法", err)
|
|
||||||
}
|
|
||||||
v := uint(parsed)
|
|
||||||
filter.BackupRecordID = &v
|
|
||||||
}
|
|
||||||
filter.Status = strings.TrimSpace(c.Query("status"))
|
|
||||||
if dateFrom := strings.TrimSpace(c.Query("dateFrom")); dateFrom != "" {
|
|
||||||
parsed, err := time.Parse(time.RFC3339, dateFrom)
|
|
||||||
if err != nil {
|
|
||||||
return filter, apperror.BadRequest("VERIFY_RECORD_FILTER_INVALID", "dateFrom 必须为 RFC3339 时间格式", err)
|
|
||||||
}
|
|
||||||
filter.DateFrom = &parsed
|
|
||||||
}
|
|
||||||
if dateTo := strings.TrimSpace(c.Query("dateTo")); dateTo != "" {
|
|
||||||
parsed, err := time.Parse(time.RFC3339, dateTo)
|
|
||||||
if err != nil {
|
|
||||||
return filter, apperror.BadRequest("VERIFY_RECORD_FILTER_INVALID", "dateTo 必须为 RFC3339 时间格式", err)
|
|
||||||
}
|
|
||||||
filter.DateTo = &parsed
|
|
||||||
}
|
|
||||||
return filter, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func writeVerifySSEEvent(writer io.Writer, event backup.LogEvent) error {
|
|
||||||
payload, err := json.Marshal(event)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
_, err = fmt.Fprintf(writer, "event: log\ndata: %s\n\n", payload)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
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,10 +27,8 @@ func TestRenderScriptSystemd(t *testing.T) {
|
|||||||
mustContain := []string{
|
mustContain := []string{
|
||||||
"BACKUPX_AGENT_MASTER=${MASTER_URL}",
|
"BACKUPX_AGENT_MASTER=${MASTER_URL}",
|
||||||
`Environment="BACKUPX_AGENT_TOKEN=${AGENT_TOKEN}"`,
|
`Environment="BACKUPX_AGENT_TOKEN=${AGENT_TOKEN}"`,
|
||||||
"/var/lib/backupx-agent/tmp",
|
|
||||||
"systemctl daemon-reload",
|
"systemctl daemon-reload",
|
||||||
"systemctl enable --now backupx-agent",
|
"systemctl enable --now backupx-agent",
|
||||||
"systemctl status backupx-agent",
|
|
||||||
"X-Agent-Token: ${AGENT_TOKEN}",
|
"X-Agent-Token: ${AGENT_TOKEN}",
|
||||||
"MASTER_URL=\"https://master.example.com\"",
|
"MASTER_URL=\"https://master.example.com\"",
|
||||||
"AGENT_TOKEN=\"deadbeefcafebabe0123456789abcdef0123456789abcdef0123456789abcdef\"",
|
"AGENT_TOKEN=\"deadbeefcafebabe0123456789abcdef0123456789abcdef0123456789abcdef\"",
|
||||||
@@ -58,9 +56,6 @@ func TestRenderScriptForeground(t *testing.T) {
|
|||||||
if !strings.Contains(got, `exec "${INSTALL_PREFIX}/backupx" agent`) {
|
if !strings.Contains(got, `exec "${INSTALL_PREFIX}/backupx" agent`) {
|
||||||
t.Errorf("foreground script missing exec line:\n%s", got)
|
t.Errorf("foreground script missing exec line:\n%s", got)
|
||||||
}
|
}
|
||||||
if !strings.Contains(got, "/var/lib/backupx-agent/tmp") {
|
|
||||||
t.Errorf("foreground script missing dedicated temp dir:\n%s", got)
|
|
||||||
}
|
|
||||||
if strings.Contains(got, "systemctl daemon-reload") {
|
if strings.Contains(got, "systemctl daemon-reload") {
|
||||||
t.Errorf("foreground script should not reference systemctl:\n%s", got)
|
t.Errorf("foreground script should not reference systemctl:\n%s", got)
|
||||||
}
|
}
|
||||||
@@ -79,9 +74,6 @@ func TestRenderScriptDocker(t *testing.T) {
|
|||||||
if !strings.Contains(got, "docker run") {
|
if !strings.Contains(got, "docker run") {
|
||||||
t.Errorf("docker script missing `docker run`:\n%s", got)
|
t.Errorf("docker script missing `docker run`:\n%s", got)
|
||||||
}
|
}
|
||||||
if !strings.Contains(got, "/var/lib/backupx-agent:/var/lib/backupx-agent") {
|
|
||||||
t.Errorf("docker script missing agent data volume:\n%s", got)
|
|
||||||
}
|
|
||||||
if !strings.Contains(got, "awuqing/backupx:${AGENT_VERSION}") {
|
if !strings.Contains(got, "awuqing/backupx:${AGENT_VERSION}") {
|
||||||
t.Errorf("docker script missing image tag reference:\n%s", got)
|
t.Errorf("docker script missing image tag reference:\n%s", got)
|
||||||
}
|
}
|
||||||
@@ -103,17 +95,14 @@ func TestRenderComposeYaml(t *testing.T) {
|
|||||||
if !strings.Contains(got, `BACKUPX_AGENT_TOKEN: "deadbeefcafebabe0123456789abcdef0123456789abcdef0123456789abcdef"`) {
|
if !strings.Contains(got, `BACKUPX_AGENT_TOKEN: "deadbeefcafebabe0123456789abcdef0123456789abcdef0123456789abcdef"`) {
|
||||||
t.Errorf("compose missing token env:\n%s", got)
|
t.Errorf("compose missing token env:\n%s", got)
|
||||||
}
|
}
|
||||||
if !strings.Contains(got, "/var/lib/backupx-agent:/var/lib/backupx-agent") {
|
|
||||||
t.Errorf("compose missing agent data volume:\n%s", got)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRenderScriptRejectsInjectedMasterURL(t *testing.T) {
|
func TestRenderScriptRejectsInjectedMasterURL(t *testing.T) {
|
||||||
bad := []string{
|
bad := []string{
|
||||||
"https://example.com\" other: inject", // 含引号和空格
|
"https://example.com\" other: inject", // 含引号和空格
|
||||||
"javascript:alert(1)", // scheme 非法
|
"javascript:alert(1)", // scheme 非法
|
||||||
"https://example.com\n- privileged", // 含换行,YAML 注入经典 payload
|
"https://example.com\n- privileged", // 含换行,YAML 注入经典 payload
|
||||||
"", // 空
|
"", // 空
|
||||||
}
|
}
|
||||||
for _, u := range bad {
|
for _, u := range bad {
|
||||||
ctx := testCtx
|
ctx := testCtx
|
||||||
@@ -172,8 +161,8 @@ func TestDownloadBaseMapping(t *testing.T) {
|
|||||||
|
|
||||||
func TestRenderScriptDefaultsApplied(t *testing.T) {
|
func TestRenderScriptDefaultsApplied(t *testing.T) {
|
||||||
ctx := testCtx
|
ctx := testCtx
|
||||||
ctx.InstallPrefix = "" // 应被默认为 /opt/backupx-agent
|
ctx.InstallPrefix = "" // 应被默认为 /opt/backupx-agent
|
||||||
ctx.DownloadBase = "" // 应被默认为 github
|
ctx.DownloadBase = "" // 应被默认为 github
|
||||||
got, err := RenderScript(ctx)
|
got, err := RenderScript(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("render err: %v", err)
|
t.Fatalf("render err: %v", err)
|
||||||
|
|||||||
@@ -10,4 +10,4 @@ services:
|
|||||||
BACKUPX_AGENT_MASTER: "{{.MasterURL}}"
|
BACKUPX_AGENT_MASTER: "{{.MasterURL}}"
|
||||||
BACKUPX_AGENT_TOKEN: "{{.AgentToken}}"
|
BACKUPX_AGENT_TOKEN: "{{.AgentToken}}"
|
||||||
volumes:
|
volumes:
|
||||||
- /var/lib/backupx-agent:/var/lib/backupx-agent
|
- /var/lib/backupx-agent:/tmp/backupx-agent
|
||||||
|
|||||||
@@ -1,16 +1,8 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# BackupX Agent 一键安装脚本(由 Master 动态渲染)
|
# BackupX Agent 一键安装脚本(由 Master 动态渲染)
|
||||||
# Magic: BACKUPX_AGENT_INSTALL_V1 —— 若 `head -3 脚本` 看不到此行,说明反向代理/CDN 改写了响应
|
|
||||||
# 模式: {{.Mode}} | 架构: {{.Arch}} | 版本: {{.AgentVersion}}
|
# 模式: {{.Mode}} | 架构: {{.Arch}} | 版本: {{.AgentVersion}}
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
# 自举到 bash(文件执行模式下生效;管道模式 $0 不是文件,exec 会静默失败,继续用 sh)。
|
|
||||||
# 动机:部分 Debian/Ubuntu 用户通过 `curl | sudo sh` 触发时,dash 对本脚本报语法错误;
|
|
||||||
# 若目标机装有 bash,优先切换到 bash 获得更一致的行为。
|
|
||||||
if [ -z "${BASH_VERSION:-}" ] && command -v bash >/dev/null 2>&1 && [ -f "$0" ]; then
|
|
||||||
exec bash "$0" "$@"
|
|
||||||
fi
|
|
||||||
|
|
||||||
MASTER_URL="{{.MasterURL}}"
|
MASTER_URL="{{.MasterURL}}"
|
||||||
AGENT_TOKEN="{{.AgentToken}}"
|
AGENT_TOKEN="{{.AgentToken}}"
|
||||||
AGENT_VERSION="{{.AgentVersion}}"
|
AGENT_VERSION="{{.AgentVersion}}"
|
||||||
@@ -47,10 +39,10 @@ else
|
|||||||
fi
|
fi
|
||||||
tar xzf "$TMPDIR/pkg.tar.gz" -C "$TMPDIR"
|
tar xzf "$TMPDIR/pkg.tar.gz" -C "$TMPDIR"
|
||||||
|
|
||||||
# 4. 安装二进制 + 数据目录
|
# 4. 安装二进制 + 用户
|
||||||
echo "[2/4] 安装到 ${INSTALL_PREFIX}"
|
echo "[2/4] 安装到 ${INSTALL_PREFIX}"
|
||||||
install -d -m 0755 "$INSTALL_PREFIX"
|
id backupx >/dev/null 2>&1 || useradd --system --home-dir "$INSTALL_PREFIX" --shell /usr/sbin/nologin backupx
|
||||||
install -d -m 0700 /var/lib/backupx-agent /var/lib/backupx-agent/tmp
|
install -d -o backupx -g backupx "$INSTALL_PREFIX" /var/lib/backupx-agent
|
||||||
install -m 0755 "$TMPDIR/backupx-${AGENT_VERSION}-linux-${ARCH}/backupx" "$INSTALL_PREFIX/backupx"
|
install -m 0755 "$TMPDIR/backupx-${AGENT_VERSION}-linux-${ARCH}/backupx" "$INSTALL_PREFIX/backupx"
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
@@ -65,11 +57,13 @@ Wants=network-online.target
|
|||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Type=simple
|
Type=simple
|
||||||
|
User=backupx
|
||||||
Environment="BACKUPX_AGENT_MASTER=${MASTER_URL}"
|
Environment="BACKUPX_AGENT_MASTER=${MASTER_URL}"
|
||||||
Environment="BACKUPX_AGENT_TOKEN=${AGENT_TOKEN}"
|
Environment="BACKUPX_AGENT_TOKEN=${AGENT_TOKEN}"
|
||||||
ExecStart=${INSTALL_PREFIX}/backupx agent --temp-dir /var/lib/backupx-agent/tmp
|
ExecStart=${INSTALL_PREFIX}/backupx agent --temp-dir /var/lib/backupx-agent
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
RestartSec=10s
|
RestartSec=10s
|
||||||
|
NoNewPrivileges=true
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
WantedBy=multi-user.target
|
WantedBy=multi-user.target
|
||||||
@@ -88,7 +82,6 @@ for i in $(seq 1 15); do
|
|||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
echo "⚠ 30s 内未收到上线心跳,请检查防火墙或 journalctl -u backupx-agent"
|
echo "⚠ 30s 内未收到上线心跳,请检查防火墙或 journalctl -u backupx-agent"
|
||||||
echo "提示:systemd 服务名是 backupx-agent,可执行 systemctl status backupx-agent 查看状态。"
|
|
||||||
exit 2
|
exit 2
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
@@ -97,7 +90,7 @@ exit 2
|
|||||||
echo "[3/3] 前台启动 agent(Ctrl+C 退出)"
|
echo "[3/3] 前台启动 agent(Ctrl+C 退出)"
|
||||||
export BACKUPX_AGENT_MASTER="${MASTER_URL}"
|
export BACKUPX_AGENT_MASTER="${MASTER_URL}"
|
||||||
export BACKUPX_AGENT_TOKEN="${AGENT_TOKEN}"
|
export BACKUPX_AGENT_TOKEN="${AGENT_TOKEN}"
|
||||||
exec "${INSTALL_PREFIX}/backupx" agent --temp-dir /var/lib/backupx-agent/tmp
|
exec "${INSTALL_PREFIX}/backupx" agent --temp-dir /var/lib/backupx-agent
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
{{if eq .Mode "docker"}}
|
{{if eq .Mode "docker"}}
|
||||||
@@ -109,7 +102,7 @@ docker rm -f backupx-agent >/dev/null 2>&1 || true
|
|||||||
docker run -d --name backupx-agent --restart=unless-stopped \
|
docker run -d --name backupx-agent --restart=unless-stopped \
|
||||||
-e "BACKUPX_AGENT_MASTER=${MASTER_URL}" \
|
-e "BACKUPX_AGENT_MASTER=${MASTER_URL}" \
|
||||||
-e "BACKUPX_AGENT_TOKEN=${AGENT_TOKEN}" \
|
-e "BACKUPX_AGENT_TOKEN=${AGENT_TOKEN}" \
|
||||||
-v /var/lib/backupx-agent:/var/lib/backupx-agent \
|
-v /var/lib/backupx-agent:/tmp/backupx-agent \
|
||||||
"awuqing/backupx:${AGENT_VERSION}" agent
|
"awuqing/backupx:${AGENT_VERSION}" agent
|
||||||
echo "✓ 容器已启动"
|
echo "✓ 容器已启动"
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@@ -1,152 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,225 +0,0 @@
|
|||||||
// 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))
|
|
||||||
}
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -20,19 +20,6 @@ const (
|
|||||||
// Payload: {"path": "/var/log"}
|
// Payload: {"path": "/var/log"}
|
||||||
// Result: {"entries": [{"name":"...", "path":"...", "isDir":true, "size":0}]}
|
// Result: {"entries": [{"name":"...", "path":"...", "isDir":true, "size":0}]}
|
||||||
AgentCommandTypeListDir = "list_dir"
|
AgentCommandTypeListDir = "list_dir"
|
||||||
// AgentCommandTypeRestoreRecord 在 Agent 节点上恢复指定备份记录
|
|
||||||
// Payload: {"restoreRecordId": 789}
|
|
||||||
// Agent 拉 /api/agent/restores/:id/spec 获取完整规格后执行恢复
|
|
||||||
AgentCommandTypeRestoreRecord = "restore_record"
|
|
||||||
// AgentCommandTypeDiscoverDB 在 Agent 节点上发现数据库列表
|
|
||||||
// Payload: {"type": "mysql", "host": "...", "port": 3306, "user": "...", "password": "..."}
|
|
||||||
// Result: {"databases": ["db1", "db2"]}
|
|
||||||
AgentCommandTypeDiscoverDB = "discover_db"
|
|
||||||
// AgentCommandTypeDeleteStorageObject 在 Agent 节点上删除指定存储对象
|
|
||||||
// Payload: {"targetType": "local_disk", "targetConfig": {...}, "storagePath": "tasks/1/x.tar.gz"}
|
|
||||||
// 用于跨节点 local_disk 场景:Master 删记录时请求 Agent 清理其本地备份文件。
|
|
||||||
// Agent 需具备对应存储 provider 的执行能力。best-effort:失败仅影响 Agent 侧文件残留。
|
|
||||||
AgentCommandTypeDeleteStorageObject = "delete_storage_object"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// AgentCommand 代表 Master 发给某个 Agent 节点的待执行命令。
|
// AgentCommand 代表 Master 发给某个 Agent 节点的待执行命令。
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
package model
|
|
||||||
|
|
||||||
import "time"
|
|
||||||
|
|
||||||
// ApiKey 用于 CI/CD、监控脚本等非交互式场景通过 HTTP API 访问 BackupX。
|
|
||||||
// 明文 Key 仅在创建时返回一次,数据库存储 SHA-256 哈希。
|
|
||||||
// 认证中间件:当 Authorization: Bearer 值以 "bax_" 前缀开头时走 API Key 验证。
|
|
||||||
type ApiKey struct {
|
|
||||||
ID uint `gorm:"primaryKey" json:"id"`
|
|
||||||
Name string `gorm:"size:128;not null" json:"name"`
|
|
||||||
Role string `gorm:"size:32;not null;default:viewer" json:"role"`
|
|
||||||
KeyHash string `gorm:"column:key_hash;size:128;uniqueIndex;not null" json:"-"`
|
|
||||||
Prefix string `gorm:"size:32;not null" json:"prefix"`
|
|
||||||
CreatedBy string `gorm:"column:created_by;size:128" json:"createdBy"`
|
|
||||||
LastUsedAt *time.Time `gorm:"column:last_used_at" json:"lastUsedAt,omitempty"`
|
|
||||||
ExpiresAt *time.Time `gorm:"column:expires_at" json:"expiresAt,omitempty"`
|
|
||||||
Disabled bool `gorm:"not null;default:false" json:"disabled"`
|
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
|
||||||
UpdatedAt time.Time `json:"updatedAt"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (ApiKey) TableName() string {
|
|
||||||
return "api_keys"
|
|
||||||
}
|
|
||||||
@@ -14,9 +14,6 @@ type BackupRecord struct {
|
|||||||
Task BackupTask `json:"task,omitempty"`
|
Task BackupTask `json:"task,omitempty"`
|
||||||
StorageTargetID uint `gorm:"column:storage_target_id;index;not null" json:"storageTargetId"`
|
StorageTargetID uint `gorm:"column:storage_target_id;index;not null" json:"storageTargetId"`
|
||||||
StorageTarget StorageTarget `json:"storageTarget,omitempty"`
|
StorageTarget StorageTarget `json:"storageTarget,omitempty"`
|
||||||
// NodeID 执行该次备份的节点(0 = 本机 Master)。用于集群中识别 local_disk 类型
|
|
||||||
// 存储的归属节点,避免 Master 端试图跨节点访问远程 Agent 的本地存储。
|
|
||||||
NodeID uint `gorm:"column:node_id;index;default:0" json:"nodeId"`
|
|
||||||
Status string `gorm:"size:20;index;not null" json:"status"`
|
Status string `gorm:"size:20;index;not null" json:"status"`
|
||||||
FileName string `gorm:"column:file_name;size:255" json:"fileName"`
|
FileName string `gorm:"column:file_name;size:255" json:"fileName"`
|
||||||
FileSize int64 `gorm:"column:file_size;not null;default:0" json:"fileSize"`
|
FileSize int64 `gorm:"column:file_size;not null;default:0" json:"fileSize"`
|
||||||
|
|||||||
@@ -39,10 +39,6 @@ type BackupTask struct {
|
|||||||
StorageTargets []StorageTarget `gorm:"many2many:backup_task_storage_targets" json:"storageTargets,omitempty"`
|
StorageTargets []StorageTarget `gorm:"many2many:backup_task_storage_targets" json:"storageTargets,omitempty"`
|
||||||
NodeID uint `gorm:"column:node_id;index;default:0" json:"nodeId"`
|
NodeID uint `gorm:"column:node_id;index;default:0" json:"nodeId"`
|
||||||
Node Node `json:"node,omitempty"`
|
Node Node `json:"node,omitempty"`
|
||||||
// NodePoolTag 节点池标签(可选)。非空且 NodeID=0 时,调度器会从 Node.Labels 包含该 tag
|
|
||||||
// 的在线节点中动态挑选一台执行(按运行中任务数最少原则),失败会 best-effort 切换到下一个候选。
|
|
||||||
// 典型场景:NodePoolTag="db" 让 MySQL 备份任务在任意标有 "db" 的数据库节点执行。
|
|
||||||
NodePoolTag string `gorm:"column:node_pool_tag;size:64;index" json:"nodePoolTag"`
|
|
||||||
Tags string `gorm:"column:tags;size:500" json:"tags"`
|
Tags string `gorm:"column:tags;size:500" json:"tags"`
|
||||||
RetentionDays int `gorm:"column:retention_days;not null;default:30" json:"retentionDays"`
|
RetentionDays int `gorm:"column:retention_days;not null;default:30" json:"retentionDays"`
|
||||||
Compression string `gorm:"size:10;not null;default:'gzip'" json:"compression"`
|
Compression string `gorm:"size:10;not null;default:'gzip'" json:"compression"`
|
||||||
@@ -50,25 +46,6 @@ type BackupTask struct {
|
|||||||
MaxBackups int `gorm:"column:max_backups;not null;default:10" json:"maxBackups"`
|
MaxBackups int `gorm:"column:max_backups;not null;default:10" json:"maxBackups"`
|
||||||
LastRunAt *time.Time `gorm:"column:last_run_at" json:"lastRunAt,omitempty"`
|
LastRunAt *time.Time `gorm:"column:last_run_at" json:"lastRunAt,omitempty"`
|
||||||
LastStatus string `gorm:"column:last_status;size:20;not null;default:'idle'" json:"lastStatus"`
|
LastStatus string `gorm:"column:last_status;size:20;not null;default:'idle'" json:"lastStatus"`
|
||||||
// 验证(恢复演练)配置 — 定期自动校验备份可恢复性
|
|
||||||
VerifyEnabled bool `gorm:"column:verify_enabled;not null;default:false" json:"verifyEnabled"`
|
|
||||||
VerifyCronExpr string `gorm:"column:verify_cron_expr;size:64" json:"verifyCronExpr"`
|
|
||||||
VerifyMode string `gorm:"column:verify_mode;size:20;not null;default:'quick'" json:"verifyMode"`
|
|
||||||
// SLA 配置 — RPO(期望最长未备份间隔)与告警阈值
|
|
||||||
SLAHoursRPO int `gorm:"column:sla_hours_rpo;not null;default:0" json:"slaHoursRpo"`
|
|
||||||
AlertOnConsecutiveFails int `gorm:"column:alert_on_consecutive_fails;not null;default:1" json:"alertOnConsecutiveFails"`
|
|
||||||
// ReplicationTargetIDs 备份复制目标存储 ID 列表(CSV)。
|
|
||||||
// 备份完成后,系统将自动把成果从任务主存储(StorageTargets 的第一个)复制到这些目标。
|
|
||||||
// 满足 3-2-1 规则:至少 2 份副本,且至少 1 份异地(不同 provider/region)。
|
|
||||||
ReplicationTargetIDs string `gorm:"column:replication_target_ids;size:500" json:"replicationTargetIds"`
|
|
||||||
// MaintenanceWindows 允许执行备份的时段(格式详见 backup/window.go)。
|
|
||||||
// 空 = 不限制。非空时调度器在非窗口跳过,手动执行返回友好错误。
|
|
||||||
MaintenanceWindows string `gorm:"column:maintenance_windows;size:500" json:"maintenanceWindows"`
|
|
||||||
// DependsOnTaskIDs 依赖的上游任务 ID 列表(CSV)。
|
|
||||||
// 语义:上游任务成功后自动触发本任务,形成工作流(如 DB 备份完成 → 归档压缩)。
|
|
||||||
// 调度器继续按本任务自己的 cron 触发,仅"自动触发"路径响应依赖完成事件。
|
|
||||||
// 循环依赖检查在 service 层完成,避免配置阶段即出错。
|
|
||||||
DependsOnTaskIDs string `gorm:"column:depends_on_task_ids;size:500" json:"dependsOnTaskIds"`
|
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
UpdatedAt time.Time `json:"updatedAt"`
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
package model
|
package model
|
||||||
|
|
||||||
import (
|
import "time"
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
const (
|
||||||
NodeStatusOnline = "online"
|
NodeStatusOnline = "online"
|
||||||
@@ -26,48 +23,8 @@ type Node struct {
|
|||||||
LastSeen time.Time `gorm:"column:last_seen" json:"lastSeen"`
|
LastSeen time.Time `gorm:"column:last_seen" json:"lastSeen"`
|
||||||
PrevToken string `gorm:"size:128;index" json:"-"`
|
PrevToken string `gorm:"size:128;index" json:"-"`
|
||||||
PrevTokenExpires *time.Time `gorm:"column:prev_token_expires" json:"-"`
|
PrevTokenExpires *time.Time `gorm:"column:prev_token_expires" json:"-"`
|
||||||
// MaxConcurrent 该节点允许的最大并发任务数(0=不限制,沿用全局 cfg.Backup.MaxConcurrent)。
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
// 用于大集群中限制单节点资源占用:例如小内存 Agent 节点可配 1,避免多个大备份同时跑挤爆。
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
MaxConcurrent int `gorm:"column:max_concurrent;not null;default:0" json:"maxConcurrent"`
|
|
||||||
// BandwidthLimit 该节点上传带宽上限(rclone 可识别格式:10M / 1G / 0=不限)。
|
|
||||||
// 对集群感知的上传场景有效(Master 本地与 Agent 运行时均会应用)。
|
|
||||||
BandwidthLimit string `gorm:"column:bandwidth_limit;size:32" json:"bandwidthLimit"`
|
|
||||||
// Labels 节点标签(CSV,如 "prod,db-host,high-mem")。
|
|
||||||
// 用于任务调度的节点池选择:任务配置 NodePoolTag 时,调度器会从 Labels 包含该 tag 的
|
|
||||||
// 在线节点中自动挑选一台执行(按当前运行中任务数升序)。单节点可属多个池。
|
|
||||||
Labels string `gorm:"column:labels;size:500" json:"labels"`
|
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
|
||||||
UpdatedAt time.Time `json:"updatedAt"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// LabelSet 把 CSV Labels 解析为 set,便于做成员判定。
|
|
||||||
// 空白与空 token 自动忽略。
|
|
||||||
func (n *Node) LabelSet() map[string]struct{} {
|
|
||||||
if n == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
out := make(map[string]struct{})
|
|
||||||
for _, raw := range strings.Split(n.Labels, ",") {
|
|
||||||
label := strings.TrimSpace(raw)
|
|
||||||
if label != "" {
|
|
||||||
out[label] = struct{}{}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// HasLabel 判断节点是否属于指定池。nil/空 tag 返回 false。
|
|
||||||
func (n *Node) HasLabel(tag string) bool {
|
|
||||||
tag = strings.TrimSpace(tag)
|
|
||||||
if n == nil || tag == "" {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
for _, raw := range strings.Split(n.Labels, ",") {
|
|
||||||
if strings.TrimSpace(raw) == tag {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (Node) TableName() string {
|
func (Node) TableName() string {
|
||||||
|
|||||||
@@ -1,47 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,26 +2,6 @@ package model
|
|||||||
|
|
||||||
import "time"
|
import "time"
|
||||||
|
|
||||||
// 通知事件类型(企业级事件总线)。
|
|
||||||
// 任一 Notification 可订阅多个事件,EventTypes 字段存 CSV。
|
|
||||||
// 空 EventTypes + OnSuccess/OnFailure=true 时沿用旧语义(仅备份成功/失败)。
|
|
||||||
const (
|
|
||||||
NotificationEventBackupSuccess = "backup_success"
|
|
||||||
NotificationEventBackupFailed = "backup_failed"
|
|
||||||
NotificationEventRestoreSuccess = "restore_success"
|
|
||||||
NotificationEventRestoreFailed = "restore_failed"
|
|
||||||
NotificationEventVerifyFailed = "verify_failed"
|
|
||||||
NotificationEventSLAViolation = "sla_violation"
|
|
||||||
// NotificationEventStorageUnhealthy 存储目标连接失败(后台健康扫描触发)。
|
|
||||||
NotificationEventStorageUnhealthy = "storage_unhealthy"
|
|
||||||
// NotificationEventReplicationFailed 备份复制失败。
|
|
||||||
NotificationEventReplicationFailed = "replication_failed"
|
|
||||||
// NotificationEventAgentOutdated Agent 版本落后 Master,建议升级。
|
|
||||||
NotificationEventAgentOutdated = "agent_outdated"
|
|
||||||
// NotificationEventStorageCapacity 存储目标使用率超过预警阈值(85%)。
|
|
||||||
NotificationEventStorageCapacity = "storage_capacity_warning"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Notification struct {
|
type Notification struct {
|
||||||
ID uint `gorm:"primaryKey" json:"id"`
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
Type string `gorm:"size:20;index;not null" json:"type"`
|
Type string `gorm:"size:20;index;not null" json:"type"`
|
||||||
@@ -30,11 +10,8 @@ type Notification struct {
|
|||||||
Enabled bool `gorm:"not null;default:true" json:"enabled"`
|
Enabled bool `gorm:"not null;default:true" json:"enabled"`
|
||||||
OnSuccess bool `gorm:"column:on_success;not null;default:false" json:"onSuccess"`
|
OnSuccess bool `gorm:"column:on_success;not null;default:false" json:"onSuccess"`
|
||||||
OnFailure bool `gorm:"column:on_failure;not null;default:true" json:"onFailure"`
|
OnFailure bool `gorm:"column:on_failure;not null;default:true" json:"onFailure"`
|
||||||
// EventTypes 逗号分隔,订阅的事件类型。
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
// 空 = 仅监听备份成功/失败(兼容旧配置);非空则严格按订阅触发。
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
EventTypes string `gorm:"column:event_types;size:500" json:"eventTypes"`
|
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
|
||||||
UpdatedAt time.Time `json:"updatedAt"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (Notification) TableName() string {
|
func (Notification) TableName() string {
|
||||||
|
|||||||
@@ -1,44 +0,0 @@
|
|||||||
package model
|
|
||||||
|
|
||||||
import "time"
|
|
||||||
|
|
||||||
// ReplicationRecord 记录一次备份复制的执行。
|
|
||||||
// 触发方式:
|
|
||||||
// - 自动:备份成功后,根据 task.ReplicationTargetIDs 自动派发
|
|
||||||
// - 手动:从备份记录详情页手动触发
|
|
||||||
//
|
|
||||||
// 核心语义:把源存储上的备份对象 mirror 到目标存储,保留 StoragePath。
|
|
||||||
// 3-2-1 规则核心:每份备份至少存在于两个独立存储目标,且至少一份异地。
|
|
||||||
const (
|
|
||||||
ReplicationStatusRunning = "running"
|
|
||||||
ReplicationStatusSuccess = "success"
|
|
||||||
ReplicationStatusFailed = "failed"
|
|
||||||
)
|
|
||||||
|
|
||||||
type ReplicationRecord struct {
|
|
||||||
ID uint `gorm:"primaryKey" json:"id"`
|
|
||||||
BackupRecordID uint `gorm:"column:backup_record_id;index;not null" json:"backupRecordId"`
|
|
||||||
BackupRecord BackupRecord `json:"backupRecord,omitempty"`
|
|
||||||
TaskID uint `gorm:"column:task_id;index;not null" json:"taskId"`
|
|
||||||
// SourceTargetID 源存储目标(备份已存在于此)
|
|
||||||
SourceTargetID uint `gorm:"column:source_target_id;index;not null" json:"sourceTargetId"`
|
|
||||||
SourceTarget StorageTarget `gorm:"foreignKey:SourceTargetID;references:ID" json:"sourceTarget,omitempty"`
|
|
||||||
// DestTargetID 目标存储(复制过去)
|
|
||||||
DestTargetID uint `gorm:"column:dest_target_id;index;not null" json:"destTargetId"`
|
|
||||||
DestTarget StorageTarget `gorm:"foreignKey:DestTargetID;references:ID" json:"destTarget,omitempty"`
|
|
||||||
Status string `gorm:"size:20;index;not null" json:"status"`
|
|
||||||
StoragePath string `gorm:"column:storage_path;size:500" json:"storagePath"`
|
|
||||||
FileSize int64 `gorm:"column:file_size;not null;default:0" json:"fileSize"`
|
|
||||||
Checksum string `gorm:"column:checksum;size:64" json:"checksum"`
|
|
||||||
ErrorMessage string `gorm:"column:error_message;size:2000" json:"errorMessage"`
|
|
||||||
DurationSeconds int `gorm:"column:duration_seconds;not null;default:0" json:"durationSeconds"`
|
|
||||||
TriggeredBy string `gorm:"column:triggered_by;size:100" json:"triggeredBy"`
|
|
||||||
StartedAt time.Time `gorm:"column:started_at;index;not null" json:"startedAt"`
|
|
||||||
CompletedAt *time.Time `gorm:"column:completed_at;index" json:"completedAt,omitempty"`
|
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
|
||||||
UpdatedAt time.Time `json:"updatedAt"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (ReplicationRecord) TableName() string {
|
|
||||||
return "replication_records"
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
package model
|
|
||||||
|
|
||||||
import "time"
|
|
||||||
|
|
||||||
// RestoreRecord 代表一次恢复执行,用于审计、实时日志与列表页。
|
|
||||||
// 每次从 BackupRecord 触发恢复都会产生独立 RestoreRecord,与 BackupRecord 一对多。
|
|
||||||
const (
|
|
||||||
RestoreRecordStatusRunning = "running"
|
|
||||||
RestoreRecordStatusSuccess = "success"
|
|
||||||
RestoreRecordStatusFailed = "failed"
|
|
||||||
)
|
|
||||||
|
|
||||||
type RestoreRecord struct {
|
|
||||||
ID uint `gorm:"primaryKey" json:"id"`
|
|
||||||
BackupRecordID uint `gorm:"column:backup_record_id;index;not null" json:"backupRecordId"`
|
|
||||||
BackupRecord BackupRecord `json:"backupRecord,omitempty"`
|
|
||||||
TaskID uint `gorm:"column:task_id;index;not null" json:"taskId"`
|
|
||||||
Task BackupTask `json:"task,omitempty"`
|
|
||||||
NodeID uint `gorm:"column:node_id;index;default:0" json:"nodeId"`
|
|
||||||
Status string `gorm:"size:20;index;not null" json:"status"`
|
|
||||||
ErrorMessage string `gorm:"column:error_message;size:2000" json:"errorMessage"`
|
|
||||||
LogContent string `gorm:"column:log_content;type:text" json:"logContent"`
|
|
||||||
DurationSeconds int `gorm:"column:duration_seconds;not null;default:0" json:"durationSeconds"`
|
|
||||||
StartedAt time.Time `gorm:"column:started_at;index;not null" json:"startedAt"`
|
|
||||||
CompletedAt *time.Time `gorm:"column:completed_at;index" json:"completedAt,omitempty"`
|
|
||||||
TriggeredBy string `gorm:"column:triggered_by;size:100" json:"triggeredBy"`
|
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
|
||||||
UpdatedAt time.Time `json:"updatedAt"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (RestoreRecord) TableName() string {
|
|
||||||
return "restore_records"
|
|
||||||
}
|
|
||||||
@@ -14,12 +14,8 @@ type StorageTarget struct {
|
|||||||
LastTestedAt *time.Time `gorm:"column:last_tested_at" json:"lastTestedAt,omitempty"`
|
LastTestedAt *time.Time `gorm:"column:last_tested_at" json:"lastTestedAt,omitempty"`
|
||||||
LastTestStatus string `gorm:"column:last_test_status;size:32;not null;default:'unknown'" json:"lastTestStatus"`
|
LastTestStatus string `gorm:"column:last_test_status;size:32;not null;default:'unknown'" json:"lastTestStatus"`
|
||||||
LastTestMessage string `gorm:"column:last_test_message;size:512" json:"lastTestMessage"`
|
LastTestMessage string `gorm:"column:last_test_message;size:512" json:"lastTestMessage"`
|
||||||
// QuotaBytes 软限额(字节)。0 = 不限制。
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
// 备份执行前检查:该目标上已累计字节数 + 本次文件大小 > QuotaBytes 时拒绝上传。
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
// 比容量预警(85% 通知)更严格,作为企业治理"防超用"的硬性闸门。
|
|
||||||
QuotaBytes int64 `gorm:"column:quota_bytes;not null;default:0" json:"quotaBytes"`
|
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
|
||||||
UpdatedAt time.Time `json:"updatedAt"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (StorageTarget) TableName() string {
|
func (StorageTarget) TableName() string {
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
package model
|
|
||||||
|
|
||||||
import "time"
|
|
||||||
|
|
||||||
// TaskTemplate 是批量创建任务的模板。
|
|
||||||
// 用途:大规模场景(100+ 任务)下保存一份参数预设,
|
|
||||||
// 再通过"应用模板"接口一次性创建多个任务(变量替换 Name/SourcePath 等)。
|
|
||||||
//
|
|
||||||
// 参数存 JSON(Payload),结构与 service.BackupTaskUpsertInput 基本一致,
|
|
||||||
// 仅以下字段在应用时可被变量覆盖:
|
|
||||||
// - name
|
|
||||||
// - sourcePath / sourcePaths 中的 {{.Host}} / {{.Env}} 等占位符
|
|
||||||
type TaskTemplate struct {
|
|
||||||
ID uint `gorm:"primaryKey" json:"id"`
|
|
||||||
Name string `gorm:"size:128;uniqueIndex;not null" json:"name"`
|
|
||||||
Description string `gorm:"size:500" json:"description"`
|
|
||||||
TaskType string `gorm:"column:task_type;size:20;not null" json:"taskType"`
|
|
||||||
// Payload JSON,存完整 BackupTaskUpsertInput 的序列化
|
|
||||||
Payload string `gorm:"type:text;not null" json:"payload"`
|
|
||||||
CreatedBy string `gorm:"column:created_by;size:128" json:"createdBy"`
|
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
|
||||||
UpdatedAt time.Time `json:"updatedAt"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (TaskTemplate) TableName() string {
|
|
||||||
return "task_templates"
|
|
||||||
}
|
|
||||||
@@ -2,49 +2,15 @@ package model
|
|||||||
|
|
||||||
import "time"
|
import "time"
|
||||||
|
|
||||||
// 用户角色常量。RBAC 策略:
|
|
||||||
// - admin:系统全权(创建用户、管理 API Key、删除数据、改设置)
|
|
||||||
// - operator:日常运维(创建/编辑/执行任务、触发恢复与验证、管理存储目标与通知)
|
|
||||||
// - viewer:只读(查看仪表盘、任务、记录、日志,不能触发或改变状态)
|
|
||||||
const (
|
|
||||||
UserRoleAdmin = "admin"
|
|
||||||
UserRoleOperator = "operator"
|
|
||||||
UserRoleViewer = "viewer"
|
|
||||||
)
|
|
||||||
|
|
||||||
// IsValidRole 校验角色字符串合法。
|
|
||||||
func IsValidRole(role string) bool {
|
|
||||||
switch role {
|
|
||||||
case UserRoleAdmin, UserRoleOperator, UserRoleViewer:
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
type User struct {
|
type User struct {
|
||||||
ID uint `gorm:"primaryKey" json:"id"`
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
Username string `gorm:"size:64;uniqueIndex;not null" json:"username"`
|
Username string `gorm:"size:64;uniqueIndex;not null" json:"username"`
|
||||||
PasswordHash string `gorm:"column:password_hash;not null" json:"-"`
|
PasswordHash string `gorm:"column:password_hash;not null" json:"-"`
|
||||||
DisplayName string `gorm:"size:128;not null" json:"displayName"`
|
DisplayName string `gorm:"size:128;not null" json:"displayName"`
|
||||||
Email string `gorm:"size:255" json:"email"`
|
Email string `gorm:"size:255" json:"email"`
|
||||||
Phone string `gorm:"size:64" json:"phone"`
|
Role string `gorm:"size:32;not null;default:admin" json:"role"`
|
||||||
Role string `gorm:"size:32;not null;default:admin" json:"role"`
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
// TwoFactorSecretCiphertext 保存 TOTP 密钥密文;未启用时可作为待确认密钥。
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
TwoFactorEnabled bool `gorm:"column:two_factor_enabled;not null;default:false" json:"twoFactorEnabled"`
|
|
||||||
TwoFactorSecretCiphertext string `gorm:"column:two_factor_secret_ciphertext;type:text" json:"-"`
|
|
||||||
// TwoFactorRecoveryCodeHashes 保存一次性恢复码哈希的 JSON 数组。
|
|
||||||
TwoFactorRecoveryCodeHashes string `gorm:"column:two_factor_recovery_code_hashes;type:text" json:"-"`
|
|
||||||
// WebAuthnCredentials 保存通行密钥公钥元数据 JSON,不包含私钥或明文密钥。
|
|
||||||
WebAuthnCredentials string `gorm:"column:webauthn_credentials;type:text" json:"-"`
|
|
||||||
WebAuthnChallengeCiphertext string `gorm:"column:webauthn_challenge_ciphertext;type:text" json:"-"`
|
|
||||||
TrustedDevices string `gorm:"column:trusted_devices;type:text" json:"-"`
|
|
||||||
EmailOTPEnabled bool `gorm:"column:email_otp_enabled;not null;default:false" json:"emailOtpEnabled"`
|
|
||||||
SMSOTPEnabled bool `gorm:"column:sms_otp_enabled;not null;default:false" json:"smsOtpEnabled"`
|
|
||||||
OutOfBandOTPCiphertext string `gorm:"column:out_of_band_otp_ciphertext;type:text" json:"-"`
|
|
||||||
// Disabled 禁用账号(不删除保留审计)。禁用后无法登录。
|
|
||||||
Disabled bool `gorm:"not null;default:false" json:"disabled"`
|
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
|
||||||
UpdatedAt time.Time `json:"updatedAt"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (User) TableName() string {
|
func (User) TableName() string {
|
||||||
|
|||||||
@@ -1,43 +0,0 @@
|
|||||||
package model
|
|
||||||
|
|
||||||
import "time"
|
|
||||||
|
|
||||||
// VerificationRecord 记录一次备份验证(或演练)的执行。
|
|
||||||
// 验证目标:从指定 BackupRecord 读取归档 → 在沙箱内执行只读校验
|
|
||||||
// (解压/格式检查/完整性校验),不改动源数据。
|
|
||||||
const (
|
|
||||||
VerificationRecordStatusRunning = "running"
|
|
||||||
VerificationRecordStatusSuccess = "success"
|
|
||||||
VerificationRecordStatusFailed = "failed"
|
|
||||||
|
|
||||||
// VerificationModeQuick 仅做格式与完整性校验(tar header、SHA-256、DB dump 头)。
|
|
||||||
// 耗时短,不占用目标系统资源,适合每日调度。
|
|
||||||
VerificationModeQuick = "quick"
|
|
||||||
// VerificationModeDeep 真正恢复到隔离沙箱(临时库或解压目录),验证可读。
|
|
||||||
// 耗时较长,适合每周/每月。当前版本保留接口不实现。
|
|
||||||
VerificationModeDeep = "deep"
|
|
||||||
)
|
|
||||||
|
|
||||||
type VerificationRecord struct {
|
|
||||||
ID uint `gorm:"primaryKey" json:"id"`
|
|
||||||
BackupRecordID uint `gorm:"column:backup_record_id;index;not null" json:"backupRecordId"`
|
|
||||||
BackupRecord BackupRecord `json:"backupRecord,omitempty"`
|
|
||||||
TaskID uint `gorm:"column:task_id;index;not null" json:"taskId"`
|
|
||||||
Task BackupTask `json:"task,omitempty"`
|
|
||||||
NodeID uint `gorm:"column:node_id;index;default:0" json:"nodeId"`
|
|
||||||
Mode string `gorm:"size:20;not null;default:'quick'" json:"mode"`
|
|
||||||
Status string `gorm:"size:20;index;not null" json:"status"`
|
|
||||||
Summary string `gorm:"size:500" json:"summary"`
|
|
||||||
ErrorMessage string `gorm:"column:error_message;size:2000" json:"errorMessage"`
|
|
||||||
LogContent string `gorm:"column:log_content;type:text" json:"logContent"`
|
|
||||||
DurationSeconds int `gorm:"column:duration_seconds;not null;default:0" json:"durationSeconds"`
|
|
||||||
StartedAt time.Time `gorm:"column:started_at;index;not null" json:"startedAt"`
|
|
||||||
CompletedAt *time.Time `gorm:"column:completed_at;index" json:"completedAt,omitempty"`
|
|
||||||
TriggeredBy string `gorm:"column:triggered_by;size:100" json:"triggeredBy"`
|
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
|
||||||
UpdatedAt time.Time `json:"updatedAt"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (VerificationRecord) TableName() string {
|
|
||||||
return "verification_records"
|
|
||||||
}
|
|
||||||
@@ -18,14 +18,7 @@ type AgentCommandRepository interface {
|
|||||||
ClaimPending(ctx context.Context, nodeID uint) (*model.AgentCommand, error)
|
ClaimPending(ctx context.Context, nodeID uint) (*model.AgentCommand, error)
|
||||||
Update(ctx context.Context, cmd *model.AgentCommand) error
|
Update(ctx context.Context, cmd *model.AgentCommand) error
|
||||||
// MarkStaleTimeout 把 dispatched 状态但超时未完成的命令标记为 timeout。
|
// MarkStaleTimeout 把 dispatched 状态但超时未完成的命令标记为 timeout。
|
||||||
// 返回被标记的行数。不返回具体命令(供背景监控简单调用)。
|
|
||||||
MarkStaleTimeout(ctx context.Context, threshold time.Time) (int64, error)
|
MarkStaleTimeout(ctx context.Context, threshold time.Time) (int64, error)
|
||||||
// ListStaleDispatched 列出 dispatched 但已超时、尚未被标记的命令。
|
|
||||||
// 调用方需要把它们逐一标记 timeout 并联动关联记录状态。
|
|
||||||
ListStaleDispatched(ctx context.Context, threshold time.Time) ([]model.AgentCommand, error)
|
|
||||||
// ListPendingByNode 列出某节点下的所有 pending/dispatched 命令。
|
|
||||||
// 用于删除节点或节点离线时的清理。
|
|
||||||
ListPendingByNode(ctx context.Context, nodeID uint) ([]model.AgentCommand, error)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type GormAgentCommandRepository struct {
|
type GormAgentCommandRepository struct {
|
||||||
@@ -106,30 +99,3 @@ func (r *GormAgentCommandRepository) MarkStaleTimeout(ctx context.Context, thres
|
|||||||
}
|
}
|
||||||
return result.RowsAffected, nil
|
return result.RowsAffected, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListStaleDispatched 列出 dispatched 但 dispatched_at 早于 threshold 的命令。
|
|
||||||
func (r *GormAgentCommandRepository) ListStaleDispatched(ctx context.Context, threshold time.Time) ([]model.AgentCommand, error) {
|
|
||||||
var items []model.AgentCommand
|
|
||||||
if err := r.db.WithContext(ctx).
|
|
||||||
Where("status = ? AND dispatched_at < ?", model.AgentCommandStatusDispatched, threshold).
|
|
||||||
Order("id asc").
|
|
||||||
Find(&items).Error; err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return items, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListPendingByNode 列出某节点下所有待执行(pending 或 dispatched)命令。
|
|
||||||
func (r *GormAgentCommandRepository) ListPendingByNode(ctx context.Context, nodeID uint) ([]model.AgentCommand, error) {
|
|
||||||
var items []model.AgentCommand
|
|
||||||
if err := r.db.WithContext(ctx).
|
|
||||||
Where("node_id = ? AND status IN ?", nodeID, []string{
|
|
||||||
model.AgentCommandStatusPending,
|
|
||||||
model.AgentCommandStatusDispatched,
|
|
||||||
}).
|
|
||||||
Order("id asc").
|
|
||||||
Find(&items).Error; err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return items, nil
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,78 +0,0 @@
|
|||||||
package repository
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"backupx/server/internal/model"
|
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
|
||||||
|
|
||||||
type ApiKeyRepository interface {
|
|
||||||
Create(ctx context.Context, key *model.ApiKey) error
|
|
||||||
Update(ctx context.Context, key *model.ApiKey) error
|
|
||||||
Delete(ctx context.Context, id uint) error
|
|
||||||
FindByID(ctx context.Context, id uint) (*model.ApiKey, error)
|
|
||||||
FindByHash(ctx context.Context, hash string) (*model.ApiKey, error)
|
|
||||||
List(ctx context.Context) ([]model.ApiKey, error)
|
|
||||||
MarkUsed(ctx context.Context, id uint, at time.Time) error
|
|
||||||
}
|
|
||||||
|
|
||||||
type GormApiKeyRepository struct {
|
|
||||||
db *gorm.DB
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewApiKeyRepository(db *gorm.DB) *GormApiKeyRepository {
|
|
||||||
return &GormApiKeyRepository{db: db}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *GormApiKeyRepository) Create(ctx context.Context, key *model.ApiKey) error {
|
|
||||||
return r.db.WithContext(ctx).Create(key).Error
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *GormApiKeyRepository) Update(ctx context.Context, key *model.ApiKey) error {
|
|
||||||
return r.db.WithContext(ctx).Save(key).Error
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *GormApiKeyRepository) Delete(ctx context.Context, id uint) error {
|
|
||||||
return r.db.WithContext(ctx).Delete(&model.ApiKey{}, id).Error
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *GormApiKeyRepository) FindByID(ctx context.Context, id uint) (*model.ApiKey, error) {
|
|
||||||
var item model.ApiKey
|
|
||||||
if err := r.db.WithContext(ctx).First(&item, id).Error; err != nil {
|
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &item, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *GormApiKeyRepository) FindByHash(ctx context.Context, hash string) (*model.ApiKey, error) {
|
|
||||||
var item model.ApiKey
|
|
||||||
if err := r.db.WithContext(ctx).Where("key_hash = ?", hash).First(&item).Error; err != nil {
|
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &item, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *GormApiKeyRepository) List(ctx context.Context) ([]model.ApiKey, error) {
|
|
||||||
var items []model.ApiKey
|
|
||||||
if err := r.db.WithContext(ctx).Order("created_at desc").Find(&items).Error; err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return items, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// MarkUsed 更新最近使用时间。写入失败不应阻断认证主流程,调用方需忽略错误。
|
|
||||||
func (r *GormApiKeyRepository) MarkUsed(ctx context.Context, id uint, at time.Time) error {
|
|
||||||
return r.db.WithContext(ctx).
|
|
||||||
Model(&model.ApiKey{}).
|
|
||||||
Where("id = ?", id).
|
|
||||||
Update("last_used_at", at).Error
|
|
||||||
}
|
|
||||||
@@ -2,7 +2,6 @@ package repository
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"time"
|
|
||||||
|
|
||||||
"backupx/server/internal/model"
|
"backupx/server/internal/model"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
@@ -10,12 +9,6 @@ import (
|
|||||||
|
|
||||||
type AuditLogListOptions struct {
|
type AuditLogListOptions struct {
|
||||||
Category string
|
Category string
|
||||||
Action string
|
|
||||||
Username string
|
|
||||||
TargetID string
|
|
||||||
Keyword string // 模糊匹配 detail / target_name
|
|
||||||
DateFrom *time.Time
|
|
||||||
DateTo *time.Time
|
|
||||||
Limit int
|
Limit int
|
||||||
Offset int
|
Offset int
|
||||||
}
|
}
|
||||||
@@ -28,7 +21,6 @@ type AuditLogListResult struct {
|
|||||||
type AuditLogRepository interface {
|
type AuditLogRepository interface {
|
||||||
Create(ctx context.Context, log *model.AuditLog) error
|
Create(ctx context.Context, log *model.AuditLog) error
|
||||||
List(ctx context.Context, opts AuditLogListOptions) (*AuditLogListResult, error)
|
List(ctx context.Context, opts AuditLogListOptions) (*AuditLogListResult, error)
|
||||||
ListAll(ctx context.Context, opts AuditLogListOptions) ([]model.AuditLog, error)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type gormAuditLogRepository struct {
|
type gormAuditLogRepository struct {
|
||||||
@@ -44,7 +36,10 @@ func (r *gormAuditLogRepository) Create(_ context.Context, log *model.AuditLog)
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (r *gormAuditLogRepository) List(_ context.Context, opts AuditLogListOptions) (*AuditLogListResult, error) {
|
func (r *gormAuditLogRepository) List(_ context.Context, opts AuditLogListOptions) (*AuditLogListResult, error) {
|
||||||
query := r.buildQuery(opts)
|
query := r.db.Model(&model.AuditLog{})
|
||||||
|
if opts.Category != "" {
|
||||||
|
query = query.Where("category = ?", opts.Category)
|
||||||
|
}
|
||||||
var total int64
|
var total int64
|
||||||
if err := query.Count(&total).Error; err != nil {
|
if err := query.Count(&total).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -59,42 +54,3 @@ func (r *gormAuditLogRepository) List(_ context.Context, opts AuditLogListOption
|
|||||||
}
|
}
|
||||||
return &AuditLogListResult{Items: items, Total: total}, nil
|
return &AuditLogListResult{Items: items, Total: total}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListAll 导出专用:不分页返回所有匹配记录(上限 10k 防爆)。
|
|
||||||
func (r *gormAuditLogRepository) ListAll(_ context.Context, opts AuditLogListOptions) ([]model.AuditLog, error) {
|
|
||||||
query := r.buildQuery(opts)
|
|
||||||
const maxExportRows = 10000
|
|
||||||
var items []model.AuditLog
|
|
||||||
if err := query.Order("created_at DESC").Limit(maxExportRows).Find(&items).Error; err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return items, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// buildQuery 统一构造带筛选条件的查询。
|
|
||||||
func (r *gormAuditLogRepository) buildQuery(opts AuditLogListOptions) *gorm.DB {
|
|
||||||
query := r.db.Model(&model.AuditLog{})
|
|
||||||
if opts.Category != "" {
|
|
||||||
query = query.Where("category = ?", opts.Category)
|
|
||||||
}
|
|
||||||
if opts.Action != "" {
|
|
||||||
query = query.Where("action = ?", opts.Action)
|
|
||||||
}
|
|
||||||
if opts.Username != "" {
|
|
||||||
query = query.Where("username = ?", opts.Username)
|
|
||||||
}
|
|
||||||
if opts.TargetID != "" {
|
|
||||||
query = query.Where("target_id = ?", opts.TargetID)
|
|
||||||
}
|
|
||||||
if opts.Keyword != "" {
|
|
||||||
pattern := "%" + opts.Keyword + "%"
|
|
||||||
query = query.Where("detail LIKE ? OR target_name LIKE ?", pattern, pattern)
|
|
||||||
}
|
|
||||||
if opts.DateFrom != nil {
|
|
||||||
query = query.Where("created_at >= ?", opts.DateFrom.UTC())
|
|
||||||
}
|
|
||||||
if opts.DateTo != nil {
|
|
||||||
query = query.Where("created_at <= ?", opts.DateTo.UTC())
|
|
||||||
}
|
|
||||||
return query
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -18,13 +18,11 @@ type BackupTaskRepository interface {
|
|||||||
FindByID(context.Context, uint) (*model.BackupTask, error)
|
FindByID(context.Context, uint) (*model.BackupTask, error)
|
||||||
FindByName(context.Context, string) (*model.BackupTask, error)
|
FindByName(context.Context, string) (*model.BackupTask, error)
|
||||||
ListSchedulable(context.Context) ([]model.BackupTask, error)
|
ListSchedulable(context.Context) ([]model.BackupTask, error)
|
||||||
ListVerifySchedulable(context.Context) ([]model.BackupTask, error)
|
|
||||||
Count(context.Context) (int64, error)
|
Count(context.Context) (int64, error)
|
||||||
CountEnabled(context.Context) (int64, error)
|
CountEnabled(context.Context) (int64, error)
|
||||||
CountByStorageTargetID(context.Context, uint) (int64, error)
|
CountByStorageTargetID(context.Context, uint) (int64, error)
|
||||||
CountByNodeID(context.Context, uint) (int64, error)
|
CountByNodeID(context.Context, uint) (int64, error)
|
||||||
ListByNodeID(context.Context, uint) ([]model.BackupTask, error)
|
ListByNodeID(context.Context, uint) ([]model.BackupTask, error)
|
||||||
DistinctTags(context.Context) ([]string, error)
|
|
||||||
Create(context.Context, *model.BackupTask) error
|
Create(context.Context, *model.BackupTask) error
|
||||||
Update(context.Context, *model.BackupTask) error
|
Update(context.Context, *model.BackupTask) error
|
||||||
Delete(context.Context, uint) error
|
Delete(context.Context, uint) error
|
||||||
@@ -39,7 +37,7 @@ func NewBackupTaskRepository(db *gorm.DB) *GormBackupTaskRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (r *GormBackupTaskRepository) List(ctx context.Context, options BackupTaskListOptions) ([]model.BackupTask, error) {
|
func (r *GormBackupTaskRepository) List(ctx context.Context, options BackupTaskListOptions) ([]model.BackupTask, error) {
|
||||||
query := r.db.WithContext(ctx).Model(&model.BackupTask{}).Preload("StorageTarget").Preload("StorageTargets").Preload("Node").Order("updated_at desc")
|
query := r.db.WithContext(ctx).Model(&model.BackupTask{}).Preload("StorageTarget").Preload("StorageTargets").Order("updated_at desc")
|
||||||
if options.Type != "" {
|
if options.Type != "" {
|
||||||
query = query.Where("type = ?", options.Type)
|
query = query.Where("type = ?", options.Type)
|
||||||
}
|
}
|
||||||
@@ -55,7 +53,7 @@ func (r *GormBackupTaskRepository) List(ctx context.Context, options BackupTaskL
|
|||||||
|
|
||||||
func (r *GormBackupTaskRepository) FindByID(ctx context.Context, id uint) (*model.BackupTask, error) {
|
func (r *GormBackupTaskRepository) FindByID(ctx context.Context, id uint) (*model.BackupTask, error) {
|
||||||
var item model.BackupTask
|
var item model.BackupTask
|
||||||
if err := r.db.WithContext(ctx).Preload("StorageTarget").Preload("StorageTargets").Preload("Node").First(&item, id).Error; err != nil {
|
if err := r.db.WithContext(ctx).Preload("StorageTarget").Preload("StorageTargets").First(&item, id).Error; err != nil {
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
@@ -77,105 +75,12 @@ func (r *GormBackupTaskRepository) FindByName(ctx context.Context, name string)
|
|||||||
|
|
||||||
func (r *GormBackupTaskRepository) ListSchedulable(ctx context.Context) ([]model.BackupTask, error) {
|
func (r *GormBackupTaskRepository) ListSchedulable(ctx context.Context) ([]model.BackupTask, error) {
|
||||||
var items []model.BackupTask
|
var items []model.BackupTask
|
||||||
if err := r.db.WithContext(ctx).Preload("StorageTarget").Preload("StorageTargets").Preload("Node").Where("enabled = ? AND cron_expr <> ''", true).Order("id asc").Find(&items).Error; err != nil {
|
if err := r.db.WithContext(ctx).Preload("StorageTarget").Preload("StorageTargets").Where("enabled = ? AND cron_expr <> ''", true).Order("id asc").Find(&items).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListVerifySchedulable 列出所有启用且配置了验证 cron 的任务。
|
|
||||||
// 与 ListSchedulable 的区别:即使任务本身没有备份 cron,只要配置了 verify_cron_expr
|
|
||||||
// 也会被调度(验证是独立的定时动作)。
|
|
||||||
func (r *GormBackupTaskRepository) ListVerifySchedulable(ctx context.Context) ([]model.BackupTask, error) {
|
|
||||||
var items []model.BackupTask
|
|
||||||
if err := r.db.WithContext(ctx).
|
|
||||||
Preload("StorageTarget").
|
|
||||||
Preload("StorageTargets").
|
|
||||||
Preload("Node").
|
|
||||||
Where("enabled = ? AND verify_enabled = ? AND verify_cron_expr <> ''", true, true).
|
|
||||||
Order("id asc").
|
|
||||||
Find(&items).Error; err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return items, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// DistinctTags 返回系统中所有任务使用过的唯一标签(用于 UI 建议)。
|
|
||||||
// tags 字段是逗号分隔字符串,此方法会扁平化后去重。
|
|
||||||
func (r *GormBackupTaskRepository) DistinctTags(ctx context.Context) ([]string, error) {
|
|
||||||
var rows []struct {
|
|
||||||
Tags string
|
|
||||||
}
|
|
||||||
if err := r.db.WithContext(ctx).
|
|
||||||
Model(&model.BackupTask{}).
|
|
||||||
Select("tags").
|
|
||||||
Where("tags <> ''").
|
|
||||||
Scan(&rows).Error; err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
seen := map[string]bool{}
|
|
||||||
result := []string{}
|
|
||||||
for _, row := range rows {
|
|
||||||
for _, raw := range splitTags(row.Tags) {
|
|
||||||
if !seen[raw] {
|
|
||||||
seen[raw] = true
|
|
||||||
result = append(result, raw)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// splitTags 把逗号分隔的 tags 字符串拆成 trim 后的非空切片。
|
|
||||||
func splitTags(value string) []string {
|
|
||||||
if value == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
var out []string
|
|
||||||
for _, t := range splitAndTrim(value, ",") {
|
|
||||||
if t != "" {
|
|
||||||
out = append(out, t)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// splitAndTrim 内部工具函数:按分隔符切分并去除每段空白。
|
|
||||||
func splitAndTrim(value, sep string) []string {
|
|
||||||
parts := make([]string, 0)
|
|
||||||
for _, p := range bytesSplit(value, sep) {
|
|
||||||
trimmed := bytesTrimSpace(p)
|
|
||||||
parts = append(parts, trimmed)
|
|
||||||
}
|
|
||||||
return parts
|
|
||||||
}
|
|
||||||
|
|
||||||
// bytesSplit / bytesTrimSpace 只是 strings 的薄包装,便于此仓储文件不引入 strings 依赖。
|
|
||||||
func bytesSplit(value, sep string) []string {
|
|
||||||
out := []string{}
|
|
||||||
start := 0
|
|
||||||
for i := 0; i+len(sep) <= len(value); i++ {
|
|
||||||
if value[i:i+len(sep)] == sep {
|
|
||||||
out = append(out, value[start:i])
|
|
||||||
start = i + len(sep)
|
|
||||||
i += len(sep) - 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
out = append(out, value[start:])
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func bytesTrimSpace(value string) string {
|
|
||||||
start, end := 0, len(value)
|
|
||||||
for start < end && (value[start] == ' ' || value[start] == '\t' || value[start] == '\n' || value[start] == '\r') {
|
|
||||||
start++
|
|
||||||
}
|
|
||||||
for end > start && (value[end-1] == ' ' || value[end-1] == '\t' || value[end-1] == '\n' || value[end-1] == '\r') {
|
|
||||||
end--
|
|
||||||
}
|
|
||||||
return value[start:end]
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *GormBackupTaskRepository) Count(ctx context.Context) (int64, error) {
|
func (r *GormBackupTaskRepository) Count(ctx context.Context) (int64, error) {
|
||||||
var count int64
|
var count int64
|
||||||
if err := r.db.WithContext(ctx).Model(&model.BackupTask{}).Count(&count).Error; err != nil {
|
if err := r.db.WithContext(ctx).Model(&model.BackupTask{}).Count(&count).Error; err != nil {
|
||||||
@@ -212,7 +117,7 @@ func (r *GormBackupTaskRepository) CountByNodeID(ctx context.Context, nodeID uin
|
|||||||
// ListByNodeID 列出绑定到指定节点的任务。用于 Agent 拉取本节点待执行任务。
|
// ListByNodeID 列出绑定到指定节点的任务。用于 Agent 拉取本节点待执行任务。
|
||||||
func (r *GormBackupTaskRepository) ListByNodeID(ctx context.Context, nodeID uint) ([]model.BackupTask, error) {
|
func (r *GormBackupTaskRepository) ListByNodeID(ctx context.Context, nodeID uint) ([]model.BackupTask, error) {
|
||||||
var items []model.BackupTask
|
var items []model.BackupTask
|
||||||
if err := r.db.WithContext(ctx).Preload("StorageTarget").Preload("StorageTargets").Preload("Node").Where("node_id = ?", nodeID).Order("id asc").Find(&items).Error; err != nil {
|
if err := r.db.WithContext(ctx).Preload("StorageTarget").Preload("StorageTargets").Where("node_id = ?", nodeID).Order("id asc").Find(&items).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return items, nil
|
return items, nil
|
||||||
|
|||||||
@@ -1,106 +0,0 @@
|
|||||||
package repository
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"backupx/server/internal/model"
|
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
|
||||||
|
|
||||||
type ReplicationRecordListOptions struct {
|
|
||||||
TaskID *uint
|
|
||||||
BackupRecordID *uint
|
|
||||||
DestTargetID *uint
|
|
||||||
Status string
|
|
||||||
DateFrom *time.Time
|
|
||||||
DateTo *time.Time
|
|
||||||
Limit int
|
|
||||||
Offset int
|
|
||||||
}
|
|
||||||
|
|
||||||
type ReplicationRecordRepository interface {
|
|
||||||
Create(ctx context.Context, record *model.ReplicationRecord) error
|
|
||||||
Update(ctx context.Context, record *model.ReplicationRecord) error
|
|
||||||
FindByID(ctx context.Context, id uint) (*model.ReplicationRecord, error)
|
|
||||||
List(ctx context.Context, opts ReplicationRecordListOptions) ([]model.ReplicationRecord, error)
|
|
||||||
Count(ctx context.Context) (int64, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
type GormReplicationRecordRepository struct {
|
|
||||||
db *gorm.DB
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewReplicationRecordRepository(db *gorm.DB) *GormReplicationRecordRepository {
|
|
||||||
return &GormReplicationRecordRepository{db: db}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *GormReplicationRecordRepository) Create(ctx context.Context, item *model.ReplicationRecord) error {
|
|
||||||
return r.db.WithContext(ctx).Create(item).Error
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *GormReplicationRecordRepository) Update(ctx context.Context, item *model.ReplicationRecord) error {
|
|
||||||
return r.db.WithContext(ctx).Save(item).Error
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *GormReplicationRecordRepository) FindByID(ctx context.Context, id uint) (*model.ReplicationRecord, error) {
|
|
||||||
var item model.ReplicationRecord
|
|
||||||
if err := r.db.WithContext(ctx).
|
|
||||||
Preload("BackupRecord").
|
|
||||||
Preload("SourceTarget").
|
|
||||||
Preload("DestTarget").
|
|
||||||
First(&item, id).Error; err != nil {
|
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &item, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *GormReplicationRecordRepository) List(ctx context.Context, opts ReplicationRecordListOptions) ([]model.ReplicationRecord, error) {
|
|
||||||
query := r.db.WithContext(ctx).
|
|
||||||
Model(&model.ReplicationRecord{}).
|
|
||||||
Preload("BackupRecord").
|
|
||||||
Preload("SourceTarget").
|
|
||||||
Preload("DestTarget").
|
|
||||||
Order("started_at desc")
|
|
||||||
if opts.TaskID != nil {
|
|
||||||
query = query.Where("task_id = ?", *opts.TaskID)
|
|
||||||
}
|
|
||||||
if opts.BackupRecordID != nil {
|
|
||||||
query = query.Where("backup_record_id = ?", *opts.BackupRecordID)
|
|
||||||
}
|
|
||||||
if opts.DestTargetID != nil {
|
|
||||||
query = query.Where("dest_target_id = ?", *opts.DestTargetID)
|
|
||||||
}
|
|
||||||
if opts.Status != "" {
|
|
||||||
query = query.Where("status = ?", opts.Status)
|
|
||||||
}
|
|
||||||
if opts.DateFrom != nil {
|
|
||||||
query = query.Where("started_at >= ?", opts.DateFrom.UTC())
|
|
||||||
}
|
|
||||||
if opts.DateTo != nil {
|
|
||||||
query = query.Where("started_at <= ?", opts.DateTo.UTC())
|
|
||||||
}
|
|
||||||
if opts.Limit > 0 {
|
|
||||||
query = query.Limit(opts.Limit)
|
|
||||||
}
|
|
||||||
if opts.Offset > 0 {
|
|
||||||
query = query.Offset(opts.Offset)
|
|
||||||
}
|
|
||||||
var items []model.ReplicationRecord
|
|
||||||
if err := query.Find(&items).Error; err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return items, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *GormReplicationRecordRepository) Count(ctx context.Context) (int64, error) {
|
|
||||||
var count int64
|
|
||||||
if err := r.db.WithContext(ctx).Model(&model.ReplicationRecord{}).Count(&count).Error; err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
return count, nil
|
|
||||||
}
|
|
||||||
@@ -1,111 +0,0 @@
|
|||||||
package repository
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"backupx/server/internal/model"
|
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
|
||||||
|
|
||||||
// RestoreRecordListOptions 恢复记录列表筛选条件。
|
|
||||||
type RestoreRecordListOptions struct {
|
|
||||||
TaskID *uint
|
|
||||||
BackupRecordID *uint
|
|
||||||
NodeID *uint
|
|
||||||
Status string
|
|
||||||
DateFrom *time.Time
|
|
||||||
DateTo *time.Time
|
|
||||||
Limit int
|
|
||||||
Offset int
|
|
||||||
}
|
|
||||||
|
|
||||||
// RestoreRecordRepository 恢复记录仓储接口。
|
|
||||||
type RestoreRecordRepository interface {
|
|
||||||
Create(ctx context.Context, item *model.RestoreRecord) error
|
|
||||||
Update(ctx context.Context, item *model.RestoreRecord) error
|
|
||||||
Delete(ctx context.Context, id uint) error
|
|
||||||
FindByID(ctx context.Context, id uint) (*model.RestoreRecord, error)
|
|
||||||
List(ctx context.Context, options RestoreRecordListOptions) ([]model.RestoreRecord, error)
|
|
||||||
Count(ctx context.Context) (int64, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
type GormRestoreRecordRepository struct {
|
|
||||||
db *gorm.DB
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewRestoreRecordRepository(db *gorm.DB) *GormRestoreRecordRepository {
|
|
||||||
return &GormRestoreRecordRepository{db: db}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *GormRestoreRecordRepository) Create(ctx context.Context, item *model.RestoreRecord) error {
|
|
||||||
return r.db.WithContext(ctx).Create(item).Error
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *GormRestoreRecordRepository) Update(ctx context.Context, item *model.RestoreRecord) error {
|
|
||||||
return r.db.WithContext(ctx).Save(item).Error
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *GormRestoreRecordRepository) Delete(ctx context.Context, id uint) error {
|
|
||||||
return r.db.WithContext(ctx).Delete(&model.RestoreRecord{}, id).Error
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *GormRestoreRecordRepository) FindByID(ctx context.Context, id uint) (*model.RestoreRecord, error) {
|
|
||||||
var item model.RestoreRecord
|
|
||||||
if err := r.db.WithContext(ctx).
|
|
||||||
Preload("Task").
|
|
||||||
Preload("BackupRecord").
|
|
||||||
First(&item, id).Error; err != nil {
|
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &item, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *GormRestoreRecordRepository) List(ctx context.Context, options RestoreRecordListOptions) ([]model.RestoreRecord, error) {
|
|
||||||
query := r.db.WithContext(ctx).
|
|
||||||
Model(&model.RestoreRecord{}).
|
|
||||||
Preload("Task").
|
|
||||||
Preload("BackupRecord").
|
|
||||||
Order("started_at desc")
|
|
||||||
if options.TaskID != nil {
|
|
||||||
query = query.Where("task_id = ?", *options.TaskID)
|
|
||||||
}
|
|
||||||
if options.BackupRecordID != nil {
|
|
||||||
query = query.Where("backup_record_id = ?", *options.BackupRecordID)
|
|
||||||
}
|
|
||||||
if options.NodeID != nil {
|
|
||||||
query = query.Where("node_id = ?", *options.NodeID)
|
|
||||||
}
|
|
||||||
if options.Status != "" {
|
|
||||||
query = query.Where("status = ?", options.Status)
|
|
||||||
}
|
|
||||||
if options.DateFrom != nil {
|
|
||||||
query = query.Where("started_at >= ?", options.DateFrom.UTC())
|
|
||||||
}
|
|
||||||
if options.DateTo != nil {
|
|
||||||
query = query.Where("started_at <= ?", options.DateTo.UTC())
|
|
||||||
}
|
|
||||||
if options.Limit > 0 {
|
|
||||||
query = query.Limit(options.Limit)
|
|
||||||
}
|
|
||||||
if options.Offset > 0 {
|
|
||||||
query = query.Offset(options.Offset)
|
|
||||||
}
|
|
||||||
var items []model.RestoreRecord
|
|
||||||
if err := query.Find(&items).Error; err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return items, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *GormRestoreRecordRepository) Count(ctx context.Context) (int64, error) {
|
|
||||||
var count int64
|
|
||||||
if err := r.db.WithContext(ctx).Model(&model.RestoreRecord{}).Count(&count).Error; err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
return count, nil
|
|
||||||
}
|
|
||||||
@@ -1,126 +0,0 @@
|
|||||||
package repository
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"backupx/server/internal/config"
|
|
||||||
"backupx/server/internal/database"
|
|
||||||
"backupx/server/internal/logger"
|
|
||||||
"backupx/server/internal/model"
|
|
||||||
)
|
|
||||||
|
|
||||||
func newRestoreRecordTestRepository(t *testing.T) (*GormRestoreRecordRepository, uint) {
|
|
||||||
t.Helper()
|
|
||||||
log, err := logger.New(config.LogConfig{Level: "error"})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("logger.New returned error: %v", err)
|
|
||||||
}
|
|
||||||
db, err := database.Open(config.DatabaseConfig{Path: filepath.Join(t.TempDir(), "backupx.db")}, log)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("database.Open returned error: %v", err)
|
|
||||||
}
|
|
||||||
storageTarget := &model.StorageTarget{Name: "local", Type: "local_disk", Enabled: true, ConfigCiphertext: "{}", ConfigVersion: 1, LastTestStatus: "unknown"}
|
|
||||||
if err := db.Create(storageTarget).Error; err != nil {
|
|
||||||
t.Fatalf("seed storage target error: %v", err)
|
|
||||||
}
|
|
||||||
task := &model.BackupTask{Name: "website", Type: "file", Enabled: true, SourcePath: "/srv/www", StorageTargetID: storageTarget.ID, RetentionDays: 30, Compression: "gzip", MaxBackups: 10, LastStatus: "idle"}
|
|
||||||
if err := db.Create(task).Error; err != nil {
|
|
||||||
t.Fatalf("seed backup task error: %v", err)
|
|
||||||
}
|
|
||||||
now := time.Now().UTC()
|
|
||||||
completedAt := now.Add(time.Minute)
|
|
||||||
backupRecord := &model.BackupRecord{TaskID: task.ID, StorageTargetID: storageTarget.ID, Status: model.BackupRecordStatusSuccess, FileName: "website.tar.gz", FileSize: 1024, StoragePath: "tasks/1/website.tar.gz", StartedAt: now, CompletedAt: &completedAt}
|
|
||||||
if err := db.Create(backupRecord).Error; err != nil {
|
|
||||||
t.Fatalf("seed backup record error: %v", err)
|
|
||||||
}
|
|
||||||
return NewRestoreRecordRepository(db), backupRecord.ID
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRestoreRecordRepositoryCRUD(t *testing.T) {
|
|
||||||
ctx := context.Background()
|
|
||||||
repo, backupRecordID := newRestoreRecordTestRepository(t)
|
|
||||||
|
|
||||||
startedAt := time.Now().UTC()
|
|
||||||
restore := &model.RestoreRecord{
|
|
||||||
BackupRecordID: backupRecordID,
|
|
||||||
TaskID: 1,
|
|
||||||
NodeID: 0,
|
|
||||||
Status: model.RestoreRecordStatusRunning,
|
|
||||||
StartedAt: startedAt,
|
|
||||||
TriggeredBy: "admin",
|
|
||||||
}
|
|
||||||
if err := repo.Create(ctx, restore); err != nil {
|
|
||||||
t.Fatalf("Create returned error: %v", err)
|
|
||||||
}
|
|
||||||
if restore.ID == 0 {
|
|
||||||
t.Fatalf("expected generated restore ID, got 0")
|
|
||||||
}
|
|
||||||
|
|
||||||
found, err := repo.FindByID(ctx, restore.ID)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("FindByID returned error: %v", err)
|
|
||||||
}
|
|
||||||
if found == nil || found.TriggeredBy != "admin" || found.Status != model.RestoreRecordStatusRunning {
|
|
||||||
t.Fatalf("unexpected restore record: %#v", found)
|
|
||||||
}
|
|
||||||
if found.BackupRecord.ID != backupRecordID {
|
|
||||||
t.Fatalf("expected BackupRecord preload, got %#v", found.BackupRecord)
|
|
||||||
}
|
|
||||||
|
|
||||||
completedAt := startedAt.Add(30 * time.Second)
|
|
||||||
found.Status = model.RestoreRecordStatusSuccess
|
|
||||||
found.DurationSeconds = 30
|
|
||||||
found.CompletedAt = &completedAt
|
|
||||||
if err := repo.Update(ctx, found); err != nil {
|
|
||||||
t.Fatalf("Update returned error: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
runningFilter := model.RestoreRecordStatusRunning
|
|
||||||
list, err := repo.List(ctx, RestoreRecordListOptions{Status: runningFilter})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("List returned error: %v", err)
|
|
||||||
}
|
|
||||||
if len(list) != 0 {
|
|
||||||
t.Fatalf("expected no running restores after update, got %d", len(list))
|
|
||||||
}
|
|
||||||
|
|
||||||
successFilter := model.RestoreRecordStatusSuccess
|
|
||||||
successList, err := repo.List(ctx, RestoreRecordListOptions{Status: successFilter})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("List success returned error: %v", err)
|
|
||||||
}
|
|
||||||
if len(successList) != 1 {
|
|
||||||
t.Fatalf("expected 1 success restore, got %d", len(successList))
|
|
||||||
}
|
|
||||||
|
|
||||||
brID := backupRecordID
|
|
||||||
byBackup, err := repo.List(ctx, RestoreRecordListOptions{BackupRecordID: &brID})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("List byBackup returned error: %v", err)
|
|
||||||
}
|
|
||||||
if len(byBackup) != 1 {
|
|
||||||
t.Fatalf("expected 1 restore for backup record, got %d", len(byBackup))
|
|
||||||
}
|
|
||||||
|
|
||||||
total, err := repo.Count(ctx)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Count returned error: %v", err)
|
|
||||||
}
|
|
||||||
if total != 1 {
|
|
||||||
t.Fatalf("expected 1 total, got %d", total)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := repo.Delete(ctx, restore.ID); err != nil {
|
|
||||||
t.Fatalf("Delete returned error: %v", err)
|
|
||||||
}
|
|
||||||
afterDel, err := repo.FindByID(ctx, restore.ID)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("FindByID after delete returned error: %v", err)
|
|
||||||
}
|
|
||||||
if afterDel != nil {
|
|
||||||
t.Fatalf("expected nil after delete, got %#v", afterDel)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
package repository
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
|
|
||||||
"backupx/server/internal/model"
|
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
|
||||||
|
|
||||||
type TaskTemplateRepository interface {
|
|
||||||
Create(ctx context.Context, template *model.TaskTemplate) error
|
|
||||||
Update(ctx context.Context, template *model.TaskTemplate) error
|
|
||||||
Delete(ctx context.Context, id uint) error
|
|
||||||
FindByID(ctx context.Context, id uint) (*model.TaskTemplate, error)
|
|
||||||
FindByName(ctx context.Context, name string) (*model.TaskTemplate, error)
|
|
||||||
List(ctx context.Context) ([]model.TaskTemplate, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
type GormTaskTemplateRepository struct {
|
|
||||||
db *gorm.DB
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewTaskTemplateRepository(db *gorm.DB) *GormTaskTemplateRepository {
|
|
||||||
return &GormTaskTemplateRepository{db: db}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *GormTaskTemplateRepository) Create(ctx context.Context, t *model.TaskTemplate) error {
|
|
||||||
return r.db.WithContext(ctx).Create(t).Error
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *GormTaskTemplateRepository) Update(ctx context.Context, t *model.TaskTemplate) error {
|
|
||||||
return r.db.WithContext(ctx).Save(t).Error
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *GormTaskTemplateRepository) Delete(ctx context.Context, id uint) error {
|
|
||||||
return r.db.WithContext(ctx).Delete(&model.TaskTemplate{}, id).Error
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *GormTaskTemplateRepository) FindByID(ctx context.Context, id uint) (*model.TaskTemplate, error) {
|
|
||||||
var item model.TaskTemplate
|
|
||||||
if err := r.db.WithContext(ctx).First(&item, id).Error; err != nil {
|
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &item, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *GormTaskTemplateRepository) FindByName(ctx context.Context, name string) (*model.TaskTemplate, error) {
|
|
||||||
var item model.TaskTemplate
|
|
||||||
if err := r.db.WithContext(ctx).Where("name = ?", name).First(&item).Error; err != nil {
|
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &item, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *GormTaskTemplateRepository) List(ctx context.Context) ([]model.TaskTemplate, error) {
|
|
||||||
var items []model.TaskTemplate
|
|
||||||
if err := r.db.WithContext(ctx).Order("name asc").Find(&items).Error; err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return items, nil
|
|
||||||
}
|
|
||||||
@@ -10,11 +10,8 @@ import (
|
|||||||
|
|
||||||
type UserRepository interface {
|
type UserRepository interface {
|
||||||
Count(context.Context) (int64, error)
|
Count(context.Context) (int64, error)
|
||||||
CountByRole(context.Context, string) (int64, error)
|
|
||||||
Create(context.Context, *model.User) error
|
Create(context.Context, *model.User) error
|
||||||
Update(context.Context, *model.User) error
|
Update(context.Context, *model.User) error
|
||||||
Delete(context.Context, uint) error
|
|
||||||
List(context.Context) ([]model.User, error)
|
|
||||||
FindByUsername(context.Context, string) (*model.User, error)
|
FindByUsername(context.Context, string) (*model.User, error)
|
||||||
FindByID(context.Context, uint) (*model.User, error)
|
FindByID(context.Context, uint) (*model.User, error)
|
||||||
}
|
}
|
||||||
@@ -35,31 +32,6 @@ func (r *GormUserRepository) Count(ctx context.Context) (int64, error) {
|
|||||||
return count, nil
|
return count, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// CountByRole 按角色统计启用(非 disabled)用户数。用于防止删除最后一个 admin。
|
|
||||||
func (r *GormUserRepository) CountByRole(ctx context.Context, role string) (int64, error) {
|
|
||||||
var count int64
|
|
||||||
if err := r.db.WithContext(ctx).Model(&model.User{}).
|
|
||||||
Where("role = ? AND disabled = ?", role, false).
|
|
||||||
Count(&count).Error; err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
return count, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// List 按创建时间升序返回所有用户。
|
|
||||||
func (r *GormUserRepository) List(ctx context.Context) ([]model.User, error) {
|
|
||||||
var items []model.User
|
|
||||||
if err := r.db.WithContext(ctx).Order("created_at asc").Find(&items).Error; err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return items, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete 物理删除用户。调用方应先在 service 层检查最后 admin。
|
|
||||||
func (r *GormUserRepository) Delete(ctx context.Context, id uint) error {
|
|
||||||
return r.db.WithContext(ctx).Delete(&model.User{}, id).Error
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *GormUserRepository) Create(ctx context.Context, user *model.User) error {
|
func (r *GormUserRepository) Create(ctx context.Context, user *model.User) error {
|
||||||
return r.db.WithContext(ctx).Create(user).Error
|
return r.db.WithContext(ctx).Create(user).Error
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,121 +0,0 @@
|
|||||||
package repository
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"backupx/server/internal/model"
|
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
|
||||||
|
|
||||||
// VerificationRecordListOptions 验证记录列表筛选条件。
|
|
||||||
type VerificationRecordListOptions struct {
|
|
||||||
TaskID *uint
|
|
||||||
BackupRecordID *uint
|
|
||||||
Status string
|
|
||||||
DateFrom *time.Time
|
|
||||||
DateTo *time.Time
|
|
||||||
Limit int
|
|
||||||
Offset int
|
|
||||||
}
|
|
||||||
|
|
||||||
type VerificationRecordRepository interface {
|
|
||||||
Create(ctx context.Context, item *model.VerificationRecord) error
|
|
||||||
Update(ctx context.Context, item *model.VerificationRecord) error
|
|
||||||
Delete(ctx context.Context, id uint) error
|
|
||||||
FindByID(ctx context.Context, id uint) (*model.VerificationRecord, error)
|
|
||||||
List(ctx context.Context, options VerificationRecordListOptions) ([]model.VerificationRecord, error)
|
|
||||||
FindLatestByTask(ctx context.Context, taskID uint) (*model.VerificationRecord, error)
|
|
||||||
Count(ctx context.Context) (int64, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
type GormVerificationRecordRepository struct {
|
|
||||||
db *gorm.DB
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewVerificationRecordRepository(db *gorm.DB) *GormVerificationRecordRepository {
|
|
||||||
return &GormVerificationRecordRepository{db: db}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *GormVerificationRecordRepository) Create(ctx context.Context, item *model.VerificationRecord) error {
|
|
||||||
return r.db.WithContext(ctx).Create(item).Error
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *GormVerificationRecordRepository) Update(ctx context.Context, item *model.VerificationRecord) error {
|
|
||||||
return r.db.WithContext(ctx).Save(item).Error
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *GormVerificationRecordRepository) Delete(ctx context.Context, id uint) error {
|
|
||||||
return r.db.WithContext(ctx).Delete(&model.VerificationRecord{}, id).Error
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *GormVerificationRecordRepository) FindByID(ctx context.Context, id uint) (*model.VerificationRecord, error) {
|
|
||||||
var item model.VerificationRecord
|
|
||||||
if err := r.db.WithContext(ctx).
|
|
||||||
Preload("Task").
|
|
||||||
Preload("BackupRecord").
|
|
||||||
First(&item, id).Error; err != nil {
|
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &item, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *GormVerificationRecordRepository) List(ctx context.Context, options VerificationRecordListOptions) ([]model.VerificationRecord, error) {
|
|
||||||
query := r.db.WithContext(ctx).
|
|
||||||
Model(&model.VerificationRecord{}).
|
|
||||||
Preload("Task").
|
|
||||||
Preload("BackupRecord").
|
|
||||||
Order("started_at desc")
|
|
||||||
if options.TaskID != nil {
|
|
||||||
query = query.Where("task_id = ?", *options.TaskID)
|
|
||||||
}
|
|
||||||
if options.BackupRecordID != nil {
|
|
||||||
query = query.Where("backup_record_id = ?", *options.BackupRecordID)
|
|
||||||
}
|
|
||||||
if options.Status != "" {
|
|
||||||
query = query.Where("status = ?", options.Status)
|
|
||||||
}
|
|
||||||
if options.DateFrom != nil {
|
|
||||||
query = query.Where("started_at >= ?", options.DateFrom.UTC())
|
|
||||||
}
|
|
||||||
if options.DateTo != nil {
|
|
||||||
query = query.Where("started_at <= ?", options.DateTo.UTC())
|
|
||||||
}
|
|
||||||
if options.Limit > 0 {
|
|
||||||
query = query.Limit(options.Limit)
|
|
||||||
}
|
|
||||||
if options.Offset > 0 {
|
|
||||||
query = query.Offset(options.Offset)
|
|
||||||
}
|
|
||||||
var items []model.VerificationRecord
|
|
||||||
if err := query.Find(&items).Error; err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return items, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *GormVerificationRecordRepository) FindLatestByTask(ctx context.Context, taskID uint) (*model.VerificationRecord, error) {
|
|
||||||
var item model.VerificationRecord
|
|
||||||
if err := r.db.WithContext(ctx).
|
|
||||||
Where("task_id = ?", taskID).
|
|
||||||
Order("started_at desc").
|
|
||||||
First(&item).Error; err != nil {
|
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &item, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *GormVerificationRecordRepository) Count(ctx context.Context) (int64, error) {
|
|
||||||
var count int64
|
|
||||||
if err := r.db.WithContext(ctx).Model(&model.VerificationRecord{}).Count(&count).Error; err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
return count, nil
|
|
||||||
}
|
|
||||||
@@ -6,7 +6,6 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"backupx/server/internal/backup"
|
|
||||||
"backupx/server/internal/model"
|
"backupx/server/internal/model"
|
||||||
"backupx/server/internal/repository"
|
"backupx/server/internal/repository"
|
||||||
servicepkg "backupx/server/internal/service"
|
servicepkg "backupx/server/internal/service"
|
||||||
@@ -18,59 +17,28 @@ type TaskRunner interface {
|
|||||||
RunTaskByID(context.Context, uint) (*servicepkg.BackupRecordDetail, error)
|
RunTaskByID(context.Context, uint) (*servicepkg.BackupRecordDetail, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// VerifyRunner 供调度器触发验证演练。
|
|
||||||
// 使用最新成功备份作为源;taskID 对应的任务须配置 VerifyEnabled=true。
|
|
||||||
type VerifyRunner interface {
|
|
||||||
StartByTask(ctx context.Context, taskID uint, mode, triggeredBy string) (*servicepkg.VerificationRecordDetail, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
// AuditRecorder 记录审计日志(可选依赖)
|
// AuditRecorder 记录审计日志(可选依赖)
|
||||||
type AuditRecorder interface {
|
type AuditRecorder interface {
|
||||||
Record(servicepkg.AuditEntry)
|
Record(servicepkg.AuditEntry)
|
||||||
}
|
}
|
||||||
|
|
||||||
type Service struct {
|
type Service struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
cron *cron.Cron
|
cron *cron.Cron
|
||||||
tasks repository.BackupTaskRepository
|
tasks repository.BackupTaskRepository
|
||||||
nodes repository.NodeRepository
|
runner TaskRunner
|
||||||
runner TaskRunner
|
logger *zap.Logger
|
||||||
verifyRunner VerifyRunner
|
audit AuditRecorder
|
||||||
logger *zap.Logger
|
entries map[uint]cron.EntryID
|
||||||
audit AuditRecorder
|
|
||||||
entries map[uint]cron.EntryID // 备份 cron 条目
|
|
||||||
verifyEntries map[uint]cron.EntryID // 验证 cron 条目
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewService(tasks repository.BackupTaskRepository, runner TaskRunner, logger *zap.Logger) *Service {
|
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)
|
parser := cron.NewParser(cron.SecondOptional | cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow | cron.Descriptor)
|
||||||
return &Service{
|
return &Service{cron: cron.New(cron.WithParser(parser), cron.WithLocation(time.UTC)), tasks: tasks, runner: runner, logger: logger, entries: make(map[uint]cron.EntryID)}
|
||||||
cron: cron.New(cron.WithParser(parser), cron.WithLocation(time.Local)),
|
|
||||||
tasks: tasks,
|
|
||||||
runner: runner,
|
|
||||||
logger: logger,
|
|
||||||
entries: make(map[uint]cron.EntryID),
|
|
||||||
verifyEntries: make(map[uint]cron.EntryID),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetVerifyRunner 注入验证调度器。可选注入:未注入时不处理验证 cron。
|
|
||||||
func (s *Service) SetVerifyRunner(runner VerifyRunner) {
|
|
||||||
s.mu.Lock()
|
|
||||||
defer s.mu.Unlock()
|
|
||||||
s.verifyRunner = runner
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) SetAuditRecorder(audit AuditRecorder) { s.audit = audit }
|
func (s *Service) SetAuditRecorder(audit AuditRecorder) { s.audit = audit }
|
||||||
|
|
||||||
// SetNodeRepository 注入节点仓储用于调度前的健康检查。
|
|
||||||
// 可选注入:未注入时调度器无条件触发任务(单节点场景)。
|
|
||||||
func (s *Service) SetNodeRepository(nodes repository.NodeRepository) {
|
|
||||||
s.mu.Lock()
|
|
||||||
defer s.mu.Unlock()
|
|
||||||
s.nodes = nodes
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) Start(ctx context.Context) error {
|
func (s *Service) Start(ctx context.Context) error {
|
||||||
if err := s.Reload(ctx); err != nil {
|
if err := s.Reload(ctx); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -94,43 +62,25 @@ func (s *Service) Reload(ctx context.Context) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
// 验证调度单独扫描(启用验证的任务可能未启用备份 cron,反之亦然)
|
|
||||||
verifyItems, err := s.tasks.ListVerifySchedulable(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
for taskID, entryID := range s.entries {
|
for taskID, entryID := range s.entries {
|
||||||
s.cron.Remove(entryID)
|
s.cron.Remove(entryID)
|
||||||
delete(s.entries, taskID)
|
delete(s.entries, taskID)
|
||||||
}
|
}
|
||||||
for taskID, entryID := range s.verifyEntries {
|
|
||||||
s.cron.Remove(entryID)
|
|
||||||
delete(s.verifyEntries, taskID)
|
|
||||||
}
|
|
||||||
for _, item := range items {
|
for _, item := range items {
|
||||||
item := item
|
item := item
|
||||||
if err := s.syncTaskLocked(&item); err != nil {
|
if err := s.syncTaskLocked(&item); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, item := range verifyItems {
|
|
||||||
item := item
|
|
||||||
if err := s.syncVerifyTaskLocked(&item); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) SyncTask(_ context.Context, task *model.BackupTask) error {
|
func (s *Service) SyncTask(_ context.Context, task *model.BackupTask) error {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
if err := s.syncTaskLocked(task); err != nil {
|
return s.syncTaskLocked(task)
|
||||||
return err
|
|
||||||
}
|
|
||||||
return s.syncVerifyTaskLocked(task)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) RemoveTask(_ context.Context, taskID uint) error {
|
func (s *Service) RemoveTask(_ context.Context, taskID uint) error {
|
||||||
@@ -140,10 +90,6 @@ func (s *Service) RemoveTask(_ context.Context, taskID uint) error {
|
|||||||
s.cron.Remove(entryID)
|
s.cron.Remove(entryID)
|
||||||
delete(s.entries, taskID)
|
delete(s.entries, taskID)
|
||||||
}
|
}
|
||||||
if entryID, ok := s.verifyEntries[taskID]; ok {
|
|
||||||
s.cron.Remove(entryID)
|
|
||||||
delete(s.verifyEntries, taskID)
|
|
||||||
}
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,56 +106,13 @@ func (s *Service) syncTaskLocked(task *model.BackupTask) error {
|
|||||||
}
|
}
|
||||||
taskID := task.ID
|
taskID := task.ID
|
||||||
taskName := task.Name
|
taskName := task.Name
|
||||||
taskNodeID := task.NodeID
|
entryID, err := s.cron.AddFunc(task.CronExpr, func() {
|
||||||
cronExpr := task.CronExpr
|
|
||||||
maintenanceWindows := task.MaintenanceWindows
|
|
||||||
entryID, err := s.cron.AddFunc(cronExpr, func() {
|
|
||||||
// 集群感知:若任务绑定了离线的远程节点,跳过本轮触发避免堆积 failed 记录
|
|
||||||
if taskNodeID > 0 && s.nodes != nil {
|
|
||||||
node, err := s.nodes.FindByID(context.Background(), taskNodeID)
|
|
||||||
if err == nil && node != nil && !node.IsLocal && node.Status != model.NodeStatusOnline {
|
|
||||||
if s.logger != nil {
|
|
||||||
s.logger.Warn("skip scheduled run: target node offline",
|
|
||||||
zap.Uint("task_id", taskID), zap.String("task_name", taskName),
|
|
||||||
zap.Uint("node_id", taskNodeID), zap.String("node_name", node.Name))
|
|
||||||
}
|
|
||||||
if s.audit != nil {
|
|
||||||
s.audit.Record(servicepkg.AuditEntry{
|
|
||||||
Username: "system", Category: "backup_task", Action: "scheduled_skip",
|
|
||||||
TargetType: "backup_task", TargetID: fmt.Sprintf("%d", taskID),
|
|
||||||
TargetName: taskName,
|
|
||||||
Detail: fmt.Sprintf("跳过调度触发:节点 %s 离线 (task: %s, cron: %s)", node.Name, taskName, cronExpr),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 维护窗口校验:非窗口时间跳过。Windows 为空则不限制。
|
|
||||||
if maintenanceWindows != "" {
|
|
||||||
windows := backup.ParseMaintenanceWindows(maintenanceWindows)
|
|
||||||
if len(windows) > 0 && !backup.IsWithinWindow(time.Now(), windows) {
|
|
||||||
if s.logger != nil {
|
|
||||||
s.logger.Info("skip scheduled run: outside maintenance window",
|
|
||||||
zap.Uint("task_id", taskID), zap.String("task_name", taskName),
|
|
||||||
zap.String("windows", maintenanceWindows))
|
|
||||||
}
|
|
||||||
if s.audit != nil {
|
|
||||||
s.audit.Record(servicepkg.AuditEntry{
|
|
||||||
Username: "system", Category: "backup_task", Action: "scheduled_skip",
|
|
||||||
TargetType: "backup_task", TargetID: fmt.Sprintf("%d", taskID),
|
|
||||||
TargetName: taskName,
|
|
||||||
Detail: fmt.Sprintf("跳过调度触发:非维护窗口 (task: %s, windows: %s)", taskName, maintenanceWindows),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 自动调度任务记录审计日志
|
// 自动调度任务记录审计日志
|
||||||
if s.audit != nil {
|
if s.audit != nil {
|
||||||
s.audit.Record(servicepkg.AuditEntry{
|
s.audit.Record(servicepkg.AuditEntry{
|
||||||
Username: "system", Category: "backup_task", Action: "scheduled_run",
|
Username: "system", Category: "backup_task", Action: "scheduled_run",
|
||||||
TargetType: "backup_task", TargetID: fmt.Sprintf("%d", taskID),
|
TargetType: "backup_task", TargetID: fmt.Sprintf("%d", taskID),
|
||||||
TargetName: taskName, Detail: fmt.Sprintf("定时调度触发备份任务: %s (cron: %s)", taskName, cronExpr),
|
TargetName: taskName, Detail: fmt.Sprintf("定时调度触发备份任务: %s (cron: %s)", taskName, task.CronExpr),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if _, runErr := s.runner.RunTaskByID(context.Background(), taskID); runErr != nil && s.logger != nil {
|
if _, runErr := s.runner.RunTaskByID(context.Background(), taskID); runErr != nil && s.logger != nil {
|
||||||
@@ -222,43 +125,3 @@ func (s *Service) syncTaskLocked(task *model.BackupTask) error {
|
|||||||
s.entries[task.ID] = entryID
|
s.entries[task.ID] = entryID
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// syncVerifyTaskLocked 同步任务的验证演练 cron 条目。
|
|
||||||
// 调度时间到 → 拉取最新成功备份 → 触发 Verify 快速校验。
|
|
||||||
// 若未注入 verifyRunner,直接返回(单节点+无验证场景)。
|
|
||||||
func (s *Service) syncVerifyTaskLocked(task *model.BackupTask) error {
|
|
||||||
if task == nil {
|
|
||||||
return fmt.Errorf("task is required")
|
|
||||||
}
|
|
||||||
if entryID, ok := s.verifyEntries[task.ID]; ok {
|
|
||||||
s.cron.Remove(entryID)
|
|
||||||
delete(s.verifyEntries, task.ID)
|
|
||||||
}
|
|
||||||
if s.verifyRunner == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if !task.Enabled || !task.VerifyEnabled || task.VerifyCronExpr == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
taskID := task.ID
|
|
||||||
taskName := task.Name
|
|
||||||
mode := task.VerifyMode
|
|
||||||
verifyCron := task.VerifyCronExpr
|
|
||||||
entryID, err := s.cron.AddFunc(verifyCron, func() {
|
|
||||||
if s.audit != nil {
|
|
||||||
s.audit.Record(servicepkg.AuditEntry{
|
|
||||||
Username: "system", Category: "backup_verify", Action: "scheduled_run",
|
|
||||||
TargetType: "backup_task", TargetID: fmt.Sprintf("%d", taskID),
|
|
||||||
TargetName: taskName, Detail: fmt.Sprintf("定时验证演练: %s (cron: %s, mode: %s)", taskName, verifyCron, mode),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if _, runErr := s.verifyRunner.StartByTask(context.Background(), taskID, mode, "system"); runErr != nil && s.logger != nil {
|
|
||||||
s.logger.Warn("scheduled verify run failed", zap.Uint("task_id", taskID), zap.Error(runErr))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
s.verifyEntries[task.ID] = entryID
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -26,12 +26,6 @@ func (r *fakeTaskRepository) FindByName(context.Context, string) (*model.BackupT
|
|||||||
func (r *fakeTaskRepository) ListSchedulable(context.Context) ([]model.BackupTask, error) {
|
func (r *fakeTaskRepository) ListSchedulable(context.Context) ([]model.BackupTask, error) {
|
||||||
return r.items, nil
|
return r.items, nil
|
||||||
}
|
}
|
||||||
func (r *fakeTaskRepository) ListVerifySchedulable(context.Context) ([]model.BackupTask, error) {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
func (r *fakeTaskRepository) DistinctTags(context.Context) ([]string, error) {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
func (r *fakeTaskRepository) Count(context.Context) (int64, error) { return 0, nil }
|
func (r *fakeTaskRepository) Count(context.Context) (int64, error) { return 0, nil }
|
||||||
func (r *fakeTaskRepository) CountEnabled(context.Context) (int64, error) { return 0, nil }
|
func (r *fakeTaskRepository) CountEnabled(context.Context) (int64, error) { return 0, nil }
|
||||||
func (r *fakeTaskRepository) CountByStorageTargetID(context.Context, uint) (int64, error) {
|
func (r *fakeTaskRepository) CountByStorageTargetID(context.Context, uint) (int64, error) {
|
||||||
@@ -68,37 +62,3 @@ func TestServiceSyncTaskAndTrigger(t *testing.T) {
|
|||||||
t.Fatalf("expected scheduled runner to be triggered")
|
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
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))
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user