feat: 优化集群部署与堡垒机接入 (#106)

支持受限网络、正向代理、私有 CA 与 SSH 堡垒机部署 Agent。

加固 Docker、systemd、Nginx、安装器、Release 校验与可信代理边界,并完善命令队列索引、前端安装向导及中英文运维文档。
This commit is contained in:
Wu Qing
2026-08-09 02:45:17 +08:00
committed by GitHub
parent 00151e466c
commit 5827074334
86 changed files with 4668 additions and 3082 deletions
+5 -5
View File
@@ -23,7 +23,7 @@ func NewAgentHandler(agentService *service.AgentService, nodeService *service.No
return &AgentHandler{agentService: agentService, nodeService: nodeService, restoreService: restoreService}
}
// extractToken 从请求头或 JSON body 中提取 Agent Token。
// extractToken 从认证请求头中提取 Agent Token。
func extractToken(c *gin.Context) string {
if t := strings.TrimSpace(c.GetHeader("X-Agent-Token")); t != "" {
return t
@@ -46,10 +46,10 @@ func (h *AgentHandler) Heartbeat(c *gin.Context) {
Arch string `json:"arch"`
}
_ = c.ShouldBindJSON(&input)
// token 优先走 body(向后兼容),否则从 header 读
token := input.Token
// 新版 Agent 只通过请求头发送 Token;JSON body 仅保留旧版本兼容。
token := extractToken(c)
if token == "" {
token = extractToken(c)
token = input.Token
}
if token == "" {
c.JSON(stdhttp.StatusBadRequest, gin.H{"code": "INVALID_INPUT", "message": "missing token"})
@@ -72,7 +72,7 @@ func (h *AgentHandler) Heartbeat(c *gin.Context) {
})
}
// Poll Agent 长轮询获取下一条待执行命令。
// Poll Agent 获取下一条待执行命令Agent 按配置间隔主动轮询
// 无命令时返回 {command: null}。
func (h *AgentHandler) Poll(c *gin.Context) {
node, err := h.agentService.AuthenticatedNode(c.Request.Context(), extractToken(c))
@@ -0,0 +1,49 @@
package http
import (
stdhttp "net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
)
func TestForwardedHeadersMiddlewareRejectsUntrustedHeaders(t *testing.T) {
gin.SetMode(gin.TestMode)
engine := gin.New()
engine.Use(ForwardedHeadersMiddleware([]string{"127.0.0.1", "10.0.0.0/8"}))
engine.GET("/master-url", func(c *gin.Context) {
c.String(stdhttp.StatusOK, resolveMasterURL(c, ""))
})
request := httptest.NewRequest(stdhttp.MethodGet, "http://master.example.com/master-url", nil)
request.RemoteAddr = "203.0.113.10:54321"
request.Header.Set("X-Forwarded-Host", "attacker.example.com")
request.Header.Set("X-Forwarded-Proto", "https")
recorder := httptest.NewRecorder()
engine.ServeHTTP(recorder, request)
if recorder.Body.String() != "http://master.example.com" {
t.Fatalf("untrusted forwarding headers changed URL: %q", recorder.Body.String())
}
}
func TestForwardedHeadersMiddlewareAcceptsTrustedProxy(t *testing.T) {
gin.SetMode(gin.TestMode)
engine := gin.New()
engine.Use(ForwardedHeadersMiddleware([]string{"10.0.0.0/8"}))
engine.GET("/master-url", func(c *gin.Context) {
c.String(stdhttp.StatusOK, resolveMasterURL(c, ""))
})
request := httptest.NewRequest(stdhttp.MethodGet, "http://backupx:8340/master-url", nil)
request.RemoteAddr = "10.10.0.5:43210"
request.Header.Set("X-Forwarded-Host", "backup.example.com")
request.Header.Set("X-Forwarded-Proto", "https")
recorder := httptest.NewRecorder()
engine.ServeHTTP(recorder, request)
if recorder.Body.String() != "https://backup.example.com" {
t.Fatalf("trusted forwarding headers were ignored: %q", recorder.Body.String())
}
}
+29 -15
View File
@@ -115,7 +115,7 @@ func setupInstallFlowRouterWithExternalURL(t *testing.T, externalURL string) (ht
return router, setupResp.Data.Token
}
func TestInstallTokenUsesConfiguredExternalURL(t *testing.T) {
func TestInstallTokenUsesAgentSpecificURLAndConnectionSettings(t *testing.T) {
const externalURL = "https://public.example.com/base"
router, jwt := setupInstallFlowRouterWithExternalURL(t, externalURL)
@@ -141,11 +141,14 @@ func TestInstallTokenUsesConfiguredExternalURL(t *testing.T) {
}
genBody, _ := json.Marshal(map[string]any{
"mode": "systemd",
"arch": "auto",
"agentVersion": "v1.7.0",
"downloadSrc": "github",
"ttlSeconds": 900,
"mode": "systemd",
"arch": "auto",
"agentVersion": "v1.7.0",
"downloadSrc": "github",
"ttlSeconds": 900,
"agentMasterUrl": "http://127.0.0.1:18340",
"proxyUrl": "socks5h://127.0.0.1:1080",
"caCertFile": "/etc/pki/internal-ca.pem",
})
genReq := httptest.NewRequest(http.MethodPost,
"/api/nodes/"+formatUint(batchResp.Data[0].ID)+"/install-tokens", bytes.NewBuffer(genBody))
@@ -156,6 +159,9 @@ func TestInstallTokenUsesConfiguredExternalURL(t *testing.T) {
if genRec.Code != 200 {
t.Fatalf("install-tokens failed: %d %s", genRec.Code, genRec.Body.String())
}
if genRec.Header().Get("Cache-Control") != "no-store" {
t.Fatalf("install-token response must not be cached: %#v", genRec.Header())
}
var genResp struct {
Data struct {
InstallToken string `json:"installToken"`
@@ -167,18 +173,21 @@ func TestInstallTokenUsesConfiguredExternalURL(t *testing.T) {
if err := json.Unmarshal(genRec.Body.Bytes(), &genResp); err != nil {
t.Fatalf("unmarshal gen: %v", err)
}
if genResp.Data.URL != externalURL+"/api/install/"+genResp.Data.InstallToken {
t.Fatalf("url should use external URL, got %q", genResp.Data.URL)
if genResp.Data.URL != "http://127.0.0.1:18340/api/install/"+genResp.Data.InstallToken {
t.Fatalf("url should use Agent-specific URL, got %q", genResp.Data.URL)
}
if genResp.Data.FallbackURL != externalURL+"/install/"+genResp.Data.InstallToken {
t.Fatalf("fallbackUrl should use external URL, got %q", genResp.Data.FallbackURL)
if genResp.Data.FallbackURL != "http://127.0.0.1:18340/install/"+genResp.Data.InstallToken {
t.Fatalf("fallbackUrl should use Agent-specific URL, got %q", genResp.Data.FallbackURL)
}
decodedScript, err := base64.StdEncoding.DecodeString(genResp.Data.ScriptBase64)
if err != nil {
t.Fatalf("scriptBase64 should be valid base64: %v", err)
}
if !strings.Contains(string(decodedScript), `MASTER_URL="`+externalURL+`"`) {
t.Fatalf("script should use external MASTER_URL:\n%s", string(decodedScript))
if !strings.Contains(string(decodedScript), `MASTER_URL="http://127.0.0.1:18340"`) {
t.Fatalf("script should use the Agent-specific Master URL:\n%s", string(decodedScript))
}
if !strings.Contains(string(decodedScript), `PROXY_URL="socks5h://127.0.0.1:1080"`) || !strings.Contains(string(decodedScript), `CA_CERT_FILE="/etc/pki/internal-ca.pem"`) {
t.Fatalf("script should include restricted-network settings:\n%s", string(decodedScript))
}
}
@@ -258,8 +267,9 @@ func TestOneClickInstallFlow(t *testing.T) {
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())
if !strings.Contains(scriptRec.Body.String(), "systemctl enable backupx-agent") ||
!strings.Contains(scriptRec.Body.String(), "systemctl restart backupx-agent") {
t.Fatalf("script missing systemctl enable/restart:\n%s", scriptRec.Body.String())
}
// Issue #46 防嗅探 headerstext/plain + nosniff + no-store + Content-Disposition
if ct := scriptRec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/plain") {
@@ -354,7 +364,8 @@ func TestInstallScriptAliasUnderAPI(t *testing.T) {
if aliasRec.Code != 200 {
t.Fatalf("/api/install alias failed: %d %s", aliasRec.Code, aliasRec.Body.String())
}
if !strings.Contains(aliasRec.Body.String(), "systemctl enable --now backupx-agent") {
if !strings.Contains(aliasRec.Body.String(), "systemctl enable backupx-agent") ||
!strings.Contains(aliasRec.Body.String(), "systemctl restart backupx-agent") {
t.Errorf("alias should return rendered script, got:\n%s", aliasRec.Body.String())
}
if ct := aliasRec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/plain") {
@@ -557,6 +568,9 @@ func TestInstallFlowComposeSuccessConsumesToken(t *testing.T) {
if !strings.Contains(composeRec.Body.String(), "BACKUPX_AGENT_TOKEN") {
t.Fatalf("compose missing token env:\n%s", composeRec.Body.String())
}
if composeRec.Header().Get("Cache-Control") != "no-store" || composeRec.Header().Get("X-Content-Type-Options") != "nosniff" {
t.Fatalf("compose response missing secret-safe headers: %#v", composeRec.Header())
}
scriptReq := httptest.NewRequest(http.MethodGet, "/api/install/"+genResp.Data.InstallToken, nil)
scriptRec := httptest.NewRecorder()
+17 -2
View File
@@ -104,16 +104,22 @@ func (h *InstallHandler) Compose(c *gin.Context) {
}
h.recordConsumeAudit(c, consumed, "compose")
yaml, err := installscript.RenderComposeYaml(installscript.Context{
MasterURL: resolveMasterURL(c, h.externalURL),
MasterURL: installMasterURL(resolveMasterURL(c, h.externalURL), consumed.Record),
AgentToken: consumed.Node.Token,
AgentVersion: consumed.Record.AgentVer,
Mode: model.InstallModeDocker,
Arch: consumed.Record.Arch,
NodeID: consumed.Node.ID,
ProxyURL: consumed.Record.ProxyURL,
CACertFile: consumed.Record.CACertFile,
})
if err != nil {
c.String(stdhttp.StatusInternalServerError, "render error\n")
return
}
c.Header("X-Content-Type-Options", "nosniff")
c.Header("Cache-Control", "no-store")
c.Header("Content-Disposition", `attachment; filename="backupx-agent-compose.yml"`)
c.Data(stdhttp.StatusOK, "text/yaml; charset=utf-8", []byte(yaml))
}
@@ -134,7 +140,7 @@ func (h *InstallHandler) recordConsumeAudit(c *gin.Context, consumed *service.Co
func renderInstallScript(masterURL string, node *model.Node, record *model.AgentInstallToken) (string, error) {
return installscript.RenderScript(installscript.Context{
MasterURL: masterURL,
MasterURL: installMasterURL(masterURL, record),
AgentToken: node.Token,
AgentVersion: record.AgentVer,
Mode: record.Mode,
@@ -142,9 +148,18 @@ func renderInstallScript(masterURL string, node *model.Node, record *model.Agent
DownloadBase: installscript.DownloadBaseFor(record.DownloadSrc),
InstallPrefix: "/opt/backupx-agent",
NodeID: node.ID,
ProxyURL: record.ProxyURL,
CACertFile: record.CACertFile,
})
}
func installMasterURL(fallback string, record *model.AgentInstallToken) string {
if record != nil && strings.TrimSpace(record.AgentMasterURL) != "" {
return strings.TrimRight(strings.TrimSpace(record.AgentMasterURL), "/")
}
return fallback
}
// resolveMasterURL 按优先级推导 Master URL:外部配置 > X-Forwarded-* > Request.Host。
// 此为包级 helper,供 install_handler 和 node_handler 共用。
func resolveMasterURL(c *gin.Context, externalURL string) string {
+41
View File
@@ -3,6 +3,7 @@ package http
import (
"context"
stdhttp "net/http"
"net/netip"
"strings"
"backupx/server/internal/apperror"
@@ -11,6 +12,46 @@ import (
"github.com/gin-gonic/gin"
)
// ForwardedHeadersMiddleware 只允许配置中的反向代理提供转发头。
// Gin 的 trusted_proxies 保护 ClientIP;这里同步保护安装命令使用的
// X-Forwarded-Host 与 X-Forwarded-Proto,避免直连请求伪造 Agent 地址。
func ForwardedHeadersMiddleware(trustedProxies []string) gin.HandlerFunc {
trustedPrefixes := make([]netip.Prefix, 0, len(trustedProxies))
for _, raw := range trustedProxies {
raw = strings.TrimSpace(raw)
if prefix, err := netip.ParsePrefix(raw); err == nil {
trustedPrefixes = append(trustedPrefixes, prefix)
continue
}
if addr, err := netip.ParseAddr(raw); err == nil {
trustedPrefixes = append(trustedPrefixes, netip.PrefixFrom(addr, addr.BitLen()))
}
}
return func(c *gin.Context) {
remote, err := netip.ParseAddrPort(c.Request.RemoteAddr)
trusted := false
if err == nil {
remoteAddr := remote.Addr().Unmap()
for _, prefix := range trustedPrefixes {
if prefix.Contains(remoteAddr) {
trusted = true
break
}
}
}
if !trusted {
for _, header := range []string{
"Forwarded", "X-Forwarded-For", "X-Forwarded-Host",
"X-Forwarded-Port", "X-Forwarded-Proto", "X-Real-IP",
} {
c.Request.Header.Del(header)
}
}
c.Next()
}
}
// CORSMiddleware handles Cross-Origin Resource Sharing for the API.
func CORSMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
+28 -14
View File
@@ -204,6 +204,7 @@ func (h *NodeHandler) RotateToken(c *gin.Context) {
recordAudit(c, h.auditService, "node", "rotate_token", "node",
fmt.Sprintf("%d", id), "",
fmt.Sprintf("轮换节点 Token (ID: %d)", id))
c.Header("Cache-Control", "no-store")
response.Success(c, gin.H{"newToken": tok})
}
@@ -220,11 +221,14 @@ func (h *NodeHandler) CreateInstallToken(c *gin.Context) {
return
}
var input struct {
Mode string `json:"mode"`
Arch string `json:"arch"`
AgentVersion string `json:"agentVersion"`
DownloadSrc string `json:"downloadSrc"`
TTLSeconds int `json:"ttlSeconds"`
Mode string `json:"mode"`
Arch string `json:"arch"`
AgentVersion string `json:"agentVersion"`
DownloadSrc string `json:"downloadSrc"`
TTLSeconds int `json:"ttlSeconds"`
AgentMasterURL string `json:"agentMasterUrl"`
ProxyURL string `json:"proxyUrl"`
CACertFile string `json:"caCertFile"`
}
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(stdhttp.StatusBadRequest, gin.H{"code": "INVALID_INPUT", "message": err.Error()})
@@ -246,13 +250,16 @@ func (h *NodeHandler) CreateInstallToken(c *gin.Context) {
out, err := h.installTokenSvc.CreateCommand(c.Request.Context(), service.InstallCommandInput{
InstallTokenInput: service.InstallTokenInput{
NodeID: uint(id),
Mode: input.Mode,
Arch: input.Arch,
AgentVersion: input.AgentVersion,
DownloadSrc: input.DownloadSrc,
TTLSeconds: input.TTLSeconds,
CreatedByID: h.resolveCurrentUserID(c),
NodeID: uint(id),
Mode: input.Mode,
Arch: input.Arch,
AgentVersion: input.AgentVersion,
DownloadSrc: input.DownloadSrc,
TTLSeconds: input.TTLSeconds,
CreatedByID: h.resolveCurrentUserID(c),
AgentMasterURL: input.AgentMasterURL,
ProxyURL: input.ProxyURL,
CACertFile: input.CACertFile,
},
MasterURL: resolveMasterURL(c, h.externalURL),
})
@@ -278,6 +285,7 @@ func (h *NodeHandler) CreateInstallToken(c *gin.Context) {
"composeUrl": out.ComposeURL,
"fallbackComposeUrl": out.FallbackComposeURL,
}
c.Header("Cache-Control", "no-store")
response.Success(c, body)
}
@@ -292,18 +300,24 @@ func (h *NodeHandler) PreviewScript(c *gin.Context) {
return
}
src := c.DefaultQuery("downloadSrc", "github")
agentMasterURL := c.Query("agentMasterUrl")
if agentMasterURL == "" {
agentMasterURL = resolveMasterURL(c, h.externalURL)
}
ctx := installscript.Context{
MasterURL: resolveMasterURL(c, h.externalURL),
MasterURL: agentMasterURL,
AgentToken: "<AGENT_TOKEN>",
AgentVersion: ver,
Mode: mode,
Arch: arch,
DownloadBase: installscript.DownloadBaseFor(src),
InstallPrefix: "/opt/backupx-agent",
ProxyURL: c.Query("proxyUrl"),
CACertFile: c.Query("caCertFile"),
}
script, err := installscript.RenderScript(ctx)
if err != nil {
response.Error(c, err)
response.Error(c, apperror.BadRequest("INSTALL_TOKEN_INVALID", "Agent 连接配置无效", err))
return
}
c.Data(stdhttp.StatusOK, "text/x-shellscript; charset=utf-8", []byte(script))
+4
View File
@@ -61,7 +61,11 @@ type RouterDependencies struct {
func NewRouter(deps RouterDependencies) *gin.Engine {
gin.SetMode(deps.Config.Server.Mode)
engine := gin.New()
if err := engine.SetTrustedProxies(deps.Config.Server.TrustedProxies); err != nil {
panic("invalid trusted proxy configuration: " + err.Error())
}
engine.Use(gin.Recovery())
engine.Use(ForwardedHeadersMiddleware(deps.Config.Server.TrustedProxies))
engine.Use(CORSMiddleware())
engine.Use(requestLogger(deps.Logger))