merge fix/agent-link-address: agent inbounds export telemetry address (V1.0.1)

# Conflicts:
#	internal/web/service/inbound.go
This commit is contained in:
Muse
2026-10-23 00:22:36 +08:00
4 changed files with 349 additions and 3 deletions
+64 -2
View File
@@ -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
}
}
+137
View File
@@ -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) {
+62 -1
View File
@@ -416,6 +416,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)
@@ -423,6 +438,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,
@@ -438,7 +459,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,
@@ -447,6 +468,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)
}
}
}