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": "模式",