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

feat(sub): auto-detect subscription format by User-Agent (Updated) (#5826)

* feat(settings): add subscription format controls

* feat(sub): auto-detect subscription formats

* fix(xray): validate balancer regexes before save

* Revert "fix(xray): validate balancer regexes before save"

This reverts commit 8a208ce71b7b3daed5d05607091203382d675e07.

* doc(endpoints): align indent spaces

* doc(settings): improve error message formatting in validateSubUserAgentRegex

- Use NewErrorf with proper formatting instead of NewError with string concatenation
- Add comment explaining the rationale for returning original pattern value
- This preserves the intentional design where empty input is stored as empty
  in the DB and inherited as the runtime default at read time

---------

Co-authored-by: Tomilla <[email protected]>
Co-authored-by: Sanaei <[email protected]>
Tomi lla 8 часов назад
Родитель
Сommit
129f50d92a
40 измененных файлов с 1590 добавлено и 91 удалено
  1. 87 0
      frontend/public/openapi.json
  2. 63 0
      frontend/src/components/form/GoRegexInput.tsx
  3. 1 0
      frontend/src/components/form/index.ts
  4. 10 0
      frontend/src/generated/examples.ts
  5. 40 0
      frontend/src/generated/schemas.ts
  6. 10 0
      frontend/src/generated/types.ts
  7. 10 0
      frontend/src/generated/zod.ts
  8. 5 0
      frontend/src/models/setting.ts
  9. 9 2
      frontend/src/pages/api-docs/endpoints.ts
  10. 15 0
      frontend/src/pages/settings/SubscriptionFormatsTab.css
  11. 70 6
      frontend/src/pages/settings/SubscriptionFormatsTab.tsx
  12. 17 1
      frontend/src/pages/settings/SubscriptionGeneralTab.tsx
  13. 5 0
      frontend/src/schemas/setting.ts
  14. 29 0
      frontend/src/test/subscription-general-tab.test.tsx
  15. 273 55
      internal/sub/controller.go
  16. 329 0
      internal/sub/controller_test.go
  17. 19 1
      internal/sub/external_only_sub_test.go
  18. 2 2
      internal/sub/host_sub_test.go
  19. 2 3
      internal/sub/json_service.go
  20. 54 5
      internal/sub/sub.go
  21. 1 1
      internal/sub/sub_scale_test.go
  22. 1 1
      internal/sub/vless_route_sub_test.go
  23. 19 0
      internal/web/controller/setting.go
  24. 46 0
      internal/web/controller/setting_test.go
  25. 5 0
      internal/web/entity/entity.go
  26. 79 1
      internal/web/service/setting.go
  27. 207 0
      internal/web/service/setting_clash_test.go
  28. 14 1
      internal/web/translation/ar-EG.json
  29. 14 1
      internal/web/translation/en-US.json
  30. 14 1
      internal/web/translation/es-ES.json
  31. 14 1
      internal/web/translation/fa-IR.json
  32. 14 1
      internal/web/translation/id-ID.json
  33. 14 1
      internal/web/translation/ja-JP.json
  34. 14 1
      internal/web/translation/pt-BR.json
  35. 14 1
      internal/web/translation/ru-RU.json
  36. 14 1
      internal/web/translation/tr-TR.json
  37. 14 1
      internal/web/translation/uk-UA.json
  38. 14 1
      internal/web/translation/vi-VN.json
  39. 14 1
      internal/web/translation/zh-CN.json
  40. 14 1
      internal/web/translation/zh-TW.json

+ 87 - 0
frontend/public/openapi.json

@@ -176,6 +176,9 @@
           "subCertFile": {
             "type": "string"
           },
+          "subClashAutoDetect": {
+            "type": "boolean"
+          },
           "subClashEnable": {
             "type": "boolean"
           },
@@ -191,6 +194,9 @@
           "subClashURI": {
             "type": "string"
           },
+          "subClashUserAgentRegex": {
+            "type": "string"
+          },
           "subDomain": {
             "type": "string"
           },
@@ -212,6 +218,12 @@
           "subIncyRoutingRules": {
             "type": "string"
           },
+          "subJsonAlwaysArray": {
+            "type": "boolean"
+          },
+          "subJsonAutoDetect": {
+            "type": "boolean"
+          },
           "subJsonEnable": {
             "type": "boolean"
           },
@@ -230,6 +242,9 @@
           "subJsonURI": {
             "type": "string"
           },
+          "subJsonUserAgentRegex": {
+            "type": "string"
+          },
           "subKeyFile": {
             "type": "string"
           },
@@ -391,11 +406,13 @@
           "smtpUsername",
           "subAnnounce",
           "subCertFile",
+          "subClashAutoDetect",
           "subClashEnable",
           "subClashEnableRouting",
           "subClashPath",
           "subClashRules",
           "subClashURI",
+          "subClashUserAgentRegex",
           "subDomain",
           "subEnable",
           "subEnableRouting",
@@ -403,12 +420,15 @@
           "subHideSettings",
           "subIncyEnableRouting",
           "subIncyRoutingRules",
+          "subJsonAlwaysArray",
+          "subJsonAutoDetect",
           "subJsonEnable",
           "subJsonFinalMask",
           "subJsonMux",
           "subJsonPath",
           "subJsonRules",
           "subJsonURI",
+          "subJsonUserAgentRegex",
           "subKeyFile",
           "subListen",
           "subPath",
@@ -617,6 +637,9 @@
           "subCertFile": {
             "type": "string"
           },
+          "subClashAutoDetect": {
+            "type": "boolean"
+          },
           "subClashEnable": {
             "type": "boolean"
           },
@@ -632,6 +655,9 @@
           "subClashURI": {
             "type": "string"
           },
+          "subClashUserAgentRegex": {
+            "type": "string"
+          },
           "subDomain": {
             "type": "string"
           },
@@ -653,6 +679,12 @@
           "subIncyRoutingRules": {
             "type": "string"
           },
+          "subJsonAlwaysArray": {
+            "type": "boolean"
+          },
+          "subJsonAutoDetect": {
+            "type": "boolean"
+          },
           "subJsonEnable": {
             "type": "boolean"
           },
@@ -671,6 +703,9 @@
           "subJsonURI": {
             "type": "string"
           },
+          "subJsonUserAgentRegex": {
+            "type": "string"
+          },
           "subKeyFile": {
             "type": "string"
           },
@@ -839,11 +874,13 @@
           "smtpUsername",
           "subAnnounce",
           "subCertFile",
+          "subClashAutoDetect",
           "subClashEnable",
           "subClashEnableRouting",
           "subClashPath",
           "subClashRules",
           "subClashURI",
+          "subClashUserAgentRegex",
           "subDomain",
           "subEnable",
           "subEnableRouting",
@@ -851,12 +888,15 @@
           "subHideSettings",
           "subIncyEnableRouting",
           "subIncyRoutingRules",
+          "subJsonAlwaysArray",
+          "subJsonAutoDetect",
           "subJsonEnable",
           "subJsonFinalMask",
           "subJsonMux",
           "subJsonPath",
           "subJsonRules",
           "subJsonURI",
+          "subJsonUserAgentRegex",
           "subKeyFile",
           "subListen",
           "subPath",
@@ -9263,6 +9303,53 @@
         }
       }
     },
