1
0

10 کامیت‌ها 72df05a403 ... cba8f0672f

نویسنده SHA1 پیام تاریخ
  Pejman Yousefi cba8f0672f feat(sub): refine Happ routing presets, serverDescription escaping, and auto-detect placement (#6488) 12 ساعت پیش
  BlindMaster24 c7518c4038 fix(tgbot): send the admin traffic reports as one message (#6490) 12 ساعت پیش
  NgaiYeanCoi 6a5b4fab6a feat(happ): generate Crypt5 subscription links locally (#6494) 13 ساعت پیش
  Sanaei c3b08b6d9f fix(tgbot): guard the mock Telegram server's call counts 13 ساعت پیش
  Sanaei b98f947efe fix(tgbot): answer only the link callbacks that match nothing 13 ساعت پیش
  DIMFLIX 2730e4d071 feat(sub): let the panel set the JSON subscription DNS servers (#6485) 14 ساعت پیش
  BlindMaster24 aaa5e61cad fix(tgbot): answer the callbacks the bot cannot route (#6493) 14 ساعت پیش
  BlindMaster24 02c6c3a9c6 fix(tgbot): render the add-client draft as HTML and escape its values (#6492) 14 ساعت پیش
  BlindMaster24 615876b2eb fix(tgbot): read the admin list and running flag under their mutex (#6491) 14 ساعت پیش
  BlindMaster24 7ac5277c4f fix(tgbot): require client ownership for non-admin link callbacks (#6489) 14 ساعت پیش
75فایلهای تغییر یافته به همراه4407 افزوده شده و 251 حذف شده
  1. 16 0
      docs/content/docs/en/reference/api/clients.mdx
  2. 77 0
      docs/public/openapi.json
  3. 77 0
      frontend/public/openapi.json
  4. 7 0
      frontend/src/generated/examples.ts
  5. 28 0
      frontend/src/generated/schemas.ts
  6. 8 1
      frontend/src/generated/types.ts
  7. 9 3
      frontend/src/generated/zod.ts
  8. 3 0
      frontend/src/hooks/useClients.ts
  9. 2 0
      frontend/src/models/setting.ts
  10. 8 0
      frontend/src/pages/api-docs/endpoints.ts
  11. 294 11
      frontend/src/pages/clients/ClientQrModal.tsx
  12. 82 65
      frontend/src/pages/settings/HappSettingsContent.tsx
  13. 12 0
      frontend/src/pages/settings/SubscriptionFormatsTab.tsx
  14. 11 2
      frontend/src/pages/settings/SubscriptionGeneralTab.tsx
  15. 36 60
      frontend/src/pages/settings/happPresets.ts
  16. 1 0
      frontend/src/schemas/defaults.ts
  17. 2 0
      frontend/src/schemas/setting.ts
  18. 65 0
      frontend/src/test/client-qr-modal-qr-capacity.test.tsx
  19. 646 0
      frontend/src/test/client-qr-modal.test.tsx
  20. 12 0
      frontend/src/test/clients-query-gating.test.tsx
  21. 12 22
      frontend/src/test/happ-presets.test.ts
  22. 21 0
      frontend/src/test/happ-settings-tun-mode.test.tsx
  23. 10 8
      frontend/src/test/multi-tunnel-client-config.test.tsx
  24. 78 0
      frontend/src/test/subscription-general-tab.test.tsx
  25. 8 2
      internal/sub/controller.go
  26. 23 5
      internal/sub/endpoint.go
  27. 4 4
      internal/sub/endpoint_test.go
  28. 11 8
      internal/sub/happ.go
  29. 124 1
      internal/sub/happ_test.go
  30. 3 0
      internal/sub/host_sub.go
  31. 20 0
      internal/sub/json_dns.go
  32. 48 0
      internal/sub/json_dns_controller_test.go
  33. 267 0
      internal/sub/json_dns_test.go
  34. 16 3
      internal/sub/json_service.go
  35. 12 2
      internal/sub/service.go
  36. 6 0
      internal/sub/sub.go
  37. 26 0
      internal/web/controller/client.go
  38. 149 0
      internal/web/controller/client_happ_test.go
  39. 2 0
      internal/web/entity/entity.go
  40. 150 0
      internal/web/service/happ.go
  41. 165 0
      internal/web/service/happ_crypto.go
  42. 129 0
      internal/web/service/happ_local_test.go
  43. 408 0
      internal/web/service/happ_test.go
  44. 28 0
      internal/web/service/setting.go
  45. 43 0
      internal/web/service/setting_happ_test.go
  46. 64 0
      internal/web/service/setting_sub_json_dns_test.go
  47. 8 0
      internal/web/service/tgbot/tgbot.go
  48. 73 0
      internal/web/service/tgbot/tgbot_admin_list_race_test.go
  49. 21 5
      internal/web/service/tgbot/tgbot_client.go
  50. 125 0
      internal/web/service/tgbot/tgbot_client_links_authz_test.go
  51. 81 0
      internal/web/service/tgbot/tgbot_declined_callback_test.go
  52. 148 0
      internal/web/service/tgbot/tgbot_draft_render_test.go
  53. 4 3
      internal/web/service/tgbot/tgbot_report.go
  54. 189 0
      internal/web/service/tgbot/tgbot_report_batching_test.go
  55. 78 42
      internal/web/service/tgbot/tgbot_router.go
  56. 4 3
      internal/web/service/tgbot/tgbot_send.go
  57. 9 1
      internal/web/service/tgbot/tgbot_stale_button_test.go
  58. 16 0
      internal/web/translation/ar-EG.json
  59. 16 0
      internal/web/translation/en-US.json
  60. 16 0
      internal/web/translation/es-ES.json
  61. 16 0
      internal/web/translation/fa-IR.json
  62. 16 0
      internal/web/translation/id-ID.json
  63. 16 0
      internal/web/translation/ja-JP.json
  64. 16 0
      internal/web/translation/pt-BR.json
  65. 16 0
      internal/web/translation/ru-RU.json
  66. 16 0
      internal/web/translation/tr-TR.json
  67. 16 0
      internal/web/translation/uk-UA.json
  68. 16 0
      internal/web/translation/vi-VN.json
  69. 16 0
      internal/web/translation/zh-CN.json
  70. 16 0
      internal/web/translation/zh-TW.json
  71. 119 0
      internal/xray/dnsconf/dnsconf.go
  72. 68 0
      internal/xray/dnsconf/dnsconf_test.go
  73. 1 0
      tools/openapigen/main.go
  74. 3 0
      tools/openapigen/walker.go
  75. 45 0
      tools/openapigen/walker_test.go

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 16 - 0
docs/content/docs/en/reference/api/clients.mdx


+ 77 - 0
docs/public/openapi.json

@@ -41,6 +41,9 @@
           "externalTrafficInformURI": {
             "type": "string"
           },
+          "happLinkEnable": {
+            "type": "boolean"
+          },
           "ipLimitAllowlist": {
             "type": "string"
           },
@@ -308,6 +311,9 @@
           "subJsonAutoDetect": {
             "type": "boolean"
           },
+          "subJsonDns": {
+            "type": "string"
+          },
           "subJsonEnable": {
             "type": "boolean"
           },
@@ -462,6 +468,7 @@
           "expireDiff",
           "externalTrafficInformEnable",
           "externalTrafficInformURI",
+          "happLinkEnable",
           "ipLimitAllowlist",
           "ldapAutoCreate",
           "ldapAutoDelete",
@@ -545,6 +552,7 @@
           "subInfoNodeEnable",
           "subJsonAlwaysArray",
           "subJsonAutoDetect",
+          "subJsonDns",
           "subJsonEnable",
           "subJsonFinalMask",
           "subJsonMux",
@@ -608,6 +616,9 @@
           "externalTrafficInformURI": {
             "type": "string"
           },
+          "happLinkEnable": {
+            "type": "boolean"
+          },
           "hasApiToken": {
             "type": "boolean"
           },
@@ -896,6 +907,9 @@
           "subJsonAutoDetect": {
             "type": "boolean"
           },
+          "subJsonDns": {
+            "type": "string"
+          },
           "subJsonEnable": {
             "type": "boolean"
           },
@@ -1050,6 +1064,7 @@
           "expireDiff",
           "externalTrafficInformEnable",
           "externalTrafficInformURI",
+          "happLinkEnable",
           "hasApiToken",
           "hasLdapPassword",
           "hasNordSecret",
@@ -1140,6 +1155,7 @@
           "subInfoNodeEnable",
           "subJsonAlwaysArray",
           "subJsonAutoDetect",
+          "subJsonDns",
           "subJsonEnable",
           "subJsonFinalMask",
           "subJsonMux",
@@ -2126,6 +2142,18 @@
         ],
         "type": "object"
       },
+      "HappLinkResult": {
+        "properties": {
+          "encryptedLink": {
+            "example": "happ://crypt5/example",
+            "type": "string"
+          }
+        },
+        "required": [
+          "encryptedLink"
+        ],
+        "type": "object"
+      },
       "HistoryOfSeeders": {
         "description": "HistoryOfSeeders tracks which database seeders have been executed to prevent re-running.",
         "properties": {
@@ -10061,6 +10089,55 @@
         }
       }
     },
+    "/panel/api/clients/happLink/{id}": {
+      "post": {
+        "tags": [
+          "Clients"
+        ],
+        "summary": "Generate a fresh Happ crypt5 link locally from the current client subscription URL when Happ link generation is enabled. The panel applies a resource limit of 8192 UTF-8 bytes to the source URL; this is not a Happ client maximum. Longer sources return success: false with msg: happ_source_too_long and obj: null. The source URL is not sent to a generation provider, and the result is not stored or reused.",
+        "operationId": "post_panel_api_clients_happLink_id",
+        "parameters": [
+          {
+            "name": "id",
+            "in": "path",
+            "required": true,
+            "description": "Stable client record ID.",
+            "schema": {
+              "type": "integer"
+            }
+          }
+        ],
+        "responses": {
+          "200": {
+            "description": "Successful response",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "object",
+                  "properties": {
+                    "success": {
+                      "type": "boolean"
+                    },
+                    "msg": {
+                      "type": "string"
+                    },
+                    "obj": {
+                      "$ref": "#/components/schemas/HappLinkResult"
+                    }
+                  }
+                },
+                "example": {
+                  "success": true,
+                  "obj": {
+                    "encryptedLink": "happ://crypt5/example"
+                  }
+                }
+              }
+            }
+          }
+        }
+      }
+    },
     "/panel/api/clients/links/{email}": {
       "get": {
         "tags": [

+ 77 - 0
frontend/public/openapi.json

@@ -41,6 +41,9 @@
           "externalTrafficInformURI": {
             "type": "string"
           },
+          "happLinkEnable": {
+            "type": "boolean"
+          },
           "ipLimitAllowlist": {
             "type": "string"
           },
@@ -308,6 +311,9 @@
           "subJsonAutoDetect": {
             "type": "boolean"
           },
+          "subJsonDns": {
+            "type": "string"
+          },
           "subJsonEnable": {
             "type": "boolean"
           },
@@ -462,6 +468,7 @@
           "expireDiff",
           "externalTrafficInformEnable",
           "externalTrafficInformURI",
+          "happLinkEnable",
           "ipLimitAllowlist",
           "ldapAutoCreate",
           "ldapAutoDelete",
@@ -545,6 +552,7 @@
           "subInfoNodeEnable",
           "subJsonAlwaysArray",
           "subJsonAutoDetect",
+          "subJsonDns",
           "subJsonEnable",
           "subJsonFinalMask",
           "subJsonMux",
@@ -608,6 +616,9 @@
           "externalTrafficInformURI": {
             "type": "string"
           },
+          "happLinkEnable": {
+            "type": "boolean"
+          },
           "hasApiToken": {
             "type": "boolean"
           },
@@ -896,6 +907,9 @@
           "subJsonAutoDetect": {
             "type": "boolean"
           },
+          "subJsonDns": {
+            "type": "string"
+          },
           "subJsonEnable": {
             "type": "boolean"
           },
@@ -1050,6 +1064,7 @@
           "expireDiff",
           "externalTrafficInformEnable",
           "externalTrafficInformURI",
+          "happLinkEnable",
           "hasApiToken",
           "hasLdapPassword",
           "hasNordSecret",
@@ -1140,6 +1155,7 @@
           "subInfoNodeEnable",
           "subJsonAlwaysArray",
           "subJsonAutoDetect",
+          "subJsonDns",
           "subJsonEnable",
           "subJsonFinalMask",
           "subJsonMux",
@@ -2126,6 +2142,18 @@
         ],
         "type": "object"
       },
+      "HappLinkResult": {
+        "properties": {
+          "encryptedLink": {
+            "example": "happ://crypt5/example",
+            "type": "string"
+          }
+        },
+        "required": [
+          "encryptedLink"
+        ],
+        "type": "object"
+      },
       "HistoryOfSeeders": {
         "description": "HistoryOfSeeders tracks which database seeders have been executed to prevent re-running.",
         "properties": {
@@ -10061,6 +10089,55 @@
         }
       }
     },
+    "/panel/api/clients/happLink/{id}": {
+      "post": {
+        "tags": [
+          "Clients"
+        ],
+        "summary": "Generate a fresh Happ crypt5 link locally from the current client subscription URL when Happ link generation is enabled. The panel applies a resource limit of 8192 UTF-8 bytes to the source URL; this is not a Happ client maximum. Longer sources return success: false with msg: happ_source_too_long and obj: null. The source URL is not sent to a generation provider, and the result is not stored or reused.",
+        "operationId": "post_panel_api_clients_happLink_id",
+        "parameters": [
+          {
+            "name": "id",
+            "in": "path",
+            "required": true,
+            "description": "Stable client record ID.",
+            "schema": {
+              "type": "integer"
+            }
+          }
+        ],
+        "responses": {
+          "200": {
+            "description": "Successful response",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "object",
+                  "properties": {
+                    "success": {
+                      "type": "boolean"
+                    },
+                    "msg": {
+                      "type": "string"
+                    },
+                    "obj": {
+                      "$ref": "#/components/schemas/HappLinkResult"
+                    }
+                  }
+                },
+                "example": {
+                  "success": true,
+                  "obj": {
+                    "encryptedLink": "happ://crypt5/example"
+                  }
+                }
+              }
+            }
+          }
+        }
+      }
+    },
     "/panel/api/clients/links/{email}": {
       "get": {
         "tags": [

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

@@ -5,6 +5,7 @@ export const EXAMPLES: Record<string, unknown> = {
     "expireDiff": 0,
     "externalTrafficInformEnable": false,
     "externalTrafficInformURI": "",
+    "happLinkEnable": false,
     "ipLimitAllowlist": "",
     "ldapAutoCreate": false,
     "ldapAutoDelete": false,
@@ -88,6 +89,7 @@ export const EXAMPLES: Record<string, unknown> = {
     "subInfoNodeEnable": false,
     "subJsonAlwaysArray": false,
     "subJsonAutoDetect": false,
+    "subJsonDns": "",
     "subJsonEnable": false,
     "subJsonFinalMask": "",
     "subJsonMux": "",
@@ -139,6 +141,7 @@ export const EXAMPLES: Record<string, unknown> = {
     "expireDiff": 0,
     "externalTrafficInformEnable": false,
     "externalTrafficInformURI": "",
+    "happLinkEnable": false,
     "hasApiToken": false,
     "hasLdapPassword": false,
     "hasNordSecret": false,
@@ -229,6 +232,7 @@ export const EXAMPLES: Record<string, unknown> = {
     "subInfoNodeEnable": false,
     "subJsonAlwaysArray": false,
     "subJsonAutoDetect": false,
+    "subJsonDns": "",
     "subJsonEnable": false,
     "subJsonFinalMask": "",
     "subJsonMux": "",
@@ -547,6 +551,9 @@ export const EXAMPLES: Record<string, unknown> = {
     "reason": "categoryMissing",
     "token": "geosite:blabla"
   },
+  "HappLinkResult": {
+    "encryptedLink": "happ://crypt5/example"
+  },
   "HistoryOfSeeders": {
     "id": 0,
     "seederName": ""

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

@@ -15,6 +15,9 @@ export const SCHEMAS: Record<string, unknown> = {
       "externalTrafficInformURI": {
         "type": "string"
       },
+      "happLinkEnable": {
+        "type": "boolean"
+      },
       "ipLimitAllowlist": {
         "type": "string"
       },
@@ -282,6 +285,9 @@ export const SCHEMAS: Record<string, unknown> = {
       "subJsonAutoDetect": {
         "type": "boolean"
       },
+      "subJsonDns": {
+        "type": "string"
+      },
       "subJsonEnable": {
         "type": "boolean"
       },
@@ -436,6 +442,7 @@ export const SCHEMAS: Record<string, unknown> = {
       "expireDiff",
       "externalTrafficInformEnable",
       "externalTrafficInformURI",
+      "happLinkEnable",
       "ipLimitAllowlist",
       "ldapAutoCreate",
       "ldapAutoDelete",
@@ -519,6 +526,7 @@ export const SCHEMAS: Record<string, unknown> = {
       "subInfoNodeEnable",
       "subJsonAlwaysArray",
       "subJsonAutoDetect",
+      "subJsonDns",
       "subJsonEnable",
       "subJsonFinalMask",
       "subJsonMux",
@@ -582,6 +590,9 @@ export const SCHEMAS: Record<string, unknown> = {
       "externalTrafficInformURI": {
         "type": "string"
       },
+      "happLinkEnable": {
+        "type": "boolean"
+      },
       "hasApiToken": {
         "type": "boolean"
       },
@@ -870,6 +881,9 @@ export const SCHEMAS: Record<string, unknown> = {
       "subJsonAutoDetect": {
         "type": "boolean"
       },
+      "subJsonDns": {
+        "type": "string"
+      },
       "subJsonEnable": {
         "type": "boolean"
       },
@@ -1024,6 +1038,7 @@ export const SCHEMAS: Record<string, unknown> = {
       "expireDiff",
       "externalTrafficInformEnable",
       "externalTrafficInformURI",
+      "happLinkEnable",
       "hasApiToken",
       "hasLdapPassword",
       "hasNordSecret",
@@ -1114,6 +1129,7 @@ export const SCHEMAS: Record<string, unknown> = {
       "subInfoNodeEnable",
       "subJsonAlwaysArray",
       "subJsonAutoDetect",
+      "subJsonDns",
       "subJsonEnable",
       "subJsonFinalMask",
       "subJsonMux",
@@ -2100,6 +2116,18 @@ export const SCHEMAS: Record<string, unknown> = {
     ],
     "type": "object"
   },
+  "HappLinkResult": {
+    "properties": {
+      "encryptedLink": {
+        "example": "happ://crypt5/example",
+        "type": "string"
+      }
+    },
+    "required": [
+      "encryptedLink"
+    ],
+    "type": "object"
+  },
   "HistoryOfSeeders": {
     "description": "HistoryOfSeeders tracks which database seeders have been executed to prevent re-running.",
     "properties": {

+ 8 - 1
frontend/src/generated/types.ts

@@ -3,7 +3,6 @@ export type GeoKind = string;
 export type OnlineAPISupport = number;
 export type ProcessState = string;
 export type Protocol = string;
-export type SubLinkProvider = unknown;
 export type staticEgressResolver = string;
 export type trafficLocalApplyAction = number;
 export type transportBits = number;
@@ -13,6 +12,7 @@ export interface AllSetting {
   expireDiff: number;
   externalTrafficInformEnable: boolean;
   externalTrafficInformURI: string;
+  happLinkEnable: boolean;
   ipLimitAllowlist: string;
   ldapAutoCreate: boolean;
   ldapAutoDelete: boolean;
@@ -96,6 +96,7 @@ export interface AllSetting {
   subInfoNodeEnable: boolean;
   subJsonAlwaysArray: boolean;
   subJsonAutoDetect: boolean;
+  subJsonDns: string;
   subJsonEnable: boolean;
   subJsonFinalMask: string;
   subJsonMux: string;
@@ -148,6 +149,7 @@ export interface AllSettingView {
   expireDiff: number;
   externalTrafficInformEnable: boolean;
   externalTrafficInformURI: string;
+  happLinkEnable: boolean;
   hasApiToken: boolean;
   hasLdapPassword: boolean;
   hasNordSecret: boolean;
@@ -238,6 +240,7 @@ export interface AllSettingView {
   subInfoNodeEnable: boolean;
   subJsonAlwaysArray: boolean;
   subJsonAutoDetect: boolean;
+  subJsonDns: string;
   subJsonEnable: boolean;
   subJsonFinalMask: string;
   subJsonMux: string;
@@ -494,6 +497,10 @@ export interface GeodataTokenIssue {
   token: string;
 }
 
+export interface HappLinkResult {
+  encryptedLink: string;
+}
+
 export interface HistoryOfSeeders {
   id: number;
   seederName: string;

+ 9 - 3
frontend/src/generated/zod.ts

@@ -12,9 +12,6 @@ export type ProcessState = z.infer<typeof ProcessStateSchema>;
 export const ProtocolSchema = z.string();
 export type Protocol = z.infer<typeof ProtocolSchema>;
 
-export const SubLinkProviderSchema = z.unknown();
-export type SubLinkProvider = z.infer<typeof SubLinkProviderSchema>;
-
 export const staticEgressResolverSchema = z.string();
 export type staticEgressResolver = z.infer<typeof staticEgressResolverSchema>;
 
@@ -29,6 +26,7 @@ export const AllSettingSchema = z.object({
   expireDiff: z.number().int().min(0),
   externalTrafficInformEnable: z.boolean(),
   externalTrafficInformURI: z.string(),
+  happLinkEnable: z.boolean(),
   ipLimitAllowlist: z.string(),
   ldapAutoCreate: z.boolean(),
   ldapAutoDelete: z.boolean(),
@@ -112,6 +110,7 @@ export const AllSettingSchema = z.object({
   subInfoNodeEnable: z.boolean(),
   subJsonAlwaysArray: z.boolean(),
   subJsonAutoDetect: z.boolean(),
+  subJsonDns: z.string(),
   subJsonEnable: z.boolean(),
   subJsonFinalMask: z.string(),
   subJsonMux: z.string(),
@@ -165,6 +164,7 @@ export const AllSettingViewSchema = z.object({
   expireDiff: z.number().int().min(0),
   externalTrafficInformEnable: z.boolean(),
   externalTrafficInformURI: z.string(),
+  happLinkEnable: z.boolean(),
   hasApiToken: z.boolean(),
   hasLdapPassword: z.boolean(),
   hasNordSecret: z.boolean(),
@@ -255,6 +255,7 @@ export const AllSettingViewSchema = z.object({
   subInfoNodeEnable: z.boolean(),
   subJsonAlwaysArray: z.boolean(),
   subJsonAutoDetect: z.boolean(),
+  subJsonDns: z.string(),
   subJsonEnable: z.boolean(),
   subJsonFinalMask: z.string(),
   subJsonMux: z.string(),
@@ -530,6 +531,11 @@ export const GeodataTokenIssueSchema = z.object({
 });
 export type GeodataTokenIssue = z.infer<typeof GeodataTokenIssueSchema>;
 
+export const HappLinkResultSchema = z.object({
+  encryptedLink: z.string(),
+});
+export type HappLinkResult = z.infer<typeof HappLinkResultSchema>;
+
 export const HistoryOfSeedersSchema = z.object({
   id: z.number().int(),
   seederName: z.string(),

+ 3 - 0
frontend/src/hooks/useClients.ts

@@ -50,6 +50,7 @@ const JSON_HEADERS = { headers: { 'Content-Type': 'application/json' } } as cons
 
 interface SubSettings {
   enable: boolean;
+  happLinkEnable: boolean;
   subURI: string;
   subJsonURI: string;
   subJsonEnable: boolean;
@@ -266,6 +267,7 @@ export function useClients(options: UseClientsOptions = {}) {
   const subSettings: SubSettings = useMemo(
     () => ({
       enable: !!defaults.subEnable,
+      happLinkEnable: defaults.happLinkEnable === true,
       subURI: (defaults.subURI as string) || '',
       subJsonURI: (defaults.subJsonURI as string) || '',
       subJsonEnable: !!defaults.subJsonEnable,
@@ -275,6 +277,7 @@ export function useClients(options: UseClientsOptions = {}) {
     }),
     [
       defaults.subEnable,
+      defaults.happLinkEnable,
       defaults.subURI,
       defaults.subJsonURI,
       defaults.subJsonEnable,

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

@@ -33,6 +33,7 @@ export class AllSetting {
   twoFactorEnable = false;
   twoFactorToken = '';
   xrayTemplateConfig = '';
+  happLinkEnable = false;
   subEnable = true;
   subJsonEnable = false;
   subJsonAutoDetect = false;
@@ -70,6 +71,7 @@ export class AllSetting {
   subJsonMux = '';
   subJsonRules = '';
   subJsonRoutingRules = '';
+  subJsonDns = '';
   subJsonFinalMask = '';
   subJsonObservatory = '';
   subThemeDir = '';

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

@@ -1574,6 +1574,14 @@ export const sections: readonly Section[] = [
         response:
           '{\n  "success": true,\n  "obj": [\n    "vless://uuid@host:443?security=reality&...#user1",\n    "vmess://eyJ2IjoyLC..."\n  ]\n}',
       },
+      {
+        method: 'POST',
+        path: '/panel/api/clients/happLink/:id',
+        summary:
+          'Generate a fresh Happ crypt5 link locally from the current client subscription URL when Happ link generation is enabled. The panel applies a resource limit of 8192 UTF-8 bytes to the source URL; this is not a Happ client maximum. Longer sources return success: false with msg: happ_source_too_long and obj: null. The source URL is not sent to a generation provider, and the result is not stored or reused.',
+        params: [{ name: 'id', in: 'path', type: 'integer', desc: 'Stable client record ID.' }],
+        responseSchema: 'HappLinkResult',
+      },
       {
         method: 'GET',
         path: '/panel/api/clients/links/:email',

+ 294 - 11
frontend/src/pages/clients/ClientQrModal.tsx

@@ -1,7 +1,11 @@
-import { useEffect, useMemo, useState } from 'react';
+import { useCallback, useEffect, useMemo, useState } from 'react';
 import { useTranslation } from 'react-i18next';
-import { Collapse, Modal, Spin, Tag } from 'antd';
+import { useNavigate } from 'react-router';
+import { Alert, Button, Collapse, Empty, Modal, Segmented, Spin, Tag, Typography } from 'antd';
+import { LockOutlined } from '@ant-design/icons';
 import { HttpUtil } from '@/utils';
+import type { HappLinkResult } from '@/generated/types';
+import { HappLinkResultSchema } from '@/generated/zod';
 import { isPostQuantumLink } from '@/lib/xray/inbound-link';
 import { LinkTags, linkMetaText, parseLinkParts } from '@/lib/xray/link-label';
 import { QrPanel } from '@/pages/inbounds/qr';
@@ -21,6 +25,7 @@ import { buildTuicClientConfig, findTuicInbound, isTuicClient } from './tuicConf
 
 interface SubSettings {
   enable: boolean;
+  happLinkEnable?: boolean;
   subURI: string;
   subJsonURI: string;
   subJsonEnable: boolean;
@@ -41,15 +46,182 @@ interface ApiMsg<T = unknown> {
   obj?: T;
 }
 
+type QrVariant = 'standard' | 'happ';
+type HappError = 'too_long' | 'unavailable' | null;
+
+const HAPP_CRYPT5_PREFIX = 'happ://crypt5/';
+const HAPP_SETTINGS_PATH = '/settings?subscriptionTab=happ&happTab=links#subscription';
+// QrPanel encodes at error level L; QR version 40 holds 2953 UTF-8 bytes at that level.
+const HAPP_QR_MAX_BYTES = 2953;
+const UTF8_ENCODER = new TextEncoder();
+
+function hasHappForbiddenCharacter(link: string) {
+  return Array.from(link).some((character) => {
+    const codePoint = character.codePointAt(0) ?? 0;
+    return /\s/u.test(character) || codePoint <= 0x1f || (codePoint >= 0x7f && codePoint <= 0x9f);
+  });
+}
+
+function isValidHappCrypt5Link(link: string) {
+  return (
+    link.startsWith(HAPP_CRYPT5_PREFIX) &&
+    link.length > HAPP_CRYPT5_PREFIX.length &&
+    !hasHappForbiddenCharacter(link)
+  );
+}
+
+function canRenderHappQr(link: string) {
+  return UTF8_ENCODER.encode(link).byteLength <= HAPP_QR_MAX_BYTES;
+}
+
+interface SubscriptionQrPresentationProps {
+  variant: QrVariant;
+  standardLink: string;
+  remark: string;
+  happLink: string;
+  happLoading: boolean;
+  happError: HappError;
+  happLinkEnabled: boolean;
+  onVariantChange: (variant: QrVariant) => void;
+  onRegenerate: () => void;
+  onOpenHappSettings: () => void;
+}
+
+function SubscriptionQrPresentation({
+  variant,
+  standardLink,
+  remark,
+  happLink,
+  happLoading,
+  happError,
+  happLinkEnabled,
+  onVariantChange,
+  onRegenerate,
+  onOpenHappSettings,
+}: SubscriptionQrPresentationProps) {
+  const { t } = useTranslation();
+  const showHappQr = canRenderHappQr(happLink);
+
+  return (
+    <div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
+      <Segmented<QrVariant>
+        block
+        value={variant}
+        options={[
+          { label: t('pages.clients.qrStandard'), value: 'standard' },
+          {
+            label: (
+              <span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
+                {!happLinkEnabled ? (
+                  <LockOutlined aria-label={t('pages.clients.happLinkDisabledHint')} />
+                ) : null}
+                <span>{t('pages.clients.happLinkOptionLabel')}</span>
+              </span>
+            ),
+            value: 'happ',
+          },
+        ]}
+        onChange={onVariantChange}
+      />
+      {variant === 'standard' ? (
+        <QrPanel value={standardLink} remark={remark} />
+      ) : !happLinkEnabled ? (
+        <Empty
+          image={<LockOutlined aria-hidden style={{ fontSize: 40, opacity: 0.45 }} />}
+          styles={{ image: { height: 44, marginBottom: 12 } }}
+          style={{
+            minHeight: 190,
+            margin: 0,
+            padding: '20px 12px',
+            display: 'flex',
+            flexDirection: 'column',
+            justifyContent: 'center',
+          }}
+          description={
+            <div style={{ maxWidth: 400, margin: '0 auto' }}>
+              <Typography.Text strong>{t('pages.clients.happLinkDisabledTitle')}</Typography.Text>
+              <Typography.Paragraph type="secondary" style={{ margin: '6px 0 0' }}>
+                {t('pages.clients.happLinkDisabledDescription')}
+              </Typography.Paragraph>
+            </div>
+          }
+        >
+          <Button type="primary" onClick={onOpenHappSettings}>
+            {t('pages.clients.happLinkSettingsAction')}
+          </Button>
+        </Empty>
+      ) : (
+        <div>
+          <Alert
+            style={{ marginBottom: 16 }}
+            type="warning"
+            showIcon
+            title={t('pages.clients.happLinkDisclosure')}
+          />
+          <Spin spinning={happLoading}>
+            <div style={{ minHeight: happLoading ? 48 : undefined }}>
+              {happLink ? (
+                <>
+                  {!showHappQr ? (
+                    <Alert
+                      style={{ marginBottom: 12 }}
+                      type="info"
+                      showIcon
+                      title={t('pages.clients.happLinkQrTooLong')}
+                    />
+                  ) : null}
+                  <QrPanel value={happLink} remark={remark} showQr={showHappQr} />
+                </>
+              ) : null}
+              {happError ? (
+                <Alert
+                  type="error"
+                  showIcon
+                  title={
+                    happError === 'too_long'
+                      ? t('pages.clients.happLinkSourceTooLong')
+                      : t('pages.clients.happLinkErrorHint', {
+                          dashboard: t('menu.dashboard'),
+                          logs: t('pages.index.logs'),
+                        })
+                  }
+                />
+              ) : null}
+            </div>
+          </Spin>
+          {happLink || happError === 'unavailable' ? (
+            <Button style={{ marginTop: 12 }} onClick={onRegenerate}>
+              {happError ? t('pages.clients.happLinkRetry') : t('regenerate')}
+            </Button>
+          ) : null}
+        </div>
+      )}
+    </div>
+  );
+}
+
 const DEFAULT_SUB: SubSettings = {
   enable: false,
+  happLinkEnable: false,
   subURI: '',
   subJsonURI: '',
   subJsonEnable: false,
   publicHost: '',
 };
 
-export default function ClientQrModal({
+export default function ClientQrModal(props: ClientQrModalProps) {
+  const subSettings = props.subSettings ?? DEFAULT_SUB;
+  const subId = props.client?.subId ?? '';
+  const subLink =
+    subId && subSettings.enable && subSettings.subURI ? subSettings.subURI + subId : '';
+  const happLinkEnabled = subSettings.happLinkEnable === true;
+  // A gate or source change remounts this scope to clear Happ state and retire any in-flight response.
+  const scopeKey = `${props.client?.id ?? ''}\0${subId}\0${subLink}\0${happLinkEnabled ? 1 : 0}`;
+
+  return <ClientQrModalContent key={scopeKey} {...props} />;
+}
+
+function ClientQrModalContent({
   open,
   client,
   inboundsById,
@@ -58,6 +230,7 @@ export default function ClientQrModal({
   onOpenChange,
 }: ClientQrModalProps) {
   const { t } = useTranslation();
+  const navigate = useNavigate();
   const [links, setLinks] = useState<string[]>([]);
   const [loading, setLoading] = useState(false);
 
@@ -68,6 +241,84 @@ export default function ClientQrModal({
     subId && subEnabled && subSettings?.subJsonEnable && subSettings?.subJsonURI
       ? subSettings.subJsonURI + subId
       : '';
+  const clientId = client?.id;
+  const clientSubId = subId ?? '';
+  const happLinkEnabled = subSettings.happLinkEnable === true;
+  const [variant, setVariant] = useState<QrVariant>('standard');
+  const [happAttempt, setHappAttempt] = useState(0);
+  const [happLink, setHappLink] = useState('');
+  const [happLoading, setHappLoading] = useState(false);
+  const [happError, setHappError] = useState<HappError>(null);
+  const canGenerateHapp =
+    happLinkEnabled &&
+    typeof clientId === 'number' &&
+    Number.isSafeInteger(clientId) &&
+    clientId > 0 &&
+    !!clientSubId &&
+    !!subLink;
+
+  useEffect(() => {
+    if (!open || variant !== 'happ' || !canGenerateHapp) return;
+
+    let cancelled = false;
+
+    (async () => {
+      try {
+        const msg = await HttpUtil.post<HappLinkResult>(
+          `/panel/api/clients/happLink/${clientId}`,
+          undefined,
+          { silent: true },
+        );
+        if (cancelled) return;
+
+        const result = HappLinkResultSchema.safeParse(msg?.obj);
+        if (msg?.success && result.success && isValidHappCrypt5Link(result.data.encryptedLink)) {
+          setHappLink(result.data.encryptedLink);
+        } else {
+          // Only this fixed API code is safe to localize; arbitrary error messages stay hidden.
+          setHappError(
+            msg?.success === false && msg.msg === 'happ_source_too_long'
+              ? 'too_long'
+              : 'unavailable',
+          );
+        }
+      } catch {
+        if (!cancelled) setHappError('unavailable');
+      } finally {
+        if (!cancelled) setHappLoading(false);
+      }
+    })();
+
+    return () => {
+      // A retired generation must never replace the QR for a newer modal scope.
+      cancelled = true;
+    };
+  }, [open, variant, clientId, clientSubId, subLink, happAttempt, canGenerateHapp]);
+
+  const selectVariant = useCallback(
+    (nextVariant: QrVariant) => {
+      const generateHapp = nextVariant === 'happ' && happLinkEnabled;
+      setVariant(nextVariant);
+      setHappLink('');
+      setHappLoading(generateHapp && canGenerateHapp);
+      setHappError(generateHapp && !canGenerateHapp ? 'unavailable' : null);
+    },
+    [canGenerateHapp, happLinkEnabled],
+  );
+
+  const regenerateHappLink = useCallback(() => {
+    setHappLink('');
+    setHappLoading(canGenerateHapp);
+    setHappError(canGenerateHapp ? null : 'unavailable');
+    if (!canGenerateHapp) return;
+    setHappAttempt((attempt) => attempt + 1);
+  }, [canGenerateHapp]);
+
+  const openHappSettings = useCallback(() => {
+    // This path only exposes the operator gate; authorization and saving remain explicit in Settings.
+    onOpenChange(false);
+    navigate(HAPP_SETTINGS_PATH);
+  }, [navigate, onOpenChange]);
 
   const wgInbounds = useMemo(
     () => findWireguardInbounds(client, inboundsById),
@@ -82,13 +333,13 @@ export default function ClientQrModal({
           client,
           ib,
           window.location.hostname,
-          subSettings?.publicHost ?? '',
+          subSettings.publicHost ?? '',
           address,
         );
         return { inbound: ib, text };
       })
       .filter((c) => !!c.text);
-  }, [client, wgInbounds, tunnelAllowedIPs, subSettings?.publicHost]);
+  }, [client, wgInbounds, tunnelAllowedIPs, subSettings.publicHost]);
 
   const awgInbounds = useMemo(
     () => findAmneziaWGInbounds(client, inboundsById),
@@ -103,13 +354,13 @@ export default function ClientQrModal({
           client,
           ib,
           window.location.hostname,
-          subSettings?.publicHost ?? '',
+          subSettings.publicHost ?? '',
           address,
         );
         return { inbound: ib, text };
       })
       .filter((c) => !!c.text);
-  }, [client, awgInbounds, tunnelAllowedIPs, subSettings?.publicHost]);
+  }, [client, awgInbounds, tunnelAllowedIPs, subSettings.publicHost]);
 
   const tuicInbound = useMemo(() => findTuicInbound(client, inboundsById), [client, inboundsById]);
   const tuicConfigText = useMemo(() => {
@@ -118,9 +369,9 @@ export default function ClientQrModal({
       client,
       tuicInbound,
       window.location.hostname,
-      subSettings?.publicHost ?? '',
+      subSettings.publicHost ?? '',
     );
-  }, [client, tuicInbound, subSettings?.publicHost]);
+  }, [client, tuicInbound, subSettings.publicHost]);
 
   const hasAnything =
     !!subLink ||
@@ -137,6 +388,10 @@ export default function ClientQrModal({
     setSyncedSubId(openSubId);
     setLinks([]);
     setLoading(!!openSubId);
+    setVariant('standard');
+    setHappLink('');
+    setHappLoading(false);
+    setHappError(null);
   }
 
   useEffect(() => {
@@ -168,7 +423,18 @@ export default function ClientQrModal({
         key: 'sub',
         label: t('subscription.title'),
         children: (
-          <QrPanel value={subLink} remark={`${client?.email || ''} — ${t('subscription.title')}`} />
+          <SubscriptionQrPresentation
+            variant={variant}
+            standardLink={subLink}
+            remark={`${client?.email || ''} — ${t('subscription.title')}`}
+            happLink={happLink}
+            happLoading={happLoading}
+            happError={happError}
+            happLinkEnabled={happLinkEnabled}
+            onVariantChange={selectVariant}
+            onRegenerate={regenerateHappLink}
+            onOpenHappSettings={openHappSettings}
+          />
         ),
       });
     }
@@ -252,7 +518,24 @@ export default function ClientQrModal({
       });
     }
     return out;
-  }, [subLink, subJsonLink, wgConfigs, awgConfigs, tuicConfigText, links, client?.email, t]);
+  }, [
+    subLink,
+    subJsonLink,
+    variant,
+    happLink,
+    happLoading,
+    happError,
+    happLinkEnabled,
+    wgConfigs,
+    awgConfigs,
+    links,
+    client?.email,
+    selectVariant,
+    regenerateHappLink,
+    openHappSettings,
+    tuicConfigText,
+    t,
+  ]);
 
   // Expanding the first panel is a render-time adjustment, not a side effect.
   const firstKey = open && items.length > 0 ? items[0].key : null;

+ 82 - 65
frontend/src/pages/settings/HappSettingsContent.tsx

@@ -6,6 +6,7 @@ import {
   BuildOutlined,
   CloudSyncOutlined,
   DesktopOutlined,
+  LinkOutlined,
   MobileOutlined,
   NotificationOutlined,
   ThunderboltOutlined,
@@ -13,12 +14,14 @@ import {
 import type { AllSetting } from '@/models/setting';
 import { SettingListItem } from '@/components/ui';
 import { buildHappPresetDeeplink, parseList, toBase64Utf8 } from './happPresets';
+import { catTabLabel } from './catTabLabel';
 
 interface HappSettingsContentProps {
   allSetting: AllSetting;
   updateSetting: (patch: Partial<AllSetting>) => void;
   isMobile: boolean;
   remoteSourceBadge: (val: string) => React.ReactNode;
+  defaultActiveTab?: 'routing' | 'links';
 }
 
 export default function HappSettingsContent({
@@ -26,6 +29,7 @@ export default function HappSettingsContent({
   updateSetting,
   isMobile,
   remoteSourceBadge,
+  defaultActiveTab = 'routing',
 }: HappSettingsContentProps) {
   const { t } = useTranslation();
   const [selectedPreset, setSelectedPreset] = useState<string>('iran-bypass');
@@ -47,55 +51,19 @@ export default function HappSettingsContent({
   };
 
   const handleBuildDeeplink = () => {
-    interface FieldRule {
-      type: string;
-      outboundTag: string;
-      domain?: string[];
-      ip?: string[];
-      network?: string;
-    }
-    const rules: FieldRule[] = [];
-
-    const bDom = parseList(blockDomains);
-    const bIp = parseList(blockIPs);
-    if (bDom.length > 0 || bIp.length > 0) {
-      rules.push({
-        type: 'field',
-        outboundTag: 'block',
-        ...(bDom.length > 0 ? { domain: bDom } : {}),
-        ...(bIp.length > 0 ? { ip: bIp } : {}),
-      });
-    }
-
-    const dDom = parseList(directDomains);
-    const dIp = parseList(directIPs);
-    if (dDom.length > 0 || dIp.length > 0) {
-      rules.push({
-        type: 'field',
-        outboundTag: 'direct',
-        ...(dDom.length > 0 ? { domain: dDom } : {}),
-        ...(dIp.length > 0 ? { ip: dIp } : {}),
-      });
-    }
-
-    const pDom = parseList(proxyDomains);
-    const pIp = parseList(proxyIPs);
-    if (pDom.length > 0 || pIp.length > 0) {
-      rules.push({
-        type: 'field',
-        outboundTag: 'proxy',
-        ...(pDom.length > 0 ? { domain: pDom } : {}),
-        ...(pIp.length > 0 ? { ip: pIp } : {}),
-      });
-    }
-
-    rules.push({
-      type: 'field',
-      outboundTag: 'proxy',
-      network: 'tcp,udp',
-    });
-
-    const deeplink = 'happ://routing/onadd/' + toBase64Utf8(JSON.stringify({ rules }));
+    const profile = {
+      Name: 'Custom Rules',
+      GlobalProxy: 'true',
+      DirectSites: parseList(directDomains),
+      DirectIp: parseList(directIPs),
+      ProxySites: parseList(proxyDomains),
+      ProxyIp: parseList(proxyIPs),
+      BlockSites: parseList(blockDomains),
+      BlockIp: parseList(blockIPs),
+      DomainStrategy: 'IPIfNonMatch',
+    };
+
+    const deeplink = 'happ://routing/onadd/' + toBase64Utf8(JSON.stringify(profile));
     updateSetting({ subRoutingRules: deeplink });
     setIsModalOpen(false);
     message.success(t('pages.settings.subHappDeeplinkGenerated'));
@@ -103,9 +71,21 @@ export default function HappSettingsContent({
 
   return (
     <>
+      <SettingListItem
+        paddings="small"
+        title={t('pages.settings.subHappAutoDetect')}
+        description={t('pages.settings.subHappAutoDetectDesc')}
+      >
+        <Switch
+          checked={allSetting.subHappAutoDetect}
+          onChange={(v) => updateSetting({ subHappAutoDetect: v })}
+        />
+      </SettingListItem>
+
       <Tabs
         type="card"
         size="small"
+        defaultActiveKey={defaultActiveTab}
         items={[
           {
             key: 'routing',
@@ -199,6 +179,22 @@ export default function HappSettingsContent({
               </>
             ),
           },
+          {
+            key: 'links',
+            label: catTabLabel(<LinkOutlined />, t('pages.settings.subHappGroupLinks'), isMobile),
+            children: (
+              <SettingListItem
+                paddings="small"
+                title={t('pages.settings.happLinkEnable')}
+                description={t('pages.settings.happLinkEnableDesc')}
+              >
+                <Switch
+                  checked={allSetting.happLinkEnable}
+                  onChange={(v) => updateSetting({ happLinkEnable: v })}
+                />
+              </SettingListItem>
+            ),
+          },
           {
             key: 'banners',
             label: (
@@ -433,11 +429,43 @@ export default function HappSettingsContent({
                   title={t('pages.settings.subHappColorProfile')}
                   description={t('pages.settings.subHappColorProfileDesc')}
                 >
-                  <Input
-                    value={allSetting.subHappColorProfile}
-                    placeholder="default, violet, turquoise, cyberpunk, or custom JSON"
-                    onChange={(e) => updateSetting({ subHappColorProfile: e.target.value })}
-                  />
+                  <Space orientation="vertical" style={{ width: '100%' }}>
+                    <Input
+                      value={allSetting.subHappColorProfile}
+                      placeholder='{"serverRowBackgroundColor":"#21003D67"} or resetcolors'
+                      onChange={(e) => updateSetting({ subHappColorProfile: e.target.value })}
+                    />
+                    <Space wrap size="small">
+                      <Button
+                        size="small"
+                        onClick={() => updateSetting({ subHappColorProfile: 'resetcolors' })}
+                      >
+                        {t('reset')}
+                      </Button>
+                      <Button
+                        size="small"
+                        onClick={() =>
+                          updateSetting({
+                            subHappColorProfile:
+                              '{"serverRowBackgroundColor":"#21003D67","cardBackgroundColor":"#120023B3"}',
+                          })
+                        }
+                      >
+                        Violet
+                      </Button>
+                      <Button
+                        size="small"
+                        onClick={() =>
+                          updateSetting({
+                            subHappColorProfile:
+                              '{"serverRowBackgroundColor":"#002B3667","cardBackgroundColor":"#001F27B3"}',
+                          })
+                        }
+                      >
+                        Turquoise
+                      </Button>
+                    </Space>
+                  </Space>
                 </SettingListItem>
               </>
             ),
@@ -451,17 +479,6 @@ export default function HappSettingsContent({
             ),
             children: (
               <>
-                <SettingListItem
-                  paddings="small"
-                  title={t('pages.settings.subHappAutoDetect')}
-                  description={t('pages.settings.subHappAutoDetectDesc')}
-                >
-                  <Switch
-                    checked={allSetting.subHappAutoDetect}
-                    onChange={(v) => updateSetting({ subHappAutoDetect: v })}
-                  />
-                </SettingListItem>
-
                 <SettingListItem
                   paddings="small"
                   title={t('pages.settings.subHappProviderId')}

+ 12 - 0
frontend/src/pages/settings/SubscriptionFormatsTab.tsx

@@ -241,6 +241,18 @@ export default function SubscriptionFormatsTab({
                       autoSize={{ minRows: 2, maxRows: 6 }}
                     />
                   </SettingListItem>
+                  <SettingListItem
+                    paddings="small"
+                    title={t('pages.settings.subJsonDns')}
+                    description={t('pages.settings.subJsonDnsDesc')}
+                  >
+                    <Input.TextArea
+                      value={allSetting.subJsonDns}
+                      placeholder='{"servers": ["https://dns.google/dns-query", "tls://1.1.1.1"]}'
+                      onChange={(e) => updateSetting({ subJsonDns: e.target.value })}
+                      autoSize={{ minRows: 2, maxRows: 6 }}
+                    />
+                  </SettingListItem>
                 </Card>
               )}
               {allSetting.subClashEnable && (

+ 11 - 2
frontend/src/pages/settings/SubscriptionGeneralTab.tsx

@@ -9,7 +9,7 @@ import {
   SettingOutlined,
 } from '@ant-design/icons';
 import { useTranslation } from 'react-i18next';
-import { useNavigate } from 'react-router';
+import { useNavigate, useSearchParams } from 'react-router';
 import type { AllSetting } from '@/models/setting';
 import { onNumber } from '@/utils/onNumber';
 import { DefaultSettingTag, SettingListItem } from '@/components/ui';
@@ -25,17 +25,24 @@ interface SubscriptionGeneralTabProps {
   updateSetting: (patch: Partial<AllSetting>) => void;
 }
 
+const PANEL_SETTINGS_TAB = '1';
+const HAPP_SETTINGS_TAB = '5';
+
 export default function SubscriptionGeneralTab({
   allSetting,
   updateSetting,
 }: SubscriptionGeneralTabProps) {
   const { t } = useTranslation();
   const navigate = useNavigate();
+  const [searchParams] = useSearchParams();
   const { isMobile } = useMediaQuery();
+  // Keep the URL semantic while mapping to the legacy numeric key used by these inner tabs.
+  const initialTab =
+    searchParams.get('subscriptionTab') === 'happ' ? HAPP_SETTINGS_TAB : PANEL_SETTINGS_TAB;
 
   return (
     <Tabs
-      defaultActiveKey="1"
+      defaultActiveKey={initialTab}
       items={[
         {
           key: '1',
@@ -346,6 +353,8 @@ export default function SubscriptionGeneralTab({
               updateSetting={updateSetting}
               isMobile={isMobile}
               remoteSourceBadge={remoteSourceBadge}
+              // QR settings links select the link control; ordinary Happ visits still start on routing.
+              defaultActiveTab={searchParams.get('happTab') === 'links' ? 'links' : 'routing'}
             />
           ),
         },

+ 36 - 60
frontend/src/pages/settings/happPresets.ts

@@ -25,24 +25,15 @@ export function buildHappPresetDeeplink(preset: string): string {
         'happ://routing/onadd/' +
         toBase64Utf8(
           JSON.stringify({
-            rules: [
-              {
-                type: 'field',
-                outboundTag: 'direct',
-                domain: ['domain:ir', 'regexp:.*\\.ir$'],
-                ip: ['geoip:ir', 'geoip:private'],
-              },
-              {
-                type: 'field',
-                outboundTag: 'block',
-                domain: ['geosite:category-ads-all'],
-              },
-              {
-                type: 'field',
-                outboundTag: 'proxy',
-                network: 'tcp,udp',
-              },
-            ],
+            Name: 'Iran Bypass',
+            GlobalProxy: 'true',
+            DirectSites: ['domain:ir', 'regexp:.*\\.ir$'],
+            DirectIp: ['geoip:ir', '10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16'],
+            BlockSites: ['geosite:category-ads-all'],
+            BlockIp: [],
+            ProxySites: [],
+            ProxyIp: [],
+            DomainStrategy: 'IPIfNonMatch',
           }),
         )
       );
@@ -51,24 +42,15 @@ export function buildHappPresetDeeplink(preset: string): string {
         'happ://routing/onadd/' +
         toBase64Utf8(
           JSON.stringify({
-            rules: [
-              {
-                type: 'field',
-                outboundTag: 'direct',
-                domain: ['domain:cn', 'geosite:cn'],
-                ip: ['geoip:cn', 'geoip:private'],
-              },
-              {
-                type: 'field',
-                outboundTag: 'block',
-                domain: ['geosite:category-ads-all'],
-              },
-              {
-                type: 'field',
-                outboundTag: 'proxy',
-                network: 'tcp,udp',
-              },
-            ],
+            Name: 'China Direct',
+            GlobalProxy: 'true',
+            DirectSites: ['geosite:cn', 'geosite:geolocation-cn'],
+            DirectIp: ['geoip:cn', '10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16'],
+            BlockSites: ['geosite:category-ads-all'],
+            BlockIp: [],
+            ProxySites: [],
+            ProxyIp: [],
+            DomainStrategy: 'IPIfNonMatch',
           }),
         )
       );
@@ -77,23 +59,15 @@ export function buildHappPresetDeeplink(preset: string): string {
         'happ://routing/onadd/' +
         toBase64Utf8(
           JSON.stringify({
-            rules: [
-              {
-                type: 'field',
-                outboundTag: 'block',
-                domain: ['geosite:category-ads-all'],
-              },
-              {
-                type: 'field',
-                outboundTag: 'direct',
-                ip: ['geoip:private'],
-              },
-              {
-                type: 'field',
-                outboundTag: 'proxy',
-                network: 'tcp,udp',
-              },
-            ],
+            Name: 'AdBlock',
+            GlobalProxy: 'true',
+            DirectSites: [],
+            DirectIp: ['10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16'],
+            BlockSites: ['geosite:category-ads-all'],
+            BlockIp: [],
+            ProxySites: [],
+            ProxyIp: [],
+            DomainStrategy: 'IPIfNonMatch',
           }),
         )
       );
@@ -102,13 +76,15 @@ export function buildHappPresetDeeplink(preset: string): string {
         'happ://routing/onadd/' +
         toBase64Utf8(
           JSON.stringify({
-            rules: [
-              {
-                type: 'field',
-                outboundTag: 'proxy',
-                network: 'tcp,udp',
-              },
-            ],
+            Name: 'Global Proxy',
+            GlobalProxy: 'true',
+            DirectSites: [],
+            DirectIp: [],
+            BlockSites: [],
+            BlockIp: [],
+            ProxySites: [],
+            ProxyIp: [],
+            DomainStrategy: 'AsIs',
           }),
         )
       );

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

@@ -5,6 +5,7 @@ export const DefaultsPayloadSchema = z
     expireDiff: z.number().optional(),
     trafficDiff: z.number().optional(),
     tgBotEnable: z.boolean().optional(),
+    happLinkEnable: z.boolean().optional(),
     subEnable: z.boolean().optional(),
     subTitle: z.string().optional(),
     subURI: z.string().optional(),

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

@@ -38,6 +38,7 @@ export const AllSettingSchema = z
     twoFactorEnable: z.boolean().optional(),
     twoFactorToken: z.string().optional(),
     xrayTemplateConfig: z.string().optional(),
+    happLinkEnable: z.boolean().optional(),
     subEnable: z.boolean().optional(),
     subJsonEnable: z.boolean().optional(),
     subJsonAutoDetect: z.boolean().optional(),
@@ -75,6 +76,7 @@ export const AllSettingSchema = z
     subJsonMux: z.string().optional(),
     subJsonRules: z.string().optional(),
     subJsonRoutingRules: z.string().optional(),
+    subJsonDns: z.string().optional(),
     subJsonFinalMask: z.string().optional(),
     subJsonObservatory: z.string().optional(),
     subHideSettings: z.boolean().optional(),

+ 65 - 0
frontend/src/test/client-qr-modal-qr-capacity.test.tsx

@@ -0,0 +1,65 @@
+import { fireEvent, screen } from '@testing-library/react';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { MemoryRouter } from 'react-router';
+
+import type { HappLinkResult } from '@/generated/types';
+import type { ClientRecord } from '@/hooks/useClients';
+import ClientQrModal from '@/pages/clients/ClientQrModal';
+import { HttpUtil, Msg } from '@/utils';
+import { renderWithProviders } from './test-utils';
+
+const CLIENT: ClientRecord = { id: 42, email: '[email protected]', subId: 'alpha' };
+const SUB_SETTINGS = {
+  enable: true,
+  subURI: 'https://panel.example/sub/',
+  subJsonURI: '',
+  subJsonEnable: false,
+  happLinkEnable: true,
+};
+// QrPanel encodes at error level L; QR version 40 holds 2953 bytes at that level.
+const LEVEL_L_CAPACITY_BYTES = 2953;
+
+function happLinkOfBytes(bytes: number) {
+  const prefix = 'happ://crypt5/';
+  return prefix + 'a'.repeat(bytes - prefix.length);
+}
+
+function renderHappVariant(link: string) {
+  vi.mocked(HttpUtil.post).mockResolvedValue(
+    new Msg<HappLinkResult>(true, '', { encryptedLink: link }),
+  );
+  renderWithProviders(
+    <MemoryRouter initialEntries={['/clients']}>
+      <ClientQrModal
+        open
+        client={CLIENT}
+        inboundsById={{}}
+        subSettings={SUB_SETTINGS}
+        onOpenChange={() => {}}
+      />
+    </MemoryRouter>,
+  );
+  fireEvent.click(screen.getByRole('radio', { name: /Happ Encrypted Link/ }));
+}
+
+describe('ClientQrModal Happ QR capacity against the real encoder', () => {
+  beforeEach(() => {
+    vi.mocked(HttpUtil.post).mockReset();
+  });
+
+  it('renders the QR for a link exactly at the level-L capacity', async () => {
+    renderHappVariant(happLinkOfBytes(LEVEL_L_CAPACITY_BYTES));
+
+    await screen.findByRole('button', { name: 'Regenerate' });
+    expect(document.body.querySelector('.qr-panel-canvas svg')).not.toBeNull();
+    expect(screen.queryByText(/too long to display as a QR code/)).toBeNull();
+  });
+
+  it('keeps a link one byte over the capacity available without a QR', async () => {
+    renderHappVariant(happLinkOfBytes(LEVEL_L_CAPACITY_BYTES + 1));
+
+    await screen.findByRole('button', { name: 'Regenerate' });
+    expect(document.body.querySelector('.qr-panel-canvas')).toBeNull();
+    expect(screen.getByText(/too long to display as a QR code/)).toBeTruthy();
+  });
+});

+ 646 - 0
frontend/src/test/client-qr-modal.test.tsx

@@ -0,0 +1,646 @@
+import { act, fireEvent, screen, waitFor } from '@testing-library/react';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { createRef, forwardRef, useImperativeHandle, useState } from 'react';
+import { MemoryRouter, useLocation } from 'react-router';
+
+import type { HappLinkResult } from '@/generated/types';
+import type { ClientRecord } from '@/hooks/useClients';
+import ClientQrModal from '@/pages/clients/ClientQrModal';
+import { HttpUtil, Msg } from '@/utils';
+import { renderWithProviders } from './test-utils';
+
+vi.mock('@/pages/inbounds/qr', () => ({
+  QrPanel: ({ value, showQr = true }: { value: string; showQr?: boolean }) => (
+    <div data-testid="qr-panel-value" data-show-qr={String(showQr)}>
+      {value}
+    </div>
+  ),
+}));
+
+const STANDARD_LINK = 'https://panel.example/sub/alpha';
+const HAPP_LINK = 'happ://crypt5/encrypted-alpha';
+const HAPP_OPTION_LABEL = 'Happ Encrypted Link';
+const SOURCE_TOO_LONG_HINT =
+  'The subscription URL exceeds the panel limit of 8192 UTF-8 bytes. Shorten the subscription URL or use Standard.';
+const CLIENT: ClientRecord = { id: 42, email: '[email protected]', subId: 'alpha' };
+const SUB_SETTINGS = {
+  enable: true,
+  subURI: 'https://panel.example/sub/',
+  subJsonURI: '',
+  subJsonEnable: false,
+  happLinkEnable: true,
+};
+
+type TestSubSettings = Omit<typeof SUB_SETTINGS, 'happLinkEnable'> & {
+  happLinkEnable?: boolean;
+};
+
+interface SubjectProps {
+  open: boolean;
+  client: ClientRecord | null;
+  subSettings: TestSubSettings;
+  onOpenChange: (open: boolean) => void;
+}
+
+interface SubjectHandle {
+  update: (patch: Partial<SubjectProps>) => void;
+}
+
+function deferred<T>() {
+  let resolve!: (value: T) => void;
+  const promise = new Promise<T>((next) => {
+    resolve = next;
+  });
+  return { promise, resolve };
+}
+
+function success(encryptedLink = HAPP_LINK) {
+  return new Msg<HappLinkResult>(true, '', { encryptedLink });
+}
+
+const Subject = forwardRef<SubjectHandle, { overrides: Partial<SubjectProps> }>(function Subject(
+  { overrides },
+  ref,
+) {
+  const [props, setProps] = useState<SubjectProps>({
+    open: true,
+    client: CLIENT,
+    subSettings: SUB_SETTINGS,
+    onOpenChange: vi.fn(),
+    ...overrides,
+  });
+  useImperativeHandle(
+    ref,
+    () => ({
+      update: (patch) => setProps((current) => ({ ...current, ...patch })),
+    }),
+    [],
+  );
+  return (
+    <ClientQrModal
+      open={props.open}
+      client={props.client}
+      inboundsById={{}}
+      subSettings={props.subSettings}
+      onOpenChange={props.onOpenChange}
+    />
+  );
+});
+
+function LocationProbe() {
+  const location = useLocation();
+  return (
+    <output data-testid="location">
+      {location.pathname}
+      {location.search}
+      {location.hash}
+    </output>
+  );
+}
+
+function renderSubject(overrides: Partial<SubjectProps> = {}) {
+  const subjectRef = createRef<SubjectHandle>();
+  const onOpenChange = vi.fn();
+  const view = renderWithProviders(
+    <MemoryRouter initialEntries={['/clients']}>
+      <Subject ref={subjectRef} overrides={{ onOpenChange, ...overrides }} />
+      <LocationProbe />
+    </MemoryRouter>,
+  );
+  return {
+    ...view,
+    onOpenChange,
+    update(patch: Partial<SubjectProps>) {
+      act(() => subjectRef.current?.update(patch));
+    },
+  };
+}
+
+function selectVariant(name: 'Standard' | 'Happ') {
+  fireEvent.click(screen.getByRole('radio', { name: name === 'Happ' ? /Happ/ : name }));
+}
+
+function actionButton(name: 'Retry' | 'Regenerate') {
+  return screen.getByRole('button', { name: new RegExp(name) }) as HTMLButtonElement;
+}
+
+describe('ClientQrModal Happ presentation', () => {
+  beforeEach(() => {
+    vi.mocked(HttpUtil.post).mockReset();
+  });
+
+  it('opens on Standard without generating a Happ link', () => {
+    renderSubject();
+
+    expect((screen.getByRole('radio', { name: 'Standard' }) as HTMLInputElement).checked).toBe(
+      true,
+    );
+    expect(screen.getByTestId('qr-panel-value').textContent).toBe(STANDARD_LINK);
+    expect(HttpUtil.post).not.toHaveBeenCalled();
+  });
+
+  it('names the Happ option as an encrypted link', () => {
+    renderSubject();
+
+    expect(screen.getByRole('radio', { name: HAPP_OPTION_LABEL })).toBeTruthy();
+  });
+
+  it.each([
+    ['missing', undefined],
+    ['false', false],
+  ])('marks the selectable Happ option as locked when the gate is %s', (_name, gate) => {
+    const subSettings: TestSubSettings = {
+      enable: SUB_SETTINGS.enable,
+      subURI: SUB_SETTINGS.subURI,
+      subJsonURI: SUB_SETTINGS.subJsonURI,
+      subJsonEnable: SUB_SETTINGS.subJsonEnable,
+    };
+    if (gate !== undefined) subSettings.happLinkEnable = gate;
+
+    renderSubject({ subSettings });
+
+    const standard = screen.getByRole('radio', { name: 'Standard' }) as HTMLInputElement;
+    const happ = screen.getByRole('radio', { name: /Happ Encrypted Link/ }) as HTMLInputElement;
+    expect(standard.checked).toBe(true);
+    expect(happ.disabled).toBe(false);
+    expect(
+      screen.getByLabelText('Enable Happ link generation in Settings before using Happ.', {
+        selector: '.anticon-lock',
+      }),
+    ).toBeTruthy();
+    expect(HttpUtil.post).not.toHaveBeenCalled();
+  });
+
+  it.each([
+    ['missing', undefined],
+    ['false', false],
+  ])(
+    'replaces the blank Happ content with a persistent empty state when the gate is %s',
+    (_name, gate) => {
+      const subSettings: TestSubSettings = {
+        enable: SUB_SETTINGS.enable,
+        subURI: SUB_SETTINGS.subURI,
+        subJsonURI: SUB_SETTINGS.subJsonURI,
+        subJsonEnable: SUB_SETTINGS.subJsonEnable,
+      };
+      if (gate !== undefined) subSettings.happLinkEnable = gate;
+
+      renderSubject({ subSettings });
+
+      const standard = screen.getByRole('radio', { name: 'Standard' }) as HTMLInputElement;
+      const happ = screen.getByRole('radio', { name: /Happ Encrypted Link/ }) as HTMLInputElement;
+      fireEvent.click(happ);
+
+      expect(standard.checked).toBe(false);
+      expect(happ.checked).toBe(true);
+      expect(screen.getByText('Happ encrypted link generation is not enabled')).toBeTruthy();
+      expect(
+        screen.getByText(
+          'Enable local generation of encrypted Happ subscription links. (Only for Happ)',
+        ),
+      ).toBeTruthy();
+      expect(screen.getByRole('button', { name: 'Go to Settings' })).toBeTruthy();
+      expect(screen.queryByTestId('qr-panel-value')).toBeNull();
+      expect(screen.queryByRole('button', { name: /Regenerate|Retry/ })).toBeNull();
+      expect(screen.getByRole('dialog').querySelector('[aria-busy="true"]')).toBeNull();
+      expect(HttpUtil.post).not.toHaveBeenCalled();
+    },
+  );
+
+  it('removes the hover and focus tooltip from the locked Happ option', async () => {
+    renderSubject({ subSettings: { ...SUB_SETTINGS, happLinkEnable: false } });
+    const happ = screen.getByRole('radio', { name: /Happ Encrypted Link/ });
+    const happLabel = happ.closest('label');
+    expect(happLabel).not.toBeNull();
+
+    fireEvent.mouseEnter(happLabel!);
+    fireEvent.focus(happ);
+    await act(async () => {
+      await new Promise((resolve) => setTimeout(resolve, 250));
+    });
+
+    expect(screen.queryByRole('tooltip')).toBeNull();
+  });
+
+  it('closes the QR modal and deep-links to Happ settings without generating', () => {
+    const view = renderSubject({ subSettings: { ...SUB_SETTINGS, happLinkEnable: false } });
+    selectVariant('Happ');
+
+    fireEvent.click(screen.getByRole('button', { name: 'Go to Settings' }));
+
+    expect(view.onOpenChange).toHaveBeenCalledOnce();
+    expect(view.onOpenChange).toHaveBeenCalledWith(false);
+    expect(screen.getByTestId('location').textContent).toBe(
+      '/settings?subscriptionTab=happ&happTab=links#subscription',
+    );
+    expect(HttpUtil.post).not.toHaveBeenCalled();
+  });
+
+  it('returns to Standard without auto-generating when the gate is enabled after selecting Happ', async () => {
+    const view = renderSubject({
+      subSettings: { ...SUB_SETTINGS, happLinkEnable: false },
+    });
+    selectVariant('Happ');
+    expect((screen.getByRole('radio', { name: /Happ/ }) as HTMLInputElement).checked).toBe(true);
+    expect(HttpUtil.post).not.toHaveBeenCalled();
+
+    view.update({ subSettings: { ...SUB_SETTINGS, happLinkEnable: true } });
+
+    await waitFor(() =>
+      expect((screen.getByRole('radio', { name: 'Standard' }) as HTMLInputElement).checked).toBe(
+        true,
+      ),
+    );
+    expect(
+      screen.queryByText(
+        'Generated locally. Anyone with this link may be able to recover or share the subscription URL.',
+      ),
+    ).toBeNull();
+    expect(HttpUtil.post).not.toHaveBeenCalled();
+  });
+
+  it('keeps the local encryption notice out of Standard and shows it only in Happ', async () => {
+    vi.mocked(HttpUtil.post).mockReturnValue(new Promise(() => {}));
+    renderSubject();
+
+    expect((screen.getByRole('radio', { name: 'Standard' }) as HTMLInputElement).checked).toBe(
+      true,
+    );
+    expect(
+      screen.queryByText(
+        'Generated locally. Anyone with this link may be able to recover or share the subscription URL.',
+      ),
+    ).toBeNull();
+
+    selectVariant('Happ');
+
+    expect(
+      await screen.findByText(
+        'Generated locally. Anyone with this link may be able to recover or share the subscription URL.',
+      ),
+    ).toBeTruthy();
+    expect(HttpUtil.post).toHaveBeenCalledOnce();
+  });
+
+  it('posts once with no body and silent errors when Standard switches to Happ', async () => {
+    const request = deferred<Msg<HappLinkResult>>();
+    const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false);
+    vi.mocked(HttpUtil.post).mockReturnValue(request.promise);
+    renderSubject();
+    const dialogCount = screen.queryAllByRole('dialog').length;
+
+    selectVariant('Happ');
+
+    await waitFor(() => {
+      expect(HttpUtil.post).toHaveBeenCalledOnce();
+      expect(HttpUtil.post).toHaveBeenCalledWith('/panel/api/clients/happLink/42', undefined, {
+        silent: true,
+      });
+    });
+    expect(confirmSpy).not.toHaveBeenCalled();
+    expect(screen.queryAllByRole('dialog')).toHaveLength(dialogCount);
+    confirmSpy.mockRestore();
+    expect((screen.getByRole('radio', { name: 'Standard' }) as HTMLInputElement).disabled).toBe(
+      false,
+    );
+    expect(screen.queryByRole('button', { name: /Regenerate|Retry/ })).toBeNull();
+  });
+
+  it('removes the Happ value on leave and makes a fresh request on re-entry', async () => {
+    vi.mocked(HttpUtil.post)
+      .mockResolvedValueOnce(success())
+      .mockResolvedValueOnce(success('happ://crypt5/encrypted-second'));
+    renderSubject();
+
+    selectVariant('Happ');
+    expect(await screen.findByText(HAPP_LINK)).toBeTruthy();
+
+    selectVariant('Standard');
+    expect(screen.queryByText(HAPP_LINK)).toBeNull();
+    expect(screen.getByTestId('qr-panel-value').textContent).toBe(STANDARD_LINK);
+
+    selectVariant('Happ');
+    expect(await screen.findByText('happ://crypt5/encrypted-second')).toBeTruthy();
+    expect(HttpUtil.post).toHaveBeenCalledTimes(2);
+  });
+
+  it('keeps request B current when request A resolves after leaving and re-entering Happ', async () => {
+    const requestA = deferred<Msg<HappLinkResult>>();
+    const requestB = deferred<Msg<HappLinkResult>>();
+    vi.mocked(HttpUtil.post)
+      .mockReturnValueOnce(requestA.promise)
+      .mockReturnValueOnce(requestB.promise);
+    renderSubject();
+
+    selectVariant('Happ');
+    await waitFor(() => expect(HttpUtil.post).toHaveBeenCalledOnce());
+    selectVariant('Standard');
+    selectVariant('Happ');
+    await waitFor(() => expect(HttpUtil.post).toHaveBeenCalledTimes(2));
+
+    await act(async () => {
+      requestA.resolve(success('happ://crypt5/request-a'));
+      await requestA.promise;
+    });
+    expect(screen.queryByText('happ://crypt5/request-a')).toBeNull();
+    expect(screen.queryByRole('button', { name: /Regenerate|Retry/ })).toBeNull();
+
+    await act(async () => {
+      requestB.resolve(success('happ://crypt5/request-b'));
+      await requestB.promise;
+    });
+    expect(await screen.findByText('happ://crypt5/request-b')).toBeTruthy();
+    expect(screen.queryByText('happ://crypt5/request-a')).toBeNull();
+  });
+
+  it('returns to Standard and ignores an in-flight response when the gate turns off', async () => {
+    const request = deferred<Msg<HappLinkResult>>();
+    vi.mocked(HttpUtil.post).mockReturnValue(request.promise);
+    const view = renderSubject();
+    selectVariant('Happ');
+    await waitFor(() => expect(HttpUtil.post).toHaveBeenCalledOnce());
+
+    view.update({ subSettings: { ...SUB_SETTINGS, happLinkEnable: false } });
+
+    await waitFor(() => {
+      expect((screen.getByRole('radio', { name: 'Standard' }) as HTMLInputElement).checked).toBe(
+        true,
+      );
+      expect((screen.getByRole('radio', { name: /Happ/ }) as HTMLInputElement).disabled).toBe(
+        false,
+      );
+    });
+    expect(screen.getByTestId('qr-panel-value').textContent).toBe(STANDARD_LINK);
+    expect(screen.queryByRole('button', { name: /Regenerate|Retry/ })).toBeNull();
+    expect(screen.getByRole('dialog').querySelector('[aria-busy="true"]')).toBeNull();
+
+    await act(async () => {
+      request.resolve(success('happ://crypt5/retired-by-gate'));
+      await request.promise;
+    });
+    expect(screen.queryByText('happ://crypt5/retired-by-gate')).toBeNull();
+    expect(HttpUtil.post).toHaveBeenCalledOnce();
+  });
+
+  it('keeps the dialog mounted through close and shows loading instead of noLinks on reopen', async () => {
+    const get = vi.mocked(HttpUtil.get);
+    const previousGet = get.getMockImplementation();
+    get.mockReturnValue(new Promise(() => {}));
+    try {
+      const view = renderSubject({ subSettings: { ...SUB_SETTINGS, enable: false } });
+      const dialog = screen.getByRole('dialog');
+
+      view.update({ open: false });
+      expect(document.body.contains(dialog)).toBe(true);
+
+      view.update({ open: true });
+      await waitFor(() =>
+        expect(screen.getByRole('dialog').querySelector('[aria-busy="true"]')).not.toBeNull(),
+      );
+      expect(screen.queryByText(/No shareable links/)).toBeNull();
+    } finally {
+      get.mockImplementation(previousGet!);
+    }
+  });
+
+  it('resets to Standard across close and reopen without reusing a prior Happ value', async () => {
+    vi.mocked(HttpUtil.post).mockResolvedValue(success());
+    const view = renderSubject();
+    selectVariant('Happ');
+    expect(await screen.findByText(HAPP_LINK)).toBeTruthy();
+
+    view.update({ open: false });
+    view.update({ open: true });
+
+    expect((screen.getByRole('radio', { name: 'Standard' }) as HTMLInputElement).checked).toBe(
+      true,
+    );
+    expect(screen.getByTestId('qr-panel-value').textContent).toBe(STANDARD_LINK);
+    expect(screen.queryByText(HAPP_LINK)).toBeNull();
+    expect(HttpUtil.post).toHaveBeenCalledOnce();
+  });
+
+  it.each([
+    ['leaving Happ', (_view: ReturnType<typeof renderSubject>) => selectVariant('Standard')],
+    ['closing', (view: ReturnType<typeof renderSubject>) => view.update({ open: false })],
+    [
+      'changing client id',
+      (view: ReturnType<typeof renderSubject>) => view.update({ client: { ...CLIENT, id: 77 } }),
+    ],
+    [
+      'changing subId',
+      (view: ReturnType<typeof renderSubject>) =>
+        view.update({ client: { ...CLIENT, subId: 'beta' } }),
+    ],
+    [
+      'changing the effective subscription source',
+      (view: ReturnType<typeof renderSubject>) =>
+        view.update({
+          subSettings: { ...SUB_SETTINGS, subURI: 'https://other.example/sub/' },
+        }),
+    ],
+  ])('ignores a generation response after %s', async (_name, retire) => {
+    const oldRequest = deferred<Msg<HappLinkResult>>();
+    const nextRequest = deferred<Msg<HappLinkResult>>();
+    vi.mocked(HttpUtil.post)
+      .mockReturnValueOnce(oldRequest.promise)
+      .mockReturnValue(nextRequest.promise);
+    const view = renderSubject();
+    selectVariant('Happ');
+    await waitFor(() => expect(HttpUtil.post).toHaveBeenCalledOnce());
+
+    retire(view);
+    await act(async () => {
+      oldRequest.resolve(success('happ://crypt5/retired-response'));
+      await oldRequest.promise;
+    });
+
+    expect(screen.queryByText('happ://crypt5/retired-response')).toBeNull();
+  });
+
+  it('shows only the localized generic hint and Retry after a backend failure', async () => {
+    vi.mocked(HttpUtil.post).mockResolvedValue(
+      new Msg<HappLinkResult>(false, 'provider token leaked by backend', null),
+    );
+    renderSubject();
+    selectVariant('Happ');
+
+    await screen.findByText('Retry');
+    expect(actionButton('Retry').disabled).toBe(false);
+    expect(
+      screen.getByText(
+        'The Happ link could not be generated. Retry, or check Overview -> Logs for details.',
+      ),
+    ).toBeTruthy();
+    expect(screen.queryByText(/provider token leaked/i)).toBeNull();
+  });
+
+  it('retries with a fresh request and exposes Regenerate after success', async () => {
+    vi.mocked(HttpUtil.post)
+      .mockResolvedValueOnce(new Msg<HappLinkResult>(false, 'backend detail', null))
+      .mockResolvedValueOnce(success());
+    renderSubject();
+    selectVariant('Happ');
+
+    await screen.findByText('Retry');
+    fireEvent.click(actionButton('Retry'));
+
+    expect(await screen.findByText(HAPP_LINK)).toBeTruthy();
+    expect(actionButton('Regenerate').disabled).toBe(false);
+    expect(HttpUtil.post).toHaveBeenCalledTimes(2);
+  });
+
+  it('explains a source length failure without Retry and keeps Standard available', async () => {
+    vi.mocked(HttpUtil.post).mockResolvedValue(
+      new Msg<HappLinkResult>(false, 'happ_source_too_long', null),
+    );
+    renderSubject();
+    selectVariant('Happ');
+
+    expect(await screen.findByText(SOURCE_TOO_LONG_HINT)).toBeTruthy();
+    expect(screen.queryByRole('button', { name: /Regenerate|Retry/ })).toBeNull();
+    expect(screen.queryByTestId('qr-panel-value')).toBeNull();
+    expect(screen.queryByText('happ_source_too_long')).toBeNull();
+
+    selectVariant('Standard');
+    expect(screen.getByTestId('qr-panel-value').textContent).toBe(STANDARD_LINK);
+    expect(screen.queryByText(SOURCE_TOO_LONG_HINT)).toBeNull();
+    expect(HttpUtil.post).toHaveBeenCalledOnce();
+  });
+
+  it.each([
+    ['non-exact error code', false, 'happ_source_too_long token=secret'],
+    ['successful malformed response', true, 'happ_source_too_long'],
+  ])('does not trust a %s as a source length failure', async (_name, successful, message) => {
+    vi.mocked(HttpUtil.post).mockResolvedValue(new Msg<HappLinkResult>(successful, message, null));
+    renderSubject();
+    selectVariant('Happ');
+
+    expect(await screen.findByRole('button', { name: 'Retry' })).toBeTruthy();
+    expect(screen.queryByText(SOURCE_TOO_LONG_HINT)).toBeNull();
+    expect(screen.queryByText(message)).toBeNull();
+  });
+
+  it('clears a source length failure when the subscription source changes', async () => {
+    vi.mocked(HttpUtil.post)
+      .mockResolvedValueOnce(new Msg<HappLinkResult>(false, 'happ_source_too_long', null))
+      .mockResolvedValueOnce(success());
+    const view = renderSubject();
+    selectVariant('Happ');
+    expect(await screen.findByText(SOURCE_TOO_LONG_HINT)).toBeTruthy();
+
+    view.update({ subSettings: { ...SUB_SETTINGS, subURI: 'https://short.example/sub/' } });
+    expect(screen.queryByText(SOURCE_TOO_LONG_HINT)).toBeNull();
+    expect((screen.getByRole('radio', { name: 'Standard' }) as HTMLInputElement).checked).toBe(
+      true,
+    );
+    selectVariant('Happ');
+    expect(await screen.findByText(HAPP_LINK)).toBeTruthy();
+    expect(screen.queryByText(SOURCE_TOO_LONG_HINT)).toBeNull();
+  });
+
+  it('ignores a source length failure from a retired request', async () => {
+    const retired = deferred<Msg<HappLinkResult>>();
+    vi.mocked(HttpUtil.post).mockReturnValueOnce(retired.promise).mockResolvedValueOnce(success());
+    renderSubject();
+    selectVariant('Happ');
+    selectVariant('Standard');
+    selectVariant('Happ');
+    expect(await screen.findByText(HAPP_LINK)).toBeTruthy();
+
+    await act(async () => {
+      retired.resolve(new Msg<HappLinkResult>(false, 'happ_source_too_long', null));
+      await retired.promise;
+    });
+    expect(screen.getByTestId('qr-panel-value').textContent).toBe(HAPP_LINK);
+    expect(screen.queryByText(SOURCE_TOO_LONG_HINT)).toBeNull();
+  });
+
+  it.each([
+    ['Retry', new Msg<HappLinkResult>(false, 'backend detail', null)],
+    ['Regenerate', success()],
+  ])('does not let a stale %s action bypass a disabled gate', async (action, response) => {
+    vi.mocked(HttpUtil.post).mockResolvedValue(response);
+    const view = renderSubject();
+    selectVariant('Happ');
+    const staleAction = await screen.findByRole('button', { name: new RegExp(action) });
+
+    view.update({ subSettings: { ...SUB_SETTINGS, happLinkEnable: false } });
+    await waitFor(() =>
+      expect(screen.queryByRole('button', { name: new RegExp(action) })).toBeNull(),
+    );
+    fireEvent.click(staleAction);
+
+    await waitFor(() => expect(HttpUtil.post).toHaveBeenCalledOnce());
+  });
+
+  it('clears the old QR and hides duplicate regeneration while loading', async () => {
+    const regeneration = deferred<Msg<HappLinkResult>>();
+    vi.mocked(HttpUtil.post)
+      .mockResolvedValueOnce(success())
+      .mockReturnValueOnce(regeneration.promise);
+    renderSubject();
+    selectVariant('Happ');
+    expect(await screen.findByText(HAPP_LINK)).toBeTruthy();
+    await waitFor(() => expect(actionButton('Regenerate').disabled).toBe(false));
+
+    fireEvent.click(actionButton('Regenerate'));
+
+    await waitFor(() => expect(HttpUtil.post).toHaveBeenCalledTimes(2));
+    expect(screen.queryByTestId('qr-panel-value')).toBeNull();
+    expect(screen.queryByRole('button', { name: /Regenerate|Retry/ })).toBeNull();
+    expect((screen.getByRole('radio', { name: 'Standard' }) as HTMLInputElement).disabled).toBe(
+      false,
+    );
+  });
+
+  it.each([
+    ['ordinary', 'happ://crypt5/AaBbCc-._~'],
+    ['standard Base64', 'happ://crypt5/AaBb+Cc/Dd=='],
+    ['maximum-size QR', `happ://crypt5/${'a'.repeat(2939)}`],
+  ])('passes a valid %s encryptedLink unchanged to QrPanel', async (_name, exactLink) => {
+    vi.mocked(HttpUtil.post).mockResolvedValue(success(exactLink));
+    renderSubject();
+    selectVariant('Happ');
+
+    const panel = await screen.findByTestId('qr-panel-value');
+    expect(panel.textContent).toBe(exactLink);
+    expect(panel.getAttribute('data-show-qr')).toBe('true');
+  });
+
+  it.each([
+    ['ASCII', `happ://crypt5/${'a'.repeat(2940)}`],
+    ['multi-byte', `happ://crypt5/${'界'.repeat(1000)}`],
+  ])('keeps a valid %s link available when it is too large for a QR code', async (_name, link) => {
+    vi.mocked(HttpUtil.post).mockResolvedValue(success(link));
+    renderSubject();
+    selectVariant('Happ');
+
+    const panel = await screen.findByTestId('qr-panel-value');
+    expect(panel.textContent).toBe(link);
+    expect(panel.getAttribute('data-show-qr')).toBe('false');
+    expect(
+      screen.getByText(
+        'This Happ link is valid, but it is too long to display as a QR code. Use Copy to use the complete link.',
+      ),
+    ).toBeTruthy();
+    expect(actionButton('Regenerate').disabled).toBe(false);
+  });
+
+  it.each([
+    ['non-string', { encryptedLink: 7 }],
+    ['stale crypt4 format', { encryptedLink: 'happ://crypt4/old-format' }],
+    ['empty payload', { encryptedLink: 'happ://crypt5/' }],
+    ['wrong scheme', { encryptedLink: 'https://provider.example/link' }],
+    ['whitespace', { encryptedLink: 'happ://crypt5/has space' }],
+    ['control character', { encryptedLink: 'happ://crypt5/example\n' }],
+  ])('rejects a %s encryptedLink before rendering QrPanel', async (_name, obj) => {
+    vi.mocked(HttpUtil.post).mockResolvedValue(new Msg(true, '', obj));
+    renderSubject();
+    selectVariant('Happ');
+
+    await screen.findByText('Retry');
+    expect(actionButton('Retry').disabled).toBe(false);
+    expect(screen.queryByTestId('qr-panel-value')).toBeNull();
+  });
+});

+ 12 - 0
frontend/src/test/clients-query-gating.test.tsx

@@ -58,6 +58,18 @@ function wrapperFor() {
 }
 
 describe('useClients query gating', () => {
+  it.each([
+    ['missing', {}, false],
+    ['false', { happLinkEnable: false }, false],
+    ['true', { happLinkEnable: true }, true],
+  ])('maps a %s Happ gate to a fail-closed client setting', async (_name, defaults, want) => {
+    mockPanel(defaults);
+    const { result } = renderHook(() => useClients(), { wrapper: wrapperFor() });
+
+    await waitFor(() => expect(result.current.settingsReady).toBe(true));
+    expect(result.current.subSettings.happLinkEnable).toBe(want);
+  });
+
   it('does not fetch the list until the page supplies a query', async () => {
     const pagedUrls = mockPanel({ pageSize: 25 });
     const { result } = renderHook(() => useClients(), { wrapper: wrapperFor() });

+ 12 - 22
frontend/src/test/happ-presets.test.ts

@@ -22,19 +22,11 @@ describe('Happ presets and helpers', () => {
     const jsonStr = atob(b64);
     const parsed = JSON.parse(jsonStr);
 
-    expect(parsed).toHaveProperty('rules');
-    expect(Array.isArray(parsed.rules)).toBe(true);
-
-    const directRule = parsed.rules.find(
-      (r: { outboundTag: string }) => r.outboundTag === 'direct',
-    );
-    expect(directRule).toBeDefined();
-    expect(directRule.domain).toContain('domain:ir');
-    expect(directRule.ip).toContain('geoip:ir');
-
-    const blockRule = parsed.rules.find((r: { outboundTag: string }) => r.outboundTag === 'block');
-    expect(blockRule).toBeDefined();
-    expect(blockRule.domain).toContain('geosite:category-ads-all');
+    expect(parsed.Name).toBe('Iran Bypass');
+    expect(parsed.GlobalProxy).toBe('true');
+    expect(parsed.DirectSites).toContain('domain:ir');
+    expect(parsed.DirectIp).toContain('geoip:ir');
+    expect(parsed.BlockSites).toContain('geosite:category-ads-all');
   });
 
   it('generates valid base64 payload for china-direct preset', () => {
@@ -45,11 +37,9 @@ describe('Happ presets and helpers', () => {
     const jsonStr = atob(b64);
     const parsed = JSON.parse(jsonStr);
 
-    const directRule = parsed.rules.find(
-      (r: { outboundTag: string }) => r.outboundTag === 'direct',
-    );
-    expect(directRule.domain).toContain('domain:cn');
-    expect(directRule.ip).toContain('geoip:cn');
+    expect(parsed.Name).toBe('China Direct');
+    expect(parsed.DirectSites).toContain('geosite:cn');
+    expect(parsed.DirectIp).toContain('geoip:cn');
   });
 
   it('generates valid base64 payload for adblock preset', () => {
@@ -58,8 +48,8 @@ describe('Happ presets and helpers', () => {
     const jsonStr = atob(b64);
     const parsed = JSON.parse(jsonStr);
 
-    const blockRule = parsed.rules.find((r: { outboundTag: string }) => r.outboundTag === 'block');
-    expect(blockRule.domain).toContain('geosite:category-ads-all');
+    expect(parsed.Name).toBe('AdBlock');
+    expect(parsed.BlockSites).toContain('geosite:category-ads-all');
   });
 
   it('generates valid base64 payload for global preset', () => {
@@ -68,8 +58,8 @@ describe('Happ presets and helpers', () => {
     const jsonStr = atob(b64);
     const parsed = JSON.parse(jsonStr);
 
-    expect(parsed.rules[0].outboundTag).toBe('proxy');
-    expect(parsed.rules[0].network).toBe('tcp,udp');
+    expect(parsed.Name).toBe('Global Proxy');
+    expect(parsed.DomainStrategy).toBe('AsIs');
   });
 
   it('encodes unicode properly via toBase64Utf8', () => {

+ 21 - 0
frontend/src/test/happ-settings-tun-mode.test.tsx

@@ -69,4 +69,25 @@ describe('Happ TUN Mode select', () => {
     const select = selectFor('TUN Mode');
     expect(select.querySelector('.ant-select-content')?.textContent).toBe('Default');
   });
+
+  it('renders Auto-Detection master switch at the top and triggers update', () => {
+    const updateSetting = vi.fn();
+    const allSetting = new AllSetting();
+    allSetting.subHappAutoDetect = false;
+
+    renderWithProviders(
+      <HappSettingsContent
+        allSetting={allSetting}
+        updateSetting={updateSetting}
+        isMobile={false}
+        remoteSourceBadge={() => null}
+      />,
+    );
+
+    const switchBtn = document.querySelector('.ant-switch');
+    if (!switchBtn) throw new Error('switch not found');
+    fireEvent.click(switchBtn);
+
+    expect(updateSetting).toHaveBeenCalledWith({ subHappAutoDetect: true });
+  });
 });

+ 10 - 8
frontend/src/test/multi-tunnel-client-config.test.tsx

@@ -1,6 +1,6 @@
 import { describe, it, expect } from 'vitest';
 import { screen } from '@testing-library/react';
-
+import { MemoryRouter } from 'react-router';
 import ClientInfoModal from '@/pages/clients/ClientInfoModal';
 import ClientQrModal from '@/pages/clients/ClientQrModal';
 import type { ClientRecord, InboundOption } from '@/hooks/useClients';
@@ -168,13 +168,15 @@ describe('Multi-tunnel Client Modals', () => {
 
   it('renders separate collapse panels in ClientQrModal for multiple AmneziaWG inbounds', () => {
     renderWithProviders(
-      <ClientQrModal
-        open
-        client={multiAwgClient}
-        inboundsById={{ 101: deAwgInbound, 102: fiAwgInbound }}
-        tunnelAllowedIPs={{ 101: '10.8.1.5/32', 102: '10.8.2.10/32' }}
-        onOpenChange={() => {}}
-      />,
+      <MemoryRouter initialEntries={['/clients']}>
+        <ClientQrModal
+          open
+          client={multiAwgClient}
+          inboundsById={{ 101: deAwgInbound, 102: fiAwgInbound }}
+          tunnelAllowedIPs={{ 101: '10.8.1.5/32', 102: '10.8.2.10/32' }}
+          onOpenChange={() => {}}
+        />
+      </MemoryRouter>,
     );
 
     expect(screen.getByText('DE · Kelsterbach')).toBeTruthy();

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

@@ -11,6 +11,7 @@ function LocationProbe() {
   return (
     <output data-testid="location">
       {location.pathname}
+      {location.search}
       {location.hash}
     </output>
   );
@@ -68,4 +69,81 @@ describe('SubscriptionGeneralTab', () => {
 
     expect(screen.getByTestId('location').textContent).toBe('/settings#subscription-formats');
   });
+
+  it.each([false, true])(
+    'updates the Happ link gate from its own tab when stored as %s',
+    (enabled) => {
+      const updateSetting = vi.fn();
+
+      renderWithProviders(
+        <MemoryRouter initialEntries={['/settings#subscription']}>
+          <SubscriptionGeneralTab
+            allSetting={new AllSetting({ happLinkEnable: enabled })}
+            updateSetting={updateSetting}
+          />
+        </MemoryRouter>,
+      );
+
+      fireEvent.click(screen.getByRole('tab', { name: /Happ/ }));
+      expect(
+        screen.getByRole('tab', { name: /Routing & Rules/ }).getAttribute('aria-selected'),
+      ).toBe('true');
+      expect(screen.queryByRole('switch', { name: 'Encrypted subscription links' })).toBeNull();
+      fireEvent.click(screen.getByRole('tab', { name: /Subscription Links/ }));
+      const linkSwitch = screen.getByRole('switch', { name: 'Encrypted subscription links' });
+      expect(linkSwitch.getAttribute('aria-checked')).toBe(String(enabled));
+      expect(updateSetting).not.toHaveBeenCalled();
+      fireEvent.click(linkSwitch);
+
+      expect(updateSetting).toHaveBeenCalledExactlyOnceWith({ happLinkEnable: !enabled });
+    },
+  );
+
+  it('opens the Happ link tab from the QR settings deep link without enabling generation', () => {
+    const updateSetting = vi.fn();
+
+    renderWithProviders(
+      <MemoryRouter initialEntries={['/settings?subscriptionTab=happ&happTab=links#subscription']}>
+        <SubscriptionGeneralTab
+          allSetting={new AllSetting({ happLinkEnable: false })}
+          updateSetting={updateSetting}
+        />
+        <LocationProbe />
+      </MemoryRouter>,
+    );
+
+    expect(screen.getByRole('tab', { name: /Happ/ }).getAttribute('aria-selected')).toBe('true');
+    expect(
+      screen.getByRole('tab', { name: /Subscription Links/ }).getAttribute('aria-selected'),
+    ).toBe('true');
+    expect(
+      screen
+        .getByRole('switch', { name: 'Encrypted subscription links' })
+        .getAttribute('aria-checked'),
+    ).toBe('false');
+    expect(screen.getByTestId('location').textContent).toBe(
+      '/settings?subscriptionTab=happ&happTab=links#subscription',
+    );
+    expect(updateSetting).not.toHaveBeenCalled();
+  });
+
+  it.each(['', '&happTab=unknown'])(
+    'keeps the routing default for a general Happ deep link %s',
+    (query) => {
+      const updateSetting = vi.fn();
+
+      renderWithProviders(
+        <MemoryRouter initialEntries={['/settings?subscriptionTab=happ' + query + '#subscription']}>
+          <SubscriptionGeneralTab allSetting={new AllSetting()} updateSetting={updateSetting} />
+        </MemoryRouter>,
+      );
+
+      expect(screen.getByRole('tab', { name: /Happ/ }).getAttribute('aria-selected')).toBe('true');
+      expect(
+        screen.getByRole('tab', { name: /Routing & Rules/ }).getAttribute('aria-selected'),
+      ).toBe('true');
+      expect(screen.queryByRole('switch', { name: 'Encrypted subscription links' })).toBeNull();
+      expect(updateSetting).not.toHaveBeenCalled();
+    },
+  );
 });

+ 8 - 2
internal/sub/controller.go

@@ -107,6 +107,7 @@ type subControllerConfig struct {
 	subJsonMux            string
 	subJsonRules          string
 	subJsonRoutingRules   string
+	subJsonDns            string
 	subJsonFinalMask      string
 	subJsonObservatory    string
 	subClashEnableRouting bool
@@ -191,6 +192,10 @@ func WithSUBJsonRoutingRules(value string) SUBControllerOption {
 	return func(config *subControllerConfig) { config.subJsonRoutingRules = value }
 }
 
+func WithSUBJsonDns(value string) SUBControllerOption {
+	return func(config *subControllerConfig) { config.subJsonDns = value }
+}
+
 func WithSUBJsonFinalMask(value string) SUBControllerOption {
 	return func(config *subControllerConfig) { config.subJsonFinalMask = value }
 }
@@ -268,6 +273,7 @@ func NewSUBController(g *gin.RouterGroup, options ...SUBControllerOption) *SUBCo
 	sub := NewSubService(config.remarkTemplate)
 	subJsonSvc := NewSubJsonService(config.subJsonMux, config.subJsonRules, config.subJsonFinalMask, config.subJsonRoutingRules, sub)
 	subJsonSvc.SetObservatoryConfig(config.subJsonObservatory)
+	subJsonSvc.SetDnsConfig(config.subJsonDns)
 	a := &SUBController{
 		subTitle:            config.subTitle,
 		subSupportUrl:       config.subSupportURL,
@@ -932,8 +938,8 @@ func (a *SUBController) ApplyCommonHeaders(
 
 	rules, remote, routingErr := resolveRoutingSource(remoteRoutingHapp, profileRoutingRules)
 	if strings.TrimSpace(profileRoutingRules) == "" {
-		// Happ/INCY fetch the geo files the baked JSON rules reference through
-		// this header, so a blank Happ setting falls back to the JSON profile.
+		// Happ/INCY fetch the geo files the baked rules reference through this
+		// header; unlike the documents, it keeps the profile's own DNS servers.
 		rules, remote, routingErr = jsonRoutingHeaderSource(a.subJsonRoutingRules), false, nil
 	}
 	// The off values undo a previously pushed setting, so they ride the same

+ 23 - 5
internal/sub/endpoint.go

@@ -1,6 +1,7 @@
 package sub
 
 import (
+	"encoding/base64"
 	"strings"
 
 	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
@@ -20,10 +21,11 @@ import (
 // because three behaviors branch on the raw string (keep-base, obj["tls"]
 // rewrite, none-strip).
 type ShareEndpoint struct {
-	Address  string
-	Port     int
-	Remark   string // extra remark slot fed to genRemark, not a rendered remark
-	ForceTls string
+	Address           string
+	Port              int
+	Remark            string // extra remark slot fed to genRemark, not a rendered remark
+	ServerDescription string // subtitle caption displayed in Happ client
+	ForceTls          string
 
 	// ep is the source externalProxy entry. nil for host/default endpoints.
 	ep map[string]any
@@ -38,6 +40,7 @@ func externalProxyToEndpoint(ep map[string]any) ShareEndpoint {
 		e.Port = int(p)
 	}
 	e.Remark, _ = ep["remark"].(string)
+	e.ServerDescription, _ = ep["serverDescription"].(string)
 	e.ForceTls, _ = ep["forceTls"].(string)
 	return e
 }
@@ -106,10 +109,14 @@ func (s *SubService) buildEndpointLinks(
 		applyEndpointHostPath(e, nextParams)
 		applyEndpointFinalMask(e, nextParams)
 		applyEndpointAllowInsecure(e, nextParams, securityToApply)
+		remark := makeRemark(e)
+		if e.ServerDescription != "" {
+			remark = appendHappServerDescription(remark, e.ServerDescription)
+		}
 		links = append(links, buildLinkWithParamsAndSecurity(
 			makeLink(e),
 			nextParams,
-			makeRemark(e),
+			remark,
 			securityToApply,
 			e.ForceTls == "none",
 		))
@@ -117,6 +124,14 @@ func (s *SubService) buildEndpointLinks(
 	return strings.Join(links, "\n")
 }
 
+func appendHappServerDescription(remark, desc string) string {
+	if desc == "" {
+		return remark
+	}
+	encoded := base64.StdEncoding.EncodeToString([]byte(desc))
+	return remark + "?serverDescription=" + encoded
+}
+
 // buildEndpointVmessLinks renders one VMess base64-JSON link per endpoint.
 func (s *SubService) buildEndpointVmessLinks(eps []ShareEndpoint, baseObj map[string]any, inbound *model.Inbound, email string, transport string) string {
 	var links strings.Builder
@@ -132,6 +147,9 @@ func (s *SubService) buildEndpointVmessLinks(eps []ShareEndpoint, baseObj map[st
 		if e.ForceTls != "same" {
 			newObj["tls"] = e.ForceTls
 		}
+		if e.ServerDescription != "" {
+			newObj["serverDescription"] = e.ServerDescription
+		}
 		applyEndpointTLSObj(e, newObj, securityToApply)
 		applyEndpointHostPathObj(e, newObj)
 		applyEndpointFinalMaskObj(e, newObj)

+ 4 - 4
internal/sub/endpoint_test.go

@@ -116,8 +116,8 @@ func TestBuildEndpointVmessLinks(t *testing.T) {
 	}
 }
 
-// happ.su documents serverDescription as a "#title?serverDescription=<base64>"
-// link parameter, never a key of the VMess object, so nothing may leak into it.
+// happ.su documents serverDescription for VMess as a JSON object key
+// {"add":"...","ps":"...","serverDescription":"Happ the best"}.
 func TestBuildEndpointVmessLinks_HostServerDescription(t *testing.T) {
 	s := &SubService{}
 	in := &model.Inbound{Remark: "ib"}
@@ -137,8 +137,8 @@ func TestBuildEndpointVmessLinks_HostServerDescription(t *testing.T) {
 	if obj["add"] != "a.example.com" {
 		t.Fatalf("host endpoint not applied: add = %v", obj["add"])
 	}
-	if value, ok := obj["serverDescription"]; ok {
-		t.Fatalf("VMess object carries serverDescription = %v; it is not a VMess object key", value)
+	if obj["serverDescription"] != "Berlin premium" {
+		t.Fatalf("VMess object missing serverDescription: got %v, want Berlin premium", obj["serverDescription"])
 	}
 }
 

+ 11 - 8
internal/sub/happ.go

@@ -41,21 +41,25 @@ func IsHappClient(userAgent string) bool {
 	return happUserAgentRegex.MatchString(userAgent)
 }
 
+func sanitizeHeaderValue(v string) string {
+	return strings.ReplaceAll(strings.ReplaceAll(strings.TrimSpace(v), "\r", ""), "\n", "")
+}
+
 // ApplyHappHeaders sets standard and advanced Happ subscription headers.
 func ApplyHappHeaders(c *gin.Context, cfg HappConfig, isHapp bool) {
 	if c == nil || c.Writer == nil || !cfg.AutoDetect || !isHapp {
 		return
 	}
 	if cfg.ProviderId != "" {
-		c.Writer.Header().Set("ProviderID", cfg.ProviderId)
+		c.Writer.Header().Set("ProviderID", strings.TrimSpace(cfg.ProviderId))
 	}
 	if cfg.NewUrl != "" {
-		c.Writer.Header().Set("New-Url", cfg.NewUrl)
+		c.Writer.Header().Set("New-Url", strings.TrimSpace(cfg.NewUrl))
 	}
 	if cfg.FallbackUrl != "" {
-		c.Writer.Header().Set("Fallback-Url", cfg.FallbackUrl)
+		c.Writer.Header().Set("Fallback-Url", strings.TrimSpace(cfg.FallbackUrl))
 	}
-	if text := strings.TrimSpace(cfg.SubInfoText); text != "" {
+	if text := sanitizeHeaderValue(cfg.SubInfoText); text != "" {
 		color := strings.TrimSpace(cfg.SubInfoColor)
 		switch strings.ToLower(color) {
 		case "primary", "info":
@@ -69,10 +73,10 @@ func ApplyHappHeaders(c *gin.Context, cfg HappConfig, isHapp bool) {
 		}
 		c.Writer.Header().Set("Sub-Info-Color", color)
 		c.Writer.Header().Set("Sub-Info-Text", text)
-		if btnText := strings.TrimSpace(cfg.SubInfoButtonText); btnText != "" {
+		if btnText := sanitizeHeaderValue(cfg.SubInfoButtonText); btnText != "" {
 			c.Writer.Header().Set("Sub-Info-Button-Text", btnText)
 		}
-		if btnLink := strings.TrimSpace(cfg.SubInfoButtonLink); btnLink != "" {
+		if btnLink := sanitizeHeaderValue(cfg.SubInfoButtonLink); btnLink != "" {
 			c.Writer.Header().Set("Sub-Info-Button-Link", btnLink)
 		}
 	}
@@ -103,8 +107,7 @@ func ApplyHappHeaders(c *gin.Context, cfg HappConfig, isHapp bool) {
 	if cfg.ExcludeApns {
 		c.Writer.Header().Set("Exclude-Apns-Enable", "true")
 	}
-	if profile := strings.TrimSpace(cfg.ColorProfile); profile != "" {
-		profile = strings.ReplaceAll(strings.ReplaceAll(profile, "\r", ""), "\n", "")
+	if profile := sanitizeHeaderValue(cfg.ColorProfile); profile != "" {
 		c.Writer.Header().Set("Color-Profile", profile)
 	}
 	if ping := strings.TrimSpace(cfg.PingType); ping != "" {

+ 124 - 1
internal/sub/happ_test.go

@@ -1,8 +1,10 @@
 package sub
 
 import (
+	"encoding/base64"
 	"net/http"
 	"net/http/httptest"
+	"strings"
 	"testing"
 
 	"github.com/gin-gonic/gin"
@@ -53,12 +55,15 @@ func TestApplyCommonHeaders_HappClientHeaders(t *testing.T) {
 	ctx.Request = httptest.NewRequest(http.MethodGet, "/sub/test", nil)
 	ctx.Request.Header.Set("User-Agent", "Happ/1.2.0 (iPhone; iOS 17.5)")
 
-	controller.ApplyCommonHeaders(ctx, "upload=0; download=100; total=1000; expire=1800000000", "12", "MyTitle", "", "", "", false, "", false)
+	controller.ApplyCommonHeaders(ctx, "upload=0; download=100; total=1000; expire=1800000000", "12", "MyTitle", "", "", "", false, "happ://routing/onadd/existing-rules", false)
 
 	h := recorder.Header()
 	if h.Get("Routing-Enable") != "0" {
 		t.Fatalf("Routing-Enable = %q, want 0 for Happ with disabled routing", h.Get("Routing-Enable"))
 	}
+	if h.Get("Routing") != "happ://routing/onadd/existing-rules" {
+		t.Fatalf("Routing = %q, want happ://routing/onadd/existing-rules for Happ with configured routing", h.Get("Routing"))
+	}
 	if h.Get("Hide-Settings") != "0" {
 		t.Fatalf("Hide-Settings = %q, want 0 for Happ with disabled hideSettings", h.Get("Hide-Settings"))
 	}
@@ -109,6 +114,28 @@ func TestApplyCommonHeaders_HappClientHeaders(t *testing.T) {
 	}
 }
 
+func TestApplyCommonHeaders_HappRoutingOffDeeplink(t *testing.T) {
+	gin.SetMode(gin.TestMode)
+
+	cfg := HappConfig{AutoDetect: true}
+	controller := &SUBController{happConfig: cfg}
+	recorder := httptest.NewRecorder()
+	ctx, _ := gin.CreateTestContext(recorder)
+	ctx.Request = httptest.NewRequest(http.MethodGet, "/sub/test", nil)
+	ctx.Request.Header.Set("User-Agent", "Happ/1.2.0 (iPhone)")
+
+	// When rules is happ://routing/off, Routing-Enable is 0 and Routing is happ://routing/off
+	controller.ApplyCommonHeaders(ctx, "", "", "Title", "", "", "", false, "happ://routing/off", false)
+
+	h := recorder.Header()
+	if h.Get("Routing-Enable") != "0" {
+		t.Fatalf("Routing-Enable = %q, want 0 when rules is happ://routing/off", h.Get("Routing-Enable"))
+	}
+	if h.Get("Routing") != "happ://routing/off" {
+		t.Fatalf("Routing = %q, want happ://routing/off", h.Get("Routing"))
+	}
+}
+
 func TestApplyHappHeaders_Gating(t *testing.T) {
 	gin.SetMode(gin.TestMode)
 
@@ -165,6 +192,9 @@ func TestApplyHappHeaders_Gating(t *testing.T) {
 		if got := recorder.Header().Get("Hide-Settings"); got != "" {
 			t.Fatalf("Hide-Settings emitted when AutoDetect is false: %q", got)
 		}
+		if got := recorder.Header().Get("Routing"); got != "" {
+			t.Fatalf("Routing emitted when AutoDetect is false: %q", got)
+		}
 	})
 }
 
@@ -225,3 +255,96 @@ func TestApplyHappHeaders_Aliases(t *testing.T) {
 		})
 	}
 }
+
+func TestAppendHappServerDescription(t *testing.T) {
+	desc := "VIP Server"
+	encoded := base64.StdEncoding.EncodeToString([]byte(desc))
+
+	got := appendHappServerDescription("My Node", desc)
+	want := "My Node?serverDescription=" + encoded
+	if got != want {
+		t.Fatalf("appendHappServerDescription = %q, want %q", got, want)
+	}
+
+	if gotEmpty := appendHappServerDescription("My Node", ""); gotEmpty != "My Node" {
+		t.Fatalf("appendHappServerDescription with empty desc = %q, want My Node", gotEmpty)
+	}
+}
+
+func TestAppendQueryAndFragment_PreservesServerDescription(t *testing.T) {
+	desc := "Fast Server"
+	encoded := base64.StdEncoding.EncodeToString([]byte(desc))
+
+	t.Run("preserves serverDescription with encoded title", func(t *testing.T) {
+		fragment := "Server 01?serverDescription=" + encoded
+		link := appendQueryAndFragment("vless://user@host:443", nil, fragment, "", false)
+		want := "vless://user@host:443#Server%2001?serverDescription=" + encoded
+		if link != want {
+			t.Fatalf("appendQueryAndFragment = %q, want %q", link, want)
+		}
+	})
+
+	t.Run("properly escapes remark containing literal question mark without serverDescription", func(t *testing.T) {
+		fragment := "Fast? Server"
+		link := appendQueryAndFragment("vless://user@host:443", nil, fragment, "", false)
+		want := "vless://user@host:443#Fast%3F%20Server"
+		if link != want {
+			t.Fatalf("appendQueryAndFragment = %q, want %q", link, want)
+		}
+	})
+
+	t.Run("properly escapes remark containing literal question mark with serverDescription", func(t *testing.T) {
+		fragment := "Fast? Server?serverDescription=" + encoded
+		link := appendQueryAndFragment("vless://user@host:443", nil, fragment, "", false)
+		want := "vless://user@host:443#Fast%3F%20Server?serverDescription=" + encoded
+		if link != want {
+			t.Fatalf("appendQueryAndFragment = %q, want %q", link, want)
+		}
+	})
+
+	t.Run("escapes fragment when serverDescription tail contains invalid base64", func(t *testing.T) {
+		fragment := "Node 1?serverDescription=not-base64!!!"
+		link := appendQueryAndFragment("vless://user@host:443", nil, fragment, "", false)
+		want := "vless://user@host:443#Node%201%3FserverDescription%3Dnot-base64%21%21%21"
+		if link != want {
+			t.Fatalf("appendQueryAndFragment = %q, want %q", link, want)
+		}
+	})
+
+	t.Run("escapes fragment when serverDescription tail contains newline injection", func(t *testing.T) {
+		fragment := "Node 1?serverDescription=" + encoded + "\nevil://inject"
+		link := appendQueryAndFragment("vless://user@host:443", nil, fragment, "", false)
+		if strings.Contains(link, "\n") {
+			t.Fatalf("appendQueryAndFragment emitted raw newline: %q", link)
+		}
+	})
+}
+
+func TestIsHappClient(t *testing.T) {
+	matching := []string{
+		"Happ/1.2.0 (iPhone; iOS 17.5)",
+		"happ/2.0",
+		"happ",
+		"HAPP/1.0",
+		"Mozilla/5.0 Happ/1.0",
+	}
+	for _, ua := range matching {
+		if !IsHappClient(ua) {
+			t.Errorf("IsHappClient(%q) = false, want true", ua)
+		}
+	}
+
+	nonMatching := []string{
+		"Happy/2.0",
+		"happier-client",
+		"Happening/1.0",
+		"unhappy",
+		"v2rayNG/1.8.5",
+		"",
+	}
+	for _, ua := range nonMatching {
+		if IsHappClient(ua) {
+			t.Errorf("IsHappClient(%q) = true, want false", ua)
+		}
+	}
+}

+ 3 - 0
internal/sub/host_sub.go

@@ -107,6 +107,9 @@ func hostToExternalProxyMap(h *model.Host, defaultDest string, defaultPort int)
 	if h.VlessRoute != "" {
 		ep["vlessRoute"] = h.VlessRoute
 	}
+	if h.ServerDescription != "" {
+		ep["serverDescription"] = h.ServerDescription
+	}
 	return ep
 }
 

+ 20 - 0
internal/sub/json_dns.go

@@ -0,0 +1,20 @@
+package sub
+
+import (
+	"github.com/mhsanaei/3x-ui/v3/internal/logger"
+	"github.com/mhsanaei/3x-ui/v3/internal/xray/dnsconf"
+)
+
+// The subJsonDns setting holds either a full xray dns block or a bare array of
+// servers; dnsconf validates it against the parser the client runs.
+
+// SetDnsConfig overrides the dns block of every emitted document; an unusable
+// value is logged and ignored so a typo cannot take subscriptions down.
+func (s *SubJsonService) SetDnsConfig(raw string) {
+	block, err := dnsconf.Parse(raw)
+	if err != nil {
+		logger.Warningf("subJsonDns: %v; keeping the template DNS", err)
+		return
+	}
+	s.dnsBlock = block
+}

+ 48 - 0
internal/sub/json_dns_controller_test.go

@@ -0,0 +1,48 @@
+package sub
+
+import (
+	"net/http"
+	"net/http/httptest"
+	"strings"
+	"testing"
+
+	"github.com/gin-gonic/gin"
+)
+
+// The panel setting must survive the controller wiring, not just the service
+// API: sub.go passes it as a controller option.
+func TestJsonEndpointServesPanelDnsServers(t *testing.T) {
+	seedSubDB(t)
+	seedSubInbound(t, "s1", "tcpin", 4910, 1, dnsTestStream)
+	gin.SetMode(gin.TestMode)
+
+	router := gin.New()
+	NewSUBController(
+		router.Group("/"),
+		WithSUBJsonEnabled(true),
+		WithSUBJsonAlwaysArray(true),
+		WithSUBJsonDns(`["https://dns.google/dns-query", "tls://1.1.1.1"]`),
+	)
+
+	req := httptest.NewRequest(http.MethodGet, "http://sub.example.com/json/s1", nil)
+	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())
+	}
+	docs := parseSubJsonDocs(t, resp.Body.String())
+	if len(docs) != 1 {
+		t.Fatalf("docs = %d, want 1", len(docs))
+	}
+	servers, _ := docDnsBlock(t, docs[0])["servers"].([]any)
+	if len(servers) != 2 || servers[0] != "https://dns.google/dns-query" || servers[1] != "tls://1.1.1.1" {
+		t.Fatalf("dns servers = %v", servers)
+	}
+	if _, hasTemplate := docDnsBlock(t, docs[0])["tag"]; hasTemplate {
+		t.Fatalf("template dns keys leaked: %v", docDnsBlock(t, docs[0]))
+	}
+	if body := resp.Body.String(); strings.Contains(body, "8.8.8.8") {
+		t.Fatalf("template resolver survived the override:\n%s", body)
+	}
+}

+ 267 - 0
internal/sub/json_dns_test.go

@@ -0,0 +1,267 @@
+package sub
+
+import (
+	"strings"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/xray"
+)
+
+const dnsTestStream = `{"network":"tcp","security":"tls","tlsSettings":{"serverName":"base.sni"}}`
+
+func docDnsBlock(t *testing.T, doc map[string]any) map[string]any {
+	t.Helper()
+	dns, _ := doc["dns"].(map[string]any)
+	if dns == nil {
+		t.Fatalf("doc has no dns block: %v", doc["dns"])
+	}
+	return dns
+}
+
+func onlySubJsonDoc(t *testing.T, js *SubJsonService, subId string) map[string]any {
+	t.Helper()
+	out, _, err := js.GetJson(subId, "req.example.com", true)
+	if err != nil {
+		t.Fatalf("GetJson: %v", err)
+	}
+	docs := parseSubJsonDocs(t, out)
+	if len(docs) != 1 {
+		t.Fatalf("docs = %d, want 1:\n%s", len(docs), out)
+	}
+	return docs[0]
+}
+
+// A bare servers array must replace the template resolver, not append to it.
+func TestSubJsonDns_ArrayReplacesTemplateServers(t *testing.T) {
+	seedSubDB(t)
+	seedSubInbound(t, "s1", "tcpin", 4901, 1, dnsTestStream)
+
+	js := NewSubJsonService("", "", "", "", NewSubService(""))
+	js.SetDnsConfig(`["https://dns.google/dns-query", {"address": "tls://1.1.1.1", "domains": ["geosite:youtube"]}]`)
+
+	dns := docDnsBlock(t, onlySubJsonDoc(t, js, "s1"))
+	servers, _ := dns["servers"].([]any)
+	if len(servers) != 2 {
+		t.Fatalf("servers = %v, want 2", servers)
+	}
+	if servers[0] != "https://dns.google/dns-query" {
+		t.Fatalf("servers[0] = %v", servers[0])
+	}
+	second, _ := servers[1].(map[string]any)
+	if second["address"] != "tls://1.1.1.1" {
+		t.Fatalf("servers[1] = %v", second)
+	}
+	if domains, _ := second["domains"].([]any); strings.Join(stringify(domains), ",") != "geosite:youtube" {
+		t.Fatalf("servers[1].domains = %v", second["domains"])
+	}
+	if _, hasTemplate := dns["tag"]; hasTemplate {
+		t.Fatalf("template dns keys leaked into the override: %v", dns)
+	}
+}
+
+func TestSubJsonDns_ObjectReplacesWholeBlock(t *testing.T) {
+	seedSubDB(t)
+	seedSubInbound(t, "s1", "tcpin", 4902, 1, dnsTestStream)
+
+	js := NewSubJsonService("", "", "", "", NewSubService(""))
+	js.SetDnsConfig(`{"tag":"panel_dns","queryStrategy":"UseIPv4","disableCache":true,"hosts":{"example.com":"1.2.3.4"},"servers":[{"address":"1.1.1.1","skipFallback":true}]}`)
+
+	dns := docDnsBlock(t, onlySubJsonDoc(t, js, "s1"))
+	if dns["tag"] != "panel_dns" || dns["queryStrategy"] != "UseIPv4" || dns["disableCache"] != true {
+		t.Fatalf("dns header = %v", dns)
+	}
+	hosts, _ := dns["hosts"].(map[string]any)
+	if hosts["example.com"] != "1.2.3.4" {
+		t.Fatalf("dns hosts = %v", dns["hosts"])
+	}
+	servers, _ := dns["servers"].([]any)
+	if len(servers) != 1 {
+		t.Fatalf("servers = %v", servers)
+	}
+	server, _ := servers[0].(map[string]any)
+	if server["address"] != "1.1.1.1" || server["skipFallback"] != true {
+		t.Fatalf("server = %v", server)
+	}
+}
+
+// The explicit panel DNS block wins over the profile's, while the profile keeps
+// owning the routing rules.
+func TestSubJsonDns_OverridesRoutingProfileDns(t *testing.T) {
+	seedSubDB(t)
+	seedSubInbound(t, "s1", "tcpin", 4903, 1, dnsTestStream)
+
+	js := NewSubJsonService("", "", "", bakedRoutingPayload, NewSubService(""))
+	js.SetDnsConfig(`["9.9.9.9"]`)
+
+	doc := onlySubJsonDoc(t, js, "s1")
+	dns := docDnsBlock(t, doc)
+	servers, _ := dns["servers"].([]any)
+	if len(servers) != 1 || servers[0] != "9.9.9.9" {
+		t.Fatalf("servers = %v, want the panel override only", servers)
+	}
+	if hosts, _ := dns["hosts"].(map[string]any); len(hosts) != 0 {
+		t.Fatalf("profile dns hosts survived the override: %v", hosts)
+	}
+	want := "domain->block,domain->proxy,domain->direct,ip->direct,network->proxy"
+	if got := strings.Join(ruleSignatures(t, doc), ","); got != want {
+		t.Fatalf("rules = %v\nwant %v", got, want)
+	}
+}
+
+func TestSubJsonDns_InvalidSettingKeepsTemplateDns(t *testing.T) {
+	cases := []struct {
+		name  string
+		value string
+	}{
+		{"malformed JSON", `{"servers": [`},
+		{"bare string", `"8.8.8.8"`},
+		{"servers not an array", `{"servers": "8.8.8.8"}`},
+		{"entry without address", `[{"domains": ["geosite:youtube"]}]`},
+		{"non-string entry", `[53]`},
+	}
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			seedSubDB(t)
+			seedSubInbound(t, "s1", "tcpin", 4904, 1, dnsTestStream)
+
+			js := NewSubJsonService("", "", "", "", NewSubService(""))
+			js.SetDnsConfig(tc.value)
+
+			dns := docDnsBlock(t, onlySubJsonDoc(t, js, "s1"))
+			if dns["tag"] != "dns_out" || dns["queryStrategy"] != "UseIP" {
+				t.Fatalf("template dns header = %v", dns)
+			}
+			servers, _ := dns["servers"].([]any)
+			if len(servers) != 1 {
+				t.Fatalf("template servers = %v", servers)
+			}
+			first, _ := servers[0].(map[string]any)
+			if first["address"] != "8.8.8.8" {
+				t.Fatalf("template server = %v", first)
+			}
+		})
+	}
+}
+
+func TestSubJsonDns_BlankKeepsTemplateDns(t *testing.T) {
+	seedSubDB(t)
+	seedSubInbound(t, "s1", "tcpin", 4905, 1, dnsTestStream)
+
+	js := NewSubJsonService("", "", "", "", NewSubService(""))
+	js.SetDnsConfig("   ")
+
+	dns := docDnsBlock(t, onlySubJsonDoc(t, js, "s1"))
+	servers, _ := dns["servers"].([]any)
+	first, _ := servers[0].(map[string]any)
+	if first["address"] != "8.8.8.8" {
+		t.Fatalf("template server = %v", first)
+	}
+}
+
+// Balancer documents are built from the same template, so they carry the
+// override too.
+func TestSubJsonDns_AppliesToBalancerDocuments(t *testing.T) {
+	seedSubDB(t)
+	tcp := seedSubInbound(t, "s1", "tcpin", 4906, 1, dnsTestStream)
+	seedSubBalancer(t, &model.SubBalancer{
+		Remark: "auto", Strategy: "random", InboundIds: []int{tcp.Id}, SortOrder: 1, Enabled: true,
+	})
+
+	js := NewSubJsonService("", "", "", "", NewSubService(""))
+	js.SetDnsConfig(`["https://dns.google/dns-query"]`)
+
+	out, _, err := js.GetJson("s1", "req.example.com", true)
+	if err != nil {
+		t.Fatalf("GetJson: %v", err)
+	}
+	balancerDoc := findDocByRemarks(parseSubJsonDocs(t, out), "auto")
+	if balancerDoc == nil {
+		t.Fatalf("balancer doc missing:\n%s", out)
+	}
+	servers, _ := docDnsBlock(t, balancerDoc)["servers"].([]any)
+	if len(servers) != 1 || servers[0] != "https://dns.google/dns-query" {
+		t.Fatalf("balancer dns servers = %v", servers)
+	}
+}
+
+// The validator rejects a block whose types xray cannot decode, even when the
+// servers list itself looks fine.
+func TestSubJsonDns_BrokenBlockKeepsTemplateDns(t *testing.T) {
+	seedSubDB(t)
+	seedSubInbound(t, "s1", "tcpin", 4907, 1, dnsTestStream)
+
+	js := NewSubJsonService("", "", "", "", NewSubService(""))
+	js.SetDnsConfig(`{"servers": ["1.1.1.1"], "hosts": 5}`)
+
+	dns := docDnsBlock(t, onlySubJsonDoc(t, js, "s1"))
+	servers, _ := dns["servers"].([]any)
+	first, _ := servers[0].(map[string]any)
+	if len(servers) != 1 || first["address"] != "8.8.8.8" {
+		t.Fatalf("template dns = %v", dns)
+	}
+}
+
+// An unusable routing profile degrades to an empty spec; the DNS override must
+// still reach the document.
+func TestSubJsonDns_AppliesWhenRoutingProfileUnusable(t *testing.T) {
+	seedSubDB(t)
+	seedSubInbound(t, "s1", "tcpin", 4908, 1, dnsTestStream)
+
+	js := NewSubJsonService("", "", "", "not-a-routing-payload", NewSubService(""))
+	js.SetDnsConfig(`["9.9.9.9"]`)
+
+	doc := onlySubJsonDoc(t, js, "s1")
+	servers, _ := docDnsBlock(t, doc)["servers"].([]any)
+	if len(servers) != 1 || servers[0] != "9.9.9.9" {
+		t.Fatalf("dns servers = %v", servers)
+	}
+	want := "network->proxy"
+	if got := strings.Join(ruleSignatures(t, doc), ","); got != want {
+		t.Fatalf("rules = %v, want the plain template rule %v", got, want)
+	}
+}
+
+// The dummy info node is emitted as a document too, so it carries the panel DNS.
+func TestSubJsonDns_AppliesToInfoNodeDocument(t *testing.T) {
+	setupInfoNodeTestDB(t)
+	db := database.GetDB()
+
+	ib := &model.Inbound{
+		Id: 1, UserId: 1, Remark: "Germany-VLESS", Enable: true, Port: 443,
+		Protocol:       model.VLESS,
+		Settings:       `{"clients":[{"id":"c1-uuid","email":"[email protected]","subId":"sub-json","enable":true,"totalGB":10737418240}]}`,
+		StreamSettings: `{"network":"tcp","security":"none"}`,
+	}
+	if err := db.Create(ib).Error; err != nil {
+		t.Fatal(err)
+	}
+	if err := db.Create(&model.ClientRecord{Id: 1, Email: "[email protected]", SubID: "sub-json", UUID: "c1-uuid", Enable: true, TotalGB: 10737418240}).Error; err != nil {
+		t.Fatal(err)
+	}
+	if err := db.Create(&model.ClientInbound{InboundId: 1, ClientId: 1}).Error; err != nil {
+		t.Fatal(err)
+	}
+	if err := db.Create(&xray.ClientTraffic{InboundId: 1, Email: "[email protected]", Up: 1073741824, Down: 1073741824, Total: 10737418240, Enable: true}).Error; err != nil {
+		t.Fatal(err)
+	}
+
+	sub := NewSubService("{{EMAIL}}|📊{{TRAFFIC_LEFT}}")
+	sub.subInfoNodeEnable = true
+	js := NewSubJsonService("", "", "", "", sub)
+	js.SetDnsConfig(`["https://dns.google/dns-query"]`)
+
+	out, _, err := js.GetJson("sub-json", "sub.example.com", true)
+	if err != nil {
+		t.Fatalf("GetJson: %v", err)
+	}
+	docs := parseSubJsonDocs(t, out)
+	if len(docs) != 2 {
+		t.Fatalf("docs = %d, want info node + inbound:\n%s", len(docs), out)
+	}
+	servers, _ := docDnsBlock(t, docs[0])["servers"].([]any)
+	if len(servers) != 1 || servers[0] != "https://dns.google/dns-query" {
+		t.Fatalf("info node dns servers = %v", servers)
+	}
+}

+ 16 - 3
internal/sub/json_service.go

@@ -37,6 +37,9 @@ type SubJsonService struct {
 	bakedRoutingMu sync.Mutex
 	bakedRouting   *bakedRoutingState
 
+	// dnsBlock is the panel DNS override, fixed for the service's lifetime.
+	dnsBlock map[string]any
+
 	SubService *SubService
 }
 
@@ -83,7 +86,7 @@ func NewSubJsonService(mux string, rules string, finalMask string, routingRules
 // Re-resolved per call so an upstream edit reaches the documents without a
 // restart; a failed resolve keeps the last good template.
 func (s *SubJsonService) bakedTemplate() map[string]any {
-	if s.routingRules == "" {
+	if s.routingRules == "" && s.dnsBlock == nil {
 		return s.configJson
 	}
 	spec := resolveJsonRoutingSpec(s.routingRules)
@@ -93,12 +96,19 @@ func (s *SubJsonService) bakedTemplate() map[string]any {
 		if spec.empty() || spec.equal(s.bakedRouting.spec) {
 			return s.bakedRouting.configJson
 		}
-	} else if spec.empty() {
+	} else if spec.empty() && s.dnsBlock == nil {
 		return s.configJson
 	}
 	template := make(map[string]any, len(s.configJson)+2)
 	maps.Copy(template, s.configJson)
-	applyJsonRouting(template, spec)
+	if !spec.empty() {
+		applyJsonRouting(template, spec)
+	}
+	// The panel-level DNS block is an explicit choice, so it also replaces the
+	// dns subtree a routing profile would otherwise bake in.
+	if s.dnsBlock != nil {
+		template["dns"] = s.dnsBlock
+	}
 	s.bakedRouting = &bakedRoutingState{spec: spec, configJson: template}
 	return template
 }
@@ -1041,6 +1051,9 @@ func (s *SubJsonService) genDummySocksConfig(remark string) json_util.RawMessage
 
 	newConfigJson := make(map[string]any)
 	maps.Copy(newConfigJson, s.configJson)
+	if s.dnsBlock != nil {
+		newConfigJson["dns"] = s.dnsBlock
+	}
 	newConfigJson["outbounds"] = newOutbounds
 	newConfigJson["remarks"] = remark
 

+ 12 - 2
internal/sub/service.go

@@ -2271,8 +2271,18 @@ func appendQueryAndFragment(link string, params map[string]string, fragment, sec
 
 	if fragment != "" {
 		sb.WriteByte('#')
-		// Match the frontend's encodeURIComponent(remark): spaces become %20.
-		sb.WriteString(strings.ReplaceAll(url.QueryEscape(fragment), "+", "%20"))
+		if before, after, ok := strings.Cut(fragment, "?serverDescription="); ok {
+			if _, err := base64.StdEncoding.DecodeString(after); err == nil && len(after) > 0 && !strings.ContainsAny(after, " \r\n\t#&") {
+				sb.WriteString(strings.ReplaceAll(url.QueryEscape(before), "+", "%20"))
+				sb.WriteString("?serverDescription=")
+				sb.WriteString(after)
+			} else {
+				sb.WriteString(strings.ReplaceAll(url.QueryEscape(fragment), "+", "%20"))
+			}
+		} else {
+			// Match the frontend's encodeURIComponent(remark): spaces become %20.
+			sb.WriteString(strings.ReplaceAll(url.QueryEscape(fragment), "+", "%20"))
+		}
 	}
 	return sb.String()
 }

+ 6 - 0
internal/sub/sub.go

@@ -155,6 +155,11 @@ func (s *Server) initRouter() (*gin.Engine, error) {
 		SubJsonRoutingRules = ""
 	}
 
+	SubJsonDns, err := s.settingService.GetSubJsonDns()
+	if err != nil {
+		SubJsonDns = ""
+	}
+
 	SubJsonFinalMask, err := s.settingService.GetSubJsonFinalMask()
 	if err != nil {
 		SubJsonFinalMask = ""
@@ -316,6 +321,7 @@ func (s *Server) initRouter() (*gin.Engine, error) {
 		WithSUBJsonMux(SubJsonMux),
 		WithSUBJsonRules(SubJsonRules),
 		WithSUBJsonRoutingRules(SubJsonRoutingRules),
+		WithSUBJsonDns(SubJsonDns),
 		WithSUBJsonFinalMask(SubJsonFinalMask),
 		WithSUBJsonObservatory(SubJsonObservatory),
 		WithSUBClashEnableRouting(SubClashEnableRouting),

+ 26 - 0
internal/web/controller/client.go

@@ -2,10 +2,13 @@ package controller
 
 import (
 	"encoding/json"
+	"errors"
+	"net/http"
 	"strconv"
 	"strings"
 
 	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/web/entity"
 	"github.com/mhsanaei/3x-ui/v3/internal/web/service"
 	"github.com/mhsanaei/3x-ui/v3/internal/web/websocket"
 
@@ -36,10 +39,12 @@ type ClientController struct {
 	inboundService service.InboundService
 	xrayService    service.XrayService
 	settingService service.SettingService
+	happGenerator  service.HappLinkGenerator
 }
 
 func NewClientController(g *gin.RouterGroup) *ClientController {
 	a := &ClientController{}
+	a.happGenerator = service.NewHappService(&a.clientService, &a.settingService)
 	a.initRouter(g)
 	return a
 }
@@ -52,6 +57,7 @@ func (a *ClientController) initRouter(g *gin.RouterGroup) {
 	g.GET("/traffic/:email", a.getTrafficByEmail)
 	g.GET("/subLinks/:subId", a.getSubLinks)
 	g.GET("/links/:email", a.getClientLinks)
+	g.POST("/happLink/:id", a.generateHappLink)
 
 	g.POST("/add", a.create)
 	g.POST("/update/:email", a.update)
@@ -646,6 +652,26 @@ func (a *ClientController) getClientLinks(c *gin.Context) {
 	jsonObj(c, links, nil)
 }
 
+func (a *ClientController) generateHappLink(c *gin.Context) {
+	c.Header("Cache-Control", "no-store")
+	clientID, err := strconv.Atoi(c.Param("id"))
+	if err != nil || clientID < 1 {
+		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), service.ErrHappLinkUnavailable)
+		return
+	}
+	result, err := a.happGenerator.Generate(c.Request.Context(), clientID, c.Request.Host)
+	if err != nil {
+		if errors.Is(err, service.ErrHappSourceTooLong) {
+			// Keep the code exact so clients can localize it without exposing internal error details.
+			c.JSON(http.StatusOK, entity.Msg{Success: false, Msg: "happ_source_too_long", Obj: nil})
+			return
+		}
+		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), service.ErrHappLinkUnavailable)
+		return
+	}
+	jsonObj(c, result, nil)
+}
+
 func (a *ClientController) detach(c *gin.Context) {
 	email := c.Param("email")
 	var body attachDetachBody

+ 149 - 0
internal/web/controller/client_happ_test.go

@@ -0,0 +1,149 @@
+package controller
+
+import (
+	"context"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"net/http"
+	"net/http/httptest"
+	"strings"
+	"testing"
+
+	"github.com/gin-gonic/gin"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/web/locale"
+	"github.com/mhsanaei/3x-ui/v3/internal/web/service"
+)
+
+type fakeHappLinkGenerator struct {
+	calls    int
+	clientID int
+	host     string
+	result   service.HappLinkResult
+	err      error
+}
+
+func (f *fakeHappLinkGenerator) Generate(_ context.Context, clientID int, host string) (service.HappLinkResult, error) {
+	f.calls++
+	f.clientID = clientID
+	f.host = host
+	return f.result, f.err
+}
+
+func newHappClientTestRouter(generator service.HappLinkGenerator) *gin.Engine {
+	gin.SetMode(gin.TestMode)
+	router := gin.New()
+	router.Use(func(c *gin.Context) {
+		c.Set("I18n", func(_ locale.I18nType, key string, _ ...string) string { return key })
+		c.Next()
+	})
+	(&ClientController{happGenerator: generator}).initRouter(router.Group("/clients"))
+	return router
+}
+
+func TestGenerateHappLinkForwardsCurrentRequestAndReturnsOnlyLink(t *testing.T) {
+	fake := &fakeHappLinkGenerator{result: service.HappLinkResult{EncryptedLink: "happ://crypt5/fresh"}}
+	router := newHappClientTestRouter(fake)
+	rec := httptest.NewRecorder()
+	req := httptest.NewRequest(http.MethodPost, "/clients/happLink/42", nil)
+	req.Host = "panel.example.com:2053"
+	router.ServeHTTP(rec, req)
+
+	if fake.clientID != 42 || fake.host != "panel.example.com:2053" {
+		t.Fatalf("Generate args = %d, %q", fake.clientID, fake.host)
+	}
+	if fake.calls != 1 {
+		t.Fatalf("Generate calls = %d", fake.calls)
+	}
+	if got := rec.Header().Get("Cache-Control"); got != "no-store" {
+		t.Fatalf("Cache-Control = %q", got)
+	}
+	if rec.Code != http.StatusOK {
+		t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String())
+	}
+
+	var response struct {
+		Success bool            `json:"success"`
+		Msg     string          `json:"msg"`
+		Obj     json.RawMessage `json:"obj"`
+	}
+	if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil {
+		t.Fatalf("unmarshal response: %v", err)
+	}
+	if !response.Success || response.Msg != "" {
+		t.Fatalf("response envelope = success:%t msg:%q", response.Success, response.Msg)
+	}
+	var link map[string]string
+	if err := json.Unmarshal(response.Obj, &link); err != nil {
+		t.Fatalf("unmarshal link result: %v", err)
+	}
+	if len(link) != 1 || link["encryptedLink"] != "happ://crypt5/fresh" {
+		t.Fatalf("response obj = %#v", link)
+	}
+	var envelope map[string]json.RawMessage
+	if err := json.Unmarshal(rec.Body.Bytes(), &envelope); err != nil {
+		t.Fatalf("unmarshal response envelope: %v", err)
+	}
+	if len(envelope) != 3 || envelope["success"] == nil || envelope["msg"] == nil || envelope["obj"] == nil {
+		t.Fatalf("response envelope fields = %#v", envelope)
+	}
+}
+
+func TestGenerateHappLinkRejectsInvalidIDWithoutCallingGenerator(t *testing.T) {
+	fake := &fakeHappLinkGenerator{}
+	router := newHappClientTestRouter(fake)
+	rec := httptest.NewRecorder()
+	router.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/clients/happLink/0", nil))
+
+	if fake.calls != 0 || fake.clientID != 0 || fake.host != "" {
+		t.Fatalf("Generate called %d times with = %d, %q", fake.calls, fake.clientID, fake.host)
+	}
+	assertHappFailureWithoutSecret(t, rec, "fake-provider-secret")
+}
+
+func TestGenerateHappLinkDoesNotExposeProviderFailure(t *testing.T) {
+	fake := &fakeHappLinkGenerator{err: errors.New("fake-provider-secret")}
+	router := newHappClientTestRouter(fake)
+	rec := httptest.NewRecorder()
+	router.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/clients/happLink/42", nil))
+
+	if fake.calls != 1 {
+		t.Fatalf("Generate calls = %d", fake.calls)
+	}
+	assertHappFailureWithoutSecret(t, rec, "fake-provider-secret")
+}
+
+func TestGenerateHappLinkReturnsOnlySafeLengthCode(t *testing.T) {
+	fake := &fakeHappLinkGenerator{err: fmt.Errorf("%w: private-subscription-url", service.ErrHappSourceTooLong)}
+	rec := httptest.NewRecorder()
+	newHappClientTestRouter(fake).ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/clients/happLink/42", nil))
+	assertHappFailureWithoutSecret(t, rec, "private-subscription-url")
+	var response struct {
+		Success bool   `json:"success"`
+		Msg     string `json:"msg"`
+		Obj     any    `json:"obj"`
+	}
+	if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil {
+		t.Fatal(err)
+	}
+	if response.Success || response.Msg != "happ_source_too_long" || response.Obj != nil {
+		t.Fatalf("length failure envelope = %s", rec.Body.String())
+	}
+}
+
+func assertHappFailureWithoutSecret(t *testing.T, rec *httptest.ResponseRecorder, secret string) {
+	t.Helper()
+	if got := rec.Header().Get("Cache-Control"); got != "no-store" {
+		t.Fatalf("Cache-Control = %q", got)
+	}
+	if rec.Code != http.StatusOK {
+		t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String())
+	}
+	if !strings.Contains(rec.Body.String(), `"success":false`) {
+		t.Fatalf("failure response = %s", rec.Body.String())
+	}
+	if strings.Contains(rec.Body.String(), secret) {
+		t.Fatalf("failure leaked provider secret: %s", rec.Body.String())
+	}
+}

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

@@ -71,6 +71,7 @@ type AllSetting struct {
 	TwoFactorEnable bool   `json:"twoFactorEnable" form:"twoFactorEnable"`
 	TwoFactorToken  string `json:"twoFactorToken" form:"twoFactorToken"`
 
+	HappLinkEnable              bool   `json:"happLinkEnable" form:"happLinkEnable"`
 	SubEnable                   bool   `json:"subEnable" form:"subEnable"`
 	SubJsonEnable               bool   `json:"subJsonEnable" form:"subJsonEnable"`
 	SubJsonAutoDetect           bool   `json:"subJsonAutoDetect" form:"subJsonAutoDetect"`
@@ -108,6 +109,7 @@ type AllSetting struct {
 	SubJsonMux                  string `json:"subJsonMux" form:"subJsonMux"`
 	SubJsonRules                string `json:"subJsonRules" form:"subJsonRules"`
 	SubJsonRoutingRules         string `json:"subJsonRoutingRules" form:"subJsonRoutingRules"`
+	SubJsonDns                  string `json:"subJsonDns" form:"subJsonDns"`
 	SubJsonFinalMask            string `json:"subJsonFinalMask" form:"subJsonFinalMask"`
 	SubJsonObservatory          string `json:"subJsonObservatory" form:"subJsonObservatory"`
 	SubThemeDir                 string `json:"subThemeDir" form:"subThemeDir"`

+ 150 - 0
internal/web/service/happ.go

@@ -0,0 +1,150 @@
+package service
+
+import (
+	"context"
+	"errors"
+	"regexp"
+	"strings"
+	"time"
+	"unicode"
+
+	"github.com/google/uuid"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/logger"
+)
+
+var (
+	// Failures deliberately carry no subscription details.
+	ErrHappLinkUnavailable = errors.New("happ link unavailable")
+	ErrHappSourceTooLong   = errors.New("happ subscription source exceeds 8192 bytes")
+)
+
+type HappLinkResult struct {
+	EncryptedLink string `json:"encryptedLink" example:"happ://crypt5/example"`
+}
+
+type HappLinkGenerator interface {
+	Generate(context.Context, int, string) (HappLinkResult, error)
+}
+
+// HappService generates one local encrypted link per action and does not retain results.
+type HappService struct {
+	clientService  *ClientService
+	settingService *SettingService
+	encrypt        func(string) (string, error)
+}
+
+func NewHappService(clientService *ClientService, settingService *SettingService) *HappService {
+	return &HappService{
+		clientService:  clientService,
+		settingService: settingService,
+		encrypt:        encryptHappLink,
+	}
+}
+
+func (s *HappService) Generate(ctx context.Context, clientID int, host string) (HappLinkResult, error) {
+	started := time.Now()
+	correlationID := uuid.NewString()
+	// Check the operator gate before constructing a subscription URL or encrypting it.
+	if reason := s.gateFailureReason(); reason != "" {
+		return HappLinkResult{}, s.fail(clientID, reason, started, correlationID, "generation unavailable", "", "")
+	}
+	if ctx.Err() != nil {
+		return HappLinkResult{}, s.fail(clientID, "request_cancelled", started, correlationID, "request cancelled", "", "")
+	}
+	source, client, reason := s.currentSource(clientID, host)
+	if reason != "" {
+		return HappLinkResult{}, s.fail(clientID, reason, started, correlationID, "source unavailable", "", "")
+	}
+	if s.encrypt == nil {
+		return HappLinkResult{}, s.fail(clientID, "service_unavailable", started, correlationID, "encryption unavailable", "", "")
+	}
+	link, err := s.encrypt(source)
+	if err != nil {
+		if errors.Is(err, ErrHappSourceTooLong) {
+			_ = s.fail(clientID, "source_too_long", started, correlationID, "source exceeds application byte limit", "", "")
+			return HappLinkResult{}, ErrHappSourceTooLong
+		}
+		return HappLinkResult{}, s.fail(clientID, "encryption", started, correlationID, err.Error(), source, client.SubID)
+	}
+	if ctx.Err() != nil {
+		return HappLinkResult{}, s.fail(clientID, "request_cancelled", started, correlationID, "request cancelled", "", "")
+	}
+	currentSource, _, currentReason := s.currentSource(clientID, host)
+	if currentReason != "" || currentSource != source {
+		return HappLinkResult{}, s.fail(clientID, "source_changed", started, correlationID, "source changed before response", "", "")
+	}
+	// Local work can still overlap a settings change; discard results after the gate is disabled.
+	if reason := s.gateFailureReason(); reason != "" {
+		return HappLinkResult{}, s.fail(clientID, reason, started, correlationID, "generation unavailable", "", "")
+	}
+	return HappLinkResult{EncryptedLink: link}, nil
+}
+
+func (s *HappService) gateFailureReason() string {
+	if s.settingService == nil {
+		return "service_unavailable"
+	}
+	enabled, err := s.settingService.GetHappLinkEnable()
+	if err != nil {
+		return "settings_unavailable"
+	}
+	if !enabled {
+		return "integration_disabled"
+	}
+	return ""
+}
+
+func (s *HappService) currentSource(clientID int, host string) (string, *model.ClientRecord, string) {
+	if s.clientService == nil || s.settingService == nil {
+		return "", nil, "service_unavailable"
+	}
+	client, err := s.clientService.GetByID(clientID)
+	if err != nil {
+		return "", nil, "client_unavailable"
+	}
+	settings, err := s.settingService.GetDefaultSettings(host)
+	if err != nil {
+		return "", client, "settings_unavailable"
+	}
+	values, ok := settings.(map[string]any)
+	if !ok {
+		return "", client, "settings_unavailable"
+	}
+	subEnable, enabled := values["subEnable"].(bool)
+	subURI, hasURI := values["subURI"].(string)
+	if !enabled || !subEnable || !hasURI || subURI == "" || client.SubID == "" {
+		return "", client, "source_unavailable"
+	}
+	return subURI + client.SubID, client, ""
+}
+
+var happSensitiveDetailToken = regexp.MustCompile(`(?i)(?:[a-z][a-z0-9+.-]*://\S+|(?:token|secret|password|passwd|credential|authorization|bearer|api[_-]?key|cookie|session)\s*(?:=|:)\s*\S+)`)
+
+func (s *HappService) fail(clientID int, reason string, started time.Time, correlationID, detail, source, subID string) error {
+	logger.Warningf("component=happ_link operation=generate outcome=failure client_id=%d reason=%s elapsed_ms=%d correlation_id=%s detail=%s",
+		clientID, reason, time.Since(started).Milliseconds(), correlationID, sanitizeHappDetail(detail, source, subID))
+	return ErrHappLinkUnavailable
+}
+
+func sanitizeHappDetail(detail, source, subID string) string {
+	if source != "" {
+		detail = strings.ReplaceAll(detail, source, "[redacted]")
+	}
+	if subID != "" {
+		detail = strings.ReplaceAll(detail, subID, "[redacted]")
+	}
+	detail = strings.Map(func(r rune) rune {
+		if unicode.IsControl(r) {
+			return -1
+		}
+		return r
+	}, detail)
+	detail = happSensitiveDetailToken.ReplaceAllString(detail, "[redacted]")
+	runes := []rune(detail)
+	if len(runes) > 160 {
+		detail = string(runes[:160])
+	}
+	return detail
+}

+ 165 - 0
internal/web/service/happ_crypto.go

@@ -0,0 +1,165 @@
+package service
+
+import (
+	"bytes"
+	"crypto/rand"
+	"crypto/rsa"
+	"crypto/sha256"
+	"crypto/x509"
+	"encoding/base64"
+	"encoding/hex"
+	"encoding/pem"
+	"fmt"
+	"math/big"
+	"net/url"
+	"strconv"
+	"strings"
+	"unicode"
+	"unicode/utf8"
+
+	"golang.org/x/crypto/chacha20poly1305"
+)
+
+// vdfzfoff public key from Omegaplexx/hpwnr 3745cb96e2551e003cb217ab7705b4d67f8ac006, src/keys.rs.
+// Salted Crypt5 with separator V passed Android 4.3.0 and Windows 4.1.2 import/update probes.
+const happPublicKeyPEM = `-----BEGIN PUBLIC KEY-----
+MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA9+umWSxp8coKnMONnI4u
+NvtPErJZt8VNgNb2XS+RrCMc9AFWZQH01ILr3Py/mviuqFgNLMEcPs3k6+ZPh6Sa
+OCXHmjQicGPJAw6Co6GQwO/b4vspHgOM4HSvX5r6SY1EKIHUSLIyRV28DfwJKdFv
+x2EKqypewlrAo4AV76uI/9U+1t40yHcVCj/OtFxsq+mMM6qySieTsA1q6C5raBrJ
+u3l/RWMxFYvDInYDs1IaTFGDFwSdFDqhNU19gPGloT/GApy+U32R6AGSxJymS2nh
+e6pm/M9bvsH0o0Oc1kyXsBpVN04n/a9gVVUoqODzrUyXDx7/jAzNJD43PWtblcz0
+ZNBKN50wvpSD5UuAQydwMT7xWJIpPaZqTUj/sg8hIm57XGlUxRCge17nB0Ff7sKO
+JAgaXVdbfqDdzx+PhSaZY9xfcAh/sHfE6hKaCQ9kIn5cjbx9bcYqZWnpuSOzSFg+
+CgMSqvG6rV6d+96dNMHuE0tRIUJ83xrLcm9hZJmJ6WDm6hteZbnb1k3eQF9c+XCF
+wSEvsWiXyduQmkVNJaCRXwy8tSaZp9JftALhRHMvd7Eq6ctAkvn7w0upynsAtLeL
+N8xZ5q1gcRgboydr588D3m8KF7mVuX/XRp2AG7hzyYdkQov9bfEfXIaBVlwHMKhy
+uPTxeM4Les6fvaHMSWJ+8EUCAwEAAQ==
+-----END PUBLIC KEY-----`
+
+const (
+	happCrypt5Marker         = "vdfzfoff"
+	happPublicKeyFingerprint = "22319c7b13647897bf5fd4f827ba92bf3946d738007a0054ccd931c31f221768"
+	// Bound application work before encoding; this is not a promised Happ client limit.
+	happMaxSourceBytes = 8192
+)
+
+func encryptHappLink(source string) (string, error) {
+	key, err := checkedHappPublicKey([]byte(happPublicKeyPEM))
+	if err != nil {
+		return "", err
+	}
+	return encryptHappSource(source, key)
+}
+
+func encryptHappSource(source string, key *rsa.PublicKey) (string, error) {
+	if !validHappRSAKey(key) {
+		return "", ErrHappLinkUnavailable
+	}
+	if len(source) > happMaxSourceBytes {
+		return "", ErrHappSourceTooLong
+	}
+	if len(source) == 0 || !utf8.ValidString(source) || strings.IndexFunc(source, unicode.IsControl) >= 0 {
+		return "", ErrHappLinkUnavailable
+	}
+	parsed, err := url.Parse(source)
+	if err != nil || !parsed.IsAbs() || parsed.Opaque != "" || parsed.Hostname() == "" ||
+		parsed.User != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") {
+		return "", ErrHappLinkUnavailable
+	}
+
+	const letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
+	const alnum = letters + "0123456789"
+	sessionKey := make([]byte, 32)
+	if _, err := rand.Read(sessionKey); err != nil {
+		return "", fmt.Errorf("happ key randomness: %w", err)
+	}
+	nonce, err := randomHappCharacters(12, alnum)
+	if err != nil {
+		return "", err
+	}
+	tag, err := randomHappCharacters(2, letters)
+	if err != nil {
+		return "", err
+	}
+	salt, err := randomHappCharacters(8, alnum)
+	if err != nil {
+		return "", err
+	}
+	wrappedKey := make([]byte, 32)
+	for i := range wrappedKey {
+		wrappedKey[i] = sessionKey[i] ^ salt[i%8]
+	}
+	rsaPlain := swapHappPairs([]byte(base64.StdEncoding.EncodeToString(wrappedKey)))
+	//nolint:staticcheck // Happ Crypt5 requires PKCS#1 v1.5 key wrapping; OAEP changes the wire format.
+	rsaCipher, err := rsa.EncryptPKCS1v15(rand.Reader, key, rsaPlain)
+	if err != nil {
+		return "", fmt.Errorf("happ RSA wrapping: %w", err)
+	}
+	aead, err := chacha20poly1305.New(sessionKey)
+	if err != nil {
+		return "", fmt.Errorf("happ AEAD initialization: %w", err)
+	}
+	// Parsing validates the URL but must not normalize its UTF-8, escapes, or query bytes.
+	plain := swapHappPairs([]byte(base64.StdEncoding.EncodeToString([]byte(source))))
+	cipherB64 := base64.StdEncoding.EncodeToString(aead.Seal(nil, nonce, plain, nil))
+	body := string(nonce) + string(tag) + string(salt) + strconv.Itoa(len(cipherB64)) +
+		"V" + cipherB64 + base64.StdEncoding.EncodeToString(rsaCipher)
+	frame := []byte(happCrypt5Marker[:4] + body + happCrypt5Marker[4:])
+	for i := 0; i+3 < len(frame); i += 4 {
+		frame[i], frame[i+2] = frame[i+2], frame[i]
+		frame[i+1], frame[i+3] = frame[i+3], frame[i+1]
+	}
+	return "happ://crypt5/" + string(frame), nil
+}
+
+func checkedHappPublicKey(pemData []byte) (*rsa.PublicKey, error) {
+	trimmed := bytes.TrimSpace(pemData)
+	block, rest := pem.Decode(trimmed)
+	if !bytes.HasPrefix(trimmed, []byte("-----BEGIN PUBLIC KEY-----")) || block == nil ||
+		block.Type != "PUBLIC KEY" || len(block.Headers) != 0 || len(bytes.TrimSpace(rest)) != 0 {
+		return nil, ErrHappLinkUnavailable
+	}
+	parsed, err := x509.ParsePKIXPublicKey(block.Bytes)
+	if err != nil {
+		return nil, ErrHappLinkUnavailable
+	}
+	key, ok := parsed.(*rsa.PublicKey)
+	if !ok || !validHappRSAKey(key) {
+		return nil, ErrHappLinkUnavailable
+	}
+	spki, err := x509.MarshalPKIXPublicKey(key)
+	if err != nil {
+		return nil, ErrHappLinkUnavailable
+	}
+	fingerprint := sha256.Sum256(spki)
+	// The marker selects the client's private key, so accepting any RSA public key would be incorrect.
+	if hex.EncodeToString(fingerprint[:]) != happPublicKeyFingerprint {
+		return nil, ErrHappLinkUnavailable
+	}
+	return key, nil
+}
+
+func validHappRSAKey(key *rsa.PublicKey) bool {
+	return key != nil && key.N != nil && key.N.Sign() > 0 && key.N.BitLen() == 4096 && key.N.Bit(0) == 1 && key.E == 65537
+}
+
+func randomHappCharacters(length int, alphabet string) ([]byte, error) {
+	result := make([]byte, length)
+	limit := big.NewInt(int64(len(alphabet)))
+	for i := range result {
+		index, err := rand.Int(rand.Reader, limit)
+		if err != nil {
+			return nil, fmt.Errorf("happ character randomness: %w", err)
+		}
+		result[i] = alphabet[index.Int64()]
+	}
+	return result, nil
+}
+
+func swapHappPairs(data []byte) []byte {
+	for i := 0; i+1 < len(data); i += 2 {
+		data[i], data[i+1] = data[i+1], data[i]
+	}
+	return data
+}

+ 129 - 0
internal/web/service/happ_local_test.go

@@ -0,0 +1,129 @@
+package service
+
+import (
+	"context"
+	"crypto/rsa"
+	"crypto/sha256"
+	"encoding/pem"
+	"errors"
+	"fmt"
+	"net"
+	"net/http"
+	"strings"
+	"sync/atomic"
+	"testing"
+)
+
+func TestHappGenerateLocallyWithoutNetwork(t *testing.T) {
+	initHappTestDB(t)
+	client := seedHappClient(t, "local-only")
+	configureHappSubscription(t, true, "https://sub.example/sub/")
+	configureHappLinkGate(t, true)
+	var calls atomic.Int32
+	previous := http.DefaultTransport
+	// Fail before opening a socket, including clients cloned from the default transport.
+	http.DefaultTransport = &http.Transport{DialContext: func(context.Context, string, string) (net.Conn, error) {
+		calls.Add(1)
+		return nil, errors.New("network is unavailable in the local-generation test")
+	}}
+	t.Cleanup(func() { http.DefaultTransport = previous })
+	svc := NewHappService(&ClientService{}, &SettingService{})
+	result, err := svc.Generate(context.Background(), client.Id, "panel.example")
+	if err != nil || !strings.HasPrefix(result.EncryptedLink, "happ://crypt5/") {
+		t.Fatalf("local generation = %#v, %v; network attempts = %d", result, err, calls.Load())
+	}
+	if calls.Load() != 0 {
+		t.Fatalf("local generation attempted %d network connections", calls.Load())
+	}
+}
+
+func TestHappEncryptPreservesUTF8AndEnforcesResourceLimit(t *testing.T) {
+	key, err := syntheticHappKey()
+	if err != nil {
+		t.Fatal(err)
+	}
+	for _, tc := range []struct {
+		name, source string
+		wantError    error
+	}{
+		{"unicode URL", "https://example.com/中文?emoji=🔒&literal=%2F&x=a+b", nil},
+		{"501 ASCII bytes", "https://example.com/" + strings.Repeat("a", 481), nil},
+		{"502 ASCII bytes", "https://example.com/" + strings.Repeat("a", 482), nil},
+		{"501 UTF8 bytes", "https://example.com/" + strings.Repeat("界", 160) + "a", nil},
+		{"502 UTF8 bytes", "https://example.com/" + strings.Repeat("界", 160) + "ab", nil},
+		{"8192 ASCII bytes", "https://example.com/" + strings.Repeat("a", 8172), nil},
+		{"8193 ASCII bytes", "https://example.com/" + strings.Repeat("a", 8173), ErrHappSourceTooLong},
+		{"8192 UTF8 bytes", "https://example.com/" + strings.Repeat("界", 2724), nil},
+		{"8193 UTF8 bytes", "https://example.com/" + strings.Repeat("界", 2724) + "a", ErrHappSourceTooLong},
+		{"raw query and fragment", "https://example.com/s%2fb?a=one+two&b=%2B#标题", nil},
+		{"empty URL", "", ErrHappLinkUnavailable},
+		{"invalid URL", "not-a-url", ErrHappLinkUnavailable},
+		{"unsupported scheme", "file:///tmp/sub", ErrHappLinkUnavailable},
+		{"empty host", "https:///sub", ErrHappLinkUnavailable},
+		{"control character", "https://example.com/a\nb", ErrHappLinkUnavailable},
+		{"Unicode control", "https://example.com/a\u0085b", ErrHappLinkUnavailable},
+		{"invalid UTF8", "https://example.com/" + string([]byte{0xff}), ErrHappLinkUnavailable},
+		{"userinfo", "https://user:[email protected]/sub", ErrHappLinkUnavailable},
+		{"opaque URL", "https:sub", ErrHappLinkUnavailable},
+	} {
+		t.Run(tc.name, func(t *testing.T) {
+			link, err := encryptHappSource(tc.source, &key.PublicKey)
+			if !errors.Is(err, tc.wantError) {
+				t.Fatalf("error = %v, want %v", err, tc.wantError)
+			}
+			if tc.wantError != nil {
+				if link != "" {
+					t.Fatal("failed encryption returned a link")
+				}
+				return
+			}
+			if got := decryptHappTestLink(t, link, key); got != tc.source {
+				t.Fatalf("source was changed or truncated: %q", got)
+			}
+		})
+	}
+	for _, invalidKey := range []*rsa.PublicKey{nil, {N: key.N, E: 0}} {
+		link, err := encryptHappSource("https://example.com/sub", invalidKey)
+		if !errors.Is(err, ErrHappLinkUnavailable) || link != "" {
+			t.Fatalf("invalid key result = %q, %v", link, err)
+		}
+	}
+}
+
+func TestHappEncryptUsesClientValidatedPublicKey(t *testing.T) {
+	block, _ := pem.Decode([]byte(happPublicKeyPEM))
+	if block == nil {
+		t.Fatal("missing public key")
+	}
+	// Pin the marker's public key from the accepted Android/Windows Crypt5 probe.
+	if got := fmt.Sprintf("%x", sha256.Sum256(block.Bytes)); got != "22319c7b13647897bf5fd4f827ba92bf3946d738007a0054ccd931c31f221768" {
+		t.Fatalf("unvalidated public key: %s", got)
+	}
+	first, err := encryptHappLink("https://example.com/sub")
+	if err != nil {
+		t.Fatal(err)
+	}
+	second, err := encryptHappLink("https://example.com/sub")
+	if err != nil || len(first) != 795 || !strings.HasPrefix(first, "happ://crypt5/") || first == second {
+		t.Fatalf("expected fresh crypt5 ciphertext: length=%d, err=%v", len(first), err)
+	}
+}
+
+func TestHappEncryptUsesFreshSessionKeysAndNonces(t *testing.T) {
+	key, err := syntheticHappKey()
+	if err != nil {
+		t.Fatal(err)
+	}
+	keys, nonces := map[string]bool{}, map[string]bool{}
+	for range 8 {
+		link, err := encryptHappSource("https://example.com/sub", &key.PublicKey)
+		if err != nil {
+			t.Fatal(err)
+		}
+		decoded := decodeHappTestLink(t, link, key)
+		if keys[string(decoded.key)] || nonces[string(decoded.nonce)] {
+			t.Fatal("generation reused a session key or nonce")
+		}
+		keys[string(decoded.key)], nonces[string(decoded.nonce)] = true, true
+	}
+}

+ 408 - 0
internal/web/service/happ_test.go

@@ -0,0 +1,408 @@
+package service
+
+import (
+	"bytes"
+	"context"
+	"crypto/rand"
+	"crypto/rsa"
+	"encoding/base64"
+	"errors"
+	"os"
+	"path/filepath"
+	"regexp"
+	"strconv"
+	"strings"
+	"sync"
+	"testing"
+
+	"golang.org/x/crypto/chacha20poly1305"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/logger"
+)
+
+func initHappTestDB(t *testing.T) {
+	t.Helper()
+	dbDir := t.TempDir()
+	t.Setenv("XUI_DB_FOLDER", dbDir)
+	t.Setenv("XUI_BIN_FOLDER", dbDir)
+	if err := os.WriteFile(filepath.Join(dbDir, "config.json"), []byte(`{"log":{}}`), 0o600); err != nil {
+		t.Fatalf("write Xray config: %v", err)
+	}
+	if err := database.InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil {
+		t.Fatalf("InitDB: %v", err)
+	}
+	t.Cleanup(func() { _ = database.CloseDB() })
+}
+
+func seedHappClient(t *testing.T, subID string) *model.ClientRecord {
+	t.Helper()
+	client := &model.ClientRecord{Email: "happ@test", SubID: subID, Enable: true}
+	if err := database.GetDB().Create(client).Error; err != nil {
+		t.Fatalf("seed client: %v", err)
+	}
+	return client
+}
+
+func configureHappSubscription(t *testing.T, enabled bool, subURI string) {
+	t.Helper()
+	settings := &SettingService{}
+	for key, value := range map[string]string{
+		"subEnable": "false",
+		"subURI":    subURI,
+		"subPath":   "/sub/",
+		"subPort":   "80",
+		"subDomain": "",
+	} {
+		if key == "subEnable" && enabled {
+			value = "true"
+		}
+		if err := settings.saveSetting(key, value); err != nil {
+			t.Fatalf("save %s: %v", key, err)
+		}
+	}
+}
+
+func configureHappLinkGate(t *testing.T, enabled bool) {
+	t.Helper()
+	if err := (&SettingService{}).saveSetting("happLinkEnable", strconv.FormatBool(enabled)); err != nil {
+		t.Fatalf("save happLinkEnable: %v", err)
+	}
+}
+
+var syntheticHappKey = sync.OnceValues(func() (*rsa.PrivateKey, error) {
+	return rsa.GenerateKey(rand.Reader, 4096)
+})
+
+func newLocalHappTestService(t *testing.T) (*HappService, *rsa.PrivateKey) {
+	t.Helper()
+	key, err := syntheticHappKey()
+	if err != nil {
+		t.Fatal(err)
+	}
+	svc := NewHappService(&ClientService{}, &SettingService{})
+	svc.encrypt = func(source string) (string, error) { return encryptHappSource(source, &key.PublicKey) }
+	return svc, key
+}
+
+func decryptHappTestLink(t *testing.T, link string, key *rsa.PrivateKey) string {
+	t.Helper()
+	return decodeHappTestLink(t, link, key).source
+}
+
+type happTestDecoded struct {
+	source string
+	key    []byte
+	nonce  []byte
+}
+
+func decodeHappTestLink(t *testing.T, link string, key *rsa.PrivateKey) happTestDecoded {
+	t.Helper()
+	const prefix = "happ://crypt5/"
+	if !strings.HasPrefix(link, prefix) {
+		t.Fatal("unexpected Happ protocol")
+	}
+	payload := []byte(link[len(prefix):])
+	// Independent inverse indexing catches encoder swap errors without sharing its helpers.
+	frame := append([]byte{}, payload...)
+	for i := 0; i+4 <= len(payload); i += 4 {
+		copy(frame[i:i+2], payload[i+2:i+4])
+		copy(frame[i+2:i+4], payload[i:i+2])
+	}
+	if len(frame) < 38 || string(frame[:4])+string(frame[len(frame)-4:]) != "vdfzfoff" {
+		t.Fatal("invalid marker or short Crypt5 frame")
+	}
+	body := frame[4 : len(frame)-4]
+	nonce, tag, salt := body[:12], body[12:14], body[14:22]
+	if !regexp.MustCompile(`^[a-zA-Z0-9]{12}$`).Match(nonce) ||
+		!regexp.MustCompile(`^[a-zA-Z]{2}$`).Match(tag) ||
+		!regexp.MustCompile(`^[a-zA-Z0-9]{8}$`).Match(salt) {
+		t.Fatal("incorrect salted field shape")
+	}
+	separatorIndex := 22
+	for separatorIndex < len(body) && body[separatorIndex] >= '0' && body[separatorIndex] <= '9' {
+		separatorIndex++
+	}
+	if separatorIndex == 22 || separatorIndex >= len(body) || body[separatorIndex] != 'V' {
+		t.Fatal("missing length or wrong tested separator")
+	}
+	segmentLength, err := strconv.Atoi(string(body[22:separatorIndex]))
+	if err != nil || segmentLength < 24 || segmentLength > len(body)-separatorIndex-1 {
+		t.Fatal("invalid ciphertext segment length")
+	}
+	cipherB64 := body[separatorIndex+1 : separatorIndex+1+segmentLength]
+	rsaB64 := body[separatorIndex+1+segmentLength:]
+	rsaCipher, err := base64.StdEncoding.Strict().DecodeString(string(rsaB64))
+	if err != nil || len(rsaCipher) != 512 || len(rsaB64) != 684 {
+		t.Fatalf("expected standard padded Base64 of a 512-byte RSA block: %v", err)
+	}
+	//nolint:staticcheck // Only an ephemeral test key decodes Happ's required PKCS#1 v1.5 wrapping.
+	rsaPlain, err := rsa.DecryptPKCS1v15(nil, key, rsaCipher)
+	if err != nil || len(rsaPlain) != 44 {
+		t.Fatalf("RSA wrapped key should contain 44 encoded bytes: %v", err)
+	}
+	keyB64 := make([]byte, len(rsaPlain))
+	for i := range rsaPlain {
+		keyB64[i] = rsaPlain[i^1]
+	}
+	wrappedKey, err := base64.StdEncoding.Strict().DecodeString(string(keyB64))
+	if err != nil || len(wrappedKey) != 32 {
+		t.Fatalf("wrapped key should decode to 32 bytes: %v", err)
+	}
+	sessionKey := make([]byte, 32)
+	for i := range sessionKey {
+		sessionKey[i] = wrappedKey[i] ^ salt[i%8]
+	}
+	ciphertext, err := base64.StdEncoding.Strict().DecodeString(string(cipherB64))
+	if err != nil || !bytes.Equal([]byte(base64.StdEncoding.EncodeToString(ciphertext)), cipherB64) {
+		t.Fatalf("noncanonical ciphertext Base64: %v", err)
+	}
+	aead, err := chacha20poly1305.New(sessionKey)
+	if err != nil {
+		t.Fatal(err)
+	}
+	swappedSource, err := aead.Open(nil, nonce, ciphertext, nil)
+	if err != nil || len(swappedSource)%4 != 0 {
+		t.Fatalf("AEAD authentication or source framing failed: %v", err)
+	}
+	sourceB64 := make([]byte, len(swappedSource))
+	for i := range swappedSource {
+		sourceB64[i] = swappedSource[i^1]
+	}
+	source, err := base64.StdEncoding.Strict().DecodeString(string(sourceB64))
+	if err != nil {
+		t.Fatal(err)
+	}
+	return happTestDecoded{string(source), sessionKey, append([]byte{}, nonce...)}
+}
+
+func TestHappGenerateRejectsDisabledGateBeforeEncryption(t *testing.T) {
+	for _, value := range []string{"", "false", "not-a-bool"} {
+		t.Run("setting="+value, func(t *testing.T) {
+			initHappTestDB(t)
+			client := seedHappClient(t, "current-sub-id")
+			configureHappSubscription(t, true, "https://sub.example/sub/")
+			if value != "" {
+				if err := (&SettingService{}).saveSetting("happLinkEnable", value); err != nil {
+					t.Fatal(err)
+				}
+			}
+			svc := NewHappService(&ClientService{}, &SettingService{})
+			svc.encrypt = func(string) (string, error) {
+				t.Fatal("disabled feature attempted encryption")
+				return "", nil
+			}
+			result, err := svc.Generate(context.Background(), client.Id, "panel.example")
+			if !errors.Is(err, ErrHappLinkUnavailable) || result != (HappLinkResult{}) {
+				t.Fatalf("disabled generation = %#v, %v", result, err)
+			}
+		})
+	}
+}
+
+func TestHappGenerateUsesCurrentSourceAndFreshCiphertext(t *testing.T) {
+	initHappTestDB(t)
+	client := seedHappClient(t, "before")
+	configureHappSubscription(t, true, "https://sub.example/sub/")
+	configureHappLinkGate(t, true)
+	svc, key := newLocalHappTestService(t)
+	var previous string
+	for range 2 {
+		result, err := svc.Generate(context.Background(), client.Id, "panel.example")
+		if err != nil {
+			t.Fatal(err)
+		}
+		if got := decryptHappTestLink(t, result.EncryptedLink, key); got != "https://sub.example/sub/before" {
+			t.Fatalf("source = %q", got)
+		}
+		if result.EncryptedLink == previous {
+			t.Fatal("generation reused cached ciphertext")
+		}
+		previous = result.EncryptedLink
+	}
+	if err := database.GetDB().Model(client).Update("sub_id", "after").Error; err != nil {
+		t.Fatal(err)
+	}
+	configureHappSubscription(t, true, "https://next.example/中文?literal=%2F&token=")
+	result, err := svc.Generate(context.Background(), client.Id, "panel.example")
+	if err != nil {
+		t.Fatal(err)
+	}
+	if got := decryptHappTestLink(t, result.EncryptedLink, key); got != "https://next.example/中文?literal=%2F&token=after" {
+		t.Fatalf("updated source = %q", got)
+	}
+	configureHappSubscription(t, true, "")
+	result, err = svc.Generate(context.Background(), client.Id, "panel.example")
+	if err != nil {
+		t.Fatal(err)
+	}
+	if got := decryptHappTestLink(t, result.EncryptedLink, key); got != "http://panel.example/sub/after" {
+		t.Fatalf("default source = %q", got)
+	}
+}
+
+func TestHappGenerateDiscardsChangedSourceOrGate(t *testing.T) {
+	for _, tc := range []struct {
+		name   string
+		reason string
+		change func(*testing.T, *model.ClientRecord)
+	}{
+		{"subscription ID", "source_changed", func(t *testing.T, c *model.ClientRecord) {
+			if err := database.GetDB().Model(c).Update("sub_id", "after").Error; err != nil {
+				t.Fatal(err)
+			}
+		}},
+		{"subscription URL", "source_changed", func(t *testing.T, _ *model.ClientRecord) {
+			configureHappSubscription(t, true, "https://next.example/sub/")
+		}},
+		{"subscription disabled", "source_changed", func(t *testing.T, _ *model.ClientRecord) {
+			configureHappSubscription(t, false, "https://sub.example/sub/")
+		}},
+		{"gate disabled", "integration_disabled", func(t *testing.T, _ *model.ClientRecord) {
+			configureHappLinkGate(t, false)
+		}},
+	} {
+		t.Run(tc.name, func(t *testing.T) {
+			initHappTestDB(t)
+			client := seedHappClient(t, "before")
+			configureHappSubscription(t, true, "https://sub.example/sub/")
+			configureHappLinkGate(t, true)
+			svc, _ := newLocalHappTestService(t)
+			encrypt := svc.encrypt
+			svc.encrypt = func(source string) (string, error) {
+				link, err := encrypt(source)
+				tc.change(t, client)
+				return link, err
+			}
+			result, err := svc.Generate(context.Background(), client.Id, "panel.example")
+			if !errors.Is(err, ErrHappLinkUnavailable) || result != (HappLinkResult{}) {
+				t.Fatalf("stale result = %#v, %v", result, err)
+			}
+			logs := logger.GetLogs(1, "WARNING")
+			if len(logs) != 1 || !strings.Contains(logs[0], "reason="+tc.reason) {
+				t.Fatalf("wrong stale-result diagnostic: %v", logs)
+			}
+		})
+	}
+}
+
+func TestHappGenerateSkipsUnavailableSources(t *testing.T) {
+	for _, tc := range []struct {
+		name    string
+		enabled bool
+		subID   string
+		missing bool
+	}{
+		{"disabled subscription", false, "current", false},
+		{"missing client", true, "current", true},
+		{"empty subscription ID", true, "", false},
+	} {
+		t.Run(tc.name, func(t *testing.T) {
+			initHappTestDB(t)
+			client := seedHappClient(t, tc.subID)
+			configureHappSubscription(t, tc.enabled, "https://sub.example/sub/")
+			configureHappLinkGate(t, true)
+			svc := NewHappService(&ClientService{}, &SettingService{})
+			svc.encrypt = func(string) (string, error) { t.Fatal("unavailable source was encrypted"); return "", nil }
+			id := client.Id
+			if tc.missing {
+				id++
+			}
+			result, err := svc.Generate(context.Background(), id, "panel.example")
+			if !errors.Is(err, ErrHappLinkUnavailable) || result != (HappLinkResult{}) {
+				t.Fatalf("unavailable result = %#v, %v", result, err)
+			}
+		})
+	}
+}
+
+func TestHappGenerateDiscardsCancelledRequests(t *testing.T) {
+	for _, before := range []bool{true, false} {
+		t.Run(strconv.FormatBool(before), func(t *testing.T) {
+			initHappTestDB(t)
+			client := seedHappClient(t, "current")
+			configureHappSubscription(t, true, "https://sub.example/sub/")
+			configureHappLinkGate(t, true)
+			ctx, cancel := context.WithCancel(context.Background())
+			defer cancel()
+			svc, _ := newLocalHappTestService(t)
+			encrypt := svc.encrypt
+			svc.encrypt = func(source string) (string, error) {
+				if before {
+					t.Fatal("cancelled request attempted encryption")
+				}
+				link, err := encrypt(source)
+				cancel()
+				return link, err
+			}
+			if before {
+				cancel()
+			}
+			result, err := svc.Generate(ctx, client.Id, "panel.example")
+			if !errors.Is(err, ErrHappLinkUnavailable) || result != (HappLinkResult{}) {
+				t.Fatalf("cancelled result = %#v, %v", result, err)
+			}
+			logs := logger.GetLogs(1, "WARNING")
+			if len(logs) != 1 || !strings.Contains(logs[0], "reason=request_cancelled") {
+				t.Fatalf("wrong cancellation diagnostic: %v", logs)
+			}
+		})
+	}
+}
+
+func TestHappGeneratePropagatesLengthErrorWithoutSecrets(t *testing.T) {
+	initHappTestDB(t)
+	client := seedHappClient(t, strings.Repeat("s", 8173))
+	configureHappSubscription(t, true, "https://example.com/")
+	configureHappLinkGate(t, true)
+	result, err := NewHappService(&ClientService{}, &SettingService{}).Generate(context.Background(), client.Id, "panel.example")
+	if !errors.Is(err, ErrHappSourceTooLong) || result != (HappLinkResult{}) {
+		t.Fatalf("length result = %#v, %v", result, err)
+	}
+	logs := logger.GetLogs(1, "WARNING")
+	if len(logs) != 1 || !strings.Contains(logs[0], "reason=source_too_long") {
+		t.Fatalf("length diagnostic = %v", logs)
+	}
+	if strings.Contains(logs[0], client.SubID) || strings.Contains(logs[0], "example.com") {
+		t.Fatal("length diagnostic leaked source")
+	}
+}
+
+func TestHappGenerateLogsSanitizedEncryptionFailure(t *testing.T) {
+	initHappTestDB(t)
+	client := seedHappClient(t, "secret-sub-id")
+	configureHappSubscription(t, true, "https://sub.example/secret-source/")
+	configureHappLinkGate(t, true)
+	svc := NewHappService(&ClientService{}, &SettingService{})
+	svc.encrypt = func(source string) (string, error) {
+		return "", errors.New("encryption failed " + source + " token=secret cookie=session authorization=Bearer-secret happ://crypt5/leak")
+	}
+	result, err := svc.Generate(context.Background(), client.Id, "panel.example")
+	if !errors.Is(err, ErrHappLinkUnavailable) || err.Error() != "happ link unavailable" || result != (HappLinkResult{}) {
+		t.Fatalf("failure = %#v, %v", result, err)
+	}
+	logs := logger.GetLogs(1, "WARNING")
+	if len(logs) != 1 {
+		t.Fatalf("logs = %v", logs)
+	}
+	for _, want := range []string{"component=happ_link", "client_id=" + strconv.Itoa(client.Id), "reason=encryption", "elapsed_ms=", "correlation_id=", "encryption failed"} {
+		if !strings.Contains(logs[0], want) {
+			t.Fatalf("diagnostic missing %q: %s", want, logs[0])
+		}
+	}
+	for _, secret := range []string{"secret-sub-id", "secret-source", "token=secret", "cookie=session", "Bearer-secret", "happ://"} {
+		if strings.Contains(logs[0], secret) {
+			t.Fatalf("diagnostic leaked %q", secret)
+		}
+	}
+}
+
+func TestSanitizeHappDetailRedactsSensitiveTokens(t *testing.T) {
+	detail := sanitizeHappDetail("provider said https://provider.example/path?token=secret password=hunter2\nsource=https://sub.example/sub/current-sub-id", "https://sub.example/sub/current-sub-id", "current-sub-id")
+	for _, secret := range []string{"provider.example", "token=secret", "hunter2", "current-sub-id", "\n"} {
+		if strings.Contains(detail, secret) {
+			t.Fatalf("sanitized detail leaked %q: %q", secret, detail)
+		}
+	}
+}

+ 28 - 0
internal/web/service/setting.go

@@ -28,6 +28,7 @@ import (
 	"github.com/mhsanaei/3x-ui/v3/internal/util/reflect_util"
 	"github.com/mhsanaei/3x-ui/v3/internal/web/entity"
 	"github.com/mhsanaei/3x-ui/v3/internal/xray"
+	"github.com/mhsanaei/3x-ui/v3/internal/xray/dnsconf"
 )
 
 //go:embed config.json
@@ -88,6 +89,7 @@ var defaultValueMap = map[string]string{
 	"tgLang":                      "en-US",
 	"twoFactorEnable":             "false",
 	"twoFactorToken":              "",
+	"happLinkEnable":              "false",
 	"subEnable":                   "true",
 	"subJsonEnable":               "false",
 	"subJsonAutoDetect":           "false",
@@ -146,6 +148,7 @@ var defaultValueMap = map[string]string{
 	"subJsonMux":                  "",
 	"subJsonRules":                "",
 	"subJsonRoutingRules":         "",
+	"subJsonDns":                  "",
 	"subJsonFinalMask":            "",
 	"subJsonObservatory":          "",
 	"subThemeDir":                 "",
@@ -781,6 +784,10 @@ func (s *SettingService) GetSubEnable() (bool, error) {
 	return s.getBool("subEnable")
 }
 
+func (s *SettingService) GetHappLinkEnable() (bool, error) {
+	return s.getBool("happLinkEnable")
+}
+
 func (s *SettingService) GetSubJsonEnable() (bool, error) {
 	return s.getBool("subJsonEnable")
 }
@@ -1023,6 +1030,10 @@ func (s *SettingService) GetSubJsonRoutingRules() (string, error) {
 	return s.getString("subJsonRoutingRules")
 }
 
+func (s *SettingService) GetSubJsonDns() (string, error) {
+	return s.getString("subJsonDns")
+}
+
 func (s *SettingService) GetSubJsonFinalMask() (string, error) {
 	return s.getString("subJsonFinalMask")
 }
@@ -1339,6 +1350,9 @@ func (s *SettingService) UpdateAllSetting(allSetting *entity.AllSetting, clears
 	if err := validateSubUserAgentRegexes(allSetting); err != nil {
 		return err
 	}
+	if err := validateSubJsonDnsSetting(allSetting); err != nil {
+		return err
+	}
 	if err := allSetting.CheckValid(); err != nil {
 		return err
 	}
@@ -1508,6 +1522,19 @@ func validateRemoteRoutingURLSetting(name string, value *string) error {
 	return nil
 }
 
+// The same parser the sub server uses, so a value can never be saved as valid
+// and then silently ignored at request time.
+func validateSubJsonDnsSetting(allSetting *entity.AllSetting) error {
+	value := strings.TrimSpace(allSetting.SubJsonDns)
+	if value != "" {
+		if _, err := dnsconf.Parse(value); err != nil {
+			return common.NewError("JSON subscription DNS is invalid:", err.Error())
+		}
+	}
+	allSetting.SubJsonDns = value
+	return nil
+}
+
 func (s *SettingService) UpdateSecret(key string, value string) error {
 	switch key {
 	case "tgBotToken", "ldapPassword", "twoFactorToken":
@@ -1580,6 +1607,7 @@ func (s *SettingService) GetDefaultSettings(host string) (any, error) {
 		"defaultKey":       func() (any, error) { return s.GetKeyFile() },
 		"tgBotEnable":      func() (any, error) { return s.GetTgbotEnabled() },
 		"subThemeDir":      func() (any, error) { return s.GetSubThemeDir() },
+		"happLinkEnable":   func() (any, error) { return s.GetHappLinkEnable() },
 		"subEnable":        func() (any, error) { return s.GetSubEnable() },
 		"subJsonEnable":    func() (any, error) { return s.GetSubJsonEnable() },
 		"subClashEnable":   func() (any, error) { return s.GetSubClashEnable() },

+ 43 - 0
internal/web/service/setting_happ_test.go

@@ -0,0 +1,43 @@
+package service
+
+import "testing"
+
+func TestHappLinkEnableReadsExplicitValues(t *testing.T) {
+	initHappTestDB(t)
+	s := &SettingService{}
+
+	for _, want := range []bool{false, true} {
+		settings, err := s.GetAllSetting()
+		if err != nil {
+			t.Fatal(err)
+		}
+		settings.HappLinkEnable = want
+		if err := s.UpdateAllSetting(settings, SecretClears{}); err != nil {
+			t.Fatal(err)
+		}
+		gotDirect, err := s.GetHappLinkEnable()
+		if err != nil || gotDirect != want {
+			t.Fatalf("GetHappLinkEnable = %t, %v; want %t, nil", gotDirect, err, want)
+		}
+		if got := happLinkEnableFromDefaults(t, s); got != want {
+			t.Fatalf("stored happLinkEnable = %t, want %t", got, want)
+		}
+	}
+}
+
+func happLinkEnableFromDefaults(t *testing.T, s *SettingService) bool {
+	t.Helper()
+	defaults, err := s.GetDefaultSettings("panel.example")
+	if err != nil {
+		t.Fatal(err)
+	}
+	values, ok := defaults.(map[string]any)
+	if !ok {
+		t.Fatalf("GetDefaultSettings type = %T, want map[string]any", defaults)
+	}
+	enabled, ok := values["happLinkEnable"].(bool)
+	if !ok {
+		t.Fatalf("happLinkEnable = %#v, want bool", values["happLinkEnable"])
+	}
+	return enabled
+}

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

@@ -0,0 +1,64 @@
+package service
+
+import (
+	"strings"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/web/entity"
+)
+
+func TestValidateSubJsonDnsSetting(t *testing.T) {
+	tests := []struct {
+		name      string
+		value     string
+		want      string
+		wantError string
+	}{
+		{name: "blank is trimmed", value: "   ", want: ""},
+		{name: "object passes through", value: ` {"servers": ["1.1.1.1"]} `, want: `{"servers": ["1.1.1.1"]}`},
+		{name: "array passes through", value: `["1.1.1.1", "tls://1.0.0.1"]`, want: `["1.1.1.1", "tls://1.0.0.1"]`},
+		{name: "object with hosts and strategy", value: `{"hosts":{"a":"b"},"queryStrategy":"UseIPv4","servers":["1.1.1.1"]}`, want: `{"hosts":{"a":"b"},"queryStrategy":"UseIPv4","servers":["1.1.1.1"]}`},
+		{name: "malformed JSON is rejected", value: `{"servers": [`, wantError: "JSON subscription DNS is invalid"},
+		{name: "broken field type is rejected", value: `{"servers": ["1.1.1.1"], "hosts": 5}`, wantError: "JSON subscription DNS is invalid"},
+		{name: "empty server list is rejected", value: `[]`, wantError: "JSON subscription DNS is invalid"},
+		{name: "server without address is rejected", value: `[{"skipFallback": true}]`, wantError: "JSON subscription DNS is invalid"},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			settings := &entity.AllSetting{SubJsonDns: tt.value}
+			err := validateSubJsonDnsSetting(settings)
+			if tt.wantError != "" {
+				if err == nil || !strings.Contains(err.Error(), tt.wantError) {
+					t.Fatalf("err=%v, want %q", err, tt.wantError)
+				}
+				return
+			}
+			if err != nil || settings.SubJsonDns != tt.want {
+				t.Fatalf("value=%q err=%v", settings.SubJsonDns, err)
+			}
+		})
+	}
+}
+
+func TestSubJsonDnsSettingDefaultsAndPersists(t *testing.T) {
+	setupSettingTestDB(t)
+	s := &SettingService{}
+
+	settings, err := s.GetAllSetting()
+	if err != nil {
+		t.Fatal(err)
+	}
+	if settings.SubJsonDns != "" {
+		t.Fatalf("expected empty default, got %q", settings.SubJsonDns)
+	}
+
+	settings.SubJsonDns = `["https://dns.google/dns-query"]`
+	if err := s.UpdateAllSetting(settings, SecretClears{}); err != nil {
+		t.Fatal(err)
+	}
+
+	got, err := s.GetSubJsonDns()
+	if err != nil || got != `["https://dns.google/dns-query"]` {
+		t.Fatalf("expected the stored DNS list, got %q, err %v", got, err)
+	}
+}

+ 8 - 0
internal/web/service/tgbot/tgbot.go

@@ -440,6 +440,14 @@ func (t *Tgbot) IsRunning() bool {
 	return isRunning
 }
 
+// adminSnapshot returns the admin chat list under the mutex Start and Stop
+// replace it under: a torn slice header is not a harmless race.
+func adminSnapshot() []int64 {
+	tgBotMutex.Lock()
+	defer tgBotMutex.Unlock()
+	return slices.Clone(adminIds)
+}
+
 // SetHostname sets the hostname for the bot.
 func (t *Tgbot) SetHostname() {
 	host, err := os.Hostname()

+ 73 - 0
internal/web/service/tgbot/tgbot_admin_list_race_test.go

@@ -0,0 +1,73 @@
+package tgbot
+
+import (
+	"sync"
+	"testing"
+)
+
+// Regression test: writers replace adminIds and isRunning under tgBotMutex, so
+// a bare read of either is reported by -race (CI's `race` job).
+func TestAdminListReadersShareTheWriterLock(t *testing.T) {
+	mock, _ := staleButtonServer(t, map[string]any{
+		"sendMessage": map[string]any{"ok": true, "result": map[string]any{
+			"message_id": 1,
+			"date":       0,
+			"chat":       map[string]any{"id": 1, "type": "private"},
+		}},
+	})
+	swapTestBot(t, mock.URL)
+	defer mock.Close()
+
+	tgBotMutex.Lock()
+	origAdmins := adminIds
+	origRunning := isRunning
+	tgBotMutex.Unlock()
+	t.Cleanup(func() {
+		tgBotMutex.Lock()
+		adminIds = origAdmins
+		isRunning = origRunning
+		tgBotMutex.Unlock()
+	})
+
+	stop := make(chan struct{})
+	var writer sync.WaitGroup
+	writer.Add(1)
+	go func() {
+		defer writer.Done()
+		for i := 0; ; i++ {
+			select {
+			case <-stop:
+				return
+			default:
+			}
+			// The same lock order Start and Stop write with.
+			tgBotMutex.Lock()
+			if i%2 == 0 {
+				adminIds = []int64{111, 222, 333}
+			} else {
+				adminIds = nil
+			}
+			isRunning = i%2 == 0
+			tgBotMutex.Unlock()
+		}
+	}()
+
+	tb := &Tgbot{}
+	var readers sync.WaitGroup
+	for range 4 {
+		readers.Add(1)
+		go func() {
+			defer readers.Done()
+			for range 300 {
+				checkAdmin(111)
+				_ = tb.IsRunning()
+				// SendMsgToTgbot reads isRunning itself; the mock bot keeps
+				// the live path cheap enough to run under -race.
+				tb.SendMsgToTgbot(1, "tick")
+			}
+		}()
+	}
+	readers.Wait()
+	close(stop)
+	writer.Wait()
+}

+ 21 - 5
internal/web/service/tgbot/tgbot_client.go

@@ -6,6 +6,7 @@ import (
 	"encoding/json"
 	"errors"
 	"fmt"
+	"html"
 	"io"
 	"net/http"
 	"slices"
@@ -73,14 +74,14 @@ func (t *Tgbot) BuildClientDraftMessage() string {
 	}
 
 	var b strings.Builder
-	b.WriteString("📝 *New client draft*\r\n")
-	fmt.Fprintf(&b, "📧 Email: `%s`\r\n", client_Email)
-	fmt.Fprintf(&b, "🔗 Attached: %s\r\n", attached)
+	b.WriteString("📝 <b>New client draft</b>\r\n")
+	fmt.Fprintf(&b, "📧 Email: <code>%s</code>\r\n", html.EscapeString(client_Email))
+	fmt.Fprintf(&b, "🔗 Attached: %s\r\n", html.EscapeString(attached))
 	fmt.Fprintf(&b, "📊 Traffic: %s\r\n", traffic)
 	fmt.Fprintf(&b, "📅 Expire: %s\r\n", expiry)
 	fmt.Fprintf(&b, "🔢 IP limit: %s\r\n", ipLimit)
-	fmt.Fprintf(&b, "👤 TG user: %s\r\n", tgID)
-	fmt.Fprintf(&b, "💬 Comment: %s\r\n", comment)
+	fmt.Fprintf(&b, "👤 TG user: %s\r\n", html.EscapeString(tgID))
+	fmt.Fprintf(&b, "💬 Comment: %s\r\n", html.EscapeString(comment))
 	return b.String()
 }
 
@@ -537,6 +538,21 @@ func (t *Tgbot) clientInfoMsg(
 	return output
 }
 
+// clientOwnedByTgUser reports whether email belongs to a client bound to this
+// Telegram account, the same list the self-service usage command reads.
+func (t *Tgbot) clientOwnedByTgUser(tgUserID int64, email string) bool {
+	traffics, err := t.inboundService.GetClientTrafficTgBot(tgUserID)
+	if err != nil {
+		return false
+	}
+	for _, traffic := range traffics {
+		if traffic.Email == email {
+			return true
+		}
+	}
+	return false
+}
+
 // getClientUsage retrieves and sends client usage information to the chat.
 func (t *Tgbot) getClientUsage(chatId int64, tgUserID int64, email ...string) {
 	traffics, err := t.inboundService.GetClientTrafficTgBot(tgUserID)

+ 125 - 0
internal/web/service/tgbot/tgbot_client_links_authz_test.go

@@ -0,0 +1,125 @@
+package tgbot
+
+import (
+	"path/filepath"
+	"testing"
+	"time"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/web/global"
+	"github.com/mhsanaei/3x-ui/v3/internal/xray"
+
+	"github.com/mymmrac/telego"
+)
+
+const (
+	ownerTgID = int64(4242)
+	ownerMail = "owner@x"
+)
+
+// newLinksCallbackTgbot seeds one inbound whose settings bind email to
+// ownerTgID, the traffic row the ownership lookup joins on, and a mocked API.
+func newLinksCallbackTgbot(t *testing.T, email string) (*Tgbot, func(string) int) {
+	t.Helper()
+	mock, calls := staleButtonServer(t, map[string]any{
+		"answerCallbackQuery": map[string]any{"ok": true, "result": true},
+		"sendMessage": map[string]any{"ok": true, "result": map[string]any{
+			"message_id": 1,
+			"date":       0,
+			"chat":       map[string]any{"id": ownerTgID, "type": "private"},
+		}},
+	})
+	swapTestBot(t, mock.URL)
+	t.Cleanup(mock.Close)
+
+	if err := database.InitDB(filepath.Join(t.TempDir(), "x-ui.db")); err != nil {
+		t.Fatalf("InitDB: %v", err)
+	}
+	t.Cleanup(func() { _ = database.CloseDB() })
+
+	inbound := &model.Inbound{
+		UserId:   1,
+		Remark:   "in",
+		Port:     443,
+		Protocol: model.VLESS,
+		Enable:   true,
+		Settings: `{"clients":[{"email":"` + email + `","tgId":4242,"subId":"sub-owned"}]}`,
+	}
+	if err := database.GetDB().Create(inbound).Error; err != nil {
+		t.Fatalf("seed inbound: %v", err)
+	}
+	if err := database.GetDB().Create(&xray.ClientTraffic{
+		InboundId: inbound.Id,
+		Email:     email,
+		Enable:    true,
+	}).Error; err != nil {
+		t.Fatalf("seed traffic: %v", err)
+	}
+
+	origRunning := isRunning
+	t.Cleanup(func() { isRunning = origRunning })
+	isRunning = true
+
+	return &Tgbot{}, calls
+}
+
+func tapClientLinks(t *testing.T, tb *Tgbot, tgUserID int64, data string) {
+	t.Helper()
+	tb.answerCallback(&telego.CallbackQuery{
+		ID:      "q1",
+		From:    telego.User{ID: tgUserID},
+		Data:    data,
+		Message: &telego.Message{Chat: telego.Chat{ID: tgUserID}},
+	}, false)
+}
+
+// Regression test: a non-admin tapping a link callback carrying another
+// client's email must be refused; without the ownership check it is served.
+func TestClientLinkCallbackRefusesForeignClient(t *testing.T) {
+	tb, calls := newLinksCallbackTgbot(t, ownerMail)
+
+	tapClientLinks(t, tb, ownerTgID, "client_sub_links someone-else@x")
+
+	if n := calls("sendMessage"); n != 0 {
+		t.Errorf("sendMessage calls = %d, want 0: a non-admin received a foreign client's links", n)
+	}
+	if n := calls("answerCallbackQuery"); n != 1 {
+		t.Errorf("answerCallbackQuery calls = %d, want 1: the refused tap must be answered", n)
+	}
+}
+
+// The same guard must not lock the owner out of their own links.
+func TestClientLinkCallbackServesOwnClient(t *testing.T) {
+	tb, calls := newLinksCallbackTgbot(t, ownerMail)
+
+	tapClientLinks(t, tb, ownerTgID, "client_sub_links "+ownerMail)
+
+	if n := calls("sendMessage"); n != 1 {
+		t.Errorf("sendMessage calls = %d, want 1: the owner must still get its links", n)
+	}
+	if n := calls("answerCallbackQuery"); n != 0 {
+		t.Errorf("answerCallbackQuery calls = %d, want 0: an allowed tap is not refused", n)
+	}
+}
+
+// Regression test: a payload past 64 chars arrives as its hash, so an email long
+// enough to be hashed must still be decoded and served to its owner.
+func TestHashedLinkCallbackServesOwnClient(t *testing.T) {
+	const longMail = "[email protected]"
+	tb, calls := newLinksCallbackTgbot(t, longMail)
+
+	origStorage := hashStorage
+	hashStorage = global.NewHashStorage(20 * time.Minute)
+	t.Cleanup(func() { hashStorage = origStorage })
+
+	data := tb.encodeQuery("client_sub_links " + longMail)
+	if data == "client_sub_links "+longMail {
+		t.Fatalf("encodeQuery left %q unhashed; the test needs a hashed payload", data)
+	}
+	tapClientLinks(t, tb, ownerTgID, data)
+
+	if n := calls("sendMessage"); n != 1 {
+		t.Errorf("sendMessage calls = %d, want 1: the owner's hashed button must still be served", n)
+	}
+}

+ 81 - 0
internal/web/service/tgbot/tgbot_declined_callback_test.go

@@ -0,0 +1,81 @@
+package tgbot
+
+import (
+	"testing"
+	"time"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/web/global"
+
+	"github.com/mymmrac/telego"
+)
+
+func tapCallback(t *testing.T, tb *Tgbot, isAdmin bool, data string) {
+	t.Helper()
+	tb.answerCallback(&telego.CallbackQuery{
+		ID:      "q1",
+		From:    telego.User{ID: 1},
+		Data:    data,
+		Message: &telego.Message{Chat: telego.Chat{ID: 1}},
+	}, isAdmin)
+}
+
+// decliningServer answers both methods a declined callback can reach, and
+// leaves isRunning set so a notice would go out if the code decided to send one.
+func decliningServer(t *testing.T) func(string) int {
+	t.Helper()
+	mock, calls := staleButtonServer(t, map[string]any{
+		"answerCallbackQuery": map[string]any{"ok": true, "result": true},
+		"sendMessage": map[string]any{"ok": true, "result": map[string]any{
+			"message_id": 1,
+			"date":       0,
+			"chat":       map[string]any{"id": 1, "type": "private"},
+		}},
+	})
+	swapTestBot(t, mock.URL)
+	t.Cleanup(mock.Close)
+
+	origRunning := isRunning
+	t.Cleanup(func() { isRunning = origRunning })
+	isRunning = true
+
+	return calls
+}
+
+// Regression test: a payload the bot could not route was dropped without an
+// answer, so Telegram kept the button spinning until the callback timed out.
+func TestUnroutableCallbackIsAnswered(t *testing.T) {
+	for _, data := range []string{"no_such_callback", "get_backup_legacy", "add_client_legacy_step 5"} {
+		t.Run(data, func(t *testing.T) {
+			calls := decliningServer(t)
+
+			tapCallback(t, &Tgbot{}, true, data)
+
+			if n := calls("answerCallbackQuery"); n != 1 {
+				t.Errorf("answerCallbackQuery calls = %d, want 1: an unroutable tap must be answered", n)
+			}
+			if n := calls("sendMessage"); n != 0 {
+				t.Errorf("sendMessage calls = %d, want 0: an answer is not a posted message", n)
+			}
+		})
+	}
+}
+
+// Regression test: a button whose hash aged out was answered with nothing at all;
+// the answer clears it and the chat notice survives a failed send.
+func TestExpiredCallbackHashIsAnsweredAndReported(t *testing.T) {
+	calls := decliningServer(t)
+
+	origHash := hashStorage
+	hashStorage = global.NewHashStorage(time.Minute)
+	t.Cleanup(func() { hashStorage = origHash })
+
+	// 32 hex characters — the shape decodeQuery looks up, and never stored.
+	tapCallback(t, &Tgbot{}, true, "0123456789abcdef0123456789abcdef")
+
+	if n := calls("answerCallbackQuery"); n != 1 {
+		t.Errorf("answerCallbackQuery calls = %d, want 1: an expired button must be answered", n)
+	}
+	if n := calls("sendMessage"); n != 1 {
+		t.Errorf("sendMessage calls = %d, want 1: the notice is the durable half of the reply", n)
+	}
+}

+ 148 - 0
internal/web/service/tgbot/tgbot_draft_render_test.go

@@ -0,0 +1,148 @@
+package tgbot
+
+import (
+	"encoding/json"
+	"html"
+	"io"
+	"net/http"
+	"net/http/httptest"
+	"strings"
+	"sync"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/web/locale"
+
+	"github.com/mymmrac/telego"
+	"github.com/nicksnyder/go-i18n/v2/i18n"
+	"golang.org/x/text/language"
+)
+
+// Regression test: the draft is sent with ParseMode HTML, so Markdown markers
+// were rendered literally and an unescaped value could break the whole message.
+func TestClientDraftMessageRendersHTML(t *testing.T) {
+	origEmail, origComment, origTgID := client_Email, client_Comment, client_TgID
+	origTotalGB, origLimitIP, origExpiry := client_TotalGB, client_LimitIP, client_ExpiryTime
+	origInboundIDs := receiver_inbound_IDs
+	t.Cleanup(func() {
+		client_Email, client_Comment, client_TgID = origEmail, origComment, origTgID
+		client_TotalGB, client_LimitIP, client_ExpiryTime = origTotalGB, origLimitIP, origExpiry
+		receiver_inbound_IDs = origInboundIDs
+	})
+
+	client_Email = "[email protected]"
+	client_Comment = "<b>promo</b> & <10 GB>"
+	client_TgID = "42"
+	client_TotalGB, client_LimitIP, client_ExpiryTime = 0, 0, 0
+	receiver_inbound_IDs = nil
+
+	out := (&Tgbot{}).BuildClientDraftMessage()
+
+	if !strings.Contains(out, "<b>New client draft</b>") {
+		t.Errorf("draft title is not HTML markup: %q", out)
+	}
+	if strings.Contains(out, "*New client draft*") || strings.Contains(out, "`") {
+		t.Errorf("draft still carries Markdown markers: %q", out)
+	}
+	if strings.Contains(out, "<b>promo</b>") {
+		t.Errorf("raw comment markup reached the message: %q", out)
+	}
+	if !strings.Contains(out, html.EscapeString(client_Comment)) {
+		t.Errorf("comment is not HTML-escaped: %q", out)
+	}
+}
+
+// botPromptLocalizer renders the two prompts the callback tests drive, with the
+// templates the translation files carry; without it I18n returns the bare key.
+func botPromptLocalizer(t *testing.T) {
+	t.Helper()
+	bundle := i18n.NewBundle(language.MustParse("en-US"))
+	bundle.RegisterUnmarshalFunc("json", json.Unmarshal)
+	_ = bundle.AddMessages(language.MustParse("en-US"),
+		&i18n.Message{ID: "tgbot.messages.email_prompt", Other: "📧 Default Email: {{ .ClientEmail }}\n\nEnter your email."},
+		&i18n.Message{ID: "tgbot.messages.comment_prompt", Other: "💬 Default Comment: {{ .ClientComment }}\n\nEnter your comment."},
+	)
+	orig := locale.LocalizerBot
+	t.Cleanup(func() { locale.LocalizerBot = orig })
+	locale.LocalizerBot = i18n.NewLocalizer(bundle, "en-US")
+}
+
+// promptTexts serves the methods these prompts touch and returns the text of
+// every sendMessage, so a test can check what Telegram would actually parse.
+func promptTexts(t *testing.T) (string, func() []string) {
+	t.Helper()
+	var mu sync.Mutex
+	var texts []string
+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		body, _ := io.ReadAll(r.Body)
+		result := any(true)
+		if r.URL.Path == "/bot"+testBotToken+"/sendMessage" {
+			var payload struct {
+				Text string `json:"text"`
+			}
+			_ = json.Unmarshal(body, &payload)
+			mu.Lock()
+			texts = append(texts, payload.Text)
+			mu.Unlock()
+			result = map[string]any{"message_id": 1, "date": 0, "chat": map[string]any{"id": 1, "type": "private"}}
+		}
+		w.Header().Set("Content-Type", "application/json")
+		_ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "result": result})
+	}))
+	t.Cleanup(srv.Close)
+
+	return srv.URL, func() []string {
+		mu.Lock()
+		defer mu.Unlock()
+		return append([]string(nil), texts...)
+	}
+}
+
+// Regression test: the wizard's own prompts are HTML-parsed as well, so the
+// draft value they echo has to be escaped exactly like the draft card.
+func TestAddClientPromptsEscapeDraftValues(t *testing.T) {
+	botPromptLocalizer(t)
+	url, texts := promptTexts(t)
+	swapTestBot(t, url)
+
+	origEmail, origComment := client_Email, client_Comment
+	origRunning := isRunning
+	t.Cleanup(func() {
+		client_Email, client_Comment = origEmail, origComment
+		isRunning = origRunning
+	})
+	isRunning = true
+
+	cases := []struct {
+		name  string
+		data  string
+		value string
+	}{
+		{"email prompt", "add_client_ch_default_email", "long<name>@example.com"},
+		{"comment prompt", "add_client_ch_default_comment", "promo <b>tag</b>"},
+	}
+	tb := &Tgbot{}
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			client_Email, client_Comment = tc.value, tc.value
+
+			tb.answerCallback(&telego.CallbackQuery{
+				ID:      "q1",
+				From:    telego.User{ID: 1},
+				Data:    tc.data,
+				Message: &telego.Message{Chat: telego.Chat{ID: 1}},
+			}, true) // admin
+
+			sent := texts()
+			if len(sent) == 0 {
+				t.Fatalf("no prompt was sent for %s", tc.data)
+			}
+			got := sent[len(sent)-1]
+			if strings.Contains(got, tc.value) {
+				t.Errorf("prompt = %q, want the draft value escaped", got)
+			}
+			if !strings.Contains(got, html.EscapeString(tc.value)) {
+				t.Errorf("prompt = %q, want it to contain %q", got, html.EscapeString(tc.value))
+			}
+		})
+	}
+}

+ 4 - 3
internal/web/service/tgbot/tgbot_report.go

@@ -53,10 +53,11 @@ func (t *Tgbot) SendBackupToAdmins() {
 		logger.Error("Error in getting db backup: ", err)
 	}
 	dbFilename := t.serverService.BackupFilename("")
-	for i, adminId := range adminIds {
+	admins := adminSnapshot()
+	for i, adminId := range admins {
 		t.sendBackupData(adminId, dbData, dbFilename)
 		// Add delay between sends to avoid Telegram rate limits
-		if i < len(adminIds)-1 {
+		if i < len(admins)-1 {
 			time.Sleep(1 * time.Second)
 		}
 	}
@@ -67,7 +68,7 @@ func (t *Tgbot) sendExhaustedToAdmins() {
 	if !t.IsRunning() {
 		return
 	}
-	for _, adminId := range adminIds {
+	for _, adminId := range adminSnapshot() {
 		t.getExhausted(adminId)
 	}
 }

+ 189 - 0
internal/web/service/tgbot/tgbot_report_batching_test.go

@@ -0,0 +1,189 @@
+package tgbot
+
+import (
+	"encoding/json"
+	"fmt"
+	"io"
+	"net/http"
+	"net/http/httptest"
+	"path/filepath"
+	"strings"
+	"sync"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/xray"
+
+	"github.com/mymmrac/telego"
+)
+
+// seedReportClients writes one inbound holding every email plus the traffic row
+// each of them needs to appear in the sorted usage report.
+func seedReportClients(t *testing.T, remark string, emails []string) {
+	t.Helper()
+	settings := make([]string, 0, len(emails))
+	for _, email := range emails {
+		settings = append(settings, fmt.Sprintf(`{"email":%q,"subId":"sub-%s"}`, email, email))
+	}
+	inbound := &model.Inbound{
+		UserId:   1,
+		Remark:   remark,
+		Port:     8443,
+		Protocol: model.VLESS,
+		Enable:   true,
+		Settings: `{"clients":[` + strings.Join(settings, ",") + `]}`,
+	}
+	if err := database.GetDB().Create(inbound).Error; err != nil {
+		t.Fatalf("seed inbound: %v", err)
+	}
+	for _, email := range emails {
+		if err := database.GetDB().Create(&xray.ClientTraffic{
+			InboundId: inbound.Id,
+			Email:     email,
+			Enable:    true,
+			Up:        1,
+			Down:      1,
+		}).Error; err != nil {
+			t.Fatalf("seed traffic for %s: %v", email, err)
+		}
+		record := (&model.Client{Email: email, Enable: true, SubID: "sub-" + email}).ToRecord()
+		if err := database.GetDB().Create(record).Error; err != nil {
+			t.Fatalf("seed client %s: %v", email, err)
+		}
+		if err := database.GetDB().Create(&model.ClientInbound{ClientId: record.Id, InboundId: inbound.Id}).Error; err != nil {
+			t.Fatalf("seed client_inbounds for %s: %v", email, err)
+		}
+	}
+}
+
+func initReportDB(t *testing.T) *Tgbot {
+	t.Helper()
+	if err := database.InitDB(filepath.Join(t.TempDir(), "x-ui.db")); err != nil {
+		t.Fatalf("InitDB: %v", err)
+	}
+	t.Cleanup(func() { _ = database.CloseDB() })
+
+	origRunning := isRunning
+	t.Cleanup(func() { isRunning = origRunning })
+	isRunning = true
+	return &Tgbot{}
+}
+
+type sentMessage struct {
+	Text        string          `json:"text"`
+	ReplyMarkup json.RawMessage `json:"reply_markup"`
+}
+
+// captureReportServer records every sendMessage call so a test can assert on
+// what Telegram would have received, not merely how many calls were made.
+func captureReportServer(t *testing.T) (*httptest.Server, func() []sentMessage) {
+	t.Helper()
+	var mu sync.Mutex
+	var sent []sentMessage
+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		body, _ := io.ReadAll(r.Body)
+		result := any(true)
+		if r.URL.Path == "/bot"+testBotToken+"/sendMessage" {
+			var payload sentMessage
+			_ = json.Unmarshal(body, &payload)
+			mu.Lock()
+			sent = append(sent, payload)
+			mu.Unlock()
+			result = map[string]any{"message_id": 1, "date": 0, "chat": map[string]any{"id": 1, "type": "private"}}
+		}
+		w.Header().Set("Content-Type", "application/json")
+		_ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "result": result})
+	}))
+	return srv, func() []sentMessage {
+		mu.Lock()
+		defer mu.Unlock()
+		return append([]sentMessage(nil), sent...)
+	}
+}
+
+// Regression test: the sorted usage report must reach Telegram as one message
+// whatever the client count; per-client sends burst past the rate limit.
+func TestTrafficUsageReportIsOneMessage(t *testing.T) {
+	mock, calls := staleButtonServer(t, map[string]any{
+		"sendMessage": map[string]any{"ok": true, "result": map[string]any{
+			"message_id": 1,
+			"date":       0,
+			"chat":       map[string]any{"id": 1, "type": "private"},
+		}},
+		"deleteMessage": map[string]any{"ok": true, "result": true},
+	})
+	swapTestBot(t, mock.URL)
+	defer mock.Close()
+
+	tb := initReportDB(t)
+	seedReportClients(t, "report", []string{"a@x", "b@x", "c@x"})
+
+	tb.answerCallback(&telego.CallbackQuery{
+		ID:      "q1",
+		From:    telego.User{ID: 1},
+		Data:    "get_sorted_traffic_usage_report",
+		Message: &telego.Message{Chat: telego.Chat{ID: 1}},
+	}, true) // admin
+
+	if n := calls("sendMessage"); n != 1 {
+		t.Errorf("sendMessage calls = %d, want 1: one report per tap, not one per client", n)
+	}
+}
+
+// Regression test: batching must not swallow the reply on a panel with no
+// clients, where the old code still answered FinishProcess.
+func TestResetAllTrafficsAnswersWithNoClients(t *testing.T) {
+	mock, sent := captureReportServer(t)
+	swapTestBot(t, mock.URL)
+	defer mock.Close()
+
+	tb := initReportDB(t)
+
+	tb.answerCallback(&telego.CallbackQuery{
+		ID:      "q1",
+		From:    telego.User{ID: 1},
+		Data:    "reset_all_traffics_c",
+		Message: &telego.Message{Chat: telego.Chat{ID: 1}},
+	}, true) // admin
+
+	got := sent()
+	if len(got) != 1 {
+		t.Fatalf("sendMessage calls = %d, want 1: an empty panel must still answer the tap", len(got))
+	}
+	if got[0].Text == "" {
+		t.Error("reset report text is empty, want the finish-process message")
+	}
+	if !strings.Contains(string(got[0].ReplyMarkup), `"remove_keyboard":true`) {
+		t.Errorf("reply_markup = %s, want the reply keyboard removed", got[0].ReplyMarkup)
+	}
+}
+
+// Regression test: the report leaves as one HTML-parsed message, so a remark
+// holding "<" must reach Telegram escaped instead of dropping the whole page.
+func TestTrafficUsageReportEscapesHtml(t *testing.T) {
+	mock, sent := captureReportServer(t)
+	swapTestBot(t, mock.URL)
+	defer mock.Close()
+
+	tb := initReportDB(t)
+	seedReportClients(t, "DE <fast>", []string{"a@x"})
+
+	tb.answerCallback(&telego.CallbackQuery{
+		ID:      "q1",
+		From:    telego.User{ID: 1},
+		Data:    "get_sorted_traffic_usage_report",
+		Message: &telego.Message{Chat: telego.Chat{ID: 1}},
+	}, true) // admin
+
+	got := sent()
+	if len(got) != 1 {
+		t.Fatalf("sendMessage calls = %d, want 1", len(got))
+	}
+	if strings.Contains(got[0].Text, "<fast>") {
+		t.Errorf("report text = %q, want the remark escaped", got[0].Text)
+	}
+	if !strings.Contains(got[0].Text, "&lt;fast&gt;") {
+		t.Errorf("report text = %q, want the remark escaped as &lt;fast&gt;", got[0].Text)
+	}
+}

+ 78 - 42
internal/web/service/tgbot/tgbot_router.go

@@ -316,6 +316,9 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool
 		// get query from hash storage
 		decodedQuery, err := t.decodeQuery(callbackQuery.Data)
 		if err != nil {
+			// A button older than the 20-minute hash window is the common case
+			// here; the answer clears it, the message outlives a failed send.
+			t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.noQuery"))
 			t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.noQuery"))
 			return
 		}
@@ -884,6 +887,10 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool
 					return
 				}
 				t.editMessageCallbackTgBot(callbackQuery.Message.GetChat().ID, callbackQuery.Message.GetMessageID(), picker)
+			default:
+				// An unknown action with arguments is still a tap, and an
+				// unanswered tap spins until Telegram times it out.
+				t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.errorOperation"))
 			}
 			return
 		} else {
@@ -922,8 +929,15 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool
 		}
 	}
 
-	if !isAdmin && !isClientSelfCallback(callbackQuery.Data) {
-		return
+	if !isAdmin {
+		// encodeQuery hashes any payload past 64 chars, so a long email's button
+		// must be decoded before the gate can see which client it names.
+		if decoded, err := t.decodeQuery(callbackQuery.Data); err == nil {
+			callbackQuery.Data = decoded
+		}
+		if !isClientSelfCallback(callbackQuery.Data) {
+			return
+		}
 	}
 
 	switch callbackQuery.Data {
@@ -1054,7 +1068,7 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool
 				tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.use_default")).WithCallbackData("add_client_default_info"),
 			),
 		)
-		prompt_message := t.I18nBot("tgbot.messages.email_prompt", "ClientEmail=="+client_Email)
+		prompt_message := t.I18nBot("tgbot.messages.email_prompt", "ClientEmail=="+html.EscapeString(client_Email))
 		t.SendMsgToTgbot(chatId, prompt_message, cancel_btn_markup)
 	case "add_client_ch_default_comment":
 		t.deleteMessageTgBot(chatId, callbackQuery.Message.GetMessageID())
@@ -1064,7 +1078,7 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool
 				tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.use_default")).WithCallbackData("add_client_default_info"),
 			),
 		)
-		prompt_message := t.I18nBot("tgbot.messages.comment_prompt", "ClientComment=="+client_Comment)
+		prompt_message := t.I18nBot("tgbot.messages.comment_prompt", "ClientComment=="+html.EscapeString(client_Comment))
 		t.SendMsgToTgbot(chatId, prompt_message, cancel_btn_markup)
 	case "add_client_ch_default_tg_id":
 		t.deleteMessageTgBot(chatId, callbackQuery.Message.GetMessageID())
@@ -1078,7 +1092,7 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool
 		if current == "" {
 			current = "—"
 		}
-		t.SendMsgToTgbot(chatId, fmt.Sprintf("Send the Telegram user id (numeric) to attach to this client, or send `-` / `none` to clear.\nCurrent: `%s`", current), cancel_btn_markup)
+		t.SendMsgToTgbot(chatId, fmt.Sprintf("Send the Telegram user id (numeric) to attach to this client, or send <code>-</code> / <code>none</code> to clear.\nCurrent: <code>%s</code>", html.EscapeString(current)), cancel_btn_markup)
 	case "add_client_ch_default_traffic":
 		inlineKeyboard := tu.InlineKeyboard(
 			tu.InlineKeyboardRow(
@@ -1254,18 +1268,22 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool
 			return
 		}
 
+		// One report per tap, not one message per client: a large panel would
+		// otherwise burst past Telegram's rate limit. SendMsgToTgbot pages it.
+		var report strings.Builder
 		for _, email := range emails {
-			err := t.inboundService.ResetClientTrafficByEmail(email)
-			if err == nil {
-				msg := t.I18nBot("tgbot.messages.SuccessResetTraffic", "ClientEmail=="+email)
-				t.SendMsgToTgbot(chatId, msg, tu.ReplyKeyboardRemove())
+			if err := t.inboundService.ResetClientTrafficByEmail(email); err == nil {
+				report.WriteString(t.I18nBot("tgbot.messages.SuccessResetTraffic", "ClientEmail=="+email))
 			} else {
-				msg := t.I18nBot("tgbot.messages.FailedResetTraffic", "ClientEmail=="+email, "ErrorMessage=="+err.Error())
-				t.SendMsgToTgbot(chatId, msg, tu.ReplyKeyboardRemove())
+				report.WriteString(t.I18nBot("tgbot.messages.FailedResetTraffic", "ClientEmail=="+email, "ErrorMessage=="+err.Error()))
 			}
+			report.WriteString("\r\n\r\n")
 		}
+		report.WriteString(t.I18nBot("tgbot.messages.FinishProcess"))
 
-		t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.messages.FinishProcess"), tu.ReplyKeyboardRemove())
+		// Escaped whole: one stray "<" in a remark or email otherwise makes
+		// Telegram reject the page it landed on, losing ~15 clients at once.
+		t.SendMsgToTgbot(chatId, html.EscapeString(report.String()), tu.ReplyKeyboardRemove())
 	case "get_sorted_traffic_usage_report":
 		t.deleteMessageTgBot(chatId, callbackQuery.Message.GetMessageID())
 		emails, err := t.inboundService.GetAllEmails()
@@ -1273,68 +1291,86 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool
 			t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation"), tu.ReplyKeyboardRemove())
 			return
 		}
-		valid_emails, extra_emails, err := t.inboundService.FilterAndSortClientEmails(emails)
+		validEmails, missingEmails, err := t.inboundService.FilterAndSortClientEmails(emails)
 		if err != nil {
 			t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation"), tu.ReplyKeyboardRemove())
 			return
 		}
 
-		for _, valid_emails := range valid_emails {
-			traffic, err := t.inboundService.GetClientTrafficByEmail(valid_emails)
+		// Batched for the same reason as the reset report above: one message
+		// per client hits Telegram's rate limit on a large panel.
+		var report strings.Builder
+		for _, email := range validEmails {
+			traffic, err := t.inboundService.GetClientTrafficByEmail(email)
 			if err != nil {
 				logger.Warning(err)
-				msg := t.I18nBot("tgbot.wentWrong")
-				t.SendMsgToTgbot(chatId, msg)
+				report.WriteString(t.I18nBot("tgbot.wentWrong"))
+				report.WriteString("\r\n\r\n")
 				continue
 			}
 			if traffic == nil {
-				msg := t.I18nBot("tgbot.noResult")
-				t.SendMsgToTgbot(chatId, msg)
+				report.WriteString(t.I18nBot("tgbot.noResult"))
+				report.WriteString("\r\n\r\n")
 				continue
 			}
-
-			output := t.clientInfoMsg(traffic, false, false, false, false, true, false)
-			t.SendMsgToTgbot(chatId, output, tu.ReplyKeyboardRemove())
+			report.WriteString(t.clientInfoMsg(traffic, false, false, false, false, true, false))
+			report.WriteString("\r\n\r\n")
 		}
-		for _, extra_emails := range extra_emails {
-			msg := fmt.Sprintf("📧 %s\n%s", extra_emails, t.I18nBot("tgbot.noResult"))
-			t.SendMsgToTgbot(chatId, msg, tu.ReplyKeyboardRemove())
-
+		for _, email := range missingEmails {
+			fmt.Fprintf(&report, "📧 %s\r\n%s\r\n\r\n", email, t.I18nBot("tgbot.noResult"))
+		}
+		if report.Len() > 0 {
+			t.SendMsgToTgbot(chatId, html.EscapeString(report.String()), tu.ReplyKeyboardRemove())
 		}
 	default:
-		if after, ok := strings.CutPrefix(callbackQuery.Data, "client_sub_links "); ok {
-			email := after
-			t.sendClientSubLinks(chatId, email)
+		action, email, ok := splitClientLinkCallback(callbackQuery.Data)
+		if !ok {
+			// Nothing matched: an unknown button still has to be answered, or it
+			// keeps spinning until Telegram times the callback out.
+			t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.errorOperation"))
 			return
 		}
-		if after, ok := strings.CutPrefix(callbackQuery.Data, "client_individual_links "); ok {
-			email := after
-			t.sendClientIndividualLinks(chatId, email)
+		// The keyboard outlives the chat it was sent to, so the email in it
+		// cannot authorise itself: a non-admin only reaches their own clients.
+		if !isAdmin && !t.clientOwnedByTgUser(callbackQuery.From.ID, email) {
+			t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.errorOperation"))
 			return
 		}
-		if after, ok := strings.CutPrefix(callbackQuery.Data, "client_qr_links "); ok {
-			email := after
+		switch action {
+		case "client_sub_links":
+			t.sendClientSubLinks(chatId, email)
+		case "client_individual_links":
+			t.sendClientIndividualLinks(chatId, email)
+		case "client_qr_links":
 			t.sendClientQRLinks(chatId, email)
-			return
 		}
 	}
 }
 
 // checkAdmin checks if the given Telegram ID is an admin.
 func checkAdmin(tgId int64) bool {
-	return slices.Contains(adminIds, tgId)
+	return slices.Contains(adminSnapshot(), tgId)
 }
 
-// isClientSelfCallback reports whether a callback is one of the per-user client
-// actions that resolve their own data from the caller's Telegram id, and so are
-// safe to run for a non-admin. Every other callback is admin-only (default-deny).
+// isClientSelfCallback reports whether a callback is per-user rather than
+// admin-only; the caller still has to prove the client is its own.
 func isClientSelfCallback(data string) bool {
 	switch data {
 	case "client_traffic", "client_commands", "client_sub_links",
 		"client_individual_links", "client_qr_links":
 		return true
 	}
-	return strings.HasPrefix(data, "client_sub_links ") ||
-		strings.HasPrefix(data, "client_individual_links ") ||
-		strings.HasPrefix(data, "client_qr_links ")
+	_, _, ok := splitClientLinkCallback(data)
+	return ok
+}
+
+// splitClientLinkCallback splits "<action> <email>" for the per-client link
+// callbacks; ok is false for every other data.
+func splitClientLinkCallback(data string) (action, email string, ok bool) {
+	for _, candidate := range []string{"client_sub_links", "client_individual_links", "client_qr_links"} {
+		if rest, found := strings.CutPrefix(data, candidate+" "); found && rest != "" {
+			return candidate, rest, true
+		}
+	}
+	return "", "", false
 }

+ 4 - 3
internal/web/service/tgbot/tgbot_send.go

@@ -121,7 +121,7 @@ func splitMessageLines(block string, limit int) []string {
 
 // SendMsgToTgbot sends a message to the Telegram bot with optional reply markup.
 func (t *Tgbot) SendMsgToTgbot(chatId int64, msg string, replyMarkup ...telego.ReplyMarkup) {
-	if !isRunning {
+	if !t.IsRunning() {
 		return
 	}
 
@@ -180,12 +180,13 @@ func (t *Tgbot) SendMsgToTgbot(chatId int64, msg string, replyMarkup ...telego.R
 
 // SendMsgToTgbotAdmins sends a message to all admin Telegram chats.
 func (t *Tgbot) SendMsgToTgbotAdmins(msg string, replyMarkup ...telego.ReplyMarkup) {
+	admins := adminSnapshot()
 	if len(replyMarkup) > 0 {
-		for _, adminId := range adminIds {
+		for _, adminId := range admins {
 			t.SendMsgToTgbot(adminId, msg, replyMarkup[0])
 		}
 	} else {
-		for _, adminId := range adminIds {
+		for _, adminId := range admins {
 			t.SendMsgToTgbot(adminId, msg)
 		}
 	}

+ 9 - 1
internal/web/service/tgbot/tgbot_stale_button_test.go

@@ -5,6 +5,7 @@ import (
 	"net/http"
 	"net/http/httptest"
 	"path/filepath"
+	"sync"
 	"testing"
 
 	"github.com/mhsanaei/3x-ui/v3/internal/database"
@@ -17,11 +18,14 @@ import (
 // bot-dependent paths; the returned func reports per-method call counts.
 func staleButtonServer(t *testing.T, responses map[string]any) (*httptest.Server, func(string) int) {
 	t.Helper()
+	var mu sync.Mutex
 	counts := map[string]int{}
 	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 		for method, body := range responses {
 			if r.URL.Path == "/bot"+testBotToken+"/"+method {
+				mu.Lock()
 				counts[method]++
+				mu.Unlock()
 				w.Header().Set("Content-Type", "application/json")
 				json.NewEncoder(w).Encode(body)
 				return
@@ -29,7 +33,11 @@ func staleButtonServer(t *testing.T, responses map[string]any) (*httptest.Server
 		}
 		w.WriteHeader(http.StatusNotFound)
 	}))
-	return srv, func(method string) int { return counts[method] }
+	return srv, func(method string) int {
+		mu.Lock()
+		defer mu.Unlock()
+		return counts[method]
+	}
 }
 
 func swapTestBot(t *testing.T, url string) {

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

@@ -800,6 +800,17 @@
       "clearAllInbounds": "مسح الكل",
       "noSubId": "هذا العميل ليس لديه subId، لا يوجد رابط قابل للمشاركة.",
       "noLinks": "لا توجد روابط للمشاركة — قم بإرفاق هذا العميل بأحد الاتصالات الواردة الداعمة للبروتوكول أولاً.",
+      "qrStandard": "قياسي",
+      "happLinkRetry": "إعادة المحاولة",
+      "happLinkErrorHint": "تعذر إنشاء رابط Happ. أعد المحاولة، أو راجع {dashboard} -> {logs} للاطلاع على التفاصيل.",
+      "happLinkSourceTooLong": "يتجاوز حجم عنوان الاشتراك بترميز UTF-8 حد المعالجة في لوحة التحكم البالغ 8192 بايت. اختصر عنوان الاشتراك أو استخدم الخيار «قياسي».",
+      "happLinkQrTooLong": "هذا الرابط من Happ صالح، لكنه طويل جدًا لعرضه كرمز QR. اضغط «نسخ» لاستخدام الرابط الكامل.",
+      "happLinkDisabledHint": "فعّل إنشاء رابط Happ من الإعدادات قبل استخدام Happ.",
+      "happLinkDisabledTitle": "إنشاء رابط Happ المشفّر غير مفعّل",
+      "happLinkDisabledDescription": "فعّل إنشاء روابط اشتراك Happ المشفّرة محليًا. (فقط لـ Happ)",
+      "happLinkSettingsAction": "الانتقال إلى الإعدادات",
+      "happLinkDisclosure": "يُنشأ الرابط محليًا. قد يتمكن أي شخص لديه هذا الرابط من استعادة عنوان الاشتراك أو مشاركته.",
+      "happLinkOptionLabel": "رابط Happ المشفّر",
       "link": "الرابط",
       "resetNotPossible": "قم بإرفاق هذا العميل بأحد الاتصالات الواردة أولاً.",
       "resetAllTraffics": "إعادة ضبط حركة مرور كل العملاء",
@@ -1178,6 +1189,8 @@
       "subJsonUserAgentRegexDesc": "تعبير Go RE2 منتظم يُطابَق مع وكيل المستخدم (User-Agent) لاختيار صيغة Xray JSON تلقائيًا على رابط الاشتراك القياسي. فارغ افتراضيًا، لذا يبقى الاكتشاف التلقائي معطلاً حتى تحدد نمطًا للعملاء الذين تريد خدمتهم. يحصل العملاء الآخرون على الاستجابة الخام/Base64. أعد تشغيل اللوحة بعد التغيير.",
       "subJsonRoutingRules": "ملف توجيه اشتراك JSON",
       "subJsonRoutingRulesDesc": "JSON لقواعد التوجيه، أو رابط happ:// أو incy://، أو رابط https://. عند تعيينه، يستبدل DNS وقواعد التوجيه لاشتراك JSON (تُدمج في الإعداد للعملاء الذين يتجاهلون الترويسات، مثل Happ وINCY مع الموازنات من جانب العميل). إذا كان إعداد ترويسة Happ فارغًا، يُرسَل نفس الملف تلقائيًا كترويسة Routing (يُحوَّل JSON والروابط إلى ديب‌لينك happ://) ليحمّل العملاء ملفات geo المطابقة.",
+      "subJsonDns": "خوادم DNS",
+      "subJsonDnsDesc": "يستبدل كتلة dns في جميع إعدادات اشتراك JSON: إما كائن dns كامل أو مصفوفة servers فقط. يستبدل أيضًا DNS ملف التوجيه داخل هذه الإعدادات؛ أما العملاء الذين يطبّقون ترويسة Routing فيُبقون على مُحلِّلات الملف. تركه فارغًا يُبقي المُحلِّل المدمج.",
       "subClashAutoDetect": "اكتشاف عملاء Clash/Mihomo تلقائيًا",
       "subClashAutoDetectDesc": "عند التفعيل، يتلقى عملاء Clash/Mihomo المعروفون الذين يطلبون رابط الاشتراك القياسي إعداد Clash بصيغة YAML تلقائيًا. تستمر المتصفحات في عرض صفحة الاشتراك، ويحصل العملاء الآخرون على الاستجابة الخام/Base64، وتبقى روابط JSON وClash الصريحة متاحة. يتطلب تفعيل اشتراك Clash/Mihomo وإعادة تشغيل اللوحة لتطبيق التغيير.",
       "subClashUserAgentRegex": "تعبير User-Agent لعملاء Clash/Mihomo",
@@ -1194,6 +1207,8 @@
       "subThemeDirDesc": "المسار المطلق لمجلد يحتوي على قالب مخصص (index.html/sub.html) لصفحة الاشتراك (مثل /etc/3x-ui/sub_templates/my-theme/). اتركه فارغًا لاستخدام الصفحة الافتراضية.",
       "subThemeDirDocs": "دليل القالب ↗",
       "subEnableRouting": "تفعيل التوجيه",
+      "happLinkEnable": "روابط اشتراك مشفّرة",
+      "happLinkEnableDesc": "السماح بإنشاء روابط Happ مشفّرة في نافذة رمز الاستجابة السريعة للعميل. تُعالج عناوين الاشتراك محليًا.",
       "subEnableRoutingDesc": "إعداد عام لتمكين التوجيه (Routing) في عميل VPN. (فقط لـ Happ)",
       "subRoutingRules": "قواعد التوجيه",
       "subRoutingRulesDesc": "ألصق رابط happ:// جاهزًا أو عنوان HTTPS دائمًا. تحدّث اللوحة القواعد البعيدة في الخلفية وتحتفظ بآخر قيمة صالحة، لذلك لا تنتظر طلبات الاشتراك المصدر. (فقط لـ Happ)",
@@ -1566,6 +1581,7 @@
       "subHappProxyIPs": "Proxy IPs / CIDRs",
       "subHappBlockIPs": "Blocked IPs / CIDRs",
       "subHappDeeplinkGenerated": "Deeplink generated and applied to routing rules",
+      "subHappGroupLinks": "روابط الاشتراك",
       "subHappGroupRouting": "Routing & Rules",
       "subHappGroupBanners": "Banners & Announcements",
       "subHappGroupNetwork": "Network & TUN Engine",

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

@@ -800,6 +800,17 @@
       "clearAllInbounds": "Clear all",
       "noSubId": "This client has no subId, no shareable link.",
       "noLinks": "No shareable links — attach this client to a protocol-capable inbound first.",
+      "qrStandard": "Standard",
+      "happLinkRetry": "Retry",
+      "happLinkErrorHint": "The Happ link could not be generated. Retry, or check {dashboard} -> {logs} for details.",
+      "happLinkSourceTooLong": "The subscription URL exceeds the panel limit of 8192 UTF-8 bytes. Shorten the subscription URL or use Standard.",
+      "happLinkQrTooLong": "This Happ link is valid, but it is too long to display as a QR code. Use Copy to use the complete link.",
+      "happLinkDisabledHint": "Enable Happ link generation in Settings before using Happ.",
+      "happLinkDisabledTitle": "Happ encrypted link generation is not enabled",
+      "happLinkDisabledDescription": "Enable local generation of encrypted Happ subscription links. (Only for Happ)",
+      "happLinkSettingsAction": "Go to Settings",
+      "happLinkDisclosure": "Generated locally. Anyone with this link may be able to recover or share the subscription URL.",
+      "happLinkOptionLabel": "Happ Encrypted Link",
       "link": "Link",
       "resetNotPossible": "Attach this client to an inbound first.",
       "resetAllTraffics": "Reset all client traffic",
@@ -1300,6 +1311,8 @@
       "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.",
       "subJsonRoutingRules": "JSON subscription routing profile",
       "subJsonRoutingRulesDesc": "Routing JSON, happ:// or incy:// deeplink, or https:// URL. When set, it replaces the JSON subscription's DNS and routing rules (baked into the config for clients that ignore headers, e.g. Happ and INCY with client-side balancers). If the Happ routing header setting is empty, the same profile is automatically sent as the Routing header (inline JSON and URLs as a happ:// deeplink) so clients fetch the matching geo files.",
+      "subJsonDns": "DNS servers",
+      "subJsonDnsDesc": "Overrides the dns block baked into every JSON subscription config: a full dns object or a servers array. It also replaces the routing profile's DNS in those configs; clients that apply the Routing header keep the profile's resolvers instead. Empty keeps the built-in resolver.",
       "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",
@@ -1316,6 +1329,8 @@
       "subThemeDirDesc": "Absolute path to a folder containing a custom index.html/sub.html subscription page template (e.g. /etc/3x-ui/sub_templates/my-theme/). Leave empty to use the default page.",
       "subThemeDirDocs": "Template guide ↗",
       "subEnableRouting": "Enable routing",
+      "happLinkEnable": "Encrypted subscription links",
+      "happLinkEnableDesc": "Allow encrypted Happ links to be generated in the client QR code window. Subscription URLs are processed locally.",
       "subEnableRoutingDesc": "Global setting to enable routing in the VPN client. (Only for Happ)",
       "subRoutingRules": "Routing rules",
       "subRoutingRulesDesc": "Paste a ready happ:// deeplink or one permanent HTTPS URL returning a deeplink or JSON. The panel refreshes remote rules in the background and keeps the last valid value, so subscription requests never wait for the source. (Happ only)",
@@ -1684,6 +1699,7 @@
       "subHappProxyIPs": "Proxy IPs / CIDRs",
       "subHappBlockIPs": "Blocked IPs / CIDRs",
       "subHappDeeplinkGenerated": "Deeplink generated and applied to routing rules",
+      "subHappGroupLinks": "Subscription Links",
       "subHappGroupRouting": "Routing & Rules",
       "subHappGroupBanners": "Banners & Announcements",
       "subHappGroupNetwork": "Network & TUN Engine",

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

@@ -800,6 +800,17 @@
       "clearAllInbounds": "Limpiar todo",
       "noSubId": "Este cliente no tiene subId, no hay enlace compartible.",
       "noLinks": "No hay enlaces compartibles — asocia primero este cliente a un inbound con protocolo válido.",
+      "qrStandard": "Estándar",
+      "happLinkRetry": "Reintentar",
+      "happLinkErrorHint": "No se pudo generar el enlace de Happ. Reinténtalo o consulta {dashboard} -> {logs} para obtener más detalles.",
+      "happLinkSourceTooLong": "La URL de suscripción supera el límite de procesamiento del panel de 8192 bytes UTF-8. Acorta la URL de suscripción o utiliza la opción «Estándar».",
+      "happLinkQrTooLong": "Este enlace de Happ es válido, pero es demasiado largo para mostrarlo como código QR. Pulsa «Copiar» para usar el enlace completo.",
+      "happLinkDisabledHint": "Activa la generación de enlaces de Happ en Configuración antes de usar Happ.",
+      "happLinkDisabledTitle": "La generación de enlaces cifrados de Happ no está activada",
+      "happLinkDisabledDescription": "Activa la generación local de enlaces cifrados de suscripción de Happ. (Solo para Happ)",
+      "happLinkSettingsAction": "Ir a Configuración",
+      "happLinkDisclosure": "El enlace se genera localmente. Cualquiera que lo tenga podría recuperar o compartir la URL de suscripción.",
+      "happLinkOptionLabel": "Enlace cifrado de Happ",
       "link": "Enlace",
       "resetNotPossible": "Asocia primero este cliente a un inbound.",
       "resetAllTraffics": "Restablecer tráfico de todos los clientes",
@@ -1178,6 +1189,8 @@
       "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.",
       "subJsonRoutingRules": "Perfil de enrutamiento para la suscripción JSON",
       "subJsonRoutingRulesDesc": "JSON de enrutamiento, deeplink happ:// o incy://, o URL https://. Si se establece, reemplaza el DNS y las reglas de enrutamiento de la suscripción JSON (integrados en la configuración para clientes que ignoran cabeceras, p. ej. Happ e INCY con balanceadores del lado cliente). Si el ajuste de cabecera de Happ está vacío, el mismo perfil se envía automáticamente como cabecera Routing (el JSON y las URL como deeplink happ://) para que los clientes descarguen los archivos geo coincidentes.",
+      "subJsonDns": "Servidores DNS",
+      "subJsonDnsDesc": "Reemplaza el bloque dns de todas las configuraciones de la suscripción JSON: un objeto dns completo o solo el array servers. También reemplaza el DNS del perfil de enrutamiento en esas configuraciones; los clientes que aplican la cabecera Routing conservan sus resolutores. Vacío mantiene el resolutor integrado.",
       "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",
@@ -1194,6 +1207,8 @@
       "subThemeDirDesc": "Ruta absoluta a una carpeta que contiene una plantilla personalizada (index.html/sub.html) para la página de suscripción (p. ej. /etc/3x-ui/sub_templates/my-theme/). Déjalo vacío para usar la página predeterminada.",
       "subThemeDirDocs": "Guía de plantillas ↗",
       "subEnableRouting": "Habilitar enrutamiento",
+      "happLinkEnable": "Enlaces de suscripción cifrados",
+      "happLinkEnableDesc": "Permite generar enlaces cifrados de Happ en la ventana del código QR del cliente. Las URL de suscripción se procesan localmente.",
       "subEnableRoutingDesc": "Configuración global para habilitar el enrutamiento en el cliente VPN. (Solo para Happ)",
       "subRoutingRules": "Reglas de enrutamiento",
       "subRoutingRulesDesc": "Pegue un enlace happ:// listo o una URL HTTPS permanente. El panel actualiza las reglas remotas en segundo plano y conserva el último valor válido, sin retrasar las solicitudes de suscripción. (Solo para Happ)",
@@ -1566,6 +1581,7 @@
       "subHappProxyIPs": "Proxy IPs / CIDRs",
       "subHappBlockIPs": "Blocked IPs / CIDRs",
       "subHappDeeplinkGenerated": "Deeplink generated and applied to routing rules",
+      "subHappGroupLinks": "Enlaces de suscripción",
       "subHappGroupRouting": "Routing & Rules",
       "subHappGroupBanners": "Banners & Announcements",
       "subHappGroupNetwork": "Network & TUN Engine",

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

@@ -800,6 +800,17 @@
       "clearAllInbounds": "پاک کردن همه",
       "noSubId": "این کلاینت subId ندارد، لینک اشتراک‌گذاری وجود ندارد.",
       "noLinks": "لینکی برای اشتراک‌گذاری نیست — ابتدا این کلاینت را به یک اینباند با پروتکل سازگار متصل کنید.",
+      "qrStandard": "استاندارد",
+      "happLinkRetry": "تلاش مجدد",
+      "happLinkErrorHint": "لینک Happ ایجاد نشد. دوباره تلاش کنید یا برای جزئیات {dashboard} -> {logs} را بررسی کنید.",
+      "happLinkSourceTooLong": "حجم نشانی اشتراک با کدگذاری UTF-8 از سقف پردازش پنل، یعنی 8192 بایت، بیشتر است. نشانی اشتراک را کوتاه کنید یا از گزینه «استاندارد» استفاده کنید.",
+      "happLinkQrTooLong": "این لینک Happ معتبر است، اما برای نمایش به‌صورت کد QR بیش از حد طولانی است. برای استفاده از لینک کامل، «کپی» را انتخاب کنید.",
+      "happLinkDisabledHint": "قبل از استفاده از Happ، ایجاد لینک Happ را در تنظیمات فعال کنید.",
+      "happLinkDisabledTitle": "ایجاد لینک رمزگذاری‌شده Happ فعال نیست",
+      "happLinkDisabledDescription": "ایجاد محلی لینک‌های رمزگذاری‌شده اشتراک Happ را فعال کنید. (فقط برای Happ)",
+      "happLinkSettingsAction": "رفتن به تنظیمات",
+      "happLinkDisclosure": "لینک به‌صورت محلی ایجاد می‌شود. هر کسی که این لینک را داشته باشد ممکن است بتواند نشانی اشتراک را بازیابی کند یا به اشتراک بگذارد.",
+      "happLinkOptionLabel": "لینک رمزگذاری‌شده Happ",
       "link": "لینک",
       "resetNotPossible": "ابتدا این کلاینت را به یک اینباند متصل کنید.",
       "resetAllTraffics": "بازنشانی ترافیک همه کلاینت‌ها",
@@ -1182,6 +1193,8 @@
       "subJsonUserAgentRegexDesc": "عبارت منظم Go RE2 که با عامل کاربر (User-Agent) کلاینت مطابقت داده می‌شود تا صیغه Xray JSON روی آدرس استاندارد اشتراک به‌طور خودکار انتخاب شود. به‌طور پیش‌فرض خالی است، بنابراین تشخیص خودکار غیرفعال می‌ماند تا زمانی که الگویی برای کلاینت‌های موردنظر خود تعیین کنید. سایر کلاینت‌ها پاسخ خام/Base64 را دریافت می‌کنند. پس از تغییر، پنل را راه‌اندازی مجدد کنید.",
       "subJsonRoutingRules": "پروفایل مسیریابی اشتراک JSON",
       "subJsonRoutingRulesDesc": "JSON قواعد مسیریابی، دیپ‌لینک happ:// یا incy://، یا آدرس https://. وقتی تنظیم شود، DNS و قواعد مسیریابی اشتراک JSON را جایگزین می‌کند (در پیکربندی برای کلاینت‌هایی که هدرها را نادیده می‌گیرند تعبیه می‌شود، مانند Happ و INCY با بالانسر سمت کلاینت). اگر تنظیم هدر Happ خالی باشد، همین پروفایل به‌صورت خودکار در هدر Routing ارسال می‌شود (JSON و آدرس‌ها به‌صورت دیپ‌لینک happ://) تا کلاینت‌ها فایل‌های geo منطبق را دانلود کنند.",
+      "subJsonDns": "سرورهای DNS",
+      "subJsonDnsDesc": "بلوک dns را که در کلاینت‌های اشتراک JSON جای می‌گیرد جایگزین می‌کند: یا یک شیء کامل dns یا فقط آرایهٔ servers. همچنین DNS پروفایل مسیریابی را در همان کلاینت‌ها جایگزین می‌کند؛ اما کلاینت‌هایی که هدر Routing را اعمال می‌کنند، ریزالورهای پروفایل را نگه می‌دارند. خالی یعنی ریزالور پیش‌فرض.",
       "subClashAutoDetect": "تشخیص خودکار کلاینت‌های Clash/Mihomo",
       "subClashAutoDetectDesc": "با فعال‌سازی، کلاینت‌های شناخته‌شده Clash/Mihomo که آدرس استاندارد اشتراک را درخواست می‌کنند، به‌طور خودکار پیکربندی Clash با فرمت YAML دریافت می‌کنند. مرورگرها همچنان صفحه اشتراک را نمایش می‌دهند، سایر کلاینت‌ها پاسخ خام/Base64 را دریافت می‌کنند و آدرس‌های صریح JSON و Clash در دسترس می‌مانند. برای اعمال تغییر، اشتراک Clash/Mihomo باید فعال باشد و پنل راه‌اندازی مجدد شود.",
       "subClashUserAgentRegex": "عبارت User-Agent برای Clash/Mihomo",
@@ -1198,6 +1211,8 @@
       "subThemeDirDesc": "مسیر مطلق پوشه‌ای که شامل یک قالب سفارشی (index.html/sub.html) برای صفحه اشتراک است (مثلاً /etc/3x-ui/sub_templates/my-theme/). برای استفاده از صفحه پیش‌فرض خالی بگذارید.",
       "subThemeDirDocs": "راهنمای قالب ↗",
       "subEnableRouting": "فعال‌سازی مسیریابی",
+      "happLinkEnable": "لینک‌های رمزگذاری‌شدهٔ اشتراک",
+      "happLinkEnableDesc": "امکان ایجاد لینک‌های رمزگذاری‌شدهٔ Happ در پنجرهٔ کد QR کلاینت را فراهم می‌کند. نشانی‌های اشتراک به‌صورت محلی پردازش می‌شوند.",
       "subEnableRoutingDesc": "تنظیمات سراسری برای فعال‌سازی مسیریابی در کلاینت VPN. (فقط برای Happ)",
       "subRoutingRules": "قوانین مسیریابی",
       "subRoutingRulesDesc": "یک پیوند آماده happ:// یا یک نشانی دائمی HTTPS وارد کنید. پنل قوانین راه‌دور را در پس‌زمینه به‌روزرسانی و آخرین مقدار معتبر را نگه می‌دارد، بنابراین درخواست اشتراک منتظر منبع نمی‌ماند. (فقط برای Happ)",
@@ -1566,6 +1581,7 @@
       "subHappProxyIPs": "آی‌پی‌های پراکسی (Proxy CIDRs)",
       "subHappBlockIPs": "آی‌پی‌های مسدود (Block CIDRs)",
       "subHappDeeplinkGenerated": "دیپ‌لینک تولید و در قوانین روتینگ اعمال شد",
+      "subHappGroupLinks": "لینک‌های اشتراک",
       "subHappGroupRouting": "قوانین و روتینگ",
       "subHappGroupBanners": "اعلانات و بنرهای هوشمند",
       "subHappGroupNetwork": "تنظیمات شبکه و TUN",

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

@@ -800,6 +800,17 @@
       "clearAllInbounds": "Hapus semua",
       "noSubId": "Klien ini tidak punya subId, tidak ada tautan yang bisa dibagikan.",
       "noLinks": "Tidak ada tautan yang bisa dibagikan — lampirkan klien ini ke inbound yang mendukung protokol terlebih dahulu.",
+      "qrStandard": "Standar",
+      "happLinkRetry": "Coba lagi",
+      "happLinkErrorHint": "Tautan Happ tidak dapat dibuat. Coba lagi, atau periksa {dashboard} -> {logs} untuk detailnya.",
+      "happLinkSourceTooLong": "URL langganan melebihi batas pemrosesan panel sebesar 8192 byte UTF-8. Persingkat URL langganan atau gunakan opsi «Standar».",
+      "happLinkQrTooLong": "Tautan Happ ini valid, tetapi terlalu panjang untuk ditampilkan sebagai kode QR. Gunakan tombol Salin untuk memakai tautan lengkap.",
+      "happLinkDisabledHint": "Aktifkan pembuatan tautan Happ di Pengaturan sebelum menggunakan Happ.",
+      "happLinkDisabledTitle": "Pembuatan tautan terenkripsi Happ belum diaktifkan",
+      "happLinkDisabledDescription": "Aktifkan pembuatan tautan langganan Happ terenkripsi secara lokal. (Hanya untuk Happ)",
+      "happLinkSettingsAction": "Buka Pengaturan",
+      "happLinkDisclosure": "Tautan dibuat secara lokal. Siapa pun yang memiliki tautan ini mungkin dapat memulihkan atau membagikan URL langganan.",
+      "happLinkOptionLabel": "Tautan terenkripsi Happ",
       "link": "Tautan",
       "resetNotPossible": "Lampirkan klien ini ke inbound terlebih dahulu.",
       "resetAllTraffics": "Reset lalu lintas semua klien",
@@ -1178,6 +1189,8 @@
       "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.",
       "subJsonRoutingRules": "Profil routing langganan JSON",
       "subJsonRoutingRulesDesc": "JSON routing, deeplink happ:// atau incy://, atau URL https://. Jika diisi, menggantikan DNS dan aturan routing langganan JSON (dibake ke dalam konfigurasi untuk klien yang mengabaikan header, mis. Happ dan INCY dengan balancer sisi klien). Jika pengaturan header Happ kosong, profil yang sama otomatis dikirim sebagai header Routing (JSON dan URL sebagai deeplink happ://) agar klien mengunduh berkas geo yang cocok.",
+      "subJsonDns": "Server DNS",
+      "subJsonDnsDesc": "Mengganti blok dns di semua konfigurasi langganan JSON: objek dns lengkap atau hanya array servers. Juga menggantikan DNS profil perutean di konfigurasi tersebut; klien yang menerapkan header Routing tetap memakai resolver profil. Kosongkan untuk memakai resolver bawaan.",
       "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",
@@ -1194,6 +1207,8 @@
       "subThemeDirDesc": "Path absolut ke folder yang berisi template kustom (index.html/sub.html) untuk halaman langganan (mis. /etc/3x-ui/sub_templates/my-theme/). Biarkan kosong untuk menggunakan halaman default.",
       "subThemeDirDocs": "Panduan templat ↗",
       "subEnableRouting": "Aktifkan perutean",
+      "happLinkEnable": "Tautan langganan terenkripsi",
+      "happLinkEnableDesc": "Izinkan pembuatan tautan Happ terenkripsi di jendela kode QR klien. URL langganan diproses secara lokal.",
       "subEnableRoutingDesc": "Pengaturan global untuk mengaktifkan perutean (routing) di klien VPN. (Hanya untuk Happ)",
       "subRoutingRules": "Aturan routing",
       "subRoutingRulesDesc": "Tempel deeplink happ:// siap pakai atau satu URL HTTPS permanen. Panel memperbarui aturan jarak jauh di latar belakang dan menyimpan nilai valid terakhir, sehingga permintaan langganan tidak menunggu sumber. (Hanya untuk Happ)",
@@ -1566,6 +1581,7 @@
       "subHappProxyIPs": "Proxy IPs / CIDRs",
       "subHappBlockIPs": "Blocked IPs / CIDRs",
       "subHappDeeplinkGenerated": "Deeplink generated and applied to routing rules",
+      "subHappGroupLinks": "Tautan Langganan",
       "subHappGroupRouting": "Routing & Rules",
       "subHappGroupBanners": "Banners & Announcements",
       "subHappGroupNetwork": "Network & TUN Engine",

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

@@ -800,6 +800,17 @@
       "clearAllInbounds": "すべてクリア",
       "noSubId": "このクライアントには subId がなく、共有可能なリンクはありません。",
       "noLinks": "共有可能なリンクがありません — まずこのクライアントを対応するプロトコルのインバウンドに関連付けてください。",
+      "qrStandard": "標準",
+      "happLinkRetry": "再試行",
+      "happLinkErrorHint": "Happ リンクを生成できませんでした。再試行するか、詳細は {dashboard} -> {logs} を確認してください。",
+      "happLinkSourceTooLong": "サブスクリプション URL がこのパネルの処理上限(UTF-8 で 8192 バイト)を超えています。URL を短くするか、「標準」を使用してください。",
+      "happLinkQrTooLong": "この Happ リンクは有効ですが、長すぎて QR コードとして表示できません。完全なリンクを使用するには、「コピー」を使ってください。",
+      "happLinkDisabledHint": "Happ を使用する前に、設定で Happ リンクの生成を有効にしてください。",
+      "happLinkDisabledTitle": "Happ 暗号化リンクの生成が有効になっていません",
+      "happLinkDisabledDescription": "暗号化された Happ のサブスクリプションリンクをローカルで生成する機能を有効にします。(Happ のみ)",
+      "happLinkSettingsAction": "設定を開く",
+      "happLinkDisclosure": "リンクはローカルで生成されます。このリンクを持つ人は、サブスクリプション URL を復元したり共有したりできる可能性があります。",
+      "happLinkOptionLabel": "Happ 暗号化リンク",
       "link": "リンク",
       "resetNotPossible": "まずこのクライアントをインバウンドに関連付けてください。",
       "resetAllTraffics": "すべてのクライアントのトラフィックをリセット",
@@ -1178,6 +1189,8 @@
       "subJsonUserAgentRegexDesc": "標準サブスクリプション URL で Xray JSON 形式を自動選択するため、クライアントの User-Agent と照合する Go RE2 正規表現です。既定では空欄のため、対象とするクライアント向けのパターンを設定するまで自動判別は無効のままです。その他のクライアントには従来の raw/Base64 応答を返します。変更後にパネルを再起動してください。",
       "subJsonRoutingRules": "JSON サブスクリプションのルーティングプロファイル",
       "subJsonRoutingRulesDesc": "ルーティング JSON、happ:// または incy:// のディープリンク、もしくは https:// URL。設定すると JSON サブスクリプションの DNS とルーティングルールを置き換えます(ヘッダーを無視するクライアント、例えばクライアントサイドバランサーを使う Happ や INCY 向けに設定へ組み込まれます)。Happ のルーティングヘッダー設定が空の場合、同じプロファイルが自動的に Routing ヘッダーとして送信され(JSON と URL は happ:// ディープリンクに変換)、クライアントは対応する geo ファイルを取得します。",
+      "subJsonDns": "DNSサーバー",
+      "subJsonDnsDesc": "JSONサブスクリプションの各設定に埋め込まれる dns ブロックを置き換えます。完全な dns オブジェクトか servers 配列のみを指定できます。設定内のルーティングプロファイルのDNSも置き換えますが、Routing ヘッダーを適用するクライアントはプロファイルのリゾルバーを使います。空欄の場合は組み込みのリゾルバーを使用します。",
       "subClashAutoDetect": "Clash/Mihomo クライアントを自動検出",
       "subClashAutoDetectDesc": "有効にすると、標準サブスクリプション URL を要求する既知の Clash/Mihomo クライアントへ Clash YAML を自動的に返します。ブラウザには引き続きサブスクリプションページを表示し、その他のクライアントには従来の raw/Base64 応答を返します。明示的な JSON および Clash URL も引き続き利用できます。Clash/Mihomo サブスクリプションを有効にし、適用のためにパネルを再起動する必要があります。",
       "subClashUserAgentRegex": "Clash/Mihomo User-Agent 正規表現",
@@ -1194,6 +1207,8 @@
       "subThemeDirDesc": "サブスクリプションページのカスタムテンプレート (index.html/sub.html) を含むフォルダーの絶対パス(例: /etc/3x-ui/sub_templates/my-theme/)。空欄の場合はデフォルトのページを使用します。",
       "subThemeDirDocs": "テンプレートガイド ↗",
       "subEnableRouting": "ルーティングを有効化",
+      "happLinkEnable": "暗号化されたサブスクリプションリンク",
+      "happLinkEnableDesc": "クライアントの QR コード画面で、暗号化された Happ リンクを生成できるようにします。サブスクリプション URL はローカルで処理されます。",
       "subEnableRoutingDesc": "VPNクライアントでルーティングを有効にするためのグローバル設定。(Happのみ)",
       "subRoutingRules": "ルーティングルール",
       "subRoutingRulesDesc": "完成した happ:// ディープリンク、または永続的な HTTPS URL を入力します。パネルはリモートルールをバックグラウンドで更新し、最後の有効値を保持するため、サブスクリプション要求は取得を待ちません。(Happのみ)",
@@ -1566,6 +1581,7 @@
       "subHappProxyIPs": "Proxy IPs / CIDRs",
       "subHappBlockIPs": "Blocked IPs / CIDRs",
       "subHappDeeplinkGenerated": "Deeplink generated and applied to routing rules",
+      "subHappGroupLinks": "サブスクリプションリンク",
       "subHappGroupRouting": "Routing & Rules",
       "subHappGroupBanners": "Banners & Announcements",
       "subHappGroupNetwork": "Network & TUN Engine",

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

@@ -800,6 +800,17 @@
       "clearAllInbounds": "Limpar tudo",
       "noSubId": "Este cliente não tem subId, sem link compartilhável.",
       "noLinks": "Sem links compartilháveis — associe primeiro este cliente a um inbound compatível com o protocolo.",
+      "qrStandard": "Padrão",
+      "happLinkRetry": "Tentar novamente",
+      "happLinkErrorHint": "Não foi possível gerar o link do Happ. Tente novamente ou consulte {dashboard} -> {logs} para obter detalhes.",
+      "happLinkSourceTooLong": "A URL da assinatura excede o limite de processamento do painel de 8192 bytes UTF-8. Encurte a URL da assinatura ou use a opção «Padrão».",
+      "happLinkQrTooLong": "Este link do Happ é válido, mas é longo demais para ser exibido como código QR. Use “Copiar” para usar o link completo.",
+      "happLinkDisabledHint": "Ative a geração de links do Happ em Configurações antes de usar o Happ.",
+      "happLinkDisabledTitle": "A geração de links criptografados do Happ não está ativada",
+      "happLinkDisabledDescription": "Ative a geração local de links criptografados de assinatura do Happ. (Apenas para Happ)",
+      "happLinkSettingsAction": "Ir para Configurações",
+      "happLinkDisclosure": "O link é gerado localmente. Qualquer pessoa com este link pode conseguir recuperar ou compartilhar a URL da assinatura.",
+      "happLinkOptionLabel": "Link criptografado do Happ",
       "link": "Link",
       "resetNotPossible": "Associe primeiro este cliente a um inbound.",
       "resetAllTraffics": "Redefinir o tráfego de todos os clientes",
@@ -1178,6 +1189,8 @@
       "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.",
       "subJsonRoutingRules": "Perfil de roteamento da assinatura JSON",
       "subJsonRoutingRulesDesc": "JSON de roteamento, deeplink happ:// ou incy://, ou URL https://. Quando definido, substitui o DNS e as regras de roteamento da assinatura JSON (embutidos na configuração para clientes que ignoram cabeçalhos, ex.: Happ e INCY com balanceadores no lado do cliente). Se a configuração de cabeçalho do Happ estiver vazia, o mesmo perfil é enviado automaticamente como cabeçalho Routing (JSON e URLs como deeplink happ://) para que os clientes baixem os arquivos geo correspondentes.",
+      "subJsonDns": "Servidores DNS",
+      "subJsonDnsDesc": "Substitui o bloco dns de todas as configurações da assinatura JSON: um objeto dns completo ou apenas o array servers. Também substitui o DNS do perfil de roteamento nessas configurações; clientes que aplicam o cabeçalho Routing mantêm os resolvedores do perfil. Vazio mantém o resolvedor integrado.",
       "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",
@@ -1194,6 +1207,8 @@
       "subThemeDirDesc": "Caminho absoluto para uma pasta contendo um modelo personalizado (index.html/sub.html) para a página de assinatura (ex.: /etc/3x-ui/sub_templates/my-theme/). Deixe vazio para usar a página padrão.",
       "subThemeDirDocs": "Guia de modelos ↗",
       "subEnableRouting": "Ativar roteamento",
+      "happLinkEnable": "Links de assinatura criptografados",
+      "happLinkEnableDesc": "Permite gerar links criptografados do Happ na janela do código QR do cliente. As URLs de assinatura são processadas localmente.",
       "subEnableRoutingDesc": "Configuração global para habilitar o roteamento no cliente VPN. (Apenas para Happ)",
       "subRoutingRules": "Regras de roteamento",
       "subRoutingRulesDesc": "Cole um deeplink happ:// pronto ou uma URL HTTPS permanente. O painel atualiza as regras remotas em segundo plano e mantém o último valor válido, sem atrasar as solicitações de assinatura. (Apenas para Happ)",
@@ -1566,6 +1581,7 @@
       "subHappProxyIPs": "Proxy IPs / CIDRs",
       "subHappBlockIPs": "Blocked IPs / CIDRs",
       "subHappDeeplinkGenerated": "Deeplink generated and applied to routing rules",
+      "subHappGroupLinks": "Links de assinatura",
       "subHappGroupRouting": "Routing & Rules",
       "subHappGroupBanners": "Banners & Announcements",
       "subHappGroupNetwork": "Network & TUN Engine",

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

@@ -800,6 +800,17 @@
       "clearAllInbounds": "Очистить всё",
       "noSubId": "У этого клиента нет subId, ссылка для общего доступа недоступна.",
       "noLinks": "Нет ссылок для общего доступа — сначала привяжите клиента к входящему с поддерживаемым протоколом.",
+      "qrStandard": "Стандартный",
+      "happLinkRetry": "Повторить",
+      "happLinkErrorHint": "Не удалось создать ссылку Happ. Повторите попытку или откройте {dashboard} -> {logs}, чтобы узнать подробности.",
+      "happLinkSourceTooLong": "URL подписки превышает лимит обработки панели в 8192 байта UTF-8. Сократите URL подписки или используйте вариант «Стандартный».",
+      "happLinkQrTooLong": "Эта ссылка Happ действительна, но она слишком длинная для отображения в виде QR-кода. Нажмите «Копировать», чтобы использовать полную ссылку.",
+      "happLinkDisabledHint": "Перед использованием Happ включите создание ссылок Happ в настройках.",
+      "happLinkDisabledTitle": "Создание зашифрованных ссылок Happ не включено",
+      "happLinkDisabledDescription": "Включите локальное создание зашифрованных ссылок подписки Happ. (Только для Happ)",
+      "happLinkSettingsAction": "Перейти к настройкам",
+      "happLinkDisclosure": "Ссылка создаётся локально. Любой, у кого есть эта ссылка, может восстановить URL подписки или поделиться им.",
+      "happLinkOptionLabel": "Зашифрованная ссылка Happ",
       "link": "Ссылка",
       "resetNotPossible": "Сначала привяжите этого клиента к входящему.",
       "resetAllTraffics": "Сбросить трафик всех клиентов",
@@ -1178,6 +1189,8 @@
       "subJsonUserAgentRegexDesc": "Регулярное выражение Go RE2, сопоставляемое с User-Agent клиента для автоматического выбора формата Xray JSON на стандартном URL подписки. По умолчанию пустое, поэтому автоопределение отключено, пока вы не зададите шаблон для нужных клиентов. Остальные клиенты получают ответ raw/Base64. После изменения перезапустите панель.",
       "subJsonRoutingRules": "Профиль маршрутизации для JSON-подписки",
       "subJsonRoutingRulesDesc": "JSON правил маршрутизации, диплинк happ:// или incy://, либо https:// URL. Если задан, заменяет DNS и правила маршрутизации JSON-подписки (правила встраиваются в конфиг для клиентов, игнорирующих заголовки, например Happ и INCY с клиентскими балансировщиками). Если настройка заголовка Happ пуста, этот же профиль автоматически отправляется в заголовке Routing (JSON и URL — в виде диплинка happ://), чтобы клиенты скачивали подходящие geo-файлы.",
+      "subJsonDns": "DNS-серверы",
+      "subJsonDnsDesc": "Заменяет блок dns, встраиваемый в конфиги JSON-подписки: полный объект dns или просто массив servers. Также заменяет DNS профиля маршрутизации в этих конфигах; клиенты, применяющие заголовок Routing, используют резолверы из профиля. Пусто — встроенный резолвер.",
       "subClashAutoDetect": "Автоматически определять клиентов Clash/Mihomo",
       "subClashAutoDetectDesc": "Если параметр включён, распознанные клиенты Clash/Mihomo при запросе стандартного URL подписки автоматически получают Clash YAML. Браузеры по-прежнему показывают страницу подписки, остальные клиенты получают прежний ответ raw/Base64, а явные URL JSON и Clash остаются доступными. Для применения требуется включить подписку Clash/Mihomo и перезапустить панель.",
       "subClashUserAgentRegex": "Регулярное выражение User-Agent Clash/Mihomo",
@@ -1194,6 +1207,8 @@
       "subThemeDirDesc": "Абсолютный путь к папке с пользовательским шаблоном (index.html/sub.html) для страницы подписки (например, /etc/3x-ui/sub_templates/my-theme/). Оставьте пустым, чтобы использовать страницу по умолчанию.",
       "subThemeDirDocs": "Руководство по шаблонам ↗",
       "subEnableRouting": "Включить маршрутизацию",
+      "happLinkEnable": "Зашифрованные ссылки подписки",
+      "happLinkEnableDesc": "Разрешает создавать зашифрованные ссылки Happ в окне QR-кода клиента. Адреса подписок обрабатываются локально.",
       "subEnableRoutingDesc": "Глобальная настройка для включения маршрутизации в VPN-клиенте. (Только для Happ)",
       "subRoutingRules": "Правила маршрутизации",
       "subRoutingRulesDesc": "Вставьте готовый happ:// deeplink либо одну постоянную HTTPS-ссылку на deeplink или JSON. Панель обновляет удалённые правила в фоне и хранит последнее рабочее значение, поэтому запрос подписки не ждёт источник. (Только для Happ)",
@@ -1566,6 +1581,7 @@
       "subHappProxyIPs": "Проксируемые IP / CIDR",
       "subHappBlockIPs": "Заблокированные IP / CIDR",
       "subHappDeeplinkGenerated": "Диплинк сгенерирован и применен к правилам маршрутизации",
+      "subHappGroupLinks": "Ссылки подписки",
       "subHappGroupRouting": "Маршрутизация и правила",
       "subHappGroupBanners": "Баннеры и уведомления",
       "subHappGroupNetwork": "Сетевые настройки и TUN",

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

@@ -800,6 +800,17 @@
       "clearAllInbounds": "Tümünü Temizle",
       "noSubId": "Bu kullanıcının subId'si yok, dolayısıyla paylaşılabilir bir bağlantısı bulunmuyor.",
       "noLinks": "Paylaşılabilir bağlantı yok — önce bu kullanıcıyı bir protokole sahip olan gelen bağlantıya bağlayın.",
+      "qrStandard": "Standart",
+      "happLinkRetry": "Yeniden dene",
+      "happLinkErrorHint": "Happ bağlantısı oluşturulamadı. Yeniden deneyin veya ayrıntılar için {dashboard} -> {logs} bölümünü kontrol edin.",
+      "happLinkSourceTooLong": "Abonelik URL’si panelin 8192 UTF-8 baytlık işleme sınırını aşıyor. Abonelik URL’sini kısaltın veya «Standart» seçeneğini kullanın.",
+      "happLinkQrTooLong": "Bu Happ bağlantısı geçerli, ancak QR kodu olarak görüntülenemeyecek kadar uzun. Bağlantının tamamını kullanmak için Kopyala'yı kullanın.",
+      "happLinkDisabledHint": "Happ'i kullanmadan önce Ayarlar'dan Happ bağlantısı oluşturmayı etkinleştirin.",
+      "happLinkDisabledTitle": "Happ şifreli bağlantı oluşturma etkin değil",
+      "happLinkDisabledDescription": "Şifreli Happ abonelik bağlantılarının yerel olarak oluşturulmasını etkinleştirin. (Yalnızca Happ için)",
+      "happLinkSettingsAction": "Ayarlara git",
+      "happLinkDisclosure": "Bağlantı yerel olarak oluşturulur. Bu bağlantıya sahip olan herkes abonelik URL’sini geri elde edebilir veya paylaşabilir.",
+      "happLinkOptionLabel": "Happ şifreli bağlantısı",
       "link": "Bağlantı",
       "resetNotPossible": "Önce bu kullanıcıyı bir gelen bağlantıya bağlayın.",
       "resetAllTraffics": "Tüm Kullanıcıların Trafiğini Sıfırla",
@@ -1178,6 +1189,8 @@
       "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.",
       "subJsonRoutingRules": "JSON aboneliği yönlendirme profili",
       "subJsonRoutingRulesDesc": "Yönlendirme JSON'u, happ:// veya incy:// deeplink'i ya da https:// URL'si. Ayarlandığında JSON aboneliğinin DNS ve yönlendirme kurallarını değiştirir (başlıkları yok sayan istemciler için yapılandırmaya gömülür, örn. istemci tarafı dengeleyicileri olan Happ ve INCY). Happ yönlendirme başlığı ayarı boşsa, aynı profil otomatik olarak Routing başlığı olarak gönderilir (JSON ve URL'ler happ:// deeplink'i olarak) ve istemciler eşleşen geo dosyalarını indirir.",
+      "subJsonDns": "DNS sunucuları",
+      "subJsonDnsDesc": "JSON abonelik yapılandırmalarındaki dns bloğunu değiştirir: tam bir dns nesnesi veya yalnızca servers dizisi. Bu yapılandırmalarda yönlendirme profilinin DNS'ini de değiştirir; Routing başlığını uygulayan istemciler ise profilin çözümleyicilerini kullanır. Boş bırakılırsa yerleşik çözümleyici kullanılır.",
       "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",
@@ -1194,6 +1207,8 @@
       "subThemeDirDesc": "Abonelik sayfası için özel bir şablon (index.html/sub.html) içeren klasörün mutlak yolu (örn. /etc/3x-ui/sub_templates/my-theme/). Varsayılan sayfayı kullanmak için boş bırakın.",
       "subThemeDirDocs": "Şablon kılavuzu ↗",
       "subEnableRouting": "Yönlendirmeyi etkinleştir",
+      "happLinkEnable": "Şifreli abonelik bağlantıları",
+      "happLinkEnableDesc": "İstemcinin QR kodu penceresinde şifreli Happ bağlantıları oluşturulmasına izin verir. Abonelik URL’leri yerel olarak işlenir.",
       "subEnableRoutingDesc": "VPN istemcisinde yönlendirmeyi etkinleştirmek için genel ayar. (Yalnızca Happ için)",
       "subRoutingRules": "Yönlendirme kuralları",
       "subRoutingRulesDesc": "Hazır bir happ:// derin bağlantısı veya kalıcı bir HTTPS URL'si yapıştırın. Panel uzak kuralları arka planda yeniler ve son geçerli değeri saklar; abonelik istekleri kaynağı beklemez. (Yalnızca Happ için)",
@@ -1566,6 +1581,7 @@
       "subHappProxyIPs": "Proxy IPs / CIDRs",
       "subHappBlockIPs": "Blocked IPs / CIDRs",
       "subHappDeeplinkGenerated": "Deeplink generated and applied to routing rules",
+      "subHappGroupLinks": "Abonelik Bağlantıları",
       "subHappGroupRouting": "Routing & Rules",
       "subHappGroupBanners": "Banners & Announcements",
       "subHappGroupNetwork": "Network & TUN Engine",

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

@@ -800,6 +800,17 @@
       "clearAllInbounds": "Очистити все",
       "noSubId": "У цього клієнта немає subId, посилання для спільного доступу відсутнє.",
       "noLinks": "Немає посилань для спільного доступу — спочатку прив'яжіть цього клієнта до вхідного з підтримкою протоколу.",
+      "qrStandard": "Стандартний",
+      "happLinkRetry": "Повторити",
+      "happLinkErrorHint": "Не вдалося створити посилання Happ. Повторіть спробу або відкрийте {dashboard} -> {logs}, щоб переглянути подробиці.",
+      "happLinkSourceTooLong": "URL-адреса підписки перевищує ліміт обробки панелі в 8192 байти UTF-8. Скоротіть URL-адресу підписки або використайте варіант «Стандартний».",
+      "happLinkQrTooLong": "Це посилання Happ дійсне, але воно надто довге для відображення у вигляді QR-коду. Натисніть «Копіювати», щоб скористатися повним посиланням.",
+      "happLinkDisabledHint": "Перш ніж використовувати Happ, увімкніть створення посилань Happ у налаштуваннях.",
+      "happLinkDisabledTitle": "Створення зашифрованих посилань Happ не ввімкнено",
+      "happLinkDisabledDescription": "Увімкніть локальне створення зашифрованих посилань на підписку Happ. (Тільки для Happ)",
+      "happLinkSettingsAction": "Перейти до налаштувань",
+      "happLinkDisclosure": "Посилання створюється локально. Кожен, хто має це посилання, може відновити URL-адресу підписки або поділитися нею.",
+      "happLinkOptionLabel": "Зашифроване посилання Happ",
       "link": "Посилання",
       "resetNotPossible": "Спочатку прив'яжіть цього клієнта до вхідного.",
       "resetAllTraffics": "Скинути трафік усіх клієнтів",
@@ -1178,6 +1189,8 @@
       "subJsonUserAgentRegexDesc": "Регулярний вираз Go RE2, який зіставляється з User-Agent клієнта для автоматичного вибору формату Xray JSON на стандартній URL-адресі підписки. Типово порожнє, тому автоматичне визначення вимкнено, доки ви не задасте шаблон для потрібних клієнтів. Інші клієнти отримують відповідь raw/Base64. Після зміни перезапустіть панель.",
       "subJsonRoutingRules": "Профіль маршрутизації для JSON-підписки",
       "subJsonRoutingRulesDesc": "JSON правил маршрутизації, діплінк happ:// або incy://, або https:// URL. Якщо задано, замінює DNS і правила маршрутизації JSON-підписки (вбудовуються в конфігурацію для клієнтів, що ігнорують заголовки, напр. Happ і INCY з клієнтськими балансувальниками). Якщо налаштування заголовка Happ порожнє, цей самий профіль автоматично надсилається в заголовку Routing (JSON і URL — у вигляді діплінка happ://), щоб клієнти завантажували відповідні geo-файли.",
+      "subJsonDns": "DNS-сервери",
+      "subJsonDnsDesc": "Замінює блок dns, що вбудовується в конфіги JSON-підписки: повний об'єкт dns або просто масив servers. Також замінює DNS профілю маршрутизації в цих конфігах; клієнти, які застосовують заголовок Routing, використовують резолвери з профілю. Порожнє — вбудований резолвер.",
       "subClashAutoDetect": "Автоматично визначати клієнтів Clash/Mihomo",
       "subClashAutoDetectDesc": "Якщо параметр увімкнено, розпізнані клієнти Clash/Mihomo під час запиту стандартної URL-адреси підписки автоматично отримують Clash YAML. Браузери й надалі показують сторінку підписки, інші клієнти отримують звичайну відповідь raw/Base64, а явні URL-адреси JSON і Clash залишаються доступними. Для застосування потрібно ввімкнути підписку Clash/Mihomo та перезапустити панель.",
       "subClashUserAgentRegex": "Регулярний вираз User-Agent Clash/Mihomo",
@@ -1194,6 +1207,8 @@
       "subThemeDirDesc": "Абсолютний шлях до теки з користувацьким шаблоном (index.html/sub.html) для сторінки підписки (наприклад, /etc/3x-ui/sub_templates/my-theme/). Залиште порожнім, щоб використовувати сторінку за замовчуванням.",
       "subThemeDirDocs": "Посібник із шаблонів ↗",
       "subEnableRouting": "Увімкнути маршрутизацію",
+      "happLinkEnable": "Зашифровані посилання на підписку",
+      "happLinkEnableDesc": "Дозволяє створювати зашифровані посилання Happ у вікні QR-коду клієнта. Адреси підписок обробляються локально.",
       "subEnableRoutingDesc": "Глобальне налаштування для увімкнення маршрутизації у VPN-клієнті. (Тільки для Happ)",
       "subRoutingRules": "Правила маршрутизації",
       "subRoutingRulesDesc": "Вставте готове посилання happ:// або одну постійну HTTPS-адресу. Панель оновлює віддалені правила у фоні та зберігає останнє коректне значення, тому запит підписки не чекає на джерело. (Тільки для Happ)",
@@ -1566,6 +1581,7 @@
       "subHappProxyIPs": "Proxy IPs / CIDRs",
       "subHappBlockIPs": "Blocked IPs / CIDRs",
       "subHappDeeplinkGenerated": "Deeplink generated and applied to routing rules",
+      "subHappGroupLinks": "Посилання на підписку",
       "subHappGroupRouting": "Routing & Rules",
       "subHappGroupBanners": "Banners & Announcements",
       "subHappGroupNetwork": "Network & TUN Engine",

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

@@ -800,6 +800,17 @@
       "clearAllInbounds": "Xóa tất cả",
       "noSubId": "Khách hàng này không có subId, không có liên kết chia sẻ.",
       "noLinks": "Không có liên kết chia sẻ — hãy gắn khách hàng này vào một inbound có giao thức tương thích trước.",
+      "qrStandard": "Tiêu chuẩn",
+      "happLinkRetry": "Thử lại",
+      "happLinkErrorHint": "Không thể tạo liên kết Happ. Hãy thử lại hoặc kiểm tra {dashboard} -> {logs} để biết chi tiết.",
+      "happLinkSourceTooLong": "URL đăng ký vượt quá giới hạn xử lý 8192 byte UTF-8 của bảng điều khiển. Hãy rút ngắn URL đăng ký hoặc sử dụng tùy chọn «Tiêu chuẩn».",
+      "happLinkQrTooLong": "Liên kết Happ này hợp lệ nhưng quá dài để hiển thị dưới dạng mã QR. Hãy dùng nút Sao chép để sử dụng liên kết đầy đủ.",
+      "happLinkDisabledHint": "Hãy bật tính năng tạo liên kết Happ trong Cài đặt trước khi sử dụng Happ.",
+      "happLinkDisabledTitle": "Tính năng tạo liên kết Happ được mã hóa chưa được bật",
+      "happLinkDisabledDescription": "Bật tính năng tạo liên kết đăng ký Happ được mã hóa ngay trên hệ thống cục bộ. (Chỉ dành cho Happ)",
+      "happLinkSettingsAction": "Đi tới Cài đặt",
+      "happLinkDisclosure": "Liên kết được tạo cục bộ. Bất kỳ ai có liên kết này đều có thể khôi phục hoặc chia sẻ URL đăng ký.",
+      "happLinkOptionLabel": "Liên kết Happ được mã hóa",
       "link": "Liên kết",
       "resetNotPossible": "Hãy gắn khách hàng này vào một inbound trước.",
       "resetAllTraffics": "Đặt lại lưu lượng của tất cả khách hàng",
@@ -1178,6 +1189,8 @@
       "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.",
       "subJsonRoutingRules": "Hồ sơ định tuyến cho đăng ký JSON",
       "subJsonRoutingRulesDesc": "JSON định tuyến, deeplink happ:// hoặc incy://, hoặc URL https://. Khi được đặt, nó thay thế DNS và quy tắc định tuyến của đăng ký JSON (được nhúng vào cấu hình cho các client bỏ qua header, ví dụ Happ và INCY với bộ cân bằng phía client). Nếu cài đặt header của Happ để trống, chính hồ sơ đó sẽ được tự động gửi dưới dạng header Routing (JSON và URL thành deeplink happ://) để client tải các tệp geo khớp.",
+      "subJsonDns": "Máy chủ DNS",
+      "subJsonDnsDesc": "Thay thế khối dns trong mọi cấu hình đăng ký JSON: đối tượng dns đầy đủ hoặc chỉ mảng servers. Cũng thay thế DNS của hồ sơ định tuyến trong các cấu hình đó; ứng dụng áp dụng tiêu đề Routing vẫn dùng bộ phân giải của hồ sơ. Để trống sẽ giữ trình phân giải tích hợp.",
       "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",
@@ -1194,6 +1207,8 @@
       "subThemeDirDesc": "Đường dẫn tuyệt đối đến thư mục chứa mẫu tùy chỉnh (index.html/sub.html) cho trang đăng ký (ví dụ: /etc/3x-ui/sub_templates/my-theme/). Để trống để dùng trang mặc định.",
       "subThemeDirDocs": "Hướng dẫn mẫu ↗",
       "subEnableRouting": "Bật định tuyến",
+      "happLinkEnable": "Liên kết đăng ký được mã hóa",
+      "happLinkEnableDesc": "Cho phép tạo liên kết Happ được mã hóa trong cửa sổ mã QR của khách hàng. URL đăng ký được xử lý cục bộ.",
       "subEnableRoutingDesc": "Cài đặt toàn cục để bật định tuyến trong ứng dụng khách VPN. (Chỉ dành cho Happ)",
       "subRoutingRules": "Quy tắc định tuyến",
       "subRoutingRulesDesc": "Dán deeplink happ:// có sẵn hoặc một URL HTTPS cố định. Bảng điều khiển cập nhật quy tắc từ xa trong nền và giữ giá trị hợp lệ gần nhất, nên yêu cầu đăng ký không phải chờ nguồn. (Chỉ dành cho Happ)",
@@ -1566,6 +1581,7 @@
       "subHappProxyIPs": "Proxy IPs / CIDRs",
       "subHappBlockIPs": "Blocked IPs / CIDRs",
       "subHappDeeplinkGenerated": "Deeplink generated and applied to routing rules",
+      "subHappGroupLinks": "Liên kết đăng ký",
       "subHappGroupRouting": "Routing & Rules",
       "subHappGroupBanners": "Banners & Announcements",
       "subHappGroupNetwork": "Network & TUN Engine",

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

@@ -800,6 +800,17 @@
       "clearAllInbounds": "全部清除",
       "noSubId": "该客户端没有 subId,无法生成共享链接。",
       "noLinks": "没有可共享的链接 — 请先将此客户端关联到支持协议的入站。",
+      "qrStandard": "标准",
+      "happLinkRetry": "重试",
+      "happLinkErrorHint": "无法生成 Happ 链接。请重试,或前往 {dashboard} -> {logs} 查看详情。",
+      "happLinkSourceTooLong": "订阅地址超过面板的 8192 字节(UTF-8)处理上限。请缩短订阅地址,或使用“标准”普通订阅。",
+      "happLinkQrTooLong": "此 Happ 链接有效,但过长,无法显示为二维码。请点击“复制”以使用完整链接。",
+      "happLinkDisabledHint": "请先在设置中启用 Happ 链接生成,然后再使用 Happ。",
+      "happLinkDisabledTitle": "Happ 加密链接生成未启用",
+      "happLinkDisabledDescription": "启用后,可在本地生成加密的 Happ 订阅链接。(仅限 Happ)",
+      "happLinkSettingsAction": "前往设置",
+      "happLinkDisclosure": "链接在本地生成。持有此链接的人可能还原或分享订阅地址。",
+      "happLinkOptionLabel": "Happ 加密链接",
       "link": "链接",
       "resetNotPossible": "请先将此客户端关联到入站。",
       "resetAllTraffics": "重置所有客户端流量",
@@ -1178,6 +1189,8 @@
       "subJsonUserAgentRegexDesc": "用于与客户端 User-Agent 进行匹配,从而在标准订阅 URL 上自动选择 Xray JSON 格式的 Go RE2 正则表达式。默认留空,因此在为需要服务的客户端设置规则之前,自动识别保持关闭。其他客户端继续获得原始/Base64 响应。更改后请重启面板。",
       "subJsonRoutingRules": "JSON 订阅路由配置",
       "subJsonRoutingRulesDesc": "路由 JSON、happ:// 或 incy:// 深度链接,或 https:// URL。设置后,将替换 JSON 订阅的 DNS 和路由规则(为忽略响应头的客户端直接嵌入配置,例如使用客户端侧负载均衡的 Happ 和 INCY)。若 Happ 路由响应头设置为空,同一配置会自动作为 Routing 响应头发送(内联 JSON 和 URL 转换为 happ:// 深度链接),以便客户端下载匹配的 geo 文件。",
+      "subJsonDns": "DNS 服务器",
+      "subJsonDnsDesc": "替换写入每个 JSON 订阅配置的 dns 块:可以是完整的 dns 对象,也可以只是 servers 数组。这些配置中路由配置文件的 DNS 也会被替换;但应用 Routing 头的客户端仍使用配置文件中的解析器。留空则使用内置解析器。",
       "subClashAutoDetect": "自动识别 Clash/Mihomo 客户端",
       "subClashAutoDetectDesc": "启用后,使用标准订阅 URL 的已识别 Clash/Mihomo 客户端将自动获得 Clash YAML。浏览器仍显示订阅页面,其他客户端继续获得原始/Base64 响应,独立的 JSON 和 Clash URL 仍然可用。需要启用 Clash/Mihomo 订阅并重启面板才能生效。",
       "subClashUserAgentRegex": "Clash/Mihomo User-Agent 正则表达式",
@@ -1194,6 +1207,8 @@
       "subThemeDirDesc": "包含自定义订阅页面模板 (index.html/sub.html) 的文件夹的绝对路径(例如 /etc/3x-ui/sub_templates/my-theme/)。留空则使用默认页面。",
       "subThemeDirDocs": "模板指南 ↗",
       "subEnableRouting": "启用路由",
+      "happLinkEnable": "加密订阅链接",
+      "happLinkEnableDesc": "允许在客户端二维码窗口生成 Happ 加密链接,订阅地址在本地处理。",
       "subEnableRoutingDesc": "在 VPN 客户端中启用路由的全局设置。(仅限 Happ)",
       "subRoutingRules": "路由规则",
       "subRoutingRulesDesc": "粘贴现成的 happ:// 深层链接或一个固定 HTTPS URL。面板会在后台更新远程规则并保留最后一个有效值,因此订阅请求无需等待远程源。(仅限 Happ)",
@@ -1566,6 +1581,7 @@
       "subHappProxyIPs": "代理 IP / CIDR",
       "subHappBlockIPs": "阻止 IP / CIDR",
       "subHappDeeplinkGenerated": "DeepLink 已生成并填入路由规则",
+      "subHappGroupLinks": "订阅链接",
       "subHappGroupRouting": "路由分流与规则",
       "subHappGroupBanners": "横幅公告与通知",
       "subHappGroupNetwork": "网络与 TUN 引擎",

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

@@ -800,6 +800,17 @@
       "clearAllInbounds": "全部清除",
       "noSubId": "此客戶端沒有 subId,無法產生共享連結。",
       "noLinks": "沒有可共享的連結 — 請先將此客戶端關聯至支援協定的入站。",
+      "qrStandard": "標準",
+      "happLinkRetry": "重試",
+      "happLinkErrorHint": "無法產生 Happ 連結。請重試,或前往 {dashboard} -> {logs} 查看詳細資訊。",
+      "happLinkSourceTooLong": "訂閱網址超過面板的 8192 位元組(UTF-8)處理上限。請縮短訂閱網址,或使用「標準」一般訂閱。",
+      "happLinkQrTooLong": "此 Happ 連結有效,但過長,無法顯示為二維碼。請點選「複製」以使用完整連結。",
+      "happLinkDisabledHint": "使用 Happ 前,請先在設定中啟用 Happ 連結產生功能。",
+      "happLinkDisabledTitle": "Happ 加密連結產生功能尚未啟用",
+      "happLinkDisabledDescription": "啟用後,可在本地產生加密的 Happ 訂閱連結。(僅限 Happ)",
+      "happLinkSettingsAction": "前往設定",
+      "happLinkDisclosure": "連結在本地產生。持有此連結的人可能還原或分享訂閱網址。",
+      "happLinkOptionLabel": "Happ 加密連結",
       "link": "連結",
       "resetNotPossible": "請先將此客戶端關聯至入站。",
       "resetAllTraffics": "重設所有客戶端流量",
@@ -1178,6 +1189,8 @@
       "subJsonUserAgentRegexDesc": "用於與用戶端 User-Agent 進行比對,以便在標準訂閱 URL 上自動選擇 Xray JSON 格式的 Go RE2 正規表示式。預設留空,因此在為要服務的用戶端設定規則之前,自動辨識會保持關閉。其他用戶端繼續取得原始/Base64 回應。變更後請重新啟動面板。",
       "subJsonRoutingRules": "JSON 訂閱路由設定檔",
       "subJsonRoutingRulesDesc": "路由 JSON、happ:// 或 incy:// 深層連結,或 https:// URL。設定後,將取代 JSON 訂閱的 DNS 與路由規則(為忽略回應標頭的用戶端直接內嵌至設定,例如使用用戶端負載平衡器的 Happ 與 INCY)。若 Happ 路由標頭設定為空,同一設定檔會自動作為 Routing 標頭傳送(內嵌 JSON 與 URL 轉換為 happ:// 深層連結),讓用戶端下載相符的 geo 檔案。",
+      "subJsonDns": "DNS 伺服器",
+      "subJsonDnsDesc": "替換寫入每個 JSON 訂閱設定的 dns 區塊:可以是完整的 dns 物件,也可以只是 servers 陣列。這些設定中路由設定檔的 DNS 也會被替換;但套用 Routing 標頭的用戶端仍使用設定檔中的解析器。留空則使用內建解析器。",
       "subClashAutoDetect": "自動識別 Clash/Mihomo 用戶端",
       "subClashAutoDetectDesc": "啟用後,使用標準訂閱 URL 的已識別 Clash/Mihomo 用戶端將自動取得 Clash YAML。瀏覽器仍會顯示訂閱頁面,其他用戶端繼續取得原始/Base64 回應,獨立的 JSON 和 Clash URL 仍可使用。需要啟用 Clash/Mihomo 訂閱並重新啟動面板才能生效。",
       "subClashUserAgentRegex": "Clash/Mihomo User-Agent 正規表示式",
@@ -1194,6 +1207,8 @@
       "subThemeDirDesc": "包含自訂訂閱頁面範本 (index.html/sub.html) 的資料夾的絕對路徑(例如 /etc/3x-ui/sub_templates/my-theme/)。留空則使用預設頁面。",
       "subThemeDirDocs": "範本指南 ↗",
       "subEnableRouting": "啟用路由",
+      "happLinkEnable": "加密訂閱連結",
+      "happLinkEnableDesc": "允許在客戶端 QR 碼視窗產生 Happ 加密連結,訂閱網址在本地處理。",
       "subEnableRoutingDesc": "在 VPN 用戶端中啟用路由的全域設定。(僅限 Happ)",
       "subRoutingRules": "路由規則",
       "subRoutingRulesDesc": "貼上現成的 happ:// 深層連結或一個固定 HTTPS URL。面板會在背景更新遠端規則並保留最後一個有效值,因此訂閱請求不需等待遠端來源。(僅限 Happ)",
@@ -1566,6 +1581,7 @@
       "subHappProxyIPs": "代理 IP / CIDR",
       "subHappBlockIPs": "阻止 IP / CIDR",
       "subHappDeeplinkGenerated": "DeepLink 已生成并填入路由规则",
+      "subHappGroupLinks": "訂閱連結",
       "subHappGroupRouting": "路由分流与规则",
       "subHappGroupBanners": "横幅公告与通知",
       "subHappGroupNetwork": "网络与 TUN 引擎",

+ 119 - 0
internal/xray/dnsconf/dnsconf.go

@@ -0,0 +1,119 @@
+// Package dnsconf validates the JSON-subscription DNS setting against xray's own
+// schema, so a block the client could not load never reaches an emitted document.
+package dnsconf
+
+import (
+	"encoding/json"
+	"errors"
+	"fmt"
+	"strings"
+
+	"github.com/xtls/xray-core/infra/conf"
+)
+
+// Parse resolves the setting into the dns subtree to emit: a full dns object, or
+// a bare array of servers wrapped into one. A blank value means "no override".
+func Parse(raw string) (map[string]any, error) {
+	trimmed := strings.TrimSpace(raw)
+	if trimmed == "" {
+		return nil, nil
+	}
+
+	var decoded any
+	if err := json.Unmarshal([]byte(trimmed), &decoded); err != nil {
+		return nil, fmt.Errorf("invalid DNS JSON: %w", err)
+	}
+
+	block := make(map[string]any)
+	servers, isList := decoded.([]any)
+	if isList {
+		block["servers"] = servers
+	} else {
+		object, isObject := decoded.(map[string]any)
+		if !isObject {
+			return nil, errors.New("DNS config must be a JSON object or an array of servers")
+		}
+		block = object
+	}
+
+	if err := validate(block); err != nil {
+		return nil, err
+	}
+	return block, nil
+}
+
+// validate decodes the block into xray's own schema. Build() is deliberately not
+// run: it resolves geosite tokens from geodata files the panel may not have.
+func validate(block map[string]any) (err error) {
+	// Third-party parser fed by a panel setting: a panic must degrade to
+	// "unusable value", never take the panel or sub server down.
+	defer func() {
+		if panicValue := recover(); panicValue != nil {
+			err = fmt.Errorf("invalid DNS config: %v", panicValue)
+		}
+	}()
+
+	payload, err := json.Marshal(block)
+	if err != nil {
+		return fmt.Errorf("invalid DNS config: %w", err)
+	}
+	var parsed conf.DNSConfig
+	if err := json.Unmarshal(payload, &parsed); err != nil {
+		return fmt.Errorf("invalid DNS config: %w", err)
+	}
+
+	servers, _ := block["servers"].([]any)
+	if len(servers) == 0 {
+		// xray quietly installs the system resolver when no server is
+		// configured, which would leak lookups outside the tunnel.
+		return errors.New(`"servers" must list at least one DNS server`)
+	}
+	for index, entry := range servers {
+		if err := validateServer(index, entry); err != nil {
+			return err
+		}
+	}
+	if err := validateClientIP("dns", parsed.ClientIP); err != nil {
+		return err
+	}
+	for index, server := range parsed.Servers {
+		if err := validateClientIP(fmt.Sprintf("DNS server #%d", index+1), server.ClientIP); err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+// validateServer enforces the address rule Build() would: xray refuses a name
+// server without one, but only reports it when the client starts.
+func validateServer(index int, entry any) error {
+	label := fmt.Sprintf("DNS server #%d", index+1)
+	switch server := entry.(type) {
+	case string:
+		if strings.TrimSpace(server) == "" {
+			return fmt.Errorf("%s is empty", label)
+		}
+	case map[string]any:
+		address, ok := server["address"]
+		if !ok {
+			return fmt.Errorf(`%s needs a non-empty "address"`, label)
+		}
+		text, ok := address.(string)
+		if ok && strings.TrimSpace(text) == "" {
+			return fmt.Errorf(`%s needs a non-empty "address"`, label)
+		}
+		if _, isObject := address.(map[string]any); !ok && !isObject {
+			return fmt.Errorf(`%s needs a non-empty "address"`, label)
+		}
+	default:
+		return fmt.Errorf("%s must be a string or an object", label)
+	}
+	return nil
+}
+
+func validateClientIP(label string, clientIP *conf.Address) error {
+	if clientIP != nil && !clientIP.Family().IsIP() {
+		return fmt.Errorf("%s clientIp must be an IP address", label)
+	}
+	return nil
+}

+ 68 - 0
internal/xray/dnsconf/dnsconf_test.go

@@ -0,0 +1,68 @@
+package dnsconf
+
+import (
+	"strings"
+	"testing"
+)
+
+func TestParse(t *testing.T) {
+	tests := []struct {
+		name      string
+		value     string
+		wantErr   string
+		wantNil   bool
+		wantCount int
+	}{
+		{name: "blank", value: "  ", wantNil: true},
+		{name: "array of strings", value: `["1.1.1.1", "tls://1.0.0.1"]`, wantCount: 2},
+		{name: "array with object entry", value: `[{"address": "1.1.1.1", "domains": ["geosite:youtube"]}]`, wantCount: 1},
+		{name: "object with hosts and strategy", value: `{"queryStrategy": "UseIPv4", "hosts": {"example.com": "1.2.3.4"}, "servers": ["https://dns.google/dns-query"]}`, wantCount: 1},
+		{name: "hosts value list", value: `{"hosts": {"example.com": ["1.2.3.4", "5.6.7.8"]}, "servers": ["1.1.1.1"]}`, wantCount: 1},
+		{name: "comma separated domains", value: `[{"address": "1.1.1.1", "domains": "geosite:youtube,geosite:netflix"}]`, wantCount: 1},
+		{name: "client ip set", value: `{"clientIp": "1.2.3.4", "servers": [{"address": "1.1.1.1", "clientIp": "2001:db8::1"}]}`, wantCount: 1},
+
+		{name: "malformed JSON", value: `[`, wantErr: "invalid DNS JSON"},
+		{name: "scalar", value: `42`, wantErr: "must be a JSON object or an array of servers"},
+		{name: "empty array", value: `[]`, wantErr: `"servers" must list at least one DNS server`},
+		{name: "object without servers", value: `{"hosts": {"a": "b"}}`, wantErr: `"servers" must list at least one DNS server`},
+		{name: "misspelled servers key", value: `{"server": ["1.1.1.1"]}`, wantErr: `"servers" must list at least one DNS server`},
+		{name: "non-string server entry", value: `[53]`, wantErr: "invalid DNS config"},
+		{name: "server without address", value: `[{"skipFallback": true}]`, wantErr: `needs a non-empty "address"`},
+		{name: "empty server string", value: `[""]`, wantErr: "is empty"},
+		{name: "empty server address", value: `[{"address": "  "}]`, wantErr: `needs a non-empty "address"`},
+		{name: "server address wrong type", value: `[{"address": 53}]`, wantErr: "invalid DNS config"},
+		{name: "hosts wrong type", value: `{"hosts": 5, "servers": ["1.1.1.1"]}`, wantErr: "invalid DNS config"},
+		{name: "strategy wrong type", value: `{"queryStrategy": 123, "servers": ["1.1.1.1"]}`, wantErr: "invalid DNS config"},
+		{name: "client ip not an address", value: `{"clientIp": "not-an-ip", "servers": ["1.1.1.1"]}`, wantErr: "clientIp must be an IP address"},
+		{name: "server client ip not an address", value: `[{"address": "1.1.1.1", "clientIp": "example.com"}]`, wantErr: "clientIp must be an IP address"},
+		{name: "server port as string", value: `[{"address": "1.1.1.1", "port": "53"}]`, wantErr: "invalid DNS config"},
+		{name: "server domains wrong type", value: `[{"address": "1.1.1.1", "domains": 5}]`, wantErr: "invalid DNS config"},
+	}
+	for _, tc := range tests {
+		t.Run(tc.name, func(t *testing.T) {
+			block, err := Parse(tc.value)
+			if tc.wantErr != "" {
+				if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
+					t.Fatalf("err = %v, want %q", err, tc.wantErr)
+				}
+				return
+			}
+			if err != nil {
+				t.Fatalf("unexpected error: %v", err)
+			}
+			if tc.wantNil {
+				if block != nil {
+					t.Fatalf("block = %v, want nil", block)
+				}
+				return
+			}
+			if block == nil {
+				t.Fatal("block = nil")
+			}
+			servers, _ := block["servers"].([]any)
+			if len(servers) != tc.wantCount {
+				t.Fatalf("servers = %v, want %d", servers, tc.wantCount)
+			}
+		})
+	}
+}

+ 1 - 0
tools/openapigen/main.go

@@ -91,6 +91,7 @@ func run(root, outDir string) error {
 			Path: resolveRel(root, "internal/web/service"),
 			StructAllow: setOf(
 				"InboundOption",
+				"HappLinkResult",
 				"ClientSlim",
 				"ClientPageResponse",
 				"ClientsSummary",

+ 3 - 0
tools/openapigen/walker.go

@@ -62,6 +62,9 @@ func walkPackages(requests []packageRequest) ([]Schema, []Alias, error) {
 							schemas = append(schemas, s)
 							continue
 						}
+						if _, ok := ts.Type.(*ast.InterfaceType); ok {
+							continue
+						}
 						if req.AliasAllow != nil && !req.AliasAllow[ts.Name.Name] {
 							continue
 						}

+ 45 - 0
tools/openapigen/walker_test.go

@@ -0,0 +1,45 @@
+package main
+
+import (
+	"bytes"
+	"os"
+	"path/filepath"
+	"strings"
+	"testing"
+)
+
+func TestWalkPackagesExportsAllowedStructsWithoutDIInterfaces(t *testing.T) {
+	dir := t.TempDir()
+	source := `package service
+
+type HappLinkGenerator interface {
+	Generate()
+}
+
+type HappLinkResult struct {
+	EncryptedLink string
+}
+`
+	if err := os.WriteFile(filepath.Join(dir, "service.go"), []byte(source), 0o644); err != nil {
+		t.Fatalf("write service fixture: %v", err)
+	}
+
+	schemas, aliases, err := walkPackages([]packageRequest{{
+		Path:        dir,
+		StructAllow: setOf("HappLinkResult"),
+	}})
+	if err != nil {
+		t.Fatalf("walk packages: %v", err)
+	}
+	var generated bytes.Buffer
+	if err := emitTypes(&generated, schemas, aliases); err != nil {
+		t.Fatalf("emit types: %v", err)
+	}
+	apiSurface := generated.String()
+	if !strings.Contains(apiSurface, "export interface HappLinkResult") {
+		t.Fatalf("generated types omit allowed response schema:\n%s", apiSurface)
+	}
+	if strings.Contains(apiSurface, "HappLinkGenerator") {
+		t.Fatalf("generated types expose DI interface:\n%s", apiSurface)
+	}
+}

برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است