merge fix/agent-public-addr: master-observed public IP resolution (V1.0.1)

This commit is contained in:
Muse
2026-10-23 00:22:58 +08:00
6 changed files with 527 additions and 10 deletions
+69
View File
@@ -6,7 +6,9 @@ import (
"encoding/hex"
"encoding/json"
"errors"
"net"
"strconv"
"strings"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/config"
@@ -100,6 +102,69 @@ func (a *AgentController) findAgent(token string) (*model.AgentNode, bool) {
return &agent, true
}
// requestSourceIP extracts the real source IP of an agent request.
// Agents reach the master through Cloudflare, so the TCP peer is a CF
// edge and the agent's own address travels in CF-Connecting-IP; the
// first X-Forwarded-For hop is the next best signal, and the transport
// RemoteAddr is the direct-connection fallback. Only literal IPs are
// accepted — anything unparsable falls through to the next source.
func requestSourceIP(c *gin.Context) net.IP {
var candidates []string
if v := c.GetHeader("CF-Connecting-IP"); v != "" {
candidates = append(candidates, v)
}
if v := c.GetHeader("X-Forwarded-For"); v != "" {
candidates = append(candidates, strings.Split(v, ",")[0])
}
if host, _, err := net.SplitHostPort(c.Request.RemoteAddr); err == nil {
candidates = append(candidates, host)
} else {
candidates = append(candidates, c.Request.RemoteAddr)
}
for _, cand := range candidates {
if ip := net.ParseIP(strings.TrimSpace(cand)); ip != nil {
return ip
}
}
return nil
}
// captureSeenIP records the source address of an authenticated agent
// request on the agent row (SeenIPv4/SeenIPv6). This is the master's
// view of the agent's public side: for a NAT'd agent it is the only
// place the public address ever appears. Writes are change-only —
// traffic reports land every ~10s and the address almost never moves,
// so an unchanged address must not turn into a DB write per request.
func (a *AgentController) captureSeenIP(c *gin.Context, agent *model.AgentNode) {
ip := requestSourceIP(c)
if ip == nil || agent == nil {
return
}
updates := map[string]any{"seen_ip_at": time.Now().UnixMilli()}
if v4 := ip.To4(); v4 != nil {
if agent.SeenIPv4 == v4.String() {
return
}
updates["seen_ipv4"] = v4.String()
} else {
if agent.SeenIPv6 == ip.String() {
return
}
updates["seen_ipv6"] = ip.String()
}
if err := database.GetDB().Model(&model.AgentNode{}).
Where("id = ?", agent.Id).Updates(updates).Error; err != nil {
return
}
if v, ok := updates["seen_ipv4"]; ok {
agent.SeenIPv4 = v.(string)
}
if v, ok := updates["seen_ipv6"]; ok {
agent.SeenIPv6 = v.(string)
}
agent.SeenIPAt = updates["seen_ip_at"].(int64)
}
func (a *AgentController) touchAgent(id uint, name string) {
updates := map[string]any{"last_seen": time.Now().UnixMilli()}
if name != "" {
@@ -133,6 +198,7 @@ func (a *AgentController) register(c *gin.Context) {
c.JSON(401, gin.H{"message": "unknown token"})
return
}
a.captureSeenIP(c, agent)
a.touchAgent(uint(agent.Id), req.Name)
logger.Infof("Agent registered: %s (token %s...)", req.Name, agent.TokenPrefix)
jsonObj(c, gin.H{"message": "registered"}, nil)
@@ -257,6 +323,7 @@ func (a *AgentController) heartbeat(c *gin.Context) {
c.JSON(401, gin.H{"message": "unauthorized"})
return
}
a.captureSeenIP(c, agent)
// Telemetry is optional and best-effort: a body that fails to parse
// (or no body at all) must never fail the heartbeat itself.
var req struct {
@@ -286,6 +353,7 @@ func (a *AgentController) getConfig(c *gin.Context) {
c.JSON(401, gin.H{"message": "unauthorized"})
return
}
a.captureSeenIP(c, agent)
if agent.NodeID == nil {
c.JSON(409, gin.H{"message": "agent token is not bound to a node"})
return
@@ -344,6 +412,7 @@ func (a *AgentController) reportTraffic(c *gin.Context) {
c.JSON(401, gin.H{"message": "unauthorized"})
return
}
a.captureSeenIP(c, agent)
var req agentTrafficReport
if err := c.ShouldBindJSON(&req); err != nil {
jsonMsg(c, "Invalid request", err)