소스 검색

feat(happ): generate Crypt5 subscription links locally (#6494)

* feat(clients): add stateless Happ link generator

Generate Happ provider links from the current effective subscription source without caching results. Reject unsafe provider responses and redact failure diagnostics.

* fix(clients): reject duplicate Happ provider fields

Parse Happ provider objects token by token so duplicate supported keys cannot be silently overwritten by encoding/json.

* feat(clients): expose on-demand Happ link API

Expose a no-store client endpoint backed by the Happ link generator and keep its generated OpenAPI contract synchronized.

* fix(openapi): exclude service interfaces from generated types

Keep dependency-injection interfaces out of the frontend API surface while preserving allowed response schemas.

* feat(clients): add stateless Happ QR presentation

Generate Happ links only for the active modal scope and retire late responses so Standard remains immediately available. Add focused component coverage and localized retry guidance across every locale.

* fix(clients): cover overlapping Happ generations

Prove the cancellation cleanup is required by resolving a retired request while its replacement remains pending. Also wait for Regenerate to leave loading state before exercising the existing action.

* fix(clients): harden Happ link handling

Validate generated responses before rendering and hide actions during unresolved requests. Strengthen route, redirect, timeout, and lint regression coverage with mutation-sensitive tests.

* fix(clients): gate Happ link generation behind operator opt-in

- add a fail-closed happLinkEnable setting
- enforce the gate before and after provider requests
- add locked Happ QR state with privacy disclosure and settings link
- cover backend, frontend, settings, and i18n regressions

* fix(frontend): guard oversized Happ QR codes

Keep valid long crypt5 links copyable while suppressing QR rendering and image actions above the encoder's UTF-8 byte limit. Add localized guidance and boundary coverage.

* fix(clients): log the sanitized transport error for Happ link failures

Every fail() call in HappService.Generate passed a string literal as the
detail, so the sanitizer written for provider errors only ever saw
constants, and an operator following the QR modal's "check Logs" hint
found nothing beyond reason=transport. Transport and body-read errors now
flow through sanitizeHappDetail, which also redacts cookie/session pairs.

Drop TestHappLinkEnableDefaultsOffWithoutPersistingRow: it pinned a getter
and its constant default, which the Generate gate test already drives.

* fix(frontend): size the Happ QR cap to level L and keep the QR modal mounted on close

HAPP_QR_MAX_BYTES was the level-M capacity (2331) while QrPanel encodes at
errorLevel "L", whose version-40 byte-mode capacity is 2953, so valid links
between 2332 and 2953 bytes lost their QR. The cap now matches the encoder
and a test renders the real QrPanel at the boundary.

Keying the modal content on `open` remounted it on every close, which cut
the Modal's exit transition and made the openSubId sync unreachable, so
`loading` never turned on for the subLinks fetch and a client without a
subscription link flashed noLinks on reopen. `open` leaves the key and the
sync block now also resets the Happ state.

* chore(clients): request Happ crypt5 links from api-v3

crypto.happ.su serves api-v2.php and api-v3.php side by side. Probed with
the same payloads, both take {"url"} over a JSON POST, answer
{"encrypted_link":"happ://crypt5/..."} of identical length with the same
crypt5 key marker, and fail the same way: 400 "No url provided.",
500 "Invalid URL format.", 405 on GET. Happ's own generator page is
branded "URL Encryption v3", so the panel follows it. The parser and the
link validator are unchanged.

* feat: add local generation of encrypted Happ links

- Implemented functionality to generate encrypted Happ links locally without network dependency.
- Added validation for URL length and format to ensure compliance with processing limits.
- Introduced new error handling for invalid URLs and control characters.
- Updated translations for various languages to reflect changes in Happ link generation.
- Created unit tests to validate the encryption process and ensure session keys and nonces are unique.

* fix(frontend): match the tuic memo deps to the non-optional subSettings

The Happ branch reads subSettings non-optionally in ClientQrModalContent
(happLinkEnable and the WireGuard/AmneziaWG publicHost memos), so React
Compiler infers subSettings.publicHost. The TUIC memo merged in from main
still listed subSettings?.publicHost, which fails oxlint's
preserve-manual-memoization rule and makes the compiler skip optimizing
the component. make verify stopped at lint-fe on the branch head.

* chore(happ): trim the pinned-key provenance comment to two lines

CLAUDE.md caps a comment block at two lines. The bare URL line repeated
the repository and file the next line already names, so it is folded
into that line (review LOW on happ_crypto.go).

---------

Co-authored-by: Sanaei <[email protected]>
NgaiYeanCoi 10 시간 전
부모
커밋
6a5b4fab6a
45개의 변경된 파일2651개의 추가작업 그리고 25개의 파일을 삭제
  1. 16 0
      docs/content/docs/en/reference/api/clients.mdx
  2. 69 0
      docs/public/openapi.json
  3. 69 0
      frontend/public/openapi.json
  4. 5 0
      frontend/src/generated/examples.ts
  5. 20 0
      frontend/src/generated/schemas.ts
  6. 6 1
      frontend/src/generated/types.ts
  7. 7 3
      frontend/src/generated/zod.ts
  8. 3 0
      frontend/src/hooks/useClients.ts
  9. 1 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. 21 0
      frontend/src/pages/settings/HappSettingsContent.tsx
  13. 11 2
      frontend/src/pages/settings/SubscriptionGeneralTab.tsx
  14. 1 0
      frontend/src/schemas/defaults.ts
  15. 1 0
      frontend/src/schemas/setting.ts
  16. 65 0
      frontend/src/test/client-qr-modal-qr-capacity.test.tsx
  17. 646 0
      frontend/src/test/client-qr-modal.test.tsx
  18. 12 0
      frontend/src/test/clients-query-gating.test.tsx
  19. 10 8
      frontend/src/test/multi-tunnel-client-config.test.tsx
  20. 78 0
      frontend/src/test/subscription-general-tab.test.tsx
  21. 26 0
      internal/web/controller/client.go
  22. 149 0
      internal/web/controller/client_happ_test.go
  23. 1 0
      internal/web/entity/entity.go
  24. 150 0
      internal/web/service/happ.go
  25. 165 0
      internal/web/service/happ_crypto.go
  26. 129 0
      internal/web/service/happ_local_test.go
  27. 408 0
      internal/web/service/happ_test.go
  28. 6 0
      internal/web/service/setting.go
  29. 43 0
      internal/web/service/setting_happ_test.go
  30. 14 0
      internal/web/translation/ar-EG.json
  31. 14 0
      internal/web/translation/en-US.json
  32. 14 0
      internal/web/translation/es-ES.json
  33. 14 0
      internal/web/translation/fa-IR.json
  34. 14 0
      internal/web/translation/id-ID.json
  35. 14 0
      internal/web/translation/ja-JP.json
  36. 14 0
      internal/web/translation/pt-BR.json
  37. 14 0
      internal/web/translation/ru-RU.json
  38. 14 0
      internal/web/translation/tr-TR.json
  39. 14 0
      internal/web/translation/uk-UA.json
  40. 14 0
      internal/web/translation/vi-VN.json
  41. 14 0
      internal/web/translation/zh-CN.json
  42. 14 0
      internal/web/translation/zh-TW.json
  43. 1 0
      tools/openapigen/main.go
  44. 3 0
      tools/openapigen/walker.go
  45. 45 0
      tools/openapigen/walker_test.go

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 16 - 0
docs/content/docs/en/reference/api/clients.mdx


+ 69 - 0
docs/public/openapi.json

@@ -41,6 +41,9 @@
           "externalTrafficInformURI": {
             "type": "string"
           },
+          "happLinkEnable": {
+            "type": "boolean"
+          },
           "ipLimitAllowlist": {
             "type": "string"
           },
@@ -465,6 +468,7 @@
           "expireDiff",
           "externalTrafficInformEnable",
           "externalTrafficInformURI",
+          "happLinkEnable",
           "ipLimitAllowlist",
           "ldapAutoCreate",
           "ldapAutoDelete",
@@ -612,6 +616,9 @@
           "externalTrafficInformURI": {
             "type": "string"
           },
+          "happLinkEnable": {
+            "type": "boolean"
+          },
           "hasApiToken": {
             "type": "boolean"
           },
@@ -1057,6 +1064,7 @@
           "expireDiff",
           "externalTrafficInformEnable",
           "externalTrafficInformURI",
+          "happLinkEnable",
           "hasApiToken",
           "hasLdapPassword",
           "hasNordSecret",
@@ -2134,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": {
@@ -10069,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": [

+ 69 - 0
frontend/public/openapi.json

@@ -41,6 +41,9 @@
           "externalTrafficInformURI": {
             "type": "string"
           },
+          "happLinkEnable": {
+            "type": "boolean"
+          },
           "ipLimitAllowlist": {
             "type": "string"
           },
@@ -465,6 +468,7 @@
           "expireDiff",
           "externalTrafficInformEnable",
           "externalTrafficInformURI",
+          "happLinkEnable",
           "ipLimitAllowlist",
           "ldapAutoCreate",
           "ldapAutoDelete",
@@ -612,6 +616,9 @@
           "externalTrafficInformURI": {
             "type": "string"
           },
+          "happLinkEnable": {
+            "type": "boolean"
+          },
           "hasApiToken": {
             "type": "boolean"
           },
@@ -1057,6 +1064,7 @@
           "expireDiff",
           "externalTrafficInformEnable",
           "externalTrafficInformURI",
+          "happLinkEnable",
           "hasApiToken",
           "hasLdapPassword",
           "hasNordSecret",
@@ -2134,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": {
@@ -10069,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": [

+ 5 - 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,
@@ -140,6 +141,7 @@ export const EXAMPLES: Record<string, unknown> = {
     "expireDiff": 0,
     "externalTrafficInformEnable": false,
     "externalTrafficInformURI": "",
+    "happLinkEnable": false,
     "hasApiToken": false,
     "hasLdapPassword": false,
     "hasNordSecret": false,
@@ -549,6 +551,9 @@ export const EXAMPLES: Record<string, unknown> = {
     "reason": "categoryMissing",
     "token": "geosite:blabla"
   },
+  "HappLinkResult": {
+    "encryptedLink": "happ://crypt5/example"
+  },
   "HistoryOfSeeders": {
     "id": 0,
     "seederName": ""

+ 20 - 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"
       },
@@ -439,6 +442,7 @@ export const SCHEMAS: Record<string, unknown> = {
       "expireDiff",
       "externalTrafficInformEnable",
       "externalTrafficInformURI",
+      "happLinkEnable",
       "ipLimitAllowlist",
       "ldapAutoCreate",
       "ldapAutoDelete",
@@ -586,6 +590,9 @@ export const SCHEMAS: Record<string, unknown> = {
       "externalTrafficInformURI": {
         "type": "string"
       },
+      "happLinkEnable": {
+        "type": "boolean"
+      },
       "hasApiToken": {
         "type": "boolean"
       },
@@ -1031,6 +1038,7 @@ export const SCHEMAS: Record<string, unknown> = {
       "expireDiff",
       "externalTrafficInformEnable",
       "externalTrafficInformURI",
+      "happLinkEnable",
       "hasApiToken",
       "hasLdapPassword",
       "hasNordSecret",
@@ -2108,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": {

+ 6 - 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;
@@ -149,6 +149,7 @@ export interface AllSettingView {
   expireDiff: number;
   externalTrafficInformEnable: boolean;
   externalTrafficInformURI: string;
+  happLinkEnable: boolean;
   hasApiToken: boolean;
   hasLdapPassword: boolean;
   hasNordSecret: boolean;
@@ -496,6 +497,10 @@ export interface GeodataTokenIssue {
   token: string;
 }
 
+export interface HappLinkResult {
+  encryptedLink: string;
+}
+
 export interface HistoryOfSeeders {
   id: number;
   seederName: string;

+ 7 - 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(),
@@ -166,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(),
@@ -532,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,

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

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

+ 21 - 0
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');
@@ -106,6 +110,7 @@ export default function HappSettingsContent({
       <Tabs
         type="card"
         size="small"
+        defaultActiveKey={defaultActiveTab}
         items={[
           {
             key: 'routing',
@@ -199,6 +204,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: (

+ 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'}
             />
           ),
         },

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

+ 1 - 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(),

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

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

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

+ 1 - 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"`

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

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

@@ -89,6 +89,7 @@ var defaultValueMap = map[string]string{
 	"tgLang":                      "en-US",
 	"twoFactorEnable":             "false",
 	"twoFactorToken":              "",
+	"happLinkEnable":              "false",
 	"subEnable":                   "true",
 	"subJsonEnable":               "false",
 	"subJsonAutoDetect":           "false",
@@ -783,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")
 }
@@ -1602,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
+}

+ 14 - 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": "إعادة ضبط حركة مرور كل العملاء",
@@ -1196,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)",
@@ -1568,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",

+ 14 - 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",
@@ -1318,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)",
@@ -1686,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",

+ 14 - 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",
@@ -1196,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)",
@@ -1568,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",

+ 14 - 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": "بازنشانی ترافیک همه کلاینت‌ها",
@@ -1200,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)",
@@ -1568,6 +1581,7 @@
       "subHappProxyIPs": "آی‌پی‌های پراکسی (Proxy CIDRs)",
       "subHappBlockIPs": "آی‌پی‌های مسدود (Block CIDRs)",
       "subHappDeeplinkGenerated": "دیپ‌لینک تولید و در قوانین روتینگ اعمال شد",
+      "subHappGroupLinks": "لینک‌های اشتراک",
       "subHappGroupRouting": "قوانین و روتینگ",
       "subHappGroupBanners": "اعلانات و بنرهای هوشمند",
       "subHappGroupNetwork": "تنظیمات شبکه و TUN",

+ 14 - 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",
@@ -1196,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)",
@@ -1568,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",

+ 14 - 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": "すべてのクライアントのトラフィックをリセット",
@@ -1196,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のみ)",
@@ -1568,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",

+ 14 - 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",
@@ -1196,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)",
@@ -1568,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",

+ 14 - 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": "Сбросить трафик всех клиентов",
@@ -1196,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)",
@@ -1568,6 +1581,7 @@
       "subHappProxyIPs": "Проксируемые IP / CIDR",
       "subHappBlockIPs": "Заблокированные IP / CIDR",
       "subHappDeeplinkGenerated": "Диплинк сгенерирован и применен к правилам маршрутизации",
+      "subHappGroupLinks": "Ссылки подписки",
       "subHappGroupRouting": "Маршрутизация и правила",
       "subHappGroupBanners": "Баннеры и уведомления",
       "subHappGroupNetwork": "Сетевые настройки и TUN",

+ 14 - 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",
@@ -1196,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)",
@@ -1568,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",

+ 14 - 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": "Скинути трафік усіх клієнтів",
@@ -1196,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)",
@@ -1568,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",

