diff --git a/internal/sub/service.go b/internal/sub/service.go index f7fdbd1..d701f46 100644 --- a/internal/sub/service.go +++ b/internal/sub/service.go @@ -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 } } diff --git a/internal/sub/service_test.go b/internal/sub/service_test.go index 5904f18..bd9e040 100644 --- a/internal/sub/service_test.go +++ b/internal/sub/service_test.go @@ -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:" 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) { diff --git a/internal/web/service/inbound.go b/internal/web/service/inbound.go index ca94bcc..df1e762 100644 --- a/internal/web/service/inbound.go +++ b/internal/web/service/inbound.go @@ -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, "" diff --git a/internal/web/service/inbound_options_node_address_test.go b/internal/web/service/inbound_options_node_address_test.go index bd8d3ea..f2b58c7 100644 --- a/internal/web/service/inbound_options_node_address_test.go +++ b/internal/web/service/inbound_options_node_address_test.go @@ -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) + } + } +}