功能: 一键部署 Agent 向导 (#44)

This commit is contained in:
Wu Qing
2026-04-19 17:25:34 +08:00
committed by GitHub
parent 1a0e6d463a
commit 726c5e134b
34 changed files with 7559 additions and 207 deletions

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,590 @@
# 一键部署 Agent 设计文档
- **Issue**: [#43 Feature: 一键部署 Agent](https://github.com/Awuqing/BackupX/issues/43)
- **日期**: 2026-04-19
- **状态**: 设计已评审,待实施
- **相关版本**: BackupX v1.7.x基于 v1.6.0 多节点能力迭代)
## 1. 背景与目标
### 1.1 现状
`docs-site/docs/features/multi-node.md` 描述的多节点集群功能已于 v1.6.0 上线。当前新增一个远程 Agent 的流程:
1. Web 控制台"添加节点" → 输入名称 → 后端生成 64 字节 hex token 并**仅显示一次**
2. 管理员手动复制 token登录目标机上传 `backupx` 二进制
3. 拼装 `backupx agent --master URL --token XXX` 命令手动执行
4. 如需守护进程,复制 `docs-site/docs/features/multi-node.md` 里 systemd unit 模板,自行填入 token
问题:在 Issue #43 描述的"大量远端 Agent 节点场景"下,步骤 2-4 存在大量手工劳动且易错。
### 1.2 目标
参考 Komari 的节点向导体验,提供:
- **UI 向导**:勾选参数(模式 / 架构 / 版本 / 批量)→ 生成一行 `curl | sudo sh` 命令
- **一次性安装链接**token 不出现在命令行15 min 后自动失效
- **幂等安装脚本**:支持 systemd / Docker / 前台三种模式,重跑可升级
- **批量创建**:一次生成 N 个节点 + N 条命令,支持导出 `.sh`
### 1.3 非目标V1 不做)
- Windows / macOS 节点(现有二进制矩阵仅 `linux/{amd64,arm64}`
- 一键卸载命令(通过手动 `systemctl disable --now` + 删目录即可)
- Agent 自动升级守护(重跑脚本即升级,足够满足 V1
- 跨 Master 漂移(节点固定绑 Master
## 2. 总体架构
```
┌───────────────────────────────────────────┐
│ Web Console (React + Arco Design) │
│ 节点管理页 → "添加节点" 向导 │
│ 步骤1: 名称 (支持多行批量) │
│ 步骤2: 勾选 模式/架构/版本 │
│ 步骤3: 显示一行命令 + 脚本预览 Tabs │
└──────────────┬────────────────────────────┘
│ JWT (管理员)
┌───────────────────────────────────────────┐
│ Master (Go + Gin) │
│ │
│ POST /api/nodes/batch ← 批量创建节点 │
│ POST /api/nodes/:id/install-tokens │
│ → 返回 15min 一次性 install token │
│ POST /api/nodes/:id/rotate-token │
│ → 生成新 agent token旧 24h TTL │
│ │
│ (下面两个端点不走 JWT 中间件) │
│ GET /install/:install_token │
│ → 渲染 shell 脚本token、master烧入
│ → 消费即作废 │
│ GET /install/:install_token/compose.yml │
│ → 返回 docker-compose.yml 片段 │
│ GET /api/v1/agent/self │
│ → Agent token 认证,供脚本探活 │
└──────────────┬────────────────────────────┘
│ curl | sudo sh
┌───────────────────────────────────────────┐
│ 目标主机 (Linux) │
│ agent-install.sh 内部流程: │
│ 1. 检测 OS/Arch │
│ 2. 下载二进制 (GitHub / ghproxy 镜像) │
│ 3. 写 systemd unit 或 docker run │
│ 4. 启动并等待 master 确认 online │
└───────────────────────────────────────────┘
```
### 2.1 新增 vs 复用模块
| 类型 | 新增 | 已存在(扩展) |
|---|---|---|
| 后端 Go 包 | `internal/installtoken`CRUD + GC + TTL<br/>`internal/installscript`(模板渲染) | `internal/service/node_service.go`(批量创建、轮换 token<br/>`internal/service/agent_service.go`Self 端点) |
| 路由 | `install_handler.go` 公开路由分组 | `router.go` 注册 |
| 数据表 | `agent_install_tokens` | `nodes` 表加 2 列(`prev_token`, `prev_token_expires` |
| 模板 | `deploy/agent-install.sh.tmpl`<br/>`deploy/agent-compose.yml.tmpl`(嵌入二进制) | — |
| 前端页面 | `web/src/pages/nodes/AgentInstallWizard.tsx` | `NodesPage.tsx`(替换旧 Modal |
| 前端服务 | `web/src/services/nodes.ts` 新 API 函数 | 现有 listNodes/createNode 保留 |
| 文档 | 更新 `docs-site/docs/features/multi-node.md` 中英双份 | — |
## 3. 数据模型
### 3.1 新增表 `agent_install_tokens`
```go
// server/internal/model/agent_install_token.go
type AgentInstallToken struct {
ID uint `gorm:"primaryKey"`
Token string `gorm:"size:64;uniqueIndex;not null"` // 32 字节 hex (crypto/rand)
NodeID uint `gorm:"not null;index"`
Mode string `gorm:"size:16;not null"` // systemd|docker|foreground
Arch string `gorm:"size:16;not null"` // amd64|arm64|auto
AgentVer string `gorm:"size:32;not null"` // 如 v1.7.0
DownloadSrc string `gorm:"size:16;not null;default:'github'"` // github|ghproxy
ExpiresAt time.Time `gorm:"not null;index"`
ConsumedAt *time.Time // 非空即作废
CreatedByID uint `gorm:"not null"` // 审计
CreatedAt time.Time
}
func (AgentInstallToken) TableName() string { return "agent_install_tokens" }
```
AutoMigrate 在 `initialize/gorm.go` 中追加。后台 GC 每 1h 扫描 `ExpiresAt < now - 7d` 记录并硬删除。
### 3.2 扩展 `nodes` 表
```go
// server/internal/model/node.go - 新增两列
PrevToken string `gorm:"size:128;index" json:"-"`
PrevTokenExpires *time.Time `gorm:"column:prev_token_expires" json:"-"`
```
`NodeRepository.FindByToken`:先查 `token`,未命中时查 `prev_token AND prev_token_expires > now`
## 4. API 契约
所有写接口走 `middleware.RequireAuth`JWT`/install/:token``/install/:token/compose.yml` 公开。
### 4.1 批量创建节点
```
POST /api/nodes/batch
Authorization: Bearer <JWT>
{
"names": ["prod-db-01", "prod-db-02", "prod-web-01"]
}
→ 200
{
"data": [
{ "id": 42, "name": "prod-db-01" },
{ "id": 43, "name": "prod-db-02" },
{ "id": 44, "name": "prod-web-01" }
]
}
```
验证:`len(names) in [1, 50]`、去重、每项长度 1-128、不允许空白。事务内创建任一失败全回滚。响应**不含 agent token**,前端随后为每个节点单独请求 install-token。
### 4.2 生成一次性安装令牌
```
POST /api/nodes/42/install-tokens
Authorization: Bearer <JWT>
{
"mode": "systemd", // systemd|docker|foreground
"arch": "auto", // amd64|arm64|auto
"agentVersion": "v1.7.0", // 默认 Master 自身版本
"downloadSrc": "github", // github|ghproxy
"ttlSeconds": 900 // 默认 900范围 300-86400
}
→ 200
{
"data": {
"installToken": "Xk3p9...vM",
"expiresAt": "2026-04-19T13:15:00Z",
"url": "https://master.example.com/install/Xk3p9...vM",
"composeUrl": "https://master.example.com/install/Xk3p9...vM/compose.yml"
}
}
```
- `composeUrl` **仅当 `mode=docker` 时返回**;其他模式该字段为空字符串或省略
- 速率限制:每节点 60s 内最多 5 次(内存令牌桶)
- 审计:`audit_log` 记录 `resource=install_token, action=create, target=nodeId`
### 4.3 轮换 Agent Token
```
POST /api/nodes/42/rotate-token
Authorization: Bearer <JWT>
→ 200
{
"data": { "newToken": "...64 hex..." }
}
```
逻辑:`node.prev_token = node.token; node.prev_token_expires = now + 24h; node.token = newToken`。UI 提示"24h 内旧 Token 仍可认证",便于滚动部署。
**对已发未消费的 install token 的影响**install token 尚未消费时token.agent_token 字段不存在(脚本渲染时从 `node.Token` 动态读),因此轮换后新生成的脚本使用新 token旧 token 自动退役。已被消费(已安装)的 Agent 通过 prev_token 机制继续可用 24h。
### 4.4 公开端点:渲染安装脚本
```
GET /install/:installToken
→ 200 (text/x-shellscript; charset=utf-8)
#!/bin/sh
...(见 §5 模板)
```
**内容按 token.Mode 分派**
| `token.Mode` | 返回内容 | Content-Type |
|---|---|---|
| `systemd` | §5.2 完整 systemd 安装脚本 | `text/x-shellscript` |
| `foreground` | §5.3 前台运行脚本 | `text/x-shellscript` |
| `docker` | §5.4 内嵌 `docker run` 的 shell 脚本 | `text/x-shellscript` |
- **不走** JWT 中间件;限流:单 IP 20 req/min滑动窗口
- 查询 `agent_install_tokens`:不存在/已过期/已消费 → 返回 410 Gone纯文本错误便于终端显示
- 查询成功:`ConsumedAt = now` 后提交事务;然后渲染脚本
- 审计:`audit_log` 记录 `resource=install_token, action=consume, ip=<remote>`
### 4.5 公开端点Docker Compose 片段(仅 Docker 模式可选)
```
GET /install/:installToken/compose.yml
→ 200 (text/yaml)
version: "3.8"
services:
...
```
- **仅当 `token.Mode == docker` 时有效**;其他 Mode 返回 400 `MODE_MISMATCH`
- `/install/:token``/install/:token/compose.yml` **共享同一枚 token 的消费状态**:任一端点首次命中即消费;另一端点随后访问 410用户二选一
- 规则同 §4.4(限流 + 审计)
### 4.6 Agent 探活端点
```
GET /api/v1/agent/self
X-Agent-Token: <agent_token>
→ 200
{
"data": {
"id": 42,
"name": "prod-db-01",
"status": "online",
"lastSeen": "2026-04-19T13:02:10Z"
}
}
```
走 Agent token 认证(`AgentService.AuthenticatedNode`),供 `agent-install.sh` 末尾轮询确认上线。
### 4.7 管理员脚本预览端点(不消费 install token
```
GET /api/nodes/:id/install-script-preview?mode=systemd&arch=auto&agentVersion=v1.7.0&downloadSrc=github
Authorization: Bearer <JWT>
→ 200 (text/x-shellscript; charset=utf-8)
#!/bin/sh
# 预览脚本(未绑定 install tokentoken 占位为 <AGENT_TOKEN>
...
```
- 仅管理员可用,走 JWT 中间件
- 渲染时 `AGENT_TOKEN` 字段使用 `<AGENT_TOKEN>` 字面量占位(不暴露真实 token
- 前端 Step 3 "展开脚本预览" 调此端点;与 install token 的创建/消费完全解耦
## 5. 安装脚本模板
文件 `deploy/agent-install.sh.tmpl`,通过 `embed` 打包进二进制:
```go
//go:embed agent-install.sh.tmpl
var agentInstallTmpl string
```
### 5.1 模板变量
```go
type InstallScriptContext struct {
MasterURL string // 从请求 Host + X-Forwarded-Proto 推导,或系统配置覆盖
AgentToken string // node.Token真正的长期凭证
AgentVersion string // 如 v1.7.0
Arch string // amd64|arm64|auto
Mode string // systemd|docker|foreground
DownloadBase string // github 或 ghproxy 渲染期决定
InstallPrefix string // 默认 /opt/backupx-agent
}
```
`DownloadBase` 映射:
| `downloadSrc` | 渲染值 |
|---|---|
| `github` | `https://github.com/Awuqing/BackupX/releases/download` |
| `ghproxy` | `https://ghproxy.com/https://github.com/Awuqing/BackupX/releases/download` |
### 5.2 systemd 模式脚本骨架
```bash
#!/bin/sh
set -eu
MASTER_URL="{{.MasterURL}}"
AGENT_TOKEN="{{.AgentToken}}"
AGENT_VERSION="{{.AgentVersion}}"
DOWNLOAD_BASE="{{.DownloadBase}}"
INSTALL_PREFIX="{{.InstallPrefix}}"
ARCH="{{.Arch}}"
# 1. 前置检查
[ "$(id -u)" -eq 0 ] || { echo "请使用 root 或 sudo 执行" >&2; exit 1; }
command -v systemctl >/dev/null || { echo "不支持非 systemd 系统" >&2; exit 1; }
command -v curl >/dev/null || command -v wget >/dev/null \
|| { echo "需要 curl 或 wget" >&2; exit 1; }
# 2. 架构检测
if [ "$ARCH" = "auto" ]; then
case "$(uname -m)" in
x86_64|amd64) ARCH=amd64 ;;
aarch64|arm64) ARCH=arm64 ;;
*) echo "不支持的架构:$(uname -m)" >&2; exit 1 ;;
esac
fi
# 3. 下载二进制
ARCHIVE="backupx-${AGENT_VERSION}-linux-${ARCH}.tar.gz"
URL="${DOWNLOAD_BASE}/${AGENT_VERSION}/${ARCHIVE}"
TMPDIR="$(mktemp -d)"; trap 'rm -rf "$TMPDIR"' EXIT
echo "[1/4] 下载 ${URL}"
if command -v curl >/dev/null; then
curl -fsSL "$URL" -o "$TMPDIR/pkg.tar.gz"
else
wget -qO "$TMPDIR/pkg.tar.gz" "$URL"
fi
tar xzf "$TMPDIR/pkg.tar.gz" -C "$TMPDIR"
# 4. 安装
echo "[2/4] 安装到 ${INSTALL_PREFIX}"
id backupx >/dev/null 2>&1 || useradd --system --home-dir "$INSTALL_PREFIX" --shell /usr/sbin/nologin backupx
install -d -o backupx -g backupx "$INSTALL_PREFIX" /var/lib/backupx-agent
install -m 0755 "$TMPDIR/backupx-${AGENT_VERSION}-linux-${ARCH}/backupx" "$INSTALL_PREFIX/backupx"
# 5. systemd unit
echo "[3/4] 配置 systemd"
cat > /etc/systemd/system/backupx-agent.service <<UNIT
[Unit]
Description=BackupX Agent
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=backupx
Environment="BACKUPX_AGENT_MASTER=${MASTER_URL}"
Environment="BACKUPX_AGENT_TOKEN=${AGENT_TOKEN}"
ExecStart=${INSTALL_PREFIX}/backupx agent --temp-dir /var/lib/backupx-agent
Restart=on-failure
RestartSec=10s
NoNewPrivileges=true
[Install]
WantedBy=multi-user.target
UNIT
systemctl daemon-reload
systemctl enable --now backupx-agent
# 6. 等待上线
echo "[4/4] 等待节点上线"
for i in $(seq 1 15); do
sleep 2
if curl -fsSL -H "X-Agent-Token: ${AGENT_TOKEN}" "${MASTER_URL}/api/v1/agent/self" 2>/dev/null \
| grep -q '"status":"online"'; then
echo "✓ 节点已上线"
exit 0
fi
done
echo "⚠ 30s 内未收到上线心跳,请检查防火墙或 journalctl -u backupx-agent"
exit 2
```
### 5.3 foreground 模式差异
跳过步骤 5systemd unit第 6 步替换为:
```bash
echo "前台运行Ctrl+C 退出)"
exec "${INSTALL_PREFIX}/backupx" agent
```
### 5.4 Docker Compose 模板(`deploy/agent-compose.yml.tmpl`
```yaml
version: "3.8"
services:
backupx-agent:
image: awuqing/backupx:{{.AgentVersion}}
command: ["agent"]
restart: unless-stopped
environment:
BACKUPX_AGENT_MASTER: "{{.MasterURL}}"
BACKUPX_AGENT_TOKEN: "{{.AgentToken}}"
volumes:
- /var/lib/backupx-agent:/tmp/backupx-agent
```
UI 同时给出一行等价命令:
```bash
docker run -d --restart=always \
-e BACKUPX_AGENT_MASTER=<MASTER> \
-e BACKUPX_AGENT_TOKEN=<TOKEN> \
-v /var/lib/backupx-agent:/tmp/backupx-agent \
awuqing/backupx:<VER> agent
```
### 5.5 幂等性
- 同一 install token 被 `curl` 消费两次?第二次返回 410 Gone
- 不同 install token 在同机重跑?允许:用户脚本覆盖二进制 + `systemctl restart`systemd unit 被完整重写(用户自定义修改**会丢失** —— 文档需提示)
- `useradd` / `install -d``|| true` 或依赖现有判断,已幂等
## 6. 前端 UI 设计
### 6.1 文件结构
```
web/src/pages/nodes/
NodesPage.tsx (改:操作列菜单、嵌入 Wizard)
AgentInstallWizard.tsx (新:三步向导主体)
BatchCommandTable.tsx (新:批量模式结果表)
wizard/
Step1NodeName.tsx
Step2DeployOptions.tsx
Step3CommandPreview.tsx
web/src/services/
nodes.ts (改:新增 batchCreateNodes / createInstallToken / rotateNodeToken)
```
### 6.2 向导步骤
**Step 1 · 节点信息**
- 单选 Tab`单节点` / `批量创建`
- 单节点:`Input`必填1-128 字符)
- 批量:`TextArea`(每行一名称,最多 50 行,前端去重)
**Step 2 · 部署参数**
| 字段 | 控件 | 默认 |
|---|---|---|
| 安装模式 | `Radio.Group` systemd/docker/foreground | systemd |
| 架构 | `Select` amd64/arm64/auto | auto |
| Agent 版本 | `Select``/api/system/releases` 拉最近 5 个 tag默认 Master 版本 | Master 版本 |
| 有效期 | `Select` 5m/15m/1h/24h | 15m |
| 下载源 | `Radio.Group` github/ghproxy | github |
**Step 3 · 部署命令**
- 单节点:显示一行命令 + `[复制]` + `[展开脚本预览 ▾]`
- 批量:`Table` 每行 {节点名, 命令, 复制按钮};底部 `[一键导出 .sh]`(拼接所有命令为 heredoc 脚本)
- 倒计时:`setInterval(1s)` 显示 `mm:ss`,到期命令变灰 + "重新生成"按钮
### 6.3 NodesPage 操作列改造
```
操作: 编辑名称 | 生成安装命令 | 重新生成 Token | 删除
```
"生成安装命令"复用向导从 Step 2 开始(跳过 Step 1。"重新生成 Token"Popconfirm → 调 `/rotate-token` → 展示新 token 与 24h 过渡提示。
### 6.4 状态与安全
- Wizard state 仅 `useState`,关闭丢弃;不写 localStorage
- install token 显示后立即给"复制"按钮CSS `user-select: all` 便于复制
- 未消费的 install token 无显式"撤销"接口;依赖 TTL 自然过期
### 6.5 i18n
V1 **不接入 i18n** —— 现有 `NodesPage.tsx` 采用硬编码中文(同页面其他文案均未使用 `useTranslation`为保持一致性Wizard 亦使用硬编码中文。待整个 Nodes 页面做 i18n 改造时(单独 PR再统一提取 `nodes.wizard.*` 键。
## 7. 安全考量
- **install token 独立于 agent token**:即便命令泄露,攻击者只能在 TTL 内拉取一次脚本;拉取后立即作废
- **脚本内容包含 agent token 明文**这是必要权衡Agent 启动需要UI 文案提示"请在受控终端执行,勿在共享屏幕操作"
- **限流**`/install/:token` 全局 IP 限流 20 req/min 防扫描install token 生成接口按节点 5/min
- **审计**:三个关键动作全部落 audit_log
- `install_token.create`(管理员生成)
- `install_token.consume`(目标机消费,记 IP
- `node.rotate_token`(管理员轮换)
- **MasterURL 推导**`Scheme + Host`(来自 `X-Forwarded-Proto` / `X-Forwarded-Host`fallback `c.Request.Host`),允许系统配置项 `system_config.masterExternalUrl` 硬编码覆盖(避免反代下协议错误)
- **二进制完整性**V2 增强,本期不做):可选脚本内 `sha256sum` 校验 Release 附带的 `.sha256`
## 8. 测试策略
### 8.1 单元测试
| 包 | 测试点 |
|---|---|
| `installtoken` | 生成 → 查询 → 消费 → 再查询 410过期记录被 GC 删除 |
| `installscript` | 三种模式的模板渲染快照golden file下载源 github/ghproxy 切换 |
| `node_service` | 批量创建事务回滚token 轮换后 `prev_token` 仍可认证24h 后失效 |
### 8.2 集成测试
- `router_test.go` 扩展:
- `POST /api/nodes/batch` 去重、长度限制
- `POST /api/nodes/:id/install-tokens` 限流触发
- `GET /install/:token` 消费语义 + 410 响应
- `GET /install/:token/compose.yml` 幂等消费与 systemd 互斥(同 token 只能消费一种模式)
### 8.3 端到端测试(本地手动 + CI 可选)
- 启动 MasterDocker
- 启动 Linux 容器Ubuntu 22.04,内含 systemd
- 容器内 `curl -fsSL http://master/install/<token> | sh`
- 断言30s 内 `GET /api/nodes` 返回该节点 `status=online`
### 8.4 手动验收
- [ ] systemd 模式clean 系统、已装老版本(重跑升级)、破坏性(手改 unit → 重跑覆盖)
- [ ] Docker 模式:从 UI 拷 compose → `docker-compose up -d`
- [ ] foreground 模式SSH 里 `curl | sh` 直接运行
- [ ] ghproxy 模式:国内 VPS 能下载
- [ ] 批量创建 20 节点,脚本导出后逐机执行
- [ ] install token 过期后 UI 重新生成
## 9. 分阶段发布
合 main 即 GA不走 feature flag通过模块独立性支持单 PR 或拆分 PR
1. **Phase 1 — 后端**(独立可合入)
- 表迁移 + `installtoken` 包 + `installscript`
- install 路由 + agent self 端点
- node_service 扩展batch + rotate
- 单元测试 + 集成测试
2. **Phase 2 — 前端**(依赖 Phase 1 合入)
- AgentInstallWizard + 三子步骤
- NodesPage 操作列改造
- services 层 API 函数
- i18n 键补齐
3. **Phase 3 — 文档**
- 更新 `docs-site/docs/features/multi-node.md`(中英)
- 增加向导截图到 `screenshots/`
- README 多节点段落引用新流程
## 10. 兼容性与回滚
- **老 Agent**:通过手动 token 启动的 Agent 继续用 `POST /api/nodes/heartbeat` 上线。本次零破坏
- **老 API**`POST /api/nodes` 单节点创建保留,仅 UI 不再暴露
- **回滚**:向导上线后如发现阻断问题,前端切回原 Modal后端新端点不影响老流程。DB 迁移可逆:
```sql
DROP TABLE agent_install_tokens;
ALTER TABLE nodes DROP COLUMN prev_token, DROP COLUMN prev_token_expires;
```
## 11. 开放问题与后续
- V2 候选Windows / macOS 节点支持、一键卸载、二进制 sha256 校验、Master 代理下载(离线内网场景)
- `MasterURL` 在复杂反代(多级 L7下的推导鲁棒性V1 用 `X-Forwarded-*`,允许 `system_config` 硬编码若反馈频繁出错V2 提供"部署时测试 URL"按钮
- Agent 版本的 `/api/system/releases` 端点是否复用已有"系统更新检查"逻辑commit `ae658f1`**是**:复用 `system_service.CheckUpdate` 的 GitHub Release 查询缓存
## 12. 变更影响摘要
| 文件 / 模块 | 变更类型 |
|---|---|
| `server/internal/model/node.go` | 新增 2 列 |
| `server/internal/model/agent_install_token.go` | 新增文件 |
| `server/internal/repository/node_repository.go` | `FindByToken` 扩展查 `prev_token` |
| `server/internal/repository/agent_install_token_repository.go` | 新增文件 |
| `server/internal/service/node_service.go` | 新增 `BatchCreate` / `RotateToken` |
| `server/internal/service/install_token_service.go` | 新增文件 |
| `server/internal/installscript/renderer.go` | 新增包 |
| `server/internal/http/install_handler.go` | 新增文件 |
| `server/internal/http/node_handler.go` | 新增 batch / rotate / install-token 端点 |
| `server/internal/http/agent_handler.go` | 新增 `Self` 端点 |
| `server/internal/http/router.go` | 注册新路由(公开分组 + JWT 分组) |
| `server/internal/initialize/gorm.go` | AutoMigrate 新表 |
| `deploy/agent-install.sh.tmpl` | 新增文件 |
| `deploy/agent-compose.yml.tmpl` | 新增文件 |
| `web/src/pages/nodes/NodesPage.tsx` | 操作列改造 |
| `web/src/pages/nodes/AgentInstallWizard.tsx` | 新增文件 |
| `web/src/pages/nodes/BatchCommandTable.tsx` | 新增文件 |
| `web/src/pages/nodes/wizard/*` | 新增 3 个子步骤 |
| `web/src/services/nodes.ts` | 新增 3 个函数 |
| ~~`web/src/locales/{zh-CN,en}/nodes.json`~~ | ~~新增 wizard.* 键~~V1 使用硬编码中文,见 §6.5 |
| `docs-site/docs/features/multi-node.md` | 重写"Walkthrough"章节 |
| `docs-site/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/multi-node.md` | 同步中文 |

View File

@@ -123,12 +123,19 @@ func New(ctx context.Context, cfg config.Config, version string) (*Application,
agentCmdRepo := repository.NewAgentCommandRepository(db)
agentService := service.NewAgentService(nodeRepo, backupTaskRepo, backupRecordRepo, storageTargetRepo, agentCmdRepo, configCipher)
agentService.StartCommandTimeoutMonitor(ctx, 30*time.Second, 10*time.Minute)
// 一键部署install token service + 后台 GC
installTokenRepo := repository.NewAgentInstallTokenRepository(db)
installTokenService := service.NewInstallTokenService(installTokenRepo, nodeRepo)
installTokenService.StartGC(ctx, time.Hour)
// 把 Agent 下发能力注入到备份执行服务,实现多节点路由
backupExecutionService.SetClusterDependencies(nodeRepo, agentService)
// 启用远程目录浏览NodeService 通过 AgentService 做同步 RPC
nodeService.SetAgentRPC(agentService)
router := aphttp.NewRouter(aphttp.RouterDependencies{
Context: ctx,
Config: cfg,
Version: version,
Logger: appLogger,
@@ -146,8 +153,10 @@ func New(ctx context.Context, cfg config.Config, version string) (*Application,
DatabaseDiscoveryService: databaseDiscoveryService,
AuditService: auditService,
JWTManager: jwtManager,
UserRepository: userRepo,
SystemConfigRepo: systemConfigRepo,
UserRepository: userRepo,
SystemConfigRepo: systemConfigRepo,
InstallTokenService: installTokenService,
MasterExternalURL: "", // 如需覆盖 URL可扩展 cfg.Server 增字段;目前留空依赖 X-Forwarded-* / Request.Host
})
httpServer := &stdhttp.Server{

View File

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

View File

@@ -154,3 +154,18 @@ func (h *AgentHandler) UpdateRecord(c *gin.Context) {
}
response.Success(c, gin.H{"status": "ok"})
}
// Self 返回当前 Agent token 所属节点的状态,供安装脚本末尾探活。
func (h *AgentHandler) Self(c *gin.Context) {
node, err := h.agentService.AuthenticatedNode(c.Request.Context(), extractToken(c))
if err != nil {
response.Error(c, err)
return
}
status, err := h.agentService.SelfStatus(c.Request.Context(), node)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, status)
}

View File

@@ -0,0 +1,331 @@
package http
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"time"
"backupx/server/internal/config"
"backupx/server/internal/database"
"backupx/server/internal/logger"
"backupx/server/internal/repository"
"backupx/server/internal/security"
"backupx/server/internal/service"
)
// setupInstallFlowRouter 构造一个 Node + Agent + InstallToken 全量依赖的 router
// 并返回已登录管理员 JWT。
func setupInstallFlowRouter(t *testing.T) (http.Handler, string) {
t.Helper()
tempDir := t.TempDir()
cfg := config.Config{
Server: config.ServerConfig{Host: "127.0.0.1", Port: 8340, Mode: "test"},
Database: config.DatabaseConfig{Path: filepath.Join(tempDir, "backupx.db")},
Security: config.SecurityConfig{JWTExpire: "24h"},
Log: config.LogConfig{Level: "error"},
}
log, err := logger.New(cfg.Log)
if err != nil {
t.Fatalf("logger: %v", err)
}
db, err := database.Open(cfg.Database, log)
if err != nil {
t.Fatalf("db: %v", err)
}
userRepo := repository.NewUserRepository(db)
systemConfigRepo := repository.NewSystemConfigRepository(db)
resolved, err := service.ResolveSecurity(context.Background(), cfg.Security, systemConfigRepo)
if err != nil {
t.Fatalf("security: %v", err)
}
jwtMgr := security.NewJWTManager(resolved.JWTSecret, time.Hour)
authSvc := service.NewAuthService(userRepo, systemConfigRepo, jwtMgr, security.NewLoginRateLimiter(5, time.Minute))
systemSvc := service.NewSystemService(cfg, "test", time.Now().UTC())
nodeRepo := repository.NewNodeRepository(db)
nodeSvc := service.NewNodeService(nodeRepo, "test")
if err := nodeSvc.EnsureLocalNode(context.Background()); err != nil {
t.Fatalf("ensure local: %v", err)
}
installTokenRepo := repository.NewAgentInstallTokenRepository(db)
installTokenSvc := service.NewInstallTokenService(installTokenRepo, nodeRepo)
auditLogRepo := repository.NewAuditLogRepository(db)
auditSvc := service.NewAuditService(auditLogRepo)
// 用 cancelable ctx测试结束时停掉 handler 启动的后台 GC 协程,
// 避免 goroutine 持有 map 导致 tempdir 清理失败。
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
router := NewRouter(RouterDependencies{
Context: ctx,
Config: cfg,
Version: "test",
Logger: log,
AuthService: authSvc,
SystemService: systemSvc,
NodeService: nodeSvc,
InstallTokenService: installTokenSvc,
AuditService: auditSvc,
JWTManager: jwtMgr,
UserRepository: userRepo,
SystemConfigRepo: systemConfigRepo,
})
// setup 管理员并登录拿 JWT
setupBody, _ := json.Marshal(map[string]string{
"username": "admin", "password": "password-123", "displayName": "admin",
})
setupReq := httptest.NewRequest(http.MethodPost, "/api/auth/setup", bytes.NewBuffer(setupBody))
setupReq.Header.Set("Content-Type", "application/json")
setupRec := httptest.NewRecorder()
router.ServeHTTP(setupRec, setupReq)
if setupRec.Code != 200 {
t.Fatalf("setup failed: %d %s", setupRec.Code, setupRec.Body.String())
}
var setupResp struct {
Data struct {
Token string `json:"token"`
} `json:"data"`
}
if err := json.Unmarshal(setupRec.Body.Bytes(), &setupResp); err != nil {
t.Fatalf("unmarshal setup: %v", err)
}
return router, setupResp.Data.Token
}
func TestOneClickInstallFlow(t *testing.T) {
router, jwt := setupInstallFlowRouter(t)
// 1. 批量创建
batchBody, _ := json.Marshal(map[string][]string{"names": {"prod-a", "prod-b"}})
batchReq := httptest.NewRequest(http.MethodPost, "/api/nodes/batch", bytes.NewBuffer(batchBody))
batchReq.Header.Set("Content-Type", "application/json")
batchReq.Header.Set("Authorization", "Bearer "+jwt)
batchRec := httptest.NewRecorder()
router.ServeHTTP(batchRec, batchReq)
if batchRec.Code != 200 {
t.Fatalf("batch create failed: %d %s", batchRec.Code, batchRec.Body.String())
}
var batchResp struct {
Data []struct {
ID uint `json:"id"`
Name string `json:"name"`
} `json:"data"`
}
if err := json.Unmarshal(batchRec.Body.Bytes(), &batchResp); err != nil {
t.Fatalf("unmarshal batch: %v", err)
}
if len(batchResp.Data) != 2 {
t.Fatalf("expected 2 nodes, got %d", len(batchResp.Data))
}
nodeID := batchResp.Data[0].ID
// 2. 生成 install token
genBody, _ := json.Marshal(map[string]any{
"mode": "systemd",
"arch": "auto",
"agentVersion": "v1.7.0",
"downloadSrc": "github",
"ttlSeconds": 900,
})
genReq := httptest.NewRequest(http.MethodPost,
"/api/nodes/"+formatUint(nodeID)+"/install-tokens", bytes.NewBuffer(genBody))
genReq.Header.Set("Content-Type", "application/json")
genReq.Header.Set("Authorization", "Bearer "+jwt)
genRec := httptest.NewRecorder()
router.ServeHTTP(genRec, genReq)
if genRec.Code != 200 {
t.Fatalf("install-tokens failed: %d %s", genRec.Code, genRec.Body.String())
}
var genResp struct {
Data struct {
InstallToken string `json:"installToken"`
URL string `json:"url"`
} `json:"data"`
}
if err := json.Unmarshal(genRec.Body.Bytes(), &genResp); err != nil {
t.Fatalf("unmarshal gen: %v", err)
}
if genResp.Data.InstallToken == "" {
t.Fatalf("missing installToken")
}
// 3. 公开端点消费
scriptReq := httptest.NewRequest(http.MethodGet, "/install/"+genResp.Data.InstallToken, nil)
scriptRec := httptest.NewRecorder()
router.ServeHTTP(scriptRec, scriptReq)
if scriptRec.Code != 200 {
t.Fatalf("script fetch failed: %d %s", scriptRec.Code, scriptRec.Body.String())
}
if !strings.Contains(scriptRec.Body.String(), "systemctl enable --now backupx-agent") {
t.Fatalf("script missing systemctl enable:\n%s", scriptRec.Body.String())
}
// 4. 再次消费应 410
scriptReq2 := httptest.NewRequest(http.MethodGet, "/install/"+genResp.Data.InstallToken, nil)
scriptRec2 := httptest.NewRecorder()
router.ServeHTTP(scriptRec2, scriptReq2)
if scriptRec2.Code != http.StatusGone {
t.Fatalf("second consume should be 410, got %d: %s", scriptRec2.Code, scriptRec2.Body.String())
}
}
func TestInstallTokenRateLimit(t *testing.T) {
router, jwt := setupInstallFlowRouter(t)
batchBody, _ := json.Marshal(map[string][]string{"names": {"rl-test"}})
batchReq := httptest.NewRequest(http.MethodPost, "/api/nodes/batch", bytes.NewBuffer(batchBody))
batchReq.Header.Set("Content-Type", "application/json")
batchReq.Header.Set("Authorization", "Bearer "+jwt)
batchRec := httptest.NewRecorder()
router.ServeHTTP(batchRec, batchReq)
if batchRec.Code != 200 {
t.Fatalf("batch: %d %s", batchRec.Code, batchRec.Body.String())
}
var batchResp struct {
Data []struct {
ID uint `json:"id"`
} `json:"data"`
}
_ = json.Unmarshal(batchRec.Body.Bytes(), &batchResp)
nodeID := batchResp.Data[0].ID
body, _ := json.Marshal(map[string]any{
"mode": "systemd", "arch": "auto", "agentVersion": "v1",
"downloadSrc": "github", "ttlSeconds": 300,
})
for i := 0; i < 5; i++ {
req := httptest.NewRequest(http.MethodPost,
"/api/nodes/"+formatUint(nodeID)+"/install-tokens", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+jwt)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != 200 {
t.Fatalf("iter %d expected 200, got %d: %s", i, rec.Code, rec.Body.String())
}
}
req := httptest.NewRequest(http.MethodPost,
"/api/nodes/"+formatUint(nodeID)+"/install-tokens", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+jwt)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusTooManyRequests {
t.Fatalf("expected 429, got %d: %s", rec.Code, rec.Body.String())
}
}
func TestRotateTokenFlow(t *testing.T) {
router, jwt := setupInstallFlowRouter(t)
batchBody, _ := json.Marshal(map[string][]string{"names": {"rot-x"}})
batchReq := httptest.NewRequest(http.MethodPost, "/api/nodes/batch", bytes.NewBuffer(batchBody))
batchReq.Header.Set("Content-Type", "application/json")
batchReq.Header.Set("Authorization", "Bearer "+jwt)
batchRec := httptest.NewRecorder()
router.ServeHTTP(batchRec, batchReq)
var batchResp struct {
Data []struct {
ID uint `json:"id"`
} `json:"data"`
}
_ = json.Unmarshal(batchRec.Body.Bytes(), &batchResp)
nodeID := batchResp.Data[0].ID
rotReq := httptest.NewRequest(http.MethodPost,
"/api/nodes/"+formatUint(nodeID)+"/rotate-token", nil)
rotReq.Header.Set("Authorization", "Bearer "+jwt)
rotRec := httptest.NewRecorder()
router.ServeHTTP(rotRec, rotReq)
if rotRec.Code != 200 {
t.Fatalf("rotate failed: %d %s", rotRec.Code, rotRec.Body.String())
}
var rotResp struct {
Data struct {
NewToken string `json:"newToken"`
} `json:"data"`
}
_ = json.Unmarshal(rotRec.Body.Bytes(), &rotResp)
if len(rotResp.Data.NewToken) != 64 {
t.Fatalf("new token wrong length: %s", rotResp.Data.NewToken)
}
}
func TestInstallFlowComposeModeMismatch(t *testing.T) {
router, jwt := setupInstallFlowRouter(t)
batchBody, _ := json.Marshal(map[string][]string{"names": {"cm"}})
batchReq := httptest.NewRequest(http.MethodPost, "/api/nodes/batch", bytes.NewBuffer(batchBody))
batchReq.Header.Set("Content-Type", "application/json")
batchReq.Header.Set("Authorization", "Bearer "+jwt)
batchRec := httptest.NewRecorder()
router.ServeHTTP(batchRec, batchReq)
var batchResp struct {
Data []struct {
ID uint `json:"id"`
} `json:"data"`
}
_ = json.Unmarshal(batchRec.Body.Bytes(), &batchResp)
nodeID := batchResp.Data[0].ID
// 生成 systemd 模式的 token
genBody, _ := json.Marshal(map[string]any{
"mode": "systemd", "arch": "auto", "agentVersion": "v1",
"downloadSrc": "github", "ttlSeconds": 300,
})
genReq := httptest.NewRequest(http.MethodPost,
"/api/nodes/"+formatUint(nodeID)+"/install-tokens", bytes.NewBuffer(genBody))
genReq.Header.Set("Content-Type", "application/json")
genReq.Header.Set("Authorization", "Bearer "+jwt)
genRec := httptest.NewRecorder()
router.ServeHTTP(genRec, genReq)
var genResp struct {
Data struct {
InstallToken string `json:"installToken"`
} `json:"data"`
}
_ = json.Unmarshal(genRec.Body.Bytes(), &genResp)
// 访问 compose.yml 应 400
req := httptest.NewRequest(http.MethodGet,
"/install/"+genResp.Data.InstallToken+"/compose.yml", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected 400 for mode mismatch, got %d: %s", rec.Code, rec.Body.String())
}
// systemd token 未被消费Peek 不消费)→ 应仍可通过 /install/:token 消费成功
req2 := httptest.NewRequest(http.MethodGet, "/install/"+genResp.Data.InstallToken, nil)
rec2 := httptest.NewRecorder()
router.ServeHTTP(rec2, req2)
if rec2.Code != 200 {
t.Fatalf("original script fetch should still work: %d %s", rec2.Code, rec2.Body.String())
}
}
// formatUint 小工具uint → 十进制字符串(无需引入 strconv
func formatUint(u uint) string {
if u == 0 {
return "0"
}
var buf [20]byte
i := len(buf)
for u > 0 {
i--
buf[i] = byte('0' + u%10)
u /= 10
}
return string(buf[i:])
}

View File

@@ -0,0 +1,221 @@
package http
import (
"context"
stdhttp "net/http"
"strconv"
"strings"
"sync"
"time"
"backupx/server/internal/installscript"
"backupx/server/internal/model"
"backupx/server/internal/service"
"github.com/gin-gonic/gin"
)
// InstallHandler 公开路由(不走 JWT 中间件):/install/:token 与 /install/:token/compose.yml。
type InstallHandler struct {
tokenService *service.InstallTokenService
auditService *service.AuditService
externalURL string
limiter *ipLimiter
}
// NewInstallHandler 构造 handler 并启动限流器的后台 GC 协程。
// gcCtx 控制 GC 协程生命周期,建议传入 app context。
func NewInstallHandler(gcCtx context.Context, tokenService *service.InstallTokenService, auditService *service.AuditService, externalURL string) *InstallHandler {
limiter := newIPLimiter(20, time.Minute)
limiter.startGC(gcCtx)
return &InstallHandler{
tokenService: tokenService,
auditService: auditService,
externalURL: externalURL,
limiter: limiter,
}
}
// Script 消费 install token 并返回 shell 脚本Mode 由 token 存储决定systemd/docker/foreground 均返回 shell
func (h *InstallHandler) Script(c *gin.Context) {
if !h.limiter.allow(c.ClientIP()) {
c.String(stdhttp.StatusTooManyRequests, "请求过于频繁,请稍后再试\n")
return
}
token := strings.TrimSpace(c.Param("token"))
consumed, err := h.tokenService.Consume(c.Request.Context(), token)
if err != nil {
c.String(stdhttp.StatusInternalServerError, "server error\n")
return
}
if consumed == nil {
c.String(stdhttp.StatusGone, "install token 不存在、已过期或已消费\n")
return
}
h.recordConsumeAudit(c, consumed, "script")
script, err := 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 {
c.String(stdhttp.StatusInternalServerError, "render error\n")
return
}
c.Data(stdhttp.StatusOK, "text/x-shellscript; charset=utf-8", []byte(script))
}
// Compose 消费 install token 并返回 docker-compose YAML仅 Mode=docker 有效。
// 注意:/install/:token 与 /install/:token/compose.yml 共享同一 token 的消费状态,任一首次命中即消费。
func (h *InstallHandler) Compose(c *gin.Context) {
if !h.limiter.allow(c.ClientIP()) {
c.String(stdhttp.StatusTooManyRequests, "请求过于频繁,请稍后再试\n")
return
}
token := strings.TrimSpace(c.Param("token"))
// 先 Peek 看 Mode不消费若非 docker 直接 400
record, err := h.tokenService.Peek(c.Request.Context(), token)
if err != nil {
c.String(stdhttp.StatusInternalServerError, "server error\n")
return
}
if record == nil {
c.String(stdhttp.StatusGone, "install token 不存在\n")
return
}
if record.Mode != model.InstallModeDocker {
c.String(stdhttp.StatusBadRequest, "该 install token 的模式不是 docker\n")
return
}
// 消费
consumed, err := h.tokenService.Consume(c.Request.Context(), token)
if err != nil {
c.String(stdhttp.StatusInternalServerError, "server error\n")
return
}
if consumed == nil {
c.String(stdhttp.StatusGone, "install token 已过期或已消费\n")
return
}
h.recordConsumeAudit(c, consumed, "compose")
yaml, err := installscript.RenderComposeYaml(installscript.Context{
MasterURL: resolveMasterURL(c, h.externalURL),
AgentToken: consumed.Node.Token,
AgentVersion: consumed.Record.AgentVer,
Mode: model.InstallModeDocker,
NodeID: consumed.Node.ID,
})
if err != nil {
c.String(stdhttp.StatusInternalServerError, "render error\n")
return
}
c.Data(stdhttp.StatusOK, "text/yaml; charset=utf-8", []byte(yaml))
}
func (h *InstallHandler) recordConsumeAudit(c *gin.Context, consumed *service.ConsumedInstallToken, kind string) {
if h.auditService == nil {
return
}
h.auditService.Record(service.AuditEntry{
Category: "install_token",
Action: "consume",
TargetType: "node",
TargetID: strconv.FormatUint(uint64(consumed.Node.ID), 10),
TargetName: consumed.Node.Name,
Detail: "install token 消费 (" + kind + ")",
ClientIP: c.ClientIP(),
})
}
// resolveMasterURL 按优先级推导 Master URL外部配置 > X-Forwarded-* > Request.Host。
// 此为包级 helper供 install_handler 和 node_handler 共用。
func resolveMasterURL(c *gin.Context, externalURL string) string {
if strings.TrimSpace(externalURL) != "" {
return strings.TrimRight(externalURL, "/")
}
scheme := strings.TrimSpace(c.GetHeader("X-Forwarded-Proto"))
if scheme == "" {
if c.Request.TLS != nil {
scheme = "https"
} else {
scheme = "http"
}
}
host := strings.TrimSpace(c.GetHeader("X-Forwarded-Host"))
if host == "" {
host = c.Request.Host
}
return scheme + "://" + host
}
// ipLimiter 简单内存滑动窗口限流,按 client IP 维度。
type ipLimiter struct {
mu sync.Mutex
events map[string][]time.Time
limit int
window time.Duration
}
func newIPLimiter(limit int, window time.Duration) *ipLimiter {
return &ipLimiter{events: make(map[string][]time.Time), limit: limit, window: window}
}
func (l *ipLimiter) allow(ip string) bool {
l.mu.Lock()
defer l.mu.Unlock()
now := time.Now()
cutoff := now.Add(-l.window)
keep := l.events[ip][:0]
for _, t := range l.events[ip] {
if t.After(cutoff) {
keep = append(keep, t)
}
}
if len(keep) >= l.limit {
l.events[ip] = keep
return false
}
l.events[ip] = append(keep, now)
return true
}
// gc 清理窗口外所有过期的 IP 条目,防止公网扫描导致 map 无界增长。
// 由后台 goroutine 周期性调用。
func (l *ipLimiter) gc(now time.Time) {
l.mu.Lock()
defer l.mu.Unlock()
cutoff := now.Add(-l.window)
for k, v := range l.events {
stale := true
for _, t := range v {
if t.After(cutoff) {
stale = false
break
}
}
if stale {
delete(l.events, k)
}
}
}
// startGC 启动后台清理协程,每 window 周期清扫一次 map。
// ctx 取消时协程退出。
func (l *ipLimiter) startGC(ctx context.Context) {
go func() {
ticker := time.NewTicker(l.window)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case t := <-ticker.C:
l.gc(t)
}
}
}()
}

View File

@@ -5,18 +5,59 @@ import (
stdhttp "net/http"
"strconv"
"backupx/server/internal/apperror"
"backupx/server/internal/installscript"
"backupx/server/internal/repository"
"backupx/server/internal/service"
"backupx/server/pkg/response"
"github.com/gin-gonic/gin"
)
type NodeHandler struct {
service *service.NodeService
auditService *service.AuditService
service *service.NodeService
auditService *service.AuditService
installTokenSvc *service.InstallTokenService
userRepo repository.UserRepository
externalURL string
}
func NewNodeHandler(service *service.NodeService, auditService *service.AuditService) *NodeHandler {
return &NodeHandler{service: service, auditService: auditService}
// NewNodeHandler 构造 handler。
// userRepo 用于把 JWT subject用户名解析为 user.ID填入 install_token.created_by_id 做审计追溯;
// 传 nil 时 created_by_id 记为 0仍可用不阻断
func NewNodeHandler(
nodeService *service.NodeService,
auditService *service.AuditService,
installTokenSvc *service.InstallTokenService,
userRepo repository.UserRepository,
externalURL string,
) *NodeHandler {
return &NodeHandler{
service: nodeService,
auditService: auditService,
installTokenSvc: installTokenSvc,
userRepo: userRepo,
externalURL: externalURL,
}
}
// resolveCurrentUserID 从 JWT subject 解析出 user.ID失败返回 0。
func (h *NodeHandler) resolveCurrentUserID(c *gin.Context) uint {
if h.userRepo == nil {
return 0
}
subjectValue, ok := c.Get(contextUserSubjectKey)
if !ok {
return 0
}
subject, err := service.SubjectFromContextValue(subjectValue)
if err != nil || subject == "" {
return 0
}
user, err := h.userRepo.FindByUsername(c.Request.Context(), subject)
if err != nil || user == nil {
return 0
}
return user.ID
}
func (h *NodeHandler) List(c *gin.Context) {
@@ -128,3 +169,135 @@ func (h *NodeHandler) Heartbeat(c *gin.Context) {
}
response.Success(c, gin.H{"status": "ok"})
}
// BatchCreate 批量创建远程节点。
func (h *NodeHandler) BatchCreate(c *gin.Context) {
var input struct {
Names []string `json:"names" binding:"required"`
}
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(stdhttp.StatusBadRequest, gin.H{"code": "INVALID_INPUT", "message": err.Error()})
return
}
results, err := h.service.BatchCreate(c.Request.Context(), input.Names)
if err != nil {
response.Error(c, err)
return
}
recordAudit(c, h.auditService, "node", "batch_create", "node", "",
fmt.Sprintf("%d", len(results)), fmt.Sprintf("批量创建 %d 个节点", len(results)))
response.Success(c, results)
}
// RotateToken 轮换节点的 agent token。
func (h *NodeHandler) RotateToken(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
response.Error(c, err)
return
}
tok, err := h.service.RotateToken(c.Request.Context(), uint(id))
if err != nil {
response.Error(c, err)
return
}
recordAudit(c, h.auditService, "node", "rotate_token", "node",
fmt.Sprintf("%d", id), "",
fmt.Sprintf("轮换节点 Token (ID: %d)", id))
response.Success(c, gin.H{"newToken": tok})
}
// CreateInstallToken 生成一次性安装令牌。
func (h *NodeHandler) CreateInstallToken(c *gin.Context) {
if h.installTokenSvc == nil {
response.Error(c, apperror.New(stdhttp.StatusServiceUnavailable,
"INSTALL_TOKEN_DISABLED", "一键部署未启用", nil))
return
}
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
response.Error(c, err)
return
}
var input struct {
Mode string `json:"mode"`
Arch string `json:"arch"`
AgentVersion string `json:"agentVersion"`
DownloadSrc string `json:"downloadSrc"`
TTLSeconds int `json:"ttlSeconds"`
}
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(stdhttp.StatusBadRequest, gin.H{"code": "INVALID_INPUT", "message": err.Error()})
return
}
// 默认值
if input.Mode == "" {
input.Mode = "systemd"
}
if input.Arch == "" {
input.Arch = "auto"
}
if input.DownloadSrc == "" {
input.DownloadSrc = "github"
}
if input.TTLSeconds == 0 {
input.TTLSeconds = 900
}
out, err := h.installTokenSvc.Create(c.Request.Context(), service.InstallTokenInput{
NodeID: uint(id),
Mode: input.Mode,
Arch: input.Arch,
AgentVersion: input.AgentVersion,
DownloadSrc: input.DownloadSrc,
TTLSeconds: input.TTLSeconds,
CreatedByID: h.resolveCurrentUserID(c),
})
if err != nil {
response.Error(c, err)
return
}
recordAudit(c, h.auditService, "install_token", "create", "node",
fmt.Sprintf("%d", id), out.Node.Name,
fmt.Sprintf("生成 %s/%s install token TTL=%ds", input.Mode, input.Arch, input.TTLSeconds))
masterURL := resolveMasterURL(c, h.externalURL)
body := gin.H{
"installToken": out.Token,
"expiresAt": out.ExpiresAt,
"url": masterURL + "/install/" + out.Token,
"composeUrl": "",
}
if input.Mode == "docker" {
body["composeUrl"] = masterURL + "/install/" + out.Token + "/compose.yml"
}
response.Success(c, body)
}
// PreviewScript 预览安装脚本token 字段用 <AGENT_TOKEN> 占位,不消费 install token
// 用于 UI Step 3 展开"脚本预览"。
func (h *NodeHandler) PreviewScript(c *gin.Context) {
mode := c.DefaultQuery("mode", "systemd")
arch := c.DefaultQuery("arch", "auto")
ver := c.Query("agentVersion")
if ver == "" {
c.JSON(stdhttp.StatusBadRequest, gin.H{"code": "INVALID_INPUT", "message": "agentVersion required"})
return
}
src := c.DefaultQuery("downloadSrc", "github")
ctx := installscript.Context{
MasterURL: resolveMasterURL(c, h.externalURL),
AgentToken: "<AGENT_TOKEN>",
AgentVersion: ver,
Mode: mode,
Arch: arch,
DownloadBase: installscript.DownloadBaseFor(src),
InstallPrefix: "/opt/backupx-agent",
}
script, err := installscript.RenderScript(ctx)
if err != nil {
response.Error(c, err)
return
}
c.Data(stdhttp.StatusOK, "text/x-shellscript; charset=utf-8", []byte(script))
}

View File

@@ -1,6 +1,7 @@
package http
import (
"context"
"errors"
stdhttp "net/http"
@@ -15,6 +16,9 @@ import (
)
type RouterDependencies struct {
// Context 控制 handler 启动的后台协程(如 ipLimiter GC的生命周期。
// app 应传入随进程退出可取消的 ctx若为 nil 则退化为 context.Background()。
Context context.Context
Config config.Config
Version string
Logger *zap.Logger
@@ -34,6 +38,8 @@ type RouterDependencies struct {
JWTManager *security.JWTManager
UserRepository repository.UserRepository
SystemConfigRepo repository.SystemConfigRepository
InstallTokenService *service.InstallTokenService
MasterExternalURL string
}
func NewRouter(deps RouterDependencies) *gin.Engine {
@@ -141,7 +147,7 @@ func NewRouter(deps RouterDependencies) *gin.Engine {
database.POST("/discover", databaseHandler.Discover)
}
nodeHandler := NewNodeHandler(deps.NodeService, deps.AuditService)
nodeHandler := NewNodeHandler(deps.NodeService, deps.AuditService, deps.InstallTokenService, deps.UserRepository, deps.MasterExternalURL)
nodes := api.Group("/nodes")
nodes.Use(AuthMiddleware(deps.JWTManager))
nodes.GET("", nodeHandler.List)
@@ -150,6 +156,10 @@ func NewRouter(deps RouterDependencies) *gin.Engine {
nodes.PUT("/:id", nodeHandler.Update)
nodes.DELETE("/:id", nodeHandler.Delete)
nodes.GET("/:id/fs/list", nodeHandler.ListDirectory)
nodes.POST("/batch", nodeHandler.BatchCreate)
nodes.POST("/:id/install-tokens", nodeHandler.CreateInstallToken)
nodes.POST("/:id/rotate-token", nodeHandler.RotateToken)
nodes.GET("/:id/install-script-preview", nodeHandler.PreviewScript)
// Agent APItoken 认证,无需 JWT
if deps.AgentService != nil {
@@ -160,12 +170,27 @@ func NewRouter(deps RouterDependencies) *gin.Engine {
agent.POST("/commands/:id/result", agentHandler.SubmitCommandResult)
agent.GET("/tasks/:id", agentHandler.GetTaskSpec)
agent.POST("/records/:id", agentHandler.UpdateRecord)
// Agent v1安装脚本探活用仅 Self 端点
v1Agent := api.Group("/v1/agent")
v1Agent.GET("/self", agentHandler.Self)
} else {
// 未启用 Agent 服务时,保留原有 heartbeat 端点以兼容
api.POST("/agent/heartbeat", nodeHandler.Heartbeat)
}
}
// 公开安装路由(不走 JWT 中间件)
if deps.InstallTokenService != nil {
gcCtx := deps.Context
if gcCtx == nil {
gcCtx = context.Background()
}
installHandler := NewInstallHandler(gcCtx, deps.InstallTokenService, deps.AuditService, deps.MasterExternalURL)
engine.GET("/install/:token", installHandler.Script)
engine.GET("/install/:token/compose.yml", installHandler.Compose)
}
engine.NoRoute(func(c *gin.Context) {
response.Error(c, apperror.New(stdhttp.StatusNotFound, "NOT_FOUND", "接口不存在", errors.New("route not found")))
})

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,108 @@
#!/bin/sh
# BackupX Agent 一键安装脚本(由 Master 动态渲染)
# 模式: {{.Mode}} | 架构: {{.Arch}} | 版本: {{.AgentVersion}}
set -eu
MASTER_URL="{{.MasterURL}}"
AGENT_TOKEN="{{.AgentToken}}"
AGENT_VERSION="{{.AgentVersion}}"
DOWNLOAD_BASE="{{.DownloadBase}}"
INSTALL_PREFIX="{{.InstallPrefix}}"
ARCH="{{.Arch}}"
# 1. 前置检查
[ "$(id -u)" -eq 0 ] || { echo "请使用 root 或 sudo 执行" >&2; exit 1; }
command -v curl >/dev/null || command -v wget >/dev/null \
|| { echo "需要 curl 或 wget" >&2; exit 1; }
{{if eq .Mode "systemd"}}command -v systemctl >/dev/null || { echo "不支持非 systemd 系统" >&2; exit 1; }
{{end}}{{if eq .Mode "docker"}}command -v docker >/dev/null || { echo "需要先安装 docker" >&2; exit 1; }
{{end}}
# 2. 架构检测
if [ "$ARCH" = "auto" ]; then
case "$(uname -m)" in
x86_64|amd64) ARCH=amd64 ;;
aarch64|arm64) ARCH=arm64 ;;
*) echo "不支持的架构: $(uname -m)" >&2; exit 1 ;;
esac
fi
{{if ne .Mode "docker"}}
# 3. 下载二进制systemd / foreground 模式)
ARCHIVE="backupx-${AGENT_VERSION}-linux-${ARCH}.tar.gz"
URL="${DOWNLOAD_BASE}/${AGENT_VERSION}/${ARCHIVE}"
TMPDIR="$(mktemp -d)"; trap 'rm -rf "$TMPDIR"' EXIT
echo "[1/4] 下载 ${URL}"
if command -v curl >/dev/null; then
curl -fsSL "$URL" -o "$TMPDIR/pkg.tar.gz"
else
wget -qO "$TMPDIR/pkg.tar.gz" "$URL"
fi
tar xzf "$TMPDIR/pkg.tar.gz" -C "$TMPDIR"
# 4. 安装二进制 + 用户
echo "[2/4] 安装到 ${INSTALL_PREFIX}"
id backupx >/dev/null 2>&1 || useradd --system --home-dir "$INSTALL_PREFIX" --shell /usr/sbin/nologin backupx
install -d -o backupx -g backupx "$INSTALL_PREFIX" /var/lib/backupx-agent
install -m 0755 "$TMPDIR/backupx-${AGENT_VERSION}-linux-${ARCH}/backupx" "$INSTALL_PREFIX/backupx"
{{end}}
{{if eq .Mode "systemd"}}
# 5. systemd unit
echo "[3/4] 配置 systemd"
cat > /etc/systemd/system/backupx-agent.service <<UNIT
[Unit]
Description=BackupX Agent
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=backupx
Environment="BACKUPX_AGENT_MASTER=${MASTER_URL}"
Environment="BACKUPX_AGENT_TOKEN=${AGENT_TOKEN}"
ExecStart=${INSTALL_PREFIX}/backupx agent --temp-dir /var/lib/backupx-agent
Restart=on-failure
RestartSec=10s
NoNewPrivileges=true
[Install]
WantedBy=multi-user.target
UNIT
systemctl daemon-reload
systemctl enable --now backupx-agent
# 6. 等待上线
echo "[4/4] 等待节点上线"
for i in $(seq 1 15); do
sleep 2
if curl -fsSL -H "X-Agent-Token: ${AGENT_TOKEN}" "${MASTER_URL}/api/v1/agent/self" 2>/dev/null \
| grep -q '"status":"online"'; then
echo "✓ 节点已上线"
exit 0
fi
done
echo "⚠ 30s 内未收到上线心跳,请检查防火墙或 journalctl -u backupx-agent"
exit 2
{{end}}
{{if eq .Mode "foreground"}}
# 5. 前台运行
echo "[3/3] 前台启动 agentCtrl+C 退出)"
export BACKUPX_AGENT_MASTER="${MASTER_URL}"
export BACKUPX_AGENT_TOKEN="${AGENT_TOKEN}"
exec "${INSTALL_PREFIX}/backupx" agent --temp-dir /var/lib/backupx-agent
{{end}}
{{if eq .Mode "docker"}}
# Docker 模式:直接用镜像启动容器
echo "[1/2] 拉取镜像 awuqing/backupx:${AGENT_VERSION}"
docker pull "awuqing/backupx:${AGENT_VERSION}"
echo "[2/2] 启动容器 backupx-agent"
docker rm -f backupx-agent >/dev/null 2>&1 || true
docker run -d --name backupx-agent --restart=unless-stopped \
-e "BACKUPX_AGENT_MASTER=${MASTER_URL}" \
-e "BACKUPX_AGENT_TOKEN=${AGENT_TOKEN}" \
-v /var/lib/backupx-agent:/tmp/backupx-agent \
"awuqing/backupx:${AGENT_VERSION}" agent
echo "✓ 容器已启动"
{{end}}

View File

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

View File

@@ -19,10 +19,12 @@ type Node struct {
IsLocal bool `gorm:"not null;default:false" json:"isLocal"`
OS string `gorm:"size:64" json:"os"`
Arch string `gorm:"size:32" json:"arch"`
AgentVer string `gorm:"column:agent_version;size:32" json:"agentVersion"`
LastSeen time.Time `gorm:"column:last_seen" json:"lastSeen"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
AgentVer string `gorm:"column:agent_version;size:32" json:"agentVersion"`
LastSeen time.Time `gorm:"column:last_seen" json:"lastSeen"`
PrevToken string `gorm:"size:128;index" json:"-"`
PrevTokenExpires *time.Time `gorm:"column:prev_token_expires" json:"-"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (Node) TableName() string {

View File

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

View File

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

View File

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

View File

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

View File

@@ -346,3 +346,24 @@ func (s *AgentService) StartCommandTimeoutMonitor(ctx context.Context, interval
}
}()
}
// AgentSelfStatus 是 /api/v1/agent/self 端点返回给 Agent 的轻量状态摘要。
type AgentSelfStatus struct {
ID uint `json:"id"`
Name string `json:"name"`
Status string `json:"status"`
LastSeen time.Time `json:"lastSeen"`
}
// SelfStatus 返回 Agent token 所属节点的当前状态,供安装脚本末尾探活。
func (s *AgentService) SelfStatus(ctx context.Context, node *model.Node) (*AgentSelfStatus, error) {
if node == nil {
return nil, apperror.Unauthorized("NODE_INVALID_TOKEN", "节点不存在", nil)
}
return &AgentSelfStatus{
ID: node.ID,
Name: node.Name,
Status: node.Status,
LastSeen: node.LastSeen,
}, nil
}

View File

@@ -0,0 +1,189 @@
package service
import (
"context"
"crypto/rand"
"encoding/hex"
"fmt"
"strings"
"time"
"backupx/server/internal/apperror"
"backupx/server/internal/model"
"backupx/server/internal/repository"
)
// InstallTokenService 负责一次性安装令牌的创建/消费/校验。
type InstallTokenService struct {
repo repository.AgentInstallTokenRepository
nodeRepo repository.NodeRepository
}
func NewInstallTokenService(repo repository.AgentInstallTokenRepository, nodeRepo repository.NodeRepository) *InstallTokenService {
return &InstallTokenService{repo: repo, nodeRepo: nodeRepo}
}
// InstallTokenInput 生成一次性安装令牌的输入。
type InstallTokenInput struct {
NodeID uint
Mode string
Arch string
AgentVersion string
DownloadSrc string
TTLSeconds int
CreatedByID uint
}
// InstallTokenOutput 生成结果。
type InstallTokenOutput struct {
Token string
ExpiresAt time.Time
Node *model.Node
Record *model.AgentInstallToken
}
// ConsumedInstallToken 消费成功后返回给 handler 的组合体。
type ConsumedInstallToken struct {
Record *model.AgentInstallToken
Node *model.Node
}
// 校验与限流常量。
const (
InstallTokenMinTTL = 300 // 5 分钟
InstallTokenMaxTTL = 86400 // 24 小时
InstallTokenRateWindow = 60 * time.Second
InstallTokenRatePerWin = 5
)
var (
validInstallModes = map[string]bool{model.InstallModeSystemd: true, model.InstallModeDocker: true, model.InstallModeForeground: true}
validInstallArches = map[string]bool{model.InstallArchAmd64: true, model.InstallArchArm64: true, model.InstallArchAuto: true}
validInstallSources = map[string]bool{model.InstallSourceGitHub: true, model.InstallSourceGhproxy: true}
)
// Create 生成一次性安装令牌。
func (s *InstallTokenService) Create(ctx context.Context, in InstallTokenInput) (*InstallTokenOutput, error) {
if err := s.validate(in); err != nil {
return nil, err
}
node, err := s.nodeRepo.FindByID(ctx, in.NodeID)
if err != nil {
return nil, err
}
if node == nil {
return nil, apperror.New(404, "NODE_NOT_FOUND", "节点不存在", nil)
}
since := time.Now().UTC().Add(-InstallTokenRateWindow)
count, err := s.repo.CountCreatedSince(ctx, in.NodeID, since)
if err != nil {
return nil, err
}
if count >= InstallTokenRatePerWin {
return nil, apperror.TooManyRequests("INSTALL_TOKEN_RATE_LIMITED",
fmt.Sprintf("每 %d 秒最多生成 %d 次", int(InstallTokenRateWindow.Seconds()), InstallTokenRatePerWin), nil)
}
token, err := generateInstallToken()
if err != nil {
return nil, fmt.Errorf("generate token: %w", err)
}
expiresAt := time.Now().UTC().Add(time.Duration(in.TTLSeconds) * time.Second)
record := &model.AgentInstallToken{
Token: token,
NodeID: in.NodeID,
Mode: in.Mode,
Arch: in.Arch,
AgentVer: in.AgentVersion,
DownloadSrc: in.DownloadSrc,
ExpiresAt: expiresAt,
CreatedByID: in.CreatedByID,
}
if err := s.repo.Create(ctx, record); err != nil {
return nil, err
}
return &InstallTokenOutput{Token: token, ExpiresAt: expiresAt, Node: node, Record: record}, nil
}
// Consume 原子消费令牌。未命中/已过期/已消费均返回 (nil, nil)。
func (s *InstallTokenService) Consume(ctx context.Context, token string) (*ConsumedInstallToken, error) {
if strings.TrimSpace(token) == "" {
return nil, nil
}
record, err := s.repo.ConsumeByToken(ctx, token)
if err != nil {
return nil, err
}
if record == nil {
return nil, nil
}
node, err := s.nodeRepo.FindByID(ctx, record.NodeID)
if err != nil {
return nil, err
}
if node == nil {
return nil, apperror.New(404, "NODE_NOT_FOUND", "节点已被删除", nil)
}
return &ConsumedInstallToken{Record: record, Node: node}, nil
}
// Peek 只读查询(不消费)且仅返回有效 token未消费、未过期供 compose 端点预检 Mode。
// 对已过期/已消费的 token 返回 (nil, nil),与 Consume 语义保持一致,
// 避免 compose handler 误放行"僵尸 token"造成后续 Consume 必然失败的迷惑链路。
func (s *InstallTokenService) Peek(ctx context.Context, token string) (*model.AgentInstallToken, error) {
if strings.TrimSpace(token) == "" {
return nil, nil
}
return s.repo.FindValidByToken(ctx, token)
}
// StartGC 启动后台 GC按 interval 扫描并删 ExpiresAt < now-7d 的记录。
func (s *InstallTokenService) StartGC(ctx context.Context, interval time.Duration) {
if interval <= 0 {
interval = time.Hour
}
go func() {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
_, _ = s.repo.DeleteExpiredBefore(ctx, time.Now().UTC().Add(-7*24*time.Hour))
}
}
}()
}
func (s *InstallTokenService) validate(in InstallTokenInput) error {
if in.NodeID == 0 {
return apperror.BadRequest("INSTALL_TOKEN_INVALID", "nodeId 必填", nil)
}
if !validInstallModes[in.Mode] {
return apperror.BadRequest("INSTALL_TOKEN_INVALID", "mode 非法", nil)
}
if !validInstallArches[in.Arch] {
return apperror.BadRequest("INSTALL_TOKEN_INVALID", "arch 非法", nil)
}
if !validInstallSources[in.DownloadSrc] {
return apperror.BadRequest("INSTALL_TOKEN_INVALID", "downloadSrc 非法", nil)
}
if strings.TrimSpace(in.AgentVersion) == "" {
return apperror.BadRequest("INSTALL_TOKEN_INVALID", "agentVersion 必填", nil)
}
if in.TTLSeconds < InstallTokenMinTTL || in.TTLSeconds > InstallTokenMaxTTL {
return apperror.BadRequest("INSTALL_TOKEN_INVALID",
fmt.Sprintf("ttlSeconds 需在 %d-%d", InstallTokenMinTTL, InstallTokenMaxTTL), nil)
}
return nil
}
func generateInstallToken() (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}

View File

@@ -0,0 +1,156 @@
package service
import (
"context"
"path/filepath"
"testing"
"time"
"backupx/server/internal/model"
"backupx/server/internal/repository"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
gormlogger "gorm.io/gorm/logger"
)
func openInstallTokenTestDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "it.db")),
&gorm.Config{Logger: gormlogger.Default.LogMode(gormlogger.Silent)})
if err != nil {
t.Fatalf("open: %v", err)
}
if err := db.AutoMigrate(&model.AgentInstallToken{}, &model.Node{}); err != nil {
t.Fatalf("migrate: %v", err)
}
return db
}
func TestInstallTokenServiceCreateAndConsume(t *testing.T) {
db := openInstallTokenTestDB(t)
repo := repository.NewAgentInstallTokenRepository(db)
nodeRepo := repository.NewNodeRepository(db)
node := &model.Node{Name: "n1", Token: "agent-token"}
if err := nodeRepo.Create(context.Background(), node); err != nil {
t.Fatalf("create node: %v", err)
}
svc := NewInstallTokenService(repo, nodeRepo)
created, err := svc.Create(context.Background(), InstallTokenInput{
NodeID: node.ID,
Mode: model.InstallModeSystemd,
Arch: model.InstallArchAuto,
AgentVersion: "v1.7.0",
DownloadSrc: model.InstallSourceGitHub,
TTLSeconds: 900,
CreatedByID: 1,
})
if err != nil {
t.Fatalf("create: %v", err)
}
if created.Token == "" || created.ExpiresAt.Before(time.Now().UTC()) {
t.Fatalf("invalid token: %+v", created)
}
consumed, err := svc.Consume(context.Background(), created.Token)
if err != nil {
t.Fatalf("consume: %v", err)
}
if consumed == nil || consumed.Node.ID != node.ID {
t.Fatalf("expected consumed token for node, got %+v", consumed)
}
again, err := svc.Consume(context.Background(), created.Token)
if err != nil {
t.Fatalf("second consume err: %v", err)
}
if again != nil {
t.Fatalf("expected nil on second consume")
}
}
func TestInstallTokenServicePeekDoesNotConsume(t *testing.T) {
db := openInstallTokenTestDB(t)
repo := repository.NewAgentInstallTokenRepository(db)
nodeRepo := repository.NewNodeRepository(db)
node := &model.Node{Name: "n2", Token: "tok2"}
_ = nodeRepo.Create(context.Background(), node)
svc := NewInstallTokenService(repo, nodeRepo)
out, err := svc.Create(context.Background(), InstallTokenInput{
NodeID: node.ID, Mode: "docker", Arch: "auto",
AgentVersion: "v1", DownloadSrc: "github", TTLSeconds: 300, CreatedByID: 1,
})
if err != nil {
t.Fatalf("create: %v", err)
}
// Peek 两次都应成功(不消费)
for i := 0; i < 2; i++ {
rec, err := svc.Peek(context.Background(), out.Token)
if err != nil {
t.Fatalf("peek %d: %v", i, err)
}
if rec == nil || rec.Mode != "docker" {
t.Fatalf("peek %d bad: %+v", i, rec)
}
}
// 之后仍可消费
consumed, _ := svc.Consume(context.Background(), out.Token)
if consumed == nil {
t.Fatalf("consume after peek failed")
}
}
func TestInstallTokenServiceValidatesInput(t *testing.T) {
db := openInstallTokenTestDB(t)
nodeRepo := repository.NewNodeRepository(db)
node := &model.Node{Name: "valid", Token: "t"}
_ = nodeRepo.Create(context.Background(), node)
svc := NewInstallTokenService(repository.NewAgentInstallTokenRepository(db), nodeRepo)
cases := []struct {
name string
in InstallTokenInput
}{
{"bad mode", InstallTokenInput{NodeID: node.ID, Mode: "xxx", Arch: "auto", AgentVersion: "v1", DownloadSrc: "github", TTLSeconds: 300, CreatedByID: 1}},
{"bad arch", InstallTokenInput{NodeID: node.ID, Mode: "systemd", Arch: "risc", AgentVersion: "v1", DownloadSrc: "github", TTLSeconds: 300, CreatedByID: 1}},
{"bad source", InstallTokenInput{NodeID: node.ID, Mode: "systemd", Arch: "auto", AgentVersion: "v1", DownloadSrc: "bogus", TTLSeconds: 300, CreatedByID: 1}},
{"bad ttl low", InstallTokenInput{NodeID: node.ID, Mode: "systemd", Arch: "auto", AgentVersion: "v1", DownloadSrc: "github", TTLSeconds: 10, CreatedByID: 1}},
{"bad ttl high", InstallTokenInput{NodeID: node.ID, Mode: "systemd", Arch: "auto", AgentVersion: "v1", DownloadSrc: "github", TTLSeconds: 999999, CreatedByID: 1}},
{"missing version", InstallTokenInput{NodeID: node.ID, Mode: "systemd", Arch: "auto", AgentVersion: "", DownloadSrc: "github", TTLSeconds: 300, CreatedByID: 1}},
{"missing node id", InstallTokenInput{NodeID: 0, Mode: "systemd", Arch: "auto", AgentVersion: "v1", DownloadSrc: "github", TTLSeconds: 300, CreatedByID: 1}},
{"node not exists", InstallTokenInput{NodeID: 999, Mode: "systemd", Arch: "auto", AgentVersion: "v1", DownloadSrc: "github", TTLSeconds: 300, CreatedByID: 1}},
}
for _, tc := range cases {
if _, err := svc.Create(context.Background(), tc.in); err == nil {
t.Errorf("%s: expected validation error", tc.name)
}
}
}
func TestInstallTokenServiceRateLimit(t *testing.T) {
db := openInstallTokenTestDB(t)
nodeRepo := repository.NewNodeRepository(db)
node := &model.Node{Name: "rl", Token: "rl"}
_ = nodeRepo.Create(context.Background(), node)
svc := NewInstallTokenService(repository.NewAgentInstallTokenRepository(db), nodeRepo)
base := InstallTokenInput{
NodeID: node.ID, Mode: "systemd", Arch: "auto",
AgentVersion: "v1", DownloadSrc: "github", TTLSeconds: 300, CreatedByID: 1,
}
// 前 5 次成功
for i := 0; i < 5; i++ {
if _, err := svc.Create(context.Background(), base); err != nil {
t.Fatalf("iter %d: %v", i, err)
}
}
// 第 6 次应被限流
_, err := svc.Create(context.Background(), base)
if err == nil {
t.Fatalf("expected rate limit error")
}
}

