Files
3x-ui-agent/internal/web/service/inbound_options_node_address_test.go
T
Muse 7d9a38490d 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.
2026-10-22 15:35:21 +00:00

176 lines
5.6 KiB
Go

package service
import (
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
)
// TestGetInboundOptions_NodeAddress verifies that a node-managed inbound carries
// its hosting node's externally reachable address, while this panel's own
// inbounds report an empty NodeAddress. The clients page uses it as the
// WireGuard endpoint host so a copied config points at the node, not the master.
func TestGetInboundOptions_NodeAddress(t *testing.T) {
setupConflictDB(t)
node := &model.Node{Name: "de-fra-1", Address: "node.example.net", Port: 2053, Enable: true}
if err := database.GetDB().Create(node).Error; err != nil {
t.Fatalf("create node: %v", err)
}
nodeInbound := &model.Inbound{
UserId: 1,
Tag: "in-51820-udp",
Enable: true,
Listen: "0.0.0.0",
Port: 51820,
Protocol: model.WireGuard,
Settings: `{"clients":[],"secretKey":"QGVlb2dXc1ZTWGw0ZXBzZndsWmtMaUM5MUlNYjBHWFdYbz0="}`,
NodeID: &node.Id,
}
localInbound := &model.Inbound{
UserId: 1,
Tag: "in-443-tcp",
Enable: true,
Listen: "0.0.0.0",
Port: 443,
Protocol: model.VLESS,
StreamSettings: `{"network":"tcp"}`,
Settings: `{"clients":[]}`,
ShareAddrStrategy: "custom",
ShareAddr: "vpn.example.com",
}
if err := database.GetDB().Create(nodeInbound).Error; err != nil {
t.Fatalf("create node inbound: %v", err)
}
if err := database.GetDB().Create(localInbound).Error; err != nil {
t.Fatalf("create local inbound: %v", err)
}
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
}
got, ok := byID[nodeInbound.Id]
if !ok {
t.Fatalf("node inbound %d missing from options", nodeInbound.Id)
}
if got.NodeAddress != "node.example.net" {
t.Fatalf("node inbound NodeAddress = %q, want node.example.net", got.NodeAddress)
}
if got.Listen != "0.0.0.0" {
t.Fatalf("node inbound Listen = %q, want 0.0.0.0", got.Listen)
}
if got.ShareAddrStrategy != "" {
t.Fatalf("node inbound ShareAddrStrategy = %q, want empty (the default node strategy is elided so omitempty drops it)", got.ShareAddrStrategy)
}
local, ok := byID[localInbound.Id]
if !ok {
t.Fatalf("local inbound %d missing from options", localInbound.Id)
}
if local.NodeAddress != "" {
t.Fatalf("local inbound NodeAddress = %q, want empty", local.NodeAddress)
}
if local.ShareAddrStrategy != "custom" {
t.Fatalf("local inbound ShareAddrStrategy = %q, want custom", local.ShareAddrStrategy)
}
if local.ShareAddr != "vpn.example.com" {
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)
}
}
}