+    "/panel/api/setting/validateRegex": {
+      "post": {
+        "tags": [
+          "Settings"
+        ],
+        "summary": "Validate any regular expression with the backend Go RE2 compiler without saving it.",
+        "operationId": "post_panel_api_setting_validateRegex",
+        "requestBody": {
+          "required": true,
+          "content": {
+            "application/json": {
+              "schema": {
+                "type": "object"
+              },
+              "example": {
+                "regex": "(?m)^general-purpose$"
+              }
+            }
+          }
+        },
+        "responses": {
+          "200": {
+            "description": "Successful response",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "object",
+                  "properties": {
+                    "success": {
+                      "type": "boolean"
+                    },
+                    "msg": {
+                      "type": "string"
+                    },
+                    "obj": {}
+                  }
+                },
+                "example": {
+                  "success": true,
+                  "msg": ""
+                }
+              }
+            }
+          }
+        }
+      }
+    },
     "/panel/api/setting/updateUser": {
       "post": {
         "tags": [

+ 63 - 0
frontend/src/components/form/GoRegexInput.tsx

@@ -0,0 +1,63 @@
+import { useRef, useState } from 'react';
+import { Input, Typography } from 'antd';
+
+import { HttpUtil } from '@/utils';
+
+interface GoRegexInputProps {
+  value: string;
+  ariaLabel?: string;
+  placeholder?: string;
+  maxLength?: number;
+  onChange: (value: string) => void;
+  externalError?: string;
+}
+
+export async function validateGoRegex(value: string): Promise<string> {
+  const result = await HttpUtil.post(
+    '/panel/api/setting/validateRegex',
+    { regex: value },
+    { silent: true },
+  );
+  return result.success ? '' : result.msg || 'Invalid Go RE2 regular expression';
+}
+
+export default function GoRegexInput({
+  value,
+  ariaLabel,
+  placeholder,
+  maxLength = 2048,
+  onChange,
+  externalError,
+}: GoRegexInputProps) {
+  const [error, setError] = useState('');
+  const validationSequence = useRef(0);
+
+  async function validate() {
+    const sequence = ++validationSequence.current;
+    const nextError = await validateGoRegex(value);
+    if (sequence === validationSequence.current) {
+      setError(nextError);
+    }
+  }
+
+  const displayError = externalError ?? error;
+
+  return (
+    <div style={{ width: '100%' }}>
+      <Input
+        value={value}
+        aria-label={ariaLabel}
+        placeholder={placeholder}
+        maxLength={maxLength}
+        status={displayError ? 'error' : undefined}
+        onChange={(event) => {
+          validationSequence.current += 1;
+          setError('');
+          onChange(event.target.value);
+        }}
+        onBlur={() => void validate()}
+      />
+      {displayError && <Typography.Text type="danger">{displayError}</Typography.Text>}
+    </div>
+  );
+}

+ 1 - 0
frontend/src/components/form/index.ts

@@ -1,6 +1,7 @@
 export { default as DateTimePicker } from './DateTimePicker';
 export { default as JsonEditor } from './JsonEditor';
 export { default as HeaderMapEditor } from './HeaderMapEditor';
+export { default as GoRegexInput, validateGoRegex } from './GoRegexInput';
 export { default as SelectAllClearButtons } from './SelectAllClearButtons';
 export { default as RemarkTemplateField } from './RemarkTemplateField';
 export { default as RemarkVarPicker } from './RemarkVarPicker';

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

@@ -45,11 +45,13 @@ export const EXAMPLES: Record<string, unknown> = {
     "smtpUsername": "",
     "subAnnounce": "",
     "subCertFile": "",
+    "subClashAutoDetect": false,
     "subClashEnable": false,
     "subClashEnableRouting": false,
     "subClashPath": "",
     "subClashRules": "",
     "subClashURI": "",
+    "subClashUserAgentRegex": "",
     "subDomain": "",
     "subEnable": false,
     "subEnableRouting": false,
@@ -57,12 +59,15 @@ export const EXAMPLES: Record<string, unknown> = {
     "subHideSettings": false,
     "subIncyEnableRouting": false,
     "subIncyRoutingRules": "",
+    "subJsonAlwaysArray": false,
+    "subJsonAutoDetect": false,
     "subJsonEnable": false,
     "subJsonFinalMask": "",
     "subJsonMux": "",
     "subJsonPath": "",
     "subJsonRules": "",
     "subJsonURI": "",
+    "subJsonUserAgentRegex": "",
     "subKeyFile": "",
     "subListen": "",
     "subPath": "",
@@ -150,11 +155,13 @@ export const EXAMPLES: Record<string, unknown> = {
     "smtpUsername": "",
     "subAnnounce": "",
     "subCertFile": "",
+    "subClashAutoDetect": false,
     "subClashEnable": false,
     "subClashEnableRouting": false,
     "subClashPath": "",
     "subClashRules": "",
     "subClashURI": "",
+    "subClashUserAgentRegex": "",
     "subDomain": "",
     "subEnable": false,
     "subEnableRouting": false,
@@ -162,12 +169,15 @@ export const EXAMPLES: Record<string, unknown> = {
     "subHideSettings": false,
     "subIncyEnableRouting": false,
     "subIncyRoutingRules": "",
+    "subJsonAlwaysArray": false,
+    "subJsonAutoDetect": false,
     "subJsonEnable": false,
     "subJsonFinalMask": "",
     "subJsonMux": "",
     "subJsonPath": "",
     "subJsonRules": "",
     "subJsonURI": "",
+    "subJsonUserAgentRegex": "",
     "subKeyFile": "",
     "subListen": "",
     "subPath": "",

+ 40 - 0
frontend/src/generated/schemas.ts

@@ -150,6 +150,9 @@ export const SCHEMAS: Record<string, unknown> = {
       "subCertFile": {
         "type": "string"
       },
+      "subClashAutoDetect": {
+        "type": "boolean"
+      },
       "subClashEnable": {
         "type": "boolean"
       },
@@ -165,6 +168,9 @@ export const SCHEMAS: Record<string, unknown> = {
       "subClashURI": {
         "type": "string"
       },
+      "subClashUserAgentRegex": {
+        "type": "string"
+      },
       "subDomain": {
         "type": "string"
       },
@@ -186,6 +192,12 @@ export const SCHEMAS: Record<string, unknown> = {
       "subIncyRoutingRules": {
         "type": "string"
       },
+      "subJsonAlwaysArray": {
+        "type": "boolean"
+      },
+      "subJsonAutoDetect": {
+        "type": "boolean"
+      },
       "subJsonEnable": {
         "type": "boolean"
       },
@@ -204,6 +216,9 @@ export const SCHEMAS: Record<string, unknown> = {
       "subJsonURI": {
         "type": "string"
       },
+      "subJsonUserAgentRegex": {
+        "type": "string"
+      },
       "subKeyFile": {
         "type": "string"
       },
@@ -365,11 +380,13 @@ export const SCHEMAS: Record<string, unknown> = {
       "smtpUsername",
       "subAnnounce",
       "subCertFile",
+      "subClashAutoDetect",
       "subClashEnable",
       "subClashEnableRouting",
       "subClashPath",
       "subClashRules",
       "subClashURI",
+      "subClashUserAgentRegex",
       "subDomain",
       "subEnable",
       "subEnableRouting",
@@ -377,12 +394,15 @@ export const SCHEMAS: Record<string, unknown> = {
       "subHideSettings",
       "subIncyEnableRouting",
       "subIncyRoutingRules",
+      "subJsonAlwaysArray",
+      "subJsonAutoDetect",
       "subJsonEnable",
       "subJsonFinalMask",
       "subJsonMux",
       "subJsonPath",
       "subJsonRules",
       "subJsonURI",
+      "subJsonUserAgentRegex",
       "subKeyFile",
       "subListen",
       "subPath",
@@ -591,6 +611,9 @@ export const SCHEMAS: Record<string, unknown> = {
       "subCertFile": {
         "type": "string"
       },
+      "subClashAutoDetect": {
+        "type": "boolean"
+      },
       "subClashEnable": {
         "type": "boolean"
       },
@@ -606,6 +629,9 @@ export const SCHEMAS: Record<string, unknown> = {
       "subClashURI": {
         "type": "string"
       },
+      "subClashUserAgentRegex": {
+        "type": "string"
+      },
       "subDomain": {
         "type": "string"
       },
@@ -627,6 +653,12 @@ export const SCHEMAS: Record<string, unknown> = {
       "subIncyRoutingRules": {
         "type": "string"
       },
+      "subJsonAlwaysArray": {
+        "type": "boolean"
+      },
+      "subJsonAutoDetect": {
+        "type": "boolean"
+      },
       "subJsonEnable": {
         "type": "boolean"
       },
@@ -645,6 +677,9 @@ export const SCHEMAS: Record<string, unknown> = {
       "subJsonURI": {
         "type": "string"
       },
+      "subJsonUserAgentRegex": {
+        "type": "string"
+      },
       "subKeyFile": {
         "type": "string"
       },
@@ -813,11 +848,13 @@ export const SCHEMAS: Record<string, unknown> = {
       "smtpUsername",
       "subAnnounce",
       "subCertFile",
+      "subClashAutoDetect",
       "subClashEnable",
       "subClashEnableRouting",
       "subClashPath",
       "subClashRules",
       "subClashURI",
+      "subClashUserAgentRegex",
       "subDomain",
       "subEnable",
       "subEnableRouting",
@@ -825,12 +862,15 @@ export const SCHEMAS: Record<string, unknown> = {
       "subHideSettings",
       "subIncyEnableRouting",
       "subIncyRoutingRules",
+      "subJsonAlwaysArray",
+      "subJsonAutoDetect",
       "subJsonEnable",
       "subJsonFinalMask",
       "subJsonMux",
       "subJsonPath",
       "subJsonRules",
       "subJsonURI",
+      "subJsonUserAgentRegex",
       "subKeyFile",
       "subListen",
       "subPath",

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

@@ -51,11 +51,13 @@ export interface AllSetting {
   smtpUsername: string;
   subAnnounce: string;
   subCertFile: string;
+  subClashAutoDetect: boolean;
   subClashEnable: boolean;
   subClashEnableRouting: boolean;
   subClashPath: string;
   subClashRules: string;
   subClashURI: string;
+  subClashUserAgentRegex: string;
   subDomain: string;
   subEnable: boolean;
   subEnableRouting: boolean;
@@ -63,12 +65,15 @@ export interface AllSetting {
   subHideSettings: boolean;
   subIncyEnableRouting: boolean;
   subIncyRoutingRules: string;
+  subJsonAlwaysArray: boolean;
+  subJsonAutoDetect: boolean;
   subJsonEnable: boolean;
   subJsonFinalMask: string;
   subJsonMux: string;
   subJsonPath: string;
   subJsonRules: string;
   subJsonURI: string;
+  subJsonUserAgentRegex: string;
   subKeyFile: string;
   subListen: string;
   subPath: string;
@@ -157,11 +162,13 @@ export interface AllSettingView {
   smtpUsername: string;
   subAnnounce: string;
   subCertFile: string;
+  subClashAutoDetect: boolean;
   subClashEnable: boolean;
   subClashEnableRouting: boolean;
   subClashPath: string;
   subClashRules: string;
   subClashURI: string;
+  subClashUserAgentRegex: string;
   subDomain: string;
   subEnable: boolean;
   subEnableRouting: boolean;
@@ -169,12 +176,15 @@ export interface AllSettingView {
   subHideSettings: boolean;
   subIncyEnableRouting: boolean;
   subIncyRoutingRules: string;
+  subJsonAlwaysArray: boolean;
+  subJsonAutoDetect: boolean;
   subJsonEnable: boolean;
   subJsonFinalMask: string;
   subJsonMux: string;
   subJsonPath: string;
   subJsonRules: string;
   subJsonURI: string;
+  subJsonUserAgentRegex: string;
   subKeyFile: string;
   subListen: string;
   subPath: string;

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

@@ -63,11 +63,13 @@ export const AllSettingSchema = z.object({
   smtpUsername: z.string(),
   subAnnounce: z.string(),
   subCertFile: z.string(),
+  subClashAutoDetect: z.boolean(),
   subClashEnable: z.boolean(),
   subClashEnableRouting: z.boolean(),
   subClashPath: z.string(),
   subClashRules: z.string(),
   subClashURI: z.string(),
+  subClashUserAgentRegex: z.string(),
   subDomain: z.string(),
   subEnable: z.boolean(),
   subEnableRouting: z.boolean(),
@@ -75,12 +77,15 @@ export const AllSettingSchema = z.object({
   subHideSettings: z.boolean(),
   subIncyEnableRouting: z.boolean(),
   subIncyRoutingRules: z.string(),
+  subJsonAlwaysArray: z.boolean(),
+  subJsonAutoDetect: z.boolean(),
   subJsonEnable: z.boolean(),
   subJsonFinalMask: z.string(),
   subJsonMux: z.string(),
   subJsonPath: z.string(),
   subJsonRules: z.string(),
   subJsonURI: z.string(),
+  subJsonUserAgentRegex: z.string(),
   subKeyFile: z.string(),
   subListen: z.string(),
   subPath: z.string(),
@@ -170,11 +175,13 @@ export const AllSettingViewSchema = z.object({
   smtpUsername: z.string(),
   subAnnounce: z.string(),
   subCertFile: z.string(),
+  subClashAutoDetect: z.boolean(),
   subClashEnable: z.boolean(),
   subClashEnableRouting: z.boolean(),
   subClashPath: z.string(),
   subClashRules: z.string(),
   subClashURI: z.string(),
+  subClashUserAgentRegex: z.string(),
   subDomain: z.string(),
   subEnable: z.boolean(),
   subEnableRouting: z.boolean(),
@@ -182,12 +189,15 @@ export const AllSettingViewSchema = z.object({
   subHideSettings: z.boolean(),
   subIncyEnableRouting: z.boolean(),
   subIncyRoutingRules: z.string(),
+  subJsonAlwaysArray: z.boolean(),
+  subJsonAutoDetect: z.boolean(),
   subJsonEnable: z.boolean(),
   subJsonFinalMask: z.string(),
   subJsonMux: z.string(),
   subJsonPath: z.string(),
   subJsonRules: z.string(),
   subJsonURI: z.string(),
+  subJsonUserAgentRegex: z.string(),
   subKeyFile: z.string(),
   subListen: z.string(),
   subPath: z.string(),

+ 5 - 0
frontend/src/models/setting.ts

@@ -29,6 +29,11 @@ export class AllSetting {
   xrayTemplateConfig = '';
   subEnable = true;
   subJsonEnable = false;
+  subJsonAutoDetect = false;
+  subJsonAlwaysArray = false;
+  subJsonUserAgentRegex = '';
+  subClashAutoDetect = false;
+  subClashUserAgentRegex = '';
   subTitle = '';
   subSupportUrl = '';
   subProfileUrl = '';

+ 9 - 2
frontend/src/pages/api-docs/endpoints.ts

@@ -1145,7 +1145,7 @@ export const sections: readonly Section[] = [
         method: 'POST',
         path: '/panel/api/setting/all',
         summary: 'Return every panel setting: web server, Telegram bot, subscription, security, LDAP. The full JSON blob that the Settings page edits.',
-        response: '{\n  "success": true,\n  "obj": {\n    "webPort": 2053,\n    "webCertFile": "",\n    "webKeyFile": "",\n    "webBasePath": "/",\n    "subPort": 10882,\n    "subPath": "/sub/",\n    "tgBotEnable": false,\n    "tgBotToken": "",\n    ...\n  }\n}',
+        response: '{\n  "success": true,\n  "obj": {\n    "webPort": 2053,\n    "webCertFile": "",\n    "webKeyFile": "",\n    "webBasePath": "/",\n    "subPort": 10882,\n    "subPath": "/sub/",\n    "subClashAutoDetect": false,\n    "subClashUserAgentRegex": "",\n    "subJsonEnable": false,\n    "subJsonAutoDetect": false,\n    "subJsonAlwaysArray": false,\n    "subJsonUserAgentRegex": "",\n    "subJsonPath": "/json/",\n    "subJsonURI": "https://sub.example.com/json/",\n    "subClashEnable": true,\n    "subClashPath": "/clash/",\n    "subClashURI": "https://sub.example.com/clash/",\n    "tgBotEnable": false,\n    "tgBotToken": "",\n    ...\n  }\n}',
       },
       {
         method: 'POST',
@@ -1156,7 +1156,14 @@ export const sections: readonly Section[] = [
         method: 'POST',
         path: '/panel/api/setting/update',
         summary: 'Persist every setting at once. The body mirrors the shape returned by /all. Invalid values (bad ports, missing cert pairs, etc.) are rejected before write.',
-        body: '{\n  "webPort": 2053,\n  "webBasePath": "/",\n  "subPort": 10882,\n  "subPath": "/sub/",\n  "tgBotEnable": false,\n  ...\n}',
+        body: '{\n  "webPort": 2053,\n  "webBasePath": "/",\n  "subPort": 10882,\n  "subPath": "/sub/",\n  "subClashAutoDetect": false,\n  "subClashUserAgentRegex": "",\n  "subJsonEnable": false,\n  "subJsonAutoDetect": false,\n  "subJsonAlwaysArray": false,\n  "subJsonUserAgentRegex": "",\n  "subJsonPath": "/json/",\n  "subJsonURI": "https://sub.example.com/json/",\n  "subClashEnable": true,\n  "subClashPath": "/clash/",\n  "subClashURI": "https://sub.example.com/clash/",\n  "tgBotEnable": false,\n  ...\n}',
+      },
+      {
+        method: 'POST',
+        path: '/panel/api/setting/validateRegex',
+        summary: 'Validate any regular expression with the backend Go RE2 compiler without saving it.',
+        body: '{\n  "regex": "(?m)^general-purpose$"\n}',
+        response: '{\n  "success": true,\n  "msg": ""\n}',
       },
       {
         method: 'POST',

+ 15 - 0
frontend/src/pages/settings/SubscriptionFormatsTab.css

@@ -1,3 +1,18 @@
+.subscription-format-sections {
+  display: grid;
+  gap: 16px;
+}
+
+.subscription-format-card-title {
+  display: inline-flex;
+  align-items: center;
+  gap: 8px;
+}
+
+.subscription-format-card .ant-card-body {
+  padding: 0;
+}
+
 .format-settings {
   margin-bottom: 8px;
   border: 1px solid var(--ant-color-border-secondary);

+ 70 - 6
frontend/src/pages/settings/SubscriptionFormatsTab.tsx

@@ -1,6 +1,7 @@
 import { useMemo } from 'react';
 import { useTranslation } from 'react-i18next';
 import {
+  Card,
   Input,
   InputNumber,
   Select,
@@ -8,6 +9,8 @@ import {
   Tabs,
 } from 'antd';
 import {
+  FileTextOutlined,
+  NodeIndexOutlined,
   PartitionOutlined,
   RocketOutlined,
   SendOutlined,
@@ -15,6 +18,7 @@ import {
 } from '@ant-design/icons';
 import type { AllSetting } from '@/models/setting';
 import { SettingListItem } from '@/components/ui';
+import { GoRegexInput } from '@/components/form';
 import { useMediaQuery } from '@/hooks/useMediaQuery';
 import { catTabLabel } from './catTabLabel';
 import { sanitizePath, normalizePath } from './uriPath';
@@ -148,9 +152,18 @@ export default function SubscriptionFormatsTab({ allSetting, updateSetting }: Su
         key: '1',
         label: catTabLabel(<SettingOutlined />, t('pages.settings.panelSettings'), isMobile),
         children: (
-          <>
+          <div className="subscription-format-sections">
             {allSetting.subJsonEnable && (
-              <>
+              <Card
+                size="small"
+                className="subscription-format-card"
+                title={(
+                  <span className="subscription-format-card-title">
+                    <FileTextOutlined />
+                    {t('pages.settings.subJsonEnableTitle')}
+                  </span>
+                )}
+              >
                 <SettingListItem paddings="small" title={<>JSON {t('pages.settings.subPath')}</>} description={t('pages.settings.subPathDesc')}>
                   <Input
                     value={allSetting.subJsonPath}
@@ -166,10 +179,40 @@ export default function SubscriptionFormatsTab({ allSetting, updateSetting }: Su
                     onChange={(e) => updateSetting({ subJsonURI: e.target.value })}
                   />
                 </SettingListItem>
-              </>
+                <SettingListItem
+                  paddings="small"
+                  title={t('pages.settings.subJsonAlwaysArray')}
+                  description={t('pages.settings.subJsonAlwaysArrayDesc')}
+                >
+                  <Switch checked={allSetting.subJsonAlwaysArray} onChange={(value) => updateSetting({ subJsonAlwaysArray: value })} />
+                </SettingListItem>
+                <SettingListItem paddings="small" title={t('pages.settings.subJsonAutoDetect')} description={t('pages.settings.subJsonAutoDetectDesc')}>
+                  <Switch checked={allSetting.subJsonAutoDetect} onChange={(v) => updateSetting({ subJsonAutoDetect: v })} />
+                </SettingListItem>
+                <SettingListItem
+                  paddings="small"
+                  title={t('pages.settings.subJsonUserAgentRegex')}
+                  description={t('pages.settings.subJsonUserAgentRegexDesc')}
+                >
+                  <GoRegexInput
+                    value={allSetting.subJsonUserAgentRegex}
+                    placeholder="(?i)^myclient([ /]|$)"
+                    onChange={(value) => updateSetting({ subJsonUserAgentRegex: value })}
+                  />
+                </SettingListItem>
+              </Card>
             )}
             {allSetting.subClashEnable && (
-              <>
+              <Card
+                size="small"
+                className="subscription-format-card"
+                title={(
+                  <span className="subscription-format-card-title">
+                    <NodeIndexOutlined />
+                    {t('pages.settings.subClashEnableTitle')}
+                  </span>
+                )}
+              >
                 <SettingListItem paddings="small" title={<>Clash {t('pages.settings.subPath')}</>} description={t('pages.settings.subPathDesc')}>
                   <Input
                     value={allSetting.subClashPath}
@@ -185,9 +228,30 @@ export default function SubscriptionFormatsTab({ allSetting, updateSetting }: Su
                     onChange={(e) => updateSetting({ subClashURI: e.target.value })}
                   />
                 </SettingListItem>
-              </>
+                <SettingListItem
+                  paddings="small"
+                  title={t('pages.settings.subClashAutoDetect')}
+                  description={t('pages.settings.subClashAutoDetectDesc')}
+                >
+                  <Switch
+                    checked={allSetting.subClashAutoDetect}
+                    onChange={(v) => updateSetting({ subClashAutoDetect: v })}
+                  />
+                </SettingListItem>
+                <SettingListItem
+                  paddings="small"
+                  title={t('pages.settings.subClashUserAgentRegex')}
+                  description={t('pages.settings.subClashUserAgentRegexDesc')}
+                >
+                  <GoRegexInput
+                    value={allSetting.subClashUserAgentRegex}
+                    placeholder="(?i)(clash|mihomo)"
+                    onChange={(value) => updateSetting({ subClashUserAgentRegex: value })}
+                  />
+                </SettingListItem>
+              </Card>
             )}
-          </>
+          </div>
         ),
       },
       {

+ 17 - 1
frontend/src/pages/settings/SubscriptionGeneralTab.tsx

@@ -1,6 +1,7 @@
-import { Input, InputNumber, Switch, Tabs } from 'antd';
+import { Alert, Button, Input, InputNumber, Switch, Tabs } from 'antd';
 import { BranchesOutlined, CompassOutlined, IdcardOutlined, InfoCircleOutlined, NodeIndexOutlined, SafetyCertificateOutlined, SettingOutlined } from '@ant-design/icons';
 import { useTranslation } from 'react-i18next';
+import { useNavigate } from 'react-router-dom';
 import type { AllSetting } from '@/models/setting';
 import { SettingListItem } from '@/components/ui';
 import { RemarkTemplateField } from '@/components/form';
@@ -15,6 +16,7 @@ interface SubscriptionGeneralTabProps {
 
 export default function SubscriptionGeneralTab({ allSetting, updateSetting }: SubscriptionGeneralTabProps) {
   const { t } = useTranslation();
+  const navigate = useNavigate();
   const { isMobile } = useMediaQuery();
 
   return (
@@ -33,6 +35,20 @@ export default function SubscriptionGeneralTab({ allSetting, updateSetting }: Su
             <SettingListItem paddings="small" title={t('pages.settings.subClashEnableTitle')}>
               <Switch checked={allSetting.subClashEnable} onChange={(v) => updateSetting({ subClashEnable: v })} />
             </SettingListItem>
+            {(allSetting.subJsonEnable || allSetting.subClashEnable) && (
+              <Alert
+                type="info"
+                showIcon
+                style={{ margin: '12px 20px' }}
+                title={t('pages.settings.subFormatsTipTitle')}
+                description={t('pages.settings.subFormatsTipDesc')}
+                action={(
+                  <Button size="small" onClick={() => navigate('/settings#subscription-formats')}>
+                    {t('pages.settings.subFormatsTipAction')}
+                  </Button>
+                )}
+              />
+            )}
             <SettingListItem paddings="small" title={t('pages.settings.subListen')} description={t('pages.settings.subListenDesc')}>
               <Input value={allSetting.subListen} onChange={(e) => updateSetting({ subListen: e.target.value })} />
             </SettingListItem>

+ 5 - 0
frontend/src/schemas/setting.ts

@@ -33,6 +33,11 @@ export const AllSettingSchema = z.object({
   xrayTemplateConfig: z.string().optional(),
   subEnable: z.boolean().optional(),
   subJsonEnable: z.boolean().optional(),
+  subJsonAutoDetect: z.boolean().optional(),
+  subJsonAlwaysArray: z.boolean().optional(),
+  subJsonUserAgentRegex: z.string().max(2048).optional(),
+  subClashAutoDetect: z.boolean().optional(),
+  subClashUserAgentRegex: z.string().max(2048).optional(),
   subTitle: z.string().optional(),
   subSupportUrl: z.string().optional(),
   subProfileUrl: z.string().optional(),

+ 29 - 0
frontend/src/test/subscription-general-tab.test.tsx

@@ -0,0 +1,29 @@
+import { fireEvent, screen } from '@testing-library/react';
+import { MemoryRouter, useLocation } from 'react-router-dom';
+import { describe, expect, it, vi } from 'vitest';
+
+import { AllSetting } from '@/models/setting';
+import SubscriptionGeneralTab from '@/pages/settings/SubscriptionGeneralTab';
+import { renderWithProviders } from './test-utils';
+
+function LocationProbe() {
+  const location = useLocation();
+  return <output data-testid="location">{location.pathname}{location.hash}</output>;
+}
+
+describe('SubscriptionGeneralTab', () => {
+  it('uses router navigation to open subscription format settings', () => {
+    const allSetting = new AllSetting({ subClashEnable: true });
+
+    renderWithProviders(
+      <MemoryRouter initialEntries={['/settings#subscription']}>
+        <SubscriptionGeneralTab allSetting={allSetting} updateSetting={vi.fn()} />
+        <LocationProbe />
+      </MemoryRouter>,
+    );
+
+    fireEvent.click(screen.getByRole('button', { name: 'Open Sub Formats' }));
+
+    expect(screen.getByTestId('location').textContent).toBe('/settings#subscription-formats');
+  });
+});

+ 273 - 55
internal/sub/controller.go

@@ -10,10 +10,12 @@ import (
 	"net/url"
 	"os"
 	"path/filepath"
+	"regexp"
 	"strconv"
 	"strings"
 	"sync"
 	"time"
+	"unicode"
 
 	"github.com/gin-gonic/gin"
 
@@ -54,13 +56,18 @@ type SUBController struct {
 	subIncyEnableRouting bool
 	subIncyRoutingRules  string
 
-	subPath        string
-	subJsonPath    string
-	subClashPath   string
-	jsonEnabled    bool
-	clashEnabled   bool
-	subEncrypt     bool
-	updateInterval string
+	subPath            string
+	subJsonPath        string
+	subClashPath       string
+	subClashAutoDetect bool
+	clashUserAgent     *regexp.Regexp
+	jsonAutoDetect     bool
+	jsonUserAgent      *regexp.Regexp
+	jsonAlwaysArray    bool
+	jsonEnabled        bool
+	clashEnabled       bool
+	subEncrypt         bool
+	updateInterval     string
 
 	subService      *SubService
 	subJsonService  *SubJsonService
@@ -71,56 +78,198 @@ type SUBController struct {
 	subTemplateCache map[string]*cachedSubTemplate
 }
 
+type subControllerConfig struct {
+	subPath      string
+	subJsonPath  string
+	subClashPath string
+
+	subClashAutoDetect     bool
+	subClashUserAgentRegex string
+	subJsonAutoDetect      bool
+	subJsonUserAgentRegex  string
+	subJsonAlwaysArray     bool
+	subJsonEnabled         bool
+	subClashEnabled        bool
+
+	subEncrypt     bool
+	remarkTemplate string
+	updateInterval string
+
+	subJsonMux            string
+	subJsonRules          string
+	subJsonFinalMask      string
+	subClashEnableRouting bool
+	subClashRules         string
+
+	subTitle         string
+	subSupportURL    string
+	subProfileURL    string
+	subAnnounce      string
+	subEnableRouting bool
+	subRoutingRules  string
+	subHideSettings  bool
+
+	subIncyEnableRouting bool
+	subIncyRoutingRules  string
+}
+
+type SUBControllerOption func(*subControllerConfig)
+
+func WithSUBPath(value string) SUBControllerOption {
+	return func(config *subControllerConfig) { config.subPath = value }
+}
+
+func WithSUBJsonPath(value string) SUBControllerOption {
+	return func(config *subControllerConfig) { config.subJsonPath = value }
+}
+
+func WithSUBClashPath(value string) SUBControllerOption {
+	return func(config *subControllerConfig) { config.subClashPath = value }
+}
+
+func WithSUBClashAutoDetect(value bool) SUBControllerOption {
+	return func(config *subControllerConfig) { config.subClashAutoDetect = value }
+}
+
+func WithSUBClashUserAgentRegex(value string) SUBControllerOption {
+	return func(config *subControllerConfig) { config.subClashUserAgentRegex = value }
+}
+
+func WithSUBJsonAutoDetect(value bool) SUBControllerOption {
+	return func(config *subControllerConfig) { config.subJsonAutoDetect = value }
+}
+
+func WithSUBJsonUserAgentRegex(value string) SUBControllerOption {
+	return func(config *subControllerConfig) { config.subJsonUserAgentRegex = value }
+}
+
+func WithSUBJsonAlwaysArray(value bool) SUBControllerOption {
+	return func(config *subControllerConfig) { config.subJsonAlwaysArray = value }
+}
+
+func WithSUBJsonEnabled(value bool) SUBControllerOption {
+	return func(config *subControllerConfig) { config.subJsonEnabled = value }
+}
+
+func WithSUBClashEnabled(value bool) SUBControllerOption {
+	return func(config *subControllerConfig) { config.subClashEnabled = value }
+}
+
+func WithSUBEncryption(value bool) SUBControllerOption {
+	return func(config *subControllerConfig) { config.subEncrypt = value }
+}
+
+func WithSUBRemarkTemplate(value string) SUBControllerOption {
+	return func(config *subControllerConfig) { config.remarkTemplate = value }
+}
+
+func WithSUBUpdateInterval(value string) SUBControllerOption {
+	return func(config *subControllerConfig) { config.updateInterval = value }
+}
+
+func WithSUBJsonMux(value string) SUBControllerOption {
+	return func(config *subControllerConfig) { config.subJsonMux = value }
+}
+
+func WithSUBJsonRules(value string) SUBControllerOption {
+	return func(config *subControllerConfig) { config.subJsonRules = value }
+}
+
+func WithSUBJsonFinalMask(value string) SUBControllerOption {
+	return func(config *subControllerConfig) { config.subJsonFinalMask = value }
+}
+
+func WithSUBClashEnableRouting(value bool) SUBControllerOption {
+	return func(config *subControllerConfig) { config.subClashEnableRouting = value }
+}
+
+func WithSUBClashRules(value string) SUBControllerOption {
+	return func(config *subControllerConfig) { config.subClashRules = value }
+}
+
+func WithSUBTitle(value string) SUBControllerOption {
+	return func(config *subControllerConfig) { config.subTitle = value }
+}
+
+func WithSUBSupportURL(value string) SUBControllerOption {
+	return func(config *subControllerConfig) { config.subSupportURL = value }
+}
+
+func WithSUBProfileURL(value string) SUBControllerOption {
+	return func(config *subControllerConfig) { config.subProfileURL = value }
+}
+
+func WithSUBAnnounce(value string) SUBControllerOption {
+	return func(config *subControllerConfig) { config.subAnnounce = value }
+}
+
+func WithSUBEnableRouting(value bool) SUBControllerOption {
+	return func(config *subControllerConfig) { config.subEnableRouting = value }
+}
+
+func WithSUBRoutingRules(value string) SUBControllerOption {
+	return func(config *subControllerConfig) { config.subRoutingRules = value }
+}
+
+func WithSUBHideSettings(value bool) SUBControllerOption {
+	return func(config *subControllerConfig) { config.subHideSettings = value }
+}
+
+func WithSUBIncyEnableRouting(value bool) SUBControllerOption {
+	return func(config *subControllerConfig) { config.subIncyEnableRouting = value }
+}
+
+func WithSUBIncyRoutingRules(value string) SUBControllerOption {
+	return func(config *subControllerConfig) { config.subIncyRoutingRules = value }
+}
+
+func defaultSUBControllerConfig() subControllerConfig {
+	return subControllerConfig{
+		subPath:        "/sub/",
+		subJsonPath:    "/json/",
+		subClashPath:   "/clash/",
+		subEncrypt:     true,
+		remarkTemplate: service.DefaultRemarkTemplate,
+		updateInterval: "12",
+	}
+}
+
 // NewSUBController creates a new subscription controller with the given configuration.
-func NewSUBController(
-	g *gin.RouterGroup,
-	subPath string,
-	jsonPath string,
-	clashPath string,
-	jsonEnabled bool,
-	clashEnabled bool,
-	encrypt bool,
-	remarkTemplate string,
-	update string,
-	jsonMux string,
-	jsonRules string,
-	jsonFinalMask string,
-	clashEnableRouting bool,
-	clashRules string,
-	subTitle string,
-	subSupportUrl string,
-	subProfileUrl string,
-	subAnnounce string,
-	subEnableRouting bool,
-	subRoutingRules string,
-	subHideSettings bool,
-	subIncyEnableRouting bool,
-	subIncyRoutingRules string,
-) *SUBController {
-	sub := NewSubService(remarkTemplate)
+func NewSUBController(g *gin.RouterGroup, options ...SUBControllerOption) *SUBController {
+	config := defaultSUBControllerConfig()
+	for _, option := range options {
+		option(&config)
+	}
+
+	sub := NewSubService(config.remarkTemplate)
 	a := &SUBController{
-		subTitle:         subTitle,
-		subSupportUrl:    subSupportUrl,
-		subProfileUrl:    subProfileUrl,
-		subAnnounce:      subAnnounce,
-		subEnableRouting: subEnableRouting,
-		subRoutingRules:  subRoutingRules,
-		subHideSettings:  subHideSettings,
-
-		subIncyEnableRouting: subIncyEnableRouting,
-		subIncyRoutingRules:  subIncyRoutingRules,
-
-		subPath:        subPath,
-		subJsonPath:    jsonPath,
-		subClashPath:   clashPath,
-		jsonEnabled:    jsonEnabled,
-		clashEnabled:   clashEnabled,
-		subEncrypt:     encrypt,
-		updateInterval: update,
+		subTitle:         config.subTitle,
+		subSupportUrl:    config.subSupportURL,
+		subProfileUrl:    config.subProfileURL,
+		subAnnounce:      config.subAnnounce,
+		subEnableRouting: config.subEnableRouting,
+		subRoutingRules:  config.subRoutingRules,
+		subHideSettings:  config.subHideSettings,
+
+		subIncyEnableRouting: config.subIncyEnableRouting,
+		subIncyRoutingRules:  config.subIncyRoutingRules,
+
+		subPath:            config.subPath,
+		subJsonPath:        config.subJsonPath,
+		subClashPath:       config.subClashPath,
+		subClashAutoDetect: config.subClashAutoDetect,
+		clashUserAgent:     compileUserAgentRegex("Clash/Mihomo", config.subClashUserAgentRegex, service.DefaultSubClashUserAgentRegex),
+		jsonAutoDetect:     config.subJsonAutoDetect,
+		jsonUserAgent:      compileUserAgentRegex("Xray JSON", config.subJsonUserAgentRegex, service.DefaultSubJsonUserAgentRegex),
+		jsonAlwaysArray:    config.subJsonAlwaysArray,
+		jsonEnabled:        config.subJsonEnabled,
+		clashEnabled:       config.subClashEnabled,
+		subEncrypt:         config.subEncrypt,
+		updateInterval:     config.updateInterval,
 
 		subService:      sub,
-		subJsonService:  NewSubJsonService(jsonMux, jsonRules, jsonFinalMask, sub),
-		subClashService: NewSubClashService(clashEnableRouting, clashRules, sub),
+		subJsonService:  NewSubJsonService(config.subJsonMux, config.subJsonRules, config.subJsonFinalMask, sub),
+		subClashService: NewSubClashService(config.subClashEnableRouting, config.subClashRules, sub),
 
 		subTemplateCache: map[string]*cachedSubTemplate{},
 	}
@@ -186,9 +335,22 @@ func (a *SUBController) maybeServeSubPage(c *gin.Context) bool {
 
 // subs handles HTTP requests for subscription links, returning either HTML page or base64-encoded subscription data.
 func (a *SUBController) subs(c *gin.Context) {
+	userAgent := c.GetHeader("User-Agent")
 	if a.maybeServeSubPage(c) {
+		logSubscriptionRoute(userAgent, "html")
+		return
+	}
+	if shouldAutoServeClash(a.subClashAutoDetect, a.clashEnabled, false, userAgent, a.clashUserAgent) {
+		logSubscriptionRoute(userAgent, "clash")
+		a.subClashs(c)
+		return
+	}
+	if shouldAutoServeJson(a.jsonAutoDetect, a.jsonEnabled, false, userAgent, a.jsonUserAgent) {
+		logSubscriptionRoute(userAgent, "json")
+		a.serveJson(c, true, "application/json; charset=utf-8")
 		return
 	}
+	logSubscriptionRoute(userAgent, "raw")
 	subId := c.Param("subid")
 	scheme, host, hostWithPort, _ := a.subService.ResolveRequest(c)
 	subReq := a.subService.ForRequest(host)
@@ -224,6 +386,58 @@ func (a *SUBController) subs(c *gin.Context) {
 	}
 }
 
+func shouldAutoServeClash(autoDetect, clashEnabled, wantsHTML bool, userAgent string, userAgentRegex *regexp.Regexp) bool {
+	return shouldAutoServeFormat(autoDetect, clashEnabled, wantsHTML, userAgent, userAgentRegex)
+}
+
+func shouldAutoServeJson(autoDetect, jsonEnabled, wantsHTML bool, userAgent string, userAgentRegex *regexp.Regexp) bool {
+	return shouldAutoServeFormat(autoDetect, jsonEnabled, wantsHTML, userAgent, userAgentRegex)
+}
+
+func shouldAutoServeFormat(autoDetect, formatEnabled, wantsHTML bool, userAgent string, userAgentRegex *regexp.Regexp) bool {
+	if !autoDetect || !formatEnabled || wantsHTML || userAgentRegex == nil {
+		return false
+	}
+	return userAgentRegex.MatchString(userAgent)
+}
+
+func logSubscriptionRoute(userAgent, branch string) {
+	logger.Debugf("Subscription request routed: branch=%s user_agent=%q", branch, sanitizeUserAgentForLog(userAgent))
+}
+
+func sanitizeUserAgentForLog(userAgent string) string {
+	clean := strings.Map(func(r rune) rune {
+		if unicode.IsControl(r) {
+			return ' '
+		}
+		return r
+	}, userAgent)
+	runes := []rune(clean)
+	if len(runes) > 512 {
+		return string(runes[:512])
+	}
+	return clean
+}
+
+func compileUserAgentRegex(name, pattern, defaultPattern string) *regexp.Regexp {
+	pattern = strings.TrimSpace(pattern)
+	if pattern == "" {
+		pattern = strings.TrimSpace(defaultPattern)
+	}
+	if pattern == "" {
+		return nil
+	}
+	compiled, err := regexp.Compile(pattern)
+	if err == nil {
+		return compiled
+	}
+	logger.Warningf("Invalid %s User-Agent regex %q; falling back to default %q: %v", name, pattern, defaultPattern, err)
+	if strings.TrimSpace(defaultPattern) == "" {
+		return nil
+	}
+	return regexp.MustCompile(defaultPattern)
+}
+
 // serveSubPage renders internal/web/dist/subpage.html for the current subscription
 // request. The Vite-built SPA reads window.__SUB_PAGE_DATA__ on mount —
 // we inject that here, along with window.X_UI_BASE_PATH so the
@@ -387,9 +601,13 @@ func (a *SUBController) subJsons(c *gin.Context) {
 	if a.maybeServeSubPage(c) {
 		return
 	}
+	a.serveJson(c, a.jsonAlwaysArray, "text/plain; charset=utf-8")
+}
+
+func (a *SUBController) serveJson(c *gin.Context, alwaysReturnArray bool, contentType string) {
 	subId := c.Param("subid")
 	scheme, host, hostWithPort, _ := a.subService.ResolveRequest(c)
-	jsonSub, header, err := a.subJsonService.GetJson(subId, host)
+	jsonSub, header, err := a.subJsonService.GetJson(subId, host, alwaysReturnArray)
 	if err != nil || len(jsonSub) == 0 {
 		writeSubError(c, err)
 	} else {
@@ -399,7 +617,7 @@ func (a *SUBController) subJsons(c *gin.Context) {
 		}
 		a.ApplyCommonHeaders(c, header, a.updateInterval, a.subTitle, a.subSupportUrl, profileUrl, a.subAnnounce, a.subEnableRouting, a.subRoutingRules, a.subHideSettings)
 
-		c.String(200, jsonSub)
+		c.Data(200, contentType, []byte(jsonSub))
 	}
 }
 

+ 329 - 0
internal/sub/controller_test.go

@@ -2,10 +2,18 @@ package sub
 
 import (
 	"bytes"
+	"encoding/base64"
+	"net/http"
+	"net/http/httptest"
 	"os"
 	"path/filepath"
+	"strings"
 	"testing"
 	"time"
+
+	"github.com/gin-gonic/gin"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/web/service"
 )
 
 // newTestSUBController builds a controller with just the bits loadSubTemplate
@@ -14,6 +22,327 @@ func newTestSUBController() *SUBController {
 	return &SUBController{subTemplateCache: map[string]*cachedSubTemplate{}}
 }
 
+type subscriptionTestRouterConfig struct {
+	clashAutoDetect     bool
+	clashUserAgentRegex string
+	jsonAutoDetect      bool
+	jsonUserAgentRegex  string
+	jsonAlwaysArray     bool
+}
+
+func newSubscriptionTestRouter(config subscriptionTestRouterConfig) *gin.Engine {
+	router := gin.New()
+	options := []SUBControllerOption{
+		WithSUBJsonEnabled(true),
+		WithSUBClashEnabled(true),
+	}
+	if config.clashAutoDetect {
+		options = append(options, WithSUBClashAutoDetect(true))
+	}
+	if config.clashUserAgentRegex != "" {
+		options = append(options, WithSUBClashUserAgentRegex(config.clashUserAgentRegex))
+	}
+	if config.jsonAutoDetect {
+		options = append(options, WithSUBJsonAutoDetect(true))
+	}
+	if config.jsonUserAgentRegex != "" {
+		options = append(options, WithSUBJsonUserAgentRegex(config.jsonUserAgentRegex))
+	}
+	if config.jsonAlwaysArray {
+		options = append(options, WithSUBJsonAlwaysArray(true))
+	}
+	NewSUBController(router.Group("/"), options...)
+	return router
+}
+
+func TestNewSUBControllerOptions(t *testing.T) {
+	gin.SetMode(gin.TestMode)
+	defaults := NewSUBController(gin.New().Group("/"))
+	if defaults.subPath != "/sub/" || defaults.subJsonPath != "/json/" || defaults.subClashPath != "/clash/" {
+		t.Fatalf("default paths = %q, %q, %q", defaults.subPath, defaults.subJsonPath, defaults.subClashPath)
+	}
+	if !defaults.subEncrypt || defaults.updateInterval != "12" {
+		t.Fatalf("default encryption/update = %v, %q", defaults.subEncrypt, defaults.updateInterval)
+	}
+	if defaults.subService.remarkTemplate != service.DefaultRemarkTemplate {
+		t.Fatalf("default remark template = %q", defaults.subService.remarkTemplate)
+	}
+	if defaults.jsonEnabled || defaults.clashEnabled {
+		t.Fatalf("format endpoints enabled by default: json=%v clash=%v", defaults.jsonEnabled, defaults.clashEnabled)
+	}
+
+	configured := NewSUBController(
+		gin.New().Group("/"),
+		WithSUBPath("/custom/"),
+		WithSUBJsonEnabled(true),
+		WithSUBEncryption(false),
+		WithSUBUpdateInterval("24"),
+	)
+	if configured.subPath != "/custom/" || !configured.jsonEnabled || configured.subEncrypt || configured.updateInterval != "24" {
+		t.Fatalf("configured values were not applied: path=%q json=%v encrypt=%v update=%q",
+			configured.subPath, configured.jsonEnabled, configured.subEncrypt, configured.updateInterval)
+	}
+}
+
+func TestShouldAutoServeClash(t *testing.T) {
+	tests := []struct {
+		name         string
+		autoDetect   bool
+		clashEnabled bool
+		wantsHTML    bool
+		userAgent    string
+		pattern      string
+		want         bool
+	}{
+		{name: "clash verge", autoDetect: true, clashEnabled: true, userAgent: "Clash-Verge/v2.4.2", want: true},
+		{name: "mihomo", autoDetect: true, clashEnabled: true, userAgent: "mihomo/1.19.12", want: true},
+		{name: "clash case insensitive", autoDetect: true, clashEnabled: true, userAgent: "CLASH-META/1.0", want: true},
+		{name: "flclash covered by clash", autoDetect: true, clashEnabled: true, userAgent: "FlClash/0.8.91", want: true},
+		{name: "generic client raw fallback", autoDetect: true, clashEnabled: true, userAgent: "GenericClient/1.10.0"},
+		{name: "other client raw fallback", autoDetect: true, clashEnabled: true, userAgent: "OtherClient/2.2"},
+		{name: "unknown raw fallback", autoDetect: true, clashEnabled: true, userAgent: "CustomClient/1.0"},
+		{name: "empty raw fallback", autoDetect: true, clashEnabled: true},
+		{name: "browser HTML wins", autoDetect: true, clashEnabled: true, wantsHTML: true, userAgent: "Clash-Verge/v2.4.2"},
+		{name: "disabled by default", clashEnabled: true, userAgent: "mihomo/1.19.12"},
+		{name: "clash endpoint disabled", autoDetect: true, userAgent: "mihomo/1.19.12"},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			got := shouldAutoServeClash(tt.autoDetect, tt.clashEnabled, tt.wantsHTML, tt.userAgent, compileUserAgentRegex("Clash/Mihomo", tt.pattern, service.DefaultSubClashUserAgentRegex))
+			if got != tt.want {
+				t.Fatalf("shouldAutoServeClash() = %v, want %v", got, tt.want)
+			}
+		})
+	}
+}
+
+func TestShouldAutoServeClashUsesConfiguredRegex(t *testing.T) {
+	configured := compileUserAgentRegex("Clash/Mihomo", `(?i)^custom-client/`, service.DefaultSubClashUserAgentRegex)
+	if !shouldAutoServeClash(true, true, false, "Custom-Client/1.0", configured) {
+		t.Fatal("configured User-Agent regex did not match")
+	}
+	if shouldAutoServeClash(true, true, false, "Mihomo/1.19", configured) {
+		t.Fatal("built-in User-Agent matched after a custom regex replaced it")
+	}
+}
+
+func TestShouldAutoServeJson(t *testing.T) {
+	configured := compileUserAgentRegex("Xray JSON", `(?i)^jsonclient([ /]|$)`, service.DefaultSubJsonUserAgentRegex)
+	for _, userAgent := range []string{"JsonClient/1.6.32", "jsonclient 1.6.32"} {
+		if !shouldAutoServeJson(true, true, false, userAgent, configured) {
+			t.Errorf("configured Xray JSON regex did not match %q", userAgent)
+		}
+	}
+	for _, userAgent := range []string{"GenericClient/1.10.0", "OtherClient/2.2", "ThirdClient/7.0", "CustomClient/1.0"} {
+		if shouldAutoServeJson(true, true, false, userAgent, configured) {
+			t.Errorf("configured Xray JSON regex unexpectedly matched %q", userAgent)
+		}
+	}
+	if shouldAutoServeJson(false, true, false, "JsonClient/1.6.32", configured) {
+		t.Fatal("disabled Xray JSON auto-detection matched")
+	}
+	if shouldAutoServeJson(true, false, false, "JsonClient/1.6.32", configured) {
+		t.Fatal("disabled JSON endpoint matched")
+	}
+	if shouldAutoServeJson(true, true, true, "JsonClient/1.6.32", configured) {
+		t.Fatal("browser HTML request matched Xray JSON")
+	}
+
+	empty := compileUserAgentRegex("Xray JSON", "", service.DefaultSubJsonUserAgentRegex)
+	if empty != nil {
+		t.Fatal("empty Xray JSON default should not compile to a matcher")
+	}
+	if shouldAutoServeJson(true, true, false, "JsonClient/1.6.32", empty) {
+		t.Fatal("empty Xray JSON default should not auto-serve")
+	}
+}
+
+func TestShouldAutoServeJsonUsesConfiguredRegex(t *testing.T) {
+	configured := compileUserAgentRegex("Xray JSON", `(?i)^custom-json/`, service.DefaultSubJsonUserAgentRegex)
+	if !shouldAutoServeJson(true, true, false, "Custom-JSON/1.0", configured) {
+		t.Fatal("configured Xray JSON User-Agent regex did not match")
+	}
+	if shouldAutoServeJson(true, true, false, "OtherClient/1.10.0", configured) {
+		t.Fatal("unrelated User-Agent matched after a custom regex was configured")
+	}
+}
+
+func TestCompileUserAgentRegexFallsBackForInvalidPattern(t *testing.T) {
+	compiled := compileUserAgentRegex("Clash/Mihomo", "[", service.DefaultSubClashUserAgentRegex)
+	if !compiled.MatchString("Mihomo/1.19") {
+		t.Fatal("invalid regex did not fall back to the default pattern")
+	}
+}
+
+func TestSanitizeUserAgentForLog(t *testing.T) {
+	if got := sanitizeUserAgentForLog("client/1.0\r\nforged\tline"); got != "client/1.0  forged line" {
+		t.Fatalf("sanitizeUserAgentForLog() = %q", got)
+	}
+	long := strings.Repeat("界", 513)
+	if got := sanitizeUserAgentForLog(long); len([]rune(got)) != 512 {
+		t.Fatalf("sanitized User-Agent length = %d runes, want 512", len([]rune(got)))
+	}
+}
+
+func TestStandardSubscriptionAutoDetectsFormats(t *testing.T) {
+	seedSubDB(t)
+	seedSubInbound(t, "s1", "auto", 4480, 1, `{"network":"tcp","security":"none"}`)
+	gin.SetMode(gin.TestMode)
+
+	t.Run("recognized client receives YAML", func(t *testing.T) {
+		req := httptest.NewRequest(http.MethodGet, "http://sub.example.com/sub/s1", nil)
+		req.Header.Set("User-Agent", "Clash-Verge/v2.4.2")
+		resp := httptest.NewRecorder()
+
+		newSubscriptionTestRouter(subscriptionTestRouterConfig{clashAutoDetect: true}).ServeHTTP(resp, req)
+
+		if resp.Code != http.StatusOK {
+			t.Fatalf("status = %d, want 200; body=%s", resp.Code, resp.Body.String())
+		}
+		if got := resp.Header().Get("Content-Type"); got != "application/yaml; charset=utf-8" {
+			t.Fatalf("Content-Type = %q, want YAML", got)
+		}
+		if body := resp.Body.String(); !strings.Contains(body, "proxies:") || !strings.Contains(body, "type: vless") {
+			t.Fatalf("auto-detected body is not Clash YAML:\n%s", body)
+		}
+	})
+
+	t.Run("Clash wins when both format regexes match", func(t *testing.T) {
+		req := httptest.NewRequest(http.MethodGet, "http://sub.example.com/sub/s1", nil)
+		req.Header.Set("User-Agent", "Hybrid/1.0")
+		resp := httptest.NewRecorder()
+
+		newSubscriptionTestRouter(subscriptionTestRouterConfig{
+			clashAutoDetect:     true,
+			clashUserAgentRegex: `(?i)^hybrid/`,
+			jsonAutoDetect:      true,
+			jsonUserAgentRegex:  `(?i)^hybrid/`,
+		}).ServeHTTP(resp, req)
+
+		if resp.Code != http.StatusOK {
+			t.Fatalf("status = %d, want 200; body=%s", resp.Code, resp.Body.String())
+		}
+		if got := resp.Header().Get("Content-Type"); got != "application/yaml; charset=utf-8" {
+			t.Fatalf("Content-Type = %q, want Clash YAML", got)
+		}
+	})
+
+	t.Run("disabled setting preserves raw base64", func(t *testing.T) {
+		req := httptest.NewRequest(http.MethodGet, "http://sub.example.com/sub/s1", nil)
+		req.Header.Set("User-Agent", "Clash-Verge/v2.4.2")
+		resp := httptest.NewRecorder()
+
+		newSubscriptionTestRouter(subscriptionTestRouterConfig{}).ServeHTTP(resp, req)
+
+		if resp.Code != http.StatusOK {
+			t.Fatalf("status = %d, want 200; body=%s", resp.Code, resp.Body.String())
+		}
+		decoded, err := base64.StdEncoding.DecodeString(resp.Body.String())
+		if err != nil {
+			t.Fatalf("raw response is not base64: %v", err)
+		}
+		if !strings.Contains(string(decoded), "vless://") {
+			t.Fatalf("decoded raw response lacks VLESS link: %s", decoded)
+		}
+	})
+
+	t.Run("configured regex controls detection", func(t *testing.T) {
+		req := httptest.NewRequest(http.MethodGet, "http://sub.example.com/sub/s1", nil)
+		req.Header.Set("User-Agent", "Mihomo/1.19")
+		resp := httptest.NewRecorder()
+
+		newSubscriptionTestRouter(subscriptionTestRouterConfig{
+			clashAutoDetect:     true,
+			clashUserAgentRegex: `(?i)^custom-client/`,
+		}).ServeHTTP(resp, req)
+
+		if resp.Code != http.StatusOK {
+			t.Fatalf("status = %d, want 200; body=%s", resp.Code, resp.Body.String())
+		}
+		if got := resp.Header().Get("Content-Type"); got == "application/yaml; charset=utf-8" {
+			t.Fatalf("Content-Type = %q, custom regex should preserve raw response", got)
+		}
+	})
+
+	t.Run("unrecognized client preserves raw base64", func(t *testing.T) {
+		req := httptest.NewRequest(http.MethodGet, "http://sub.example.com/sub/s1", nil)
+		req.Header.Set("User-Agent", "GenericClient/1.10.0")
+		resp := httptest.NewRecorder()
+
+		newSubscriptionTestRouter(subscriptionTestRouterConfig{
+			clashAutoDetect: true,
+			jsonAutoDetect:  true,
+		}).ServeHTTP(resp, req)
+
+		if resp.Code != http.StatusOK {
+			t.Fatalf("status = %d, want 200; body=%s", resp.Code, resp.Body.String())
+		}
+		decoded, err := base64.StdEncoding.DecodeString(resp.Body.String())
+		if err != nil {
+			t.Fatalf("raw response is not base64: %v", err)
+		}
+		if !strings.Contains(string(decoded), "vless://") {
+			t.Fatalf("decoded raw response lacks VLESS link: %s", decoded)
+		}
+	})
+
+	t.Run("recognized Xray JSON client receives configuration array", func(t *testing.T) {
+		req := httptest.NewRequest(http.MethodGet, "http://sub.example.com/sub/s1", nil)
+		req.Header.Set("User-Agent", "JsonClient/1.6.32")
+		resp := httptest.NewRecorder()
+
+		newSubscriptionTestRouter(subscriptionTestRouterConfig{
+			jsonAutoDetect:     true,
+			jsonUserAgentRegex: `(?i)^jsonclient([ /]|$)`,
+		}).ServeHTTP(resp, req)
+
+		if resp.Code != http.StatusOK {
+			t.Fatalf("status = %d, want 200; body=%s", resp.Code, resp.Body.String())
+		}
+		if got := resp.Header().Get("Content-Type"); got != "application/json; charset=utf-8" {
+			t.Fatalf("Content-Type = %q, want JSON", got)
+		}
+		if body := strings.TrimSpace(resp.Body.String()); !strings.HasPrefix(body, "[") || !strings.Contains(body, `"outbounds"`) {
+			t.Fatalf("auto-detected body is not an Xray JSON configuration array:\n%s", body)
+		}
+	})
+
+	t.Run("explicit JSON endpoint preserves legacy single object by default", func(t *testing.T) {
+		req := httptest.NewRequest(http.MethodGet, "http://sub.example.com/json/s1", nil)
+		resp := httptest.NewRecorder()
+
+		newSubscriptionTestRouter(subscriptionTestRouterConfig{}).ServeHTTP(resp, req)
+
+		if resp.Code != http.StatusOK {
+			t.Fatalf("status = %d, want 200; body=%s", resp.Code, resp.Body.String())
+		}
+		if got := resp.Header().Get("Content-Type"); got != "text/plain; charset=utf-8" {
+			t.Fatalf("Content-Type = %q, want legacy text/plain", got)
+		}
+		if body := strings.TrimSpace(resp.Body.String()); !strings.HasPrefix(body, "{") {
+			t.Fatalf("legacy explicit JSON body is not an object: %s", body)
+		}
+	})
+
+	t.Run("explicit JSON endpoint can follow XTLS array standard", func(t *testing.T) {
+		req := httptest.NewRequest(http.MethodGet, "http://sub.example.com/json/s1", nil)
+		resp := httptest.NewRecorder()
+
+		newSubscriptionTestRouter(subscriptionTestRouterConfig{jsonAlwaysArray: true}).ServeHTTP(resp, req)
+
+		if resp.Code != http.StatusOK {
+			t.Fatalf("status = %d, want 200; body=%s", resp.Code, resp.Body.String())
+		}
+		if got := resp.Header().Get("Content-Type"); got != "text/plain; charset=utf-8" {
+			t.Fatalf("Content-Type = %q, want legacy text/plain", got)
+		}
+		if body := strings.TrimSpace(resp.Body.String()); !strings.HasPrefix(body, "[") {
+			t.Fatalf("standards-compliant explicit JSON body is not an array: %s", body)
+		}
+	})
+}
+
 func writeFile(t *testing.T, path, content string) {
 	t.Helper()
 	if err := os.WriteFile(path, []byte(content), 0o644); err != nil {

+ 19 - 1
internal/sub/external_only_sub_test.go

@@ -1,6 +1,7 @@
 package sub
 
 import (
+	"encoding/json"
 	"strings"
 	"testing"
 
@@ -27,7 +28,8 @@ func TestJsonAndClashServeExternalLinkOnlySub(t *testing.T) {
 
 	base := NewSubService("")
 
-	jsonOut, _, err := NewSubJsonService("", "", "", base).GetJson("ext-only", "sub.example.com")
+	jsonService := NewSubJsonService("", "", "", base)
+	jsonOut, _, err := jsonService.GetJson("ext-only", "sub.example.com", false)
 	if err != nil {
 		t.Fatalf("GetJson err = %v", err)
 	}
@@ -37,6 +39,22 @@ func TestJsonAndClashServeExternalLinkOnlySub(t *testing.T) {
 	if !strings.Contains(jsonOut, "DE-Provider") {
 		t.Fatalf("GetJson missing external remark: %s", jsonOut)
 	}
+	var config map[string]any
+	if err := json.Unmarshal([]byte(jsonOut), &config); err != nil {
+		t.Fatalf("legacy GetJson must return an object for a single profile: %v; body=%s", err, jsonOut)
+	}
+
+	standardOut, _, err := jsonService.GetJson("ext-only", "sub.example.com", true)
+	if err != nil {
+		t.Fatalf("standards-compliant GetJson err = %v", err)
+	}
+	var configs []map[string]any
+	if err := json.Unmarshal([]byte(standardOut), &configs); err != nil {
+		t.Fatalf("standards-compliant GetJson must return an array for a single profile: %v; body=%s", err, standardOut)
+	}
+	if len(configs) != 1 {
+		t.Fatalf("standards-compliant GetJson profile count = %d, want 1", len(configs))
+	}
 
 	clashOut, _, err := NewSubClashService(false, "", base).GetClash("ext-only", "sub.example.com")
 	if err != nil {

+ 2 - 2
internal/sub/host_sub_test.go

@@ -304,7 +304,7 @@ func TestSub_HostSockoptJSON(t *testing.T) {
 		SockoptParams: `{"tcpFastOpen":true}`,
 	})
 	js := NewSubJsonService("", "", "", NewSubService(""))
-	out, _, err := js.GetJson("s1", "req.example.com")
+	out, _, err := js.GetJson("s1", "req.example.com", false)
 	if err != nil {
 		t.Fatalf("GetJson: %v", err)
 	}
@@ -322,7 +322,7 @@ func TestSub_HostMuxJSON(t *testing.T) {
 		MuxParams: `{"enabled":true,"concurrency":8}`,
 	})
 	js := NewSubJsonService("", "", "", NewSubService(""))
-	out, _, err := js.GetJson("s1", "req.example.com")
+	out, _, err := js.GetJson("s1", "req.example.com", false)
 	if err != nil {
 		t.Fatalf("GetJson: %v", err)
 	}

+ 2 - 3
internal/sub/json_service.go

@@ -58,7 +58,7 @@ func NewSubJsonService(mux string, rules string, finalMask string, subService *S
 }
 
 // GetJson generates a JSON subscription configuration for the given subscription ID and host.
-func (s *SubJsonService) GetJson(subId string, host string) (string, string, error) {
+func (s *SubJsonService) GetJson(subId string, host string, alwaysReturnArray bool) (string, string, error) {
 	subReq := s.SubService.ForRequest(host)
 	subReq.subscriptionBody = true
 	inbounds, err := subReq.getInboundsBySubId(subId)
@@ -125,9 +125,8 @@ func (s *SubJsonService) GetJson(subId string, host string) (string, string, err
 	}
 	traffic, _ := subReq.AggregateTrafficByEmails(emails)
 
-	// Combile outbounds
 	var finalJson []byte
-	if len(configArray) == 1 {
+	if len(configArray) == 1 && !alwaysReturnArray {
 		finalJson, _ = json.MarshalIndent(configArray[0], "", "  ")
 	} else {
 		finalJson, _ = json.MarshalIndent(configArray, "", "  ")

+ 54 - 5
internal/sub/sub.go

@@ -89,6 +89,31 @@ func (s *Server) initRouter() (*gin.Engine, error) {
 		return nil, err
 	}
 
+	subClashAutoDetect, err := s.settingService.GetSubClashAutoDetect()
+	if err != nil {
+		subClashAutoDetect = false
+	}
+
+	subJsonAutoDetect, err := s.settingService.GetSubJsonAutoDetect()
+	if err != nil {
+		subJsonAutoDetect = false
+	}
+
+	subJsonAlwaysArray, err := s.settingService.GetSubJsonAlwaysArray()
+	if err != nil {
+		subJsonAlwaysArray = false
+	}
+
+	subJsonUserAgentRegex, err := s.settingService.GetSubJsonUserAgentRegex()
+	if err != nil {
+		subJsonUserAgentRegex = service.DefaultSubJsonUserAgentRegex
+	}
+
+	subClashUserAgentRegex, err := s.settingService.GetSubClashUserAgentRegex()
+	if err != nil {
+		subClashUserAgentRegex = service.DefaultSubClashUserAgentRegex
+	}
+
 	// Set base_path based on LinksPath for template rendering
 	// Ensure LinksPath ends with "/" for proper asset URL generation
 	basePath := LinksPath
@@ -239,11 +264,35 @@ func (s *Server) initRouter() (*gin.Engine, error) {
 
 	g := engine.Group("/")
 
-	s.sub = NewSUBController(
-		g, LinksPath, JsonPath, ClashPath, subJsonEnable, subClashEnable, Encrypt, RemarkTemplate, SubUpdates,
-		SubJsonMux, SubJsonRules, SubJsonFinalMask, SubClashEnableRouting, SubClashRules, SubTitle, SubSupportUrl,
-		SubProfileUrl, SubAnnounce, SubEnableRouting, SubRoutingRules, SubHideSettings,
-		SubIncyEnableRouting, SubIncyRoutingRules)
+	s.sub = NewSUBController(g,
+		WithSUBPath(LinksPath),
+		WithSUBJsonPath(JsonPath),
+		WithSUBClashPath(ClashPath),
+		WithSUBClashAutoDetect(subClashAutoDetect),
+		WithSUBClashUserAgentRegex(subClashUserAgentRegex),
+		WithSUBJsonAutoDetect(subJsonAutoDetect),
+		WithSUBJsonUserAgentRegex(subJsonUserAgentRegex),
+		WithSUBJsonAlwaysArray(subJsonAlwaysArray),
+		WithSUBJsonEnabled(subJsonEnable),
+		WithSUBClashEnabled(subClashEnable),
+		WithSUBEncryption(Encrypt),
+		WithSUBRemarkTemplate(RemarkTemplate),
+		WithSUBUpdateInterval(SubUpdates),
+		WithSUBJsonMux(SubJsonMux),
+		WithSUBJsonRules(SubJsonRules),
+		WithSUBJsonFinalMask(SubJsonFinalMask),
+		WithSUBClashEnableRouting(SubClashEnableRouting),
+		WithSUBClashRules(SubClashRules),
+		WithSUBTitle(SubTitle),
+		WithSUBSupportURL(SubSupportUrl),
+		WithSUBProfileURL(SubProfileUrl),
+		WithSUBAnnounce(SubAnnounce),
+		WithSUBEnableRouting(SubEnableRouting),
+		WithSUBRoutingRules(SubRoutingRules),
+		WithSUBHideSettings(SubHideSettings),
+		WithSUBIncyEnableRouting(SubIncyEnableRouting),
+		WithSUBIncyRoutingRules(SubIncyRoutingRules),
+	)
 
 	return engine, nil
 }

+ 1 - 1
internal/sub/sub_scale_test.go

@@ -212,7 +212,7 @@ func TestGetSubsScale(t *testing.T) {
 			jsonSvc := NewSubJsonService("", "", "", &SubService{})
 			start = time.Now()
 			for range reps {
-				body, _, err := jsonSvc.GetJson(scaleTargetSubId, "sub.example.com")
+				body, _, err := jsonSvc.GetJson(scaleTargetSubId, "sub.example.com", false)
 				if err != nil {
 					t.Fatalf("GetJson: %v", err)
 				}

+ 1 - 1
internal/sub/vless_route_sub_test.go

@@ -52,7 +52,7 @@ func TestSub_HostVlessRoute_JSON(t *testing.T) {
 	seedHost(t, &model.Host{InboundId: ib.Id, SortOrder: 1, Remark: "J", Address: "j.cdn.com", Port: 8443, Security: "tls", VlessRoute: "443"})
 
 	js := NewSubJsonService("", "", "", NewSubService(""))
-	out, _, err := js.GetJson("s1", "req.example.com")
+	out, _, err := js.GetJson("s1", "req.example.com", false)
 	if err != nil {
 		t.Fatalf("GetJson: %v", err)
 	}

+ 19 - 0
internal/web/controller/setting.go

@@ -2,6 +2,7 @@ package controller
 
 import (
 	"errors"
+	"net/http"
 	"strconv"
 	"time"
 
@@ -38,6 +39,10 @@ type updateSettingForm struct {
 	ClearSmtpPassword bool   `json:"clearSmtpPassword" form:"clearSmtpPassword"`
 }
 
+type validateRegexForm struct {
+	Regex string `json:"regex" form:"regex"`
+}
+
 // SettingController handles settings and user management operations.
 type SettingController struct {
 	settingService  service.SettingService
@@ -61,6 +66,7 @@ func (a *SettingController) initRouter(g *gin.RouterGroup) {
 	g.POST("/all", a.getAllSetting)
 	g.POST("/defaultSettings", a.getDefaultSettings)
 	g.POST("/update", a.updateSetting)
+	g.POST("/validateRegex", a.validateRegex)
 	g.POST("/updateUser", a.updateUser)
 	g.POST("/restartPanel", a.restartPanel)
 	g.GET("/getDefaultJsonConfig", a.getDefaultXrayConfig)
@@ -72,6 +78,19 @@ func (a *SettingController) initRouter(g *gin.RouterGroup) {
 	g.POST("/testTgBot", a.testTgBot)
 }
 
+func (a *SettingController) validateRegex(c *gin.Context) {
+	form := &validateRegexForm{}
+	if err := c.ShouldBind(form); err != nil {
+		pureJsonMsg(c, http.StatusOK, false, err.Error())
+		return
+	}
+	if err := service.ValidateRegex(form.Regex); err != nil {
+		pureJsonMsg(c, http.StatusOK, false, err.Error())
+		return
+	}
+	pureJsonMsg(c, http.StatusOK, true, "")
+}
+
 // getAllSetting retrieves all current settings as the browser-safe view:
 // secret values are redacted and surfaced as has* presence flags instead.
 func (a *SettingController) getAllSetting(c *gin.Context) {

+ 46 - 0
internal/web/controller/setting_test.go

@@ -0,0 +1,46 @@
+package controller
+
+import (
+	"net/http"
+	"net/http/httptest"
+	"strings"
+	"testing"
+
+	"github.com/gin-gonic/gin"
+)
+
+func TestValidateRegex(t *testing.T) {
+	gin.SetMode(gin.TestMode)
+	router := gin.New()
+	NewSettingController(router.Group("/panel/api"))
+
+	tests := []struct {
+		name    string
+		body    string
+		success bool
+	}{
+		{name: "Go RE2 inline flag", body: `{"regex":"(?m)^general-purpose$"}`, success: true},
+		{name: "invalid expression", body: `{"regex":"["}`, success: false},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			req := httptest.NewRequest(http.MethodPost, "/panel/api/setting/validateRegex", strings.NewReader(tt.body))
+			req.Header.Set("Content-Type", "application/json")
+			resp := httptest.NewRecorder()
+
+			router.ServeHTTP(resp, req)
+
+			if resp.Code != http.StatusOK {
+				t.Fatalf("status = %d, want 200; body=%s", resp.Code, resp.Body.String())
+			}
+			needle := `"success":true`
+			if !tt.success {
+				needle = `"success":false`
+			}
+			if !strings.Contains(resp.Body.String(), needle) {
+				t.Fatalf("body = %s, want %s", resp.Body.String(), needle)
+			}
+		})
+	}
+}

+ 5 - 0
internal/web/entity/entity.go

@@ -65,6 +65,11 @@ type AllSetting struct {
 
 	SubEnable                   bool   `json:"subEnable" form:"subEnable"`
 	SubJsonEnable               bool   `json:"subJsonEnable" form:"subJsonEnable"`
+	SubJsonAutoDetect           bool   `json:"subJsonAutoDetect" form:"subJsonAutoDetect"`
+	SubJsonAlwaysArray          bool   `json:"subJsonAlwaysArray" form:"subJsonAlwaysArray"`
+	SubJsonUserAgentRegex       string `json:"subJsonUserAgentRegex" form:"subJsonUserAgentRegex"`
+	SubClashAutoDetect          bool   `json:"subClashAutoDetect" form:"subClashAutoDetect"`
+	SubClashUserAgentRegex      string `json:"subClashUserAgentRegex" form:"subClashUserAgentRegex"`
 	SubTitle                    string `json:"subTitle" form:"subTitle"`
 	SubSupportUrl               string `json:"subSupportUrl" form:"subSupportUrl"`
 	SubProfileUrl               string `json:"subProfileUrl" form:"subProfileUrl"`

+ 79 - 1
internal/web/service/setting.go

@@ -9,6 +9,7 @@ import (
 	"net/http"
 	"os"
 	"reflect"
+	"regexp"
 	"strconv"
 	"strings"
 	"time"
@@ -32,6 +33,13 @@ import (
 //go:embed config.json
 var xrayTemplateConfig string
 
+const (
+	DefaultSubClashUserAgentRegex = `(?i)(clash|mihomo)`
+	DefaultSubJsonUserAgentRegex  = ``
+	DefaultRemarkTemplate         = "{{INBOUND}}-{{EMAIL}}|📊{{TRAFFIC_LEFT}}|⏳{{DAYS_LEFT}}D"
+	maxRegexLength                = 2048
+)
+
 var defaultValueMap = map[string]string{
 	"xrayTemplateConfig": xrayTemplateConfig,
 	"webListen":          "",
@@ -57,7 +65,7 @@ var defaultValueMap = map[string]string{
 	"pageSize":                    "25",
 	"expireDiff":                  "0",
 	"trafficDiff":                 "0",
-	"remarkTemplate":              "{{INBOUND}}-{{EMAIL}}|📊{{TRAFFIC_LEFT}}|⏳{{DAYS_LEFT}}D",
+	"remarkTemplate":              DefaultRemarkTemplate,
 	"timeLocation":                "Local",
 	"tgBotEnable":                 "false",
 	"tgBotToken":                  "",
@@ -73,6 +81,11 @@ var defaultValueMap = map[string]string{
 	"twoFactorToken":              "",
 	"subEnable":                   "true",
 	"subJsonEnable":               "false",
+	"subJsonAutoDetect":           "false",
+	"subJsonAlwaysArray":          "false",
+	"subJsonUserAgentRegex":       "",
+	"subClashAutoDetect":          "false",
+	"subClashUserAgentRegex":      "",
 	"subTitle":                    "",
 	"subSupportUrl":               "",
 	"subProfileUrl":               "",
@@ -707,6 +720,26 @@ func (s *SettingService) GetSubJsonEnable() (bool, error) {
 	return s.getBool("subJsonEnable")
 }
 
+func (s *SettingService) GetSubJsonAutoDetect() (bool, error) {
+	return s.getBool("subJsonAutoDetect")
+}
+
+func (s *SettingService) GetSubJsonAlwaysArray() (bool, error) {
+	return s.getBool("subJsonAlwaysArray")
+}
+
+func (s *SettingService) GetSubJsonUserAgentRegex() (string, error) {
+	return s.getString("subJsonUserAgentRegex")
+}
+
+func (s *SettingService) GetSubClashAutoDetect() (bool, error) {
+	return s.getBool("subClashAutoDetect")
+}
+
+func (s *SettingService) GetSubClashUserAgentRegex() (string, error) {
+	return s.getString("subClashUserAgentRegex")
+}
+
 func (s *SettingService) GetSubTitle() (string, error) {
 	return s.getString("subTitle")
 }
@@ -1119,6 +1152,9 @@ func (s *SettingService) UpdateAllSetting(allSetting *entity.AllSetting, clears
 	if err := validateSettingsURLs(allSetting); err != nil {
 		return err
 	}
+	if err := validateSubUserAgentRegexes(allSetting); err != nil {
+		return err
+	}
 	if err := allSetting.CheckValid(); err != nil {
 		return err
 	}
@@ -1159,6 +1195,48 @@ func (s *SettingService) UpdateAllSetting(allSetting *entity.AllSetting, clears
 	})
 }
 
+func validateSubUserAgentRegexes(allSetting *entity.AllSetting) error {
+	jsonPattern, err := validateSubUserAgentRegex("Xray JSON", allSetting.SubJsonUserAgentRegex, DefaultSubJsonUserAgentRegex)
+	if err != nil {
+		return err
+	}
+	clashPattern, err := validateSubUserAgentRegex("Clash/Mihomo", allSetting.SubClashUserAgentRegex, DefaultSubClashUserAgentRegex)
+	if err != nil {
+		return err
+	}
+	allSetting.SubJsonUserAgentRegex = jsonPattern
+	allSetting.SubClashUserAgentRegex = clashPattern
+	return nil
+}
+
+func validateSubUserAgentRegex(name, pattern, defaultPattern string) (string, error) {
+	pattern = strings.TrimSpace(pattern)
+	effectivePattern := pattern
+	if effectivePattern == "" {
+		effectivePattern = defaultPattern
+	}
+	if len(effectivePattern) > maxRegexLength {
+		return "", common.NewErrorf("%s User-Agent regex must not exceed %d characters", name, maxRegexLength)
+	}
+	if _, err := regexp.Compile(effectivePattern); err != nil {
+		return "", common.NewErrorf("%s User-Agent regex is invalid: %v", name, err)
+	}
+	// Return the original pattern (empty string if cleared) so the caller
+	// can distinguish "user explicitly set empty" from "user set a value".
+	// The empty value is stored in the DB and inherited as runtime default.
+	return pattern, nil
+}
+
+func ValidateRegex(pattern string) error {
+	if len(pattern) > maxRegexLength {
+		return common.NewErrorf("Regular expression must not exceed %d characters", maxRegexLength)
+	}
+	if _, err := regexp.Compile(pattern); err != nil {
+		return common.NewError("Regular expression is invalid:", err)
+	}
+	return nil
+}
+
 func (s *SettingService) preserveRedactedSecrets(allSetting *entity.AllSetting, clears SecretClears) error {
 	if !clears.TgBotToken && strings.TrimSpace(allSetting.TgBotToken) == "" {
 		value, err := s.GetTgBotToken()

+ 207 - 0
internal/web/service/setting_clash_test.go

@@ -0,0 +1,207 @@
+package service
+
+import (
+	"strings"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+)
+
+func TestValidateRegex(t *testing.T) {
+	for _, pattern := range []string{"", `(?i)^jsonclient([ /]|$)`, `(?m)^general-purpose$`} {
+		if err := ValidateRegex(pattern); err != nil {
+			t.Errorf("ValidateRegex(%q) returned %v", pattern, err)
+		}
+	}
+	for _, pattern := range []string{"[", strings.Repeat("a", 2049)} {
+		if err := ValidateRegex(pattern); err == nil {
+			t.Errorf("ValidateRegex(%q) accepted an invalid pattern", pattern)
+		}
+	}
+}
+
+func TestSubscriptionAutoDetectDefaultsWithoutStoredRows(t *testing.T) {
+	setupSettingTestDB(t)
+	keys := []string{"subClashAutoDetect", "subClashUserAgentRegex", "subJsonAutoDetect", "subJsonAlwaysArray", "subJsonUserAgentRegex"}
+	if err := database.GetDB().Where("key IN ?", keys).Delete(&model.Setting{}).Error; err != nil {
+		t.Fatal(err)
+	}
+
+	s := &SettingService{}
+	clashEnabled, err := s.GetSubClashAutoDetect()
+	if err != nil {
+		t.Fatal(err)
+	}
+	jsonEnabled, err := s.GetSubJsonAutoDetect()
+	if err != nil {
+		t.Fatal(err)
+	}
+	jsonAlwaysArray, err := s.GetSubJsonAlwaysArray()
+	if err != nil {
+		t.Fatal(err)
+	}
+	clashRegex, err := s.GetSubClashUserAgentRegex()
+	if err != nil {
+		t.Fatal(err)
+	}
+	jsonRegex, err := s.GetSubJsonUserAgentRegex()
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	if clashEnabled || jsonEnabled || jsonAlwaysArray {
+		t.Fatalf("missing subscription flags must default off: clashAuto=%v jsonAuto=%v jsonAlwaysArray=%v", clashEnabled, jsonEnabled, jsonAlwaysArray)
+	}
+	if clashRegex != "" {
+		t.Fatalf("missing Clash regex = %q, want empty inherited value", clashRegex)
+	}
+	if jsonRegex != "" {
+		t.Fatalf("missing JSON regex = %q, want empty inherited value", jsonRegex)
+	}
+
+	var count int64
+	if err := database.GetDB().Model(&model.Setting{}).Where("key IN ?", keys).Count(&count).Error; err != nil {
+		t.Fatal(err)
+	}
+	if count != 0 {
+		t.Fatalf("default lookup unexpectedly persisted %d setting rows", count)
+	}
+}
+
+func TestUpdateAllSettingPreservesEmptyUserAgentRegexes(t *testing.T) {
+	setupSettingTestDB(t)
+	s := &SettingService{}
+	settings, err := s.GetAllSetting()
+	if err != nil {
+		t.Fatal(err)
+	}
+	settings.SubJsonUserAgentRegex = "   "
+	settings.SubClashUserAgentRegex = ""
+
+	if err := s.UpdateAllSetting(settings, SecretClears{}); err != nil {
+		t.Fatal(err)
+	}
+
+	for _, key := range []string{"subJsonUserAgentRegex", "subClashUserAgentRegex"} {
+		var stored model.Setting
+		if err := database.GetDB().Where("key = ?", key).First(&stored).Error; err != nil {
+			t.Fatal(err)
+		}
+		if stored.Value != "" {
+			t.Fatalf("%s stored value = %q, want empty inherited value", key, stored.Value)
+		}
+	}
+}
+
+func TestUpdateAllSettingPersistsClashSubscriptionSettings(t *testing.T) {
+	setupSettingTestDB(t)
+	s := &SettingService{}
+
+	settings, err := s.GetAllSetting()
+	if err != nil {
+		t.Fatal(err)
+	}
+	if settings.SubClashAutoDetect {
+		t.Fatal("subClashAutoDetect default = true, want false")
+	}
+	if settings.SubJsonAutoDetect {
+		t.Fatal("subJsonAutoDetect default = true, want false")
+	}
+	if settings.SubJsonAlwaysArray {
+		t.Fatal("subJsonAlwaysArray default = true, want false")
+	}
+	if settings.SubJsonUserAgentRegex != "" {
+		t.Fatalf("subJsonUserAgentRegex = %q, want empty inherited value", settings.SubJsonUserAgentRegex)
+	}
+	if settings.SubClashUserAgentRegex != "" {
+		t.Fatalf("subClashUserAgentRegex = %q, want empty inherited value", settings.SubClashUserAgentRegex)
+	}
+	settings.SubClashAutoDetect = true
+	settings.SubClashUserAgentRegex = `(?i)^custom-clash/`
+	settings.SubJsonAutoDetect = true
+	settings.SubJsonAlwaysArray = true
+	settings.SubJsonUserAgentRegex = `(?i)^custom-json/`
+	settings.SubJsonEnable = true
+	settings.SubJsonPath = "/json-custom/"
+	settings.SubJsonURI = "https://subscriptions.example.com/json-custom/"
+	settings.SubClashEnable = true
+	settings.SubClashPath = "/clash-custom/"
+	settings.SubClashURI = "https://subscriptions.example.com/clash-custom/"
+	settings.SubClashEnableRouting = true
+	settings.SubClashRules = "GEOIP,private,DIRECT"
+
+	if err := s.UpdateAllSetting(settings, SecretClears{}); err != nil {
+		t.Fatal(err)
+	}
+
+	got, err := s.GetAllSetting()
+	if err != nil {
+		t.Fatal(err)
+	}
+	if !got.SubClashEnable {
+		t.Fatal("subClashEnable = false, want true")
+	}
+	if !got.SubClashAutoDetect {
+		t.Fatal("subClashAutoDetect = false, want true")
+	}
+	if !got.SubJsonAutoDetect {
+		t.Fatal("subJsonAutoDetect = false, want true")
+	}
+	if !got.SubJsonAlwaysArray {
+		t.Fatal("subJsonAlwaysArray = false, want true")
+	}
+	if !got.SubJsonEnable {
+		t.Fatal("subJsonEnable = false, want true")
+	}
+	if got.SubJsonPath != "/json-custom/" {
+		t.Fatalf("subJsonPath = %q, want %q", got.SubJsonPath, "/json-custom/")
+	}
+	if got.SubJsonURI != "https://subscriptions.example.com/json-custom/" {
+		t.Fatalf("subJsonURI = %q, want %q", got.SubJsonURI, "https://subscriptions.example.com/json-custom/")
+	}
+	if got.SubJsonUserAgentRegex != `(?i)^custom-json/` {
+		t.Fatalf("subJsonUserAgentRegex = %q, want %q", got.SubJsonUserAgentRegex, `(?i)^custom-json/`)
+	}
+	if got.SubClashUserAgentRegex != `(?i)^custom-clash/` {
+		t.Fatalf("subClashUserAgentRegex = %q, want %q", got.SubClashUserAgentRegex, `(?i)^custom-clash/`)
+	}
+	if got.SubClashPath != "/clash-custom/" {
+		t.Fatalf("subClashPath = %q, want %q", got.SubClashPath, "/clash-custom/")
+	}
+	if got.SubClashURI != "https://subscriptions.example.com/clash-custom/" {
+		t.Fatalf("subClashURI = %q, want %q", got.SubClashURI, "https://subscriptions.example.com/clash-custom/")
+	}
+	if !got.SubClashEnableRouting {
+		t.Fatal("subClashEnableRouting = false, want true")
+	}
+	if got.SubClashRules != "GEOIP,private,DIRECT" {
+		t.Fatalf("subClashRules = %q, want %q", got.SubClashRules, "GEOIP,private,DIRECT")
+	}
+}
+
+func TestUpdateAllSettingRejectsInvalidClashUserAgentRegex(t *testing.T) {
+	setupSettingTestDB(t)
+	s := &SettingService{}
+	settings, err := s.GetAllSetting()
+	if err != nil {
+		t.Fatal(err)
+	}
+	settings.SubClashUserAgentRegex = "["
+	if err := s.UpdateAllSetting(settings, SecretClears{}); err == nil {
+		t.Fatal("UpdateAllSetting accepted an invalid Clash/Mihomo User-Agent regex")
+	}
+}
+
+func TestUpdateAllSettingRejectsInvalidJsonUserAgentRegex(t *testing.T) {
+	setupSettingTestDB(t)
+	s := &SettingService{}
+	settings, err := s.GetAllSetting()
+	if err != nil {
+		t.Fatal(err)
+	}
+	settings.SubJsonUserAgentRegex = "["
+	if err := s.UpdateAllSetting(settings, SecretClears{}); err == nil {
+		t.Fatal("UpdateAllSetting accepted an invalid Xray JSON User-Agent regex")
+	}
+}

+ 14 - 1
internal/web/translation/ar-EG.json

@@ -1191,6 +1191,19 @@
       "subJsonEnable": "تمكين/تعطيل نقطة نهاية اشتراك JSON بشكل مستقل.",
       "subJsonEnableTitle": "اشتراك JSON",
       "subClashEnableTitle": "اشتراك Clash / Mihomo",
+      "subFormatsTipTitle": "إعدادات الاشتراك الخاصة بالتنسيق",
+      "subFormatsTipDesc": "اضبط مسارات URL وعناوين URL العكسية والاكتشاف التلقائي للعملاء لكل من JSON وClash / Mihomo بشكل منفصل.",
+      "subFormatsTipAction": "فتح تنسيقات الاشتراك",
+      "subJsonAutoDetect": "اكتشاف عملاء Xray JSON تلقائيًا",
+      "subJsonAutoDetectDesc": "عند التفعيل، يتلقى العملاء المتوافقون المعروفون الذين يطلبون رابط الاشتراك القياسي مصفوفة إعدادات Xray JSON تلقائيًا. يحصل العملاء الآخرون على الاستجابة الخام/Base64. يتطلب تفعيل اشتراك JSON وإعادة تشغيل اللوحة.",
+      "subJsonAlwaysArray": "إرجاع مصفوفة JSON دائمًا",
+      "subJsonAlwaysArrayDesc": "يعيد مسار اشتراك JSON الصريح كمصفوفة حتى عند وجود ملف واحد، وفقًا لمعيار XTLS. تستخدم استجابات JSON المكتشفة تلقائيًا المصفوفات دائمًا. عطّله للاحتفاظ باستجابة الكائن الواحد القديمة.",
+      "subJsonUserAgentRegex": "تعبير User-Agent لعملاء Xray JSON",
+      "subJsonUserAgentRegexDesc": "تعبير Go RE2 منتظم يُطابَق مع وكيل المستخدم (User-Agent) لاختيار صيغة Xray JSON تلقائيًا على رابط الاشتراك القياسي. فارغ افتراضيًا، لذا يبقى الاكتشاف التلقائي معطلاً حتى تحدد نمطًا للعملاء الذين تريد خدمتهم. يحصل العملاء الآخرون على الاستجابة الخام/Base64. أعد تشغيل اللوحة بعد التغيير.",
+      "subClashAutoDetect": "اكتشاف عملاء Clash/Mihomo تلقائيًا",
+      "subClashAutoDetectDesc": "عند التفعيل، يتلقى عملاء Clash/Mihomo المعروفون الذين يطلبون رابط الاشتراك القياسي إعداد Clash بصيغة YAML تلقائيًا. تستمر المتصفحات في عرض صفحة الاشتراك، ويحصل العملاء الآخرون على الاستجابة الخام/Base64، وتبقى روابط JSON وClash الصريحة متاحة. يتطلب تفعيل اشتراك Clash/Mihomo وإعادة تشغيل اللوحة لتطبيق التغيير.",
+      "subClashUserAgentRegex": "تعبير User-Agent لعملاء Clash/Mihomo",
+      "subClashUserAgentRegexDesc": "تعبير Go RE2 منتظم يُطابَق مع وكيل المستخدم (User-Agent) للتعرف على عملاء Clash/Mihomo في رابط الاشتراك القياسي. اتركه فارغًا لاستخدام النمط الافتراضي. أعد تشغيل اللوحة بعد التغيير.",
       "subTitle": "عنوان الاشتراك",
       "subTitleDesc": "العنوان اللي هيظهر في عميل VPN",
       "subSupportUrl": "رابط الدعم",
@@ -1281,7 +1294,7 @@
       },
       "subFormats": {
         "finalMask": "Final Mask",
-        "finalMaskDesc": "أقنعة finalmask في xray (TCP/UDP) وضبط QUIC تُضاف إلى كل تدفق اشتراك JSON. يتطلب إصدار xray حديثًا على العميل.",
+        "finalMaskDesc": "يضيف أقنعة TCP/UDP الخاصة بـ Xray finalmask ومعلمات QUIC إلى كل ملف Xray JSON يتم إنشاؤه. يتطلب تطبيقًا يدعم اشتراكات Xray JSON وإصدارًا حديثًا من نواة Xray.",
         "packets": "الحزم",
         "length": "الطول",
         "interval": "الفاصل",

+ 14 - 1
internal/web/translation/en-US.json

@@ -1310,6 +1310,19 @@
       "subJsonEnable": "Enable/Disable the JSON subscription endpoint independently.",
       "subJsonEnableTitle": "JSON subscription",
       "subClashEnableTitle": "Clash / Mihomo subscription",
+      "subFormatsTipTitle": "Format-specific subscription settings",
+      "subFormatsTipDesc": "Configure JSON and Clash / Mihomo URL paths, reverse URLs, and client auto-detection separately.",
+      "subFormatsTipAction": "Open Sub Formats",
+      "subJsonAutoDetect": "Auto-detect Xray JSON clients",
+      "subJsonAutoDetectDesc": "When enabled, recognized compatible clients requesting the standard subscription URL receive an Xray JSON configuration array automatically. Other clients keep the raw/base64 response. Requires JSON subscription to be enabled and a panel restart to apply.",
+      "subJsonAlwaysArray": "Always return a JSON array",
+      "subJsonAlwaysArrayDesc": "Return the explicit JSON subscription endpoint as an array even when it contains one profile, as required by the XTLS subscription standard. Auto-detected JSON responses always use arrays. Leave disabled to preserve the legacy single-object response.",
+      "subJsonUserAgentRegex": "Xray JSON User-Agent regex",
+      "subJsonUserAgentRegexDesc": "Go RE2 regular expression matched against the client's User-Agent to auto-select the Xray JSON format on the standard subscription URL. Empty by default, so auto-detection stays off until you set a pattern for the clients you want to serve. Other clients keep the raw/base64 response. Restart the panel after changes.",
+      "subClashAutoDetect": "Auto-detect Clash/Mihomo clients",
+      "subClashAutoDetectDesc": "When enabled, recognized Clash/Mihomo clients requesting the standard subscription URL receive Clash YAML automatically. Browsers still show the subscription page, other clients keep the raw/base64 response, and the explicit JSON and Clash URLs remain available. Requires Clash/Mihomo subscription to be enabled and a panel restart to apply.",
+      "subClashUserAgentRegex": "Clash/Mihomo User-Agent regex",
+      "subClashUserAgentRegexDesc": "Go RE2 regular expression matched against the client's User-Agent to recognize Clash/Mihomo clients on the standard subscription URL. Leave empty to use the default pattern. Restart the panel after changes.",
       "subTitle": "Subscription Title",
       "subTitleDesc": "Title shown in VPN client",
       "subSupportUrl": "Support URL",
@@ -1400,7 +1413,7 @@
       },
       "subFormats": {
         "finalMask": "Final Mask",
-        "finalMaskDesc": "xray finalmask masks (TCP/UDP) and QUIC tuning injected into every JSON subscription stream. Requires a recent xray client.",
+        "finalMaskDesc": "Inject Xray finalmask TCP/UDP masks and QUIC parameters into every generated Xray JSON profile. Requires a client app that supports Xray JSON subscriptions and a recent Xray core.",
         "packets": "Packets",
         "length": "Length",
         "interval": "Interval",

+ 14 - 1
internal/web/translation/es-ES.json

@@ -1191,6 +1191,19 @@
       "subJsonEnable": "Habilitar/Deshabilitar el endpoint de suscripción JSON de forma independiente.",
       "subJsonEnableTitle": "Suscripción JSON",
       "subClashEnableTitle": "Suscripción Clash / Mihomo",
+      "subFormatsTipTitle": "Configuración de suscripción específica por formato",
+      "subFormatsTipDesc": "Configura por separado las rutas URL, las URL inversas y la detección automática de clientes para JSON y Clash / Mihomo.",
+      "subFormatsTipAction": "Abrir formatos de suscripción",
+      "subJsonAutoDetect": "Detectar automáticamente clientes Xray JSON",
+      "subJsonAutoDetectDesc": "Al activarlo, los clientes compatibles reconocidos que soliciten la URL de suscripción estándar recibirán automáticamente una matriz de configuraciones Xray JSON. Los demás clientes conservarán la respuesta sin procesar/Base64. Requiere activar la suscripción JSON y reiniciar el panel.",
+      "subJsonAlwaysArray": "Devolver siempre una matriz JSON",
+      "subJsonAlwaysArrayDesc": "Devuelve el endpoint JSON explícito como una matriz incluso con un solo perfil, según el estándar XTLS. Las respuestas JSON detectadas automáticamente siempre usan matrices. Desactívalo para conservar la respuesta heredada de un solo objeto.",
+      "subJsonUserAgentRegex": "Expresión User-Agent de Xray JSON",
+      "subJsonUserAgentRegexDesc": "Expresión regular Go RE2 que se compara con el User-Agent del cliente para seleccionar automáticamente el formato Xray JSON en la URL de suscripción estándar. Vacía de forma predeterminada, por lo que la detección automática permanece desactivada hasta que definas un patrón para los clientes que quieras atender. Los demás clientes conservan la respuesta sin procesar/Base64. Reinicia el panel después de cambiarla.",
+      "subClashAutoDetect": "Detectar automáticamente clientes Clash/Mihomo",
+      "subClashAutoDetectDesc": "Al activarlo, los clientes Clash/Mihomo reconocidos que soliciten la URL de suscripción estándar recibirán automáticamente YAML de Clash. Los navegadores seguirán mostrando la página de suscripción, los demás clientes conservarán la respuesta sin procesar/Base64 y las URL explícitas de JSON y Clash seguirán disponibles. Requiere activar la suscripción Clash/Mihomo y reiniciar el panel para aplicar el cambio.",
+      "subClashUserAgentRegex": "Expresión User-Agent de Clash/Mihomo",
+      "subClashUserAgentRegexDesc": "Expresión regular Go RE2 que se compara con el User-Agent del cliente para reconocer clientes Clash/Mihomo en la URL de suscripción estándar. Déjala vacía para usar el patrón predeterminado. Reinicia el panel después de cambiarla.",
       "subTitle": "Título de la Suscripción",
       "subTitleDesc": "Título mostrado en el cliente VPN",
       "subSupportUrl": "URL de soporte",
@@ -1281,7 +1294,7 @@
       },
       "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.",
+        "finalMaskDesc": "Inyecta máscaras TCP/UDP de finalmask de Xray y parámetros QUIC en cada perfil Xray JSON generado. Requiere una aplicación compatible con suscripciones Xray JSON y un núcleo Xray reciente.",
         "packets": "Paquetes",
         "length": "Longitud",
         "interval": "Intervalo",

+ 14 - 1
internal/web/translation/fa-IR.json

@@ -1193,6 +1193,19 @@
       "subJsonEnable": "فعال/غیرفعال‌سازی مستقل نقطه دسترسی سابسکریپشن JSON.",
       "subJsonEnableTitle": "اشتراک JSON",
       "subClashEnableTitle": "اشتراک Clash / Mihomo",
+      "subFormatsTipTitle": "تنظیمات اشتراک ویژه هر قالب",
+      "subFormatsTipDesc": "مسیرهای URL، آدرس‌های معکوس و تشخیص خودکار کلاینت را برای JSON و Clash / Mihomo جداگانه پیکربندی کنید.",
+      "subFormatsTipAction": "باز کردن قالب‌های اشتراک",
+      "subJsonAutoDetect": "تشخیص خودکار کلاینت‌های Xray JSON",
+      "subJsonAutoDetectDesc": "با فعال‌سازی، کلاینت‌های سازگار و شناخته‌شده که آدرس استاندارد اشتراک را درخواست می‌کنند، به‌طور خودکار آرایه تنظیمات Xray JSON دریافت می‌کنند. سایر کلاینت‌ها پاسخ خام/Base64 را دریافت می‌کنند. برای اعمال تغییر، اشتراک JSON باید فعال باشد و پنل راه‌اندازی مجدد شود.",
+      "subJsonAlwaysArray": "همیشه آرایه JSON برگردان",
+      "subJsonAlwaysArrayDesc": "مسیر صریح اشتراک JSON را حتی برای یک پروفایل، مطابق استاندارد XTLS به‌صورت آرایه برمی‌گرداند. پاسخ‌های JSON تشخیص‌داده‌شده همیشه آرایه هستند. برای حفظ پاسخ قدیمی تک‌شیء غیرفعال بگذارید.",
+      "subJsonUserAgentRegex": "عبارت User-Agent برای Xray JSON",
+      "subJsonUserAgentRegexDesc": "عبارت منظم Go RE2 که با عامل کاربر (User-Agent) کلاینت مطابقت داده می‌شود تا صیغه Xray JSON روی آدرس استاندارد اشتراک به‌طور خودکار انتخاب شود. به‌طور پیش‌فرض خالی است، بنابراین تشخیص خودکار غیرفعال می‌ماند تا زمانی که الگویی برای کلاینت‌های موردنظر خود تعیین کنید. سایر کلاینت‌ها پاسخ خام/Base64 را دریافت می‌کنند. پس از تغییر، پنل را راه‌اندازی مجدد کنید.",
+      "subClashAutoDetect": "تشخیص خودکار کلاینت‌های Clash/Mihomo",
+      "subClashAutoDetectDesc": "با فعال‌سازی، کلاینت‌های شناخته‌شده Clash/Mihomo که آدرس استاندارد اشتراک را درخواست می‌کنند، به‌طور خودکار پیکربندی Clash با فرمت YAML دریافت می‌کنند. مرورگرها همچنان صفحه اشتراک را نمایش می‌دهند، سایر کلاینت‌ها پاسخ خام/Base64 را دریافت می‌کنند و آدرس‌های صریح JSON و Clash در دسترس می‌مانند. برای اعمال تغییر، اشتراک Clash/Mihomo باید فعال باشد و پنل راه‌اندازی مجدد شود.",
+      "subClashUserAgentRegex": "عبارت User-Agent برای Clash/Mihomo",
+      "subClashUserAgentRegexDesc": "عبارت منظم Go RE2 که با عامل کاربر (User-Agent) کلاینت مطابقت داده می‌شود تا کلاینت‌های Clash/Mihomo در آدرس استاندارد اشتراک شناسایی شوند. برای استفاده از الگوی پیش‌فرض خالی بگذارید. پس از تغییر، پنل را راه‌اندازی مجدد کنید.",
       "subTitle": "عنوان اشتراک",
       "subTitleDesc": "عنوان نمایش داده شده در کلاینت VPN",
       "subSupportUrl": "آدرس پشتیبانی",
@@ -1283,7 +1296,7 @@
       },
       "subFormats": {
         "finalMask": "Final Mask",
-        "finalMaskDesc": "ماسک‌های finalmask ایکس‌ری (TCP/UDP) و تنظیمات QUIC که داخل همه‌ی stream های اشتراک JSON تزریق می‌شوند. به نسخه‌ی جدید هسته‌ی xray در کلاینت نیاز دارد.",
+        "finalMaskDesc": "ماسک‌های TCP/UDP مربوط به Xray finalmask و پارامترهای QUIC را به هر پروفایل Xray JSON تولیدشده اضافه می‌کند. به برنامه‌ای با پشتیبانی از اشتراک Xray JSON و هسته جدید Xray نیاز دارد.",
         "packets": "بسته‌ها",
         "length": "طول",
         "interval": "بازه",

+ 14 - 1
internal/web/translation/id-ID.json

@@ -1191,6 +1191,19 @@
       "subJsonEnable": "Aktifkan/Nonaktifkan endpoint langganan JSON secara mandiri.",
       "subJsonEnableTitle": "Langganan JSON",
       "subClashEnableTitle": "Langganan Clash / Mihomo",
+      "subFormatsTipTitle": "Pengaturan langganan khusus format",
+      "subFormatsTipDesc": "Konfigurasikan jalur URL, URL proksi balik, dan deteksi otomatis klien untuk JSON serta Clash / Mihomo secara terpisah.",
+      "subFormatsTipAction": "Buka Format Langganan",
+      "subJsonAutoDetect": "Deteksi otomatis klien Xray JSON",
+      "subJsonAutoDetectDesc": "Jika diaktifkan, klien kompatibel yang dikenali dan meminta URL langganan standar akan otomatis menerima larik konfigurasi Xray JSON. Klien lain tetap menerima respons mentah/Base64. Langganan JSON harus diaktifkan dan panel harus dimulai ulang.",
+      "subJsonAlwaysArray": "Selalu kembalikan larik JSON",
+      "subJsonAlwaysArrayDesc": "Mengembalikan endpoint langganan JSON eksplisit sebagai larik meski hanya berisi satu profil, sesuai standar XTLS. Respons JSON yang terdeteksi otomatis selalu menggunakan larik. Nonaktifkan untuk mempertahankan respons objek tunggal lama.",
+      "subJsonUserAgentRegex": "Regex User-Agent Xray JSON",
+      "subJsonUserAgentRegexDesc": "Ekspresi reguler Go RE2 yang dicocokkan dengan User-Agent klien untuk memilih format Xray JSON secara otomatis pada URL langganan standar. Kosong secara bawaan, sehingga deteksi otomatis tetap nonaktif hingga Anda menetapkan pola untuk klien yang ingin dilayani. Klien lain tetap menerima respons mentah/Base64. Mulai ulang panel setelah mengubahnya.",
+      "subClashAutoDetect": "Deteksi otomatis klien Clash/Mihomo",
+      "subClashAutoDetectDesc": "Jika diaktifkan, klien Clash/Mihomo yang dikenali dan meminta URL langganan standar akan otomatis menerima YAML Clash. Browser tetap menampilkan halaman langganan, klien lain tetap menerima respons mentah/Base64, dan URL JSON serta Clash eksplisit tetap tersedia. Langganan Clash/Mihomo harus diaktifkan dan panel harus dimulai ulang agar perubahan diterapkan.",
+      "subClashUserAgentRegex": "Regex User-Agent Clash/Mihomo",
+      "subClashUserAgentRegexDesc": "Ekspresi reguler Go RE2 yang dicocokkan dengan User-Agent klien untuk mengenali klien Clash/Mihomo pada URL langganan standar. Kosongkan untuk memakai pola bawaan. Mulai ulang panel setelah mengubahnya.",
       "subTitle": "Judul Langganan",
       "subTitleDesc": "Judul yang ditampilkan di klien VPN",
       "subSupportUrl": "URL Dukungan",
@@ -1281,7 +1294,7 @@
       },
       "subFormats": {
         "finalMask": "Final Mask",
-        "finalMaskDesc": "Mask finalmask xray (TCP/UDP) dan penyetelan QUIC yang disuntikkan ke setiap stream langganan JSON. Membutuhkan klien xray terbaru.",
+        "finalMaskDesc": "Menyisipkan mask TCP/UDP Xray finalmask dan parameter QUIC ke setiap profil Xray JSON yang dibuat. Membutuhkan aplikasi klien yang mendukung langganan Xray JSON dan inti Xray terbaru.",
         "packets": "Paket",
         "length": "Panjang",
         "interval": "Interval",

+ 14 - 1
internal/web/translation/ja-JP.json

@@ -1191,6 +1191,19 @@
       "subJsonEnable": "JSON サブスクリプションのエンドポイントを個別に有効/無効にする。",
       "subJsonEnableTitle": "JSON サブスクリプション",
       "subClashEnableTitle": "Clash / Mihomo サブスクリプション",
+      "subFormatsTipTitle": "形式別サブスクリプション設定",
+      "subFormatsTipDesc": "JSON と Clash / Mihomo の URL パス、リバース URL、クライアント自動検出を個別に設定します。",
+      "subFormatsTipAction": "サブスクリプション形式を開く",
+      "subJsonAutoDetect": "Xray JSON クライアントを自動検出",
+      "subJsonAutoDetectDesc": "有効にすると、標準サブスクリプション URL を要求する既知の互換クライアントへ Xray JSON 設定配列を自動的に返します。その他のクライアントには従来の raw/Base64 応答を返します。JSON サブスクリプションを有効にし、適用のためにパネルを再起動する必要があります。",
+      "subJsonAlwaysArray": "常に JSON 配列を返す",
+      "subJsonAlwaysArrayDesc": "プロファイルが 1 件でも、明示的な JSON サブスクリプションを XTLS 標準に従う配列として返します。自動検出された JSON 応答は常に配列です。無効時は従来の単一オブジェクト応答を維持します。",
+      "subJsonUserAgentRegex": "Xray JSON User-Agent 正規表現",
+      "subJsonUserAgentRegexDesc": "標準サブスクリプション URL で Xray JSON 形式を自動選択するため、クライアントの User-Agent と照合する Go RE2 正規表現です。既定では空欄のため、対象とするクライアント向けのパターンを設定するまで自動判別は無効のままです。その他のクライアントには従来の raw/Base64 応答を返します。変更後にパネルを再起動してください。",
+      "subClashAutoDetect": "Clash/Mihomo クライアントを自動検出",
+      "subClashAutoDetectDesc": "有効にすると、標準サブスクリプション URL を要求する既知の Clash/Mihomo クライアントへ Clash YAML を自動的に返します。ブラウザには引き続きサブスクリプションページを表示し、その他のクライアントには従来の raw/Base64 応答を返します。明示的な JSON および Clash URL も引き続き利用できます。Clash/Mihomo サブスクリプションを有効にし、適用のためにパネルを再起動する必要があります。",
+      "subClashUserAgentRegex": "Clash/Mihomo User-Agent 正規表現",
+      "subClashUserAgentRegexDesc": "標準サブスクリプション URL で Clash/Mihomo クライアントを識別するため、クライアントの User-Agent と照合する Go RE2 正規表現です。空欄の場合は既定のパターンを使用します。変更後にパネルを再起動してください。",
       "subTitle": "サブスクリプションタイトル",
       "subTitleDesc": "VPNクライアントに表示されるタイトル",
       "subSupportUrl": "サポートURL",
@@ -1281,7 +1294,7 @@
       },
       "subFormats": {
         "finalMask": "Final Mask",
-        "finalMaskDesc": "すべての JSON サブスクリプションストリームに注入される xray finalmask マスク(TCP/UDP)と QUIC チューニング。新しい xray クライアントが必要です。",
+        "finalMaskDesc": "生成される各 Xray JSON プロファイルへ Xray finalmask の TCP/UDP マスクと QUIC パラメーターを追加します。Xray JSON サブスクリプション対応アプリと新しい Xray コアが必要です。",
         "packets": "パケット",
         "length": "長さ",
         "interval": "間隔",

+ 14 - 1
internal/web/translation/pt-BR.json

@@ -1191,6 +1191,19 @@
       "subJsonEnable": "Ativar/Desativar o endpoint de assinatura JSON de forma independente.",
       "subJsonEnableTitle": "Assinatura JSON",
       "subClashEnableTitle": "Assinatura Clash / Mihomo",
+      "subFormatsTipTitle": "Configurações de assinatura específicas por formato",
+      "subFormatsTipDesc": "Configure separadamente os caminhos de URL, URLs reversas e a detecção automática de clientes para JSON e Clash / Mihomo.",
+      "subFormatsTipAction": "Abrir formatos de assinatura",
+      "subJsonAutoDetect": "Detectar clientes Xray JSON automaticamente",
+      "subJsonAutoDetectDesc": "Quando ativado, clientes compatíveis reconhecidos que solicitarem a URL de assinatura padrão receberão automaticamente uma matriz de configurações Xray JSON. Os outros clientes manterão a resposta bruta/Base64. Requer a assinatura JSON ativada e a reinicialização do painel.",
+      "subJsonAlwaysArray": "Sempre retornar uma matriz JSON",
+      "subJsonAlwaysArrayDesc": "Retorna o endpoint JSON explícito como matriz mesmo com apenas um perfil, conforme o padrão XTLS. Respostas JSON detectadas automaticamente sempre usam matrizes. Desative para preservar a resposta legada de objeto único.",
+      "subJsonUserAgentRegex": "Expressão User-Agent do Xray JSON",
+      "subJsonUserAgentRegexDesc": "Expressão regular Go RE2 comparada com o User-Agent do cliente para selecionar automaticamente o formato Xray JSON na URL de assinatura padrão. Vazia por padrão, então a detecção automática permanece desativada até você definir um padrão para os clientes que deseja atender. Os outros clientes mantêm a resposta bruta/Base64. Reinicie o painel após alterá-la.",
+      "subClashAutoDetect": "Detectar clientes Clash/Mihomo automaticamente",
+      "subClashAutoDetectDesc": "Quando ativado, clientes Clash/Mihomo reconhecidos que solicitarem a URL de assinatura padrão receberão automaticamente YAML do Clash. Os navegadores continuarão exibindo a página de assinatura, os outros clientes manterão a resposta bruta/Base64 e as URLs explícitas de JSON e Clash continuarão disponíveis. Requer a assinatura Clash/Mihomo ativada e a reinicialização do painel para aplicar a alteração.",
+      "subClashUserAgentRegex": "Expressão User-Agent do Clash/Mihomo",
+      "subClashUserAgentRegexDesc": "Expressão regular Go RE2 comparada com o User-Agent do cliente para reconhecer clientes Clash/Mihomo na URL de assinatura padrão. Deixe em branco para usar o padrão predefinido. Reinicie o painel após alterá-la.",
       "subTitle": "Título da Assinatura",
       "subTitleDesc": "Título exibido no cliente VPN",
       "subSupportUrl": "URL de Suporte",
@@ -1281,7 +1294,7 @@
       },
       "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.",
+        "finalMaskDesc": "Injeta máscaras TCP/UDP do finalmask do Xray e parâmetros QUIC em cada perfil Xray JSON gerado. Requer um aplicativo compatível com assinaturas Xray JSON e um núcleo Xray recente.",
         "packets": "Pacotes",
         "length": "Comprimento",
         "interval": "Intervalo",

+ 14 - 1
internal/web/translation/ru-RU.json

@@ -1191,6 +1191,19 @@
       "subJsonEnable": "Включить/отключить JSON-эндпоинт подписки независимо.",
       "subJsonEnableTitle": "JSON-подписка",
       "subClashEnableTitle": "Подписка Clash / Mihomo",
+      "subFormatsTipTitle": "Настройки подписки для отдельных форматов",
+      "subFormatsTipDesc": "Настройте отдельно URL-пути, обратные URL и автоматическое определение клиентов для JSON и Clash / Mihomo.",
+      "subFormatsTipAction": "Открыть форматы подписки",
+      "subJsonAutoDetect": "Автоматически определять клиентов Xray JSON",
+      "subJsonAutoDetectDesc": "Если параметр включён, распознанные совместимые клиенты при запросе стандартного URL подписки автоматически получают массив конфигураций Xray JSON. Остальные клиенты получают прежний ответ raw/Base64. Для применения требуется включить JSON-подписку и перезапустить панель.",
+      "subJsonAlwaysArray": "Всегда возвращать массив JSON",
+      "subJsonAlwaysArrayDesc": "Возвращает явную JSON-подписку как массив даже при одном профиле согласно стандарту XTLS. Автоматически определённые JSON-ответы всегда используют массив. Отключите, чтобы сохранить прежний ответ в виде одного объекта.",
+      "subJsonUserAgentRegex": "Регулярное выражение User-Agent Xray JSON",
+      "subJsonUserAgentRegexDesc": "Регулярное выражение Go RE2, сопоставляемое с User-Agent клиента для автоматического выбора формата Xray JSON на стандартном URL подписки. По умолчанию пустое, поэтому автоопределение отключено, пока вы не зададите шаблон для нужных клиентов. Остальные клиенты получают ответ raw/Base64. После изменения перезапустите панель.",
+      "subClashAutoDetect": "Автоматически определять клиентов Clash/Mihomo",
+      "subClashAutoDetectDesc": "Если параметр включён, распознанные клиенты Clash/Mihomo при запросе стандартного URL подписки автоматически получают Clash YAML. Браузеры по-прежнему показывают страницу подписки, остальные клиенты получают прежний ответ raw/Base64, а явные URL JSON и Clash остаются доступными. Для применения требуется включить подписку Clash/Mihomo и перезапустить панель.",
+      "subClashUserAgentRegex": "Регулярное выражение User-Agent Clash/Mihomo",
+      "subClashUserAgentRegexDesc": "Регулярное выражение Go RE2, сопоставляемое с User-Agent клиента для распознавания клиентов Clash/Mihomo на стандартном URL подписки. Оставьте поле пустым, чтобы использовать шаблон по умолчанию. После изменения перезапустите панель.",
       "subTitle": "Заголовок подписки",
       "subTitleDesc": "Название подписки, которое видит клиент в VPN-клиенте",
       "subSupportUrl": "URL поддержки",
@@ -1281,7 +1294,7 @@
       },
       "subFormats": {
         "finalMask": "Final Mask",
-        "finalMaskDesc": "Маски finalmask xray (TCP/UDP) и настройки QUIC, добавляемые в каждый поток JSON-подписки. Требуется свежая версия xray на клиенте.",
+        "finalMaskDesc": "Добавляет TCP/UDP-маски finalmask Xray и параметры QUIC в каждый созданный профиль Xray JSON. Требуется приложение с поддержкой подписок Xray JSON и свежая версия ядра Xray.",
         "packets": "Пакеты",
         "length": "Длина",
         "interval": "Интервал",

+ 14 - 1
internal/web/translation/tr-TR.json

@@ -1191,6 +1191,19 @@
       "subJsonEnable": "JSON abonelik uç noktasını bağımsız olarak etkinleştirir/devre dışı bırakır.",
       "subJsonEnableTitle": "JSON Aboneliği",
       "subClashEnableTitle": "Clash / Mihomo Aboneliği",
+      "subFormatsTipTitle": "Biçime özel abonelik ayarları",
+      "subFormatsTipDesc": "JSON ve Clash / Mihomo için URL yollarını, ters URL'leri ve otomatik istemci algılamayı ayrı ayrı yapılandırın.",
+      "subFormatsTipAction": "Abonelik Biçimlerini Aç",
+      "subJsonAutoDetect": "Xray JSON istemcilerini otomatik algıla",
+      "subJsonAutoDetectDesc": "Etkinleştirildiğinde, standart abonelik URL'sini isteyen tanınmış uyumlu istemciler otomatik olarak bir Xray JSON yapılandırma dizisi alır. Diğer istemciler ham/Base64 yanıtını almaya devam eder. JSON aboneliğinin etkinleştirilmesi ve panelin yeniden başlatılması gerekir.",
+      "subJsonAlwaysArray": "Her zaman JSON dizisi döndür",
+      "subJsonAlwaysArrayDesc": "Açık JSON abonelik uç noktasını tek profil olsa bile XTLS standardına uygun bir dizi olarak döndürür. Otomatik algılanan JSON yanıtları her zaman dizi kullanır. Eski tek nesne yanıtını korumak için devre dışı bırakın.",
+      "subJsonUserAgentRegex": "Xray JSON User-Agent düzenli ifadesi",
+      "subJsonUserAgentRegexDesc": "Standart abonelik URL'sinde Xray JSON biçimini otomatik seçmek için istemcinin User-Agent değeriyle eşleştirilen Go RE2 düzenli ifadesi. Varsayılan olarak boştur; bu nedenle hizmet vermek istediğiniz istemciler için bir desen belirleyene kadar otomatik algılama kapalı kalır. Diğer istemciler ham/Base64 yanıtını almaya devam eder. Değişiklikten sonra paneli yeniden başlatın.",
+      "subClashAutoDetect": "Clash/Mihomo istemcilerini otomatik algıla",
+      "subClashAutoDetectDesc": "Etkinleştirildiğinde, standart abonelik URL'sini isteyen tanınmış Clash/Mihomo istemcileri otomatik olarak Clash YAML alır. Tarayıcılar abonelik sayfasını göstermeye devam eder, diğer istemciler ham/Base64 yanıtını almaya devam eder ve açık JSON ile Clash URL'leri kullanılabilir kalır. Uygulanması için Clash/Mihomo aboneliğinin etkinleştirilmesi ve panelin yeniden başlatılması gerekir.",
+      "subClashUserAgentRegex": "Clash/Mihomo User-Agent düzenli ifadesi",
+      "subClashUserAgentRegexDesc": "Standart abonelik URL'sinde Clash/Mihomo istemcilerini tanımak için istemcinin User-Agent değeriyle eşleştirilen Go RE2 düzenli ifadesi. Varsayılan deseni kullanmak için boş bırakın. Değişiklikten sonra paneli yeniden başlatın.",
       "subTitle": "Abonelik Başlığı",
       "subTitleDesc": "VPN istemcisinde gösterilen başlık.",
       "subSupportUrl": "Destek URL'si",
@@ -1281,7 +1294,7 @@
       },
       "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.",
+        "finalMaskDesc": "Xray finalmask TCP/UDP maskelerini ve QUIC parametrelerini oluşturulan her Xray JSON profiline ekler. Xray JSON aboneliklerini destekleyen bir istemci uygulaması ve güncel bir Xray çekirdeği gerektirir.",
         "packets": "Paketler",
         "length": "Uzunluk",
         "interval": "Aralık",

+ 14 - 1
internal/web/translation/uk-UA.json

@@ -1191,6 +1191,19 @@
       "subJsonEnable": "Увімкнути/вимкнути JSON-кінець підписки незалежно.",
       "subJsonEnableTitle": "JSON-підписка",
       "subClashEnableTitle": "Підписка Clash / Mihomo",
+      "subFormatsTipTitle": "Налаштування підписки для окремих форматів",
+      "subFormatsTipDesc": "Окремо налаштуйте URL-шляхи, зворотні URL-адреси й автоматичне визначення клієнтів для JSON та Clash / Mihomo.",
+      "subFormatsTipAction": "Відкрити формати підписки",
+      "subJsonAutoDetect": "Автоматично визначати клієнтів Xray JSON",
+      "subJsonAutoDetectDesc": "Якщо параметр увімкнено, розпізнані сумісні клієнти під час запиту стандартної URL-адреси підписки автоматично отримують масив конфігурацій Xray JSON. Інші клієнти отримують звичайну відповідь raw/Base64. Для застосування потрібно ввімкнути JSON-підписку та перезапустити панель.",
+      "subJsonAlwaysArray": "Завжди повертати масив JSON",
+      "subJsonAlwaysArrayDesc": "Повертає явну JSON-підписку як масив навіть для одного профілю відповідно до стандарту XTLS. Автоматично визначені JSON-відповіді завжди використовують масив. Вимкніть, щоб зберегти попередню відповідь одним об’єктом.",
+      "subJsonUserAgentRegex": "Регулярний вираз User-Agent Xray JSON",
+      "subJsonUserAgentRegexDesc": "Регулярний вираз Go RE2, який зіставляється з User-Agent клієнта для автоматичного вибору формату Xray JSON на стандартній URL-адресі підписки. Типово порожнє, тому автоматичне визначення вимкнено, доки ви не задасте шаблон для потрібних клієнтів. Інші клієнти отримують відповідь raw/Base64. Після зміни перезапустіть панель.",
+      "subClashAutoDetect": "Автоматично визначати клієнтів Clash/Mihomo",
+      "subClashAutoDetectDesc": "Якщо параметр увімкнено, розпізнані клієнти Clash/Mihomo під час запиту стандартної URL-адреси підписки автоматично отримують Clash YAML. Браузери й надалі показують сторінку підписки, інші клієнти отримують звичайну відповідь raw/Base64, а явні URL-адреси JSON і Clash залишаються доступними. Для застосування потрібно ввімкнути підписку Clash/Mihomo та перезапустити панель.",
+      "subClashUserAgentRegex": "Регулярний вираз User-Agent Clash/Mihomo",
+      "subClashUserAgentRegexDesc": "Регулярний вираз Go RE2, який зіставляється з User-Agent клієнта для розпізнавання клієнтів Clash/Mihomo на стандартній URL-адресі підписки. Залиште поле порожнім для стандартного шаблону. Після зміни перезапустіть панель.",
       "subTitle": "Назва Підписки",
       "subTitleDesc": "Назва, яка відображається у VPN-клієнті",
       "subSupportUrl": "URL підтримки",
@@ -1281,7 +1294,7 @@
       },
       "subFormats": {
         "finalMask": "Final Mask",
-        "finalMaskDesc": "Маски finalmask xray (TCP/UDP) і налаштування QUIC, що додаються до кожного потоку JSON-підписки. Потрібна свіжа версія xray на клієнті.",
+        "finalMaskDesc": "Додає TCP/UDP-маски finalmask Xray і параметри QUIC до кожного створеного профілю Xray JSON. Потрібен застосунок із підтримкою підписок Xray JSON і нова версія ядра Xray.",
         "packets": "Пакети",
         "length": "Довжина",
         "interval": "Інтервал",

+ 14 - 1
internal/web/translation/vi-VN.json

@@ -1191,6 +1191,19 @@
       "subJsonEnable": "Bật/Tắt điểm cuối đăng ký JSON độc lập.",
       "subJsonEnableTitle": "Đăng ký JSON",
       "subClashEnableTitle": "Đăng ký Clash / Mihomo",
+      "subFormatsTipTitle": "Cài đặt đăng ký theo từng định dạng",
+      "subFormatsTipDesc": "Cấu hình riêng đường dẫn URL, URL proxy ngược và tính năng tự động nhận diện ứng dụng cho JSON và Clash / Mihomo.",
+      "subFormatsTipAction": "Mở định dạng đăng ký",
+      "subJsonAutoDetect": "Tự động nhận diện ứng dụng Xray JSON",
+      "subJsonAutoDetectDesc": "Khi bật, các ứng dụng tương thích được nhận diện khi yêu cầu URL đăng ký tiêu chuẩn sẽ tự động nhận một mảng cấu hình Xray JSON. Các ứng dụng khác tiếp tục nhận phản hồi thô/Base64. Cần bật đăng ký JSON và khởi động lại bảng điều khiển.",
+      "subJsonAlwaysArray": "Luôn trả về mảng JSON",
+      "subJsonAlwaysArrayDesc": "Trả endpoint đăng ký JSON tường minh dưới dạng mảng ngay cả khi chỉ có một hồ sơ, theo tiêu chuẩn XTLS. Phản hồi JSON được tự động nhận diện luôn dùng mảng. Tắt để giữ phản hồi một đối tượng kiểu cũ.",
+      "subJsonUserAgentRegex": "Biểu thức User-Agent Xray JSON",
+      "subJsonUserAgentRegexDesc": "Biểu thức chính quy Go RE2 được so khớp với User-Agent của ứng dụng để tự động chọn định dạng Xray JSON trên URL đăng ký tiêu chuẩn. Mặc định để trống, nên tính năng tự động nhận diện vẫn tắt cho đến khi bạn đặt mẫu cho các ứng dụng muốn phục vụ. Các ứng dụng khác tiếp tục nhận phản hồi thô/Base64. Khởi động lại bảng điều khiển sau khi thay đổi.",
+      "subClashAutoDetect": "Tự động nhận diện ứng dụng Clash/Mihomo",
+      "subClashAutoDetectDesc": "Khi bật, các ứng dụng Clash/Mihomo được nhận diện khi yêu cầu URL đăng ký tiêu chuẩn sẽ tự động nhận cấu hình Clash YAML. Trình duyệt vẫn hiển thị trang đăng ký, các ứng dụng khác tiếp tục nhận phản hồi thô/Base64, còn các URL JSON và Clash riêng vẫn khả dụng. Cần bật đăng ký Clash/Mihomo và khởi động lại bảng điều khiển để áp dụng thay đổi.",
+      "subClashUserAgentRegex": "Biểu thức User-Agent Clash/Mihomo",
+      "subClashUserAgentRegexDesc": "Biểu thức chính quy Go RE2 được so khớp với User-Agent của ứng dụng để nhận diện ứng dụng Clash/Mihomo trên URL đăng ký tiêu chuẩn. Để trống để dùng mẫu mặc định. Khởi động lại bảng điều khiển sau khi thay đổi.",
       "subTitle": "Tiêu đề Đăng ký",
       "subTitleDesc": "Tiêu đề hiển thị trong ứng dụng VPN",
       "subSupportUrl": "URL Hỗ trợ",
@@ -1281,7 +1294,7 @@
       },
       "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.",
+        "finalMaskDesc": "Thêm mask TCP/UDP của Xray finalmask và tham số QUIC vào mỗi hồ sơ Xray JSON được tạo. Yêu cầu ứng dụng hỗ trợ đăng ký Xray JSON và lõi Xray mới.",
         "packets": "Gói",
         "length": "Độ dài",
         "interval": "Khoảng",

+ 14 - 1
internal/web/translation/zh-CN.json

@@ -1191,6 +1191,19 @@
       "subJsonEnable": "单独启用/禁用 JSON 订阅端点。",
       "subJsonEnableTitle": "JSON 订阅",
       "subClashEnableTitle": "Clash / Mihomo 订阅",
+      "subFormatsTipTitle": "特定格式的订阅设置",
+      "subFormatsTipDesc": "分别配置 JSON 和 Clash / Mihomo 的 URL 路径、反向代理 URL 和客户端自动识别。",
+      "subFormatsTipAction": "打开订阅格式",
+      "subJsonAutoDetect": "自动识别 Xray JSON 客户端",
+      "subJsonAutoDetectDesc": "启用后,使用标准订阅 URL 的已识别兼容客户端将自动获得 Xray JSON 配置数组。其他客户端继续获得原始/Base64 响应。需要启用 JSON 订阅并重启面板才能生效。",
+      "subJsonAlwaysArray": "始终返回 JSON 数组",
+      "subJsonAlwaysArrayDesc": "即使只有一个配置,也将显式 JSON 订阅端点返回为数组,以符合 XTLS 订阅标准。自动识别的 JSON 响应始终使用数组。禁用时保留旧版单对象响应。",
+      "subJsonUserAgentRegex": "Xray JSON User-Agent 正则表达式",
+      "subJsonUserAgentRegexDesc": "用于与客户端 User-Agent 进行匹配,从而在标准订阅 URL 上自动选择 Xray JSON 格式的 Go RE2 正则表达式。默认留空,因此在为需要服务的客户端设置规则之前,自动识别保持关闭。其他客户端继续获得原始/Base64 响应。更改后请重启面板。",
+      "subClashAutoDetect": "自动识别 Clash/Mihomo 客户端",
+      "subClashAutoDetectDesc": "启用后,使用标准订阅 URL 的已识别 Clash/Mihomo 客户端将自动获得 Clash YAML。浏览器仍显示订阅页面,其他客户端继续获得原始/Base64 响应,独立的 JSON 和 Clash URL 仍然可用。需要启用 Clash/Mihomo 订阅并重启面板才能生效。",
+      "subClashUserAgentRegex": "Clash/Mihomo User-Agent 正则表达式",
+      "subClashUserAgentRegexDesc": "用于与客户端 User-Agent 进行匹配,从而在标准订阅 URL 上识别 Clash/Mihomo 客户端的 Go RE2 正则表达式。留空则使用默认规则。更改后请重启面板。",
       "subTitle": "订阅标题",
       "subTitleDesc": "在VPN客户端中显示的标题",
       "subSupportUrl": "支持链接",
@@ -1281,7 +1294,7 @@
       },
       "subFormats": {
         "finalMask": "Final Mask",
-        "finalMaskDesc": "注入到每个 JSON 订阅流的 xray finalmask 掩码(TCP/UDP)和 QUIC 调优。需要较新的 xray 客户端。",
+        "finalMaskDesc": "将 Xray finalmask TCP/UDP 掩码和 QUIC 参数注入每个生成的 Xray JSON 配置。需要支持 Xray JSON 订阅的客户端应用和较新的 Xray 核心。",
         "packets": "数据包",
         "length": "长度",
         "interval": "间隔",

+ 14 - 1
internal/web/translation/zh-TW.json

@@ -1191,6 +1191,19 @@
       "subJsonEnable": "獨立啟用/停用 JSON 訂閱端點。",
       "subJsonEnableTitle": "JSON 訂閱",
       "subClashEnableTitle": "Clash / Mihomo 訂閱",
+      "subFormatsTipTitle": "特定格式的訂閱設定",
+      "subFormatsTipDesc": "分別設定 JSON 和 Clash / Mihomo 的 URL 路徑、反向代理 URL 與用戶端自動識別。",
+      "subFormatsTipAction": "開啟訂閱格式",
+      "subJsonAutoDetect": "自動識別 Xray JSON 用戶端",
+      "subJsonAutoDetectDesc": "啟用後,使用標準訂閱 URL 的已識別相容用戶端將自動取得 Xray JSON 設定陣列。其他用戶端繼續取得原始/Base64 回應。需要啟用 JSON 訂閱並重新啟動面板才能生效。",
+      "subJsonAlwaysArray": "始終回傳 JSON 陣列",
+      "subJsonAlwaysArrayDesc": "即使只有一個設定,也將明確 JSON 訂閱端點回傳為陣列,以符合 XTLS 訂閱標準。自動識別的 JSON 回應始終使用陣列。停用時保留舊版單一物件回應。",
+      "subJsonUserAgentRegex": "Xray JSON User-Agent 正規表示式",
+      "subJsonUserAgentRegexDesc": "用於與用戶端 User-Agent 進行比對,以便在標準訂閱 URL 上自動選擇 Xray JSON 格式的 Go RE2 正規表示式。預設留空,因此在為要服務的用戶端設定規則之前,自動辨識會保持關閉。其他用戶端繼續取得原始/Base64 回應。變更後請重新啟動面板。",
+      "subClashAutoDetect": "自動識別 Clash/Mihomo 用戶端",
+      "subClashAutoDetectDesc": "啟用後,使用標準訂閱 URL 的已識別 Clash/Mihomo 用戶端將自動取得 Clash YAML。瀏覽器仍會顯示訂閱頁面,其他用戶端繼續取得原始/Base64 回應,獨立的 JSON 和 Clash URL 仍可使用。需要啟用 Clash/Mihomo 訂閱並重新啟動面板才能生效。",
+      "subClashUserAgentRegex": "Clash/Mihomo User-Agent 正規表示式",
+      "subClashUserAgentRegexDesc": "用於與用戶端 User-Agent 進行比對,以便在標準訂閱 URL 上識別 Clash/Mihomo 用戶端的 Go RE2 正規表示式。留空則使用預設規則。變更後請重新啟動面板。",
       "subTitle": "訂閱標題",
       "subTitleDesc": "在VPN客戶端中顯示的標題",
       "subSupportUrl": "支援連結",
@@ -1281,7 +1294,7 @@
       },
       "subFormats": {
         "finalMask": "Final Mask",
-        "finalMaskDesc": "注入到每個 JSON 訂閱串流的 xray finalmask 遮罩(TCP/UDP)與 QUIC 調校。需要較新的 xray 用戶端。",
+        "finalMaskDesc": "將 Xray finalmask TCP/UDP 遮罩和 QUIC 參數注入每個產生的 Xray JSON 設定。需要支援 Xray JSON 訂閱的用戶端應用程式和較新的 Xray 核心。",
         "packets": "封包",
         "length": "長度",
         "interval": "間隔",