merge fix/agent-public-addr: master-observed public IP resolution (V1.0.1)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 <version>" 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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user