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:
Muse
2026-10-22 15:25:08 +00:00
parent e310abcba0
commit c490bdcd76
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"
@@ -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)
@@ -0,0 +1,169 @@
package controller
import (
"bytes"
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
"github.com/gin-gonic/gin"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
)
// Public-address capture: every authenticated agent request records its
// source address on the agent row — CF-Connecting-IP first (agents come
// through Cloudflare), then the first X-Forwarded-For hop, then the
// transport RemoteAddr. Writes are change-only so the ~10s traffic
// cadence does not become a DB write per request.
func TestAgentSeenIPCapture(t *testing.T) {
gin.SetMode(gin.TestMode)
dbDir := t.TempDir()
t.Setenv("XUI_DB_FOLDER", dbDir)
if err := database.InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil {
t.Fatalf("InitDB: %v", err)
}
t.Cleanup(func() { _ = database.CloseDB() })
db := database.GetDB()
node := &model.Node{Name: "seen-ip-node", Address: ""}
if err := db.Create(node).Error; err != nil {
t.Fatalf("seed node: %v", err)
}
const token = "seen-ip-test-token"
agent := &model.AgentNode{
TokenHash: hashAgentToken(token),
TokenPrefix: "seen-ip-",
Name: "seen-ip-node",
NodeID: &node.Id,
}
if err := db.Create(agent).Error; err != nil {
t.Fatalf("seed agent: %v", err)
}
engine := gin.New()
NewAgentController(engine.Group("/panel/api/agent"))
heartbeat := func(headers map[string]string, remoteAddr string) int {
req := httptest.NewRequest(http.MethodPost, "/panel/api/agent/heartbeat", bytes.NewBufferString(`{}`))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
for k, v := range headers {
req.Header.Set(k, v)
}
if remoteAddr != "" {
req.RemoteAddr = remoteAddr
}
rec := httptest.NewRecorder()
engine.ServeHTTP(rec, req)
return rec.Code
}
load := func() model.AgentNode {
var row model.AgentNode
if err := db.First(&row, agent.Id).Error; err != nil {
t.Fatalf("reload agent: %v", err)
}
return row
}
// CF-Connecting-IP wins and is stored.
if code := heartbeat(map[string]string{"CF-Connecting-IP": "111.91.29.150"}, ""); code != http.StatusOK {
t.Fatalf("heartbeat with CF header: status %d", code)
}
row := load()
if row.SeenIPv4 != "111.91.29.150" || row.SeenIPAt == 0 {
t.Fatalf("CF source not stored: %+v", row)
}
// Change-only: same address again must not rewrite (SeenIPAt frozen).
frozen := row.SeenIPAt
if err := db.Model(&model.AgentNode{}).Where("id = ?", agent.Id).
Update("seen_ip_at", frozen).Error; err != nil {
t.Fatalf("pin seen_ip_at: %v", err)
}
if code := heartbeat(map[string]string{"CF-Connecting-IP": "111.91.29.150"}, ""); code != http.StatusOK {
t.Fatalf("repeat heartbeat: status %d", code)
}
if row = load(); row.SeenIPAt != frozen {
t.Fatalf("unchanged address rewrote the row: seen_ip_at %d -> %d", frozen, row.SeenIPAt)
}
// X-Forwarded-For first hop is next in precedence.
if code := heartbeat(map[string]string{"X-Forwarded-For": "203.0.113.9, 70.41.3.18"}, ""); code != http.StatusOK {
t.Fatalf("heartbeat with XFF: status %d", code)
}
if row = load(); row.SeenIPv4 != "203.0.113.9" {
t.Fatalf("XFF first hop not stored: %+v", row)
}
// CF beats XFF when both are present.
if code := heartbeat(map[string]string{
"CF-Connecting-IP": "111.91.29.150",
"X-Forwarded-For": "203.0.113.9",
}, ""); code != http.StatusOK {
t.Fatalf("heartbeat with both headers: status %d", code)
}
if row = load(); row.SeenIPv4 != "111.91.29.150" {
t.Fatalf("CF header did not win over XFF: %+v", row)
}
// A garbage CF value falls through to XFF.
if code := heartbeat(map[string]string{
"CF-Connecting-IP": "not-an-ip",
"X-Forwarded-For": "198.51.100.7",
}, ""); code != http.StatusOK {
t.Fatalf("heartbeat with garbage CF header: status %d", code)
}
if row = load(); row.SeenIPv4 != "198.51.100.7" {
t.Fatalf("garbage CF value did not fall through to XFF: %+v", row)
}
// No headers: the transport RemoteAddr is the fallback.
if code := heartbeat(nil, "198.51.100.23:5555"); code != http.StatusOK {
t.Fatalf("heartbeat without headers: status %d", code)
}
if row = load(); row.SeenIPv4 != "198.51.100.23" {
t.Fatalf("RemoteAddr fallback not stored: %+v", row)
}
// IPv6 sources land in the v6 column.
if code := heartbeat(map[string]string{"CF-Connecting-IP": "2602:f32b:1:310f::1"}, ""); code != http.StatusOK {
t.Fatalf("heartbeat with v6 CF header: status %d", code)
}
if row = load(); row.SeenIPv6 != "2602:f32b:1:310f::1" {
t.Fatalf("v6 source not stored: %+v", row)
}
// Unauthenticated requests capture nothing.
if err := db.Model(&model.AgentNode{}).Where("id = ?", agent.Id).
Updates(map[string]any{"seen_ipv4": "", "seen_ipv6": ""}).Error; err != nil {
t.Fatalf("reset seen: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/panel/api/agent/heartbeat", bytes.NewBufferString(`{}`))
req.Header.Set("Authorization", "Bearer wrong-token")
req.Header.Set("CF-Connecting-IP", "111.91.29.150")
rec := httptest.NewRecorder()
engine.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("bad token heartbeat: status %d, want 401", rec.Code)
}
if row = load(); row.SeenIPv4 != "" || row.SeenIPv6 != "" {
t.Fatalf("unauthenticated request captured a source: %+v", row)
}
// Register is an authenticated agent request too: it captures.
regReq := httptest.NewRequest(http.MethodPost, "/panel/api/agent/register",
bytes.NewBufferString(`{"token":"`+token+`","name":"seen-ip-node"}`))
regReq.Header.Set("Content-Type", "application/json")
regReq.Header.Set("CF-Connecting-IP", "111.91.29.150")
regRec := httptest.NewRecorder()
engine.ServeHTTP(regRec, regReq)
if regRec.Code != http.StatusOK {
t.Fatalf("register: status %d", regRec.Code)
}
if row = load(); row.SeenIPv4 != "111.91.29.150" {
t.Fatalf("register did not capture source: %+v", row)
}
}