1
0

9 Commits 49773c18de ... 62f303905e

Autor SHA1 Nachricht Datum
  MHSanaei 62f303905e fix(scripts): pass --force to acme.sh --installcert so it survives sudo vor 1 Tag
  MHSanaei c8ef1b1f68 feat(reality): derive a stable per-client spiderX for shared links vor 1 Tag
  MHSanaei 64c306037f feat(wireguard): make client allowedIPs editable with validation vor 1 Tag
  MHSanaei 8dd3b31ee8 fix(node): show the activated first-use deadline on the Clients page vor 1 Tag
  MHSanaei e5b56c9444 fix(xray): reconcile client auto-disable through the API instead of a forced restart vor 1 Tag
  MHSanaei 1153d5db8c fix(groups): keep group traffic totals stable across client resets and deletes vor 1 Tag
  MHSanaei 539bcc897c fix(inbounds): apply the legacy xhttp session-key migration when editing vor 2 Tagen
  MHSanaei 273f88721e fix(database): stop noisy per-startup errors in the Postgres server log vor 2 Tagen
  MHSanaei 1f2e3e1447 fix(sub): use configured spiderX instead of always randomizing vor 2 Tagen
55 geänderte Dateien mit 1160 neuen und 93 gelöschten Zeilen
  1. 1 0
      frontend/package-lock.json
  2. 1 0
      frontend/package.json
  3. 4 2
      frontend/src/lib/xray/inbound-form-adapter.ts
  4. 20 5
      frontend/src/lib/xray/inbound-link.ts
  5. 10 0
      frontend/src/lib/xray/spider-x.ts
  6. 13 0
      frontend/src/lib/xray/stream-wire-normalize.ts
  7. 17 5
      frontend/src/pages/clients/ClientFormModal.tsx
  8. 2 0
      frontend/src/pages/inbounds/form/InboundFormModal.tsx
  9. 12 2
      frontend/src/pages/inbounds/form/security/reality.tsx
  10. 9 0
      frontend/src/pages/inbounds/form/useSecurityActions.ts
  11. 2 2
      frontend/src/test/__snapshots__/inbound-link.test.ts.snap
  12. 49 0
      frontend/src/test/inbound-form-adapter.test.ts
  13. 1 0
      frontend/src/test/inbound-form-blocks.test.tsx
  14. 27 0
      frontend/src/test/spider-x.test.ts
  15. 3 3
      install.sh
  16. 27 0
      internal/database/db.go
  17. 57 0
      internal/database/db_settled_test.go
  18. 17 9
      internal/database/migrate_data.go
  19. 18 8
      internal/sub/json_service.go
  20. 83 6
      internal/sub/json_service_test.go
  21. 5 5
      internal/sub/mutation_audit_test.go
  22. 27 4
      internal/sub/service.go
  23. 95 6
      internal/sub/service_sharelink_test.go
  24. 2 2
      internal/web/job/xray_traffic_job.go
  25. 3 0
      internal/web/service/client_bulk.go
  26. 22 15
      internal/web/service/client_crud.go
  27. 102 0
      internal/web/service/client_group_reset_test.go
  28. 53 0
      internal/web/service/client_groups.go
  29. 20 0
      internal/web/service/client_inbound_apply.go
  30. 3 0
      internal/web/service/client_portable.go
  31. 7 0
      internal/web/service/client_traffic.go
  32. 53 0
      internal/web/service/client_wireguard.go
  33. 64 0
      internal/web/service/client_wireguard_test.go
  34. 15 0
      internal/web/service/inbound_node.go
  35. 23 10
      internal/web/service/inbound_traffic.go
  36. 42 0
      internal/web/service/node_client_expiry_sync_test.go
  37. 25 0
      internal/web/service/xray.go
  38. 2 0
      internal/web/translation/ar-EG.json
  39. 2 0
      internal/web/translation/en-US.json
  40. 2 0
      internal/web/translation/es-ES.json
  41. 2 0
      internal/web/translation/fa-IR.json
  42. 2 0
      internal/web/translation/id-ID.json
  43. 2 0
      internal/web/translation/ja-JP.json
  44. 2 0
      internal/web/translation/pt-BR.json
  45. 2 0
      internal/web/translation/ru-RU.json
  46. 2 0
      internal/web/translation/tr-TR.json
  47. 2 0
      internal/web/translation/uk-UA.json
  48. 2 0
      internal/web/translation/vi-VN.json
  49. 2 0
      internal/web/translation/zh-CN.json
  50. 2 0
      internal/web/translation/zh-TW.json
  51. 9 0
      internal/xray/api.go
  52. 108 0
      internal/xray/hot_diff.go
  53. 76 2
      internal/xray/hot_diff_test.go
  54. 3 3
      update.sh
  55. 4 4
      x-ui.sh

+ 1 - 0
frontend/package-lock.json

@@ -11,6 +11,7 @@
         "@ant-design/icons": "^6.3.2",
         "@codemirror/lang-json": "^6.0.2",
         "@codemirror/theme-one-dark": "^6.1.3",
+        "@noble/hashes": "^2.2.0",
         "@tanstack/react-query": "^5.101.2",
         "@tanstack/react-query-devtools": "^5.101.2",
         "antd": "^6.5.0",

+ 1 - 0
frontend/package.json

@@ -24,6 +24,7 @@
     "@ant-design/icons": "^6.3.2",
     "@codemirror/lang-json": "^6.0.2",
     "@codemirror/theme-one-dark": "^6.1.3",
+    "@noble/hashes": "^2.2.0",
     "@tanstack/react-query": "^5.101.2",
     "@tanstack/react-query-devtools": "^5.101.2",
     "antd": "^6.5.0",

+ 4 - 2
frontend/src/lib/xray/inbound-form-adapter.ts

@@ -13,7 +13,7 @@ import type { Sniffing } from '@/schemas/primitives';
 import type { z } from 'zod';
 import { normalizeStreamSettingsForWire } from '@/lib/xray/stream-wire-normalize';
 import { canEnableSniffing } from '@/lib/xray/protocol-capabilities';
-import { XHttpXmuxSchema } from '@/schemas/protocols/stream/xhttp';
+import { XHttpStreamSettingsSchema, XHttpXmuxSchema } from '@/schemas/protocols/stream/xhttp';
 
 const XMUX_DEFAULTS = XHttpXmuxSchema.parse({});
 
