From c490bdcd76f111c4f199eb6077273e4ae0ac3bcd Mon Sep 17 00:00:00 2001 From: Muse Date: Thu, 22 Oct 2026 15:25:08 +0000 Subject: [PATCH] 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. --- internal/database/model/agent_node.go | 14 ++ internal/web/controller/agent.go | 69 +++++++ internal/web/controller/agent_seen_ip_test.go | 169 ++++++++++++++++++ internal/web/service/agent_address.go | 92 ++++++++++ internal/web/service/agent_address_test.go | 164 +++++++++++++++++ internal/web/service/node_contract.go | 29 +-- 6 files changed, 527 insertions(+), 10 deletions(-) create mode 100644 internal/web/controller/agent_seen_ip_test.go create mode 100644 internal/web/service/agent_address.go create mode 100644 internal/web/service/agent_address_test.go diff --git a/internal/database/model/agent_node.go b/internal/database/model/agent_node.go index 44de167..11f104b 100644 --- a/internal/database/model/agent_node.go +++ b/internal/database/model/agent_node.go @@ -48,6 +48,20 @@ type AgentNode struct { // a telemetry object. TelemetryAt int64 `json:"telemetryAt"` + // Seen source addresses (public-address resolution): the source IP of + // the agent's own authenticated requests, as observed by the master — + // the CF-Connecting-IP header when the agent comes through Cloudflare, + // else the direct peer address. An agent behind 1:1 NAT reports only + // its private interface address in telemetry (its public address is + // not assigned to any local interface, so enumeration can never see + // it); the observed source address is the master's view of the + // agent's public side. Writes are change-only (see + // AgentController.captureSeenIP); SeenIPAt is the unix-milli time of + // the last change, not of the last request. + SeenIPv4 string `json:"seenIpv4"` + SeenIPv6 string `json:"seenIpv6"` + SeenIPAt int64 `json:"seenIpAt"` + // One-click update (item 20): the master records an update intent here; // the agent picks it up from its config poll, downloads + verifies + // swaps its binary, restarts itself and reports the outcome back on diff --git a/internal/web/controller/agent.go b/internal/web/controller/agent.go index fb9be6d..9d43100 100644 --- a/internal/web/controller/agent.go +++ b/internal/web/controller/agent.go @@ -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) diff --git a/internal/web/controller/agent_seen_ip_test.go b/internal/web/controller/agent_seen_ip_test.go new file mode 100644 index 0000000..d635352 --- /dev/null +++ b/internal/web/controller/agent_seen_ip_test.go @@ -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) + } +} diff --git a/internal/web/service/agent_address.go b/internal/web/service/agent_address.go new file mode 100644 index 0000000..c7924fa --- /dev/null +++ b/internal/web/service/agent_address.go @@ -0,0 +1,92 @@ +package service + +import ( + "net" + "strings" + + "github.com/mhsanaei/3x-ui/v3/internal/database/model" +) + +// Public-address resolution for agent nodes. +// +// An agent reports the addresses of its own network interfaces in its +// heartbeat telemetry (P13). On a host behind 1:1 NAT (typical cloud +// EIP setups) the public address is mapped in the provider's network +// and never assigned to a local interface, so the agent can only ever +// report its private address — the node list then shows an unusable +// RFC1918 address for such nodes. The master, however, observes the +// source address of every authenticated agent request (recorded on the +// agent row as SeenIPv4/SeenIPv6 by AgentController.captureSeenIP). +// ResolveAgentAddresses combines both views into the address pair a +// client can actually connect to. + +// IsPublicIPLiteral reports whether s is an IP literal that is globally +// routable: not loopback, link-local, multicast, unspecified, RFC1918, +// ULA, or the CGNAT shared range 100.64.0.0/10 (RFC 6598 — not private, +// but not publicly routable either). Go's IsGlobalUnicast alone is not +// sufficient for this: it counts RFC1918 addresses as global unicast. +// +// Exported for reuse by the subscription/export chain. +func IsPublicIPLiteral(s string) bool { + ip := net.ParseIP(strings.TrimSpace(s)) + if ip == nil { + return false + } + if ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || + ip.IsMulticast() || ip.IsUnspecified() || ip.IsPrivate() { + return false + } + if v4 := ip.To4(); v4 != nil && v4[0] == 100 && v4[1]&0xC0 == 0x40 { + return false // 100.64.0.0/10, CGNAT shared address space + } + return true +} + +// familyLiteral returns s normalized as an IP literal of the wanted +// family, or "" when s is not a valid literal of that family. +func familyLiteral(s string, want4 bool) string { + ip := net.ParseIP(strings.TrimSpace(s)) + if ip == nil { + return "" + } + if want4 { + if v4 := ip.To4(); v4 != nil { + return v4.String() + } + return "" + } + if ip.To4() != nil { + return "" + } + return ip.String() +} + +// resolveAddressFamily picks the externally usable address of one +// family: the agent-reported address when it is public; otherwise the +// master-observed source address when IT is public; otherwise the +// reported address as-is (a private telemetry address still beats +// nothing, e.g. for LAN-only deployments). A non-public observed +// address is never promoted over the reported one. +func resolveAddressFamily(reported, seen string, want4 bool) string { + rep := familyLiteral(reported, want4) + if IsPublicIPLiteral(rep) { + return rep + } + if sn := familyLiteral(seen, want4); IsPublicIPLiteral(sn) { + return sn + } + return rep +} + +// ResolveAgentAddresses returns the externally usable IPv4/IPv6 pair +// for an agent binding, resolved from its telemetry addresses and the +// source addresses the master observed on its requests. This is the +// single resolution point shared by the node list (agent row address) +// and the subscription/export chain (advertised connect address). +func ResolveAgentAddresses(ag *model.AgentNode) (ipv4, ipv6 string) { + if ag == nil { + return "", "" + } + return resolveAddressFamily(ag.IPv4, ag.SeenIPv4, true), + resolveAddressFamily(ag.IPv6, ag.SeenIPv6, false) +} diff --git a/internal/web/service/agent_address_test.go b/internal/web/service/agent_address_test.go new file mode 100644 index 0000000..b2ce0e7 --- /dev/null +++ b/internal/web/service/agent_address_test.go @@ -0,0 +1,164 @@ +package service + +import ( + "testing" + + "github.com/mhsanaei/3x-ui/v3/internal/database" + "github.com/mhsanaei/3x-ui/v3/internal/database/model" +) + +// Public-address resolution: the node list must show the address a +// client can actually connect to. An agent behind 1:1 NAT only ever +// reports its private interface address; the master-observed source +// address (SeenIPv4/SeenIPv6) is the public side. Resolution order per +// family: public telemetry > public observed > telemetry as-is. + +func TestIsPublicIPLiteral(t *testing.T) { + cases := map[string]bool{ + "8.8.8.8": true, + "111.91.29.150": true, + "203.0.113.7": true, // documentation range: not excluded (codebase treats it as public) + "2602:f32b:1:310f::1": true, + "2001:db8::7": true, + "10.0.0.1": false, // RFC1918 + "172.31.2.240": false, // RFC1918 (the HWSG case) + "192.168.1.1": false, // RFC1918 + "100.64.1.2": false, // CGNAT shared space + "100.127.255.254": false, // CGNAT shared space, top edge + "127.0.0.1": false, // loopback + "169.254.1.1": false, // link-local + "fd00::1": false, // ULA + "fe80::1": false, // link-local v6 + "::1": false, + "::": false, + "224.0.0.1": false, // multicast + "": false, + "not-an-ip": false, + "999.1.1.1": false, + } + for in, want := range cases { + if got := IsPublicIPLiteral(in); got != want { + t.Errorf("IsPublicIPLiteral(%q) = %v, want %v", in, got, want) + } + } +} + +func TestResolveAgentAddresses(t *testing.T) { + cases := []struct { + name string + ag *model.AgentNode + want4, want6 string + }{ + { + name: "telemetry public wins over seen public", + ag: &model.AgentNode{IPv4: "140.235.229.19", SeenIPv4: "8.8.8.8"}, + want4: "140.235.229.19", + }, + { + name: "NAT: private telemetry, public seen", + ag: &model.AgentNode{IPv4: "172.31.2.240", SeenIPv4: "111.91.29.150"}, + want4: "111.91.29.150", + }, + { + name: "both private: telemetry fallback", + ag: &model.AgentNode{IPv4: "172.31.2.240", SeenIPv4: "192.168.0.8"}, + want4: "172.31.2.240", + }, + { + name: "no telemetry yet, public seen", + ag: &model.AgentNode{SeenIPv4: "111.91.29.150"}, + want4: "111.91.29.150", + }, + { + name: "CGNAT seen is not trusted", + ag: &model.AgentNode{IPv4: "172.31.2.240", SeenIPv4: "100.64.1.2"}, + want4: "172.31.2.240", + }, + { + name: "v6 only, telemetry public", + ag: &model.AgentNode{IPv6: "2602:f32b:1:310f::1"}, + want6: "2602:f32b:1:310f::1", + }, + { + name: "v6 ULA telemetry, public seen", + ag: &model.AgentNode{IPv6: "fd00::1", SeenIPv6: "2401:1da0:22:148::a"}, + want6: "2401:1da0:22:148::a", + }, + { + name: "garbage telemetry ignored", + ag: &model.AgentNode{IPv4: "not-an-ip", SeenIPv4: "111.91.29.150"}, + want4: "111.91.29.150", + }, + { + name: "families resolve independently", + ag: &model.AgentNode{IPv4: "140.235.229.19", IPv6: "fd00::9", SeenIPv6: "2401:1da0:22:148::a"}, + want4: "140.235.229.19", + want6: "2401:1da0:22:148::a", + }, + { + name: "nothing known", + ag: &model.AgentNode{}, + }, + { + name: "nil agent", + ag: nil, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + v4, v6 := ResolveAgentAddresses(tc.ag) + if v4 != tc.want4 || v6 != tc.want6 { + t.Fatalf("ResolveAgentAddresses(%+v) = (%q, %q), want (%q, %q)", + tc.ag, v4, v6, tc.want4, tc.want6) + } + }) + } +} + +// Display: the node list row address comes from ResolveAgentAddresses, +// including for an agent that has not reported telemetry yet. +func TestAnnotateAgentTelemetryResolvedAddress(t *testing.T) { + setupConflictDB(t) + db := database.GetDB() + + natNode := &model.Node{Id: 7501, Name: "nat-agent-node", Address: ""} + preNode := &model.Node{Id: 7502, Name: "pre-telemetry-agent-node", Address: ""} + for _, n := range []*model.Node{natNode, preNode} { + if err := db.Create(n).Error; err != nil { + t.Fatalf("create node %d: %v", n.Id, err) + } + } + nat := &model.AgentNode{ + TokenHash: "hash-nat", Name: "nat-agent-node", NodeID: &natNode.Id, + AgentVersion: "A1.0.0", XrayVersion: "26.6.27", TelemetryAt: 1700000000000, + IPv4: "172.31.2.240", SeenIPv4: "111.91.29.150", + } + pre := &model.AgentNode{ + TokenHash: "hash-pre", Name: "pre-telemetry-agent-node", NodeID: &preNode.Id, + SeenIPv4: "203.0.113.55", SeenIPAt: 1700000000000, + } + for _, ag := range []*model.AgentNode{nat, pre} { + if err := db.Create(ag).Error; err != nil { + t.Fatalf("create agent row: %v", err) + } + } + + views := toNodeViews([]*model.Node{natNode, preNode}) + annotateNodeTypes(views) + annotateAgentTelemetry(views) + byID := map[int]*NodeView{} + for _, v := range views { + byID[v.Id] = v + } + + if got := byID[7501].Ipv4; got != "111.91.29.150" { + t.Fatalf("NAT node address = %q, want observed public 111.91.29.150 (view %+v)", got, byID[7501]) + } + if got := byID[7502].Ipv4; got != "203.0.113.55" { + t.Fatalf("pre-telemetry node address = %q, want observed 203.0.113.55 (view %+v)", got, byID[7502]) + } + // The pre-telemetry overlay must not invent other fields. + if pv := byID[7502]; pv.XrayVersion != "" || pv.UptimeSecs != 0 || pv.PanelVersion != "" { + t.Fatalf("pre-telemetry view picked up telemetry fields: %+v", pv) + } +} diff --git a/internal/web/service/node_contract.go b/internal/web/service/node_contract.go index 60ba883..15717eb 100644 --- a/internal/web/service/node_contract.go +++ b/internal/web/service/node_contract.go @@ -33,10 +33,11 @@ type NodeView struct { // panel UI annotate it via annotateNodeTypes. Empty on views built // outside those paths. NodeType string `json:"nodeType,omitempty" example:"traditional"` - // Ipv4/Ipv6 carry the agent-reported host addresses for agent nodes - // (P13). The nodes page renders them as plain text on agent rows - // instead of the panel-URL link used for traditional nodes. Empty for - // traditional nodes and for agents that never reported telemetry. + // Ipv4/Ipv6 carry the resolved externally usable host addresses for + // agent nodes (P13 telemetry, public-address resolution on top). + // The nodes page renders them as plain text on agent rows instead + // of the panel-URL link used for traditional nodes. Empty for + // traditional nodes and for agents with no known address yet. Ipv4 string `json:"ipv4,omitempty" example:"203.0.113.7"` Ipv6 string `json:"ipv6,omitempty" example:"2001:db8::7"` // Agent one-click update bookkeeping (item 20), agent nodes only: @@ -171,9 +172,12 @@ func annotateNodeTypes(views []*NodeView) { // annotateAgentTelemetry overlays the latest heartbeat telemetry reported // by each node's bound agent onto its view (P13): xray version, the // "agent " panel-version label, host uptime, node->master latency, -// CPU/memory percentages and the host IPv4/IPv6 pair. Views for traditional -// nodes (and agent nodes whose agent never reported telemetry) are left -// exactly as built. Must run after annotateNodeTypes. +// CPU/memory percentages and the host IPv4/IPv6 pair. The address pair is +// the resolved externally usable one (ResolveAgentAddresses), not the raw +// telemetry — a NAT'd agent only ever reports its private interface +// address. Views for traditional nodes (and agent nodes whose agent never +// reported telemetry) are left exactly as built. Must run after +// annotateNodeTypes. func annotateAgentTelemetry(views []*NodeView) { if len(views) == 0 { return @@ -198,7 +202,14 @@ func annotateAgentTelemetry(views []*NodeView) { continue } ag, ok := byNode[v.Id] - if !ok || ag.TelemetryAt == 0 { + if !ok { + continue + } + // Address resolution applies to every bound agent, even before + // its first telemetry report: an agent that has only registered + // already has a master-observed source address. + v.Ipv4, v.Ipv6 = ResolveAgentAddresses(ag) + if ag.TelemetryAt == 0 { continue } if ag.XrayVersion != "" { @@ -221,8 +232,6 @@ func annotateAgentTelemetry(views []*NodeView) { v.LatencyMs = ag.LatencyMs v.CpuPct = ag.CpuPct v.MemPct = ag.MemPct - v.Ipv4 = ag.IPv4 - v.Ipv6 = ag.IPv6 } }