Files
BackupX/server/internal/http/node_handler.go
Awuqing 34106556be 修复: 后端审查发现的 5 项问题
根据 Spec + Code Quality 双审查修复:

1. BatchCreate 事务保护(node_service.go/node_repository.go)
   原循环 Create 在 DB 约束失败时会残留半截数据。改为预先构造所有 Node
   再走 repo.BatchCreate 单一事务,任一失败整体回滚。

2. Peek 语义与 Consume 对齐(agent_install_token_repository.go)
   FindByToken 无条件返回任意记录,导致已消费/已过期的僵尸 token
   可通过 compose 端点的 mode 检查但必然 Consume 失败,出现 410 假错。
   新增 FindValidByToken,Peek 改用之。

3. MasterURL / AgentToken / AgentVersion 渲染前校验(installscript/renderer.go)
   防止 YAML 注入(换行/引号逃逸 compose 配置)、shell 注入($(...))、
   非法字符。加 TestRenderScriptRejects* 系列测试覆盖。

4. ipLimiter 无界增长修复(install_handler.go)
   新增 gc 方法 + startGC 后台协程,每 window 周期清理过期 IP 条目。
   RouterDependencies.Context 控制生命周期;app 传入 ctx,测试 t.Cleanup 取消。

5. CreateInstallToken 的 CreatedByID 从 JWT subject 解析(node_handler.go)
   原硬编码 0 导致审计不可追溯。新增 resolveCurrentUserID helper,
   借助 UserRepository 把 JWT subject(用户名)→ user.ID;失败退回 0。
2026-04-19 17:14:17 +08:00

304 lines
8.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package http
import (
"fmt"
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
installTokenSvc *service.InstallTokenService
userRepo repository.UserRepository
externalURL string
}
// 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) {
items, err := h.service.List(c.Request.Context())
if err != nil {
response.Error(c, err)
return
}
response.Success(c, items)
}
func (h *NodeHandler) Get(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
response.Error(c, err)
return
}
item, err := h.service.Get(c.Request.Context(), uint(id))
if err != nil {
response.Error(c, err)
return
}
response.Success(c, item)
}
func (h *NodeHandler) Create(c *gin.Context) {
var input service.NodeCreateInput
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(stdhttp.StatusBadRequest, gin.H{"code": "INVALID_INPUT", "message": err.Error()})
return
}
token, err := h.service.Create(c.Request.Context(), input)
if err != nil {
response.Error(c, err)
return
}
recordAudit(c, h.auditService, "node", "create", "node", "", input.Name,
fmt.Sprintf("创建远程节点「%s」", input.Name))
response.Success(c, gin.H{"token": token})
}
func (h *NodeHandler) Delete(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
response.Error(c, err)
return
}
if err := h.service.Delete(c.Request.Context(), uint(id)); err != nil {
response.Error(c, err)
return
}
recordAudit(c, h.auditService, "node", "delete", "node", fmt.Sprintf("%d", id), "",
fmt.Sprintf("删除节点 (ID: %d)", id))
response.Success(c, nil)
}
func (h *NodeHandler) ListDirectory(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
response.Error(c, err)
return
}
path := c.DefaultQuery("path", "/")
entries, err := h.service.ListDirectory(c.Request.Context(), uint(id), path)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, entries)
}
func (h *NodeHandler) Update(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
response.Error(c, err)
return
}
var input service.NodeUpdateInput
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(stdhttp.StatusBadRequest, gin.H{"code": "INVALID_INPUT", "message": err.Error()})
return
}
item, err := h.service.Update(c.Request.Context(), uint(id), input)
if err != nil {
response.Error(c, err)
return
}
recordAudit(c, h.auditService, "node", "update", "node", fmt.Sprintf("%d", id), item.Name,
fmt.Sprintf("更新节点「%s」(ID: %d)", item.Name, id))
response.Success(c, item)
}
func (h *NodeHandler) Heartbeat(c *gin.Context) {
var input struct {
Token string `json:"token" binding:"required"`
Hostname string `json:"hostname"`
IPAddress string `json:"ipAddress"`
AgentVersion string `json:"agentVersion"`
OS string `json:"os"`
Arch string `json:"arch"`
}
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(stdhttp.StatusBadRequest, gin.H{"code": "INVALID_INPUT", "message": err.Error()})
return
}
if err := h.service.Heartbeat(c.Request.Context(), input.Token, input.Hostname, input.IPAddress, input.AgentVersion, input.OS, input.Arch); err != nil {
response.Error(c, err)
return
}
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))
}