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
@@ -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