+ 14 - 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",
@@ -1196,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)",
@@ -1568,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",

+ 14 - 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": "重置所有客户端流量",
@@ -1196,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)",
@@ -1568,6 +1581,7 @@
       "subHappProxyIPs": "代理 IP / CIDR",
       "subHappBlockIPs": "阻止 IP / CIDR",
       "subHappDeeplinkGenerated": "DeepLink 已生成并填入路由规则",
+      "subHappGroupLinks": "订阅链接",
       "subHappGroupRouting": "路由分流与规则",
       "subHappGroupBanners": "横幅公告与通知",
       "subHappGroupNetwork": "网络与 TUN 引擎",

+ 14 - 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": "重設所有客戶端流量",
@@ -1196,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)",
@@ -1568,6 +1581,7 @@
       "subHappProxyIPs": "代理 IP / CIDR",
       "subHappBlockIPs": "阻止 IP / CIDR",
       "subHappDeeplinkGenerated": "DeepLink 已生成并填入路由规则",
+      "subHappGroupLinks": "訂閱連結",
       "subHappGroupRouting": "路由分流与规则",
       "subHappGroupBanners": "横幅公告与通知",
       "subHappGroupNetwork": "网络与 TUN 引擎",

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

이 변경점에서 너무 많은 파일들이 변경되어 몇몇 파일들은 표시되지 않았습니다.