From 83bf5ec6562159cf2b0d3deb4a8ca8e5a2b9e7e6 Mon Sep 17 00:00:00 2001 From: Wu Qing <3184394176@qq.com> Date: Sun, 19 Apr 2026 17:25:34 +0800 Subject: [PATCH] =?UTF-8?q?=E5=8A=9F=E8=83=BD:=20=E4=B8=80=E9=94=AE?= =?UTF-8?q?=E9=83=A8=E7=BD=B2=20Agent=20=E5=90=91=E5=AF=BC=20(#44)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs-site/docs/features/multi-node.md | 47 ++- .../current/features/multi-node.md | 49 ++- server/internal/app/app.go | 13 +- server/internal/database/database.go | 2 +- server/internal/http/agent_handler.go | 15 + server/internal/http/install_flow_test.go | 331 ++++++++++++++++++ server/internal/http/install_handler.go | 221 ++++++++++++ server/internal/http/node_handler.go | 181 +++++++++- server/internal/http/router.go | 27 +- server/internal/installscript/renderer.go | 170 +++++++++ .../internal/installscript/renderer_test.go | 176 ++++++++++ .../templates/agent-compose.yml.tmpl | 13 + .../templates/agent-install.sh.tmpl | 108 ++++++ server/internal/model/agent_install_token.go | 36 ++ server/internal/model/node.go | 10 +- .../agent_install_token_repository.go | 107 ++++++ .../agent_install_token_repository_test.go | 151 ++++++++ server/internal/repository/node_repository.go | 33 +- .../repository/node_repository_test.go | 76 ++++ server/internal/service/agent_service.go | 21 ++ .../internal/service/install_token_service.go | 189 ++++++++++ .../service/install_token_service_test.go | 156 +++++++++ server/internal/service/node_service.go | 115 ++++++ server/internal/service/node_service_test.go | 159 +++++++++ web/src/pages/nodes/AgentInstallWizard.tsx | 301 ++++++++++++++++ web/src/pages/nodes/BatchCommandTable.tsx | 108 ++++++ web/src/pages/nodes/NodesPage.tsx | 231 +++++------- web/src/pages/nodes/wizard/Step1NodeName.tsx | 60 ++++ .../pages/nodes/wizard/Step2DeployOptions.tsx | 111 ++++++ .../nodes/wizard/Step3CommandPreview.tsx | 111 ++++++ web/src/services/nodes.ts | 32 +- web/src/types/nodes.ts | 24 ++ 32 files changed, 3177 insertions(+), 207 deletions(-) create mode 100644 server/internal/http/install_flow_test.go create mode 100644 server/internal/http/install_handler.go create mode 100644 server/internal/installscript/renderer.go create mode 100644 server/internal/installscript/renderer_test.go create mode 100644 server/internal/installscript/templates/agent-compose.yml.tmpl create mode 100644 server/internal/installscript/templates/agent-install.sh.tmpl create mode 100644 server/internal/model/agent_install_token.go create mode 100644 server/internal/repository/agent_install_token_repository.go create mode 100644 server/internal/repository/agent_install_token_repository_test.go create mode 100644 server/internal/repository/node_repository_test.go create mode 100644 server/internal/service/install_token_service.go create mode 100644 server/internal/service/install_token_service_test.go create mode 100644 server/internal/service/node_service_test.go create mode 100644 web/src/pages/nodes/AgentInstallWizard.tsx create mode 100644 web/src/pages/nodes/BatchCommandTable.tsx create mode 100644 web/src/pages/nodes/wizard/Step1NodeName.tsx create mode 100644 web/src/pages/nodes/wizard/Step2DeployOptions.tsx create mode 100644 web/src/pages/nodes/wizard/Step3CommandPreview.tsx diff --git a/docs-site/docs/features/multi-node.md b/docs-site/docs/features/multi-node.md index f7b4a19..9d0acd3 100644 --- a/docs-site/docs/features/multi-node.md +++ b/docs-site/docs/features/multi-node.md @@ -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 +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: -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= \ -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: diff --git a/docs-site/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/multi-node.md b/docs-site/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/multi-node.md index 88ac8e9..332797b 100644 --- a/docs-site/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/multi-node.md +++ b/docs-site/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/multi-node.md @@ -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 +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: -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= \ -backupx agent -``` +节点操作列(︙)→ **重新生成 Token**。新 Token 一次性显示,旧 Token 24 小时内仍有效,便于滚动替换无需停机。24 小时后旧 Token 被拒绝。 -连接成功后节点在列表中显示为 **在线**。 +### 4. 批量部署 -### 3. 把任务路由到该节点 +第一步选"批量创建"粘贴节点名(每行一个,最多 50 个)。第三步显示每个节点对应的命令表格,底部「导出 .sh」可打包为单个 shell 文件,方便 SSH 循环或 Ansible 任务。 + +### 5. 把任务路由到该节点 在 **备份任务** 页面新建任务时选择对应节点。任务触发时: diff --git a/server/internal/app/app.go b/server/internal/app/app.go index fa913f1..960e85b 100644 --- a/server/internal/app/app.go +++ b/server/internal/app/app.go @@ -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{ diff --git a/server/internal/database/database.go b/server/internal/database/database.go index 030e1f5..85385ec 100644 --- a/server/internal/database/database.go +++ b/server/internal/database/database.go @@ -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) } diff --git a/server/internal/http/agent_handler.go b/server/internal/http/agent_handler.go index b0195eb..9395fce 100644 --- a/server/internal/http/agent_handler.go +++ b/server/internal/http/agent_handler.go @@ -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) +} diff --git a/server/internal/http/install_flow_test.go b/server/internal/http/install_flow_test.go new file mode 100644 index 0000000..37782ca --- /dev/null +++ b/server/internal/http/install_flow_test.go @@ -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:]) +} diff --git a/server/internal/http/install_handler.go b/server/internal/http/install_handler.go new file mode 100644 index 0000000..2eb6b92 --- /dev/null +++ b/server/internal/http/install_handler.go @@ -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) + } + } + }() +} diff --git a/server/internal/http/node_handler.go b/server/internal/http/node_handler.go index f915a6c..cfc28e0 100644 --- a/server/internal/http/node_handler.go +++ b/server/internal/http/node_handler.go @@ -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 字段用 占位,不消费 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: "", + 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)) +} diff --git a/server/internal/http/router.go b/server/internal/http/router.go index 47445ed..51e0bf3 100644 --- a/server/internal/http/router.go +++ b/server/internal/http/router.go @@ -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"))) }) diff --git a/server/internal/installscript/renderer.go b/server/internal/installscript/renderer.go new file mode 100644 index 0000000..2b6f466 --- /dev/null +++ b/server/internal/installscript/renderer.go @@ -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 允许占位符 (PreviewScript 使用), +// 或 32 字节 hex(64 字符)+ 小幅兼容(16-128 hex 字符) +func validateAgentToken(tok string) error { + if tok == "" { + 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 +} diff --git a/server/internal/installscript/renderer_test.go b/server/internal/installscript/renderer_test.go new file mode 100644 index 0000000..e1d3ee5 --- /dev/null +++ b/server/internal/installscript/renderer_test.go @@ -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 = "" // 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) + } +} diff --git a/server/internal/installscript/templates/agent-compose.yml.tmpl b/server/internal/installscript/templates/agent-compose.yml.tmpl new file mode 100644 index 0000000..4a27919 --- /dev/null +++ b/server/internal/installscript/templates/agent-compose.yml.tmpl @@ -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 diff --git a/server/internal/installscript/templates/agent-install.sh.tmpl b/server/internal/installscript/templates/agent-install.sh.tmpl new file mode 100644 index 0000000..ed5f667 --- /dev/null +++ b/server/internal/installscript/templates/agent-install.sh.tmpl @@ -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 </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}} diff --git a/server/internal/model/agent_install_token.go b/server/internal/model/agent_install_token.go new file mode 100644 index 0000000..f211d7e --- /dev/null +++ b/server/internal/model/agent_install_token.go @@ -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" +) diff --git a/server/internal/model/node.go b/server/internal/model/node.go index 87aca51..3c81335 100644 --- a/server/internal/model/node.go +++ b/server/internal/model/node.go @@ -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 { diff --git a/server/internal/repository/agent_install_token_repository.go b/server/internal/repository/agent_install_token_repository.go new file mode 100644 index 0000000..41dbf5d --- /dev/null +++ b/server/internal/repository/agent_install_token_repository.go @@ -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 +} diff --git a/server/internal/repository/agent_install_token_repository_test.go b/server/internal/repository/agent_install_token_repository_test.go new file mode 100644 index 0000000..e0f9534 --- /dev/null +++ b/server/internal/repository/agent_install_token_repository_test.go @@ -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) + } +} diff --git a/server/internal/repository/node_repository.go b/server/internal/repository/node_repository.go index b497b26..f8a60ef 100644 --- a/server/internal/repository/node_repository.go +++ b/server/internal/repository/node_repository.go @@ -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 } diff --git a/server/internal/repository/node_repository_test.go b/server/internal/repository/node_repository_test.go new file mode 100644 index 0000000..9c7ed2a --- /dev/null +++ b/server/internal/repository/node_repository_test.go @@ -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) + } +} diff --git a/server/internal/service/agent_service.go b/server/internal/service/agent_service.go index 17e74b5..4b5510f 100644 --- a/server/internal/service/agent_service.go +++ b/server/internal/service/agent_service.go @@ -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 +} diff --git a/server/internal/service/install_token_service.go b/server/internal/service/install_token_service.go new file mode 100644 index 0000000..21ce31d --- /dev/null +++ b/server/internal/service/install_token_service.go @@ -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 +} diff --git a/server/internal/service/install_token_service_test.go b/server/internal/service/install_token_service_test.go new file mode 100644 index 0000000..0552202 --- /dev/null +++ b/server/internal/service/install_token_service_test.go @@ -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") + } +} diff --git a/server/internal/service/node_service.go b/server/internal/service/node_service.go index 6dee41a..12317b7 100644 --- a/server/internal/service/node_service.go +++ b/server/internal/service/node_service.go @@ -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 { diff --git a/server/internal/service/node_service_test.go b/server/internal/service/node_service_test.go new file mode 100644 index 0000000..51cca16 --- /dev/null +++ b/server/internal/service/node_service_test.go @@ -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") + } +} diff --git a/web/src/pages/nodes/AgentInstallWizard.tsx b/web/src/pages/nodes/AgentInstallWizard.tsx new file mode 100644 index 0000000..6533833 --- /dev/null +++ b/web/src/pages/nodes/AgentInstallWizard.tsx @@ -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('single') + const [singleName, setSingleName] = useState('') + const [batchText, setBatchText] = useState('') + + // 批量进度(已生成 / 总数) + const [batchProgress, setBatchProgress] = useState<{ done: number; total: number } | null>(null) + + const [deploy, setDeploy] = useState({ + 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(null) + const [singleNodeInfo, setSingleNodeInfo] = useState<{ id: number; name: string } | null>(null) + const [batchRows, setBatchRows] = useState([]) + 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() + 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 ( + + + {!fixedNode && } + + + + + {submitting && ( +
+ + {batchProgress && ( +
+
+ 正在生成安装命令 {batchProgress.done} / {batchProgress.total} +
+ +
+ )} +
+ )} + + {!submitting && step === 0 && ( + <> + +
+ + + + +
+ + )} + + {!submitting && step === 1 && ( + <> + +
+ + {!fixedNode && ( + + )} + + + +
+ + )} + + {!submitting && step === 2 && ( + <> + {singleToken && singleNodeInfo && ( + + )} + {batchRows.length > 0 && } +
+ +
+ + )} +
+ ) +} diff --git a/web/src/pages/nodes/BatchCommandTable.tsx b/web/src/pages/nodes/BatchCommandTable.tsx new file mode 100644 index 0000000..d6701ed --- /dev/null +++ b/web/src/pages/nodes/BatchCommandTable.tsx @@ -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>({}) + + useEffect(() => { + const tick = () => { + const next: Record = {} + 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 ( +
+ { + const left = remaining[row.nodeId] ?? 0 + return ( + + {cmd as string} + + ) + }, + }, + { + title: '剩余', dataIndex: 'expiresAt', width: 90, + render: (_v: unknown, row: BatchCommandRow) => { + const left = remaining[row.nodeId] ?? 0 + return ( + + {left === 0 ? '已过期' : `${Math.floor(left / 60)}:${String(left % 60).padStart(2, '0')}`} + + ) + }, + }, + { + title: '操作', width: 80, + render: (_v: unknown, row: BatchCommandRow) => ( + + ), + }, + ]} + data={rows} + rowKey="nodeId" + /> +
+ + + +
+ + ) +} diff --git a/web/src/pages/nodes/NodesPage.tsx b/web/src/pages/nodes/NodesPage.tsx index 44e6688..bccff3e 100644 --- a/web/src/pages/nodes/NodesPage.tsx +++ b/web/src/pages/nodes/NodesPage.tsx @@ -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([]) 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(null) + const [editVisible, setEditVisible] = useState(false) const [editNode, setEditNode] = useState(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: ( +
+ + 新 Token(24 小时内新旧 Token 均可认证,便于滚动替换): + + + {newToken} + +
+ ), + }) + } catch { + Message.error('轮换 Token 失败') + } + } + const columns = [ { - title: '节点名称', - dataIndex: 'name', + title: '节点名称', dataIndex: 'name', render: (name: string, record: NodeSummary) => ( {record.isLocal ? : } @@ -89,60 +105,48 @@ export default function NodesPage() { ), }, { - title: '状态', - dataIndex: 'status', - width: 100, - render: (status: string) => { - if (status === 'online') return - return - }, + title: '状态', dataIndex: 'status', width: 100, + render: (status: string) => status === 'online' + ? + : , }, + { 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 + ? {record.os}/{record.arch} : '-', }, + { 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 {record.os}/{record.arch} - }, - }, - { - 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) => ( - } /> -
} - /> +
} /> - {/* 添加节点弹窗 */} - setCreateVisible(false)} - style={{ width: 640 }} - footer={newToken ? ( - - ) : undefined} - onOk={handleCreate} - okText="创建" - > - {!newToken ? ( - - ) : ( -
- {newToken} }, - ]} /> -
- - 令牌仅显示一次,请妥善保存。将其配置到远程服务器后,Agent 会自动连接 Master。 - -
-
- Agent 部署步骤 -
    -
  1. 把 BackupX 二进制上传到目标服务器(与 Master 同一个可执行文件)
  2. -
  3. 通过以下命令启动 Agent(替换 MASTER_URL):
  4. -
