Просмотр исходного кода

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 <[email protected]>
iYuan 18 часов назад
Родитель
Сommit
2a7342baa9
40 измененных файлов с 872 добавлено и 38 удалено
  1. 15 0
      frontend/public/openapi.json
  2. 2 0
      frontend/src/generated/examples.ts
  3. 13 0
      frontend/src/generated/schemas.ts
  4. 2 0
      frontend/src/generated/types.ts
  5. 2 0
      frontend/src/generated/zod.ts
  6. 16 1
      frontend/src/lib/xray/inbound-form-adapter.ts
  7. 4 0
      frontend/src/lib/xray/inbound-from-db.ts
  8. 71 14
      frontend/src/lib/xray/inbound-link.ts
  9. 6 0
      frontend/src/models/dbinbound.ts
  10. 2 0
      frontend/src/pages/inbounds/InboundsPage.tsx
  11. 57 0
      frontend/src/pages/inbounds/form/InboundFormModal.tsx
  12. 2 0
      frontend/src/schemas/api/inbound.ts
  13. 4 0
      frontend/src/schemas/forms/inbound-form.ts
  14. 10 0
      frontend/src/test/__snapshots__/inbound-form-modal.test.tsx.snap
  15. 16 0
      frontend/src/test/__snapshots__/inbound-full.test.ts.snap
  16. 13 0
      frontend/src/test/inbound-form-adapter.test.ts
  17. 52 0
      frontend/src/test/inbound-link.test.ts
  18. 10 8
      internal/database/model/model.go
  19. 2 2
      internal/sub/service.go
  20. 8 0
      internal/web/runtime/remote.go
  21. 27 0
      internal/web/runtime/remote_test.go
  22. 135 0
      internal/web/service/inbound.go
  23. 3 0
      internal/web/service/inbound_migration.go
  24. 87 0
      internal/web/service/inbound_migration_test.go
  25. 1 0
      internal/web/service/inbound_node.go
  26. 82 0
      internal/web/service/inbound_update_tag_test.go
  27. 100 0
      internal/web/service/node_origin_guid_test.go
  28. 10 1
      internal/web/translation/ar-EG.json
  29. 10 1
      internal/web/translation/en-US.json
  30. 10 1
      internal/web/translation/es-ES.json
  31. 10 1
      internal/web/translation/fa-IR.json
  32. 10 1
      internal/web/translation/id-ID.json
  33. 10 1
      internal/web/translation/ja-JP.json
  34. 10 1
      internal/web/translation/pt-BR.json
  35. 10 1
      internal/web/translation/ru-RU.json
  36. 10 1
      internal/web/translation/tr-TR.json
  37. 10 1
      internal/web/translation/uk-UA.json
  38. 10 1
      internal/web/translation/vi-VN.json
  39. 10 1
      internal/web/translation/zh-CN.json
  40. 10 1
      internal/web/translation/zh-TW.json

+ 15 - 0
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",

+ 2 - 0
frontend/src/generated/examples.ts

@@ -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 - 0
frontend/src/generated/schemas.ts

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

+ 2 - 0
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(),

+ 16 - 1
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<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 - 0
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<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,

+ 71 - 14
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<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('@');
 }
 
+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/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)
+// 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 {
-  if (hostOverride.length > 0) return hostOverride;
-  if (inbound.listen.length > 0 && inbound.listen !== '0.0.0.0' && !isUnixSocketListen(inbound.listen)) {
-    return inbound.listen;
+  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 - 0
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) {

+ 2 - 0
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();

+ 57 - 0
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<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 - 0
frontend/src/schemas/api/inbound.ts

@@ -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'],

+ 4 - 0
frontend/src/schemas/forms/inbound-form.ts

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

+ 10 - 0
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",

+ 16 - 0
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",

+ 13 - 0
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

+ 52 - 0
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

+ 10 - 8
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

+ 2 - 2
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 != "" {

+ 8 - 0
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)
 	}

+ 27 - 0
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

+ 135 - 0
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 = ""
 	}

+ 3 - 0
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

+ 87 - 0
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)
+	}
+}

+ 1 - 0
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)

+ 82 - 0
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)
+			}
+		})
+	}
+}

+ 100 - 0
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)
+	}
+}

+ 10 - 1
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": "الوضع",

+ 10 - 1
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",

+ 10 - 1
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",

+ 10 - 1
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": "حالت",

+ 10 - 1
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",

+ 10 - 1
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": "モード",

+ 10 - 1
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",

+ 10 - 1
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": "Режим",

+ 10 - 1
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",

+ 10 - 1
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": "Режим",

+ 10 - 1
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ế độ",

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

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