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.
176 lines
5.6 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|