15 Commits a07c7b7f4e ... d6d2085d60

Autore SHA1 Messaggio Data
  Hamed d6d2085d60 fix: restart remote xray after disabling a client to kill active sessions (#4918) 11 ore fa
  Hamed 12d84c2a46 fix(node-traffic): prevent stale node snapshot from re-enabling disabled client (#4917) 11 ore fa
  biohazardous-man 97f88fb1a9 feat(sub): modern xray JSON format with unified finalmask editor (#4912) 11 ore fa
  Misfit-s f947fbd6c6 feat(Clash): Add routing rules and enable routing option for Clash subscriptions (#4904) 13 ore fa
  dependabot[bot] ba63fa8569 chore(deps): bump i18next from 26.3.0 to 26.3.1 in /frontend (#4901) 13 ore fa
  康厚超 73ce11508e fix(tgbot): ignore commands for other bots (#4894) 13 ore fa
  lim-kim930 a4b3e999a1 fix(i18n): add 1-year expiration to language cookie (#4890) 13 ore fa
  MHSanaei d3db828b46 perf(clients): scale-audit remaining client/inbound endpoints to 200k 13 ore fa
  MHSanaei d1e733b9e9 perf(clients): chunk IN queries and de-quadratic bulk delete/group/list 14 ore fa
  MHSanaei f185d3315c perf(clients): scale add/delete and bulk client operations 15 ore fa
  MHSanaei 756746dbca perf(clients): make SyncInbound bulk to fix large-inbound timeouts (#4885) 16 ore fa
  MHSanaei 44291de989 fix(ssl): clean ECC state, guard cert reuse, register renew hook (#4875) 17 ore fa
  MHSanaei b1d079fc24 fix(fail2ban): exempt SSH and panel ports from IP-limit ban (#4896) 18 ore fa
  MHSanaei 14e2d4954a fix(migrate-db): drop legacy client_traffics FK before Postgres copy (#4882) 18 ore fa
  MHSanaei db86007ab8 fix(multi-node): scope remote client update/delete to one inbound (#4892) 18 ore fa
49 ha cambiato i file con 2318 aggiunte e 597 eliminazioni
  1. 13 2
      DockerEntrypoint.sh
  2. 8 0
      database/migrate_data.go
  3. 4 34
      frontend/package-lock.json
  4. 1 1
      frontend/package.json
  5. 31 1
      frontend/public/openapi.json
  6. 6 4
      frontend/src/generated/types.ts
  7. 6 4
      frontend/src/generated/zod.ts
  8. 15 21
      frontend/src/lib/xray/forms/transport/FinalMaskForm.tsx
  9. 3 2
      frontend/src/models/setting.ts
  10. 1 1
      frontend/src/pages/api-docs/endpoints.ts
  11. 1 1
      frontend/src/pages/clients/ClientBulkAddModal.tsx
  12. 2 4
      frontend/src/pages/clients/ClientsPage.tsx
  13. 55 0
      frontend/src/pages/settings/SubJsonFinalMaskForm.tsx
  14. 9 147
      frontend/src/pages/settings/SubscriptionFormatsTab.tsx
  15. 14 0
      frontend/src/pages/settings/SubscriptionGeneralTab.tsx
  16. 1 1
      frontend/src/schemas/client.ts
  17. 3 2
      frontend/src/schemas/setting.ts
  18. 4 4
      frontend/src/utils/index.ts
  19. 29 13
      install.sh
  20. 14 9
      sub/sub.go
  21. 107 12
      sub/subClashService.go
  22. 5 4
      sub/subController.go
  23. 78 70
      sub/subJsonService.go
  24. 148 0
      sub/subJsonService_test.go
  25. 19 1
      web/controller/client.go
  26. 3 2
      web/entity/entity.go
  27. 16 9
      web/runtime/remote.go
  28. 216 0
      web/service/api_scale_postgres_test.go
  29. 149 0
      web/service/bulk_traffic_test.go
  30. 588 207
      web/service/client.go
  31. 152 21
      web/service/inbound.go
  32. 11 6
      web/service/setting.go
  33. 431 0
      web/service/sync_scale_postgres_test.go
  34. 20 0
      web/service/tgbot.go
  35. 24 0
      web/service/tgbot_test.go
  36. 6 0
      web/translation/ar-EG.json
  37. 6 0
      web/translation/en-US.json
  38. 6 0
      web/translation/es-ES.json
  39. 6 0
      web/translation/fa-IR.json
  40. 6 0
      web/translation/id-ID.json
  41. 6 0
      web/translation/ja-JP.json
  42. 6 0
      web/translation/pt-BR.json
  43. 6 0
      web/translation/ru-RU.json
  44. 6 0
      web/translation/tr-TR.json
  45. 6 0
      web/translation/uk-UA.json
  46. 6 0
      web/translation/vi-VN.json
  47. 6 0
      web/translation/zh-CN.json
  48. 6 0
      web/translation/zh-TW.json
  49. 53 14
      x-ui.sh

+ 13 - 2
DockerEntrypoint.sh

@@ -27,6 +27,16 @@ failregex   = \[LIMIT_IP\]\s*Email\s*=\s*<F-USER>.+</F-USER>\s*\|\|\s*Disconnect
 ignoreregex =
 EOF
 
+    # Ports to exempt from the ban so an over-limit proxy client can never lock
+    # the administrator out of SSH or the panel. The ban still covers every other
+    # TCP port (including all Xray inbounds), so IP-limit keeps working for inbounds
+    # added later without regenerating these files.
+    SSH_PORTS=$(grep -oE '^[[:space:]]*Port[[:space:]]+[0-9]+' /etc/ssh/sshd_config 2>/dev/null | grep -oE '[0-9]+' | paste -sd, -)
+    [ -z "$SSH_PORTS" ] && SSH_PORTS="22"
+    PANEL_PORT=$(/app/x-ui setting -show true 2>/dev/null | grep -Eo 'port: .+' | awk '{print $2}')
+    EXEMPT_PORTS="$SSH_PORTS"
+    [ -n "$PANEL_PORT" ] && EXEMPT_PORTS="$EXEMPT_PORTS,$PANEL_PORT"
+
     cat > /etc/fail2ban/action.d/3x-ipl.conf << EOF
 [INCLUDES]
 before = iptables-allports.conf
@@ -42,16 +52,17 @@ actionstop = <iptables> -D <chain> -p <protocol> -j f2b-<name>
 
 actioncheck = <iptables> -n -L <chain> | grep -q 'f2b-<name>[ \t]'
 
-actionban = <iptables> -I f2b-<name> 1 -s <ip> -j <blocktype>
+actionban = <iptables> -I f2b-<name> 1 -s <ip> -p <protocol> -m multiport ! --dports <exemptports> -j <blocktype>
             echo "\$(date +"%%Y/%%m/%%d %%H:%%M:%%S")   BAN   [Email] = <F-USER> [IP] = <ip> banned for <bantime> seconds." >> $LOG_FOLDER/3xipl-banned.log
 
-actionunban = <iptables> -D f2b-<name> -s <ip> -j <blocktype>
+actionunban = <iptables> -D f2b-<name> -s <ip> -p <protocol> -m multiport ! --dports <exemptports> -j <blocktype>
               echo "\$(date +"%%Y/%%m/%%d %%H:%%M:%%S")   UNBAN   [Email] = <F-USER> [IP] = <ip> unbanned." >> $LOG_FOLDER/3xipl-banned.log
 
 [Init]
 name = default
 protocol = tcp
 chain = INPUT
+exemptports = $EXEMPT_PORTS
 EOF
 
     fail2ban-client -x start

+ 8 - 0
database/migrate_data.go

@@ -86,6 +86,14 @@ func MigrateData(srcPath, dstDSN string) error {
 		}
 	}
 
+	// AutoMigrate re-creates the legacy client_traffics -> inbounds foreign key,
+	// but the running panel drops it (see dropLegacyForeignKeys) and tolerates
+	// client_traffics rows whose inbound was deleted. Drop it here too so copying
+	// such orphaned rows can't fail with an fk_inbounds_client_stats violation.
+	if err := dst.Exec("ALTER TABLE client_traffics DROP CONSTRAINT IF EXISTS fk_inbounds_client_stats").Error; err != nil {
+		return fmt.Errorf("drop legacy foreign key: %w", err)
+	}
+
 	// Empty the destination tables so the migration is idempotent: a fresh
 	// PostgreSQL DB already holds an auto-seeded admin (id=1) from any prior
 	// panel start, and a partially-failed earlier run leaves rows behind. Either

+ 4 - 34
frontend/package-lock.json

@@ -17,7 +17,7 @@
         "axios": "^1.17.0",
         "codemirror": "^6.0.2",
         "dayjs": "^1.11.21",
-        "i18next": "^26.3.0",
+        "i18next": "^26.3.1",
         "otpauth": "^9.5.1",
         "persian-calendar-suite": "^1.5.5",
         "qs": "^6.15.2",
@@ -1934,9 +1934,6 @@
         "arm64"
       ],
       "dev": true,
-      "libc": [
-        "glibc"
-      ],
       "license": "MIT",
       "optional": true,
       "os": [
@@ -1954,9 +1951,6 @@
         "arm64"
       ],
       "dev": true,
-      "libc": [
-        "musl"
-      ],
       "license": "MIT",
       "optional": true,
       "os": [
@@ -1974,9 +1968,6 @@
         "ppc64"
       ],
       "dev": true,
-      "libc": [
-        "glibc"
-      ],
       "license": "MIT",
       "optional": true,
       "os": [
@@ -1994,9 +1985,6 @@
         "s390x"
       ],
       "dev": true,
-      "libc": [
-        "glibc"
-      ],
       "license": "MIT",
       "optional": true,
       "os": [
@@ -2014,9 +2002,6 @@
         "x64"
       ],
       "dev": true,
-      "libc": [
-        "glibc"
-      ],
       "license": "MIT",
       "optional": true,
       "os": [
@@ -2034,9 +2019,6 @@
         "x64"
       ],
       "dev": true,
-      "libc": [
-        "musl"
-      ],
       "license": "MIT",
       "optional": true,
       "os": [
@@ -5087,9 +5069,9 @@
       }
     },
     "node_modules/i18next": {
-      "version": "26.3.0",
-      "resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.0.tgz",
-      "integrity": "sha512-gHSgGpUXVmuqE2El1W61DmxeyeTlFfZgdJRWMo9jScAn5pu7TuTuiccb1zh3E2J9hEBVGJ23+96x0ieBhfuIHA==",
+      "version": "26.3.1",
+      "resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.1.tgz",
+      "integrity": "sha512-txQqd5EULsqEh9OJqRH15aCaOuy/nLJyhw5EHCSKLKJE1aBbb3Zve2+uQIxgWhPm1QqUQoWyQBm2kfmmIrzkcQ==",
       "funding": [
         {
           "type": "individual",
@@ -5615,9 +5597,6 @@
         "arm64"
       ],
       "dev": true,
-      "libc": [
-        "glibc"
-      ],
       "license": "MPL-2.0",
       "optional": true,
       "os": [
@@ -5639,9 +5618,6 @@
         "arm64"
       ],
       "dev": true,
-      "libc": [
-        "musl"
-      ],
       "license": "MPL-2.0",
       "optional": true,
       "os": [
@@ -5663,9 +5639,6 @@
         "x64"
       ],
       "dev": true,
-      "libc": [
-        "glibc"
-      ],
       "license": "MPL-2.0",
       "optional": true,
       "os": [
@@ -5687,9 +5660,6 @@
         "x64"
       ],
       "dev": true,
-      "libc": [
-        "musl"
-      ],
       "license": "MPL-2.0",
       "optional": true,
       "os": [

+ 1 - 1
frontend/package.json

@@ -29,7 +29,7 @@
     "axios": "^1.17.0",
     "codemirror": "^6.0.2",
     "dayjs": "^1.11.21",
-    "i18next": "^26.3.0",
+    "i18next": "^26.3.1",
     "otpauth": "^9.5.1",
     "persian-calendar-suite": "^1.5.5",
     "qs": "^6.15.2",

+ 31 - 1
frontend/public/openapi.json

@@ -1495,6 +1495,36 @@
         }
       }
     },
+    "/panel/api/server/getMigration": {
+      "get": {
+        "tags": [
+          "Server"
+        ],
+        "summary": "Stream a cross-engine migration file as an attachment: a .dump (SQL text) on SQLite, or a .db SQLite database built from the live data on PostgreSQL.",
+        "operationId": "get_panel_api_server_getMigration",
+        "responses": {
+          "200": {
+            "description": "Successful response",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "object",
+                  "properties": {
+                    "success": {
+                      "type": "boolean"
+                    },
+                    "msg": {
+                      "type": "string"
+                    },
+                    "obj": {}
+                  }
+                }
+              }
+            }
+          }
+        }
+      }
+    },
     "/panel/api/server/getNewUUID": {
       "get": {
         "tags": [
@@ -5761,7 +5791,7 @@
         "tags": [
           "Subscription Server"
         ],
-        "summary": "Return subscription as a Clash/Mihomo-compatible YAML config. Only when Clash subscription is enabled in settings. Default path: /clash/:subid.",
+        "summary": "Return subscription as a Clash/Mihomo-compatible YAML config, including configured global Clash routing rules. Only when Clash subscription is enabled in settings. Default path: /clash/:subid.",
         "operationId": "get_clashPath_subid",
         "parameters": [
           {

+ 6 - 4
frontend/src/generated/types.ts

@@ -34,7 +34,9 @@ export interface AllSetting {
   subAnnounce: string;
   subCertFile: string;
   subClashEnable: boolean;
+  subClashEnableRouting: boolean;
   subClashPath: string;
+  subClashRules: string;
   subClashURI: string;
   subDomain: string;
   subEmailInRemark: boolean;
@@ -42,9 +44,8 @@ export interface AllSetting {
   subEnableRouting: boolean;
   subEncrypt: boolean;
   subJsonEnable: boolean;
-  subJsonFragment: string;
+  subJsonFinalMask: string;
   subJsonMux: string;
-  subJsonNoises: string;
   subJsonPath: string;
   subJsonRules: string;
   subJsonURI: string;
@@ -121,7 +122,9 @@ export interface AllSettingView {
   subAnnounce: string;
   subCertFile: string;
   subClashEnable: boolean;
+  subClashEnableRouting: boolean;
   subClashPath: string;
+  subClashRules: string;
   subClashURI: string;
   subDomain: string;
   subEmailInRemark: boolean;
@@ -129,9 +132,8 @@ export interface AllSettingView {
   subEnableRouting: boolean;
   subEncrypt: boolean;
   subJsonEnable: boolean;
-  subJsonFragment: string;
+  subJsonFinalMask: string;
   subJsonMux: string;
-  subJsonNoises: string;
   subJsonPath: string;
   subJsonRules: string;
   subJsonURI: string;

+ 6 - 4
frontend/src/generated/zod.ts

@@ -36,7 +36,9 @@ export const AllSettingSchema = z.object({
   subAnnounce: z.string(),
   subCertFile: z.string(),
   subClashEnable: z.boolean(),
+  subClashEnableRouting: z.boolean(),
   subClashPath: z.string(),
+  subClashRules: z.string(),
   subClashURI: z.string(),
   subDomain: z.string(),
   subEmailInRemark: z.boolean(),
@@ -44,9 +46,8 @@ export const AllSettingSchema = z.object({
   subEnableRouting: z.boolean(),
   subEncrypt: z.boolean(),
   subJsonEnable: z.boolean(),
-  subJsonFragment: z.string(),
+  subJsonFinalMask: z.string(),
   subJsonMux: z.string(),
-  subJsonNoises: z.string(),
   subJsonPath: z.string(),
   subJsonRules: z.string(),
   subJsonURI: z.string(),
@@ -124,7 +125,9 @@ export const AllSettingViewSchema = z.object({
   subAnnounce: z.string(),
   subCertFile: z.string(),
   subClashEnable: z.boolean(),
+  subClashEnableRouting: z.boolean(),
   subClashPath: z.string(),
+  subClashRules: z.string(),
   subClashURI: z.string(),
   subDomain: z.string(),
   subEmailInRemark: z.boolean(),
@@ -132,9 +135,8 @@ export const AllSettingViewSchema = z.object({
   subEnableRouting: z.boolean(),
   subEncrypt: z.boolean(),
   subJsonEnable: z.boolean(),
-  subJsonFragment: z.string(),
+  subJsonFinalMask: z.string(),
   subJsonMux: z.string(),
-  subJsonNoises: z.string(),
   subJsonPath: z.string(),
   subJsonRules: z.string(),
   subJsonURI: z.string(),

+ 15 - 21
frontend/src/lib/xray/forms/transport/FinalMaskForm.tsx

@@ -6,21 +6,15 @@ import type { NamePath } from 'antd/es/form/interface';
 import { RandomUtil } from '@/utils';
 import { OutboundProtocols } from '@/schemas/primitives';
 
-// Pattern A FinalMaskForm. Renders a Fragment of Form.Items at absolute
-// paths under `name`; the parent modal owns the Form instance.
-//
-// Naming convention inside Form.List: AntD prefixes Form.Item `name`
-// with the Form.List's own `name`. So Form.Items inside the render
-// prop use RELATIVE paths (e.g. `[field.name, 'type']`). Nested
-// Form.Lists also use relative names. Using absolute paths here would
-// double up the prefix and silently route reads/writes to the wrong
-// storage path.
-
 export interface FinalMaskFormProps {
   name: NamePath;
   network: string;
   protocol: string;
   form: FormInstance;
+  // When true, all sections (TCP / UDP / QUIC) are shown regardless of
+  // network/protocol. Used by the global sub-JSON finalmask editor where
+  // the masks apply to every stream rather than one specific transport.
+  showAll?: boolean;
 }
 
 const TCP_NETWORKS = ['raw', 'tcp', 'httpupgrade', 'ws', 'grpc', 'xhttp'];
@@ -99,12 +93,12 @@ function defaultUdpHop(): Record<string, unknown> {
   return { ports: '20000-50000', interval: '5-10' };
 }
 
-export default function FinalMaskForm({ name, network, protocol, form }: FinalMaskFormProps) {
+export default function FinalMaskForm({ name, network, protocol, form, showAll = false }: FinalMaskFormProps) {
   const base = asPath(name);
   const isHysteria = protocol === OutboundProtocols.Hysteria || protocol === 'hysteria';
-  const showTcp = TCP_NETWORKS.includes(network);
-  const showUdp = isHysteria || network === 'kcp';
-  const showQuic = isHysteria || network === 'xhttp';
+  const showTcp = showAll || TCP_NETWORKS.includes(network);
+  const showUdp = showAll || isHysteria || network === 'kcp';
+  const showQuic = showAll || isHysteria || network === 'xhttp';
   const quicParams = Form.useWatch([...base, 'quicParams'], { form, preserve: true });
   const hasQuicParams = quicParams != null;
 
@@ -392,13 +386,13 @@ function UdpMaskItem({
   const options = isHysteria
     ? [{ value: 'salamander', label: 'Salamander (Hysteria2)' }]
     : [
-        { value: 'mkcp-legacy', label: 'mKCP Legacy' },
-        { value: 'xdns', label: 'xDNS' },
-        { value: 'xicmp', label: 'xICMP' },
-        { value: 'realm', label: 'Realm' },
-        { value: 'header-custom', label: 'Header Custom' },
-        { value: 'noise', label: 'Noise' },
-      ];
+      { value: 'mkcp-legacy', label: 'mKCP Legacy' },
+      { value: 'xdns', label: 'xDNS' },
+      { value: 'xicmp', label: 'xICMP' },
+      { value: 'realm', label: 'Realm' },
+      { value: 'header-custom', label: 'Header Custom' },
+      { value: 'noise', label: 'Noise' },
+    ];
 
   return (
     <div>

+ 3 - 2
frontend/src/models/setting.ts

@@ -55,10 +55,11 @@ export class AllSetting {
   subURI = '';
   subJsonURI = '';
   subClashURI = '';
-  subJsonFragment = '';
-  subJsonNoises = '';
+  subClashEnableRouting = false;
+  subClashRules = '';
   subJsonMux = '';
   subJsonRules = '';
+  subJsonFinalMask = '';
 
   timeLocation = 'Local';
 

+ 1 - 1
frontend/src/pages/api-docs/endpoints.ts

@@ -1114,7 +1114,7 @@ export const sections: readonly Section[] = [
       {
         method: 'GET',
         path: '/{clashPath}:subid',
-        summary: 'Return subscription as a Clash/Mihomo-compatible YAML config. Only when Clash subscription is enabled in settings. Default path: /clash/:subid.',
+        summary: 'Return subscription as a Clash/Mihomo-compatible YAML config, including configured global Clash routing rules. Only when Clash subscription is enabled in settings. Default path: /clash/:subid.',
         params: [
           { name: 'subid', in: 'path', type: 'string', desc: 'Client subscription ID.' },
         ],

+ 1 - 1
frontend/src/pages/clients/ClientBulkAddModal.tsx

@@ -249,7 +249,7 @@ export default function ClientBulkAddModal({
           )}
           {form.emailMethod < 2 && (
             <Form.Item label={t('pages.clients.clientCount')}>
-              <InputNumber value={form.quantity} min={1} max={100} onChange={(v) => update('quantity', Number(v) || 1)} />
+              <InputNumber value={form.quantity} min={1} max={1000} onChange={(v) => update('quantity', Number(v) || 1)} />
             </Form.Item>
           )}
 

+ 2 - 4
frontend/src/pages/clients/ClientsPage.tsx

@@ -71,6 +71,7 @@ import type { ClientFilters } from './filters';
 import './ClientsPage.css';
 
 const FILTER_STATE_KEY = 'clientsFilterState';
+const DISABLED_PAGE_SIZE = 200;
 
 function UngroupIcon() {
   return (
@@ -276,10 +277,7 @@ export default function ClientsPage() {
   const activeCount = activeFilterCount(filters);
 
   useEffect(() => {
-    if (pageSize > 0) {
-
-      setTablePageSize(pageSize);
-    }
+    setTablePageSize(pageSize > 0 ? pageSize : DISABLED_PAGE_SIZE);
   }, [pageSize]);
 
   const onlineSet = useMemo(() => new Set(onlines || []), [onlines]);

+ 55 - 0
frontend/src/pages/settings/SubJsonFinalMaskForm.tsx

@@ -0,0 +1,55 @@
+import { useEffect, useRef, useState } from 'react';
+import { Form } from 'antd';
+
+import { FinalMaskForm } from '@/lib/xray/forms/transport';
+import type { FinalMaskStreamSettings } from '@/schemas/protocols/stream/finalmask';
+
+interface SubJsonFinalMaskFormProps {
+  value: string;
+  onChange: (next: string) => void;
+}
+
+function hasValue(v: unknown): boolean {
+  if (v == null) return false;
+  if (Array.isArray(v)) return v.some(hasValue);
+  if (typeof v === 'object') return Object.values(v as Record<string, unknown>).some(hasValue);
+  if (typeof v === 'string') return v.length > 0;
+  return true;
+}
+
+function parseFinalMask(raw: string): FinalMaskStreamSettings {
+  try {
+    if (raw) return JSON.parse(raw) as FinalMaskStreamSettings;
+  } catch {
+    return { tcp: [], udp: [] };
+  }
+  return { tcp: [], udp: [] };
+}
+
+export default function SubJsonFinalMaskForm({ value, onChange }: SubJsonFinalMaskFormProps) {
+  const [form] = Form.useForm();
+  const [initial] = useState(() => parseFinalMask(value));
+  const onChangeRef = useRef(onChange);
+  onChangeRef.current = onChange;
+
+  const finalmask = Form.useWatch('finalmask', form) as FinalMaskStreamSettings | undefined;
+
+  useEffect(() => {
+    if (finalmask === undefined) return;
+    const next = hasValue(finalmask) ? JSON.stringify(finalmask) : '';
+    if (next !== value) onChangeRef.current(next);
+  }, [finalmask, value]);
+
+  return (
+    <Form
+      form={form}
+      layout="horizontal"
+      labelCol={{ flex: '160px' }}
+      wrapperCol={{ flex: 'auto' }}
+      colon={false}
+      initialValues={{ finalmask: initial }}
+    >
+      <FinalMaskForm name="finalmask" network="" protocol="" form={form} showAll />
+    </Form>
+  );
+}

+ 9 - 147
frontend/src/pages/settings/SubscriptionFormatsTab.tsx

@@ -1,8 +1,6 @@
 import { useMemo } from 'react';
 import { useTranslation } from 'react-i18next';
 import {
-  Button,
-  Card,
   Input,
   InputNumber,
   Select,
@@ -10,19 +8,17 @@ import {
   Tabs,
 } from 'antd';
 import {
-  DeleteOutlined,
   PartitionOutlined,
-  PlusOutlined,
-  ScissorOutlined,
+  RocketOutlined,
   SendOutlined,
   SettingOutlined,
-  ThunderboltOutlined,
 } from '@ant-design/icons';
 import type { AllSetting } from '@/models/setting';
 import { SettingListItem } from '@/components/ui';
 import { useMediaQuery } from '@/hooks/useMediaQuery';
 import { catTabLabel } from './catTabLabel';
 import { sanitizePath, normalizePath } from './uriPath';
+import SubJsonFinalMaskForm from './SubJsonFinalMaskForm';
 import './SubscriptionFormatsTab.css';
 
 interface SubscriptionFormatsTabProps {
@@ -30,15 +26,6 @@ interface SubscriptionFormatsTabProps {
   updateSetting: (patch: Partial<AllSetting>) => void;
 }
 
-const DEFAULT_FRAGMENT = {
-  packets: 'tlshello',
-  length: '100-200',
-  interval: '10-20',
-  maxSplit: '300-400',
-};
-const DEFAULT_NOISES: { type: string; packet: string; delay: string; applyTo: string }[] = [
-  { type: 'rand', packet: '10-20', delay: '10-16', applyTo: 'ip' },
-];
 const DEFAULT_MUX = {
   enabled: true,
   concurrency: 8,
@@ -85,55 +72,9 @@ export default function SubscriptionFormatsTab({ allSetting, updateSetting }: Su
   const { t } = useTranslation();
   const { isMobile } = useMediaQuery();
 
-  const fragment = allSetting.subJsonFragment !== '';
-  const noisesEnabled = allSetting.subJsonNoises !== '';
   const muxEnabled = allSetting.subJsonMux !== '';
   const directEnabled = allSetting.subJsonRules !== '';
 
-  const fragmentObj = useMemo(
-    () => (fragment ? readJson<typeof DEFAULT_FRAGMENT>(allSetting.subJsonFragment, DEFAULT_FRAGMENT) : DEFAULT_FRAGMENT),
-    [allSetting.subJsonFragment, fragment],
-  );
-
-  function setFragmentEnabled(v: boolean) {
-    updateSetting({ subJsonFragment: v ? JSON.stringify(DEFAULT_FRAGMENT) : '' });
-  }
-
-  function setFragmentField<K extends keyof typeof DEFAULT_FRAGMENT>(key: K, value: string) {
-    if (value === '') return;
-    const next = { ...fragmentObj, [key]: value };
-    updateSetting({ subJsonFragment: JSON.stringify(next) });
-  }
-
-  const noisesArray = useMemo(
-    () => (noisesEnabled ? readJson<typeof DEFAULT_NOISES>(allSetting.subJsonNoises, DEFAULT_NOISES) : []),
-    [allSetting.subJsonNoises, noisesEnabled],
-  );
-
-  function setNoisesEnabled(v: boolean) {
-    updateSetting({ subJsonNoises: v ? JSON.stringify(DEFAULT_NOISES) : '' });
-  }
-
-  function setNoisesArray(next: typeof DEFAULT_NOISES) {
-    if (noisesEnabled) updateSetting({ subJsonNoises: JSON.stringify(next) });
-  }
-
-  function addNoise() {
-    setNoisesArray([...noisesArray, { ...DEFAULT_NOISES[0] }]);
-  }
-
-  function removeNoise(index: number) {
-    const next = [...noisesArray];
-    next.splice(index, 1);
-    setNoisesArray(next);
-  }
-
-  function updateNoiseField(index: number, field: keyof typeof DEFAULT_NOISES[number], value: string) {
-    const next = [...noisesArray];
-    next[index] = { ...next[index], [field]: value };
-    setNoisesArray(next);
-  }
-
   const muxObj = useMemo(
     () => (muxEnabled ? readJson<typeof DEFAULT_MUX>(allSetting.subJsonMux, DEFAULT_MUX) : DEFAULT_MUX),
     [allSetting.subJsonMux, muxEnabled],
@@ -251,98 +192,19 @@ export default function SubscriptionFormatsTab({ allSetting, updateSetting }: Su
       },
       {
         key: '2',
-        label: catTabLabel(<ScissorOutlined />, t('pages.settings.fragment'), isMobile),
+        label: catTabLabel(<RocketOutlined />, t('pages.settings.subFormats.finalMask'), isMobile),
         children: (
           <>
-            <SettingListItem paddings="small" title={t('pages.settings.fragment')} description={t('pages.settings.fragmentDesc')}>
-              <Switch checked={fragment} onChange={setFragmentEnabled} />
-            </SettingListItem>
-            {fragment && (
-              <div className="format-settings">
-                <SettingListItem paddings="small" title={t('pages.settings.subFormats.packets')}>
-                  <Input value={fragmentObj.packets} placeholder="1-1 | 1-3 | tlshello | …"
-                    onChange={(e) => setFragmentField('packets', e.target.value)} />
-                </SettingListItem>
-                <SettingListItem paddings="small" title={t('pages.settings.subFormats.length')}>
-                  <Input value={fragmentObj.length} placeholder="100-200"
-                    onChange={(e) => setFragmentField('length', e.target.value)} />
-                </SettingListItem>
-                <SettingListItem paddings="small" title={t('pages.settings.subFormats.interval')}>
-                  <Input value={fragmentObj.interval} placeholder="10-20"
-                    onChange={(e) => setFragmentField('interval', e.target.value)} />
-                </SettingListItem>
-                <SettingListItem paddings="small" title={t('pages.settings.subFormats.maxSplit')}>
-                  <Input value={fragmentObj.maxSplit} placeholder="300-400"
-                    onChange={(e) => setFragmentField('maxSplit', e.target.value)} />
-                </SettingListItem>
-              </div>
-            )}
+            <SettingListItem paddings="small" title={t('pages.settings.subFormats.finalMask')} description={t('pages.settings.subFormats.finalMaskDesc')} />
+            <SubJsonFinalMaskForm
+              value={allSetting.subJsonFinalMask}
+              onChange={(v) => updateSetting({ subJsonFinalMask: v })}
+            />
           </>
         ),
       },
       {
         key: '3',
-        label: catTabLabel(<ThunderboltOutlined />, t('pages.settings.subFormats.noises'), isMobile),
-        children: (
-          <>
-            <SettingListItem paddings="small" title={t('pages.settings.subFormats.noises')} description={t('pages.settings.noisesDesc')}>
-              <Switch checked={noisesEnabled} onChange={setNoisesEnabled} />
-            </SettingListItem>
-            {noisesEnabled && (
-              <div className="format-settings-list">
-                {noisesArray.map((noise, index) => (
-                  <Card
-                    key={index}
-                    size="small"
-                    className="noise-card"
-                    title={t('pages.settings.subFormats.noiseItem', { n: index + 1 })}
-                    extra={noisesArray.length > 1 ? (
-                      <Button
-                        size="small"
-                        danger
-                        icon={<DeleteOutlined />}
-                        aria-label={t('delete')}
-                        onClick={() => removeNoise(index)}
-                      />
-                    ) : null}
-                    styles={{ body: { padding: 0 } }}
-                  >
-                    <SettingListItem paddings="small" title={t('pages.settings.subFormats.type')}>
-                      <Select
-                        value={noise.type}
-                        style={{ width: '100%' }}
-                        onChange={(v) => updateNoiseField(index, 'type', v)}
-                        options={['rand', 'base64', 'str', 'hex'].map((p) => ({ value: p, label: p }))}
-                      />
-                    </SettingListItem>
-                    <SettingListItem paddings="small" title={t('pages.settings.subFormats.packet')}>
-                      <Input value={noise.packet} placeholder="5-10"
-                        onChange={(e) => updateNoiseField(index, 'packet', e.target.value)} />
-                    </SettingListItem>
-                    <SettingListItem paddings="small" title={t('pages.settings.subFormats.delayMs')}>
-                      <Input value={noise.delay} placeholder="10-20"
-                        onChange={(e) => updateNoiseField(index, 'delay', e.target.value)} />
-                    </SettingListItem>
-                    <SettingListItem paddings="small" title={t('pages.settings.subFormats.applyTo')}>
-                      <Select
-                        value={noise.applyTo}
-                        style={{ width: '100%' }}
-                        onChange={(v) => updateNoiseField(index, 'applyTo', v)}
-                        options={['ip', 'ipv4', 'ipv6'].map((p) => ({ value: p, label: p }))}
-                      />
-                    </SettingListItem>
-                  </Card>
-                ))}
-                <Button type="dashed" block icon={<PlusOutlined />} onClick={addNoise}>
-                  {t('pages.settings.subFormats.addNoise')}
-                </Button>
-              </div>
-            )}
-          </>
-        ),
-      },
-      {
-        key: '4',
         label: catTabLabel(<PartitionOutlined />, t('pages.settings.mux'), isMobile),
         children: (
           <>
@@ -373,7 +235,7 @@ export default function SubscriptionFormatsTab({ allSetting, updateSetting }: Su
         ),
       },
       {
-        key: '5',
+        key: '4',
         label: catTabLabel(<SendOutlined />, t('pages.settings.direct'), isMobile),
         children: (
           <>

+ 14 - 0
frontend/src/pages/settings/SubscriptionGeneralTab.tsx

@@ -166,6 +166,20 @@ export default function SubscriptionGeneralTab({ allSetting, updateSetting }: Su
               <Input.TextArea value={allSetting.subRoutingRules} placeholder="happ://routing/add/..."
                 onChange={(e) => updateSetting({ subRoutingRules: e.target.value })} />
             </SettingListItem>
+
+            <Divider>Clash / Mihomo</Divider>
+
+            <SettingListItem paddings="small" title={t('pages.settings.subClashEnableRouting')} description={t('pages.settings.subClashEnableRoutingDesc')}>
+              <Switch checked={allSetting.subClashEnableRouting} onChange={(v) => updateSetting({ subClashEnableRouting: v })} />
+            </SettingListItem>
+            <SettingListItem paddings="small" title={t('pages.settings.subClashRoutingRules')} description={t('pages.settings.subClashRoutingRulesDesc')}>
+              <Input.TextArea
+                value={allSetting.subClashRules}
+                rows={8}
+                placeholder={'GEOSITE,category-ir,DIRECT\nGEOIP,private,DIRECT'}
+                onChange={(e) => updateSetting({ subClashRules: e.target.value })}
+              />
+            </SettingListItem>
           </>
         ),
       },

+ 1 - 1
frontend/src/schemas/client.ts

@@ -182,7 +182,7 @@ export const ClientBulkAddFormSchema = z.object({
   lastNum: z.number().int().min(1),
   emailPrefix: z.string(),
   emailPostfix: z.string(),
-  quantity: z.number().int().min(1).max(100),
+  quantity: z.number().int().min(1).max(1000),
   subId: z.string(),
   group: z.string(),
   comment: z.string(),

+ 3 - 2
frontend/src/schemas/setting.ts

@@ -59,10 +59,11 @@ export const AllSettingSchema = z.object({
   subURI: z.string().optional(),
   subJsonURI: z.string().optional(),
   subClashURI: z.string().optional(),
-  subJsonFragment: z.string().optional(),
-  subJsonNoises: z.string().optional(),
+  subClashEnableRouting: z.boolean().optional(),
+  subClashRules: z.string().optional(),
   subJsonMux: z.string().optional(),
   subJsonRules: z.string().optional(),
+  subJsonFinalMask: z.string().optional(),
   timeLocation: z.string().optional(),
   ldapEnable: z.boolean().optional(),
   ldapHost: z.string().optional(),

+ 4 - 4
frontend/src/utils/index.ts

@@ -858,13 +858,13 @@ export class LanguageManager {
       });
 
       if (LanguageManager.isSupportLanguage(lang)) {
-        CookieManager.setCookie('lang', lang);
+        CookieManager.setCookie('lang', lang, 365);
       } else {
-        CookieManager.setCookie('lang', 'en-US');
+        CookieManager.setCookie('lang', 'en-US', 365);
         window.location.reload();
       }
     } else {
-      CookieManager.setCookie('lang', 'en-US');
+      CookieManager.setCookie('lang', 'en-US', 365);
       window.location.reload();
     }
 
@@ -875,7 +875,7 @@ export class LanguageManager {
     if (!LanguageManager.isSupportLanguage(language)) {
       language = 'en-US';
     }
-    CookieManager.setCookie('lang', language);
+    CookieManager.setCookie('lang', language, 365);
     window.location.reload();
   }
 

+ 29 - 13
install.sh

@@ -297,7 +297,7 @@ setup_ssl_certificate() {
     if [ $? -ne 0 ]; then
         echo -e "${yellow}Failed to issue certificate for ${domain}${plain}"
         echo -e "${yellow}Please ensure port 80 is open and try again later with: x-ui${plain}"
-        rm -rf ~/.acme.sh/${domain} 2> /dev/null
+        rm -rf ~/.acme.sh/${domain} ~/.acme.sh/${domain}_ecc 2> /dev/null
         rm -rf "$certPath" 2> /dev/null
         return 1
     fi
@@ -431,8 +431,8 @@ setup_ip_certificate() {
         echo -e "${red}Failed to issue IP certificate${plain}"
         echo -e "${yellow}Please ensure port ${WebPort} is reachable (or forwarded from external port 80)${plain}"
         # Cleanup acme.sh data for both IPv4 and IPv6 if specified
-        rm -rf ~/.acme.sh/${ipv4} 2> /dev/null
-        [[ -n "$ipv6" ]] && rm -rf ~/.acme.sh/${ipv6} 2> /dev/null
+        rm -rf ~/.acme.sh/${ipv4} ~/.acme.sh/${ipv4}_ecc 2> /dev/null
+        [[ -n "$ipv6" ]] && rm -rf ~/.acme.sh/${ipv6} ~/.acme.sh/${ipv6}_ecc 2> /dev/null
         rm -rf ${certDir} 2> /dev/null
         return 1
     fi
@@ -451,8 +451,8 @@ setup_ip_certificate() {
     if [[ ! -f "${certDir}/fullchain.pem" || ! -f "${certDir}/privkey.pem" ]]; then
         echo -e "${red}Certificate files not found after installation${plain}"
         # Cleanup acme.sh data for both IPv4 and IPv6 if specified
-        rm -rf ~/.acme.sh/${ipv4} 2> /dev/null
-        [[ -n "$ipv6" ]] && rm -rf ~/.acme.sh/${ipv6} 2> /dev/null
+        rm -rf ~/.acme.sh/${ipv4} ~/.acme.sh/${ipv4}_ecc 2> /dev/null
+        [[ -n "$ipv6" ]] && rm -rf ~/.acme.sh/${ipv6} ~/.acme.sh/${ipv6}_ecc 2> /dev/null
         rm -rf ${certDir} 2> /dev/null
         return 1
     fi
@@ -524,14 +524,30 @@ ssl_cert_issue() {
     echo -e "${green}Your domain is: ${domain}, checking it...${plain}"
     SSL_ISSUED_DOMAIN="${domain}"
 
-    # detect existing certificate and reuse it if present
+    # detect existing certificate and reuse it only if its files are actually
+    # present and non-empty. acme.sh stores ECC certs under ${domain}_ecc and RSA
+    # certs under ${domain}; a failed issuance can leave a domain entry in --list
+    # with no usable cert files, which must not be reused (it produces a 0-byte
+    # fullchain.pem). Broken partial state is cleaned up so issuance can proceed.
     local cert_exists=0
     if ~/.acme.sh/acme.sh --list 2> /dev/null | awk '{print $1}' | grep -Fxq "${domain}"; then
-        cert_exists=1
-        local certInfo=$(~/.acme.sh/acme.sh --list 2> /dev/null | grep -F "${domain}")
-        echo -e "${yellow}Existing certificate found for ${domain}, will reuse it.${plain}"
-        [[ -n "${certInfo}" ]] && echo "$certInfo"
-    else
+        local acmeCertDir=""
+        if [[ -s ~/.acme.sh/${domain}_ecc/fullchain.cer && -s ~/.acme.sh/${domain}_ecc/${domain}.key ]]; then
+            acmeCertDir=~/.acme.sh/${domain}_ecc
+        elif [[ -s ~/.acme.sh/${domain}/fullchain.cer && -s ~/.acme.sh/${domain}/${domain}.key ]]; then
+            acmeCertDir=~/.acme.sh/${domain}
+        fi
+        if [[ -n "${acmeCertDir}" ]]; then
+            cert_exists=1
+            local certInfo=$(~/.acme.sh/acme.sh --list 2> /dev/null | grep -F "${domain}")
+            echo -e "${yellow}Existing certificate found for ${domain}, will reuse it.${plain}"
+            [[ -n "${certInfo}" ]] && echo "$certInfo"
+        else
+            echo -e "${yellow}Found incomplete acme.sh state for ${domain} (no valid certificate files); cleaning it up and re-issuing.${plain}"
+            rm -rf ~/.acme.sh/${domain} ~/.acme.sh/${domain}_ecc
+        fi
+    fi
+    if [[ ${cert_exists} -eq 0 ]]; then
         echo -e "${green}Your domain is ready for issuing certificates now...${plain}"
     fi
 
@@ -563,7 +579,7 @@ ssl_cert_issue() {
         ~/.acme.sh/acme.sh --issue -d ${domain} --listen-v6 --standalone --httpport ${WebPort} --force
         if [ $? -ne 0 ]; then
             echo -e "${red}Issuing certificate failed, please check logs.${plain}"
-            rm -rf ~/.acme.sh/${domain}
+            rm -rf ~/.acme.sh/${domain} ~/.acme.sh/${domain}_ecc
             systemctl start x-ui 2> /dev/null || rc-service x-ui start 2> /dev/null
             return 1
         else
@@ -617,7 +633,7 @@ ssl_cert_issue() {
     else
         echo -e "${red}Installing certificate failed, exiting.${plain}"
         if [[ ${cert_exists} -eq 0 ]]; then
-            rm -rf ~/.acme.sh/${domain}
+            rm -rf ~/.acme.sh/${domain} ~/.acme.sh/${domain}_ecc
         fi
         systemctl start x-ui 2> /dev/null || rc-service x-ui start 2> /dev/null
         return 1

+ 14 - 9
sub/sub.go

@@ -120,24 +120,29 @@ func (s *Server) initRouter() (*gin.Engine, error) {
 		SubUpdates = "10"
 	}
 
-	SubJsonFragment, err := s.settingService.GetSubJsonFragment()
+	SubJsonMux, err := s.settingService.GetSubJsonMux()
 	if err != nil {
-		SubJsonFragment = ""
+		SubJsonMux = ""
 	}
 
-	SubJsonNoises, err := s.settingService.GetSubJsonNoises()
+	SubJsonRules, err := s.settingService.GetSubJsonRules()
 	if err != nil {
-		SubJsonNoises = ""
+		SubJsonRules = ""
 	}
 
-	SubJsonMux, err := s.settingService.GetSubJsonMux()
+	SubJsonFinalMask, err := s.settingService.GetSubJsonFinalMask()
 	if err != nil {
-		SubJsonMux = ""
+		SubJsonFinalMask = ""
 	}
 
-	SubJsonRules, err := s.settingService.GetSubJsonRules()
+	SubClashEnableRouting, err := s.settingService.GetSubClashEnableRouting()
 	if err != nil {
-		SubJsonRules = ""
+		SubClashEnableRouting = false
+	}
+
+	SubClashRules, err := s.settingService.GetSubClashRules()
+	if err != nil {
+		SubClashRules = ""
 	}
 
 	SubTitle, err := s.settingService.GetSubTitle()
@@ -226,7 +231,7 @@ func (s *Server) initRouter() (*gin.Engine, error) {
 
 	s.sub = NewSUBController(
 		g, LinksPath, JsonPath, ClashPath, subJsonEnable, subClashEnable, Encrypt, ShowInfo, RemarkModel, SubUpdates,
-		SubJsonFragment, SubJsonNoises, SubJsonMux, SubJsonRules, SubTitle, SubSupportUrl,
+		SubJsonMux, SubJsonRules, SubJsonFinalMask, SubClashEnableRouting, SubClashRules, SubTitle, SubSupportUrl,
 		SubProfileUrl, SubAnnounce, SubEnableRouting, SubRoutingRules)
 
 	return engine, nil

+ 107 - 12
sub/subClashService.go

@@ -15,17 +15,13 @@ import (
 
 type SubClashService struct {
 	inboundService service.InboundService
+	enableRouting  bool
+	clashRules     string
 	SubService     *SubService
 }
 
-type ClashConfig struct {
-	Proxies     []map[string]any `yaml:"proxies"`
-	ProxyGroups []map[string]any `yaml:"proxy-groups"`
-	Rules       []string         `yaml:"rules"`
-}
-
-func NewSubClashService(subService *SubService) *SubClashService {
-	return &SubClashService{SubService: subService}
+func NewSubClashService(enableRouting bool, clashRules string, subService *SubService) *SubClashService {
+	return &SubClashService{enableRouting: enableRouting, clashRules: clashRules, SubService: subService}
 }
 
 func (s *SubClashService) GetClash(subId string, host string) (string, string, error) {
@@ -76,14 +72,20 @@ func (s *SubClashService) GetClash(subId string, host string) (string, string, e
 	}
 	proxyNames = append(proxyNames, "DIRECT")
 
-	config := ClashConfig{
-		Proxies: proxies,
-		ProxyGroups: []map[string]any{{
+	config := map[string]any{
+		"proxies": proxies,
+		"proxy-groups": []map[string]any{{
 			"name":    "PROXY",
 			"type":    "select",
 			"proxies": proxyNames,
 		}},
-		Rules: []string{"MATCH,PROXY"},
+		"rules": []string{"MATCH,PROXY"},
+	}
+
+	if s.enableRouting {
+		if err := mergeClashRulesYAML(config, s.clashRules); err != nil {
+			return "", "", err
+		}
 	}
 
 	finalYAML, err := yaml.Marshal(config)
@@ -554,3 +556,96 @@ func cloneMap(src map[string]any) map[string]any {
 	maps.Copy(dst, src)
 	return dst
 }
+
+func mergeClashRulesYAML(base map[string]any, raw string) error {
+	raw = strings.TrimSpace(raw)
+	if raw == "" {
+		return nil
+	}
+
+	var custom any
+	if err := yaml.Unmarshal([]byte(raw), &custom); err != nil {
+		mergeClashRules(base, linesToClashRules(raw))
+		return nil
+	}
+
+	switch typed := custom.(type) {
+	case []any:
+		mergeClashRules(base, typed)
+	case map[string]any:
+		if rules, ok := typed["rules"]; ok {
+			if ruleList, ok := asAnySlice(rules); ok {
+				mergeClashRules(base, ruleList)
+			}
+		}
+	default:
+		mergeClashRules(base, linesToClashRules(raw))
+	}
+
+	return nil
+}
+
+func mergeClashRules(base map[string]any, customRules []any) {
+	if len(customRules) == 0 {
+		return
+	}
+
+	baseRules, _ := asAnySlice(base["rules"])
+	if hasClashMatchRule(customRules) {
+		base["rules"] = customRules
+		return
+	}
+
+	merged := make([]any, 0, len(customRules)+len(baseRules))
+	merged = append(merged, customRules...)
+	merged = append(merged, baseRules...)
+	base["rules"] = merged
+}
+
+func asAnySlice(value any) ([]any, bool) {
+	switch typed := value.(type) {
+	case []any:
+		return typed, true
+	case []string:
+		out := make([]any, 0, len(typed))
+		for _, item := range typed {
+			out = append(out, item)
+		}
+		return out, true
+	case []map[string]any:
+		out := make([]any, 0, len(typed))
+		for _, item := range typed {
+			out = append(out, item)
+		}
+		return out, true
+	default:
+		return nil, false
+	}
+}
+
+func hasClashMatchRule(rules []any) bool {
+	for _, rule := range rules {
+		ruleText, ok := rule.(string)
+		if !ok {
+			continue
+		}
+		parts := strings.SplitN(ruleText, ",", 2)
+		if strings.EqualFold(strings.TrimSpace(parts[0]), "MATCH") {
+			return true
+		}
+	}
+	return false
+}
+
+func linesToClashRules(raw string) []any {
+	lines := strings.Split(raw, "\n")
+	rules := make([]any, 0, len(lines))
+	for _, line := range lines {
+		line = strings.TrimSpace(line)
+		if line == "" || strings.HasPrefix(line, "#") {
+			continue
+		}
+		rules = append(rules, line)
+	}
+	return rules
+}

+ 5 - 4
sub/subController.go

@@ -62,10 +62,11 @@ func NewSUBController(
 	showInfo bool,
 	rModel string,
 	update string,
-	jsonFragment string,
-	jsonNoise string,
 	jsonMux string,
 	jsonRules string,
+	jsonFinalMask string,
+	clashEnableRouting bool,
+	clashRules string,
 	subTitle string,
 	subSupportUrl string,
 	subProfileUrl string,
@@ -90,8 +91,8 @@ func NewSUBController(
 		updateInterval:   update,
 
 		subService:      sub,
-		subJsonService:  NewSubJsonService(jsonFragment, jsonNoise, jsonMux, jsonRules, sub),
-		subClashService: NewSubClashService(sub),
+		subJsonService:  NewSubJsonService(jsonMux, jsonRules, jsonFinalMask, sub),
+		subClashService: NewSubClashService(clashEnableRouting, clashRules, sub),
 	}
 	a.initRouter(g)
 	return a

+ 78 - 70
sub/subJsonService.go

@@ -21,7 +21,7 @@ var defaultJson string
 type SubJsonService struct {
 	configJson       map[string]any
 	defaultOutbounds []json_util.RawMessage
-	fragmentOrNoises bool
+	finalMask        string
 	mux              string
 
 	inboundService service.InboundService
@@ -29,7 +29,7 @@ type SubJsonService struct {
 }
 
 // NewSubJsonService creates a new JSON subscription service with the given configuration.
-func NewSubJsonService(fragment string, noises string, mux string, rules string, subService *SubService) *SubJsonService {
+func NewSubJsonService(mux string, rules string, finalMask string, subService *SubService) *SubJsonService {
 	var configJson map[string]any
 	var defaultOutbounds []json_util.RawMessage
 	json.Unmarshal([]byte(defaultJson), &configJson)
@@ -40,31 +40,6 @@ func NewSubJsonService(fragment string, noises string, mux string, rules string,
 		}
 	}
 
-	fragmentOrNoises := false
-	if fragment != "" || noises != "" {
-		fragmentOrNoises = true
-		defaultOutboundsSettings := map[string]any{
-			"domainStrategy": "UseIP",
-			"redirect":       "",
-		}
-
-		if fragment != "" {
-			defaultOutboundsSettings["fragment"] = json_util.RawMessage(fragment)
-		}
-
-		if noises != "" {
-			defaultOutboundsSettings["noises"] = json_util.RawMessage(noises)
-		}
-
-		defaultDirectOutbound := map[string]any{
-			"protocol": "freedom",
-			"settings": defaultOutboundsSettings,
-			"tag":      "direct_out",
-		}
-		jsonBytes, _ := json.MarshalIndent(defaultDirectOutbound, "", "  ")
-		defaultOutbounds = append(defaultOutbounds, jsonBytes)
-	}
-
 	if rules != "" {
 		var newRules []any
 		routing, _ := configJson["routing"].(map[string]any)
@@ -78,7 +53,7 @@ func NewSubJsonService(fragment string, noises string, mux string, rules string,
 	return &SubJsonService{
 		configJson:       configJson,
 		defaultOutbounds: defaultOutbounds,
-		fragmentOrNoises: fragmentOrNoises,
+		finalMask:        finalMask,
 		mux:              mux,
 		SubService:       subService,
 	}
@@ -230,8 +205,8 @@ func (s *SubJsonService) streamData(stream string) map[string]any {
 	}
 	delete(streamSettings, "sockopt")
 
-	if s.fragmentOrNoises {
-		streamSettings["sockopt"] = json_util.RawMessage(`{"dialerProxy": "direct_out", "tcpKeepAliveIdle": 100}`)
+	if s.finalMask != "" {
+		s.applyGlobalFinalMask(streamSettings)
 	}
 
 	// remove proxy protocol
@@ -255,6 +230,17 @@ func (s *SubJsonService) streamData(stream string) map[string]any {
 	return streamSettings
 }
 
+func (s *SubJsonService) applyGlobalFinalMask(streamSettings map[string]any) {
+	var fm map[string]any
+	if err := json.Unmarshal([]byte(s.finalMask), &fm); err != nil || len(fm) == 0 {
+		return
+	}
+	merged := mergeFinalMask(streamSettings["finalmask"], fm)
+	if len(merged) > 0 {
+		streamSettings["finalmask"] = merged
+	}
+}
+
 func (s *SubJsonService) removeAcceptProxy(setting any) map[string]any {
 	netSettings, ok := setting.(map[string]any)
 	if ok {
@@ -307,17 +293,6 @@ func (s *SubJsonService) realityData(rData map[string]any) map[string]any {
 
 func (s *SubJsonService) genVnext(inbound *model.Inbound, streamSettings json_util.RawMessage, client model.Client) json_util.RawMessage {
 	outbound := Outbound{}
-	usersData := make([]UserVnext, 1)
-
-	usersData[0].ID = client.ID
-	usersData[0].Email = client.Email
-	usersData[0].Security = client.Security
-	vnextData := make([]VnextSetting, 1)
-	vnextData[0] = VnextSetting{
-		Address: inbound.Listen,
-		Port:    inbound.Port,
-		Users:   usersData,
-	}
 
 	outbound.Protocol = string(inbound.Protocol)
 	outbound.Tag = "proxy"
@@ -325,8 +300,17 @@ func (s *SubJsonService) genVnext(inbound *model.Inbound, streamSettings json_ut
 		outbound.Mux = json_util.RawMessage(s.mux)
 	}
 	outbound.StreamSettings = streamSettings
+
+	security := client.Security
+	if security == "" {
+		security = "auto"
+	}
 	outbound.Settings = map[string]any{
-		"vnext": vnextData,
+		"address":  inbound.Listen,
+		"port":     inbound.Port,
+		"id":       client.ID,
+		"security": security,
+		"level":    8,
 	}
 
 	result, _ := json.MarshalIndent(outbound, "", "  ")
@@ -347,24 +331,17 @@ func (s *SubJsonService) genVless(inbound *model.Inbound, streamSettings json_ut
 	json.Unmarshal([]byte(inbound.Settings), &inboundSettings)
 	encryption, _ := inboundSettings["encryption"].(string)
 
-	user := map[string]any{
+	settings := map[string]any{
+		"address":    inbound.Listen,
+		"port":       inbound.Port,
 		"id":         client.ID,
-		"level":      8,
 		"encryption": encryption,
+		"level":      8,
 	}
 	if client.Flow != "" {
-		user["flow"] = client.Flow
-	}
-
-	vnext := map[string]any{
-		"address": inbound.Listen,
-		"port":    inbound.Port,
-		"users":   []any{user},
-	}
-
-	outbound.Settings = map[string]any{
-		"vnext": []any{vnext},
+		settings["flow"] = client.Flow
 	}
+	outbound.Settings = settings
 	result, _ := json.MarshalIndent(outbound, "", "  ")
 	return result
 }
@@ -400,9 +377,17 @@ func (s *SubJsonService) genServer(inbound *model.Inbound, streamSettings json_u
 		outbound.Mux = json_util.RawMessage(s.mux)
 	}
 	outbound.StreamSettings = streamSettings
-	outbound.Settings = map[string]any{
-		"servers": serverData,
+
+	settings := map[string]any{
+		"address":  serverData[0].Address,
+		"port":     serverData[0].Port,
+		"password": serverData[0].Password,
+		"level":    8,
 	}
+	if inbound.Protocol == model.Shadowsocks {
+		settings["method"] = serverData[0].Method
+	}
+	outbound.Settings = settings
 
 	result, _ := json.MarshalIndent(outbound, "", "  ")
 	return result
@@ -442,7 +427,7 @@ func (s *SubJsonService) genHy(inbound *model.Inbound, newStream map[string]any,
 	newStream["hysteriaSettings"] = outHyStream
 
 	if finalmask, ok := hyStream["finalmask"].(map[string]any); ok {
-		newStream["finalmask"] = finalmask
+		newStream["finalmask"] = mergeFinalMask(newStream["finalmask"], finalmask)
 	}
 
 	newStream["network"] = "hysteria"
@@ -454,6 +439,41 @@ func (s *SubJsonService) genHy(inbound *model.Inbound, newStream map[string]any,
 	return result
 }
 
+func mergeFinalMask(base any, extra map[string]any) map[string]any {
+	merged := map[string]any{}
+	if baseMap, ok := base.(map[string]any); ok {
+		for key, value := range baseMap {
+			switch key {
+			case "tcp", "udp":
+				if masks, ok := value.([]any); ok {
+					merged[key] = append([]any(nil), masks...)
+				}
+			default:
+				merged[key] = value
+			}
+		}
+	}
+
+	for key, value := range extra {
+		switch key {
+		case "tcp", "udp":
+			baseMasks, _ := merged[key].([]any)
+			extraMasks, _ := value.([]any)
+			if len(extraMasks) > 0 {
+				merged[key] = append(baseMasks, extraMasks...)
+			}
+		case "quicParams":
+			if _, exists := merged[key]; !exists {
+				merged[key] = value
+			}
+		default:
+			merged[key] = value
+		}
+	}
+
+	return merged
+}
+
 type Outbound struct {
 	Protocol       string               `json:"protocol"`
 	Tag            string               `json:"tag"`
@@ -462,18 +482,6 @@ type Outbound struct {
 	Settings       map[string]any       `json:"settings,omitempty"`
 }
 
-type VnextSetting struct {
-	Address string      `json:"address"`
-	Port    int         `json:"port"`
-	Users   []UserVnext `json:"users"`
-}
-
-type UserVnext struct {
-	ID       string `json:"id"`
-	Email    string `json:"email,omitempty"`
-	Security string `json:"security,omitempty"`
-}
-
 type ServerSetting struct {
 	Password string `json:"password"`
 	Level    int    `json:"level"`

+ 148 - 0
sub/subJsonService_test.go

@@ -0,0 +1,148 @@
+package sub
+
+import (
+	"encoding/json"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/database/model"
+)
+
+func hasDirectOutOutbound(svc *SubJsonService) bool {
+	for _, raw := range svc.defaultOutbounds {
+		var outbound map[string]any
+		if err := json.Unmarshal(raw, &outbound); err != nil {
+			continue
+		}
+		if outbound["tag"] == "direct_out" {
+			return true
+		}
+	}
+	return false
+}
+
+func outboundSettings(t *testing.T, raw []byte) map[string]any {
+	t.Helper()
+	var parsed map[string]any
+	if err := json.Unmarshal(raw, &parsed); err != nil {
+		t.Fatalf("failed to unmarshal outbound: %v", err)
+	}
+	settings, _ := parsed["settings"].(map[string]any)
+	if settings == nil {
+		t.Fatal("outbound has no settings")
+	}
+	return settings
+}
+
+func TestSubJsonServiceInjectsGlobalFinalMask(t *testing.T) {
+	finalMask := `{"tcp":[{"type":"fragment","settings":{"packets":"tlshello","length":"100-200","delay":"10-20"}}],"udp":[{"type":"noise","settings":{"noise":[{"type":"base64","packet":"SGVsbG8="}]}}],"quicParams":{"congestion":"bbr"}}`
+	svc := NewSubJsonService("", "", finalMask, nil)
+
+	if hasDirectOutOutbound(svc) {
+		t.Fatal("direct_out outbound must never be emitted")
+	}
+
+	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")
+	}
+
+	finalmask, _ := stream["finalmask"].(map[string]any)
+	if finalmask == nil {
+		t.Fatal("streamSettings is missing finalmask")
+	}
+
+	tcp, _ := finalmask["tcp"].([]any)
+	if len(tcp) != 1 {
+		t.Fatalf("tcp masks len = %d, want 1", len(tcp))
+	}
+	if first, _ := tcp[0].(map[string]any); first["type"] != "fragment" {
+		t.Fatalf("tcp[0] type = %v, want fragment", first["type"])
+	}
+
+	udp, _ := finalmask["udp"].([]any)
+	if len(udp) != 1 {
+		t.Fatalf("udp masks len = %d, want 1", len(udp))
+	}
+
+	quic, _ := finalmask["quicParams"].(map[string]any)
+	if quic == nil || quic["congestion"] != "bbr" {
+		t.Fatalf("quicParams missing/wrong: %#v", finalmask["quicParams"])
+	}
+}
+
+func TestSubJsonServiceMergesWithExistingFinalMask(t *testing.T) {
+	finalMask := `{"tcp":[{"type":"fragment","settings":{"packets":"tlshello"}}]}`
+	svc := NewSubJsonService("", "", finalMask, nil)
+
+	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)
+	if len(tcp) != 2 {
+		t.Fatalf("tcp masks len = %d, want 2 (existing + global)", len(tcp))
+	}
+	a, _ := tcp[0].(map[string]any)
+	b, _ := tcp[1].(map[string]any)
+	if a["type"] != "sudoku" || b["type"] != "fragment" {
+		t.Fatalf("tcp masks = %#v, want existing sudoku then global fragment", tcp)
+	}
+}
+
+func TestSubJsonServiceNoFinalMaskWhenEmpty(t *testing.T) {
+	svc := NewSubJsonService("", "", "", nil)
+	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")
+	}
+	if _, ok := stream["sockopt"]; ok {
+		t.Fatal("legacy direct_out sockopt must never be set")
+	}
+}
+
+func TestSubJsonServiceVlessFlattened(t *testing.T) {
+	inbound := &model.Inbound{Listen: "1.2.3.4", Port: 443, Protocol: model.VLESS, Settings: `{"encryption":"none"}`}
+	client := model.Client{ID: "uuid-1", Flow: "xtls-rprx-vision"}
+
+	settings := outboundSettings(t, NewSubJsonService("", "", "", nil).genVless(inbound, nil, client))
+	if _, ok := settings["vnext"]; ok {
+		t.Fatal("vless outbound must not use vnext")
+	}
+	if settings["address"] != "1.2.3.4" || settings["id"] != "uuid-1" || settings["encryption"] != "none" || settings["flow"] != "xtls-rprx-vision" {
+		t.Fatalf("flat vless settings wrong: %#v", settings)
+	}
+}
+
+func TestSubJsonServiceVmessFlattened(t *testing.T) {
+	inbound := &model.Inbound{Listen: "1.2.3.4", Port: 443, Protocol: model.VMESS, Settings: `{}`}
+	client := model.Client{ID: "uuid-2"}
+
+	settings := outboundSettings(t, NewSubJsonService("", "", "", nil).genVnext(inbound, nil, client))
+	if _, ok := settings["vnext"]; ok {
+		t.Fatal("vmess outbound must not use vnext")
+	}
+	if settings["id"] != "uuid-2" || settings["security"] != "auto" {
+		t.Fatalf("flat vmess settings wrong: %#v", settings)
+	}
+}
+
+func TestSubJsonServiceServerFlattened(t *testing.T) {
+	trojan := &model.Inbound{Listen: "1.2.3.4", Port: 443, Protocol: model.Trojan, Settings: `{}`}
+	client := model.Client{Password: "p4ss"}
+
+	settings := outboundSettings(t, NewSubJsonService("", "", "", nil).genServer(trojan, nil, client))
+	if _, ok := settings["servers"]; ok {
+		t.Fatal("trojan outbound must not use servers array")
+	}
+	if settings["password"] != "p4ss" || settings["address"] != "1.2.3.4" {
+		t.Fatalf("flat trojan settings wrong: %#v", settings)
+	}
+
+	ss := &model.Inbound{Listen: "1.2.3.4", Port: 443, Protocol: model.Shadowsocks, Settings: `{"method":"aes-256-gcm"}`}
+	ssSettings := outboundSettings(t, NewSubJsonService("", "", "", nil).genServer(ss, nil, client))
+	if ssSettings["method"] != "aes-256-gcm" {
+		t.Fatalf("flat shadowsocks must carry method: %#v", ssSettings)
+	}
+}

+ 19 - 1
web/controller/client.go

@@ -3,6 +3,8 @@ package controller
 import (
 	"encoding/json"
 	"fmt"
+	"strconv"
+	"strings"
 	"time"
 
 	"github.com/mhsanaei/3x-ui/v3/database/model"
@@ -16,6 +18,21 @@ func notifyClientsChanged() {
 	websocket.BroadcastInvalidate(websocket.MessageTypeClients)
 }
 
+func parseInboundIdsQuery(raw string) []int {
+	raw = strings.TrimSpace(raw)
+	if raw == "" {
+		return nil
+	}
+	parts := strings.Split(raw, ",")
+	ids := make([]int, 0, len(parts))
+	for _, p := range parts {
+		if id, err := strconv.Atoi(strings.TrimSpace(p)); err == nil {
+			ids = append(ids, id)
+		}
+	}
+	return ids
+}
+
 type ClientController struct {
 	clientService  service.ClientService
 	inboundService service.InboundService
@@ -129,7 +146,8 @@ func (a *ClientController) update(c *gin.Context) {
 		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
 		return
 	}
-	needRestart, err := a.clientService.UpdateByEmail(&a.inboundService, email, updated)
+	inboundFilter := parseInboundIdsQuery(c.Query("inboundIds"))
+	needRestart, err := a.clientService.UpdateByEmail(&a.inboundService, email, updated, inboundFilter...)
 	if err != nil {
 		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
 		return

+ 3 - 2
web/entity/entity.go

@@ -83,10 +83,11 @@ type AllSetting struct {
 	SubClashEnable              bool   `json:"subClashEnable" form:"subClashEnable"`                           // Enable Clash/Mihomo subscription endpoint
 	SubClashPath                string `json:"subClashPath" form:"subClashPath"`                               // Path for Clash/Mihomo subscription endpoint
 	SubClashURI                 string `json:"subClashURI" form:"subClashURI"`                                 // Clash/Mihomo subscription server URI
-	SubJsonFragment             string `json:"subJsonFragment" form:"subJsonFragment"`                         // JSON subscription fragment configuration
-	SubJsonNoises               string `json:"subJsonNoises" form:"subJsonNoises"`                             // JSON subscription noise configuration
+	SubClashEnableRouting       bool   `json:"subClashEnableRouting" form:"subClashEnableRouting"`             // Enable global routing rules for Clash/Mihomo
+	SubClashRules               string `json:"subClashRules" form:"subClashRules"`                             // Clash/Mihomo global routing rules
 	SubJsonMux                  string `json:"subJsonMux" form:"subJsonMux"`                                   // JSON subscription mux configuration
 	SubJsonRules                string `json:"subJsonRules" form:"subJsonRules"`
+	SubJsonFinalMask            string `json:"subJsonFinalMask" form:"subJsonFinalMask"` // JSON subscription global finalmask (tcp/udp masks + quicParams)
 
 	// LDAP settings
 	LdapEnable     bool   `json:"ldapEnable" form:"ldapEnable"`

+ 16 - 9
web/runtime/remote.go

@@ -286,15 +286,17 @@ func (r *Remote) AddClient(ctx context.Context, ib *model.Inbound, client model.
 	return nil
 }
 
-// DeleteUser is idempotent: master's per-inbound Delete loop may call it
-// multiple times for the same node, and "not found" on the follow-ups is
-// the expected success path.
-func (r *Remote) DeleteUser(ctx context.Context, _ *model.Inbound, email string) error {
+func (r *Remote) DeleteUser(ctx context.Context, ib *model.Inbound, email string) error {
 	if email == "" {
 		return nil
 	}
-	_, err := r.do(ctx, http.MethodPost,
-		"panel/api/clients/del/"+url.PathEscape(email), nil)
+	id, err := r.resolveRemoteID(ctx, ib.Tag)
+	if err != nil {
+		return nil
+	}
+	body := map[string]any{"inboundIds": []int{id}}
+	_, err = r.do(ctx, http.MethodPost,
+		"panel/api/clients/"+url.PathEscape(email)+"/detach", body)
 	if err == nil {
 		return nil
 	}
@@ -304,12 +306,17 @@ func (r *Remote) DeleteUser(ctx context.Context, _ *model.Inbound, email string)
 	return err
 }
 
-func (r *Remote) UpdateUser(ctx context.Context, _ *model.Inbound, oldEmail string, payload model.Client) error {
+func (r *Remote) UpdateUser(ctx context.Context, ib *model.Inbound, oldEmail string, payload model.Client) error {
 	if oldEmail == "" {
 		oldEmail = payload.Email
 	}
-	if _, err := r.do(ctx, http.MethodPost,
-		"panel/api/clients/update/"+url.PathEscape(oldEmail), payload); err != nil {
+	id, err := r.resolveRemoteID(ctx, ib.Tag)
+	if err != nil {
+		return err
+	}
+	path := "panel/api/clients/update/" + url.PathEscape(oldEmail) +
+		"?inboundIds=" + strconv.Itoa(id)
+	if _, err := r.do(ctx, http.MethodPost, path, payload); err != nil {
 		return err
 	}
 	return nil

+ 216 - 0
web/service/api_scale_postgres_test.go

@@ -0,0 +1,216 @@
+package service
+
+import (
+	"fmt"
+	"os"
+	"strings"
+	"testing"
+	"time"
+
+	"github.com/mhsanaei/3x-ui/v3/database"
+	"github.com/mhsanaei/3x-ui/v3/database/model"
+	xuilogger "github.com/mhsanaei/3x-ui/v3/logger"
+	"github.com/mhsanaei/3x-ui/v3/xray"
+
+	"github.com/op/go-logging"
+)
+
+func seedClientTraffics(t *testing.T, inboundId int, clients []model.Client) {
+	t.Helper()
+	db := database.GetDB()
+	rows := make([]xray.ClientTraffic, len(clients))
+	for i := range clients {
+		rows[i] = xray.ClientTraffic{
+			InboundId:  inboundId,
+			Email:      clients[i].Email,
+			Enable:     true,
+			Total:      clients[i].TotalGB,
+			ExpiryTime: clients[i].ExpiryTime,
+		}
+	}
+	if err := db.CreateInBatches(rows, 1000).Error; err != nil {
+		t.Fatalf("seed client_traffics: %v", err)
+	}
+}
+
+// TestAllAPIsPostgresScale exercises every client/inbound/group service method
+// reachable from the REST API at 100k/200k clients, asserting none crash on the
+// PostgreSQL bind-parameter ceiling and logging the wall-clock cost of each.
+func TestAllAPIsPostgresScale(t *testing.T) {
+	if strings.TrimSpace(os.Getenv("XUI_DB_DSN")) == "" || os.Getenv("XUI_DB_TYPE") != "postgres" {
+		t.Skip("set XUI_DB_TYPE=postgres and XUI_DB_DSN to run the postgres scale benchmark")
+	}
+	xuilogger.InitLogger(logging.ERROR)
+	if err := database.InitDB(""); err != nil {
+		t.Fatalf("InitDB: %v", err)
+	}
+	t.Cleanup(func() { _ = database.CloseDB() })
+
+	svc := &ClientService{}
+	inboundSvc := &InboundService{}
+	settingSvc := &SettingService{}
+	const userId = 1
+	const m = 2000
+	sizes := []int{50000, 100000, 200000}
+
+	for _, n := range sizes {
+		t.Run(fmt.Sprintf("N=%d", n), func(t *testing.T) {
+			db := database.GetDB()
+			if err := db.Exec("TRUNCATE TABLE inbounds, clients, client_inbounds, client_traffics, client_groups RESTART IDENTITY CASCADE").Error; err != nil {
+				t.Fatalf("truncate: %v", err)
+			}
+
+			clients := makeScaleClients(n)
+			exp := time.Now().AddDate(1, 0, 0).UnixMilli()
+			for i := range clients {
+				clients[i].ExpiryTime = exp
+				clients[i].TotalGB = 100 << 30
+			}
+			ib := &model.Inbound{UserId: userId, Tag: fmt.Sprintf("all-%d", n), Enable: true, Port: 40000, Protocol: model.VLESS, Settings: clientsSettings(t, clients)}
+			if err := db.Create(ib).Error; err != nil {
+				t.Fatalf("create inbound: %v", err)
+			}
+			ib2 := &model.Inbound{UserId: userId, Tag: fmt.Sprintf("all2-%d", n), Enable: true, Port: 40001, Protocol: model.VLESS, Settings: `{"clients":[]}`}
+			if err := db.Create(ib2).Error; err != nil {
+				t.Fatalf("create inbound2: %v", err)
+			}
+			if err := svc.SyncInbound(nil, ib.Id, clients); err != nil {
+				t.Fatalf("seed SyncInbound: %v", err)
+			}
+
+			run := func(name string, fn func() error) {
+				start := time.Now()
+				if err := fn(); err != nil {
+					t.Fatalf("%s: %v", name, err)
+				}
+				t.Logf("N=%-7d %-26s %v", n, name, time.Since(start).Round(time.Millisecond))
+			}
+
+			run("GetInboundDetail(noTraffic)", func() error { _, err := inboundSvc.GetInboundDetail(ib.Id); return err })
+
+			seedClientTraffics(t, ib.Id, clients)
+			db.Exec("ANALYZE")
+
+			emails := make([]string, n)
+			for i := 0; i < n; i++ {
+				emails[i] = clients[i].Email
+			}
+			emailsM := emails[:m]
+
+			run("GetInbounds", func() error { _, err := inboundSvc.GetInbounds(userId); return err })
+			run("GetInboundsSlim", func() error { _, err := inboundSvc.GetInboundsSlim(userId); return err })
+			run("GetInboundDetail", func() error { _, err := inboundSvc.GetInboundDetail(ib.Id); return err })
+			run("GetInboundOptions", func() error { _, err := inboundSvc.GetInboundOptions(userId); return err })
+			run("ListPaged", func() error { _, err := svc.ListPaged(inboundSvc, settingSvc, ClientPageParams{Page: 1, PageSize: 25}); return err })
+			run("ListPaged+search", func() error {
+				_, err := svc.ListPaged(inboundSvc, settingSvc, ClientPageParams{Page: 1, PageSize: 25, Search: "user-0012345"})
+				return err
+			})
+			run("GetClientsLastOnline", func() error { _, err := inboundSvc.GetClientsLastOnline(); return err })
+			run("GetClientTrafficByEmail", func() error { _, err := inboundSvc.GetClientTrafficByEmail(emails[n/2]); return err })
+			run("GetRecordByEmail", func() error { _, err := svc.GetRecordByEmail(nil, emails[n/2]); return err })
+
+			run("ListGroups", func() error { _, err := svc.ListGroups(); return err })
+			run("AddToGroup(M)", func() error { _, err := svc.AddToGroup(emailsM, "g1"); return err })
+			run("EmailsByGroup", func() error { _, err := svc.EmailsByGroup("g1"); return err })
+			run("RenameGroup", func() error { _, err := svc.RenameGroup("g1", "g2"); return err })
+			run("DeleteGroup", func() error { _, err := svc.DeleteGroup("g2"); return err })
+
+			run("ResetInboundTraffic", func() error { return inboundSvc.ResetInboundTraffic(ib.Id) })
+			run("Inbound.ResetAllTraffics", func() error { return inboundSvc.ResetAllTraffics() })
+			run("Client.ResetAllTraffics", func() error { _, err := svc.ResetAllTraffics(); return err })
+			run("BulkResetTraffic(M)", func() error { _, err := svc.BulkResetTraffic(inboundSvc, emailsM); return err })
+
+			run("UpdateByEmail", func() error {
+				upd := clients[n/3]
+				upd.Comment = "touched"
+				_, err := svc.UpdateByEmail(inboundSvc, upd.Email, upd)
+				return err
+			})
+			run("AttachByEmail", func() error { _, err := svc.AttachByEmail(inboundSvc, emails[n/3], []int{ib2.Id}); return err })
+			run("DetachByEmailMany", func() error { _, err := svc.DetachByEmailMany(inboundSvc, emails[n/3], []int{ib2.Id}); return err })
+
+			depEmails := emails[:1000]
+			for _, batch := range chunkStrings(depEmails, sqlInChunk) {
+				if err := db.Model(xray.ClientTraffic{}).Where("email IN ?", batch).Update("down", int64(200)<<30).Error; err != nil {
+					t.Fatalf("mark depleted: %v", err)
+				}
+			}
+			run("DelDepleted(1k)", func() error { _, _, err := svc.DelDepleted(inboundSvc); return err })
+
+			run("DelInbound(full)", func() error { _, err := inboundSvc.DelInbound(ib.Id); return err })
+		})
+	}
+}
+
+// TestGetClientTrafficByEmailABScale measures the GetClientTrafficByEmail change:
+// old path (GetClientByEmail, which parses the inbound's entire settings JSON to
+// find one client) vs new path (UUID/subId read from the indexed clients table).
+func TestGetClientTrafficByEmailABScale(t *testing.T) {
+	if strings.TrimSpace(os.Getenv("XUI_DB_DSN")) == "" || os.Getenv("XUI_DB_TYPE") != "postgres" {
+		t.Skip("set XUI_DB_TYPE=postgres and XUI_DB_DSN to run the postgres scale benchmark")
+	}
+	xuilogger.InitLogger(logging.ERROR)
+	if err := database.InitDB(""); err != nil {
+		t.Fatalf("InitDB: %v", err)
+	}
+	t.Cleanup(func() { _ = database.CloseDB() })
+
+	svc := &ClientService{}
+	inboundSvc := &InboundService{}
+	const reps = 10
+	sizes := []int{50000, 100000, 200000}
+
+	oldImpl := func(email string) error {
+		tr, client, err := inboundSvc.GetClientByEmail(email)
+		if err != nil {
+			return err
+		}
+		if tr != nil && client != nil {
+			tr.UUID = client.ID
+			tr.SubId = client.SubID
+		}
+		return nil
+	}
+
+	for _, n := range sizes {
+		t.Run(fmt.Sprintf("N=%d", n), func(t *testing.T) {
+			db := database.GetDB()
+			if err := db.Exec("TRUNCATE TABLE inbounds, clients, client_inbounds, client_traffics RESTART IDENTITY CASCADE").Error; err != nil {
+				t.Fatalf("truncate: %v", err)
+			}
+			clients := makeScaleClients(n)
+			ib := &model.Inbound{UserId: 1, Tag: fmt.Sprintf("ctbe-%d", n), Enable: true, Port: 40000, Protocol: model.VLESS, Settings: clientsSettings(t, clients)}
+			if err := db.Create(ib).Error; err != nil {
+				t.Fatalf("create inbound: %v", err)
+			}
+			if err := svc.SyncInbound(nil, ib.Id, clients); err != nil {
+				t.Fatalf("seed SyncInbound: %v", err)
+			}
+			seedClientTraffics(t, ib.Id, clients)
+			db.Exec("ANALYZE")
+
+			targets := []string{clients[0].Email, clients[n/2].Email, clients[n-1].Email}
+
+			start := time.Now()
+			for i := 0; i < reps; i++ {
+				if _, err := inboundSvc.GetClientTrafficByEmail(targets[i%len(targets)]); err != nil {
+					t.Fatalf("new GetClientTrafficByEmail: %v", err)
+				}
+			}
+			newDur := time.Since(start) / reps
+
+			start = time.Now()
+			for i := 0; i < reps; i++ {
+				if err := oldImpl(targets[i%len(targets)]); err != nil {
+					t.Fatalf("old GetClientTrafficByEmail: %v", err)
+				}
+			}
+			oldDur := time.Since(start) / reps
+
+			t.Logf("N=%-7d new=%-9v old=%-9v speedup=%.0fx", n,
+				newDur.Round(time.Microsecond), oldDur.Round(time.Millisecond),
+				float64(oldDur)/float64(maxDur(newDur, time.Microsecond)))
+		})
+	}
+}

+ 149 - 0
web/service/bulk_traffic_test.go

@@ -0,0 +1,149 @@
+package service
+
+import (
+	"testing"
+	"time"
+
+	"github.com/mhsanaei/3x-ui/v3/database"
+	"github.com/mhsanaei/3x-ui/v3/database/model"
+	"github.com/mhsanaei/3x-ui/v3/xray"
+)
+
+func mkTraffic(t *testing.T, inboundId int, email string, up, down, total, expiry int64, enable bool) {
+	t.Helper()
+	row := xray.ClientTraffic{
+		InboundId:  inboundId,
+		Email:      email,
+		Up:         up,
+		Down:       down,
+		Total:      total,
+		ExpiryTime: expiry,
+		Enable:     enable,
+	}
+	if err := database.GetDB().Create(&row).Error; err != nil {
+		t.Fatalf("create traffic %s: %v", email, err)
+	}
+}
+
+func trafficOf(t *testing.T, email string) xray.ClientTraffic {
+	t.Helper()
+	var row xray.ClientTraffic
+	if err := database.GetDB().Where("email = ?", email).First(&row).Error; err != nil {
+		t.Fatalf("load traffic %s: %v", email, err)
+	}
+	return row
+}
+
+func TestBulkResetTrafficZeroesUsageAndReenables(t *testing.T) {
+	setupBulkDB(t)
+	svc := &ClientService{}
+	inboundSvc := &InboundService{}
+
+	source := []model.Client{
+		{Email: "alice@x", ID: "11111111-1111-1111-1111-111111111111", SubID: "sa", Enable: true},
+		{Email: "bob@x", ID: "22222222-2222-2222-2222-222222222222", SubID: "sb", Enable: true},
+		{Email: "carol@x", ID: "33333333-3333-3333-3333-333333333333", SubID: "sc", Enable: true},
+	}
+	ib := mkInbound(t, 21001, model.VLESS, clientsSettings(t, source))
+	if err := svc.SyncInbound(nil, ib.Id, source); err != nil {
+		t.Fatalf("seed linkage: %v", err)
+	}
+	mkTraffic(t, ib.Id, "alice@x", 10, 20, 0, 0, false)
+	mkTraffic(t, ib.Id, "bob@x", 5, 5, 0, 0, true)
+	mkTraffic(t, ib.Id, "carol@x", 7, 0, 0, 0, true)
+
+	affected, err := svc.BulkResetTraffic(inboundSvc, []string{"alice@x", "bob@x"})
+	if err != nil {
+		t.Fatalf("BulkResetTraffic: %v", err)
+	}
+	if affected != 2 {
+		t.Fatalf("expected 2 affected, got %d", affected)
+	}
+
+	for _, e := range []string{"alice@x", "bob@x"} {
+		tr := trafficOf(t, e)
+		if tr.Up != 0 || tr.Down != 0 {
+			t.Fatalf("%s: expected up/down 0, got up=%d down=%d", e, tr.Up, tr.Down)
+		}
+		if !tr.Enable {
+			t.Fatalf("%s: expected re-enabled", e)
+		}
+	}
+
+	carol := trafficOf(t, "carol@x")
+	if carol.Up != 7 {
+		t.Fatalf("carol not in list should be untouched, got up=%d", carol.Up)
+	}
+}
+
+func TestDelDepletedRemovesOnlyDepleted(t *testing.T) {
+	setupBulkDB(t)
+	svc := &ClientService{}
+	inboundSvc := &InboundService{}
+
+	source := []model.Client{
+		{Email: "alice@x", ID: "11111111-1111-1111-1111-111111111111", SubID: "sa", Enable: true},
+		{Email: "bob@x", ID: "22222222-2222-2222-2222-222222222222", SubID: "sb", Enable: true},
+		{Email: "carol@x", ID: "33333333-3333-3333-3333-333333333333", SubID: "sc", Enable: true},
+	}
+	ib := mkInbound(t, 21002, model.VLESS, clientsSettings(t, source))
+	if err := svc.SyncInbound(nil, ib.Id, source); err != nil {
+		t.Fatalf("seed linkage: %v", err)
+	}
+	past := time.Now().Add(-time.Hour).UnixMilli()
+	mkTraffic(t, ib.Id, "alice@x", 60, 60, 100, 0, true)
+	mkTraffic(t, ib.Id, "bob@x", 10, 10, 100, 0, true)
+	mkTraffic(t, ib.Id, "carol@x", 0, 0, 0, past, true)
+
+	deleted, _, err := svc.DelDepleted(inboundSvc)
+	if err != nil {
+		t.Fatalf("DelDepleted: %v", err)
+	}
+	if deleted != 2 {
+		t.Fatalf("expected 2 deleted (alice traffic-depleted, carol expired), got %d", deleted)
+	}
+
+	if _, err := svc.GetRecordByEmail(nil, "bob@x"); err != nil {
+		t.Fatalf("bob should survive: %v", err)
+	}
+	for _, e := range []string{"alice@x", "carol@x"} {
+		if _, err := svc.GetRecordByEmail(nil, e); err == nil {
+			t.Fatalf("%s should be deleted", e)
+		}
+	}
+
+	reloaded, _ := inboundSvc.GetInbound(ib.Id)
+	jsonClients, _ := inboundSvc.GetClients(reloaded)
+	if len(jsonClients) != 1 || jsonClients[0].Email != "bob@x" {
+		t.Fatalf("settings JSON should contain only bob, got %d clients", len(jsonClients))
+	}
+}
+
+func TestGetClientTrafficByEmailReadsClientsTable(t *testing.T) {
+	setupBulkDB(t)
+	svc := &ClientService{}
+	inboundSvc := &InboundService{}
+
+	source := []model.Client{
+		{Email: "alice@x", ID: "11111111-1111-1111-1111-111111111111", SubID: "sa", Enable: true},
+	}
+	ib := mkInbound(t, 21003, model.VLESS, clientsSettings(t, source))
+	if err := svc.SyncInbound(nil, ib.Id, source); err != nil {
+		t.Fatalf("seed linkage: %v", err)
+	}
+	mkTraffic(t, ib.Id, "alice@x", 1, 2, 0, 0, true)
+
+	tr, err := inboundSvc.GetClientTrafficByEmail("alice@x")
+	if err != nil {
+		t.Fatalf("GetClientTrafficByEmail: %v", err)
+	}
+	if tr == nil {
+		t.Fatalf("expected traffic, got nil")
+	}
+	if tr.UUID != "11111111-1111-1111-1111-111111111111" {
+		t.Fatalf("UUID not enriched from clients table, got %q", tr.UUID)
+	}
+	if tr.SubId != "sa" {
+		t.Fatalf("SubId not enriched from clients table, got %q", tr.SubId)
+	}
+}

+ 588 - 207
web/service/client.go

@@ -124,19 +124,23 @@ func compactOrphans(db *gorm.DB, clients []any) []any {
 	if len(emails) == 0 {
 		return clients
 	}
-	var existingEmails []string
-	if err := db.Model(&model.ClientRecord{}).Where("email IN ?", emails).Pluck("email", &existingEmails).Error; err != nil {
-		logger.Warning("compactOrphans pluck:", err)
-		return clients
+	existing := make(map[string]struct{}, len(emails))
+	const orphanChunk = 400
+	for start := 0; start < len(emails); start += orphanChunk {
+		end := min(start+orphanChunk, len(emails))
+		var found []string
+		if err := db.Model(&model.ClientRecord{}).Where("email IN ?", emails[start:end]).Pluck("email", &found).Error; err != nil {
+			logger.Warning("compactOrphans pluck:", err)
+			return clients
+		}
+		for _, e := range found {
+			existing[e] = struct{}{}
+		}
 	}
-	if len(existingEmails) == len(emails) {
+	if len(existing) == len(emails) {
 		return clients
 	}
-	existing := make(map[string]struct{}, len(existingEmails))
-	for _, e := range existingEmails {
-		existing[e] = struct{}{}
-	}
-	out := make([]any, 0, len(existingEmails))
+	out := make([]any, 0, len(existing))
 	for _, c := range clients {
 		cm, ok := c.(map[string]any)
 		if !ok {
@@ -170,6 +174,26 @@ func tombstoneClientEmail(email string) {
 	}
 }
 
+func tombstoneClientEmails(emails []string) {
+	if len(emails) == 0 {
+		return
+	}
+	now := time.Now()
+	cutoff := now.Add(-deleteTombstoneTTL)
+	recentlyDeletedMu.Lock()
+	defer recentlyDeletedMu.Unlock()
+	for _, email := range emails {
+		if email != "" {
+			recentlyDeleted[email] = now
+		}
+	}
+	for e, ts := range recentlyDeleted {
+		if ts.Before(cutoff) {
+			delete(recentlyDeleted, e)
+		}
+	}
+}
+
 func isClientEmailTombstoned(email string) bool {
 	if email == "" {
 		return false
@@ -196,73 +220,134 @@ func (s *ClientService) SyncInbound(tx *gorm.DB, inboundId int, clients []model.
 		return err
 	}
 
+	emails := make([]string, 0, len(clients))
+	seen := make(map[string]struct{}, len(clients))
 	for i := range clients {
-		c := clients[i]
-		email := strings.TrimSpace(c.Email)
+		email := strings.TrimSpace(clients[i].Email)
 		if email == "" {
 			continue
 		}
+		if _, ok := seen[email]; ok {
+			continue
+		}
+		seen[email] = struct{}{}
+		emails = append(emails, email)
+	}
 
-		incoming := c.ToRecord()
-		row := &model.ClientRecord{}
-		err := tx.Where("email = ?", email).First(row).Error
-		if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
+	existing := make(map[string]*model.ClientRecord, len(emails))
+	const selectChunk = 400
+	for start := 0; start < len(emails); start += selectChunk {
+		end := min(start+selectChunk, len(emails))
+		var rows []model.ClientRecord
+		if err := tx.Where("email IN ?", emails[start:end]).Find(&rows).Error; err != nil {
 			return err
 		}
-		if errors.Is(err, gorm.ErrRecordNotFound) {
-			if err := tx.Create(incoming).Error; err != nil {
-				return err
-			}
-			row = incoming
-		} else {
-			if incoming.UUID != "" {
-				row.UUID = incoming.UUID
-			}
-			if incoming.Password != "" {
-				row.Password = incoming.Password
-			}
-			if incoming.Auth != "" {
-				row.Auth = incoming.Auth
-			}
-			row.Flow = incoming.Flow
-			if incoming.Security != "" {
-				row.Security = incoming.Security
-			}
-			if incoming.Reverse != "" {
-				row.Reverse = incoming.Reverse
-			}
-			row.SubID = incoming.SubID
-			row.LimitIP = incoming.LimitIP
-			row.TotalGB = incoming.TotalGB
-			row.ExpiryTime = incoming.ExpiryTime
-			row.Enable = incoming.Enable
-			row.TgID = incoming.TgID
-			if incoming.Group != "" {
-				row.Group = incoming.Group
-			}
-			row.Comment = incoming.Comment
-			row.Reset = incoming.Reset
-			if incoming.CreatedAt > 0 && (row.CreatedAt == 0 || incoming.CreatedAt < row.CreatedAt) {
-				row.CreatedAt = incoming.CreatedAt
-			}
-			preservedUpdatedAt := max(incoming.UpdatedAt, row.UpdatedAt)
-			row.UpdatedAt = preservedUpdatedAt
-			if err := tx.Save(row).Error; err != nil {
-				return err
-			}
-			if err := tx.Model(&model.ClientRecord{}).
-				Where("id = ?", row.Id).
-				UpdateColumn("updated_at", preservedUpdatedAt).Error; err != nil {
-				return err
+		for i := range rows {
+			r := rows[i]
+			existing[r.Email] = &r
+		}
+	}
+
+	idByEmail := make(map[string]int, len(emails))
+	pending := make(map[string]*model.ClientRecord, len(emails))
+	toCreate := make([]*model.ClientRecord, 0, len(emails))
+	for i := range clients {
+		email := strings.TrimSpace(clients[i].Email)
+		if email == "" {
+			continue
+		}
+
+		incoming := clients[i].ToRecord()
+		row, ok := existing[email]
+		if !ok {
+			if _, dup := pending[email]; !dup {
+				pending[email] = incoming
+				toCreate = append(toCreate, incoming)
 			}
+			continue
 		}
 
-		link := model.ClientInbound{
-			ClientId:     row.Id,
-			InboundId:    inboundId,
-			FlowOverride: c.Flow,
+		before := *row
+		if incoming.UUID != "" {
+			row.UUID = incoming.UUID
+		}
+		if incoming.Password != "" {
+			row.Password = incoming.Password
+		}
+		if incoming.Auth != "" {
+			row.Auth = incoming.Auth
+		}
+		row.Flow = incoming.Flow
+		if incoming.Security != "" {
+			row.Security = incoming.Security
+		}
+		if incoming.Reverse != "" {
+			row.Reverse = incoming.Reverse
+		}
+		row.SubID = incoming.SubID
+		row.LimitIP = incoming.LimitIP
+		row.TotalGB = incoming.TotalGB
+		row.ExpiryTime = incoming.ExpiryTime
+		row.Enable = incoming.Enable
+		row.TgID = incoming.TgID
+		if incoming.Group != "" {
+			row.Group = incoming.Group
+		}
+		row.Comment = incoming.Comment
+		row.Reset = incoming.Reset
+		if incoming.CreatedAt > 0 && (row.CreatedAt == 0 || incoming.CreatedAt < row.CreatedAt) {
+			row.CreatedAt = incoming.CreatedAt
+		}
+		preservedUpdatedAt := max(incoming.UpdatedAt, row.UpdatedAt)
+		row.UpdatedAt = preservedUpdatedAt
+
+		idByEmail[email] = row.Id
+
+		if *row == before {
+			continue
+		}
+		if err := tx.Save(row).Error; err != nil {
+			return err
+		}
+		if err := tx.Model(&model.ClientRecord{}).
+			Where("id = ?", row.Id).
+			UpdateColumn("updated_at", preservedUpdatedAt).Error; err != nil {
+			return err
 		}
-		if err := tx.Create(&link).Error; err != nil {
+	}
+
+	if len(toCreate) > 0 {
+		if err := tx.CreateInBatches(toCreate, 200).Error; err != nil {
+			return err
+		}
+		for _, rec := range toCreate {
+			idByEmail[rec.Email] = rec.Id
+		}
+	}
+
+	links := make([]model.ClientInbound, 0, len(clients))
+	linked := make(map[int]struct{}, len(clients))
+	for i := range clients {
+		email := strings.TrimSpace(clients[i].Email)
+		if email == "" {
+			continue
+		}
+		id, ok := idByEmail[email]
+		if !ok {
+			continue
+		}
+		if _, dup := linked[id]; dup {
+			continue
+		}
+		linked[id] = struct{}{}
+		links = append(links, model.ClientInbound{
+			ClientId:     id,
+			InboundId:    inboundId,
+			FlowOverride: clients[i].Flow,
+		})
+	}
+	if len(links) > 0 {
+		if err := tx.CreateInBatches(links, 200).Error; err != nil {
 			return err
 		}
 	}
@@ -397,20 +482,26 @@ func (s *ClientService) List() ([]ClientWithAttachments, error) {
 		}
 	}
 
-	var links []model.ClientInbound
-	if err := db.Where("client_id IN ?", clientIds).Find(&links).Error; err != nil {
-		return nil, err
-	}
 	attachments := make(map[int][]int, len(rows))
-	for _, l := range links {
-		attachments[l.ClientId] = append(attachments[l.ClientId], l.InboundId)
+	for _, batch := range chunkInts(clientIds, sqlInChunk) {
+		var links []model.ClientInbound
+		if err := db.Where("client_id IN ?", batch).Find(&links).Error; err != nil {
+			return nil, err
+		}
+		for _, l := range links {
+			attachments[l.ClientId] = append(attachments[l.ClientId], l.InboundId)
+		}
 	}
 
 	trafficByEmail := make(map[string]*xray.ClientTraffic, len(emails))
 	if len(emails) > 0 {
 		var stats []xray.ClientTraffic
-		if err := db.Where("email IN ?", emails).Find(&stats).Error; err != nil {
-			return nil, err
+		for _, batch := range chunkStrings(emails, sqlInChunk) {
+			var batchStats []xray.ClientTraffic
+			if err := db.Where("email IN ?", batch).Find(&batchStats).Error; err != nil {
+				return nil, err
+			}
+			stats = append(stats, batchStats...)
 		}
 		for i := range stats {
 			trafficByEmail[stats[i].Email] = &stats[i]
@@ -634,7 +725,7 @@ func applyShadowsocksClientMethod(clients []any, settings map[string]any) {
 	}
 }
 
-func (s *ClientService) Update(inboundSvc *InboundService, id int, updated model.Client) (bool, error) {
+func (s *ClientService) Update(inboundSvc *InboundService, id int, updated model.Client, inboundFilter ...int) (bool, error) {
 	existing, err := s.GetByID(id)
 	if err != nil {
 		return false, err
@@ -643,6 +734,19 @@ func (s *ClientService) Update(inboundSvc *InboundService, id int, updated model
 	if err != nil {
 		return false, err
 	}
+	if len(inboundFilter) > 0 {
+		allow := make(map[int]struct{}, len(inboundFilter))
+		for _, fid := range inboundFilter {
+			allow[fid] = struct{}{}
+		}
+		filtered := inboundIds[:0:0]
+		for _, ibId := range inboundIds {
+			if _, ok := allow[ibId]; ok {
+				filtered = append(filtered, ibId)
+			}
+		}
+		inboundIds = filtered
+	}
 
 	if strings.TrimSpace(updated.Email) == "" {
 		return false, common.NewError("client email is required")
@@ -1170,13 +1274,25 @@ func (s *ClientService) delInboundClients(inboundSvc *InboundService, inboundId
 	}
 	oldInbound.Settings = string(newSettings)
 
+	var sharedSet map[string]bool
+	if !keepTraffic {
+		removedEmails := make([]string, 0, len(removed))
+		for _, r := range removed {
+			if r.email != "" {
+				removedEmails = append(removedEmails, r.email)
+			}
+		}
+		var sharedErr error
+		sharedSet, sharedErr = inboundSvc.emailsUsedByOtherInbounds(removedEmails, inboundId)
+		if sharedErr != nil {
+			return false, sharedErr
+		}
+	}
+
 	needRestart := false
 	for _, r := range removed {
 		email := r.email
-		emailShared, err := inboundSvc.emailUsedByOtherInbounds(email, inboundId)
-		if err != nil {
-			return needRestart, err
-		}
+		emailShared := sharedSet[strings.ToLower(strings.TrimSpace(email))]
 		if !emailShared && !keepTraffic {
 			if err := inboundSvc.DelClientIPs(db, email); err != nil {
 				logger.Error("Error in delete client IPs")
@@ -1317,7 +1433,7 @@ func (s *ClientService) findInboundIdsByClientEmail(email string) ([]int, error)
 	return out, nil
 }
 
-func (s *ClientService) UpdateByEmail(inboundSvc *InboundService, email string, updated model.Client) (bool, error) {
+func (s *ClientService) UpdateByEmail(inboundSvc *InboundService, email string, updated model.Client, inboundFilter ...int) (bool, error) {
 	if email == "" {
 		return false, common.NewError("client email is required")
 	}
@@ -1325,7 +1441,7 @@ func (s *ClientService) UpdateByEmail(inboundSvc *InboundService, email string,
 	if err != nil {
 		return false, err
 	}
-	return s.Update(inboundSvc, rec.Id, updated)
+	return s.Update(inboundSvc, rec.Id, updated, inboundFilter...)
 }
 
 func (s *ClientService) ResetTrafficByEmail(inboundSvc *InboundService, email string) (bool, error) {
@@ -1631,14 +1747,43 @@ func (s *ClientService) BulkResetTraffic(inboundSvc *InboundService, emails []st
 	if len(emails) == 0 {
 		return 0, nil
 	}
-	count := 0
-	for _, email := range emails {
-		if _, err := s.ResetTrafficByEmail(inboundSvc, email); err != nil {
-			return count, err
+	seen := map[string]struct{}{}
+	cleanEmails := make([]string, 0, len(emails))
+	for _, e := range emails {
+		e = strings.TrimSpace(e)
+		if e == "" {
+			continue
 		}
-		count++
+		if _, ok := seen[e]; ok {
+			continue
+		}
+		seen[e] = struct{}{}
+		cleanEmails = append(cleanEmails, e)
 	}
-	return count, nil
+	if len(cleanEmails) == 0 {
+		return 0, nil
+	}
+
+	affected := 0
+	err := submitTrafficWrite(func() error {
+		db := database.GetDB()
+		return db.Transaction(func(tx *gorm.DB) error {
+			for _, batch := range chunkStrings(cleanEmails, sqlInChunk) {
+				res := tx.Model(xray.ClientTraffic{}).
+					Where("email IN ?", batch).
+					Updates(map[string]any{"enable": true, "up": 0, "down": 0})
+				if res.Error != nil {
+					return res.Error
+				}
+				affected += int(res.RowsAffected)
+			}
+			return nil
+		})
+	})
+	if err != nil {
+		return 0, err
+	}
+	return affected, nil
 }
 
 func (s *ClientService) CreateGroup(name string) error {
@@ -1710,8 +1855,12 @@ func (s *ClientService) AddToGroup(emails []string, group string) (int, error) {
 	}
 
 	var records []model.ClientRecord
-	if err := db.Where("email IN ?", emails).Find(&records).Error; err != nil {
-		return 0, err
+	for _, batch := range chunkStrings(emails, sqlInChunk) {
+		var rows []model.ClientRecord
+		if err := db.Where("email IN ?", batch).Find(&rows).Error; err != nil {
+			return 0, err
+		}
+		records = append(records, rows...)
 	}
 	if len(records) == 0 {
 		return 0, nil
@@ -1722,21 +1871,33 @@ func (s *ClientService) AddToGroup(emails []string, group string) (int, error) {
 	}
 
 	tx := db.Begin()
-	if err := tx.Model(&model.ClientRecord{}).
-		Where("email IN ?", affectedEmails).
-		UpdateColumn("group_name", group).Error; err != nil {
-		tx.Rollback()
-		return 0, err
+	for _, batch := range chunkStrings(affectedEmails, sqlInChunk) {
+		if err := tx.Model(&model.ClientRecord{}).
+			Where("email IN ?", batch).
+			UpdateColumn("group_name", group).Error; err != nil {
+			tx.Rollback()
+			return 0, err
+		}
 	}
 
 	var inboundIDs []int
-	if err := tx.Table("client_inbounds").
-		Joins("JOIN clients ON clients.id = client_inbounds.client_id").
-		Where("clients.email IN ?", affectedEmails).
-		Distinct("client_inbounds.inbound_id").
-		Pluck("inbound_id", &inboundIDs).Error; err != nil {
-		tx.Rollback()
-		return 0, err
+	inboundIDSeen := make(map[int]struct{})
+	for _, batch := range chunkStrings(affectedEmails, sqlInChunk) {
+		var ids []int
+		if err := tx.Table("client_inbounds").
+			Joins("JOIN clients ON clients.id = client_inbounds.client_id").
+			Where("clients.email IN ?", batch).
+			Distinct("client_inbounds.inbound_id").
+			Pluck("inbound_id", &ids).Error; err != nil {
+			tx.Rollback()
+			return 0, err
+		}
+		for _, id := range ids {
+			if _, ok := inboundIDSeen[id]; !ok {
+				inboundIDSeen[id] = struct{}{}
+				inboundIDs = append(inboundIDs, id)
+			}
+		}
 	}
 
 	emailSet := make(map[string]struct{}, len(affectedEmails))
@@ -1828,13 +1989,23 @@ func (s *ClientService) replaceGroupValue(oldName, newName string) (int, error)
 	}
 
 	var inboundIDs []int
-	if err := tx.Table("client_inbounds").
-		Joins("JOIN clients ON clients.id = client_inbounds.client_id").
-		Where("clients.email IN ?", affectedEmails).
-		Distinct("client_inbounds.inbound_id").
-		Pluck("inbound_id", &inboundIDs).Error; err != nil {
-		tx.Rollback()
-		return 0, err
+	inboundIDSeen := make(map[int]struct{})
+	for _, batch := range chunkStrings(affectedEmails, sqlInChunk) {
+		var ids []int
+		if err := tx.Table("client_inbounds").
+			Joins("JOIN clients ON clients.id = client_inbounds.client_id").
+			Where("clients.email IN ?", batch).
+			Distinct("client_inbounds.inbound_id").
+			Pluck("inbound_id", &ids).Error; err != nil {
+			tx.Rollback()
+			return 0, err
+		}
+		for _, id := range ids {
+			if _, ok := inboundIDSeen[id]; !ok {
+				inboundIDSeen[id] = struct{}{}
+				inboundIDs = append(inboundIDs, id)
+			}
+		}
 	}
 
 	for _, ibID := range inboundIDs {
@@ -2304,8 +2475,12 @@ func (s *ClientService) BulkAdjust(inboundSvc *InboundService, emails []string,
 	db := database.GetDB()
 
 	var records []model.ClientRecord
-	if err := db.Where("email IN ?", cleanEmails).Find(&records).Error; err != nil {
-		return result, false, err
+	for _, batch := range chunkStrings(cleanEmails, sqlInChunk) {
+		var rows []model.ClientRecord
+		if err := db.Where("email IN ?", batch).Find(&rows).Error; err != nil {
+			return result, false, err
+		}
+		records = append(records, rows...)
 	}
 	recordsByEmail := make(map[string]*model.ClientRecord, len(records))
 	for i := range records {
@@ -2381,8 +2556,12 @@ func (s *ClientService) BulkAdjust(inboundSvc *InboundService, emails []string,
 	}
 
 	var mappings []model.ClientInbound
-	if err := db.Where("client_id IN ?", plannedIds).Find(&mappings).Error; err != nil {
-		return result, false, err
+	for _, batch := range chunkInts(plannedIds, sqlInChunk) {
+		var rows []model.ClientInbound
+		if err := db.Where("client_id IN ?", batch).Find(&rows).Error; err != nil {
+			return result, false, err
+		}
+		mappings = append(mappings, rows...)
 	}
 	emailsByInbound := map[int][]string{}
 	for _, m := range mappings {
@@ -2570,20 +2749,22 @@ func (s *ClientService) bulkAdjustInboundClients(
 	}
 
 	db := database.GetDB()
-	if err := db.Save(oldInbound).Error; err != nil {
+	txErr := db.Transaction(func(tx *gorm.DB) error {
+		if err := tx.Save(oldInbound).Error; err != nil {
+			return err
+		}
+		finalClients, gcErr := inboundSvc.GetClients(oldInbound)
+		if gcErr != nil {
+			return gcErr
+		}
+		return s.SyncInbound(tx, inboundId, finalClients)
+	})
+	if txErr != nil {
 		for email := range foundEmails {
 			if _, skip := res.perEmailSkipped[email]; !skip {
-				res.perEmailSkipped[email] = err.Error()
+				res.perEmailSkipped[email] = txErr.Error()
 			}
 		}
-		return res
-	}
-
-	finalClients, gcErr := inboundSvc.GetClients(oldInbound)
-	if gcErr == nil {
-		if syncErr := s.SyncInbound(db, inboundId, finalClients); syncErr != nil {
-			logger.Warning("bulkAdjust SyncInbound:", syncErr)
-		}
 	}
 
 	return res
@@ -2601,6 +2782,8 @@ type BulkDeleteReport struct {
 	Reason string `json:"reason"`
 }
 
+const sqlInChunk = 400
+
 // BulkDelete removes every client in the list in one optimized pass.
 // Instead of running the full single-delete pipeline N times (which would
 // re-read, re-parse, and re-write each inbound's settings JSON for every
@@ -2631,14 +2814,20 @@ func (s *ClientService) BulkDelete(inboundSvc *InboundService, emails []string,
 	db := database.GetDB()
 
 	var records []model.ClientRecord
-	if err := db.Where("email IN ?", cleanEmails).Find(&records).Error; err != nil {
-		return result, false, err
+	for _, batch := range chunkStrings(cleanEmails, sqlInChunk) {
+		var rows []model.ClientRecord
+		if err := db.Where("email IN ?", batch).Find(&rows).Error; err != nil {
+			return result, false, err
+		}
+		records = append(records, rows...)
 	}
 	recordsByEmail := make(map[string]*model.ClientRecord, len(records))
+	tombstoneEmails := make([]string, 0, len(records))
 	for i := range records {
 		recordsByEmail[records[i].Email] = &records[i]
-		tombstoneClientEmail(records[i].Email)
+		tombstoneEmails = append(tombstoneEmails, records[i].Email)
 	}
+	tombstoneClientEmails(tombstoneEmails)
 
 	skippedReasons := map[string]string{}
 	for _, email := range cleanEmails {
@@ -2657,8 +2846,12 @@ func (s *ClientService) BulkDelete(inboundSvc *InboundService, emails []string,
 	emailsByInbound := map[int][]string{}
 	if len(clientIds) > 0 {
 		var mappings []model.ClientInbound
-		if err := db.Where("client_id IN ?", clientIds).Find(&mappings).Error; err != nil {
-			return result, false, err
+		for _, batch := range chunkInts(clientIds, sqlInChunk) {
+			var rows []model.ClientInbound
+			if err := db.Where("client_id IN ?", batch).Find(&rows).Error; err != nil {
+				return result, false, err
+			}
+			mappings = append(mappings, rows...)
 		}
 		for _, m := range mappings {
 			email, ok := recordIdToEmail[m.ClientId]
@@ -2693,20 +2886,26 @@ func (s *ClientService) BulkDelete(inboundSvc *InboundService, emails []string,
 	}
 
 	if len(successIds) > 0 {
-		if err := db.Where("client_id IN ?", successIds).Delete(&model.ClientInbound{}).Error; err != nil {
-			return result, needRestart, err
+		for _, batch := range chunkInts(successIds, sqlInChunk) {
+			if err := db.Where("client_id IN ?", batch).Delete(&model.ClientInbound{}).Error; err != nil {
+				return result, needRestart, err
+			}
 		}
 		if !keepTraffic && len(successEmails) > 0 {
-			if err := db.Where("email IN ?", successEmails).Delete(&xray.ClientTraffic{}).Error; err != nil {
-				return result, needRestart, err
+			for _, batch := range chunkStrings(successEmails, sqlInChunk) {
+				if err := db.Where("email IN ?", batch).Delete(&xray.ClientTraffic{}).Error; err != nil {
+					return result, needRestart, err
+				}
+				if err := db.Where("client_email IN ?", batch).Delete(&model.InboundClientIps{}).Error; err != nil {
+					return result, needRestart, err
+				}
 			}
-			if err := db.Where("client_email IN ?", successEmails).Delete(&model.InboundClientIps{}).Error; err != nil {
+		}
+		for _, batch := range chunkInts(successIds, sqlInChunk) {
+			if err := db.Where("id IN ?", batch).Delete(&model.ClientRecord{}).Error; err != nil {
 				return result, needRestart, err
 			}
 		}
-		if err := db.Where("id IN ?", successIds).Delete(&model.ClientRecord{}).Error; err != nil {
-			return result, needRestart, err
-		}
 	}
 
 	result.Deleted = len(successEmails)
@@ -2835,38 +3034,52 @@ func (s *ClientService) bulkDelInboundClients(
 			Email  string
 			Enable bool
 		}
-		var rows []trafficRow
-		if err := db.Model(xray.ClientTraffic{}).
-			Where("email IN ?", foundList).
-			Select("email, enable").
-			Scan(&rows).Error; err == nil {
-			for _, r := range rows {
-				notDepletedByEmail[r.Email] = r.Enable
+		for _, batch := range chunkStrings(foundList, sqlInChunk) {
+			var rows []trafficRow
+			if err := db.Model(xray.ClientTraffic{}).
+				Where("email IN ?", batch).
+				Select("email, enable").
+				Scan(&rows).Error; err == nil {
+				for _, r := range rows {
+					notDepletedByEmail[r.Email] = r.Enable
+				}
 			}
 		}
 	}
 
-	for email := range foundEmails {
-		shared, sharedErr := inboundSvc.emailUsedByOtherInbounds(email, inboundId)
+	var sharedSet map[string]bool
+	if !keepTraffic {
+		var sharedErr error
+		sharedSet, sharedErr = inboundSvc.emailsUsedByOtherInbounds(foundList, inboundId)
 		if sharedErr != nil {
-			res.perEmailSkipped[email] = sharedErr.Error()
-			delete(foundEmails, email)
-			continue
-		}
-		if shared || keepTraffic {
-			continue
+			for email := range foundEmails {
+				res.perEmailSkipped[email] = sharedErr.Error()
+				delete(foundEmails, email)
+			}
+			return res
 		}
-		if delErr := inboundSvc.DelClientIPs(db, email); delErr != nil {
-			logger.Error("Error in delete client IPs")
-			res.perEmailSkipped[email] = delErr.Error()
-			delete(foundEmails, email)
-			continue
+	}
+	if !keepTraffic {
+		purge := make([]string, 0, len(foundEmails))
+		for email := range foundEmails {
+			if !sharedSet[strings.ToLower(strings.TrimSpace(email))] {
+				purge = append(purge, email)
+			}
 		}
-		if delErr := inboundSvc.DelClientStat(db, email); delErr != nil {
-			logger.Error("Delete stats Data Error")
-			res.perEmailSkipped[email] = delErr.Error()
-			delete(foundEmails, email)
-			continue
+		if len(purge) > 0 {
+			if delErr := inboundSvc.delClientIPsByEmails(db, purge); delErr != nil {
+				logger.Error("Error in delete client IPs")
+				for _, email := range purge {
+					res.perEmailSkipped[email] = delErr.Error()
+					delete(foundEmails, email)
+				}
+			} else if delErr := inboundSvc.delClientStatsByEmails(db, purge); delErr != nil {
+				logger.Error("Delete stats Data Error")
+				for _, email := range purge {
+					res.perEmailSkipped[email] = delErr.Error()
+					delete(foundEmails, email)
+				}
+			}
 		}
 	}
 
@@ -2907,21 +3120,22 @@ func (s *ClientService) bulkDelInboundClients(
 		}
 	}
 
-	if err := db.Save(oldInbound).Error; err != nil {
+	txErr := db.Transaction(func(tx *gorm.DB) error {
+		if err := tx.Save(oldInbound).Error; err != nil {
+			return err
+		}
+		finalClients, err := inboundSvc.GetClients(oldInbound)
+		if err != nil {
+			return err
+		}
+		return s.SyncInbound(tx, inboundId, finalClients)
+	})
+	if txErr != nil {
 		for email := range foundEmails {
 			if _, skip := res.perEmailSkipped[email]; !skip {
-				res.perEmailSkipped[email] = err.Error()
+				res.perEmailSkipped[email] = txErr.Error()
 			}
 		}
-		return res
-	}
-
-	finalClients, err := inboundSvc.GetClients(oldInbound)
-	if err != nil {
-		return res
-	}
-	if err := s.SyncInbound(db, inboundId, finalClients); err != nil {
-		return res
 	}
 
 	return res
@@ -2938,27 +3152,200 @@ type BulkCreateReport struct {
 	Reason string `json:"reason"`
 }
 
-// BulkCreate iterates payloads sequentially. Each item is the same shape
-// the single-create endpoint accepts, so callers can submit a heterogeneous
-// list (different inboundIds, plans, etc.) in one round-trip.
 func (s *ClientService) BulkCreate(inboundSvc *InboundService, payloads []ClientCreatePayload) (BulkCreateResult, bool, error) {
 	result := BulkCreateResult{}
-	needRestart := false
+	if len(payloads) == 0 {
+		return result, false, nil
+	}
+
+	skip := func(email, reason string) {
+		if strings.TrimSpace(email) == "" {
+			email = "(missing email)"
+		}
+		result.Skipped = append(result.Skipped, BulkCreateReport{Email: email, Reason: reason})
+	}
+
+	emailSubIDs, err := inboundSvc.getAllEmailSubIDs()
+	if err != nil {
+		emailSubIDs = nil
+	}
+
+	type prepared struct {
+		client     model.Client
+		inboundIds []int
+	}
+	prep := make([]prepared, 0, len(payloads))
+	emails := make([]string, 0, len(payloads))
+	subIDs := make([]string, 0, len(payloads))
+	seenEmail := make(map[string]struct{}, len(payloads))
+	seenSubID := make(map[string]string, len(payloads))
+
 	for i := range payloads {
-		p := payloads[i]
-		email := strings.TrimSpace(p.Client.Email)
-		nr, err := s.Create(inboundSvc, &p)
-		if err != nil {
-			if email == "" {
-				email = "(missing email)"
+		client := payloads[i].Client
+		email := strings.TrimSpace(client.Email)
+		if email == "" {
+			skip("", "client email is required")
+			continue
+		}
+		if verr := validateClientEmail(email); verr != nil {
+			skip(email, verr.Error())
+			continue
+		}
+		if verr := validateClientSubID(client.SubID); verr != nil {
+			skip(email, verr.Error())
+			continue
+		}
+		if len(payloads[i].InboundIds) == 0 {
+			skip(email, "at least one inbound is required")
+			continue
+		}
+
+		client.Email = email
+		if client.SubID == "" {
+			client.SubID = uuid.NewString()
+		}
+		if !client.Enable {
+			client.Enable = true
+		}
+		now := time.Now().UnixMilli()
+		if client.CreatedAt == 0 {
+			client.CreatedAt = now
+		}
+		client.UpdatedAt = now
+
+		le := strings.ToLower(email)
+		if _, dup := seenEmail[le]; dup {
+			skip(email, "email already in use: "+email)
+			continue
+		}
+		if owner, ok := seenSubID[client.SubID]; ok && owner != le {
+			skip(email, "subId already in use: "+client.SubID)
+			continue
+		}
+		seenEmail[le] = struct{}{}
+		seenSubID[client.SubID] = le
+
+		prep = append(prep, prepared{client: client, inboundIds: payloads[i].InboundIds})
+		emails = append(emails, email)
+		subIDs = append(subIDs, client.SubID)
+	}
+
+	if len(prep) == 0 {
+		return result, false, nil
+	}
+
+	db := database.GetDB()
+	const lookupChunk = 400
+	existingEmailSub := make(map[string]string, len(emails))
+	for start := 0; start < len(emails); start += lookupChunk {
+		end := min(start+lookupChunk, len(emails))
+		var rows []model.ClientRecord
+		if e := db.Where("email IN ?", emails[start:end]).Find(&rows).Error; e != nil {
+			return result, false, e
+		}
+		for i := range rows {
+			existingEmailSub[strings.ToLower(rows[i].Email)] = rows[i].SubID
+		}
+	}
+	existingSubOwner := make(map[string]string, len(subIDs))
+	for start := 0; start < len(subIDs); start += lookupChunk {
+		end := min(start+lookupChunk, len(subIDs))
+		var rows []model.ClientRecord
+		if e := db.Where("sub_id IN ?", subIDs[start:end]).Find(&rows).Error; e != nil {
+			return result, false, e
+		}
+		for i := range rows {
+			existingSubOwner[rows[i].SubID] = strings.ToLower(rows[i].Email)
+		}
+	}
+
+	inboundCache := make(map[int]*model.Inbound)
+	getIb := func(id int) (*model.Inbound, error) {
+		if ib, ok := inboundCache[id]; ok {
+			return ib, nil
+		}
+		ib, e := inboundSvc.GetInbound(id)
+		if e != nil {
+			return nil, e
+		}
+		inboundCache[id] = ib
+		return ib, nil
+	}
+
+	byInbound := make(map[int][]model.Client)
+	idxByInbound := make(map[int][]int)
+	inboundOrder := make([]int, 0)
+	failed := make([]bool, len(prep))
+	reason := make([]string, len(prep))
+
+	for idx := range prep {
+		le := strings.ToLower(prep[idx].client.Email)
+		if existSub, ok := existingEmailSub[le]; ok && existSub != prep[idx].client.SubID {
+			failed[idx] = true
+			reason[idx] = "email already in use: " + prep[idx].client.Email
+			continue
+		}
+		if owner, ok := existingSubOwner[prep[idx].client.SubID]; ok && owner != le {
+			failed[idx] = true
+			reason[idx] = "subId already in use: " + prep[idx].client.SubID
+			continue
+		}
+
+		ok := true
+		for _, ibId := range prep[idx].inboundIds {
+			ib, e := getIb(ibId)
+			if e != nil {
+				failed[idx] = true
+				reason[idx] = e.Error()
+				ok = false
+				break
 			}
-			result.Skipped = append(result.Skipped, BulkCreateReport{Email: email, Reason: err.Error()})
+			if e := s.fillProtocolDefaults(&prep[idx].client, ib); e != nil {
+				failed[idx] = true
+				reason[idx] = e.Error()
+				ok = false
+				break
+			}
+		}
+		if !ok {
 			continue
 		}
-		if nr {
-			needRestart = true
+		for _, ibId := range prep[idx].inboundIds {
+			ib, _ := getIb(ibId)
+			if _, seen := byInbound[ibId]; !seen {
+				inboundOrder = append(inboundOrder, ibId)
+			}
+			byInbound[ibId] = append(byInbound[ibId], clientWithInboundFlow(prep[idx].client, ib))
+			idxByInbound[ibId] = append(idxByInbound[ibId], idx)
+		}
+	}
+
+	needRestart := false
+	for _, ibId := range inboundOrder {
+		payload, e := json.Marshal(map[string][]model.Client{"clients": byInbound[ibId]})
+		if e == nil {
+			var nr bool
+			nr, e = s.addInboundClient(inboundSvc, &model.Inbound{Id: ibId, Settings: string(payload)}, emailSubIDs)
+			if e == nil && nr {
+				needRestart = true
+			}
+		}
+		if e != nil {
+			for _, idx := range idxByInbound[ibId] {
+				failed[idx] = true
+				if reason[idx] == "" {
+					reason[idx] = e.Error()
+				}
+			}
+		}
+	}
+
+	for idx := range prep {
+		if failed[idx] {
+			skip(prep[idx].client.Email, reason[idx])
+		} else {
+			result.Created++
 		}
-		result.Created++
 	}
 	return result, needRestart, nil
 }
@@ -2976,33 +3363,27 @@ func (s *ClientService) DelDepleted(inboundSvc *InboundService) (int, bool, erro
 		return 0, false, nil
 	}
 
-	emails := make(map[string]struct{}, len(rows))
+	seen := make(map[string]struct{}, len(rows))
+	emails := make([]string, 0, len(rows))
 	for _, r := range rows {
-		if r.Email != "" {
-			emails[r.Email] = struct{}{}
+		if r.Email == "" {
+			continue
+		}
+		if _, ok := seen[r.Email]; ok {
+			continue
 		}
+		seen[r.Email] = struct{}{}
+		emails = append(emails, r.Email)
+	}
+	if len(emails) == 0 {
+		return 0, false, nil
 	}
 
-	needRestart := false
-	deleted := 0
-	for email := range emails {
-		var rec model.ClientRecord
-		if err := db.Where("email = ?", email).First(&rec).Error; err != nil {
-			if errors.Is(err, gorm.ErrRecordNotFound) {
-				continue
-			}
-			return deleted, needRestart, err
-		}
-		nr, err := s.Delete(inboundSvc, rec.Id, false)
-		if err != nil {
-			return deleted, needRestart, err
-		}
-		if nr {
-			needRestart = true
-		}
-		deleted++
+	res, needRestart, err := s.BulkDelete(inboundSvc, emails, false)
+	if err != nil {
+		return res.Deleted, needRestart, err
 	}
-	return deleted, needRestart, nil
+	return res.Deleted, needRestart, nil
 }
 
 func (s *ClientService) ResetAllClientTraffics(inboundSvc *InboundService, id int) error {

+ 152 - 21
web/service/inbound.go

@@ -83,8 +83,17 @@ func (s *InboundService) enrichClientStats(db *gorm.DB, inbounds []*model.Inboun
 			emails = append(emails, e)
 		}
 		var extra []xray.ClientTraffic
-		if err := db.Model(xray.ClientTraffic{}).Where("email IN ?", emails).Find(&extra).Error; err != nil {
-			logger.Warning("enrichClientStats:", err)
+		var loadErr error
+		for _, batch := range chunkStrings(emails, sqlInChunk) {
+			var page []xray.ClientTraffic
+			if err := db.Model(xray.ClientTraffic{}).Where("email IN ?", batch).Find(&page).Error; err != nil {
+				loadErr = err
+				break
+			}
+			extra = append(extra, page...)
+		}
+		if loadErr != nil {
+			logger.Warning("enrichClientStats:", loadErr)
 		} else {
 			byEmail := make(map[string]xray.ClientTraffic, len(extra))
 			for _, st := range extra {
@@ -438,6 +447,37 @@ func (s *InboundService) emailUsedByOtherInbounds(email string, exceptInboundId
 	return count > 0, nil
 }
 
+func (s *InboundService) emailsUsedByOtherInbounds(emails []string, exceptInboundId int) (map[string]bool, error) {
+	shared := make(map[string]bool, len(emails))
+	want := make(map[string]struct{}, len(emails))
+	for _, e := range emails {
+		e = strings.ToLower(strings.TrimSpace(e))
+		if e != "" {
+			want[e] = struct{}{}
+		}
+	}
+	if len(want) == 0 {
+		return shared, nil
+	}
+	db := database.GetDB()
+	var rows []string
+	query := fmt.Sprintf(
+		"SELECT DISTINCT LOWER(%s) %s WHERE inbounds.id != ?",
+		database.JSONFieldText("client.value", "email"),
+		database.JSONClientsFromInbound(),
+	)
+	if err := db.Raw(query, exceptInboundId).Scan(&rows).Error; err != nil {
+		return nil, err
+	}
+	for _, e := range rows {
+		e = strings.ToLower(strings.TrimSpace(e))
+		if _, ok := want[e]; ok {
+			shared[e] = true
+		}
+	}
+	return shared, nil
+}
+
 // normalizeStreamSettings clears StreamSettings for protocols that don't use it.
 // Only vmess, vless, trojan, shadowsocks, and hysteria protocols use streamSettings.
 func (s *InboundService) normalizeStreamSettings(inbound *model.Inbound) {
@@ -1575,12 +1615,19 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
 				structuralChange = true
 			}
 
+			// Only allow the node to disable a client (cs.Enable=false), never
+			// to re-enable one the panel has already disabled. A stale snapshot
+			// from the node arriving after a central disable would otherwise
+			// overwrite enable=false back to true, letting the client accumulate
+			// far more traffic than their limit before being disabled again.
+			enableExpr := "CASE WHEN ? = 0 THEN 0 ELSE enable END"
 			if err := tx.Exec(
 				fmt.Sprintf(
 					`UPDATE client_traffics
-					 SET up = up + ?, down = down + ?, enable = ?, total = ?, expiry_time = ?, reset = ?,
+					 SET up = up + ?, down = down + ?, enable = %s, total = ?, expiry_time = ?, reset = ?,
 					     last_online = %s
 					 WHERE email = ?`,
+					enableExpr,
 					database.GreatestExpr("last_online", "?"),
 				),
 				deltaUp, deltaDown, cs.Enable, cs.Total, cs.ExpiryTime, cs.Reset,
@@ -1742,15 +1789,19 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
 }
 
 func (s *InboundService) AddTraffic(inboundTraffics []*xray.Traffic, clientTraffics []*xray.ClientTraffic) (needRestart bool, clientsDisabled bool, err error) {
+	var disabledNodeIDs []int
 	err = submitTrafficWrite(func() error {
 		var inner error
-		needRestart, clientsDisabled, inner = s.addTrafficLocked(inboundTraffics, clientTraffics)
+		needRestart, clientsDisabled, disabledNodeIDs, inner = s.addTrafficLocked(inboundTraffics, clientTraffics)
 		return inner
 	})
+	if err == nil && len(disabledNodeIDs) > 0 {
+		s.restartRemoteNodesOnDisable(disabledNodeIDs)
+	}
 	return
 }
 
-func (s *InboundService) addTrafficLocked(inboundTraffics []*xray.Traffic, clientTraffics []*xray.ClientTraffic) (bool, bool, error) {
+func (s *InboundService) addTrafficLocked(inboundTraffics []*xray.Traffic, clientTraffics []*xray.ClientTraffic) (bool, bool, []int, error) {
 	var err error
 	db := database.GetDB()
 	tx := db.Begin()
@@ -1764,11 +1815,11 @@ func (s *InboundService) addTrafficLocked(inboundTraffics []*xray.Traffic, clien
 	}()
 	err = s.addInboundTraffic(tx, inboundTraffics)
 	if err != nil {
-		return false, false, err
+		return false, false, nil, err
 	}
 	err = s.addClientTraffic(tx, clientTraffics)
 	if err != nil {
-		return false, false, err
+		return false, false, nil, err
 	}
 
 	needRestart0, count, err := s.autoRenewClients(tx)
@@ -1779,7 +1830,7 @@ func (s *InboundService) addTrafficLocked(inboundTraffics []*xray.Traffic, clien
 	}
 
 	disabledClientsCount := int64(0)
-	needRestart1, count, err := s.disableInvalidClients(tx)
+	needRestart1, count, disabledNodeIDs, err := s.disableInvalidClients(tx)
 	if err != nil {
 		logger.Warning("Error in disabling invalid clients:", err)
 	} else if count > 0 {
@@ -1793,7 +1844,7 @@ func (s *InboundService) addTrafficLocked(inboundTraffics []*xray.Traffic, clien
 	} else if count > 0 {
 		logger.Debugf("%v inbounds disabled", count)
 	}
-	return needRestart0 || needRestart1 || needRestart2, disabledClientsCount > 0, nil
+	return needRestart0 || needRestart1 || needRestart2, disabledClientsCount > 0, disabledNodeIDs, nil
 }
 
 func (s *InboundService) addInboundTraffic(tx *gorm.DB, traffics []*xray.Traffic) error {
@@ -2149,7 +2200,7 @@ func (s *InboundService) disableInvalidInbounds(tx *gorm.DB) (bool, int64, error
 	return needRestart, count, err
 }
 
-func (s *InboundService) disableInvalidClients(tx *gorm.DB) (bool, int64, error) {
+func (s *InboundService) disableInvalidClients(tx *gorm.DB) (bool, int64, []int, error) {
 	now := time.Now().Unix() * 1000
 	needRestart := false
 
@@ -2158,10 +2209,10 @@ func (s *InboundService) disableInvalidClients(tx *gorm.DB) (bool, int64, error)
 		Where("((total > 0 AND up + down >= total) OR (expiry_time > 0 AND expiry_time <= ?)) AND enable = ?", now, true).
 		Find(&depletedRows).Error
 	if err != nil {
-		return false, 0, err
+		return false, 0, nil, err
 	}
 	if len(depletedRows) == 0 {
-		return false, 0, nil
+		return false, 0, nil, nil
 	}
 
 	depletedEmails := make([]string, 0, len(depletedRows))
@@ -2189,7 +2240,7 @@ func (s *InboundService) disableInvalidClients(tx *gorm.DB) (bool, int64, error)
 			WHERE clients.email IN ?
 		`, depletedEmails).Scan(&targets).Error
 		if err != nil {
-			return false, 0, err
+			return false, 0, nil, err
 		}
 	}
 
@@ -2236,7 +2287,7 @@ func (s *InboundService) disableInvalidClients(tx *gorm.DB) (bool, int64, error)
 	err = result.Error
 	count := result.RowsAffected
 	if err != nil {
-		return needRestart, count, err
+		return needRestart, count, nil, err
 	}
 
 	if len(depletedEmails) > 0 {
@@ -2247,6 +2298,7 @@ func (s *InboundService) disableInvalidClients(tx *gorm.DB) (bool, int64, error)
 		}
 	}
 
+	disabledNodeIDs := make(map[int]struct{})
 	for inboundID, group := range remoteByInbound {
 		emails := make(map[string]struct{}, len(group))
 		for _, t := range group {
@@ -2255,10 +2307,43 @@ func (s *InboundService) disableInvalidClients(tx *gorm.DB) (bool, int64, error)
 		if pushErr := s.disableRemoteClients(tx, inboundID, emails); pushErr != nil {
 			logger.Warning("disableInvalidClients: push to remote failed for inbound", inboundID, ":", pushErr)
 			needRestart = true
+		} else {
+			for _, t := range group {
+				if t.NodeID != nil {
+					disabledNodeIDs[*t.NodeID] = struct{}{}
+				}
+			}
 		}
 	}
 
-	return needRestart, count, nil
+	nodeIDs := make([]int, 0, len(disabledNodeIDs))
+	for nodeID := range disabledNodeIDs {
+		nodeIDs = append(nodeIDs, nodeID)
+	}
+
+	return needRestart, count, nodeIDs, nil
+}
+
+func (s *InboundService) restartRemoteNodesOnDisable(nodeIDs []int) {
+	restartOnDisable, err := (&SettingService{}).GetRestartXrayOnClientDisable()
+	if err != nil {
+		logger.Warning("disableInvalidClients: get RestartXrayOnClientDisable failed:", err)
+		return
+	}
+	if !restartOnDisable {
+		return
+	}
+	for _, nodeID := range nodeIDs {
+		nodeIDCopy := nodeID
+		rt, rtErr := runtime.GetManager().RuntimeFor(&nodeIDCopy)
+		if rtErr != nil {
+			logger.Warning("disableInvalidClients: get runtime for node", nodeID, "failed:", rtErr)
+			continue
+		}
+		if rtErr = rt.RestartXray(context.Background()); rtErr != nil {
+			logger.Warning("disableInvalidClients: restart xray on node", nodeID, "failed:", rtErr)
+		}
+	}
 }
 
 // markClientsDisabledInSettings flips client.enable=false in the inbound's
@@ -2438,6 +2523,32 @@ func (s *InboundService) DelClientIPs(tx *gorm.DB, email string) error {
 	return tx.Where("client_email = ?", email).Delete(model.InboundClientIps{}).Error
 }
 
+func (s *InboundService) delClientStatsByEmails(tx *gorm.DB, emails []string) error {
+	const chunk = 400
+	for start := 0; start < len(emails); start += chunk {
+		end := min(start+chunk, len(emails))
+		batch := emails[start:end]
+		if err := tx.Where("email IN ?", batch).Delete(xray.ClientTraffic{}).Error; err != nil {
+			return err
+		}
+		if err := tx.Where("email IN ?", batch).Delete(&model.NodeClientTraffic{}).Error; err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+func (s *InboundService) delClientIPsByEmails(tx *gorm.DB, emails []string) error {
+	const chunk = 400
+	for start := 0; start < len(emails); start += chunk {
+		end := min(start+chunk, len(emails))
+		if err := tx.Where("client_email IN ?", emails[start:end]).Delete(model.InboundClientIps{}).Error; err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
 func (s *InboundService) GetClientInboundByTrafficID(trafficId int) (traffic *xray.ClientTraffic, inbound *model.Inbound, err error) {
 	db := database.GetDB()
 	var traffics []*xray.ClientTraffic
@@ -2991,17 +3102,34 @@ func (s *InboundService) GetInboundsTrafficSummary() ([]InboundTrafficSummary, e
 }
 
 func (s *InboundService) GetClientTrafficByEmail(email string) (traffic *xray.ClientTraffic, err error) {
-	// Prefer retrieving along with client to reflect actual enabled state from inbound settings
-	t, client, err := s.GetClientByEmail(email)
-	if err != nil {
+	db := database.GetDB()
+	var traffics []*xray.ClientTraffic
+	if err := db.Model(xray.ClientTraffic{}).Where("email = ?", email).Find(&traffics).Error; err != nil {
 		logger.Warningf("Error retrieving ClientTraffic with email %s: %v", email, err)
 		return nil, err
 	}
-	if t != nil && client != nil {
-		t.UUID = client.ID
-		t.SubId = client.SubID
+	if len(traffics) == 0 {
+		return nil, nil
+	}
+	t := traffics[0]
+
+	if rec, rErr := s.clientService.GetRecordByEmail(db, email); rErr == nil && rec != nil {
+		c := rec.ToClient()
+		t.UUID = c.ID
+		t.SubId = c.SubID
 		return t, nil
 	}
+
+	t2, client, err := s.GetClientByEmail(email)
+	if err != nil {
+		logger.Warningf("Error retrieving ClientTraffic with email %s: %v", email, err)
+		return nil, err
+	}
+	if t2 != nil && client != nil {
+		t2.UUID = client.ID
+		t2.SubId = client.SubID
+		return t2, nil
+	}
 	return nil, nil
 }
 
@@ -3329,6 +3457,9 @@ func (s *InboundService) MigrateDB() {
 }
 
 func (s *InboundService) GetOnlineClients() []string {
+	if p == nil {
+		return []string{}
+	}
 	return p.GetOnlineClients()
 }
 

+ 11 - 6
web/service/setting.go

@@ -79,10 +79,11 @@ var defaultValueMap = map[string]string{
 	"subClashEnable":              "false",
 	"subClashPath":                "/clash/",
 	"subClashURI":                 "",
-	"subJsonFragment":             "",
-	"subJsonNoises":               "",
+	"subClashEnableRouting":       "false",
+	"subClashRules":               "",
 	"subJsonMux":                  "",
 	"subJsonRules":                "",
+	"subJsonFinalMask":            "",
 	"datepicker":                  "gregorian",
 	"warp":                        "",
 	"nord":                        "",
@@ -658,12 +659,12 @@ func (s *SettingService) GetSubClashURI() (string, error) {
 	return s.getString("subClashURI")
 }
 
-func (s *SettingService) GetSubJsonFragment() (string, error) {
-	return s.getString("subJsonFragment")
+func (s *SettingService) GetSubClashEnableRouting() (bool, error) {
+	return s.getBool("subClashEnableRouting")
 }
 
-func (s *SettingService) GetSubJsonNoises() (string, error) {
-	return s.getString("subJsonNoises")
+func (s *SettingService) GetSubClashRules() (string, error) {
+	return s.getString("subClashRules")
 }
 
 func (s *SettingService) GetSubJsonMux() (string, error) {
@@ -674,6 +675,10 @@ func (s *SettingService) GetSubJsonRules() (string, error) {
 	return s.getString("subJsonRules")
 }
 
+func (s *SettingService) GetSubJsonFinalMask() (string, error) {
+	return s.getString("subJsonFinalMask")
+}
+
 func (s *SettingService) GetDatepicker() (string, error) {
 	return s.getString("datepicker")
 }

+ 431 - 0
web/service/sync_scale_postgres_test.go

@@ -0,0 +1,431 @@
+package service
+
+import (
+	"errors"
+	"fmt"
+	"os"
+	"strings"
+	"testing"
+	"time"
+
+	"github.com/google/uuid"
+	"github.com/mhsanaei/3x-ui/v3/database"
+	"github.com/mhsanaei/3x-ui/v3/database/model"
+
+	"gorm.io/gorm"
+)
+
+func syncInboundOld(tx *gorm.DB, inboundId int, clients []model.Client) error {
+	if tx == nil {
+		tx = database.GetDB()
+	}
+	if err := tx.Where("inbound_id = ?", inboundId).Delete(&model.ClientInbound{}).Error; err != nil {
+		return err
+	}
+	for i := range clients {
+		c := clients[i]
+		email := strings.TrimSpace(c.Email)
+		if email == "" {
+			continue
+		}
+		incoming := c.ToRecord()
+		row := &model.ClientRecord{}
+		err := tx.Where("email = ?", email).First(row).Error
+		if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
+			return err
+		}
+		if errors.Is(err, gorm.ErrRecordNotFound) {
+			if err := tx.Create(incoming).Error; err != nil {
+				return err
+			}
+			row = incoming
+		} else {
+			row.Flow = incoming.Flow
+			row.SubID = incoming.SubID
+			row.LimitIP = incoming.LimitIP
+			row.TotalGB = incoming.TotalGB
+			row.ExpiryTime = incoming.ExpiryTime
+			row.Enable = incoming.Enable
+			row.TgID = incoming.TgID
+			row.Comment = incoming.Comment
+			row.Reset = incoming.Reset
+			preservedUpdatedAt := max(incoming.UpdatedAt, row.UpdatedAt)
+			row.UpdatedAt = preservedUpdatedAt
+			if err := tx.Save(row).Error; err != nil {
+				return err
+			}
+			if err := tx.Model(&model.ClientRecord{}).
+				Where("id = ?", row.Id).
+				UpdateColumn("updated_at", preservedUpdatedAt).Error; err != nil {
+				return err
+			}
+		}
+		link := model.ClientInbound{ClientId: row.Id, InboundId: inboundId, FlowOverride: c.Flow}
+		if err := tx.Create(&link).Error; err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+func makeScaleClients(n int) []model.Client {
+	out := make([]model.Client, n)
+	for i := 0; i < n; i++ {
+		out[i] = model.Client{
+			ID:     uuid.NewString(),
+			Email:  fmt.Sprintf("user-%07d@scale", i),
+			SubID:  fmt.Sprintf("sub-%07d", i),
+			Enable: true,
+		}
+	}
+	return out
+}
+
+func TestSyncInboundPostgresScale(t *testing.T) {
+	if strings.TrimSpace(os.Getenv("XUI_DB_DSN")) == "" || os.Getenv("XUI_DB_TYPE") != "postgres" {
+		t.Skip("set XUI_DB_TYPE=postgres and XUI_DB_DSN to run the postgres scale benchmark")
+	}
+	if err := database.InitDB(""); err != nil {
+		t.Fatalf("InitDB: %v", err)
+	}
+	t.Cleanup(func() { _ = database.CloseDB() })
+
+	svc := &ClientService{}
+	sizes := []int{5000, 10000, 20000, 50000, 100000, 200000}
+
+	for _, n := range sizes {
+		t.Run(fmt.Sprintf("N=%d", n), func(t *testing.T) {
+			db := database.GetDB()
+			if err := db.Exec("TRUNCATE TABLE inbounds, clients, client_inbounds RESTART IDENTITY CASCADE").Error; err != nil {
+				t.Fatalf("truncate: %v", err)
+			}
+
+			clients := makeScaleClients(n)
+			ib := &model.Inbound{
+				Tag:      fmt.Sprintf("scale-%d", n),
+				Enable:   true,
+				Port:     40000,
+				Protocol: model.VLESS,
+				Settings: clientsSettings(t, clients),
+			}
+			if err := db.Create(ib).Error; err != nil {
+				t.Fatalf("create inbound: %v", err)
+			}
+
+			start := time.Now()
+			if err := svc.SyncInbound(nil, ib.Id, clients); err != nil {
+				t.Fatalf("seed SyncInbound: %v", err)
+			}
+			seed := time.Since(start)
+
+			clients[n/2].Enable = !clients[n/2].Enable
+			start = time.Now()
+			if err := svc.SyncInbound(nil, ib.Id, clients); err != nil {
+				t.Fatalf("toggle SyncInbound (new): %v", err)
+			}
+			toggleNew := time.Since(start)
+
+			start = time.Now()
+			if err := svc.SyncInbound(nil, ib.Id, clients); err != nil {
+				t.Fatalf("noop SyncInbound (new): %v", err)
+			}
+			noopNew := time.Since(start)
+
+			toggleOld := time.Duration(0)
+			if n <= 10000 {
+				clients[n/2].Enable = !clients[n/2].Enable
+				start = time.Now()
+				if err := syncInboundOld(db, ib.Id, clients); err != nil {
+					t.Fatalf("toggle SyncInbound (old): %v", err)
+				}
+				toggleOld = time.Since(start)
+			}
+
+			var linkCount, recCount int64
+			db.Model(&model.ClientInbound{}).Where("inbound_id = ?", ib.Id).Count(&linkCount)
+			db.Model(&model.ClientRecord{}).Count(&recCount)
+			if int(linkCount) != n || int(recCount) != n {
+				t.Fatalf("row mismatch: links=%d records=%d want %d", linkCount, recCount, n)
+			}
+
+			oldStr, speedup := "skipped", ""
+			if toggleOld > 0 {
+				oldStr = toggleOld.Round(time.Millisecond).String()
+				speedup = fmt.Sprintf("  speedup=%.0fx", float64(toggleOld)/float64(maxDur(toggleNew, time.Millisecond)))
+			}
+			t.Logf("N=%-7d seed=%-10v toggle_new=%-10v noop_new=%-10v toggle_old=%-10s%s",
+				n, seed.Round(time.Millisecond), toggleNew.Round(time.Millisecond),
+				noopNew.Round(time.Millisecond), oldStr, speedup)
+		})
+	}
+}
+
+func maxDur(d, floor time.Duration) time.Duration {
+	if d < floor {
+		return floor
+	}
+	return d
+}
+
+func TestAddDelClientPostgresScale(t *testing.T) {
+	if strings.TrimSpace(os.Getenv("XUI_DB_DSN")) == "" || os.Getenv("XUI_DB_TYPE") != "postgres" {
+		t.Skip("set XUI_DB_TYPE=postgres and XUI_DB_DSN to run the postgres scale benchmark")
+	}
+	if err := database.InitDB(""); err != nil {
+		t.Fatalf("InitDB: %v", err)
+	}
+	t.Cleanup(func() { _ = database.CloseDB() })
+
+	svc := &ClientService{}
+	inboundSvc := &InboundService{}
+	sizes := []int{5000, 20000, 50000, 100000, 200000}
+
+	for _, n := range sizes {
+		t.Run(fmt.Sprintf("N=%d", n), func(t *testing.T) {
+			db := database.GetDB()
+			if err := db.Exec("TRUNCATE TABLE inbounds, clients, client_inbounds, client_traffics RESTART IDENTITY CASCADE").Error; err != nil {
+				t.Fatalf("truncate: %v", err)
+			}
+
+			clients := makeScaleClients(n)
+			ib := &model.Inbound{
+				Tag:      fmt.Sprintf("adddel-%d", n),
+				Enable:   true,
+				Port:     40000,
+				Protocol: model.VLESS,
+				Settings: clientsSettings(t, clients),
+			}
+			if err := db.Create(ib).Error; err != nil {
+				t.Fatalf("create inbound: %v", err)
+			}
+			if err := svc.SyncInbound(nil, ib.Id, clients); err != nil {
+				t.Fatalf("seed SyncInbound: %v", err)
+			}
+
+			newC := model.Client{
+				ID:     uuid.NewString(),
+				Email:  "added-client@scale",
+				SubID:  "added-sub",
+				Enable: true,
+			}
+			addData := &model.Inbound{Id: ib.Id, Protocol: model.VLESS, Settings: clientsSettings(t, []model.Client{newC})}
+			start := time.Now()
+			if _, err := svc.AddInboundClient(inboundSvc, addData); err != nil {
+				t.Fatalf("AddInboundClient: %v", err)
+			}
+			addDur := time.Since(start)
+
+			delId := clients[n/2].ID
+			start = time.Now()
+			if _, err := svc.DelInboundClient(inboundSvc, ib.Id, delId, false); err != nil {
+				t.Fatalf("DelInboundClient: %v", err)
+			}
+			delDur := time.Since(start)
+
+			var recCount, linkCount int64
+			db.Model(&model.ClientRecord{}).Count(&recCount)
+			db.Model(&model.ClientInbound{}).Where("inbound_id = ?", ib.Id).Count(&linkCount)
+
+			t.Logf("N=%-7d add=%-10v del=%-10v records=%d links=%d", n,
+				addDur.Round(time.Millisecond), delDur.Round(time.Millisecond), recCount, linkCount)
+		})
+	}
+}
+
+func TestGroupAndListPostgresScale(t *testing.T) {
+	if strings.TrimSpace(os.Getenv("XUI_DB_DSN")) == "" || os.Getenv("XUI_DB_TYPE") != "postgres" {
+		t.Skip("set XUI_DB_TYPE=postgres and XUI_DB_DSN to run the postgres scale benchmark")
+	}
+	if err := database.InitDB(""); err != nil {
+		t.Fatalf("InitDB: %v", err)
+	}
+	t.Cleanup(func() { _ = database.CloseDB() })
+
+	svc := &ClientService{}
+	sizes := []int{5000, 100000}
+
+	for _, n := range sizes {
+		t.Run(fmt.Sprintf("N=%d", n), func(t *testing.T) {
+			db := database.GetDB()
+			if err := db.Exec("TRUNCATE TABLE inbounds, clients, client_inbounds, client_traffics RESTART IDENTITY CASCADE").Error; err != nil {
+				t.Fatalf("truncate: %v", err)
+			}
+			clients := makeScaleClients(n)
+			ib := &model.Inbound{Tag: fmt.Sprintf("grp-%d", n), Enable: true, Port: 40000, Protocol: model.VLESS, Settings: clientsSettings(t, clients)}
+			if err := db.Create(ib).Error; err != nil {
+				t.Fatalf("create inbound: %v", err)
+			}
+			if err := svc.SyncInbound(nil, ib.Id, clients); err != nil {
+				t.Fatalf("seed SyncInbound: %v", err)
+			}
+			db.Exec("ANALYZE")
+			emails := make([]string, n)
+			for i := 0; i < n; i++ {
+				emails[i] = clients[i].Email
+			}
+
+			start := time.Now()
+			if _, err := svc.AddToGroup(emails, "benchgroup"); err != nil {
+				t.Fatalf("AddToGroup: %v", err)
+			}
+			addDur := time.Since(start)
+
+			start = time.Now()
+			if _, err := svc.RemoveFromGroup(emails); err != nil {
+				t.Fatalf("RemoveFromGroup: %v", err)
+			}
+			rmDur := time.Since(start)
+
+			start = time.Now()
+			list, err := svc.List()
+			if err != nil {
+				t.Fatalf("List: %v", err)
+			}
+			listDur := time.Since(start)
+			if len(list) != n {
+				t.Fatalf("List returned %d, want %d", len(list), n)
+			}
+
+			t.Logf("N=%-7d bulkAdd=%-9v bulkRemove=%-9v list=%-9v", n,
+				addDur.Round(time.Millisecond), rmDur.Round(time.Millisecond), listDur.Round(time.Millisecond))
+		})
+	}
+}
+
+func TestDelAllClientsPostgresScale(t *testing.T) {
+	if strings.TrimSpace(os.Getenv("XUI_DB_DSN")) == "" || os.Getenv("XUI_DB_TYPE") != "postgres" {
+		t.Skip("set XUI_DB_TYPE=postgres and XUI_DB_DSN to run the postgres scale benchmark")
+	}
+	if err := database.InitDB(""); err != nil {
+		t.Fatalf("InitDB: %v", err)
+	}
+	t.Cleanup(func() { _ = database.CloseDB() })
+
+	svc := &ClientService{}
+	inboundSvc := &InboundService{}
+	sizes := []int{5000, 50000, 100000}
+
+	for _, n := range sizes {
+		t.Run(fmt.Sprintf("N=%d", n), func(t *testing.T) {
+			db := database.GetDB()
+			if err := db.Exec("TRUNCATE TABLE inbounds, clients, client_inbounds, client_traffics RESTART IDENTITY CASCADE").Error; err != nil {
+				t.Fatalf("truncate: %v", err)
+			}
+			clients := makeScaleClients(n)
+			ib := &model.Inbound{Tag: fmt.Sprintf("delall-%d", n), Enable: true, Port: 40000, Protocol: model.VLESS, Settings: clientsSettings(t, clients)}
+			if err := db.Create(ib).Error; err != nil {
+				t.Fatalf("create inbound: %v", err)
+			}
+			if err := svc.SyncInbound(nil, ib.Id, clients); err != nil {
+				t.Fatalf("seed SyncInbound: %v", err)
+			}
+
+			emails, err := inboundSvc.EmailsByInbound(ib.Id)
+			if err != nil {
+				t.Fatalf("EmailsByInbound: %v", err)
+			}
+			start := time.Now()
+			res, _, err := svc.BulkDelete(inboundSvc, emails, false)
+			if err != nil {
+				t.Fatalf("BulkDelete: %v", err)
+			}
+			dur := time.Since(start)
+
+			var recCount, linkCount int64
+			db.Model(&model.ClientRecord{}).Count(&recCount)
+			db.Model(&model.ClientInbound{}).Where("inbound_id = ?", ib.Id).Count(&linkCount)
+			if recCount != 0 || linkCount != 0 {
+				t.Fatalf("after delAll: records=%d links=%d want 0/0", recCount, linkCount)
+			}
+			t.Logf("N=%-7d delAllClients=%-10v deleted=%d", n, dur.Round(time.Millisecond), res.Deleted)
+		})
+	}
+}
+
+func TestBulkOpsPostgresScale(t *testing.T) {
+	if strings.TrimSpace(os.Getenv("XUI_DB_DSN")) == "" || os.Getenv("XUI_DB_TYPE") != "postgres" {
+		t.Skip("set XUI_DB_TYPE=postgres and XUI_DB_DSN to run the postgres scale benchmark")
+	}
+	if err := database.InitDB(""); err != nil {
+		t.Fatalf("InitDB: %v", err)
+	}
+	t.Cleanup(func() { _ = database.CloseDB() })
+
+	svc := &ClientService{}
+	inboundSvc := &InboundService{}
+	sizes := []int{5000, 20000, 50000, 100000}
+	const m = 2000
+
+	for _, n := range sizes {
+		t.Run(fmt.Sprintf("N=%d", n), func(t *testing.T) {
+			db := database.GetDB()
+			if err := db.Exec("TRUNCATE TABLE inbounds, clients, client_inbounds, client_traffics RESTART IDENTITY CASCADE").Error; err != nil {
+				t.Fatalf("truncate: %v", err)
+			}
+
+			clients := makeScaleClients(n)
+			exp := time.Now().AddDate(1, 0, 0).UnixMilli()
+			for i := range clients {
+				clients[i].ExpiryTime = exp
+				clients[i].TotalGB = 100 << 30
+			}
+			ib := &model.Inbound{Tag: fmt.Sprintf("bulk-%d", n), Enable: true, Port: 40000, Protocol: model.VLESS, Settings: clientsSettings(t, clients)}
+			if err := db.Create(ib).Error; err != nil {
+				t.Fatalf("create inbound: %v", err)
+			}
+			if err := svc.SyncInbound(nil, ib.Id, clients); err != nil {
+				t.Fatalf("seed SyncInbound: %v", err)
+			}
+			ib2 := &model.Inbound{Tag: fmt.Sprintf("bulk2-%d", n), Enable: true, Port: 40001, Protocol: model.VLESS, Settings: `{"clients":[]}`}
+			if err := db.Create(ib2).Error; err != nil {
+				t.Fatalf("create inbound2: %v", err)
+			}
+
+			emailsM := make([]string, m)
+			for i := 0; i < m; i++ {
+				emailsM[i] = clients[i].Email
+			}
+
+			t0 := time.Now()
+			if _, _, err := svc.BulkAdjust(inboundSvc, emailsM, 7, 1<<30); err != nil {
+				t.Fatalf("BulkAdjust: %v", err)
+			}
+			adjustDur := time.Since(t0)
+
+			t0 = time.Now()
+			if _, _, err := svc.BulkAttach(inboundSvc, emailsM, []int{ib2.Id}); err != nil {
+				t.Fatalf("BulkAttach: %v", err)
+			}
+			attachDur := time.Since(t0)
+
+			t0 = time.Now()
+			if _, _, err := svc.BulkDetach(inboundSvc, emailsM, []int{ib2.Id}); err != nil {
+				t.Fatalf("BulkDetach: %v", err)
+			}
+			detachDur := time.Since(t0)
+
+			payloads := make([]ClientCreatePayload, m)
+			for i := 0; i < m; i++ {
+				payloads[i] = ClientCreatePayload{
+					Client:     model.Client{ID: uuid.NewString(), Email: fmt.Sprintf("bulknew-%07d@scale", i), SubID: fmt.Sprintf("bnsub-%07d", i), Enable: true},
+					InboundIds: []int{ib.Id},
+				}
+			}
+			t0 = time.Now()
+			if _, _, err := svc.BulkCreate(inboundSvc, payloads); err != nil {
+				t.Fatalf("BulkCreate: %v", err)
+			}
+			createDur := time.Since(t0)
+
+			t0 = time.Now()
+			if _, _, err := svc.BulkDelete(inboundSvc, emailsM, false); err != nil {
+				t.Fatalf("BulkDelete: %v", err)
+			}
+			deleteDur := time.Since(t0)
+
+			t.Logf("N=%-6d M=%d adjust=%-9v attach=%-9v detach=%-9v create=%-9v delete=%-9v", n, m,
+				adjustDur.Round(time.Millisecond), attachDur.Round(time.Millisecond), detachDur.Round(time.Millisecond),
+				createDur.Round(time.Millisecond), deleteDur.Round(time.Millisecond))
+		})
+	}
+}

+ 20 - 0
web/service/tgbot.go

@@ -495,6 +495,10 @@ func (t *Tgbot) OnReceive() {
 		}, th.TextEqual(t.I18nBot("tgbot.buttons.closeKeyboard")))
 
 		h.HandleMessage(func(ctx *th.Context, message telego.Message) error {
+			if !t.isCommandForCurrentBot(&message) {
+				return nil
+			}
+
 			// Use goroutine with worker pool for concurrent command processing
 			go func() {
 				messageWorkerPool <- struct{}{}        // Acquire worker
@@ -684,6 +688,22 @@ func (t *Tgbot) answerCommand(message *telego.Message, chatId int64, isAdmin boo
 	}
 }
 
+func (t *Tgbot) isCommandForCurrentBot(message *telego.Message) bool {
+	return isCommandForBot(message.Text, botUsername())
+}
+
+func botUsername() string {
+	if bot == nil {
+		return ""
+	}
+	return bot.Username()
+}
+
+func isCommandForBot(text string, username string) bool {
+	_, commandUsername, _ := tu.ParseCommand(text)
+	return commandUsername == "" || username == "" || strings.EqualFold(commandUsername, username)
+}
+
 // sendResponse sends the response message based on the onlyMessage flag.
 func (t *Tgbot) sendResponse(chatId int64, msg string, onlyMessage, isAdmin bool) {
 	if onlyMessage {

+ 24 - 0
web/service/tgbot_test.go

@@ -99,3 +99,27 @@ func TestTgbotProxyDialerNoneWhenEmpty(t *testing.T) {
 		t.Fatal("Dial must be nil when no proxy is configured")
 	}
 }
+
+func TestIsCommandForBotAllowsUntargetedCommand(t *testing.T) {
+	if !isCommandForBot("/status", "panel_bot") {
+		t.Fatal("untargeted commands must remain accepted")
+	}
+}
+
+func TestIsCommandForBotAllowsMatchingUsername(t *testing.T) {
+	if !isCommandForBot("/status@panel_bot", "Panel_Bot") {
+		t.Fatal("commands targeted to this bot must be accepted")
+	}
+}
+
+func TestIsCommandForBotRejectsOtherUsername(t *testing.T) {
+	if isCommandForBot("/status@other_bot", "panel_bot") {
+		t.Fatal("commands targeted to another bot must be ignored")
+	}
+}
+
+func TestIsCommandForBotKeepsLegacyBehaviorWhenUsernameUnavailable(t *testing.T) {
+	if !isCommandForBot("/status@panel_bot", "") {
+		t.Fatal("commands must remain accepted when the current bot username is unavailable")
+	}
+}

+ 6 - 0
web/translation/ar-EG.json

@@ -1004,6 +1004,10 @@
       "subEnableRoutingDesc": "إعداد عام لتمكين التوجيه (Routing) في عميل VPN. (فقط لـ Happ)",
       "subRoutingRules": "قواعد التوجيه",
       "subRoutingRulesDesc": "قواعد التوجيه العامة لعميل VPN. (فقط لـ Happ)",
+      "subClashEnableRouting": "تفعيل التوجيه",
+      "subClashEnableRoutingDesc": "تضمين قواعد توجيه Clash/Mihomo العامة في اشتراكات YAML المُنشأة.",
+      "subClashRoutingRules": "قواعد التوجيه العامة",
+      "subClashRoutingRulesDesc": "قواعد Clash/Mihomo التي تُضاف في بداية كل اشتراك YAML قبل MATCH,PROXY.",
       "subListen": "IP الاستماع",
       "subListenDesc": "عنوان IP لخدمة الاشتراك. (سيبه فاضي عشان يستمع على كل الـ IPs)",
       "subPort": "بورت الاستماع",
@@ -1070,6 +1074,8 @@
         "defaultIpLimit": "حد IP الافتراضي"
       },
       "subFormats": {
+        "finalMask": "Final Mask",
+        "finalMaskDesc": "أقنعة finalmask في xray (TCP/UDP) وضبط QUIC تُضاف إلى كل تدفق اشتراك JSON. يتطلب إصدار xray حديثًا على العميل.",
         "packets": "الحزم",
         "length": "الطول",
         "interval": "الفاصل",

+ 6 - 0
web/translation/en-US.json

@@ -1004,6 +1004,10 @@
       "subEnableRoutingDesc": "Global setting to enable routing in the VPN client. (Only for Happ)",
       "subRoutingRules": "Routing rules",
       "subRoutingRulesDesc": "Global routing rules for the VPN client. (Only for Happ)",
+      "subClashEnableRouting": "Enable routing",
+      "subClashEnableRoutingDesc": "Include global Clash/Mihomo routing rules in generated YAML subscriptions.",
+      "subClashRoutingRules": "Global routing rules",
+      "subClashRoutingRulesDesc": "Default Clash/Mihomo rules prepended to every generated YAML subscription before MATCH,PROXY.",
       "subListen": "Listen IP",
       "subListenDesc": "The IP address for the subscription service. (leave blank to listen on all IPs)",
       "subPort": "Listen Port",
@@ -1070,6 +1074,8 @@
         "defaultIpLimit": "Default IP limit"
       },
       "subFormats": {
+        "finalMask": "Final Mask",
+        "finalMaskDesc": "xray finalmask masks (TCP/UDP) and QUIC tuning injected into every JSON subscription stream. Requires a recent xray client.",
         "packets": "Packets",
         "length": "Length",
         "interval": "Interval",

+ 6 - 0
web/translation/es-ES.json

@@ -1004,6 +1004,10 @@
       "subEnableRoutingDesc": "Configuración global para habilitar el enrutamiento en el cliente VPN. (Solo para Happ)",
       "subRoutingRules": "Reglas de enrutamiento",
       "subRoutingRulesDesc": "Reglas de enrutamiento globales para el cliente VPN. (Solo para Happ)",
+      "subClashEnableRouting": "Habilitar enrutamiento",
+      "subClashEnableRoutingDesc": "Incluir reglas globales de enrutamiento Clash/Mihomo en las suscripciones YAML generadas.",
+      "subClashRoutingRules": "Reglas globales de enrutamiento",
+      "subClashRoutingRulesDesc": "Reglas Clash/Mihomo agregadas al inicio de cada suscripción YAML antes de MATCH,PROXY.",
       "subListen": "Listening IP",
       "subListenDesc": "Dejar en blanco por defecto para monitorear todas las IPs.",
       "subPort": "Puerto de Suscripción",
@@ -1070,6 +1074,8 @@
         "defaultIpLimit": "Límite IP por defecto"
       },
       "subFormats": {
+        "finalMask": "Final Mask",
+        "finalMaskDesc": "Máscaras finalmask de xray (TCP/UDP) y ajustes QUIC inyectados en cada flujo de suscripción JSON. Requiere un cliente xray reciente.",
         "packets": "Paquetes",
         "length": "Longitud",
         "interval": "Intervalo",

+ 6 - 0
web/translation/fa-IR.json

@@ -1004,6 +1004,10 @@
       "subEnableRoutingDesc": "تنظیمات سراسری برای فعال‌سازی مسیریابی در کلاینت VPN. (فقط برای Happ)",
       "subRoutingRules": "قوانین مسیریابی",
       "subRoutingRulesDesc": "قوانین مسیریابی سراسری برای کلاینت VPN. (فقط برای Happ)",
+      "subClashEnableRouting": "فعال‌سازی مسیریابی",
+      "subClashEnableRoutingDesc": "قوانین مسیریابی سراسری Clash/Mihomo را در اشتراک‌های YAML تولیدشده وارد کن.",
+      "subClashRoutingRules": "قوانین مسیریابی سراسری",
+      "subClashRoutingRulesDesc": "قوانین Clash/Mihomo که پیش از MATCH,PROXY به ابتدای هر اشتراک YAML افزوده می‌شوند.",
       "subListen": "آدرس آی‌پی",
       "subListenDesc": "آدرس آی‌پی برای سرویس سابسکریپشن. برای گوش دادن به‌تمام آی‌پی‌ها خالی‌بگذارید",
       "subPort": "پورت",
@@ -1070,6 +1074,8 @@
         "defaultIpLimit": "محدودیت IP پیش‌فرض"
       },
       "subFormats": {
+        "finalMask": "Final Mask",
+        "finalMaskDesc": "ماسک‌های finalmask ایکس‌ری (TCP/UDP) و تنظیمات QUIC که داخل همه‌ی stream های اشتراک JSON تزریق می‌شوند. به نسخه‌ی جدید هسته‌ی xray در کلاینت نیاز دارد.",
         "packets": "بسته‌ها",
         "length": "طول",
         "interval": "بازه",

+ 6 - 0
web/translation/id-ID.json

@@ -1004,6 +1004,10 @@
       "subEnableRoutingDesc": "Pengaturan global untuk mengaktifkan perutean (routing) di klien VPN. (Hanya untuk Happ)",
       "subRoutingRules": "Aturan routing",
       "subRoutingRulesDesc": "Aturan routing global untuk klien VPN. (Hanya untuk Happ)",
+      "subClashEnableRouting": "Aktifkan routing",
+      "subClashEnableRoutingDesc": "Sertakan aturan routing global Clash/Mihomo dalam langganan YAML yang dibuat.",
+      "subClashRoutingRules": "Aturan routing global",
+      "subClashRoutingRulesDesc": "Aturan Clash/Mihomo yang ditambahkan di awal setiap langganan YAML sebelum MATCH,PROXY.",
       "subListen": "IP Pendengar",
       "subListenDesc": "Alamat IP untuk layanan langganan. (biarkan kosong untuk mendengarkan semua IP)",
       "subPort": "Port Pendengar",
@@ -1070,6 +1074,8 @@
         "defaultIpLimit": "Batas IP default"
       },
       "subFormats": {
+        "finalMask": "Final Mask",
+        "finalMaskDesc": "Mask finalmask xray (TCP/UDP) dan penyetelan QUIC yang disuntikkan ke setiap stream langganan JSON. Membutuhkan klien xray terbaru.",
         "packets": "Paket",
         "length": "Panjang",
         "interval": "Interval",

+ 6 - 0
web/translation/ja-JP.json

@@ -1004,6 +1004,10 @@
       "subEnableRoutingDesc": "VPNクライアントでルーティングを有効にするためのグローバル設定。(Happのみ)",
       "subRoutingRules": "ルーティングルール",
       "subRoutingRulesDesc": "VPNクライアントのグローバルルーティングルール。(Happのみ)",
+      "subClashEnableRouting": "ルーティングを有効化",
+      "subClashEnableRoutingDesc": "生成されたYAMLサブスクリプションにClash/Mihomoのグローバルルーティングルールを含めます。",
+      "subClashRoutingRules": "グローバルルーティングルール",
+      "subClashRoutingRulesDesc": "各YAMLサブスクリプションのMATCH,PROXYより前に追加されるClash/Mihomoルール。",
       "subListen": "監視IP",
       "subListenDesc": "サブスクリプションサービスが監視するIPアドレス(空白にするとすべてのIPを監視)",
       "subPort": "監視ポート",
@@ -1070,6 +1074,8 @@
         "defaultIpLimit": "デフォルト IP 制限"
       },
       "subFormats": {
+        "finalMask": "Final Mask",
+        "finalMaskDesc": "すべての JSON サブスクリプションストリームに注入される xray finalmask マスク(TCP/UDP)と QUIC チューニング。新しい xray クライアントが必要です。",
         "packets": "パケット",
         "length": "長さ",
         "interval": "間隔",

+ 6 - 0
web/translation/pt-BR.json

@@ -1004,6 +1004,10 @@
       "subEnableRoutingDesc": "Configuração global para habilitar o roteamento no cliente VPN. (Apenas para Happ)",
       "subRoutingRules": "Regras de roteamento",
       "subRoutingRulesDesc": "Regras de roteamento globais para o cliente VPN. (Apenas para Happ)",
+      "subClashEnableRouting": "Ativar roteamento",
+      "subClashEnableRoutingDesc": "Incluir regras globais de roteamento Clash/Mihomo nas assinaturas YAML geradas.",
+      "subClashRoutingRules": "Regras globais de roteamento",
+      "subClashRoutingRulesDesc": "Regras Clash/Mihomo adicionadas ao início de cada assinatura YAML antes de MATCH,PROXY.",
       "subListen": "IP de Escuta",
       "subListenDesc": "O endereço IP para o serviço de assinatura. (deixe em branco para escutar em todos os IPs)",
       "subPort": "Porta de Escuta",
@@ -1070,6 +1074,8 @@
         "defaultIpLimit": "Limite de IP padrão"
       },
       "subFormats": {
+        "finalMask": "Final Mask",
+        "finalMaskDesc": "Máscaras finalmask do xray (TCP/UDP) e ajustes QUIC injetados em cada fluxo de assinatura JSON. Requer um cliente xray recente.",
         "packets": "Pacotes",
         "length": "Comprimento",
         "interval": "Intervalo",

+ 6 - 0
web/translation/ru-RU.json

@@ -1004,6 +1004,10 @@
       "subEnableRoutingDesc": "Глобальная настройка для включения маршрутизации в VPN-клиенте. (Только для Happ)",
       "subRoutingRules": "Правила маршрутизации",
       "subRoutingRulesDesc": "Глобальные правила маршрутизации для VPN-клиента. (Только для Happ)",
+      "subClashEnableRouting": "Включить маршрутизацию",
+      "subClashEnableRoutingDesc": "Добавлять глобальные правила маршрутизации Clash/Mihomo в сгенерированные YAML-подписки.",
+      "subClashRoutingRules": "Глобальные правила маршрутизации",
+      "subClashRoutingRulesDesc": "Правила Clash/Mihomo, добавляемые в начало каждой YAML-подписки перед MATCH,PROXY.",
       "subListen": "Прослушивание IP",
       "subListenDesc": "Оставьте пустым по умолчанию, чтобы отслеживать все IP-адреса",
       "subPort": "Порт подписки",
@@ -1070,6 +1074,8 @@
         "defaultIpLimit": "Лимит IP по умолчанию"
       },
       "subFormats": {
+        "finalMask": "Final Mask",
+        "finalMaskDesc": "Маски finalmask xray (TCP/UDP) и настройки QUIC, добавляемые в каждый поток JSON-подписки. Требуется свежая версия xray на клиенте.",
         "packets": "Пакеты",
         "length": "Длина",
         "interval": "Интервал",

+ 6 - 0
web/translation/tr-TR.json

@@ -1004,6 +1004,10 @@
       "subEnableRoutingDesc": "VPN istemcisinde yönlendirmeyi etkinleştirmek için genel ayar. (Yalnızca Happ için)",
       "subRoutingRules": "Yönlendirme kuralları",
       "subRoutingRulesDesc": "VPN istemcisi için genel yönlendirme kuralları. (Yalnızca Happ için)",
+      "subClashEnableRouting": "Yönlendirmeyi etkinleştir",
+      "subClashEnableRoutingDesc": "Oluşturulan YAML aboneliklerine genel Clash/Mihomo yönlendirme kurallarını ekle.",
+      "subClashRoutingRules": "Genel yönlendirme kuralları",
+      "subClashRoutingRulesDesc": "Her YAML aboneliğinin başına MATCH,PROXY öncesinde eklenen Clash/Mihomo kuralları.",
       "subListen": "Dinleme IP",
       "subListenDesc": "Abonelik hizmeti için IP adresi. (tüm IP'leri dinlemek için boş bırakın)",
       "subPort": "Dinleme Portu",
@@ -1070,6 +1074,8 @@
         "defaultIpLimit": "Varsayılan IP limiti"
       },
       "subFormats": {
+        "finalMask": "Final Mask",
+        "finalMaskDesc": "Her JSON abonelik akışına eklenen xray finalmask maskeleri (TCP/UDP) ve QUIC ayarları. Güncel bir xray istemcisi gerektirir.",
         "packets": "Paketler",
         "length": "Uzunluk",
         "interval": "Aralık",

+ 6 - 0
web/translation/uk-UA.json

@@ -1004,6 +1004,10 @@
       "subEnableRoutingDesc": "Глобальне налаштування для увімкнення маршрутизації у VPN-клієнті. (Тільки для Happ)",
       "subRoutingRules": "Правила маршрутизації",
       "subRoutingRulesDesc": "Глобальні правила маршрутизації для VPN-клієнта. (Тільки для Happ)",
+      "subClashEnableRouting": "Увімкнути маршрутизацію",
+      "subClashEnableRoutingDesc": "Додавати глобальні правила маршрутизації Clash/Mihomo до згенерованих YAML-підписок.",
+      "subClashRoutingRules": "Глобальні правила маршрутизації",
+      "subClashRoutingRulesDesc": "Правила Clash/Mihomo, що додаються на початок кожної YAML-підписки перед MATCH,PROXY.",
       "subListen": "Слухати IP",
       "subListenDesc": "IP-адреса для служби підписки. (залиште порожнім, щоб слухати всі IP-адреси)",
       "subPort": "Слухати порт",
@@ -1070,6 +1074,8 @@
         "defaultIpLimit": "Ліміт IP за замовч."
       },
       "subFormats": {
+        "finalMask": "Final Mask",
+        "finalMaskDesc": "Маски finalmask xray (TCP/UDP) і налаштування QUIC, що додаються до кожного потоку JSON-підписки. Потрібна свіжа версія xray на клієнті.",
         "packets": "Пакети",
         "length": "Довжина",
         "interval": "Інтервал",

+ 6 - 0
web/translation/vi-VN.json

@@ -1004,6 +1004,10 @@
       "subEnableRoutingDesc": "Cài đặt toàn cục để bật định tuyến trong ứng dụng khách VPN. (Chỉ dành cho Happ)",
       "subRoutingRules": "Quy tắc định tuyến",
       "subRoutingRulesDesc": "Quy tắc định tuyến toàn cầu cho client VPN. (Chỉ dành cho Happ)",
+      "subClashEnableRouting": "Bật định tuyến",
+      "subClashEnableRoutingDesc": "Bao gồm quy tắc định tuyến Clash/Mihomo toàn cầu trong các đăng ký YAML được tạo.",
+      "subClashRoutingRules": "Quy tắc định tuyến toàn cầu",
+      "subClashRoutingRulesDesc": "Quy tắc Clash/Mihomo được thêm vào đầu mỗi đăng ký YAML trước MATCH,PROXY.",
       "subListen": "Listening IP",
       "subListenDesc": "Mặc định để trống để nghe tất cả các IP",
       "subPort": "Cổng gói đăng ký",
@@ -1070,6 +1074,8 @@
         "defaultIpLimit": "Giới hạn IP mặc định"
       },
       "subFormats": {
+        "finalMask": "Final Mask",
+        "finalMaskDesc": "Mask finalmask của xray (TCP/UDP) và tinh chỉnh QUIC được thêm vào mọi luồng đăng ký JSON. Yêu cầu client xray mới hơn.",
         "packets": "Gói",
         "length": "Độ dài",
         "interval": "Khoảng",

+ 6 - 0
web/translation/zh-CN.json

@@ -1004,6 +1004,10 @@
       "subEnableRoutingDesc": "在 VPN 客户端中启用路由的全局设置。(僅限 Happ)",
       "subRoutingRules": "路由規則",
       "subRoutingRulesDesc": "VPN 用戶端的全域路由規則。(僅限 Happ)",
+      "subClashEnableRouting": "启用路由",
+      "subClashEnableRoutingDesc": "在生成的 YAML 订阅中包含 Clash/Mihomo 全局路由规则。",
+      "subClashRoutingRules": "全局路由规则",
+      "subClashRoutingRulesDesc": "添加到每个 YAML 订阅开头、MATCH,PROXY 之前的 Clash/Mihomo 规则。",
       "subListen": "监听 IP",
       "subListenDesc": "订阅服务监听的 IP 地址(留空表示监听所有 IP)",
       "subPort": "监听端口",
@@ -1070,6 +1074,8 @@
         "defaultIpLimit": "默认 IP 限制"
       },
       "subFormats": {
+        "finalMask": "Final Mask",
+        "finalMaskDesc": "注入到每个 JSON 订阅流的 xray finalmask 掩码(TCP/UDP)和 QUIC 调优。需要较新的 xray 客户端。",
         "packets": "数据包",
         "length": "长度",
         "interval": "间隔",

+ 6 - 0
web/translation/zh-TW.json

@@ -1004,6 +1004,10 @@
       "subEnableRoutingDesc": "在 VPN 用戶端中啟用路由的全域設定。(僅限 Happ)",
       "subRoutingRules": "路由規則",
       "subRoutingRulesDesc": "VPN 用戶端的全域路由規則。(僅限 Happ)",
+      "subClashEnableRouting": "啟用路由",
+      "subClashEnableRoutingDesc": "在產生的 YAML 訂閱中包含 Clash/Mihomo 全域路由規則。",
+      "subClashRoutingRules": "全域路由規則",
+      "subClashRoutingRulesDesc": "加入到每個 YAML 訂閱開頭、MATCH,PROXY 之前的 Clash/Mihomo 規則。",
       "subListen": "監聽 IP",
       "subListenDesc": "訂閱服務監聽的 IP 地址(留空表示監聽所有 IP)",
       "subPort": "監聽埠",
@@ -1070,6 +1074,8 @@
         "defaultIpLimit": "預設 IP 限制"
       },
       "subFormats": {
+        "finalMask": "Final Mask",
+        "finalMaskDesc": "注入到每個 JSON 訂閱串流的 xray finalmask 遮罩(TCP/UDP)與 QUIC 調校。需要較新的 xray 用戶端。",
         "packets": "封包",
         "length": "長度",
         "interval": "間隔",

+ 53 - 14
x-ui.sh

@@ -1269,6 +1269,16 @@ ssl_cert_issue_main() {
                         echo "Panel paths set for domain: $domain"
                         echo "  - Certificate File: $webCertFile"
                         echo "  - Private Key File: $webKeyFile"
+                        # Register the acme.sh install-cert hook so auto-renewal copies the
+                        # 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}" \
+                                --key-file "${webKeyFile}" \
+                                --fullchain-file "${webCertFile}" \
+                                --reloadcmd "x-ui restart" 2>&1 || true
+                            echo "Registered acme.sh auto-renewal hook for ${domain}."
+                        fi
                         restart
                     else
                         echo "Certificate or private key not found for domain: $domain."
@@ -1448,8 +1458,8 @@ ssl_cert_issue_for_ip() {
         LOGE "Failed to issue certificate for IP: ${server_ip}"
         LOGE "Make sure port ${WebPort} is open and the server is accessible from the internet"
         # Cleanup acme.sh data for both IPv4 and IPv6 if specified
-        rm -rf ~/.acme.sh/${server_ip} 2> /dev/null
-        [[ -n "$ipv6_addr" ]] && rm -rf ~/.acme.sh/${ipv6_addr} 2> /dev/null
+        rm -rf ~/.acme.sh/${server_ip} ~/.acme.sh/${server_ip}_ecc 2> /dev/null
+        [[ -n "$ipv6_addr" ]] && rm -rf ~/.acme.sh/${ipv6_addr} ~/.acme.sh/${ipv6_addr}_ecc 2> /dev/null
         rm -rf ${certPath} 2> /dev/null
         return 1
     else
@@ -1468,8 +1478,8 @@ ssl_cert_issue_for_ip() {
     if [[ ! -f "${certPath}/fullchain.pem" || ! -f "${certPath}/privkey.pem" ]]; then
         LOGE "Certificate files not found after installation"
         # Cleanup acme.sh data for both IPv4 and IPv6 if specified
-        rm -rf ~/.acme.sh/${server_ip} 2> /dev/null
-        [[ -n "$ipv6_addr" ]] && rm -rf ~/.acme.sh/${ipv6_addr} 2> /dev/null
+        rm -rf ~/.acme.sh/${server_ip} ~/.acme.sh/${server_ip}_ecc 2> /dev/null
+        [[ -n "$ipv6_addr" ]] && rm -rf ~/.acme.sh/${ipv6_addr} ~/.acme.sh/${ipv6_addr}_ecc 2> /dev/null
         rm -rf ${certPath} 2> /dev/null
         return 1
     fi
@@ -1576,14 +1586,30 @@ ssl_cert_issue() {
     LOGD "Your domain is: ${domain}, checking it..."
     SSL_ISSUED_DOMAIN="${domain}"
 
-    # detect existing certificate and reuse it if present
+    # detect existing certificate and reuse it only if its files are actually
+    # present and non-empty. acme.sh stores ECC certs under ${domain}_ecc and RSA
+    # certs under ${domain}; a failed issuance can leave a domain entry in --list
+    # with no usable cert files, which must not be reused (it produces a 0-byte
+    # fullchain.pem). Broken partial state is cleaned up so issuance can proceed.
     local cert_exists=0
     if ~/.acme.sh/acme.sh --list 2> /dev/null | awk '{print $1}' | grep -Fxq "${domain}"; then
-        cert_exists=1
-        local certInfo=$(~/.acme.sh/acme.sh --list 2> /dev/null | grep -F "${domain}")
-        LOGI "Existing certificate found for ${domain}, will reuse it."
-        [[ -n "${certInfo}" ]] && LOGI "${certInfo}"
-    else
+        local acmeCertDir=""
+        if [[ -s ~/.acme.sh/${domain}_ecc/fullchain.cer && -s ~/.acme.sh/${domain}_ecc/${domain}.key ]]; then
+            acmeCertDir=~/.acme.sh/${domain}_ecc
+        elif [[ -s ~/.acme.sh/${domain}/fullchain.cer && -s ~/.acme.sh/${domain}/${domain}.key ]]; then
+            acmeCertDir=~/.acme.sh/${domain}
+        fi
+        if [[ -n "${acmeCertDir}" ]]; then
+            cert_exists=1
+            local certInfo=$(~/.acme.sh/acme.sh --list 2> /dev/null | grep -F "${domain}")
+            LOGI "Existing certificate found for ${domain}, will reuse it."
+            [[ -n "${certInfo}" ]] && LOGI "${certInfo}"
+        else
+            LOGW "Found incomplete acme.sh state for ${domain} (no valid certificate files); cleaning it up and re-issuing."
+            rm -rf ~/.acme.sh/${domain} ~/.acme.sh/${domain}_ecc
+        fi
+    fi
+    if [[ ${cert_exists} -eq 0 ]]; then
         LOGI "Your domain is ready for issuing certificates now..."
     fi
 
@@ -1611,7 +1637,7 @@ ssl_cert_issue() {
         ~/.acme.sh/acme.sh --issue -d ${domain} --listen-v6 --standalone --httpport ${WebPort} --force
         if [ $? -ne 0 ]; then
             LOGE "Issuing certificate failed, please check logs."
-            rm -rf ~/.acme.sh/${domain}
+            rm -rf ~/.acme.sh/${domain} ~/.acme.sh/${domain}_ecc
             exit 1
         else
             LOGE "Issuing certificate succeeded, installing certificates..."
@@ -1664,7 +1690,7 @@ ssl_cert_issue() {
     else
         LOGE "Installing certificate failed, exiting."
         if [[ ${cert_exists} -eq 0 ]]; then
-            rm -rf ~/.acme.sh/${domain}
+            rm -rf ~/.acme.sh/${domain} ~/.acme.sh/${domain}_ecc
         fi
         exit 1
     fi
@@ -2248,6 +2274,18 @@ failregex   = \[LIMIT_IP\]\s*Email\s*=\s*<F-USER>.+</F-USER>\s*\|\|\s*Disconnect
 ignoreregex =
 EOF
 
+    # Ports to exempt from the ban so an over-limit proxy client can never lock
+    # the administrator out of SSH or the panel. The ban still covers every other
+    # TCP port (including all Xray inbounds), so IP-limit keeps working for inbounds
+    # added later without regenerating these files.
+    local ssh_ports
+    ssh_ports=$(grep -oP '^[[:space:]]*Port[[:space:]]+\K[0-9]+' /etc/ssh/sshd_config 2>/dev/null | paste -sd, -)
+    [[ -z "${ssh_ports}" ]] && ssh_ports="22"
+    local panel_port
+    panel_port=$(${xui_folder}/x-ui setting -show true 2>/dev/null | grep -Eo 'port: .+' | awk '{print $2}')
+    local exempt_ports="${ssh_ports}"
+    [[ -n "${panel_port}" ]] && exempt_ports="${exempt_ports},${panel_port}"
+
     cat << EOF > /etc/fail2ban/action.d/3x-ipl.conf
 [INCLUDES]
 before = iptables-allports.conf
@@ -2263,16 +2301,17 @@ actionstop = <iptables> -D <chain> -p <protocol> -j f2b-<name>
 
 actioncheck = <iptables> -n -L <chain> | grep -q 'f2b-<name>[ \t]'
 
-actionban = <iptables> -I f2b-<name> 1 -s <ip> -j <blocktype>
+actionban = <iptables> -I f2b-<name> 1 -s <ip> -p <protocol> -m multiport ! --dports <exemptports> -j <blocktype>
             echo "\$(date +"%%Y/%%m/%%d %%H:%%M:%%S")   BAN   [Email] = <F-USER> [IP] = <ip> banned for <bantime> seconds." >> ${iplimit_banned_log_path}
 
-actionunban = <iptables> -D f2b-<name> -s <ip> -j <blocktype>
+actionunban = <iptables> -D f2b-<name> -s <ip> -p <protocol> -m multiport ! --dports <exemptports> -j <blocktype>
               echo "\$(date +"%%Y/%%m/%%d %%H:%%M:%%S")   UNBAN   [Email] = <F-USER> [IP] = <ip> unbanned." >> ${iplimit_banned_log_path}
 
 [Init]
 name = default
 protocol = tcp
 chain = INPUT
+exemptports = ${exempt_ports}
 EOF
 
     echo -e "${green}Ip Limit jail files created with a bantime of ${bantime} minutes.${plain}"