From 2a7342baa9bea24952ef73601341feeeb49ca026 Mon Sep 17 00:00:00 2001 From: iYuan Date: Fri, 12 Jun 2026 02:24:15 +0800 Subject: [PATCH] feat: add inbound share address strategy (#5162) * feat: add inbound share address strategy Allow node-managed inbounds to choose whether exported share links use the node address, routable listen address, or a custom endpoint. Preserve locally configured share address fields during remote node traffic sync. Refs #5161 Refs #4891 * fix: preserve inbound share address settings Forward share address fields to remote nodes, keep existing values when older update payloads omit them, align localhost handling between frontend and subscriptions, and preserve share address settings when cloning inbounds. * fix: keep share address strategy out of subscriptions Limit the new share address strategy to direct exported share links and QR codes. Restore subscription address resolution to the existing panel-owned behavior and update the UI help text accordingly. * fix: address share address review feedback * fix: validate custom share address * fix --------- Co-authored-by: Sanaei --- frontend/public/openapi.json | 15 ++ frontend/src/generated/examples.ts | 2 + frontend/src/generated/schemas.ts | 13 ++ frontend/src/generated/types.ts | 2 + frontend/src/generated/zod.ts | 2 + frontend/src/lib/xray/inbound-form-adapter.ts | 17 ++- frontend/src/lib/xray/inbound-from-db.ts | 4 + frontend/src/lib/xray/inbound-link.ts | 89 +++++++++--- frontend/src/models/dbinbound.ts | 6 + frontend/src/pages/inbounds/InboundsPage.tsx | 2 + .../pages/inbounds/form/InboundFormModal.tsx | 57 ++++++++ frontend/src/schemas/api/inbound.ts | 2 + frontend/src/schemas/forms/inbound-form.ts | 4 + .../inbound-form-modal.test.tsx.snap | 10 ++ .../__snapshots__/inbound-full.test.ts.snap | 16 +++ .../src/test/inbound-form-adapter.test.ts | 13 ++ frontend/src/test/inbound-link.test.ts | 52 +++++++ internal/database/model/model.go | 18 +-- internal/sub/service.go | 4 +- internal/web/runtime/remote.go | 8 ++ internal/web/runtime/remote_test.go | 27 ++++ internal/web/service/inbound.go | 135 ++++++++++++++++++ internal/web/service/inbound_migration.go | 3 + .../web/service/inbound_migration_test.go | 87 +++++++++++ internal/web/service/inbound_node.go | 1 + .../web/service/inbound_update_tag_test.go | 82 +++++++++++ internal/web/service/node_origin_guid_test.go | 100 +++++++++++++ internal/web/translation/ar-EG.json | 11 +- internal/web/translation/en-US.json | 11 +- internal/web/translation/es-ES.json | 11 +- internal/web/translation/fa-IR.json | 11 +- internal/web/translation/id-ID.json | 11 +- internal/web/translation/ja-JP.json | 11 +- internal/web/translation/pt-BR.json | 11 +- internal/web/translation/ru-RU.json | 11 +- internal/web/translation/tr-TR.json | 11 +- internal/web/translation/uk-UA.json | 11 +- internal/web/translation/vi-VN.json | 11 +- internal/web/translation/zh-CN.json | 11 +- internal/web/translation/zh-TW.json | 11 +- 40 files changed, 874 insertions(+), 40 deletions(-) diff --git a/frontend/public/openapi.json b/frontend/public/openapi.json index 1026144bd..d142f0d43 100644 --- a/frontend/public/openapi.json +++ b/frontend/public/openapi.json @@ -1332,6 +1332,17 @@ "type": "string" }, "settings": {}, + "shareAddr": { + "type": "string" + }, + "shareAddrStrategy": { + "enum": [ + "node", + "listen", + "custom" + ], + "type": "string" + }, "sniffing": {}, "streamSettings": {}, "tag": { @@ -1370,6 +1381,8 @@ "protocol", "remark", "settings", + "shareAddr", + "shareAddrStrategy", "sniffing", "streamSettings", "tag", @@ -2116,6 +2129,8 @@ "protocol": "vless", "remark": "VLESS-443", "settings": null, + "shareAddr": "", + "shareAddrStrategy": "node", "sniffing": null, "streamSettings": null, "tag": "in-443-tcp", diff --git a/frontend/src/generated/examples.ts b/frontend/src/generated/examples.ts index fc406257d..16992415f 100644 --- a/frontend/src/generated/examples.ts +++ b/frontend/src/generated/examples.ts @@ -288,6 +288,8 @@ export const EXAMPLES: Record = { "protocol": "vless", "remark": "VLESS-443", "settings": null, + "shareAddr": "", + "shareAddrStrategy": "node", "sniffing": null, "streamSettings": null, "tag": "in-443-tcp", diff --git a/frontend/src/generated/schemas.ts b/frontend/src/generated/schemas.ts index 7ff2a4aeb..6c63731fb 100644 --- a/frontend/src/generated/schemas.ts +++ b/frontend/src/generated/schemas.ts @@ -1306,6 +1306,17 @@ export const SCHEMAS: Record = { "type": "string" }, "settings": {}, + "shareAddr": { + "type": "string" + }, + "shareAddrStrategy": { + "enum": [ + "node", + "listen", + "custom" + ], + "type": "string" + }, "sniffing": {}, "streamSettings": {}, "tag": { @@ -1344,6 +1355,8 @@ export const SCHEMAS: Record = { "protocol", "remark", "settings", + "shareAddr", + "shareAddrStrategy", "sniffing", "streamSettings", "tag", diff --git a/frontend/src/generated/types.ts b/frontend/src/generated/types.ts index 1cca057e4..c9869acf0 100644 --- a/frontend/src/generated/types.ts +++ b/frontend/src/generated/types.ts @@ -289,6 +289,8 @@ export interface Inbound { protocol: Protocol; remark: string; settings: unknown; + shareAddr: string; + shareAddrStrategy: string; sniffing: unknown; streamSettings: unknown; tag: string; diff --git a/frontend/src/generated/zod.ts b/frontend/src/generated/zod.ts index 34d7e23c3..b3207d6c1 100644 --- a/frontend/src/generated/zod.ts +++ b/frontend/src/generated/zod.ts @@ -310,6 +310,8 @@ export const InboundSchema = z.object({ protocol: z.enum(['vmess', 'vless', 'trojan', 'shadowsocks', 'wireguard', 'hysteria', 'http', 'mixed', 'tunnel', 'tun', 'mtproto']), remark: z.string(), settings: z.unknown(), + shareAddr: z.string(), + shareAddrStrategy: z.enum(['node', 'listen', 'custom']), sniffing: z.unknown(), streamSettings: z.unknown(), tag: z.string(), diff --git a/frontend/src/lib/xray/inbound-form-adapter.ts b/frontend/src/lib/xray/inbound-form-adapter.ts index 2d19278c8..6c53175c8 100644 --- a/frontend/src/lib/xray/inbound-form-adapter.ts +++ b/frontend/src/lib/xray/inbound-form-adapter.ts @@ -1,4 +1,4 @@ -import type { InboundFormValues, TrafficReset } from '@/schemas/forms/inbound-form'; +import type { InboundFormValues, ShareAddrStrategy, TrafficReset } from '@/schemas/forms/inbound-form'; import type { InboundSettings } from '@/schemas/protocols/inbound'; import { HysteriaClientSchema, @@ -37,6 +37,8 @@ export interface RawInboundRow { trafficReset?: string; lastTrafficResetTime?: number; nodeId?: number | null; + shareAddrStrategy?: string; + shareAddr?: string; clientStats?: unknown; } @@ -61,6 +63,8 @@ export interface WireInboundPayload { tag: string; clientStats?: unknown; nodeId?: number; + shareAddrStrategy: ShareAddrStrategy; + shareAddr: string; } function coerceJsonObject(value: unknown): Record { @@ -82,6 +86,7 @@ function coerceJsonObject(value: unknown): Record { } const TRAFFIC_RESETS: TrafficReset[] = ['never', 'hourly', 'daily', 'weekly', 'monthly']; +const SHARE_ADDR_STRATEGIES: ShareAddrStrategy[] = ['node', 'listen', 'custom']; function coerceTrafficReset(v: unknown): TrafficReset { return typeof v === 'string' && (TRAFFIC_RESETS as string[]).includes(v) @@ -89,6 +94,12 @@ function coerceTrafficReset(v: unknown): TrafficReset { : 'never'; } +function coerceShareAddrStrategy(v: unknown): ShareAddrStrategy { + return typeof v === 'string' && (SHARE_ADDR_STRATEGIES as string[]).includes(v) + ? (v as ShareAddrStrategy) + : 'node'; +} + // Network values that map to a required `${network}Settings` key in // NetworkSettingsSchema. Older saved inbounds may be missing the per- // network sub-object (the legacy panel sometimes emitted streamSettings @@ -162,6 +173,8 @@ export function rawInboundToFormValues(row: RawInboundRow): InboundFormValues { trafficReset: coerceTrafficReset(row.trafficReset), lastTrafficResetTime: row.lastTrafficResetTime ?? 0, nodeId: row.nodeId ?? null, + shareAddrStrategy: coerceShareAddrStrategy(row.shareAddrStrategy), + shareAddr: row.shareAddr ?? '', protocol, settings, } as InboundFormValues; @@ -307,6 +320,8 @@ export function formValuesToWirePayload(values: InboundFormValues): WireInboundP // rather than the default { enabled: false } so the row carries no sniffing. sniffing: canEnableSniffing({ protocol: values.protocol }) ? JSON.stringify(normalizeSniffing(values.sniffing)) : '', tag: values.tag, + shareAddrStrategy: values.shareAddrStrategy, + shareAddr: values.shareAddr, }; if (values.nodeId != null) payload.nodeId = values.nodeId; return payload; diff --git a/frontend/src/lib/xray/inbound-from-db.ts b/frontend/src/lib/xray/inbound-from-db.ts index c29abc388..a5d7f3e36 100644 --- a/frontend/src/lib/xray/inbound-from-db.ts +++ b/frontend/src/lib/xray/inbound-from-db.ts @@ -18,6 +18,8 @@ export interface DbInboundLike { up?: number; down?: number; total?: number; + shareAddrStrategy?: string; + shareAddr?: string; } function fillProtocolSettingsDefaults(protocol: string, settings: Record): Record { @@ -48,6 +50,8 @@ export function inboundFromDb(raw: DbInboundLike): Inbound { up: raw.up ?? 0, down: raw.down ?? 0, total: raw.total ?? 0, + shareAddrStrategy: raw.shareAddrStrategy ?? 'node', + shareAddr: raw.shareAddr ?? '', settings, streamSettings, sniffing, diff --git a/frontend/src/lib/xray/inbound-link.ts b/frontend/src/lib/xray/inbound-link.ts index decc72721..bd3db73cf 100644 --- a/frontend/src/lib/xray/inbound-link.ts +++ b/frontend/src/lib/xray/inbound-link.ts @@ -21,6 +21,7 @@ import { getHeaderValue } from './headers'; // directly. type ForceTls = 'same' | 'tls' | 'none'; +const SHARE_HOSTNAME_RE = /^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)*$/; // xHTTP headers ship as Record on the wire (Zod schema) // rather than the legacy class's HeaderEntry[]. Lookup by case-folded key. @@ -777,19 +778,76 @@ function isUnixSocketListen(listen: string): boolean { return listen.startsWith('/') || listen.startsWith('@'); } -// Orchestrators. -// resolveAddr picks the host that goes into share/sub links. Order: -// 1. hostOverride (caller supplies node address for node-managed inbounds) -// 2. inbound's bind listen (when it's an explicit reachable address — -// not 0.0.0.0 and not a unix domain socket path) -// 3. fallbackHostname (caller-supplied — typically window.location.hostname -// in the browser; tests pass a fixed value) -export function resolveAddr(inbound: Inbound, hostOverride: string, fallbackHostname: string): string { - if (hostOverride.length > 0) return hostOverride; - if (inbound.listen.length > 0 && inbound.listen !== '0.0.0.0' && !isUnixSocketListen(inbound.listen)) { - return inbound.listen; +function normalizeShareHost(host: string): string { + const h = host.trim(); + if ( + h.length === 0 + || h.includes('://') + || h.startsWith('//') + || /[/?#@]/.test(h) + ) { + return ''; + } + if (h.startsWith('[')) { + if (!h.endsWith(']')) return ''; + try { + return new URL(`http://${h}`).hostname; + } catch { + return ''; + } + } + if (h.includes(':')) { + try { + return new URL(`http://[${h}]`).hostname; + } catch { + return ''; + } + } + return SHARE_HOSTNAME_RE.test(h) ? h : ''; +} + +function isShareableHost(host: string): boolean { + const h = normalizeShareHost(host).replace(/^\[|\]$/g, '').toLowerCase(); + if (h.length === 0) return false; + if (h === '0.0.0.0' || h === '::' || h === '::0') return false; + if (h === 'localhost' || h === '::1' || h.startsWith('127.')) return false; + return true; +} + +function shareableListen(inbound: Inbound): string { + const listen = inbound.listen.trim(); + return listen.length > 0 && !isUnixSocketListen(listen) && isShareableHost(listen) + ? normalizeShareHost(listen) + : ''; +} + +type ShareAddrStrategy = 'node' | 'listen' | 'custom'; + +function shareAddrStrategy(inbound: Inbound): ShareAddrStrategy { + const strategy = inbound.shareAddrStrategy; + return strategy === 'listen' || strategy === 'custom' + ? strategy + : 'node'; +} + +// Orchestrators. +// resolveAddr picks the host that goes into share/QR links. The default +// `node` strategy keeps the previous node-address-first behavior for +// 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 fallbackHostname; } // A loopback browser host means the panel was reached through a tunnel (e.g. @@ -801,10 +859,9 @@ function isLoopbackHost(host: string): boolean { // preferPublicHost is the browser-side analog of the backend's // configuredPublicHost: when the panel is reached on a loopback host, prefer a -// configured public host (Sub/Web Domain) for share/QR links so they match the -// subscription links instead of leaking localhost. An explicit per-inbound -// listen or node override still wins, since resolveAddr only reaches the -// fallbackHostname after those. +// configured public host (Sub/Web Domain) for share/QR links instead of leaking +// localhost. An explicit per-inbound listen or node override still wins, since +// resolveAddr only reaches the fallbackHostname after those. export function preferPublicHost(browserHost: string, publicHost: string): string { return publicHost && isLoopbackHost(browserHost) ? publicHost : browserHost; } diff --git a/frontend/src/models/dbinbound.ts b/frontend/src/models/dbinbound.ts index d6ba7bdca..badb35106 100644 --- a/frontend/src/models/dbinbound.ts +++ b/frontend/src/models/dbinbound.ts @@ -40,6 +40,8 @@ export type DBInboundInit = Partial<{ sniffing: RawJsonField; clientStats: ClientStats[]; nodeId: number | null; + shareAddrStrategy: string; + shareAddr: string; originNodeGuid: string; fallbackParent: FallbackParentRef | null; }>; @@ -84,6 +86,8 @@ export class DBInbound { sniffing: RawJsonField; clientStats: ClientStats[]; nodeId: number | null; + shareAddrStrategy: string; + shareAddr: string; originNodeGuid: string; fallbackParent: FallbackParentRef | null; @@ -110,6 +114,8 @@ export class DBInbound { this.sniffing = ""; this.clientStats = []; this.nodeId = null; + this.shareAddrStrategy = "node"; + this.shareAddr = ""; this.originNodeGuid = ""; this.fallbackParent = null; if (data == null) { diff --git a/frontend/src/pages/inbounds/InboundsPage.tsx b/frontend/src/pages/inbounds/InboundsPage.tsx index e78ef93d2..9c385d173 100644 --- a/frontend/src/pages/inbounds/InboundsPage.tsx +++ b/frontend/src/pages/inbounds/InboundsPage.tsx @@ -457,6 +457,8 @@ export default function InboundsPage() { settings: clonedSettings, streamSettings: streamSettingsString, sniffing: sniffingString, + shareAddrStrategy: dbInbound.shareAddrStrategy, + shareAddr: dbInbound.shareAddr, }; const msg = await HttpUtil.post('/panel/api/inbounds/add', data); if (msg?.success) await refresh(); diff --git a/frontend/src/pages/inbounds/form/InboundFormModal.tsx b/frontend/src/pages/inbounds/form/InboundFormModal.tsx index caf7f9fd5..6cd021292 100644 --- a/frontend/src/pages/inbounds/form/InboundFormModal.tsx +++ b/frontend/src/pages/inbounds/form/InboundFormModal.tsx @@ -84,6 +84,8 @@ import type { NodeRecord } from '@/api/queries/useNodesQuery'; const PROTOCOL_OPTIONS = Object.values(Protocols).map((p) => ({ value: p, label: p })); const TRAFFIC_RESETS = ['never', 'hourly', 'daily', 'weekly', 'monthly'] as const; +const SHARE_ADDR_STRATEGIES = ['node', 'listen', 'custom'] as const; +const SHARE_ADDR_HOSTNAME_RE = /^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)*$/; const NODE_ELIGIBLE_PROTOCOLS = new Set([ Protocols.VLESS, Protocols.VMESS, @@ -93,6 +95,30 @@ const NODE_ELIGIBLE_PROTOCOLS = new Set([ Protocols.WIREGUARD, ]); +function isValidShareAddrInput(value: string): boolean { + const v = value.trim(); + if (v.length === 0) return true; + if (v.includes('://') || v.startsWith('//') || /[/?#@]/.test(v)) return false; + if (v.startsWith('[')) { + if (!v.endsWith(']')) return false; + try { + new URL(`http://${v}`); + return true; + } catch { + return false; + } + } + if (v.includes(':')) { + try { + new URL(`http://[${v}]`); + return true; + } catch { + return false; + } + } + return SHARE_ADDR_HOSTNAME_RE.test(v); +} + interface InboundFormModalProps { open: boolean; onClose: () => void; @@ -176,6 +202,7 @@ export default function InboundFormModal({ const wListen = (Form.useWatch('listen', form) ?? '') as string; const isUdsListen = wListen.startsWith('/'); const wNodeId = Form.useWatch('nodeId', form) ?? null; + const shareAddrStrategy = Form.useWatch('shareAddrStrategy', form) ?? 'node'; const wTag = Form.useWatch('tag', form) ?? ''; const wSsNetwork = Form.useWatch(['settings', 'network'], form); const wTunnelNetwork = Form.useWatch(['settings', 'allowedNetwork'], form); @@ -499,6 +526,36 @@ export default function InboundFormModal({ + + + + )} + ; export const TrafficResetSchema = z.enum(['never', 'hourly', 'daily', 'weekly', 'monthly']); export type TrafficReset = z.infer; +export const ShareAddrStrategySchema = z.enum(['node', 'listen', 'custom']); +export type ShareAddrStrategy = z.infer; // Db-side fields layered on top of the xray slice. These mirror the // DBInbound model — they live in the SQL row, not in xray's config. @@ -35,6 +37,8 @@ export const InboundDbFieldsSchema = z.object({ trafficReset: TrafficResetSchema.default('never'), lastTrafficResetTime: z.number().int().default(0), nodeId: z.number().int().nullable().optional(), + shareAddrStrategy: ShareAddrStrategySchema.default('node'), + shareAddr: z.string().default(''), }); export type InboundDbFields = z.infer; diff --git a/frontend/src/test/__snapshots__/inbound-form-modal.test.tsx.snap b/frontend/src/test/__snapshots__/inbound-form-modal.test.tsx.snap index 9e97d9795..d18a905f4 100644 --- a/frontend/src/test/__snapshots__/inbound-form-modal.test.tsx.snap +++ b/frontend/src/test/__snapshots__/inbound-form-modal.test.tsx.snap @@ -6,6 +6,7 @@ exports[`InboundFormModal > field structure is stable for every protocol > http "Remark", "Protocol", "Address", + "Share address strategy", "Port", "Total Flow", "Traffic Reset", @@ -20,6 +21,7 @@ exports[`InboundFormModal > field structure is stable for every protocol > hyste "Remark", "Protocol", "Address", + "Share address strategy", "Port", "Total Flow", "Traffic Reset", @@ -34,6 +36,7 @@ exports[`InboundFormModal > field structure is stable for every protocol > mixed "Remark", "Protocol", "Address", + "Share address strategy", "Port", "Total Flow", "Traffic Reset", @@ -48,6 +51,7 @@ exports[`InboundFormModal > field structure is stable for every protocol > shado "Remark", "Protocol", "Address", + "Share address strategy", "Port", "Total Flow", "Traffic Reset", @@ -62,6 +66,7 @@ exports[`InboundFormModal > field structure is stable for every protocol > troja "Remark", "Protocol", "Address", + "Share address strategy", "Port", "Total Flow", "Traffic Reset", @@ -76,6 +81,7 @@ exports[`InboundFormModal > field structure is stable for every protocol > tun 1 "Remark", "Protocol", "Address", + "Share address strategy", "Port", "Total Flow", "Traffic Reset", @@ -90,6 +96,7 @@ exports[`InboundFormModal > field structure is stable for every protocol > tunne "Remark", "Protocol", "Address", + "Share address strategy", "Port", "Total Flow", "Traffic Reset", @@ -104,6 +111,7 @@ exports[`InboundFormModal > field structure is stable for every protocol > vless "Remark", "Protocol", "Address", + "Share address strategy", "Port", "Total Flow", "Traffic Reset", @@ -118,6 +126,7 @@ exports[`InboundFormModal > field structure is stable for every protocol > vmess "Remark", "Protocol", "Address", + "Share address strategy", "Port", "Total Flow", "Traffic Reset", @@ -132,6 +141,7 @@ exports[`InboundFormModal > field structure is stable for every protocol > wireg "Remark", "Protocol", "Address", + "Share address strategy", "Port", "Total Flow", "Traffic Reset", diff --git a/frontend/src/test/__snapshots__/inbound-full.test.ts.snap b/frontend/src/test/__snapshots__/inbound-full.test.ts.snap index 87f3b1064..8b16f3581 100644 --- a/frontend/src/test/__snapshots__/inbound-full.test.ts.snap +++ b/frontend/src/test/__snapshots__/inbound-full.test.ts.snap @@ -27,6 +27,8 @@ exports[`InboundSchema (full) fixtures > parses hysteria-v1-tls byte-stably 1`] ], "version": 1, }, + "shareAddr": "", + "shareAddrStrategy": "node", "sniffing": { "destOverride": [ "http", @@ -112,6 +114,8 @@ exports[`InboundSchema (full) fixtures > parses shadowsocks-tcp-2022 byte-stably "network": "tcp,udp", "password": "ZmFrZS1zZXJ2ZXItcGFzc3dvcmQtMDAwMQ==", }, + "shareAddr": "", + "shareAddrStrategy": "node", "sniffing": { "destOverride": [ "http", @@ -168,6 +172,8 @@ exports[`InboundSchema (full) fixtures > parses trojan-ws-tls byte-stably 1`] = ], "fallbacks": [], }, + "shareAddr": "", + "shareAddrStrategy": "node", "sniffing": { "destOverride": [ "http", @@ -257,6 +263,8 @@ exports[`InboundSchema (full) fixtures > parses vless-tcp-reality byte-stably 1` "encryption": "none", "fallbacks": [], }, + "shareAddr": "", + "shareAddrStrategy": "node", "sniffing": { "destOverride": [ "http", @@ -341,6 +349,8 @@ exports[`InboundSchema (full) fixtures > parses vless-ws-tls byte-stably 1`] = ` "encryption": "none", "fallbacks": [], }, + "shareAddr": "", + "shareAddrStrategy": "node", "sniffing": { "destOverride": [ "http", @@ -430,6 +440,8 @@ exports[`InboundSchema (full) fixtures > parses vless-ws-tls-pinned byte-stably "encryption": "none", "fallbacks": [], }, + "shareAddr": "", + "shareAddrStrategy": "node", "sniffing": { "destOverride": [ "http", @@ -520,6 +532,8 @@ exports[`InboundSchema (full) fixtures > parses vmess-tcp-tls byte-stably 1`] = }, ], }, + "shareAddr": "", + "shareAddrStrategy": "node", "sniffing": { "destOverride": [ "http", @@ -603,6 +617,8 @@ exports[`InboundSchema (full) fixtures > parses wireguard-server byte-stably 1`] ], "secretKey": "iJ2cBkrSGqRwIfYIDIxk7hr5RXfdR93MfJUL7yqkkH8=", }, + "shareAddr": "", + "shareAddrStrategy": "node", "sniffing": { "destOverride": [ "http", diff --git a/frontend/src/test/inbound-form-adapter.test.ts b/frontend/src/test/inbound-form-adapter.test.ts index 3f01c539e..201bf42b0 100644 --- a/frontend/src/test/inbound-form-adapter.test.ts +++ b/frontend/src/test/inbound-form-adapter.test.ts @@ -104,6 +104,8 @@ describe('rawInboundToFormValues', () => { if (name === 'empty stream settings drop to undefined') { expect(values.streamSettings).toBeUndefined(); } + expect(values.shareAddrStrategy).toBe('node'); + expect(values.shareAddr).toBe(''); }); } @@ -215,6 +217,17 @@ describe('formValuesToWirePayload', () => { expect(payload.nodeId).toBe(42); }); + it('round-trips share address strategy fields', () => { + const values = rawInboundToFormValues({ + ...vlessRow, + shareAddrStrategy: 'custom', + shareAddr: 'edge.example.test', + }); + const payload = formValuesToWirePayload(values); + expect(payload.shareAddrStrategy).toBe('custom'); + expect(payload.shareAddr).toBe('edge.example.test'); + }); + it('round-trips top-level fields through raw → values → payload → values', () => { // settings/streamSettings/sniffing don't round-trip byte-equal because // the wire payload prunes empty arrays and collapses disabled sniffing diff --git a/frontend/src/test/inbound-link.test.ts b/frontend/src/test/inbound-link.test.ts index 91a7a7d87..accee1a63 100644 --- a/frontend/src/test/inbound-link.test.ts +++ b/frontend/src/test/inbound-link.test.ts @@ -309,6 +309,58 @@ describe('resolveAddr precedence', () => { 'fallback.test', )).toBe('fallback.test'); }); + + it('uses listen strategy with a shareable IPv6 listen before node override', () => { + expect(resolveAddr( + { ...baseInbound, listen: '[2001:db8::1]', shareAddrStrategy: 'listen', shareAddr: '' } as never, + 'node.example.test', + 'fallback.test', + )).toBe('[2001:db8::1]'); + }); + + it('uses listen strategy to prefer listen and fall back to node override', () => { + expect(resolveAddr( + { ...baseInbound, listen: '10.0.0.1', shareAddrStrategy: 'listen', shareAddr: '' } as never, + 'node.example.test', + 'fallback.test', + )).toBe('10.0.0.1'); + expect(resolveAddr( + { ...baseInbound, listen: '0.0.0.0', shareAddrStrategy: 'listen', shareAddr: '' } as never, + 'node.example.test', + 'fallback.test', + )).toBe('node.example.test'); + expect(resolveAddr( + { ...baseInbound, listen: 'localhost', shareAddrStrategy: 'listen', shareAddr: '' } as never, + 'node.example.test', + 'fallback.test', + )).toBe('node.example.test'); + }); + + it('uses custom strategy address before node override', () => { + expect(resolveAddr( + { ...baseInbound, listen: '10.0.0.1', shareAddrStrategy: 'custom', shareAddr: 'edge.example.test' } as never, + 'node.example.test', + 'fallback.test', + )).toBe('edge.example.test'); + }); + + it('normalizes a bare IPv6 custom strategy address', () => { + expect(resolveAddr( + { ...baseInbound, listen: '10.0.0.1', shareAddrStrategy: 'custom', shareAddr: '2001:db8::2' } as never, + 'node.example.test', + 'fallback.test', + )).toBe('[2001:db8::2]'); + }); + + it('ignores invalid custom strategy addresses and falls back to node override', () => { + for (const shareAddr of ['https://edge.example.test', 'edge.example.test:8443', '[2001:db8::2]:8443', 'bad host']) { + expect(resolveAddr( + { ...baseInbound, listen: '10.0.0.1', shareAddrStrategy: 'custom', shareAddr } as never, + 'node.example.test', + 'fallback.test', + )).toBe('node.example.test'); + } + }); }); // #4829: reaching the panel through an SSH tunnel (127.0.0.1/localhost) must not diff --git a/internal/database/model/model.go b/internal/database/model/model.go index 1de73b7b1..353a6d84c 100644 --- a/internal/database/model/model.go +++ b/internal/database/model/model.go @@ -57,14 +57,16 @@ type Inbound struct { ClientStats []xray.ClientTraffic `gorm:"foreignKey:InboundId;references:Id" json:"clientStats" form:"clientStats"` // Client traffic statistics // Xray configuration fields - Listen string `json:"listen" form:"listen"` - Port int `json:"port" form:"port" validate:"gte=0,lte=65535" example:"443"` - Protocol Protocol `json:"protocol" form:"protocol" validate:"required,oneof=vmess vless trojan shadowsocks wireguard hysteria http mixed tunnel tun mtproto" example:"vless"` - Settings string `json:"settings" form:"settings"` - StreamSettings string `json:"streamSettings" form:"streamSettings"` - Tag string `json:"tag" form:"tag" gorm:"unique" example:"in-443-tcp"` - Sniffing string `json:"sniffing" form:"sniffing"` - NodeID *int `json:"nodeId,omitempty" form:"nodeId" gorm:"index"` + Listen string `json:"listen" form:"listen"` + Port int `json:"port" form:"port" validate:"gte=0,lte=65535" example:"443"` + Protocol Protocol `json:"protocol" form:"protocol" validate:"required,oneof=vmess vless trojan shadowsocks wireguard hysteria http mixed tunnel tun mtproto" example:"vless"` + Settings string `json:"settings" form:"settings"` + StreamSettings string `json:"streamSettings" form:"streamSettings"` + Tag string `json:"tag" form:"tag" gorm:"unique" example:"in-443-tcp"` + Sniffing string `json:"sniffing" form:"sniffing"` + NodeID *int `json:"nodeId,omitempty" form:"nodeId" gorm:"index"` + ShareAddrStrategy string `json:"shareAddrStrategy" form:"shareAddrStrategy" gorm:"column:share_addr_strategy;default:node" validate:"omitempty,oneof=node listen custom"` + ShareAddr string `json:"shareAddr" form:"shareAddr" gorm:"column:share_addr"` // OriginNodeGuid is the panelGuid of the node that physically hosts this // inbound, propagated up across hops (#4983). Empty for an inbound that diff --git a/internal/sub/service.go b/internal/sub/service.go index 1634b5a41..48dec16d5 100644 --- a/internal/sub/service.go +++ b/internal/sub/service.go @@ -795,8 +795,8 @@ func (s *SubService) loadNodes() { // // A loopback/wildcard bind or a unix-domain-socket listen is a server-side // detail and is never advertised; External Proxy remains the way to advertise -// an arbitrary endpoint. Mirrors the frontend's resolveAddr so the panel QR and -// the subscription agree. +// an arbitrary endpoint. This subscription path intentionally ignores +// per-inbound share address settings because subscription URLs are panel-owned. func (s *SubService) resolveInboundAddress(inbound *model.Inbound) string { if inbound.NodeID != nil && s.nodesByID != nil { if n, ok := s.nodesByID[*inbound.NodeID]; ok && n.Address != "" { diff --git a/internal/web/runtime/remote.go b/internal/web/runtime/remote.go index 3bbc695b2..03475c5df 100644 --- a/internal/web/runtime/remote.go +++ b/internal/web/runtime/remote.go @@ -480,6 +480,14 @@ func wireInbound(ib *model.Inbound) url.Values { v.Set("streamSettings", sanitizeStreamSettingsForRemote(ib.StreamSettings)) v.Set("tag", ib.Tag) v.Set("sniffing", ib.Sniffing) + shareAddrStrategy := strings.TrimSpace(ib.ShareAddrStrategy) + switch shareAddrStrategy { + case "listen", "custom": + default: + shareAddrStrategy = "node" + } + v.Set("shareAddrStrategy", shareAddrStrategy) + v.Set("shareAddr", ib.ShareAddr) if ib.TrafficReset != "" { v.Set("trafficReset", ib.TrafficReset) } diff --git a/internal/web/runtime/remote_test.go b/internal/web/runtime/remote_test.go index c4f4b3b57..224dacedf 100644 --- a/internal/web/runtime/remote_test.go +++ b/internal/web/runtime/remote_test.go @@ -36,6 +36,33 @@ func TestCacheGetTag_PrefixAgnostic(t *testing.T) { } } +func TestWireInboundIncludesShareAddressFields(t *testing.T) { + values := wireInbound(&model.Inbound{ + ShareAddrStrategy: "custom", + ShareAddr: "edge.example.com", + }) + + if got := values.Get("shareAddrStrategy"); got != "custom" { + t.Fatalf("shareAddrStrategy = %q, want custom", got) + } + if got := values.Get("shareAddr"); got != "edge.example.com" { + t.Fatalf("shareAddr = %q, want edge.example.com", got) + } +} + +func TestWireInboundDefaultsShareAddressStrategy(t *testing.T) { + values := wireInbound(&model.Inbound{}) + + if got := values.Get("shareAddrStrategy"); got != "node" { + t.Fatalf("shareAddrStrategy = %q, want node", got) + } + + values = wireInbound(&model.Inbound{ShareAddrStrategy: "auto"}) + if got := values.Get("shareAddrStrategy"); got != "node" { + t.Fatalf("invalid shareAddrStrategy = %q, want node", got) + } +} + func TestSanitizeStreamSettingsForRemote(t *testing.T) { tests := []struct { name string diff --git a/internal/web/service/inbound.go b/internal/web/service/inbound.go index 99953bfea..60d07484d 100644 --- a/internal/web/service/inbound.go +++ b/internal/web/service/inbound.go @@ -6,6 +6,7 @@ import ( "context" "encoding/json" "fmt" + "net" "sort" "strings" "time" @@ -14,6 +15,7 @@ import ( "github.com/mhsanaei/3x-ui/v3/internal/database/model" "github.com/mhsanaei/3x-ui/v3/internal/logger" "github.com/mhsanaei/3x-ui/v3/internal/util/common" + "github.com/mhsanaei/3x-ui/v3/internal/util/netsafe" "github.com/mhsanaei/3x-ui/v3/internal/xray" "gorm.io/gorm" @@ -25,6 +27,125 @@ type InboundService struct { fallbackService FallbackService } +func normalizeInboundShareAddrStrategy(strategy string) string { + strategy = strings.TrimSpace(strategy) + switch strategy { + case "listen", "custom": + return strategy + default: + return "node" + } +} + +func normalizeInboundShareAddress(inbound *model.Inbound) { + if inbound == nil { + return + } + inbound.ShareAddrStrategy = normalizeInboundShareAddrStrategy(inbound.ShareAddrStrategy) + if addr, err := normalizeInboundShareHost(inbound.ShareAddr); err == nil { + inbound.ShareAddr = addr + } else { + inbound.ShareAddr = strings.TrimSpace(inbound.ShareAddr) + } +} + +func normalizeInboundShareAddressStrict(inbound *model.Inbound) error { + if inbound == nil { + return nil + } + inbound.ShareAddrStrategy = normalizeInboundShareAddrStrategy(inbound.ShareAddrStrategy) + addr, err := normalizeInboundShareHost(inbound.ShareAddr) + if err != nil { + return common.NewError("shareAddr must be a host or IP without scheme or port") + } + inbound.ShareAddr = addr + return nil +} + +func normalizeInboundShareHost(raw string) (string, error) { + addr := strings.TrimSpace(raw) + if addr == "" { + return "", nil + } + if strings.Contains(addr, "://") || strings.HasPrefix(addr, "//") || strings.ContainsAny(addr, "/?#@") { + return "", fmt.Errorf("invalid share address %q", raw) + } + if strings.HasPrefix(addr, "[") { + if !strings.HasSuffix(addr, "]") { + return "", fmt.Errorf("invalid IPv6 host %q", raw) + } + ip := net.ParseIP(addr[1 : len(addr)-1]) + if ip == nil || ip.To4() != nil { + return "", fmt.Errorf("invalid IPv6 host %q", raw) + } + return "[" + ip.String() + "]", nil + } + if strings.Contains(addr, ":") { + if _, _, err := net.SplitHostPort(addr); err == nil { + return "", fmt.Errorf("share address must not include port") + } + ip := net.ParseIP(addr) + if ip == nil || ip.To4() != nil { + return "", fmt.Errorf("invalid IPv6 host %q", raw) + } + return "[" + ip.String() + "]", nil + } + host, err := netsafe.NormalizeHost(addr) + if err != nil { + return "", err + } + return host, nil +} + +func normalizeInboundShareAddressColumns(tx *gorm.DB) error { + if tx == nil || !tx.Migrator().HasColumn(&model.Inbound{}, "share_addr_strategy") { + return nil + } + + strategyExpr := `CASE TRIM(COALESCE(share_addr_strategy, '')) WHEN 'listen' THEN 'listen' WHEN 'custom' THEN 'custom' ELSE 'node' END` + if err := tx.Exec(`UPDATE inbounds SET share_addr_strategy = ` + strategyExpr + ` WHERE share_addr_strategy IS NULL OR share_addr_strategy <> ` + strategyExpr).Error; err != nil { + return err + } + hasShareAddr := tx.Migrator().HasColumn(&model.Inbound{}, "share_addr") + if hasShareAddr { + if err := tx.Exec(`UPDATE inbounds SET share_addr = TRIM(share_addr) WHERE share_addr IS NOT NULL AND share_addr <> TRIM(share_addr)`).Error; err != nil { + return err + } + } + if !hasShareAddr { + return nil + } + var rows []struct { + Id int + ShareAddrStrategy string + ShareAddr string + } + if err := tx.Model(&model.Inbound{}).Select("id", "share_addr_strategy", "share_addr").Find(&rows).Error; err != nil { + return err + } + for _, row := range rows { + strategy := normalizeInboundShareAddrStrategy(row.ShareAddrStrategy) + addr, addrErr := normalizeInboundShareHost(row.ShareAddr) + if addrErr != nil { + strategy = "node" + addr = "" + } + updates := map[string]any{} + if strategy != row.ShareAddrStrategy { + updates["share_addr_strategy"] = strategy + } + if addr != row.ShareAddr { + updates["share_addr"] = addr + } + if len(updates) > 0 { + if err := tx.Model(&model.Inbound{}).Where("id = ?", row.Id).Updates(updates).Error; err != nil { + return err + } + } + } + return nil +} + // GetInbounds retrieves all inbounds for a specific user with client stats. func (s *InboundService) GetInbounds(userId int) ([]*model.Inbound, error) { db := database.GetDB() @@ -332,6 +453,9 @@ func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, boo // Normalize streamSettings based on protocol s.normalizeStreamSettings(inbound) s.normalizeMtprotoSecret(inbound) + if err := normalizeInboundShareAddressStrict(inbound); err != nil { + return inbound, false, err + } conflict, err := s.checkPortConflict(inbound, 0) if err != nil { @@ -760,6 +884,17 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound, oldInbound.Settings = inbound.Settings oldInbound.StreamSettings = inbound.StreamSettings oldInbound.Sniffing = inbound.Sniffing + if strings.TrimSpace(inbound.ShareAddrStrategy) == "" { + normalizeInboundShareAddress(oldInbound) + inbound.ShareAddrStrategy = oldInbound.ShareAddrStrategy + inbound.ShareAddr = oldInbound.ShareAddr + } else { + if err := normalizeInboundShareAddressStrict(inbound); err != nil { + return inbound, false, err + } + oldInbound.ShareAddrStrategy = inbound.ShareAddrStrategy + oldInbound.ShareAddr = inbound.ShareAddr + } if oldTagWasAuto && inbound.Tag == tag { inbound.Tag = "" } diff --git a/internal/web/service/inbound_migration.go b/internal/web/service/inbound_migration.go index 17860bd8b..e2258c2bc 100644 --- a/internal/web/service/inbound_migration.go +++ b/internal/web/service/inbound_migration.go @@ -52,6 +52,9 @@ func (s *InboundService) MigrationRequirements() { return } } + if err = normalizeInboundShareAddressColumns(tx); err != nil { + return + } // Normalize "enable" columns to boolean on Postgres. Legacy SQLite data // (0/1 integers), partial migrations, or mixed write paths (public API diff --git a/internal/web/service/inbound_migration_test.go b/internal/web/service/inbound_migration_test.go index 197acaa20..a2a4c8f64 100644 --- a/internal/web/service/inbound_migration_test.go +++ b/internal/web/service/inbound_migration_test.go @@ -89,3 +89,90 @@ func TestMigrationRequirements_BackfillsClientTrafficsWithMultiDomainInbound(t * t.Errorf("MultiDomain migration did not commit; streamSettings = %q", refreshed.StreamSettings) } } + +func TestMigrationRequirements_NormalizesShareAddressFields(t *testing.T) { + setupConflictDB(t) + db := database.GetDB() + + invalidStrategy := &model.Inbound{ + UserId: 1, + Tag: "invalid-share-strategy", + Enable: true, + Port: 31001, + Protocol: model.VLESS, + Settings: `{"clients":[]}`, + StreamSettings: `{"network":"tcp","security":"none"}`, + } + paddedStrategy := &model.Inbound{ + UserId: 1, + Tag: "padded-share-strategy", + Enable: true, + Port: 31002, + Protocol: model.VLESS, + Settings: `{"clients":[]}`, + StreamSettings: `{"network":"tcp","security":"none"}`, + } + invalidAddress := &model.Inbound{ + UserId: 1, + Tag: "invalid-share-address", + Enable: true, + Port: 31003, + Protocol: model.VLESS, + Settings: `{"clients":[]}`, + StreamSettings: `{"network":"tcp","security":"none"}`, + } + if err := db.Create(invalidStrategy).Error; err != nil { + t.Fatalf("create invalid strategy inbound: %v", err) + } + if err := db.Create(paddedStrategy).Error; err != nil { + t.Fatalf("create padded strategy inbound: %v", err) + } + if err := db.Create(invalidAddress).Error; err != nil { + t.Fatalf("create invalid address inbound: %v", err) + } + if err := db.Model(&model.Inbound{}).Where("id = ?", invalidStrategy.Id).Updates(map[string]any{ + "share_addr_strategy": " auto ", + "share_addr": " edge.example.com ", + }).Error; err != nil { + t.Fatalf("seed invalid share fields: %v", err) + } + if err := db.Model(&model.Inbound{}).Where("id = ?", paddedStrategy.Id).Updates(map[string]any{ + "share_addr_strategy": " listen ", + "share_addr": " 10.0.0.1 ", + }).Error; err != nil { + t.Fatalf("seed padded share fields: %v", err) + } + if err := db.Model(&model.Inbound{}).Where("id = ?", invalidAddress.Id).Updates(map[string]any{ + "share_addr_strategy": "custom", + "share_addr": "edge.example.com:8443", + }).Error; err != nil { + t.Fatalf("seed invalid address share fields: %v", err) + } + + svc := InboundService{} + svc.MigrationRequirements() + + var gotInvalid model.Inbound + if err := db.First(&gotInvalid, invalidStrategy.Id).Error; err != nil { + t.Fatalf("reload invalid strategy inbound: %v", err) + } + if gotInvalid.ShareAddrStrategy != "node" || gotInvalid.ShareAddr != "edge.example.com" { + t.Fatalf("invalid share fields = (%q, %q), want (node, edge.example.com)", gotInvalid.ShareAddrStrategy, gotInvalid.ShareAddr) + } + + var gotPadded model.Inbound + if err := db.First(&gotPadded, paddedStrategy.Id).Error; err != nil { + t.Fatalf("reload padded strategy inbound: %v", err) + } + if gotPadded.ShareAddrStrategy != "listen" || gotPadded.ShareAddr != "10.0.0.1" { + t.Fatalf("padded share fields = (%q, %q), want (listen, 10.0.0.1)", gotPadded.ShareAddrStrategy, gotPadded.ShareAddr) + } + + var gotInvalidAddress model.Inbound + if err := db.First(&gotInvalidAddress, invalidAddress.Id).Error; err != nil { + t.Fatalf("reload invalid address inbound: %v", err) + } + if gotInvalidAddress.ShareAddrStrategy != "node" || gotInvalidAddress.ShareAddr != "" { + t.Fatalf("invalid address share fields = (%q, %q), want (node, empty)", gotInvalidAddress.ShareAddrStrategy, gotInvalidAddress.ShareAddr) + } +} diff --git a/internal/web/service/inbound_node.go b/internal/web/service/inbound_node.go index 97ace9bdc..d5a28f240 100644 --- a/internal/web/service/inbound_node.go +++ b/internal/web/service/inbound_node.go @@ -329,6 +329,7 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi ExpiryTime: snapIb.ExpiryTime, Up: snapIb.Up, Down: snapIb.Down, + ShareAddrStrategy: "node", } if err := tx.Create(&newIb).Error; err != nil { logger.Warningf("setRemoteTraffic: create central inbound for tag %q failed: %v", snapIb.Tag, err) diff --git a/internal/web/service/inbound_update_tag_test.go b/internal/web/service/inbound_update_tag_test.go index 7def711da..28f8c787b 100644 --- a/internal/web/service/inbound_update_tag_test.go +++ b/internal/web/service/inbound_update_tag_test.go @@ -98,3 +98,85 @@ func TestUpdateInbound_KeepsCustomTagOnPortChange(t *testing.T) { t.Fatalf("returned tag = %q, want my-custom-tag", got.Tag) } } + +func TestUpdateInbound_PreservesShareAddressFieldsWhenOmitted(t *testing.T) { + setupConflictDB(t) + + existing := model.Inbound{ + Tag: "in-443-tcp", + Enable: true, + Listen: "0.0.0.0", + Port: 443, + Protocol: model.VLESS, + StreamSettings: `{"network":"tcp"}`, + Settings: `{"clients":[]}`, + ShareAddrStrategy: "custom", + ShareAddr: " edge.example.com ", + } + if err := database.GetDB().Create(&existing).Error; err != nil { + t.Fatalf("seed inbound: %v", err) + } + + update := existing + update.Remark = "updated" + update.ShareAddrStrategy = "" + update.ShareAddr = "" + + svc := &InboundService{} + got, _, err := svc.UpdateInbound(&update) + if err != nil { + t.Fatalf("UpdateInbound: %v", err) + } + + var reloaded model.Inbound + if err := database.GetDB().First(&reloaded, existing.Id).Error; err != nil { + t.Fatalf("reload: %v", err) + } + if reloaded.ShareAddrStrategy != "custom" || reloaded.ShareAddr != "edge.example.com" { + t.Fatalf("persisted share fields = (%q, %q), want (custom, edge.example.com)", reloaded.ShareAddrStrategy, reloaded.ShareAddr) + } + if got.ShareAddrStrategy != "custom" || got.ShareAddr != "edge.example.com" { + t.Fatalf("returned share fields = (%q, %q), want (custom, edge.example.com)", got.ShareAddrStrategy, got.ShareAddr) + } +} + +func TestNormalizeInboundShareAddressStrict_RequiresHostOnly(t *testing.T) { + tests := []struct { + name string + addr string + want string + wantErr bool + }{ + {name: "hostname", addr: " edge.example.com ", want: "edge.example.com"}, + {name: "ipv4", addr: "203.0.113.10", want: "203.0.113.10"}, + {name: "bare ipv6", addr: "2001:db8::1", want: "[2001:db8::1]"}, + {name: "bracketed ipv6", addr: "[2001:db8::2]", want: "[2001:db8::2]"}, + {name: "scheme rejected", addr: "https://edge.example.com", wantErr: true}, + {name: "port rejected", addr: "edge.example.com:8443", wantErr: true}, + {name: "bracketed ipv6 port rejected", addr: "[2001:db8::1]:8443", wantErr: true}, + {name: "path rejected", addr: "edge.example.com/path", wantErr: true}, + {name: "space rejected", addr: "bad host", wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + inbound := &model.Inbound{ + ShareAddrStrategy: "custom", + ShareAddr: tt.addr, + } + err := normalizeInboundShareAddressStrict(inbound) + if tt.wantErr { + if err == nil { + t.Fatalf("normalizeInboundShareAddressStrict(%q) expected error", tt.addr) + } + return + } + if err != nil { + t.Fatalf("normalizeInboundShareAddressStrict(%q): %v", tt.addr, err) + } + if inbound.ShareAddr != tt.want { + t.Fatalf("ShareAddr = %q, want %q", inbound.ShareAddr, tt.want) + } + }) + } +} diff --git a/internal/web/service/node_origin_guid_test.go b/internal/web/service/node_origin_guid_test.go index 320a960a7..fa155aa75 100644 --- a/internal/web/service/node_origin_guid_test.go +++ b/internal/web/service/node_origin_guid_test.go @@ -69,3 +69,103 @@ func TestSetRemoteTraffic_AttributesOriginNodeGuid(t *testing.T) { t.Fatalf("forwarded inbound origin = %q, want node3-guid (kept across the hop)", og) } } + +func TestSetRemoteTraffic_PreservesLocalShareAddressStrategy(t *testing.T) { + setupConflictDB(t) + db := database.GetDB() + + const nodeID = 1 + if err := db.Create(&model.Node{ + Id: nodeID, + Name: "node2", + Address: "10.0.0.2", + Port: 2053, + ApiToken: "t", + Guid: "node2-guid", + }).Error; err != nil { + t.Fatalf("create node: %v", err) + } + + nodeIDPtr := nodeID + if err := db.Create(&model.Inbound{ + UserId: 1, + NodeID: &nodeIDPtr, + Tag: "remote-in", + Enable: true, + Port: 443, + Protocol: model.VLESS, + Settings: `{"clients":[]}`, + ShareAddrStrategy: "custom", + ShareAddr: "edge.example.com", + }).Error; err != nil { + t.Fatalf("create central inbound: %v", err) + } + + snap := &runtime.TrafficSnapshot{ + Inbounds: []*model.Inbound{{ + Tag: "remote-in", + Enable: true, + Port: 8443, + Protocol: model.VLESS, + Settings: `{"clients":[]}`, + }}, + } + + svc := InboundService{} + if _, err := svc.setRemoteTrafficLocked(nodeID, snap, false); err != nil { + t.Fatalf("setRemoteTrafficLocked: %v", err) + } + + var ib model.Inbound + if err := db.Where("tag = ?", "remote-in").First(&ib).Error; err != nil { + t.Fatalf("load inbound: %v", err) + } + if ib.ShareAddrStrategy != "custom" || ib.ShareAddr != "edge.example.com" { + t.Fatalf("share address fields were overwritten: strategy=%q addr=%q", ib.ShareAddrStrategy, ib.ShareAddr) + } + if ib.Port != 8443 { + t.Fatalf("sync should still update regular remote fields; port = %d, want 8443", ib.Port) + } +} + +func TestSetRemoteTraffic_DefaultsShareAddressFieldsForNewCentralInbound(t *testing.T) { + setupConflictDB(t) + db := database.GetDB() + + const nodeID = 1 + if err := db.Create(&model.Node{ + Id: nodeID, + Name: "node2", + Address: "10.0.0.2", + Port: 2053, + ApiToken: "t", + Guid: "node2-guid", + }).Error; err != nil { + t.Fatalf("create node: %v", err) + } + + snap := &runtime.TrafficSnapshot{ + Inbounds: []*model.Inbound{{ + Tag: "remote-in", + Enable: true, + Port: 8443, + Protocol: model.VLESS, + Settings: `{"clients":[]}`, + ShareAddrStrategy: "custom", + ShareAddr: "remote.example.com", + }}, + } + + svc := InboundService{} + if _, err := svc.setRemoteTrafficLocked(nodeID, snap, false); err != nil { + t.Fatalf("setRemoteTrafficLocked: %v", err) + } + + var ib model.Inbound + if err := db.Where("tag = ?", "remote-in").First(&ib).Error; err != nil { + t.Fatalf("load inbound: %v", err) + } + if ib.ShareAddrStrategy != "node" || ib.ShareAddr != "" { + t.Fatalf("new central inbound share fields = (%q, %q), want (node, empty)", ib.ShareAddrStrategy, ib.ShareAddr) + } +} diff --git a/internal/web/translation/ar-EG.json b/internal/web/translation/ar-EG.json index 7a74a6de9..09e6172f8 100644 --- a/internal/web/translation/ar-EG.json +++ b/internal/web/translation/ar-EG.json @@ -589,7 +589,16 @@ "mldsa65Seed": "mldsa65 Seed", "mldsa65Verify": "mldsa65 Verify", "getNewSeed": "احصل على Seed جديد", - "listenHelp": "يمكنك أيضًا إدخال مسار Unix socket (مثل /run/xray/in.sock) للاستماع على socket بدلاً من منفذ TCP — في هذه الحالة اضبط المنفذ على 0." + "listenHelp": "يمكنك أيضًا إدخال مسار Unix socket (مثل /run/xray/in.sock) للاستماع على socket بدلاً من منفذ TCP — في هذه الحالة اضبط المنفذ على 0.", + "shareAddrStrategy": "استراتيجية عنوان المشاركة", + "shareAddrStrategyHelp": "تحدد العنوان الذي يُكتب في روابط المشاركة المصدّرة ورموز QR. لا تتأثر روابط الاشتراك.", + "shareAddr": "عنوان مشاركة مخصص", + "shareAddrHelp": "يُستخدم فقط عندما تكون استراتيجية عنوان المشاركة مخصصة. أدخل اسم مضيف أو عنوان IP بدون بروتوكول أو منفذ.", + "shareAddrStrategyOptions": { + "node": "عنوان العقدة", + "listen": "عنوان استماع الوارد", + "custom": "مخصص" + } }, "info": { "mode": "الوضع", diff --git a/internal/web/translation/en-US.json b/internal/web/translation/en-US.json index d1263fcc5..af88c8a72 100644 --- a/internal/web/translation/en-US.json +++ b/internal/web/translation/en-US.json @@ -590,7 +590,16 @@ "mldsa65Seed": "mldsa65 Seed", "mldsa65Verify": "mldsa65 Verify", "getNewSeed": "Get New Seed", - "listenHelp": "You can also enter a Unix socket path (e.g. /run/xray/in.sock) to listen on a socket instead of a TCP port — set Port to 0 in that case." + "listenHelp": "You can also enter a Unix socket path (e.g. /run/xray/in.sock) to listen on a socket instead of a TCP port — set Port to 0 in that case.", + "shareAddrStrategy": "Share address strategy", + "shareAddrStrategyHelp": "Controls which address is written into exported share links and QR codes. Subscription links are not affected.", + "shareAddr": "Custom share address", + "shareAddrHelp": "Used only when the share address strategy is Custom. Enter a host or IP without a scheme or port.", + "shareAddrStrategyOptions": { + "node": "Node address", + "listen": "Inbound listen", + "custom": "Custom" + } }, "info": { "mode": "Mode", diff --git a/internal/web/translation/es-ES.json b/internal/web/translation/es-ES.json index 49c475b07..a44523717 100644 --- a/internal/web/translation/es-ES.json +++ b/internal/web/translation/es-ES.json @@ -589,7 +589,16 @@ "mldsa65Seed": "mldsa65 Seed", "mldsa65Verify": "mldsa65 Verify", "getNewSeed": "Obtener nuevo Seed", - "listenHelp": "También puedes introducir una ruta de socket Unix (p. ej. /run/xray/in.sock) para escuchar en un socket en lugar de un puerto TCP; en ese caso, establece el Puerto en 0." + "listenHelp": "También puedes introducir una ruta de socket Unix (p. ej. /run/xray/in.sock) para escuchar en un socket en lugar de un puerto TCP; en ese caso, establece el Puerto en 0.", + "shareAddrStrategy": "Estrategia de dirección para compartir", + "shareAddrStrategyHelp": "Controla qué dirección se escribe en los enlaces compartidos exportados y códigos QR. Los enlaces de suscripción no se ven afectados.", + "shareAddr": "Dirección compartida personalizada", + "shareAddrHelp": "Solo se usa cuando la estrategia de dirección para compartir es Personalizada. Introduce un host o IP sin esquema ni puerto.", + "shareAddrStrategyOptions": { + "node": "Dirección del nodo", + "listen": "Dirección de escucha del inbound", + "custom": "Personalizada" + } }, "info": { "mode": "Modo", diff --git a/internal/web/translation/fa-IR.json b/internal/web/translation/fa-IR.json index 2ee7c4b17..00916c698 100644 --- a/internal/web/translation/fa-IR.json +++ b/internal/web/translation/fa-IR.json @@ -589,7 +589,16 @@ "mldsa65Seed": "mldsa65 Seed", "mldsa65Verify": "mldsa65 Verify", "getNewSeed": "دریافت Seed جدید", - "listenHelp": "می‌توانید به‌جای پورت TCP یک مسیر سوکت یونیکس وارد کنید (مثلاً /run/xray/in.sock) تا روی سوکت گوش داده شود — در این حالت پورت را روی ۰ بگذارید." + "listenHelp": "می‌توانید به‌جای پورت TCP یک مسیر سوکت یونیکس وارد کنید (مثلاً /run/xray/in.sock) تا روی سوکت گوش داده شود — در این حالت پورت را روی ۰ بگذارید.", + "shareAddrStrategy": "راهبرد آدرس اشتراک‌گذاری", + "shareAddrStrategyHelp": "مشخص می‌کند کدام آدرس در لینک‌های اشتراک‌گذاری خروجی و کدهای QR نوشته شود. لینک‌های اشتراک تحت تأثیر قرار نمی‌گیرند.", + "shareAddr": "آدرس اشتراک‌گذاری سفارشی", + "shareAddrHelp": "فقط زمانی استفاده می‌شود که راهبرد آدرس اشتراک‌گذاری روی سفارشی باشد. میزبان یا IP را بدون طرح و پورت وارد کنید.", + "shareAddrStrategyOptions": { + "node": "آدرس نود", + "listen": "آدرس شنود ورودی", + "custom": "سفارشی" + } }, "info": { "mode": "حالت", diff --git a/internal/web/translation/id-ID.json b/internal/web/translation/id-ID.json index 5c9ad9557..bdc7c5ecb 100644 --- a/internal/web/translation/id-ID.json +++ b/internal/web/translation/id-ID.json @@ -589,7 +589,16 @@ "mldsa65Seed": "mldsa65 Seed", "mldsa65Verify": "mldsa65 Verify", "getNewSeed": "Dapatkan Seed baru", - "listenHelp": "Anda juga dapat memasukkan path Unix socket (mis. /run/xray/in.sock) untuk listen pada socket alih-alih port TCP — dalam hal ini setel Port ke 0." + "listenHelp": "Anda juga dapat memasukkan path Unix socket (mis. /run/xray/in.sock) untuk listen pada socket alih-alih port TCP — dalam hal ini setel Port ke 0.", + "shareAddrStrategy": "Strategi alamat berbagi", + "shareAddrStrategyHelp": "Menentukan alamat yang ditulis ke tautan berbagi yang diekspor dan kode QR. Tautan langganan tidak terpengaruh.", + "shareAddr": "Alamat berbagi kustom", + "shareAddrHelp": "Hanya digunakan saat strategi alamat berbagi adalah Kustom. Masukkan host atau IP tanpa skema atau port.", + "shareAddrStrategyOptions": { + "node": "Alamat node", + "listen": "Alamat listen inbound", + "custom": "Kustom" + } }, "info": { "mode": "Mode", diff --git a/internal/web/translation/ja-JP.json b/internal/web/translation/ja-JP.json index 33b1943c4..32a283e02 100644 --- a/internal/web/translation/ja-JP.json +++ b/internal/web/translation/ja-JP.json @@ -589,7 +589,16 @@ "mldsa65Seed": "mldsa65 Seed", "mldsa65Verify": "mldsa65 Verify", "getNewSeed": "新しい Seed を取得", - "listenHelp": "TCP ポートの代わりに Unix ソケットのパス(例: /run/xray/in.sock)を入力してソケットでリッスンすることもできます。その場合はポートを 0 に設定してください。" + "listenHelp": "TCP ポートの代わりに Unix ソケットのパス(例: /run/xray/in.sock)を入力してソケットでリッスンすることもできます。その場合はポートを 0 に設定してください。", + "shareAddrStrategy": "共有アドレス戦略", + "shareAddrStrategyHelp": "エクスポートされる共有リンクとQRコードに書き込むアドレスを制御します。サブスクリプションリンクには影響しません。", + "shareAddr": "カスタム共有アドレス", + "shareAddrHelp": "共有アドレス戦略がカスタムの場合のみ使用されます。スキームやポートを含めずにホスト名またはIPを入力してください。", + "shareAddrStrategyOptions": { + "node": "ノードアドレス", + "listen": "インバウンドのリッスンアドレス", + "custom": "カスタム" + } }, "info": { "mode": "モード", diff --git a/internal/web/translation/pt-BR.json b/internal/web/translation/pt-BR.json index 7233f1df8..85fb1ec48 100644 --- a/internal/web/translation/pt-BR.json +++ b/internal/web/translation/pt-BR.json @@ -589,7 +589,16 @@ "mldsa65Seed": "mldsa65 Seed", "mldsa65Verify": "mldsa65 Verify", "getNewSeed": "Obter novo Seed", - "listenHelp": "Você também pode informar um caminho de socket Unix (ex.: /run/xray/in.sock) para escutar em um socket em vez de uma porta TCP — nesse caso, defina a Porta como 0." + "listenHelp": "Você também pode informar um caminho de socket Unix (ex.: /run/xray/in.sock) para escutar em um socket em vez de uma porta TCP — nesse caso, defina a Porta como 0.", + "shareAddrStrategy": "Estratégia de endereço de compartilhamento", + "shareAddrStrategyHelp": "Controla qual endereço é gravado nos links de compartilhamento exportados e nos códigos QR. Links de assinatura não são afetados.", + "shareAddr": "Endereço de compartilhamento personalizado", + "shareAddrHelp": "Usado apenas quando a estratégia de endereço de compartilhamento é Personalizada. Informe um host ou IP sem esquema nem porta.", + "shareAddrStrategyOptions": { + "node": "Endereço do nó", + "listen": "Endereço de escuta do inbound", + "custom": "Personalizada" + } }, "info": { "mode": "Modo", diff --git a/internal/web/translation/ru-RU.json b/internal/web/translation/ru-RU.json index 5bfc0e5d4..0bbc0355b 100644 --- a/internal/web/translation/ru-RU.json +++ b/internal/web/translation/ru-RU.json @@ -589,7 +589,16 @@ "mldsa65Seed": "mldsa65 Seed", "mldsa65Verify": "mldsa65 Verify", "getNewSeed": "Получить новый Seed", - "listenHelp": "Можно также указать путь Unix-сокета (например, /run/xray/in.sock), чтобы слушать сокет вместо TCP-порта — в этом случае задайте порт 0." + "listenHelp": "Можно также указать путь Unix-сокета (например, /run/xray/in.sock), чтобы слушать сокет вместо TCP-порта — в этом случае задайте порт 0.", + "shareAddrStrategy": "Стратегия адреса для ссылок", + "shareAddrStrategyHelp": "Определяет, какой адрес записывать в экспортируемые ссылки и QR-коды. Ссылки подписки не затрагиваются.", + "shareAddr": "Пользовательский адрес для ссылок", + "shareAddrHelp": "Используется только когда стратегия адреса для ссылок — пользовательская. Укажите хост или IP без схемы и порта.", + "shareAddrStrategyOptions": { + "node": "Адрес узла", + "listen": "Адрес прослушивания inbound", + "custom": "Пользовательская" + } }, "info": { "mode": "Режим", diff --git a/internal/web/translation/tr-TR.json b/internal/web/translation/tr-TR.json index 29fc7d6f1..2b9ebb4d6 100644 --- a/internal/web/translation/tr-TR.json +++ b/internal/web/translation/tr-TR.json @@ -590,7 +590,16 @@ "mldsa65Seed": "mldsa65 Seed", "mldsa65Verify": "mldsa65 Verify", "getNewSeed": "Yeni Seed Al", - "listenHelp": "TCP portu yerine bir Unix soket yolu da girebilirsiniz (örn. /run/xray/in.sock) — bu durumda Port'u 0 olarak ayarlayın." + "listenHelp": "TCP portu yerine bir Unix soket yolu da girebilirsiniz (örn. /run/xray/in.sock) — bu durumda Port'u 0 olarak ayarlayın.", + "shareAddrStrategy": "Paylaşım adresi stratejisi", + "shareAddrStrategyHelp": "Dışa aktarılan paylaşım bağlantılarına ve QR kodlarına hangi adresin yazılacağını belirler. Abonelik bağlantıları etkilenmez.", + "shareAddr": "Özel paylaşım adresi", + "shareAddrHelp": "Yalnızca paylaşım adresi stratejisi Özel olduğunda kullanılır. Şema veya port olmadan bir ana makine ya da IP girin.", + "shareAddrStrategyOptions": { + "node": "Düğüm adresi", + "listen": "Inbound dinleme adresi", + "custom": "Özel" + } }, "info": { "mode": "Mod", diff --git a/internal/web/translation/uk-UA.json b/internal/web/translation/uk-UA.json index 7eb7bf6c6..2a643bf93 100644 --- a/internal/web/translation/uk-UA.json +++ b/internal/web/translation/uk-UA.json @@ -589,7 +589,16 @@ "mldsa65Seed": "mldsa65 Seed", "mldsa65Verify": "mldsa65 Verify", "getNewSeed": "Отримати новий Seed", - "listenHelp": "Можна також указати шлях Unix-сокета (наприклад, /run/xray/in.sock), щоб слухати сокет замість TCP-порту — у цьому разі встановіть порт 0." + "listenHelp": "Можна також указати шлях Unix-сокета (наприклад, /run/xray/in.sock), щоб слухати сокет замість TCP-порту — у цьому разі встановіть порт 0.", + "shareAddrStrategy": "Стратегія адреси поширення", + "shareAddrStrategyHelp": "Визначає, яку адресу записувати в експортовані посилання поширення та QR-коди. Посилання підписки не змінюються.", + "shareAddr": "Користувацька адреса поширення", + "shareAddrHelp": "Використовується лише коли стратегія адреси поширення — користувацька. Введіть хост або IP без схеми та порту.", + "shareAddrStrategyOptions": { + "node": "Адреса вузла", + "listen": "Адреса прослуховування inbound", + "custom": "Користувацька" + } }, "info": { "mode": "Режим", diff --git a/internal/web/translation/vi-VN.json b/internal/web/translation/vi-VN.json index dabbf81f0..cbc727d84 100644 --- a/internal/web/translation/vi-VN.json +++ b/internal/web/translation/vi-VN.json @@ -589,7 +589,16 @@ "mldsa65Seed": "mldsa65 Seed", "mldsa65Verify": "mldsa65 Verify", "getNewSeed": "Lấy Seed mới", - "listenHelp": "Bạn cũng có thể nhập đường dẫn Unix socket (ví dụ /run/xray/in.sock) để lắng nghe trên socket thay vì cổng TCP — khi đó hãy đặt Port là 0." + "listenHelp": "Bạn cũng có thể nhập đường dẫn Unix socket (ví dụ /run/xray/in.sock) để lắng nghe trên socket thay vì cổng TCP — khi đó hãy đặt Port là 0.", + "shareAddrStrategy": "Chiến lược địa chỉ chia sẻ", + "shareAddrStrategyHelp": "Kiểm soát địa chỉ được ghi vào liên kết chia sẻ đã xuất và mã QR. Liên kết đăng ký không bị ảnh hưởng.", + "shareAddr": "Địa chỉ chia sẻ tùy chỉnh", + "shareAddrHelp": "Chỉ dùng khi chiến lược địa chỉ chia sẻ là Tùy chỉnh. Nhập host hoặc IP không kèm giao thức hoặc cổng.", + "shareAddrStrategyOptions": { + "node": "Địa chỉ node", + "listen": "Địa chỉ listen inbound", + "custom": "Tùy chỉnh" + } }, "info": { "mode": "Chế độ", diff --git a/internal/web/translation/zh-CN.json b/internal/web/translation/zh-CN.json index 365fbad78..b8eb612c1 100644 --- a/internal/web/translation/zh-CN.json +++ b/internal/web/translation/zh-CN.json @@ -589,7 +589,16 @@ "mldsa65Seed": "mldsa65 Seed", "mldsa65Verify": "mldsa65 Verify", "getNewSeed": "获取新 Seed", - "listenHelp": "也可以填写 Unix socket 路径(例如 /run/xray/in.sock),以使用套接字而非 TCP 端口监听——此时请将端口设为 0。" + "listenHelp": "也可以填写 Unix socket 路径(例如 /run/xray/in.sock),以使用套接字而非 TCP 端口监听——此时请将端口设为 0。", + "shareAddrStrategy": "分享地址策略", + "shareAddrStrategyHelp": "控制导出分享链接和二维码时写入哪个地址,不影响订阅链接。", + "shareAddr": "自定义分享地址", + "shareAddrHelp": "仅在分享地址策略为自定义时使用。填写不带协议和端口的域名或 IP。", + "shareAddrStrategyOptions": { + "node": "节点地址", + "listen": "入站监听地址", + "custom": "自定义" + } }, "info": { "mode": "模式", diff --git a/internal/web/translation/zh-TW.json b/internal/web/translation/zh-TW.json index 24f6b5275..09507b56c 100644 --- a/internal/web/translation/zh-TW.json +++ b/internal/web/translation/zh-TW.json @@ -589,7 +589,16 @@ "mldsa65Seed": "mldsa65 Seed", "mldsa65Verify": "mldsa65 Verify", "getNewSeed": "取得新 Seed", - "listenHelp": "也可以填寫 Unix socket 路徑(例如 /run/xray/in.sock),以使用通訊端而非 TCP 連接埠監聽——此時請將連接埠設為 0。" + "listenHelp": "也可以填寫 Unix socket 路徑(例如 /run/xray/in.sock),以使用通訊端而非 TCP 連接埠監聽——此時請將連接埠設為 0。", + "shareAddrStrategy": "分享地址策略", + "shareAddrStrategyHelp": "控制匯出分享連結和 QR Code 時寫入哪個地址,不影響訂閱連結。", + "shareAddr": "自訂分享地址", + "shareAddrHelp": "僅在分享地址策略為自訂時使用。填寫不帶協定和連接埠的網域或 IP。", + "shareAddrStrategyOptions": { + "node": "節點地址", + "listen": "入站監聽地址", + "custom": "自訂" + } }, "info": { "mode": "模式",