mirror of
https://github.com/Awuqing/BackupX.git
synced 2026-05-30 22:19:37 +08:00
功能: 一键部署 Agent 向导 (#44)
This commit is contained in:
@@ -154,3 +154,18 @@ func (h *AgentHandler) UpdateRecord(c *gin.Context) {
|
||||
}
|
||||
response.Success(c, gin.H{"status": "ok"})
|
||||
}
|
||||
|
||||
// Self 返回当前 Agent token 所属节点的状态,供安装脚本末尾探活。
|
||||
func (h *AgentHandler) Self(c *gin.Context) {
|
||||
node, err := h.agentService.AuthenticatedNode(c.Request.Context(), extractToken(c))
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
status, err := h.agentService.SelfStatus(c.Request.Context(), node)
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, status)
|
||||
}
|
||||
|
||||
331
server/internal/http/install_flow_test.go
Normal file
331
server/internal/http/install_flow_test.go
Normal file
@@ -0,0 +1,331 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"backupx/server/internal/config"
|
||||
"backupx/server/internal/database"
|
||||
"backupx/server/internal/logger"
|
||||
"backupx/server/internal/repository"
|
||||
"backupx/server/internal/security"
|
||||
"backupx/server/internal/service"
|
||||
)
|
||||
|
||||
// setupInstallFlowRouter 构造一个 Node + Agent + InstallToken 全量依赖的 router,
|
||||
// 并返回已登录管理员 JWT。
|
||||
func setupInstallFlowRouter(t *testing.T) (http.Handler, string) {
|
||||
t.Helper()
|
||||
tempDir := t.TempDir()
|
||||
cfg := config.Config{
|
||||
Server: config.ServerConfig{Host: "127.0.0.1", Port: 8340, Mode: "test"},
|
||||
Database: config.DatabaseConfig{Path: filepath.Join(tempDir, "backupx.db")},
|
||||
Security: config.SecurityConfig{JWTExpire: "24h"},
|
||||
Log: config.LogConfig{Level: "error"},
|
||||
}
|
||||
log, err := logger.New(cfg.Log)
|
||||
if err != nil {
|
||||
t.Fatalf("logger: %v", err)
|
||||
}
|
||||
db, err := database.Open(cfg.Database, log)
|
||||
if err != nil {
|
||||
t.Fatalf("db: %v", err)
|
||||
}
|
||||
|
||||
userRepo := repository.NewUserRepository(db)
|
||||
systemConfigRepo := repository.NewSystemConfigRepository(db)
|
||||
resolved, err := service.ResolveSecurity(context.Background(), cfg.Security, systemConfigRepo)
|
||||
if err != nil {
|
||||
t.Fatalf("security: %v", err)
|
||||
}
|
||||
jwtMgr := security.NewJWTManager(resolved.JWTSecret, time.Hour)
|
||||
authSvc := service.NewAuthService(userRepo, systemConfigRepo, jwtMgr, security.NewLoginRateLimiter(5, time.Minute))
|
||||
systemSvc := service.NewSystemService(cfg, "test", time.Now().UTC())
|
||||
|
||||
nodeRepo := repository.NewNodeRepository(db)
|
||||
nodeSvc := service.NewNodeService(nodeRepo, "test")
|
||||
if err := nodeSvc.EnsureLocalNode(context.Background()); err != nil {
|
||||
t.Fatalf("ensure local: %v", err)
|
||||
}
|
||||
|
||||
installTokenRepo := repository.NewAgentInstallTokenRepository(db)
|
||||
installTokenSvc := service.NewInstallTokenService(installTokenRepo, nodeRepo)
|
||||
|
||||
auditLogRepo := repository.NewAuditLogRepository(db)
|
||||
auditSvc := service.NewAuditService(auditLogRepo)
|
||||
|
||||
// 用 cancelable ctx,测试结束时停掉 handler 启动的后台 GC 协程,
|
||||
// 避免 goroutine 持有 map 导致 tempdir 清理失败。
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
t.Cleanup(cancel)
|
||||
|
||||
router := NewRouter(RouterDependencies{
|
||||
Context: ctx,
|
||||
Config: cfg,
|
||||
Version: "test",
|
||||
Logger: log,
|
||||
AuthService: authSvc,
|
||||
SystemService: systemSvc,
|
||||
NodeService: nodeSvc,
|
||||
InstallTokenService: installTokenSvc,
|
||||
AuditService: auditSvc,
|
||||
JWTManager: jwtMgr,
|
||||
UserRepository: userRepo,
|
||||
SystemConfigRepo: systemConfigRepo,
|
||||
})
|
||||
|
||||
// setup 管理员并登录拿 JWT
|
||||
setupBody, _ := json.Marshal(map[string]string{
|
||||
"username": "admin", "password": "password-123", "displayName": "admin",
|
||||
})
|
||||
setupReq := httptest.NewRequest(http.MethodPost, "/api/auth/setup", bytes.NewBuffer(setupBody))
|
||||
setupReq.Header.Set("Content-Type", "application/json")
|
||||
setupRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(setupRec, setupReq)
|
||||
if setupRec.Code != 200 {
|
||||
t.Fatalf("setup failed: %d %s", setupRec.Code, setupRec.Body.String())
|
||||
}
|
||||
var setupResp struct {
|
||||
Data struct {
|
||||
Token string `json:"token"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(setupRec.Body.Bytes(), &setupResp); err != nil {
|
||||
t.Fatalf("unmarshal setup: %v", err)
|
||||
}
|
||||
|
||||
return router, setupResp.Data.Token
|
||||
}
|
||||
|
||||
func TestOneClickInstallFlow(t *testing.T) {
|
||||
router, jwt := setupInstallFlowRouter(t)
|
||||
|
||||
// 1. 批量创建
|
||||
batchBody, _ := json.Marshal(map[string][]string{"names": {"prod-a", "prod-b"}})
|
||||
batchReq := httptest.NewRequest(http.MethodPost, "/api/nodes/batch", bytes.NewBuffer(batchBody))
|
||||
batchReq.Header.Set("Content-Type", "application/json")
|
||||
batchReq.Header.Set("Authorization", "Bearer "+jwt)
|
||||
batchRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(batchRec, batchReq)
|
||||
if batchRec.Code != 200 {
|
||||
t.Fatalf("batch create failed: %d %s", batchRec.Code, batchRec.Body.String())
|
||||
}
|
||||
var batchResp struct {
|
||||
Data []struct {
|
||||
ID uint `json:"id"`
|
||||
Name string `json:"name"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(batchRec.Body.Bytes(), &batchResp); err != nil {
|
||||
t.Fatalf("unmarshal batch: %v", err)
|
||||
}
|
||||
if len(batchResp.Data) != 2 {
|
||||
t.Fatalf("expected 2 nodes, got %d", len(batchResp.Data))
|
||||
}
|
||||
nodeID := batchResp.Data[0].ID
|
||||
|
||||
// 2. 生成 install token
|
||||
genBody, _ := json.Marshal(map[string]any{
|
||||
"mode": "systemd",
|
||||
"arch": "auto",
|
||||
"agentVersion": "v1.7.0",
|
||||
"downloadSrc": "github",
|
||||
"ttlSeconds": 900,
|
||||
})
|
||||
genReq := httptest.NewRequest(http.MethodPost,
|
||||
"/api/nodes/"+formatUint(nodeID)+"/install-tokens", bytes.NewBuffer(genBody))
|
||||
genReq.Header.Set("Content-Type", "application/json")
|
||||
genReq.Header.Set("Authorization", "Bearer "+jwt)
|
||||
genRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(genRec, genReq)
|
||||
if genRec.Code != 200 {
|
||||
t.Fatalf("install-tokens failed: %d %s", genRec.Code, genRec.Body.String())
|
||||
}
|
||||
var genResp struct {
|
||||
Data struct {
|
||||
InstallToken string `json:"installToken"`
|
||||
URL string `json:"url"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(genRec.Body.Bytes(), &genResp); err != nil {
|
||||
t.Fatalf("unmarshal gen: %v", err)
|
||||
}
|
||||
if genResp.Data.InstallToken == "" {
|
||||
t.Fatalf("missing installToken")
|
||||
}
|
||||
|
||||
// 3. 公开端点消费
|
||||
scriptReq := httptest.NewRequest(http.MethodGet, "/install/"+genResp.Data.InstallToken, nil)
|
||||
scriptRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(scriptRec, scriptReq)
|
||||
if scriptRec.Code != 200 {
|
||||
t.Fatalf("script fetch failed: %d %s", scriptRec.Code, scriptRec.Body.String())
|
||||
}
|
||||
if !strings.Contains(scriptRec.Body.String(), "systemctl enable --now backupx-agent") {
|
||||
t.Fatalf("script missing systemctl enable:\n%s", scriptRec.Body.String())
|
||||
}
|
||||
|
||||
// 4. 再次消费应 410
|
||||
scriptReq2 := httptest.NewRequest(http.MethodGet, "/install/"+genResp.Data.InstallToken, nil)
|
||||
scriptRec2 := httptest.NewRecorder()
|
||||
router.ServeHTTP(scriptRec2, scriptReq2)
|
||||
if scriptRec2.Code != http.StatusGone {
|
||||
t.Fatalf("second consume should be 410, got %d: %s", scriptRec2.Code, scriptRec2.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallTokenRateLimit(t *testing.T) {
|
||||
router, jwt := setupInstallFlowRouter(t)
|
||||
|
||||
batchBody, _ := json.Marshal(map[string][]string{"names": {"rl-test"}})
|
||||
batchReq := httptest.NewRequest(http.MethodPost, "/api/nodes/batch", bytes.NewBuffer(batchBody))
|
||||
batchReq.Header.Set("Content-Type", "application/json")
|
||||
batchReq.Header.Set("Authorization", "Bearer "+jwt)
|
||||
batchRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(batchRec, batchReq)
|
||||
if batchRec.Code != 200 {
|
||||
t.Fatalf("batch: %d %s", batchRec.Code, batchRec.Body.String())
|
||||
}
|
||||
var batchResp struct {
|
||||
Data []struct {
|
||||
ID uint `json:"id"`
|
||||
} `json:"data"`
|
||||
}
|
||||
_ = json.Unmarshal(batchRec.Body.Bytes(), &batchResp)
|
||||
nodeID := batchResp.Data[0].ID
|
||||
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"mode": "systemd", "arch": "auto", "agentVersion": "v1",
|
||||
"downloadSrc": "github", "ttlSeconds": 300,
|
||||
})
|
||||
for i := 0; i < 5; i++ {
|
||||
req := httptest.NewRequest(http.MethodPost,
|
||||
"/api/nodes/"+formatUint(nodeID)+"/install-tokens", bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+jwt)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("iter %d expected 200, got %d: %s", i, rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost,
|
||||
"/api/nodes/"+formatUint(nodeID)+"/install-tokens", bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+jwt)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusTooManyRequests {
|
||||
t.Fatalf("expected 429, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRotateTokenFlow(t *testing.T) {
|
||||
router, jwt := setupInstallFlowRouter(t)
|
||||
|
||||
batchBody, _ := json.Marshal(map[string][]string{"names": {"rot-x"}})
|
||||
batchReq := httptest.NewRequest(http.MethodPost, "/api/nodes/batch", bytes.NewBuffer(batchBody))
|
||||
batchReq.Header.Set("Content-Type", "application/json")
|
||||
batchReq.Header.Set("Authorization", "Bearer "+jwt)
|
||||
batchRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(batchRec, batchReq)
|
||||
var batchResp struct {
|
||||
Data []struct {
|
||||
ID uint `json:"id"`
|
||||
} `json:"data"`
|
||||
}
|
||||
_ = json.Unmarshal(batchRec.Body.Bytes(), &batchResp)
|
||||
nodeID := batchResp.Data[0].ID
|
||||
|
||||
rotReq := httptest.NewRequest(http.MethodPost,
|
||||
"/api/nodes/"+formatUint(nodeID)+"/rotate-token", nil)
|
||||
rotReq.Header.Set("Authorization", "Bearer "+jwt)
|
||||
rotRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rotRec, rotReq)
|
||||
if rotRec.Code != 200 {
|
||||
t.Fatalf("rotate failed: %d %s", rotRec.Code, rotRec.Body.String())
|
||||
}
|
||||
var rotResp struct {
|
||||
Data struct {
|
||||
NewToken string `json:"newToken"`
|
||||
} `json:"data"`
|
||||
}
|
||||
_ = json.Unmarshal(rotRec.Body.Bytes(), &rotResp)
|
||||
if len(rotResp.Data.NewToken) != 64 {
|
||||
t.Fatalf("new token wrong length: %s", rotResp.Data.NewToken)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallFlowComposeModeMismatch(t *testing.T) {
|
||||
router, jwt := setupInstallFlowRouter(t)
|
||||
|
||||
batchBody, _ := json.Marshal(map[string][]string{"names": {"cm"}})
|
||||
batchReq := httptest.NewRequest(http.MethodPost, "/api/nodes/batch", bytes.NewBuffer(batchBody))
|
||||
batchReq.Header.Set("Content-Type", "application/json")
|
||||
batchReq.Header.Set("Authorization", "Bearer "+jwt)
|
||||
batchRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(batchRec, batchReq)
|
||||
var batchResp struct {
|
||||
Data []struct {
|
||||
ID uint `json:"id"`
|
||||
} `json:"data"`
|
||||
}
|
||||
_ = json.Unmarshal(batchRec.Body.Bytes(), &batchResp)
|
||||
nodeID := batchResp.Data[0].ID
|
||||
|
||||
// 生成 systemd 模式的 token
|
||||
genBody, _ := json.Marshal(map[string]any{
|
||||
"mode": "systemd", "arch": "auto", "agentVersion": "v1",
|
||||
"downloadSrc": "github", "ttlSeconds": 300,
|
||||
})
|
||||
genReq := httptest.NewRequest(http.MethodPost,
|
||||
"/api/nodes/"+formatUint(nodeID)+"/install-tokens", bytes.NewBuffer(genBody))
|
||||
genReq.Header.Set("Content-Type", "application/json")
|
||||
genReq.Header.Set("Authorization", "Bearer "+jwt)
|
||||
genRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(genRec, genReq)
|
||||
var genResp struct {
|
||||
Data struct {
|
||||
InstallToken string `json:"installToken"`
|
||||
} `json:"data"`
|
||||
}
|
||||
_ = json.Unmarshal(genRec.Body.Bytes(), &genResp)
|
||||
|
||||
// 访问 compose.yml 应 400
|
||||
req := httptest.NewRequest(http.MethodGet,
|
||||
"/install/"+genResp.Data.InstallToken+"/compose.yml", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400 for mode mismatch, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
// systemd token 未被消费(Peek 不消费)→ 应仍可通过 /install/:token 消费成功
|
||||
req2 := httptest.NewRequest(http.MethodGet, "/install/"+genResp.Data.InstallToken, nil)
|
||||
rec2 := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec2, req2)
|
||||
if rec2.Code != 200 {
|
||||
t.Fatalf("original script fetch should still work: %d %s", rec2.Code, rec2.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// formatUint 小工具:uint → 十进制字符串(无需引入 strconv)。
|
||||
func formatUint(u uint) string {
|
||||
if u == 0 {
|
||||
return "0"
|
||||
}
|
||||
var buf [20]byte
|
||||
i := len(buf)
|
||||
for u > 0 {
|
||||
i--
|
||||
buf[i] = byte('0' + u%10)
|
||||
u /= 10
|
||||
}
|
||||
return string(buf[i:])
|
||||
}
|
||||
221
server/internal/http/install_handler.go
Normal file
221
server/internal/http/install_handler.go
Normal file
@@ -0,0 +1,221 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"context"
|
||||
stdhttp "net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"backupx/server/internal/installscript"
|
||||
"backupx/server/internal/model"
|
||||
"backupx/server/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// InstallHandler 公开路由(不走 JWT 中间件):/install/:token 与 /install/:token/compose.yml。
|
||||
type InstallHandler struct {
|
||||
tokenService *service.InstallTokenService
|
||||
auditService *service.AuditService
|
||||
externalURL string
|
||||
limiter *ipLimiter
|
||||
}
|
||||
|
||||
// NewInstallHandler 构造 handler 并启动限流器的后台 GC 协程。
|
||||
// gcCtx 控制 GC 协程生命周期,建议传入 app context。
|
||||
func NewInstallHandler(gcCtx context.Context, tokenService *service.InstallTokenService, auditService *service.AuditService, externalURL string) *InstallHandler {
|
||||
limiter := newIPLimiter(20, time.Minute)
|
||||
limiter.startGC(gcCtx)
|
||||
return &InstallHandler{
|
||||
tokenService: tokenService,
|
||||
auditService: auditService,
|
||||
externalURL: externalURL,
|
||||
limiter: limiter,
|
||||
}
|
||||
}
|
||||
|
||||
// Script 消费 install token 并返回 shell 脚本;Mode 由 token 存储决定(systemd/docker/foreground 均返回 shell)。
|
||||
func (h *InstallHandler) Script(c *gin.Context) {
|
||||
if !h.limiter.allow(c.ClientIP()) {
|
||||
c.String(stdhttp.StatusTooManyRequests, "请求过于频繁,请稍后再试\n")
|
||||
return
|
||||
}
|
||||
token := strings.TrimSpace(c.Param("token"))
|
||||
consumed, err := h.tokenService.Consume(c.Request.Context(), token)
|
||||
if err != nil {
|
||||
c.String(stdhttp.StatusInternalServerError, "server error\n")
|
||||
return
|
||||
}
|
||||
if consumed == nil {
|
||||
c.String(stdhttp.StatusGone, "install token 不存在、已过期或已消费\n")
|
||||
return
|
||||
}
|
||||
h.recordConsumeAudit(c, consumed, "script")
|
||||
script, err := installscript.RenderScript(installscript.Context{
|
||||
MasterURL: resolveMasterURL(c, h.externalURL),
|
||||
AgentToken: consumed.Node.Token,
|
||||
AgentVersion: consumed.Record.AgentVer,
|
||||
Mode: consumed.Record.Mode,
|
||||
Arch: consumed.Record.Arch,
|
||||
DownloadBase: installscript.DownloadBaseFor(consumed.Record.DownloadSrc),
|
||||
InstallPrefix: "/opt/backupx-agent",
|
||||
NodeID: consumed.Node.ID,
|
||||
})
|
||||
if err != nil {
|
||||
c.String(stdhttp.StatusInternalServerError, "render error\n")
|
||||
return
|
||||
}
|
||||
c.Data(stdhttp.StatusOK, "text/x-shellscript; charset=utf-8", []byte(script))
|
||||
}
|
||||
|
||||
// Compose 消费 install token 并返回 docker-compose YAML,仅 Mode=docker 有效。
|
||||
// 注意:/install/:token 与 /install/:token/compose.yml 共享同一 token 的消费状态,任一首次命中即消费。
|
||||
func (h *InstallHandler) Compose(c *gin.Context) {
|
||||
if !h.limiter.allow(c.ClientIP()) {
|
||||
c.String(stdhttp.StatusTooManyRequests, "请求过于频繁,请稍后再试\n")
|
||||
return
|
||||
}
|
||||
token := strings.TrimSpace(c.Param("token"))
|
||||
// 先 Peek 看 Mode(不消费),若非 docker 直接 400
|
||||
record, err := h.tokenService.Peek(c.Request.Context(), token)
|
||||
if err != nil {
|
||||
c.String(stdhttp.StatusInternalServerError, "server error\n")
|
||||
return
|
||||
}
|
||||
if record == nil {
|
||||
c.String(stdhttp.StatusGone, "install token 不存在\n")
|
||||
return
|
||||
}
|
||||
if record.Mode != model.InstallModeDocker {
|
||||
c.String(stdhttp.StatusBadRequest, "该 install token 的模式不是 docker\n")
|
||||
return
|
||||
}
|
||||
// 消费
|
||||
consumed, err := h.tokenService.Consume(c.Request.Context(), token)
|
||||
if err != nil {
|
||||
c.String(stdhttp.StatusInternalServerError, "server error\n")
|
||||
return
|
||||
}
|
||||
if consumed == nil {
|
||||
c.String(stdhttp.StatusGone, "install token 已过期或已消费\n")
|
||||
return
|
||||
}
|
||||
h.recordConsumeAudit(c, consumed, "compose")
|
||||
yaml, err := installscript.RenderComposeYaml(installscript.Context{
|
||||
MasterURL: resolveMasterURL(c, h.externalURL),
|
||||
AgentToken: consumed.Node.Token,
|
||||
AgentVersion: consumed.Record.AgentVer,
|
||||
Mode: model.InstallModeDocker,
|
||||
NodeID: consumed.Node.ID,
|
||||
})
|
||||
if err != nil {
|
||||
c.String(stdhttp.StatusInternalServerError, "render error\n")
|
||||
return
|
||||
}
|
||||
c.Data(stdhttp.StatusOK, "text/yaml; charset=utf-8", []byte(yaml))
|
||||
}
|
||||
|
||||
func (h *InstallHandler) recordConsumeAudit(c *gin.Context, consumed *service.ConsumedInstallToken, kind string) {
|
||||
if h.auditService == nil {
|
||||
return
|
||||
}
|
||||
h.auditService.Record(service.AuditEntry{
|
||||
Category: "install_token",
|
||||
Action: "consume",
|
||||
TargetType: "node",
|
||||
TargetID: strconv.FormatUint(uint64(consumed.Node.ID), 10),
|
||||
TargetName: consumed.Node.Name,
|
||||
Detail: "install token 消费 (" + kind + ")",
|
||||
ClientIP: c.ClientIP(),
|
||||
})
|
||||
}
|
||||
|
||||
// resolveMasterURL 按优先级推导 Master URL:外部配置 > X-Forwarded-* > Request.Host。
|
||||
// 此为包级 helper,供 install_handler 和 node_handler 共用。
|
||||
func resolveMasterURL(c *gin.Context, externalURL string) string {
|
||||
if strings.TrimSpace(externalURL) != "" {
|
||||
return strings.TrimRight(externalURL, "/")
|
||||
}
|
||||
scheme := strings.TrimSpace(c.GetHeader("X-Forwarded-Proto"))
|
||||
if scheme == "" {
|
||||
if c.Request.TLS != nil {
|
||||
scheme = "https"
|
||||
} else {
|
||||
scheme = "http"
|
||||
}
|
||||
}
|
||||
host := strings.TrimSpace(c.GetHeader("X-Forwarded-Host"))
|
||||
if host == "" {
|
||||
host = c.Request.Host
|
||||
}
|
||||
return scheme + "://" + host
|
||||
}
|
||||
|
||||
// ipLimiter 简单内存滑动窗口限流,按 client IP 维度。
|
||||
type ipLimiter struct {
|
||||
mu sync.Mutex
|
||||
events map[string][]time.Time
|
||||
limit int
|
||||
window time.Duration
|
||||
}
|
||||
|
||||
func newIPLimiter(limit int, window time.Duration) *ipLimiter {
|
||||
return &ipLimiter{events: make(map[string][]time.Time), limit: limit, window: window}
|
||||
}
|
||||
|
||||
func (l *ipLimiter) allow(ip string) bool {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
now := time.Now()
|
||||
cutoff := now.Add(-l.window)
|
||||
keep := l.events[ip][:0]
|
||||
for _, t := range l.events[ip] {
|
||||
if t.After(cutoff) {
|
||||
keep = append(keep, t)
|
||||
}
|
||||
}
|
||||
if len(keep) >= l.limit {
|
||||
l.events[ip] = keep
|
||||
return false
|
||||
}
|
||||
l.events[ip] = append(keep, now)
|
||||
return true
|
||||
}
|
||||
|
||||
// gc 清理窗口外所有过期的 IP 条目,防止公网扫描导致 map 无界增长。
|
||||
// 由后台 goroutine 周期性调用。
|
||||
func (l *ipLimiter) gc(now time.Time) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
cutoff := now.Add(-l.window)
|
||||
for k, v := range l.events {
|
||||
stale := true
|
||||
for _, t := range v {
|
||||
if t.After(cutoff) {
|
||||
stale = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if stale {
|
||||
delete(l.events, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// startGC 启动后台清理协程,每 window 周期清扫一次 map。
|
||||
// ctx 取消时协程退出。
|
||||
func (l *ipLimiter) startGC(ctx context.Context) {
|
||||
go func() {
|
||||
ticker := time.NewTicker(l.window)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case t := <-ticker.C:
|
||||
l.gc(t)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
@@ -5,18 +5,59 @@ import (
|
||||
stdhttp "net/http"
|
||||
"strconv"
|
||||
|
||||
"backupx/server/internal/apperror"
|
||||
"backupx/server/internal/installscript"
|
||||
"backupx/server/internal/repository"
|
||||
"backupx/server/internal/service"
|
||||
"backupx/server/pkg/response"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type NodeHandler struct {
|
||||
service *service.NodeService
|
||||
auditService *service.AuditService
|
||||
service *service.NodeService
|
||||
auditService *service.AuditService
|
||||
installTokenSvc *service.InstallTokenService
|
||||
userRepo repository.UserRepository
|
||||
externalURL string
|
||||
}
|
||||
|
||||
func NewNodeHandler(service *service.NodeService, auditService *service.AuditService) *NodeHandler {
|
||||
return &NodeHandler{service: service, auditService: auditService}
|
||||
// NewNodeHandler 构造 handler。
|
||||
// userRepo 用于把 JWT subject(用户名)解析为 user.ID,填入 install_token.created_by_id 做审计追溯;
|
||||
// 传 nil 时 created_by_id 记为 0(仍可用,不阻断)。
|
||||
func NewNodeHandler(
|
||||
nodeService *service.NodeService,
|
||||
auditService *service.AuditService,
|
||||
installTokenSvc *service.InstallTokenService,
|
||||
userRepo repository.UserRepository,
|
||||
externalURL string,
|
||||
) *NodeHandler {
|
||||
return &NodeHandler{
|
||||
service: nodeService,
|
||||
auditService: auditService,
|
||||
installTokenSvc: installTokenSvc,
|
||||
userRepo: userRepo,
|
||||
externalURL: externalURL,
|
||||
}
|
||||
}
|
||||
|
||||
// resolveCurrentUserID 从 JWT subject 解析出 user.ID,失败返回 0。
|
||||
func (h *NodeHandler) resolveCurrentUserID(c *gin.Context) uint {
|
||||
if h.userRepo == nil {
|
||||
return 0
|
||||
}
|
||||
subjectValue, ok := c.Get(contextUserSubjectKey)
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
subject, err := service.SubjectFromContextValue(subjectValue)
|
||||
if err != nil || subject == "" {
|
||||
return 0
|
||||
}
|
||||
user, err := h.userRepo.FindByUsername(c.Request.Context(), subject)
|
||||
if err != nil || user == nil {
|
||||
return 0
|
||||
}
|
||||
return user.ID
|
||||
}
|
||||
|
||||
func (h *NodeHandler) List(c *gin.Context) {
|
||||
@@ -128,3 +169,135 @@ func (h *NodeHandler) Heartbeat(c *gin.Context) {
|
||||
}
|
||||
response.Success(c, gin.H{"status": "ok"})
|
||||
}
|
||||
|
||||
// BatchCreate 批量创建远程节点。
|
||||
func (h *NodeHandler) BatchCreate(c *gin.Context) {
|
||||
var input struct {
|
||||
Names []string `json:"names" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
c.JSON(stdhttp.StatusBadRequest, gin.H{"code": "INVALID_INPUT", "message": err.Error()})
|
||||
return
|
||||
}
|
||||
results, err := h.service.BatchCreate(c.Request.Context(), input.Names)
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
recordAudit(c, h.auditService, "node", "batch_create", "node", "",
|
||||
fmt.Sprintf("%d", len(results)), fmt.Sprintf("批量创建 %d 个节点", len(results)))
|
||||
response.Success(c, results)
|
||||
}
|
||||
|
||||
// RotateToken 轮换节点的 agent token。
|
||||
func (h *NodeHandler) RotateToken(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
tok, err := h.service.RotateToken(c.Request.Context(), uint(id))
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
recordAudit(c, h.auditService, "node", "rotate_token", "node",
|
||||
fmt.Sprintf("%d", id), "",
|
||||
fmt.Sprintf("轮换节点 Token (ID: %d)", id))
|
||||
response.Success(c, gin.H{"newToken": tok})
|
||||
}
|
||||
|
||||
// CreateInstallToken 生成一次性安装令牌。
|
||||
func (h *NodeHandler) CreateInstallToken(c *gin.Context) {
|
||||
if h.installTokenSvc == nil {
|
||||
response.Error(c, apperror.New(stdhttp.StatusServiceUnavailable,
|
||||
"INSTALL_TOKEN_DISABLED", "一键部署未启用", nil))
|
||||
return
|
||||
}
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
var input struct {
|
||||
Mode string `json:"mode"`
|
||||
Arch string `json:"arch"`
|
||||
AgentVersion string `json:"agentVersion"`
|
||||
DownloadSrc string `json:"downloadSrc"`
|
||||
TTLSeconds int `json:"ttlSeconds"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
c.JSON(stdhttp.StatusBadRequest, gin.H{"code": "INVALID_INPUT", "message": err.Error()})
|
||||
return
|
||||
}
|
||||
// 默认值
|
||||
if input.Mode == "" {
|
||||
input.Mode = "systemd"
|
||||
}
|
||||
if input.Arch == "" {
|
||||
input.Arch = "auto"
|
||||
}
|
||||
if input.DownloadSrc == "" {
|
||||
input.DownloadSrc = "github"
|
||||
}
|
||||
if input.TTLSeconds == 0 {
|
||||
input.TTLSeconds = 900
|
||||
}
|
||||
|
||||
out, err := h.installTokenSvc.Create(c.Request.Context(), service.InstallTokenInput{
|
||||
NodeID: uint(id),
|
||||
Mode: input.Mode,
|
||||
Arch: input.Arch,
|
||||
AgentVersion: input.AgentVersion,
|
||||
DownloadSrc: input.DownloadSrc,
|
||||
TTLSeconds: input.TTLSeconds,
|
||||
CreatedByID: h.resolveCurrentUserID(c),
|
||||
})
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
recordAudit(c, h.auditService, "install_token", "create", "node",
|
||||
fmt.Sprintf("%d", id), out.Node.Name,
|
||||
fmt.Sprintf("生成 %s/%s install token TTL=%ds", input.Mode, input.Arch, input.TTLSeconds))
|
||||
|
||||
masterURL := resolveMasterURL(c, h.externalURL)
|
||||
body := gin.H{
|
||||
"installToken": out.Token,
|
||||
"expiresAt": out.ExpiresAt,
|
||||
"url": masterURL + "/install/" + out.Token,
|
||||
"composeUrl": "",
|
||||
}
|
||||
if input.Mode == "docker" {
|
||||
body["composeUrl"] = masterURL + "/install/" + out.Token + "/compose.yml"
|
||||
}
|
||||
response.Success(c, body)
|
||||
}
|
||||
|
||||
// PreviewScript 预览安装脚本(token 字段用 <AGENT_TOKEN> 占位,不消费 install token)。
|
||||
// 用于 UI Step 3 展开"脚本预览"。
|
||||
func (h *NodeHandler) PreviewScript(c *gin.Context) {
|
||||
mode := c.DefaultQuery("mode", "systemd")
|
||||
arch := c.DefaultQuery("arch", "auto")
|
||||
ver := c.Query("agentVersion")
|
||||
if ver == "" {
|
||||
c.JSON(stdhttp.StatusBadRequest, gin.H{"code": "INVALID_INPUT", "message": "agentVersion required"})
|
||||
return
|
||||
}
|
||||
src := c.DefaultQuery("downloadSrc", "github")
|
||||
ctx := installscript.Context{
|
||||
MasterURL: resolveMasterURL(c, h.externalURL),
|
||||
AgentToken: "<AGENT_TOKEN>",
|
||||
AgentVersion: ver,
|
||||
Mode: mode,
|
||||
Arch: arch,
|
||||
DownloadBase: installscript.DownloadBaseFor(src),
|
||||
InstallPrefix: "/opt/backupx-agent",
|
||||
}
|
||||
script, err := installscript.RenderScript(ctx)
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
c.Data(stdhttp.StatusOK, "text/x-shellscript; charset=utf-8", []byte(script))
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
stdhttp "net/http"
|
||||
|
||||
@@ -15,6 +16,9 @@ import (
|
||||
)
|
||||
|
||||
type RouterDependencies struct {
|
||||
// Context 控制 handler 启动的后台协程(如 ipLimiter GC)的生命周期。
|
||||
// app 应传入随进程退出可取消的 ctx;若为 nil 则退化为 context.Background()。
|
||||
Context context.Context
|
||||
Config config.Config
|
||||
Version string
|
||||
Logger *zap.Logger
|
||||
@@ -34,6 +38,8 @@ type RouterDependencies struct {
|
||||
JWTManager *security.JWTManager
|
||||
UserRepository repository.UserRepository
|
||||
SystemConfigRepo repository.SystemConfigRepository
|
||||
InstallTokenService *service.InstallTokenService
|
||||
MasterExternalURL string
|
||||
}
|
||||
|
||||
func NewRouter(deps RouterDependencies) *gin.Engine {
|
||||
@@ -141,7 +147,7 @@ func NewRouter(deps RouterDependencies) *gin.Engine {
|
||||
database.POST("/discover", databaseHandler.Discover)
|
||||
}
|
||||
|
||||
nodeHandler := NewNodeHandler(deps.NodeService, deps.AuditService)
|
||||
nodeHandler := NewNodeHandler(deps.NodeService, deps.AuditService, deps.InstallTokenService, deps.UserRepository, deps.MasterExternalURL)
|
||||
nodes := api.Group("/nodes")
|
||||
nodes.Use(AuthMiddleware(deps.JWTManager))
|
||||
nodes.GET("", nodeHandler.List)
|
||||
@@ -150,6 +156,10 @@ func NewRouter(deps RouterDependencies) *gin.Engine {
|
||||
nodes.PUT("/:id", nodeHandler.Update)
|
||||
nodes.DELETE("/:id", nodeHandler.Delete)
|
||||
nodes.GET("/:id/fs/list", nodeHandler.ListDirectory)
|
||||
nodes.POST("/batch", nodeHandler.BatchCreate)
|
||||
nodes.POST("/:id/install-tokens", nodeHandler.CreateInstallToken)
|
||||
nodes.POST("/:id/rotate-token", nodeHandler.RotateToken)
|
||||
nodes.GET("/:id/install-script-preview", nodeHandler.PreviewScript)
|
||||
|
||||
// Agent API(token 认证,无需 JWT)
|
||||
if deps.AgentService != nil {
|
||||
@@ -160,12 +170,27 @@ func NewRouter(deps RouterDependencies) *gin.Engine {
|
||||
agent.POST("/commands/:id/result", agentHandler.SubmitCommandResult)
|
||||
agent.GET("/tasks/:id", agentHandler.GetTaskSpec)
|
||||
agent.POST("/records/:id", agentHandler.UpdateRecord)
|
||||
|
||||
// Agent v1(安装脚本探活用),仅 Self 端点
|
||||
v1Agent := api.Group("/v1/agent")
|
||||
v1Agent.GET("/self", agentHandler.Self)
|
||||
} else {
|
||||
// 未启用 Agent 服务时,保留原有 heartbeat 端点以兼容
|
||||
api.POST("/agent/heartbeat", nodeHandler.Heartbeat)
|
||||
}
|
||||
}
|
||||
|
||||
// 公开安装路由(不走 JWT 中间件)
|
||||
if deps.InstallTokenService != nil {
|
||||
gcCtx := deps.Context
|
||||
if gcCtx == nil {
|
||||
gcCtx = context.Background()
|
||||
}
|
||||
installHandler := NewInstallHandler(gcCtx, deps.InstallTokenService, deps.AuditService, deps.MasterExternalURL)
|
||||
engine.GET("/install/:token", installHandler.Script)
|
||||
engine.GET("/install/:token/compose.yml", installHandler.Compose)
|
||||
}
|
||||
|
||||
engine.NoRoute(func(c *gin.Context) {
|
||||
response.Error(c, apperror.New(stdhttp.StatusNotFound, "NOT_FOUND", "接口不存在", errors.New("route not found")))
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user