feat(cluster): support restricted-network agent connectivity

This commit is contained in:
Awuqing
2026-08-09 02:30:42 +08:00
parent 00151e466c
commit ea46a30f11
13 changed files with 481 additions and 22 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())
}
}
+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) {
+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))