Files
3x-ui-agent/internal/web/service/agent_address_test.go
T
Muse c490bdcd76 fix(nodes): resolve agent public address from master-observed source IP
NAT'd agents (cloud 1:1 EIP) only ever report their private
interface address in heartbeat telemetry, so the node list showed
an unusable RFC1918 address. The master now records the source
address of every authenticated agent request (CF-Connecting-IP,
else first X-Forwarded-For hop, else RemoteAddr; change-only
writes) on the agent row as seen_ipv4/seen_ipv6, and the node
list resolves the displayed pair via service.ResolveAgentAddresses:
public telemetry wins, else a public observed source, else the
reported address as-is. CGNAT 100.64/10 observed addresses are
never promoted. Telemetry values are kept verbatim; resolution
is read-time only, and agents need no changes.
2026-10-22 15:25:08 +00:00

165 lines
5.2 KiB
Go

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)
}
}