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.
|
// a telemetry object.
|
||||||
TelemetryAt int64 `json:"telemetryAt"`
|
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;
|
// One-click update (item 20): the master records an update intent here;
|
||||||
// the agent picks it up from its config poll, downloads + verifies +
|
// the agent picks it up from its config poll, downloads + verifies +
|
||||||
// swaps its binary, restarts itself and reports the outcome back on
|
// swaps its binary, restarts itself and reports the outcome back on
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ import (
|
|||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"net"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/config"
|
"github.com/mhsanaei/3x-ui/v3/internal/config"
|
||||||
@@ -100,6 +102,69 @@ func (a *AgentController) findAgent(token string) (*model.AgentNode, bool) {
|
|||||||
return &agent, true
|
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) {
|
func (a *AgentController) touchAgent(id uint, name string) {
|
||||||
updates := map[string]any{"last_seen": time.Now().UnixMilli()}
|
updates := map[string]any{"last_seen": time.Now().UnixMilli()}
|
||||||
if name != "" {
|
if name != "" {
|
||||||
@@ -133,6 +198,7 @@ func (a *AgentController) register(c *gin.Context) {
|
|||||||
c.JSON(401, gin.H{"message": "unknown token"})
|
c.JSON(401, gin.H{"message": "unknown token"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
a.captureSeenIP(c, agent)
|
||||||
a.touchAgent(uint(agent.Id), req.Name)
|
a.touchAgent(uint(agent.Id), req.Name)
|
||||||
logger.Infof("Agent registered: %s (token %s...)", req.Name, agent.TokenPrefix)
|
logger.Infof("Agent registered: %s (token %s...)", req.Name, agent.TokenPrefix)
|
||||||
jsonObj(c, gin.H{"message": "registered"}, nil)
|
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"})
|
c.JSON(401, gin.H{"message": "unauthorized"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
a.captureSeenIP(c, agent)
|
||||||
// Telemetry is optional and best-effort: a body that fails to parse
|
// Telemetry is optional and best-effort: a body that fails to parse
|
||||||
// (or no body at all) must never fail the heartbeat itself.
|
// (or no body at all) must never fail the heartbeat itself.
|
||||||
var req struct {
|
var req struct {
|
||||||
@@ -286,6 +353,7 @@ func (a *AgentController) getConfig(c *gin.Context) {
|
|||||||
c.JSON(401, gin.H{"message": "unauthorized"})
|
c.JSON(401, gin.H{"message": "unauthorized"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
a.captureSeenIP(c, agent)
|
||||||
if agent.NodeID == nil {
|
if agent.NodeID == nil {
|
||||||
c.JSON(409, gin.H{"message": "agent token is not bound to a node"})
|
c.JSON(409, gin.H{"message": "agent token is not bound to a node"})
|
||||||
return
|
return
|
||||||
@@ -344,6 +412,7 @@ func (a *AgentController) reportTraffic(c *gin.Context) {
|
|||||||
c.JSON(401, gin.H{"message": "unauthorized"})
|
c.JSON(401, gin.H{"message": "unauthorized"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
a.captureSeenIP(c, agent)
|
||||||
var req agentTrafficReport
|
var req agentTrafficReport
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
jsonMsg(c, "Invalid request", err)
|
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
|
// panel UI annotate it via annotateNodeTypes. Empty on views built
|
||||||
// outside those paths.
|
// outside those paths.
|
||||||
NodeType string `json:"nodeType,omitempty" example:"traditional"`
|
NodeType string `json:"nodeType,omitempty" example:"traditional"`
|
||||||
// Ipv4/Ipv6 carry the agent-reported host addresses for agent nodes
|
// Ipv4/Ipv6 carry the resolved externally usable host addresses for
|
||||||
// (P13). The nodes page renders them as plain text on agent rows
|
// agent nodes (P13 telemetry, public-address resolution on top).
|
||||||
// instead of the panel-URL link used for traditional nodes. Empty for
|
// The nodes page renders them as plain text on agent rows instead
|
||||||
// traditional nodes and for agents that never reported telemetry.
|
// 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"`
|
Ipv4 string `json:"ipv4,omitempty" example:"203.0.113.7"`
|
||||||
Ipv6 string `json:"ipv6,omitempty" example:"2001:db8::7"`
|
Ipv6 string `json:"ipv6,omitempty" example:"2001:db8::7"`
|
||||||
// Agent one-click update bookkeeping (item 20), agent nodes only:
|
// 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
|
// annotateAgentTelemetry overlays the latest heartbeat telemetry reported
|
||||||
// by each node's bound agent onto its view (P13): xray version, the
|
// by each node's bound agent onto its view (P13): xray version, the
|
||||||
// "agent <version>" panel-version label, host uptime, node->master latency,
|
// "agent <version>" panel-version label, host uptime, node->master latency,
|
||||||
// CPU/memory percentages and the host IPv4/IPv6 pair. Views for traditional
|
// CPU/memory percentages and the host IPv4/IPv6 pair. The address pair is
|
||||||
// nodes (and agent nodes whose agent never reported telemetry) are left
|
// the resolved externally usable one (ResolveAgentAddresses), not the raw
|
||||||
// exactly as built. Must run after annotateNodeTypes.
|
// 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) {
|
func annotateAgentTelemetry(views []*NodeView) {
|
||||||
if len(views) == 0 {
|
if len(views) == 0 {
|
||||||
return
|
return
|
||||||
@@ -209,7 +213,14 @@ func annotateAgentTelemetry(views []*NodeView) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
ag, ok := byNode[v.Id]
|
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
|
continue
|
||||||
}
|
}
|
||||||
if ag.XrayVersion != "" {
|
if ag.XrayVersion != "" {
|
||||||
@@ -232,8 +243,6 @@ func annotateAgentTelemetry(views []*NodeView) {
|
|||||||
v.LatencyMs = ag.LatencyMs
|
v.LatencyMs = ag.LatencyMs
|
||||||
v.CpuPct = ag.CpuPct
|
v.CpuPct = ag.CpuPct
|
||||||
v.MemPct = ag.MemPct
|
v.MemPct = ag.MemPct
|
||||||
v.Ipv4 = ag.IPv4
|
|
||||||
v.Ipv6 = ag.IPv6
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user