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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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