From f90e4a6962f0581bcf5c556b39cb9941631a89bb Mon Sep 17 00:00:00 2001 From: Grigoriy Date: Fri, 3 Jul 2026 02:12:32 +0300 Subject: [PATCH] fix(panel): use the hosting node address for WireGuard client configs (#5679) * fix(panel): use the hosting node address for WireGuard client configs The clients page rendered a node-managed WireGuard inbound's config with the master panel's host in Endpoint instead of the hosting node's address, so the copied/QR config pointed at the wrong server. The subscription path already resolves this via resolveInboundAddress; the UI generator did not. Expose the share-host resolution inputs (node address, listen, share-address strategy/address) on InboundOption and route buildWireguardClientConfig through the same canonical resolver the inbounds-page share links use, extracted as resolveShareHost. This also brings local inbounds with a shareable listen or a listen/custom share strategy into parity with the subscription Endpoint; the common listen=0.0.0.0 case still falls back to the panel host. * fix(frontend): keep a raw fallback host and refresh node-fed inbound options Code review of the WireGuard node-endpoint change surfaced two gaps. resolveShareHost normalized its last-resort fallbackHostname, so a panel reached via a hostname the share-host grammar rejects (underscore label, trailing-dot FQDN) emitted a broken 'Endpoint = :51820'; the fallback now stays verbatim when normalization empties it. Node mutations only invalidated the nodes query, leaving the staleTime-Infinity inbound options cache serving an edited node address until the sync job broadcast (never, for disabled/offline nodes); they now invalidate the options key too. Also folds the ShareHostFields projections into direct structural passes, elides the default node shareAddrStrategy so omitempty drops it, and replaces the nullable node-address scan with COALESCE. --------- Co-authored-by: STRENCH0 <17428017+STRENCH0@users.noreply.github.com> Co-authored-by: Sanaei --- frontend/public/openapi.json | 17 ++++ frontend/src/api/queries/useNodeMutations.ts | 5 +- frontend/src/generated/examples.ts | 4 + frontend/src/generated/schemas.ts | 13 +++ frontend/src/generated/types.ts | 4 + frontend/src/generated/zod.ts | 4 + frontend/src/lib/xray/inbound-link.ts | 62 ++++++++----- frontend/src/pages/clients/wireguardConfig.ts | 4 +- frontend/src/schemas/client.ts | 7 ++ .../src/test/wireguard-client-config.test.ts | 37 ++++++++ internal/web/service/inbound.go | 67 +++++++++----- .../inbound_options_node_address_test.go | 89 +++++++++++++++++++ 12 files changed, 267 insertions(+), 46 deletions(-) create mode 100644 internal/web/service/inbound_options_node_address_test.go diff --git a/frontend/public/openapi.json b/frontend/public/openapi.json index 8f86332f9..1d330ba02 100644 --- a/frontend/public/openapi.json +++ b/frontend/public/openapi.json @@ -1828,6 +1828,13 @@ "example": 1, "type": "integer" }, + "listen": { + "type": "string" + }, + "nodeAddress": { + "description": "Share-host resolution inputs, mirroring the subscription's\nresolveInboundAddress so the clients page renders a node-managed WireGuard\nEndpoint that points at the node, not the master panel. NodeAddress is the\nhosting node's externally reachable address (empty for this panel's own\ninbounds); Listen and ShareAddrStrategy/ShareAddr feed the same\nnode→listen→custom fallback the share/QR links already use.", + "type": "string" + }, "nodeId": { "description": "Hosting node; nil for this panel's own inbounds. Lets the clients\npage map a node filter onto inbound IDs (#4997).", "nullable": true, @@ -1845,6 +1852,12 @@ "example": "VLESS-443", "type": "string" }, + "shareAddr": { + "type": "string" + }, + "shareAddrStrategy": { + "type": "string" + }, "ssMethod": { "type": "string" }, @@ -2783,10 +2796,14 @@ "obj": [ { "id": 1, + "listen": "", + "nodeAddress": "", "nodeId": null, "port": 443, "protocol": "vless", "remark": "VLESS-443", + "shareAddr": "", + "shareAddrStrategy": "", "ssMethod": "", "tag": "in-443-tcp", "tlsFlowCapable": true, diff --git a/frontend/src/api/queries/useNodeMutations.ts b/frontend/src/api/queries/useNodeMutations.ts index 45229145b..b24175b06 100644 --- a/frontend/src/api/queries/useNodeMutations.ts +++ b/frontend/src/api/queries/useNodeMutations.ts @@ -24,7 +24,10 @@ export interface RemoteInboundOption { export function useNodeMutations() { const queryClient = useQueryClient(); - const invalidate = () => queryClient.invalidateQueries({ queryKey: keys.nodes.root() }); + const invalidate = () => { + queryClient.invalidateQueries({ queryKey: keys.nodes.root() }); + queryClient.invalidateQueries({ queryKey: keys.inbounds.options() }); + }; const createMut = useMutation({ mutationFn: (payload: Partial) => diff --git a/frontend/src/generated/examples.ts b/frontend/src/generated/examples.ts index ff2fdfbdb..bdd81af88 100644 --- a/frontend/src/generated/examples.ts +++ b/frontend/src/generated/examples.ts @@ -400,10 +400,14 @@ export const EXAMPLES: Record = { }, "InboundOption": { "id": 1, + "listen": "", + "nodeAddress": "", "nodeId": null, "port": 443, "protocol": "vless", "remark": "VLESS-443", + "shareAddr": "", + "shareAddrStrategy": "", "ssMethod": "", "tag": "in-443-tcp", "tlsFlowCapable": true, diff --git a/frontend/src/generated/schemas.ts b/frontend/src/generated/schemas.ts index 405879ff4..4af04e5d5 100644 --- a/frontend/src/generated/schemas.ts +++ b/frontend/src/generated/schemas.ts @@ -1802,6 +1802,13 @@ export const SCHEMAS: Record = { "example": 1, "type": "integer" }, + "listen": { + "type": "string" + }, + "nodeAddress": { + "description": "Share-host resolution inputs, mirroring the subscription's\nresolveInboundAddress so the clients page renders a node-managed WireGuard\nEndpoint that points at the node, not the master panel. NodeAddress is the\nhosting node's externally reachable address (empty for this panel's own\ninbounds); Listen and ShareAddrStrategy/ShareAddr feed the same\nnode→listen→custom fallback the share/QR links already use.", + "type": "string" + }, "nodeId": { "description": "Hosting node; nil for this panel's own inbounds. Lets the clients\npage map a node filter onto inbound IDs (#4997).", "nullable": true, @@ -1819,6 +1826,12 @@ export const SCHEMAS: Record = { "example": "VLESS-443", "type": "string" }, + "shareAddr": { + "type": "string" + }, + "shareAddrStrategy": { + "type": "string" + }, "ssMethod": { "type": "string" }, diff --git a/frontend/src/generated/types.ts b/frontend/src/generated/types.ts index a5dc724ea..186fa5cc7 100644 --- a/frontend/src/generated/types.ts +++ b/frontend/src/generated/types.ts @@ -394,10 +394,14 @@ export interface InboundFallback { export interface InboundOption { id: number; + listen?: string; + nodeAddress?: string; nodeId?: number | null; port: number; protocol: string; remark: string; + shareAddr?: string; + shareAddrStrategy?: string; ssMethod: string; tag: string; tlsFlowCapable: boolean; diff --git a/frontend/src/generated/zod.ts b/frontend/src/generated/zod.ts index d78e2c4fe..45397a31e 100644 --- a/frontend/src/generated/zod.ts +++ b/frontend/src/generated/zod.ts @@ -421,10 +421,14 @@ export type InboundFallback = z.infer; export const InboundOptionSchema = z.object({ id: z.number().int(), + listen: z.string().optional(), + nodeAddress: z.string().optional(), nodeId: z.number().int().nullable().optional(), port: z.number().int(), protocol: z.string(), remark: z.string(), + shareAddr: z.string().optional(), + shareAddrStrategy: z.string().optional(), ssMethod: z.string(), tag: z.string(), tlsFlowCapable: z.boolean(), diff --git a/frontend/src/lib/xray/inbound-link.ts b/frontend/src/lib/xray/inbound-link.ts index a43c0d1da..abd64026c 100644 --- a/frontend/src/lib/xray/inbound-link.ts +++ b/frontend/src/lib/xray/inbound-link.ts @@ -967,20 +967,51 @@ function isShareableHost(host: string): boolean { return true; } -function shareableListen(inbound: Inbound): string { - const listen = inbound.listen.trim(); - return listen.length > 0 && !isUnixSocketListen(listen) && isShareableHost(listen) - ? normalizeShareHost(listen) +function shareableListenFrom(listen: string): string { + const trimmed = listen.trim(); + return trimmed.length > 0 && !isUnixSocketListen(trimmed) && isShareableHost(trimmed) + ? normalizeShareHost(trimmed) : ''; } type ShareAddrStrategy = 'node' | 'listen' | 'custom'; -function shareAddrStrategy(inbound: Inbound): ShareAddrStrategy { - const strategy = inbound.shareAddrStrategy; - return strategy === 'listen' || strategy === 'custom' - ? strategy - : 'node'; +function normalizeShareAddrStrategy(strategy: string | undefined): ShareAddrStrategy { + return strategy === 'listen' || strategy === 'custom' ? strategy : 'node'; +} + +// ShareHostFields is the subset of an inbound resolveShareHost needs, so callers +// holding only a lightweight projection (e.g. the clients page InboundOption) +// can pick the same host as the full-inbound share/QR path. +export interface ShareHostFields { + listen?: string; + shareAddr?: string; + shareAddrStrategy?: string; +} + +// resolveShareHost picks the host that goes into share/QR links, the browser-side +// analog of the backend resolveInboundAddress. hostOverride is the hosting node's +// address (empty for this panel's own inbounds); fallbackHostname is the +// already-resolved panel/public host used as the last resort — kept verbatim when +// it fails normalization (e.g. an underscore intranet hostname) so the last +// resort never degrades to an empty host. +export function resolveShareHost( + fields: ShareHostFields, + hostOverride: string, + fallbackHostname: string, +): string { + const nodeAddr = normalizeShareHost(hostOverride); + const listenAddr = shareableListenFrom(fields.listen ?? ''); + const customAddr = normalizeShareHost(fields.shareAddr ?? ''); + const fallbackAddr = normalizeShareHost(fallbackHostname) || fallbackHostname.trim(); + switch (normalizeShareAddrStrategy(fields.shareAddrStrategy)) { + case 'listen': + return listenAddr || nodeAddr || fallbackAddr; + case 'custom': + return customAddr || nodeAddr || listenAddr || fallbackAddr; + default: + return nodeAddr || listenAddr || fallbackAddr; + } } // Orchestrators. @@ -989,18 +1020,7 @@ function shareAddrStrategy(inbound: Inbound): ShareAddrStrategy { // node-managed inbounds; other strategies let a row prefer its listen address // or a custom endpoint. export function resolveAddr(inbound: Inbound, hostOverride: string, fallbackHostname: string): string { - const nodeAddr = normalizeShareHost(hostOverride); - const listenAddr = shareableListen(inbound); - const customAddr = normalizeShareHost(inbound.shareAddr ?? ''); - const fallbackAddr = normalizeShareHost(fallbackHostname); - switch (shareAddrStrategy(inbound)) { - case 'listen': - return listenAddr || nodeAddr || fallbackAddr; - case 'custom': - return customAddr || nodeAddr || listenAddr || fallbackAddr; - default: - return nodeAddr || listenAddr || fallbackAddr; - } + return resolveShareHost(inbound, hostOverride, fallbackHostname); } // A loopback browser host means the panel was reached through a tunnel (e.g. diff --git a/frontend/src/pages/clients/wireguardConfig.ts b/frontend/src/pages/clients/wireguardConfig.ts index 53541d19f..27196a0a4 100644 --- a/frontend/src/pages/clients/wireguardConfig.ts +++ b/frontend/src/pages/clients/wireguardConfig.ts @@ -1,5 +1,5 @@ import { formatInboundLabel } from '@/lib/inbounds/label'; -import { preferPublicHost } from '@/lib/xray/inbound-link'; +import { preferPublicHost, resolveShareHost } from '@/lib/xray/inbound-link'; import type { ClientRecord, InboundOption } from '@/hooks/useClients'; export function isWireguardClient(client: ClientRecord | null | undefined): boolean { @@ -22,7 +22,7 @@ export function buildWireguardClientConfig( host = window.location.hostname, publicHost = '', ): string { - const endpointHost = preferPublicHost(host, publicHost); + const endpointHost = resolveShareHost(inbound ?? {}, inbound?.nodeAddress ?? '', preferPublicHost(host, publicHost)); const address = client.allowedIPs || '10.0.0.2/32'; const endpoint = `${endpointHost}:${inbound?.port || ''}`; const inboundName = inbound ? formatInboundLabel(inbound.tag, inbound.remark) : ''; diff --git a/frontend/src/schemas/client.ts b/frontend/src/schemas/client.ts index 6e91e4f32..601d39ea2 100644 --- a/frontend/src/schemas/client.ts +++ b/frontend/src/schemas/client.ts @@ -54,6 +54,13 @@ export const InboundOptionSchema = z.object({ wgDns: z.string().optional(), // Hosting node id; absent/null for this panel's own inbounds (#4997). nodeId: z.number().nullable().optional(), + // Share-host resolution inputs, mirroring the backend resolveInboundAddress so + // the clients page picks the same WireGuard endpoint host as the subscription: + // the hosting node address, the inbound listen, and its share-address strategy. + nodeAddress: z.string().optional(), + listen: z.string().optional(), + shareAddr: z.string().optional(), + shareAddrStrategy: z.string().optional(), }).loose(); export const InboundOptionsSchema = z.array(InboundOptionSchema); diff --git a/frontend/src/test/wireguard-client-config.test.ts b/frontend/src/test/wireguard-client-config.test.ts index 84399d2cf..be1be7f3c 100644 --- a/frontend/src/test/wireguard-client-config.test.ts +++ b/frontend/src/test/wireguard-client-config.test.ts @@ -52,4 +52,41 @@ describe('buildWireguardClientConfig', () => { const cfg = buildWireguardClientConfig({ ...client, preSharedKey: undefined }, inbound, 'example.com', ''); expect(cfg).not.toContain('PresharedKey'); }); + + it('uses the hosting node address as the endpoint host for node-managed inbounds', () => { + const cfg = buildWireguardClientConfig(client, { ...inbound, nodeAddress: 'node.example.net' }, 'master.example.com', ''); + expect(cfg).toContain('Endpoint = node.example.net:51820'); + expect(cfg).not.toContain('master.example.com'); + }); + + it('falls back to the panel host when the node address is blank', () => { + const cfg = buildWireguardClientConfig(client, { ...inbound, nodeAddress: ' ' }, 'master.example.com', ''); + expect(cfg).toContain('Endpoint = master.example.com:51820'); + }); + + it('honors the custom share-address strategy over the node address', () => { + const cfg = buildWireguardClientConfig( + client, + { ...inbound, nodeAddress: 'node.example.net', shareAddrStrategy: 'custom', shareAddr: 'vpn.example.com' }, + 'master.example.com', + '', + ); + expect(cfg).toContain('Endpoint = vpn.example.com:51820'); + }); + + it('honors the listen share-address strategy over the node address', () => { + const cfg = buildWireguardClientConfig( + client, + { ...inbound, nodeAddress: 'node.example.net', shareAddrStrategy: 'listen', listen: '198.51.100.7' }, + 'master.example.com', + '', + ); + expect(cfg).toContain('Endpoint = 198.51.100.7:51820'); + }); + + it('keeps a panel hostname that fails share-host normalization instead of emitting an empty endpoint', () => { + const cfg = buildWireguardClientConfig(client, { ...inbound, listen: '0.0.0.0' }, 'wg_gw.corp.lan', ''); + expect(cfg).toContain('Endpoint = wg_gw.corp.lan:51820'); + expect(cfg).not.toContain('Endpoint = :51820'); + }); }); diff --git a/internal/web/service/inbound.go b/internal/web/service/inbound.go index cfae7910f..7407e31d2 100644 --- a/internal/web/service/inbound.go +++ b/internal/web/service/inbound.go @@ -305,24 +305,39 @@ type InboundOption struct { // Hosting node; nil for this panel's own inbounds. Lets the clients // page map a node filter onto inbound IDs (#4997). NodeId *int `json:"nodeId,omitempty"` + // Share-host resolution inputs, mirroring the subscription's + // resolveInboundAddress so the clients page renders a node-managed WireGuard + // Endpoint that points at the node, not the master panel. NodeAddress is the + // hosting node's externally reachable address (empty for this panel's own + // inbounds); Listen and ShareAddrStrategy/ShareAddr feed the same + // node→listen→custom fallback the share/QR links already use. + NodeAddress string `json:"nodeAddress,omitempty"` + Listen string `json:"listen,omitempty"` + ShareAddr string `json:"shareAddr,omitempty"` + ShareAddrStrategy string `json:"shareAddrStrategy,omitempty"` } func (s *InboundService) GetInboundOptions(userId int) ([]InboundOption, error) { db := database.GetDB() var rows []struct { - Id int `gorm:"column:id"` - Remark string `gorm:"column:remark"` - Tag string `gorm:"column:tag"` - Protocol string `gorm:"column:protocol"` - Port int `gorm:"column:port"` - StreamSettings string `gorm:"column:stream_settings"` - Settings string `gorm:"column:settings"` - NodeId *int `gorm:"column:node_id"` + Id int `gorm:"column:id"` + Remark string `gorm:"column:remark"` + Tag string `gorm:"column:tag"` + Protocol string `gorm:"column:protocol"` + Port int `gorm:"column:port"` + StreamSettings string `gorm:"column:stream_settings"` + Settings string `gorm:"column:settings"` + Listen string `gorm:"column:listen"` + ShareAddr string `gorm:"column:share_addr"` + ShareAddrStrategy string `gorm:"column:share_addr_strategy"` + NodeId *int `gorm:"column:node_id"` + NodeAddress string `gorm:"column:node_address"` } err := db.Table("inbounds"). - Select("id, remark, tag, protocol, port, stream_settings, settings, node_id"). - Where("user_id = ?", userId). - Order("id ASC"). + Select("inbounds.id, inbounds.remark, inbounds.tag, inbounds.protocol, inbounds.port, inbounds.stream_settings, inbounds.settings, inbounds.listen, inbounds.share_addr, inbounds.share_addr_strategy, inbounds.node_id, COALESCE(nodes.address, '') AS node_address"). + Joins("LEFT JOIN nodes ON nodes.id = inbounds.node_id"). + Where("inbounds.user_id = ?", userId). + Order("inbounds.id ASC"). Scan(&rows).Error if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { return nil, err @@ -330,18 +345,26 @@ func (s *InboundService) GetInboundOptions(userId int) ([]InboundOption, error) out := make([]InboundOption, 0, len(rows)) for _, r := range rows { wgPublicKey, wgMtu, wgDns := inboundWireguardHints(r.Protocol, r.Settings) + shareAddrStrategy := r.ShareAddrStrategy + if shareAddrStrategy == "node" { + shareAddrStrategy = "" + } out = append(out, InboundOption{ - Id: r.Id, - Remark: r.Remark, - Tag: r.Tag, - Protocol: r.Protocol, - Port: r.Port, - TlsFlowCapable: inboundCanEnableTlsFlow(r.Protocol, r.StreamSettings, r.Settings), - SsMethod: inboundShadowsocksMethod(r.Protocol, r.Settings), - WgPublicKey: wgPublicKey, - WgMtu: wgMtu, - WgDns: wgDns, - NodeId: r.NodeId, + Id: r.Id, + Remark: r.Remark, + Tag: r.Tag, + Protocol: r.Protocol, + Port: r.Port, + TlsFlowCapable: inboundCanEnableTlsFlow(r.Protocol, r.StreamSettings, r.Settings), + SsMethod: inboundShadowsocksMethod(r.Protocol, r.Settings), + WgPublicKey: wgPublicKey, + WgMtu: wgMtu, + WgDns: wgDns, + NodeId: r.NodeId, + NodeAddress: r.NodeAddress, + Listen: r.Listen, + ShareAddr: r.ShareAddr, + ShareAddrStrategy: shareAddrStrategy, }) } return out, nil diff --git a/internal/web/service/inbound_options_node_address_test.go b/internal/web/service/inbound_options_node_address_test.go new file mode 100644 index 000000000..bd8d3ea90 --- /dev/null +++ b/internal/web/service/inbound_options_node_address_test.go @@ -0,0 +1,89 @@ +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) + } +}