View File

@@ -373,6 +373,121 @@ func detectLocalIP() string {
return ""
}
// NodeCreateResult 批量创建结果。注意:不暴露 agent tokentoken 获取走 install-token 流程。
type NodeCreateResult struct {
ID uint `json:"id"`
Name string `json:"name"`
}
// BatchCreate 批量创建远程节点。
// 校验1-50 项、每项 1-128 字符、批次内去重、与已有节点名去重。
// 返回 NodeCreateResult 列表(不含 token调用方应再调 install-tokens 接口)。
func (s *NodeService) BatchCreate(ctx context.Context, names []string) ([]NodeCreateResult, error) {
cleaned, err := validateBatchNames(names)
if err != nil {
return nil, err
}
existing, err := s.repo.List(ctx)
if err != nil {
return nil, err
}
existingSet := make(map[string]bool, len(existing))
for _, n := range existing {
existingSet[n.Name] = true
}
for _, name := range cleaned {
if existingSet[name] {
return nil, apperror.BadRequest("NODE_DUPLICATE_NAME",
fmt.Sprintf("节点名「%s」已存在", name), nil)
}
}
// 预先构造所有 Nodetoken 生成在事务外完成(纯内存操作,失败不会影响 DB 状态)
nodes := make([]*model.Node, 0, len(cleaned))
now := time.Now().UTC()
for _, name := range cleaned {
tok, err := generateToken()
if err != nil {
return nil, fmt.Errorf("generate token: %w", err)
}
nodes = append(nodes, &model.Node{
Name: name,
Token: tok,
Status: model.NodeStatusOffline,
IsLocal: false,
LastSeen: now,
})
}
// 事务内批量创建:任一失败整体回滚
if err := s.repo.BatchCreate(ctx, nodes); err != nil {
return nil, err
}
results := make([]NodeCreateResult, 0, len(nodes))
for _, n := range nodes {
results = append(results, NodeCreateResult{ID: n.ID, Name: n.Name})
}
return results, nil
}
// RotateToken 轮换指定节点的 agent token。
// 旧 token 复制到 prev_token24h 内新旧 token 均可认证。
func (s *NodeService) RotateToken(ctx context.Context, id uint) (string, error) {
node, err := s.repo.FindByID(ctx, id)
if err != nil {
return "", err
}
if node == nil {
return "", apperror.New(http.StatusNotFound, "NODE_NOT_FOUND", "节点不存在", nil)
}
if node.IsLocal {
return "", apperror.BadRequest("NODE_ROTATE_LOCAL", "本机节点无需轮换 Token", nil)
}
newTok, err := generateToken()
if err != nil {
return "", fmt.Errorf("generate: %w", err)
}
expires := time.Now().UTC().Add(24 * time.Hour)
node.PrevToken = node.Token
node.PrevTokenExpires = &expires
node.Token = newTok
if err := s.repo.Update(ctx, node); err != nil {
return "", err
}
return newTok, nil
}
// validateBatchNames 校验并去重批次内名称(空白行忽略)。
func validateBatchNames(names []string) ([]string, error) {
if len(names) == 0 {
return nil, apperror.BadRequest("NODE_BATCH_EMPTY", "节点名列表不能为空", nil)
}
if len(names) > 50 {
return nil, apperror.BadRequest("NODE_BATCH_TOO_MANY", "单次最多创建 50 个节点", nil)
}
seen := make(map[string]bool, len(names))
out := make([]string, 0, len(names))
for _, raw := range names {
name := strings.TrimSpace(raw)
if name == "" {
continue
}
if len(name) > 128 {
return nil, apperror.BadRequest("NODE_NAME_TOO_LONG",
fmt.Sprintf("节点名「%s」超过 128 字符", name), nil)
}
if seen[name] {
return nil, apperror.BadRequest("NODE_DUPLICATE_NAME",
fmt.Sprintf("批次内重复节点名「%s」", name), nil)
}
seen[name] = true
out = append(out, name)
}
if len(out) == 0 {
return nil, apperror.BadRequest("NODE_BATCH_EMPTY", "去除空白后列表为空", nil)
}
return out, nil
}
func generateToken() (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {

View File

@@ -0,0 +1,159 @@
package service
import (
"context"
"path/filepath"
"testing"
"time"
"backupx/server/internal/model"
"backupx/server/internal/repository"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
gormlogger "gorm.io/gorm/logger"
)
func openNodeServiceDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "ns.db")),
&gorm.Config{Logger: gormlogger.Default.LogMode(gormlogger.Silent)})
if err != nil {
t.Fatalf("open: %v", err)
}
if err := db.AutoMigrate(&model.Node{}); err != nil {
t.Fatalf("migrate: %v", err)
}
return db
}
func TestBatchCreateNodes(t *testing.T) {
db := openNodeServiceDB(t)
svc := NewNodeService(repository.NewNodeRepository(db), "test")
ctx := context.Background()
items, err := svc.BatchCreate(ctx, []string{"a", "b", "c"})
if err != nil {
t.Fatalf("batch: %v", err)
}
if len(items) != 3 {
t.Fatalf("expected 3, got %d", len(items))
}
for _, it := range items {
if it.ID == 0 || it.Name == "" {
t.Errorf("invalid item %+v", it)
}
}
}
func TestBatchCreateRejectsDuplicatesAgainstDB(t *testing.T) {
db := openNodeServiceDB(t)
svc := NewNodeService(repository.NewNodeRepository(db), "test")
ctx := context.Background()
if _, err := svc.Create(ctx, NodeCreateInput{Name: "a"}); err != nil {
t.Fatalf("create: %v", err)
}
_, err := svc.BatchCreate(ctx, []string{"a", "b"})
if err == nil {
t.Fatalf("expected error on duplicate with existing")
}
}
func TestBatchCreateRejectsIntraBatchDuplicates(t *testing.T) {
db := openNodeServiceDB(t)
svc := NewNodeService(repository.NewNodeRepository(db), "test")
_, err := svc.BatchCreate(context.Background(), []string{"x", "x"})
if err == nil {
t.Fatalf("expected error on intra-batch duplicate")
}
}
func TestBatchCreateLimitEnforced(t *testing.T) {
db := openNodeServiceDB(t)
svc := NewNodeService(repository.NewNodeRepository(db), "test")
names := make([]string, 51)
for i := range names {
names[i] = "n" + string(rune('A'+i))
}
_, err := svc.BatchCreate(context.Background(), names)
if err == nil {
t.Fatalf("expected error on >50 batch")
}
}
func TestBatchCreateSkipsEmptyLines(t *testing.T) {
db := openNodeServiceDB(t)
svc := NewNodeService(repository.NewNodeRepository(db), "test")
items, err := svc.BatchCreate(context.Background(), []string{"a", " ", "", "b"})
if err != nil {
t.Fatalf("batch: %v", err)
}
if len(items) != 2 {
t.Fatalf("expected 2 (a,b), got %d", len(items))
}
}
func TestRotateToken(t *testing.T) {
db := openNodeServiceDB(t)
repo := repository.NewNodeRepository(db)
svc := NewNodeService(repo, "test")
ctx := context.Background()
_, err := svc.Create(ctx, NodeCreateInput{Name: "rot"})
if err != nil {
t.Fatalf("create: %v", err)
}
var node model.Node
db.First(&node, "name = ?", "rot")
oldTok := node.Token
newTok, err := svc.RotateToken(ctx, node.ID)
if err != nil {
t.Fatalf("rotate: %v", err)
}
if newTok == oldTok || len(newTok) != 64 {
t.Fatalf("invalid new token: %s", newTok)
}
// 旧 token 仍可查24h 内)
found, _ := repo.FindByToken(ctx, oldTok)
if found == nil || found.ID != node.ID {
t.Fatalf("old token should still work via prev_token fallback")
}
found2, _ := repo.FindByToken(ctx, newTok)
if found2 == nil || found2.ID != node.ID {
t.Fatalf("new token should work")
}
db.First(&node, node.ID)
if node.PrevTokenExpires == nil {
t.Fatalf("prev_token_expires not set")
}
diff := node.PrevTokenExpires.Sub(time.Now().UTC())
if diff < 23*time.Hour || diff > 25*time.Hour {
t.Fatalf("prev_token_expires out of range: %v", diff)
}
}
func TestRotateTokenRejectsLocal(t *testing.T) {
db := openNodeServiceDB(t)
repo := repository.NewNodeRepository(db)
svc := NewNodeService(repo, "test")
ctx := context.Background()
if err := svc.EnsureLocalNode(ctx); err != nil {
t.Fatalf("ensure local: %v", err)
}
local, _ := repo.FindLocal(ctx)
if _, err := svc.RotateToken(ctx, local.ID); err == nil {
t.Fatalf("expected error rotating local node")
}
}
func TestRotateTokenNotFound(t *testing.T) {
db := openNodeServiceDB(t)
svc := NewNodeService(repository.NewNodeRepository(db), "test")
if _, err := svc.RotateToken(context.Background(), 9999); err == nil {
t.Fatalf("expected not found error")
}
}

