merge fix/agent-public-addr: master-observed public IP resolution (V1.0.1)

This commit is contained in:
Muse
2026-10-23 00:22:58 +08:00
6 changed files with 527 additions and 10 deletions
+14
View File
@@ -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
+69
View File
@@ -6,7 +6,9 @@ import (
"encoding/hex"
"encoding/json"
"errors"
"net"
"strconv"
"strings"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/config"
@@ -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)
}
}
+92
View File
@@ -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)
}
+164
View File
@@ -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)
}
}
+19 -10
View File
@@ -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
}
}