24 Commits 58905d81a4 ... 0e0e41197f

Autor SHA1 Mensaje Fecha
  MHSanaei 0e0e41197f fix(settings): normalize tgCpu on load so a bad value can't block saving (#5091) hace 9 horas
  MHSanaei 5c29851be1 fix(nodes): "Invalid input" when saving a node with inbound sync mode "all" hace 10 horas
  MHSanaei 60da6bed15 fix(xhttp): stop injecting scMaxEachPostBytes/scMinPostsIntervalMs defaults (#5141) hace 10 horas
  MHSanaei 7e87b7dc60 i18n: point API token hint at the Authentication page in all locales hace 11 horas
  MHSanaei dbee150b33 fix(script): SSL management fixes (#4994, #5010, #5070) hace 11 horas
  MHSanaei 1a525b4cb4 fix(client): apply per-field client edits to every inbound of the email (#5039) hace 11 horas
  MHSanaei b062cb5a14 fix(sub): tag node-hosted entries with the node name in remarks (#5035) hace 11 horas
  MHSanaei a27d57b2ff fix(ui): keep dropdown action menus inside the viewport (#5133) hace 11 horas
  MHSanaei 10a0c9131c fix(hysteria): clamp udpIdleTimeout to xray-core's accepted 2-600s range (#5117) hace 11 horas
  MHSanaei a5e5640804 fix(inbound): explain how to unlock fallbacks on the inbound form (#5014) hace 11 horas
  MHSanaei 0711d3077b chore: pin generated files to LF to avoid phantom CRLF diffs on Windows hace 12 horas
  MHSanaei 8578b229ce feat(settings): allow a balancer as the panel traffic outbound hace 13 horas
  MHSanaei c47a905ad2 fix(inbound): offer node share-address strategy only when a node exists hace 13 horas
  MHSanaei 825778144c fix(outbound): widen probe timeout and surface failure reason in outbound test (#5152) hace 13 horas
  MHSanaei 1b0dbf8e6d fix(sub): deduplicate settings.clients entries per inbound in subscription output (#5134) hace 14 horas
  MHSanaei 09a887f95c fix(warp): prefer IPv4 with v6 fallback and userspace TUN in generated WireGuard outbounds (#5205) hace 14 horas
  MHSanaei cc65f37164 fix(sub): honor per-inbound share address strategy in subscription output (#5208) hace 15 horas
  MHSanaei 21143a6d72 fix(node-sync): keep node baseline while a sibling inbound still reports the email (#5202) hace 15 horas
  MHSanaei 1508666e52 fix: DNS server edit modal showing defaults instead of saved values (#5155) hace 15 horas
  MHSanaei 2db48174b0 fix: apply only the x-ui sysctl config when toggling BBR (#5160) hace 15 horas
  animesha3 554d85c2f7 feat: allow selecting inbounds synchronized from nodes (#5178) hace 15 horas
  iYuan 2a7342baa9 feat: add inbound share address strategy (#5162) hace 16 horas
  w3struk ec45d3491a fix: derive JSON/Clash subscription URLs from configured subURI (#5203) hace 16 horas
  MHSanaei 7bcc5830c6 feat(online): use xray online-stats API for onlines and access-log-free IP limit hace 16 horas
Se han modificado 98 ficheros con 3332 adiciones y 546 borrados
  1. 3 2
      .gitattributes
  2. 1 0
      .gitignore
  3. 93 0
      frontend/public/openapi.json
  4. 9 0
      frontend/src/api/queries/useNodeMutations.ts
  5. 6 0
      frontend/src/generated/examples.ts
  6. 28 0
      frontend/src/generated/schemas.ts
  7. 5 0
      frontend/src/generated/types.ts
  8. 7 0
      frontend/src/generated/zod.ts
  9. 16 1
      frontend/src/lib/xray/inbound-form-adapter.ts
  10. 4 0
      frontend/src/lib/xray/inbound-from-db.ts
  11. 78 15
      frontend/src/lib/xray/inbound-link.ts
  12. 1 1
      frontend/src/lib/xray/outbound-link-parser.ts
  13. 2 0
      frontend/src/lib/xray/stream-wire-normalize.ts
  14. 6 0
      frontend/src/models/dbinbound.ts
  15. 2 0
      frontend/src/models/setting.ts
  16. 7 0
      frontend/src/pages/api-docs/endpoints.ts
  17. 3 7
      frontend/src/pages/clients/ClientBulkAddModal.tsx
  18. 227 210
      frontend/src/pages/clients/ClientFormModal.tsx
  19. 1 3
      frontend/src/pages/clients/ClientsPage.tsx
  20. 2 0
      frontend/src/pages/inbounds/InboundsPage.tsx
  21. 85 0
      frontend/src/pages/inbounds/form/InboundFormModal.tsx
  22. 1 1
      frontend/src/pages/inbounds/form/protocols/hysteria.tsx
  23. 4 4
      frontend/src/pages/index/IndexPage.tsx
  24. 5 3
      frontend/src/pages/index/XrayStatusCard.tsx
  25. 73 0
      frontend/src/pages/nodes/NodeFormModal.tsx
  26. 2 1
      frontend/src/pages/nodes/NodesPage.tsx
  27. 33 6
      frontend/src/pages/settings/GeneralTab.tsx
  28. 42 26
      frontend/src/pages/xray/dns/DnsTab.tsx
  29. 2 4
      frontend/src/pages/xray/dns/useDnsColumns.tsx
  30. 1 1
      frontend/src/pages/xray/outbounds/outbound-form-helpers.ts
  31. 1 1
      frontend/src/pages/xray/outbounds/transport/hysteria.tsx
  32. 4 1
      frontend/src/pages/xray/overrides/NordModal.tsx
  33. 10 2
      frontend/src/pages/xray/overrides/WarpModal.tsx
  34. 2 0
      frontend/src/schemas/api/inbound.ts
  35. 1 0
      frontend/src/schemas/defaults.ts
  36. 4 0
      frontend/src/schemas/forms/inbound-form.ts
  37. 7 0
      frontend/src/schemas/node.ts
  38. 2 1
      frontend/src/schemas/protocols/stream/hysteria.ts
  39. 5 2
      frontend/src/schemas/protocols/stream/xhttp.ts
  40. 7 0
      frontend/src/styles/page-shell.css
  41. 10 0
      frontend/src/test/__snapshots__/inbound-form-modal.test.tsx.snap
  42. 16 0
      frontend/src/test/__snapshots__/inbound-full.test.ts.snap
  43. 6 6
      frontend/src/test/__snapshots__/stream.test.ts.snap
  44. 13 0
      frontend/src/test/inbound-form-adapter.test.ts
  45. 52 0
      frontend/src/test/inbound-link.test.ts
  46. 14 2
      install.sh
  47. 24 20
      internal/database/model/model.go
  48. 48 0
      internal/sub/build_urls_test.go
  49. 7 15
      internal/sub/clash_service.go
  50. 14 13
      internal/sub/json_service.go
  51. 116 26
      internal/sub/service.go
  52. 65 0
      internal/sub/service_dedup_test.go
  53. 62 0
      internal/sub/service_test.go
  54. 3 1
      internal/util/link/outbound.go
  55. 13 0
      internal/web/controller/node.go
  56. 101 27
      internal/web/job/check_client_ip_job.go
  57. 2 2
      internal/web/job/check_client_ip_job_integration_test.go
  58. 65 5
      internal/web/job/check_client_ip_job_test.go
  59. 2 1
      internal/web/job/node_traffic_sync_job.go
  60. 30 7
      internal/web/job/xray_traffic_job.go
  61. 27 0
      internal/web/runtime/remote.go
  62. 27 0
      internal/web/runtime/remote_test.go
  63. 84 0
      internal/web/service/client_apply_field_test.go
  64. 60 36
      internal/web/service/client_inbound_apply.go
  65. 151 0
      internal/web/service/inbound.go
  66. 3 0
      internal/web/service/inbound_migration.go
  67. 87 0
      internal/web/service/inbound_migration_test.go
  68. 52 3
      internal/web/service/inbound_node.go
  69. 197 0
      internal/web/service/inbound_node_reconcile_test.go
  70. 22 0
      internal/web/service/inbound_traffic.go
  71. 82 0
      internal/web/service/inbound_update_tag_test.go
  72. 85 0
      internal/web/service/node.go
  73. 48 0
      internal/web/service/node_client_traffic_sum_test.go
  74. 100 0
      internal/web/service/node_origin_guid_test.go
  75. 52 0
      internal/web/service/node_test.go
  76. 5 15
      internal/web/service/outbound/outbound.go
  77. 31 19
      internal/web/service/setting.go
  78. 117 3
      internal/web/service/xray.go
  79. 114 0
      internal/web/service/xray_config_inject_test.go
  80. 25 3
      internal/web/translation/ar-EG.json
  81. 25 3
      internal/web/translation/en-US.json
  82. 25 3
      internal/web/translation/es-ES.json
  83. 25 3
      internal/web/translation/fa-IR.json
  84. 25 3
      internal/web/translation/id-ID.json
  85. 25 3
      internal/web/translation/ja-JP.json
  86. 25 3
      internal/web/translation/pt-BR.json
  87. 25 3
      internal/web/translation/ru-RU.json
  88. 25 3
      internal/web/translation/tr-TR.json
  89. 25 3
      internal/web/translation/uk-UA.json
  90. 25 3
      internal/web/translation/vi-VN.json
  91. 25 3
      internal/web/translation/zh-CN.json
  92. 25 3
      internal/web/translation/zh-TW.json
  93. 60 2
      internal/xray/api.go
  94. 19 2
      internal/xray/api_e2e_test.go
  95. 18 0
      internal/xray/online_test.go
  96. 29 0
      internal/xray/process.go
  97. 14 2
      update.sh
  98. 68 8
      x-ui.sh

+ 3 - 2
.gitattributes

@@ -1,5 +1,6 @@
-# Shell scripts must stay LF so the Docker build works when the repo is
-# checked out on Windows (CRLF breaks the script shebang -> exit 127).
 *.sh text eol=lf
 DockerInit.sh text eol=lf
 DockerEntrypoint.sh text eol=lf
+frontend/src/generated/** text eol=lf
+frontend/public/openapi.json text eol=lf
+frontend/src/test/__snapshots__/** text eol=lf

+ 1 - 0
.gitignore

@@ -36,6 +36,7 @@ Thumbs.db
 x-ui.db
 x-ui.db-shm
 x-ui.db-wal
+system_metrics.gob
 *.dump
 
 # Ignore Docker specific files

+ 93 - 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",
@@ -1559,6 +1572,19 @@
             "example": 5,
             "type": "integer"
           },
+          "inboundSyncMode": {
+            "enum": [
+              "all",
+              "selected"
+            ],
+            "type": "string"
+          },
+          "inboundTags": {
+            "items": {
+              "type": "string"
+            },
+            "type": "array"
+          },
           "lastError": {
             "type": "string"
           },
@@ -1662,6 +1688,8 @@
           "guid",
           "id",
           "inboundCount",
+          "inboundSyncMode",
+          "inboundTags",
           "lastError",
           "lastHeartbeat",
           "latencyMs",
@@ -2116,6 +2144,8 @@
                       "protocol": "vless",
                       "remark": "VLESS-443",
                       "settings": null,
+                      "shareAddr": "",
+                      "shareAddrStrategy": "node",
                       "sniffing": null,
                       "streamSettings": null,
                       "tag": "in-443-tcp",
@@ -5996,6 +6026,10 @@
                       "guid": "",
                       "id": 1,
                       "inboundCount": 5,
+                      "inboundSyncMode": "all",
+                      "inboundTags": [
+                        ""
+                      ],
                       "lastError": "",
                       "lastHeartbeat": 1700000000,
                       "latencyMs": 42,
@@ -6436,6 +6470,65 @@
         }
       }
     },
+    "/panel/api/nodes/inbounds": {
+      "post": {
+        "tags": [
+          "Nodes"
+        ],
+        "summary": "Use unsaved node connection details to list the remote inbounds available for selective import.",
+        "operationId": "post_panel_api_nodes_inbounds",
+        "requestBody": {
+          "required": true,
+          "content": {
+            "application/json": {
+              "schema": {
+                "type": "object"
+              },
+              "example": {
+                "name": "de-fra-1",
+                "scheme": "https",
+                "address": "node1.example.com",
+                "port": 2053,
+                "basePath": "/",
+                "apiToken": "abcdef..."
+              }
+            }
+          }
+        },
+        "responses": {
+          "200": {
+            "description": "Successful response",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "object",
+                  "properties": {
+                    "success": {
+                      "type": "boolean"
+                    },
+                    "msg": {
+                      "type": "string"
+                    },
+                    "obj": {}
+                  }
+                },
+                "example": {
+                  "success": true,
+                  "obj": [
+                    {
+                      "tag": "inbound-443",
+                      "remark": "VLESS",
+                      "protocol": "vless",
+                      "port": 443
+                    }
+                  ]
+                }
+              }
+            }
+          }
+        }
+      }
+    },
     "/panel/api/nodes/probe/{id}": {
       "post": {
         "tags": [

+ 9 - 0
frontend/src/api/queries/useNodeMutations.ts

@@ -15,6 +15,13 @@ export interface NodeUpdateResult {
   error?: string;
 }
 
+export interface RemoteInboundOption {
+  tag: string;
+  remark?: string;
+  protocol?: string;
+  port?: number;
+}
+
 export function useNodeMutations() {
   const queryClient = useQueryClient();
   const invalidate = () => queryClient.invalidateQueries({ queryKey: keys.nodes.root() });
@@ -72,5 +79,7 @@ export function useNodeMutations() {
     },
     fetchFingerprint: (payload: Partial<NodeRecord>): Promise<Msg<string>> =>
       HttpUtil.post<string>('/panel/api/nodes/certFingerprint', payload),
+    fetchInbounds: (payload: Partial<NodeRecord>): Promise<Msg<RemoteInboundOption[]>> =>
+      HttpUtil.post<RemoteInboundOption[]>('/panel/api/nodes/inbounds', payload),
   };
 }

+ 6 - 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",
@@ -340,6 +342,10 @@ export const EXAMPLES: Record<string, unknown> = {
     "guid": "",
     "id": 1,
     "inboundCount": 5,
+    "inboundSyncMode": "all",
+    "inboundTags": [
+      ""
+    ],
     "lastError": "",
     "lastHeartbeat": 1700000000,
     "latencyMs": 42,

+ 28 - 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",
@@ -1533,6 +1546,19 @@ export const SCHEMAS: Record<string, unknown> = {
         "example": 5,
         "type": "integer"
       },
+      "inboundSyncMode": {
+        "enum": [
+          "all",
+          "selected"
+        ],
+        "type": "string"
+      },
+      "inboundTags": {
+        "items": {
+          "type": "string"
+        },
+        "type": "array"
+      },
       "lastError": {
         "type": "string"
       },
@@ -1636,6 +1662,8 @@ export const SCHEMAS: Record<string, unknown> = {
       "guid",
       "id",
       "inboundCount",
+      "inboundSyncMode",
+      "inboundTags",
       "lastError",
       "lastHeartbeat",
       "latencyMs",

+ 5 - 0
frontend/src/generated/types.ts

@@ -1,4 +1,5 @@
 // Code generated by tools/openapigen. DO NOT EDIT.
+export type OnlineAPISupport = number;
 export type ProcessState = string;
 export type Protocol = string;
 export type SubLinkProvider = unknown;
@@ -288,6 +289,8 @@ export interface Inbound {
   protocol: Protocol;
   remark: string;
   settings: unknown;
+  shareAddr: string;
+  shareAddrStrategy: string;
   sniffing: unknown;
   streamSettings: unknown;
   tag: string;
@@ -345,6 +348,8 @@ export interface Node {
   guid: string;
   id: number;
   inboundCount: number;
+  inboundSyncMode: string;
+  inboundTags: string[];
   lastError: string;
   lastHeartbeat: number;
   latencyMs: number;

+ 7 - 0
frontend/src/generated/zod.ts

@@ -1,5 +1,8 @@
 // Code generated by tools/openapigen. DO NOT EDIT.
 import { z } from 'zod';
+export const OnlineAPISupportSchema = z.number().int();
+export type OnlineAPISupport = z.infer<typeof OnlineAPISupportSchema>;
+
 export const ProcessStateSchema = z.string();
 export type ProcessState = z.infer<typeof ProcessStateSchema>;
 
@@ -307,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(),
@@ -369,6 +374,8 @@ export const NodeSchema = z.object({
   guid: z.string(),
   id: z.number().int(),
   inboundCount: z.number().int(),
+  inboundSyncMode: z.enum(['all', 'selected']),
+  inboundTags: z.array(z.string()),
   lastError: z.string(),
   lastHeartbeat: z.number().int(),
   latencyMs: z.number().int(),

+ 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,

+ 78 - 15
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.
@@ -58,9 +59,15 @@ function buildXhttpExtra(xhttp: XHttpStreamSettings | undefined): Record<string,
     'uplinkDataKey',
     'scMaxEachPostBytes',
   ] as const;
+  // Values matching xray-core's own defaults stay off the wire — old panels
+  // seeded them into every config and the literal values are a DPI
+  // fingerprint (#5141). Mirrors the sub service's filter.
+  const coreDefaults: Partial<Record<(typeof stringFields)[number], string>> = {
+    scMaxEachPostBytes: '1000000',
+  };
   for (const k of stringFields) {
     const v = xhttp[k];
-    if (typeof v === 'string' && v.length > 0) extra[k] = v;
+    if (typeof v === 'string' && v.length > 0 && v !== coreDefaults[k]) extra[k] = v;
   }
 
   // Headers on the wire are a record; emit them as a map upstream's
@@ -777,19 +784,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 +865,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;
 }

+ 1 - 1
frontend/src/lib/xray/outbound-link-parser.ts

@@ -114,7 +114,7 @@ function buildStream(network: string, security: string): Raw {
     case 'xhttp':
       stream.xhttpSettings = {
         path: '/', host: '', mode: 'auto', headers: {},
-        xPaddingBytes: '100-1000', scMaxEachPostBytes: '1000000',
+        xPaddingBytes: '100-1000',
       };
       break;
     default:

+ 2 - 0
frontend/src/lib/xray/stream-wire-normalize.ts

@@ -125,6 +125,8 @@ export function normalizeXhttpForWire(
   }
 
   dropEmptyStrings(out, PLACEMENT_STRING_FIELDS);
+  // Empty tuning fields mean "use xray-core's default" — never emit them.
+  dropEmptyStrings(out, ['scMaxEachPostBytes', 'scMinPostsIntervalMs', 'scStreamUpServerSecs']);
 
   if (!hasMeaningfulHeaders(out.headers)) {
     delete out.headers;

+ 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/models/setting.ts

@@ -95,6 +95,8 @@ export class AllSetting {
     if (data != null) {
       ObjectUtil.cloneProps(this, data);
     }
+    const cpu = Math.round(Number(this.tgCpu));
+    this.tgCpu = Number.isFinite(cpu) ? Math.min(100, Math.max(0, cpu)) : 80;
   }
 
   equals(other: AllSetting): boolean {

+ 7 - 0
frontend/src/pages/api-docs/endpoints.ts

@@ -843,6 +843,13 @@ export const sections: readonly Section[] = [
         body: '{\n  "scheme": "https",\n  "address": "node1.example.com",\n  "port": 2053,\n  "basePath": "/"\n}',
         response: '{\n  "success": true,\n  "obj": "k3b1...base64-sha256...="\n}',
       },
+      {
+        method: 'POST',
+        path: '/panel/api/nodes/inbounds',
+        summary: 'Use unsaved node connection details to list the remote inbounds available for selective import.',
+        body: '{\n  "name": "de-fra-1",\n  "scheme": "https",\n  "address": "node1.example.com",\n  "port": 2053,\n  "basePath": "/",\n  "apiToken": "abcdef..."\n}',
+        response: '{\n  "success": true,\n  "obj": [\n    { "tag": "inbound-443", "remark": "VLESS", "protocol": "vless", "port": 443 }\n  ]\n}',
+      },
       {
         method: 'POST',
         path: '/panel/api/nodes/probe/:id',

+ 3 - 7
frontend/src/pages/clients/ClientBulkAddModal.tsx

@@ -21,7 +21,6 @@ const MULTI_CLIENT_PROTOCOLS = new Set([
 interface ClientBulkAddModalProps {
   open: boolean;
   inbounds: InboundOption[];
-  ipLimitEnable?: boolean;
   groups?: string[];
   onOpenChange: (open: boolean) => void;
   onSaved?: () => void;
@@ -52,7 +51,6 @@ function emptyForm(): FormState {
 export default function ClientBulkAddModal({
   open,
   inbounds,
-  ipLimitEnable = false,
   groups = [],
   onOpenChange,
   onSaved,
@@ -316,11 +314,9 @@ export default function ClientBulkAddModal({
             </Form.Item>
           )}
 
-          {ipLimitEnable && (
-            <Form.Item label={t('pages.clients.limitIp')}>
-              <InputNumber value={form.limitIp} min={0} onChange={(v) => update('limitIp', Number(v) || 0)} />
-            </Form.Item>
-          )}
+          <Form.Item label={t('pages.clients.limitIp')}>
+            <InputNumber value={form.limitIp} min={0} onChange={(v) => update('limitIp', Number(v) || 0)} />
+          </Form.Item>
 
           <Form.Item label={t('pages.clients.totalGB')}>
             <InputNumber value={form.totalGB} min={0} step={1} onChange={(v) => update('totalGB', Number(v) || 0)} />

+ 227 - 210
frontend/src/pages/clients/ClientFormModal.tsx

@@ -12,6 +12,7 @@ import {
   Select,
   Space,
   Switch,
+  Tabs,
   Tag,
   message,
 } from 'antd';
@@ -64,7 +65,6 @@ interface ClientFormModalProps {
   client: ClientRecord | null;
   inbounds: InboundOption[];
   attachedIds?: number[];
-  ipLimitEnable?: boolean;
   tgBotEnable?: boolean;
   groups?: string[];
   save: (
@@ -136,7 +136,6 @@ export default function ClientFormModal({
   client,
   inbounds,
   attachedIds = [],
-  ipLimitEnable = false,
   tgBotEnable = false,
   groups = [],
   save,
@@ -424,214 +423,232 @@ export default function ClientFormModal({
         onCancel={close}
       >
         <Form layout="vertical">
-          <Row gutter={16}>
-            <Col xs={24} md={12}>
-              <Form.Item label={t('pages.clients.email')} required>
-                <Space.Compact style={{ display: 'flex' }}>
-                  <Input
-                    value={form.email}
-                    placeholder={t('pages.clients.email')}
-                    style={{ flex: 1 }}
-                    onChange={(e) => update('email', e.target.value)}
-                  />
-                  <Button icon={<ReloadOutlined />} onClick={() => update('email', RandomUtil.randomLowerAndNum(12))} />
-                </Space.Compact>
-              </Form.Item>
-            </Col>
-            <Col xs={24} md={12}>
-              <Form.Item label={t('pages.clients.subId')}>
-                <Space.Compact style={{ display: 'flex' }}>
-                  <Input value={form.subId} style={{ flex: 1 }} onChange={(e) => update('subId', e.target.value)} />
-                  <Button icon={<ReloadOutlined />} onClick={() => update('subId', RandomUtil.randomLowerAndNum(16))} />
-                </Space.Compact>
-              </Form.Item>
-            </Col>
-          </Row>
-
-          <Row gutter={16}>
-            <Col xs={24} md={12}>
-              <Form.Item label={t('pages.clients.hysteriaAuth')}>
-                <Space.Compact style={{ display: 'flex' }}>
-                  <Input value={form.auth} style={{ flex: 1 }} onChange={(e) => update('auth', e.target.value)} />
-                  <Button icon={<ReloadOutlined />} onClick={() => update('auth', RandomUtil.randomLowerAndNum(16))} />
-                </Space.Compact>
-              </Form.Item>
-            </Col>
-            <Col xs={24} md={12}>
-              <Form.Item label={t('pages.clients.password')}>
-                <Space.Compact style={{ display: 'flex' }}>
-                  <Input value={form.password} style={{ flex: 1 }} onChange={(e) => update('password', e.target.value)} />
-                  <Button icon={<ReloadOutlined />} onClick={regeneratePassword} />
-                </Space.Compact>
-              </Form.Item>
-            </Col>
-          </Row>
-
-          <Row gutter={16}>
-            <Col xs={24} md={12}>
-              <Form.Item label={t('pages.clients.uuid')}>
-                <Space.Compact style={{ display: 'flex' }}>
-                  <Input value={form.uuid} style={{ flex: 1 }} onChange={(e) => update('uuid', e.target.value)} />
-                  <Button icon={<ReloadOutlined />} onClick={() => update('uuid', RandomUtil.randomUUID())} />
-                </Space.Compact>
-              </Form.Item>
-            </Col>
-            <Col xs={24} md={ipLimitEnable ? 8 : 12}>
-              <Form.Item label={t('pages.clients.totalGB')}>
-                <InputNumber value={form.totalGB} min={0} step={1} style={{ width: '100%' }}
-                  onChange={(v) => update('totalGB', Number(v) || 0)} />
-              </Form.Item>
-            </Col>
-            {ipLimitEnable && (
-              <Col xs={24} md={4}>
-                <Form.Item label={t('pages.clients.limitIp')}>
-                  <InputNumber value={form.limitIp} min={0} style={{ width: '100%' }}
-                    onChange={(v) => update('limitIp', Number(v) || 0)} />
-                </Form.Item>
-              </Col>
-            )}
-          </Row>
-
-          <Row gutter={16}>
-            <Col xs={24} md={12}>
-              {form.delayedStart ? (
-                <Form.Item label={t('pages.clients.expireDays')}>
-                  <InputNumber value={form.delayedDays} min={0} style={{ width: '100%' }}
-                    onChange={(v) => update('delayedDays', Number(v) || 0)} />
-                </Form.Item>
-              ) : (
-                <Form.Item label={t('pages.clients.expiryTime')}>
-                  <DateTimePicker
-                    value={form.expiryDate}
-                    onChange={(d) => update('expiryDate', d || null)}
-                  />
-                </Form.Item>
-              )}
-            </Col>
-            <Col xs={24} md={12}>
-              <Form.Item label={t('pages.clients.delayedStart')}>
-                <Switch
-                  checked={form.delayedStart}
-                  onChange={(v) => {
-                    update('delayedStart', v);
-                    if (v) update('expiryDate', null);
-                    else update('delayedDays', 0);
-                  }}
-                />
-              </Form.Item>
-            </Col>
-          </Row>
-
-          <Row gutter={16}>
-            <Col xs={24} md={12}>
-              <Form.Item
-                label={t('pages.clients.renew')}
-                tooltip={t('pages.clients.renewDesc')}
-              >
-                <InputNumber value={form.reset} min={0} style={{ width: '100%' }}
-                  onChange={(v) => update('reset', Number(v) || 0)} />
-              </Form.Item>
-            </Col>
-            {showReverseTag && (
-              <Col xs={24} md={12}>
-                <Form.Item label={t('pages.clients.reverseTag')}>
-                  <Input value={form.reverseTag} placeholder={t('pages.clients.reverseTagPlaceholder')}
-                    onChange={(e) => update('reverseTag', e.target.value)} />
-                </Form.Item>
-              </Col>
-            )}
-            {showFlow && (
-              <Col xs={24} md={12}>
-                <Form.Item label={t('pages.clients.flow')}>
-                  <Select
-                    value={form.flow}
-                    onChange={(v) => update('flow', v)}
-                    options={[
-                      { value: '', label: t('none') },
-                      ...FLOW_OPTIONS.map((k) => ({ value: k, label: k })),
-                    ]}
-                  />
-                </Form.Item>
-              </Col>
-            )}
-            {showSecurity && (
-              <Col xs={24} md={12}>
-                <Form.Item label={t('pages.clients.vmessSecurity')}>
-                  <Select
-                    value={form.security}
-                    onChange={(v) => update('security', v)}
-                    options={VMESS_SECURITY_OPTIONS.map((k) => ({ value: k, label: k }))}
-                  />
-                </Form.Item>
-              </Col>
-            )}
-          </Row>
-
-          <Row gutter={16}>
-            {tgBotEnable && (
-              <Col xs={24} md={12}>
-                <Form.Item label={t('pages.clients.telegramId')}>
-                  <InputNumber value={form.tgId} min={0} controls={false}
-                    placeholder={t('pages.clients.telegramIdPlaceholder')} style={{ width: '100%' }}
-                    onChange={(v) => update('tgId', Number(v) || 0)} />
-                </Form.Item>
-              </Col>
-            )}
-            <Col xs={24} md={tgBotEnable ? 12 : 24}>
-              <Form.Item label={t('pages.clients.comment')}>
-                <Input value={form.comment} onChange={(e) => update('comment', e.target.value)} />
-              </Form.Item>
-            </Col>
-            <Col xs={24} md={12}>
-              <Form.Item label={t('pages.clients.group')} tooltip={t('pages.clients.groupDesc')}>
-                <AutoComplete
-                  value={form.group}
-                  placeholder={t('pages.clients.groupPlaceholder')}
-                  options={groups.map((g) => ({ value: g }))}
-                  onChange={(v) => update('group', v ?? '')}
-                  filterOption={(input, option) =>
-                    String(option?.value ?? '').toLowerCase().includes((input || '').toLowerCase())
-                  }
-                  allowClear
-                  style={{ width: '100%' }}
-                />
-              </Form.Item>
-            </Col>
-          </Row>
-
-          <Form.Item label={t('pages.clients.attachedInbounds')} required={!isEdit}>
-            <SelectAllClearButtons
-              options={inboundOptions}
-              value={form.inboundIds}
-              onChange={(v) => update('inboundIds', v)}
-            />
-            <Select
-              mode="multiple"
-              value={form.inboundIds}
-              onChange={(v) => update('inboundIds', v)}
-              options={inboundOptions}
-              placeholder={t('pages.clients.selectInbound')}
-              maxTagCount="responsive"
-              placement="topLeft"
-              listHeight={220}
-              showSearch={{
-                filterOption: (input, option) => ((option?.label as string) || '').toLowerCase().includes(input.toLowerCase()),
-              }}
-            />
-          </Form.Item>
-
-          <Form.Item>
-            <Switch checked={form.enable} onChange={(v) => update('enable', v)} />
-            <span style={{ marginLeft: 8 }}>{t('enable')}</span>
-          </Form.Item>
-
-          {isEdit && ipLimitEnable && (
-            <Form.Item label={t('pages.clients.ipLog')}>
-              <Button icon={<EyeOutlined />} loading={ipsLoading} onClick={openIpsModal}>
-                {clientIps.length > 0 ? clientIps.length : ''}
-              </Button>
-            </Form.Item>
-          )}
+          <Tabs
+            defaultActiveKey="basic"
+            items={[
+              {
+                key: 'basic',
+                label: t('pages.clients.tabBasic'),
+                children: (
+                  <>
+                    <Row gutter={16}>
+                      <Col xs={24} md={12}>
+                        <Form.Item label={t('pages.clients.email')} required>
+                          <Space.Compact style={{ display: 'flex' }}>
+                            <Input
+                              value={form.email}
+                              placeholder={t('pages.clients.email')}
+                              style={{ flex: 1 }}
+                              onChange={(e) => update('email', e.target.value)}
+                            />
+                            <Button icon={<ReloadOutlined />} onClick={() => update('email', RandomUtil.randomLowerAndNum(12))} />
+                          </Space.Compact>
+                        </Form.Item>
+                      </Col>
+                      <Col xs={24} md={8}>
+                        <Form.Item label={t('pages.clients.totalGB')}>
+                          <InputNumber value={form.totalGB} min={0} step={1} style={{ width: '100%' }}
+                            onChange={(v) => update('totalGB', Number(v) || 0)} />
+                        </Form.Item>
+                      </Col>
+                      <Col xs={24} md={4}>
+                        <Form.Item label={t('pages.clients.limitIp')}>
+                          <InputNumber value={form.limitIp} min={0} style={{ width: '100%' }}
+                            onChange={(v) => update('limitIp', Number(v) || 0)} />
+                        </Form.Item>
+                      </Col>
+                    </Row>
+
+                    <Row gutter={16}>
+                      <Col xs={24} md={12}>
+                        {form.delayedStart ? (
+                          <Form.Item label={t('pages.clients.expireDays')}>
+                            <InputNumber value={form.delayedDays} min={0} style={{ width: '100%' }}
+                              onChange={(v) => update('delayedDays', Number(v) || 0)} />
+                          </Form.Item>
+                        ) : (
+                          <Form.Item label={t('pages.clients.expiryTime')}>
+                            <DateTimePicker
+                              value={form.expiryDate}
+                              onChange={(d) => update('expiryDate', d || null)}
+                            />
+                          </Form.Item>
+                        )}
+                      </Col>
+                      <Col xs={12} md={6}>
+                        <Form.Item label={t('pages.clients.delayedStart')}>
+                          <Switch
+                            checked={form.delayedStart}
+                            onChange={(v) => {
+                              update('delayedStart', v);
+                              if (v) update('expiryDate', null);
+                              else update('delayedDays', 0);
+                            }}
+                          />
+                        </Form.Item>
+                      </Col>
+                      <Col xs={12} md={6}>
+                        <Form.Item
+                          label={t('pages.clients.renew')}
+                          tooltip={t('pages.clients.renewDesc')}
+                        >
+                          <InputNumber value={form.reset} min={0} style={{ width: '100%' }}
+                            onChange={(v) => update('reset', Number(v) || 0)} />
+                        </Form.Item>
+                      </Col>
+                    </Row>
+
+                    <Row gutter={16}>
+                      {tgBotEnable && (
+                        <Col xs={24} md={12}>
+                          <Form.Item label={t('pages.clients.telegramId')}>
+                            <InputNumber value={form.tgId} min={0} controls={false}
+                              placeholder={t('pages.clients.telegramIdPlaceholder')} style={{ width: '100%' }}
+                              onChange={(v) => update('tgId', Number(v) || 0)} />
+                          </Form.Item>
+                        </Col>
+                      )}
+                      <Col xs={24} md={tgBotEnable ? 12 : 24}>
+                        <Form.Item label={t('pages.clients.comment')}>
+                          <Input value={form.comment} onChange={(e) => update('comment', e.target.value)} />
+                        </Form.Item>
+                      </Col>
+                      <Col xs={24} md={12}>
+                        <Form.Item label={t('pages.clients.group')} tooltip={t('pages.clients.groupDesc')}>
+                          <AutoComplete
+                            value={form.group}
+                            placeholder={t('pages.clients.groupPlaceholder')}
+                            options={groups.map((g) => ({ value: g }))}
+                            onChange={(v) => update('group', v ?? '')}
+                            filterOption={(input, option) =>
+                              String(option?.value ?? '').toLowerCase().includes((input || '').toLowerCase())
+                            }
+                            allowClear
+                            style={{ width: '100%' }}
+                          />
+                        </Form.Item>
+                      </Col>
+                    </Row>
+
+                    <Form.Item label={t('pages.clients.attachedInbounds')} required={!isEdit}>
+                      <SelectAllClearButtons
+                        options={inboundOptions}
+                        value={form.inboundIds}
+                        onChange={(v) => update('inboundIds', v)}
+                      />
+                      <Select
+                        mode="multiple"
+                        value={form.inboundIds}
+                        onChange={(v) => update('inboundIds', v)}
+                        options={inboundOptions}
+                        placeholder={t('pages.clients.selectInbound')}
+                        maxTagCount="responsive"
+                        placement="topLeft"
+                        listHeight={220}
+                        showSearch={{
+                          filterOption: (input, option) => ((option?.label as string) || '').toLowerCase().includes(input.toLowerCase()),
+                        }}
+                      />
+                    </Form.Item>
+
+                    <Form.Item>
+                      <Switch checked={form.enable} onChange={(v) => update('enable', v)} />
+                      <span style={{ marginLeft: 8 }}>{t('enable')}</span>
+                    </Form.Item>
+
+                    {isEdit && (
+                      <Form.Item label={t('pages.clients.ipLog')}>
+                        <Button icon={<EyeOutlined />} loading={ipsLoading} onClick={openIpsModal}>
+                          {clientIps.length > 0 ? clientIps.length : ''}
+                        </Button>
+                      </Form.Item>
+                    )}
+                  </>
+                ),
+              },
+              {
+                key: 'config',
+                label: t('pages.clients.tabConfig'),
+                children: (
+                  <>
+                    <Row gutter={16}>
+                      <Col xs={24} md={12}>
+                        <Form.Item label={t('pages.clients.uuid')}>
+                          <Space.Compact style={{ display: 'flex' }}>
+                            <Input value={form.uuid} style={{ flex: 1 }} onChange={(e) => update('uuid', e.target.value)} />
+                            <Button icon={<ReloadOutlined />} onClick={() => update('uuid', RandomUtil.randomUUID())} />
+                          </Space.Compact>
+                        </Form.Item>
+                      </Col>
+                      <Col xs={24} md={12}>
+                        <Form.Item label={t('pages.clients.password')}>
+                          <Space.Compact style={{ display: 'flex' }}>
+                            <Input value={form.password} style={{ flex: 1 }} onChange={(e) => update('password', e.target.value)} />
+                            <Button icon={<ReloadOutlined />} onClick={regeneratePassword} />
+                          </Space.Compact>
+                        </Form.Item>
+                      </Col>
+                    </Row>
+
+                    <Row gutter={16}>
+                      <Col xs={24} md={12}>
+                        <Form.Item label={t('pages.clients.subId')}>
+                          <Space.Compact style={{ display: 'flex' }}>
+                            <Input value={form.subId} style={{ flex: 1 }} onChange={(e) => update('subId', e.target.value)} />
+                            <Button icon={<ReloadOutlined />} onClick={() => update('subId', RandomUtil.randomLowerAndNum(16))} />
+                          </Space.Compact>
+                        </Form.Item>
+                      </Col>
+                      <Col xs={24} md={12}>
+                        <Form.Item label={t('pages.clients.hysteriaAuth')}>
+                          <Space.Compact style={{ display: 'flex' }}>
+                            <Input value={form.auth} style={{ flex: 1 }} onChange={(e) => update('auth', e.target.value)} />
+                            <Button icon={<ReloadOutlined />} onClick={() => update('auth', RandomUtil.randomLowerAndNum(16))} />
+                          </Space.Compact>
+                        </Form.Item>
+                      </Col>
+                    </Row>
+
+                    <Row gutter={16}>
+                      {showFlow && (
+                        <Col xs={24} md={12}>
+                          <Form.Item label={t('pages.clients.flow')}>
+                            <Select
+                              value={form.flow}
+                              onChange={(v) => update('flow', v)}
+                              options={[
+                                { value: '', label: t('none') },
+                                ...FLOW_OPTIONS.map((k) => ({ value: k, label: k })),
+                              ]}
+                            />
+                          </Form.Item>
+                        </Col>
+                      )}
+                      {showSecurity && (
+                        <Col xs={24} md={12}>
+                          <Form.Item label={t('pages.clients.vmessSecurity')}>
+                            <Select
+                              value={form.security}
+                              onChange={(v) => update('security', v)}
+                              options={VMESS_SECURITY_OPTIONS.map((k) => ({ value: k, label: k }))}
+                            />
+                          </Form.Item>
+                        </Col>
+                      )}
+                      {showReverseTag && (
+                        <Col xs={24} md={12}>
+                          <Form.Item label={t('pages.clients.reverseTag')}>
+                            <Input value={form.reverseTag} placeholder={t('pages.clients.reverseTagPlaceholder')}
+                              onChange={(e) => update('reverseTag', e.target.value)} />
+                          </Form.Item>
+                        </Col>
+                      )}
+                    </Row>
+                  </>
+                ),
+              },
+            ]}
+          />
         </Form>
       </Modal>
 

+ 1 - 3
frontend/src/pages/clients/ClientsPage.tsx

@@ -196,7 +196,7 @@ export default function ClientsPage() {
     allGroups,
     setQuery,
     inbounds, onlines, loading, fetched, fetchError, subSettings,
-    ipLimitEnable, tgBotEnable, expireDiff, trafficDiff, pageSize,
+    tgBotEnable, expireDiff, trafficDiff, pageSize,
     create, update, remove, bulkDelete, bulkAdjust, bulkAddToGroup, bulkRemoveFromGroup, attach, bulkAttach, detach, bulkDetach,
     resetTraffic, resetAllTraffics, delDepleted, setEnable,
     applyTrafficEvent, applyClientStatsEvent,
@@ -1219,7 +1219,6 @@ export default function ClientsPage() {
             client={editingClient}
             attachedIds={editingAttachedIds}
             inbounds={inbounds}
-            ipLimitEnable={ipLimitEnable}
             tgBotEnable={tgBotEnable}
             groups={allGroups}
             save={onSave}
@@ -1248,7 +1247,6 @@ export default function ClientsPage() {
           <ClientBulkAddModal
             open={bulkAddOpen}
             inbounds={inbounds}
-            ipLimitEnable={ipLimitEnable}
             groups={allGroups}
             onOpenChange={setBulkAddOpen}
             onSaved={() => setBulkAddOpen(false)}

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

+ 85 - 0
frontend/src/pages/inbounds/form/InboundFormModal.tsx

@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react';
 import { useTranslation } from 'react-i18next';
 import dayjs from 'dayjs';
 import {
+  Alert,
   Form,
   Input,
   InputNumber,
@@ -84,6 +85,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 +96,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;
@@ -150,6 +177,10 @@ export default function InboundFormModal({
   const selectableNodes = (availableNodes || []).filter((n) => n.enable);
   const protocol = (Form.useWatch('protocol', form) ?? '') as string;
   const isNodeEligible = NODE_ELIGIBLE_PROTOCOLS.has(protocol);
+  // The `node` share-address strategy only means something when the inbound can
+  // actually live on a node — otherwise the node address it would resolve to is
+  // always empty. Offer it only then; `listen`/`custom` work for local inbounds.
+  const nodeShareOptionAvailable = selectableNodes.length > 0 && isNodeEligible;
   const sniffingEnabled = Form.useWatch(['sniffing', 'enabled'], form) ?? false;
   const vlessEncryption = Form.useWatch(['settings', 'encryption'], form) ?? '';
   const ssMethod = Form.useWatch(['settings', 'method'], form);
@@ -176,6 +207,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);
@@ -343,6 +375,18 @@ export default function InboundFormModal({
     // eslint-disable-next-line react-hooks/exhaustive-deps
   }, [open, wPort, wNodeId, protocol, network, mixedUdpOn, wSsNetwork, wTunnelNetwork]);
 
+  // Keep the strategy value inside the visible option set: when `node` isn't
+  // offered (no node, or a protocol that can't deploy to one) fall back to
+  // `listen`, which yields the same link for a local inbound. Mirrors how the
+  // protocol reset drops a nodeId that no longer applies.
+  useEffect(() => {
+    if (!open) return;
+    if (!nodeShareOptionAvailable && shareAddrStrategy === 'node') {
+      form.setFieldValue('shareAddrStrategy', 'listen');
+    }
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [open, nodeShareOptionAvailable, shareAddrStrategy]);
+
   // Why: protocol picker reset cascades through the form — clearing the
   // settings DU branch and dropping a nodeId that no longer applies. The
   // legacy modal did this imperatively in onProtocolChange; here we hook
@@ -499,6 +543,38 @@ 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
+            .filter((strategy) => strategy !== 'node' || nodeShareOptionAvailable)
+            .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')}
@@ -605,6 +681,15 @@ export default function InboundFormModal({
       {protocol === Protocols.VLESS && <VlessFields saving={saving} selectedVlessAuth={selectedVlessAuth} network={network} security={security} getNewVlessEnc={getNewVlessEnc} clearVlessEnc={clearVlessEnc} />}
 
       {isFallbackHost && fallbacksCard}
+      {(protocol === Protocols.VLESS || protocol === Protocols.TROJAN)
+        && network === 'tcp' && !isFallbackHost && (
+        <Alert
+          className="mt-12"
+          type="info"
+          showIcon
+          message={t('pages.inbounds.fallbacks.needsTls')}
+        />
+      )}
     </>
   );
 

+ 1 - 1
frontend/src/pages/inbounds/form/protocols/hysteria.tsx

@@ -19,7 +19,7 @@ export default function HysteriaFields({ form }: { form: FormInstance }) {
         label={t('pages.inbounds.form.udpIdleTimeout')}
         name={['streamSettings', 'hysteriaSettings', 'udpIdleTimeout']}
       >
-        <InputNumber min={1} style={{ width: '100%' }} />
+        <InputNumber min={2} max={600} style={{ width: '100%' }} />
       </Form.Item>
 
       <Form.Item label={t('pages.inbounds.form.masquerade')}>

+ 4 - 4
frontend/src/pages/index/IndexPage.tsx

@@ -64,7 +64,7 @@ export default function IndexPage() {
   const [messageApi, messageContextHolder] = message.useMessage();
   useEffect(() => { setMessageInstance(messageApi); }, [messageApi]);
 
-  const [ipLimitEnable, setIpLimitEnable] = useState(false);
+  const [accessLogEnable, setAccessLogEnable] = useState(false);
   const [panelUpdateInfo, setPanelUpdateInfo] = useState<PanelUpdateInfo>({
     currentVersion: '',
     latestVersion: '',
@@ -87,8 +87,8 @@ export default function IndexPage() {
   const [loadingTip, setLoadingTip] = useState(t('loading'));
 
   useEffect(() => {
-    HttpUtil.post<{ ipLimitEnable?: boolean }>('/panel/api/setting/defaultSettings').then((msg) => {
-      if (msg?.success && msg.obj) setIpLimitEnable(!!msg.obj.ipLimitEnable);
+    HttpUtil.post<{ accessLogEnable?: boolean }>('/panel/api/setting/defaultSettings').then((msg) => {
+      if (msg?.success && msg.obj) setAccessLogEnable(!!msg.obj.accessLogEnable);
     });
     HttpUtil.get<PanelUpdateInfo>('/panel/api/server/getPanelUpdateInfo').then((msg) => {
       if (msg?.success && msg.obj) setPanelUpdateInfo(msg.obj);
@@ -186,7 +186,7 @@ export default function IndexPage() {
                     <XrayStatusCard
                       status={status}
                       isMobile={isMobile}
-                      ipLimitEnable={ipLimitEnable}
+                      accessLogEnable={accessLogEnable}
                       onStopXray={stopXray}
                       onRestartXray={restartXray}
                       onOpenXrayLogs={() => setXrayLogsOpen(true)}

+ 5 - 3
frontend/src/pages/index/XrayStatusCard.tsx

@@ -14,7 +14,7 @@ import './XrayStatusCard.css';
 interface XrayStatusCardProps {
   status: Status;
   isMobile: boolean;
-  ipLimitEnable: boolean;
+  accessLogEnable: boolean;
   onStopXray: () => void;
   onRestartXray: () => void;
   onOpenLogs: () => void;
@@ -31,7 +31,7 @@ const XRAY_STATE_KEYS: Record<string, string> = {
 export default function XrayStatusCard({
   status,
   isMobile,
-  ipLimitEnable,
+  accessLogEnable,
   onStopXray,
   onRestartXray,
   onOpenLogs,
@@ -86,7 +86,9 @@ export default function XrayStatusCard({
     );
 
   const actions = [
-    ...(ipLimitEnable
+    // the xray log viewer reads the access log file, so the button only makes
+    // sense when one is configured (unlike IP limit, which no longer needs it)
+    ...(accessLogEnable
       ? [
           <Space className="action" key="xraylogs" onClick={onOpenXrayLogs}>
             <BarsOutlined />

+ 73 - 0
frontend/src/pages/nodes/NodeFormModal.tsx

@@ -14,6 +14,7 @@ import {
   message,
 } from 'antd';
 import type { NodeRecord } from '@/api/queries/useNodesQuery';
+import type { RemoteInboundOption } from '@/api/queries/useNodeMutations';
 import type { Msg } from '@/utils';
 import { NodeFormSchema, type NodeFormValues, type ProbeResult } from '@/schemas/node';
 import { antdRule } from '@/utils/zodForm';
@@ -27,6 +28,7 @@ interface NodeFormModalProps {
   node: NodeRecord | null;
   testConnection: (payload: Partial<NodeRecord>) => Promise<Msg<ProbeResult>>;
   fetchFingerprint: (payload: Partial<NodeRecord>) => Promise<Msg<string>>;
+  fetchInbounds: (payload: Partial<NodeRecord>) => Promise<Msg<RemoteInboundOption[]>>;
   save: (payload: Partial<NodeRecord>) => Promise<Msg<unknown>>;
   onOpenChange: (open: boolean) => void;
 }
@@ -45,6 +47,8 @@ function defaultValues(): NodeFormValues {
     allowPrivateAddress: false,
     tlsVerifyMode: 'verify',
     pinnedCertSha256: '',
+    inboundSyncMode: 'all',
+    inboundTags: [],
   };
 }
 
@@ -54,6 +58,7 @@ export default function NodeFormModal({
   node,
   testConnection,
   fetchFingerprint,
+  fetchInbounds,
   save,
   onOpenChange,
 }: NodeFormModalProps) {
@@ -64,9 +69,12 @@ export default function NodeFormModal({
   const [submitting, setSubmitting] = useState(false);
   const [testing, setTesting] = useState(false);
   const [fetchingPin, setFetchingPin] = useState(false);
+  const [fetchingInbounds, setFetchingInbounds] = useState(false);
+  const [inboundOptions, setInboundOptions] = useState<RemoteInboundOption[]>([]);
   const [testResult, setTestResult] = useState<ProbeResult | null>(null);
   const scheme = Form.useWatch('scheme', form) ?? 'https';
   const tlsVerifyMode = Form.useWatch('tlsVerifyMode', form) ?? 'verify';
+  const inboundSyncMode = Form.useWatch('inboundSyncMode', form) ?? 'all';
 
   useEffect(() => {
     if (!open) return;
@@ -77,11 +85,14 @@ export default function NodeFormModal({
         ...(node as unknown as Partial<NodeFormValues>),
         id: node.id,
         scheme: (node.scheme as 'http' | 'https') || base.scheme,
+        inboundSyncMode: (node.inboundSyncMode as 'all' | 'selected') || base.inboundSyncMode,
+        inboundTags: node.inboundTags ?? [],
       }
       : base;
     if (next.scheme === 'http') next.tlsVerifyMode = 'skip';
     form.resetFields();
     form.setFieldsValue(next);
+    setInboundOptions((next.inboundTags || []).map((tag) => ({ tag })));
     setTestResult(null);
   }, [open, mode, node, form]);
 
@@ -104,6 +115,8 @@ export default function NodeFormModal({
       allowPrivateAddress: values.allowPrivateAddress,
       tlsVerifyMode: values.tlsVerifyMode,
       pinnedCertSha256: values.tlsVerifyMode === 'pin' ? values.pinnedCertSha256.trim() : '',
+      inboundSyncMode: values.inboundSyncMode,
+      inboundTags: values.inboundSyncMode === 'selected' ? values.inboundTags : [],
     };
   }
 
@@ -149,6 +162,26 @@ export default function NodeFormModal({
     }
   }
 
+  async function onFetchInbounds() {
+    try {
+      await form.validateFields(['name', 'address', 'port', 'apiToken']);
+    } catch {
+      return;
+    }
+    setFetchingInbounds(true);
+    try {
+      const msg = await fetchInbounds(buildPayload(form.getFieldsValue(true)));
+      if (msg?.success && Array.isArray(msg.obj)) {
+        setInboundOptions(msg.obj);
+        messageApi.success(t('pages.nodes.inboundsLoaded', { count: msg.obj.length }));
+      } else {
+        messageApi.error(msg?.msg || t('pages.nodes.inboundsLoadFailed'));
+      }
+    } finally {
+      setFetchingInbounds(false);
+    }
+  }
+
   async function onFinish(values: NodeFormValues) {
     const result = NodeFormSchema.safeParse(values);
     if (!result.success) {
@@ -323,6 +356,46 @@ export default function NodeFormModal({
             <Input.Password placeholder={t('pages.nodes.apiTokenPlaceholder')} />
           </Form.Item>
 
+          <Form.Item
+            label={t('pages.nodes.inboundSyncMode')}
+            name="inboundSyncMode"
+            extra={t('pages.nodes.inboundSyncModeHint')}
+          >
+            <Select
+              options={[
+                { value: 'all', label: t('pages.nodes.allInbounds') },
+                { value: 'selected', label: t('pages.nodes.selectedInbounds') },
+              ]}
+            />
+          </Form.Item>
+
+          {inboundSyncMode === 'selected' && (
+            <Form.Item
+              label={t('pages.nodes.inboundTags')}
+              name="inboundTags"
+              extra={t('pages.nodes.inboundTagsHint')}
+            >
+              <Select
+                mode="multiple"
+                allowClear
+                loading={fetchingInbounds}
+                placeholder={t('pages.nodes.inboundTagsPlaceholder')}
+                popupRender={(menu) => (
+                  <>
+                    <Button type="text" block loading={fetchingInbounds} onClick={onFetchInbounds}>
+                      {t('pages.nodes.loadInbounds')}
+                    </Button>
+                    {menu}
+                  </>
+                )}
+                options={inboundOptions.map((inbound) => ({
+                  value: inbound.tag,
+                  label: `${inbound.remark || inbound.tag}${inbound.protocol ? ` (${inbound.protocol}:${inbound.port || 0})` : ''}`,
+                }))}
+              />
+            </Form.Item>
+          )}
+
           <div className="test-row">
             <Button type="default" loading={testing} onClick={onTest}>
               {t('pages.nodes.testConnection')}

+ 2 - 1
frontend/src/pages/nodes/NodesPage.tsx

@@ -30,7 +30,7 @@ export default function NodesPage() {
   useEffect(() => { setMessageInstance(messageApi); }, [messageApi]);
 
   const { nodes, loading, fetched, fetchError, refetch, totals } = useNodesQuery();
-  const { create, update, remove, setEnable, testConnection, fetchFingerprint, probe, updatePanels } = useNodeMutations();
+  const { create, update, remove, setEnable, testConnection, fetchFingerprint, fetchInbounds, probe, updatePanels } = useNodeMutations();
 
   const { data: latestVersion = '' } = useQuery({
     queryKey: ['server', 'panelUpdateInfo'],
@@ -235,6 +235,7 @@ export default function NodesPage() {
           node={formNode}
           testConnection={testConnection}
           fetchFingerprint={fetchFingerprint}
+          fetchInbounds={fetchInbounds}
           save={onSave}
           onOpenChange={setFormOpen}
         />

+ 33 - 6
frontend/src/pages/settings/GeneralTab.tsx

@@ -43,7 +43,8 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
 
   const [lang, setLang] = useState<string>(() => LanguageManager.getLanguage());
   const [inboundOptions, setInboundOptions] = useState<{ label: string; value: string }[]>([]);
-  const [outboundOptions, setOutboundOptions] = useState<{ label: string; value: string }[]>([]);
+  const [outboundTagList, setOutboundTagList] = useState<string[]>([]);
+  const [balancerTagList, setBalancerTagList] = useState<string[]>([]);
 
   useEffect(() => {
     let cancelled = false;
@@ -69,9 +70,11 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
   useEffect(() => {
     let cancelled = false;
     (async () => {
-      // Outbound tags for the panel egress picker: template outbounds plus
-      // subscription-derived outbounds, same candidate set as the geodata
-      // download picker.
+      // Candidates for the panel egress picker: template outbounds plus
+      // subscription-derived outbounds, and routing balancers. The panel egress
+      // is injected as a routing rule, so a balancer tag is a valid target
+      // (it load-balances the panel's own traffic). The geodata picker, by
+      // contrast, dials a forced tag and can only use a concrete outbound.
       const msg = await HttpUtil.post('/panel/api/xray/', undefined, { silent: true }) as ApiMsg<string>;
       if (cancelled || !msg?.success || typeof msg.obj !== 'string') return;
       try {
@@ -90,14 +93,38 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
         for (const tag of subTags) {
           if (typeof tag === 'string' && tag) tags.add(tag);
         }
-        setOutboundOptions([...tags].map((tag) => ({ label: tag, value: tag })));
+        const balancerTags: string[] = [];
+        const routing = (template.routing || {}) as Record<string, unknown>;
+        const balancers = Array.isArray(routing.balancers) ? routing.balancers : [];
+        for (const b of balancers) {
+          if (!b || typeof b !== 'object') continue;
+          const tag = (b as Record<string, unknown>).tag;
+          if (typeof tag === 'string' && tag && !tags.has(tag)) balancerTags.push(tag);
+        }
+        setOutboundTagList([...tags]);
+        setBalancerTagList(balancerTags);
       } catch {
-        setOutboundOptions([]);
+        setOutboundTagList([]);
+        setBalancerTagList([]);
       }
     })();
     return () => { cancelled = true; };
   }, []);
 
+  // Outbound tags and balancer tags share one picker. When balancers exist they
+  // get their own labeled group so it's clear the selection routes through a
+  // balancer rather than a single outbound.
+  const outboundOptions = useMemo<
+    ({ label: string; value: string } | { label: string; options: { label: string; value: string }[] })[]
+  >(() => {
+    const outOpts = outboundTagList.map((tag) => ({ label: tag, value: tag }));
+    if (balancerTagList.length === 0) return outOpts;
+    return [
+      { label: t('pages.xray.Outbounds'), options: outOpts },
+      { label: t('pages.xray.Balancers'), options: balancerTagList.map((tag) => ({ label: tag, value: tag })) },
+    ];
+  }, [outboundTagList, balancerTagList, t]);
+
   const ldapInboundTagList = useMemo(() => {
     const csv = allSetting.ldapInboundTags || '';
     return csv.length ? csv.split(',').map((s) => s.trim()).filter(Boolean) : [];

+ 42 - 26
frontend/src/pages/xray/dns/DnsTab.tsx

@@ -128,6 +128,26 @@ export default function DnsTab({ templateSettings, setTemplateSettings }: DnsTab
     return list.map((server, idx) => ({ key: idx, server }));
   }, [dns?.servers]);
 
+  // Stable callbacks: the column definitions in useDnsServerColumns are
+  // memoized, so they must be able to depend on these (see issue #5155)
+  const openEditServer = useCallback(
+    (idx: number) => {
+      setEditingServer((dns?.servers || [])[idx] || null);
+      setEditingIndex(idx);
+      setServerModalOpen(true);
+    },
+    [dns?.servers],
+  );
+  const deleteServer = useCallback(
+    (idx: number) => {
+      mutate((tt) => {
+        const cfg = tt.dns as DnsConfig | undefined;
+        if (cfg?.servers) cfg.servers.splice(idx, 1);
+      });
+    },
+    [mutate],
+  );
+
   const dnsColumns = useDnsServerColumns({ openEditServer, deleteServer });
 
   function openAddServer() {
@@ -135,11 +155,6 @@ export default function DnsTab({ templateSettings, setTemplateSettings }: DnsTab
     setEditingIndex(null);
     setServerModalOpen(true);
   }
-  function openEditServer(idx: number) {
-    setEditingServer((dns?.servers || [])[idx] || null);
-    setEditingIndex(idx);
-    setServerModalOpen(true);
-  }
   function onServerConfirm(value: DnsServerValue) {
     mutate((tt) => {
       if (!tt.dns) return;
@@ -150,12 +165,6 @@ export default function DnsTab({ templateSettings, setTemplateSettings }: DnsTab
     });
     setServerModalOpen(false);
   }
-  function deleteServer(idx: number) {
-    mutate((tt) => {
-      const cfg = tt.dns as DnsConfig | undefined;
-      if (cfg?.servers) cfg.servers.splice(idx, 1);
-    });
-  }
   function clearAllServers() {
     modal.confirm({
       title: t('pages.xray.dns.clearAllTitle'),
@@ -182,6 +191,28 @@ export default function DnsTab({ templateSettings, setTemplateSettings }: DnsTab
     return list.map((entry, idx) => ({ key: idx, ...entry }));
   }, [templateSettings?.fakedns]);
 
+  const deleteFakedns = useCallback(
+    (idx: number) => {
+      mutate((tt) => {
+        const list = tt.fakedns as FakednsRow[] | undefined;
+        if (!list) return;
+        list.splice(idx, 1);
+        if (list.length === 0) tt.fakedns = null;
+      });
+    },
+    [mutate],
+  );
+  const updateFakednsField = useCallback(
+    (idx: number, field: 'ipPool' | 'poolSize', value: string | number) => {
+      mutate((tt) => {
+        const list = tt.fakedns as FakednsRow[] | undefined;
+        if (!list?.[idx]) return;
+        (list[idx] as unknown as Record<string, unknown>)[field] = value;
+      });
+    },
+    [mutate],
+  );
+
   const fakednsColumns = useFakednsColumns({ deleteFakedns, updateFakednsField });
 
   function addFakedns() {
@@ -190,21 +221,6 @@ export default function DnsTab({ templateSettings, setTemplateSettings }: DnsTab
       (tt.fakedns as FakednsRow[]).push(DEFAULT_FAKEDNS());
     });
   }
-  function deleteFakedns(idx: number) {
-    mutate((tt) => {
-      const list = tt.fakedns as FakednsRow[] | undefined;
-      if (!list) return;
-      list.splice(idx, 1);
-      if (list.length === 0) tt.fakedns = null;
-    });
-  }
-  function updateFakednsField(idx: number, field: 'ipPool' | 'poolSize', value: string | number) {
-    mutate((tt) => {
-      const list = tt.fakedns as FakednsRow[] | undefined;
-      if (!list?.[idx]) return;
-      (list[idx] as unknown as Record<string, unknown>)[field] = value;
-    });
-  }
 
   const items = useMemo(() => {
     const out = [

+ 2 - 4
frontend/src/pages/xray/dns/useDnsColumns.tsx

@@ -61,8 +61,7 @@ export function useDnsServerColumns({
         render: (_v, record) => <span className="muted">{expectedIPsFor(record.server)}</span>,
       },
     ],
-    // eslint-disable-next-line react-hooks/exhaustive-deps
-    [t],
+    [t, openEditServer, deleteServer],
   );
 }
 
@@ -116,7 +115,6 @@ export function useFakednsColumns({
         ),
       },
     ],
-    // eslint-disable-next-line react-hooks/exhaustive-deps
-    [],
+    [deleteFakedns, updateFakednsField],
   );
 }

+ 1 - 1
frontend/src/pages/xray/outbounds/outbound-form-helpers.ts

@@ -45,7 +45,7 @@ export function newStreamSlice(network: string): Record<string, unknown> {
         network: 'xhttp',
         xhttpSettings: {
           path: '/', host: '', mode: '', headers: [],
-          xPaddingBytes: '100-1000', scMaxEachPostBytes: '1000000',
+          xPaddingBytes: '100-1000',
         },
       };
     case 'hysteria':

+ 1 - 1
frontend/src/pages/xray/outbounds/transport/hysteria.tsx

@@ -25,7 +25,7 @@ export default function HysteriaForm({ form }: { form: FormInstance }) {
         label={t('pages.inbounds.form.udpIdleTimeout')}
         name={['streamSettings', 'hysteriaSettings', 'udpIdleTimeout']}
       >
-        <InputNumber min={1} style={{ width: '100%' }} />
+        <InputNumber min={2} max={600} style={{ width: '100%' }} />
       </Form.Item>
 
       <Form.Item label={t('pages.inbounds.form.masquerade')}>

+ 4 - 1
frontend/src/pages/xray/overrides/NordModal.tsx

@@ -209,7 +209,10 @@ export default function NordModal({
         secretKey: nordData?.private_key,
         address: ['10.5.0.2/32'],
         peers: [{ publicKey, endpoint: `${server.station}:51820` }],
-        noKernelTun: false,
+        // Userspace TUN — same reasoning as the WARP outbound (#5205): kernel
+        // TUN fails silently on many VPS setups and diverges from the data
+        // path the panel's connectivity test exercises.
+        noKernelTun: true,
       },
     };
   }

+ 10 - 2
frontend/src/pages/xray/overrides/WarpModal.tsx

@@ -103,9 +103,17 @@ export default function WarpModal({
           secretKey: data?.private_key,
           address: addressesFor(cfg.interface?.addresses || {}),
           reserved: reservedFor(cfg.client_id ?? data?.client_id),
-          domainStrategy: 'ForceIP',
+          // Prefer IPv4 with IPv6 fallback: plain ForceIP may pick the AAAA
+          // record for engage.cloudflareclient.com, and a host with
+          // half-configured IPv6 then blackholes the handshake with no error
+          // logged (#5205).
+          domainStrategy: 'ForceIPv4v6',
           peers: [{ publicKey: peer.public_key, endpoint: peer.endpoint?.host }],
-          noKernelTun: false,
+          // Userspace TUN: kernel TUN needs CAP_NET_ADMIN + fwmark routing and
+          // fails silently on many VPS setups, and it is a different data path
+          // than the panel's connectivity test (which always probes with
+          // noKernelTun=true), so "test ok" and "traffic flows" can disagree.
+          noKernelTun: true,
         },
       };
       setStagedOutbound(outbound);

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

+ 1 - 0
frontend/src/schemas/defaults.ts

@@ -15,6 +15,7 @@ export const DefaultsPayloadSchema = z.object({
   remarkModel: z.string().optional(),
   datepicker: z.enum(['gregorian', 'jalalian']).optional(),
   ipLimitEnable: z.boolean().optional(),
+  accessLogEnable: z.boolean().optional(),
   webDomain: z.string().optional(),
   subDomain: z.string().optional(),
 }).loose();

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

+ 7 - 0
frontend/src/schemas/node.ts

@@ -31,6 +31,9 @@ export const NodeRecordSchema = z.object({
   allowPrivateAddress: z.boolean().optional(),
   tlsVerifyMode: z.enum(['verify', 'skip', 'pin']).optional(),
   pinnedCertSha256: z.string().optional(),
+  inboundSyncMode: z.enum(['all', 'selected']).optional(),
+  // Backend serializes a nil []string as null for nodes saved before #5178.
+  inboundTags: z.array(z.string()).nullish(),
   // Multi-hop node tree (#4983): a node's stable GUID, its parent's GUID, and
   // whether it's a read-only transitive sub-node surfaced from a downstream node.
   guid: z.string().optional(),
@@ -63,6 +66,10 @@ export const NodeFormSchema = z.object({
   allowPrivateAddress: z.boolean(),
   tlsVerifyMode: z.enum(['verify', 'skip', 'pin']),
   pinnedCertSha256: z.string().optional().default(''),
+  inboundSyncMode: z.enum(['all', 'selected']).optional().default('all'),
+  // Unmounted when sync mode is "all" (absent from antd onFinish values) and
+  // serialized as null by the backend for a nil slice — tolerate both.
+  inboundTags: z.array(z.string()).nullish().transform((tags) => tags ?? []),
 });
 
 export type NodeRecord = z.infer<typeof NodeRecordSchema>;

+ 2 - 1
frontend/src/schemas/protocols/stream/hysteria.ts

@@ -25,7 +25,8 @@ export type HysteriaMasquerade = z.infer<typeof HysteriaMasqueradeSchema>;
 export const HysteriaStreamSettingsSchema = z.object({
   version: z.literal(2).default(2),
   auth: z.string().default(''),
-  udpIdleTimeout: z.number().int().min(1).default(60),
+  // Xray-core rejects values outside 2-600 seconds at startup.
+  udpIdleTimeout: z.number().int().min(2).max(600).default(60),
   masquerade: HysteriaMasqueradeSchema.optional(),
 });
 export type HysteriaStreamSettings = z.infer<typeof HysteriaStreamSettingsSchema>;

+ 5 - 2
frontend/src/schemas/protocols/stream/xhttp.ts

@@ -41,7 +41,10 @@ export const XHttpStreamSettingsSchema = z.object({
   seqKey: z.string().default(''),
   uplinkDataPlacement: z.string().default(''),
   uplinkDataKey: z.string().default(''),
-  scMaxEachPostBytes: z.string().default('1000000'),
+  // Empty default on purpose: xray-core already defaults to 1MB/30ms, and
+  // baking the literal values into every config and share link gives DPI a
+  // stable fingerprint (#5141 — TSPU keys on scMinPostsIntervalMs=30).
+  scMaxEachPostBytes: z.string().default(''),
   noSSEHeader: z.boolean().default(false),
   scMaxBufferedPosts: z.number().int().min(0).default(30),
   scStreamUpServerSecs: z.string().default('20-80'),
@@ -51,7 +54,7 @@ export const XHttpStreamSettingsSchema = z.object({
   // Outbound-only fields. Server (inbound) listener ignores these. The
   // panel embeds them in share-link `extra` blobs so the same xhttp
   // config can roundtrip on both sides.
-  scMinPostsIntervalMs: z.string().default('30'),
+  scMinPostsIntervalMs: z.string().default(''),
   uplinkChunkSize: z.number().int().min(0).default(0),
   noGRPCHeader: z.boolean().default(false),
   xmux: XHttpXmuxSchema.optional(),

+ 7 - 0
frontend/src/styles/page-shell.css

@@ -89,6 +89,13 @@
   min-height: calc(100vh - 120px);
 }
 
+/* Tall action menus (e.g. the inbound/client context menus) must stay inside
+   the viewport even when antd flips them upward near the screen edge. */
+.ant-dropdown .ant-dropdown-menu {
+  max-height: calc(100vh - 32px);
+  overflow-y: auto;
+}
+
 .ant-dropdown-menu-item:not(.ant-dropdown-menu-item-disabled):not(.ant-dropdown-menu-item-danger):hover,
 .ant-dropdown-menu-item:not(.ant-dropdown-menu-item-disabled):not(.ant-dropdown-menu-item-danger):hover .ant-dropdown-menu-title-content,
 .ant-dropdown-menu-item:not(.ant-dropdown-menu-item-disabled):not(.ant-dropdown-menu-item-danger):hover > .anticon {

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

+ 6 - 6
frontend/src/test/__snapshots__/stream.test.ts.snap

@@ -47,8 +47,8 @@ exports[`NetworkSettingsSchema fixtures > parses xhttp-basic byte-stably 1`] = `
     "noSSEHeader": false,
     "path": "/sp",
     "scMaxBufferedPosts": 30,
-    "scMaxEachPostBytes": "1000000",
-    "scMinPostsIntervalMs": "30",
+    "scMaxEachPostBytes": "",
+    "scMinPostsIntervalMs": "",
     "scStreamUpServerSecs": "20-80",
     "seqKey": "",
     "seqPlacement": "",
@@ -81,8 +81,8 @@ exports[`NetworkSettingsSchema fixtures > parses xhttp-extra-padding byte-stably
     "noSSEHeader": false,
     "path": "/sp",
     "scMaxBufferedPosts": 30,
-    "scMaxEachPostBytes": "1000000",
-    "scMinPostsIntervalMs": "30",
+    "scMaxEachPostBytes": "",
+    "scMinPostsIntervalMs": "",
     "scStreamUpServerSecs": "20-80",
     "seqKey": "",
     "seqPlacement": "",
@@ -115,8 +115,8 @@ exports[`NetworkSettingsSchema fixtures > parses xhttp-extra-placement byte-stab
     "noSSEHeader": false,
     "path": "/sp",
     "scMaxBufferedPosts": 30,
-    "scMaxEachPostBytes": "1000000",
-    "scMinPostsIntervalMs": "30",
+    "scMaxEachPostBytes": "",
+    "scMinPostsIntervalMs": "",
     "scStreamUpServerSecs": "20-80",
     "seqKey": "X-Seq",
     "seqPlacement": "cookie",

+ 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

+ 14 - 2
install.sh

@@ -56,6 +56,18 @@ is_domain() {
     [[ "$1" =~ ^([A-Za-z0-9](-*[A-Za-z0-9])*\.)+(xn--[a-z0-9]{2,}|[A-Za-z]{2,})$ ]] && return 0 || return 1
 }
 
+# acme.sh's standalone server binds IPv4 by default; --listen-v6 makes it
+# v6-only, which breaks HTTP-01 validation when the domain's A record points
+# at this host's IPv4 (#4994). Only force IPv6 when the host has no global
+# IPv4 address at all.
+acme_listen_flag() {
+    if ip -4 addr show scope global 2> /dev/null | grep -q "inet "; then
+        echo ""
+    else
+        echo "--listen-v6"
+    fi
+}
+
 # Port helpers
 is_port_in_use() {
     local port="$1"
@@ -292,7 +304,7 @@ setup_ssl_certificate() {
     echo -e "${yellow}Note: Port 80 must be open and accessible from the internet${plain}"
 
     ~/.acme.sh/acme.sh --set-default-ca --server letsencrypt --force > /dev/null 2>&1
-    ~/.acme.sh/acme.sh --issue -d ${domain} --listen-v6 --standalone --httpport 80 --force
+    ~/.acme.sh/acme.sh --issue -d ${domain} $(acme_listen_flag) --standalone --httpport 80 --force
 
     if [ $? -ne 0 ]; then
         echo -e "${yellow}Failed to issue certificate for ${domain}${plain}"
@@ -576,7 +588,7 @@ ssl_cert_issue() {
     if [[ ${cert_exists} -eq 0 ]]; then
         # issue the certificate
         ~/.acme.sh/acme.sh --set-default-ca --server letsencrypt --force
-        ~/.acme.sh/acme.sh --issue -d ${domain} --listen-v6 --standalone --httpport ${WebPort} --force
+        ~/.acme.sh/acme.sh --issue -d ${domain} $(acme_listen_flag) --standalone --httpport ${WebPort} --force
         if [ $? -ne 0 ]; then
             echo -e "${red}Issuing certificate failed, please check logs.${plain}"
             rm -rf ~/.acme.sh/${domain} ~/.acme.sh/${domain}_ecc

+ 24 - 20
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
@@ -445,18 +447,20 @@ type Setting struct {
 // endpoint over HTTP using the per-node ApiToken to populate the runtime
 // status fields below.
 type Node struct {
-	Id                  int    `json:"id" form:"id" gorm:"primaryKey;autoIncrement" example:"1"`
-	Name                string `json:"name" form:"name" gorm:"uniqueIndex" validate:"required" example:"de-fra-1"`
-	Remark              string `json:"remark" form:"remark"`
-	Scheme              string `json:"scheme" form:"scheme" validate:"omitempty,oneof=http https" example:"https"`
-	Address             string `json:"address" form:"address" validate:"required" example:"node1.example.com"`
-	Port                int    `json:"port" form:"port" validate:"gte=1,lte=65535" example:"2053"`
-	BasePath            string `json:"basePath" form:"basePath" example:"/"`
-	ApiToken            string `json:"apiToken" form:"apiToken" validate:"required" example:"abcdef0123456789"`
-	Enable              bool   `json:"enable" form:"enable" gorm:"default:true" example:"true"`
-	AllowPrivateAddress bool   `json:"allowPrivateAddress" form:"allowPrivateAddress" gorm:"default:false"`
-	TlsVerifyMode       string `json:"tlsVerifyMode" form:"tlsVerifyMode" gorm:"column:tls_verify_mode;default:verify" validate:"omitempty,oneof=verify skip pin"`
-	PinnedCertSha256    string `json:"pinnedCertSha256" form:"pinnedCertSha256" gorm:"column:pinned_cert_sha256"`
+	Id                  int      `json:"id" form:"id" gorm:"primaryKey;autoIncrement" example:"1"`
+	Name                string   `json:"name" form:"name" gorm:"uniqueIndex" validate:"required" example:"de-fra-1"`
+	Remark              string   `json:"remark" form:"remark"`
+	Scheme              string   `json:"scheme" form:"scheme" validate:"omitempty,oneof=http https" example:"https"`
+	Address             string   `json:"address" form:"address" validate:"required" example:"node1.example.com"`
+	Port                int      `json:"port" form:"port" validate:"gte=1,lte=65535" example:"2053"`
+	BasePath            string   `json:"basePath" form:"basePath" example:"/"`
+	ApiToken            string   `json:"apiToken" form:"apiToken" validate:"required" example:"abcdef0123456789"`
+	Enable              bool     `json:"enable" form:"enable" gorm:"default:true" example:"true"`
+	AllowPrivateAddress bool     `json:"allowPrivateAddress" form:"allowPrivateAddress" gorm:"default:false"`
+	TlsVerifyMode       string   `json:"tlsVerifyMode" form:"tlsVerifyMode" gorm:"column:tls_verify_mode;default:verify" validate:"omitempty,oneof=verify skip pin"`
+	PinnedCertSha256    string   `json:"pinnedCertSha256" form:"pinnedCertSha256" gorm:"column:pinned_cert_sha256"`
+	InboundSyncMode     string   `json:"inboundSyncMode" form:"inboundSyncMode" gorm:"column:inbound_sync_mode;default:all" validate:"omitempty,oneof=all selected"`
+	InboundTags         []string `json:"inboundTags" form:"inboundTags" gorm:"serializer:json;column:inbound_tags"`
 
 	// Guid is the remote panel's stable self-identifier (its panelGuid),
 	// learned from each heartbeat. It is the globally stable node identity used

+ 48 - 0
internal/sub/build_urls_test.go

@@ -69,3 +69,51 @@ func TestBuildURLs_EmptySubId(t *testing.T) {
 		t.Fatalf("empty subId must yield empty URLs, got %q %q %q", a, b, c)
 	}
 }
+
+// A subscriber arriving via a reverse proxy (subURI configured with full
+// HTTPS URL) must see the same scheme+host in the JSON and Clash Copy
+// URLs as in the main subURL — not the raw sub-server port 2096.
+func TestBuildURLs_DerivesJsonFromConfiguredSubURI(t *testing.T) {
+	initSubDB(t)
+	s := &SubService{}
+	s.PrepareForRequest("sub.example.com")
+
+	// Simulate the admin having set subURI (reverse-proxy setup).
+	database.GetDB().Exec(
+		"INSERT INTO settings (key, value) VALUES (?, ?)",
+		"subURI", "https://example.com/sub-xxx/")
+
+	subURL, jsonURL, clashURL := s.BuildURLs("/sub-xxx/", "/json/", "/clash/", "ABC")
+
+	if subURL != "https://example.com/sub-xxx/ABC" {
+		t.Fatalf("subURL = %q", subURL)
+	}
+	if jsonURL != "https://example.com/json/ABC" {
+		t.Fatalf("jsonURL = %q (should derive scheme+host from subURI), want %q", jsonURL, "https://example.com/json/ABC")
+	}
+	if clashURL != "https://example.com/clash/ABC" {
+		t.Fatalf("clashURL = %q (should derive scheme+host from subURI), want %q", clashURL, "https://example.com/clash/ABC")
+	}
+}
+
+// A malformed subURI (no scheme/host) must not leak a broken base into the
+// JSON/Clash URLs; BuildURLs should fall back to the request-derived base.
+func TestBuildURLs_MalformedSubURIFallsBackToRequestBase(t *testing.T) {
+	initSubDB(t)
+	s := &SubService{}
+	s.PrepareForRequest("sub.example.com")
+
+	// A value with no scheme can't yield a usable scheme+host.
+	database.GetDB().Exec(
+		"INSERT INTO settings (key, value) VALUES (?, ?)",
+		"subURI", "example.com/sub-xxx/")
+
+	_, jsonURL, clashURL := s.BuildURLs("/sub-xxx/", "/json/", "/clash/", "ABC")
+
+	if jsonURL != "http://sub.example.com:2096/json/ABC" {
+		t.Fatalf("jsonURL = %q, want fallback to request base %q", jsonURL, "http://sub.example.com:2096/json/ABC")
+	}
+	if clashURL != "http://sub.example.com:2096/clash/ABC" {
+		t.Fatalf("clashURL = %q, want fallback to request base %q", clashURL, "http://sub.example.com:2096/clash/ABC")
+	}
+}

+ 7 - 15
internal/sub/clash_service.go

@@ -9,15 +9,12 @@ import (
 	yaml "github.com/goccy/go-yaml"
 
 	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
-	"github.com/mhsanaei/3x-ui/v3/internal/logger"
-	"github.com/mhsanaei/3x-ui/v3/internal/web/service"
 )
 
 type SubClashService struct {
-	inboundService service.InboundService
-	enableRouting  bool
-	clashRules     string
-	SubService     *SubService
+	enableRouting bool
+	clashRules    string
+	SubService    *SubService
 }
 
 func NewSubClashService(enableRouting bool, clashRules string, subService *SubService) *SubClashService {
@@ -36,19 +33,14 @@ func (s *SubClashService) GetClash(subId string, host string) (string, string, e
 
 	seenEmails := make(map[string]struct{})
 	for _, inbound := range inbounds {
-		clients, err := s.inboundService.GetClients(inbound)
-		if err != nil {
-			logger.Error("SubClashService - GetClients: Unable to get clients from inbound")
-		}
-		if clients == nil {
+		clients := s.SubService.matchingClients(inbound, subId)
+		if len(clients) == 0 {
 			continue
 		}
 		s.SubService.projectThroughFallbackMaster(inbound)
 		for _, client := range clients {
-			if client.SubID == subId {
-				seenEmails[client.Email] = struct{}{}
-				proxies = append(proxies, s.getProxies(inbound, client, host)...)
-			}
+			seenEmails[client.Email] = struct{}{}
+			proxies = append(proxies, s.getProxies(inbound, client, host)...)
 		}
 	}
 

+ 14 - 13
internal/sub/json_service.go

@@ -8,10 +8,8 @@ import (
 	"strings"
 
 	"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/json_util"
 	"github.com/mhsanaei/3x-ui/v3/internal/util/random"
-	"github.com/mhsanaei/3x-ui/v3/internal/web/service"
 )
 
 //go:embed default.json
@@ -24,8 +22,7 @@ type SubJsonService struct {
 	finalMask        string
 	mux              string
 
-	inboundService service.InboundService
-	SubService     *SubService
+	SubService *SubService
 }
 
 // NewSubJsonService creates a new JSON subscription service with the given configuration.
@@ -75,20 +72,15 @@ func (s *SubJsonService) GetJson(subId string, host string) (string, string, err
 	seenEmails := make(map[string]struct{})
 	// Prepare Inbounds
 	for _, inbound := range inbounds {
-		clients, err := s.inboundService.GetClients(inbound)
-		if err != nil {
-			logger.Error("SubJsonService - GetClients: Unable to get clients from inbound")
-		}
-		if clients == nil {
+		clients := s.SubService.matchingClients(inbound, subId)
+		if len(clients) == 0 {
 			continue
 		}
 		s.SubService.projectThroughFallbackMaster(inbound)
 
 		for _, client := range clients {
-			if client.SubID == subId {
-				seenEmails[client.Email] = struct{}{}
-				configArray = append(configArray, s.getConfig(inbound, client, host)...)
-			}
+			seenEmails[client.Email] = struct{}{}
+			configArray = append(configArray, s.getConfig(inbound, client, host)...)
 		}
 	}
 
@@ -225,6 +217,15 @@ func (s *SubJsonService) streamData(stream string) map[string]any {
 			delete(xhttp, "scMaxBufferedPosts")
 			delete(xhttp, "scStreamUpServerSecs")
 			delete(xhttp, "serverMaxHeaderBytes")
+			// Values matching xray-core's own defaults stay off the wire:
+			// old panels seeded them into every stored config and the
+			// literal scMinPostsIntervalMs=30 is a DPI fingerprint (#5141).
+			if v, _ := xhttp["scMaxEachPostBytes"].(string); v == "" || v == "1000000" {
+				delete(xhttp, "scMaxEachPostBytes")
+			}
+			if v, _ := xhttp["scMinPostsIntervalMs"].(string); v == "" || v == "30" {
+				delete(xhttp, "scMinPostsIntervalMs")
+			}
 		}
 	}
 	return streamSettings

+ 116 - 26
internal/sub/service.go

@@ -106,6 +106,36 @@ func listenIsInternalOnly(listen string) bool {
 	return isLoopbackHost(listen)
 }
 
+// matchingClients returns the inbound's clients whose SubID equals subId,
+// deduplicated by email. settings.clients can accumulate duplicate entries
+// for the same client (multi-node sync/import drift, old DBs): SyncInbound
+// dedupes the normalized client_inbounds rows on write but never rewrites
+// the legacy JSON, and the subscription builders iterate that JSON — so
+// without this guard every duplicate became a duplicate profile in the
+// output (#5134). Link generation keys purely on (inbound, email), so
+// same-email entries are pure duplicates and dropping them is lossless.
+func (s *SubService) matchingClients(inbound *model.Inbound, subId string) []model.Client {
+	clients, err := s.inboundService.GetClients(inbound)
+	if err != nil {
+		logger.Error("SubService - GetClients: Unable to get clients from inbound")
+		return nil
+	}
+	var out []model.Client
+	seen := make(map[string]struct{}, len(clients))
+	for _, client := range clients {
+		if client.SubID != subId {
+			continue
+		}
+		key := strings.ToLower(client.Email)
+		if _, dup := seen[key]; dup {
+			continue
+		}
+		seen[key] = struct{}{}
+		out = append(out, client)
+	}
+	return out
+}
+
 // GetSubs retrieves subscription links for a given subscription ID and host.
 func (s *SubService) GetSubs(subId string, host string) ([]string, []string, int64, xray.ClientTraffic, error) {
 	s.PrepareForRequest(host)
@@ -134,23 +164,18 @@ func (s *SubService) GetSubs(subId string, host string) ([]string, []string, int
 
 	seenEmails := make(map[string]struct{})
 	for _, inbound := range inbounds {
-		clients, err := s.inboundService.GetClients(inbound)
-		if err != nil {
-			logger.Error("SubService - GetClients: Unable to get clients from inbound")
-		}
-		if clients == nil {
+		clients := s.matchingClients(inbound, subId)
+		if len(clients) == 0 {
 			continue
 		}
 		s.projectThroughFallbackMaster(inbound)
 		for _, client := range clients {
-			if client.SubID == subId {
-				if client.Enable {
-					hasEnabledClient = true
-				}
-				result = append(result, s.GetLink(inbound, client.Email))
-				emails = append(emails, client.Email)
-				seenEmails[client.Email] = struct{}{}
+			if client.Enable {
+				hasEnabledClient = true
 			}
+			result = append(result, s.GetLink(inbound, client.Email))
+			emails = append(emails, client.Email)
+			seenEmails[client.Email] = struct{}{}
 		}
 	}
 
@@ -788,23 +813,42 @@ func (s *SubService) loadNodes() {
 	s.nodesByID = m
 }
 
-// resolveInboundAddress picks the host an external client should connect to:
-//  1. node-managed inbound -> the node's address
-//  2. an explicit, client-reachable bind Listen -> that Listen
-//  3. otherwise the subscriber's request host (s.address)
+// resolveInboundAddress picks the host an external client should connect to,
+// honoring the inbound's share address strategy the same way the panel's
+// share/QR link builder does (#5208):
+//   - "listen": an explicit, client-reachable bind Listen wins, backed by the
+//     node's address for node-managed inbounds;
+//   - "custom": the inbound's ShareAddr wins, then node, then listen;
+//   - "node" (default, and any unknown value): the node's address for
+//     node-managed inbounds, then a routable Listen — the pre-strategy order.
 //
-// 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.
+// Every chain ends at the subscriber's request host (s.address). A
+// loopback/wildcard bind or a unix-domain-socket listen is a server-side
+// detail and is never advertised; External Proxy still overrides everything
+// upstream of this call.
 func (s *SubService) resolveInboundAddress(inbound *model.Inbound) string {
+	var nodeAddr string
 	if inbound.NodeID != nil && s.nodesByID != nil {
-		if n, ok := s.nodesByID[*inbound.NodeID]; ok && n.Address != "" {
-			return n.Address
+		if n, ok := s.nodesByID[*inbound.NodeID]; ok {
+			nodeAddr = n.Address
 		}
 	}
+	var listenAddr string
 	if listen := inbound.Listen; listen != "" && listen[0] != '@' && listen[0] != '/' && isRoutableHost(listen) {
-		return listen
+		listenAddr = listen
+	}
+
+	candidates := []string{nodeAddr, listenAddr}
+	switch inbound.ShareAddrStrategy {
+	case "listen":
+		candidates = []string{listenAddr, nodeAddr}
+	case "custom":
+		candidates = []string{strings.TrimSpace(inbound.ShareAddr), nodeAddr, listenAddr}
+	}
+	for _, c := range candidates {
+		if c != "" {
+			return c
+		}
 	}
 	return s.address
 }
@@ -1475,6 +1519,19 @@ func (s *SubService) genRemark(inbound *model.Inbound, email string, extra strin
 	if len(extra) > 0 {
 		orders['o'] = extra
 	}
+	// A node-hosted inbound usually shares its remark with the local copy it
+	// was synced from, so a multi-node subscription would list several
+	// identically-named entries differing only by address (#5035). Tag such
+	// entries with the node name unless the admin already put it in the remark.
+	if inbound.NodeID != nil && s.nodesByID != nil {
+		if n, ok := s.nodesByID[*inbound.NodeID]; ok && n != nil && n.Name != "" && !strings.Contains(orders['i'], n.Name) {
+			if orders['i'] != "" {
+				orders['i'] += "@" + n.Name
+			} else {
+				orders['i'] = n.Name
+			}
+		}
+	}
 
 	var remark []string
 	for i := 0; i < len(orderChars); i++ {
@@ -1604,8 +1661,16 @@ func buildXhttpExtra(xhttp map[string]any) map[string]any {
 		"uplinkDataPlacement", "uplinkDataKey",
 		"scMaxEachPostBytes", "scMinPostsIntervalMs",
 	}
+	// Values matching xray-core's own defaults are redundant on the wire and
+	// the literal scMinPostsIntervalMs=30 is a known DPI fingerprint (#5141).
+	// Old panels seeded these defaults into every xhttp inbound, so filter
+	// them here instead of requiring every stored config to be re-saved.
+	coreDefaults := map[string]string{
+		"scMaxEachPostBytes":   "1000000",
+		"scMinPostsIntervalMs": "30",
+	}
 	for _, field := range stringFields {
-		if v, ok := xhttp[field].(string); ok && len(v) > 0 {
+		if v, ok := xhttp[field].(string); ok && len(v) > 0 && v != coreDefaults[field] {
 			extra[field] = v
 		}
 	}
@@ -2123,12 +2188,37 @@ func (s *SubService) BuildURLs(subPath, subJsonPath, subClashPath, subId string)
 	base := s.settingService.BuildSubURIBase(s.address)
 
 	subURL = s.buildSingleURL(configuredSubURI, base, subPath, subId)
-	subJsonURL = s.buildSingleURL(configuredSubJsonURI, base, subJsonPath, subId)
-	subClashURL = s.buildSingleURL(configuredSubClashURI, base, subClashPath, subId)
+
+	// When subURI is explicitly configured (reverse-proxy setup), use its
+	// scheme+host as the base for JSON and Clash URLs so they match the
+	// reverse-proxy endpoint instead of the raw sub-server port. Fall back
+	// to the request-derived base if subURI is empty or can't be parsed
+	// into a scheme+host (e.g. a malformed value with no scheme).
+	jsonClashBase := base
+	if configuredSubURI != "" {
+		if derived := s.extractBaseFromURI(configuredSubURI); derived != "" {
+			jsonClashBase = derived
+		}
+	}
+
+	subJsonURL = s.buildSingleURL(configuredSubJsonURI, jsonClashBase, subJsonPath, subId)
+	subClashURL = s.buildSingleURL(configuredSubClashURI, jsonClashBase, subClashPath, subId)
 
 	return subURL, subJsonURL, subClashURL
 }
 
+// extractBaseFromURI extracts scheme://host from a configured URI.
+// e.g., "https://example.com/sub-xxx/" → "https://example.com".
+// Returns "" when the URI is empty or lacks a scheme/host, so callers can
+// fall back to the request-derived base instead of emitting a broken value.
+func (s *SubService) extractBaseFromURI(uri string) string {
+	u, err := url.Parse(uri)
+	if err != nil || u.Scheme == "" || u.Host == "" {
+		return ""
+	}
+	return fmt.Sprintf("%s://%s", u.Scheme, u.Host)
+}
+
 // buildSingleURL constructs a single URL using configured URI or base components
 func (s *SubService) buildSingleURL(configuredURI, base, basePath, subId string) string {
 	if configuredURI != "" {

+ 65 - 0
internal/sub/service_dedup_test.go

@@ -0,0 +1,65 @@
+package sub
+
+import (
+	"fmt"
+	"path/filepath"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+)
+
+// TestGetSubs_DuplicateSettingsClients_Deduped reproduces #5134: multi-node
+// sync/import drift can leave the same client twice inside an inbound's
+// legacy settings.clients JSON while the normalized client_inbounds table
+// stays clean. The subscription output must still contain one profile per
+// (inbound, client).
+func TestGetSubs_DuplicateSettingsClients_Deduped(t *testing.T) {
+	dbDir := t.TempDir()
+	t.Setenv("XUI_DB_FOLDER", dbDir)
+	if err := database.InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil {
+		t.Fatalf("InitDB: %v", err)
+	}
+	t.Cleanup(func() { _ = database.CloseDB() })
+
+	const subId = "sub-dup"
+	const email = "[email protected]"
+	const uuid = "f1b9265f-26a8-4b75-9be2-c64a94b15de1"
+
+	db := database.GetDB()
+	settings := fmt.Sprintf(`{"clients": [
+		{"id": %q, "email": %q, "subId": %q, "enable": true},
+		{"id": %q, "email": %q, "subId": %q, "enable": true}
+	]}`, uuid, email, subId, uuid, email, subId)
+	ib := &model.Inbound{
+		UserId:         1,
+		Tag:            "dup-in",
+		Enable:         true,
+		Port:           42001,
+		Protocol:       model.VLESS,
+		Settings:       settings,
+		StreamSettings: `{"network": "tcp", "security": "none"}`,
+	}
+	if err := db.Create(ib).Error; err != nil {
+		t.Fatalf("seed inbound: %v", err)
+	}
+	client := &model.ClientRecord{Email: email, SubID: subId, UUID: uuid, Enable: true}
+	if err := db.Create(client).Error; err != nil {
+		t.Fatalf("seed client: %v", err)
+	}
+	if err := db.Create(&model.ClientInbound{ClientId: client.Id, InboundId: ib.Id}).Error; err != nil {
+		t.Fatalf("seed client_inbound: %v", err)
+	}
+
+	s := NewSubService(false, "-ieo")
+	links, emails, _, _, err := s.GetSubs(subId, "sub.example.com")
+	if err != nil {
+		t.Fatalf("GetSubs: %v", err)
+	}
+	if len(links) != 1 {
+		t.Fatalf("links = %d, want 1 (duplicate settings.clients entries must collapse)", len(links))
+	}
+	if len(emails) != 1 {
+		t.Fatalf("emails = %d, want 1, got %v", len(emails), emails)
+	}
+}

+ 62 - 0
internal/sub/service_test.go

@@ -127,6 +127,68 @@ func TestResolveInboundAddress(t *testing.T) {
 			t.Fatalf("unknown-node address = %q, want subscriber host %q", got, reqHost)
 		}
 	})
+
+	// Per-inbound share address strategy (#5208): subscriptions follow the
+	// same order as the panel's share/QR links.
+	t.Run("listen strategy prefers the bind over the node address", func(t *testing.T) {
+		id := 7
+		s := &SubService{
+			address:   reqHost,
+			nodesByID: map[int]*model.Node{7: {Id: 7, Address: "node7.example.com"}},
+		}
+		ib := &model.Inbound{NodeID: &id, Listen: "203.0.113.7", ShareAddrStrategy: "listen"}
+		if got := s.resolveInboundAddress(ib); got != "203.0.113.7" {
+			t.Fatalf("listen-strategy address = %q, want the bind 203.0.113.7", got)
+		}
+	})
+
+	t.Run("listen strategy falls back to node address on a wildcard bind", func(t *testing.T) {
+		id := 7
+		s := &SubService{
+			address:   reqHost,
+			nodesByID: map[int]*model.Node{7: {Id: 7, Address: "node7.example.com"}},
+		}
+		ib := &model.Inbound{NodeID: &id, Listen: "0.0.0.0", ShareAddrStrategy: "listen"}
+		if got := s.resolveInboundAddress(ib); got != "node7.example.com" {
+			t.Fatalf("listen-strategy wildcard address = %q, want node7.example.com", got)
+		}
+	})
+
+	t.Run("custom strategy uses the share address", func(t *testing.T) {
+		id := 7
+		s := &SubService{
+			address:   reqHost,
+			nodesByID: map[int]*model.Node{7: {Id: 7, Address: "node7.example.com"}},
+		}
+		ib := &model.Inbound{NodeID: &id, Listen: "203.0.113.7", ShareAddrStrategy: "custom", ShareAddr: "edge.example.com"}
+		if got := s.resolveInboundAddress(ib); got != "edge.example.com" {
+			t.Fatalf("custom-strategy address = %q, want edge.example.com", got)
+		}
+	})
+
+	t.Run("custom strategy with empty share address falls back to node", func(t *testing.T) {
+		id := 7
+		s := &SubService{
+			address:   reqHost,
+			nodesByID: map[int]*model.Node{7: {Id: 7, Address: "node7.example.com"}},
+		}
+		ib := &model.Inbound{NodeID: &id, ShareAddrStrategy: "custom"}
+		if got := s.resolveInboundAddress(ib); got != "node7.example.com" {
+			t.Fatalf("custom-strategy fallback address = %q, want node7.example.com", got)
+		}
+	})
+
+	t.Run("node strategy keeps the pre-strategy order", func(t *testing.T) {
+		id := 7
+		s := &SubService{
+			address:   reqHost,
+			nodesByID: map[int]*model.Node{7: {Id: 7, Address: "node7.example.com"}},
+		}
+		ib := &model.Inbound{NodeID: &id, Listen: "203.0.113.7", ShareAddrStrategy: "node"}
+		if got := s.resolveInboundAddress(ib); got != "node7.example.com" {
+			t.Fatalf("node-strategy address = %q, want node7.example.com", got)
+		}
+	})
 }
 
 func TestUnmarshalStreamSettings(t *testing.T) {

+ 3 - 1
internal/util/link/outbound.go

@@ -545,9 +545,11 @@ func buildStream(network, security string) map[string]any {
 	case "httpupgrade":
 		stream["httpupgradeSettings"] = map[string]any{"path": "/", "host": "", "headers": map[string]any{}}
 	case "xhttp":
+		// No scMaxEachPostBytes/scMinPostsIntervalMs seed: xray-core's own
+		// defaults apply, and the literal values fingerprint traffic (#5141).
 		stream["xhttpSettings"] = map[string]any{
 			"path": "/", "host": "", "mode": "auto", "headers": map[string]any{},
-			"xPaddingBytes": "100-1000", "scMaxEachPostBytes": "1000000",
+			"xPaddingBytes": "100-1000",
 		}
 	default:
 		stream["tcpSettings"] = map[string]any{"header": map[string]any{"type": "none"}}

+ 13 - 0
internal/web/controller/node.go

@@ -37,6 +37,7 @@ func (a *NodeController) initRouter(g *gin.RouterGroup) {
 
 	g.POST("/test", a.test)
 	g.POST("/certFingerprint", a.certFingerprint)
+	g.POST("/inbounds", a.inbounds)
 	g.POST("/probe/:id", a.probe)
 	g.POST("/updatePanel", a.updatePanel)
 	g.GET("/history/:id/:metric/:bucket", a.history)
@@ -160,6 +161,18 @@ func (a *NodeController) setEnable(c *gin.Context) {
 	jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.update"), nil)
 }
 
+func (a *NodeController) inbounds(c *gin.Context) {
+	n := &model.Node{}
+	if err := c.ShouldBind(n); err != nil {
+		jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.obtain"), err)
+		return
+	}
+	ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second)
+	defer cancel()
+	options, err := a.nodeService.GetRemoteInboundOptions(ctx, n)
+	jsonObj(c, options, err)
+}
+
 func (a *NodeController) test(c *gin.Context) {
 	n := &model.Node{}
 	if err := c.ShouldBind(n); err != nil {

+ 101 - 27
internal/web/job/check_client_ip_job.go

@@ -16,6 +16,7 @@ import (
 	"github.com/mhsanaei/3x-ui/v3/internal/database"
 	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
 	"github.com/mhsanaei/3x-ui/v3/internal/logger"
+	"github.com/mhsanaei/3x-ui/v3/internal/web/service"
 	"github.com/mhsanaei/3x-ui/v3/internal/xray"
 
 	"gorm.io/gorm"
@@ -27,10 +28,14 @@ type IPWithTimestamp struct {
 	Timestamp int64  `json:"timestamp"`
 }
 
-// CheckClientIpJob monitors client IP addresses from access logs and manages IP blocking based on configured limits.
+// CheckClientIpJob monitors client IP addresses and manages IP blocking based
+// on configured limits. The per-client IPs come from the core's online-stats
+// API when the running core supports it (no access log needed), falling back
+// to access-log parsing on older cores.
 type CheckClientIpJob struct {
 	lastClear     int64
 	disAllowedIps []string
+	xrayService   service.XrayService
 }
 
 var job *CheckClientIpJob
@@ -50,22 +55,32 @@ func (j *CheckClientIpJob) Run() {
 		j.lastClear = time.Now().Unix()
 	}
 
-	shouldClearAccessLog := false
 	fail2BanEnabled := isFail2BanEnabled()
 	hasLimit := fail2BanEnabled && j.hasLimitIp()
 	f2bInstalled := false
 	if hasLimit {
 		f2bInstalled = j.checkFail2BanInstalled()
 	}
+
+	if observed, apiMode := j.collectFromOnlineAPI(); apiMode {
+		if fail2BanEnabled {
+			j.processObserved(observed, j.resolveEnforce(hasLimit, f2bInstalled), true)
+		}
+		// The core tracks online IPs itself, so no access log is needed in this
+		// mode; still rotate a user-configured access log hourly so it doesn't
+		// grow unboundedly. The enforcement-triggered rotation is skipped —
+		// nothing here reads the log.
+		if j.checkAccessLogAvailable(false) && time.Now().Unix()-j.lastClear > 3600 {
+			j.clearAccessLog()
+		}
+		return
+	}
+
+	shouldClearAccessLog := false
 	isAccessLogAvailable := j.checkAccessLogAvailable(hasLimit)
 
 	if fail2BanEnabled && isAccessLogAvailable {
-		enforce := hasLimit
-		if hasLimit && runtime.GOOS != "windows" && !f2bInstalled {
-			logger.Warning("[LimitIP] Fail2Ban is not installed, Please install Fail2Ban from the x-ui bash menu.")
-			enforce = false
-		}
-		shouldClearAccessLog = j.processLogFile(enforce)
+		shouldClearAccessLog = j.processLogFile(j.resolveEnforce(hasLimit, f2bInstalled))
 	}
 
 	if shouldClearAccessLog || (isAccessLogAvailable && time.Now().Unix()-j.lastClear > 3600) {
@@ -73,6 +88,50 @@ func (j *CheckClientIpJob) Run() {
 	}
 }
 
+// resolveEnforce decides whether limits can actually be enforced this run,
+// warning when fail2ban is missing on a platform that needs it.
+func (j *CheckClientIpJob) resolveEnforce(hasLimit, f2bInstalled bool) bool {
+	if hasLimit && runtime.GOOS != "windows" && !f2bInstalled {
+		logger.Warning("[LimitIP] Fail2Ban is not installed, Please install Fail2Ban from the x-ui bash menu.")
+		return false
+	}
+	return hasLimit
+}
+
+// collectFromOnlineAPI builds per-email IP observations (email -> ip ->
+// last-seen unix seconds) from the core's online-stats API. ok=false means the
+// API is unavailable — xray not running, an older core, or a transient gRPC
+// failure — and the caller must fall back to access-log parsing.
+func (j *CheckClientIpJob) collectFromOnlineAPI() (map[string]map[string]int64, bool) {
+	onlineUsers, ok, err := j.xrayService.GetOnlineUsers()
+	if err != nil {
+		logger.Debug("[LimitIP] online-stats API unavailable this run:", err)
+		return nil, false
+	}
+	if !ok {
+		return nil, false
+	}
+	now := time.Now().Unix()
+	observed := make(map[string]map[string]int64, len(onlineUsers))
+	for _, user := range onlineUsers {
+		for _, entry := range user.IPs {
+			// No localhost guard needed here: the core's OnlineMap.AddIP drops
+			// 127.0.0.1/[::1] itself, so they never reach this list.
+			ts := entry.LastSeen
+			if ts <= 0 {
+				ts = now
+			}
+			if _, exists := observed[user.Email]; !exists {
+				observed[user.Email] = make(map[string]int64)
+			}
+			if existing, seen := observed[user.Email][entry.IP]; !seen || ts > existing {
+				observed[user.Email][entry.IP] = ts
+			}
+		}
+	}
+	return observed, true
+}
+
 func (j *CheckClientIpJob) clearAccessLog() {
 	logAccessP, err := os.OpenFile(xray.GetAccessPersistentLogPath(), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
 	j.checkError(err)
@@ -183,18 +242,26 @@ func (j *CheckClientIpJob) processLogFile(enforce bool) bool {
 		j.checkError(err)
 	}
 
+	return j.processObserved(inboundClientIps, enforce, false)
+}
+
+// processObserved runs collection + enforcement for one scan's observations
+// (email -> ip -> last-seen unix seconds). observedAreLive marks the
+// observations as live connections (online-stats API) rather than recent log
+// lines: live entries bypass the stale cutoff, since a connection that opened
+// hours ago is still live even though its timestamp is old.
+func (j *CheckClientIpJob) processObserved(observed map[string]map[string]int64, enforce, observedAreLive bool) bool {
 	shouldCleanLog := false
-	for email, ipTimestamps := range inboundClientIps {
+	for email, ipTimestamps := range observed {
 
-		// The access log can still reference a client that was just renamed
+		// The observations can still reference a client that was just renamed
 		// or deleted; its email no longer matches any inbound. Skip it (and
 		// drop any orphaned tracking row) instead of recreating a row and
-		// logging an ERROR every run until the log rotates out the old email
-		// (#4963).
+		// logging an ERROR every run (#4963).
 		inbound, err := j.getInboundByEmail(email)
 		if err != nil {
 			if errors.Is(err, gorm.ErrRecordNotFound) {
-				logger.Debugf("[LimitIP] skipping stale access-log email %q (renamed or deleted)", email)
+				logger.Debugf("[LimitIP] skipping stale observed email %q (renamed or deleted)", email)
 				j.delInboundClientIps(email)
 			} else {
 				j.checkError(err)
@@ -214,13 +281,17 @@ func (j *CheckClientIpJob) processLogFile(enforce bool) bool {
 			continue
 		}
 
-		shouldCleanLog = j.updateInboundClientIps(clientIpsRecord, inbound, email, ipsWithTime, enforce) || shouldCleanLog
+		shouldCleanLog = j.updateInboundClientIps(clientIpsRecord, inbound, email, ipsWithTime, enforce, observedAreLive) || shouldCleanLog
 	}
 
 	return shouldCleanLog
 }
 
-func mergeClientIps(old, new []IPWithTimestamp, staleCutoff int64) map[string]int64 {
+// mergeClientIps folds this scan's observations into the persisted set,
+// dropping entries older than staleCutoff. newAlwaysLive exempts the new
+// entries from that cutoff: an API-observed IP is a live connection by
+// definition, even when its lastSeen (set at dispatch time) is hours old.
+func mergeClientIps(old, new []IPWithTimestamp, staleCutoff int64, newAlwaysLive bool) map[string]int64 {
 	ipMap := make(map[string]int64, len(old)+len(new))
 	for _, ipTime := range old {
 		if ipTime.Timestamp < staleCutoff {
@@ -229,7 +300,7 @@ func mergeClientIps(old, new []IPWithTimestamp, staleCutoff int64) map[string]in
 		ipMap[ipTime.IP] = ipTime.Timestamp
 	}
 	for _, ipTime := range new {
-		if ipTime.Timestamp < staleCutoff {
+		if !newAlwaysLive && ipTime.Timestamp < staleCutoff {
 			continue
 		}
 		if existingTime, ok := ipMap[ipTime.IP]; !ok || ipTime.Timestamp > existingTime {
@@ -239,6 +310,16 @@ func mergeClientIps(old, new []IPWithTimestamp, staleCutoff int64) map[string]in
 	return ipMap
 }
 
+// selectIpsToBan splits the live IPs (sorted oldest-first by partitionLiveIps)
+// into the newest `limit` entries to keep and the older remainder to ban.
+func selectIpsToBan(live []IPWithTimestamp, limit int) (kept, banned []IPWithTimestamp) {
+	if limit <= 0 || len(live) <= limit {
+		return live, nil
+	}
+	cutoff := len(live) - limit
+	return live[cutoff:], live[:cutoff]
+}
+
 func partitionLiveIps(ipMap map[string]int64, observedThisScan map[string]bool) (live, historical []IPWithTimestamp) {
 	live = make([]IPWithTimestamp, 0, len(observedThisScan))
 	historical = make([]IPWithTimestamp, 0, len(ipMap))
@@ -343,7 +424,7 @@ func (j *CheckClientIpJob) delInboundClientIps(clientEmail string) {
 	}
 }
 
-func (j *CheckClientIpJob) updateInboundClientIps(inboundClientIps *model.InboundClientIps, inbound *model.Inbound, clientEmail string, newIpsWithTime []IPWithTimestamp, enforce bool) bool {
+func (j *CheckClientIpJob) updateInboundClientIps(inboundClientIps *model.InboundClientIps, inbound *model.Inbound, clientEmail string, newIpsWithTime []IPWithTimestamp, enforce, observedAreLive bool) bool {
 	if inbound.Settings == "" {
 		logger.Debug("wrong data:", inbound)
 		return false
@@ -380,7 +461,7 @@ func (j *CheckClientIpJob) updateInboundClientIps(inboundClientIps *model.Inboun
 		json.Unmarshal([]byte(inboundClientIps.Ips), &oldIpsWithTime)
 	}
 
-	ipMap := mergeClientIps(oldIpsWithTime, newIpsWithTime, time.Now().Unix()-ipStaleAfterSeconds)
+	ipMap := mergeClientIps(oldIpsWithTime, newIpsWithTime, time.Now().Unix()-ipStaleAfterSeconds, observedAreLive)
 
 	// only ips seen in this scan count toward the limit. see
 	// partitionLiveIps.
@@ -394,15 +475,10 @@ func (j *CheckClientIpJob) updateInboundClientIps(inboundClientIps *model.Inboun
 	j.disAllowedIps = []string{}
 
 	// historical db-only ips are excluded from this count on purpose.
-	var keptLive []IPWithTimestamp
-	if len(liveIps) > limitIp {
+	keptLive, bannedLive := selectIpsToBan(liveIps, limitIp)
+	if len(bannedLive) > 0 {
 		shouldCleanLog = true
 
-		// keep the newest live ips, ban older ones.
-		cutoff := len(liveIps) - limitIp
-		keptLive = liveIps[cutoff:]
-		bannedLive := liveIps[:cutoff]
-
 		logIpFile, err := os.OpenFile(xray.GetIPLimitLogPath(), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644)
 		if err != nil {
 			logger.Errorf("failed to open IP limit log file: %s", err)
@@ -422,8 +498,6 @@ func (j *CheckClientIpJob) updateInboundClientIps(inboundClientIps *model.Inboun
 
 		// force xray to drop existing connections from banned ips
 		j.disconnectClientTemporarily(inbound, clientEmail, clients)
-	} else {
-		keptLive = liveIps
 	}
 
 	// keep kept-live + historical in the blob so the panel keeps showing

+ 2 - 2
internal/web/job/check_client_ip_job_integration_test.go

@@ -199,7 +199,7 @@ func TestUpdateInboundClientIps_LiveIpNotBannedByStillFreshHistoricals(t *testin
 	if err != nil {
 		t.Fatalf("getInboundByEmail: %v", err)
 	}
-	shouldCleanLog := j.updateInboundClientIps(row, inbound, email, live, true)
+	shouldCleanLog := j.updateInboundClientIps(row, inbound, email, live, true, false)
 
 	if shouldCleanLog {
 		t.Fatalf("shouldCleanLog must be false, nothing should have been banned with 1 live ip under limit 3")
@@ -252,7 +252,7 @@ func TestUpdateInboundClientIps_ExcessLiveIpIsStillBanned(t *testing.T) {
 	if err != nil {
 		t.Fatalf("getInboundByEmail: %v", err)
 	}
-	shouldCleanLog := j.updateInboundClientIps(row, inbound, email, live, true)
+	shouldCleanLog := j.updateInboundClientIps(row, inbound, email, live, true, false)
 
 	if !shouldCleanLog {
 		t.Fatalf("shouldCleanLog must be true when the live set exceeds the limit")

+ 65 - 5
internal/web/job/check_client_ip_job_test.go

@@ -22,7 +22,7 @@ func TestMergeClientIps_EvictsStaleOldEntries(t *testing.T) {
 		{IP: "2.2.2.2", Timestamp: 2000}, // same IP, newer log line
 	}
 
-	got := mergeClientIps(old, new, 1000)
+	got := mergeClientIps(old, new, 1000, false)
 
 	want := map[string]int64{"2.2.2.2": 2000}
 	if !reflect.DeepEqual(got, want) {
@@ -36,7 +36,7 @@ func TestMergeClientIps_KeepsFreshOldEntriesUnchanged(t *testing.T) {
 	old := []IPWithTimestamp{
 		{IP: "1.1.1.1", Timestamp: 1500},
 	}
-	got := mergeClientIps(old, nil, 1000)
+	got := mergeClientIps(old, nil, 1000, false)
 
 	want := map[string]int64{"1.1.1.1": 1500}
 	if !reflect.DeepEqual(got, want) {
@@ -48,7 +48,7 @@ func TestMergeClientIps_PrefersLaterTimestampForSameIp(t *testing.T) {
 	old := []IPWithTimestamp{{IP: "1.1.1.1", Timestamp: 1500}}
 	new := []IPWithTimestamp{{IP: "1.1.1.1", Timestamp: 1700}}
 
-	got := mergeClientIps(old, new, 1000)
+	got := mergeClientIps(old, new, 1000, false)
 
 	if got["1.1.1.1"] != 1700 {
 		t.Fatalf("expected latest timestamp 1700, got %d", got["1.1.1.1"])
@@ -59,7 +59,7 @@ func TestMergeClientIps_DropsStaleNewEntries(t *testing.T) {
 	// A log line with a clock-skewed old timestamp must not resurrect a
 	// stale IP past the cutoff.
 	new := []IPWithTimestamp{{IP: "1.1.1.1", Timestamp: 500}}
-	got := mergeClientIps(nil, new, 1000)
+	got := mergeClientIps(nil, new, 1000, false)
 
 	if len(got) != 0 {
 		t.Fatalf("stale new IP should have been dropped, got %v", got)
@@ -72,7 +72,7 @@ func TestMergeClientIps_NoStaleCutoffStillWorks(t *testing.T) {
 	old := []IPWithTimestamp{{IP: "1.1.1.1", Timestamp: 100}}
 	new := []IPWithTimestamp{{IP: "2.2.2.2", Timestamp: 200}}
 
-	got := mergeClientIps(old, new, 0)
+	got := mergeClientIps(old, new, 0, false)
 
 	want := map[string]int64{"1.1.1.1": 100, "2.2.2.2": 200}
 	if !reflect.DeepEqual(got, want) {
@@ -80,6 +80,66 @@ func TestMergeClientIps_NoStaleCutoffStillWorks(t *testing.T) {
 	}
 }
 
+func TestMergeClientIps_LiveObservationsBypassStaleCutoff(t *testing.T) {
+	// online-API mode: lastSeen is set when the connection was dispatched, so
+	// a connection held open for hours has an "old" timestamp while being live
+	// by definition. It must survive the stale cutoff.
+	new := []IPWithTimestamp{{IP: "1.1.1.1", Timestamp: 500}} // opened long ago, still connected
+	got := mergeClientIps(nil, new, 1000, true)
+
+	want := map[string]int64{"1.1.1.1": 500}
+	if !reflect.DeepEqual(got, want) {
+		t.Fatalf("live observation must bypass the stale cutoff\ngot:  %v\nwant: %v", got, want)
+	}
+}
+
+func TestMergeClientIps_LiveModeStillEvictsStaleOldEntries(t *testing.T) {
+	// the bypass applies only to this scan's observations — persisted entries
+	// from past scans still age out as before.
+	old := []IPWithTimestamp{{IP: "2.2.2.2", Timestamp: 100}}
+	new := []IPWithTimestamp{{IP: "1.1.1.1", Timestamp: 2000}}
+	got := mergeClientIps(old, new, 1000, true)
+
+	want := map[string]int64{"1.1.1.1": 2000}
+	if !reflect.DeepEqual(got, want) {
+		t.Fatalf("stale db entry must still be evicted in live mode\ngot:  %v\nwant: %v", got, want)
+	}
+}
+
+func TestSelectIpsToBan(t *testing.T) {
+	live := []IPWithTimestamp{ // sorted oldest-first, as partitionLiveIps returns
+		{IP: "A", Timestamp: 100},
+		{IP: "B", Timestamp: 200},
+		{IP: "C", Timestamp: 300},
+	}
+
+	// over the limit: oldest connections are banned, newest keep the slots
+	kept, banned := selectIpsToBan(live, 1)
+	if got := collectIps(kept); !reflect.DeepEqual(got, []string{"C"}) {
+		t.Fatalf("newest ip must keep the slot, got %v", got)
+	}
+	if got := collectIps(banned); !reflect.DeepEqual(got, []string{"A", "B"}) {
+		t.Fatalf("older ips must be banned oldest-first, got %v", got)
+	}
+
+	// at the limit: nothing banned
+	kept, banned = selectIpsToBan(live, 3)
+	if len(banned) != 0 || len(kept) != 3 {
+		t.Fatalf("at-limit set must not ban, kept=%v banned=%v", kept, banned)
+	}
+
+	// under the limit: nothing banned
+	kept, banned = selectIpsToBan(live[:1], 3)
+	if len(banned) != 0 || len(kept) != 1 {
+		t.Fatalf("under-limit set must not ban, kept=%v banned=%v", kept, banned)
+	}
+
+	// defensive: non-positive limit never reaches enforcement, but must not panic
+	if _, banned := selectIpsToBan(live, 0); banned != nil {
+		t.Fatalf("zero limit must not ban, got %v", banned)
+	}
+}
+
 func collectIps(entries []IPWithTimestamp) []string {
 	out := make([]string, 0, len(entries))
 	for _, e := range entries {

+ 2 - 1
internal/web/job/node_traffic_sync_job.go

@@ -239,7 +239,7 @@ func (j *NodeTrafficSyncJob) syncOne(mgr *runtime.Manager, n *model.Node, doIpSy
 
 	if n.ConfigDirty {
 		reconcileCtx, reconcileCancel := context.WithTimeout(context.Background(), nodeReconcileTimeout)
-		reconcileErr := j.inboundService.ReconcileNode(reconcileCtx, rt, n.Id)
+		reconcileErr := j.inboundService.ReconcileNode(reconcileCtx, rt, n)
 		reconcileCancel()
 		if reconcileErr != nil {
 			logger.Warning("node traffic sync: reconcile for", n.Name, "failed:", reconcileErr)
@@ -260,6 +260,7 @@ func (j *NodeTrafficSyncJob) syncOne(mgr *runtime.Manager, n *model.Node, doIpSy
 		j.inboundService.ClearNodeOnlineClients(n.Id)
 		return
 	}
+	service.FilterNodeSnapshot(n, snap)
 	_, _, dirty, _, _ := j.nodeService.NodeSyncState(n.Id)
 	changed, err := j.inboundService.SetRemoteTraffic(n.Id, snap, dirty)
 	if err != nil {

+ 30 - 7
internal/web/job/xray_traffic_job.go

@@ -66,21 +66,37 @@ func (j *XrayTrafficJob) Run() {
 		j.xrayService.SetToNeedRestart()
 	}
 
-	lastOnlineMap, err := j.inboundService.GetClientsLastOnline()
-	if err != nil {
-		logger.Warning("get clients last online failed:", err)
-	}
-	if lastOnlineMap == nil {
-		lastOnlineMap = make(map[string]int64)
-	}
 	// Derive the local online set from this poll's per-email deltas rather
 	// than the shared last_online column, which remote-node syncs also bump
 	// and would otherwise make a client active only on a remote node appear
 	// online on local inbounds.
 	activeEmails := make([]string, 0, len(clientTraffics))
+	deltaActive := make(map[string]bool, len(clientTraffics))
 	for _, ct := range clientTraffics {
 		if ct != nil && ct.Up+ct.Down > 0 {
 			activeEmails = append(activeEmails, ct.Email)
+			deltaActive[ct.Email] = true
+		}
+	}
+	// When the core supports the online-stats API, union in connection-based
+	// onlines. Neither signal alone covers everything: an idle-but-connected
+	// client moves no bytes between polls (the delta heuristic's blind spot),
+	// while a short-lived connection can close before this poll yet still show
+	// in the delta. Older cores fall back to deltas alone.
+	if onlineUsers, apiMode, ouErr := j.xrayService.GetOnlineUsers(); ouErr != nil {
+		logger.Debug("get online users from xray api failed:", ouErr)
+	} else if apiMode {
+		idleOnline := make([]string, 0, len(onlineUsers))
+		for _, u := range onlineUsers {
+			if !deltaActive[u.Email] {
+				activeEmails = append(activeEmails, u.Email)
+				idleOnline = append(idleOnline, u.Email)
+			}
+		}
+		// The traffic path only bumps last_online on a non-zero delta; keep the
+		// column fresh for clients kept online purely by a live connection.
+		if err := j.inboundService.BumpClientsLastOnline(idleOnline); err != nil {
+			logger.Warning("bump last online for connected clients failed:", err)
 		}
 	}
 	// Pair the email signal with the inbound tags that moved bytes this poll.
@@ -100,6 +116,13 @@ func (j *XrayTrafficJob) Run() {
 		return
 	}
 
+	lastOnlineMap, err := j.inboundService.GetClientsLastOnline()
+	if err != nil {
+		logger.Warning("get clients last online failed:", err)
+	}
+	if lastOnlineMap == nil {
+		lastOnlineMap = make(map[string]int64)
+	}
 	onlineClients := j.inboundService.GetOnlineClients()
 	if onlineClients == nil {
 		onlineClients = []string{}

+ 27 - 0
internal/web/runtime/remote.go

@@ -45,6 +45,13 @@ type Remote struct {
 	remoteIDByTag map[string]int
 }
 
+type RemoteInboundOption struct {
+	Tag      string         `json:"tag"`
+	Remark   string         `json:"remark"`
+	Protocol model.Protocol `json:"protocol"`
+	Port     int            `json:"port"`
+}
+
 func NewRemote(n *model.Node) *Remote {
 	return &Remote{
 		node:          n,
@@ -205,6 +212,18 @@ func (r *Remote) ListRemoteTags(ctx context.Context) ([]string, error) {
 	return tags, nil
 }
 
+func (r *Remote) ListInboundOptions(ctx context.Context) ([]RemoteInboundOption, error) {
+	env, err := r.do(ctx, http.MethodGet, "panel/api/inbounds/list", nil)
+	if err != nil {
+		return nil, err
+	}
+	var list []RemoteInboundOption
+	if err := json.Unmarshal(env.Obj, &list); err != nil {
+		return nil, fmt.Errorf("decode inbound list: %w", err)
+	}
+	return list, nil
+}
+
 func (r *Remote) refreshRemoteIDs(ctx context.Context) error {
 	env, err := r.do(ctx, http.MethodGet, "panel/api/inbounds/list", nil)
 	if err != nil {
@@ -480,6 +499,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

+ 84 - 0
internal/web/service/client_apply_field_test.go

@@ -0,0 +1,84 @@
+package service
+
+import (
+	"encoding/json"
+	"path/filepath"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+)
+
+// TestResetClientExpiryTimeByEmail_MultiInbound reproduces #5039: a client
+// attached to several inbounds had its expiry patched only on the first
+// inbound's JSON, so the stale siblings reverted the change on the next sync.
+func TestResetClientExpiryTimeByEmail_MultiInbound(t *testing.T) {
+	dbDir := t.TempDir()
+	t.Setenv("XUI_DB_FOLDER", dbDir)
+	if err := database.InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil {
+		t.Fatalf("InitDB: %v", err)
+	}
+	t.Cleanup(func() { _ = database.CloseDB() })
+
+	db := database.GetDB()
+
+	const email = "[email protected]"
+	const uid = "ce8d33df-3a64-4f10-8f9b-91c3a8e0c111"
+	const oldExpiry = int64(1700000000000)
+	const newExpiry = int64(1800000000000)
+
+	clientJSON := func(expiry int64) string {
+		b, _ := json.Marshal(map[string]any{"clients": []map[string]any{{
+			"email": email, "id": uid, "enable": true, "expiryTime": expiry, "subId": "sub-multi-1",
+		}}})
+		return string(b)
+	}
+
+	first := &model.Inbound{Tag: "vless-a", Enable: true, Port: 50001, Protocol: model.VLESS,
+		StreamSettings: `{"network":"tcp","security":"reality"}`, Settings: clientJSON(oldExpiry)}
+	second := &model.Inbound{Tag: "vless-b", Enable: true, Port: 50002, Protocol: model.VLESS,
+		StreamSettings: `{"network":"ws","security":"tls"}`, Settings: clientJSON(oldExpiry)}
+	for _, ib := range []*model.Inbound{first, second} {
+		if err := db.Create(ib).Error; err != nil {
+			t.Fatalf("create inbound %s: %v", ib.Tag, err)
+		}
+	}
+
+	clientSvc := ClientService{}
+	inboundSvc := InboundService{}
+	for _, ib := range []*model.Inbound{first, second} {
+		clients, err := inboundSvc.GetClients(ib)
+		if err != nil {
+			t.Fatalf("GetClients(%s): %v", ib.Tag, err)
+		}
+		if err := clientSvc.SyncInbound(nil, ib.Id, clients); err != nil {
+			t.Fatalf("SyncInbound(%s): %v", ib.Tag, err)
+		}
+	}
+
+	if _, err := clientSvc.ResetClientExpiryTimeByEmail(&inboundSvc, email, newExpiry); err != nil {
+		t.Fatalf("ResetClientExpiryTimeByEmail: %v", err)
+	}
+
+	for _, ib := range []*model.Inbound{first, second} {
+		fresh, err := inboundSvc.GetInbound(ib.Id)
+		if err != nil {
+			t.Fatalf("GetInbound(%s): %v", ib.Tag, err)
+		}
+		clients, err := inboundSvc.GetClients(fresh)
+		if err != nil {
+			t.Fatalf("GetClients(%s): %v", ib.Tag, err)
+		}
+		if len(clients) != 1 || clients[0].ExpiryTime != newExpiry {
+			t.Errorf("inbound %s settings expiry = %d, want %d (#5039)", ib.Tag, clients[0].ExpiryTime, newExpiry)
+		}
+	}
+
+	rec, err := clientSvc.GetRecordByEmail(nil, email)
+	if err != nil {
+		t.Fatalf("GetRecordByEmail: %v", err)
+	}
+	if rec.ExpiryTime != newExpiry {
+		t.Errorf("client record expiry = %d, want %d", rec.ExpiryTime, newExpiry)
+	}
+}

+ 60 - 36
internal/web/service/client_inbound_apply.go

@@ -949,54 +949,78 @@ func (s *ClientService) SetClientEnableByEmail(inboundSvc *InboundService, clien
 // the matched client — that is the input contract UpdateInboundClient expects
 // (clients[0] is the new data; clientEmail locates the row to replace). It
 // backs the single-field by-email setters below.
+// applyClientFieldByEmail mutates a client field on every inbound the email is
+// attached to. A multi-inbound client is one logical identity: patching only
+// the first inbound's JSON would leave the siblings stale, and the next
+// SyncInbound over a stale sibling would revert the edit in the normalized
+// records (#5039).
 func (s *ClientService) applyClientFieldByEmail(inboundSvc *InboundService, clientEmail string, mutate func(c map[string]any)) (bool, error) {
-	_, inbound, err := inboundSvc.GetClientInboundByEmail(clientEmail)
+	inboundIds, err := s.GetInboundIdsForEmail(database.GetDB(), clientEmail)
 	if err != nil {
 		return false, err
 	}
-	if inbound == nil {
-		return false, common.NewError("Inbound Not Found For Email:", clientEmail)
-	}
-
-	oldClients, err := inboundSvc.GetClients(inbound)
-	if err != nil {
-		return false, err
+	if len(inboundIds) == 0 {
+		// Legacy fallback for clients that only live in the inbound JSON and
+		// were never normalized into client_inbounds.
+		_, inbound, gErr := inboundSvc.GetClientInboundByEmail(clientEmail)
+		if gErr != nil {
+			return false, gErr
+		}
+		if inbound == nil {
+			return false, common.NewError("Inbound Not Found For Email:", clientEmail)
+		}
+		inboundIds = []int{inbound.Id}
 	}
 
+	needRestart := false
 	found := false
-	for _, oldClient := range oldClients {
-		if oldClient.Email == clientEmail {
-			found = true
-			break
+	for _, ibId := range inboundIds {
+		inbound, gErr := inboundSvc.GetInbound(ibId)
+		if gErr != nil {
+			return needRestart, gErr
 		}
-	}
-
-	if !found {
-		return false, common.NewError("Client Not Found For Email:", clientEmail)
-	}
 
-	var settings map[string]any
-	err = json.Unmarshal([]byte(inbound.Settings), &settings)
-	if err != nil {
-		return false, err
-	}
-	clients := settings["clients"].([]any)
-	var newClients []any
-	for client_index := range clients {
-		c := clients[client_index].(map[string]any)
-		if c["email"] == clientEmail {
-			mutate(c)
-			c["updated_at"] = time.Now().Unix() * 1000
-			newClients = append(newClients, any(c))
+		var settings map[string]any
+		if uErr := json.Unmarshal([]byte(inbound.Settings), &settings); uErr != nil {
+			return needRestart, uErr
+		}
+		clients, _ := settings["clients"].([]any)
+		// UpdateInboundClient expects a single-client payload, so keep only the
+		// matching entry in the scratch copy; it splices the result back into
+		// the inbound's full client list itself.
+		var newClients []any
+		for client_index := range clients {
+			c, ok := clients[client_index].(map[string]any)
+			if !ok {
+				continue
+			}
+			if c["email"] == clientEmail {
+				mutate(c)
+				c["updated_at"] = time.Now().Unix() * 1000
+				newClients = append(newClients, any(c))
+			}
 		}
+		if len(newClients) == 0 {
+			continue
+		}
+		found = true
+		settings["clients"] = newClients
+		modifiedSettings, mErr := json.MarshalIndent(settings, "", "  ")
+		if mErr != nil {
+			return needRestart, mErr
+		}
+		inbound.Settings = string(modifiedSettings)
+		nr, uErr := s.UpdateInboundClient(inboundSvc, inbound, clientEmail)
+		if uErr != nil {
+			return needRestart, uErr
+		}
+		needRestart = needRestart || nr
 	}
-	settings["clients"] = newClients
-	modifiedSettings, err := json.MarshalIndent(settings, "", "  ")
-	if err != nil {
-		return false, err
+
+	if !found {
+		return needRestart, common.NewError("Client Not Found For Email:", clientEmail)
 	}
-	inbound.Settings = string(modifiedSettings)
-	return s.UpdateInboundClient(inboundSvc, inbound, clientEmail)
+	return needRestart, nil
 }
 
 func (s *ClientService) ResetClientIpLimitByEmail(inboundSvc *InboundService, clientEmail string, count int) (bool, error) {

+ 151 - 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 {
@@ -435,6 +559,14 @@ func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, boo
 		return inbound, false, err
 	}
 
+	// Before the deferred commit, so a node in "selected" sync mode cannot
+	// sweep the new central row in the gap before its tag is allowed.
+	if inbound.NodeID != nil {
+		if aErr := (&NodeService{}).EnsureInboundTagAllowed(*inbound.NodeID, inbound.Tag); aErr != nil {
+			logger.Warning("allow inbound tag on node failed:", aErr)
+		}
+	}
+
 	needRestart := false
 	if inbound.Enable {
 		rt, push, dirty, perr := s.nodePushPlan(inbound)
@@ -760,6 +892,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 = ""
 	}
@@ -814,6 +957,14 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
 		}
 	}
 
+	// A rename must allow the new tag before the deferred commit, or a node in
+	// "selected" sync mode would sweep the renamed central row on the next pull.
+	if oldInbound.NodeID != nil {
+		if aErr := (&NodeService{}).EnsureInboundTagAllowed(*oldInbound.NodeID, oldInbound.Tag); aErr != nil {
+			logger.Warning("allow inbound tag on node failed:", aErr)
+		}
+	}
+
 	if err = tx.Save(oldInbound).Error; err != nil {
 		return inbound, false, err
 	}

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

+ 52 - 3
internal/web/service/inbound_node.go

@@ -76,10 +76,11 @@ func (s *InboundService) AnyNodePending(inboundIds []int) bool {
 	return false
 }
 
-func (s *InboundService) ReconcileNode(ctx context.Context, rt *runtime.Remote, nodeID int) error {
-	if rt == nil || nodeID <= 0 {
+func (s *InboundService) ReconcileNode(ctx context.Context, rt *runtime.Remote, n *model.Node) error {
+	if rt == nil || n == nil || n.Id <= 0 {
 		return nil
 	}
+	nodeID := n.Id
 	db := database.GetDB()
 	var inbounds []*model.Inbound
 	if err := db.Model(model.Inbound{}).Where("node_id = ?", nodeID).Find(&inbounds).Error; err != nil {
@@ -104,10 +105,26 @@ func (s *InboundService) ReconcileNode(ctx context.Context, rt *runtime.Remote,
 			return fmt.Errorf("reconcile inbound %q: %w", ib.Tag, err)
 		}
 	}
+	// In "selected" sync mode the panel only manages the selected tags: the
+	// rest were never imported, so their absence from the local DB must not
+	// delete them from the node. Only a selected tag missing locally (the
+	// panel deleted it while the node was unreachable) may be swept.
+	var selected map[string]struct{}
+	if n.InboundSyncMode == "selected" {
+		selected = make(map[string]struct{}, len(n.InboundTags))
+		for _, tag := range n.InboundTags {
+			selected[tag] = struct{}{}
+		}
+	}
 	for _, tag := range remoteTags {
 		if _, want := desiredTags[tag]; want {
 			continue
 		}
+		if selected != nil {
+			if _, managed := selected[tag]; !managed {
+				continue
+			}
+		}
 		if err := rt.DelInbound(ctx, &model.Inbound{Tag: tag}); err != nil {
 			return fmt.Errorf("reconcile delete %q: %w", tag, err)
 		}
@@ -245,6 +262,22 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
 		}
 	}
 
+	// Union of every email the snapshot still reports, across all inbounds.
+	// The (node, email) baseline rows are keyed per node, not per inbound, so
+	// the sweeps below must only drop one when the email left the node
+	// entirely — an email whose stats moved to (or always lived under) a
+	// sibling inbound still needs its baseline for the sibling's delta
+	// computation (#5202).
+	snapEmailsAll := make(map[string]struct{})
+	for _, snapIb := range snap.Inbounds {
+		if snapIb == nil {
+			continue
+		}
+		for i := range snapIb.ClientStats {
+			snapEmailsAll[snapIb.ClientStats[i].Email] = struct{}{}
+		}
+	}
+
 	tx := db.Begin()
 	committed := false
 	defer func() {
@@ -329,6 +362,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)
@@ -403,9 +437,17 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
 			return false, err
 		}
 		if len(goneEmails) > 0 {
+			// Baselines are per (node, email), not per inbound: keep them for
+			// emails the snapshot still reports under a sibling inbound (#5202).
+			baselineGone := make([]string, 0, len(goneEmails))
+			for _, e := range goneEmails {
+				if _, still := snapEmailsAll[e]; !still {
+					baselineGone = append(baselineGone, e)
+				}
+			}
 			// Chunk to avoid SQLite bind var limit when a node has many clients
 			// removed (e.g. after API bulk delete or structural change on node inbound).
-			for _, batch := range chunkStrings(goneEmails, sqliteMaxVars) {
+			for _, batch := range chunkStrings(baselineGone, sqliteMaxVars) {
 				if err := tx.Where("node_id = ? AND email IN ?", nodeID, batch).
 					Delete(&model.NodeClientTraffic{}).Error; err != nil {
 					return false, err
@@ -536,6 +578,13 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
 			if _, kept := snapEmails[k.email]; kept {
 				continue
 			}
+			// Gone from this inbound's stats but still reported by the node under
+			// a sibling inbound: both the shared accumulator row and the (node,
+			// email) baseline must survive, or the sibling's next delta would
+			// compute against nothing and freeze the counter (#5202).
+			if _, still := snapEmailsAll[k.email]; still {
+				continue
+			}
 			if err := tx.Where("node_id = ? AND email = ?", nodeID, existing.Email).
 				Delete(&model.NodeClientTraffic{}).Error; err != nil {
 				return false, err

+ 197 - 0
internal/web/service/inbound_node_reconcile_test.go

@@ -0,0 +1,197 @@
+package service
+
+import (
+	"context"
+	"encoding/json"
+	"net/http"
+	"net/http/httptest"
+	"net/url"
+	"sort"
+	"strconv"
+	"strings"
+	"sync"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/web/runtime"
+)
+
+// fakeNodePanel serves just enough of the node API for ReconcileNode: the
+// inbound list plus update/del endpoints, recording which remote ids get
+// deleted.
+func fakeNodePanel(t *testing.T, tagToID map[string]int) (*httptest.Server, func() []int) {
+	t.Helper()
+	var mu sync.Mutex
+	var deleted []int
+	writeOK := func(w http.ResponseWriter, obj any) {
+		w.Header().Set("Content-Type", "application/json")
+		_ = json.NewEncoder(w).Encode(map[string]any{"success": true, "msg": "", "obj": obj})
+	}
+	mux := http.NewServeMux()
+	mux.HandleFunc("/panel/api/inbounds/list", func(w http.ResponseWriter, _ *http.Request) {
+		type row struct {
+			Id  int    `json:"id"`
+			Tag string `json:"tag"`
+		}
+		rows := make([]row, 0, len(tagToID))
+		for tag, id := range tagToID {
+			rows = append(rows, row{Id: id, Tag: tag})
+		}
+		writeOK(w, rows)
+	})
+	mux.HandleFunc("/panel/api/inbounds/update/", func(w http.ResponseWriter, _ *http.Request) {
+		writeOK(w, nil)
+	})
+	mux.HandleFunc("/panel/api/inbounds/del/", func(w http.ResponseWriter, r *http.Request) {
+		id, err := strconv.Atoi(strings.TrimPrefix(r.URL.Path, "/panel/api/inbounds/del/"))
+		if err != nil {
+			http.Error(w, "bad id", http.StatusBadRequest)
+			return
+		}
+		mu.Lock()
+		deleted = append(deleted, id)
+		mu.Unlock()
+		writeOK(w, nil)
+	})
+	ts := httptest.NewServer(mux)
+	t.Cleanup(ts.Close)
+	return ts, func() []int {
+		mu.Lock()
+		defer mu.Unlock()
+		out := append([]int(nil), deleted...)
+		sort.Ints(out)
+		return out
+	}
+}
+
+func reconcileTestNode(t *testing.T, ts *httptest.Server, name, mode string, tags []string) *model.Node {
+	t.Helper()
+	u, err := url.Parse(ts.URL)
+	if err != nil {
+		t.Fatalf("parse test server URL: %v", err)
+	}
+	port, err := strconv.Atoi(u.Port())
+	if err != nil {
+		t.Fatalf("parse test server port: %v", err)
+	}
+	n := &model.Node{
+		Name:                name,
+		Scheme:              "http",
+		Address:             u.Hostname(),
+		Port:                port,
+		BasePath:            "/",
+		ApiToken:            "tok",
+		Enable:              true,
+		AllowPrivateAddress: true,
+		Status:              "online",
+		InboundSyncMode:     mode,
+		InboundTags:         tags,
+	}
+	if err := database.GetDB().Create(n).Error; err != nil {
+		t.Fatalf("create node: %v", err)
+	}
+	return n
+}
+
+// In "selected" sync mode the panel never imports the unselected inbounds, so
+// reconcile must not treat their absence from the local DB as a deletion: only
+// a *selected* tag missing locally may be swept from the node.
+func TestReconcileNode_SelectedModeLeavesUnselectedRemoteInbounds(t *testing.T) {
+	setupConflictDB(t)
+
+	ts, deletedIDs := fakeNodePanel(t, map[string]int{
+		"keep":          1,
+		"selected-gone": 2,
+		"unmanaged":     3,
+	})
+	node := reconcileTestNode(t, ts, "sel-node", "selected", []string{"keep", "selected-gone"})
+	seedInboundConflictNode(t, "keep", "", 443, model.VLESS, `{"network":"tcp"}`, `{"clients":[]}`, &node.Id)
+
+	svc := InboundService{}
+	if err := svc.ReconcileNode(context.Background(), runtime.NewRemote(node), node); err != nil {
+		t.Fatalf("ReconcileNode: %v", err)
+	}
+
+	got := deletedIDs()
+	if len(got) != 1 || got[0] != 2 {
+		t.Fatalf("deleted remote ids = %v, want [2] (unmanaged inbound 3 must survive)", got)
+	}
+}
+
+// "all" mode keeps the original anti-entropy contract: every remote inbound
+// missing from the local DB is deleted on the node.
+func TestReconcileNode_AllModeDeletesUndesiredRemoteInbounds(t *testing.T) {
+	setupConflictDB(t)
+
+	ts, deletedIDs := fakeNodePanel(t, map[string]int{
+		"keep":   1,
+		"gone-a": 2,
+		"gone-b": 3,
+	})
+	node := reconcileTestNode(t, ts, "all-node", "all", nil)
+	seedInboundConflictNode(t, "keep", "", 443, model.VLESS, `{"network":"tcp"}`, `{"clients":[]}`, &node.Id)
+
+	svc := InboundService{}
+	if err := svc.ReconcileNode(context.Background(), runtime.NewRemote(node), node); err != nil {
+		t.Fatalf("ReconcileNode: %v", err)
+	}
+
+	got := deletedIDs()
+	if len(got) != 2 || got[0] != 2 || got[1] != 3 {
+		t.Fatalf("deleted remote ids = %v, want [2 3]", got)
+	}
+}
+
+func TestEnsureInboundTagAllowed(t *testing.T) {
+	setupConflictDB(t)
+	db := database.GetDB()
+	svc := NodeService{}
+
+	selected := &model.Node{
+		Name: "ensure-sel", Address: "127.0.0.1", Port: 2096, ApiToken: "tok",
+		InboundSyncMode: "selected", InboundTags: []string{"a"},
+	}
+	if err := db.Create(selected).Error; err != nil {
+		t.Fatalf("create node: %v", err)
+	}
+
+	if err := svc.EnsureInboundTagAllowed(selected.Id, "b"); err != nil {
+		t.Fatalf("EnsureInboundTagAllowed add: %v", err)
+	}
+	var got model.Node
+	if err := db.First(&got, selected.Id).Error; err != nil {
+		t.Fatalf("reload node: %v", err)
+	}
+	if len(got.InboundTags) != 2 || got.InboundTags[0] != "a" || got.InboundTags[1] != "b" {
+		t.Fatalf("InboundTags = %#v, want [a b]", got.InboundTags)
+	}
+
+	if err := svc.EnsureInboundTagAllowed(selected.Id, "a"); err != nil {
+		t.Fatalf("EnsureInboundTagAllowed existing: %v", err)
+	}
+	if err := db.First(&got, selected.Id).Error; err != nil {
+		t.Fatalf("reload node: %v", err)
+	}
+	if len(got.InboundTags) != 2 {
+		t.Fatalf("existing tag must not duplicate, got %#v", got.InboundTags)
+	}
+
+	all := &model.Node{
+		Name: "ensure-all", Address: "127.0.0.1", Port: 2097, ApiToken: "tok",
+		InboundSyncMode: "all",
+	}
+	if err := db.Create(all).Error; err != nil {
+		t.Fatalf("create node: %v", err)
+	}
+	if err := svc.EnsureInboundTagAllowed(all.Id, "x"); err != nil {
+		t.Fatalf("EnsureInboundTagAllowed all-mode: %v", err)
+	}
+	var gotAll model.Node
+	if err := db.First(&gotAll, all.Id).Error; err != nil {
+		t.Fatalf("reload node: %v", err)
+	}
+	if len(gotAll.InboundTags) != 0 {
+		t.Fatalf("all-mode node must stay without tags, got %#v", gotAll.InboundTags)
+	}
+}

+ 22 - 0
internal/web/service/inbound_traffic.go

@@ -837,6 +837,28 @@ func (s *InboundService) GetClientTrafficTgBot(tgId int64) ([]*xray.ClientTraffi
 	return traffics, nil
 }
 
+// BumpClientsLastOnline sets client_traffics.last_online to now for the given
+// emails. Used in online-API mode for clients that hold a live connection but
+// moved no bytes this poll — the traffic path (addClientTraffic) only bumps
+// last_online on a non-zero delta, so idle-but-connected clients would
+// otherwise show a stale "last online" while being reported online.
+func (s *InboundService) BumpClientsLastOnline(emails []string) error {
+	uniq := uniqueNonEmptyStrings(emails)
+	if len(uniq) == 0 {
+		return nil
+	}
+	now := time.Now().UnixMilli()
+	return submitTrafficWrite(func() error {
+		db := database.GetDB()
+		for _, batch := range chunkStrings(uniq, sqliteMaxVars) {
+			if err := db.Model(xray.ClientTraffic{}).Where("email IN ?", batch).Update("last_online", now).Error; err != nil {
+				return err
+			}
+		}
+		return nil
+	})
+}
+
 func (s *InboundService) GetActiveClientTraffics(emails []string) ([]*xray.ClientTraffic, error) {
 	uniq := uniqueNonEmptyStrings(emails)
 	if len(uniq) == 0 {

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

+ 85 - 0
internal/web/service/node.go

@@ -347,6 +347,25 @@ func (s *NodeService) normalize(n *model.Node) error {
 		n.TlsVerifyMode = "verify"
 	}
 	n.PinnedCertSha256 = strings.TrimSpace(n.PinnedCertSha256)
+	if n.InboundSyncMode != "selected" {
+		n.InboundSyncMode = "all"
+		n.InboundTags = nil
+	} else {
+		seen := make(map[string]struct{}, len(n.InboundTags))
+		tags := make([]string, 0, len(n.InboundTags))
+		for _, tag := range n.InboundTags {
+			tag = strings.TrimSpace(tag)
+			if tag == "" {
+				continue
+			}
+			if _, ok := seen[tag]; ok {
+				continue
+			}
+			seen[tag] = struct{}{}
+			tags = append(tags, tag)
+		}
+		n.InboundTags = tags
+	}
 	if n.TlsVerifyMode == "pin" {
 		if _, err := decodeCertPin(n.PinnedCertSha256); err != nil {
 			return common.NewError(err.Error())
@@ -368,6 +387,10 @@ func (s *NodeService) Update(id int, in *model.Node) error {
 	if err := s.normalize(in); err != nil {
 		return err
 	}
+	inboundTagsJSON, err := json.Marshal(in.InboundTags)
+	if err != nil {
+		return err
+	}
 	db := database.GetDB()
 	existing := &model.Node{}
 	if err := db.Where("id = ?", id).First(existing).Error; err != nil {
@@ -385,6 +408,8 @@ func (s *NodeService) Update(id int, in *model.Node) error {
 		"allow_private_address": in.AllowPrivateAddress,
 		"tls_verify_mode":       in.TlsVerifyMode,
 		"pinned_cert_sha256":    in.PinnedCertSha256,
+		"inbound_sync_mode":     in.InboundSyncMode,
+		"inbound_tags":          string(inboundTagsJSON),
 	}
 	if err := db.Model(model.Node{}).Where("id = ?", id).Updates(updates).Error; err != nil {
 		return err
@@ -395,6 +420,66 @@ func (s *NodeService) Update(id int, in *model.Node) error {
 	return nil
 }
 
+func (s *NodeService) GetRemoteInboundOptions(ctx context.Context, n *model.Node) ([]runtime.RemoteInboundOption, error) {
+	if err := s.normalize(n); err != nil {
+		return nil, err
+	}
+	return runtime.NewRemote(n).ListInboundOptions(ctx)
+}
+
+// EnsureInboundTagAllowed adds a panel-managed inbound's tag to the node's
+// selection when the node syncs in "selected" mode. Without it, the next
+// traffic sync would filter the tag out of the snapshot and the orphan sweep
+// would silently delete the central row the panel just created or renamed.
+// Tags are only ever added (never removed): on a rename the node may keep
+// reporting the old tag until the remote update lands, and a leftover entry
+// that matches nothing is harmless.
+func (s *NodeService) EnsureInboundTagAllowed(nodeID int, tag string) error {
+	tag = strings.TrimSpace(tag)
+	if nodeID <= 0 || tag == "" {
+		return nil
+	}
+	db := database.GetDB()
+	node := &model.Node{}
+	if err := db.Where("id = ?", nodeID).First(node).Error; err != nil {
+		return err
+	}
+	if node.InboundSyncMode != "selected" {
+		return nil
+	}
+	for _, t := range node.InboundTags {
+		if t == tag {
+			return nil
+		}
+	}
+	buf, err := json.Marshal(append(node.InboundTags, tag))
+	if err != nil {
+		return err
+	}
+	return db.Model(model.Node{}).Where("id = ?", nodeID).
+		Updates(map[string]any{"inbound_tags": string(buf)}).Error
+}
+
+func FilterNodeSnapshot(n *model.Node, snap *runtime.TrafficSnapshot) {
+	if n == nil || snap == nil || n.InboundSyncMode != "selected" {
+		return
+	}
+	allowed := make(map[string]struct{}, len(n.InboundTags))
+	for _, tag := range n.InboundTags {
+		allowed[tag] = struct{}{}
+	}
+	filtered := make([]*model.Inbound, 0, len(snap.Inbounds))
+	for _, inbound := range snap.Inbounds {
+		if inbound == nil {
+			continue
+		}
+		if _, ok := allowed[inbound.Tag]; ok {
+			filtered = append(filtered, inbound)
+		}
+	}
+	snap.Inbounds = filtered
+}
+
 func (s *NodeService) Delete(id int) error {
 	db := database.GetDB()
 	if err := db.Where("id = ?", id).Delete(model.Node{}).Error; err != nil {

+ 48 - 0
internal/web/service/node_client_traffic_sum_test.go

@@ -226,6 +226,54 @@ func TestClientGoneFromOneNode_KeepsSharedEmailRow(t *testing.T) {
 	assertUpDown(t, readTraffic(t, db, email), 140, 140, "node 2 keeps accruing")
 }
 
+// TestStatsUnderSiblingInbound_KeepsNodeBaseline reproduces the recurring
+// sweep bug behind #5202: the client is attached to two inbounds of the SAME
+// node, the node reports its stats under n1-a only, but the master-side row
+// is owned by n1-b's mirror. The per-email sweep for n1-b must not drop the
+// (node, email) baseline that n1-a's delta computation needs — doing so every
+// cycle froze the client's counter permanently.
+func TestStatsUnderSiblingInbound_KeepsNodeBaseline(t *testing.T) {
+	db := initTrafficTestDB(t)
+	createNodeInboundWithClient(t, db, 1, "n1-a", 41001, "fresh")
+	createNodeInbound(t, db, 1, "n1-b", 41002)
+	svc := &InboundService{}
+
+	const email = "fresh"
+	var ibB model.Inbound
+	if err := db.Where("tag = ?", "n1-b").First(&ibB).Error; err != nil {
+		t.Fatalf("load n1-b: %v", err)
+	}
+	// Master-side row created when the client was added on the panel, owned by
+	// n1-b's mirror (e.g. the client form targeted that inbound).
+	if err := db.Create(&xray.ClientTraffic{InboundId: ibB.Id, Email: email, Enable: true}).Error; err != nil {
+		t.Fatalf("seed master row: %v", err)
+	}
+
+	settings := fmt.Sprintf(`{"clients": [{"email": %q, "enable": true}]}`, email)
+	sync := func(up, down int64) {
+		t.Helper()
+		snap := &runtime.TrafficSnapshot{Inbounds: []*model.Inbound{
+			{Tag: "n1-a", Settings: settings, ClientStats: []xray.ClientTraffic{{Email: email, Up: up, Down: down, Enable: true}}},
+			{Tag: "n1-b", Settings: `{"clients": []}`},
+		}}
+		if _, err := svc.setRemoteTrafficLocked(1, snap, false); err != nil {
+			t.Fatalf("sync: %v", err)
+		}
+	}
+
+	sync(630, 630)
+	var baselines int64
+	if err := db.Model(&model.NodeClientTraffic{}).Where("node_id = ? AND email = ?", 1, email).Count(&baselines).Error; err != nil {
+		t.Fatalf("count baselines: %v", err)
+	}
+	if baselines != 1 {
+		t.Fatalf("baseline must survive the sibling-inbound sweep, found %d rows", baselines)
+	}
+
+	sync(700, 700)
+	assertUpDown(t, readTraffic(t, db, email), 70, 70, "delta accrues once baseline survives")
+}
+
 func TestDelClientStat_CleansNodeBaselines(t *testing.T) {
 	db := initTrafficTestDB(t)
 	svc := &InboundService{}

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

+ 52 - 0
internal/web/service/node_test.go

@@ -4,6 +4,7 @@ import (
 	"testing"
 
 	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/web/runtime"
 )
 
 func TestNormalizeBasePath(t *testing.T) {
@@ -160,3 +161,54 @@ func TestNodeService_Normalize_OverridesUnknownScheme(t *testing.T) {
 		t.Fatalf("Scheme = %q, want https", n.Scheme)
 	}
 }
+
+func TestNodeService_NormalizeInboundSelection(t *testing.T) {
+	s := &NodeService{}
+	n := &model.Node{
+		Name:            "n",
+		Address:         "example.com",
+		Port:            443,
+		InboundSyncMode: "selected",
+		InboundTags:     []string{" alpha ", "", "beta", "alpha"},
+	}
+	if err := s.normalize(n); err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	if n.InboundSyncMode != "selected" {
+		t.Fatalf("InboundSyncMode = %q, want selected", n.InboundSyncMode)
+	}
+	if len(n.InboundTags) != 2 || n.InboundTags[0] != "alpha" || n.InboundTags[1] != "beta" {
+		t.Fatalf("InboundTags = %#v, want [alpha beta]", n.InboundTags)
+	}
+}
+
+func TestFilterNodeSnapshot(t *testing.T) {
+	snapshot := func() *runtime.TrafficSnapshot {
+		return &runtime.TrafficSnapshot{Inbounds: []*model.Inbound{
+			{Tag: "alpha"},
+			{Tag: "beta"},
+			{Tag: "gamma"},
+		}}
+	}
+
+	all := snapshot()
+	FilterNodeSnapshot(&model.Node{InboundSyncMode: "all"}, all)
+	if len(all.Inbounds) != 3 {
+		t.Fatalf("all mode kept %d inbounds, want 3", len(all.Inbounds))
+	}
+
+	selected := snapshot()
+	FilterNodeSnapshot(&model.Node{
+		InboundSyncMode: "selected",
+		InboundTags:     []string{"beta"},
+	}, selected)
+	if len(selected.Inbounds) != 1 || selected.Inbounds[0].Tag != "beta" {
+		t.Fatalf("selected mode produced %#v, want only beta", selected.Inbounds)
+	}
+
+	none := snapshot()
+	FilterNodeSnapshot(&model.Node{InboundSyncMode: "selected"}, none)
+	if len(none.Inbounds) != 0 {
+		t.Fatalf("empty selection kept %d inbounds, want 0", len(none.Inbounds))
+	}
+}

+ 5 - 15
internal/web/service/outbound/outbound.go

@@ -399,7 +399,7 @@ func (s *OutboundService) testOutboundHTTP(outboundJSON string, testURL string,
 		return &TestOutboundResult{Mode: "http", Success: false, Error: fmt.Sprintf("Xray process exited: %s", result)}, nil
 	}
 
-	return pollObservatoryResult(testProcess, metricsPort, outboundTag, 12*time.Second), nil
+	return pollObservatoryResult(testProcess, metricsPort, outboundTag, 15*time.Second), nil
 }
 
 // outboundsContainTag reports whether any outbound in the slice has the given tag.
@@ -414,11 +414,6 @@ func outboundsContainTag(outbounds []any, tag string) bool {
 	return false
 }
 
-// createTestConfig builds a probe-only xray config: the original outbounds
-// are kept as-is so dialerProxy chains still resolve, a burstObservatory
-// is wired to probe the target tag, and a metrics listener exposes the
-// observatory snapshot via /debug/vars. No inbound or routing rules are
-// needed — burstObservatory issues the probe traffic itself.
 func (s *OutboundService) createTestConfig(outboundTag string, allOutbounds []any, metricsPort int, probeURL string) *xray.Config {
 	processedOutbounds := make([]any, len(allOutbounds))
 	for i, ob := range allOutbounds {
@@ -449,7 +444,7 @@ func (s *OutboundService) createTestConfig(outboundTag string, allOutbounds []an
 			"destination":   probeURL,
 			"interval":      "1s",
 			"connectivity":  "",
-			"timeout":       "5s",
+			"timeout":       "10s",
 			"samplingCount": 1,
 		},
 	})
@@ -462,7 +457,7 @@ func (s *OutboundService) createTestConfig(outboundTag string, allOutbounds []an
 	logConfig := map[string]any{
 		"loglevel": "warning",
 		"access":   "none",
-		"error":    "none",
+		"error":    "",
 		"dnsLog":   false,
 	}
 	logJSON, _ := json.Marshal(logConfig)
@@ -491,11 +486,6 @@ type observatoryEntry struct {
 	OutboundTag  string `json:"outbound_tag"`
 }
 
-// pollObservatoryResult repeatedly reads /debug/vars and returns as soon
-// as the target outbound reports alive=true. burstObservatory updates the
-// snapshot after each ping (interval=1s, timeout=5s), so a healthy
-// outbound usually surfaces within ~2s and the timeout caps the wait for
-// truly dead ones.
 func pollObservatoryResult(testProcess *xray.Process, metricsPort int, tag string, timeout time.Duration) *TestOutboundResult {
 	url := fmt.Sprintf("http://127.0.0.1:%d/debug/vars", metricsPort)
 	client := &http.Client{Timeout: 2 * time.Second}
@@ -522,9 +512,9 @@ func pollObservatoryResult(testProcess *xray.Process, metricsPort int, tag strin
 		time.Sleep(400 * time.Millisecond)
 	}
 
-	msg := "Probe timed out — outbound did not become reachable"
+	msg := "Probe timed out — outbound did not become reachable (see Xray log for details)"
 	if sawEntry && lastEntry.LastTryTime > 0 {
-		msg = fmt.Sprintf("All probes failed (last attempt %ds ago)", time.Now().Unix()-lastEntry.LastTryTime)
+		msg = fmt.Sprintf("All probes failed (last attempt %ds ago; see Xray log for details)", time.Now().Unix()-lastEntry.LastTryTime)
 	}
 	return &TestOutboundResult{Mode: "http", Success: false, Error: msg}
 }

+ 31 - 19
internal/web/service/setting.go

@@ -798,7 +798,18 @@ func (s *SettingService) SetRestartXrayOnClientDisable(value bool) error {
 	return s.setBool("restartXrayOnClientDisable", value)
 }
 
+// GetIpLimitEnable reports whether the IP-limit feature is available. Always
+// true since the panel enforces limits via the core's online-stats API; on an
+// older core the job falls back to access-log parsing and warns there when the
+// log is missing, so the UI no longer hides the field behind that condition.
 func (s *SettingService) GetIpLimitEnable() (bool, error) {
+	return true, nil
+}
+
+// GetAccessLogEnable reports whether an Xray access log is configured. Used by
+// the UI for features that genuinely read the log file (the xray log viewer) —
+// distinct from IP limiting, which works without it.
+func (s *SettingService) GetAccessLogEnable() (bool, error) {
 	accessLogPath, err := xray.GetAccessLogPath()
 	if err != nil {
 		return false, err
@@ -1022,25 +1033,26 @@ func (s *SettingService) BuildSubURIBase(host string) string {
 func (s *SettingService) GetDefaultSettings(host string) (any, error) {
 	type settingFunc func() (any, error)
 	settings := map[string]settingFunc{
-		"expireDiff":     func() (any, error) { return s.GetExpireDiff() },
-		"trafficDiff":    func() (any, error) { return s.GetTrafficDiff() },
-		"pageSize":       func() (any, error) { return s.GetPageSize() },
-		"defaultCert":    func() (any, error) { return s.GetCertFile() },
-		"defaultKey":     func() (any, error) { return s.GetKeyFile() },
-		"tgBotEnable":    func() (any, error) { return s.GetTgbotEnabled() },
-		"subThemeDir":    func() (any, error) { return s.GetSubThemeDir() },
-		"subEnable":      func() (any, error) { return s.GetSubEnable() },
-		"subJsonEnable":  func() (any, error) { return s.GetSubJsonEnable() },
-		"subClashEnable": func() (any, error) { return s.GetSubClashEnable() },
-		"subTitle":       func() (any, error) { return s.GetSubTitle() },
-		"subURI":         func() (any, error) { return s.GetSubURI() },
-		"subJsonURI":     func() (any, error) { return s.GetSubJsonURI() },
-		"subClashURI":    func() (any, error) { return s.GetSubClashURI() },
-		"remarkModel":    func() (any, error) { return s.GetRemarkModel() },
-		"datepicker":     func() (any, error) { return s.GetDatepicker() },
-		"ipLimitEnable":  func() (any, error) { return s.GetIpLimitEnable() },
-		"webDomain":      func() (any, error) { return s.GetWebDomain() },
-		"subDomain":      func() (any, error) { return s.GetSubDomain() },
+		"expireDiff":      func() (any, error) { return s.GetExpireDiff() },
+		"trafficDiff":     func() (any, error) { return s.GetTrafficDiff() },
+		"pageSize":        func() (any, error) { return s.GetPageSize() },
+		"defaultCert":     func() (any, error) { return s.GetCertFile() },
+		"defaultKey":      func() (any, error) { return s.GetKeyFile() },
+		"tgBotEnable":     func() (any, error) { return s.GetTgbotEnabled() },
+		"subThemeDir":     func() (any, error) { return s.GetSubThemeDir() },
+		"subEnable":       func() (any, error) { return s.GetSubEnable() },
+		"subJsonEnable":   func() (any, error) { return s.GetSubJsonEnable() },
+		"subClashEnable":  func() (any, error) { return s.GetSubClashEnable() },
+		"subTitle":        func() (any, error) { return s.GetSubTitle() },
+		"subURI":          func() (any, error) { return s.GetSubURI() },
+		"subJsonURI":      func() (any, error) { return s.GetSubJsonURI() },
+		"subClashURI":     func() (any, error) { return s.GetSubClashURI() },
+		"remarkModel":     func() (any, error) { return s.GetRemarkModel() },
+		"datepicker":      func() (any, error) { return s.GetDatepicker() },
+		"ipLimitEnable":   func() (any, error) { return s.GetIpLimitEnable() },
+		"accessLogEnable": func() (any, error) { return s.GetAccessLogEnable() },
+		"webDomain":       func() (any, error) { return s.GetWebDomain() },
+		"subDomain":       func() (any, error) { return s.GetSubDomain() },
 	}
 
 	result := make(map[string]any)

+ 117 - 3
internal/web/service/xray.go

@@ -116,6 +116,7 @@ func (s *XrayService) GetXrayConfig() (*xray.Config, error) {
 	}
 	xrayConfig.LogConfig = resolveXrayLogPaths(xrayConfig.LogConfig)
 	xrayConfig.API = ensureAPIServices(xrayConfig.API)
+	xrayConfig.Policy = ensureStatsPolicy(xrayConfig.Policy)
 
 	_, _, _ = s.inboundService.AddTraffic(nil, nil)
 
@@ -316,9 +317,17 @@ func injectPanelEgress(cfg *xray.Config, outboundTag string) {
 	}
 	rules, _ := routing["rules"].([]any)
 	rule := map[string]any{
-		"type":        "field",
-		"inboundTag":  []any{PanelEgressInboundTag},
-		"outboundTag": outboundTag,
+		"type":       "field",
+		"inboundTag": []any{PanelEgressInboundTag},
+	}
+	// The configured tag may name a routing balancer instead of a concrete
+	// outbound. A field rule can target either, so emit the matching key —
+	// balancerTag load-balances the panel's own traffic across the balancer's
+	// outbounds, while a plain outbound tag keeps the original behavior.
+	if routingTagIsBalancer(routing, outboundTag) {
+		rule["balancerTag"] = outboundTag
+	} else {
+		rule["outboundTag"] = outboundTag
 	}
 	routing["rules"] = append([]any{rule}, rules...)
 	newRouting, err := json.Marshal(routing)
@@ -349,6 +358,29 @@ func injectPanelEgress(cfg *xray.Config, outboundTag string) {
 	})
 }
 
+// routingTagIsBalancer reports whether tag names a balancer in the parsed
+// routing section. The panel-egress rule targets a balancer via balancerTag and
+// a concrete outbound via outboundTag, so the caller picks the key from this.
+func routingTagIsBalancer(routing map[string]any, tag string) bool {
+	if tag == "" {
+		return false
+	}
+	balancers, ok := routing["balancers"].([]any)
+	if !ok {
+		return false
+	}
+	for _, b := range balancers {
+		bm, ok := b.(map[string]any)
+		if !ok {
+			continue
+		}
+		if t, ok := bm["tag"].(string); ok && t == tag {
+			return true
+		}
+	}
+	return false
+}
+
 // mergeSubscriptionOutbounds appends the subscription outbounds to the
 // OutboundConfigs array of the xray config. It works on the already-unmarshaled
 // template so that manually configured outbounds are never overwritten.
@@ -421,6 +453,51 @@ func ensureAPIServices(api json_util.RawMessage) json_util.RawMessage {
 	return out
 }
 
+// ensureStatsPolicy guarantees every policy level in the generated config has
+// statsUserOnline enabled, so the core tracks per-email online IPs for the
+// panel's online view and access-log-free IP limiting. Generated clients carry
+// no explicit level, so level "0" is created when absent. The flag is panel
+// infrastructure and is forced on even over an explicit false in the template,
+// same as the api services above. An entirely missing or unparsable policy
+// block is left alone; the stored template itself is never modified — only the
+// generated runtime config.
+func ensureStatsPolicy(policy json_util.RawMessage) json_util.RawMessage {
+	if len(policy) == 0 {
+		return policy
+	}
+	var parsed map[string]any
+	if err := json.Unmarshal(policy, &parsed); err != nil {
+		return policy
+	}
+	levels, _ := parsed["levels"].(map[string]any)
+	if levels == nil {
+		levels = make(map[string]any)
+	}
+	if _, ok := levels["0"]; !ok {
+		levels["0"] = map[string]any{}
+	}
+	changed := false
+	for _, raw := range levels {
+		level, ok := raw.(map[string]any)
+		if !ok {
+			continue
+		}
+		if enabled, ok := level["statsUserOnline"].(bool); !ok || !enabled {
+			level["statsUserOnline"] = true
+			changed = true
+		}
+	}
+	if !changed {
+		return policy
+	}
+	parsed["levels"] = levels
+	out, err := json.Marshal(parsed)
+	if err != nil {
+		return policy
+	}
+	return out
+}
+
 // resolveXrayLogPaths rewrites relative `log.access` / `log.error` values to
 // absolute paths under config.GetLogFolder(), so Xray writes those files
 // alongside the panel's other logs regardless of the working directory the
@@ -493,6 +570,43 @@ func (s *XrayService) GetXrayTraffic() ([]*xray.Traffic, []*xray.ClientTraffic,
 	return traffic, clientTraffic, nil
 }
 
+// GetOnlineUsers returns connection-based online users (email + source IPs)
+// from the running core's online-stats API. ok=false means the API is not
+// available — xray isn't running or the core predates the online-stats RPCs —
+// and callers must use the legacy traffic-delta / access-log paths. The
+// capability is probed lazily per process: an Unimplemented answer pins this
+// core as unsupported until the next restart, while transient errors leave the
+// capability undecided so a flaky poll can't lock in legacy mode.
+func (s *XrayService) GetOnlineUsers() ([]xray.OnlineUser, bool, error) {
+	if !s.IsXrayRunning() {
+		return nil, false, nil
+	}
+	if p.OnlineAPISupport() == xray.OnlineAPIUnsupported {
+		return nil, false, nil
+	}
+	if err := s.xrayAPI.Init(p.GetAPIPort()); err != nil {
+		logger.Debug("Failed to initialize Xray API:", err)
+		return nil, false, err
+	}
+	defer s.xrayAPI.Close()
+
+	users, err := s.xrayAPI.GetOnlineUsers()
+	if err != nil {
+		if xray.IsUnimplementedErr(err) {
+			p.SetOnlineAPISupport(xray.OnlineAPIUnsupported)
+			logger.Info("xray core does not support the online-stats API; falling back to traffic-delta onlines and access-log IP limit")
+			return nil, false, nil
+		}
+		logger.Debug("Failed to fetch Xray online users:", err)
+		return nil, false, err
+	}
+	if p.OnlineAPISupport() == xray.OnlineAPIUnknown {
+		p.SetOnlineAPISupport(xray.OnlineAPISupported)
+		logger.Info("xray core supports the online-stats API; using connection-based onlines and access-log-free IP limit")
+	}
+	return users, true, nil
+}
+
 // BalancerStatus is the live view of one balancer for the panel UI. Running
 // is false when the balancer isn't present in the running core (e.g. xray is
 // stopped or the balancer hasn't been saved/applied yet).

+ 114 - 0
internal/web/service/xray_config_inject_test.go

@@ -54,6 +54,71 @@ func TestEnsureAPIServices(t *testing.T) {
 	}
 }
 
+func TestEnsureStatsPolicy(t *testing.T) {
+	// default-template shape: level "0" exists with traffic flags — the online
+	// flag is added and the siblings survive untouched
+	out := ensureStatsPolicy(json_util.RawMessage(`{"levels":{"0":{"handshake":4,"statsUserUplink":true,"statsUserDownlink":true}},"system":{"statsInboundDownlink":true}}`))
+	var parsed struct {
+		Levels map[string]map[string]any `json:"levels"`
+		System map[string]any            `json:"system"`
+	}
+	if err := json.Unmarshal(out, &parsed); err != nil {
+		t.Fatal(err)
+	}
+	level0 := parsed.Levels["0"]
+	if level0["statsUserOnline"] != true {
+		t.Fatalf("statsUserOnline must be injected into level 0, got %v", level0)
+	}
+	if level0["statsUserUplink"] != true || level0["statsUserDownlink"] != true || level0["handshake"] != float64(4) {
+		t.Fatalf("sibling keys must be preserved, got %v", level0)
+	}
+	if parsed.System["statsInboundDownlink"] != true {
+		t.Fatalf("system block must be preserved, got %v", parsed.System)
+	}
+
+	// missing levels block: level "0" is created with the flag
+	out = ensureStatsPolicy(json_util.RawMessage(`{"system":{}}`))
+	if err := json.Unmarshal(out, &parsed); err != nil {
+		t.Fatal(err)
+	}
+	if parsed.Levels["0"]["statsUserOnline"] != true {
+		t.Fatalf("level 0 must be created with statsUserOnline, got %s", out)
+	}
+
+	// every level gets the flag, an explicit false included — the flag is
+	// panel infrastructure, like the api services
+	out = ensureStatsPolicy(json_util.RawMessage(`{"levels":{"0":{"statsUserOnline":false},"1":{"connIdle":300}}}`))
+	if err := json.Unmarshal(out, &parsed); err != nil {
+		t.Fatal(err)
+	}
+	for _, key := range []string{"0", "1"} {
+		if parsed.Levels[key]["statsUserOnline"] != true {
+			t.Fatalf("level %s must have statsUserOnline forced on, got %s", key, out)
+		}
+	}
+	if parsed.Levels["1"]["connIdle"] != float64(300) {
+		t.Fatalf("level 1 siblings must be preserved, got %s", out)
+	}
+
+	// already-enabled input passes through byte-identical (no marshal churn,
+	// no spurious restart)
+	full := json_util.RawMessage(`{"levels":{"0":{"statsUserOnline":true}}}`)
+	if got := ensureStatsPolicy(full); string(got) != string(full) {
+		t.Fatalf("already-enabled policy must pass through untouched, got %s", got)
+	}
+
+	// absent policy block stays absent
+	if got := ensureStatsPolicy(nil); got != nil {
+		t.Fatalf("nil policy must stay nil, got %s", got)
+	}
+
+	// unparsable policy is left untouched
+	bad := json_util.RawMessage(`{not json`)
+	if got := ensureStatsPolicy(bad); string(got) != string(bad) {
+		t.Fatalf("unparsable policy must be left untouched, got %s", got)
+	}
+}
+
 func egressTestConfig() *xray.Config {
 	return &xray.Config{
 		RouterConfig: json_util.RawMessage(`{"domainStrategy":"AsIs","rules":[{"type":"field","inboundTag":["api"],"outboundTag":"api"}]}`),
@@ -104,6 +169,55 @@ func TestInjectPanelEgress(t *testing.T) {
 	}
 }
 
+func TestInjectPanelEgress_BalancerTag(t *testing.T) {
+	cfg := egressTestConfig()
+	cfg.RouterConfig = json_util.RawMessage(`{"domainStrategy":"AsIs","rules":[],"balancers":[{"tag":"lb","selector":["warp"]}]}`)
+
+	// A tag that names a balancer must be targeted via balancerTag so the
+	// router resolves it; an outbound tag coexisting with balancers still uses
+	// outboundTag.
+	injectPanelEgress(cfg, "lb")
+
+	var routing struct {
+		Rules []struct {
+			InboundTag  []string `json:"inboundTag"`
+			OutboundTag string   `json:"outboundTag"`
+			BalancerTag string   `json:"balancerTag"`
+			Type        string   `json:"type"`
+		} `json:"rules"`
+	}
+	if err := json.Unmarshal(cfg.RouterConfig, &routing); err != nil {
+		t.Fatal(err)
+	}
+	if len(routing.Rules) != 1 {
+		t.Fatalf("expected the egress rule, got %+v", routing.Rules)
+	}
+	first := routing.Rules[0]
+	if first.BalancerTag != "lb" || first.OutboundTag != "" {
+		t.Fatalf("a balancer tag must target balancerTag, not outboundTag, got %+v", first)
+	}
+	if len(first.InboundTag) != 1 || first.InboundTag[0] != PanelEgressInboundTag {
+		t.Fatalf("egress rule must bind the egress inbound, got %+v", first)
+	}
+
+	// A non-balancer tag alongside balancers keeps the plain outbound path.
+	cfg2 := egressTestConfig()
+	cfg2.RouterConfig = json_util.RawMessage(`{"rules":[],"balancers":[{"tag":"lb","selector":["warp"]}]}`)
+	injectPanelEgress(cfg2, "warp")
+	var routing2 struct {
+		Rules []struct {
+			OutboundTag string `json:"outboundTag"`
+			BalancerTag string `json:"balancerTag"`
+		} `json:"rules"`
+	}
+	if err := json.Unmarshal(cfg2.RouterConfig, &routing2); err != nil {
+		t.Fatal(err)
+	}
+	if routing2.Rules[0].OutboundTag != "warp" || routing2.Rules[0].BalancerTag != "" {
+		t.Fatalf("a concrete outbound must target outboundTag, got %+v", routing2.Rules[0])
+	}
+}
+
 func TestInjectPanelEgress_PortCollision(t *testing.T) {
 	cfg := egressTestConfig()
 	cfg.InboundConfigs = append(cfg.InboundConfigs,

+ 25 - 3
internal/web/translation/ar-EG.json

@@ -282,7 +282,8 @@
         "quickAdded": "تمت إضافة {n} fallback",
         "quickAddedNone": "لا توجد inbounds جديدة مؤهلة للإضافة",
         "routesWhen": "يوجَّه عندما",
-        "defaultCatchAll": "افتراضي — يلتقط أي شيء آخر"
+        "defaultCatchAll": "افتراضي — يلتقط أي شيء آخر",
+        "needsTls": "تصبح الـ Fallbacks متاحة بعد اختيار TLS أو Reality في تبويب الأمان (فقط VLESS/Trojan عبر RAW)."
       },
       "protocol": "بروتوكول",
       "port": "المنفذ",
@@ -589,7 +590,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": "الوضع",
@@ -627,6 +637,8 @@
       }
     },
     "clients": {
+      "tabBasic": "أساسي",
+      "tabConfig": "التكوين",
       "add": "إضافة عميل",
       "edit": "تعديل العميل",
       "submitAdd": "إضافة عميل",
@@ -844,11 +856,21 @@
       "basePath": "المسار الأساسي",
       "apiToken": "رمز API",
       "apiTokenPlaceholder": "التوكن من صفحة إعدادات البانل البعيد",
-      "apiTokenHint": "البانل البعيد بيعرض توكن API بتاعه في الإعدادات → توكن API.",
+      "apiTokenHint": "البانل البعيد بيعرض توكن API بتاعه في المصادقة → توكن API.",
       "regenerate": "تجديد التوكن",
       "regenerateConfirm": "تجديد التوكن هيلغي التوكن الحالي. أي بانل مركزي بيستخدمه هيفقد الصلاحية لحد ما تحدّث التوكن. تكمّل؟",
       "allowPrivateAddress": "السماح بالعنوان الخاص",
       "allowPrivateAddressHint": "التفعيل فقط للعقد على شبكة خاصة أو VPN.",
+      "inboundSyncMode": "استيراد الاتصالات الواردة",
+      "inboundSyncModeHint": "اختر الاتصالات الواردة التي سيتم استيرادها من هذه العقدة. تستورد العقد الحالية جميع الاتصالات افتراضيًا.",
+      "allInbounds": "جميع الاتصالات الواردة",
+      "selectedInbounds": "الاتصالات الواردة المحددة",
+      "inboundTags": "الاتصالات الواردة",
+      "inboundTagsHint": "تتم المطابقة حسب وسم الاتصال الوارد. القائمة الفارغة لا تستورد أي اتصال.",
+      "inboundTagsPlaceholder": "حمّل الاتصالات الواردة وحددها",
+      "loadInbounds": "تحميل الاتصالات الواردة من العقدة",
+      "inboundsLoaded": "تم تحميل {{count}} اتصال وارد",
+      "inboundsLoadFailed": "فشل تحميل الاتصالات الواردة",
       "enable": "مفعل",
       "status": "الحالة",
       "cpu": "CPU",

+ 25 - 3
internal/web/translation/en-US.json

@@ -282,7 +282,8 @@
         "quickAdded": "Added {n} fallback(s)",
         "quickAddedNone": "No new eligible inbounds to add",
         "routesWhen": "Routes when",
-        "defaultCatchAll": "Default — catches anything else"
+        "defaultCatchAll": "Default — catches anything else",
+        "needsTls": "Fallbacks become available once Security is set to TLS or Reality on the Security tab (VLESS/Trojan over RAW only)."
       },
       "protocol": "Protocol",
       "port": "Port",
@@ -590,7 +591,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, QR codes, and subscription output.",
+        "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",
@@ -628,6 +638,8 @@
       }
     },
     "clients": {
+      "tabBasic": "Basic",
+      "tabConfig": "Config",
       "add": "Add Client",
       "edit": "Edit Client",
       "submitAdd": "Add Client",
@@ -845,11 +857,21 @@
       "basePath": "Base Path",
       "apiToken": "API Token",
       "apiTokenPlaceholder": "Token from the remote panel's Settings page",
-      "apiTokenHint": "The remote panel exposes its API token under Settings → API Token.",
+      "apiTokenHint": "The remote panel exposes its API token under Authentication → API Token.",
       "regenerate": "Regenerate Token",
       "regenerateConfirm": "Regenerating invalidates the current token. Any central panel using it will lose access until updated. Continue?",
       "allowPrivateAddress": "Allow private address",
       "allowPrivateAddressHint": "Enable only for nodes on a private network or VPN.",
+      "inboundSyncMode": "Inbound import",
+      "inboundSyncModeHint": "Choose which inbounds are imported from this node. Existing nodes default to all inbounds.",
+      "allInbounds": "All inbounds",
+      "selectedInbounds": "Selected inbounds",
+      "inboundTags": "Inbounds",
+      "inboundTagsHint": "Selection is matched by the inbound tag. An empty selection imports none.",
+      "inboundTagsPlaceholder": "Load and select inbounds",
+      "loadInbounds": "Load inbounds from node",
+      "inboundsLoaded": "Loaded {{count}} inbounds",
+      "inboundsLoadFailed": "Failed to load inbounds",
       "enable": "Enabled",
       "status": "Status",
       "cpu": "CPU",

+ 25 - 3
internal/web/translation/es-ES.json

@@ -282,7 +282,8 @@
         "quickAdded": "Se añadieron {n} fallback(s)",
         "quickAddedNone": "No hay nuevos inbounds elegibles",
         "routesWhen": "Enruta cuando",
-        "defaultCatchAll": "Por defecto — captura cualquier otra cosa"
+        "defaultCatchAll": "Por defecto — captura cualquier otra cosa",
+        "needsTls": "Los fallbacks estarán disponibles al seleccionar TLS o Reality en la pestaña de Seguridad (solo VLESS/Trojan sobre RAW)."
       },
       "protocol": "Protocolo",
       "port": "Puerto",
@@ -589,7 +590,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, códigos QR y la salida de suscripción.",
+        "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",
@@ -627,6 +637,8 @@
       }
     },
     "clients": {
+      "tabBasic": "Básico",
+      "tabConfig": "Configuración",
       "add": "Añadir cliente",
       "edit": "Editar cliente",
       "submitAdd": "Añadir cliente",
@@ -844,11 +856,21 @@
       "basePath": "Ruta base",
       "apiToken": "Token API",
       "apiTokenPlaceholder": "Token desde la página de Configuración del panel remoto",
-      "apiTokenHint": "El panel remoto expone su token de API en Configuración → Token de API.",
+      "apiTokenHint": "El panel remoto expone su token de API en Configuraciones de Seguridad → Token de API.",
       "regenerate": "Regenerar token",
       "regenerateConfirm": "Regenerar invalida el token actual. Cualquier panel central que lo use perderá el acceso hasta que se actualice. ¿Continuar?",
       "allowPrivateAddress": "Permitir dirección privada",
       "allowPrivateAddressHint": "Habilitar solo para nodos en una red privada o VPN.",
+      "inboundSyncMode": "Importación de inbounds",
+      "inboundSyncModeHint": "Elige qué inbounds importar desde este nodo. Los nodos existentes importan todos de forma predeterminada.",
+      "allInbounds": "Todos los inbounds",
+      "selectedInbounds": "Inbounds seleccionados",
+      "inboundTags": "Inbounds",
+      "inboundTagsHint": "La selección se compara por la etiqueta del inbound. Una selección vacía no importa ninguno.",
+      "inboundTagsPlaceholder": "Carga y selecciona inbounds",
+      "loadInbounds": "Cargar inbounds desde el nodo",
+      "inboundsLoaded": "Se cargaron {{count}} inbounds",
+      "inboundsLoadFailed": "No se pudieron cargar los inbounds",
       "enable": "Habilitado",
       "status": "Estado",
       "cpu": "CPU",

+ 25 - 3
internal/web/translation/fa-IR.json

@@ -282,7 +282,8 @@
         "quickAdded": "{n} فال‌بک افزوده شد",
         "quickAddedNone": "اینباند جدیدی برای افزودن وجود ندارد",
         "routesWhen": "هدایت می‌شود وقتی",
-        "defaultCatchAll": "پیش‌فرض — همه‌ی موارد دیگر را می‌گیرد"
+        "defaultCatchAll": "پیش‌فرض — همه‌ی موارد دیگر را می‌گیرد",
+        "needsTls": "فال‌بک‌ها پس از انتخاب TLS یا Reality در برگه‌ی امنیت در دسترس می‌شوند (فقط VLESS/Trojan روی RAW)."
       },
       "protocol": "پروتکل",
       "port": "پورت",
@@ -589,7 +590,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": "حالت",
@@ -627,6 +637,8 @@
       }
     },
     "clients": {
+      "tabBasic": "پایه",
+      "tabConfig": "پیکربندی",
       "add": "افزودن کلاینت",
       "edit": "ویرایش کلاینت",
       "submitAdd": "افزودن کلاینت",
@@ -844,11 +856,21 @@
       "basePath": "مسیر پایه",
       "apiToken": "توکن API",
       "apiTokenPlaceholder": "توکن از صفحه تنظیمات پنل ریموت",
-      "apiTokenHint": "پنل ریموت توکن API خودش را در بخش تنظیمات → توکن API نمایش می‌دهد.",
+      "apiTokenHint": "پنل ریموت توکن API خودش را در بخش احرازهویت → توکن API نمایش می‌دهد.",
       "regenerate": "تولید مجدد توکن",
       "regenerateConfirm": "تولید مجدد، توکن فعلی را باطل می‌کند. هر پنل مرکزی‌ای که از این توکن استفاده می‌کند تا زمان به‌روزرسانی، دسترسی‌اش قطع می‌شود. ادامه می‌دهید؟",
       "allowPrivateAddress": "اجازه آدرس خصوصی",
       "allowPrivateAddressHint": "فقط برای نودهای روی شبکه خصوصی یا VPN فعال شود.",
+      "inboundSyncMode": "وارد کردن اینباندها",
+      "inboundSyncModeHint": "اینباندهای قابل وارد کردن از این نود را انتخاب کنید. نودهای موجود به‌طور پیش‌فرض همه را وارد می‌کنند.",
+      "allInbounds": "همه اینباندها",
+      "selectedInbounds": "اینباندهای انتخاب‌شده",
+      "inboundTags": "اینباندها",
+      "inboundTagsHint": "انتخاب بر اساس تگ اینباند تطبیق داده می‌شود. انتخاب خالی چیزی وارد نمی‌کند.",
+      "inboundTagsPlaceholder": "اینباندها را بارگیری و انتخاب کنید",
+      "loadInbounds": "بارگیری اینباندها از نود",
+      "inboundsLoaded": "{{count}} اینباند بارگیری شد",
+      "inboundsLoadFailed": "بارگیری اینباندها ناموفق بود",
       "enable": "فعال",
       "status": "وضعیت",
       "cpu": "CPU",

+ 25 - 3
internal/web/translation/id-ID.json

@@ -282,7 +282,8 @@
         "quickAdded": "Menambahkan {n} fallback",
         "quickAddedNone": "Tidak ada inbound baru yang memenuhi syarat",
         "routesWhen": "Diarahkan ketika",
-        "defaultCatchAll": "Default — menangkap apa pun lainnya"
+        "defaultCatchAll": "Default — menangkap apa pun lainnya",
+        "needsTls": "Fallback tersedia setelah memilih TLS atau Reality di tab Keamanan (hanya VLESS/Trojan melalui RAW)."
       },
       "protocol": "Protokol",
       "port": "Port",
@@ -589,7 +590,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, kode QR, dan keluaran langganan.",
+        "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",
@@ -627,6 +637,8 @@
       }
     },
     "clients": {
+      "tabBasic": "Dasar",
+      "tabConfig": "Konfigurasi",
       "add": "Tambah klien",
       "edit": "Ubah klien",
       "submitAdd": "Tambah klien",
@@ -844,11 +856,21 @@
       "basePath": "Path dasar",
       "apiToken": "Token API",
       "apiTokenPlaceholder": "Token dari halaman Pengaturan panel jarak jauh",
-      "apiTokenHint": "Panel jarak jauh menampilkan token API-nya di Pengaturan → Token API.",
+      "apiTokenHint": "Panel jarak jauh menampilkan token API-nya di Otentikasi → Token API.",
       "regenerate": "Buat Ulang Token",
       "regenerateConfirm": "Membuat ulang akan membatalkan token saat ini. Setiap panel pusat yang menggunakannya akan kehilangan akses sampai diperbarui. Lanjutkan?",
       "allowPrivateAddress": "Izinkan alamat pribadi",
       "allowPrivateAddressHint": "Aktifkan hanya untuk node di jaringan pribadi atau VPN.",
+      "inboundSyncMode": "Impor inbound",
+      "inboundSyncModeHint": "Pilih inbound yang diimpor dari node ini. Node yang sudah ada mengimpor semua inbound secara default.",
+      "allInbounds": "Semua inbound",
+      "selectedInbounds": "Inbound terpilih",
+      "inboundTags": "Inbound",
+      "inboundTagsHint": "Pilihan dicocokkan berdasarkan tag inbound. Pilihan kosong tidak mengimpor apa pun.",
+      "inboundTagsPlaceholder": "Muat dan pilih inbound",
+      "loadInbounds": "Muat inbound dari node",
+      "inboundsLoaded": "{{count}} inbound dimuat",
+      "inboundsLoadFailed": "Gagal memuat inbound",
       "enable": "Aktif",
       "status": "Status",
       "cpu": "CPU",

+ 25 - 3
internal/web/translation/ja-JP.json

@@ -282,7 +282,8 @@
         "quickAdded": "{n} 件のフォールバックを追加しました",
         "quickAddedNone": "追加可能な新規インバウンドはありません",
         "routesWhen": "次の条件でルーティング",
-        "defaultCatchAll": "デフォルト — その他すべてを捕捉"
+        "defaultCatchAll": "デフォルト — その他すべてを捕捉",
+        "needsTls": "フォールバックは、セキュリティタブで TLS または Reality を選択すると設定できます(RAW 上の VLESS/Trojan のみ)。"
       },
       "protocol": "プロトコル",
       "port": "ポート",
@@ -589,7 +590,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": "モード",
@@ -627,6 +637,8 @@
       }
     },
     "clients": {
+      "tabBasic": "基本",
+      "tabConfig": "設定",
       "add": "クライアントを追加",
       "edit": "クライアントを編集",
       "submitAdd": "クライアントを追加",
@@ -844,11 +856,21 @@
       "basePath": "ベースパス",
       "apiToken": "API トークン",
       "apiTokenPlaceholder": "リモートパネルの設定ページから取得したトークン",
-      "apiTokenHint": "リモートパネルでは、設定 → APIトークン でAPIトークンを確認できます。",
+      "apiTokenHint": "リモートパネルでは、セキュリティ設定 → APIトークン でAPIトークンを確認できます。",
       "regenerate": "トークンを再生成",
       "regenerateConfirm": "再生成すると現在のトークンは無効になります。これを使用しているすべての中央パネルは更新されるまでアクセスできなくなります。続行しますか?",
       "allowPrivateAddress": "プライベートアドレスを許可",
       "allowPrivateAddressHint": "プライベートネットワークまたはVPN上のノードにのみ有効にします。",
+      "inboundSyncMode": "インバウンドのインポート",
+      "inboundSyncModeHint": "このノードからインポートするインバウンドを選択します。既存のノードは既定ですべてをインポートします。",
+      "allInbounds": "すべてのインバウンド",
+      "selectedInbounds": "選択したインバウンド",
+      "inboundTags": "インバウンド",
+      "inboundTagsHint": "インバウンドタグで照合します。何も選択しない場合はインポートされません。",
+      "inboundTagsPlaceholder": "インバウンドを読み込んで選択",
+      "loadInbounds": "ノードからインバウンドを読み込む",
+      "inboundsLoaded": "{{count}}件のインバウンドを読み込みました",
+      "inboundsLoadFailed": "インバウンドを読み込めませんでした",
       "enable": "有効",
       "status": "ステータス",
       "cpu": "CPU",

+ 25 - 3
internal/web/translation/pt-BR.json

@@ -282,7 +282,8 @@
         "quickAdded": "{n} fallback(s) adicionado(s)",
         "quickAddedNone": "Nenhum inbound novo elegível para adicionar",
         "routesWhen": "Roteia quando",
-        "defaultCatchAll": "Padrão — captura qualquer outra coisa"
+        "defaultCatchAll": "Padrão — captura qualquer outra coisa",
+        "needsTls": "Os fallbacks ficam disponíveis após selecionar TLS ou Reality na aba Segurança (apenas VLESS/Trojan sobre RAW)."
       },
       "protocol": "Protocolo",
       "port": "Porta",
@@ -589,7 +590,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, códigos QR e na saída de assinatura.",
+        "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",
@@ -627,6 +637,8 @@
       }
     },
     "clients": {
+      "tabBasic": "Básico",
+      "tabConfig": "Configuração",
       "add": "Adicionar cliente",
       "edit": "Editar cliente",
       "submitAdd": "Adicionar cliente",
@@ -844,11 +856,21 @@
       "basePath": "Caminho base",
       "apiToken": "Token API",
       "apiTokenPlaceholder": "Token da página de Configurações do painel remoto",
-      "apiTokenHint": "O painel remoto exibe o token da API em Configurações → Token da API.",
+      "apiTokenHint": "O painel remoto exibe o token da API em Autenticação → Token da API.",
       "regenerate": "Regenerar token",
       "regenerateConfirm": "Regenerar invalida o token atual. Qualquer painel central que o utilize perderá acesso até ser atualizado. Continuar?",
       "allowPrivateAddress": "Permitir endereço privado",
       "allowPrivateAddressHint": "Ativar apenas para nós em uma rede privada ou VPN.",
+      "inboundSyncMode": "Importação de inbounds",
+      "inboundSyncModeHint": "Escolha quais inbounds importar deste nó. Nós existentes importam todos por padrão.",
+      "allInbounds": "Todos os inbounds",
+      "selectedInbounds": "Inbounds selecionados",
+      "inboundTags": "Inbounds",
+      "inboundTagsHint": "A seleção é comparada pela tag do inbound. Uma seleção vazia não importa nenhum.",
+      "inboundTagsPlaceholder": "Carregue e selecione inbounds",
+      "loadInbounds": "Carregar inbounds do nó",
+      "inboundsLoaded": "{{count}} inbounds carregados",
+      "inboundsLoadFailed": "Falha ao carregar inbounds",
       "enable": "Ativado",
       "status": "Status",
       "cpu": "CPU",

+ 25 - 3
internal/web/translation/ru-RU.json

@@ -282,7 +282,8 @@
         "quickAdded": "Добавлено {n} фолбэк(ов)",
         "quickAddedNone": "Нет новых подходящих инбаундов",
         "routesWhen": "Маршрутизирует, когда",
-        "defaultCatchAll": "По умолчанию — ловит всё остальное"
+        "defaultCatchAll": "По умолчанию — ловит всё остальное",
+        "needsTls": "Fallbacks станут доступны после выбора TLS или Reality на вкладке «Безопасность» (только VLESS/Trojan поверх RAW)."
       },
       "protocol": "Протокол",
       "port": "Порт",
@@ -589,7 +590,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": "Режим",
@@ -627,6 +637,8 @@
       }
     },
     "clients": {
+      "tabBasic": "Основные",
+      "tabConfig": "Конфигурация",
       "add": "Добавить клиента",
       "edit": "Изменить клиента",
       "submitAdd": "Добавить клиента",
@@ -844,11 +856,21 @@
       "basePath": "Базовый путь",
       "apiToken": "API Токен",
       "apiTokenPlaceholder": "Токен со страницы Настроек удалённой панели",
-      "apiTokenHint": "Удалённая панель показывает свой токен API в разделе Настройки → Токен API.",
+      "apiTokenHint": "Удалённая панель показывает свой токен API в разделе Учетная запись → Токен API.",
       "regenerate": "Сгенерировать токен заново",
       "regenerateConfirm": "Повторная генерация аннулирует текущий токен. Любая центральная панель, использующая его, потеряет доступ до обновления. Продолжить?",
       "allowPrivateAddress": "Разрешить частный адрес",
       "allowPrivateAddressHint": "Включить только для узлов в частной сети или VPN.",
+      "inboundSyncMode": "Импорт инбаундов",
+      "inboundSyncModeHint": "Выберите, какие инбаунды импортировать с этой ноды. Для существующих нод по умолчанию импортируются все.",
+      "allInbounds": "Все инбаунды",
+      "selectedInbounds": "Выбранные инбаунды",
+      "inboundTags": "Инбаунды",
+      "inboundTagsHint": "Выбор сопоставляется по тегу инбаунда. Пустой список не импортирует ничего.",
+      "inboundTagsPlaceholder": "Загрузите и выберите инбаунды",
+      "loadInbounds": "Загрузить инбаунды с ноды",
+      "inboundsLoaded": "Загружено инбаундов: {{count}}",
+      "inboundsLoadFailed": "Не удалось загрузить инбаунды",
       "enable": "Включён",
       "status": "Статус",
       "cpu": "CPU",

+ 25 - 3
internal/web/translation/tr-TR.json

@@ -282,7 +282,8 @@
         "quickAdded": "{n} fallback eklendi",
         "quickAddedNone": "Eklenecek yeni uygun gelen bağlantı yok",
         "routesWhen": "Şu Durumda Yönlendirir",
-        "defaultCatchAll": "Varsayılan — başka her şeyi yakalar"
+        "defaultCatchAll": "Varsayılan — başka her şeyi yakalar",
+        "needsTls": "Geri düşüşler (fallback), Güvenlik sekmesinde TLS veya Reality seçildiğinde kullanılabilir olur (yalnızca RAW üzerinde VLESS/Trojan)."
       },
       "protocol": "Protokol",
       "port": "Port",
@@ -590,7 +591,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, QR kodlarına ve abonelik çıktısına hangi adresin yazılacağını belirler.",
+        "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",
@@ -628,6 +638,8 @@
       }
     },
     "clients": {
+      "tabBasic": "Temel",
+      "tabConfig": "Yapılandırma",
       "add": "Kullanıcı Ekle",
       "edit": "Kullanıcıyı Düzenle",
       "submitAdd": "Kullanıcı Ekle",
@@ -845,11 +857,21 @@
       "basePath": "Temel Yol",
       "apiToken": "API Token",
       "apiTokenPlaceholder": "Uzak panelin Ayarlar sayfasındaki token",
-      "apiTokenHint": "Uzak panel API token'ını Ayarlar → API Token altında gösterir.",
+      "apiTokenHint": "Uzak panel API token'ını Kimlik Doğrulama → API Token altında gösterir.",
       "regenerate": "Token'ı Yeniden Oluştur",
       "regenerateConfirm": "Yeniden oluşturmak mevcut token'ı geçersiz kılar. Onu kullanan tüm merkezi paneller, güncellenene kadar erişimini kaybeder. Devam edilsin mi?",
       "allowPrivateAddress": "Özel Adrese İzin Ver",
       "allowPrivateAddressHint": "Yalnızca özel ağ veya VPN üzerindeki düğümler için etkinleştirin.",
+      "inboundSyncMode": "Inbound içe aktarma",
+      "inboundSyncModeHint": "Bu düğümden içe aktarılacak inbound'ları seçin. Mevcut düğümler varsayılan olarak tümünü içe aktarır.",
+      "allInbounds": "Tüm inbound'lar",
+      "selectedInbounds": "Seçili inbound'lar",
+      "inboundTags": "Inbound'lar",
+      "inboundTagsHint": "Seçim inbound etiketiyle eşleştirilir. Boş seçim hiçbir şeyi içe aktarmaz.",
+      "inboundTagsPlaceholder": "Inbound'ları yükleyip seçin",
+      "loadInbounds": "Inbound'ları düğümden yükle",
+      "inboundsLoaded": "{{count}} inbound yüklendi",
+      "inboundsLoadFailed": "Inbound'lar yüklenemedi",
       "enable": "Etkin",
       "status": "Durum",
       "cpu": "CPU",

+ 25 - 3
internal/web/translation/uk-UA.json

@@ -282,7 +282,8 @@
         "quickAdded": "Додано {n} фолбек(ів)",
         "quickAddedNone": "Немає нових придатних інбаундів",
         "routesWhen": "Маршрутизує, коли",
-        "defaultCatchAll": "За замовчуванням — ловить усе інше"
+        "defaultCatchAll": "За замовчуванням — ловить усе інше",
+        "needsTls": "Fallbacks стануть доступні після вибору TLS або Reality на вкладці «Безпека» (лише VLESS/Trojan поверх RAW)."
       },
       "protocol": "Протокол",
       "port": "Порт",
@@ -589,7 +590,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": "Режим",
@@ -627,6 +637,8 @@
       }
     },
     "clients": {
+      "tabBasic": "Основні",
+      "tabConfig": "Конфігурація",
       "add": "Додати клієнта",
       "edit": "Редагувати клієнта",
       "submitAdd": "Додати клієнта",
@@ -844,11 +856,21 @@
       "basePath": "Базовий шлях",
       "apiToken": "API Токен",
       "apiTokenPlaceholder": "Токен зі сторінки Налаштувань віддаленої панелі",
-      "apiTokenHint": "Віддалена панель показує свій токен API в Налаштуваннях → Токен API.",
+      "apiTokenHint": "Віддалена панель показує свій токен API в Автентифікація → Токен API.",
       "regenerate": "Перегенерувати токен",
       "regenerateConfirm": "Перегенерація скасовує поточний токен. Будь-яка центральна панель, що його використовує, втратить доступ до оновлення. Продовжити?",
       "allowPrivateAddress": "Дозволити приватну адресу",
       "allowPrivateAddressHint": "Увімкнути лише для вузлів у приватній мережі або VPN.",
+      "inboundSyncMode": "Імпорт інбаундів",
+      "inboundSyncModeHint": "Виберіть інбаунди для імпорту з цього вузла. Для наявних вузлів типово імпортуються всі.",
+      "allInbounds": "Усі інбаунди",
+      "selectedInbounds": "Вибрані інбаунди",
+      "inboundTags": "Інбаунди",
+      "inboundTagsHint": "Вибір зіставляється за тегом інбаунду. Порожній список нічого не імпортує.",
+      "inboundTagsPlaceholder": "Завантажте та виберіть інбаунди",
+      "loadInbounds": "Завантажити інбаунди з вузла",
+      "inboundsLoaded": "Завантажено інбаундів: {{count}}",
+      "inboundsLoadFailed": "Не вдалося завантажити інбаунди",
       "enable": "Увімкнено",
       "status": "Статус",
       "cpu": "CPU",

+ 25 - 3
internal/web/translation/vi-VN.json

@@ -282,7 +282,8 @@
         "quickAdded": "Đã thêm {n} fallback",
         "quickAddedNone": "Không có inbound mới nào đủ điều kiện",
         "routesWhen": "Định tuyến khi",
-        "defaultCatchAll": "Mặc định — bắt mọi thứ khác"
+        "defaultCatchAll": "Mặc định — bắt mọi thứ khác",
+        "needsTls": "Fallback khả dụng sau khi chọn TLS hoặc Reality trong thẻ Bảo mật (chỉ VLESS/Trojan trên RAW)."
       },
       "protocol": "Giao thức",
       "port": "Cổng",
@@ -589,7 +590,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, mã QR và nội dung đăng ký.",
+        "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ế độ",
@@ -627,6 +637,8 @@
       }
     },
     "clients": {
+      "tabBasic": "Cơ bản",
+      "tabConfig": "Cấu hình",
       "add": "Thêm khách hàng",
       "edit": "Chỉnh sửa khách hàng",
       "submitAdd": "Thêm khách hàng",
@@ -844,11 +856,21 @@
       "basePath": "Đường dẫn cơ sở",
       "apiToken": "Token API",
       "apiTokenPlaceholder": "Token từ trang Cài đặt của panel từ xa",
-      "apiTokenHint": "Panel từ xa hiển thị token API tại Cài đặt → Token API.",
+      "apiTokenHint": "Panel từ xa hiển thị token API tại Bảo mật → Token API.",
       "regenerate": "Tạo lại token",
       "regenerateConfirm": "Tạo lại sẽ vô hiệu hóa token hiện tại. Mọi panel trung tâm dùng nó sẽ mất quyền truy cập cho đến khi được cập nhật. Tiếp tục?",
       "allowPrivateAddress": "Cho phép địa chỉ riêng",
       "allowPrivateAddressHint": "Chỉ bật cho các nút trên mạng riêng hoặc VPN.",
+      "inboundSyncMode": "Nhập inbound",
+      "inboundSyncModeHint": "Chọn các inbound được nhập từ nút này. Các nút hiện có mặc định nhập tất cả.",
+      "allInbounds": "Tất cả inbound",
+      "selectedInbounds": "Inbound đã chọn",
+      "inboundTags": "Inbound",
+      "inboundTagsHint": "Lựa chọn được đối chiếu theo tag inbound. Lựa chọn trống sẽ không nhập gì.",
+      "inboundTagsPlaceholder": "Tải và chọn inbound",
+      "loadInbounds": "Tải inbound từ nút",
+      "inboundsLoaded": "Đã tải {{count}} inbound",
+      "inboundsLoadFailed": "Không thể tải inbound",
       "enable": "Kích hoạt",
       "status": "Trạng thái",
       "cpu": "CPU",

+ 25 - 3
internal/web/translation/zh-CN.json

@@ -282,7 +282,8 @@
         "quickAdded": "已添加 {n} 条回落",
         "quickAddedNone": "没有可添加的新入站",
         "routesWhen": "当满足条件时路由",
-        "defaultCatchAll": "默认 — 兜底匹配其他所有"
+        "defaultCatchAll": "默认 — 兜底匹配其他所有",
+        "needsTls": "在“安全”标签页选择 TLS 或 Reality 后即可配置回落(仅限 RAW 上的 VLESS/Trojan)。"
       },
       "protocol": "协议",
       "port": "端口",
@@ -589,7 +590,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": "模式",
@@ -627,6 +637,8 @@
       }
     },
     "clients": {
+      "tabBasic": "基本",
+      "tabConfig": "配置",
       "add": "添加客户端",
       "edit": "编辑客户端",
       "submitAdd": "添加客户端",
@@ -844,11 +856,21 @@
       "basePath": "基础路径",
       "apiToken": "API 令牌",
       "apiTokenPlaceholder": "远程面板设置页中的令牌",
-      "apiTokenHint": "远程面板在 设置 → API 令牌 中显示其 API 令牌。",
+      "apiTokenHint": "远程面板在 安全设定 → API 令牌 中显示其 API 令牌。",
       "regenerate": "重新生成令牌",
       "regenerateConfirm": "重新生成会使当前令牌失效。任何使用该令牌的中央面板都会失去访问权限,直至更新。是否继续?",
       "allowPrivateAddress": "允许私有地址",
       "allowPrivateAddressHint": "仅对私有网络或VPN上的节点启用。",
+      "inboundSyncMode": "入站导入",
+      "inboundSyncModeHint": "选择要从此节点导入的入站。现有节点默认导入全部入站。",
+      "allInbounds": "全部入站",
+      "selectedInbounds": "选定的入站",
+      "inboundTags": "入站",
+      "inboundTagsHint": "按入站标签匹配。空选择不会导入任何入站。",
+      "inboundTagsPlaceholder": "加载并选择入站",
+      "loadInbounds": "从节点加载入站",
+      "inboundsLoaded": "已加载 {{count}} 个入站",
+      "inboundsLoadFailed": "加载入站失败",
       "enable": "已启用",
       "status": "状态",
       "cpu": "CPU",

+ 25 - 3
internal/web/translation/zh-TW.json

@@ -282,7 +282,8 @@
         "quickAdded": "已新增 {n} 個回落",
         "quickAddedNone": "沒有可新增的新入站",
         "routesWhen": "當條件成立時路由",
-        "defaultCatchAll": "預設 — 兜底匹配其餘"
+        "defaultCatchAll": "預設 — 兜底匹配其餘",
+        "needsTls": "在「安全」分頁選擇 TLS 或 Reality 後即可設定回落(僅限 RAW 上的 VLESS/Trojan)。"
       },
       "protocol": "協議",
       "port": "連接埠",
@@ -589,7 +590,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": "模式",
@@ -627,6 +637,8 @@
       }
     },
     "clients": {
+      "tabBasic": "基本",
+      "tabConfig": "配置",
       "add": "新增客戶端",
       "edit": "編輯客戶端",
       "submitAdd": "新增客戶端",
@@ -844,11 +856,21 @@
       "basePath": "基礎路徑",
       "apiToken": "API 權杖",
       "apiTokenPlaceholder": "遠端面板設定頁中的權杖",
-      "apiTokenHint": "遠端面板在 設定 → API 權杖 中顯示其 API 權杖。",
+      "apiTokenHint": "遠端面板在 安全設定 → API 權杖 中顯示其 API 權杖。",
       "regenerate": "重新產生權杖",
       "regenerateConfirm": "重新產生會使目前的權杖失效。任何使用該權杖的中央面板將失去存取權,直到更新為止。是否繼續?",
       "allowPrivateAddress": "允許私有地址",
       "allowPrivateAddressHint": "僅對私有網路或VPN上的節點啟用。",
+      "inboundSyncMode": "入站匯入",
+      "inboundSyncModeHint": "選擇要從此節點匯入的入站。現有節點預設匯入所有入站。",
+      "allInbounds": "所有入站",
+      "selectedInbounds": "選取的入站",
+      "inboundTags": "入站",
+      "inboundTagsHint": "依入站標籤比對。空白選取不會匯入任何入站。",
+      "inboundTagsPlaceholder": "載入並選取入站",
+      "loadInbounds": "從節點載入入站",
+      "inboundsLoaded": "已載入 {{count}} 個入站",
+      "inboundsLoadFailed": "載入入站失敗",
       "enable": "已啟用",
       "status": "狀態",
       "cpu": "CPU",

+ 60 - 2
internal/xray/api.go

@@ -33,7 +33,9 @@ import (
 	"github.com/xtls/xray-core/proxy/vless"
 	"github.com/xtls/xray-core/proxy/vmess"
 	"google.golang.org/grpc"
+	"google.golang.org/grpc/codes"
 	"google.golang.org/grpc/credentials/insecure"
+	"google.golang.org/grpc/status"
 )
 
 // XrayAPI is a gRPC client for managing Xray core configuration, inbounds, outbounds, and statistics.
@@ -289,8 +291,8 @@ type RouteTestRequest struct {
 type RouteTestResult struct {
 	// Matched is false when no routing rule matched — traffic would use the
 	// default (first) outbound and OutboundTag is empty.
-	Matched     bool     `json:"matched"`
-	OutboundTag string   `json:"outboundTag"`
+	Matched     bool   `json:"matched"`
+	OutboundTag string `json:"outboundTag"`
 	// GroupTags lists the balancer chain the decision went through, when any.
 	GroupTags []string `json:"groupTags,omitempty"`
 }
@@ -571,6 +573,62 @@ func (x *XrayAPI) GetTraffic() ([]*Traffic, []*ClientTraffic, error) {
 	return mapToSlice(tagTrafficMap), mapToSlice(emailTrafficMap), nil
 }
 
+// OnlineIP is one source address of a live connection, with the unix time (seconds)
+// the core last dispatched a link from it.
+type OnlineIP struct {
+	IP       string `json:"ip"`
+	LastSeen int64  `json:"lastSeen"`
+}
+
+// OnlineUser is a client email with at least one live connection and the source
+// IPs of those connections, as tracked by Xray's statsUserOnline policy.
+type OnlineUser struct {
+	Email string     `json:"email"`
+	IPs   []OnlineIP `json:"ips"`
+}
+
+// GetOnlineUsers returns every user with at least one live connection plus their
+// source IPs, via StatsService.GetUsersStats (one RPC covers all users). Requires
+// statsUserOnline enabled in the policy levels; older cores return Unimplemented.
+func (x *XrayAPI) GetOnlineUsers() ([]OnlineUser, error) {
+	if x.grpcClient == nil {
+		return nil, common.NewError("xray api is not initialized")
+	}
+	if x.StatsServiceClient == nil {
+		return nil, common.NewError("xray StatsServiceClient is not initialized")
+	}
+
+	ctx, cancel := context.WithTimeout(context.Background(), time.Second*10)
+	defer cancel()
+
+	resp, err := (*x.StatsServiceClient).GetUsersStats(ctx, &statsService.GetUsersStatsRequest{})
+	if err != nil {
+		return nil, err
+	}
+
+	users := make([]OnlineUser, 0, len(resp.GetUsers()))
+	for _, u := range resp.GetUsers() {
+		if u == nil || u.GetEmail() == "" {
+			continue
+		}
+		ips := make([]OnlineIP, 0, len(u.GetIps()))
+		for _, entry := range u.GetIps() {
+			if entry == nil || entry.GetIp() == "" {
+				continue
+			}
+			ips = append(ips, OnlineIP{IP: entry.GetIp(), LastSeen: entry.GetLastSeen()})
+		}
+		users = append(users, OnlineUser{Email: u.GetEmail(), IPs: ips})
+	}
+	return users, nil
+}
+
+// IsUnimplementedErr reports whether err is the running core saying it lacks an
+// RPC (an older Xray binary without the online-stats API).
+func IsUnimplementedErr(err error) bool {
+	return status.Code(err) == codes.Unimplemented
+}
+
 // processTraffic aggregates a traffic stat into trafficMap using regex matches and value.
 func processTraffic(matches []string, value int64, trafficMap map[string]*Traffic) {
 	isInbound := matches[1] == "inbound"

+ 19 - 2
internal/xray/api_e2e_test.go

@@ -53,8 +53,12 @@ func TestXrayAPI_E2E(t *testing.T) {
 				map[string]any{"type": "field", "inboundTag": []string{"api"}, "outboundTag": "api"},
 			},
 		},
-		"policy": map[string]any{},
-		"stats":  map[string]any{},
+		"policy": map[string]any{
+			"levels": map[string]any{
+				"0": map[string]any{"statsUserOnline": true},
+			},
+		},
+		"stats": map[string]any{},
 	}
 	cfgBytes, err := json.MarshalIndent(cfg, "", "  ")
 	if err != nil {
@@ -130,6 +134,19 @@ func TestXrayAPI_E2E(t *testing.T) {
 		t.Fatalf("missing inbound error not matched by IsMissingHandlerErr: %q", err)
 	}
 
+	// --- online-stats API ---
+	// statsUserOnline is enabled in the policy above; with no client
+	// connections the call must succeed and return an empty set. This proves
+	// the GetUsersStats plumbing against a real core (an older binary would
+	// return Unimplemented here — see IsUnimplementedErr).
+	online, err := api.GetOnlineUsers()
+	if err != nil {
+		t.Fatalf("GetOnlineUsers: %v", err)
+	}
+	if len(online) != 0 {
+		t.Fatalf("expected no online users on an idle core, got %+v", online)
+	}
+
 	// --- routing (rules + balancers replace) ---
 	newRouting := []byte(`{
 		"domainStrategy": "AsIs",

+ 18 - 0
internal/xray/online_test.go

@@ -129,3 +129,21 @@ func TestClearNodeOnlineClientsDropsNode(t *testing.T) {
 		t.Errorf("node 3's subtree should be absent after ClearNodeOnlineClients")
 	}
 }
+
+// TestOnlineAPISupportTriState pins the lazy capability probe contract: a new
+// process starts Unknown (so the first caller probes), and the flag holds
+// whatever the probe recorded until the process is replaced on restart.
+func TestOnlineAPISupportTriState(t *testing.T) {
+	p := newOnlineTestProcess()
+	if got := p.OnlineAPISupport(); got != OnlineAPIUnknown {
+		t.Fatalf("new process must start with OnlineAPIUnknown, got %v", got)
+	}
+	p.SetOnlineAPISupport(OnlineAPISupported)
+	if got := p.OnlineAPISupport(); got != OnlineAPISupported {
+		t.Fatalf("expected OnlineAPISupported, got %v", got)
+	}
+	p.SetOnlineAPISupport(OnlineAPIUnsupported)
+	if got := p.OnlineAPISupport(); got != OnlineAPIUnsupported {
+		t.Fatalf("expected OnlineAPIUnsupported, got %v", got)
+	}
+}

+ 29 - 0
internal/xray/process.go

@@ -172,6 +172,12 @@ type process struct {
 	nodeOnlineTrees map[int]map[string][]string
 	onlineMu        sync.RWMutex
 
+	// onlineAPISupport caches whether the running core implements the
+	// online-stats RPCs (GetUsersStats). A new process is created on every
+	// restart/version switch, so the flag resets to Unknown and is re-probed
+	// lazily by the first caller.
+	onlineAPISupport atomic.Int32
+
 	config     *Config
 	configPath string // if set, use this path instead of GetConfigPath() and remove on Stop
 	logWriter  *LogWriter
@@ -181,6 +187,29 @@ type process struct {
 	intentionalStop atomic.Bool
 }
 
+// OnlineAPISupport describes whether the running Xray core implements the
+// online-stats API (statsUserOnline + GetUsersStats).
+type OnlineAPISupport int32
+
+const (
+	// OnlineAPIUnknown means support has not been probed yet for this process.
+	OnlineAPIUnknown OnlineAPISupport = iota
+	// OnlineAPISupported means the core answered the online-stats RPC.
+	OnlineAPISupported
+	// OnlineAPIUnsupported means the core returned Unimplemented (older binary).
+	OnlineAPIUnsupported
+)
+
+// OnlineAPISupport returns the cached online-stats capability of this process.
+func (p *process) OnlineAPISupport() OnlineAPISupport {
+	return OnlineAPISupport(p.onlineAPISupport.Load())
+}
+
+// SetOnlineAPISupport records the probed online-stats capability of this process.
+func (p *process) SetOnlineAPISupport(v OnlineAPISupport) {
+	p.onlineAPISupport.Store(int32(v))
+}
+
 var (
 	xrayGracefulStopTimeout = 5 * time.Second
 	xrayForceStopTimeout    = 2 * time.Second

+ 14 - 2
update.sh

@@ -81,6 +81,18 @@ is_domain() {
     [[ "$1" =~ ^([A-Za-z0-9](-*[A-Za-z0-9])*\.)+(xn--[a-z0-9]{2,}|[A-Za-z]{2,})$ ]] && return 0 || return 1
 }
 
+# acme.sh's standalone server binds IPv4 by default; --listen-v6 makes it
+# v6-only, which breaks HTTP-01 validation when the domain's A record points
+# at this host's IPv4 (#4994). Only force IPv6 when the host has no global
+# IPv4 address at all.
+acme_listen_flag() {
+    if ip -4 addr show scope global 2> /dev/null | grep -q "inet "; then
+        echo ""
+    else
+        echo "--listen-v6"
+    fi
+}
+
 # Port helpers
 is_port_in_use() {
     local port="$1"
@@ -200,7 +212,7 @@ setup_ssl_certificate() {
     echo -e "${yellow}Note: Port 80 must be open and accessible from the internet${plain}"
 
     ~/.acme.sh/acme.sh --set-default-ca --server letsencrypt --force > /dev/null 2>&1
-    ~/.acme.sh/acme.sh --issue -d ${domain} --listen-v6 --standalone --httpport 80 --force
+    ~/.acme.sh/acme.sh --issue -d ${domain} $(acme_listen_flag) --standalone --httpport 80 --force
 
     if [ $? -ne 0 ]; then
         echo -e "${yellow}Failed to issue certificate for ${domain}${plain}"
@@ -465,7 +477,7 @@ ssl_cert_issue() {
     if [[ ${cert_exists} -eq 0 ]]; then
         # issue the certificate
         ~/.acme.sh/acme.sh --set-default-ca --server letsencrypt --force
-        ~/.acme.sh/acme.sh --issue -d ${domain} --listen-v6 --standalone --httpport ${WebPort} --force
+        ~/.acme.sh/acme.sh --issue -d ${domain} $(acme_listen_flag) --standalone --httpport ${WebPort} --force
         if [ $? -ne 0 ]; then
             echo -e "${red}Issuing certificate failed, please check logs.${plain}"
             rm -rf ~/.acme.sh/${domain}

+ 68 - 8
x-ui.sh

@@ -50,6 +50,18 @@ is_domain() {
     [[ "$1" =~ ^([A-Za-z0-9](-*[A-Za-z0-9])*\.)+(xn--[a-z0-9]{2,}|[A-Za-z]{2,})$ ]] && return 0 || return 1
 }
 
+# acme.sh's standalone server binds IPv4 by default; --listen-v6 makes it
+# v6-only, which breaks HTTP-01 validation when the domain's A record points
+# at this host's IPv4 (#4994). Only force IPv6 when the host has no global
+# IPv4 address at all.
+acme_listen_flag() {
+    if ip -4 addr show scope global 2> /dev/null | grep -q "inet "; then
+        echo ""
+    else
+        echo "--listen-v6"
+    fi
+}
+
 # check root
 [[ $EUID -ne 0 ]] && LOGE "ERROR: You must be root to run this script! \n" && exit 1
 
@@ -361,12 +373,26 @@ check_config() {
 
     if [[ -n "$existing_cert" ]]; then
         local domain=$(basename "$(dirname "$existing_cert")")
+        # The cert folder name is only the certificate's first domain. A
+        # multidomain (SAN) certificate may be served under any name it covers,
+        # so read the real names from the certificate itself (#5070).
+        local cert_sans=""
+        if [[ -f "$existing_cert" ]] && command -v openssl > /dev/null 2>&1; then
+            cert_sans=$(openssl x509 -in "$existing_cert" -noout -ext subjectAltName 2> /dev/null \
+                | grep -Eo 'DNS:[^,[:space:]]+' | cut -d: -f2)
+            if [[ -n "$cert_sans" ]] && ! echo "$cert_sans" | grep -qx "$domain"; then
+                domain=$(echo "$cert_sans" | head -n1)
+            fi
+        fi
 
         if [[ "$domain" =~ ^[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$ ]]; then
             echo -e "${green}Access URL: https://${domain}:${existing_port}${existing_webBasePath}${plain}"
         else
             echo -e "${green}Access URL: https://${server_ip}:${existing_port}${existing_webBasePath}${plain}"
         fi
+        if [[ -n "$cert_sans" && $(echo "$cert_sans" | wc -l) -gt 1 ]]; then
+            echo -e "${yellow}The certificate also covers:${plain} $(echo "$cert_sans" | grep -vx "$domain" | tr '\n' ' ')"
+        fi
     else
         echo -e "${red}⚠ WARNING: No SSL certificate configured!${plain}"
         echo -e "${yellow}You can get a Let's Encrypt certificate for your IP address (valid ~6 days, auto-renews).${plain}"
@@ -688,10 +714,12 @@ disable_bbr() {
 
     if [ -f "/etc/sysctl.d/99-bbr-x-ui.conf" ]; then
         old_settings=$(head -1 /etc/sysctl.d/99-bbr-x-ui.conf | tr -d '#')
+        # sysctl -w already restores the live values, so no `sysctl --system`
+        # afterwards — it would re-apply every sysctl file on the host and
+        # surface unrelated errors from the distro's own defaults (see issue #5160)
         sysctl -w net.core.default_qdisc="${old_settings%:*}"
         sysctl -w net.ipv4.tcp_congestion_control="${old_settings#*:}"
         rm /etc/sysctl.d/99-bbr-x-ui.conf
-        sysctl --system
     else
         # Replace BBR with CUBIC configurations
         if [ -f "/etc/sysctl.conf" ]; then
@@ -726,7 +754,10 @@ enable_bbr() {
             sed -i 's/^net.core.default_qdisc/# &/' /etc/sysctl.conf
             sed -i 's/^net.ipv4.tcp_congestion_control/# &/' /etc/sysctl.conf
         fi
-        sysctl --system
+        # Apply only our config file; `sysctl --system` would re-apply every
+        # sysctl file on the host and surface unrelated errors from the distro's
+        # own defaults (see issue #5160)
+        sysctl -p /etc/sysctl.d/99-bbr-x-ui.conf
     else
         sed -i '/net.core.default_qdisc/d' /etc/sysctl.conf
         sed -i '/net.ipv4.tcp_congestion_control/d' /etc/sysctl.conf
@@ -1226,7 +1257,7 @@ ssl_cert_issue_main() {
             ssl_cert_issue_main
             ;;
         2)
-            local domains=$(find /root/cert/ -mindepth 1 -maxdepth 1 -type d -exec basename {} \;)
+            local domains=$(find /root/cert/ -mindepth 1 -maxdepth 1 -type d -exec basename {} \; 2> /dev/null)
             if [ -z "$domains" ]; then
                 echo "No certificates found to revoke."
             else
@@ -1267,7 +1298,7 @@ ssl_cert_issue_main() {
             ssl_cert_issue_main
             ;;
         3)
-            local domains=$(find /root/cert/ -mindepth 1 -maxdepth 1 -type d -exec basename {} \;)
+            local domains=$(find /root/cert/ -mindepth 1 -maxdepth 1 -type d -exec basename {} \; 2> /dev/null)
             if [ -z "$domains" ]; then
                 echo "No certificates found to renew."
             else
@@ -1284,9 +1315,9 @@ ssl_cert_issue_main() {
             ssl_cert_issue_main
             ;;
         4)
-            local domains=$(find /root/cert/ -mindepth 1 -maxdepth 1 -type d -exec basename {} \;)
+            local domains=$(find /root/cert/ -mindepth 1 -maxdepth 1 -type d -exec basename {} \; 2> /dev/null)
             if [ -z "$domains" ]; then
-                echo "No certificates found."
+                echo "No certificates found under /root/cert."
             else
                 echo "Existing domains and their paths:"
                 for domain in $domains; do
@@ -1301,10 +1332,39 @@ ssl_cert_issue_main() {
                     fi
                 done
             fi
+            # The panel's configured certificate may live outside /root/cert
+            # (e.g. certbot under /etc/letsencrypt) — show it too (#5070).
+            local panel_cert=$(${xui_folder}/x-ui setting -getCert true | grep 'cert:' | awk -F': ' '{print $2}' | tr -d '[:space:]')
+            if [[ -n "${panel_cert}" && "${panel_cert}" != /root/cert/* ]]; then
+                echo -e "Panel certificate (custom path): ${panel_cert}"
+                if [[ -f "${panel_cert}" ]] && command -v openssl > /dev/null 2>&1; then
+                    local panel_sans=$(openssl x509 -in "${panel_cert}" -noout -ext subjectAltName 2> /dev/null \
+                        | grep -Eo 'DNS:[^,[:space:]]+' | cut -d: -f2 | tr '\n' ' ')
+                    [[ -n "${panel_sans}" ]] && echo -e "\tCovers: ${panel_sans}"
+                fi
+            fi
             ssl_cert_issue_main
             ;;
         5)
-            local domains=$(find /root/cert/ -mindepth 1 -maxdepth 1 -type d -exec basename {} \;)
+            echo -e "${green}\t1.${plain} Use a certificate from /root/cert"
+            echo -e "${green}\t2.${plain} Enter custom certificate file paths (e.g. certbot, /etc/letsencrypt/...)"
+            read -rp "Choose an option: " pathChoice
+            if [[ "$pathChoice" == "2" ]]; then
+                read -rp "Certificate file path (fullchain): " webCertFile
+                read -rp "Private key file path: " webKeyFile
+                if [[ -f "${webCertFile}" && -f "${webKeyFile}" ]]; then
+                    ${xui_folder}/x-ui cert -webCert "$webCertFile" -webCertKey "$webKeyFile"
+                    echo "Panel certificate paths set:"
+                    echo "  - Certificate File: $webCertFile"
+                    echo "  - Private Key File: $webKeyFile"
+                    restart
+                else
+                    echo "Certificate or private key file not found."
+                fi
+                ssl_cert_issue_main
+                return
+            fi
+            local domains=$(find /root/cert/ -mindepth 1 -maxdepth 1 -type d -exec basename {} \; 2> /dev/null)
             if [ -z "$domains" ]; then
                 echo "No certificates found."
             else
@@ -1686,7 +1746,7 @@ ssl_cert_issue() {
     if [[ ${cert_exists} -eq 0 ]]; then
         # issue the certificate
         ~/.acme.sh/acme.sh --set-default-ca --server letsencrypt --force
-        ~/.acme.sh/acme.sh --issue -d ${domain} --listen-v6 --standalone --httpport ${WebPort} --force
+        ~/.acme.sh/acme.sh --issue -d ${domain} $(acme_listen_flag) --standalone --httpport ${WebPort} --force
         if [ $? -ne 0 ]; then
             LOGE "Issuing certificate failed, please check logs."
             rm -rf ~/.acme.sh/${domain} ~/.acme.sh/${domain}_ecc