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:
+64
-2
@@ -53,6 +53,13 @@ type SubService struct {
|
||||
// inbound whose NodeID is set. Keeps the per-link host derivation
|
||||
// O(1) instead of O(N) DB hits.
|
||||
nodesByID map[int]*model.Node
|
||||
// agentByNodeID is populated alongside nodesByID with the latest
|
||||
// agent binding per node (agent_nodes rows). A node present here is
|
||||
// agent-managed: its Node.Address is the literal placeholder "agent"
|
||||
// written by the create form (the panel never dials agent nodes), so
|
||||
// resolveInboundAddress advertises the agent's telemetry address
|
||||
// instead of the stored placeholder.
|
||||
agentByNodeID map[int]*model.AgentNode
|
||||
// statsByEmail maps a client email to its traffic row across ALL inbounds
|
||||
// loaded for the request. client_traffics.email is globally unique, so this
|
||||
// lets statsForClient resolve usage for a client even on an inbound that
|
||||
@@ -1296,6 +1303,7 @@ func (s *SubService) loadNodes() {
|
||||
if err := db.Model(&model.Node{}).Where("address != ''").Find(&nodes).Error; err != nil {
|
||||
logger.Warning("subscription: load nodes failed:", err)
|
||||
s.nodesByID = nil
|
||||
s.agentByNodeID = nil
|
||||
return
|
||||
}
|
||||
m := make(map[int]*model.Node, len(nodes))
|
||||
@@ -1303,11 +1311,59 @@ func (s *SubService) loadNodes() {
|
||||
m[n.Id] = n
|
||||
}
|
||||
s.nodesByID = m
|
||||
s.loadAgentBindings()
|
||||
}
|
||||
|
||||
// loadAgentBindings refreshes agentByNodeID with the most recently seen
|
||||
// agent binding for each node, mirroring the selection the node list
|
||||
// makes in annotateAgentTelemetry (latest reporter wins).
|
||||
func (s *SubService) loadAgentBindings() {
|
||||
db := database.GetDB()
|
||||
var agents []model.AgentNode
|
||||
if err := db.Model(&model.AgentNode{}).
|
||||
Where("node_id IS NOT NULL").
|
||||
Order("last_seen DESC").
|
||||
Find(&agents).Error; err != nil {
|
||||
logger.Warning("subscription: load agent bindings failed:", err)
|
||||
s.agentByNodeID = nil
|
||||
return
|
||||
}
|
||||
byNode := make(map[int]*model.AgentNode, len(agents))
|
||||
for i := range agents {
|
||||
if agents[i].NodeID == nil {
|
||||
continue
|
||||
}
|
||||
if _, ok := byNode[*agents[i].NodeID]; !ok {
|
||||
byNode[*agents[i].NodeID] = &agents[i]
|
||||
}
|
||||
}
|
||||
s.agentByNodeID = byNode
|
||||
}
|
||||
|
||||
// agentNodeAddress is the single point where the export chain resolves
|
||||
// the connect address of an agent-managed node: the IPv4 from the
|
||||
// agent's latest heartbeat telemetry, falling back to its IPv6. It
|
||||
// returns "" when the node has no bound agent or the agent never
|
||||
// reported an address, letting the caller's fallback chain continue to
|
||||
// listen / configured public host / request host. The address stored in
|
||||
// the node row is never used for agent nodes — it is the create form's
|
||||
// placeholder "agent", which is not a connectable host.
|
||||
func (s *SubService) agentNodeAddress(nodeID int) string {
|
||||
ag, ok := s.agentByNodeID[nodeID]
|
||||
if !ok || ag == nil {
|
||||
return ""
|
||||
}
|
||||
if ip := strings.TrimSpace(ag.IPv4); ip != "" {
|
||||
return ip
|
||||
}
|
||||
return strings.TrimSpace(ag.IPv6)
|
||||
}
|
||||
|
||||
// resolveInboundAddress picks the host an external client should connect to,
|
||||
// honoring the inbound's share address strategy the same way the panel's
|
||||
// share/QR link builder does (#5208):
|
||||
// share/QR link builder does (#5208). For agent-managed nodes the "node"
|
||||
// step of every chain is the agent's telemetry-reported address (see
|
||||
// agentNodeAddress), never the placeholder stored in the node row:
|
||||
// - "listen": an explicit, client-reachable bind Listen wins, backed by the
|
||||
// node's address for node-managed inbounds;
|
||||
// - "custom": the inbound's ShareAddr wins, then node, then listen;
|
||||
@@ -1324,7 +1380,13 @@ func (s *SubService) loadNodes() {
|
||||
func (s *SubService) resolveInboundAddress(inbound *model.Inbound) string {
|
||||
var nodeAddr string
|
||||
if inbound.NodeID != nil && s.nodesByID != nil {
|
||||
if n, ok := s.nodesByID[*inbound.NodeID]; ok {
|
||||
if _, isAgent := s.agentByNodeID[*inbound.NodeID]; isAgent {
|
||||
// Agent-managed node: the stored address is the create
|
||||
// form's placeholder ("agent"), never a connectable host,
|
||||
// so the node step of every strategy resolves through the
|
||||
// agent's own reported address instead.
|
||||
nodeAddr = s.agentNodeAddress(*inbound.NodeID)
|
||||
} else if n, ok := s.nodesByID[*inbound.NodeID]; ok {
|
||||
nodeAddr = n.Address
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
)
|
||||
|
||||
@@ -204,6 +205,142 @@ func TestResolveInboundAddress(t *testing.T) {
|
||||
t.Fatalf("node-strategy address = %q, want node7.example.com", got)
|
||||
}
|
||||
})
|
||||
|
||||
// Agent-managed nodes store the literal placeholder "agent" in the
|
||||
// node row (the create form writes it; the panel never dials agent
|
||||
// nodes). The node step of every strategy must resolve through the
|
||||
// agent's telemetry-reported address instead — a link addressed to
|
||||
// "agent:<port>" is unusable.
|
||||
agentSvc := func(ipv4, ipv6 string) *SubService {
|
||||
id := 8
|
||||
return &SubService{
|
||||
address: reqHost,
|
||||
nodesByID: map[int]*model.Node{8: {Id: 8, Address: "agent", Port: 2053}},
|
||||
agentByNodeID: map[int]*model.AgentNode{
|
||||
8: {NodeID: &id, IPv4: ipv4, IPv6: ipv6},
|
||||
},
|
||||
}
|
||||
}
|
||||
agentInbound := func(mut func(*model.Inbound)) *model.Inbound {
|
||||
id := 8
|
||||
ib := &model.Inbound{NodeID: &id, Port: 31324}
|
||||
if mut != nil {
|
||||
mut(ib)
|
||||
}
|
||||
return ib
|
||||
}
|
||||
|
||||
t.Run("agent-managed inbound advertises the agent telemetry ipv4", func(t *testing.T) {
|
||||
s := agentSvc("198.51.100.23", "2001:db8::23")
|
||||
if got := s.resolveInboundAddress(agentInbound(nil)); got != "198.51.100.23" {
|
||||
t.Fatalf("agent address = %q, want telemetry IPv4 198.51.100.23 (never the placeholder)", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("agent-managed inbound falls back to telemetry ipv6", func(t *testing.T) {
|
||||
s := agentSvc("", "2001:db8::23")
|
||||
if got := s.resolveInboundAddress(agentInbound(nil)); got != "2001:db8::23" {
|
||||
t.Fatalf("agent address = %q, want telemetry IPv6 2001:db8::23", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("agent-managed inbound without telemetry continues the chain", func(t *testing.T) {
|
||||
s := agentSvc("", "")
|
||||
ib := agentInbound(func(ib *model.Inbound) { ib.Listen = "203.0.113.9" })
|
||||
if got := s.resolveInboundAddress(ib); got != "203.0.113.9" {
|
||||
t.Fatalf("agent-without-telemetry address = %q, want routable listen 203.0.113.9", got)
|
||||
}
|
||||
ib.Listen = "0.0.0.0"
|
||||
if got := s.resolveInboundAddress(ib); got != reqHost {
|
||||
t.Fatalf("agent-without-telemetry address = %q, want subscriber host %q (never the placeholder)", got, reqHost)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("agent-managed listen strategy still prefers the bind", func(t *testing.T) {
|
||||
s := agentSvc("198.51.100.23", "")
|
||||
ib := agentInbound(func(ib *model.Inbound) {
|
||||
ib.Listen = "203.0.113.9"
|
||||
ib.ShareAddrStrategy = "listen"
|
||||
})
|
||||
if got := s.resolveInboundAddress(ib); got != "203.0.113.9" {
|
||||
t.Fatalf("listen-strategy agent address = %q, want the bind 203.0.113.9", got)
|
||||
}
|
||||
ib.Listen = "0.0.0.0"
|
||||
if got := s.resolveInboundAddress(ib); got != "198.51.100.23" {
|
||||
t.Fatalf("listen-strategy agent address = %q, want telemetry IPv4 fallback 198.51.100.23", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("agent-managed custom strategy keeps the share address first", func(t *testing.T) {
|
||||
s := agentSvc("198.51.100.23", "")
|
||||
ib := agentInbound(func(ib *model.Inbound) {
|
||||
ib.ShareAddrStrategy = "custom"
|
||||
ib.ShareAddr = "edge.example.com"
|
||||
})
|
||||
if got := s.resolveInboundAddress(ib); got != "edge.example.com" {
|
||||
t.Fatalf("custom-strategy agent address = %q, want edge.example.com", got)
|
||||
}
|
||||
ib.ShareAddr = ""
|
||||
if got := s.resolveInboundAddress(ib); got != "198.51.100.23" {
|
||||
t.Fatalf("custom-strategy agent fallback = %q, want telemetry IPv4 198.51.100.23", got)
|
||||
}
|
||||
})
|
||||
|
||||
// loadNodes must surface the latest agent binding per node so a real
|
||||
// request (PrepareForRequest) resolves agent addresses from the DB,
|
||||
// not just from hand-built fixtures.
|
||||
t.Run("loadNodes picks the latest agent binding per node", func(t *testing.T) {
|
||||
initSubDB(t)
|
||||
node := &model.Node{Name: "agent-1", Scheme: "https", Address: "agent", Port: 2053, Enable: true}
|
||||
if err := database.GetDB().Create(node).Error; err != nil {
|
||||
t.Fatalf("create node: %v", err)
|
||||
}
|
||||
stale := &model.AgentNode{TokenHash: "stalehash", Name: "agent-1", NodeID: &node.Id, IPv4: "192.0.2.1", LastSeen: 1000}
|
||||
fresh := &model.AgentNode{TokenHash: "freshhash", Name: "agent-1", NodeID: &node.Id, IPv4: "198.51.100.23", IPv6: "2001:db8::23", LastSeen: 2000}
|
||||
if err := database.GetDB().Create(stale).Error; err != nil {
|
||||
t.Fatalf("create stale binding: %v", err)
|
||||
}
|
||||
if err := database.GetDB().Create(fresh).Error; err != nil {
|
||||
t.Fatalf("create fresh binding: %v", err)
|
||||
}
|
||||
s := &SubService{address: reqHost}
|
||||
s.loadNodes()
|
||||
ib := &model.Inbound{NodeID: &node.Id, Port: 31324}
|
||||
if got := s.resolveInboundAddress(ib); got != "198.51.100.23" {
|
||||
t.Fatalf("loaded agent address = %q, want latest binding IPv4 198.51.100.23", got)
|
||||
}
|
||||
if got := s.agentNodeAddress(node.Id); got != "198.51.100.23" {
|
||||
t.Fatalf("agentNodeAddress = %q, want 198.51.100.23", got)
|
||||
}
|
||||
if got := s.agentNodeAddress(99999); got != "" {
|
||||
t.Fatalf("agentNodeAddress(unknown) = %q, want empty", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// A full share link for an agent-hosted inbound must carry the agent's
|
||||
// telemetry address and inbound port — the regression was a link whose
|
||||
// host was the node row's placeholder, e.g. vless://…@agent:31324.
|
||||
func TestAgentInboundShareLinkUsesTelemetryAddress(t *testing.T) {
|
||||
nodeID := 8
|
||||
s := &SubService{
|
||||
address: "sub.example.com",
|
||||
nodesByID: map[int]*model.Node{8: {Id: 8, Address: "agent", Port: 2053}},
|
||||
agentByNodeID: map[int]*model.AgentNode{
|
||||
8: {NodeID: &nodeID, IPv4: "198.51.100.23", IPv6: "2001:db8::23"},
|
||||
},
|
||||
}
|
||||
ib := shareLinkInbound(`{"network":"tcp","security":"none","tcpSettings":{"header":{"type":"none"}}}`)
|
||||
ib.NodeID = &nodeID
|
||||
ib.Listen = "0.0.0.0"
|
||||
ib.Port = 31324
|
||||
link := s.GetLink(ib, "user")
|
||||
if !strings.Contains(link, "@198.51.100.23:31324") {
|
||||
t.Fatalf("agent share link = %q, want host 198.51.100.23:31324", link)
|
||||
}
|
||||
if strings.Contains(link, "@agent:") {
|
||||
t.Fatalf("agent share link leaks the placeholder address: %q", link)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnmarshalStreamSettings(t *testing.T) {
|
||||
|
||||
@@ -196,7 +196,6 @@ func (s *InboundService) annotateLocalOriginGuid(inbounds []*model.Inbound) {
|
||||
}
|
||||
}
|
||||
|
||||
// GetInboundsSlim returns the same list of inbounds as GetInbounds but
|
||||
// strips every per-client field other than email / enable / comment from
|
||||
// settings.clients and skips UUID/SubId enrichment on ClientStats. The
|
||||
// inbounds page only needs those three to roll up client counts and
|
||||
@@ -362,6 +361,21 @@ func (s *InboundService) GetInboundOptions(userId int) ([]InboundOption, error)
|
||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
// Agent-managed nodes carry the create form's placeholder ("agent")
|
||||
// in nodes.address, which is not a connectable host. For those rows
|
||||
// NodeAddress must be the agent's telemetry-reported address (IPv4,
|
||||
// then IPv6; "" when never reported, so the frontend fallback chain
|
||||
// continues as it does for a local inbound) — otherwise every
|
||||
// share/QR link and WireGuard endpoint rendered from these options
|
||||
// points at the literal placeholder. Mirrors the backend export
|
||||
// chain's agentNodeAddress in internal/sub.
|
||||
rowNodeIDs := make(map[int]bool)
|
||||
for _, r := range rows {
|
||||
if r.NodeId != nil {
|
||||
rowNodeIDs[*r.NodeId] = true
|
||||
}
|
||||
}
|
||||
agentAddrByNode := agentAdvertisedAddresses(db, rowNodeIDs)
|
||||
out := make([]InboundOption, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
wgPublicKey, wgMtu, wgDns := inboundWireguardHints(r.Protocol, r.Settings)
|
||||
@@ -369,6 +383,12 @@ func (s *InboundService) GetInboundOptions(userId int) ([]InboundOption, error)
|
||||
if shareAddrStrategy == "node" {
|
||||
shareAddrStrategy = ""
|
||||
}
|
||||
nodeAddress := r.NodeAddress
|
||||
if r.NodeId != nil {
|
||||
if addr, isAgent := agentAddrByNode[*r.NodeId]; isAgent {
|
||||
nodeAddress = addr
|
||||
}
|
||||
}
|
||||
out = append(out, InboundOption{
|
||||
Id: r.Id,
|
||||
Remark: r.Remark,
|
||||
@@ -384,7 +404,7 @@ func (s *InboundService) GetInboundOptions(userId int) ([]InboundOption, error)
|
||||
MtprotoDomain: inboundMtprotoDomain(r.Protocol, r.Settings),
|
||||
AwgServer: inboundAmneziaWGServer(r.Protocol, r.Settings),
|
||||
NodeId: r.NodeId,
|
||||
NodeAddress: r.NodeAddress,
|
||||
NodeAddress: nodeAddress,
|
||||
Listen: r.Listen,
|
||||
ShareAddr: r.ShareAddr,
|
||||
ShareAddrStrategy: shareAddrStrategy,
|
||||
@@ -393,6 +413,46 @@ func (s *InboundService) GetInboundOptions(userId int) ([]InboundOption, error)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// agentAdvertisedAddresses resolves, for each requested node id that is
|
||||
// agent-managed, the address share/export surfaces should advertise for
|
||||
// it: the IPv4 from the bound agent's latest heartbeat telemetry,
|
||||
// falling back to its IPv6 ("" when the agent never reported one). The
|
||||
// map contains an entry for every agent-managed id in the set —
|
||||
// presence in the map is the agent-managed signal, mirroring
|
||||
// IsAgentManaged's binding-existence check — so callers can tell
|
||||
// "agent node without telemetry" apart from "traditional node" and
|
||||
// never fall back to the placeholder stored in nodes.address. Latest
|
||||
// reporter wins when several tokens are bound to one node, mirroring
|
||||
// annotateAgentTelemetry.
|
||||
func agentAdvertisedAddresses(db *gorm.DB, nodeIDs map[int]bool) map[int]string {
|
||||
out := make(map[int]string)
|
||||
if len(nodeIDs) == 0 {
|
||||
return out
|
||||
}
|
||||
var agents []model.AgentNode
|
||||
if err := db.Model(&model.AgentNode{}).
|
||||
Where("node_id IS NOT NULL").
|
||||
Order("last_seen DESC").
|
||||
Find(&agents).Error; err != nil {
|
||||
return out
|
||||
}
|
||||
for i := range agents {
|
||||
ag := &agents[i]
|
||||
if ag.NodeID == nil || !nodeIDs[*ag.NodeID] {
|
||||
continue
|
||||
}
|
||||
if _, done := out[*ag.NodeID]; done {
|
||||
continue
|
||||
}
|
||||
addr := strings.TrimSpace(ag.IPv4)
|
||||
if addr == "" {
|
||||
addr = strings.TrimSpace(ag.IPv6)
|
||||
}
|
||||
out[*ag.NodeID] = addr
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func inboundWireguardHints(protocol string, settings string) (string, int, string) {
|
||||
if protocol != string(model.WireGuard) || strings.TrimSpace(settings) == "" {
|
||||
return "", 0, ""
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user