@@ -164,7 +164,9 @@ export function rawInboundToFormValues(row: RawInboundRow): InboundFormValues {
     const streamRecord = streamSettings as unknown as Record<string, unknown>;
     const xh = streamRecord.xhttpSettings;
     if (xh && typeof xh === 'object' && !Array.isArray(xh)) {
-      const xhttp = xh as Record<string, unknown>;
+      const parsed = XHttpStreamSettingsSchema.safeParse(xh);
+      const xhttp = (parsed.success ? parsed.data : xh) as Record<string, unknown>;
+      streamRecord.xhttpSettings = xhttp;
       const xmux = xhttp.xmux;
       if (xmux && typeof xmux === 'object' && !Array.isArray(xmux)) {
         xhttp.enableXmux = true;

+ 20 - 5
frontend/src/lib/xray/inbound-link.ts

@@ -13,6 +13,7 @@ import type { XHttpStreamSettings } from '@/schemas/protocols/stream/xhttp';
 
 import { getHeaderValue } from './headers';
 import { canEnableTlsFlow } from './protocol-capabilities';
+import { deriveSpiderX } from './spider-x';
 
 // Share-link generators. Each per-protocol fn takes a typed inbound plus
 // client overrides and returns a URL (or '' when the protocol doesn't
@@ -322,6 +323,7 @@ export interface GenVlessLinkInput {
   forceTls?: ForceTls;
   remark?: string;
   clientId: string;
+  clientKey?: string;
   flow?: VlessClient['flow'];
   externalProxy?: ExternalProxyEntry | null;
 }
@@ -350,6 +352,7 @@ export function genVlessLink(input: GenVlessLinkInput): string {
     forceTls = 'same',
     remark = '',
     clientId,
+    clientKey = '',
     flow = '',
     externalProxy = null,
   } = input;
@@ -430,7 +433,8 @@ export function genVlessLink(input: GenVlessLinkInput): string {
       if (sni && sni.length > 0) params.set('sni', sni);
 
       if (reality.shortIds.length > 0) params.set('sid', reality.shortIds[0]);
-      if (reality.settings.spiderX.length > 0) params.set('spx', reality.settings.spiderX);
+      const spx = deriveSpiderX(reality.settings.spiderX, clientKey);
+      if (spx.length > 0) params.set('spx', spx);
       if (reality.settings.mldsa65Verify.length > 0) params.set('pqv', reality.settings.mldsa65Verify);
     }
   } else {
@@ -512,7 +516,7 @@ function writeTlsParams(stream: NonNullable<Inbound['streamSettings']>, params:
 
 // Reality query-string writer shared by VLESS and Trojan. Preserves the
 // legacy SNI-omission quirk (see genVlessLink for the full story).
-function writeRealityParams(stream: NonNullable<Inbound['streamSettings']>, params: URLSearchParams): void {
+function writeRealityParams(stream: NonNullable<Inbound['streamSettings']>, params: URLSearchParams, clientKey: string): void {
   if (stream.security !== 'reality') return;
   const reality = stream.realitySettings;
   params.set('pbk', reality.settings.publicKey);
@@ -526,7 +530,8 @@ function writeRealityParams(stream: NonNullable<Inbound['streamSettings']>, para
   if (sni && sni.length > 0) params.set('sni', sni);
 
   if (reality.shortIds.length > 0) params.set('sid', reality.shortIds[0]);
-  if (reality.settings.spiderX.length > 0) params.set('spx', reality.settings.spiderX);
+  const spx = deriveSpiderX(reality.settings.spiderX, clientKey);
+  if (spx.length > 0) params.set('spx', spx);
   if (reality.settings.mldsa65Verify.length > 0) params.set('pqv', reality.settings.mldsa65Verify);
 }
 
@@ -537,6 +542,7 @@ export interface GenTrojanLinkInput {
   forceTls?: ForceTls;
   remark?: string;
   clientPassword: string;
+  clientKey?: string;
   externalProxy?: ExternalProxyEntry | null;
 }
 
@@ -551,6 +557,7 @@ export function genTrojanLink(input: GenTrojanLinkInput): string {
     forceTls = 'same',
     remark = '',
     clientPassword,
+    clientKey = '',
     externalProxy = null,
   } = input;
 
@@ -571,7 +578,7 @@ export function genTrojanLink(input: GenTrojanLinkInput): string {
     applyExternalProxyTLSParams(externalProxy, params, security);
   } else if (security === 'reality') {
     params.set('security', 'reality');
-    writeRealityParams(stream, params);
+    writeRealityParams(stream, params, clientKey);
   } else {
     params.set('security', 'none');
   }
@@ -1017,7 +1024,13 @@ export function preferPublicHost(browserHost: string, publicHost: string): strin
 // `this.clients` getter, which used isSSMultiUser to gate). Returns null
 // for SS single-user, http, mixed, tunnel, wireguard, hysteria2-without-
 // clients, and any protocol without a clients array.
-type ClientShape = { id?: string; security?: VmessSecurity; flow?: VlessClient['flow']; password?: string; auth?: string; email?: string };
+type ClientShape = { id?: string; security?: VmessSecurity; flow?: VlessClient['flow']; password?: string; auth?: string; email?: string; subId?: string };
+
+// Mirror of the Go subKey: the stable per-client identity spx derivation
+// keys on — subscription id first, unique email as the fallback.
+function clientSubKey(client: ClientShape): string {
+  return client.subId || client.email || '';
+}
 
 export function getInboundClients(inbound: Inbound): ClientShape[] | null {
   switch (inbound.protocol) {
@@ -1066,6 +1079,7 @@ export function genLink(input: GenLinkInput): string {
       return genVlessLink({
         inbound, address, port, forceTls, remark,
         clientId: client.id ?? '',
+        clientKey: clientSubKey(client),
         flow: client.flow,
         externalProxy,
       });
@@ -1081,6 +1095,7 @@ export function genLink(input: GenLinkInput): string {
       return genTrojanLink({
         inbound, address, port, forceTls, remark,
         clientPassword: client.password ?? '',
+        clientKey: clientSubKey(client),
         externalProxy,
       });
     case 'hysteria':

+ 10 - 0
frontend/src/lib/xray/spider-x.ts

@@ -0,0 +1,10 @@
+import { sha256 } from '@noble/hashes/sha2.js';
+import { bytesToHex, utf8ToBytes } from '@noble/hashes/utils.js';
+
+// Mirrors deriveSpiderX in internal/sub/service.go byte-for-byte so panel
+// links and subscription links agree; returns '' when there is no seed and
+// no client key (the caller then omits spx, as the legacy builder did).
+export function deriveSpiderX(seed: string, clientKey: string): string {
+  if (!seed && !clientKey) return '';
+  return `/${bytesToHex(sha256(utf8ToBytes(`${seed}|${clientKey}`))).slice(0, 15)}`;
+}

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

@@ -108,6 +108,18 @@ export function validateRealityTarget(target: string): string | undefined {
   return undefined;
 }
 
+function liftLegacyXhttpSessionKeys(obj: Record<string, unknown>): void {
+  const lift = (legacy: string, renamed: string) => {
+    const v = obj[legacy];
+    if ((obj[renamed] === undefined || obj[renamed] === '') && typeof v === 'string' && v !== '') {
+      obj[renamed] = v;
+    }
+    delete obj[legacy];
+  };
+  lift('sessionPlacement', 'sessionIDPlacement');
+  lift('sessionKey', 'sessionIDKey');
+}
+
 function dropEmptyStrings(obj: Record<string, unknown>, keys: readonly string[]): void {
   for (const key of keys) {
     const v = obj[key];
@@ -160,6 +172,7 @@ export function normalizeXhttpForWire(
   side: StreamWireSide,
 ): Record<string, unknown> {
   const out: Record<string, unknown> = { ...raw };
+  liftLegacyXhttpSessionKeys(out);
   const mode = typeof out.mode === 'string' && out.mode !== '' ? out.mode : 'auto';
   const enableXmux = out.enableXmux === true;
   delete out.enableXmux;

+ 17 - 5
frontend/src/pages/clients/ClientFormModal.tsx

@@ -492,6 +492,13 @@ export default function ClientFormModal({
       if (form.wgPreSharedKey) {
         clientPayload.preSharedKey = form.wgPreSharedKey;
       }
+      const allowedIPs = form.wgAllowedIPs
+        .split(',')
+        .map((s) => s.trim())
+        .filter((s) => s !== '');
+      if (allowedIPs.length > 0) {
+        clientPayload.allowedIPs = allowedIPs;
+      }
     }
 
     const externalLinks: ExternalLinkInput[] = form.externalLinks
@@ -802,11 +809,16 @@ export default function ClientFormModal({
                             onChange={(e) => update('wgPreSharedKey', e.target.value)}
                           />
                         </Form.Item>
-                        {isEdit && form.wgAllowedIPs && (
-                          <Form.Item label={t('pages.clients.wireguardAllowedIPs')}>
-                            <Input value={form.wgAllowedIPs} disabled />
-                          </Form.Item>
-                        )}
+                        <Form.Item
+                          label={t('pages.clients.wireguardAllowedIPs')}
+                          extra={t('pages.clients.wireguardAllowedIPsHint')}
+                        >
+                          <Input
+                            value={form.wgAllowedIPs}
+                            placeholder="10.0.0.2/32"
+                            onChange={(e) => update('wgAllowedIPs', e.target.value)}
+                          />
+                        </Form.Item>
                       </>
                     )}
                   </>

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

@@ -248,6 +248,7 @@ export default function InboundFormModal({
     scanRealityCandidates,
     applyRealityScanResult,
     randomizeShortIds,
+    randomizeSpiderX,
     getNewEchCert,
     clearEchCert,
     pinFromCert,
@@ -896,6 +897,7 @@ export default function InboundFormModal({
           scanRealityCandidates={scanRealityCandidates}
           applyRealityScanResult={applyRealityScanResult}
           randomizeShortIds={randomizeShortIds}
+          randomizeSpiderX={randomizeSpiderX}
           genRealityKeypair={genRealityKeypair}
           clearRealityKeypair={clearRealityKeypair}
           genMldsa65={genMldsa65}

+ 12 - 2
frontend/src/pages/inbounds/form/security/reality.tsx

@@ -16,6 +16,7 @@ interface RealityFormProps {
   scanRealityCandidates: (targets?: string) => Promise<RealityScanResult[]>;
   applyRealityScanResult: (result: RealityScanResult) => void;
   randomizeShortIds: () => void;
+  randomizeSpiderX: () => void;
   genRealityKeypair: () => void;
   clearRealityKeypair: () => void;
   genMldsa65: () => void;
@@ -30,6 +31,7 @@ export default function RealityForm({
   scanRealityCandidates,
   applyRealityScanResult,
   randomizeShortIds,
+  randomizeSpiderX,
   genRealityKeypair,
   clearRealityKeypair,
   genMldsa65,
@@ -147,10 +149,18 @@ export default function RealityForm({
         </Space.Compact>
       </Form.Item>
       <Form.Item
-        name={['streamSettings', 'realitySettings', 'settings', 'spiderX']}
         label={t('pages.inbounds.form.spiderX')}
+        tooltip={t('pages.inbounds.form.spiderXHint')}
       >
-        <Input />
+        <Space.Compact block style={{ display: 'flex' }}>
+          <Form.Item
+            name={['streamSettings', 'realitySettings', 'settings', 'spiderX']}
+            noStyle
+          >
+            <Input style={{ flex: 1 }} />
+          </Form.Item>
+          <Button aria-label={t('regenerate')} icon={<ReloadOutlined />} onClick={randomizeSpiderX} />
+        </Space.Compact>
       </Form.Item>
       <Form.Item
         name={['streamSettings', 'realitySettings', 'settings', 'publicKey']}

+ 9 - 0
frontend/src/pages/inbounds/form/useSecurityActions.ts

@@ -124,6 +124,13 @@ export function useSecurityActions({ form, setSaving, messageApi, nodeId, setSca
     );
   };
 
+  const randomizeSpiderX = () => {
+    form.setFieldValue(
+      ['streamSettings', 'realitySettings', 'settings', 'spiderX'],
+      `/${RandomUtil.randomSeq(15)}`,
+    );
+  };
+
   const getNewEchCert = async () => {
     const sni = form.getFieldValue(['streamSettings', 'tlsSettings', 'serverName']);
     setSaving(true);
@@ -270,6 +277,7 @@ export function useSecurityActions({ form, setSaving, messageApi, nodeId, setSca
     }
     form.setFieldValue('streamSettings', cleaned);
     if (next === 'reality') {
+      randomizeSpiderX();
       try {
         const msg = await HttpUtil.get('/panel/api/server/getNewX25519Cert');
         if (msg?.success) {
@@ -292,6 +300,7 @@ export function useSecurityActions({ form, setSaving, messageApi, nodeId, setSca
     scanRealityCandidates,
     applyRealityScanResult,
     randomizeShortIds,
+    randomizeSpiderX,
     getNewEchCert,
     clearEchCert,
     pinFromCert,

+ 2 - 2
frontend/src/test/__snapshots__/inbound-link.test.ts.snap

@@ -8,7 +8,7 @@ exports[`genInboundLinks orchestrator > shadowsocks-tcp-2022: byte-stable 1`] =
 
 exports[`genInboundLinks orchestrator > trojan-ws-tls: byte-stable 1`] = `"trojan://[email protected]:443?type=ws&path=%2Ftrojan&host=trojan.example.test&security=tls&fp=chrome&alpn=h2%2Chttp%2F1.1&sni=trojan.example.test#parity-test"`;
 
-exports[`genInboundLinks orchestrator > vless-tcp-reality: byte-stable 1`] = `"vless://[email protected]:443?type=tcp&encryption=none&security=reality&pbk=Tx5yj1bRcOPHkdvT2pIAQ2zh0gQ8m4OPdnzqXJxxV3o&fp=chrome&sni=yahoo.com&sid=a3f1&spx=%2F&flow=xtls-rprx-vision#parity-test"`;
+exports[`genInboundLinks orchestrator > vless-tcp-reality: byte-stable 1`] = `"vless://[email protected]:443?type=tcp&encryption=none&security=reality&pbk=Tx5yj1bRcOPHkdvT2pIAQ2zh0gQ8m4OPdnzqXJxxV3o&fp=chrome&sni=yahoo.com&sid=a3f1&spx=%2Fdafd018f50a389b&flow=xtls-rprx-vision#parity-test"`;
 
 exports[`genInboundLinks orchestrator > vless-ws-tls: byte-stable 1`] = `"vless://[email protected]:443?type=ws&encryption=none&path=%2Fws&host=cdn.example.test&security=tls&fp=chrome&alpn=h2%2Chttp%2F1.1&sni=cdn.example.test#parity-test"`;
 
@@ -36,7 +36,7 @@ exports[`genShadowsocksLink > shadowsocks-tcp-2022: byte-stable 1`] = `"ss://202
 
 exports[`genTrojanLink > trojan-ws-tls: byte-stable 1`] = `"trojan://[email protected]:443?type=ws&path=%2Ftrojan&host=trojan.example.test&security=tls&fp=chrome&alpn=h2%2Chttp%2F1.1&sni=trojan.example.test#parity-test"`;
 
-exports[`genVlessLink > vless-tcp-reality: byte-stable 1`] = `"vless://[email protected]:443?type=tcp&encryption=none&security=reality&pbk=Tx5yj1bRcOPHkdvT2pIAQ2zh0gQ8m4OPdnzqXJxxV3o&fp=chrome&sni=yahoo.com&sid=a3f1&spx=%2F&flow=xtls-rprx-vision#parity-test"`;
+exports[`genVlessLink > vless-tcp-reality: byte-stable 1`] = `"vless://[email protected]:443?type=tcp&encryption=none&security=reality&pbk=Tx5yj1bRcOPHkdvT2pIAQ2zh0gQ8m4OPdnzqXJxxV3o&fp=chrome&sni=yahoo.com&sid=a3f1&spx=%2Fd08ed99bd9afc60&flow=xtls-rprx-vision#parity-test"`;
 
 exports[`genVlessLink > vless-ws-tls: byte-stable 1`] = `"vless://[email protected]:443?type=ws&encryption=none&path=%2Fws&host=cdn.example.test&security=tls&fp=chrome&alpn=h2%2Chttp%2F1.1&sni=cdn.example.test#parity-test"`;
 

+ 49 - 0
frontend/src/test/inbound-form-adapter.test.ts

@@ -7,6 +7,7 @@ import {
   type RawInboundRow,
 } from '@/lib/xray/inbound-form-adapter';
 import { InboundDbFieldsSchema, InboundFormSchema } from '@/schemas/forms/inbound-form';
+import { normalizeXhttpForWire } from '@/lib/xray/stream-wire-normalize';
 import { SockoptStreamSettingsSchema } from '@/schemas/protocols/stream/sockopt';
 
 // Round-trip: raw DB row → InboundFormValues → wire payload, asserting
@@ -304,3 +305,51 @@ describe('subSortIndex', () => {
     expect(InboundDbFieldsSchema.parse({}).subSortIndex).toBe(1);
   });
 });
+
+describe('legacy xhttp session keys on edit (#5621)', () => {
+  const legacyXhttpRow: RawInboundRow = {
+    ...vlessRow,
+    streamSettings: {
+      network: 'xhttp',
+      security: 'none',
+      xhttpSettings: {
+        path: '/xh',
+        mode: 'packet-up',
+        sessionPlacement: 'cookie',
+        sessionKey: 'x_session',
+      },
+    },
+  };
+
+  it('rawInboundToFormValues lifts sessionPlacement/sessionKey onto the renamed keys', () => {
+    const values = rawInboundToFormValues(legacyXhttpRow);
+    const xhttp = (values.streamSettings as unknown as Record<string, Record<string, unknown>>).xhttpSettings;
+    expect(xhttp.sessionIDPlacement).toBe('cookie');
+    expect(xhttp.sessionIDKey).toBe('x_session');
+    expect(xhttp.sessionPlacement).toBeUndefined();
+    expect(xhttp.sessionKey).toBeUndefined();
+    expect(xhttp.path).toBe('/xh');
+    expect(xhttp.xPaddingBytes).toBe('100-1000');
+  });
+
+  it('formValuesToWirePayload never emits the legacy key names', () => {
+    const values = rawInboundToFormValues(legacyXhttpRow);
+    const payload = formValuesToWirePayload(values);
+    const stream = JSON.parse(payload.streamSettings) as Record<string, Record<string, unknown>>;
+    expect(stream.xhttpSettings.sessionPlacement).toBeUndefined();
+    expect(stream.xhttpSettings.sessionKey).toBeUndefined();
+    expect(stream.xhttpSettings.sessionIDPlacement).toBe('cookie');
+    expect(stream.xhttpSettings.sessionIDKey).toBe('x_session');
+  });
+
+  it('normalizeXhttpForWire lifts stale legacy keys that bypassed the schema', () => {
+    const out = normalizeXhttpForWire(
+      { sessionPlacement: 'header', sessionKey: 'x_raw' },
+      'inbound',
+    );
+    expect(out.sessionIDPlacement).toBe('header');
+    expect(out.sessionIDKey).toBe('x_raw');
+    expect(out.sessionPlacement).toBeUndefined();
+    expect(out.sessionKey).toBeUndefined();
+  });
+});

+ 1 - 0
frontend/src/test/inbound-form-blocks.test.tsx

@@ -104,6 +104,7 @@ describe('inbound security forms', () => {
         scanRealityCandidates={async () => []}
         applyRealityScanResult={noop}
         randomizeShortIds={noop}
+        randomizeSpiderX={noop}
         genRealityKeypair={noop}
         clearRealityKeypair={noop}
         genMldsa65={noop}

+ 27 - 0
frontend/src/test/spider-x.test.ts

@@ -0,0 +1,27 @@
+import { describe, expect, it } from 'vitest';
+
+import { deriveSpiderX } from '@/lib/xray/spider-x';
+
+// Cross-language vectors shared with TestDeriveSpiderXMatchesFrontendVectors
+// in internal/sub/service_sharelink_test.go: subscription links come from Go,
+// panel links from this module, and the two must agree byte-for-byte.
+describe('deriveSpiderX', () => {
+  it('matches the Go deriveSpiderX vectors', () => {
+    expect(deriveSpiderX('/seed', 'subAlice')).toBe('/c252fbc3ecd3e3c');
+    expect(deriveSpiderX('/', '')).toBe('/d08ed99bd9afc60');
+  });
+
+  it('is stable per client, distinct across clients, and rotates with the seed', () => {
+    expect(deriveSpiderX('/seed', 'subAlice')).toBe(deriveSpiderX('/seed', 'subAlice'));
+    expect(deriveSpiderX('/seed', 'subAlice')).not.toBe(deriveSpiderX('/seed', 'subBob'));
+    expect(deriveSpiderX('/seedA', 'subAlice')).not.toBe(deriveSpiderX('/seedB', 'subAlice'));
+  });
+
+  it('returns empty when there is nothing to derive from', () => {
+    expect(deriveSpiderX('', '')).toBe('');
+  });
+
+  it('emits a /-prefixed 15-hex-char path', () => {
+    expect(deriveSpiderX('/some-seed', '[email protected]')).toMatch(/^\/[0-9a-f]{15}$/);
+  });
+});

+ 3 - 3
install.sh

@@ -373,7 +373,7 @@ setup_ssl_certificate() {
     fi
 
     # Install certificate
-    ~/.acme.sh/acme.sh --installcert -d ${domain} \
+    ~/.acme.sh/acme.sh --installcert --force -d ${domain} \
         --key-file /root/cert/${domain}/privkey.pem \
         --fullchain-file /root/cert/${domain}/fullchain.pem \
         --reloadcmd "systemctl restart x-ui" > /dev/null 2>&1
@@ -517,7 +517,7 @@ setup_ip_certificate() {
     # Install certificate
     # Note: acme.sh may report "Reload error" and exit non-zero if reloadcmd fails,
     # but the cert files are still installed. We check for files instead of exit code.
-    ~/.acme.sh/acme.sh --installcert -d ${ipv4} \
+    ~/.acme.sh/acme.sh --installcert --force -d ${ipv4} \
         --key-file "${certDir}/privkey.pem" \
         --fullchain-file "${certDir}/fullchain.pem" \
         --reloadcmd "${reloadCmd}" 2>&1 || true
@@ -705,7 +705,7 @@ ssl_cert_issue() {
 
     # install the certificate
     local installOutput=""
-    installOutput=$(~/.acme.sh/acme.sh --installcert -d ${domain} \
+    installOutput=$(~/.acme.sh/acme.sh --installcert --force -d ${domain} \
         --key-file /root/cert/${domain}/privkey.pem \
         --fullchain-file /root/cert/${domain}/fullchain.pem --reloadcmd "${reloadCmd}" 2>&1)
     local installRc=$?

+ 27 - 0
internal/database/db.go

@@ -83,6 +83,9 @@ func initModels() error {
 		&model.OutboundSubscription{},
 	}
 	for _, mdl := range models {
+		if IsPostgres() && postgresModelSettled(mdl) {
+			continue
+		}
 		if err := db.AutoMigrate(mdl); err != nil {
 			if isIgnorableDuplicateColumnErr(err, mdl) {
 				log.Printf("Ignoring duplicate column during auto migration for %T: %v", mdl, err)
@@ -119,6 +122,30 @@ func initModels() error {
 	return nil
 }
 
+// postgresModelSettled skips AutoMigrate when table, columns, and indexes all exist:
+// its catalog-filtered column probe misdetects on some setups and re-ADDs columns forever (#5665).
+func postgresModelSettled(mdl any) bool {
+	migrator := db.Migrator()
+	if !migrator.HasTable(mdl) {
+		return false
+	}
+	stmt := &gorm.Statement{DB: db}
+	if err := stmt.Parse(mdl); err != nil || stmt.Schema == nil {
+		return false
+	}
+	for _, dbName := range stmt.Schema.DBNames {
+		if !migrator.HasColumn(mdl, dbName) {
+			return false
+		}
+	}
+	for _, idx := range stmt.Schema.ParseIndexes() {
+		if !migrator.HasIndex(mdl, idx.Name) {
+			return false
+		}
+	}
+	return true
+}
+
 func dropLegacyForeignKeys() error {
 	if !IsPostgres() {
 		return nil

+ 57 - 0
internal/database/db_settled_test.go

@@ -0,0 +1,57 @@
+package database
+
+import (
+	"path/filepath"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+)
+
+// Locks the #5665 guard: composite-PK client_inbounds has no id column, so the
+// sequence-reset SQL must never be issued for it.
+func TestTableWithIdColumn_SkipsCompositeKeyModels(t *testing.T) {
+	if err := InitDB(filepath.Join(t.TempDir(), "x-ui.db")); err != nil {
+		t.Fatalf("InitDB: %v", err)
+	}
+	t.Cleanup(func() { _ = CloseDB() })
+
+	if table, ok := tableWithIdColumn(db, &model.ClientInbound{}); ok {
+		t.Errorf("ClientInbound (table %q) has no id column but was not skipped", table)
+	}
+	table, ok := tableWithIdColumn(db, &model.Inbound{})
+	if !ok {
+		t.Fatal("Inbound has an id column but was reported as skippable")
+	}
+	if table != "inbounds" {
+		t.Errorf("Inbound table = %q, want inbounds", table)
+	}
+}
+
+// Exercises the #5665 AutoMigrate skip on SQLite (the check is dialect-agnostic):
+// settled after InitDB, not settled with a missing column or table.
+func TestPostgresModelSettled_TracksSchemaPresence(t *testing.T) {
+	if err := InitDB(filepath.Join(t.TempDir(), "x-ui.db")); err != nil {
+		t.Fatalf("InitDB: %v", err)
+	}
+	t.Cleanup(func() { _ = CloseDB() })
+
+	for _, mdl := range []any{&model.ClientRecord{}, &model.ClientGroup{}, &model.ClientInbound{}} {
+		if !postgresModelSettled(mdl) {
+			t.Errorf("%T not settled right after InitDB", mdl)
+		}
+	}
+
+	if err := db.Migrator().DropColumn(&model.ClientGroup{}, "reset_up"); err != nil {
+		t.Fatalf("drop column: %v", err)
+	}
+	if postgresModelSettled(&model.ClientGroup{}) {
+		t.Error("ClientGroup settled despite missing reset_up column")
+	}
+
+	if err := db.Migrator().DropTable(&model.ClientGroup{}); err != nil {
+		t.Fatalf("drop table: %v", err)
+	}
+	if postgresModelSettled(&model.ClientGroup{}) {
+		t.Error("ClientGroup settled despite missing table")
+	}
+}

+ 17 - 9
internal/database/migrate_data.go

@@ -270,19 +270,14 @@ func resetPostgresSequences(dst *gorm.DB) error {
 	return resyncPostgresSequences(dst, migrationModels())
 }
 
-// resyncPostgresSequences sets each model's id sequence to MAX(id) so the next
-// auto-increment INSERT won't collide with an existing row. Table names are
-// resolved from the models themselves (not hardcoded), so they always match the
-// migrated tables. The statement is a no-op for tables without an id sequence
-// (e.g. composite-PK tables), and idempotent on a healthy DB, so it is safe to
-// run both after migration and on every Postgres startup.
+// resyncPostgresSequences sets each model's id sequence to MAX(id); idempotent. Id-less
+// composite-PK tables are skipped — Postgres rejects MAX(id) at parse time and logs it (#5665).
 func resyncPostgresSequences(db *gorm.DB, models []any) error {
 	for _, m := range models {
-		stmt := &gorm.Statement{DB: db}
-		if err := stmt.Parse(m); err != nil {
+		t, ok := tableWithIdColumn(db, m)
+		if !ok {
 			continue
 		}
-		t := stmt.Table
 		// t comes from the trusted model set parsed by GORM, not user input, so
 		// interpolating it as an identifier is safe. We ignore errors per-table.
 		_ = db.Exec(
@@ -293,3 +288,16 @@ func resyncPostgresSequences(db *gorm.DB, models []any) error {
 	}
 	return nil
 }
+
+// tableWithIdColumn resolves a model's table name and reports whether its GORM
+// schema maps an "id" database column.
+func tableWithIdColumn(db *gorm.DB, m any) (string, bool) {
+	stmt := &gorm.Statement{DB: db}
+	if err := stmt.Parse(m); err != nil {
+		return "", false
+	}
+	if stmt.Schema == nil || stmt.Schema.LookUpField("id") == nil {
+		return "", false
+	}
+	return stmt.Table, true
+}

+ 18 - 8
internal/sub/json_service.go

@@ -138,7 +138,7 @@ func (s *SubJsonService) GetJson(subId string, host string) (string, string, err
 
 func (s *SubJsonService) getConfig(subReq *SubService, inbound *model.Inbound, client model.Client, host string) []json_util.RawMessage {
 	var newJsonArray []json_util.RawMessage
-	stream := s.streamData(inbound.StreamSettings)
+	stream := s.streamData(inbound.StreamSettings, subKey(client))
 
 	// When externalProxy is empty the JSON config falls back to a
 	// synthetic one whose `dest` is the host the client connects to.
@@ -234,15 +234,25 @@ func (s *SubJsonService) getConfig(subReq *SubService, inbound *model.Inbound, c
 	return newJsonArray
 }
 
-func (s *SubJsonService) streamData(stream string) map[string]any {
+func (s *SubJsonService) streamData(stream string, clientKey string) map[string]any {
 	var streamSettings map[string]any
-	_ = json.Unmarshal([]byte(stream), &streamSettings)
+	if err := json.Unmarshal([]byte(stream), &streamSettings); err != nil || streamSettings == nil {
+		streamSettings = map[string]any{}
+	}
 	security, _ := streamSettings["security"].(string)
 	switch security {
 	case "tls":
-		streamSettings["tlsSettings"] = s.tlsData(streamSettings["tlsSettings"].(map[string]any))
+		if tlsSettings, ok := streamSettings["tlsSettings"].(map[string]any); ok {
+			streamSettings["tlsSettings"] = s.tlsData(tlsSettings)
+		} else {
+			delete(streamSettings, "tlsSettings")
+		}
 	case "reality":
-		streamSettings["realitySettings"] = s.realityData(streamSettings["realitySettings"].(map[string]any))
+		if realitySettings, ok := streamSettings["realitySettings"].(map[string]any); ok {
+			streamSettings["realitySettings"] = s.realityData(realitySettings, clientKey)
+		} else {
+			delete(streamSettings, "realitySettings")
+		}
 	}
 	delete(streamSettings, "sockopt")
 
@@ -322,7 +332,7 @@ func (s *SubJsonService) tlsData(tData map[string]any) map[string]any {
 	return tlsData
 }
 
-func (s *SubJsonService) realityData(rData map[string]any) map[string]any {
+func (s *SubJsonService) realityData(rData map[string]any, clientKey string) map[string]any {
 	rltyData := make(map[string]any, 1)
 	rltyClientSettings, _ := rData["settings"].(map[string]any)
 
@@ -331,8 +341,8 @@ func (s *SubJsonService) realityData(rData map[string]any) map[string]any {
 	rltyData["fingerprint"] = rltyClientSettings["fingerprint"]
 	rltyData["mldsa65Verify"] = rltyClientSettings["mldsa65Verify"]
 
-	// Set random data
-	rltyData["spiderX"] = "/" + random.Seq(15)
+	seed, _ := rltyClientSettings["spiderX"].(string)
+	rltyData["spiderX"] = deriveSpiderX(seed, clientKey)
 	shortIds, ok := rData["shortIds"].([]any)
 	if ok && len(shortIds) > 0 {
 		rltyData["shortId"] = shortIds[random.Num(len(shortIds))].(string)

+ 83 - 6
internal/sub/json_service_test.go

@@ -41,7 +41,7 @@ func TestSubJsonServiceInjectsGlobalFinalMask(t *testing.T) {
 		t.Fatal("direct_out outbound must never be emitted")
 	}
 
-	stream := svc.streamData(`{"network":"tcp","security":"none","tcpSettings":{"header":{"type":"none"}}}`)
+	stream := svc.streamData(`{"network":"tcp","security":"none","tcpSettings":{"header":{"type":"none"}}}`, "")
 	if _, ok := stream["sockopt"]; ok {
 		t.Fatal("legacy direct_out dialerProxy sockopt must never be set")
 	}
@@ -77,7 +77,7 @@ func TestSubJsonServiceMergesWithExistingFinalMask(t *testing.T) {
 	stream := svc.streamData(`{
 		"network":"tcp","security":"none","tcpSettings":{"header":{"type":"none"}},
 		"finalmask":{"tcp":[{"type":"sudoku"}]}
-	}`)
+	}`, "")
 
 	finalmask, _ := stream["finalmask"].(map[string]any)
 	tcp, _ := finalmask["tcp"].([]any)
@@ -93,7 +93,7 @@ func TestSubJsonServiceMergesWithExistingFinalMask(t *testing.T) {
 
 func TestSubJsonServiceNoFinalMaskWhenEmpty(t *testing.T) {
 	svc := NewSubJsonService("", "", "", nil)
-	stream := svc.streamData(`{"network":"tcp","security":"none","tcpSettings":{"header":{"type":"none"}}}`)
+	stream := svc.streamData(`{"network":"tcp","security":"none","tcpSettings":{"header":{"type":"none"}}}`, "")
 	if _, ok := stream["finalmask"]; ok {
 		t.Fatal("no finalmask should be emitted when subJsonFinalMask is empty")
 	}
@@ -107,7 +107,7 @@ func TestSubJsonServiceNoFinalMaskWhenEmpty(t *testing.T) {
 // to import the config (#5401).
 func TestSubJsonServicePinnedCertJoinedToString(t *testing.T) {
 	svc := NewSubJsonService("", "", "", nil)
-	stream := svc.streamData(`{"network":"tcp","security":"tls","tlsSettings":{"serverName":"a.example.com","settings":{"pinnedPeerCertSha256":["aa11","bb22"]}}}`)
+	stream := svc.streamData(`{"network":"tcp","security":"tls","tlsSettings":{"serverName":"a.example.com","settings":{"pinnedPeerCertSha256":["aa11","bb22"]}}}`, "")
 
 	tls, _ := stream["tlsSettings"].(map[string]any)
 	if tls == nil {
@@ -181,7 +181,7 @@ func TestSubJsonServiceXmuxSuppressesGlobalMux(t *testing.T) {
 	// When xmux is present in xhttpSettings, the per-inbound xmux handles
 	// multiplexing and the legacy outbound.Mux must NOT be set.
 	stream := `{"network":"xhttp","security":"tls","tlsSettings":{"serverName":"example.com"},"xhttpSettings":{"path":"/api","mode":"packet-up","xmux":{"maxConcurrency":"16-32"}}}`
-	parsed := svc.streamData(stream)
+	parsed := svc.streamData(stream, "")
 
 	mux := globalMux
 	if xhttp, ok := parsed["xhttpSettings"].(map[string]any); ok {
@@ -227,7 +227,7 @@ func TestSubJsonServiceGlobalMuxWhenNoXmux(t *testing.T) {
 
 	// When no xmux is present, the global subJsonMux should be used.
 	stream := `{"network":"xhttp","security":"tls","tlsSettings":{"serverName":"example.com"},"xhttpSettings":{"path":"/api","mode":"packet-up"}}`
-	parsed := svc.streamData(stream)
+	parsed := svc.streamData(stream, "")
 
 	mux := globalMux
 	if xhttp, ok := parsed["xhttpSettings"].(map[string]any); ok {
@@ -254,3 +254,80 @@ func TestSubJsonServiceGlobalMuxWhenNoXmux(t *testing.T) {
 		t.Fatalf("mux payload wrong: %#v", m)
 	}
 }
+
+func realitySpiderXFromStream(t *testing.T, svc *SubJsonService, clientKey string) string {
+	t.Helper()
+	stream := svc.streamData(`{
+		"network":"tcp","security":"reality","tcpSettings":{"header":{"type":"none"}},
+		"realitySettings":{
+			"serverNames":["reality.example.com"],
+			"shortIds":["ab12cd"],
+			"settings":{"publicKey":"PBKvalue","fingerprint":"firefox","spiderX":"/seed"}
+		}
+	}`, clientKey)
+	rlty, _ := stream["realitySettings"].(map[string]any)
+	if rlty == nil {
+		t.Fatal("streamData dropped realitySettings")
+	}
+	spx, _ := rlty["spiderX"].(string)
+	if len(spx) != 16 || spx[0] != '/' {
+		t.Fatalf("spiderX = %q, want a 16-char /-prefixed value", spx)
+	}
+	return spx
+}
+
+func TestSubJsonServiceRealityDataDerivesPerClientSpiderX(t *testing.T) {
+	svc := NewSubJsonService("", "", "", nil)
+
+	alice := realitySpiderXFromStream(t, svc, "subAlice")
+	if again := realitySpiderXFromStream(t, svc, "subAlice"); again != alice {
+		t.Fatalf("spiderX not stable for the same client: %q vs %q", alice, again)
+	}
+	if bob := realitySpiderXFromStream(t, svc, "subBob"); bob == alice {
+		t.Fatalf("spiderX identical across clients (fingerprintable): %q", alice)
+	}
+}
+
+// streamData must tolerate malformed stored inbounds: unparseable stream JSON
+// (with a finalMask configured, which writes into the map) and tls/reality
+// security whose settings key is missing or null previously panicked the
+// subscription request.
+func TestSubJsonServiceStreamDataMalformedInputs(t *testing.T) {
+	withMask := NewSubJsonService("", "", `{"tcp":[{"type":"fragment"}]}`, nil)
+	stream := withMask.streamData("not-json", "clientKey")
+	if _, ok := stream["finalmask"]; !ok {
+		t.Fatal("finalMask must still apply when stream settings fail to parse")
+	}
+
+	svc := NewSubJsonService("", "", "", nil)
+	noReality := svc.streamData(`{"network":"tcp","security":"reality"}`, "clientKey")
+	if v, ok := noReality["realitySettings"]; ok {
+		t.Fatalf("missing realitySettings must stay absent, got %v", v)
+	}
+	nullTls := svc.streamData(`{"network":"tcp","security":"tls","tlsSettings":null}`, "")
+	if v, ok := nullTls["tlsSettings"]; ok {
+		t.Fatalf("null tlsSettings must be dropped, got %v", v)
+	}
+}
+
+func TestSubJsonServiceRealityDataSpiderXFallsBackWhenNoClientKey(t *testing.T) {
+	svc := NewSubJsonService("", "", "", nil)
+
+	stream := svc.streamData(`{
+		"network":"tcp","security":"reality","tcpSettings":{"header":{"type":"none"}},
+		"realitySettings":{
+			"serverNames":["reality.example.com"],
+			"shortIds":["ab12cd"],
+			"settings":{"publicKey":"PBKvalue","fingerprint":"firefox"}
+		}
+	}`, "")
+
+	rlty, _ := stream["realitySettings"].(map[string]any)
+	if rlty == nil {
+		t.Fatal("streamData dropped realitySettings")
+	}
+	spx, _ := rlty["spiderX"].(string)
+	if len(spx) != 16 || spx[0] != '/' {
+		t.Fatalf("spiderX fallback = %q, want random 16-char /-prefixed value", spx)
+	}
+}

+ 5 - 5
internal/sub/mutation_audit_test.go

@@ -95,15 +95,15 @@ func TestSubJsonService_MuxAttachedWhenConfigured(t *testing.T) {
 	}
 }
 
-// --- json_service.go:268 — a non-empty finalMask that merges to nothing must
+// --- applyGlobalFinalMask — a non-empty finalMask that merges to nothing must
 // not add the finalmask key (the `len(merged) > 0` guard). ---
 
 func TestSubJsonService_FinalMaskMergingToEmptyNotAdded(t *testing.T) {
 	// finalMask is non-empty (passes the len(fm)==0 early return) but its only
 	// key is an empty tcp slice, which mergeFinalMask drops → merged is empty,
-	// so applyGlobalFinalMask (json_service.go:268) must NOT set finalmask.
+	// so applyGlobalFinalMask must NOT set finalmask.
 	svc := NewSubJsonService("", "", `{"tcp":[]}`, nil)
-	stream := svc.streamData(`{"network":"tcp","security":"none","tcpSettings":{"header":{"type":"none"}}}`)
+	stream := svc.streamData(`{"network":"tcp","security":"none","tcpSettings":{"header":{"type":"none"}}}`, "")
 	if _, ok := stream["finalmask"]; ok {
 		t.Fatalf("finalMask merging to empty must not add a finalmask key: %#v", stream["finalmask"])
 	}
@@ -111,13 +111,13 @@ func TestSubJsonService_FinalMaskMergingToEmptyNotAdded(t *testing.T) {
 	// Sanity: a finalMask that DOES merge to something still gets set, so the
 	// guard is the only distinguishing factor.
 	svc2 := NewSubJsonService("", "", `{"tcp":[{"type":"fragment"}]}`, nil)
-	stream2 := svc2.streamData(`{"network":"tcp","security":"none","tcpSettings":{"header":{"type":"none"}}}`)
+	stream2 := svc2.streamData(`{"network":"tcp","security":"none","tcpSettings":{"header":{"type":"none"}}}`, "")
 	if _, ok := stream2["finalmask"]; !ok {
 		t.Fatal("non-empty finalMask must be set")
 	}
 }
 
-// --- json_service.go:494 — an empty extra tcp slice must not clobber the base ---
+// --- mergeFinalMask — an empty extra tcp slice must not clobber the base ---
 
 func TestMergeFinalMask_EmptyExtraTcpKeepsBase(t *testing.T) {
 	base := map[string]any{"tcp": []any{map[string]any{"type": "keep"}}}

+ 27 - 4
internal/sub/service.go

@@ -683,7 +683,7 @@ func (s *SubService) genVlessLink(inbound *model.Inbound, email string) string {
 	case "tls":
 		applyShareTLSParams(stream, params)
 	case "reality":
-		applyShareRealityParams(stream, params)
+		applyShareRealityParams(stream, params, subKey(clients[clientIndex]))
 	default:
 		params["security"] = "none"
 	}
@@ -734,7 +734,7 @@ func (s *SubService) genTrojanLink(inbound *model.Inbound, email string) string
 	case "tls":
 		applyShareTLSParams(stream, params)
 	case "reality":
-		applyShareRealityParams(stream, params)
+		applyShareRealityParams(stream, params, subKey(clients[clientIndex]))
 		if streamNetwork == "tcp" && len(clients[clientIndex].Flow) > 0 {
 			params["flow"] = clients[clientIndex].Flow
 		}
@@ -1330,7 +1330,7 @@ func hysteriaPinHex(pin string) string {
 	return pin
 }
 
-func applyShareRealityParams(stream map[string]any, params map[string]string) {
+func applyShareRealityParams(stream map[string]any, params map[string]string, clientKey string) {
 	params["security"] = "reality"
 	realitySetting, _ := stream["realitySettings"].(map[string]any)
 	realitySettings, _ := searchKey(realitySetting, "settings")
@@ -1356,8 +1356,31 @@ func applyShareRealityParams(stream map[string]any, params map[string]string) {
 				params["pqv"] = pqv
 			}
 		}
-		params["spx"] = "/" + random.Seq(15)
+		seed := ""
+		if spxValue, ok := searchKey(realitySettings, "spiderX"); ok {
+			seed, _ = spxValue.(string)
+		}
+		params["spx"] = deriveSpiderX(seed, clientKey)
+	}
+}
+
+// subKey returns a stable per-client identity for deterministic derivations,
+// preferring the subscription id and falling back to the (unique) email.
+func subKey(c model.Client) string {
+	if c.SubID != "" {
+		return c.SubID
+	}
+	return c.Email
+}
+
+// deriveSpiderX maps the inbound's spiderX seed plus a stable client key to a
+// deterministic per-client "/path"; frontend/src/lib/xray/spider-x.ts mirrors it.
+func deriveSpiderX(seed, clientKey string) string {
+	if seed == "" && clientKey == "" {
+		return "/" + random.Seq(15)
 	}
+	sum := sha256.Sum256([]byte(seed + "|" + clientKey))
+	return "/" + hex.EncodeToString(sum[:])[:15]
 }
 
 func buildVmessLink(obj map[string]any) string {

+ 95 - 6
internal/sub/service_sharelink_test.go

@@ -1,6 +1,7 @@
 package sub
 
 import (
+	"net/url"
 	"strings"
 	"testing"
 
@@ -52,10 +53,8 @@ func TestGenVlessLink_TLSParamsMapped(t *testing.T) {
 	}
 }
 
-// TestGenVlessLink_RealityParamsMapped locks the reality field mapping
-// (applyShareRealityParams, service.go:1147). serverNames/shortIds are single-element
-// so random.Num is deterministic (index 0); spx is random so it is asserted by prefix.
-// Distinct pbk/sid values catch a pbk<->sid swap mutant.
+// Locks the reality field mapping of applyShareRealityParams; distinct pbk/sid
+// catch a swap mutant. spx is now a per-client derived value (#5718 / follow-up).
 func TestGenVlessLink_RealityParamsMapped(t *testing.T) {
 	stream := `{
 		"network":"tcp","security":"reality",
@@ -63,7 +62,7 @@ func TestGenVlessLink_RealityParamsMapped(t *testing.T) {
 		"realitySettings":{
 			"serverNames":["reality.example.com"],
 			"shortIds":["ab12cd"],
-			"settings":{"publicKey":"PBKvalue","fingerprint":"firefox"}
+			"settings":{"publicKey":"PBKvalue","fingerprint":"firefox","spiderX":"/mypath"}
 		}
 	}`
 	s := &SubService{}
@@ -75,7 +74,7 @@ func TestGenVlessLink_RealityParamsMapped(t *testing.T) {
 		"pbk=PBKvalue",
 		"sid=ab12cd",
 		"fp=firefox",
-		"spx=%2F", // "/" + random.Seq(15), percent-encoded leading slash
+		"spx=%2F",
 	}
 	for _, w := range wants {
 		if !strings.Contains(link, w) {
@@ -87,3 +86,93 @@ func TestGenVlessLink_RealityParamsMapped(t *testing.T) {
 		t.Fatalf("reality pbk/sid mapping crossed: %s", link)
 	}
 }
+
+// realityTwoClientInbound builds a reality VLESS inbound carrying two clients
+// with distinct subIds so the per-client spx derivation can be exercised.
+func realityTwoClientInbound() *model.Inbound {
+	return &model.Inbound{
+		Listen:   "203.0.113.1",
+		Port:     443,
+		Protocol: model.VLESS,
+		Remark:   "sharelink",
+		Settings: `{"clients":[
+			{"id":"11111111-2222-4333-8444-555555555555","email":"alice","subId":"subAlice"},
+			{"id":"22222222-3333-4444-8555-666666666666","email":"bob","subId":"subBob"}
+		],"decryption":"none","encryption":"none"}`,
+		StreamSettings: `{
+			"network":"tcp","security":"reality",
+			"tcpSettings":{"header":{"type":"none"}},
+			"realitySettings":{
+				"serverNames":["reality.example.com"],
+				"shortIds":["ab12cd"],
+				"settings":{"publicKey":"PBKvalue","fingerprint":"firefox","spiderX":"/seed"}
+			}
+		}`,
+	}
+}
+
+func spxParam(t *testing.T, link string) string {
+	t.Helper()
+	u, err := url.Parse(link)
+	if err != nil {
+		t.Fatalf("parse link %q: %v", link, err)
+	}
+	spx := u.Query().Get("spx")
+	if spx == "" || spx[0] != '/' {
+		t.Fatalf("spx missing or not /-prefixed in %q", link)
+	}
+	return spx
+}
+
+// spx must be stable for a given client across repeated exports (the #5718
+// complaint) yet differ between clients so the value can't be fingerprinted.
+func TestGenVlessLink_RealitySpiderXPerClientStable(t *testing.T) {
+	s := &SubService{}
+	inbound := realityTwoClientInbound()
+
+	aliceFirst := spxParam(t, s.genVlessLink(inbound, "alice"))
+	aliceSecond := spxParam(t, s.genVlessLink(inbound, "alice"))
+	bob := spxParam(t, s.genVlessLink(inbound, "bob"))
+
+	if aliceFirst != aliceSecond {
+		t.Fatalf("spx not stable for the same client: %q vs %q", aliceFirst, aliceSecond)
+	}
+	if aliceFirst == bob {
+		t.Fatalf("spx identical across clients (fingerprintable): %q", aliceFirst)
+	}
+}
+
+func TestDeriveSpiderX(t *testing.T) {
+	if got := deriveSpiderX("seed", "clientA"); got != deriveSpiderX("seed", "clientA") {
+		t.Fatalf("deriveSpiderX not deterministic: %q", got)
+	}
+	if deriveSpiderX("seed", "clientA") == deriveSpiderX("seed", "clientB") {
+		t.Fatal("deriveSpiderX must differ per client")
+	}
+	if deriveSpiderX("seedA", "clientA") == deriveSpiderX("seedB", "clientA") {
+		t.Fatal("rotating the seed must rotate a client's spx")
+	}
+	got := deriveSpiderX("seed", "clientA")
+	if len(got) != 16 || got[0] != '/' {
+		t.Fatalf("deriveSpiderX shape = %q, want /-prefixed 15-char path", got)
+	}
+	if fallback := deriveSpiderX("", ""); len(fallback) != 16 || fallback[0] != '/' {
+		t.Fatalf("empty-input fallback = %q, want /-prefixed path", fallback)
+	}
+}
+
+// Cross-language vectors shared with frontend/src/test/spider-x.test.ts: the
+// panel builds these links in TS, so both derivations must agree byte-for-byte.
+func TestDeriveSpiderXMatchesFrontendVectors(t *testing.T) {
+	vectors := map[string]struct{ seed, clientKey, want string }{
+		"seed and subId": {"/seed", "subAlice", "/c252fbc3ecd3e3c"},
+		"seed only":      {"/", "", "/d08ed99bd9afc60"},
+	}
+	for name, v := range vectors {
+		t.Run(name, func(t *testing.T) {
+			if got := deriveSpiderX(v.seed, v.clientKey); got != v.want {
+				t.Fatalf("deriveSpiderX(%q, %q) = %q, want %q (must match frontend/src/lib/xray/spider-x.ts)", v.seed, v.clientKey, got, v.want)
+			}
+		})
+	}
+}

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

@@ -50,8 +50,8 @@ func (j *XrayTrafficJob) Run() {
 			logger.Warning("get RestartXrayOnClientDisable failed:", settingErr)
 		}
 		if restartOnDisable {
-			if err := j.xrayService.RestartXray(true); err != nil {
-				logger.Warning("restart xray after disabling clients failed:", err)
+			if err := j.xrayService.RestartXray(false); err != nil {
+				logger.Warning("reconcile xray after disabling clients failed:", err)
 				j.xrayService.SetToNeedRestart()
 			}
 		}

+ 3 - 0
internal/web/service/client_bulk.go

@@ -834,6 +834,9 @@ func (s *ClientService) BulkDelete(inboundSvc *InboundService, emails []string,
 		// Serialize the row cleanup against the traffic poll to avoid the
 		// cross-transaction lock-order deadlock on client_traffics/inbounds.
 		if err := runSerializedTx(func(tx *gorm.DB) error {
+			if e := adjustGroupBaselinesForRemovedTraffic(tx, successEmails); e != nil {
+				return e
+			}
 			for _, batch := range chunkInts(successIds, sqlInChunk) {
 				if e := tx.Where("client_id IN ?", batch).Delete(&model.ClientInbound{}).Error; e != nil {
 					return e

+ 22 - 15
internal/web/service/client_crud.go

@@ -466,24 +466,31 @@ func (s *ClientService) Delete(inboundSvc *InboundService, id int, keepTraffic b
 	}
 
 	db := database.GetDB()
-	if err := db.Where("client_id = ?", id).Delete(&model.ClientInbound{}).Error; err != nil {
-		return needRestart, err
-	}
-	if err := db.Where("client_id = ?", id).Delete(&model.ClientExternalLink{}).Error; err != nil {
-		return needRestart, err
-	}
-	if !keepTraffic && existing.Email != "" {
-		if err := db.Where("email = ?", existing.Email).Delete(&xray.ClientTraffic{}).Error; err != nil {
-			return needRestart, err
+	if err := db.Transaction(func(tx *gorm.DB) error {
+		if existing.Email != "" {
+			if err := adjustGroupBaselinesForRemovedTraffic(tx, []string{existing.Email}); err != nil {
+				return err
+			}
 		}
-		if err := clearGlobalTraffic(db, existing.Email); err != nil {
-			return needRestart, err
+		if err := tx.Where("client_id = ?", id).Delete(&model.ClientInbound{}).Error; err != nil {
+			return err
 		}
-		if err := db.Where("client_email = ?", existing.Email).Delete(&model.InboundClientIps{}).Error; err != nil {
-			return needRestart, err
+		if err := tx.Where("client_id = ?", id).Delete(&model.ClientExternalLink{}).Error; err != nil {
+			return err
 		}
-	}
-	if err := db.Delete(&model.ClientRecord{}, id).Error; err != nil {
+		if !keepTraffic && existing.Email != "" {
+			if err := tx.Where("email = ?", existing.Email).Delete(&xray.ClientTraffic{}).Error; err != nil {
+				return err
+			}
+			if err := clearGlobalTraffic(tx, existing.Email); err != nil {
+				return err
+			}
+			if err := tx.Where("client_email = ?", existing.Email).Delete(&model.InboundClientIps{}).Error; err != nil {
+				return err
+			}
+		}
+		return tx.Delete(&model.ClientRecord{}, id).Error
+	}); err != nil {
 		return needRestart, err
 	}
 	return needRestart, nil

+ 102 - 0
internal/web/service/client_group_reset_test.go

@@ -125,3 +125,105 @@ func TestResetGroupTraffic_EmptyNameRejected(t *testing.T) {
 		t.Fatal("ResetGroupTraffic(blank) = nil, want error")
 	}
 }
+
+func TestGroupTotalsSurviveSingleClientReset(t *testing.T) {
+	initTrafficTestDB(t)
+	csvc := &ClientService{}
+	isvc := &InboundService{}
+
+	seedGroupedClient(t, "erin", "gold", 100, 200)
+	seedGroupedClient(t, "frank", "gold", 40, 60)
+
+	if err := isvc.ResetClientTrafficByEmail("erin"); err != nil {
+		t.Fatalf("ResetClientTrafficByEmail: %v", err)
+	}
+
+	g := groupByName(t, csvc, "gold")
+	if g.Up != 140 || g.Down != 260 || g.TrafficUsed != 400 {
+		t.Fatalf("group totals changed by client reset: got %+v, want up=140 down=260 used=400", g)
+	}
+
+	var erin xray.ClientTraffic
+	if err := database.GetDB().Where("email = ?", "erin").First(&erin).Error; err != nil {
+		t.Fatalf("load erin traffic: %v", err)
+	}
+	if erin.Up != 0 || erin.Down != 0 {
+		t.Fatalf("client traffic not reset: up=%d down=%d", erin.Up, erin.Down)
+	}
+}
+
+func TestGroupTotalsSurviveBulkClientReset(t *testing.T) {
+	initTrafficTestDB(t)
+	csvc := &ClientService{}
+	isvc := &InboundService{}
+
+	seedGroupedClient(t, "gina", "silver", 10, 20)
+	seedGroupedClient(t, "hank", "silver", 30, 40)
+
+	affected, err := csvc.BulkResetTraffic(isvc, []string{"gina", "hank"})
+	if err != nil {
+		t.Fatalf("BulkResetTraffic: %v", err)
+	}
+	if affected != 2 {
+		t.Fatalf("BulkResetTraffic affected = %d, want 2", affected)
+	}
+
+	g := groupByName(t, csvc, "silver")
+	if g.Up != 40 || g.Down != 60 || g.TrafficUsed != 100 {
+		t.Fatalf("group totals changed by bulk reset: got %+v, want up=40 down=60 used=100", g)
+	}
+}
+
+func TestGroupTotalsSurviveClientDelete(t *testing.T) {
+	initTrafficTestDB(t)
+	csvc := &ClientService{}
+	isvc := &InboundService{}
+
+	seedGroupedClient(t, "iris", "bronze", 70, 30)
+	seedGroupedClient(t, "jack", "bronze", 5, 5)
+
+	var rec model.ClientRecord
+	if err := database.GetDB().Where("email = ?", "iris").First(&rec).Error; err != nil {
+		t.Fatalf("load iris record: %v", err)
+	}
+	if _, err := csvc.Delete(isvc, rec.Id, false); err != nil {
+		t.Fatalf("Delete: %v", err)
+	}
+
+	g := groupByName(t, csvc, "bronze")
+	if g.Up != 75 || g.Down != 35 || g.TrafficUsed != 110 {
+		t.Fatalf("group totals changed by client delete: got %+v, want up=75 down=35 used=110", g)
+	}
+	if g.ClientCount != 1 {
+		t.Fatalf("client count = %d, want 1", g.ClientCount)
+	}
+
+	var trafficRows int64
+	if err := database.GetDB().Model(&xray.ClientTraffic{}).Where("email = ?", "iris").Count(&trafficRows).Error; err != nil {
+		t.Fatalf("count iris traffic rows: %v", err)
+	}
+	if trafficRows != 0 {
+		t.Fatalf("iris traffic row survived delete")
+	}
+}
+
+func TestGroupResetStillZeroesAfterBaselineAdjustments(t *testing.T) {
+	initTrafficTestDB(t)
+	csvc := &ClientService{}
+	isvc := &InboundService{}
+
+	seedGroupedClient(t, "kate", "iron", 100, 100)
+	if err := isvc.ResetClientTrafficByEmail("kate"); err != nil {
+		t.Fatalf("ResetClientTrafficByEmail: %v", err)
+	}
+	if g := groupByName(t, csvc, "iron"); g.TrafficUsed != 200 {
+		t.Fatalf("pre group-reset: got %+v, want used=200", g)
+	}
+
+	if err := csvc.ResetGroupTraffic("iron"); err != nil {
+		t.Fatalf("ResetGroupTraffic: %v", err)
+	}
+	if g := groupByName(t, csvc, "iron"); g.Up != 0 || g.Down != 0 || g.TrafficUsed != 0 {
+		t.Fatalf("group reset did not zero adjusted baselines: got %+v", g)
+	}
+}

+ 53 - 0
internal/web/service/client_groups.go

@@ -8,6 +8,8 @@ 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/util/common"
+
+	"gorm.io/gorm"
 )
 
 type GroupSummary struct {
@@ -69,6 +71,57 @@ func (s *ClientService) ListGroups() ([]GroupSummary, error) {
 	return out, nil
 }
 
+// adjustGroupBaselinesForRemovedTraffic shifts group baselines down by the clients'
+// current counters so ListGroups totals survive a traffic reset or client delete (#5675).
+func adjustGroupBaselinesForRemovedTraffic(tx *gorm.DB, emails []string) error {
+	if len(emails) == 0 {
+		return nil
+	}
+	type groupDelta struct {
+		Name string
+		Up   int64
+		Down int64
+	}
+	totals := make(map[string]*groupDelta)
+	for _, batch := range chunkStrings(emails, sqlInChunk) {
+		var part []groupDelta
+		if err := tx.Table("clients AS c").
+			Select("c.group_name AS name, COALESCE(SUM(ct.up), 0) AS up, COALESCE(SUM(ct.down), 0) AS down").
+			Joins("JOIN client_traffics ct ON ct.email = c.email").
+			Where("c.group_name <> '' AND c.email IN ?", batch).
+			Group("c.group_name").
+			Scan(&part).Error; err != nil {
+			return err
+		}
+		for i := range part {
+			if agg, ok := totals[part[i].Name]; ok {
+				agg.Up += part[i].Up
+				agg.Down += part[i].Down
+			} else {
+				totals[part[i].Name] = &part[i]
+			}
+		}
+	}
+	for name, d := range totals {
+		if d.Up == 0 && d.Down == 0 {
+			continue
+		}
+		res := tx.Model(&model.ClientGroup{}).Where("name = ?", name).Updates(map[string]any{
+			"reset_up":   gorm.Expr("reset_up - ?", d.Up),
+			"reset_down": gorm.Expr("reset_down - ?", d.Down),
+		})
+		if res.Error != nil {
+			return res.Error
+		}
+		if res.RowsAffected == 0 {
+			if err := tx.Create(&model.ClientGroup{Name: name, ResetUp: -d.Up, ResetDown: -d.Down}).Error; err != nil {
+				return err
+			}
+		}
+	}
+	return nil
+}
+
 func (s *ClientService) EmailsByGroup(name string) ([]string, error) {
 	name = strings.TrimSpace(name)
 	if name == "" {

+ 20 - 0
internal/web/service/client_inbound_apply.go

@@ -528,6 +528,26 @@ func (s *ClientService) UpdateInboundClient(inboundSvc *InboundService, data *mo
 		}
 		if len(clients[0].AllowedIPs) == 0 {
 			clients[0].AllowedIPs = old.AllowedIPs
+		} else {
+			normalized, nErr := normalizeWireguardAllowedIPs(clients[0].AllowedIPs)
+			if nErr != nil {
+				return false, nErr
+			}
+			if len(normalized) == 0 {
+				clients[0].AllowedIPs = old.AllowedIPs
+			} else {
+				peers := make([]string, 0, len(oldClients))
+				for i := range oldClients {
+					if i == clientIndex {
+						continue
+					}
+					peers = append(peers, oldClients[i].AllowedIPs...)
+				}
+				if hit := wireguardAllowedIPsCollision(normalized, peers); hit != "" {
+					return false, common.NewError("wireguard: allowedIPs entry already used by another client:", hit)
+				}
+				clients[0].AllowedIPs = normalized
+			}
 		}
 		if clients[0].PreSharedKey == "" {
 			clients[0].PreSharedKey = old.PreSharedKey

+ 3 - 0
internal/web/service/client_portable.go

@@ -187,6 +187,9 @@ func (s *ClientService) DeleteOrphans() (int, error) {
 	tombstoneClientEmails(emails)
 
 	if err := runSerializedTx(func(tx *gorm.DB) error {
+		if e := adjustGroupBaselinesForRemovedTraffic(tx, emails); e != nil {
+			return e
+		}
 		for _, batch := range chunkInts(ids, sqlInChunk) {
 			if e := tx.Where("client_id IN ?", batch).Delete(&model.ClientInbound{}).Error; e != nil {
 				return e

+ 7 - 0
internal/web/service/client_traffic.go

@@ -92,6 +92,9 @@ func (s *ClientService) BulkResetTraffic(inboundSvc *InboundService, emails []st
 	err := submitTrafficWrite(func() error {
 		db := database.GetDB()
 		return db.Transaction(func(tx *gorm.DB) error {
+			if err := adjustGroupBaselinesForRemovedTraffic(tx, cleanEmails); err != nil {
+				return err
+			}
 			for _, batch := range chunkStrings(cleanEmails, sqlInChunk) {
 				res := tx.Model(xray.ClientTraffic{}).
 					Where("email IN ?", batch).
@@ -150,6 +153,10 @@ func (s *ClientService) resetAllClientTrafficsLocked(id int) error {
 			return nil
 		}
 
+		if err := adjustGroupBaselinesForRemovedTraffic(tx, resetEmails); err != nil {
+			return err
+		}
+
 		result := tx.Model(xray.ClientTraffic{}).
 			Where("email IN ?", resetEmails).
 			Updates(map[string]any{"enable": true, "up": 0, "down": 0})

+ 53 - 0
internal/web/service/client_wireguard.go

@@ -73,6 +73,47 @@ func allocateWireguardAddress(used []string, base string) (string, error) {
 	return "", common.NewError("wireguard: no free address available in", base)
 }
 
+// normalizeWireguardAllowedIPs validates user-supplied allowedIPs entries and
+// canonicalizes them: bare addresses become single-host prefixes, duplicates drop.
+func normalizeWireguardAllowedIPs(values []string) ([]string, error) {
+	out := make([]string, 0, len(values))
+	seen := make(map[string]struct{}, len(values))
+	for _, v := range values {
+		v = strings.TrimSpace(v)
+		if v == "" {
+			continue
+		}
+		p, err := netip.ParsePrefix(v)
+		if err != nil {
+			a, aErr := netip.ParseAddr(v)
+			if aErr != nil {
+				return nil, common.NewError("wireguard: invalid allowedIPs entry:", v)
+			}
+			p = netip.PrefixFrom(a, a.BitLen())
+		}
+		norm := p.String()
+		if _, dup := seen[norm]; dup {
+			continue
+		}
+		seen[norm] = struct{}{}
+		out = append(out, norm)
+	}
+	return out, nil
+}
+
+func wireguardAllowedIPsCollision(entries, used []string) string {
+	taken := make(map[string]struct{}, len(used))
+	for _, u := range used {
+		taken[strings.TrimSpace(u)] = struct{}{}
+	}
+	for _, e := range entries {
+		if _, ok := taken[e]; ok {
+			return e
+		}
+	}
+	return ""
+}
+
 // defaultWireguardClients fills in blank WireGuard credentials for newly added
 // clients: a generated keypair when none was provided, a derived public key when
 // only a private key was given, and a unique tunnel address allocated from the
@@ -107,6 +148,18 @@ func defaultWireguardClients(existing, clients []model.Client, interfaceClients
 				return err
 			}
 			c.AllowedIPs = []string{addr}
+		} else {
+			normalized, err := normalizeWireguardAllowedIPs(c.AllowedIPs)
+			if err != nil {
+				return err
+			}
+			if len(normalized) == 0 {
+				return common.NewError("wireguard: allowedIPs has no usable entry")
+			}
+			if hit := wireguardAllowedIPsCollision(normalized, used); hit != "" {
+				return common.NewError("wireguard: allowedIPs entry already used by another client:", hit)
+			}
+			c.AllowedIPs = normalized
 		}
 		used = append(used, c.AllowedIPs...)
 

+ 64 - 0
internal/web/service/client_wireguard_test.go

@@ -140,3 +140,67 @@ func TestDefaultWireguardClientsAllocatesDistinctIPs(t *testing.T) {
 		t.Fatalf("two clients got the same address: %v", clients[0].AllowedIPs)
 	}
 }
+
+func TestNormalizeWireguardAllowedIPs(t *testing.T) {
+	tests := []struct {
+		name string
+		in   []string
+		want []string
+		err  bool
+	}{
+		{name: "cidr passes through", in: []string{"10.0.0.5/32"}, want: []string{"10.0.0.5/32"}},
+		{name: "bare ipv4 becomes /32", in: []string{"10.0.0.5"}, want: []string{"10.0.0.5/32"}},
+		{name: "bare ipv6 becomes /128", in: []string{"fd00::5"}, want: []string{"fd00::5/128"}},
+		{name: "trims and drops empties", in: []string{" 10.0.0.5/32 ", "", "  "}, want: []string{"10.0.0.5/32"}},
+		{name: "dedupes", in: []string{"10.0.0.5/32", "10.0.0.5/32"}, want: []string{"10.0.0.5/32"}},
+		{name: "routed subnet allowed", in: []string{"10.0.0.5/32", "192.168.1.0/24"}, want: []string{"10.0.0.5/32", "192.168.1.0/24"}},
+		{name: "garbage rejected", in: []string{"not-an-ip"}, err: true},
+		{name: "bad prefix rejected", in: []string{"10.0.0.5/99"}, err: true},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			got, err := normalizeWireguardAllowedIPs(tt.in)
+			if tt.err {
+				if err == nil {
+					t.Fatalf("expected error, got %v", got)
+				}
+				return
+			}
+			if err != nil {
+				t.Fatalf("unexpected error: %v", err)
+			}
+			if len(got) != len(tt.want) {
+				t.Fatalf("got %v, want %v", got, tt.want)
+			}
+			for i := range got {
+				if got[i] != tt.want[i] {
+					t.Fatalf("got %v, want %v", got, tt.want)
+				}
+			}
+		})
+	}
+}
+
+func TestDefaultWireguardClientsHonorsAndValidatesSuppliedAllowedIPs(t *testing.T) {
+	existing := []model.Client{{Email: "old@wg", AllowedIPs: []string{"10.0.0.2/32"}}}
+
+	clients := []model.Client{{Email: "c@wg", AllowedIPs: []string{"10.0.0.9"}}}
+	ifaces := []any{map[string]any{"email": "c@wg"}}
+	if err := defaultWireguardClients(existing, clients, ifaces); err != nil {
+		t.Fatalf("defaultWireguardClients: %v", err)
+	}
+	if len(clients[0].AllowedIPs) != 1 || clients[0].AllowedIPs[0] != "10.0.0.9/32" {
+		t.Fatalf("supplied allowedIPs not normalized: %v", clients[0].AllowedIPs)
+	}
+
+	dup := []model.Client{{Email: "d@wg", AllowedIPs: []string{"10.0.0.2/32"}}}
+	err := defaultWireguardClients(existing, dup, []any{map[string]any{"email": "d@wg"}})
+	if err == nil {
+		t.Fatal("duplicate allowedIPs across clients must be rejected")
+	}
+
+	bad := []model.Client{{Email: "e@wg", AllowedIPs: []string{"not-an-ip"}}}
+	if err := defaultWireguardClients(existing, bad, []any{map[string]any{"email": "e@wg"}}); err == nil {
+		t.Fatal("invalid allowedIPs entry must be rejected")
+	}
+}

+ 15 - 0
internal/web/service/inbound_node.go

@@ -191,6 +191,17 @@ func mergeActivationExpiry(existing, node int64) int64 {
 	return node
 }
 
+// liftActivatedClientRecordExpiries copies a node-activated deadline from
+// client_traffics onto client records still holding the negative duration (#5714).
+func liftActivatedClientRecordExpiries(tx *gorm.DB) error {
+	return tx.Exec(
+		`UPDATE clients
+		 SET expiry_time = (SELECT ct.expiry_time FROM client_traffics ct WHERE ct.email = clients.email AND ct.expiry_time > 0 LIMIT 1)
+		 WHERE clients.expiry_time < 0
+		   AND EXISTS (SELECT 1 FROM client_traffics ct WHERE ct.email = clients.email AND ct.expiry_time > 0)`,
+	).Error
+}
+
 func (s *InboundService) SetRemoteTraffic(nodeID int, snap *runtime.TrafficSnapshot, dirty bool) (bool, error) {
 	var structuralChange bool
 	err := submitTrafficWrite(func() error {
@@ -847,6 +858,10 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
 		}
 	}
 
+	if err := liftActivatedClientRecordExpiries(tx); err != nil {
+		logger.Warning("setRemoteTraffic: lift activated expiries failed:", err)
+	}
+
 	if err := tx.Commit().Error; err != nil {
 		return false, err
 	}

+ 23 - 10
internal/web/service/inbound_traffic.go

@@ -474,6 +474,9 @@ func (s *InboundService) UpdateClientStat(tx *gorm.DB, email string, client *mod
 }
 
 func (s *InboundService) DelClientStat(tx *gorm.DB, email string) error {
+	if err := adjustGroupBaselinesForRemovedTraffic(tx, []string{email}); err != nil {
+		return err
+	}
 	if err := tx.Where("email = ?", email).Delete(xray.ClientTraffic{}).Error; err != nil {
 		return err
 	}
@@ -484,6 +487,9 @@ func (s *InboundService) DelClientStat(tx *gorm.DB, email string) error {
 }
 
 func (s *InboundService) delClientStatsByEmails(tx *gorm.DB, emails []string) error {
+	if err := adjustGroupBaselinesForRemovedTraffic(tx, emails); err != nil {
+		return err
+	}
 	const chunk = 400
 	for start := 0; start < len(emails); start += chunk {
 		end := min(start+chunk, len(emails))
@@ -503,16 +509,20 @@ func (s *InboundService) delClientStatsByEmails(tx *gorm.DB, emails []string) er
 
 func (s *InboundService) ResetClientTrafficByEmail(clientEmail string) error {
 	return submitTrafficWrite(func() error {
-		db := database.GetDB()
-		if err := clearGlobalTraffic(db, clientEmail); err != nil {
-			return err
-		}
-		if err := db.Model(xray.ClientTraffic{}).
-			Where("email = ?", clientEmail).
-			Updates(map[string]any{"enable": true, "up": 0, "down": 0}).Error; err != nil {
-			return err
-		}
-		return db.Where("email = ?", clientEmail).Delete(&model.NodeClientTraffic{}).Error
+		return database.GetDB().Transaction(func(tx *gorm.DB) error {
+			if err := adjustGroupBaselinesForRemovedTraffic(tx, []string{clientEmail}); err != nil {
+				return err
+			}
+			if err := clearGlobalTraffic(tx, clientEmail); err != nil {
+				return err
+			}
+			if err := tx.Model(xray.ClientTraffic{}).
+				Where("email = ?", clientEmail).
+				Updates(map[string]any{"enable": true, "up": 0, "down": 0}).Error; err != nil {
+				return err
+			}
+			return tx.Where("email = ?", clientEmail).Delete(&model.NodeClientTraffic{}).Error
+		})
 	})
 }
 
@@ -596,6 +606,9 @@ func (s *InboundService) resetClientTrafficLocked(id int, clientEmail string) (b
 		return false, err
 	}
 	if err := db.Transaction(func(tx *gorm.DB) error {
+		if err := adjustGroupBaselinesForRemovedTraffic(tx, []string{clientEmail}); err != nil {
+			return err
+		}
 		if err := tx.Save(traffic).Error; err != nil {
 			return err
 		}

+ 42 - 0
internal/web/service/node_client_expiry_sync_test.go

@@ -3,6 +3,7 @@ package service
 import (
 	"testing"
 
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
 	"github.com/mhsanaei/3x-ui/v3/internal/xray"
 )
 
@@ -139,3 +140,44 @@ func TestNodeRenewExtendsExpiry(t *testing.T) {
 		t.Fatalf("node renewal did not propagate: expiry = %d, want %d", got, renewed)
 	}
 }
+
+// TestNodeActivationLiftsClientRecordExpiry reproduces #5714: the node activates
+// the deadline (positive ClientStats) while its settings JSON still carries the
+// negative duration, so SyncInbound keeps writing the stale value into the
+// client record and the Clients page shows "not started" forever.
+func TestNodeActivationLiftsClientRecordExpiry(t *testing.T) {
+	db := initTrafficTestDB(t)
+	createNodeInboundWithClient(t, db, 1, "n1-in", 41001, "delayed")
+	svc := &InboundService{}
+
+	const email = "delayed"
+	const duration = int64(-2592000000)
+	const activated = int64(1798448344010)
+	negSettings := `{"clients":[{"email":"delayed","enable":true,"expiryTime":-2592000000}]}`
+
+	if err := db.Create(&model.ClientRecord{Email: email, Enable: true, ExpiryTime: duration}).Error; err != nil {
+		t.Fatalf("seed client record: %v", err)
+	}
+
+	readRecordExpiry := func() int64 {
+		t.Helper()
+		var rec model.ClientRecord
+		if err := db.Where("email = ?", email).First(&rec).Error; err != nil {
+			t.Fatalf("read client record: %v", err)
+		}
+		return rec.ExpiryTime
+	}
+
+	syncNodeWithSettings(t, svc, 1, "n1-in", negSettings, xray.ClientTraffic{Email: email, ExpiryTime: duration, Enable: true})
+	if got := readRecordExpiry(); got != duration {
+		t.Fatalf("before activation: record expiry = %d, want %d", got, duration)
+	}
+
+	syncNodeWithSettings(t, svc, 1, "n1-in", negSettings, xray.ClientTraffic{Email: email, Up: 100, Down: 100, ExpiryTime: activated, Enable: true})
+	if got := readTraffic(t, db, email).ExpiryTime; got != activated {
+		t.Fatalf("client_traffics not activated: expiry = %d, want %d", got, activated)
+	}
+	if got := readRecordExpiry(); got != activated {
+		t.Fatalf("client record kept stale duration (#5714): expiry = %d, want %d", got, activated)
+	}
+}

+ 25 - 0
internal/web/service/xray.go

@@ -1004,6 +1004,12 @@ func (s *XrayService) tryHotApply(newCfg *xray.Config) bool {
 
 	// Removals first so changed handlers and port swaps never collide with
 	// the additions that follow.
+	for _, u := range diff.RemovedUsers {
+		if err := hotAPI.RemoveUser(u.Tag, u.Email); err != nil && !xray.IsMissingHandlerErr(err) {
+			logger.Info("hot apply: remove user [", u.Email, "] from [", u.Tag, "] failed:", err)
+			return false
+		}
+	}
 	for _, tag := range diff.RemovedInboundTags {
 		if err := hotAPI.DelInbound(tag); err != nil && !xray.IsMissingHandlerErr(err) {
 			logger.Info("hot apply: remove inbound [", tag, "] failed:", err)
@@ -1028,6 +1034,12 @@ func (s *XrayService) tryHotApply(newCfg *xray.Config) bool {
 			return false
 		}
 	}
+	for _, u := range diff.AddedUsers {
+		if err := addUserReconciling(&hotAPI, u); err != nil {
+			logger.Info("hot apply: add user [", u.Email, "] to [", u.Tag, "] failed:", err)
+			return false
+		}
+	}
 	if diff.RoutingConfig != nil {
 		if err := hotAPI.ApplyRoutingConfig(diff.RoutingConfig); err != nil {
 			logger.Info("hot apply: apply routing config failed:", err)
@@ -1039,6 +1051,19 @@ func (s *XrayService) tryHotApply(newCfg *xray.Config) bool {
 	return true
 }
 
+// addUserReconciling adds a user, and on an email conflict (the user was
+// already applied through the runtime API) replaces the existing user instead.
+func addUserReconciling(api *xray.XrayAPI, u xray.UserOp) error {
+	err := api.AddUser(u.Protocol, u.Tag, u.User)
+	if err == nil || !xray.IsUserExistsErr(err) {
+		return err
+	}
+	if delErr := api.RemoveUser(u.Tag, u.Email); delErr != nil && !xray.IsMissingHandlerErr(delErr) {
+		return delErr
+	}
+	return api.AddUser(u.Protocol, u.Tag, u.User)
+}
+
 // addInboundReconciling adds an inbound, and on a tag conflict (the handler
 // was already created through the runtime API while the stored snapshot was
 // stale) replaces the existing handler instead.

+ 2 - 0
internal/web/translation/ar-EG.json

@@ -646,6 +646,7 @@
         "scanUse": "استخدام",
         "scanRescan": "إعادة الفحص",
         "spiderX": "SpiderX",
+        "spiderXHint": "بذرة لكل عميل — تشتق اللوحة مسار spx فريدًا لكل عميل منها؛ أعد التوليد لتدوير مسارات الجميع",
         "getNewCert": "احصل على شهادة جديدة",
         "mldsa65Seed": "mldsa65 Seed",
         "mldsa65Verify": "mldsa65 Verify",
@@ -900,6 +901,7 @@
       "wireguardPublicKey": "مفتاح وايرغارد العام",
       "wireguardPreSharedKey": "مفتاح وايرغارد المشترك مسبقًا",
       "wireguardAllowedIPs": "عناوين IP المسموحة لوايرغارد",
+      "wireguardAllowedIPsHint": "اتركه فارغًا للتعيين التلقائي؛ افصل بين الإدخالات بفواصل",
       "reverseTag": "وسم عكسي",
       "reverseTagPlaceholder": "Reverse tag اختياري",
       "telegramId": "معرّف مستخدم تلغرام",

+ 2 - 0
internal/web/translation/en-US.json

@@ -658,6 +658,7 @@
         "scanUse": "Use",
         "scanRescan": "Rescan",
         "spiderX": "SpiderX",
+        "spiderXHint": "Per-client seed — the panel derives a unique spx path for each client from it; regenerate to rotate everyone's paths",
         "getNewCert": "Get New Cert",
         "mldsa65Seed": "mldsa65 Seed",
         "mldsa65Verify": "mldsa65 Verify",
@@ -900,6 +901,7 @@
       "wireguardPublicKey": "WireGuard Public Key",
       "wireguardPreSharedKey": "WireGuard Pre-Shared Key",
       "wireguardAllowedIPs": "WireGuard Allowed IPs",
+      "wireguardAllowedIPsHint": "Leave empty to auto-assign; separate entries with commas",
       "reverseTag": "Reverse tag",
       "reverseTagPlaceholder": "Optional reverse tag",
       "telegramId": "Telegram user ID",

+ 2 - 0
internal/web/translation/es-ES.json

@@ -667,6 +667,7 @@
         "scanUse": "Usar",
         "scanRescan": "Reescanear",
         "spiderX": "SpiderX",
+        "spiderXHint": "Semilla por cliente: el panel deriva de ella una ruta spx única para cada cliente; regenera para rotar las rutas de todos",
         "getNewCert": "Obtener nuevo cert",
         "mldsa65Seed": "mldsa65 Seed",
         "mldsa65Verify": "mldsa65 Verify",
@@ -900,6 +901,7 @@
       "wireguardPublicKey": "Clave pública de WireGuard",
       "wireguardPreSharedKey": "Clave precompartida de WireGuard",
       "wireguardAllowedIPs": "IP permitidas de WireGuard",
+      "wireguardAllowedIPsHint": "Déjalo vacío para asignar automáticamente; separa las entradas con comas",
       "reverseTag": "Etiqueta inversa",
       "reverseTagPlaceholder": "Reverse tag opcional",
       "telegramId": "ID de usuario de Telegram",

+ 2 - 0
internal/web/translation/fa-IR.json

@@ -658,6 +658,7 @@
         "scanUse": "استفاده",
         "scanRescan": "اسکن مجدد",
         "spiderX": "SpiderX",
+        "spiderXHint": "دانه‌ی هر کاربر — پنل از روی آن مسیر spx یکتا برای هر کاربر می‌سازد؛ برای چرخش مسیر همه، دوباره تولید کنید",
         "getNewCert": "دریافت گواهی جدید",
         "mldsa65Seed": "mldsa65 Seed",
         "mldsa65Verify": "mldsa65 Verify",
@@ -900,6 +901,7 @@
       "wireguardPublicKey": "کلید عمومی وایرگارد",
       "wireguardPreSharedKey": "کلید پیش‌اشتراکی وایرگارد",
       "wireguardAllowedIPs": "آی‌پی‌های مجاز وایرگارد",
+      "wireguardAllowedIPsHint": "برای تخصیص خودکار خالی بگذارید؛ ورودی‌ها را با کاما جدا کنید",
       "reverseTag": "تگ معکوس",
       "reverseTagPlaceholder": "Reverse tag اختیاری",
       "telegramId": "شناسه کاربر تلگرام",

+ 2 - 0
internal/web/translation/id-ID.json

@@ -646,6 +646,7 @@
         "scanUse": "Gunakan",
         "scanRescan": "Pindai ulang",
         "spiderX": "SpiderX",
+        "spiderXHint": "Seed per-klien — panel menurunkan jalur spx unik untuk tiap klien darinya; regenerasi untuk merotasi jalur semua klien",
         "getNewCert": "Dapatkan sertifikat baru",
         "mldsa65Seed": "mldsa65 Seed",
         "mldsa65Verify": "mldsa65 Verify",
@@ -900,6 +901,7 @@
       "wireguardPublicKey": "Kunci Publik WireGuard",
       "wireguardPreSharedKey": "Kunci Pra-Berbagi WireGuard",
       "wireguardAllowedIPs": "IP yang Diizinkan WireGuard",
+      "wireguardAllowedIPsHint": "Biarkan kosong untuk penetapan otomatis; pisahkan entri dengan koma",
       "reverseTag": "Reverse tag",
       "reverseTagPlaceholder": "Reverse tag opsional",
       "telegramId": "ID pengguna Telegram",

+ 2 - 0
internal/web/translation/ja-JP.json

@@ -667,6 +667,7 @@
         "scanUse": "使用",
         "scanRescan": "再スキャン",
         "spiderX": "SpiderX",
+        "spiderXHint": "クライアントごとのシード。パネルはこれから各クライアント固有の spx パスを生成します。再生成で全員のパスを更新します",
         "getNewCert": "新しい証明書を取得",
         "mldsa65Seed": "mldsa65 Seed",
         "mldsa65Verify": "mldsa65 Verify",
@@ -900,6 +901,7 @@
       "wireguardPublicKey": "WireGuard 公開鍵",
       "wireguardPreSharedKey": "WireGuard 事前共有鍵",
       "wireguardAllowedIPs": "WireGuard 許可IP",
+      "wireguardAllowedIPsHint": "空欄で自動割り当て。複数指定はカンマ区切り",
       "reverseTag": "Reverse tag",
       "reverseTagPlaceholder": "任意の Reverse tag",
       "telegramId": "Telegram ユーザー ID",

+ 2 - 0
internal/web/translation/pt-BR.json

@@ -667,6 +667,7 @@
         "scanUse": "Usar",
         "scanRescan": "Reescanear",
         "spiderX": "SpiderX",
+        "spiderXHint": "Semente por cliente — o painel deriva dela um caminho spx único para cada cliente; regenere para rotacionar os caminhos de todos",
         "getNewCert": "Obter novo certificado",
         "mldsa65Seed": "mldsa65 Seed",
         "mldsa65Verify": "mldsa65 Verify",
@@ -900,6 +901,7 @@
       "wireguardPublicKey": "Chave pública do WireGuard",
       "wireguardPreSharedKey": "Chave pré-compartilhada do WireGuard",
       "wireguardAllowedIPs": "IPs permitidos do WireGuard",
+      "wireguardAllowedIPsHint": "Deixe vazio para atribuir automaticamente; separe as entradas com vírgulas",
       "reverseTag": "Tag reversa",
       "reverseTagPlaceholder": "Reverse tag opcional",
       "telegramId": "ID de usuário do Telegram",

+ 2 - 0
internal/web/translation/ru-RU.json

@@ -667,6 +667,7 @@
         "scanUse": "Выбрать",
         "scanRescan": "Пересканировать",
         "spiderX": "SpiderX",
+        "spiderXHint": "Сид на клиента — панель формирует из него уникальный путь spx для каждого клиента; перегенерируйте, чтобы обновить пути всех",
         "getNewCert": "Получить новый сертификат",
         "mldsa65Seed": "mldsa65 Seed",
         "mldsa65Verify": "mldsa65 Verify",
@@ -900,6 +901,7 @@
       "wireguardPublicKey": "Публичный ключ WireGuard",
       "wireguardPreSharedKey": "Общий ключ WireGuard",
       "wireguardAllowedIPs": "Разрешённые IP WireGuard",
+      "wireguardAllowedIPsHint": "Оставьте пустым для автоназначения; разделяйте записи запятыми",
       "reverseTag": "Обратный тег",
       "reverseTagPlaceholder": "Необязательный Reverse tag",
       "telegramId": "ID пользователя Telegram",

+ 2 - 0
internal/web/translation/tr-TR.json

@@ -646,6 +646,7 @@
         "scanUse": "Kullan",
         "scanRescan": "Yeniden tara",
         "spiderX": "SpiderX",
+        "spiderXHint": "İstemci başına tohum — panel bundan her istemci için benzersiz bir spx yolu türetir; herkesin yolunu döndürmek için yeniden üretin",
         "getNewCert": "Yeni Sertifika Al",
         "mldsa65Seed": "mldsa65 Seed",
         "mldsa65Verify": "mldsa65 Verify",
@@ -900,6 +901,7 @@
       "wireguardPublicKey": "WireGuard Genel Anahtarı",
       "wireguardPreSharedKey": "WireGuard Ön Paylaşımlı Anahtar",
       "wireguardAllowedIPs": "WireGuard İzin Verilen IP'ler",
+      "wireguardAllowedIPsHint": "Otomatik atama için boş bırakın; girişleri virgülle ayırın",
       "reverseTag": "Reverse Tag",
       "reverseTagPlaceholder": "İsteğe Bağlı Reverse Tag",
       "telegramId": "Telegram Kullanıcı ID'si",

+ 2 - 0
internal/web/translation/uk-UA.json

@@ -646,6 +646,7 @@
         "scanUse": "Обрати",
         "scanRescan": "Пересканувати",
         "spiderX": "SpiderX",
+        "spiderXHint": "Сід на клієнта — панель формує з нього унікальний шлях spx для кожного клієнта; перегенеруйте, щоб оновити шляхи всіх",
         "getNewCert": "Отримати новий сертифікат",
         "mldsa65Seed": "mldsa65 Seed",
         "mldsa65Verify": "mldsa65 Verify",
@@ -900,6 +901,7 @@
       "wireguardPublicKey": "Публічний ключ WireGuard",
       "wireguardPreSharedKey": "Спільний ключ WireGuard",
       "wireguardAllowedIPs": "Дозволені IP WireGuard",
+      "wireguardAllowedIPsHint": "Залиште порожнім для автопризначення; розділяйте записи комами",
       "reverseTag": "Зворотний тег",
       "reverseTagPlaceholder": "Необов'язковий Reverse tag",
       "telegramId": "ID користувача Telegram",

+ 2 - 0
internal/web/translation/vi-VN.json

@@ -667,6 +667,7 @@
         "scanUse": "Dùng",
         "scanRescan": "Quét lại",
         "spiderX": "SpiderX",
+        "spiderXHint": "Hạt giống theo từng client — bảng điều khiển suy ra đường dẫn spx riêng cho mỗi client từ đó; tạo lại để xoay đường dẫn của tất cả",
         "getNewCert": "Lấy chứng chỉ mới",
         "mldsa65Seed": "mldsa65 Seed",
         "mldsa65Verify": "mldsa65 Verify",
@@ -900,6 +901,7 @@
       "wireguardPublicKey": "Khóa công khai WireGuard",
       "wireguardPreSharedKey": "Khóa chia sẻ trước WireGuard",
       "wireguardAllowedIPs": "IP được phép WireGuard",
+      "wireguardAllowedIPsHint": "Để trống để tự động gán; phân tách các mục bằng dấu phẩy",
       "reverseTag": "Reverse tag",
       "reverseTagPlaceholder": "Reverse tag tùy chọn",
       "telegramId": "ID người dùng Telegram",

+ 2 - 0
internal/web/translation/zh-CN.json

@@ -666,6 +666,7 @@
         "scanUse": "使用",
         "scanRescan": "重新扫描",
         "spiderX": "SpiderX",
+        "spiderXHint": "按客户端的种子——面板据此为每个客户端派生唯一的 spx 路径;重新生成可轮换所有客户端的路径",
         "getNewCert": "获取新证书",
         "mldsa65Seed": "mldsa65 Seed",
         "mldsa65Verify": "mldsa65 Verify",
@@ -900,6 +901,7 @@
       "wireguardPublicKey": "WireGuard 公钥",
       "wireguardPreSharedKey": "WireGuard 预共享密钥",
       "wireguardAllowedIPs": "WireGuard 允许的 IP",
+      "wireguardAllowedIPsHint": "留空则自动分配;多个条目用逗号分隔",
       "reverseTag": "反向标签",
       "reverseTagPlaceholder": "可选 Reverse tag",
       "telegramId": "Telegram 用户 ID",

+ 2 - 0
internal/web/translation/zh-TW.json

@@ -646,6 +646,7 @@
         "scanUse": "使用",
         "scanRescan": "重新掃描",
         "spiderX": "SpiderX",
+        "spiderXHint": "各客戶端的種子——面板據此為每個客戶端衍生唯一的 spx 路徑;重新產生可輪換所有客戶端的路徑",
         "getNewCert": "取得新憑證",
         "mldsa65Seed": "mldsa65 Seed",
         "mldsa65Verify": "mldsa65 Verify",
@@ -900,6 +901,7 @@
       "wireguardPublicKey": "WireGuard 公鑰",
       "wireguardPreSharedKey": "WireGuard 預共用金鑰",
       "wireguardAllowedIPs": "WireGuard 允許的 IP",
+      "wireguardAllowedIPsHint": "留空則自動分配;多個條目用逗號分隔",
       "reverseTag": "反向標籤",
       "reverseTagPlaceholder": "選用 Reverse tag",
       "telegramId": "Telegram 使用者 ID",

+ 9 - 0
internal/xray/api.go

@@ -397,6 +397,15 @@ func IsExistingTagErr(err error) bool {
 	return strings.Contains(strings.ToLower(err.Error()), "existing tag")
 }
 
+// IsUserExistsErr reports whether err is xray's response to adding a user whose
+// email is already registered on the inbound.
+func IsUserExistsErr(err error) bool {
+	if err == nil {
+		return false
+	}
+	return strings.Contains(strings.ToLower(err.Error()), "already exists")
+}
+
 // ensureXrayAssetLocation makes geoip.dat/geosite.dat resolvable when xray-core
 // config builders run inside the panel process. The xray binary resolves assets
 // relative to its own executable, but the panel binary lives one level above

+ 108 - 0
internal/xray/hot_diff.go

@@ -15,15 +15,27 @@ import (
 type HotDiff struct {
 	RemovedInboundTags  []string
 	AddedInbounds       [][]byte
+	RemovedUsers        []UserOp
+	AddedUsers          []UserOp
 	RemovedOutboundTags []string
 	AddedOutbounds      [][]byte
 	RoutingConfig       []byte // full new routing section; nil when unchanged
 }
 
+// UserOp is a per-user AlterInbound operation; User is nil for removals.
+type UserOp struct {
+	Tag      string
+	Protocol string
+	Email    string
+	User     map[string]any
+}
+
 // Empty reports whether the diff contains no operations.
 func (d *HotDiff) Empty() bool {
 	return len(d.RemovedInboundTags) == 0 &&
 		len(d.AddedInbounds) == 0 &&
+		len(d.RemovedUsers) == 0 &&
+		len(d.AddedUsers) == 0 &&
 		len(d.RemovedOutboundTags) == 0 &&
 		len(d.AddedOutbounds) == 0 &&
 		d.RoutingConfig == nil
@@ -112,6 +124,9 @@ func diffInbounds(oldCfg, newCfg *Config, diff *HotDiff) bool {
 			logger.Debug("hot diff: inbound [", oldIb.Tag, "] carries a reverse-tagged client, forcing a full restart instead of a hot swap")
 			return false
 		}
+		if exists && diffInboundUsers(oldIb, newIb, diff) {
+			continue
+		}
 		diff.RemovedInboundTags = append(diff.RemovedInboundTags, oldIb.Tag)
 		if exists {
 			raw, err := json.Marshal(newIb)
@@ -138,6 +153,99 @@ func diffInbounds(oldCfg, newCfg *Config, diff *HotDiff) bool {
 	return true
 }
 
+var userDiffableProtocols = map[string]struct{}{"vless": {}, "vmess": {}, "trojan": {}}
+
+// diffInboundUsers emits per-user AlterInbound ops when two same-tag inbounds
+// differ only in settings.clients, so the handler (and its listener) survives.
+func diffInboundUsers(oldIb, newIb *InboundConfig, diff *HotDiff) bool {
+	if oldIb.Port != newIb.Port || oldIb.Protocol != newIb.Protocol || oldIb.Tag != newIb.Tag {
+		return false
+	}
+	if _, ok := userDiffableProtocols[oldIb.Protocol]; !ok {
+		return false
+	}
+	if !rawEqualNormalized(oldIb.Listen, newIb.Listen) ||
+		!rawEqualNormalized(oldIb.StreamSettings, newIb.StreamSettings) ||
+		!rawEqualNormalized(oldIb.Sniffing, newIb.Sniffing) {
+		return false
+	}
+	oldClients, oldRest, ok := splitSettingsClients(oldIb.Settings)
+	if !ok {
+		return false
+	}
+	newClients, newRest, ok := splitSettingsClients(newIb.Settings)
+	if !ok {
+		return false
+	}
+	if !bytes.Equal(oldRest, newRest) {
+		return false
+	}
+	for email, oldC := range oldClients {
+		newC, exists := newClients[email]
+		if exists && bytes.Equal(oldC.norm, newC.norm) {
+			continue
+		}
+		diff.RemovedUsers = append(diff.RemovedUsers, UserOp{Tag: oldIb.Tag, Protocol: oldIb.Protocol, Email: email})
+		if exists {
+			diff.AddedUsers = append(diff.AddedUsers, UserOp{Tag: oldIb.Tag, Protocol: oldIb.Protocol, Email: email, User: newC.user})
+		}
+	}
+	for email, newC := range newClients {
+		if _, exists := oldClients[email]; !exists {
+			diff.AddedUsers = append(diff.AddedUsers, UserOp{Tag: oldIb.Tag, Protocol: oldIb.Protocol, Email: email, User: newC.user})
+		}
+	}
+	return true
+}
+
+type clientEntry struct {
+	user map[string]any
+	norm []byte
+}
+
+// splitSettingsClients indexes settings.clients by email and returns the rest of
+// the settings in canonical form; ok is false when a client has no unique email.
+func splitSettingsClients(raw json_util.RawMessage) (map[string]clientEntry, []byte, bool) {
+	if len(raw) == 0 {
+		return nil, nil, false
+	}
+	settings := map[string]any{}
+	decoder := json.NewDecoder(bytes.NewReader(raw))
+	decoder.UseNumber()
+	if err := decoder.Decode(&settings); err != nil {
+		return nil, nil, false
+	}
+	clientsRaw, hasClients := settings["clients"].([]any)
+	if !hasClients {
+		return nil, nil, false
+	}
+	clients := make(map[string]clientEntry, len(clientsRaw))
+	for _, c := range clientsRaw {
+		obj, ok := c.(map[string]any)
+		if !ok {
+			return nil, nil, false
+		}
+		email, _ := obj["email"].(string)
+		if email == "" {
+			return nil, nil, false
+		}
+		if _, dup := clients[email]; dup {
+			return nil, nil, false
+		}
+		norm, err := json.Marshal(obj)
+		if err != nil {
+			return nil, nil, false
+		}
+		clients[email] = clientEntry{user: obj, norm: norm}
+	}
+	delete(settings, "clients")
+	rest, err := json.Marshal(settings)
+	if err != nil {
+		return nil, nil, false
+	}
+	return clients, rest, true
+}
+
 func inboundHasReverseClient(ib *InboundConfig) bool {
 	if ib == nil {
 		return false

+ 76 - 2
internal/xray/hot_diff_test.go

@@ -148,8 +148,8 @@ func TestComputeHotDiff_StaticSectionChangeNeedsRestart(t *testing.T) {
 func TestComputeHotDiff_InboundAddRemoveChange(t *testing.T) {
 	oldCfg := makeHotConfig()
 	newCfg := makeHotConfig()
-	// change existing
-	newCfg.InboundConfigs[1].Settings = json_util.RawMessage(`{"clients":[{"email":"a"}]}`)
+	// change existing beyond the clients list, so no user-level shortcut applies
+	newCfg.InboundConfigs[1].Settings = json_util.RawMessage(`{"clients":[],"decryption":"none"}`)
 	// add new
 	newCfg.InboundConfigs = append(newCfg.InboundConfigs, InboundConfig{
 		Port: 2080, Protocol: "vmess", Tag: "inbound-2080",
@@ -171,6 +171,80 @@ func TestComputeHotDiff_InboundAddRemoveChange(t *testing.T) {
 	}
 }
 
+func TestComputeHotDiff_ClientOnlyChangeUsesUserOps(t *testing.T) {
+	oldCfg := makeHotConfig()
+	oldCfg.InboundConfigs[1].Settings = json_util.RawMessage(`{"clients":[{"email":"a","id":"uuid-a"},{"email":"b","id":"uuid-b"}],"decryption":"none"}`)
+	newCfg := makeHotConfig()
+	// b expired and is stripped from the generated config (#5712); a's id rotated.
+	newCfg.InboundConfigs[1].Settings = json_util.RawMessage(`{"clients":[{"email":"a","id":"uuid-a2"},{"email":"c","id":"uuid-c"}],"decryption":"none"}`)
+
+	diff, ok := ComputeHotDiff(oldCfg, newCfg)
+	if !ok {
+		t.Fatal("client-only change must be hot-appliable")
+	}
+	if len(diff.RemovedInboundTags) != 0 || len(diff.AddedInbounds) != 0 {
+		t.Fatalf("client-only change must not replace the handler, got %+v", diff)
+	}
+	removed := map[string]bool{}
+	for _, u := range diff.RemovedUsers {
+		if u.Tag != "inbound-1080" || u.Protocol != "vless" {
+			t.Fatalf("removed user op has wrong target: %+v", u)
+		}
+		removed[u.Email] = true
+	}
+	if len(removed) != 2 || !removed["a"] || !removed["b"] {
+		t.Fatalf("expected users a (changed) and b (gone) removed, got %v", removed)
+	}
+	added := map[string]string{}
+	for _, u := range diff.AddedUsers {
+		id, _ := u.User["id"].(string)
+		added[u.Email] = id
+	}
+	if len(added) != 2 || added["a"] != "uuid-a2" || added["c"] != "uuid-c" {
+		t.Fatalf("expected users a (new id) and c added, got %v", added)
+	}
+}
+
+func TestComputeHotDiff_ClientChangeFallsBackToReplace(t *testing.T) {
+	cases := []struct {
+		name   string
+		mutate func(cfg *Config)
+	}{
+		{
+			name: "unsupported protocol",
+			mutate: func(cfg *Config) {
+				cfg.InboundConfigs[1].Protocol = "shadowsocks"
+			},
+		},
+		{
+			name: "client without email",
+			mutate: func(cfg *Config) {
+				cfg.InboundConfigs[1].Settings = json_util.RawMessage(`{"clients":[{"id":"uuid-a"}]}`)
+			},
+		},
+	}
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			oldCfg := makeHotConfig()
+			newCfg := makeHotConfig()
+			tc.mutate(oldCfg)
+			tc.mutate(newCfg)
+			newCfg.InboundConfigs[1].Settings = json_util.RawMessage(`{"clients":[{"email":"x","id":"uuid-x","password":"pw"}]}`)
+
+			diff, ok := ComputeHotDiff(oldCfg, newCfg)
+			if !ok {
+				t.Fatal("change must still be hot-appliable via handler replacement")
+			}
+			if len(diff.RemovedUsers) != 0 || len(diff.AddedUsers) != 0 {
+				t.Fatalf("expected no user ops, got %+v", diff)
+			}
+			if len(diff.RemovedInboundTags) != 1 || len(diff.AddedInbounds) != 1 {
+				t.Fatalf("expected handler replacement, got %+v", diff)
+			}
+		})
+	}
+}
+
 func TestComputeHotDiff_ApiInboundChangeNeedsRestart(t *testing.T) {
 	newCfg := makeHotConfig()
 	newCfg.InboundConfigs[0].Port = 62790

+ 3 - 3
update.sh

@@ -223,7 +223,7 @@ setup_ssl_certificate() {
     fi
 
     # Install certificate
-    ~/.acme.sh/acme.sh --installcert -d ${domain} \
+    ~/.acme.sh/acme.sh --installcert --force -d ${domain} \
         --key-file /root/cert/${domain}/privkey.pem \
         --fullchain-file /root/cert/${domain}/fullchain.pem \
         --reloadcmd "systemctl restart x-ui" > /dev/null 2>&1
@@ -361,7 +361,7 @@ setup_ip_certificate() {
     # Install certificate
     # Note: acme.sh may report "Reload error" and exit non-zero if reloadcmd fails,
     # but the cert files are still installed. We check for files instead of exit code.
-    ~/.acme.sh/acme.sh --installcert -d ${ipv4} \
+    ~/.acme.sh/acme.sh --installcert --force -d ${ipv4} \
         --key-file "${certDir}/privkey.pem" \
         --fullchain-file "${certDir}/fullchain.pem" \
         --reloadcmd "${reloadCmd}" 2>&1 || true
@@ -518,7 +518,7 @@ ssl_cert_issue() {
 
     # install the certificate
     local installOutput=""
-    installOutput=$(~/.acme.sh/acme.sh --installcert -d ${domain} \
+    installOutput=$(~/.acme.sh/acme.sh --installcert --force -d ${domain} \
         --key-file /root/cert/${domain}/privkey.pem \
         --fullchain-file /root/cert/${domain}/fullchain.pem --reloadcmd "${reloadCmd}" 2>&1)
     local installRc=$?

+ 4 - 4
x-ui.sh

@@ -1435,7 +1435,7 @@ ssl_cert_issue_main() {
                         # renewed cert to these paths and reloads the panel. Without it acme.sh
                         # renews but never updates /root/cert, silently serving a stale cert.
                         if command -v ~/.acme.sh/acme.sh &> /dev/null && ~/.acme.sh/acme.sh --list 2> /dev/null | awk '{print $1}' | grep -Fxq "${domain}"; then
-                            ~/.acme.sh/acme.sh --installcert -d "${domain}" \
+                            ~/.acme.sh/acme.sh --installcert --force -d "${domain}" \
                                 --key-file "${webKeyFile}" \
                                 --fullchain-file "${webCertFile}" \
                                 --reloadcmd "x-ui restart" 2>&1 || true
@@ -1631,7 +1631,7 @@ ssl_cert_issue_for_ip() {
     # Install the certificate
     # Note: acme.sh may report "Reload error" and exit non-zero if reloadcmd fails,
     # but the cert files are still installed. We check for files instead of exit code.
-    ~/.acme.sh/acme.sh --installcert -d ${server_ip} \
+    ~/.acme.sh/acme.sh --installcert --force -d ${server_ip} \
         --key-file "${certPath}/privkey.pem" \
         --fullchain-file "${certPath}/fullchain.pem" \
         --reloadcmd "${reloadCmd}" 2>&1 || true
@@ -1836,7 +1836,7 @@ ssl_cert_issue() {
 
     # install the certificate
     local installOutput=""
-    installOutput=$(~/.acme.sh/acme.sh --installcert -d ${domain} \
+    installOutput=$(~/.acme.sh/acme.sh --installcert --force -d ${domain} \
         --key-file /root/cert/${domain}/privkey.pem \
         --fullchain-file /root/cert/${domain}/fullchain.pem --reloadcmd "${reloadCmd}" 2>&1)
     local installRc=$?
@@ -1998,7 +1998,7 @@ ssl_cert_issue_CF() {
                     ;;
             esac
         fi
-        ~/.acme.sh/acme.sh --installcert -d ${CF_Domain} -d *.${CF_Domain} \
+        ~/.acme.sh/acme.sh --installcert --force -d ${CF_Domain} -d *.${CF_Domain} \
             --key-file ${certPath}/privkey.pem \
             --fullchain-file ${certPath}/fullchain.pem --reloadcmd "${reloadCmd}"