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 a56af71..0f7aa50 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" @@ -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) 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 db06fd0..ab58d60 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: @@ -182,9 +183,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 @@ -209,7 +213,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 != "" { @@ -232,8 +243,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 } }