fix(nodes): resolve agent public address from master-observed source IP
NAT'd agents (cloud 1:1 EIP) only ever report their private interface address in heartbeat telemetry, so the node list showed an unusable RFC1918 address. The master now records the source address of every authenticated agent request (CF-Connecting-IP, else first X-Forwarded-For hop, else RemoteAddr; change-only writes) on the agent row as seen_ipv4/seen_ipv6, and the node list resolves the displayed pair via service.ResolveAgentAddresses: public telemetry wins, else a public observed source, else the reported address as-is. CGNAT 100.64/10 observed addresses are never promoted. Telemetry values are kept verbatim; resolution is read-time only, and agents need no changes.
This commit is contained in:
@@ -6,7 +6,9 @@ import (
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/config"
|
||||
@@ -95,6 +97,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 != "" {
|
||||
@@ -128,6 +193,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)
|
||||
@@ -252,6 +318,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 {
|
||||
@@ -281,6 +348,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
|
||||
@@ -339,6 +407,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)
|
||||
|
||||
Reference in New Issue
Block a user