View File

@@ -0,0 +1,301 @@
import React, { useEffect, useRef, useState } from 'react'
import { Modal, Steps, Button, Space, Message, Spin, Progress } from '@arco-design/web-react'
import { Step1NodeName, type Mode } from './wizard/Step1NodeName'
import { Step2DeployOptions, type DeployOptions } from './wizard/Step2DeployOptions'
import { Step3CommandPreview } from './wizard/Step3CommandPreview'
import { BatchCommandTable, type BatchCommandRow } from './BatchCommandTable'
import { batchCreateNodes, createInstallToken } from '../../services/nodes'
import type { InstallTokenResult } from '../../types/nodes'
const Step = Steps.Step
interface Props {
visible: boolean
onClose: () => void
onSuccess: () => void
// null = 正在拉取;空字符串 = 拉取失败Step2 将展示手动输入框而非 Select
masterVersion: string | null
// 当从节点列表直接点"生成安装命令"时传入,跳过 Step1
fixedNode?: { id: number; name: string }
}
export function AgentInstallWizard({ visible, onClose, onSuccess, masterVersion, fixedNode }: Props) {
const [step, setStep] = useState(fixedNode ? 1 : 0)
const [mode, setMode] = useState<Mode>('single')
const [singleName, setSingleName] = useState('')
const [batchText, setBatchText] = useState('')
// 批量进度(已生成 / 总数)
const [batchProgress, setBatchProgress] = useState<{ done: number; total: number } | null>(null)
const [deploy, setDeploy] = useState<DeployOptions>({
mode: 'systemd',
arch: 'auto',
agentVersion: masterVersion || '',
downloadSrc: 'github',
ttlSeconds: 900,
})
// 当父组件异步拿到 masterVersion 后,同步到 deploy.agentVersion仅初始为空时
useEffect(() => {
if (masterVersion && !deploy.agentVersion) {
setDeploy((prev) => ({ ...prev, agentVersion: masterVersion }))
}
}, [masterVersion]) // eslint-disable-line react-hooks/exhaustive-deps
// unmount 保护:用户关 Modal / 切页时,已发出的请求完成后不再更新 state
const mountedRef = useRef(true)
useEffect(() => {
mountedRef.current = true
return () => {
mountedRef.current = false
}
}, [])
const [singleToken, setSingleToken] = useState<InstallTokenResult | null>(null)
const [singleNodeInfo, setSingleNodeInfo] = useState<{ id: number; name: string } | null>(null)
const [batchRows, setBatchRows] = useState<BatchCommandRow[]>([])
const [submitting, setSubmitting] = useState(false)
const reset = () => {
setStep(fixedNode ? 1 : 0)
setMode('single')
setSingleName('')
setBatchText('')
setSingleToken(null)
setSingleNodeInfo(null)
setBatchRows([])
setBatchProgress(null)
}
const handleClose = () => {
reset()
onClose()
}
const parseBatchNames = (): string[] =>
batchText.split('\n').map((s) => s.trim()).filter(Boolean)
const handleNextFromStep1 = () => {
if (mode === 'single') {
if (!singleName.trim()) {
Message.warning('请输入节点名称')
return
}
} else {
const names = parseBatchNames()
if (names.length === 0) {
Message.warning('请至少输入一个节点名称')
return
}
if (names.length > 50) {
Message.warning('单次最多创建 50 个节点')
return
}
}
setStep(1)
}
const handleGenerate = async () => {
if (!deploy.agentVersion.trim()) {
Message.warning('请填写 Agent 版本号(形如 v1.7.0')
return
}
// 步骤 1 的批次内去重在前端先提示一次,再由后端最终校验
if (mode === 'batch' && !fixedNode) {
const names = parseBatchNames()
const seen = new Set<string>()
const dups: string[] = []
for (const n of names) {
if (seen.has(n)) dups.push(n)
seen.add(n)
}
if (dups.length > 0) {
Message.warning(`批次内有重复节点名:${Array.from(new Set(dups)).join(', ')}`)
return
}
}
setSubmitting(true)
try {
if (fixedNode) {
const tok = await createInstallToken(fixedNode.id, {
mode: deploy.mode,
arch: deploy.arch,
agentVersion: deploy.agentVersion,
downloadSrc: deploy.downloadSrc,
ttlSeconds: deploy.ttlSeconds,
})
setSingleNodeInfo(fixedNode)
setSingleToken(tok)
} else if (mode === 'single') {
const created = await batchCreateNodes([singleName.trim()])
const one = created[0]
const tok = await createInstallToken(one.id, {
mode: deploy.mode,
arch: deploy.arch,
agentVersion: deploy.agentVersion,
downloadSrc: deploy.downloadSrc,
ttlSeconds: deploy.ttlSeconds,
})
setSingleNodeInfo({ id: one.id, name: one.name })
setSingleToken(tok)
} else {
const names = parseBatchNames()
const created = await batchCreateNodes(names)
setBatchProgress({ done: 0, total: created.length })
// 并发生成 install tokenPromise.all每完成一个递增 done 计数
let done = 0
const tokens = await Promise.all(
created.map(async (c) => {
const tok = await createInstallToken(c.id, {
mode: deploy.mode,
arch: deploy.arch,
agentVersion: deploy.agentVersion,
downloadSrc: deploy.downloadSrc,
ttlSeconds: deploy.ttlSeconds,
})
done += 1
if (mountedRef.current) setBatchProgress({ done, total: created.length })
return { c, tok }
}),
)
const rows: BatchCommandRow[] = tokens.map(({ c, tok }) => ({
nodeId: c.id,
nodeName: c.name,
command: `curl -fsSL ${tok.url} | sudo sh`,
expiresAt: tok.expiresAt,
}))
if (mountedRef.current) setBatchRows(rows)
}
setStep(2)
onSuccess()
} catch (e: any) {
Message.error(e?.message || '操作失败')
} finally {
setSubmitting(false)
}
}
const regenerateSingle = async () => {
if (!singleNodeInfo) return
setSubmitting(true)
try {
const tok = await createInstallToken(singleNodeInfo.id, {
mode: deploy.mode,
arch: deploy.arch,
agentVersion: deploy.agentVersion,
downloadSrc: deploy.downloadSrc,
ttlSeconds: deploy.ttlSeconds,
})
setSingleToken(tok)
} catch (e: any) {
Message.error(e?.message || '重新生成失败')
} finally {
setSubmitting(false)
}
}
const previewParams = {
mode: deploy.mode,
arch: deploy.arch,
agentVersion: deploy.agentVersion,
downloadSrc: deploy.downloadSrc,
}
// fixedNode 路径下步骤只有 2 步(部署参数 + 安装命令step 值从 1 开始,
// 需要映射到 Steps current0-based
const stepsCurrent = fixedNode ? step - 1 : step
return (
<Modal
title={fixedNode ? `为「${fixedNode.name}」生成安装命令` : '添加节点'}
visible={visible}
onCancel={handleClose}
footer={null}
style={{ width: 760 }}
unmountOnExit
>
<Steps current={stepsCurrent} size="small" style={{ marginBottom: 24 }}>
{!fixedNode && <Step title="节点信息" />}
<Step title="部署参数" />
<Step title="安装命令" />
</Steps>
{submitting && (
<div style={{ textAlign: 'center', padding: 32 }}>
<Spin />
{batchProgress && (
<div style={{ marginTop: 16, maxWidth: 360, marginLeft: 'auto', marginRight: 'auto' }}>
<div style={{ fontSize: 13, marginBottom: 6 }}>
{batchProgress.done} / {batchProgress.total}
</div>
<Progress
percent={Math.round((batchProgress.done / batchProgress.total) * 100)}
showText
/>
</div>
)}
</div>
)}
{!submitting && step === 0 && (
<>
<Step1NodeName
mode={mode}
onModeChange={setMode}
singleName={singleName}
onSingleNameChange={setSingleName}
batchText={batchText}
onBatchTextChange={setBatchText}
/>
<div style={{ marginTop: 24, textAlign: 'right' }}>
<Space>
<Button onClick={handleClose}></Button>
<Button type="primary" onClick={handleNextFromStep1}>
</Button>
</Space>
</div>
</>
)}
{!submitting && step === 1 && (
<>
<Step2DeployOptions masterVersion={masterVersion} value={deploy} onChange={setDeploy} />
<div style={{ marginTop: 24, textAlign: 'right' }}>
<Space>
{!fixedNode && (
<Button onClick={() => setStep(0)}></Button>
)}
<Button onClick={handleClose}></Button>
<Button type="primary" onClick={handleGenerate} loading={submitting}>
</Button>
</Space>
</div>
</>
)}
{!submitting && step === 2 && (
<>
{singleToken && singleNodeInfo && (
<Step3CommandPreview
nodeId={singleNodeInfo.id}
nodeName={singleNodeInfo.name}
token={singleToken}
mode={deploy.mode}
previewParams={previewParams}
onRegenerate={regenerateSingle}
/>
)}
{batchRows.length > 0 && <BatchCommandTable rows={batchRows} />}
<div style={{ marginTop: 24, textAlign: 'right' }}>
<Button type="primary" onClick={handleClose}>
</Button>
</div>
</>
)}
</Modal>
)
}

