fix(sub): agent-hosted inbounds export the agent's telemetry address, not the 'agent' placeholder

Agent nodes store the create form's placeholder in nodes.address
('agent', port 2053; NodeFormModal.buildPayload). The export fallback
chain treated it as a real node address, so every host-less inbound on
an agent node produced links like vless://...@agent:31324.

SubService now loads agent_nodes bindings alongside nodes (latest
last_seen per node, same rule as the node list) and resolves an
agent-managed node's export address through the single convergence
point agentNodeAddress(nodeID): telemetry IPv4, then IPv6, then ""
so the chain continues to listen/public/request host and can never
emit the placeholder. Host-bound inbounds and traditional nodes are
untouched. GetInboundOptions applies the same substitution via
agentAdvertisedAddresses so the frontend share/QR/WireGuard feed
never sees the placeholder either.
This commit is contained in:
Muse
2026-10-22 15:35:21 +00:00
parent e310abcba0
commit 7d9a38490d
4 changed files with 349 additions and 4 deletions
@@ -87,3 +87,89 @@ func TestGetInboundOptions_NodeAddress(t *testing.T) {
t.Fatalf("local inbound ShareAddr = %q, want vpn.example.com", local.ShareAddr)
}
}
// TestGetInboundOptions_AgentNodeAddress verifies that an agent-managed
// node's inbounds never inherit the node row's stored address: the
// create form writes the literal placeholder "agent" there, which is
// not a connectable host. The option must carry the agent's
// telemetry-reported address instead (IPv4 first, then IPv6; empty when
// the agent never reported one), so share/QR links and WireGuard
// endpoints rendered from these options reach the agent host.
func TestGetInboundOptions_AgentNodeAddress(t *testing.T) {
setupConflictDB(t)
db := database.GetDB()
mkNode := func(name string) *model.Node {
n := &model.Node{Name: name, Scheme: "https", Address: "agent", Port: 2053, Enable: true}
if err := db.Create(n).Error; err != nil {
t.Fatalf("create node %s: %v", name, err)
}
return n
}
mkInbound := func(tag string, node *model.Node) *model.Inbound {
ib := &model.Inbound{
UserId: 1,
Tag: tag,
Enable: true,
Listen: "0.0.0.0",
Port: 31324,
Protocol: model.VLESS,
Settings: `{"clients":[]}`,
NodeID: &node.Id,
}
if err := db.Create(ib).Error; err != nil {
t.Fatalf("create inbound %s: %v", tag, err)
}
return ib
}
mkBinding := func(node *model.Node, ipv4, ipv6 string, lastSeen int64) {
ag := &model.AgentNode{
TokenHash: "hash-" + node.Name,
Name: node.Name,
NodeID: &node.Id,
IPv4: ipv4,
IPv6: ipv6,
LastSeen: lastSeen,
}
if err := db.Create(ag).Error; err != nil {
t.Fatalf("create binding for %s: %v", node.Name, err)
}
}
dualNode := mkNode("agent-dual")
mkBinding(dualNode, "198.51.100.23", "2001:db8::23", 2000)
dualInbound := mkInbound("in-agent-dual", dualNode)
v6Node := mkNode("agent-v6")
mkBinding(v6Node, "", "2001:db8::99", 2000)
v6Inbound := mkInbound("in-agent-v6", v6Node)
silentNode := mkNode("agent-silent")
mkBinding(silentNode, "", "", 2000)
silentInbound := mkInbound("in-agent-silent", silentNode)
svc := &InboundService{}
options, err := svc.GetInboundOptions(1)
if err != nil {
t.Fatalf("GetInboundOptions: %v", err)
}
byID := make(map[int]InboundOption, len(options))
for _, o := range options {
byID[o.Id] = o
}
if got := byID[dualInbound.Id].NodeAddress; got != "198.51.100.23" {
t.Fatalf("dual-stack agent NodeAddress = %q, want telemetry IPv4 198.51.100.23 (never the placeholder)", got)
}
if got := byID[v6Inbound.Id].NodeAddress; got != "2001:db8::99" {
t.Fatalf("ipv6-only agent NodeAddress = %q, want telemetry IPv6 2001:db8::99", got)
}
if got := byID[silentInbound.Id].NodeAddress; got != "" {
t.Fatalf("silent agent NodeAddress = %q, want empty (never the placeholder)", got)
}
for id, o := range byID {
if o.NodeAddress == "agent" {
t.Fatalf("inbound %d NodeAddress is the placeholder %q", id, o.NodeAddress)
}
}
}