-
- - {`backupx agent --master ${window.location.origin} --token ${newToken}`} - -
- - 或使用配置文件 / 环境变量:
- BACKUPX_AGENT_MASTER={window.location.origin}
- BACKUPX_AGENT_TOKEN={newToken} -
-
-
- )} -
+ setWizardVisible(false)} + onSuccess={fetchNodes} + masterVersion={masterVersion} + fixedNode={wizardFixedNode} + /> - {/* 编辑节点弹窗 */} - setEditVisible(false)} - onOk={handleEdit} - okText="保存" - cancelText="取消" - > + setEditVisible(false)} onOk={handleEdit} + okText="保存" cancelText="取消">
节点名称
- +
) diff --git a/web/src/pages/nodes/wizard/Step1NodeName.tsx b/web/src/pages/nodes/wizard/Step1NodeName.tsx new file mode 100644 index 0000000..67c592c --- /dev/null +++ b/web/src/pages/nodes/wizard/Step1NodeName.tsx @@ -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 ( +
+
+ onModeChange(v as Mode)} + options={[ + { label: '单节点', value: 'single' }, + { label: '批量创建', value: 'batch' }, + ]} + /> +
+ {mode === 'single' ? ( +
+ 节点名称 + +
+ ) : ( +
+ 节点名称(每行一个,最多 50 个) +