mirror of
https://github.com/Awuqing/BackupX.git
synced 2026-06-25 11:33:42 +08:00
Compare commits
22 Commits
v2.3.1
...
feat/one-c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
34a9bc82be | ||
|
|
5c896bb96c | ||
|
|
e28f2d3454 | ||
|
|
5b03485f15 | ||
|
|
42b7e2013a | ||
|
|
275a466e6a | ||
|
|
ae71d952b1 | ||
|
|
46de6cf03a | ||
|
|
37440e583d | ||
|
|
3487dfcee8 | ||
|
|
60fda5a5e4 | ||
|
|
65ee70f6d3 | ||
|
|
c0db0e0eac | ||
|
|
e1a70624bc | ||
|
|
665a88a0a7 | ||
|
|
8d52be23b0 | ||
|
|
b7ace8e3a5 | ||
|
|
f53fa661c8 | ||
|
|
0d26bc3d91 | ||
|
|
c8b5095b2f | ||
|
|
fb043627bb | ||
|
|
65cbbea3b6 |
@@ -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:
|
||||
|
||||
|
||||
@@ -26,47 +26,44 @@ BackupX 支持 Master-Agent 模式:备份任务可以指定在哪个节点执
|
||||
- **执行** — Agent 复用 BackupRunner(file / mysql / postgresql / sqlite / saphana)并直接上传到存储
|
||||
- **安全** — 每个节点独立 Token;Agent 不持有 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. 目标机一条命令完成
|
||||
|
||||
**方式 A:CLI 参数**
|
||||
示例(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. 把任务路由到该节点
|
||||
|
||||
在 **备份任务** 页面新建任务时选择对应节点。任务触发时:
|
||||
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
331
server/internal/http/install_flow_test.go
Normal file
331
server/internal/http/install_flow_test.go
Normal 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:])
|
||||
}
|
||||
221
server/internal/http/install_handler.go
Normal file
221
server/internal/http/install_handler.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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 API(token 认证,无需 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")))
|
||||
})
|
||||
|
||||
170
server/internal/installscript/renderer.go
Normal file
170
server/internal/installscript/renderer.go
Normal 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 字节 hex(64 字符)+ 小幅兼容(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
|
||||
}
|
||||
176
server/internal/installscript/renderer_test.go
Normal file
176
server/internal/installscript/renderer_test.go
Normal file
@@ -0,0 +1,176 @@
|
||||
package installscript
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"backupx/server/internal/model"
|
||||
)
|
||||
|
||||
// 使用合法 hex token(32 字节 = 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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
108
server/internal/installscript/templates/agent-install.sh.tmpl
Normal file
108
server/internal/installscript/templates/agent-install.sh.tmpl
Normal 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] 前台启动 agent(Ctrl+C 退出)"
|
||||
export BACKUPX_AGENT_MASTER="${MASTER_URL}"
|
||||
export BACKUPX_AGENT_TOKEN="${AGENT_TOKEN}"
|
||||
exec "${INSTALL_PREFIX}/backupx" agent --temp-dir /var/lib/backupx-agent
|
||||
{{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}}
|
||||
36
server/internal/model/agent_install_token.go
Normal file
36
server/internal/model/agent_install_token.go
Normal 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"
|
||||
)
|
||||
@@ -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 {
|
||||
|
||||
107
server/internal/repository/agent_install_token_repository.go
Normal file
107
server/internal/repository/agent_install_token_repository.go
Normal 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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
76
server/internal/repository/node_repository_test.go
Normal file
76
server/internal/repository/node_repository_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
189
server/internal/service/install_token_service.go
Normal file
189
server/internal/service/install_token_service.go
Normal 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
|
||||
}
|
||||
156
server/internal/service/install_token_service_test.go
Normal file
156
server/internal/service/install_token_service_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@@ -373,6 +373,121 @@ func detectLocalIP() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// NodeCreateResult 批量创建结果。注意:不暴露 agent token,token 获取走 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)
|
||||
}
|
||||
}
|
||||
|
||||
// 预先构造所有 Node,token 生成在事务外完成(纯内存操作,失败不会影响 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_token,24h 内新旧 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 {
|
||||
|
||||
159
server/internal/service/node_service_test.go
Normal file
159
server/internal/service/node_service_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
301
web/src/pages/nodes/AgentInstallWizard.tsx
Normal file
301
web/src/pages/nodes/AgentInstallWizard.tsx
Normal 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 token(Promise.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 current(0-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>
|
||||
)
|
||||
}
|
||||
108
web/src/pages/nodes/BatchCommandTable.tsx
Normal file
108
web/src/pages/nodes/BatchCommandTable.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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 }}>
|
||||
新 Token(24 小时内新旧 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>
|
||||
)
|
||||
|
||||
60
web/src/pages/nodes/wizard/Step1NodeName.tsx
Normal file
60
web/src/pages/nodes/wizard/Step1NodeName.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
111
web/src/pages/nodes/wizard/Step2DeployOptions.tsx
Normal file
111
web/src/pages/nodes/wizard/Step2DeployOptions.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
111
web/src/pages/nodes/wizard/Step3CommandPreview.tsx
Normal file
111
web/src/pages/nodes/wizard/Step3CommandPreview.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user