View File

@@ -0,0 +1,108 @@
import React, { useEffect, useState } from 'react'
import { Table, Button, Space, Message, Typography } from '@arco-design/web-react'
import { IconCopy, IconDownload } from '@arco-design/web-react/icon'
const { Text } = Typography
export interface BatchCommandRow {
nodeId: number
nodeName: string
command: string
expiresAt: string
}
interface Props {
rows: BatchCommandRow[]
}
export function BatchCommandTable({ rows }: Props) {
const [remaining, setRemaining] = useState<Record<number, number>>({})
useEffect(() => {
const tick = () => {
const next: Record<number, number> = {}
rows.forEach((r) => {
const exp = new Date(r.expiresAt).getTime()
next[r.nodeId] = Math.max(0, Math.floor((exp - Date.now()) / 1000))
})
setRemaining(next)
}
tick()
const id = setInterval(tick, 1000)
return () => clearInterval(id)
}, [rows])
const copy = async (s: string) => {
await navigator.clipboard.writeText(s)
Message.success('已复制')
}
const exportAll = () => {
const content = [
'#!/bin/sh',
'# BackupX Agent 批量部署脚本',
'# 使用方法:在目标机逐个执行下面对应节点命令',
'',
...rows.map((r) => `# --- ${r.nodeName} ---\n${r.command}`),
].join('\n\n')
const blob = new Blob([content], { type: 'text/x-shellscript' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `backupx-batch-install-${new Date().toISOString().slice(0, 10)}.sh`
a.click()
URL.revokeObjectURL(url)
}
return (
<div>
<Table
size="small"
pagination={false}
columns={[
{ title: '节点', dataIndex: 'nodeName', width: 140 },
{
title: '安装命令',
dataIndex: 'command',
render: (cmd: unknown, row: BatchCommandRow) => {
const left = remaining[row.nodeId] ?? 0
return (
<Text style={{
fontFamily: 'monospace', fontSize: 12, wordBreak: 'break-all',
opacity: left === 0 ? 0.4 : 1,
}}>
{cmd as string}
</Text>
)
},
},
{
title: '剩余', dataIndex: 'expiresAt', width: 90,
render: (_v: unknown, row: BatchCommandRow) => {
const left = remaining[row.nodeId] ?? 0
return (
<Text type={left === 0 ? 'secondary' : 'primary'} style={{ fontSize: 12 }}>
{left === 0 ? '已过期' : `${Math.floor(left / 60)}:${String(left % 60).padStart(2, '0')}`}
</Text>
)
},
},
{
title: '操作', width: 80,
render: (_v: unknown, row: BatchCommandRow) => (
<Button size="small" icon={<IconCopy />} onClick={() => copy(row.command)}
disabled={(remaining[row.nodeId] ?? 0) === 0}></Button>
),
},
]}
data={rows}
rowKey="nodeId"
/>
<div style={{ marginTop: 12, textAlign: 'right' }}>
<Space>
<Button icon={<IconDownload />} onClick={exportAll}> .sh</Button>
</Space>
</div>
</div>
)
}

View File

@@ -1,23 +1,27 @@
import React, { useEffect, useState, useCallback } from 'react'
import {
Table, Button, Space, Tag, Typography, PageHeader, Modal, Input, Message, Badge, Popconfirm, Card, Descriptions, Empty
Table, Button, Space, Tag, Typography, PageHeader, Modal, Input, Message, Badge, Popconfirm, Card,
Empty, Dropdown, Menu,
} from '@arco-design/web-react'
import {
IconPlus, IconDelete, IconDesktop, IconCloudDownload, IconEdit
IconPlus, IconDelete, IconDesktop, IconCloudDownload, IconEdit, IconMore,
} from '@arco-design/web-react/icon'
import type { NodeSummary } from '../../types/nodes'
import { listNodes, createNode, deleteNode, updateNode } from '../../services/nodes'
import { listNodes, deleteNode, updateNode, rotateNodeToken } from '../../services/nodes'
import { fetchSystemInfo } from '../../services/system'
import { AgentInstallWizard } from './AgentInstallWizard'
const { Title, Text } = Typography
const { Text } = Typography
export default function NodesPage() {
const [nodes, setNodes] = useState<NodeSummary[]>([])
const [loading, setLoading] = useState(false)
const [createVisible, setCreateVisible] = useState(false)
const [newNodeName, setNewNodeName] = useState('')
const [newToken, setNewToken] = useState('')
// 编辑状态
const [wizardVisible, setWizardVisible] = useState(false)
const [wizardFixedNode, setWizardFixedNode] = useState<{ id: number; name: string } | undefined>()
// null = 拉取中 / 未知;空字符串 = 拉取失败UI 将要求用户手动输入版本,避免生成无效 URL
const [masterVersion, setMasterVersion] = useState<string | null>(null)
const [editVisible, setEditVisible] = useState(false)
const [editNode, setEditNode] = useState<NodeSummary | null>(null)
const [editName, setEditName] = useState('')
@@ -34,22 +38,14 @@ export default function NodesPage() {
}
}, [])
useEffect(() => { fetchNodes() }, [fetchNodes])
const handleCreate = async () => {
if (!newNodeName.trim()) {
Message.warning('请输入节点名称')
return
}
try {
const result = await createNode(newNodeName.trim())
setNewToken(result.token)
Message.success('节点创建成功')
fetchNodes()
} catch {
Message.error('创建节点失败')
}
}
useEffect(() => {
fetchNodes()
// 取 Master 版本号作为 Wizard agentVersion 默认值。
// 拉取失败或字段缺失时置为空串Wizard 会提示用户手动输入。
fetchSystemInfo().then((info) => {
setMasterVersion(info?.version || '')
}).catch(() => setMasterVersion(''))
}, [fetchNodes])
const handleDelete = async (id: number) => {
try {
@@ -76,10 +72,30 @@ export default function NodesPage() {
}
}
const handleRotate = async (record: NodeSummary) => {
try {
const { newToken } = await rotateNodeToken(record.id)
Modal.success({
title: 'Token 已轮换',
content: (
<div>
<Text type="secondary" style={{ display: 'block', marginBottom: 8 }}>
Token24 Token 便
</Text>
<Text copyable style={{ fontFamily: 'monospace', fontSize: 12, wordBreak: 'break-all' }}>
{newToken}
</Text>
</div>
),
})
} catch {
Message.error('轮换 Token 失败')
}
}
const columns = [
{
title: '节点名称',
dataIndex: 'name',
title: '节点名称', dataIndex: 'name',
render: (name: string, record: NodeSummary) => (
<Space>
{record.isLocal ? <IconDesktop style={{ color: 'var(--color-primary-6)' }} /> : <IconCloudDownload />}
@@ -89,60 +105,48 @@ export default function NodesPage() {
),
},
{
title: '状态',
dataIndex: 'status',
width: 100,
render: (status: string) => {
if (status === 'online') return <Badge status="success" text="在线" />
return <Badge status="default" text="离线" />
},
title: '状态', dataIndex: 'status', width: 100,
render: (status: string) => status === 'online'
? <Badge status="success" text="在线" />
: <Badge status="default" text="离线" />,
},
{ title: '主机名', dataIndex: 'hostname', render: (v: string) => v || '-' },
{ title: 'IP 地址', dataIndex: 'ipAddress', render: (v: string) => v || '-' },
{
title: '主机名',
dataIndex: 'hostname',
render: (v: string) => v || '-',
title: '系统', dataIndex: 'os', width: 120,
render: (_: string, record: NodeSummary) => record.os
? <Tag bordered>{record.os}/{record.arch}</Tag> : '-',
},
{ title: 'Agent 版本', dataIndex: 'agentVersion', width: 100, render: (v: string) => v || '-' },
{
title: 'IP 地址',
dataIndex: 'ipAddress',
render: (v: string) => v || '-',
},
{
title: '系统',
dataIndex: 'os',
width: 120,
render: (_: string, record: NodeSummary) => {
if (!record.os) return '-'
return <Tag bordered>{record.os}/{record.arch}</Tag>
},
},
{
title: 'Agent 版本',
dataIndex: 'agentVersion',
width: 100,
render: (v: string) => v || '-',
},
{
title: '最后活跃',
dataIndex: 'lastSeen',
width: 170,
title: '最后活跃', dataIndex: 'lastSeen', width: 170,
render: (v: string) => v ? new Date(v).toLocaleString('zh-CN') : '-',
},
{
title: '操作',
width: 120,
title: '操作', width: 180,
render: (_: unknown, record: NodeSummary) => (
<Space>
<Button
type="text"
icon={<IconEdit />}
size="small"
onClick={() => { setEditNode(record); setEditName(record.name); setEditVisible(true) }}
/>
<Button type="text" icon={<IconEdit />} size="small"
onClick={() => { setEditNode(record); setEditName(record.name); setEditVisible(true) }} />
{!record.isLocal && (
<Popconfirm title="确定删除该节点?" onOk={() => handleDelete(record.id)}>
<Button type="text" status="danger" icon={<IconDelete />} size="small" />
</Popconfirm>
<>
<Dropdown trigger="click" droplist={(
<Menu>
<Menu.Item key="install"
onClick={() => { setWizardFixedNode({ id: record.id, name: record.name }); setWizardVisible(true) }}>
</Menu.Item>
<Menu.Item key="rotate" onClick={() => handleRotate(record)}>
Token
</Menu.Item>
</Menu>
)}>
<Button type="text" icon={<IconMore />} size="small" />
</Dropdown>
<Popconfirm title="确定删除该节点?" onOk={() => handleDelete(record.id)}>
<Button type="text" status="danger" icon={<IconDelete />} size="small" />
</Popconfirm>
</>
)}
</Space>
),
@@ -155,90 +159,33 @@ export default function NodesPage() {
title="节点管理"
subTitle="管理集群中的服务器节点"
extra={
<Button type="primary" icon={<IconPlus />} onClick={() => { setCreateVisible(true); setNewToken(''); setNewNodeName('') }}>
<Button type="primary" icon={<IconPlus />}
onClick={() => { setWizardFixedNode(undefined); setWizardVisible(true) }}>
</Button>
}
/>
<Card style={{ marginTop: 16 }}>
<Table
columns={columns}
data={nodes}
rowKey="id"
loading={loading}
pagination={false}
noDataElement={<Empty description="暂无节点数据,系统将自动创建本机节点" />}
/>
<Table columns={columns} data={nodes} rowKey="id" loading={loading} pagination={false}
noDataElement={<Empty description="暂无节点数据,系统将自动创建本机节点" />} />
</Card>
{/* 添加节点弹窗 */}
<Modal
title="添加远程节点"
visible={createVisible}
onCancel={() => setCreateVisible(false)}
style={{ width: 640 }}
footer={newToken ? (
<Button type="primary" onClick={() => setCreateVisible(false)}></Button>
) : undefined}
onOk={handleCreate}
okText="创建"
>
{!newToken ? (
<Input
placeholder="输入节点名称,如:生产服务器-A"
value={newNodeName}
onChange={setNewNodeName}
/>
) : (
<div>
<Descriptions column={1} border data={[
{ label: '节点名称', value: newNodeName },
{ label: '认证令牌', value: <Text copyable style={{ wordBreak: 'break-all', fontSize: 12, fontFamily: 'monospace' }}>{newToken}</Text> },
]} />
<div style={{ marginTop: 12, padding: '8px 12px', background: 'var(--color-fill-2)', borderRadius: 6 }}>
<Text type="secondary" style={{ fontSize: 12 }}>
Agent Master
</Text>
</div>
<div style={{ marginTop: 12 }}>
<Text bold style={{ fontSize: 13 }}>Agent </Text>
<ol style={{ fontSize: 12, color: 'var(--color-text-2)', paddingLeft: 20, marginTop: 8 }}>
<li> BackupX Master </li>
<li> Agent MASTER_URL</li>
</ol>
<div style={{ background: 'var(--color-fill-2)', padding: '8px 12px', borderRadius: 6, marginTop: 4 }}>
<Text copyable style={{ fontFamily: 'monospace', fontSize: 12, wordBreak: 'break-all' }}>
{`backupx agent --master ${window.location.origin} --token ${newToken}`}
</Text>
</div>
<Text type="secondary" style={{ fontSize: 12, display: 'block', marginTop: 8 }}>
使 / <br />
<code>BACKUPX_AGENT_MASTER={window.location.origin}</code><br />
<code>BACKUPX_AGENT_TOKEN={newToken}</code>
</Text>
</div>
</div>
)}
</Modal>
<AgentInstallWizard
visible={wizardVisible}
onClose={() => setWizardVisible(false)}
onSuccess={fetchNodes}
masterVersion={masterVersion}
fixedNode={wizardFixedNode}
/>
{/* 编辑节点弹窗 */}
<Modal
title="编辑节点"
visible={editVisible}
onCancel={() => setEditVisible(false)}
onOk={handleEdit}
okText="保存"
cancelText="取消"
>
<Modal title="编辑节点" visible={editVisible}
onCancel={() => setEditVisible(false)} onOk={handleEdit}
okText="保存" cancelText="取消">
<div style={{ marginBottom: 8 }}>
<Text type="secondary"></Text>
</div>
<Input
placeholder="输入节点名称"
value={editName}
onChange={setEditName}
/>
<Input placeholder="输入节点名称" value={editName} onChange={setEditName} />
</Modal>
</div>
)

View File

@@ -0,0 +1,60 @@
import { Radio, Input, Typography } from '@arco-design/web-react'
const { Text } = Typography
const TextArea = Input.TextArea
export type Mode = 'single' | 'batch'
interface Props {
mode: Mode
onModeChange: (m: Mode) => void
singleName: string
onSingleNameChange: (v: string) => void
batchText: string
onBatchTextChange: (v: string) => void
}
export function Step1NodeName({
mode, onModeChange, singleName, onSingleNameChange, batchText, onBatchTextChange,
}: Props) {
return (
<div>
<div style={{ marginBottom: 16 }}>
<Radio.Group
type="button"
value={mode}
onChange={(v) => onModeChange(v as Mode)}
options={[
{ label: '单节点', value: 'single' },
{ label: '批量创建', value: 'batch' },
]}
/>
</div>
{mode === 'single' ? (
<div>
<Text bold style={{ marginBottom: 6, display: 'block' }}></Text>
<Input
placeholder="如prod-db-01"
value={singleName}
onChange={onSingleNameChange}
maxLength={128}
/>
</div>
) : (
<div>
<Text bold style={{ marginBottom: 6, display: 'block' }}> 50 </Text>
<TextArea
rows={8}
placeholder={'prod-db-01\nprod-db-02\nprod-web-01'}
value={batchText}
onChange={onBatchTextChange}
style={{ fontFamily: 'monospace', fontSize: 13 }}
/>
<Text type="secondary" style={{ fontSize: 12, marginTop: 4, display: 'block' }}>
</Text>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,111 @@
import React from 'react'
import { Form, Radio, Select, Input, Typography } from '@arco-design/web-react'
import type { InstallMode, InstallArch, InstallSource } from '../../../types/nodes'
const { Text } = Typography
export interface DeployOptions {
mode: InstallMode
arch: InstallArch
agentVersion: string
downloadSrc: InstallSource
ttlSeconds: number
}
interface Props {
// null = 拉取中;空串 = 拉取失败(改为手动输入)
masterVersion: string | null
value: DeployOptions
onChange: (v: DeployOptions) => void
}
export function Step2DeployOptions({ masterVersion, value, onChange }: Props) {
const update = (patch: Partial<DeployOptions>) => onChange({ ...value, ...patch })
const versionKnown = !!masterVersion
const versionLoading = masterVersion === null
return (
<Form layout="vertical" size="default">
<Form.Item label="安装模式">
<Radio.Group
type="button"
value={value.mode}
onChange={(v) => update({ mode: v as InstallMode })}
options={[
{ label: 'systemd推荐', value: 'systemd' },
{ label: 'Docker', value: 'docker' },
{ label: '前台运行(调试)', value: 'foreground' },
]}
/>
</Form.Item>
<Form.Item label="架构">
<Select
value={value.arch}
onChange={(v) => update({ arch: v as InstallArch })}
options={[
{ label: '自动检测uname -m', value: 'auto' },
{ label: 'amd64 (x86_64)', value: 'amd64' },
{ label: 'arm64 (aarch64)', value: 'arm64' },
]}
/>
</Form.Item>
<Form.Item
label="Agent 版本"
extra={
!versionKnown && !versionLoading ? (
<Text type="warning" style={{ fontSize: 12 }}>
Master v1.7.0
</Text>
) : undefined
}
>
{versionKnown ? (
<Select
value={value.agentVersion}
onChange={(v) => update({ agentVersion: v })}
options={[
{ label: `${masterVersion}(跟随 Master推荐`, value: masterVersion as string },
]}
/>
) : (
<Input
placeholder={versionLoading ? '加载中...' : 'v1.7.0'}
value={value.agentVersion}
onChange={(v) => update({ agentVersion: v })}
disabled={versionLoading}
/>
)}
</Form.Item>
<Form.Item label="安装命令有效期">
<Select
value={value.ttlSeconds}
onChange={(v) => update({ ttlSeconds: v as number })}
options={[
{ label: '5 分钟', value: 300 },
{ label: '15 分钟(推荐)', value: 900 },
{ label: '1 小时', value: 3600 },
{ label: '24 小时', value: 86400 },
]}
/>
</Form.Item>
<Form.Item
label="二进制下载源"
extra={<Text type="secondary"> ghproxy </Text>}
>
<Radio.Group
type="button"
value={value.downloadSrc}
onChange={(v) => update({ downloadSrc: v as InstallSource })}
options={[
{ label: 'GitHub 直连', value: 'github' },
{ label: 'ghproxy 镜像', value: 'ghproxy' },
]}
/>
</Form.Item>
</Form>
)
}

View File

@@ -0,0 +1,111 @@
import React, { useEffect, useState } from 'react'
import { Typography, Button, Space, Collapse, Spin, Message, Tag } from '@arco-design/web-react'
import { IconCopy, IconRefresh } from '@arco-design/web-react/icon'
import { fetchScriptPreview } from '../../../services/nodes'
import type { InstallTokenResult, InstallMode } from '../../../types/nodes'
const { Text } = Typography
interface Props {
nodeId: number
nodeName: string
token: InstallTokenResult
mode: InstallMode
previewParams: { mode: string; arch: string; agentVersion: string; downloadSrc: string }
onRegenerate: () => void
}
export function Step3CommandPreview({ nodeId, nodeName, token, mode, previewParams, onRegenerate }: Props) {
const [remaining, setRemaining] = useState(0)
const [preview, setPreview] = useState<string>('')
const [loadingPreview, setLoadingPreview] = useState(false)
useEffect(() => {
const expires = new Date(token.expiresAt).getTime()
const tick = () => setRemaining(Math.max(0, Math.floor((expires - Date.now()) / 1000)))
tick()
const id = setInterval(tick, 1000)
return () => clearInterval(id)
}, [token.expiresAt])
const expired = remaining === 0
const command = `curl -fsSL ${token.url} | sudo sh`
const dockerComposeCmd = mode === 'docker' && token.composeUrl
? `curl -fsSL ${token.composeUrl} -o docker-compose.yml && docker-compose up -d`
: null
const copy = async (s: string) => {
await navigator.clipboard.writeText(s)
Message.success('已复制')
}
const loadPreview = async () => {
setLoadingPreview(true)
try {
const text = await fetchScriptPreview(nodeId, previewParams)
setPreview(text)
} catch {
Message.error('预览加载失败')
} finally {
setLoadingPreview(false)
}
}
return (
<div>
<Space style={{ marginBottom: 12 }}>
<Text bold></Text>
<Tag>{nodeName}</Tag>
<Tag color={expired ? 'gray' : 'green'}>
{expired ? '已过期' : `有效期 ${Math.floor(remaining / 60)}:${String(remaining % 60).padStart(2, '0')}`}
</Tag>
</Space>
<div style={{ background: 'var(--color-fill-2)', padding: '12px 14px', borderRadius: 6, marginBottom: 12 }}>
<Text style={{
fontFamily: 'monospace', fontSize: 13, wordBreak: 'break-all',
opacity: expired ? 0.4 : 1, userSelect: 'all',
}}>
{command}
</Text>
<div style={{ marginTop: 8 }}>
<Space>
<Button size="small" icon={<IconCopy />} disabled={expired} onClick={() => copy(command)}></Button>
{expired && <Button size="small" type="primary" icon={<IconRefresh />} onClick={onRegenerate}></Button>}
</Space>
</div>
</div>
{dockerComposeCmd && (
<div style={{ background: 'var(--color-fill-2)', padding: '12px 14px', borderRadius: 6, marginBottom: 12 }}>
<Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 4 }}>
使 docker-compose
</Text>
<Text style={{ fontFamily: 'monospace', fontSize: 13, wordBreak: 'break-all', opacity: expired ? 0.4 : 1 }}>
{dockerComposeCmd}
</Text>
<div style={{ marginTop: 8 }}>
<Button size="small" icon={<IconCopy />} disabled={expired} onClick={() => copy(dockerComposeCmd)}></Button>
</div>
</div>
)}
<Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 8 }}>
token
</Text>
<Collapse bordered={false} onChange={(_key, keys) => {
if (keys.includes('preview') && !preview) loadPreview()
}}>
<Collapse.Item name="preview" header="展开脚本预览">
{loadingPreview ? <Spin /> : (
<pre style={{
background: 'var(--color-fill-2)', padding: 12, borderRadius: 4,
fontSize: 12, maxHeight: 400, overflow: 'auto', whiteSpace: 'pre-wrap',
}}>{preview}</pre>
)}
</Collapse.Item>
</Collapse>
</div>
)
}

View File

@@ -1,5 +1,5 @@
import { http, type ApiEnvelope, unwrapApiEnvelope } from './http'
import type { NodeSummary, DirEntry } from '../types/nodes'
import type { NodeSummary, DirEntry, BatchCreateResult, InstallTokenInput, InstallTokenResult } from '../types/nodes'
export async function listNodes() {
const response = await http.get<ApiEnvelope<NodeSummary[]>>('/nodes')
@@ -30,3 +30,33 @@ export async function listNodeDirectory(nodeId: number, path: string) {
const response = await http.get<ApiEnvelope<DirEntry[]>>(`/nodes/${nodeId}/fs/list`, { params: { path } })
return unwrapApiEnvelope(response.data)
}
export async function batchCreateNodes(names: string[]) {
const response = await http.post<ApiEnvelope<BatchCreateResult[]>>('/nodes/batch', { names })
return unwrapApiEnvelope(response.data)
}
export async function createInstallToken(nodeId: number, input: InstallTokenInput) {
const response = await http.post<ApiEnvelope<InstallTokenResult>>(
`/nodes/${nodeId}/install-tokens`, input,
)
return unwrapApiEnvelope(response.data)
}
export async function rotateNodeToken(nodeId: number) {
const response = await http.post<ApiEnvelope<{ newToken: string }>>(
`/nodes/${nodeId}/rotate-token`,
)
return unwrapApiEnvelope(response.data)
}
export async function fetchScriptPreview(
nodeId: number,
params: { mode: string; arch: string; agentVersion: string; downloadSrc: string },
) {
const response = await http.get<string>(`/nodes/${nodeId}/install-script-preview`, {
params,
responseType: 'text',
})
return response.data
}

View File

@@ -18,3 +18,27 @@ export interface DirEntry {
isDir: boolean
size: number
}
export type InstallMode = 'systemd' | 'docker' | 'foreground'
export type InstallArch = 'amd64' | 'arm64' | 'auto'
export type InstallSource = 'github' | 'ghproxy'
export interface BatchCreateResult {
id: number
name: string
}
export interface InstallTokenInput {
mode: InstallMode
arch: InstallArch
agentVersion: string
downloadSrc: InstallSource
ttlSeconds: number
}
export interface InstallTokenResult {
installToken: string
expiresAt: string
url: string
composeUrl: string
}