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 <ho3ein.sanaei@gmail.com>
This commit is contained in:
iYuan
2026-06-12 02:24:15 +08:00
committed by GitHub
parent ec45d3491a
commit 2a7342baa9
40 changed files with 874 additions and 40 deletions
+2
View File
@@ -288,6 +288,8 @@ export const EXAMPLES: Record<string, unknown> = {
"protocol": "vless",
"remark": "VLESS-443",
"settings": null,
"shareAddr": "",
"shareAddrStrategy": "node",
"sniffing": null,
"streamSettings": null,
"tag": "in-443-tcp",
+13
View File
@@ -1306,6 +1306,17 @@ export const SCHEMAS: Record<string, unknown> = {
"type": "string"
},
"settings": {},
"shareAddr": {
"type": "string"
},
"shareAddrStrategy": {
"enum": [
"node",
"listen",
"custom"
],
"type": "string"
},
"sniffing": {},
"streamSettings": {},
"tag": {
@@ -1344,6 +1355,8 @@ export const SCHEMAS: Record<string, unknown> = {
"protocol",
"remark",
"settings",
"shareAddr",
"shareAddrStrategy",
"sniffing",
"streamSettings",
"tag",
+2
View File
@@ -289,6 +289,8 @@ export interface Inbound {
protocol: Protocol;
remark: string;
settings: unknown;
shareAddr: string;
shareAddrStrategy: string;
sniffing: unknown;
streamSettings: unknown;
tag: string;
+2
View File
@@ -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(),
+16 -1
View File
@@ -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<string, unknown> {
@@ -82,6 +86,7 @@ function coerceJsonObject(value: unknown): Record<string, unknown> {
}
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;
+4
View File
@@ -18,6 +18,8 @@ export interface DbInboundLike {
up?: number;
down?: number;
total?: number;
shareAddrStrategy?: string;
shareAddr?: string;
}
function fillProtocolSettingsDefaults(protocol: string, settings: Record<string, unknown>): Record<string, unknown> {
@@ -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,
+73 -16
View File
@@ -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<string, string> 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;
}
+6
View File
@@ -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) {
@@ -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();
@@ -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<string>([
Protocols.VLESS,
Protocols.VMESS,
@@ -93,6 +95,30 @@ const NODE_ELIGIBLE_PROTOCOLS = new Set<string>([
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({
<Input placeholder={t('pages.inbounds.monitorDesc')} />
</Form.Item>
<Form.Item
name="shareAddrStrategy"
label={t('pages.inbounds.form.shareAddrStrategy')}
extra={t('pages.inbounds.form.shareAddrStrategyHelp')}
>
<Select
options={SHARE_ADDR_STRATEGIES.map((strategy) => ({
value: strategy,
label: t(`pages.inbounds.form.shareAddrStrategyOptions.${strategy}`),
}))}
/>
</Form.Item>
{shareAddrStrategy === 'custom' && (
<Form.Item
name="shareAddr"
label={t('pages.inbounds.form.shareAddr')}
extra={t('pages.inbounds.form.shareAddrHelp')}
rules={[{
validator: (_, value) => (
isValidShareAddrInput(String(value ?? ''))
? Promise.resolve()
: Promise.reject(new Error(t('pages.inbounds.form.shareAddrHelp')))
),
}]}
>
<Input placeholder="edge.example.com" />
</Form.Item>
)}
<Form.Item
name="port"
label={t('pages.inbounds.port')}
+2
View File
@@ -34,6 +34,8 @@ export const InboundCoreSchema = z.object({
listen: z.string().default(''),
port: InboundPortSchema,
tag: z.string().default(''),
shareAddrStrategy: z.enum(['node', 'listen', 'custom']).default('node'),
shareAddr: z.string().default(''),
sniffing: SniffingSchema.default({
enabled: false,
destOverride: ['http', 'tls', 'quic', 'fakedns'],
@@ -25,6 +25,8 @@ export type InboundStreamFormValues = z.infer<typeof InboundStreamFormSchema>;
export const TrafficResetSchema = z.enum(['never', 'hourly', 'daily', 'weekly', 'monthly']);
export type TrafficReset = z.infer<typeof TrafficResetSchema>;
export const ShareAddrStrategySchema = z.enum(['node', 'listen', 'custom']);
export type ShareAddrStrategy = z.infer<typeof ShareAddrStrategySchema>;
// 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<typeof InboundDbFieldsSchema>;
@@ -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",
@@ -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",
@@ -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
+52
View File
@@ -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