6 次代碼提交 8076d5edfa ... fc08b53395

作者 SHA1 備註 提交日期
  MRVX fc08b53395 feat(ui): add global command palette (Ctrl+K) for fast navigation and search (#6352) 7 小時之前
  DIMFLIX 2dd903ea8e feat(sub): bake Happ/INCY routing profiles into the JSON subscription (#6402) 8 小時之前
  Rouzbeh† ed5465d0f2 feat(clients): support setting HWID limit and MTProto ad-tag in bulk adjust (#6399) 9 小時之前
  Pejman Yousefi 1456658028 feat(sub): add Happ client integration, routing presets, and app management (#6434) 9 小時之前
  Rouzbeh† d5ab84e8d5 feat(amneziawg): add AmneziaWG as an outbound protocol (#6320) 10 小時之前
  VibeProgramm 876497db6e feat(sub): add AmneziaWG proxy generation for Clash subscriptions (#6326) 11 小時之前
共有 100 個文件被更改,包括 10428 次插入268 次删除
  1. 27 0
      docs/architecture.md
  2. 17 13
      docs/content/docs/en/reference/api/clients.mdx
  3. 206 2
      docs/public/openapi.json
  4. 3 0
      frontend/.storybook/preview-head.html
  5. 206 2
      frontend/public/openapi.json
  6. 625 0
      frontend/src/components/command-palette/CommandPalette.css
  7. 802 0
      frontend/src/components/command-palette/CommandPalette.tsx
  8. 49 0
      frontend/src/components/command-palette/useCommandPalette.ts
  9. 50 0
      frontend/src/generated/examples.ts
  10. 200 0
      frontend/src/generated/schemas.ts
  11. 50 0
      frontend/src/generated/types.ts
  12. 50 0
      frontend/src/generated/zod.ts
  13. 12 3
      frontend/src/hooks/useClients.ts
  14. 8 1
      frontend/src/hooks/useXraySetting.ts
  15. 50 1
      frontend/src/layouts/AppSidebar.tsx
  16. 7 1
      frontend/src/layouts/PanelLayout.tsx
  17. 106 0
      frontend/src/lib/xray/outbound-form-adapter.ts
  18. 24 0
      frontend/src/models/setting.ts
  19. 2 2
      frontend/src/pages/api-docs/endpoints.ts
  20. 39 4
      frontend/src/pages/clients/ClientBulkAdjustModal.tsx
  21. 25 3
      frontend/src/pages/clients/ClientsPage.tsx
  22. 2 2
      frontend/src/pages/groups/GroupsPage.tsx
  23. 13 1
      frontend/src/pages/inbounds/list/InboundList.tsx
  24. 637 0
      frontend/src/pages/settings/HappSettingsContent.tsx
  25. 14 0
      frontend/src/pages/settings/SubscriptionFormatsTab.tsx
  26. 9 40
      frontend/src/pages/settings/SubscriptionGeneralTab.tsx
  27. 118 0
      frontend/src/pages/settings/happPresets.ts
  28. 6 0
      frontend/src/pages/settings/subscriptionShared.tsx
  29. 2 0
      frontend/src/pages/xray/outbounds/OutboundFormModal.tsx
  30. 228 0
      frontend/src/pages/xray/outbounds/protocols/amneziawg.tsx
  31. 1 0
      frontend/src/pages/xray/outbounds/protocols/index.ts
  32. 26 3
      frontend/src/schemas/client.ts
  33. 7 0
      frontend/src/schemas/forms/outbound-form.ts
  34. 1 0
      frontend/src/schemas/primitives/outbound-protocol.ts
  35. 49 0
      frontend/src/schemas/protocols/outbound/amneziawg.ts
  36. 3 0
      frontend/src/schemas/protocols/outbound/index.ts
  37. 24 0
      frontend/src/schemas/setting.ts
  38. 95 0
      frontend/src/test/amneziawg-outbound-adapter.test.ts
  39. 6 0
      frontend/src/test/app-sidebar.test.tsx
  40. 330 0
      frontend/src/test/command-palette.test.tsx
  41. 85 0
      frontend/src/test/happ-presets.test.ts
  42. 72 0
      frontend/src/test/happ-settings-tun-mode.test.tsx
  43. 323 0
      internal/amneziawg/outbound.go
  44. 312 0
      internal/amneziawg/outbound_test.go
  45. 139 0
      internal/amneziawgnet/client_device.go
  46. 117 0
      internal/amneziawgnet/client_device_test.go
  47. 9 3
      internal/amneziawgnet/device.go
  48. 216 0
      internal/amneziawgnet/dns.go
  49. 652 0
      internal/amneziawgnet/egress.go
  50. 741 0
      internal/amneziawgnet/egress_domain_test.go
  51. 194 0
      internal/amneziawgnet/outbound_manager.go
  52. 145 0
      internal/amneziawgnet/outbound_manager_test.go
  53. 67 0
      internal/amneziawgnet/resolving_bind.go
  54. 70 0
      internal/amneziawgnet/resolving_bind_test.go
  55. 33 0
      internal/amneziawgnet/socks_bridge.go
  56. 52 0
      internal/amneziawgnet/socks_bridge_test.go
  57. 185 0
      internal/sub/clash_service.go
  58. 399 0
      internal/sub/clash_service_test.go
  59. 43 17
      internal/sub/controller.go
  60. 26 0
      internal/sub/endpoint_test.go
  61. 1 1
      internal/sub/external_only_sub_test.go
  62. 141 0
      internal/sub/happ.go
  63. 227 0
      internal/sub/happ_test.go
  64. 3 3
      internal/sub/host_sub_test.go
  65. 2 2
      internal/sub/json_flow_gate_test.go
  66. 4 4
      internal/sub/json_info_node_test.go
  67. 366 0
      internal/sub/json_routing.go
  68. 447 0
      internal/sub/json_routing_baked_test.go
  69. 192 0
      internal/sub/json_routing_test.go
  70. 50 9
      internal/sub/json_service.go
  71. 21 21
      internal/sub/json_service_test.go
  72. 10 10
      internal/sub/mutation_audit_test.go
  73. 30 17
      internal/sub/remote_routing.go
  74. 1 2
      internal/sub/service.go
  75. 32 0
      internal/sub/sub.go
  76. 1 1
      internal/sub/sub_balancer_protocol_tag_test.go
  77. 12 12
      internal/sub/sub_balancer_test.go
  78. 1 1
      internal/sub/sub_json_observatory_test.go
  79. 3 3
      internal/sub/sub_panic_test.go
  80. 1 1
      internal/sub/sub_scale_test.go
  81. 1 1
      internal/sub/vless_route_sub_test.go
  82. 7 5
      internal/web/controller/client.go
  83. 26 0
      internal/web/entity/entity.go
  84. 67 11
      internal/web/job/amneziawg_job.go
  85. 6 1
      internal/web/job/remote_routing_job.go
  86. 130 36
      internal/web/service/client_bulk.go
  87. 1 1
      internal/web/service/client_bulk_fanout_test.go
  88. 323 7
      internal/web/service/client_bulk_flow_test.go
  89. 11 11
      internal/web/service/client_bulk_reenable_test.go
  90. 43 0
      internal/web/service/inbound.go
  91. 1 1
      internal/web/service/node_bulk_dispatch_test.go
  92. 1 1
      internal/web/service/outbound/outbound.go
  93. 28 0
      internal/web/service/outbound/probe_http.go
  94. 27 0
      internal/web/service/outbound/probe_http_test.go
  95. 12 0
      internal/web/service/port_conflict.go
  96. 21 4
      internal/web/service/port_conflict_test.go
  97. 134 3
      internal/web/service/setting.go
  98. 29 0
      internal/web/service/setting_remote_routing_test.go
  99. 1 1
      internal/web/service/sync_scale_postgres_test.go
  100. 5 0
      internal/web/service/xray.go

+ 27 - 0
docs/architecture.md

@@ -600,3 +600,30 @@ only - it changes no code), `claude-issue-analyst.yml` (issue triage).
 - **Tests live next to code** (`foo.go` ↔ `foo_test.go`), plus golden snapshots in
 - **Tests live next to code** (`foo.go` ↔ `foo_test.go`), plus golden snapshots in
   `frontend/src/test/golden/fixtures/` for config generation — update fixtures intentionally,
   `frontend/src/test/golden/fixtures/` for config generation — update fixtures intentionally,
   not blindly, when output changes.
   not blindly, when output changes.
+
+## AmneziaWG outbound pseudo-protocol
+
+The template stores `protocol: "amneziawg"` rows verbatim; Xray-core has no
+such proxy. At config generation (`GetXrayConfig` and the outbound latency
+probe's batch config) each row is swapped by `amneziawgnet.BuildSocksBridge`
+into a loopback socks outbound pointed at the panel's egress server (port
+`EgressBasePort`), authenticating with the row's tag as username. Sibling keys
+(`mux`, `sendThrough`, `targetStrategy`, `streamSettings.sockopt`) survive the
+swap. The embedded amneziawg-go client device lives in the panel process; an
+unbridgeable entry (unreadable settings, empty/non-string tag) fails config
+generation instead of skipping, because a skipped entry leaves
+`protocol: "amneziawg"` behind -- which makes Xray refuse the whole config.
+
+Traffic flow: Xray socks client -> egress SOCKS5 server (tag = username) ->
+per-tag device netstack -> amneziawg-go tunnel. Domain targets are resolved by
+a DNS exchange through that same netstack (`resolveTunnelVia`, default server
+`DefaultTunnelDNSServer`), so names never leak to the panel host's resolver and
+answers are valid at the tunnel's location; results cache for 60s. UDP flows
+key sessions on the resolved address:port. Peer endpoints may be hostnames:
+`resolvingBind.ParseEndpoint` resolves once at configure time (kernel
+`wg setconf` semantics); a hostname whose DNS dies later needs a template
+re-save or job restart to re-resolve.
+
+`randomTrailers` defaults to false wherever the panel does not control the
+peer (outbound form/schema): a receiver without 3.1 trailers silently drops
+oversized packets from a sender with it enabled.

+ 17 - 13
docs/content/docs/en/reference/api/clients.mdx

@@ -104,9 +104,11 @@ _openapi:
         still-depleted client is left disabled. The optional flow directive sets
         still-depleted client is left disabled. The optional flow directive sets
         the XTLS flow on every client: "none" clears it,
         the XTLS flow on every client: "none" clears it,
         "xtls-rprx-vision"/"xtls-rprx-vision-udp443" set it where the inbound
         "xtls-rprx-vision"/"xtls-rprx-vision-udp443" set it where the inbound
-        supports it (omit or "" to leave it unchanged). Returns the adjusted
+        supports it (omit or "" to leave it unchanged). The optional limitHwid
+        sets maximum registered devices (0 = unlimited). The optional adTag sets
+        MTProto Telegram sponsor channel ("none" clears). Returns the adjusted
         count and per-email skip reasons.'
         count and per-email skip reasons.'
-      url: '#shift-expiry-andor-traffic-quota-for-many-clients-in-one-call-adddaysaddbytes-may-be-negative-clients-with-unlimited-expiry-expirytime0-or-unlimited-traffic-totalgb0-are-skipped-for-the-corresponding-field--bulk-extend-never-converts-unlimited-to-limited-a-client-that-was-auto-disabled-solely-because-it-was-depleted-expired-or-over-quota-is-automatically-re-enabled--locally-and-on-its-node--when-the-adjustment-lifts-it-out-of-depletion-a-manually-disabled-or-still-depleted-client-is-left-disabled-the-optional-flow-directive-sets-the-xtls-flow-on-every-client-none-clears-it-xtls-rprx-visionxtls-rprx-vision-udp443-set-it-where-the-inbound-supports-it-omit-or--to-leave-it-unchanged-returns-the-adjusted-count-and-per-email-skip-reasons'
+      url: '#shift-expiry-andor-traffic-quota-for-many-clients-in-one-call-adddaysaddbytes-may-be-negative-clients-with-unlimited-expiry-expirytime0-or-unlimited-traffic-totalgb0-are-skipped-for-the-corresponding-field--bulk-extend-never-converts-unlimited-to-limited-a-client-that-was-auto-disabled-solely-because-it-was-depleted-expired-or-over-quota-is-automatically-re-enabled--locally-and-on-its-node--when-the-adjustment-lifts-it-out-of-depletion-a-manually-disabled-or-still-depleted-client-is-left-disabled-the-optional-flow-directive-sets-the-xtls-flow-on-every-client-none-clears-it-xtls-rprx-visionxtls-rprx-vision-udp443-set-it-where-the-inbound-supports-it-omit-or--to-leave-it-unchanged-the-optional-limithwid-sets-maximum-registered-devices-0--unlimited-the-optional-adtag-sets-mtproto-telegram-sponsor-channel-none-clears-returns-the-adjusted-count-and-per-email-skip-reasons'
     - depth: 2
     - depth: 2
       title: Enable many clients in one call. Emails are grouped by inbound and
       title: Enable many clients in one call. Emails are grouped by inbound and
         applied with a single read-modify-write per inbound; the running Xray
         applied with a single read-modify-write per inbound; the running Xray
@@ -276,9 +278,9 @@ _openapi:
       title: 'Return every URL for one client across all attached inbounds, one per
       title: 'Return every URL for one client across all attached inbounds, one per
         advertised endpoint: the managed hosts of the inbound, else its
         advertised endpoint: the managed hosts of the inbound, else its
         streamSettings.externalProxy entries, else its own address. Supported
         streamSettings.externalProxy entries, else its own address. Supported
-        protocols: vmess, vless, trojan, shadowsocks, hysteria, mtproto. Protocols
-        without a URL form (socks, http, mixed, wireguard, dokodemo, tunnel)
-        contribute nothing.'
+        protocols: vmess, vless, trojan, shadowsocks, hysteria, mtproto.
+        Protocols without a URL form (socks, http, mixed, wireguard, dokodemo,
+        tunnel) contribute nothing.'
       url: '#return-every-url-for-one-client-across-all-attached-inbounds-one-per-advertised-endpoint-the-managed-hosts-of-the-inbound-else-its-streamsettingsexternalproxy-entries-else-its-own-address-supported-protocols-vmess-vless-trojan-shadowsocks-hysteria-mtproto-protocols-without-a-url-form-socks-http-mixed-wireguard-dokodemo-tunnel-contribute-nothing'
       url: '#return-every-url-for-one-client-across-all-attached-inbounds-one-per-advertised-endpoint-the-managed-hosts-of-the-inbound-else-its-streamsettingsexternalproxy-entries-else-its-own-address-supported-protocols-vmess-vless-trojan-shadowsocks-hysteria-mtproto-protocols-without-a-url-form-socks-http-mixed-wireguard-dokodemo-tunnel-contribute-nothing'
   structuredData:
   structuredData:
     headings:
     headings:
@@ -364,9 +366,11 @@ _openapi:
           manually-disabled or still-depleted client is left disabled. The
           manually-disabled or still-depleted client is left disabled. The
           optional flow directive sets the XTLS flow on every client: "none"
           optional flow directive sets the XTLS flow on every client: "none"
           clears it, "xtls-rprx-vision"/"xtls-rprx-vision-udp443" set it where
           clears it, "xtls-rprx-vision"/"xtls-rprx-vision-udp443" set it where
-          the inbound supports it (omit or "" to leave it unchanged). Returns
-          the adjusted count and per-email skip reasons.'
-        id: shift-expiry-andor-traffic-quota-for-many-clients-in-one-call-adddaysaddbytes-may-be-negative-clients-with-unlimited-expiry-expirytime0-or-unlimited-traffic-totalgb0-are-skipped-for-the-corresponding-field--bulk-extend-never-converts-unlimited-to-limited-a-client-that-was-auto-disabled-solely-because-it-was-depleted-expired-or-over-quota-is-automatically-re-enabled--locally-and-on-its-node--when-the-adjustment-lifts-it-out-of-depletion-a-manually-disabled-or-still-depleted-client-is-left-disabled-the-optional-flow-directive-sets-the-xtls-flow-on-every-client-none-clears-it-xtls-rprx-visionxtls-rprx-vision-udp443-set-it-where-the-inbound-supports-it-omit-or--to-leave-it-unchanged-returns-the-adjusted-count-and-per-email-skip-reasons
+          the inbound supports it (omit or "" to leave it unchanged). The
+          optional limitHwid sets maximum registered devices (0 = unlimited).
+          The optional adTag sets MTProto Telegram sponsor channel ("none"
+          clears). Returns the adjusted count and per-email skip reasons.'
+        id: shift-expiry-andor-traffic-quota-for-many-clients-in-one-call-adddaysaddbytes-may-be-negative-clients-with-unlimited-expiry-expirytime0-or-unlimited-traffic-totalgb0-are-skipped-for-the-corresponding-field--bulk-extend-never-converts-unlimited-to-limited-a-client-that-was-auto-disabled-solely-because-it-was-depleted-expired-or-over-quota-is-automatically-re-enabled--locally-and-on-its-node--when-the-adjustment-lifts-it-out-of-depletion-a-manually-disabled-or-still-depleted-client-is-left-disabled-the-optional-flow-directive-sets-the-xtls-flow-on-every-client-none-clears-it-xtls-rprx-visionxtls-rprx-vision-udp443-set-it-where-the-inbound-supports-it-omit-or--to-leave-it-unchanged-the-optional-limithwid-sets-maximum-registered-devices-0--unlimited-the-optional-adtag-sets-mtproto-telegram-sponsor-channel-none-clears-returns-the-adjusted-count-and-per-email-skip-reasons
       - content: Enable many clients in one call. Emails are grouped by inbound and
       - content: Enable many clients in one call. Emails are grouped by inbound and
           applied with a single read-modify-write per inbound; the running Xray
           applied with a single read-modify-write per inbound; the running Xray
           (local or remote node) is updated to add each user. Note that enabling
           (local or remote node) is updated to add each user. Note that enabling
@@ -508,12 +512,12 @@ _openapi:
           URL is emitted per external proxy. Empty array when the subId has no
           URL is emitted per external proxy. Empty array when the subId has no
           enabled clients.
           enabled clients.
         id: return-every-protocol-url-vless-vmess-trojan-ss-hysteria-hy2-for-clients-matching-the-subscription-id-same-result-set-as-the-configured-subpath-endpoint-but-as-a-json-array--no-base64-when-an-inbound-has-streamsettingsexternalproxy-set-one-url-is-emitted-per-external-proxy-empty-array-when-the-subid-has-no-enabled-clients
         id: return-every-protocol-url-vless-vmess-trojan-ss-hysteria-hy2-for-clients-matching-the-subscription-id-same-result-set-as-the-configured-subpath-endpoint-but-as-a-json-array--no-base64-when-an-inbound-has-streamsettingsexternalproxy-set-one-url-is-emitted-per-external-proxy-empty-array-when-the-subid-has-no-enabled-clients
-      - content: 'Return every URL for one client across all attached inbounds, one
-          per advertised endpoint: the managed hosts of the inbound, else its
+      - content: 'Return every URL for one client across all attached inbounds, one per
+          advertised endpoint: the managed hosts of the inbound, else its
           streamSettings.externalProxy entries, else its own address. Supported
           streamSettings.externalProxy entries, else its own address. Supported
-          protocols: vmess, vless, trojan, shadowsocks, hysteria, mtproto. Protocols
-          without a URL form (socks, http, mixed, wireguard, dokodemo, tunnel)
-          contribute nothing.'
+          protocols: vmess, vless, trojan, shadowsocks, hysteria, mtproto.
+          Protocols without a URL form (socks, http, mixed, wireguard, dokodemo,
+          tunnel) contribute nothing.'
         id: return-every-url-for-one-client-across-all-attached-inbounds-one-per-advertised-endpoint-the-managed-hosts-of-the-inbound-else-its-streamsettingsexternalproxy-entries-else-its-own-address-supported-protocols-vmess-vless-trojan-shadowsocks-hysteria-mtproto-protocols-without-a-url-form-socks-http-mixed-wireguard-dokodemo-tunnel-contribute-nothing
         id: return-every-url-for-one-client-across-all-attached-inbounds-one-per-advertised-endpoint-the-managed-hosts-of-the-inbound-else-its-streamsettingsexternalproxy-entries-else-its-own-address-supported-protocols-vmess-vless-trojan-shadowsocks-hysteria-mtproto-protocols-without-a-url-form-socks-http-mixed-wireguard-dokodemo-tunnel-contribute-nothing
     contents:
     contents:
       - content: >-
       - content: >-

+ 206 - 2
docs/public/openapi.json

@@ -220,6 +220,76 @@
           "subExpiredTemplate": {
           "subExpiredTemplate": {
             "type": "string"
             "type": "string"
           },
           },
+          "subHappAlwaysHwid": {
+            "type": "boolean"
+          },
+          "subHappAutoConnect": {
+            "type": "boolean"
+          },
+          "subHappAutoConnectType": {
+            "type": "string"
+          },
+          "subHappAutoDetect": {
+            "description": "Happ client customization settings (app-management / routing / UX).",
+            "type": "boolean"
+          },
+          "subHappColorProfile": {
+            "type": "string"
+          },
+          "subHappExcludeApns": {
+            "type": "boolean"
+          },
+          "subHappExcludeRoutes": {
+            "type": "string"
+          },
+          "subHappFallbackUrl": {
+            "type": "string"
+          },
+          "subHappNewUrl": {
+            "type": "string"
+          },
+          "subHappNoLimit": {
+            "type": "boolean"
+          },
+          "subHappNotificationExpire": {
+            "type": "boolean"
+          },
+          "subHappPerAppList": {
+            "type": "string"
+          },
+          "subHappPerAppMode": {
+            "type": "string"
+          },
+          "subHappPingType": {
+            "type": "string"
+          },
+          "subHappProviderId": {
+            "type": "string"
+          },
+          "subHappSubExpire": {
+            "type": "boolean"
+          },
+          "subHappSubExpireButtonLink": {
+            "type": "string"
+          },
+          "subHappSubInfoButtonLink": {
+            "type": "string"
+          },
+          "subHappSubInfoButtonText": {
+            "type": "string"
+          },
+          "subHappSubInfoColor": {
+            "type": "string"
+          },
+          "subHappSubInfoText": {
+            "type": "string"
+          },
+          "subHappTunMode": {
+            "type": "string"
+          },
+          "subHappTunType": {
+            "type": "string"
+          },
           "subHideSettings": {
           "subHideSettings": {
             "type": "boolean"
             "type": "boolean"
           },
           },
@@ -253,6 +323,9 @@
           "subJsonPath": {
           "subJsonPath": {
             "type": "string"
             "type": "string"
           },
           },
+          "subJsonRoutingRules": {
+            "type": "string"
+          },
           "subJsonRules": {
           "subJsonRules": {
             "type": "string"
             "type": "string"
           },
           },
@@ -443,6 +516,29 @@
           "subEnableRouting",
           "subEnableRouting",
           "subEncrypt",
           "subEncrypt",
           "subExpiredTemplate",
           "subExpiredTemplate",
+          "subHappAlwaysHwid",
+          "subHappAutoConnect",
+          "subHappAutoConnectType",
+          "subHappAutoDetect",
+          "subHappColorProfile",
+          "subHappExcludeApns",
+          "subHappExcludeRoutes",
+          "subHappFallbackUrl",
+          "subHappNewUrl",
+          "subHappNoLimit",
+          "subHappNotificationExpire",
+          "subHappPerAppList",
+          "subHappPerAppMode",
+          "subHappPingType",
+          "subHappProviderId",
+          "subHappSubExpire",
+          "subHappSubExpireButtonLink",
+          "subHappSubInfoButtonLink",
+          "subHappSubInfoButtonText",
+          "subHappSubInfoColor",
+          "subHappSubInfoText",
+          "subHappTunMode",
+          "subHappTunType",
           "subHideSettings",
           "subHideSettings",
           "subIncyEnableRouting",
           "subIncyEnableRouting",
           "subIncyRoutingRules",
           "subIncyRoutingRules",
@@ -454,6 +550,7 @@
           "subJsonMux",
           "subJsonMux",
           "subJsonObservatory",
           "subJsonObservatory",
           "subJsonPath",
           "subJsonPath",
+          "subJsonRoutingRules",
           "subJsonRules",
           "subJsonRules",
           "subJsonURI",
           "subJsonURI",
           "subJsonUserAgentRegex",
           "subJsonUserAgentRegex",
@@ -711,6 +808,76 @@
           "subExpiredTemplate": {
           "subExpiredTemplate": {
             "type": "string"
             "type": "string"
           },
           },
+          "subHappAlwaysHwid": {
+            "type": "boolean"
+          },
+          "subHappAutoConnect": {
+            "type": "boolean"
+          },
+          "subHappAutoConnectType": {
+            "type": "string"
+          },
+          "subHappAutoDetect": {
+            "description": "Happ client customization settings (app-management / routing / UX).",
+            "type": "boolean"
+          },
+          "subHappColorProfile": {
+            "type": "string"
+          },
+          "subHappExcludeApns": {
+            "type": "boolean"
+          },
+          "subHappExcludeRoutes": {
+            "type": "string"
+          },
+          "subHappFallbackUrl": {
+            "type": "string"
+          },
+          "subHappNewUrl": {
+            "type": "string"
+          },
+          "subHappNoLimit": {
+            "type": "boolean"
+          },
+          "subHappNotificationExpire": {
+            "type": "boolean"
+          },
+          "subHappPerAppList": {
+            "type": "string"
+          },
+          "subHappPerAppMode": {
+            "type": "string"
+          },
+          "subHappPingType": {
+            "type": "string"
+          },
+          "subHappProviderId": {
+            "type": "string"
+          },
+          "subHappSubExpire": {
+            "type": "boolean"
+          },
+          "subHappSubExpireButtonLink": {
+            "type": "string"
+          },
+          "subHappSubInfoButtonLink": {
+            "type": "string"
+          },
+          "subHappSubInfoButtonText": {
+            "type": "string"
+          },
+          "subHappSubInfoColor": {
+            "type": "string"
+          },
+          "subHappSubInfoText": {
+            "type": "string"
+          },
+          "subHappTunMode": {
+            "type": "string"
+          },
+          "subHappTunType": {
+            "type": "string"
+          },
           "subHideSettings": {
           "subHideSettings": {
             "type": "boolean"
             "type": "boolean"
           },
           },
@@ -744,6 +911,9 @@
           "subJsonPath": {
           "subJsonPath": {
             "type": "string"
             "type": "string"
           },
           },
+          "subJsonRoutingRules": {
+            "type": "string"
+          },
           "subJsonRules": {
           "subJsonRules": {
             "type": "string"
             "type": "string"
           },
           },
@@ -941,6 +1111,29 @@
           "subEnableRouting",
           "subEnableRouting",
           "subEncrypt",
           "subEncrypt",
           "subExpiredTemplate",
           "subExpiredTemplate",
+          "subHappAlwaysHwid",
+          "subHappAutoConnect",
+          "subHappAutoConnectType",
+          "subHappAutoDetect",
+          "subHappColorProfile",
+          "subHappExcludeApns",
+          "subHappExcludeRoutes",
+          "subHappFallbackUrl",
+          "subHappNewUrl",
+          "subHappNoLimit",
+          "subHappNotificationExpire",
+          "subHappPerAppList",
+          "subHappPerAppMode",
+          "subHappPingType",
+          "subHappProviderId",
+          "subHappSubExpire",
+          "subHappSubExpireButtonLink",
+          "subHappSubInfoButtonLink",
+          "subHappSubInfoButtonText",
+          "subHappSubInfoColor",
+          "subHappSubInfoText",
+          "subHappTunMode",
+          "subHappTunType",
           "subHideSettings",
           "subHideSettings",
           "subIncyEnableRouting",
           "subIncyEnableRouting",
           "subIncyRoutingRules",
           "subIncyRoutingRules",
@@ -952,6 +1145,7 @@
           "subJsonMux",
           "subJsonMux",
           "subJsonObservatory",
           "subJsonObservatory",
           "subJsonPath",
           "subJsonPath",
+          "subJsonRoutingRules",
           "subJsonRules",
           "subJsonRules",
           "subJsonURI",
           "subJsonURI",
           "subJsonUserAgentRegex",
           "subJsonUserAgentRegex",
@@ -2550,6 +2744,9 @@
           "mtprotoDomain": {
           "mtprotoDomain": {
             "type": "string"
             "type": "string"
           },
           },
+          "network": {
+            "type": "string"
+          },
           "nodeAddress": {
           "nodeAddress": {
             "description": "Share-host resolution inputs, mirroring the subscription's\nresolveInboundAddress so the clients page renders a node-managed WireGuard\nEndpoint that points at the node, not the master panel. NodeAddress is the\nhosting node's externally reachable address (empty for this panel's own\ninbounds); Listen and ShareAddrStrategy/ShareAddr feed the same\nnode→listen→custom fallback the share/QR links already use.",
             "description": "Share-host resolution inputs, mirroring the subscription's\nresolveInboundAddress so the clients page renders a node-managed WireGuard\nEndpoint that points at the node, not the master panel. NodeAddress is the\nhosting node's externally reachable address (empty for this panel's own\ninbounds); Listen and ShareAddrStrategy/ShareAddr feed the same\nnode→listen→custom fallback the share/QR links already use.",
             "type": "string"
             "type": "string"
@@ -2571,6 +2768,9 @@
             "example": "VLESS-443",
             "example": "VLESS-443",
             "type": "string"
             "type": "string"
           },
           },
+          "security": {
+            "type": "string"
+          },
           "shareAddr": {
           "shareAddr": {
             "type": "string"
             "type": "string"
           },
           },
@@ -4321,11 +4521,13 @@
                       "id": 1,
                       "id": 1,
                       "listen": "",
                       "listen": "",
                       "mtprotoDomain": "",
                       "mtprotoDomain": "",
+                      "network": "",
                       "nodeAddress": "",
                       "nodeAddress": "",
                       "nodeId": null,
                       "nodeId": null,
                       "port": 443,
                       "port": 443,
                       "protocol": "vless",
                       "protocol": "vless",
                       "remark": "VLESS-443",
                       "remark": "VLESS-443",
+                      "security": "",
                       "shareAddr": "",
                       "shareAddr": "",
                       "shareAddrStrategy": "",
                       "shareAddrStrategy": "",
                       "ssMethod": "",
                       "ssMethod": "",
@@ -8180,7 +8382,7 @@
         "tags": [
         "tags": [
           "Clients"
           "Clients"
         ],
         ],
-        "summary": "Shift expiry and/or traffic quota for many clients in one call. addDays/addBytes may be negative. Clients with unlimited expiry (expiryTime=0) or unlimited traffic (totalGB=0) are skipped for the corresponding field — bulk extend never converts unlimited to limited. A client that was auto-disabled solely because it was depleted (expired or over quota) is automatically re-enabled — locally and on its node — when the adjustment lifts it out of depletion; a manually-disabled or still-depleted client is left disabled. The optional flow directive sets the XTLS flow on every client: \"none\" clears it, \"xtls-rprx-vision\"/\"xtls-rprx-vision-udp443\" set it where the inbound supports it (omit or \"\" to leave it unchanged). Returns the adjusted count and per-email skip reasons.",
+        "summary": "Shift expiry and/or traffic quota for many clients in one call. addDays/addBytes may be negative. Clients with unlimited expiry (expiryTime=0) or unlimited traffic (totalGB=0) are skipped for the corresponding field — bulk extend never converts unlimited to limited. A client that was auto-disabled solely because it was depleted (expired or over quota) is automatically re-enabled — locally and on its node — when the adjustment lifts it out of depletion; a manually-disabled or still-depleted client is left disabled. The optional flow directive sets the XTLS flow on every client: \"none\" clears it, \"xtls-rprx-vision\"/\"xtls-rprx-vision-udp443\" set it where the inbound supports it (omit or \"\" to leave it unchanged). The optional limitHwid sets maximum registered devices (0 = unlimited). The optional adTag sets MTProto Telegram sponsor channel (\"none\" clears). Returns the adjusted count and per-email skip reasons.",
         "operationId": "post_panel_api_clients_bulkAdjust",
         "operationId": "post_panel_api_clients_bulkAdjust",
         "requestBody": {
         "requestBody": {
           "required": true,
           "required": true,
@@ -8196,7 +8398,9 @@
                 ],
                 ],
                 "addDays": 30,
                 "addDays": 30,
                 "addBytes": 53687091200,
                 "addBytes": 53687091200,
-                "flow": "xtls-rprx-vision"
+                "flow": "xtls-rprx-vision",
+                "limitHwid": 2,
+                "adTag": "0123456789abcdef0123456789abcdef"
               }
               }
             }
             }
           }
           }

+ 3 - 0
frontend/.storybook/preview-head.html

@@ -1,4 +1,7 @@
 <script>
 <script>
+  if (typeof navigator !== 'undefined' && (!navigator.language || navigator.language.includes('@'))) {
+    Object.defineProperty(navigator, 'language', { value: 'en-US', configurable: true });
+  }
   if (localStorage.getItem('dark-mode') === null) localStorage.setItem('dark-mode', 'false');
   if (localStorage.getItem('dark-mode') === null) localStorage.setItem('dark-mode', 'false');
   if (localStorage.getItem('isUltraDarkThemeEnabled') === null) {
   if (localStorage.getItem('isUltraDarkThemeEnabled') === null) {
     localStorage.setItem('isUltraDarkThemeEnabled', 'false');
     localStorage.setItem('isUltraDarkThemeEnabled', 'false');

+ 206 - 2
frontend/public/openapi.json

@@ -220,6 +220,76 @@
           "subExpiredTemplate": {
           "subExpiredTemplate": {
             "type": "string"
             "type": "string"
           },
           },
+          "subHappAlwaysHwid": {
+            "type": "boolean"
+          },
+          "subHappAutoConnect": {
+            "type": "boolean"
+          },
+          "subHappAutoConnectType": {
+            "type": "string"
+          },
+          "subHappAutoDetect": {
+            "description": "Happ client customization settings (app-management / routing / UX).",
+            "type": "boolean"
+          },
+          "subHappColorProfile": {
+            "type": "string"
+          },
+          "subHappExcludeApns": {
+            "type": "boolean"
+          },
+          "subHappExcludeRoutes": {
+            "type": "string"
+          },
+          "subHappFallbackUrl": {
+            "type": "string"
+          },
+          "subHappNewUrl": {
+            "type": "string"
+          },
+          "subHappNoLimit": {
+            "type": "boolean"
+          },
+          "subHappNotificationExpire": {
+            "type": "boolean"
+          },
+          "subHappPerAppList": {
+            "type": "string"
+          },
+          "subHappPerAppMode": {
+            "type": "string"
+          },
+          "subHappPingType": {
+            "type": "string"
+          },
+          "subHappProviderId": {
+            "type": "string"
+          },
+          "subHappSubExpire": {
+            "type": "boolean"
+          },
+          "subHappSubExpireButtonLink": {
+            "type": "string"
+          },
+          "subHappSubInfoButtonLink": {
+            "type": "string"
+          },
+          "subHappSubInfoButtonText": {
+            "type": "string"
+          },
+          "subHappSubInfoColor": {
+            "type": "string"
+          },
+          "subHappSubInfoText": {
+            "type": "string"
+          },
+          "subHappTunMode": {
+            "type": "string"
+          },
+          "subHappTunType": {
+            "type": "string"
+          },
           "subHideSettings": {
           "subHideSettings": {
             "type": "boolean"
             "type": "boolean"
           },
           },
@@ -253,6 +323,9 @@
           "subJsonPath": {
           "subJsonPath": {
             "type": "string"
             "type": "string"
           },
           },
+          "subJsonRoutingRules": {
+            "type": "string"
+          },
           "subJsonRules": {
           "subJsonRules": {
             "type": "string"
             "type": "string"
           },
           },
@@ -443,6 +516,29 @@
           "subEnableRouting",
           "subEnableRouting",
           "subEncrypt",
           "subEncrypt",
           "subExpiredTemplate",
           "subExpiredTemplate",
+          "subHappAlwaysHwid",
+          "subHappAutoConnect",
+          "subHappAutoConnectType",
+          "subHappAutoDetect",
+          "subHappColorProfile",
+          "subHappExcludeApns",
+          "subHappExcludeRoutes",
+          "subHappFallbackUrl",
+          "subHappNewUrl",
+          "subHappNoLimit",
+          "subHappNotificationExpire",
+          "subHappPerAppList",
+          "subHappPerAppMode",
+          "subHappPingType",
+          "subHappProviderId",
+          "subHappSubExpire",
+          "subHappSubExpireButtonLink",
+          "subHappSubInfoButtonLink",
+          "subHappSubInfoButtonText",
+          "subHappSubInfoColor",
+          "subHappSubInfoText",
+          "subHappTunMode",
+          "subHappTunType",
           "subHideSettings",
           "subHideSettings",
           "subIncyEnableRouting",
           "subIncyEnableRouting",
           "subIncyRoutingRules",
           "subIncyRoutingRules",
@@ -454,6 +550,7 @@
           "subJsonMux",
           "subJsonMux",
           "subJsonObservatory",
           "subJsonObservatory",
           "subJsonPath",
           "subJsonPath",
+          "subJsonRoutingRules",
           "subJsonRules",
           "subJsonRules",
           "subJsonURI",
           "subJsonURI",
           "subJsonUserAgentRegex",
           "subJsonUserAgentRegex",
@@ -711,6 +808,76 @@
           "subExpiredTemplate": {
           "subExpiredTemplate": {
             "type": "string"
             "type": "string"
           },
           },
+          "subHappAlwaysHwid": {
+            "type": "boolean"
+          },
+          "subHappAutoConnect": {
+            "type": "boolean"
+          },
+          "subHappAutoConnectType": {
+            "type": "string"
+          },
+          "subHappAutoDetect": {
+            "description": "Happ client customization settings (app-management / routing / UX).",
+            "type": "boolean"
+          },
+          "subHappColorProfile": {
+            "type": "string"
+          },
+          "subHappExcludeApns": {
+            "type": "boolean"
+          },
+          "subHappExcludeRoutes": {
+            "type": "string"
+          },
+          "subHappFallbackUrl": {
+            "type": "string"
+          },
+          "subHappNewUrl": {
+            "type": "string"
+          },
+          "subHappNoLimit": {
+            "type": "boolean"
+          },
+          "subHappNotificationExpire": {
+            "type": "boolean"
+          },
+          "subHappPerAppList": {
+            "type": "string"
+          },
+          "subHappPerAppMode": {
+            "type": "string"
+          },
+          "subHappPingType": {
+            "type": "string"
+          },
+          "subHappProviderId": {
+            "type": "string"
+          },
+          "subHappSubExpire": {
+            "type": "boolean"
+          },
+          "subHappSubExpireButtonLink": {
+            "type": "string"
+          },
+          "subHappSubInfoButtonLink": {
+            "type": "string"
+          },
+          "subHappSubInfoButtonText": {
+            "type": "string"
+          },
+          "subHappSubInfoColor": {
+            "type": "string"
+          },
+          "subHappSubInfoText": {
+            "type": "string"
+          },
+          "subHappTunMode": {
+            "type": "string"
+          },
+          "subHappTunType": {
+            "type": "string"
+          },
           "subHideSettings": {
           "subHideSettings": {
             "type": "boolean"
             "type": "boolean"
           },
           },
@@ -744,6 +911,9 @@
           "subJsonPath": {
           "subJsonPath": {
             "type": "string"
             "type": "string"
           },
           },
+          "subJsonRoutingRules": {
+            "type": "string"
+          },
           "subJsonRules": {
           "subJsonRules": {
             "type": "string"
             "type": "string"
           },
           },
@@ -941,6 +1111,29 @@
           "subEnableRouting",
           "subEnableRouting",
           "subEncrypt",
           "subEncrypt",
           "subExpiredTemplate",
           "subExpiredTemplate",
+          "subHappAlwaysHwid",
+          "subHappAutoConnect",
+          "subHappAutoConnectType",
+          "subHappAutoDetect",
+          "subHappColorProfile",
+          "subHappExcludeApns",
+          "subHappExcludeRoutes",
+          "subHappFallbackUrl",
+          "subHappNewUrl",
+          "subHappNoLimit",
+          "subHappNotificationExpire",
+          "subHappPerAppList",
+          "subHappPerAppMode",
+          "subHappPingType",
+          "subHappProviderId",
+          "subHappSubExpire",
+          "subHappSubExpireButtonLink",
+          "subHappSubInfoButtonLink",
+          "subHappSubInfoButtonText",
+          "subHappSubInfoColor",
+          "subHappSubInfoText",
+          "subHappTunMode",
+          "subHappTunType",
           "subHideSettings",
           "subHideSettings",
           "subIncyEnableRouting",
           "subIncyEnableRouting",
           "subIncyRoutingRules",
           "subIncyRoutingRules",
@@ -952,6 +1145,7 @@
           "subJsonMux",
           "subJsonMux",
           "subJsonObservatory",
           "subJsonObservatory",
           "subJsonPath",
           "subJsonPath",
+          "subJsonRoutingRules",
           "subJsonRules",
           "subJsonRules",
           "subJsonURI",
           "subJsonURI",
           "subJsonUserAgentRegex",
           "subJsonUserAgentRegex",
@@ -2550,6 +2744,9 @@
           "mtprotoDomain": {
           "mtprotoDomain": {
             "type": "string"
             "type": "string"
           },
           },
+          "network": {
+            "type": "string"
+          },
           "nodeAddress": {
           "nodeAddress": {
             "description": "Share-host resolution inputs, mirroring the subscription's\nresolveInboundAddress so the clients page renders a node-managed WireGuard\nEndpoint that points at the node, not the master panel. NodeAddress is the\nhosting node's externally reachable address (empty for this panel's own\ninbounds); Listen and ShareAddrStrategy/ShareAddr feed the same\nnode→listen→custom fallback the share/QR links already use.",
             "description": "Share-host resolution inputs, mirroring the subscription's\nresolveInboundAddress so the clients page renders a node-managed WireGuard\nEndpoint that points at the node, not the master panel. NodeAddress is the\nhosting node's externally reachable address (empty for this panel's own\ninbounds); Listen and ShareAddrStrategy/ShareAddr feed the same\nnode→listen→custom fallback the share/QR links already use.",
             "type": "string"
             "type": "string"
@@ -2571,6 +2768,9 @@
             "example": "VLESS-443",
             "example": "VLESS-443",
             "type": "string"
             "type": "string"
           },
           },
+          "security": {
+            "type": "string"
+          },
           "shareAddr": {
           "shareAddr": {
             "type": "string"
             "type": "string"
           },
           },
@@ -4321,11 +4521,13 @@
                       "id": 1,
                       "id": 1,
                       "listen": "",
                       "listen": "",
                       "mtprotoDomain": "",
                       "mtprotoDomain": "",
+                      "network": "",
                       "nodeAddress": "",
                       "nodeAddress": "",
                       "nodeId": null,
                       "nodeId": null,
                       "port": 443,
                       "port": 443,
                       "protocol": "vless",
                       "protocol": "vless",
                       "remark": "VLESS-443",
                       "remark": "VLESS-443",
+                      "security": "",
                       "shareAddr": "",
                       "shareAddr": "",
                       "shareAddrStrategy": "",
                       "shareAddrStrategy": "",
                       "ssMethod": "",
                       "ssMethod": "",
@@ -8180,7 +8382,7 @@
         "tags": [
         "tags": [
           "Clients"
           "Clients"
         ],
         ],
-        "summary": "Shift expiry and/or traffic quota for many clients in one call. addDays/addBytes may be negative. Clients with unlimited expiry (expiryTime=0) or unlimited traffic (totalGB=0) are skipped for the corresponding field — bulk extend never converts unlimited to limited. A client that was auto-disabled solely because it was depleted (expired or over quota) is automatically re-enabled — locally and on its node — when the adjustment lifts it out of depletion; a manually-disabled or still-depleted client is left disabled. The optional flow directive sets the XTLS flow on every client: \"none\" clears it, \"xtls-rprx-vision\"/\"xtls-rprx-vision-udp443\" set it where the inbound supports it (omit or \"\" to leave it unchanged). Returns the adjusted count and per-email skip reasons.",
+        "summary": "Shift expiry and/or traffic quota for many clients in one call. addDays/addBytes may be negative. Clients with unlimited expiry (expiryTime=0) or unlimited traffic (totalGB=0) are skipped for the corresponding field — bulk extend never converts unlimited to limited. A client that was auto-disabled solely because it was depleted (expired or over quota) is automatically re-enabled — locally and on its node — when the adjustment lifts it out of depletion; a manually-disabled or still-depleted client is left disabled. The optional flow directive sets the XTLS flow on every client: \"none\" clears it, \"xtls-rprx-vision\"/\"xtls-rprx-vision-udp443\" set it where the inbound supports it (omit or \"\" to leave it unchanged). The optional limitHwid sets maximum registered devices (0 = unlimited). The optional adTag sets MTProto Telegram sponsor channel (\"none\" clears). Returns the adjusted count and per-email skip reasons.",
         "operationId": "post_panel_api_clients_bulkAdjust",
         "operationId": "post_panel_api_clients_bulkAdjust",
         "requestBody": {
         "requestBody": {
           "required": true,
           "required": true,
@@ -8196,7 +8398,9 @@
                 ],
                 ],
                 "addDays": 30,
                 "addDays": 30,
                 "addBytes": 53687091200,
                 "addBytes": 53687091200,
-                "flow": "xtls-rprx-vision"
+                "flow": "xtls-rprx-vision",
+                "limitHwid": 2,
+                "adTag": "0123456789abcdef0123456789abcdef"
               }
               }
             }
             }
           }
           }

+ 625 - 0
frontend/src/components/command-palette/CommandPalette.css

@@ -0,0 +1,625 @@
+/* --------------------------------------------------------------------------
+   Command Palette Backdrop & Modal
+   -------------------------------------------------------------------------- */
+
+.command-palette-backdrop {
+  position: fixed;
+  inset: 0;
+  z-index: 2000;
+  display: flex;
+  align-items: flex-start;
+  justify-content: center;
+  padding-top: 12vh;
+  background: rgba(0, 0, 0, 0.45);
+  backdrop-filter: blur(8px);
+  -webkit-backdrop-filter: blur(8px);
+  animation: cp-fade-in 0.15s ease-out;
+}
+
+.command-palette-backdrop.light {
+  background: rgba(0, 0, 0, 0.25);
+}
+
+.command-palette-backdrop.ultra {
+  background: rgba(0, 0, 0, 0.75);
+}
+
+@keyframes cp-fade-in {
+  from {
+    opacity: 0;
+  }
+  to {
+    opacity: 1;
+  }
+}
+
+.command-palette-modal {
+  position: relative;
+  width: 680px;
+  max-width: 92vw;
+  max-height: 72vh;
+  display: flex;
+  flex-direction: column;
+  border-radius: 14px;
+  overflow: hidden;
+  animation: cp-scale-in 0.18s cubic-bezier(0.16, 1, 0.3, 1);
+  transition:
+    background 0.2s ease,
+    border-color 0.2s ease,
+    color 0.2s ease,
+    box-shadow 0.2s ease;
+}
+
+@keyframes cp-scale-in {
+  from {
+    opacity: 0;
+    transform: scale(0.96) translateY(-10px);
+  }
+  to {
+    opacity: 1;
+    transform: scale(1) translateY(0);
+  }
+}
+
+.command-palette-modal.light,
+body.light .command-palette-modal {
+  background: rgba(255, 255, 255, 0.96);
+  color: #1f1f1f;
+  border: 1px solid rgba(0, 0, 0, 0.12);
+  box-shadow:
+    0 20px 50px rgba(0, 0, 0, 0.16),
+    0 2px 8px rgba(0, 0, 0, 0.08);
+}
+
+.command-palette-modal.dark,
+body.dark .command-palette-modal {
+  background: rgba(35, 37, 43, 0.95);
+  color: #ffffff;
+  border: 1px solid rgba(255, 255, 255, 0.12);
+  box-shadow:
+    0 25px 60px rgba(0, 0, 0, 0.65),
+    0 4px 16px rgba(0, 0, 0, 0.4);
+}
+
+.command-palette-modal.ultra,
+html[data-theme='ultra-dark'] .command-palette-modal {
+  background: rgba(16, 16, 19, 0.98);
+  color: #ffffff;
+  border: 1px solid rgba(255, 255, 255, 0.18);
+  box-shadow: 0 30px 70px rgba(0, 0, 0, 0.9);
+}
+
+/* --------------------------------------------------------------------------
+   Header & Search Input
+   -------------------------------------------------------------------------- */
+
+.command-palette-header {
+  display: flex;
+  align-items: center;
+  padding: 16px 20px;
+  gap: 14px;
+  border-bottom: 1px solid;
+}
+
+.command-palette-modal.light .command-palette-header {
+  border-bottom-color: rgba(0, 0, 0, 0.08);
+}
+
+.command-palette-modal.dark .command-palette-header {
+  border-bottom-color: rgba(255, 255, 255, 0.08);
+}
+
+.command-palette-modal.ultra .command-palette-header {
+  border-bottom-color: rgba(255, 255, 255, 0.12);
+}
+
+.command-palette-search-icon {
+  font-size: 24px;
+  width: 28px;
+  height: 28px;
+  color: #1677ff;
+  flex-shrink: 0;
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  transition: transform 0.2s ease;
+}
+
+.command-palette-search-icon.spinning {
+  color: #1677ff;
+  animation: cp-spin 0.8s linear infinite;
+}
+
+@keyframes cp-spin {
+  100% {
+    transform: rotate(360deg);
+  }
+}
+
+.command-palette-input {
+  flex: 1;
+  background: transparent;
+  border: none;
+  outline: none;
+  font-size: 16.5px;
+  font-weight: 400;
+  font-family: inherit;
+  letter-spacing: -0.2px;
+}
+
+.command-palette-modal.light .command-palette-input {
+  color: #1f1f1f;
+}
+
+.command-palette-modal.light .command-palette-input::placeholder {
+  color: #8c8c8c;
+}
+
+.command-palette-modal.dark .command-palette-input,
+.command-palette-modal.ultra .command-palette-input {
+  color: #ffffff;
+}
+
+.command-palette-modal.dark .command-palette-input::placeholder,
+.command-palette-modal.ultra .command-palette-input::placeholder {
+  color: #737373;
+}
+
+/* --------------------------------------------------------------------------
+   Body & Scrollbar
+   -------------------------------------------------------------------------- */
+
+.command-palette-body {
+  flex: 1;
+  overflow-y: auto;
+  padding: 10px;
+  max-height: calc(72vh - 116px);
+  scrollbar-width: thin;
+}
+
+.command-palette-modal.light .command-palette-body {
+  scrollbar-color: rgba(0, 0, 0, 0.2) transparent;
+}
+
+.command-palette-modal.dark .command-palette-body,
+.command-palette-modal.ultra .command-palette-body {
+  scrollbar-color: rgba(255, 255, 255, 0.2) transparent;
+}
+
+.command-palette-body::-webkit-scrollbar {
+  width: 6px;
+}
+
+.command-palette-body::-webkit-scrollbar-track {
+  background: transparent;
+}
+
+.command-palette-modal.light .command-palette-body::-webkit-scrollbar-thumb {
+  background: rgba(0, 0, 0, 0.2);
+  border-radius: 3px;
+}
+
+.command-palette-modal.light .command-palette-body::-webkit-scrollbar-thumb:hover {
+  background: rgba(0, 0, 0, 0.35);
+}
+
+.command-palette-modal.dark .command-palette-body::-webkit-scrollbar-thumb,
+.command-palette-modal.ultra .command-palette-body::-webkit-scrollbar-thumb {
+  background: rgba(255, 255, 255, 0.2);
+  border-radius: 3px;
+}
+
+.command-palette-modal.dark .command-palette-body::-webkit-scrollbar-thumb:hover,
+.command-palette-modal.ultra .command-palette-body::-webkit-scrollbar-thumb:hover {
+  background: rgba(255, 255, 255, 0.35);
+}
+
+/* --------------------------------------------------------------------------
+   Groups & Items
+   -------------------------------------------------------------------------- */
+
+.command-palette-group {
+  margin-bottom: 8px;
+}
+
+.command-palette-group-title {
+  padding: 6px 12px 4px;
+  font-size: 11px;
+  font-weight: 700;
+  text-transform: uppercase;
+  letter-spacing: 0.6px;
+}
+
+.command-palette-modal.light .command-palette-group-title {
+  color: #8c8c8c;
+}
+
+.command-palette-modal.dark .command-palette-group-title,
+.command-palette-modal.ultra .command-palette-group-title {
+  color: #8c8c8c;
+}
+
+.command-palette-item {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  width: 100%;
+  padding: 9px 14px;
+  background: transparent;
+  border: none;
+  border-radius: 9px;
+  cursor: pointer;
+  text-align: start;
+  font-family: inherit;
+  /* The row was a <button>; keep the UA line-height it had so swapping the
+     tag does not grow every row by the panel's body line-height. */
+  line-height: normal;
+  color: inherit;
+  transition:
+    background 0.12s ease,
+    color 0.12s ease;
+  user-select: none;
+}
+
+.command-palette-modal.light .command-palette-item.active {
+  background: #e6f4ff;
+  color: #0958d9;
+}
+
+.command-palette-modal.dark .command-palette-item.active {
+  background: rgba(22, 119, 255, 0.2);
+  color: #4096ff;
+}
+
+.command-palette-modal.ultra .command-palette-item.active {
+  background: rgba(22, 119, 255, 0.28);
+  color: #69b1ff;
+}
+
+.command-palette-item-main {
+  display: flex;
+  align-items: center;
+  gap: 14px;
+  min-width: 0;
+  flex: 1;
+}
+
+.command-palette-item-icon {
+  font-size: 20px;
+  width: 26px;
+  height: 26px;
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  flex-shrink: 0;
+}
+
+.command-palette-item-content {
+  display: flex;
+  flex-direction: column;
+  min-width: 0;
+  overflow: hidden;
+}
+
+.command-palette-item-title {
+  font-size: 14px;
+  font-weight: 500;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+
+.command-palette-item-subtitle {
+  font-size: 12px;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+
+.command-palette-modal.light .command-palette-item-subtitle {
+  color: #8c8c8c;
+}
+
+.command-palette-modal.dark .command-palette-item-subtitle,
+.command-palette-modal.ultra .command-palette-item-subtitle {
+  color: #8c8c8c;
+}
+
+.command-palette-item-actions {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  margin-inline-start: 12px;
+  flex-shrink: 0;
+}
+
+.command-palette-action-btn {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  width: 28px;
+  height: 28px;
+  padding: 0;
+  border: none;
+  border-radius: 6px;
+  background: transparent;
+  cursor: pointer;
+  font-size: 15px;
+  transition: all 0.15s ease;
+}
+
+.command-palette-modal.light .command-palette-action-btn {
+  color: rgba(0, 0, 0, 0.4);
+}
+
+.command-palette-modal.light .command-palette-action-btn:hover {
+  color: #0958d9;
+  background: rgba(0, 0, 0, 0.06);
+}
+
+.command-palette-modal.dark .command-palette-action-btn {
+  color: rgba(255, 255, 255, 0.45);
+}
+
+.command-palette-modal.dark .command-palette-action-btn:hover {
+  color: #4096ff;
+  background: rgba(255, 255, 255, 0.1);
+}
+
+.command-palette-modal.ultra .command-palette-action-btn {
+  color: rgba(255, 255, 255, 0.5);
+}
+
+.command-palette-modal.ultra .command-palette-action-btn:hover {
+  color: #69b1ff;
+  background: rgba(255, 255, 255, 0.15);
+}
+
+.command-palette-item.active .command-palette-action-btn {
+  color: inherit;
+  opacity: 0.8;
+}
+
+.command-palette-item.active .command-palette-action-btn:hover {
+  opacity: 1;
+}
+
+.command-palette-tooltip {
+  z-index: 2500 !important;
+}
+
+.command-palette-empty {
+  padding: 36px 16px;
+  text-align: center;
+  font-size: 14px;
+}
+
+.command-palette-modal.light .command-palette-empty {
+  color: #8c8c8c;
+}
+
+.command-palette-modal.dark .command-palette-empty,
+.command-palette-modal.ultra .command-palette-empty {
+  color: #8c8c8c;
+}
+
+/* --------------------------------------------------------------------------
+   Footer & Keyboard Badges
+   -------------------------------------------------------------------------- */
+
+.command-palette-footer {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 10px 18px;
+  font-size: 11.5px;
+  border-top: 1px solid;
+}
+
+.command-palette-modal.light .command-palette-footer {
+  background: #fafafa;
+  border-top-color: rgba(0, 0, 0, 0.08);
+  color: #8c8c8c;
+}
+
+.command-palette-modal.dark .command-palette-footer {
+  background: rgba(21, 22, 26, 0.95);
+  border-top-color: rgba(255, 255, 255, 0.08);
+  color: #8c8c8c;
+}
+
+.command-palette-modal.ultra .command-palette-footer {
+  background: #050507;
+  border-top-color: rgba(255, 255, 255, 0.12);
+  color: #8c8c8c;
+}
+
+.command-palette-kbd-group {
+  display: flex;
+  align-items: center;
+  gap: 14px;
+}
+
+.command-palette-kbd {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  min-width: 22px;
+  height: 22px;
+  padding: 0 5px;
+  font-family: inherit;
+  font-size: 11.5px;
+  font-weight: 600;
+  line-height: 1;
+  border-radius: 4px;
+  margin-inline-end: 4px;
+}
+
+.command-palette-modal.light .command-palette-kbd {
+  background: #ffffff;
+  border: 1px solid #d9d9d9;
+  color: #595959;
+  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06);
+}
+
+.command-palette-modal.dark .command-palette-kbd {
+  background: rgba(255, 255, 255, 0.1);
+  border: 1px solid rgba(255, 255, 255, 0.15);
+  color: #d9d9d9;
+}
+
+.command-palette-modal.ultra .command-palette-kbd {
+  background: rgba(255, 255, 255, 0.15);
+  border: 1px solid rgba(255, 255, 255, 0.25);
+  color: #ffffff;
+}
+
+/* --------------------------------------------------------------------------
+   Sidebar Trigger Button
+   -------------------------------------------------------------------------- */
+
+.sidebar-command-trigger {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  width: calc(100% - 16px);
+  height: 36px;
+  box-sizing: border-box;
+  margin: 8px 8px 4px;
+  padding: 0 10px;
+  border-radius: 7px;
+  font-size: 13px;
+  cursor: pointer;
+  overflow: hidden;
+  white-space: nowrap;
+  transition: all 0.2s cubic-bezier(0.2, 0, 0, 1);
+}
+
+.sidebar-command-left {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  min-width: 0;
+  overflow: hidden;
+  white-space: nowrap;
+}
+
+.sidebar-command-trigger .sidebar-command-icon {
+  font-size: 15px;
+  flex-shrink: 0;
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  width: 18px;
+}
+
+.sidebar-command-text {
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+  opacity: 1;
+  max-width: 130px;
+  transition:
+    opacity 0.2s ease,
+    max-width 0.2s ease;
+}
+
+.sidebar-command-trigger.collapsed {
+  justify-content: center;
+  padding: 0;
+}
+
+.sidebar-command-trigger.collapsed .sidebar-command-left {
+  justify-content: center;
+  width: 100%;
+  gap: 0;
+}
+
+.sidebar-command-trigger.collapsed .sidebar-command-icon {
+  margin: 0 auto;
+  font-size: 16px;
+  width: 16px;
+  height: 16px;
+}
+
+.sidebar-command-trigger.collapsed .sidebar-command-text {
+  display: none;
+}
+
+.sidebar-command-trigger.collapsed .sidebar-command-kbd {
+  display: none;
+}
+
+body.light .sidebar-command-trigger {
+  background: rgba(0, 0, 0, 0.04);
+  border: 1px solid rgba(0, 0, 0, 0.08);
+  color: #595959;
+}
+
+body.light .sidebar-command-trigger:hover {
+  background: rgba(0, 0, 0, 0.08);
+  border-color: rgba(0, 0, 0, 0.15);
+  color: #1f1f1f;
+}
+
+body.dark .sidebar-command-trigger {
+  background: rgba(255, 255, 255, 0.05);
+  border: 1px solid rgba(255, 255, 255, 0.08);
+  color: #8c8c8c;
+}
+
+body.dark .sidebar-command-trigger:hover {
+  background: rgba(255, 255, 255, 0.09);
+  border-color: rgba(255, 255, 255, 0.18);
+  color: #ffffff;
+}
+
+.sidebar-command-trigger .sidebar-command-icon {
+  font-size: 15px;
+}
+
+.sidebar-command-trigger .sidebar-command-kbd {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  gap: 2.5px;
+  height: 20px;
+  padding: 0 5px;
+  border-radius: 4px;
+  user-select: none;
+}
+
+.sidebar-command-trigger .sidebar-command-kbd .kbd-cmd {
+  font-family:
+    -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
+  font-size: 10px;
+  line-height: 1;
+  display: inline-block;
+  opacity: 0.9;
+}
+
+.sidebar-command-trigger .sidebar-command-kbd .kbd-key {
+  font-family:
+    -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
+  font-size: 11px;
+  font-weight: 700;
+  line-height: 1;
+  display: inline-block;
+}
+
+body.light .sidebar-command-trigger .sidebar-command-kbd {
+  background: rgba(0, 0, 0, 0.06);
+  border: 1px solid rgba(0, 0, 0, 0.12);
+  color: #595959;
+}
+
+body.dark .sidebar-command-trigger .sidebar-command-kbd {
+  background: rgba(255, 255, 255, 0.1);
+  border: 1px solid rgba(255, 255, 255, 0.15);
+  color: #d9d9d9;
+}
+
+body.dark .sidebar-command-trigger:hover .sidebar-command-kbd {
+  border-color: rgba(255, 255, 255, 0.25);
+  color: #ffffff;
+}

+ 802 - 0
frontend/src/components/command-palette/CommandPalette.tsx

@@ -0,0 +1,802 @@
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import type { ReactNode } from 'react';
+import { useNavigate } from 'react-router';
+import { useTranslation } from 'react-i18next';
+import { ConfigProvider, Tag, Tooltip, message } from 'antd';
+import {
+  ApiOutlined,
+  ApartmentOutlined,
+  CheckCircleFilled,
+  ClockCircleOutlined,
+  CloseCircleFilled,
+  CloudServerOutlined,
+  ClusterOutlined,
+  CodeOutlined,
+  CopyOutlined,
+  DashboardOutlined,
+  DatabaseOutlined,
+  ExportOutlined,
+  FileTextOutlined,
+  GlobalOutlined,
+  ImportOutlined,
+  LoadingOutlined,
+  MailOutlined,
+  MessageOutlined,
+  MoonOutlined,
+  PlusOutlined,
+  ReloadOutlined,
+  SafetyOutlined,
+  SearchOutlined,
+  SettingOutlined,
+  SunOutlined,
+  SwapOutlined,
+  TagsOutlined,
+  TeamOutlined,
+  ToolOutlined,
+} from '@ant-design/icons';
+
+import { ClipboardManager, HttpUtil, SizeFormatter } from '@/utils';
+import { activateOnKey } from '@/utils/a11y';
+import { useInboundOptions } from '@/api/queries/useInboundOptions';
+import { useAllSettings } from '@/api/queries/useAllSettings';
+import { useTheme } from '@/hooks/useTheme';
+import type { ClientRecord, InboundOption } from '@/schemas/client';
+import { commandPaletteStore, useCommandPalette } from './useCommandPalette';
+import './CommandPalette.css';
+
+interface PaletteItem {
+  id: string;
+  category: 'clients' | 'inbounds' | 'navigation' | 'settings' | 'actions';
+  title: string;
+  subtitle?: string;
+  keywords?: string[];
+  icon: ReactNode;
+  tag?: ReactNode;
+  action: () => void | Promise<void>;
+  secondaryAction?: {
+    label: string;
+    icon: ReactNode;
+    execute: (e: React.MouseEvent) => void;
+  };
+}
+
+export default function CommandPalette() {
+  const { t } = useTranslation();
+  const navigate = useNavigate();
+  const { isDark, isUltra, toggleTheme, toggleUltra, antdThemeConfig } = useTheme();
+  const { isOpen, close } = useCommandPalette();
+  const { allSetting } = useAllSettings();
+  const { data: inbounds = [] } = useInboundOptions();
+
+  const [query, setQuery] = useState('');
+  const [debouncedQuery, setDebouncedQuery] = useState('');
+  const [clientSearch, setClientSearch] = useState<{ query: string; items: ClientRecord[] }>({
+    query: '',
+    items: [],
+  });
+  const [loadingClients, setLoadingClients] = useState(false);
+  const [activeIndex, setActiveIndex] = useState(0);
+
+  const inputRef = useRef<HTMLInputElement>(null);
+  const listRef = useRef<HTMLDivElement>(null);
+
+  useEffect(() => {
+    function handleGlobalKeyDown(e: KeyboardEvent) {
+      const isK = e.code === 'KeyK' || e.key === 'k' || e.key === 'K';
+      if ((e.metaKey || e.ctrlKey) && isK) {
+        e.preventDefault();
+        if (isOpen) {
+          close();
+        } else {
+          commandPaletteStore.open();
+        }
+      } else if (e.key === 'Escape' && isOpen) {
+        e.preventDefault();
+        close();
+      }
+    }
+
+    window.addEventListener('keydown', handleGlobalKeyDown, { capture: true });
+    return () => {
+      window.removeEventListener('keydown', handleGlobalKeyDown, { capture: true });
+    };
+  }, [isOpen, close]);
+
+  const [prevIsOpen, setPrevIsOpen] = useState(isOpen);
+  if (isOpen !== prevIsOpen) {
+    setPrevIsOpen(isOpen);
+    if (!isOpen) {
+      setQuery('');
+      setDebouncedQuery('');
+      setClientSearch({ query: '', items: [] });
+      setActiveIndex(0);
+      setLoadingClients(false);
+    }
+  }
+
+  const [prevQuery, setPrevQuery] = useState(query);
+  if (query !== prevQuery) {
+    setPrevQuery(query);
+    setActiveIndex(0);
+    if (!query.trim()) {
+      setDebouncedQuery('');
+      setClientSearch({ query: '', items: [] });
+      setLoadingClients(false);
+    }
+  }
+
+  useEffect(() => {
+    if (isOpen) {
+      setTimeout(() => inputRef.current?.focus(), 50);
+    }
+  }, [isOpen]);
+
+  useEffect(() => {
+    if (!isOpen) {
+      return;
+    }
+
+    const trimmed = query.trim();
+    if (!trimmed || trimmed === debouncedQuery) {
+      return;
+    }
+
+    const timer = window.setTimeout(() => {
+      setLoadingClients(true);
+      setDebouncedQuery(trimmed);
+    }, 300);
+
+    return () => {
+      window.clearTimeout(timer);
+    };
+  }, [isOpen, query, debouncedQuery]);
+
+  useEffect(() => {
+    if (!isOpen || debouncedQuery.length < 1) {
+      return;
+    }
+
+    let isCurrent = true;
+    const controller = new AbortController();
+
+    HttpUtil.get(
+      `/panel/api/clients/list/paged?search=${encodeURIComponent(debouncedQuery)}&pageSize=8`,
+      undefined,
+      { silent: true, signal: controller.signal },
+    )
+      .then((msg) => {
+        if (!isCurrent) return;
+        if (
+          msg?.success &&
+          msg?.obj &&
+          Array.isArray((msg.obj as { items?: ClientRecord[] }).items)
+        ) {
+          setClientSearch({
+            query: debouncedQuery,
+            items: (msg.obj as { items: ClientRecord[] }).items,
+          });
+        } else {
+          setClientSearch({ query: debouncedQuery, items: [] });
+        }
+      })
+      .finally(() => {
+        if (isCurrent) setLoadingClients(false);
+      });
+
+    return () => {
+      isCurrent = false;
+      controller.abort();
+    };
+  }, [isOpen, debouncedQuery]);
+
+  const copySubscription = useCallback(
+    async (client: ClientRecord) => {
+      if (!client.subId || !allSetting.subURI) {
+        message.warning(t('pages.clients.noSubId'));
+        return;
+      }
+      const link = `${allSetting.subURI}${client.subId}`;
+      const ok = await ClipboardManager.copyText(link);
+      if (ok) message.success(t('copied'));
+    },
+    [allSetting.subURI, t],
+  );
+
+  const restartXray = useCallback(async () => {
+    close();
+    const msg = await HttpUtil.post('/panel/api/server/restartXrayService', undefined, {
+      silentSuccess: true,
+    });
+    if (msg?.success) {
+      message.success(t('commandPalette.restartXraySuccess'));
+    }
+  }, [close, t]);
+
+  const cycleTheme = useCallback(() => {
+    if (!isDark) {
+      toggleTheme();
+      if (isUltra) toggleUltra();
+    } else if (!isUltra) {
+      toggleUltra();
+    } else {
+      toggleUltra();
+      toggleTheme();
+    }
+    close();
+  }, [isDark, isUltra, toggleTheme, toggleUltra, close]);
+
+  const trimmedQuery = query.trim();
+  const isDebouncing = isOpen && trimmedQuery.length > 0 && trimmedQuery !== debouncedQuery;
+  const isClientSearching =
+    isOpen &&
+    trimmedQuery.length > 0 &&
+    (loadingClients || isDebouncing || clientSearch.query !== trimmedQuery);
+
+  const items = useMemo<PaletteItem[]>(() => {
+    const list: PaletteItem[] = [];
+    const q = query.trim().toLowerCase();
+
+    const matches = (title: string, subtitle?: string, keywords: string[] = []) => {
+      if (!q) return true;
+      if (title.toLowerCase().includes(q)) return true;
+      if (subtitle && subtitle.toLowerCase().includes(q)) return true;
+      return keywords.some((k) => k.toLowerCase().includes(q));
+    };
+
+    const trimmed = query.trim();
+    if (trimmed.length > 0 && clientSearch.query === trimmed && clientSearch.items.length > 0) {
+      clientSearch.items.forEach((c) => {
+        const up = Number(c.traffic?.up || 0);
+        const down = Number(c.traffic?.down || 0);
+        const total = Number(c.traffic?.total || c.totalGB || 0);
+        const trafficUsed = SizeFormatter.sizeFormat(up + down);
+        const trafficTotal = total > 0 ? SizeFormatter.sizeFormat(total) : '∞';
+        const isOnline = c.enable !== false;
+
+        list.push({
+          id: `client-${c.id ?? c.email}`,
+          category: 'clients',
+          title: c.email,
+          subtitle: `${trafficUsed} / ${trafficTotal}${c.comment ? ` · ${c.comment}` : ''}`,
+          icon: isOnline ? (
+            <CheckCircleFilled style={{ color: '#52c41a' }} />
+          ) : (
+            <CloseCircleFilled style={{ color: '#ff4d4f' }} />
+          ),
+          action: () => {
+            close();
+            navigate(`/clients?search=${encodeURIComponent(c.email)}`);
+          },
+          secondaryAction:
+            c.subId && allSetting.subURI
+              ? {
+                  label: t('commandPalette.copySubscription'),
+                  icon: <CopyOutlined />,
+                  execute: (e) => {
+                    e.stopPropagation();
+                    copySubscription(c);
+                  },
+                }
+              : undefined,
+        });
+      });
+    }
+
+    const matchedInbounds = inbounds.filter((ib: InboundOption) => {
+      if (!q) return false;
+      return (
+        (ib.tag && ib.tag.toLowerCase().includes(q)) ||
+        (ib.remark && ib.remark.toLowerCase().includes(q)) ||
+        (ib.protocol && ib.protocol.toLowerCase().includes(q)) ||
+        (ib.port && String(ib.port).includes(q))
+      );
+    });
+
+    matchedInbounds.slice(0, 8).forEach((ib) => {
+      const tags: ReactNode[] = [];
+      if (ib.protocol) {
+        tags.push(
+          <Tag key="protocol" color="purple">
+            {ib.protocol}
+          </Tag>,
+        );
+      }
+      if (ib.network) {
+        const n = ib.network.toLowerCase();
+        let netLabel = n.toUpperCase();
+        if (n === 'httpupgrade') netLabel = 'HTTPUpgrade';
+        else if (n === 'splithttp') netLabel = 'SplitHTTP';
+        else if (n === 'xhttp') netLabel = 'XHTTP';
+        tags.push(
+          <Tag key="network" color="green">
+            {netLabel}
+          </Tag>,
+        );
+      }
+      if (ib.security && ib.security !== 'none') {
+        const s = ib.security.toLowerCase();
+        const secLabel = s === 'reality' ? 'Reality' : s === 'tls' ? 'TLS' : s.toUpperCase();
+        tags.push(
+          <Tag key="security" color="blue">
+            {secLabel}
+          </Tag>,
+        );
+      }
+
+      list.push({
+        id: `inbound-${ib.id}`,
+        category: 'inbounds',
+        title: ib.remark || ib.tag || `Inbound #${ib.id}`,
+        subtitle: `Port ${ib.port || ''}`,
+        icon: <ImportOutlined style={{ color: '#1677ff' }} />,
+        tag:
+          tags.length > 0 ? (
+            <div style={{ display: 'inline-flex', gap: 4, flexWrap: 'wrap' }}>{tags}</div>
+          ) : undefined,
+        action: () => {
+          close();
+          navigate(`/inbounds?search=${encodeURIComponent(ib.remark || String(ib.port || ''))}`);
+        },
+      });
+    });
+
+    const pages = [
+      {
+        path: '/',
+        title: t('menu.dashboard'),
+        keywords: ['overview', 'dashboard', 'cpu', 'ram', 'memory', 'traffic', 'speed'],
+        icon: <DashboardOutlined />,
+      },
+      {
+        path: '/inbounds',
+        title: t('menu.inbounds'),
+        keywords: [
+          'inbounds',
+          'ports',
+          'vless',
+          'vmess',
+          'reality',
+          'trojan',
+          'shadowsocks',
+          'wireguard',
+          'hysteria',
+        ],
+        icon: <ImportOutlined />,
+      },
+      {
+        path: '/clients',
+        title: t('menu.clients'),
+        keywords: ['clients', 'users', 'sub', 'traffic', 'quota'],
+        icon: <TeamOutlined />,
+      },
+      {
+        path: '/groups',
+        title: t('menu.groups'),
+        keywords: ['groups', 'tags', 'batch'],
+        icon: <TagsOutlined />,
+      },
+      {
+        path: '/nodes',
+        title: t('menu.nodes'),
+        keywords: ['nodes', 'servers', 'cluster', 'remote nodes'],
+        icon: <ClusterOutlined />,
+      },
+      {
+        path: '/hosts',
+        title: t('menu.hosts'),
+        keywords: ['hosts', 'sni', 'domains'],
+        icon: <GlobalOutlined />,
+      },
+      {
+        path: '/outbound',
+        title: t('menu.outbounds'),
+        keywords: ['outbounds', 'freedom', 'blackhole', 'socks', 'http', 'warp', 'nord', 'pia'],
+        icon: <ExportOutlined />,
+      },
+      {
+        path: '/routing',
+        title: t('menu.routing'),
+        keywords: ['routing', 'rules', 'geoip', 'geosite', 'direct', 'block'],
+        icon: <SwapOutlined />,
+      },
+      {
+        path: '/settings',
+        title: t('menu.settings'),
+        keywords: ['settings', 'config', 'port', 'password', 'ssl', 'telegram'],
+        icon: <SettingOutlined />,
+      },
+      {
+        path: '/xray',
+        title: t('menu.xray'),
+        keywords: ['xray', 'templates', 'balancer', 'dns'],
+        icon: <ToolOutlined />,
+      },
+      {
+        path: '/api-docs',
+        title: t('menu.apiDocs'),
+        keywords: ['api', 'api docs', 'swagger', 'rest api', 'endpoints'],
+        icon: <ApiOutlined />,
+      },
+    ];
+
+    pages
+      .filter((p) => matches(p.title, undefined, p.keywords))
+      .forEach((p) => {
+        list.push({
+          id: `nav-${p.path}`,
+          category: 'navigation',
+          title: p.title,
+          keywords: p.keywords,
+          icon: p.icon,
+          action: () => {
+            close();
+            navigate(p.path);
+          },
+        });
+      });
+
+    const settingsSubSections = [
+      {
+        path: '/settings#general',
+        title: `${t('menu.settings')} · ${t('pages.settings.panelSettings')}`,
+        subtitle: t('pages.settings.panelSettings'),
+        keywords: ['general', 'webPort', 'webBasePath', 'listenIP', 'ssl', 'certificate'],
+        icon: <SettingOutlined />,
+      },
+      {
+        path: '/settings#security',
+        title: `${t('menu.settings')} · ${t('pages.settings.securitySettings')}`,
+        subtitle: t('pages.settings.securitySettings'),
+        keywords: ['security', 'password', 'username', '2fa', 'two factor', 'login limit'],
+        icon: <SafetyOutlined />,
+      },
+      {
+        path: '/settings#telegram',
+        title: `${t('menu.settings')} · ${t('pages.settings.TGBotSettings')}`,
+        subtitle: t('pages.settings.TGBotSettings'),
+        keywords: ['telegram', 'tgbot', 'bot token', 'chat id', 'notifications', 'alerts'],
+        icon: <MessageOutlined />,
+      },
+      {
+        path: '/settings#email',
+        title: `${t('menu.settings')} · ${t('pages.settings.emailSettings')}`,
+        subtitle: t('pages.settings.emailSettings'),
+        keywords: ['email', 'smtp', 'mail', 'crash alerts'],
+        icon: <MailOutlined />,
+      },
+      {
+        path: '/settings#subscription',
+        title: `${t('menu.settings')} · ${t('pages.settings.subSettings')}`,
+        subtitle: t('pages.settings.subSettings'),
+        keywords: ['subscription', 'subPort', 'subURI', 'subDomain', 'reverse proxy'],
+        icon: <CloudServerOutlined />,
+      },
+      {
+        path: '/settings#subscription-formats',
+        title: `${t('menu.settings')} · ${t('menu.subFormats')}`,
+        subtitle: t('menu.subFormats'),
+        keywords: ['formats', 'clash', 'sing-box', 'v2ray', 'json', 'sub formats'],
+        icon: <CodeOutlined />,
+      },
+      {
+        path: '/settings#subscription-balancers',
+        title: `${t('menu.settings')} · ${t('pages.settings.subBalancers.menu')}`,
+        subtitle: t('pages.settings.subBalancers.menu'),
+        keywords: ['balancers', 'sub balancers', 'balancer nodes'],
+        icon: <ApartmentOutlined />,
+      },
+      {
+        path: '/xray#basic',
+        title: `${t('menu.xray')} · ${t('pages.xray.basicTemplate')}`,
+        subtitle: t('pages.xray.basicTemplate'),
+        keywords: ['basics', 'freedom strategy', 'happy eyeballs', 'torrent', 'connection'],
+        icon: <ToolOutlined />,
+      },
+      {
+        path: '/xray#basic',
+        title: `${t('menu.xray')} · ${t('pages.xray.metricsListen')}`,
+        subtitle: t('pages.xray.metricsListen'),
+        keywords: [
+          'metrics',
+          'prometheus',
+          'statistics',
+          'listen',
+          'statsInbound',
+          'statsOutbound',
+          'metrics_out',
+        ],
+        icon: <DashboardOutlined />,
+      },
+      {
+        path: '/xray#basic',
+        title: `${t('menu.xray')} · ${t('pages.xray.connectionLimits')}`,
+        subtitle: t('pages.xray.connectionLimits'),
+        keywords: ['limits', 'idle timeout', 'bufferSize', 'connIdle', 'timeout'],
+        icon: <ClockCircleOutlined />,
+      },
+      {
+        path: '/xray#basic',
+        title: `${t('menu.xray')} · ${t('pages.xray.logConfigs')}`,
+        subtitle: t('pages.xray.logConfigs'),
+        keywords: ['logs', 'access log', 'error log', 'dns log', 'mask address', 'loglevel'],
+        icon: <FileTextOutlined />,
+      },
+      {
+        path: '/xray#balancer',
+        title: `${t('menu.xray')} · ${t('pages.xray.Balancers')}`,
+        subtitle: t('pages.xray.Balancers'),
+        keywords: ['balancers', 'leastPing', 'roundRobin', 'fallback', 'strategy'],
+        icon: <ClusterOutlined />,
+      },
+      {
+        path: '/xray#dns',
+        title: `${t('menu.xray')} · DNS`,
+        subtitle: 'DNS',
+        keywords: ['dns', 'dns servers', 'hosts', 'doh', 'dot', 'cloudflare dns'],
+        icon: <DatabaseOutlined />,
+      },
+      {
+        path: '/xray#outbound',
+        title: `${t('menu.xray')} · ${t('pages.xray.Outbounds')}`,
+        subtitle: t('pages.xray.Outbounds'),
+        keywords: ['outbound', 'freedom', 'direct', 'proxy outbounds'],
+        icon: <ExportOutlined />,
+      },
+      {
+        path: '/xray#routing',
+        title: `${t('menu.xray')} · ${t('pages.xray.basicRouting')}`,
+        subtitle: t('pages.xray.basicRouting'),
+        keywords: ['routing', 'routing rules', 'geoip', 'geosite', 'block', 'direct'],
+        icon: <SwapOutlined />,
+      },
+      {
+        path: '/xray#advanced',
+        title: `${t('menu.xray')} · ${t('pages.xray.advancedTemplate')}`,
+        subtitle: t('pages.xray.advancedTemplate'),
+        keywords: ['advanced', 'json template', 'advanced config', 'custom json'],
+        icon: <CodeOutlined />,
+      },
+    ];
+
+    settingsSubSections
+      .filter((s) => matches(s.title, s.subtitle, s.keywords))
+      .forEach((s) => {
+        list.push({
+          id: `setting-${s.path}-${s.title}`,
+          category: 'settings',
+          title: s.title,
+          subtitle: s.subtitle,
+          keywords: s.keywords,
+          icon: s.icon,
+          action: () => {
+            close();
+            navigate(s.path);
+          },
+        });
+      });
+
+    const actions: PaletteItem[] = [
+      {
+        id: 'act-restart-xray',
+        category: 'actions',
+        title: t('commandPalette.restartXray'),
+        subtitle: t('pages.index.restartXray'),
+        keywords: ['restart', 'xray restart', 'reboot xray'],
+        icon: <ReloadOutlined style={{ color: '#faad14' }} />,
+        action: restartXray,
+      },
+      {
+        id: 'act-cycle-theme',
+        category: 'actions',
+        title: t('menu.theme'),
+        subtitle: isUltra ? 'Ultra Dark' : isDark ? 'Dark' : 'Light',
+        keywords: ['theme', 'light', 'dark', 'ultra'],
+        icon: isDark ? <SunOutlined /> : <MoonOutlined />,
+        action: cycleTheme,
+      },
+      {
+        id: 'act-add-inbound',
+        category: 'actions',
+        title: t('pages.inbounds.addInbound'),
+        subtitle: t('menu.inbounds'),
+        keywords: ['add inbound', 'create inbound', 'new port', 'new inbound'],
+        icon: <PlusOutlined style={{ color: '#52c41a' }} />,
+        action: () => {
+          close();
+          navigate('/inbounds');
+        },
+      },
+      {
+        id: 'act-add-client',
+        category: 'actions',
+        title: t('pages.clients.addClient'),
+        subtitle: t('menu.clients'),
+        keywords: ['add client', 'create user', 'new client', 'new user'],
+        icon: <PlusOutlined style={{ color: '#52c41a' }} />,
+        action: () => {
+          close();
+          navigate('/clients');
+        },
+      },
+    ];
+
+    actions.filter((a) => matches(a.title, a.subtitle, a.keywords)).forEach((a) => list.push(a));
+
+    return list;
+  }, [
+    query,
+    clientSearch,
+    inbounds,
+    isDark,
+    isUltra,
+    allSetting.subURI,
+    t,
+    close,
+    navigate,
+    copySubscription,
+    restartXray,
+    cycleTheme,
+  ]);
+
+  const clampedActiveIndex = Math.min(activeIndex, Math.max(0, items.length - 1));
+
+  useEffect(() => {
+    if (!listRef.current) return;
+    const activeEl = listRef.current.querySelector(
+      `.command-palette-item[data-index="${clampedActiveIndex}"]`,
+    ) as HTMLElement | null;
+    if (activeEl) {
+      activeEl.scrollIntoView({ block: 'nearest' });
+    }
+  }, [clampedActiveIndex]);
+
+  const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
+    if (e.key === 'ArrowDown') {
+      e.preventDefault();
+      setActiveIndex((prev) => (items.length ? (prev + 1) % items.length : 0));
+    } else if (e.key === 'ArrowUp') {
+      e.preventDefault();
+      setActiveIndex((prev) => (items.length ? (prev - 1 + items.length) % items.length : 0));
+    } else if (e.key === 'Enter') {
+      e.preventDefault();
+      const current = items[clampedActiveIndex];
+      if (current) current.action();
+    }
+  };
+
+  if (!isOpen) return null;
+
+  let lastCategory = '';
+  const themeModeClass = isUltra ? 'ultra' : isDark ? 'dark' : 'light';
+
+  return (
+    <ConfigProvider theme={antdThemeConfig}>
+      <div
+        className={`command-palette-backdrop ${themeModeClass}`}
+        role="presentation"
+        onClick={(e) => {
+          if (e.target === e.currentTarget) close();
+        }}
+      >
+        <div
+          className={`command-palette-modal ${themeModeClass}`}
+          role="dialog"
+          aria-modal="true"
+          aria-label={t('commandPalette.title')}
+        >
+          <div className="command-palette-header">
+            {isClientSearching ? (
+              <LoadingOutlined className="command-palette-search-icon spinning" />
+            ) : (
+              <SearchOutlined className="command-palette-search-icon" />
+            )}
+            <input
+              ref={inputRef}
+              className="command-palette-input"
+              type="text"
+              placeholder={t('commandPalette.placeholder')}
+              value={query}
+              onChange={(e) => {
+                setQuery(e.target.value);
+              }}
+              onKeyDown={handleKeyDown}
+            />
+          </div>
+
+          <div className="command-palette-body" ref={listRef}>
+            {!isClientSearching && items.length === 0 && (
+              <div className="command-palette-empty">{t('noData')}</div>
+            )}
+
+            {items.map((item, index) => {
+              const isFirstOfCategory = item.category !== lastCategory;
+              lastCategory = item.category;
+
+              const categoryLabel =
+                item.category === 'clients'
+                  ? t('menu.clients')
+                  : item.category === 'inbounds'
+                    ? t('menu.inbounds')
+                    : item.category === 'navigation'
+                      ? t('commandPalette.navigation')
+                      : item.category === 'settings'
+                        ? t('commandPalette.settings') || t('menu.settings')
+                        : t('commandPalette.actions');
+
+              return (
+                <div key={item.id} className="command-palette-group">
+                  {isFirstOfCategory && (
+                    <div className="command-palette-group-title">{categoryLabel}</div>
+                  )}
+                  <div
+                    role="button"
+                    tabIndex={0}
+                    className={`command-palette-item ${index === clampedActiveIndex ? 'active' : ''}`}
+                    data-index={index}
+                    onClick={() => item.action()}
+                    onKeyDown={(e) => {
+                      // Enter on the nested copy button must activate that
+                      // button, not the row it sits in.
+                      if (e.target === e.currentTarget) activateOnKey(() => item.action())(e);
+                    }}
+                    onMouseEnter={() => setActiveIndex(index)}
+                  >
+                    <div className="command-palette-item-main">
+                      <span className="command-palette-item-icon">{item.icon}</span>
+                      <div className="command-palette-item-content">
+                        <span className="command-palette-item-title">{item.title}</span>
+                        {item.subtitle && (
+                          <span className="command-palette-item-subtitle">{item.subtitle}</span>
+                        )}
+                      </div>
+                    </div>
+
+                    <div className="command-palette-item-actions">
+                      {item.tag}
+                      {item.secondaryAction && (
+                        <Tooltip
+                          title={item.secondaryAction.label}
+                          placement="top"
+                          zIndex={2500}
+                          rootClassName="command-palette-tooltip"
+                        >
+                          <button
+                            type="button"
+                            className="command-palette-action-btn"
+                            onClick={item.secondaryAction.execute}
+                            aria-label={item.secondaryAction.label}
+                          >
+                            {item.secondaryAction.icon}
+                          </button>
+                        </Tooltip>
+                      )}
+                    </div>
+                  </div>
+                </div>
+              );
+            })}
+          </div>
+
+          <div className="command-palette-footer">
+            <div className="command-palette-kbd-group">
+              <span>
+                <kbd className="command-palette-kbd">↑</kbd>
+                <kbd className="command-palette-kbd">↓</kbd>
+                {t('commandPalette.navigate')}
+              </span>
+              <span>
+                <kbd className="command-palette-kbd">↵</kbd>
+                {t('commandPalette.select')}
+              </span>
+              <span>
+                <kbd className="command-palette-kbd">Esc</kbd>
+                {t('close')}
+              </span>
+            </div>
+            <span>3x-ui Command Palette</span>
+          </div>
+        </div>
+      </div>
+    </ConfigProvider>
+  );
+}

+ 49 - 0
frontend/src/components/command-palette/useCommandPalette.ts

@@ -0,0 +1,49 @@
+import { useCallback, useSyncExternalStore } from 'react';
+
+let isOpen = false;
+const listeners = new Set<() => void>();
+
+function notify() {
+  listeners.forEach((listener) => listener());
+}
+
+export const commandPaletteStore = {
+  getSnapshot: () => isOpen,
+  subscribe: (listener: () => void) => {
+    listeners.add(listener);
+    return () => {
+      listeners.delete(listener);
+    };
+  },
+  open: () => {
+    if (!isOpen) {
+      isOpen = true;
+      notify();
+    }
+  },
+  close: () => {
+    if (isOpen) {
+      isOpen = false;
+      notify();
+    }
+  },
+  toggle: () => {
+    isOpen = !isOpen;
+    notify();
+  },
+};
+
+export function useCommandPalette() {
+  const open = useSyncExternalStore(commandPaletteStore.subscribe, commandPaletteStore.getSnapshot);
+
+  const openPalette = useCallback(() => commandPaletteStore.open(), []);
+  const closePalette = useCallback(() => commandPaletteStore.close(), []);
+  const togglePalette = useCallback(() => commandPaletteStore.toggle(), []);
+
+  return {
+    isOpen: open,
+    open: openPalette,
+    close: closePalette,
+    toggle: togglePalette,
+  };
+}

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

@@ -59,6 +59,29 @@ export const EXAMPLES: Record<string, unknown> = {
     "subEnableRouting": false,
     "subEnableRouting": false,
     "subEncrypt": false,
     "subEncrypt": false,
     "subExpiredTemplate": "",
     "subExpiredTemplate": "",
+    "subHappAlwaysHwid": false,
+    "subHappAutoConnect": false,
+    "subHappAutoConnectType": "",
+    "subHappAutoDetect": false,
+    "subHappColorProfile": "",
+    "subHappExcludeApns": false,
+    "subHappExcludeRoutes": "",
+    "subHappFallbackUrl": "",
+    "subHappNewUrl": "",
+    "subHappNoLimit": false,
+    "subHappNotificationExpire": false,
+    "subHappPerAppList": "",
+    "subHappPerAppMode": "",
+    "subHappPingType": "",
+    "subHappProviderId": "",
+    "subHappSubExpire": false,
+    "subHappSubExpireButtonLink": "",
+    "subHappSubInfoButtonLink": "",
+    "subHappSubInfoButtonText": "",
+    "subHappSubInfoColor": "",
+    "subHappSubInfoText": "",
+    "subHappTunMode": "",
+    "subHappTunType": "",
     "subHideSettings": false,
     "subHideSettings": false,
     "subIncyEnableRouting": false,
     "subIncyEnableRouting": false,
     "subIncyRoutingRules": "",
     "subIncyRoutingRules": "",
@@ -70,6 +93,7 @@ export const EXAMPLES: Record<string, unknown> = {
     "subJsonMux": "",
     "subJsonMux": "",
     "subJsonObservatory": "",
     "subJsonObservatory": "",
     "subJsonPath": "",
     "subJsonPath": "",
+    "subJsonRoutingRules": "",
     "subJsonRules": "",
     "subJsonRules": "",
     "subJsonURI": "",
     "subJsonURI": "",
     "subJsonUserAgentRegex": "",
     "subJsonUserAgentRegex": "",
@@ -176,6 +200,29 @@ export const EXAMPLES: Record<string, unknown> = {
     "subEnableRouting": false,
     "subEnableRouting": false,
     "subEncrypt": false,
     "subEncrypt": false,
     "subExpiredTemplate": "",
     "subExpiredTemplate": "",
+    "subHappAlwaysHwid": false,
+    "subHappAutoConnect": false,
+    "subHappAutoConnectType": "",
+    "subHappAutoDetect": false,
+    "subHappColorProfile": "",
+    "subHappExcludeApns": false,
+    "subHappExcludeRoutes": "",
+    "subHappFallbackUrl": "",
+    "subHappNewUrl": "",
+    "subHappNoLimit": false,
+    "subHappNotificationExpire": false,
+    "subHappPerAppList": "",
+    "subHappPerAppMode": "",
+    "subHappPingType": "",
+    "subHappProviderId": "",
+    "subHappSubExpire": false,
+    "subHappSubExpireButtonLink": "",
+    "subHappSubInfoButtonLink": "",
+    "subHappSubInfoButtonText": "",
+    "subHappSubInfoColor": "",
+    "subHappSubInfoText": "",
+    "subHappTunMode": "",
+    "subHappTunType": "",
     "subHideSettings": false,
     "subHideSettings": false,
     "subIncyEnableRouting": false,
     "subIncyEnableRouting": false,
     "subIncyRoutingRules": "",
     "subIncyRoutingRules": "",
@@ -187,6 +234,7 @@ export const EXAMPLES: Record<string, unknown> = {
     "subJsonMux": "",
     "subJsonMux": "",
     "subJsonObservatory": "",
     "subJsonObservatory": "",
     "subJsonPath": "",
     "subJsonPath": "",
+    "subJsonRoutingRules": "",
     "subJsonRules": "",
     "subJsonRules": "",
     "subJsonURI": "",
     "subJsonURI": "",
     "subJsonUserAgentRegex": "",
     "subJsonUserAgentRegex": "",
@@ -664,11 +712,13 @@ export const EXAMPLES: Record<string, unknown> = {
     "id": 1,
     "id": 1,
     "listen": "",
     "listen": "",
     "mtprotoDomain": "",
     "mtprotoDomain": "",
+    "network": "",
     "nodeAddress": "",
     "nodeAddress": "",
     "nodeId": null,
     "nodeId": null,
     "port": 443,
     "port": 443,
     "protocol": "vless",
     "protocol": "vless",
     "remark": "VLESS-443",
     "remark": "VLESS-443",
+    "security": "",
     "shareAddr": "",
     "shareAddr": "",
     "shareAddrStrategy": "",
     "shareAddrStrategy": "",
     "ssMethod": "",
     "ssMethod": "",

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

@@ -194,6 +194,76 @@ export const SCHEMAS: Record<string, unknown> = {
       "subExpiredTemplate": {
       "subExpiredTemplate": {
         "type": "string"
         "type": "string"
       },
       },
+      "subHappAlwaysHwid": {
+        "type": "boolean"
+      },
+      "subHappAutoConnect": {
+        "type": "boolean"
+      },
+      "subHappAutoConnectType": {
+        "type": "string"
+      },
+      "subHappAutoDetect": {
+        "description": "Happ client customization settings (app-management / routing / UX).",
+        "type": "boolean"
+      },
+      "subHappColorProfile": {
+        "type": "string"
+      },
+      "subHappExcludeApns": {
+        "type": "boolean"
+      },
+      "subHappExcludeRoutes": {
+        "type": "string"
+      },
+      "subHappFallbackUrl": {
+        "type": "string"
+      },
+      "subHappNewUrl": {
+        "type": "string"
+      },
+      "subHappNoLimit": {
+        "type": "boolean"
+      },
+      "subHappNotificationExpire": {
+        "type": "boolean"
+      },
+      "subHappPerAppList": {
+        "type": "string"
+      },
+      "subHappPerAppMode": {
+        "type": "string"
+      },
+      "subHappPingType": {
+        "type": "string"
+      },
+      "subHappProviderId": {
+        "type": "string"
+      },
+      "subHappSubExpire": {
+        "type": "boolean"
+      },
+      "subHappSubExpireButtonLink": {
+        "type": "string"
+      },
+      "subHappSubInfoButtonLink": {
+        "type": "string"
+      },
+      "subHappSubInfoButtonText": {
+        "type": "string"
+      },
+      "subHappSubInfoColor": {
+        "type": "string"
+      },
+      "subHappSubInfoText": {
+        "type": "string"
+      },
+      "subHappTunMode": {
+        "type": "string"
+      },
+      "subHappTunType": {
+        "type": "string"
+      },
       "subHideSettings": {
       "subHideSettings": {
         "type": "boolean"
         "type": "boolean"
       },
       },
@@ -227,6 +297,9 @@ export const SCHEMAS: Record<string, unknown> = {
       "subJsonPath": {
       "subJsonPath": {
         "type": "string"
         "type": "string"
       },
       },
+      "subJsonRoutingRules": {
+        "type": "string"
+      },
       "subJsonRules": {
       "subJsonRules": {
         "type": "string"
         "type": "string"
       },
       },
@@ -417,6 +490,29 @@ export const SCHEMAS: Record<string, unknown> = {
       "subEnableRouting",
       "subEnableRouting",
       "subEncrypt",
       "subEncrypt",
       "subExpiredTemplate",
       "subExpiredTemplate",
+      "subHappAlwaysHwid",
+      "subHappAutoConnect",
+      "subHappAutoConnectType",
+      "subHappAutoDetect",
+      "subHappColorProfile",
+      "subHappExcludeApns",
+      "subHappExcludeRoutes",
+      "subHappFallbackUrl",
+      "subHappNewUrl",
+      "subHappNoLimit",
+      "subHappNotificationExpire",
+      "subHappPerAppList",
+      "subHappPerAppMode",
+      "subHappPingType",
+      "subHappProviderId",
+      "subHappSubExpire",
+      "subHappSubExpireButtonLink",
+      "subHappSubInfoButtonLink",
+      "subHappSubInfoButtonText",
+      "subHappSubInfoColor",
+      "subHappSubInfoText",
+      "subHappTunMode",
+      "subHappTunType",
       "subHideSettings",
       "subHideSettings",
       "subIncyEnableRouting",
       "subIncyEnableRouting",
       "subIncyRoutingRules",
       "subIncyRoutingRules",
@@ -428,6 +524,7 @@ export const SCHEMAS: Record<string, unknown> = {
       "subJsonMux",
       "subJsonMux",
       "subJsonObservatory",
       "subJsonObservatory",
       "subJsonPath",
       "subJsonPath",
+      "subJsonRoutingRules",
       "subJsonRules",
       "subJsonRules",
       "subJsonURI",
       "subJsonURI",
       "subJsonUserAgentRegex",
       "subJsonUserAgentRegex",
@@ -685,6 +782,76 @@ export const SCHEMAS: Record<string, unknown> = {
       "subExpiredTemplate": {
       "subExpiredTemplate": {
         "type": "string"
         "type": "string"
       },
       },
+      "subHappAlwaysHwid": {
+        "type": "boolean"
+      },
+      "subHappAutoConnect": {
+        "type": "boolean"
+      },
+      "subHappAutoConnectType": {
+        "type": "string"
+      },
+      "subHappAutoDetect": {
+        "description": "Happ client customization settings (app-management / routing / UX).",
+        "type": "boolean"
+      },
+      "subHappColorProfile": {
+        "type": "string"
+      },
+      "subHappExcludeApns": {
+        "type": "boolean"
+      },
+      "subHappExcludeRoutes": {
+        "type": "string"
+      },
+      "subHappFallbackUrl": {
+        "type": "string"
+      },
+      "subHappNewUrl": {
+        "type": "string"
+      },
+      "subHappNoLimit": {
+        "type": "boolean"
+      },
+      "subHappNotificationExpire": {
+        "type": "boolean"
+      },
+      "subHappPerAppList": {
+        "type": "string"
+      },
+      "subHappPerAppMode": {
+        "type": "string"
+      },
+      "subHappPingType": {
+        "type": "string"
+      },
+      "subHappProviderId": {
+        "type": "string"
+      },
+      "subHappSubExpire": {
+        "type": "boolean"
+      },
+      "subHappSubExpireButtonLink": {
+        "type": "string"
+      },
+      "subHappSubInfoButtonLink": {
+        "type": "string"
+      },
+      "subHappSubInfoButtonText": {
+        "type": "string"
+      },
+      "subHappSubInfoColor": {
+        "type": "string"
+      },
+      "subHappSubInfoText": {
+        "type": "string"
+      },
+      "subHappTunMode": {
+        "type": "string"
+      },
+      "subHappTunType": {
+        "type": "string"
+      },
       "subHideSettings": {
       "subHideSettings": {
         "type": "boolean"
         "type": "boolean"
       },
       },
@@ -718,6 +885,9 @@ export const SCHEMAS: Record<string, unknown> = {
       "subJsonPath": {
       "subJsonPath": {
         "type": "string"
         "type": "string"
       },
       },
+      "subJsonRoutingRules": {
+        "type": "string"
+      },
       "subJsonRules": {
       "subJsonRules": {
         "type": "string"
         "type": "string"
       },
       },
@@ -915,6 +1085,29 @@ export const SCHEMAS: Record<string, unknown> = {
       "subEnableRouting",
       "subEnableRouting",
       "subEncrypt",
       "subEncrypt",
       "subExpiredTemplate",
       "subExpiredTemplate",
+      "subHappAlwaysHwid",
+      "subHappAutoConnect",
+      "subHappAutoConnectType",
+      "subHappAutoDetect",
+      "subHappColorProfile",
+      "subHappExcludeApns",
+      "subHappExcludeRoutes",
+      "subHappFallbackUrl",
+      "subHappNewUrl",
+      "subHappNoLimit",
+      "subHappNotificationExpire",
+      "subHappPerAppList",
+      "subHappPerAppMode",
+      "subHappPingType",
+      "subHappProviderId",
+      "subHappSubExpire",
+      "subHappSubExpireButtonLink",
+      "subHappSubInfoButtonLink",
+      "subHappSubInfoButtonText",
+      "subHappSubInfoColor",
+      "subHappSubInfoText",
+      "subHappTunMode",
+      "subHappTunType",
       "subHideSettings",
       "subHideSettings",
       "subIncyEnableRouting",
       "subIncyEnableRouting",
       "subIncyRoutingRules",
       "subIncyRoutingRules",
@@ -926,6 +1119,7 @@ export const SCHEMAS: Record<string, unknown> = {
       "subJsonMux",
       "subJsonMux",
       "subJsonObservatory",
       "subJsonObservatory",
       "subJsonPath",
       "subJsonPath",
+      "subJsonRoutingRules",
       "subJsonRules",
       "subJsonRules",
       "subJsonURI",
       "subJsonURI",
       "subJsonUserAgentRegex",
       "subJsonUserAgentRegex",
@@ -2524,6 +2718,9 @@ export const SCHEMAS: Record<string, unknown> = {
       "mtprotoDomain": {
       "mtprotoDomain": {
         "type": "string"
         "type": "string"
       },
       },
+      "network": {
+        "type": "string"
+      },
       "nodeAddress": {
       "nodeAddress": {
         "description": "Share-host resolution inputs, mirroring the subscription's\nresolveInboundAddress so the clients page renders a node-managed WireGuard\nEndpoint that points at the node, not the master panel. NodeAddress is the\nhosting node's externally reachable address (empty for this panel's own\ninbounds); Listen and ShareAddrStrategy/ShareAddr feed the same\nnode→listen→custom fallback the share/QR links already use.",
         "description": "Share-host resolution inputs, mirroring the subscription's\nresolveInboundAddress so the clients page renders a node-managed WireGuard\nEndpoint that points at the node, not the master panel. NodeAddress is the\nhosting node's externally reachable address (empty for this panel's own\ninbounds); Listen and ShareAddrStrategy/ShareAddr feed the same\nnode→listen→custom fallback the share/QR links already use.",
         "type": "string"
         "type": "string"
@@ -2545,6 +2742,9 @@ export const SCHEMAS: Record<string, unknown> = {
         "example": "VLESS-443",
         "example": "VLESS-443",
         "type": "string"
         "type": "string"
       },
       },
+      "security": {
+        "type": "string"
+      },
       "shareAddr": {
       "shareAddr": {
         "type": "string"
         "type": "string"
       },
       },

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

@@ -67,6 +67,29 @@ export interface AllSetting {
   subEnableRouting: boolean;
   subEnableRouting: boolean;
   subEncrypt: boolean;
   subEncrypt: boolean;
   subExpiredTemplate: string;
   subExpiredTemplate: string;
+  subHappAlwaysHwid: boolean;
+  subHappAutoConnect: boolean;
+  subHappAutoConnectType: string;
+  subHappAutoDetect: boolean;
+  subHappColorProfile: string;
+  subHappExcludeApns: boolean;
+  subHappExcludeRoutes: string;
+  subHappFallbackUrl: string;
+  subHappNewUrl: string;
+  subHappNoLimit: boolean;
+  subHappNotificationExpire: boolean;
+  subHappPerAppList: string;
+  subHappPerAppMode: string;
+  subHappPingType: string;
+  subHappProviderId: string;
+  subHappSubExpire: boolean;
+  subHappSubExpireButtonLink: string;
+  subHappSubInfoButtonLink: string;
+  subHappSubInfoButtonText: string;
+  subHappSubInfoColor: string;
+  subHappSubInfoText: string;
+  subHappTunMode: string;
+  subHappTunType: string;
   subHideSettings: boolean;
   subHideSettings: boolean;
   subIncyEnableRouting: boolean;
   subIncyEnableRouting: boolean;
   subIncyRoutingRules: string;
   subIncyRoutingRules: string;
@@ -78,6 +101,7 @@ export interface AllSetting {
   subJsonMux: string;
   subJsonMux: string;
   subJsonObservatory: string;
   subJsonObservatory: string;
   subJsonPath: string;
   subJsonPath: string;
+  subJsonRoutingRules: string;
   subJsonRules: string;
   subJsonRules: string;
   subJsonURI: string;
   subJsonURI: string;
   subJsonUserAgentRegex: string;
   subJsonUserAgentRegex: string;
@@ -185,6 +209,29 @@ export interface AllSettingView {
   subEnableRouting: boolean;
   subEnableRouting: boolean;
   subEncrypt: boolean;
   subEncrypt: boolean;
   subExpiredTemplate: string;
   subExpiredTemplate: string;
+  subHappAlwaysHwid: boolean;
+  subHappAutoConnect: boolean;
+  subHappAutoConnectType: string;
+  subHappAutoDetect: boolean;
+  subHappColorProfile: string;
+  subHappExcludeApns: boolean;
+  subHappExcludeRoutes: string;
+  subHappFallbackUrl: string;
+  subHappNewUrl: string;
+  subHappNoLimit: boolean;
+  subHappNotificationExpire: boolean;
+  subHappPerAppList: string;
+  subHappPerAppMode: string;
+  subHappPingType: string;
+  subHappProviderId: string;
+  subHappSubExpire: boolean;
+  subHappSubExpireButtonLink: string;
+  subHappSubInfoButtonLink: string;
+  subHappSubInfoButtonText: string;
+  subHappSubInfoColor: string;
+  subHappSubInfoText: string;
+  subHappTunMode: string;
+  subHappTunType: string;
   subHideSettings: boolean;
   subHideSettings: boolean;
   subIncyEnableRouting: boolean;
   subIncyEnableRouting: boolean;
   subIncyRoutingRules: string;
   subIncyRoutingRules: string;
@@ -196,6 +243,7 @@ export interface AllSettingView {
   subJsonMux: string;
   subJsonMux: string;
   subJsonObservatory: string;
   subJsonObservatory: string;
   subJsonPath: string;
   subJsonPath: string;
+  subJsonRoutingRules: string;
   subJsonRules: string;
   subJsonRules: string;
   subJsonURI: string;
   subJsonURI: string;
   subJsonUserAgentRegex: string;
   subJsonUserAgentRegex: string;
@@ -574,11 +622,13 @@ export interface InboundOption {
   id: number;
   id: number;
   listen?: string;
   listen?: string;
   mtprotoDomain?: string;
   mtprotoDomain?: string;
+  network?: string;
   nodeAddress?: string;
   nodeAddress?: string;
   nodeId?: number | null;
   nodeId?: number | null;
   port: number;
   port: number;
   protocol: string;
   protocol: string;
   remark: string;
   remark: string;
+  security?: string;
   shareAddr?: string;
   shareAddr?: string;
   shareAddrStrategy?: string;
   shareAddrStrategy?: string;
   ssMethod: string;
   ssMethod: string;

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

@@ -83,6 +83,29 @@ export const AllSettingSchema = z.object({
   subEnableRouting: z.boolean(),
   subEnableRouting: z.boolean(),
   subEncrypt: z.boolean(),
   subEncrypt: z.boolean(),
   subExpiredTemplate: z.string(),
   subExpiredTemplate: z.string(),
+  subHappAlwaysHwid: z.boolean(),
+  subHappAutoConnect: z.boolean(),
+  subHappAutoConnectType: z.string(),
+  subHappAutoDetect: z.boolean(),
+  subHappColorProfile: z.string(),
+  subHappExcludeApns: z.boolean(),
+  subHappExcludeRoutes: z.string(),
+  subHappFallbackUrl: z.string(),
+  subHappNewUrl: z.string(),
+  subHappNoLimit: z.boolean(),
+  subHappNotificationExpire: z.boolean(),
+  subHappPerAppList: z.string(),
+  subHappPerAppMode: z.string(),
+  subHappPingType: z.string(),
+  subHappProviderId: z.string(),
+  subHappSubExpire: z.boolean(),
+  subHappSubExpireButtonLink: z.string(),
+  subHappSubInfoButtonLink: z.string(),
+  subHappSubInfoButtonText: z.string(),
+  subHappSubInfoColor: z.string(),
+  subHappSubInfoText: z.string(),
+  subHappTunMode: z.string(),
+  subHappTunType: z.string(),
   subHideSettings: z.boolean(),
   subHideSettings: z.boolean(),
   subIncyEnableRouting: z.boolean(),
   subIncyEnableRouting: z.boolean(),
   subIncyRoutingRules: z.string(),
   subIncyRoutingRules: z.string(),
@@ -94,6 +117,7 @@ export const AllSettingSchema = z.object({
   subJsonMux: z.string(),
   subJsonMux: z.string(),
   subJsonObservatory: z.string(),
   subJsonObservatory: z.string(),
   subJsonPath: z.string(),
   subJsonPath: z.string(),
+  subJsonRoutingRules: z.string(),
   subJsonRules: z.string(),
   subJsonRules: z.string(),
   subJsonURI: z.string(),
   subJsonURI: z.string(),
   subJsonUserAgentRegex: z.string(),
   subJsonUserAgentRegex: z.string(),
@@ -202,6 +226,29 @@ export const AllSettingViewSchema = z.object({
   subEnableRouting: z.boolean(),
   subEnableRouting: z.boolean(),
   subEncrypt: z.boolean(),
   subEncrypt: z.boolean(),
   subExpiredTemplate: z.string(),
   subExpiredTemplate: z.string(),
+  subHappAlwaysHwid: z.boolean(),
+  subHappAutoConnect: z.boolean(),
+  subHappAutoConnectType: z.string(),
+  subHappAutoDetect: z.boolean(),
+  subHappColorProfile: z.string(),
+  subHappExcludeApns: z.boolean(),
+  subHappExcludeRoutes: z.string(),
+  subHappFallbackUrl: z.string(),
+  subHappNewUrl: z.string(),
+  subHappNoLimit: z.boolean(),
+  subHappNotificationExpire: z.boolean(),
+  subHappPerAppList: z.string(),
+  subHappPerAppMode: z.string(),
+  subHappPingType: z.string(),
+  subHappProviderId: z.string(),
+  subHappSubExpire: z.boolean(),
+  subHappSubExpireButtonLink: z.string(),
+  subHappSubInfoButtonLink: z.string(),
+  subHappSubInfoButtonText: z.string(),
+  subHappSubInfoColor: z.string(),
+  subHappSubInfoText: z.string(),
+  subHappTunMode: z.string(),
+  subHappTunType: z.string(),
   subHideSettings: z.boolean(),
   subHideSettings: z.boolean(),
   subIncyEnableRouting: z.boolean(),
   subIncyEnableRouting: z.boolean(),
   subIncyRoutingRules: z.string(),
   subIncyRoutingRules: z.string(),
@@ -213,6 +260,7 @@ export const AllSettingViewSchema = z.object({
   subJsonMux: z.string(),
   subJsonMux: z.string(),
   subJsonObservatory: z.string(),
   subJsonObservatory: z.string(),
   subJsonPath: z.string(),
   subJsonPath: z.string(),
+  subJsonRoutingRules: z.string(),
   subJsonRules: z.string(),
   subJsonRules: z.string(),
   subJsonURI: z.string(),
   subJsonURI: z.string(),
   subJsonUserAgentRegex: z.string(),
   subJsonUserAgentRegex: z.string(),
@@ -616,11 +664,13 @@ export const InboundOptionSchema = z.object({
   id: z.number().int(),
   id: z.number().int(),
   listen: z.string().optional(),
   listen: z.string().optional(),
   mtprotoDomain: z.string().optional(),
   mtprotoDomain: z.string().optional(),
+  network: z.string().optional(),
   nodeAddress: z.string().optional(),
   nodeAddress: z.string().optional(),
   nodeId: z.number().int().nullable().optional(),
   nodeId: z.number().int().nullable().optional(),
   port: z.number().int(),
   port: z.number().int(),
   protocol: z.string(),
   protocol: z.string(),
   remark: z.string(),
   remark: z.string(),
+  security: z.string().optional(),
   shareAddr: z.string().optional(),
   shareAddr: z.string().optional(),
   shareAddrStrategy: z.string().optional(),
   shareAddrStrategy: z.string().optional(),
   ssMethod: z.string(),
   ssMethod: z.string(),

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

@@ -392,7 +392,9 @@ export function useClients(options: UseClientsOptions = {}) {
       emails: string[];
       emails: string[];
       addDays: number;
       addDays: number;
       addBytes: number;
       addBytes: number;
-      flow: string;
+      flow?: string;
+      limitHwid?: number | null;
+      adTag?: string;
     }): Promise<Msg<BulkAdjustResult>> => {
     }): Promise<Msg<BulkAdjustResult>> => {
       const raw = await HttpUtil.post('/panel/api/clients/bulkAdjust', payload, JSON_HEADERS);
       const raw = await HttpUtil.post('/panel/api/clients/bulkAdjust', payload, JSON_HEADERS);
       return parseMsg(raw, BulkAdjustResultSchema, 'clients/bulkAdjust');
       return parseMsg(raw, BulkAdjustResultSchema, 'clients/bulkAdjust');
@@ -561,9 +563,16 @@ export function useClients(options: UseClientsOptions = {}) {
     [bulkCreateMut],
     [bulkCreateMut],
   );
   );
   const bulkAdjust = useCallback(
   const bulkAdjust = useCallback(
-    (emails: string[], addDays: number, addBytes: number, flow = '') => {
+    (
+      emails: string[],
+      addDays: number,
+      addBytes: number,
+      flow = '',
+      limitHwid?: number | null,
+      adTag?: string,
+    ) => {
       if (!Array.isArray(emails) || emails.length === 0) return Promise.resolve(null);
       if (!Array.isArray(emails) || emails.length === 0) return Promise.resolve(null);
-      return bulkAdjustMut.mutateAsync({ emails, addDays, addBytes, flow });
+      return bulkAdjustMut.mutateAsync({ emails, addDays, addBytes, flow, limitHwid, adTag });
     },
     },
     [bulkAdjustMut],
     [bulkAdjustMut],
   );
   );

+ 8 - 1
frontend/src/hooks/useXraySetting.ts

@@ -32,7 +32,14 @@ export function isUdpOutbound(outbound: unknown): boolean {
     | undefined;
     | undefined;
   const p = o?.protocol;
   const p = o?.protocol;
   const n = o?.streamSettings?.network;
   const n = o?.streamSettings?.network;
-  return p === 'wireguard' || p === 'hysteria' || n === 'hysteria' || n === 'kcp' || n === 'quic';
+  return (
+    p === 'wireguard' ||
+    p === 'hysteria' ||
+    p === 'amneziawg' ||
+    n === 'hysteria' ||
+    n === 'kcp' ||
+    n === 'quic'
+  );
 }
 }
 
 
 export type OutboundTestMode = 'tcp' | 'http' | 'real';
 export type OutboundTestMode = 'tcp' | 'http' | 'real';

+ 50 - 1
frontend/src/layouts/AppSidebar.tsx

@@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
 import type { ComponentType, CSSProperties } from 'react';
 import type { ComponentType, CSSProperties } from 'react';
 import { useLocation, useNavigate } from 'react-router';
 import { useLocation, useNavigate } from 'react-router';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
-import { Drawer, Layout, Menu } from 'antd';
+import { Drawer, Layout, Menu, Tooltip } from 'antd';
 import type { MenuProps } from 'antd';
 import type { MenuProps } from 'antd';
 import {
 import {
   ApiOutlined,
   ApiOutlined,
@@ -28,6 +28,7 @@ import {
   PushpinOutlined,
   PushpinOutlined,
   ReadOutlined,
   ReadOutlined,
   SafetyOutlined,
   SafetyOutlined,
+  SearchOutlined,
   SettingOutlined,
   SettingOutlined,
   SunOutlined,
   SunOutlined,
   SwapOutlined,
   SwapOutlined,
@@ -40,9 +41,13 @@ import { HttpUtil } from '@/utils';
 import { formatPanelVersion } from '@/lib/panel-version';
 import { formatPanelVersion } from '@/lib/panel-version';
 import { pauseAnimationsUntilLeave, useTheme } from '@/hooks/useTheme';
 import { pauseAnimationsUntilLeave, useTheme } from '@/hooks/useTheme';
 import { useAllSettings } from '@/api/queries/useAllSettings';
 import { useAllSettings } from '@/api/queries/useAllSettings';
+import { useCommandPalette } from '@/components/command-palette/useCommandPalette';
 import './AppSidebar.css';
 import './AppSidebar.css';
 
 
 const DONATE_URL = 'https://donate.sanaei.dev/';
 const DONATE_URL = 'https://donate.sanaei.dev/';
+// The palette listens for Ctrl as well as Cmd, so the chip must not show a
+// Mac glyph to the Linux and Windows operators who are most of this panel's.
+const SHORTCUT_MODIFIER = /Mac|iPhone|iPad|iPod/.test(navigator.userAgent) ? '⌘' : 'Ctrl';
 const DOCS_URL = 'https://docs.sanaei.dev/';
 const DOCS_URL = 'https://docs.sanaei.dev/';
 const REPO_URL = 'https://github.com/MHSanaei/3x-ui';
 const REPO_URL = 'https://github.com/MHSanaei/3x-ui';
 const LOGOUT_KEY = '__logout__';
 const LOGOUT_KEY = '__logout__';
@@ -174,6 +179,7 @@ function saveSidebarPinned(pinned: boolean) {
 export default function AppSidebar() {
 export default function AppSidebar() {
   const { t } = useTranslation();
   const { t } = useTranslation();
   const { isDark, isUltra, toggleTheme, toggleUltra } = useTheme();
   const { isDark, isUltra, toggleTheme, toggleUltra } = useTheme();
+  const { open: openCommandPalette } = useCommandPalette();
   const navigate = useNavigate();
   const navigate = useNavigate();
   const { pathname, hash } = useLocation();
   const { pathname, hash } = useLocation();
   const { allSetting } = useAllSettings();
   const { allSetting } = useAllSettings();
@@ -392,6 +398,30 @@ export default function AppSidebar() {
             </div>
             </div>
           )}
           )}
         </div>
         </div>
+        <Tooltip
+          title={
+            railCollapsed ? t('commandPalette.title') || 'Command Palette (Ctrl + K)' : undefined
+          }
+          placement="right"
+        >
+          <button
+            type="button"
+            className={`sidebar-command-trigger${railCollapsed ? ' collapsed' : ''}`}
+            onClick={openCommandPalette}
+            aria-label={t('commandPalette.title') || 'Command Palette (Ctrl + K)'}
+          >
+            <span className="sidebar-command-left">
+              <SearchOutlined className="sidebar-command-icon" />
+              <span className="sidebar-command-text">
+                {t('commandPalette.search') || 'Search...'}
+              </span>
+            </span>
+            <span className="sidebar-command-kbd">
+              <span className="kbd-cmd">{SHORTCUT_MODIFIER}</span>
+              <span className="kbd-key">K</span>
+            </span>
+          </button>
+        </Tooltip>
         <Menu
         <Menu
           theme={currentTheme}
           theme={currentTheme}
           mode="inline"
           mode="inline"
@@ -452,6 +482,25 @@ export default function AppSidebar() {
             </button>
             </button>
           </div>
           </div>
         </div>
         </div>
+        <button
+          type="button"
+          className="sidebar-command-trigger"
+          onClick={() => {
+            setDrawerOpen(false);
+            openCommandPalette();
+          }}
+          aria-label={t('commandPalette.title') || 'Command Palette (Ctrl + K)'}
+          style={{ margin: '8px 12px 4px', width: 'calc(100% - 24px)' }}
+        >
+          <span style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
+            <SearchOutlined className="sidebar-command-icon" />
+            <span>{t('commandPalette.search') || 'Search...'}</span>
+          </span>
+          <span className="sidebar-command-kbd">
+            <span className="kbd-cmd">{SHORTCUT_MODIFIER}</span>
+            <span className="kbd-key">K</span>
+          </span>
+        </button>
         <Menu
         <Menu
           theme={currentTheme}
           theme={currentTheme}
           mode="inline"
           mode="inline"

+ 7 - 1
frontend/src/layouts/PanelLayout.tsx

@@ -2,9 +2,15 @@ import { Outlet } from 'react-router';
 
 
 import { useWebSocketBridge } from '@/api/websocketBridge';
 import { useWebSocketBridge } from '@/api/websocketBridge';
 import { usePageTitle } from '@/hooks/usePageTitle';
 import { usePageTitle } from '@/hooks/usePageTitle';
+import CommandPalette from '@/components/command-palette/CommandPalette';
 
 
 export default function PanelLayout() {
 export default function PanelLayout() {
   useWebSocketBridge();
   useWebSocketBridge();
   usePageTitle();
   usePageTitle();
-  return <Outlet />;
+  return (
+    <>
+      <Outlet />
+      <CommandPalette />
+    </>
+  );
 }
 }

+ 106 - 0
frontend/src/lib/xray/outbound-form-adapter.ts

@@ -1,11 +1,13 @@
 import { XHttpXmuxSchema } from '@/schemas/protocols/stream/xhttp';
 import { XHttpXmuxSchema } from '@/schemas/protocols/stream/xhttp';
 import { OutboundDomainStrategySchema } from '@/schemas/protocols/outbound';
 import { OutboundDomainStrategySchema } from '@/schemas/protocols/outbound';
+import { AmneziaWGOutboundSettingsSchema } from '@/schemas/protocols/outbound';
 import { normalizeStreamSettingsForWire } from '@/lib/xray/stream-wire-normalize';
 import { normalizeStreamSettingsForWire } from '@/lib/xray/stream-wire-normalize';
 import { Wireguard } from '@/utils';
 import { Wireguard } from '@/utils';
 import type { Sniffing, SniffingDest } from '@/schemas/primitives';
 import type { Sniffing, SniffingDest } from '@/schemas/primitives';
 import type { OutboundDomainStrategy } from '@/schemas/protocols/outbound';
 import type { OutboundDomainStrategy } from '@/schemas/protocols/outbound';
 
 
 import type {
 import type {
+  AmneziaWGOutboundFormSettings,
   BlackholeOutboundFormSettings,
   BlackholeOutboundFormSettings,
   DnsOutboundFormSettings,
   DnsOutboundFormSettings,
   DnsRuleForm,
   DnsRuleForm,
@@ -377,6 +379,104 @@ function loopbackFromWire(raw: Raw): LoopbackOutboundFormSettings {
   };
   };
 }
 }
 
 
+function amneziawgPeerFromWire(p: unknown): AmneziaWGOutboundFormSettings['peers'][number] {
+  const pp = asObject(p);
+  const allowed = asArray(pp.allowedIPs).map((x) => asString(x));
+  return {
+    publicKey: asString(pp.publicKey),
+    presharedKey: asString(pp.presharedKey),
+    allowedIPs: allowed.length > 0 ? allowed : ['0.0.0.0/0', '::/0'],
+    endpoint: asString(pp.endpoint),
+    keepAlive: asNumber(pp.keepAlive, 0),
+  };
+}
+
+// The form state IS the wire shape; hydrate only to apply defaults for keys
+// an older template may omit.
+function amneziawgFromWire(raw: Raw): AmneziaWGOutboundFormSettings {
+  return AmneziaWGOutboundSettingsSchema.parse({
+    mtu: asNumber(raw.mtu, 0),
+    secretKey: asString(raw.secretKey),
+    address: asArray(raw.address).map((x) => asString(x)),
+    listenPort: asNumber(raw.listenPort, 0),
+    dns: asString(raw.dns),
+    jc: asNumber(raw.jc, 0),
+    jmin: asNumber(raw.jmin, 40),
+    jmax: asNumber(raw.jmax, 100),
+    s1: asNumber(raw.s1, 15),
+    s2: asNumber(raw.s2, 80),
+    s3: asNumber(raw.s3, 12),
+    s4: asNumber(raw.s4, 12),
+    h1: asString(raw.h1),
+    h2: asString(raw.h2),
+    h3: asString(raw.h3),
+    h4: asString(raw.h4),
+    i1: asString(raw.i1),
+    i2: asString(raw.i2),
+    i3: asString(raw.i3),
+    i4: asString(raw.i4),
+    i5: asString(raw.i5),
+    headerProtectionKey: asString(raw.headerProtectionKey),
+    contentPaddingAddition: asString(raw.contentPaddingAddition),
+    rekeyAfterTime: asString(raw.rekeyAfterTime),
+    rekeyTimeout: asString(raw.rekeyTimeout),
+    rejectAfterTime: asString(raw.rejectAfterTime),
+    keepaliveTimeout: asString(raw.keepaliveTimeout),
+    maxHandshakeAttempts: asString(raw.maxHandshakeAttempts),
+    randomTrailers: raw.randomTrailers === undefined ? false : asBool(raw.randomTrailers),
+    disableCookies: raw.disableCookies === undefined ? true : asBool(raw.disableCookies),
+    peers: asArray(raw.peers).map(amneziawgPeerFromWire),
+  });
+}
+
+function amneziawgToWire(s: AmneziaWGOutboundFormSettings): Raw {
+  const out: Raw = {
+    mtu: s.mtu || undefined,
+    secretKey: s.secretKey,
+    address: s.address,
+    jc: s.jc,
+    jmin: s.jmin,
+    jmax: s.jmax,
+    s1: s.s1,
+    s2: s.s2,
+    s3: s.s3,
+    s4: s.s4,
+    h1: s.h1,
+    h2: s.h2,
+    h3: s.h3,
+    h4: s.h4,
+    randomTrailers: s.randomTrailers,
+    disableCookies: s.disableCookies,
+    peers: s.peers.map((p) => ({
+      publicKey: p.publicKey,
+      presharedKey: p.presharedKey.length > 0 ? p.presharedKey : undefined,
+      allowedIPs: p.allowedIPs.length > 0 ? p.allowedIPs : undefined,
+      endpoint: p.endpoint,
+      keepAlive: p.keepAlive || undefined,
+    })),
+  };
+  if (s.listenPort > 0) out.listenPort = s.listenPort;
+  if (s.dns && s.dns.length > 0) out.dns = s.dns;
+  const optionalStrings = [
+    'i1',
+    'i2',
+    'i3',
+    'i4',
+    'i5',
+    'headerProtectionKey',
+    'contentPaddingAddition',
+    'rekeyAfterTime',
+    'rekeyTimeout',
+    'rejectAfterTime',
+    'keepaliveTimeout',
+    'maxHandshakeAttempts',
+  ] as const;
+  for (const k of optionalStrings) {
+    if (s[k].length > 0) out[k] = s[k];
+  }
+  return out;
+}
+
 function muxFromWire(raw: unknown): MuxForm {
 function muxFromWire(raw: unknown): MuxForm {
   const m = asObject(raw);
   const m = asObject(raw);
   return {
   return {
@@ -457,6 +557,9 @@ export function rawOutboundToFormValues(raw: RawOutboundRow): OutboundFormValues
     case 'wireguard':
     case 'wireguard':
       typed = { protocol: 'wireguard', settings: wireguardFromWire(settings) };
       typed = { protocol: 'wireguard', settings: wireguardFromWire(settings) };
       break;
       break;
+    case 'amneziawg':
+      typed = { protocol: 'amneziawg', settings: amneziawgFromWire(settings) };
+      break;
     case 'hysteria':
     case 'hysteria':
       typed = { protocol: 'hysteria', settings: hysteriaFromWire(settings) };
       typed = { protocol: 'hysteria', settings: hysteriaFromWire(settings) };
       break;
       break;
@@ -753,6 +856,9 @@ export function formValuesToWirePayload(values: OutboundFormValues): WireOutboun
     case 'wireguard':
     case 'wireguard':
       settings = wireguardToWire(values.settings);
       settings = wireguardToWire(values.settings);
       break;
       break;
+    case 'amneziawg':
+      settings = amneziawgToWire(values.settings);
+      break;
     case 'hysteria':
     case 'hysteria':
       settings = hysteriaToWire(values.settings);
       settings = hysteriaToWire(values.settings);
       break;
       break;

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

@@ -69,10 +69,34 @@ export class AllSetting {
   subClashRules = '';
   subClashRules = '';
   subJsonMux = '';
   subJsonMux = '';
   subJsonRules = '';
   subJsonRules = '';
+  subJsonRoutingRules = '';
   subJsonFinalMask = '';
   subJsonFinalMask = '';
   subJsonObservatory = '';
   subJsonObservatory = '';
   subThemeDir = '';
   subThemeDir = '';
   subHideSettings = false;
   subHideSettings = false;
+  subHappAutoDetect = false;
+  subHappProviderId = '';
+  subHappNewUrl = '';
+  subHappFallbackUrl = '';
+  subHappSubInfoColor = 'blue';
+  subHappSubInfoText = '';
+  subHappSubInfoButtonText = '';
+  subHappSubInfoButtonLink = '';
+  subHappSubExpire = false;
+  subHappSubExpireButtonLink = '';
+  subHappNotificationExpire = false;
+  subHappNoLimit = false;
+  subHappAlwaysHwid = false;
+  subHappTunMode = '';
+  subHappTunType = '';
+  subHappExcludeRoutes = '';
+  subHappExcludeApns = false;
+  subHappColorProfile = '';
+  subHappPingType = '';
+  subHappAutoConnect = false;
+  subHappAutoConnectType = 'lowestdelay';
+  subHappPerAppMode = 'off';
+  subHappPerAppList = '';
 
 
   timeLocation = 'Local';
   timeLocation = 'Local';
 
 

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

@@ -1280,8 +1280,8 @@ export const sections: readonly Section[] = [
         method: 'POST',
         method: 'POST',
         path: '/panel/api/clients/bulkAdjust',
         path: '/panel/api/clients/bulkAdjust',
         summary:
         summary:
-          'Shift expiry and/or traffic quota for many clients in one call. addDays/addBytes may be negative. Clients with unlimited expiry (expiryTime=0) or unlimited traffic (totalGB=0) are skipped for the corresponding field — bulk extend never converts unlimited to limited. A client that was auto-disabled solely because it was depleted (expired or over quota) is automatically re-enabled — locally and on its node — when the adjustment lifts it out of depletion; a manually-disabled or still-depleted client is left disabled. The optional flow directive sets the XTLS flow on every client: "none" clears it, "xtls-rprx-vision"/"xtls-rprx-vision-udp443" set it where the inbound supports it (omit or "" to leave it unchanged). Returns the adjusted count and per-email skip reasons.',
-        body: '{\n  "emails": ["alice", "bob"],\n  "addDays": 30,\n  "addBytes": 53687091200,\n  "flow": "xtls-rprx-vision"\n}',
+          'Shift expiry and/or traffic quota for many clients in one call. addDays/addBytes may be negative. Clients with unlimited expiry (expiryTime=0) or unlimited traffic (totalGB=0) are skipped for the corresponding field — bulk extend never converts unlimited to limited. A client that was auto-disabled solely because it was depleted (expired or over quota) is automatically re-enabled — locally and on its node — when the adjustment lifts it out of depletion; a manually-disabled or still-depleted client is left disabled. The optional flow directive sets the XTLS flow on every client: "none" clears it, "xtls-rprx-vision"/"xtls-rprx-vision-udp443" set it where the inbound supports it (omit or "" to leave it unchanged). The optional limitHwid sets maximum registered devices (0 = unlimited). The optional adTag sets MTProto Telegram sponsor channel ("none" clears). Returns the adjusted count and per-email skip reasons.',
+        body: '{\n  "emails": ["alice", "bob"],\n  "addDays": 30,\n  "addBytes": 53687091200,\n  "flow": "xtls-rprx-vision",\n  "limitHwid": 2,\n  "adTag": "0123456789abcdef0123456789abcdef"\n}',
         response:
         response:
           '{\n  "success": true,\n  "obj": {\n    "adjusted": 2,\n    "skipped": [\n      { "email": "carol", "reason": "unlimited expiry" }\n    ]\n  }\n}',
           '{\n  "success": true,\n  "obj": {\n    "adjusted": 2,\n    "skipped": [\n      { "email": "carol", "reason": "unlimited expiry" }\n    ]\n  }\n}',
       },
       },

+ 39 - 4
frontend/src/pages/clients/ClientBulkAdjustModal.tsx

@@ -1,6 +1,6 @@
 import { useEffect, useState } from 'react';
 import { useEffect, useState } from 'react';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
-import { Alert, Form, InputNumber, Modal, Select, message } from 'antd';
+import { Alert, Form, Input, InputNumber, Modal, Select, message } from 'antd';
 import { FormProvider, useForm } from 'react-hook-form';
 import { FormProvider, useForm } from 'react-hook-form';
 
 
 import { ClientBulkAdjustFormSchema, type ClientBulkAdjustFormValues } from '@/schemas/client';
 import { ClientBulkAdjustFormSchema, type ClientBulkAdjustFormValues } from '@/schemas/client';
@@ -11,7 +11,13 @@ const GB = 1024 * 1024 * 1024;
 
 
 const FLOW_CLEAR = 'none';
 const FLOW_CLEAR = 'none';
 
 
-const EMPTY: ClientBulkAdjustFormValues = { addDays: 0, addGB: 0, flow: '' };
+const EMPTY: ClientBulkAdjustFormValues = {
+  addDays: 0,
+  addGB: 0,
+  flow: '',
+  limitHwid: null,
+  adTag: '',
+};
 
 
 interface ClientBulkAdjustModalProps {
 interface ClientBulkAdjustModalProps {
   open: boolean;
   open: boolean;
@@ -21,6 +27,8 @@ interface ClientBulkAdjustModalProps {
     addDays: number,
     addDays: number,
     addBytes: number,
     addBytes: number,
     flow: string,
     flow: string,
+    limitHwid?: number | null,
+    adTag?: string,
   ) => Promise<{ adjusted: number; skipped?: { email: string; reason: string }[] } | null>;
   ) => Promise<{ adjusted: number; skipped?: { email: string; reason: string }[] } | null>;
 }
 }
 
 
@@ -45,16 +53,23 @@ export default function ClientBulkAdjustModal({
       addDays: Math.trunc(Number(values.addDays) || 0),
       addDays: Math.trunc(Number(values.addDays) || 0),
       addGB: Number(values.addGB) || 0,
       addGB: Number(values.addGB) || 0,
       flow: values.flow,
       flow: values.flow,
+      limitHwid:
+        values.limitHwid !== null &&
+        values.limitHwid !== undefined &&
+        (values.limitHwid as unknown) !== ''
+          ? Math.trunc(Number(values.limitHwid))
+          : null,
+      adTag: values.adTag?.trim() ?? '',
     });
     });
     if (!validated.success) {
     if (!validated.success) {
       messageApi.warning(t(validated.error.issues[0]?.message ?? 'somethingWentWrong'));
       messageApi.warning(t(validated.error.issues[0]?.message ?? 'somethingWentWrong'));
       return;
       return;
     }
     }
-    const { addDays: days, addGB: gb, flow: flowValue } = validated.data;
+    const { addDays: days, addGB: gb, flow: flowValue, limitHwid, adTag } = validated.data;
     setSubmitting(true);
     setSubmitting(true);
     try {
     try {
       const bytes = Math.trunc(gb * GB);
       const bytes = Math.trunc(gb * GB);
-      const result = await onSubmit(days, bytes, flowValue);
+      const result = await onSubmit(days, bytes, flowValue, limitHwid, adTag);
       if (!result) return;
       if (!result) return;
       const ok = result.adjusted ?? 0;
       const ok = result.adjusted ?? 0;
       const skipped = result.skipped?.length ?? 0;
       const skipped = result.skipped?.length ?? 0;
@@ -111,6 +126,26 @@ export default function ClientBulkAdjustModal({
                 ]}
                 ]}
               />
               />
             </FormField>
             </FormField>
+            <FormField
+              name="limitHwid"
+              label={t('pages.clients.limitHwid')}
+              tooltip={t('pages.clients.limitHwidDesc')}
+            >
+              <InputNumber
+                style={{ width: '100%' }}
+                min={0}
+                step={1}
+                precision={0}
+                placeholder={t('pages.clients.bulkFlowNoChange')}
+              />
+            </FormField>
+            <FormField
+              name="adTag"
+              label={t('pages.clients.mtprotoAdTag')}
+              extra={t('pages.clients.bulkAdTagHint')}
+            >
+              <Input placeholder={t('pages.clients.bulkFlowNoChange')} allowClear />
+            </FormField>
           </Form>
           </Form>
         </FormProvider>
         </FormProvider>
       </Modal>
       </Modal>

+ 25 - 3
frontend/src/pages/clients/ClientsPage.tsx

@@ -1,4 +1,5 @@
 import { lazy, useCallback, useEffect, useMemo, useRef, useState } from 'react';
 import { lazy, useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { useLocation, useSearchParams } from 'react-router';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 import {
 import {
   Badge,
   Badge,
@@ -382,7 +383,12 @@ export default function ClientsPage() {
   >(null);
   >(null);
 
 
   const initial = readFilterState();
   const initial = readFilterState();
-  const [searchKey, setSearchKey] = useState(initial.searchKey);
+  const location = useLocation();
+  const [searchParams] = useSearchParams();
+  const searchParam = searchParams.get('search');
+  const [searchKey, setSearchKey] = useState(
+    searchParam !== null ? searchParam : initial.searchKey,
+  );
   const [filters, setFilters] = useState<ClientFilters>(initial.filters);
   const [filters, setFilters] = useState<ClientFilters>(initial.filters);
   const [filterDrawerOpen, setFilterDrawerOpen] = useState(false);
   const [filterDrawerOpen, setFilterDrawerOpen] = useState(false);
 
 
@@ -405,6 +411,15 @@ export default function ClientsPage() {
   // debouncedSearch lags behind the input so we don't spam the server on every
   // debouncedSearch lags behind the input so we don't spam the server on every
   // keystroke; the search box still feels instant locally.
   // keystroke; the search box still feels instant locally.
   const [debouncedSearch, setDebouncedSearch] = useState(searchKey);
   const [debouncedSearch, setDebouncedSearch] = useState(searchKey);
+  const [prevLocationKey, setPrevLocationKey] = useState(location.key);
+
+  if (location.key !== prevLocationKey) {
+    setPrevLocationKey(location.key);
+    if (searchParam !== null) {
+      setSearchKey(searchParam);
+      setDebouncedSearch(searchParam);
+    }
+  }
 
 
   useEffect(() => {
   useEffect(() => {
     localStorage.setItem(
     localStorage.setItem(
@@ -1893,8 +1908,15 @@ export default function ClientsPage() {
             open={bulkAdjustOpen}
             open={bulkAdjustOpen}
             count={selectedRowKeys.length}
             count={selectedRowKeys.length}
             onOpenChange={setBulkAdjustOpen}
             onOpenChange={setBulkAdjustOpen}
-            onSubmit={async (addDays, addBytes, flow) => {
-              const msg = await bulkAdjust([...selectedRowKeys], addDays, addBytes, flow);
+            onSubmit={async (addDays, addBytes, flow, limitHwid, adTag) => {
+              const msg = await bulkAdjust(
+                [...selectedRowKeys],
+                addDays,
+                addBytes,
+                flow,
+                limitHwid,
+                adTag,
+              );
               if (msg?.success) {
               if (msg?.success) {
                 setSelectedRowKeys([]);
                 setSelectedRowKeys([]);
                 return msg.obj ?? { adjusted: 0 };
                 return msg.obj ?? { adjusted: 0 };

+ 2 - 2
frontend/src/pages/groups/GroupsPage.tsx

@@ -654,8 +654,8 @@ export default function GroupsPage() {
             open={adjustOpen}
             open={adjustOpen}
             count={groupEmails.length}
             count={groupEmails.length}
             onOpenChange={setAdjustOpen}
             onOpenChange={setAdjustOpen}
-            onSubmit={async (addDays, addBytes) => {
-              const msg = await bulkAdjust(groupEmails, addDays, addBytes);
+            onSubmit={async (addDays, addBytes, flow, limitHwid, adTag) => {
+              const msg = await bulkAdjust(groupEmails, addDays, addBytes, flow, limitHwid, adTag);
               if (msg?.success) {
               if (msg?.success) {
                 const obj = msg.obj ?? { adjusted: 0 };
                 const obj = msg.obj ?? { adjusted: 0 };
                 messageApi.success(
                 messageApi.success(

+ 13 - 1
frontend/src/pages/inbounds/list/InboundList.tsx

@@ -1,4 +1,5 @@
 import { useCallback, useMemo, useState, type Key } from 'react';
 import { useCallback, useMemo, useState, type Key } from 'react';
+import { useLocation, useSearchParams } from 'react-router';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 import {
 import {
   Button,
   Button,
@@ -58,7 +59,18 @@ export default function InboundList({
   // Node filter (#4997): 'all' shows everything, 0 is the local-panel
   // Node filter (#4997): 'all' shows everything, 0 is the local-panel
   // sentinel (inbounds without a nodeId), otherwise a node id. Session-only.
   // sentinel (inbounds without a nodeId), otherwise a node id. Session-only.
   const [nodeFilter, setNodeFilter] = useState<number | 'all'>('all');
   const [nodeFilter, setNodeFilter] = useState<number | 'all'>('all');
-  const [searchKey, setSearchKey] = useState('');
+  const location = useLocation();
+  const [searchParams] = useSearchParams();
+  const searchParam = searchParams.get('search');
+  const [searchKey, setSearchKey] = useState(() => searchParam || '');
+  const [prevLocationKey, setPrevLocationKey] = useState(location.key);
+
+  if (location.key !== prevLocationKey) {
+    setPrevLocationKey(location.key);
+    if (searchParam !== null) {
+      setSearchKey(searchParam);
+    }
+  }
 
 
   const showNodeFilter = useMemo(
   const showNodeFilter = useMemo(
     () => nodesById.size > 0 || dbInbounds.some((ib) => ib.nodeId != null),
     () => nodesById.size > 0 || dbInbounds.some((ib) => ib.nodeId != null),

+ 637 - 0
frontend/src/pages/settings/HappSettingsContent.tsx

@@ -0,0 +1,637 @@
+import { useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { Button, Input, Modal, Select, Space, Switch, Tabs, message } from 'antd';
+import {
+  BranchesOutlined,
+  BuildOutlined,
+  CloudSyncOutlined,
+  DesktopOutlined,
+  MobileOutlined,
+  NotificationOutlined,
+  ThunderboltOutlined,
+} from '@ant-design/icons';
+import type { AllSetting } from '@/models/setting';
+import { SettingListItem } from '@/components/ui';
+import { buildHappPresetDeeplink, parseList, toBase64Utf8 } from './happPresets';
+
+interface HappSettingsContentProps {
+  allSetting: AllSetting;
+  updateSetting: (patch: Partial<AllSetting>) => void;
+  isMobile: boolean;
+  remoteSourceBadge: (val: string) => React.ReactNode;
+}
+
+export default function HappSettingsContent({
+  allSetting,
+  updateSetting,
+  isMobile,
+  remoteSourceBadge,
+}: HappSettingsContentProps) {
+  const { t } = useTranslation();
+  const [selectedPreset, setSelectedPreset] = useState<string>('iran-bypass');
+  const [isModalOpen, setIsModalOpen] = useState(false);
+
+  const [directDomains, setDirectDomains] = useState('');
+  const [proxyDomains, setProxyDomains] = useState('');
+  const [blockDomains, setBlockDomains] = useState('');
+  const [directIPs, setDirectIPs] = useState('');
+  const [proxyIPs, setProxyIPs] = useState('');
+  const [blockIPs, setBlockIPs] = useState('');
+
+  const applyPreset = () => {
+    const payload = buildHappPresetDeeplink(selectedPreset);
+    if (payload) {
+      updateSetting({ subRoutingRules: payload });
+      message.success(t('pages.settings.subHappPresetApplied'));
+    }
+  };
+
+  const handleBuildDeeplink = () => {
+    interface FieldRule {
+      type: string;
+      outboundTag: string;
+      domain?: string[];
+      ip?: string[];
+      network?: string;
+    }
+    const rules: FieldRule[] = [];
+
+    const bDom = parseList(blockDomains);
+    const bIp = parseList(blockIPs);
+    if (bDom.length > 0 || bIp.length > 0) {
+      rules.push({
+        type: 'field',
+        outboundTag: 'block',
+        ...(bDom.length > 0 ? { domain: bDom } : {}),
+        ...(bIp.length > 0 ? { ip: bIp } : {}),
+      });
+    }
+
+    const dDom = parseList(directDomains);
+    const dIp = parseList(directIPs);
+    if (dDom.length > 0 || dIp.length > 0) {
+      rules.push({
+        type: 'field',
+        outboundTag: 'direct',
+        ...(dDom.length > 0 ? { domain: dDom } : {}),
+        ...(dIp.length > 0 ? { ip: dIp } : {}),
+      });
+    }
+
+    const pDom = parseList(proxyDomains);
+    const pIp = parseList(proxyIPs);
+    if (pDom.length > 0 || pIp.length > 0) {
+      rules.push({
+        type: 'field',
+        outboundTag: 'proxy',
+        ...(pDom.length > 0 ? { domain: pDom } : {}),
+        ...(pIp.length > 0 ? { ip: pIp } : {}),
+      });
+    }
+
+    rules.push({
+      type: 'field',
+      outboundTag: 'proxy',
+      network: 'tcp,udp',
+    });
+
+    const deeplink = 'happ://routing/onadd/' + toBase64Utf8(JSON.stringify({ rules }));
+    updateSetting({ subRoutingRules: deeplink });
+    setIsModalOpen(false);
+    message.success(t('pages.settings.subHappDeeplinkGenerated'));
+  };
+
+  return (
+    <>
+      <Tabs
+        type="card"
+        size="small"
+        items={[
+          {
+            key: 'routing',
+            label: (
+              <span>
+                <BranchesOutlined /> {!isMobile && t('pages.settings.subHappGroupRouting')}
+              </span>
+            ),
+            children: (
+              <>
+                <SettingListItem
+                  paddings="small"
+                  title={t('pages.settings.subEnableRouting')}
+                  description={t('pages.settings.subEnableRoutingDesc')}
+                >
+                  <Switch
+                    checked={allSetting.subEnableRouting}
+                    onChange={(v) => updateSetting({ subEnableRouting: v })}
+                  />
+                </SettingListItem>
+
+                <SettingListItem
+                  paddings="small"
+                  title={t('pages.settings.subHappPresets')}
+                  description={t('pages.settings.subHappPresetsDesc')}
+                >
+                  <Space orientation="horizontal" style={{ width: '100%' }}>
+                    <Select
+                      value={selectedPreset}
+                      style={{ minWidth: 170 }}
+                      onChange={setSelectedPreset}
+                      options={[
+                        { value: 'iran-bypass', label: t('pages.settings.subHappPresetIran') },
+                        { value: 'china-direct', label: t('pages.settings.subHappPresetChina') },
+                        { value: 'adblock', label: t('pages.settings.subHappPresetAdblock') },
+                        { value: 'global', label: t('pages.settings.subHappPresetGlobal') },
+                        { value: 'off', label: t('pages.settings.subHappPresetOff') },
+                      ]}
+                    />
+                    <Button type="primary" onClick={applyPreset}>
+                      {t('pages.settings.subHappPresets')}
+                    </Button>
+                  </Space>
+                </SettingListItem>
+
+                <SettingListItem
+                  paddings="small"
+                  title={t('pages.settings.subHappVisualBuilder')}
+                  description={t('pages.settings.subHappVisualBuilderDesc')}
+                >
+                  <Button icon={<BuildOutlined />} onClick={() => setIsModalOpen(true)}>
+                    {t('pages.settings.subHappVisualBuilder')}
+                  </Button>
+                </SettingListItem>
+
+                <SettingListItem
+                  paddings="small"
+                  title={t('pages.settings.subRoutingRules')}
+                  badge={remoteSourceBadge(allSetting.subRoutingRules)}
+                  description={t('pages.settings.subRoutingRulesDesc')}
+                >
+                  <Input.TextArea
+                    value={allSetting.subRoutingRules}
+                    rows={4}
+                    placeholder="happ://routing/onadd/... or https://.../DEFAULT.DEEPLINK"
+                    onChange={(e) => updateSetting({ subRoutingRules: e.target.value })}
+                  />
+                </SettingListItem>
+
+                <SettingListItem
+                  paddings="small"
+                  title={t('pages.settings.subHappNoLimit')}
+                  description={t('pages.settings.subHappNoLimitDesc')}
+                >
+                  <Switch
+                    checked={allSetting.subHappNoLimit}
+                    onChange={(v) => updateSetting({ subHappNoLimit: v })}
+                  />
+                </SettingListItem>
+
+                <SettingListItem
+                  paddings="small"
+                  title={t('pages.settings.subHideSettings')}
+                  description={t('pages.settings.subHideSettingsDesc')}
+                >
+                  <Switch
+                    checked={allSetting.subHideSettings}
+                    onChange={(v) => updateSetting({ subHideSettings: v })}
+                  />
+                </SettingListItem>
+              </>
+            ),
+          },
+          {
+            key: 'banners',
+            label: (
+              <span>
+                <NotificationOutlined /> {!isMobile && t('pages.settings.subHappGroupBanners')}
+              </span>
+            ),
+            children: (
+              <>
+                <SettingListItem
+                  paddings="small"
+                  title={t('pages.settings.subHappSubInfoText')}
+                  description={t('pages.settings.subHappSubInfoTextDesc')}
+                >
+                  <Input
+                    value={allSetting.subHappSubInfoText}
+                    maxLength={200}
+                    placeholder="Welcome to our high-speed network!"
+                    onChange={(e) => updateSetting({ subHappSubInfoText: e.target.value })}
+                  />
+                </SettingListItem>
+
+                <SettingListItem
+                  paddings="small"
+                  title={t('pages.settings.subHappSubInfoColor')}
+                  description={t('pages.settings.subHappSubInfoColorDesc')}
+                >
+                  <Select
+                    value={allSetting.subHappSubInfoColor || 'blue'}
+                    style={{ width: '100%' }}
+                    onChange={(v) => updateSetting({ subHappSubInfoColor: v })}
+                    options={[
+                      { value: 'blue', label: t('pages.settings.subHappColorBlue') },
+                      { value: 'green', label: t('pages.settings.subHappColorGreen') },
+                      { value: 'red', label: t('pages.settings.subHappColorRed') },
+                    ]}
+                  />
+                </SettingListItem>
+
+                <SettingListItem
+                  paddings="small"
+                  title={t('pages.settings.subHappSubInfoButtonText')}
+                  description={t('pages.settings.subHappSubInfoButtonTextDesc')}
+                >
+                  <Input
+                    value={allSetting.subHappSubInfoButtonText}
+                    maxLength={25}
+                    placeholder="Support Channel"
+                    onChange={(e) => updateSetting({ subHappSubInfoButtonText: e.target.value })}
+                  />
+                </SettingListItem>
+
+                <SettingListItem
+                  paddings="small"
+                  title={t('pages.settings.subHappSubInfoButtonLink')}
+                  description={t('pages.settings.subHappSubInfoButtonLinkDesc')}
+                >
+                  <Input
+                    value={allSetting.subHappSubInfoButtonLink}
+                    placeholder="https://t.me/your_channel"
+                    onChange={(e) => updateSetting({ subHappSubInfoButtonLink: e.target.value })}
+                  />
+                </SettingListItem>
+
+                <SettingListItem
+                  paddings="small"
+                  title={t('pages.settings.subHappSubExpire')}
+                  description={t('pages.settings.subHappSubExpireDesc')}
+                >
+                  <Switch
+                    checked={allSetting.subHappSubExpire}
+                    onChange={(v) => updateSetting({ subHappSubExpire: v })}
+                  />
+                </SettingListItem>
+
+                <SettingListItem
+                  paddings="small"
+                  title={t('pages.settings.subHappSubExpireButtonLink')}
+                  description={t('pages.settings.subHappSubExpireButtonLinkDesc')}
+                >
+                  <Input
+                    value={allSetting.subHappSubExpireButtonLink}
+                    placeholder="https://example.com/renew"
+                    onChange={(e) => updateSetting({ subHappSubExpireButtonLink: e.target.value })}
+                  />
+                </SettingListItem>
+
+                <SettingListItem
+                  paddings="small"
+                  title={t('pages.settings.subHappNotificationExpire')}
+                  description={t('pages.settings.subHappNotificationExpireDesc')}
+                >
+                  <Switch
+                    checked={allSetting.subHappNotificationExpire}
+                    onChange={(v) => updateSetting({ subHappNotificationExpire: v })}
+                  />
+                </SettingListItem>
+              </>
+            ),
+          },
+          {
+            key: 'network',
+            label: (
+              <span>
+                <ThunderboltOutlined /> {!isMobile && t('pages.settings.subHappGroupNetwork')}
+              </span>
+            ),
+            children: (
+              <>
+                <SettingListItem
+                  paddings="small"
+                  title={t('pages.settings.subHappTunMode')}
+                  description={t('pages.settings.subHappTunModeDesc')}
+                >
+                  <Select
+                    value={allSetting.subHappTunMode}
+                    style={{ width: '100%' }}
+                    onChange={(v) => updateSetting({ subHappTunMode: v })}
+                    options={[
+                      // happ.su documents tun-mode as system|gvisor only, so
+                      // Default is the unset state rather than a third value.
+                      { value: '', label: t('pages.settings.subHappTunModeDefault') },
+                      { value: 'system', label: t('pages.settings.subHappTunModeSystem') },
+                      { value: 'gvisor', label: t('pages.settings.subHappTunModeGvisor') },
+                    ]}
+                  />
+                </SettingListItem>
+
+                <SettingListItem
+                  paddings="small"
+                  title={t('pages.settings.subHappTunType')}
+                  description={t('pages.settings.subHappTunTypeDesc')}
+                >
+                  <Select
+                    value={allSetting.subHappTunType || 'default'}
+                    style={{ width: '100%' }}
+                    onChange={(v) => updateSetting({ subHappTunType: v })}
+                    options={[
+                      { value: 'singbox', label: t('pages.settings.subHappTunTypeSingbox') },
+                      { value: 'tun2proxy', label: t('pages.settings.subHappTunTypeTun2proxy') },
+                      { value: 'default', label: t('pages.settings.subHappTunTypeDefault') },
+                      { value: 'xray', label: t('pages.settings.subHappTunTypeXray') },
+                    ]}
+                  />
+                </SettingListItem>
+
+                <SettingListItem
+                  paddings="small"
+                  title={t('pages.settings.subHappExcludeRoutes')}
+                  description={t('pages.settings.subHappExcludeRoutesDesc')}
+                >
+                  <Input
+                    value={allSetting.subHappExcludeRoutes}
+                    placeholder="192.168.0.0/16, 10.0.0.0/8"
+                    onChange={(e) => updateSetting({ subHappExcludeRoutes: e.target.value })}
+                  />
+                </SettingListItem>
+
+                <SettingListItem
+                  paddings="small"
+                  title={t('pages.settings.subHappExcludeApns')}
+                  description={t('pages.settings.subHappExcludeApnsDesc')}
+                >
+                  <Switch
+                    checked={allSetting.subHappExcludeApns}
+                    onChange={(v) => updateSetting({ subHappExcludeApns: v })}
+                  />
+                </SettingListItem>
+
+                <SettingListItem
+                  paddings="small"
+                  title={t('pages.settings.subHappPingType')}
+                  description={t('pages.settings.subHappPingTypeDesc')}
+                >
+                  <Select
+                    value={allSetting.subHappPingType || 'proxy'}
+                    style={{ width: '100%' }}
+                    onChange={(v) => updateSetting({ subHappPingType: v })}
+                    options={[
+                      { value: 'proxy', label: t('pages.settings.subHappPingProxy') },
+                      { value: 'proxy-head', label: t('pages.settings.subHappPingProxyHead') },
+                      { value: 'tcp', label: t('pages.settings.subHappPingTcp') },
+                      { value: 'icmp', label: t('pages.settings.subHappPingIcmp') },
+                    ]}
+                  />
+                </SettingListItem>
+
+                <SettingListItem
+                  paddings="small"
+                  title={t('pages.settings.subHappAutoConnect')}
+                  description={t('pages.settings.subHappAutoConnectDesc')}
+                >
+                  <Switch
+                    checked={allSetting.subHappAutoConnect}
+                    onChange={(v) => updateSetting({ subHappAutoConnect: v })}
+                  />
+                </SettingListItem>
+
+                <SettingListItem
+                  paddings="small"
+                  title={t('pages.settings.subHappAutoConnectType')}
+                  description={t('pages.settings.subHappAutoConnectTypeDesc')}
+                >
+                  <Select
+                    value={allSetting.subHappAutoConnectType || 'lowestdelay'}
+                    style={{ width: '100%' }}
+                    onChange={(v) => updateSetting({ subHappAutoConnectType: v })}
+                    options={[
+                      {
+                        value: 'lowestdelay',
+                        label: t('pages.settings.subHappAutoConnectLowestDelay'),
+                      },
+                      { value: 'lastused', label: t('pages.settings.subHappAutoConnectLastUsed') },
+                      { value: 'random', label: t('pages.settings.subHappAutoConnectRandom') },
+                    ]}
+                  />
+                </SettingListItem>
+              </>
+            ),
+          },
+          {
+            key: 'themes',
+            label: (
+              <span>
+                <DesktopOutlined /> {!isMobile && t('pages.settings.subHappGroupThemes')}
+              </span>
+            ),
+            children: (
+              <>
+                <SettingListItem
+                  paddings="small"
+                  title={t('pages.settings.subHappColorProfile')}
+                  description={t('pages.settings.subHappColorProfileDesc')}
+                >
+                  <Input
+                    value={allSetting.subHappColorProfile}
+                    placeholder="default, violet, turquoise, cyberpunk, or custom JSON"
+                    onChange={(e) => updateSetting({ subHappColorProfile: e.target.value })}
+                  />
+                </SettingListItem>
+              </>
+            ),
+          },
+          {
+            key: 'failover',
+            label: (
+              <span>
+                <CloudSyncOutlined /> {!isMobile && t('pages.settings.subHappGroupFailover')}
+              </span>
+            ),
+            children: (
+              <>
+                <SettingListItem
+                  paddings="small"
+                  title={t('pages.settings.subHappAutoDetect')}
+                  description={t('pages.settings.subHappAutoDetectDesc')}
+                >
+                  <Switch
+                    checked={allSetting.subHappAutoDetect}
+                    onChange={(v) => updateSetting({ subHappAutoDetect: v })}
+                  />
+                </SettingListItem>
+
+                <SettingListItem
+                  paddings="small"
+                  title={t('pages.settings.subHappProviderId')}
+                  description={t('pages.settings.subHappProviderIdDesc')}
+                >
+                  <Input
+                    value={allSetting.subHappProviderId}
+                    placeholder="my-happ-provider"
+                    onChange={(e) => updateSetting({ subHappProviderId: e.target.value })}
+                  />
+                </SettingListItem>
+
+                <SettingListItem
+                  paddings="small"
+                  title={t('pages.settings.subHappNewUrl')}
+                  description={t('pages.settings.subHappNewUrlDesc')}
+                >
+                  <Input
+                    value={allSetting.subHappNewUrl}
+                    placeholder="https://new-domain.com/sub/..."
+                    onChange={(e) => updateSetting({ subHappNewUrl: e.target.value })}
+                  />
+                </SettingListItem>
+
+                <SettingListItem
+                  paddings="small"
+                  title={t('pages.settings.subHappFallbackUrl')}
+                  description={t('pages.settings.subHappFallbackUrlDesc')}
+                >
+                  <Input
+                    value={allSetting.subHappFallbackUrl}
+                    placeholder="https://backup-domain.com/sub/..."
+                    onChange={(e) => updateSetting({ subHappFallbackUrl: e.target.value })}
+                  />
+                </SettingListItem>
+
+                <SettingListItem
+                  paddings="small"
+                  title={t('pages.settings.subHappAlwaysHwid')}
+                  description={t('pages.settings.subHappAlwaysHwidDesc')}
+                >
+                  <Switch
+                    checked={allSetting.subHappAlwaysHwid}
+                    onChange={(v) => updateSetting({ subHappAlwaysHwid: v })}
+                  />
+                </SettingListItem>
+              </>
+            ),
+          },
+          {
+            key: 'android',
+            label: (
+              <span>
+                <MobileOutlined /> {!isMobile && t('pages.settings.subHappGroupAndroid')}
+              </span>
+            ),
+            children: (
+              <>
+                <SettingListItem
+                  paddings="small"
+                  title={t('pages.settings.subHappPerAppMode')}
+                  description={t('pages.settings.subHappPerAppModeDesc')}
+                >
+                  <Select
+                    value={allSetting.subHappPerAppMode || 'off'}
+                    style={{ width: '100%' }}
+                    onChange={(v) => updateSetting({ subHappPerAppMode: v })}
+                    options={[
+                      { value: 'off', label: t('pages.settings.subHappPerAppOff') },
+                      { value: 'on', label: t('pages.settings.subHappPerAppOn') },
+                      { value: 'bypass', label: t('pages.settings.subHappPerAppBypass') },
+                    ]}
+                  />
+                </SettingListItem>
+
+                <SettingListItem
+                  paddings="small"
+                  title={t('pages.settings.subHappPerAppList')}
+                  description={t('pages.settings.subHappPerAppListDesc')}
+                >
+                  <Input.TextArea
+                    value={allSetting.subHappPerAppList}
+                    rows={4}
+                    placeholder="org.telegram.messenger, com.google.android.youtube"
+                    onChange={(e) => updateSetting({ subHappPerAppList: e.target.value })}
+                  />
+                </SettingListItem>
+              </>
+            ),
+          },
+        ]}
+      />
+
+      <Modal
+        title={t('pages.settings.subHappModalTitle')}
+        open={isModalOpen}
+        onCancel={() => setIsModalOpen(false)}
+        onOk={handleBuildDeeplink}
+        okText={t('pages.settings.subHappBuildDeeplink')}
+        width={650}
+      >
+        <Space direction="vertical" style={{ width: '100%', marginTop: 12 }} size="middle">
+          <div>
+            <div style={{ fontWeight: 600, marginBottom: 4 }}>
+              {t('pages.settings.subHappDirectDomains')}
+            </div>
+            <Input.TextArea
+              rows={2}
+              value={directDomains}
+              placeholder="domain:ir, domain:cn, example.local"
+              onChange={(e) => setDirectDomains(e.target.value)}
+            />
+          </div>
+          <div>
+            <div style={{ fontWeight: 600, marginBottom: 4 }}>
+              {t('pages.settings.subHappProxyDomains')}
+            </div>
+            <Input.TextArea
+              rows={2}
+              value={proxyDomains}
+              placeholder="geosite:google, youtube.com"
+              onChange={(e) => setProxyDomains(e.target.value)}
+            />
+          </div>
+          <div>
+            <div style={{ fontWeight: 600, marginBottom: 4 }}>
+              {t('pages.settings.subHappBlockDomains')}
+            </div>
+            <Input.TextArea
+              rows={2}
+              value={blockDomains}
+              placeholder="geosite:category-ads-all, analytics.google.com"
+              onChange={(e) => setBlockDomains(e.target.value)}
+            />
+          </div>
+          <div>
+            <div style={{ fontWeight: 600, marginBottom: 4 }}>
+              {t('pages.settings.subHappDirectIPs')}
+            </div>
+            <Input.TextArea
+              rows={2}
+              value={directIPs}
+              placeholder="geoip:ir, 192.168.0.0/16, 10.0.0.0/8"
+              onChange={(e) => setDirectIPs(e.target.value)}
+            />
+          </div>
+          <div>
+            <div style={{ fontWeight: 600, marginBottom: 4 }}>
+              {t('pages.settings.subHappProxyIPs')}
+            </div>
+            <Input.TextArea
+              rows={2}
+              value={proxyIPs}
+              placeholder="1.1.1.1/32, 8.8.8.8/32"
+              onChange={(e) => setProxyIPs(e.target.value)}
+            />
+          </div>
+          <div>
+            <div style={{ fontWeight: 600, marginBottom: 4 }}>
+              {t('pages.settings.subHappBlockIPs')}
+            </div>
+            <Input.TextArea
+              rows={2}
+              value={blockIPs}
+              placeholder="geoip:phishing, 0.0.0.0/8"
+              onChange={(e) => setBlockIPs(e.target.value)}
+            />
+          </div>
+        </Space>
+      </Modal>
+    </>
+  );
+}

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

@@ -16,6 +16,7 @@ import { GoRegexInput } from '@/components/form';
 import { useMediaQuery } from '@/hooks/useMediaQuery';
 import { useMediaQuery } from '@/hooks/useMediaQuery';
 import { catTabLabel } from './catTabLabel';
 import { catTabLabel } from './catTabLabel';
 import { sanitizePath, normalizePath } from './uriPath';
 import { sanitizePath, normalizePath } from './uriPath';
+import { remoteSourceBadge } from './subscriptionShared';
 import SubJsonFinalMaskForm from './SubJsonFinalMaskForm';
 import SubJsonFinalMaskForm from './SubJsonFinalMaskForm';
 import './SubscriptionFormatsTab.css';
 import './SubscriptionFormatsTab.css';
 
 
@@ -220,6 +221,19 @@ export default function SubscriptionFormatsTab({
                       onChange={(value) => updateSetting({ subJsonUserAgentRegex: value })}
                       onChange={(value) => updateSetting({ subJsonUserAgentRegex: value })}
                     />
                     />
                   </SettingListItem>
                   </SettingListItem>
+                  <SettingListItem
+                    paddings="small"
+                    title={t('pages.settings.subJsonRoutingRules')}
+                    badge={remoteSourceBadge(allSetting.subJsonRoutingRules)}
+                    description={t('pages.settings.subJsonRoutingRulesDesc')}
+                  >
+                    <Input.TextArea
+                      value={allSetting.subJsonRoutingRules}
+                      placeholder="happ://routing/onadd/... , routing JSON, or https://.../DEFAULT.JSON"
+                      onChange={(e) => updateSetting({ subJsonRoutingRules: e.target.value })}
+                      autoSize={{ minRows: 2, maxRows: 6 }}
+                    />
+                  </SettingListItem>
                 </Card>
                 </Card>
               )}
               )}
               {allSetting.subClashEnable && (
               {allSetting.subClashEnable && (

+ 9 - 40
frontend/src/pages/settings/SubscriptionGeneralTab.tsx

@@ -1,4 +1,4 @@
-import { Alert, Button, Input, InputNumber, Switch, Tabs, Tag } from 'antd';
+import { Alert, Button, Input, InputNumber, Switch, Tabs } from 'antd';
 import {
 import {
   BranchesOutlined,
   BranchesOutlined,
   CompassOutlined,
   CompassOutlined,
@@ -17,17 +17,14 @@ import { RemarkTemplateField } from '@/components/form';
 import { useMediaQuery } from '@/hooks/useMediaQuery';
 import { useMediaQuery } from '@/hooks/useMediaQuery';
 import { catTabLabel } from './catTabLabel';
 import { catTabLabel } from './catTabLabel';
 import { sanitizePath, normalizePath } from './uriPath';
 import { sanitizePath, normalizePath } from './uriPath';
+import HappSettingsContent from './HappSettingsContent';
+import { remoteSourceBadge } from './subscriptionShared';
 
 
 interface SubscriptionGeneralTabProps {
 interface SubscriptionGeneralTabProps {
   allSetting: AllSetting;
   allSetting: AllSetting;
   updateSetting: (patch: Partial<AllSetting>) => void;
   updateSetting: (patch: Partial<AllSetting>) => void;
 }
 }
 
 
-const isRemoteRoutingSource = (value: string) => /^https:\/\/\S+$/i.test(value.trim());
-
-const remoteSourceBadge = (value: string) =>
-  isRemoteRoutingSource(value) ? <Tag color="blue">HTTPS URL</Tag> : undefined;
-
 export default function SubscriptionGeneralTab({
 export default function SubscriptionGeneralTab({
   allSetting,
   allSetting,
   updateSetting,
   updateSetting,
@@ -344,40 +341,12 @@ export default function SubscriptionGeneralTab({
           key: '5',
           key: '5',
           label: catTabLabel(<BranchesOutlined />, 'Happ', isMobile),
           label: catTabLabel(<BranchesOutlined />, 'Happ', isMobile),
           children: (
           children: (
-            <>
-              <SettingListItem
-                paddings="small"
-                title={t('pages.settings.subEnableRouting')}
-                description={t('pages.settings.subEnableRoutingDesc')}
-              >
-                <Switch
-                  checked={allSetting.subEnableRouting}
-                  onChange={(v) => updateSetting({ subEnableRouting: v })}
-                />
-              </SettingListItem>
-              <SettingListItem
-                paddings="small"
-                title={t('pages.settings.subRoutingRules')}
-                badge={remoteSourceBadge(allSetting.subRoutingRules)}
-                description={t('pages.settings.subRoutingRulesDesc')}
-              >
-                <Input.TextArea
-                  value={allSetting.subRoutingRules}
-                  placeholder="happ://routing/onadd/... or https://.../DEFAULT.DEEPLINK"
-                  onChange={(e) => updateSetting({ subRoutingRules: e.target.value })}
-                />
-              </SettingListItem>
-              <SettingListItem
-                paddings="small"
-                title={t('pages.settings.subHideSettings')}
-                description={t('pages.settings.subHideSettingsDesc')}
-              >
-                <Switch
-                  checked={allSetting.subHideSettings}
-                  onChange={(v) => updateSetting({ subHideSettings: v })}
-                />
-              </SettingListItem>
-            </>
+            <HappSettingsContent
+              allSetting={allSetting}
+              updateSetting={updateSetting}
+              isMobile={isMobile}
+              remoteSourceBadge={remoteSourceBadge}
+            />
           ),
           ),
         },
         },
         {
         {

+ 118 - 0
frontend/src/pages/settings/happPresets.ts

@@ -0,0 +1,118 @@
+// Encode UTF-8 string to standard Base64 safe for browser environments.
+export function toBase64Utf8(str: string): string {
+  return btoa(
+    encodeURIComponent(str).replace(/%([0-9A-F]{2})/g, (_, p1) =>
+      String.fromCharCode(Number(`0x${p1}`)),
+    ),
+  );
+}
+
+// Splits multiline or comma-separated string into clean unique token arrays.
+export function parseList(input: string): string[] {
+  return input
+    .split(/[\n,]+/)
+    .map((s) => s.trim())
+    .filter(Boolean);
+}
+
+// Build standard Happ routing deeplink or special state for curated presets.
+export function buildHappPresetDeeplink(preset: string): string {
+  switch (preset) {
+    case 'off':
+      return 'happ://routing/off';
+    case 'iran-bypass':
+      return (
+        'happ://routing/onadd/' +
+        toBase64Utf8(
+          JSON.stringify({
+            rules: [
+              {
+                type: 'field',
+                outboundTag: 'direct',
+                domain: ['domain:ir', 'regexp:.*\\.ir$'],
+                ip: ['geoip:ir', 'geoip:private'],
+              },
+              {
+                type: 'field',
+                outboundTag: 'block',
+                domain: ['geosite:category-ads-all'],
+              },
+              {
+                type: 'field',
+                outboundTag: 'proxy',
+                network: 'tcp,udp',
+              },
+            ],
+          }),
+        )
+      );
+    case 'china-direct':
+      return (
+        'happ://routing/onadd/' +
+        toBase64Utf8(
+          JSON.stringify({
+            rules: [
+              {
+                type: 'field',
+                outboundTag: 'direct',
+                domain: ['domain:cn', 'geosite:cn'],
+                ip: ['geoip:cn', 'geoip:private'],
+              },
+              {
+                type: 'field',
+                outboundTag: 'block',
+                domain: ['geosite:category-ads-all'],
+              },
+              {
+                type: 'field',
+                outboundTag: 'proxy',
+                network: 'tcp,udp',
+              },
+            ],
+          }),
+        )
+      );
+    case 'adblock':
+      return (
+        'happ://routing/onadd/' +
+        toBase64Utf8(
+          JSON.stringify({
+            rules: [
+              {
+                type: 'field',
+                outboundTag: 'block',
+                domain: ['geosite:category-ads-all'],
+              },
+              {
+                type: 'field',
+                outboundTag: 'direct',
+                ip: ['geoip:private'],
+              },
+              {
+                type: 'field',
+                outboundTag: 'proxy',
+                network: 'tcp,udp',
+              },
+            ],
+          }),
+        )
+      );
+    case 'global':
+      return (
+        'happ://routing/onadd/' +
+        toBase64Utf8(
+          JSON.stringify({
+            rules: [
+              {
+                type: 'field',
+                outboundTag: 'proxy',
+                network: 'tcp,udp',
+              },
+            ],
+          }),
+        )
+      );
+    default:
+      return '';
+  }
+}

+ 6 - 0
frontend/src/pages/settings/subscriptionShared.tsx

@@ -0,0 +1,6 @@
+import { Tag } from 'antd';
+
+export const isRemoteRoutingSource = (value: string) => /^https:\/\/\S+$/i.test(value.trim());
+
+export const remoteSourceBadge = (value: string) =>
+  isRemoteRoutingSource(value) ? <Tag color="blue">HTTPS URL</Tag> : undefined;

+ 2 - 0
frontend/src/pages/xray/outbounds/OutboundFormModal.tsx

@@ -45,6 +45,7 @@ import {
   VmessFields,
   VmessFields,
   WireguardFields,
   WireguardFields,
 } from './protocols';
 } from './protocols';
+import { AmneziawgFields } from './protocols';
 import {
 import {
   GrpcForm,
   GrpcForm,
   HttpUpgradeForm,
   HttpUpgradeForm,
@@ -453,6 +454,7 @@ export default function OutboundFormModal({
                       )}
                       )}
 
 
                       {protocol === 'wireguard' && <WireguardFields />}
                       {protocol === 'wireguard' && <WireguardFields />}
+                      {protocol === 'amneziawg' && <AmneziawgFields />}
 
 
                       {streamAllowed && network && (
                       {streamAllowed && network && (
                         <>
                         <>

+ 228 - 0
frontend/src/pages/xray/outbounds/protocols/amneziawg.tsx

@@ -0,0 +1,228 @@
+import { useTranslation } from 'react-i18next';
+import { Button, Form, Input, InputNumber, Space, Switch } from 'antd';
+import { MinusOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons';
+import { useFieldArray, useFormContext } from 'react-hook-form';
+
+import { Wireguard } from '@/utils';
+import { activateOnKey } from '@/utils/a11y';
+import { InputAddon } from '@/components/ui';
+import { FormField } from '@/components/form/rhf';
+
+// amneziawg outbound fields reuse the inbound i18n keys: identical protocol
+// parameters on both tunnel ends, so one label set serves both forms.
+export default function AmneziawgFields() {
+  const { t } = useTranslation();
+  const { control, setValue } = useFormContext();
+  const {
+    fields: peerFields,
+    append: appendPeer,
+    remove: removePeer,
+  } = useFieldArray({ control, name: 'settings.peers' });
+
+  return (
+    <>
+      <FormField label={t('pages.xray.amneziawg.mtu')} name={['settings', 'mtu']}>
+        <InputNumber min={0} style={{ width: '100%' }} />
+      </FormField>
+      <FormField
+        label={t('pages.xray.amneziawg.listenPort')}
+        name={['settings', 'listenPort']}
+        extra={t('pages.xray.amneziawg.listenPortHint')}
+      >
+        <InputNumber min={0} max={65535} style={{ width: '100%' }} />
+      </FormField>
+      <Form.Item label={t('pages.inbounds.privatekey')}>
+        <FormField name={['settings', 'secretKey']} noStyle>
+          <Input
+            aria-label={t('pages.inbounds.privatekey')}
+            style={{ width: 'calc(100% - 32px)' }}
+          />
+        </FormField>
+        <Button
+          icon={<ReloadOutlined />}
+          aria-label={t('regenerate')}
+          onClick={() => {
+            const pair = Wireguard.generateKeypair();
+            setValue('settings.secretKey', pair.privateKey);
+          }}
+        />
+      </Form.Item>
+      <Form.Item label={t('pages.inbounds.address')} required>
+        <FormField name={['settings', 'address', 0]} noStyle>
+          <Input placeholder="10.8.0.2/32" aria-label={t('pages.inbounds.address')} />
+        </FormField>
+      </Form.Item>
+      <FormField label={t('pages.inbounds.info.dns')} name={['settings', 'dns']}>
+        <Input placeholder="1.1.1.1:53" />
+      </FormField>
+      <FormField
+        name={['settings', 'headerProtectionKey']}
+        label={t('pages.xray.amneziawg.headerProtectionKey')}
+        extra={t('pages.xray.amneziawg.headerProtectionKeyHint')}
+      >
+        <Input />
+      </FormField>
+
+      <Form.Item
+        label={t('pages.xray.amneziawg.obfuscation')}
+        extra={t('pages.xray.amneziawg.outboundObfuscationHint')}
+      />
+      <ObfNumber name="jc" label={t('pages.xray.amneziawg.jc')} min={0} />
+      <ObfNumber name="jmin" label={t('pages.xray.amneziawg.jmin')} min={0} />
+      <ObfNumber name="jmax" label={t('pages.xray.amneziawg.jmax')} min={0} />
+      <ObfNumber name="s1" label={t('pages.xray.amneziawg.s1')} min={0} />
+      <ObfNumber name="s2" label={t('pages.xray.amneziawg.s2')} min={0} />
+      <ObfNumber name="s3" label={t('pages.xray.amneziawg.s3')} min={0} max={64} />
+      <ObfNumber name="s4" label={t('pages.xray.amneziawg.s4')} min={0} max={32} />
+      <ObfText name="h1" label={t('pages.xray.amneziawg.h1')} placeholder="100-800" />
+      <ObfText name="h2" label={t('pages.xray.amneziawg.h2')} placeholder="900-1600" />
+      <ObfText name="h3" label={t('pages.xray.amneziawg.h3')} placeholder="1700-2400" />
+      <ObfText name="h4" label={t('pages.xray.amneziawg.h4')} placeholder="2500-3200" />
+      <ObfText name="i1" label={t('pages.xray.amneziawg.i1')} placeholder="<r 64>" />
+      <ObfText
+        name="contentPaddingAddition"
+        label={t('pages.xray.amneziawg.contentPaddingAddition')}
+        placeholder="8-64"
+      />
+
+      <Form.Item label={t('pages.inbounds.form.peers')}>
+        <Button
+          size="small"
+          type="primary"
+          icon={<PlusOutlined />}
+          aria-label={t('add')}
+          onClick={() =>
+            appendPeer({
+              publicKey: '',
+              presharedKey: '',
+              allowedIPs: ['0.0.0.0/0', '::/0'],
+              endpoint: '',
+              keepAlive: 25,
+            })
+          }
+        />
+      </Form.Item>
+      {peerFields.map((field, index) => (
+        <div key={field.id}>
+          <Form.Item wrapperCol={{ md: { span: 14, offset: 8 } }}>
+            <div className="item-heading">
+              <span>{t('pages.inbounds.info.peerNumber', { n: index + 1 })}</span>
+              {peerFields.length > 1 && (
+                <MinusOutlined
+                  className="danger-icon"
+                  role="button"
+                  tabIndex={0}
+                  aria-label={t('remove')}
+                  onClick={() => removePeer(index)}
+                  onKeyDown={activateOnKey(() => removePeer(index))}
+                />
+              )}
+            </div>
+          </Form.Item>
+          <FormField
+            label={t('pages.xray.wireguard.endpoint')}
+            name={['settings', 'peers', index, 'endpoint']}
+          >
+            <Input placeholder="203.0.113.7:51820" />
+          </FormField>
+          <FormField
+            label={t('pages.inbounds.publicKey')}
+            name={['settings', 'peers', index, 'publicKey']}
+          >
+            <Input />
+          </FormField>
+          <FormField label="PSK" name={['settings', 'peers', index, 'presharedKey']}>
+            <Input />
+          </FormField>
+          <PeerAllowedIPs peerIndex={index} />
+          <FormField
+            label={t('pages.inbounds.info.keepAlive')}
+            name={['settings', 'peers', index, 'keepAlive']}
+          >
+            <InputNumber min={0} />
+          </FormField>
+        </div>
+      ))}
+
+      <FormField
+        name={['settings', 'randomTrailers']}
+        label={t('pages.xray.amneziawg.randomTrailers')}
+        valueProp="checked"
+      >
+        <Switch />
+      </FormField>
+      <FormField
+        name={['settings', 'disableCookies']}
+        label={t('pages.xray.amneziawg.disableCookies')}
+        valueProp="checked"
+      >
+        <Switch />
+      </FormField>
+    </>
+  );
+}
+
+function PeerAllowedIPs({ peerIndex }: { peerIndex: number }) {
+  const { t } = useTranslation();
+  const { control } = useFormContext();
+  const { fields, append, remove } = useFieldArray({
+    control,
+    name: `settings.peers.${peerIndex}.allowedIPs`,
+  });
+  return (
+    <Form.Item label={t('pages.xray.wireguard.allowedIPs')}>
+      {fields.map((field, ipIdx) => (
+        <Space.Compact key={field.id} block style={{ marginBottom: 4 }}>
+          <FormField noStyle name={['settings', 'peers', peerIndex, 'allowedIPs', ipIdx]}>
+            <Input aria-label={t('pages.xray.wireguard.allowedIPs')} />
+          </FormField>
+          {fields.length > 1 && (
+            <InputAddon ariaLabel={t('remove')} onClick={() => remove(ipIdx)}>
+              <MinusOutlined />
+            </InputAddon>
+          )}
+        </Space.Compact>
+      ))}
+      <Button
+        size="small"
+        icon={<PlusOutlined />}
+        aria-label={t('add')}
+        onClick={() => append('')}
+      />
+    </Form.Item>
+  );
+}
+
+function ObfNumber({
+  name,
+  label,
+  min,
+  max,
+}: {
+  name: string;
+  label: string;
+  min?: number;
+  max?: number;
+}) {
+  return (
+    <FormField label={label} name={['settings', name] as never}>
+      <InputNumber min={min} max={max} style={{ width: '100%' }} />
+    </FormField>
+  );
+}
+
+function ObfText({
+  name,
+  label,
+  placeholder,
+}: {
+  name: string;
+  label: string;
+  placeholder?: string;
+}) {
+  return (
+    <FormField label={label} name={['settings', name] as never}>
+      <Input placeholder={placeholder} />
+    </FormField>
+  );
+}

+ 1 - 0
frontend/src/pages/xray/outbounds/protocols/index.ts

@@ -6,6 +6,7 @@ export { default as ShadowsocksFields } from './shadowsocks';
 export { default as HttpFields } from './http';
 export { default as HttpFields } from './http';
 export { default as SocksFields } from './socks';
 export { default as SocksFields } from './socks';
 export { default as WireguardFields } from './wireguard';
 export { default as WireguardFields } from './wireguard';
+export { default as AmneziawgFields } from './amneziawg';
 export { default as FreedomFields } from './freedom';
 export { default as FreedomFields } from './freedom';
 export { default as LoopbackFields } from './loopback';
 export { default as LoopbackFields } from './loopback';
 export { default as BlackholeFields } from './blackhole';
 export { default as BlackholeFields } from './blackhole';

+ 26 - 3
frontend/src/schemas/client.ts

@@ -108,6 +108,8 @@ export const InboundOptionSchema = z
     tag: z.string().optional(),
     tag: z.string().optional(),
     protocol: z.string().optional(),
     protocol: z.string().optional(),
     port: z.number().optional(),
     port: z.number().optional(),
+    network: z.string().optional(),
+    security: z.string().optional(),
     tlsFlowCapable: z.boolean().optional(),
     tlsFlowCapable: z.boolean().optional(),
     ssMethod: z.string().optional(),
     ssMethod: z.string().optional(),
     wgPublicKey: z.string().optional(),
     wgPublicKey: z.string().optional(),
@@ -329,10 +331,31 @@ export const ClientBulkAdjustFormSchema = z
     addDays: z.number().int(),
     addDays: z.number().int(),
     addGB: z.number(),
     addGB: z.number(),
     flow: z.string().optional().default(''),
     flow: z.string().optional().default(''),
+    limitHwid: z.number().int().min(0).nullable().optional(),
+    adTag: z.string().optional().default(''),
   })
   })
-  .refine((v) => v.addDays !== 0 || v.addGB !== 0 || v.flow !== '', {
-    message: 'pages.clients.bulkAdjustNothing',
-  });
+  .refine(
+    (v) =>
+      v.addDays !== 0 ||
+      v.addGB !== 0 ||
+      v.flow !== '' ||
+      (v.limitHwid !== undefined && v.limitHwid !== null) ||
+      (v.adTag !== undefined && v.adTag.trim() !== ''),
+    {
+      message: 'pages.clients.bulkAdjustNothing',
+    },
+  )
+  .refine(
+    (v) => {
+      const tag = v.adTag?.trim();
+      if (!tag || tag === 'none') return true;
+      return /^[0-9a-fA-F]{32}$/.test(tag);
+    },
+    {
+      message: 'pages.inbounds.form.mtgAdTagInvalid',
+      path: ['adTag'],
+    },
+  );
 
 
 export const ClientBulkAddFormSchema = z.object({
 export const ClientBulkAddFormSchema = z.object({
   emailMethod: z.number().int().min(0).max(4),
   emailMethod: z.number().int().min(0).max(4),

+ 7 - 0
frontend/src/schemas/forms/outbound-form.ts

@@ -6,6 +6,7 @@ import { VmessSecuritySchema } from '@/schemas/protocols/shared/vmess';
 import { SecuritySettingsSchema } from '@/schemas/protocols/security';
 import { SecuritySettingsSchema } from '@/schemas/protocols/security';
 import { NetworkSettingsSchema, StreamExtrasSchema } from '@/schemas/protocols/stream';
 import { NetworkSettingsSchema, StreamExtrasSchema } from '@/schemas/protocols/stream';
 import {
 import {
+  AmneziaWGOutboundSettingsSchema,
   BlackholeResponseTypeSchema,
   BlackholeResponseTypeSchema,
   DNSRuleActionSchema,
   DNSRuleActionSchema,
   FreedomFinalRuleActionSchema,
   FreedomFinalRuleActionSchema,
@@ -112,6 +113,11 @@ export const WireguardOutboundFormSettingsSchema = z.object({
 });
 });
 export type WireguardOutboundFormSettings = z.infer<typeof WireguardOutboundFormSettingsSchema>;
 export type WireguardOutboundFormSettings = z.infer<typeof WireguardOutboundFormSettingsSchema>;
 
 
+// Re-export under the form name: the form state IS the wire shape (flat
+// obfuscation fields, same as the inbound server block), so no rename layer.
+export const AmneziaWGOutboundFormSettingsSchema = AmneziaWGOutboundSettingsSchema;
+export type AmneziaWGOutboundFormSettings = z.infer<typeof AmneziaWGOutboundFormSettingsSchema>;
+
 // Hysteria outbound carries the connect target only; transport-layer knobs
 // Hysteria outbound carries the connect target only; transport-layer knobs
 // (auth, congestion, up/down, hop port, timeouts) ride on stream.hysteria.
 // (auth, congestion, up/down, hop port, timeouts) ride on stream.hysteria.
 export const HysteriaOutboundFormSettingsSchema = z.object({
 export const HysteriaOutboundFormSettingsSchema = z.object({
@@ -194,6 +200,7 @@ export const OutboundFormSettingsSchema = z.discriminatedUnion('protocol', [
   z.object({ protocol: z.literal('socks'), settings: SocksOutboundFormSettingsSchema }),
   z.object({ protocol: z.literal('socks'), settings: SocksOutboundFormSettingsSchema }),
   z.object({ protocol: z.literal('http'), settings: HttpOutboundFormSettingsSchema }),
   z.object({ protocol: z.literal('http'), settings: HttpOutboundFormSettingsSchema }),
   z.object({ protocol: z.literal('wireguard'), settings: WireguardOutboundFormSettingsSchema }),
   z.object({ protocol: z.literal('wireguard'), settings: WireguardOutboundFormSettingsSchema }),
+  z.object({ protocol: z.literal('amneziawg'), settings: AmneziaWGOutboundFormSettingsSchema }),
   z.object({ protocol: z.literal('hysteria'), settings: HysteriaOutboundFormSettingsSchema }),
   z.object({ protocol: z.literal('hysteria'), settings: HysteriaOutboundFormSettingsSchema }),
   z.object({ protocol: z.literal('freedom'), settings: FreedomOutboundFormSettingsSchema }),
   z.object({ protocol: z.literal('freedom'), settings: FreedomOutboundFormSettingsSchema }),
   z.object({ protocol: z.literal('blackhole'), settings: BlackholeOutboundFormSettingsSchema }),
   z.object({ protocol: z.literal('blackhole'), settings: BlackholeOutboundFormSettingsSchema }),

+ 1 - 0
frontend/src/schemas/primitives/outbound-protocol.ts

@@ -7,6 +7,7 @@ export const OutboundProtocols = Object.freeze({
   Trojan: 'trojan',
   Trojan: 'trojan',
   Shadowsocks: 'shadowsocks',
   Shadowsocks: 'shadowsocks',
   Wireguard: 'wireguard',
   Wireguard: 'wireguard',
+  AmneziaWG: 'amneziawg',
   Hysteria: 'hysteria',
   Hysteria: 'hysteria',
   Socks: 'socks',
   Socks: 'socks',
   HTTP: 'http',
   HTTP: 'http',

+ 49 - 0
frontend/src/schemas/protocols/outbound/amneziawg.ts

@@ -0,0 +1,49 @@
+import { z } from 'zod';
+
+// Wire format of an "amneziawg" OUTBOUND settings block; form-edited and
+// backend-validated, swapped for a socks bridge at config generation.
+export const AmneziaWGOutboundPeerSchema = z.object({
+  publicKey: z.string().default(''),
+  presharedKey: z.string().default(''),
+  allowedIPs: z.array(z.string()).default(['0.0.0.0/0', '::/0']),
+  endpoint: z.string().default(''),
+  keepAlive: z.number().int().min(0).default(0),
+});
+export type AmneziaWGOutboundPeer = z.infer<typeof AmneziaWGOutboundPeerSchema>;
+
+export const AmneziaWGOutboundSettingsSchema = z.object({
+  // 0 = unset, so the backend derives MTU from S4 (amneziawg.EffectiveMTU);
+  // a pinned 1420 fragments every full-size packet once S4 exceeds 20.
+  mtu: z.number().int().min(0).default(0),
+  secretKey: z.string().default(''),
+  address: z.array(z.string()).default([]),
+  listenPort: z.number().int().min(0).max(65535).default(0),
+  dns: z.string().default(''),
+  jc: z.number().int().min(0).default(0),
+  jmin: z.number().int().min(0).default(40),
+  jmax: z.number().int().min(0).default(100),
+  s1: z.number().int().min(0).default(15),
+  s2: z.number().int().min(0).default(80),
+  s3: z.number().int().min(0).max(64).default(12),
+  s4: z.number().int().min(0).max(32).default(12),
+  h1: z.string().default(''),
+  h2: z.string().default(''),
+  h3: z.string().default(''),
+  h4: z.string().default(''),
+  i1: z.string().default(''),
+  i2: z.string().default(''),
+  i3: z.string().default(''),
+  i4: z.string().default(''),
+  i5: z.string().default(''),
+  headerProtectionKey: z.string().default(''),
+  contentPaddingAddition: z.string().default(''),
+  rekeyAfterTime: z.string().default(''),
+  rekeyTimeout: z.string().default(''),
+  rejectAfterTime: z.string().default(''),
+  keepaliveTimeout: z.string().default(''),
+  maxHandshakeAttempts: z.string().default(''),
+  randomTrailers: z.boolean().default(false),
+  disableCookies: z.boolean().default(true),
+  peers: z.array(AmneziaWGOutboundPeerSchema).default([]),
+});
+export type AmneziaWGOutboundSettings = z.infer<typeof AmneziaWGOutboundSettingsSchema>;

+ 3 - 0
frontend/src/schemas/protocols/outbound/index.ts

@@ -1,6 +1,7 @@
 import { z } from 'zod';
 import { z } from 'zod';
 
 
 import { BlackholeOutboundSettingsSchema } from './blackhole';
 import { BlackholeOutboundSettingsSchema } from './blackhole';
+import { AmneziaWGOutboundSettingsSchema } from './amneziawg';
 import { DNSOutboundSettingsSchema } from './dns';
 import { DNSOutboundSettingsSchema } from './dns';
 import { FreedomOutboundSettingsSchema } from './freedom';
 import { FreedomOutboundSettingsSchema } from './freedom';
 import { HttpOutboundSettingsSchema } from './http';
 import { HttpOutboundSettingsSchema } from './http';
@@ -14,6 +15,7 @@ import { VmessOutboundSettingsSchema } from './vmess';
 import { WireguardOutboundSettingsSchema } from './wireguard';
 import { WireguardOutboundSettingsSchema } from './wireguard';
 
 
 export * from './blackhole';
 export * from './blackhole';
+export * from './amneziawg';
 export * from './dns';
 export * from './dns';
 export * from './freedom';
 export * from './freedom';
 export * from './http';
 export * from './http';
@@ -32,6 +34,7 @@ export const OutboundSettingsSchema = z.discriminatedUnion('protocol', [
   z.object({ protocol: z.literal('trojan'), settings: TrojanOutboundSettingsSchema }),
   z.object({ protocol: z.literal('trojan'), settings: TrojanOutboundSettingsSchema }),
   z.object({ protocol: z.literal('shadowsocks'), settings: ShadowsocksOutboundSettingsSchema }),
   z.object({ protocol: z.literal('shadowsocks'), settings: ShadowsocksOutboundSettingsSchema }),
   z.object({ protocol: z.literal('wireguard'), settings: WireguardOutboundSettingsSchema }),
   z.object({ protocol: z.literal('wireguard'), settings: WireguardOutboundSettingsSchema }),
+  z.object({ protocol: z.literal('amneziawg'), settings: AmneziaWGOutboundSettingsSchema }),
   z.object({ protocol: z.literal('hysteria'), settings: HysteriaOutboundSettingsSchema }),
   z.object({ protocol: z.literal('hysteria'), settings: HysteriaOutboundSettingsSchema }),
   z.object({ protocol: z.literal('http'), settings: HttpOutboundSettingsSchema }),
   z.object({ protocol: z.literal('http'), settings: HttpOutboundSettingsSchema }),
   z.object({ protocol: z.literal('socks'), settings: SocksOutboundSettingsSchema }),
   z.object({ protocol: z.literal('socks'), settings: SocksOutboundSettingsSchema }),

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

@@ -74,9 +74,33 @@ export const AllSettingSchema = z
     subClashRules: z.string().optional(),
     subClashRules: z.string().optional(),
     subJsonMux: z.string().optional(),
     subJsonMux: z.string().optional(),
     subJsonRules: z.string().optional(),
     subJsonRules: z.string().optional(),
+    subJsonRoutingRules: z.string().optional(),
     subJsonFinalMask: z.string().optional(),
     subJsonFinalMask: z.string().optional(),
     subJsonObservatory: z.string().optional(),
     subJsonObservatory: z.string().optional(),
     subHideSettings: z.boolean().optional(),
     subHideSettings: z.boolean().optional(),
+    subHappAutoDetect: z.boolean().optional(),
+    subHappProviderId: z.string().optional(),
+    subHappNewUrl: z.string().optional(),
+    subHappFallbackUrl: z.string().optional(),
+    subHappSubInfoColor: z.string().optional(),
+    subHappSubInfoText: z.string().optional(),
+    subHappSubInfoButtonText: z.string().optional(),
+    subHappSubInfoButtonLink: z.string().optional(),
+    subHappSubExpire: z.boolean().optional(),
+    subHappSubExpireButtonLink: z.string().optional(),
+    subHappNotificationExpire: z.boolean().optional(),
+    subHappNoLimit: z.boolean().optional(),
+    subHappAlwaysHwid: z.boolean().optional(),
+    subHappTunMode: z.string().optional(),
+    subHappTunType: z.string().optional(),
+    subHappExcludeRoutes: z.string().optional(),
+    subHappExcludeApns: z.boolean().optional(),
+    subHappColorProfile: z.string().optional(),
+    subHappPingType: z.string().optional(),
+    subHappAutoConnect: z.boolean().optional(),
+    subHappAutoConnectType: z.string().optional(),
+    subHappPerAppMode: z.string().optional(),
+    subHappPerAppList: z.string().optional(),
     timeLocation: z.string().optional(),
     timeLocation: z.string().optional(),
     ldapEnable: z.boolean().optional(),
     ldapEnable: z.boolean().optional(),
     ldapHost: z.string().optional(),
     ldapHost: z.string().optional(),

+ 95 - 0
frontend/src/test/amneziawg-outbound-adapter.test.ts

@@ -0,0 +1,95 @@
+import { describe, expect, it } from 'vitest';
+
+import { formValuesToWirePayload, rawOutboundToFormValues } from '@/lib/xray/outbound-form-adapter';
+import type { AmneziaWGOutboundFormSettings } from '@/schemas/forms/outbound-form';
+
+// amneziawg outbound: lossless form->wire->form; payload stays a raw row.
+describe('amneziawg outbound adapter', () => {
+  const wire = {
+    mtu: 1380,
+    secretKey: '6Nn0ZB4C1Pj3TBEsXgLv7VdmSnYXGxS+HhVBDhvGgHE=',
+    address: ['10.8.0.2/32'],
+    listenPort: 40001,
+    jc: 5,
+    jmin: 40,
+    jmax: 90,
+    s1: 20,
+    s2: 90,
+    s3: 15,
+    s4: 13,
+    h1: '100-800',
+    h2: '900-1600',
+    h3: '1700-2400',
+    h4: '2500-3200',
+    i1: '<r 64>',
+    contentPaddingAddition: '8-40',
+    randomTrailers: true,
+    disableCookies: false,
+    peers: [
+      {
+        publicKey: 'Qk9fWqDqC7LzKpYvJq0m2b1tq8eF3uY6oPpRrSsTtUu=',
+        presharedKey: 'cHNo',
+        allowedIPs: ['0.0.0.0/0', '::/0'],
+        endpoint: '203.0.113.7:51820',
+        keepAlive: 25,
+      },
+    ],
+  };
+
+  it('hydrates defaults when the template omits optional keys', () => {
+    const values = rawOutboundToFormValues({ protocol: 'amneziawg', tag: 'awg-x' });
+    expect(values.protocol).toBe('amneziawg');
+    const s = values.settings as AmneziaWGOutboundFormSettings;
+    expect(s.mtu).toBe(0);
+    expect(s.randomTrailers).toBe(false);
+    expect(s.disableCookies).toBe(true);
+    expect(s.peers).toEqual([]);
+    expect(values.tag).toBe('awg-x');
+  });
+
+  // A blank MTU must reach the backend absent, not pinned to 1420: the Go
+  // EffectiveMTU subtracts S4 from the default only when the field is unset.
+  it('leaves a defaulted MTU out of the payload so the backend derives it', () => {
+    const values = rawOutboundToFormValues({ protocol: 'amneziawg', tag: 'awg-x' });
+    const payload = formValuesToWirePayload(values);
+    expect((payload.settings as Record<string, unknown>).mtu).toBeUndefined();
+  });
+
+  it('round-trips wire -> form -> wire losslessly', () => {
+    const values = rawOutboundToFormValues({ protocol: 'amneziawg', tag: 'awg-x', settings: wire });
+    const payload = formValuesToWirePayload(values);
+    expect(payload.protocol).toBe('amneziawg');
+    expect(payload.tag).toBe('awg-x');
+    // undefined-valued optionals are dropped by JSON semantics; compare the
+    // meaningful fields directly.
+    expect((payload.settings as Record<string, unknown>).mtu).toBe(1380);
+    expect((payload.settings as Record<string, unknown>).listenPort).toBe(40001);
+    expect((payload.settings as Record<string, unknown>).i1).toBe('<r 64>');
+    expect((payload.settings as Record<string, unknown>).peers).toEqual(wire.peers);
+    expect((payload.settings as Record<string, unknown>).disableCookies).toBe(false);
+  });
+
+  it('omits empty optional strings and zero listenPort from the payload', () => {
+    const values = rawOutboundToFormValues({ protocol: 'amneziawg', settings: wire });
+    const awg = values.settings as AmneziaWGOutboundFormSettings;
+    awg.i1 = '';
+    awg.listenPort = 0;
+    const payload = formValuesToWirePayload(values);
+    const s = payload.settings as Record<string, unknown>;
+    expect(s.i1).toBeUndefined();
+    expect(s.listenPort).toBeUndefined();
+    // always-present booleans survive so a true->false edit is diffable
+    expect(s.randomTrailers).toBe(true);
+  });
+
+  it('is included in every protocol-capability gate like wireguard (non-stream, non-mux)', () => {
+    const values = rawOutboundToFormValues({
+      protocol: 'amneziawg',
+      settings: wire,
+      streamSettings: { network: 'tcp', tcpSettings: {} },
+    });
+    const payload = formValuesToWirePayload(values);
+    // Non-stream protocol keeps only sockopt; here there is none.
+    expect(payload.streamSettings).toBeUndefined();
+  });
+});

+ 6 - 0
frontend/src/test/app-sidebar.test.tsx

@@ -65,3 +65,9 @@ test('returns to the compact rail after unpinning', () => {
   expect(sidebarRoot?.getAttribute('style')).toContain('--sider-rail: 72px');
   expect(sidebarRoot?.getAttribute('style')).toContain('--sider-rail: 72px');
   expect(localStorage.getItem('sidebar-pinned')).toBe('false');
   expect(localStorage.getItem('sidebar-pinned')).toBe('false');
 });
 });
+
+test('labels the palette shortcut with the modifier the platform actually uses', () => {
+  const view = renderSidebar();
+  const chip = view.container.querySelector('.sidebar-command-kbd');
+  expect(chip?.textContent).toBe('CtrlK');
+});

+ 330 - 0
frontend/src/test/command-palette.test.tsx

@@ -0,0 +1,330 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { act, fireEvent, screen, waitFor } from '@testing-library/react';
+import { MemoryRouter } from 'react-router';
+
+import CommandPalette from '@/components/command-palette/CommandPalette';
+import { commandPaletteStore } from '@/components/command-palette/useCommandPalette';
+import { HttpUtil, Msg } from '@/utils';
+import { renderWithProviders } from './test-utils';
+
+function renderPalette() {
+  return renderWithProviders(
+    <MemoryRouter>
+      <CommandPalette />
+    </MemoryRouter>,
+  );
+}
+
+describe('CommandPalette component', () => {
+  beforeEach(() => {
+    window.HTMLElement.prototype.scrollIntoView = vi.fn();
+    act(() => {
+      commandPaletteStore.close();
+    });
+    vi.clearAllMocks();
+  });
+
+  it('does not render when closed', () => {
+    renderPalette();
+    expect(screen.queryByRole('dialog')).toBeNull();
+  });
+
+  it('renders and focuses input when opened via store', async () => {
+    renderPalette();
+
+    act(() => {
+      commandPaletteStore.open();
+    });
+
+    expect(screen.getByRole('dialog')).toBeTruthy();
+    const input = screen.getByPlaceholderText(/Type a command or search/i);
+    expect(input).toBeTruthy();
+    await waitFor(() => {
+      expect(document.activeElement).toBe(input);
+    });
+  });
+
+  it('toggles open and closed with Ctrl+K and Escape keyboard shortcuts', () => {
+    renderPalette();
+
+    act(() => {
+      window.dispatchEvent(new KeyboardEvent('keydown', { key: 'k', code: 'KeyK', ctrlKey: true }));
+    });
+    expect(commandPaletteStore.getSnapshot()).toBe(true);
+
+    act(() => {
+      window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
+    });
+    expect(commandPaletteStore.getSnapshot()).toBe(false);
+
+    act(() => {
+      window.dispatchEvent(new KeyboardEvent('keydown', { key: 'ن', code: 'KeyK', ctrlKey: true }));
+    });
+    expect(commandPaletteStore.getSnapshot()).toBe(true);
+
+    act(() => {
+      window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
+    });
+    expect(commandPaletteStore.getSnapshot()).toBe(false);
+  });
+
+  it('closes when clicking backdrop', () => {
+    renderPalette();
+    act(() => {
+      commandPaletteStore.open();
+    });
+
+    const backdrop = screen.getByRole('presentation');
+    fireEvent.click(backdrop);
+
+    expect(commandPaletteStore.getSnapshot()).toBe(false);
+  });
+
+  it('navigates items with ArrowDown and ArrowUp', () => {
+    renderPalette();
+    act(() => {
+      commandPaletteStore.open();
+    });
+
+    const input = screen.getByPlaceholderText(/Type a command or search/i);
+    const items = document.querySelectorAll('.command-palette-item');
+    expect(items.length).toBeGreaterThan(0);
+
+    expect(items[0]?.classList.contains('active')).toBe(true);
+
+    fireEvent.keyDown(input, { key: 'ArrowDown' });
+    const updatedItems = document.querySelectorAll('.command-palette-item');
+    expect(updatedItems[1]?.classList.contains('active')).toBe(true);
+
+    fireEvent.keyDown(input, { key: 'ArrowUp' });
+    const reupdatedItems = document.querySelectorAll('.command-palette-item');
+    expect(reupdatedItems[0]?.classList.contains('active')).toBe(true);
+  });
+
+  it('filters items when typing a search query', async () => {
+    renderPalette();
+    act(() => {
+      commandPaletteStore.open();
+    });
+
+    const input = screen.getByPlaceholderText(/Type a command or search/i);
+    fireEvent.change(input, { target: { value: 'settings' } });
+
+    expect(screen.getAllByText(/Panel Settings/i).length).toBeGreaterThan(0);
+  });
+
+  it('resets query on close and does not persist query on reopen', async () => {
+    renderPalette();
+    act(() => {
+      commandPaletteStore.open();
+    });
+
+    const input = screen.getByPlaceholderText(/Type a command or search/i) as HTMLInputElement;
+    fireEvent.change(input, { target: { value: 'settings' } });
+    expect(input.value).toBe('settings');
+
+    act(() => {
+      commandPaletteStore.close();
+    });
+
+    act(() => {
+      commandPaletteStore.open();
+    });
+
+    const reopenedInput = screen.getByPlaceholderText(
+      /Type a command or search/i,
+    ) as HTMLInputElement;
+    expect(reopenedInput.value).toBe('');
+  });
+
+  it('does not show spinning loader on whitespace-only input', () => {
+    renderPalette();
+    act(() => {
+      commandPaletteStore.open();
+    });
+
+    const input = screen.getByPlaceholderText(/Type a command or search/i);
+    fireEvent.change(input, { target: { value: '   ' } });
+
+    expect(document.querySelector('.command-palette-search-icon.spinning')).toBeNull();
+  });
+
+  it('does not display stale client rows when a new search query is being fetched', async () => {
+    let resolveBob: ((val: Msg<{ items: unknown[] }>) => void) | undefined;
+    const bobPromise = new Promise<Msg<{ items: unknown[] }>>((resolve) => {
+      resolveBob = resolve;
+    });
+
+    vi.spyOn(HttpUtil, 'get').mockImplementation(async (url: string) => {
+      if (url.includes('/panel/api/inbounds/options')) {
+        return new Msg(true, '', []);
+      }
+      if (url.includes('search=ali')) {
+        return new Msg(true, '', {
+          items: [
+            {
+              id: 1,
+              email: '[email protected]',
+              totalGB: 1000,
+              enable: true,
+              traffic: { up: 100, down: 200, total: 1000 },
+            },
+          ],
+        });
+      }
+      if (url.includes('search=bob')) {
+        return bobPromise as Promise<Msg<unknown>>;
+      }
+      return new Msg(true, '', {});
+    });
+
+    renderPalette();
+    act(() => {
+      commandPaletteStore.open();
+    });
+
+    const input = screen.getByPlaceholderText(/Type a command or search/i);
+
+    // Type 'ali' and wait for Alice to appear after debounce
+    fireEvent.change(input, { target: { value: 'ali' } });
+    await waitFor(
+      () => {
+        expect(screen.getByText('[email protected]')).toBeTruthy();
+      },
+      { timeout: 2000 },
+    );
+
+    // Now type 'bob'
+    fireEvent.change(input, { target: { value: 'bob' } });
+
+    // Alice must vanish immediately upon new input
+    await waitFor(() => {
+      expect(screen.queryByText('[email protected]')).toBeNull();
+    });
+
+    // Wait past the 300ms debounce interval while bob fetch is still pending
+    await new Promise((resolve) => setTimeout(resolve, 350));
+
+    // Stale Alice row must STILL not be rendered
+    expect(screen.queryByText('[email protected]')).toBeNull();
+
+    // Now resolve bob
+    act(() => {
+      resolveBob?.(
+        new Msg(true, '', {
+          items: [
+            {
+              id: 2,
+              email: '[email protected]',
+              totalGB: 500,
+              enable: true,
+              traffic: { up: 50, down: 100, total: 500 },
+            },
+          ],
+        }),
+      );
+    });
+
+    await waitFor(
+      () => {
+        expect(screen.getByText('[email protected]')).toBeTruthy();
+      },
+      { timeout: 2000 },
+    );
+    expect(screen.queryByText('[email protected]')).toBeNull();
+  });
+
+  it('does not re-trigger loading when adding trailing whitespace to settled query', async () => {
+    const getSpy = vi.spyOn(HttpUtil, 'get').mockImplementation(async (url: string) => {
+      if (url.includes('/panel/api/inbounds/options')) return new Msg(true, '', []);
+      return new Msg(true, '', { items: [] });
+    });
+
+    renderPalette();
+    act(() => {
+      commandPaletteStore.open();
+    });
+
+    const input = screen.getByPlaceholderText(/Type a command or search/i);
+    fireEvent.change(input, { target: { value: 'abc' } });
+
+    await waitFor(
+      () => {
+        const calls = getSpy.mock.calls.filter((c) => String(c[0]).includes('search=abc')).length;
+        expect(calls).toBe(1);
+      },
+      { timeout: 2000 },
+    );
+
+    // Add trailing whitespace
+    fireEvent.change(input, { target: { value: 'abc ' } });
+    await new Promise((resolve) => setTimeout(resolve, 350));
+
+    // No extra search call because trimmed query has not changed
+    const callsAfterAbcSpace = getSpy.mock.calls.filter((c) =>
+      String(c[0]).includes('search=abc'),
+    ).length;
+    expect(callsAfterAbcSpace).toBe(1);
+    expect(document.querySelector('.command-palette-search-icon.spinning')).toBeNull();
+  });
+
+  it('keeps the row secondary action independent of the row control', async () => {
+    vi.spyOn(HttpUtil, 'post').mockImplementation(
+      async (url: string) =>
+        new Msg(true, '', url.includes('/setting/all') ? { subURI: 'https://sub.example/' } : {}),
+    );
+    vi.spyOn(HttpUtil, 'get').mockImplementation(async (url: string) => {
+      if (url.includes('/panel/api/inbounds/options')) return new Msg(true, '', []);
+      if (url.includes('search=ali')) {
+        return new Msg(true, '', {
+          items: [
+            {
+              id: 1,
+              email: '[email protected]',
+              subId: 'sub123',
+              enable: true,
+              totalGB: 0,
+              traffic: { up: 100, down: 200, total: 0 },
+            },
+          ],
+        });
+      }
+      return new Msg(true, '', {});
+    });
+
+    renderPalette();
+    act(() => {
+      commandPaletteStore.open();
+    });
+
+    const input = screen.getByPlaceholderText(/Type a command or search/i);
+    fireEvent.change(input, { target: { value: 'ali' } });
+    await waitFor(
+      () => {
+        expect(screen.getByText('[email protected]')).toBeTruthy();
+      },
+      { timeout: 2000 },
+    );
+
+    const copyBtn = document.querySelector('.command-palette-action-btn');
+    expect(copyBtn).toBeTruthy();
+    expect(copyBtn?.parentElement?.closest('button')).toBeNull();
+
+    // Enter on the copy button must not also fire the row's own action.
+    fireEvent.keyDown(copyBtn as Element, { key: 'Enter' });
+    expect(commandPaletteStore.getSnapshot()).toBe(true);
+  });
+
+  it('renders a single theme action item without duplicates', () => {
+    renderPalette();
+    act(() => {
+      commandPaletteStore.open();
+    });
+
+    const input = screen.getByPlaceholderText(/Type a command or search/i);
+    fireEvent.change(input, { target: { value: 'theme' } });
+
+    const themeItems = screen.getAllByText(/Theme/i);
+    expect(themeItems.length).toBe(1);
+  });
+});

+ 85 - 0
frontend/src/test/happ-presets.test.ts

@@ -0,0 +1,85 @@
+import { describe, expect, it } from 'vitest';
+
+import { buildHappPresetDeeplink, parseList, toBase64Utf8 } from '@/pages/settings/happPresets';
+
+describe('Happ presets and helpers', () => {
+  it('correctly parses comma and newline separated lists', () => {
+    const raw = 'domain:ir\nregexp:.*\\.ir$\n, example.com, , google.com';
+    const parsed = parseList(raw);
+    expect(parsed).toEqual(['domain:ir', 'regexp:.*\\.ir$', 'example.com', 'google.com']);
+  });
+
+  it('generates valid happ://routing/off for off preset', () => {
+    const link = buildHappPresetDeeplink('off');
+    expect(link).toBe('happ://routing/off');
+  });
+
+  it('generates valid base64 payload for iran-bypass preset', () => {
+    const link = buildHappPresetDeeplink('iran-bypass');
+    expect(link.startsWith('happ://routing/onadd/')).toBe(true);
+
+    const b64 = link.replace('happ://routing/onadd/', '');
+    const jsonStr = atob(b64);
+    const parsed = JSON.parse(jsonStr);
+
+    expect(parsed).toHaveProperty('rules');
+    expect(Array.isArray(parsed.rules)).toBe(true);
+
+    const directRule = parsed.rules.find(
+      (r: { outboundTag: string }) => r.outboundTag === 'direct',
+    );
+    expect(directRule).toBeDefined();
+    expect(directRule.domain).toContain('domain:ir');
+    expect(directRule.ip).toContain('geoip:ir');
+
+    const blockRule = parsed.rules.find((r: { outboundTag: string }) => r.outboundTag === 'block');
+    expect(blockRule).toBeDefined();
+    expect(blockRule.domain).toContain('geosite:category-ads-all');
+  });
+
+  it('generates valid base64 payload for china-direct preset', () => {
+    const link = buildHappPresetDeeplink('china-direct');
+    expect(link.startsWith('happ://routing/onadd/')).toBe(true);
+
+    const b64 = link.replace('happ://routing/onadd/', '');
+    const jsonStr = atob(b64);
+    const parsed = JSON.parse(jsonStr);
+
+    const directRule = parsed.rules.find(
+      (r: { outboundTag: string }) => r.outboundTag === 'direct',
+    );
+    expect(directRule.domain).toContain('domain:cn');
+    expect(directRule.ip).toContain('geoip:cn');
+  });
+
+  it('generates valid base64 payload for adblock preset', () => {
+    const link = buildHappPresetDeeplink('adblock');
+    const b64 = link.replace('happ://routing/onadd/', '');
+    const jsonStr = atob(b64);
+    const parsed = JSON.parse(jsonStr);
+
+    const blockRule = parsed.rules.find((r: { outboundTag: string }) => r.outboundTag === 'block');
+    expect(blockRule.domain).toContain('geosite:category-ads-all');
+  });
+
+  it('generates valid base64 payload for global preset', () => {
+    const link = buildHappPresetDeeplink('global');
+    const b64 = link.replace('happ://routing/onadd/', '');
+    const jsonStr = atob(b64);
+    const parsed = JSON.parse(jsonStr);
+
+    expect(parsed.rules[0].outboundTag).toBe('proxy');
+    expect(parsed.rules[0].network).toBe('tcp,udp');
+  });
+
+  it('encodes unicode properly via toBase64Utf8', () => {
+    const text = 'فیلترشکن و روتینگ';
+    const b64 = toBase64Utf8(text);
+    const decoded = decodeURIComponent(
+      Array.prototype.map
+        .call(atob(b64), (c: string) => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2))
+        .join(''),
+    );
+    expect(decoded).toBe(text);
+  });
+});

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

@@ -0,0 +1,72 @@
+import { describe, expect, it, vi } from 'vitest';
+import { fireEvent } from '@testing-library/react';
+
+import HappSettingsContent from '@/pages/settings/HappSettingsContent';
+import { AllSetting } from '@/models/setting';
+
+import { renderWithProviders } from './test-utils';
+
+function openTab(name: string) {
+  const tab = Array.from(document.querySelectorAll('.ant-tabs-tab')).find((t) =>
+    (t.textContent ?? '').includes(name),
+  );
+  if (!tab) throw new Error(`tab '${name}' not found`);
+  fireEvent.click(tab);
+}
+
+function selectFor(title: string): HTMLElement {
+  const row = Array.from(document.querySelectorAll('.ant-select')).find((s) =>
+    (s.closest('li,div[class*="setting"]')?.textContent ?? '').includes(title),
+  );
+  if (!row) throw new Error(`select for '${title}' not found`);
+  return row as HTMLElement;
+}
+
+function clickOption(text: string) {
+  const option = Array.from(document.querySelectorAll('.ant-select-item-option')).find(
+    (o) => (o.textContent ?? '').trim() === text,
+  );
+  if (!option) throw new Error(`option '${text}' not found`);
+  fireEvent.click(option);
+}
+
+describe('Happ TUN Mode select', () => {
+  // happ.su documents tun-mode as system|gvisor only, so the Default entry has
+  // to mean "send no header", the same state a fresh panel ships with.
+  it('stores the empty value for Default so no Tun-Mode header is emitted', () => {
+    const updateSetting = vi.fn();
+    const allSetting = new AllSetting();
+    allSetting.subHappTunMode = 'gvisor';
+
+    renderWithProviders(
+      <HappSettingsContent
+        allSetting={allSetting}
+        updateSetting={updateSetting}
+        isMobile={false}
+        remoteSourceBadge={() => null}
+      />,
+    );
+
+    openTab('Network');
+    const select = selectFor('TUN Mode');
+    fireEvent.mouseDown(select.querySelector('.ant-select-selector') ?? select);
+    clickOption('Default');
+
+    expect(updateSetting).toHaveBeenCalledWith({ subHappTunMode: '' });
+  });
+
+  it('labels the unset state Default rather than leaving the control blank', () => {
+    renderWithProviders(
+      <HappSettingsContent
+        allSetting={new AllSetting()}
+        updateSetting={vi.fn()}
+        isMobile={false}
+        remoteSourceBadge={() => null}
+      />,
+    );
+
+    openTab('Network');
+    const select = selectFor('TUN Mode');
+    expect(select.querySelector('.ant-select-content')?.textContent).toBe('Default');
+  });
+});

+ 323 - 0
internal/amneziawg/outbound.go

@@ -0,0 +1,323 @@
+package amneziawg
+
+import (
+	"encoding/json"
+	"fmt"
+	"net"
+	"net/netip"
+	"strconv"
+	"strings"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/util/wireguard"
+)
+
+// OutboundPeer is one remote AmneziaWG server: its public key, the routes
+// AllowedIPs steers into the tunnel, and its "host:port" Endpoint.
+type OutboundPeer struct {
+	PublicKey    string
+	PresharedKey string
+	AllowedIPs   []string
+	Endpoint     string
+	KeepAlive    int
+}
+
+// OutboundInstance is the desired runtime config of one client-mode
+// AmneziaWG outbound -- the mirror of Instance, consumed by amneziawgnet.
+type OutboundInstance struct {
+	Tag         string
+	Address     []string
+	MTU         int
+	PrivateKey  string
+	Obfuscation Obfuscation31
+	Peers       []OutboundPeer
+	ListenPort  int
+	DNS         string
+}
+
+// OutboundSettings is the Settings JSON stored on an "amneziawg" outbound
+// row; flat obfuscation keys mirror ServerSettings so values paste 1:1.
+type OutboundSettings struct {
+	MTU        int      `json:"mtu,omitempty"`
+	SecretKey  string   `json:"secretKey"`
+	Address    []string `json:"address"`
+	ListenPort int      `json:"listenPort,omitempty"`
+	DNS        string   `json:"dns,omitempty"`
+
+	// Flat Obfuscation31 mirror -- see OutboundSettings' doc comment.
+	Jc   int    `json:"jc"`
+	Jmin int    `json:"jmin"`
+	Jmax int    `json:"jmax"`
+	S1   int    `json:"s1"`
+	S2   int    `json:"s2"`
+	S3   int    `json:"s3"`
+	S4   int    `json:"s4"`
+	H1   string `json:"h1"`
+	H2   string `json:"h2"`
+	H3   string `json:"h3"`
+	H4   string `json:"h4"`
+	I1   string `json:"i1,omitempty"`
+	I2   string `json:"i2,omitempty"`
+	I3   string `json:"i3,omitempty"`
+	I4   string `json:"i4,omitempty"`
+	I5   string `json:"i5,omitempty"`
+
+	HeaderProtectionKey    string `json:"headerProtectionKey,omitempty"`
+	ContentPaddingAddition string `json:"contentPaddingAddition,omitempty"`
+	RekeyAfterTime         string `json:"rekeyAfterTime,omitempty"`
+	RekeyTimeout           string `json:"rekeyTimeout,omitempty"`
+	RejectAfterTime        string `json:"rejectAfterTime,omitempty"`
+	KeepaliveTimeout       string `json:"keepaliveTimeout,omitempty"`
+	MaxHandshakeAttempts   string `json:"maxHandshakeAttempts,omitempty"`
+	RandomTrailers         bool   `json:"randomTrailers"`
+	DisableCookies         bool   `json:"disableCookies"`
+
+	Peers []OutboundSettingsPeer `json:"peers"`
+}
+
+// OutboundSettingsPeer is one entry of OutboundSettings.Peers.
+type OutboundSettingsPeer struct {
+	PublicKey    string   `json:"publicKey"`
+	PresharedKey string   `json:"presharedKey,omitempty"`
+	AllowedIPs   []string `json:"allowedIPs"`
+	Endpoint     string   `json:"endpoint"`
+	KeepAlive    int      `json:"keepAlive,omitempty"`
+}
+
+// Obfuscation folds the flat wire fields back into the grouped type, matching
+// ServerSettings.Obfuscation.
+func (s OutboundSettings) Obfuscation() Obfuscation31 {
+	return Obfuscation31{
+		Jc: s.Jc, Jmin: s.Jmin, Jmax: s.Jmax,
+		S1: s.S1, S2: s.S2, S3: s.S3, S4: s.S4,
+		H1: s.H1, H2: s.H2, H3: s.H3, H4: s.H4,
+		I1: s.I1, I2: s.I2, I3: s.I3, I4: s.I4, I5: s.I5,
+		HeaderProtectionKey:    s.HeaderProtectionKey,
+		ContentPaddingAddition: s.ContentPaddingAddition,
+		RekeyAfterTime:         s.RekeyAfterTime,
+		RekeyTimeout:           s.RekeyTimeout,
+		RejectAfterTime:        s.RejectAfterTime,
+		KeepaliveTimeout:       s.KeepaliveTimeout,
+		MaxHandshakeAttempts:   s.MaxHandshakeAttempts,
+		RandomTrailers:         s.RandomTrailers,
+		DisableCookies:         s.DisableCookies,
+	}
+}
+
+// IsAmneziaWGOutbound reports whether a raw outbound JSON object from the
+// Xray template carries the panel's amneziawg pseudo-protocol.
+func IsAmneziaWGOutbound(raw []byte) bool {
+	var probe struct {
+		Protocol string `json:"protocol"`
+	}
+	if err := json.Unmarshal(raw, &probe); err != nil {
+		return false
+	}
+	return probe.Protocol == "amneziawg"
+}
+
+// outboundSettingsOf extracts the nested "settings" block from a raw
+// amneziawg template outbound.
+func outboundSettingsOf(raw []byte) (json.RawMessage, bool) {
+	var wrapper struct {
+		Settings json.RawMessage `json:"settings"`
+	}
+	if err := json.Unmarshal(raw, &wrapper); err != nil || len(wrapper.Settings) == 0 {
+		return nil, false
+	}
+	return wrapper.Settings, true
+}
+
+// InstanceFromOutbound derives a client-mode instance from one raw template
+// outbound; false when unusable or a peer lacks key/endpoint/allowedIPs.
+func InstanceFromOutbound(tag string, raw []byte) (OutboundInstance, bool) {
+	settingsRaw, ok := outboundSettingsOf(raw)
+	if !ok {
+		return OutboundInstance{}, false
+	}
+	var parsed OutboundSettings
+	if err := json.Unmarshal(settingsRaw, &parsed); err != nil {
+		return OutboundInstance{}, false
+	}
+	inst := OutboundInstance{
+		Tag:        tag,
+		Address:    parsed.Address,
+		MTU:        parsed.MTU,
+		PrivateKey: parsed.SecretKey,
+		ListenPort: parsed.ListenPort,
+		DNS:        NormalizeDNSServer(parsed.DNS),
+		Obfuscation: Obfuscation31{
+			Jc: parsed.Jc, Jmin: parsed.Jmin, Jmax: parsed.Jmax,
+			S1: parsed.S1, S2: parsed.S2, S3: parsed.S3, S4: parsed.S4,
+			H1: parsed.H1, H2: parsed.H2, H3: parsed.H3, H4: parsed.H4,
+			I1: parsed.I1, I2: parsed.I2, I3: parsed.I3, I4: parsed.I4, I5: parsed.I5,
+			HeaderProtectionKey:    parsed.HeaderProtectionKey,
+			ContentPaddingAddition: parsed.ContentPaddingAddition,
+			RekeyAfterTime:         parsed.RekeyAfterTime,
+			RekeyTimeout:           parsed.RekeyTimeout,
+			RejectAfterTime:        parsed.RejectAfterTime,
+			KeepaliveTimeout:       parsed.KeepaliveTimeout,
+			MaxHandshakeAttempts:   parsed.MaxHandshakeAttempts,
+			RandomTrailers:         parsed.RandomTrailers,
+			DisableCookies:         parsed.DisableCookies,
+		},
+	}
+	for _, p := range parsed.Peers {
+		if p.PublicKey == "" || len(p.AllowedIPs) == 0 || p.Endpoint == "" {
+			continue
+		}
+		peer := OutboundPeer(p)
+		peer.AllowedIPs = peer.AllowedIPs[:0:0]
+		for _, a := range p.AllowedIPs {
+			prefix, err := netip.ParsePrefix(strings.TrimSpace(a))
+			if err != nil {
+				return OutboundInstance{}, false
+			}
+			peer.AllowedIPs = append(peer.AllowedIPs, prefix.String())
+		}
+		inst.Peers = append(inst.Peers, peer)
+	}
+	if len(inst.Address) == 0 || len(inst.Peers) == 0 {
+		return OutboundInstance{}, false
+	}
+	return inst, true
+}
+
+// validateEndpoint accepts "host:port" with a numeric port and no control
+// characters; hostnames resolve at IpcSet time via resolvingBind.
+func validateEndpoint(ep string) error {
+	if ep == "" {
+		return fmt.Errorf("endpoint is required")
+	}
+	if err := ValidateConfigValue("endpoint", ep); err != nil {
+		return err
+	}
+	host, portS, err := net.SplitHostPort(ep)
+	if err != nil {
+		return fmt.Errorf("invalid endpoint %q: must be host:port", ep)
+	}
+	port, err := strconv.Atoi(portS)
+	if err != nil || port <= 0 || port > 65535 {
+		return fmt.Errorf("invalid endpoint %q: bad port", ep)
+	}
+	if strings.TrimSpace(host) == "" {
+		return fmt.Errorf("invalid endpoint %q: empty host", ep)
+	}
+	return nil
+}
+
+// validateTunnelAddresses requires every entry to be a parseable IP prefix
+// (the outbound's own tunnel address(es), e.g. "10.8.1.2/32").
+func validateTunnelAddresses(addrs []string) error {
+	if len(addrs) == 0 {
+		return fmt.Errorf("at least one tunnel address is required")
+	}
+	for _, a := range addrs {
+		prefix, err := netip.ParsePrefix(a)
+		if err != nil {
+			return fmt.Errorf("invalid tunnel address %q: %w", a, err)
+		}
+		_ = prefix
+	}
+	return nil
+}
+
+// NormalizeDNSServer converts a bare IP or IP:port into a standard host:port.
+func NormalizeDNSServer(s string) string {
+	s = strings.TrimSpace(s)
+	if s == "" {
+		return ""
+	}
+	if addr, err := netip.ParseAddr(s); err == nil {
+		return netip.AddrPortFrom(addr, 53).String()
+	}
+	if ap, err := netip.ParseAddrPort(s); err == nil {
+		return ap.String()
+	}
+	return s
+}
+
+// ValidateDNSServer checks that dns is empty or a valid IP or IP:port.
+func ValidateDNSServer(s string) error {
+	if s == "" {
+		return nil
+	}
+	if err := ValidateConfigValue("dns", s); err != nil {
+		return err
+	}
+	if _, err := netip.ParseAddr(s); err == nil {
+		return nil
+	}
+	if _, err := netip.ParseAddrPort(s); err == nil {
+		return nil
+	}
+	return fmt.Errorf("must be an IP address or IP:port")
+}
+
+// ValidateAmneziaWGOutbound rejects settings that could break the embedded
+// device's UAPI apply or smuggle control characters downstream.
+func ValidateAmneziaWGOutbound(tag string, raw []byte) error {
+	if strings.TrimSpace(tag) == "" {
+		return fmt.Errorf("amneziawg outbound: tag must be a non-empty string")
+	}
+	settingsRaw, ok := outboundSettingsOf(raw)
+	if !ok {
+		return fmt.Errorf("amneziawg outbound %q: missing settings block", tag)
+	}
+	var parsed OutboundSettings
+	if err := json.Unmarshal(settingsRaw, &parsed); err != nil {
+		return fmt.Errorf("amneziawg outbound %q: invalid settings: %w", tag, err)
+	}
+	if err := validateTunnelAddresses(parsed.Address); err != nil {
+		return fmt.Errorf("amneziawg outbound %q: %w", tag, err)
+	}
+	if err := ValidateDNSServer(parsed.DNS); err != nil {
+		return fmt.Errorf("amneziawg outbound %q: invalid dns: %w", tag, err)
+	}
+	if strings.TrimSpace(parsed.SecretKey) == "" {
+		return fmt.Errorf("amneziawg outbound %q: privateKey is required", tag)
+	}
+	if _, err := wireguard.KeyToHex(parsed.SecretKey); err != nil {
+		return fmt.Errorf("amneziawg outbound %q: invalid privateKey: %w", tag, err)
+	}
+	if err := ValidateObfuscation(parsed.Obfuscation()); err != nil {
+		return fmt.Errorf("amneziawg outbound %q: %w", tag, err)
+	}
+	for n, iv := range map[string]string{
+		"i1": parsed.I1, "i2": parsed.I2, "i3": parsed.I3, "i4": parsed.I4, "i5": parsed.I5,
+	} {
+		if err := ValidateConfigValue(n, iv); err != nil {
+			return fmt.Errorf("amneziawg outbound %q: %w", tag, err)
+		}
+	}
+	if err := validateHeaderProtectionKey(parsed.HeaderProtectionKey); err != nil {
+		return fmt.Errorf("amneziawg outbound %q: %w", tag, err)
+	}
+	if len(parsed.Peers) == 0 {
+		return fmt.Errorf("amneziawg outbound %q: at least one peer is required", tag)
+	}
+	for i, p := range parsed.Peers {
+		if strings.TrimSpace(p.PublicKey) == "" {
+			return fmt.Errorf("amneziawg outbound %q: peer %d: publicKey is required", tag, i)
+		}
+		if _, err := wireguard.KeyToHex(p.PublicKey); err != nil {
+			return fmt.Errorf("amneziawg outbound %q: peer %d: invalid publicKey: %w", tag, i, err)
+		}
+		if p.PresharedKey != "" {
+			if _, err := wireguard.KeyToHex(p.PresharedKey); err != nil {
+				return fmt.Errorf("amneziawg outbound %q: peer %d: invalid presharedKey: %w", tag, i, err)
+			}
+		}
+		if err := validateEndpoint(p.Endpoint); err != nil {
+			return fmt.Errorf("amneziawg outbound %q: peer %d: %w", tag, i, err)
+		}
+		if len(p.AllowedIPs) == 0 {
+			return fmt.Errorf("amneziawg outbound %q: peer %d: at least one allowedIPs entry is required", tag, i)
+		}
+		for _, a := range p.AllowedIPs {
+			if _, err := netip.ParsePrefix(a); err != nil {
+				return fmt.Errorf("amneziawg outbound %q: peer %d: invalid allowedIP %q: %w", tag, i, a, err)
+			}
+		}
+	}
+	return nil
+}

+ 312 - 0
internal/amneziawg/outbound_test.go

@@ -0,0 +1,312 @@
+package amneziawg
+
+import (
+	"encoding/json"
+	"testing"
+
+	wgutil "github.com/mhsanaei/3x-ui/v3/internal/util/wireguard"
+)
+
+// validOutboundJSON is a fully valid amneziawg outbound settings payload.
+func validOutboundJSON(t *testing.T) []byte {
+	t.Helper()
+	raw := map[string]any{
+		"mtu":       1420,
+		"secretKey": validPrivKey(t),
+		"address":   []string{"10.8.0.2/32"},
+		"jc":        4,
+		"jmin":      40,
+		"jmax":      100,
+		"s1":        15,
+		"s2":        80,
+		"s3":        12,
+		"s4":        12,
+		"h1":        "100-800",
+		"h2":        "900-1600",
+		"h3":        "1700-2400",
+		"h4":        "2500-3200",
+		"peers": []map[string]any{{
+			"publicKey":  validPubKey(t),
+			"allowedIPs": []string{"0.0.0.0/0", "::/0"},
+			"endpoint":   "203.0.113.7:51820",
+			"keepAlive":  25,
+		}},
+	}
+	bs, err := json.Marshal(raw)
+	if err != nil {
+		t.Fatal(err)
+	}
+	return bs
+}
+
+func validPubKey(t *testing.T) string {
+	t.Helper()
+	_, pub, err := wgutil.GenerateWireguardKeypair()
+	if err != nil {
+		t.Fatal(err)
+	}
+	return pub
+}
+
+func validPrivKey(t *testing.T) string {
+	t.Helper()
+	priv, _, err := wgutil.GenerateWireguardKeypair()
+	if err != nil {
+		t.Fatal(err)
+	}
+	return priv
+}
+
+func TestInstanceFromOutbound_OK(t *testing.T) {
+	wrapped, err := json.Marshal(map[string]any{
+		"protocol": "amneziawg",
+		"tag":      "awg-out-test",
+		"settings": json.RawMessage(validOutboundJSON(t)),
+	})
+	if err != nil {
+		t.Fatal(err)
+	}
+	inst, ok := InstanceFromOutbound("awg-out-test", wrapped)
+	if !ok {
+		t.Fatal("InstanceFromOutbound returned false for a valid outbound")
+	}
+	if inst.Tag != "awg-out-test" {
+		t.Fatalf("Tag = %q, want awg-out-test", inst.Tag)
+	}
+	if len(inst.Peers) != 1 {
+		t.Fatalf("len(Peers) = %d, want 1", len(inst.Peers))
+	}
+	p := inst.Peers[0]
+	if p.Endpoint != "203.0.113.7:51820" {
+		t.Fatalf("Endpoint = %q", p.Endpoint)
+	}
+	if p.KeepAlive != 25 {
+		t.Fatalf("KeepAlive = %d, want 25", p.KeepAlive)
+	}
+	if len(p.AllowedIPs) != 2 {
+		t.Fatalf("AllowedIPs = %v", p.AllowedIPs)
+	}
+	if inst.MTU != 1420 {
+		t.Fatalf("MTU = %d, want 1420", inst.MTU)
+	}
+	if inst.Obfuscation.Jc != 4 || inst.Obfuscation.S1 != 15 {
+		t.Fatalf("Obfuscation not carried: %+v", inst.Obfuscation)
+	}
+}
+
+func TestInstanceFromOutbound_RejectsIncompletePeer(t *testing.T) {
+	m := validOutboundMapT(t)
+	m["address"] = []any{}
+	bs, _ := json.Marshal(m)
+	wrapped, _ := json.Marshal(map[string]any{"protocol": "amneziawg", "settings": json.RawMessage(bs)})
+	if _, ok := InstanceFromOutbound("t", wrapped); ok {
+		t.Fatal("expected false when address list is empty")
+	}
+
+	m2 := validOutboundMapT(t)
+	m2["peers"].([]any)[0].(map[string]any)["endpoint"] = ""
+	bs2, _ := json.Marshal(m2)
+	wrapped2, _ := json.Marshal(map[string]any{"protocol": "amneziawg", "settings": json.RawMessage(bs2)})
+	// The only peer is incomplete -> skipped -> zero usable peers -> false,
+	// mirroring InstanceFromInbound's "nothing to serve" contract.
+	if _, ok := InstanceFromOutbound("t", wrapped2); ok {
+		t.Fatal("outbound whose only peer lacks an endpoint must be unusable")
+	}
+
+	// With a second, complete peer the instance stays usable and only the
+	// broken entry disappears.
+	m3 := validOutboundMapT(t)
+	brokenPeer := validOutboundMapT(t)["peers"].([]any)[0].(map[string]any)
+	brokenPeer["endpoint"] = ""
+	m3["peers"] = []any{brokenPeer, validOutboundMapT(t)["peers"].([]any)[0]}
+	bs3, _ := json.Marshal(m3)
+	wrapped3, _ := json.Marshal(map[string]any{"protocol": "amneziawg", "settings": json.RawMessage(bs3)})
+	inst, ok := InstanceFromOutbound("t", wrapped3)
+	if !ok {
+		t.Fatal("one good peer should keep the outbound usable")
+	}
+	if len(inst.Peers) != 1 {
+		t.Fatalf("broken peer must be dropped; got %d peers", len(inst.Peers))
+	}
+}
+
+func TestValidateAmneziaWGOutbound_AcceptsValidAndRejectsBroken(t *testing.T) {
+	if err := ValidateAmneziaWGOutbound("t", wrapOutboundSettings(validOutboundJSON(t))); err != nil {
+		t.Fatalf("valid outbound rejected: %v", err)
+	}
+
+	cases := []struct {
+		name   string
+		breakF func(m map[string]any)
+	}{
+		{"empty secretKey", func(m map[string]any) { m["secretKey"] = "" }},
+		{"empty peer publicKey", func(m map[string]any) { peer(m)["publicKey"] = "" }},
+		{"whitespace secretKey", func(m map[string]any) { m["secretKey"] = "   " }},
+		{"whitespace peer publicKey", func(m map[string]any) { peer(m)["publicKey"] = "   " }},
+		{"bad endpoint no port", func(m map[string]any) { peer(m)["endpoint"] = "203.0.113.7" }},
+		{"endpoint control char", func(m map[string]any) { peer(m)["endpoint"] = "host:51820\nPostUp=x" }},
+		{"empty allowedIPs", func(m map[string]any) { peer(m)["allowedIPs"] = []string{} }},
+		{"no peers", func(m map[string]any) { m["peers"] = []any{} }},
+		{"bad allowedIP", func(m map[string]any) { peer(m)["allowedIPs"] = []string{"not-a-prefix"} }},
+		{"no address", func(m map[string]any) { m["address"] = []any{} }},
+		{"bad jc/jmin order", func(m map[string]any) { m["jmin"] = 200; m["jmax"] = 100 }},
+	}
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			m := validOutboundMapT(t)
+			tc.breakF(m)
+			bs, _ := json.Marshal(m)
+			if err := ValidateAmneziaWGOutbound("t", wrapOutboundSettings(bs)); err == nil {
+				t.Fatalf("%s: expected error, got nil", tc.name)
+			}
+		})
+	}
+}
+
+// wrapOutboundSettings embeds a settings payload the way the template stores
+// it: as the nested "settings" of an amneziawg outbound row.
+func wrapOutboundSettings(settings json.RawMessage) []byte {
+	bs, err := json.Marshal(map[string]any{
+		"protocol": "amneziawg",
+		"tag":      "t",
+		"settings": settings,
+	})
+	if err != nil {
+		panic(err)
+	}
+	return bs
+}
+
+func peer(m map[string]any) map[string]any {
+	return m["peers"].([]any)[0].(map[string]any)
+}
+
+func validOutboundMapT(t *testing.T) map[string]any {
+	t.Helper()
+	var m map[string]any
+	if err := json.Unmarshal(validOutboundJSON(t), &m); err != nil {
+		t.Fatal(err)
+	}
+	return m
+}
+
+func TestIsAmneziaWGOutbound(t *testing.T) {
+	yes := []byte(`{"protocol":"amneziawg","tag":"x"}`)
+	if !IsAmneziaWGOutbound(yes) {
+		t.Fatal("amneziawg protocol not detected")
+	}
+	no := []byte(`{"protocol":"freedom","tag":"x"}`)
+	if IsAmneziaWGOutbound(no) {
+		t.Fatal("freedom misdetected as amneziawg")
+	}
+	if IsAmneziaWGOutbound([]byte(`{broken`)) {
+		t.Fatal("garbage misdetected as amneziawg")
+	}
+}
+
+// A blank line terminates IpcSetOperation, silently truncating the peer set;
+// validation rejects a trailing newline, parsing normalizes it away.
+func TestValidateAmneziaWGOutbound_RejectsAllowedIPWithNewline(t *testing.T) {
+	m := validOutboundMapT(t)
+	peer(m)["allowedIPs"] = []string{"0.0.0.0/0\n", "::/0"}
+	bs, err := json.Marshal(m)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if err := ValidateAmneziaWGOutbound("t", wrapOutboundSettings(bs)); err == nil {
+		t.Fatal("allowedIP with trailing newline: expected error, got nil")
+	}
+}
+
+func TestInstanceFromOutbound_NormalizesAllowedIPs(t *testing.T) {
+	m := validOutboundMapT(t)
+	peer(m)["allowedIPs"] = []string{" 0.0.0.0/0\n", "::/0"}
+	bs, err := json.Marshal(m)
+	if err != nil {
+		t.Fatal(err)
+	}
+	inst, ok := InstanceFromOutbound("t", wrapOutboundSettings(bs))
+	if !ok {
+		t.Fatal("InstanceFromOutbound returned false for trimmable allowedIPs")
+	}
+	got := inst.Peers[0].AllowedIPs
+	want := []string{"0.0.0.0/0", "::/0"}
+	if len(got) != len(want) {
+		t.Fatalf("AllowedIPs = %v, want %v", got, want)
+	}
+	for i := range want {
+		if got[i] != want[i] {
+			t.Fatalf("AllowedIPs[%d] = %q, want %q (newline must not survive)", i, got[i], want[i])
+		}
+	}
+}
+
+func TestInstanceFromOutbound_RejectsUnparseableAllowedIP(t *testing.T) {
+	m := validOutboundMapT(t)
+	peer(m)["allowedIPs"] = []string{"not-a-prefix"}
+	bs, err := json.Marshal(m)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if _, ok := InstanceFromOutbound("t", wrapOutboundSettings(bs)); ok {
+		t.Fatal("unparseable allowedIP must make InstanceFromOutbound return false")
+	}
+}
+
+func TestValidateAmneziaWGOutbound_RejectsControlCharInIParams(t *testing.T) {
+	for _, field := range []string{"i1", "i2", "i3", "i4", "i5"} {
+		t.Run(field, func(t *testing.T) {
+			m := validOutboundMapT(t)
+			m[field] = "<r 64>\nPostUp=x"
+			bs, err := json.Marshal(m)
+			if err != nil {
+				t.Fatal(err)
+			}
+			if err := ValidateAmneziaWGOutbound("t", wrapOutboundSettings(bs)); err == nil {
+				t.Fatalf("%s with embedded newline: expected error, got nil", field)
+			}
+		})
+	}
+}
+
+func TestValidateAmneziaWGOutbound_RejectsEmptyTag(t *testing.T) {
+	raw := []byte(`{"protocol":"amneziawg","tag":"","settings":{"secretKey":"x"}}`)
+	for _, tag := range []string{"", "   "} {
+		if err := ValidateAmneziaWGOutbound(tag, raw); err == nil {
+			t.Fatalf("tag %q accepted", tag)
+		}
+	}
+}
+
+func TestValidateAmneziaWGOutbound_DNSField(t *testing.T) {
+	valid := map[string]string{
+		"":                          "",
+		"1.1.1.1":                   "1.1.1.1:53",
+		"8.8.8.8:53":                "8.8.8.8:53",
+		"2606:4700:4700::1111":      "[2606:4700:4700::1111]:53",
+		"[2606:4700:4700::1111]:53": "[2606:4700:4700::1111]:53",
+	}
+	for d, expected := range valid {
+		m := validOutboundMapT(t)
+		if d != "" {
+			m["dns"] = d
+		}
+		bs, _ := json.Marshal(m)
+		if err := ValidateAmneziaWGOutbound("t", wrapOutboundSettings(bs)); err != nil {
+			t.Fatalf("valid dns %q rejected: %v", d, err)
+		}
+		inst, ok := InstanceFromOutbound("t", wrapOutboundSettings(bs))
+		if !ok || inst.DNS != expected {
+			t.Fatalf("InstanceFromOutbound dns=%q, want %q", inst.DNS, expected)
+		}
+	}
+	invalid := []string{"not-an-ip", "1.1.1.1\nPostUp=x", "999.999.999.999"}
+	for _, d := range invalid {
+		m := validOutboundMapT(t)
+		m["dns"] = d
+		bs, _ := json.Marshal(m)
+		if err := ValidateAmneziaWGOutbound("t", wrapOutboundSettings(bs)); err == nil {
+			t.Fatalf("invalid dns %q accepted", d)
+		}
+	}
+}

+ 139 - 0
internal/amneziawgnet/client_device.go

@@ -0,0 +1,139 @@
+package amneziawgnet
+
+import (
+	"fmt"
+	"strings"
+
+	"github.com/amnezia-vpn/amneziawg-go/v3/device"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
+	"github.com/mhsanaei/3x-ui/v3/internal/util/wireguard"
+)
+
+// buildClientUAPIConfig renders a client-mode UAPI set string: the device
+// lines of buildUAPIConfig plus per-peer endpoint/keepalive for dialing.
+func buildClientUAPIConfig(inst amneziawg.OutboundInstance, opts DeviceOptions) (string, error) {
+	var b strings.Builder
+
+	privHex, err := wireguard.KeyToHex(inst.PrivateKey)
+	if err != nil {
+		return "", fmt.Errorf("invalid private key: %w", err)
+	}
+	fmt.Fprintf(&b, "private_key=%s\n", privHex)
+	if inst.ListenPort > 0 {
+		fmt.Fprintf(&b, "listen_port=%d\n", inst.ListenPort)
+	}
+	b.WriteString("replace_peers=true\n")
+
+	o := inst.Obfuscation
+	fmt.Fprintf(&b, "jc=%d\njmin=%d\njmax=%d\n", o.Jc, o.Jmin, o.Jmax)
+	fmt.Fprintf(&b, "s1=%d\ns2=%d\ns3=%d\ns4=%d\n", o.S1, o.S2, o.S3, o.S4)
+	writeOptionalLine(&b, "h1", o.H1)
+	writeOptionalLine(&b, "h2", o.H2)
+	writeOptionalLine(&b, "h3", o.H3)
+	writeOptionalLine(&b, "h4", o.H4)
+	writeOptionalLine(&b, "i1", o.I1)
+	writeOptionalLine(&b, "i2", o.I2)
+	writeOptionalLine(&b, "i3", o.I3)
+	writeOptionalLine(&b, "i4", o.I4)
+	writeOptionalLine(&b, "i5", o.I5)
+
+	// An omitted line means "unchanged" to amneziawg-go, so a cleared key can
+	// only reach a live device as the all-zero one that disables the feature.
+	hpHex := strings.Repeat("0", 64)
+	if opts.HeaderProtectionKey != "" {
+		var err error
+		hpHex, err = wireguard.KeyToHex(opts.HeaderProtectionKey)
+		if err != nil {
+			return "", fmt.Errorf("invalid header protection key: %w", err)
+		}
+	}
+	fmt.Fprintf(&b, "header_protection_key=%s\n", hpHex)
+	if opts.ContentPaddingAddition != "" {
+		fmt.Fprintf(&b, "content_padding_addition=%s\n", opts.ContentPaddingAddition)
+	}
+	if opts.RekeyAfterTime != "" {
+		fmt.Fprintf(&b, "rekey_after_time=%s\n", opts.RekeyAfterTime)
+	}
+	if opts.RekeyTimeout != "" {
+		fmt.Fprintf(&b, "rekey_timeout=%s\n", opts.RekeyTimeout)
+	}
+	if opts.RejectAfterTime != "" {
+		fmt.Fprintf(&b, "reject_after_time=%s\n", opts.RejectAfterTime)
+	}
+	if opts.KeepaliveTimeout != "" {
+		fmt.Fprintf(&b, "keepalive_timeout=%s\n", opts.KeepaliveTimeout)
+	}
+	if opts.MaxHandshakeAttempts != "" {
+		fmt.Fprintf(&b, "max_handshake_attempts=%s\n", opts.MaxHandshakeAttempts)
+	}
+	fmt.Fprintf(&b, "random_trailers=%t\n", opts.RandomTrailers)
+	fmt.Fprintf(&b, "disable_cookies=%t\n", opts.DisableCookies)
+
+	for _, p := range inst.Peers {
+		pubHex, err := wireguard.KeyToHex(p.PublicKey)
+		if err != nil {
+			return "", fmt.Errorf("peer %q: invalid public key: %w", p.Endpoint, err)
+		}
+		fmt.Fprintf(&b, "public_key=%s\n", pubHex)
+		if p.PresharedKey != "" {
+			pskHex, err := wireguard.KeyToHex(p.PresharedKey)
+			if err != nil {
+				return "", fmt.Errorf("peer %q: invalid preshared key: %w", p.Endpoint, err)
+			}
+			fmt.Fprintf(&b, "preshared_key=%s\n", pskHex)
+		}
+		fmt.Fprintf(&b, "endpoint=%s\n", p.Endpoint)
+		if p.KeepAlive > 0 {
+			fmt.Fprintf(&b, "persistent_keepalive_interval=%d\n", p.KeepAlive)
+		}
+		for _, allowedIP := range p.AllowedIPs {
+			fmt.Fprintf(&b, "allowed_ip=%s\n", allowedIP)
+		}
+	}
+
+	return b.String(), nil
+}
+
+// newUnconfiguredClientDevice builds the tun/netstack/device trio for a
+// client-mode instance; same construction rules as newUnconfiguredDevice.
+func newUnconfiguredClientDevice(inst amneziawg.OutboundInstance, opts DeviceOptions) (*Device, error) {
+	addrs, err := hostAddresses(inst.Address)
+	if err != nil {
+		return nil, fmt.Errorf("amneziawgnet: %w", err)
+	}
+
+	mtu := amneziawg.EffectiveMTU(inst.MTU, inst.Obfuscation.S4)
+
+	tun, gstack, err := createNetTUNWithStack(addrs, mtu)
+	if err != nil {
+		return nil, fmt.Errorf("amneziawgnet: create netstack: %w", err)
+	}
+
+	logger := opts.Logger
+	if logger == nil {
+		logger = device.NewLogger(device.LogLevelSilent, fmt.Sprintf("(awg-out %s) ", inst.Tag))
+	}
+	dev := device.NewDevice(tun, newResolvingBind(), logger)
+
+	return &Device{Device: dev, Stack: gstack, localAddrs: addrs}, nil
+}
+
+// ConfigureClient applies inst/opts via UAPI and brings the interface up;
+// same single-call contract as Configure.
+func (d *Device) ConfigureClient(inst amneziawg.OutboundInstance, opts DeviceOptions) error {
+	conf, err := buildClientUAPIConfig(inst, opts)
+	if err != nil {
+		d.Close()
+		return fmt.Errorf("amneziawgnet: %w", err)
+	}
+	if err := d.IpcSet(conf); err != nil {
+		d.Close()
+		return fmt.Errorf("amneziawgnet: IpcSet for outbound %q: %w", inst.Tag, err)
+	}
+	if err := d.Up(); err != nil {
+		d.Close()
+		return fmt.Errorf("amneziawgnet: bring up outbound %q: %w", inst.Tag, err)
+	}
+	return nil
+}

+ 117 - 0
internal/amneziawgnet/client_device_test.go

@@ -0,0 +1,117 @@
+package amneziawgnet
+
+import (
+	"strings"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
+
+	wgutil "github.com/mhsanaei/3x-ui/v3/internal/util/wireguard"
+)
+
+// clientDeviceTestInstance builds a minimal valid client instance with one
+// peer and a non-zero keepalive -- the exact shape the outbound form seeds.
+func clientDeviceTestInstance(t *testing.T) amneziawg.OutboundInstance {
+	t.Helper()
+	priv, pub, err := wgutil.GenerateWireguardKeypair()
+	if err != nil {
+		t.Fatal(err)
+	}
+	return amneziawg.OutboundInstance{
+		Tag:        "awg-out-test",
+		Address:    []string{"10.8.0.2/32"},
+		MTU:        1420,
+		PrivateKey: priv,
+		Peers: []amneziawg.OutboundPeer{{
+			PublicKey:  pub,
+			AllowedIPs: []string{"0.0.0.0/0", "::/0"},
+			Endpoint:   "203.0.113.7:51820",
+			KeepAlive:  25,
+		}},
+	}
+}
+
+func TestBuildClientUAPIConfig_KeepAliveKeyIsValidUAPIPeerKey(t *testing.T) {
+	inst := clientDeviceTestInstance(t)
+	conf, err := buildClientUAPIConfig(inst, DeviceOptions{})
+	if err != nil {
+		t.Fatal(err)
+	}
+	want := "persistent_keepalive_interval=25\n"
+	if !strings.Contains(conf, want) {
+		t.Fatalf("UAPI config missing %q:\n%s", want, conf)
+	}
+	if strings.Contains(conf, "persistent_keepalive_seconds") {
+		t.Fatalf("UAPI config contains invalid peer key persistent_keepalive_seconds:\n%s", conf)
+	}
+}
+
+func TestBuildClientUAPIConfig_ZeroKeepAliveOmitsLine(t *testing.T) {
+	inst := clientDeviceTestInstance(t)
+	inst.Peers[0].KeepAlive = 0
+	conf, err := buildClientUAPIConfig(inst, DeviceOptions{})
+	if err != nil {
+		t.Fatal(err)
+	}
+	if strings.Contains(conf, "persistent_keepalive") {
+		t.Fatalf("zero KeepAlive must not emit a keepalive line:\n%s", conf)
+	}
+}
+
+// amneziawg-go reads an absent UAPI line as "keep the current value", and
+// ensureLocked reconfigures in place, so a cleared key must be sent as zero.
+func TestBuildClientUAPIConfig_ClearedHeaderProtectionKeyIsSentAsZero(t *testing.T) {
+	inst := clientDeviceTestInstance(t)
+	inst.Obfuscation = amneziawg.Obfuscation31{S1: 20, S2: 20, S3: 20, S4: 20}
+
+	key, err := wgutil.GenerateWireguardPSK()
+	if err != nil {
+		t.Fatal(err)
+	}
+	withKey, err := buildClientUAPIConfig(inst, DeviceOptions{HeaderProtectionKey: key})
+	if err != nil {
+		t.Fatal(err)
+	}
+	keyHex, err := wgutil.KeyToHex(key)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if !strings.Contains(withKey, "header_protection_key="+keyHex+"\n") {
+		t.Fatalf("a set key must be emitted verbatim, got:\n%s", withKey)
+	}
+
+	cleared, err := buildClientUAPIConfig(inst, DeviceOptions{})
+	if err != nil {
+		t.Fatal(err)
+	}
+	zero := "header_protection_key=" + strings.Repeat("0", 64) + "\n"
+	if !strings.Contains(cleared, zero) {
+		t.Fatalf("an unset key must be emitted as the all-zero key, got:\n%s", cleared)
+	}
+}
+
+// With no explicit MTU the netstack is built from S4, so an S4-only edit must
+// move the fingerprint or ensureLocked reconfigures in place and keeps the old.
+func TestOutboundFingerprintTracksTheS4DerivedMTU(t *testing.T) {
+	tests := []struct {
+		name       string
+		mtu        int
+		wantChange bool
+	}{
+		{"derived MTU", 0, true},
+		{"explicit MTU", 1420, false},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			inst := clientDeviceTestInstance(t)
+			inst.MTU = tt.mtu
+			inst.Obfuscation.S4 = 12
+			before := outboundFingerprint(inst)
+			inst.Obfuscation.S4 = 28
+			after := outboundFingerprint(inst)
+			if changed := before != after; changed != tt.wantChange {
+				t.Fatalf("fingerprint changed = %v, want %v (%q -> %q)", changed, tt.wantChange, before, after)
+			}
+		})
+	}
+}

+ 9 - 3
internal/amneziawgnet/device.go

@@ -5,7 +5,6 @@ import (
 	"net/netip"
 	"net/netip"
 	"strings"
 	"strings"
 
 
-	awgconn "github.com/amnezia-vpn/amneziawg-go/v3/conn"
 	"github.com/amnezia-vpn/amneziawg-go/v3/device"
 	"github.com/amnezia-vpn/amneziawg-go/v3/device"
 	"gvisor.dev/gvisor/pkg/tcpip/stack"
 	"gvisor.dev/gvisor/pkg/tcpip/stack"
 
 
@@ -69,8 +68,15 @@ type DeviceOptions struct {
 type Device struct {
 type Device struct {
 	*device.Device
 	*device.Device
 	Stack *stack.Stack
 	Stack *stack.Stack
+
+	// localAddrs snapshots the netstack interface addresses (gVisor exposes
+	// no read-back); set once at construction, read-only afterwards.
+	localAddrs []netip.Addr
 }
 }
 
 
+// LocalAddresses returns the configured tunnel-local address(es).
+func (d *Device) LocalAddresses() []netip.Addr { return d.localAddrs }
+
 // NewDevice constructs, configures, and brings up an embedded AmneziaWG
 // NewDevice constructs, configures, and brings up an embedded AmneziaWG
 // interface for inst in one call: a gVisor-backed tun.Device sized to
 // interface for inst in one call: a gVisor-backed tun.Device sized to
 // amneziawg.EffectiveMTU, addressed with inst.Address, configured via
 // amneziawg.EffectiveMTU, addressed with inst.Address, configured via
@@ -128,9 +134,9 @@ func newUnconfiguredDevice(inst amneziawg.Instance, opts DeviceOptions) (*Device
 	if logger == nil {
 	if logger == nil {
 		logger = device.NewLogger(device.LogLevelSilent, "")
 		logger = device.NewLogger(device.LogLevelSilent, "")
 	}
 	}
-	dev := device.NewDevice(tun, awgconn.NewDefaultBind(), logger)
+	dev := device.NewDevice(tun, newResolvingBind(), logger)
 
 
-	return &Device{Device: dev, Stack: gstack}, nil
+	return &Device{Device: dev, Stack: gstack, localAddrs: addrs}, nil
 }
 }
 
 
 // Configure applies inst/opts to d via UAPI and brings the interface up.
 // Configure applies inst/opts to d via UAPI and brings the interface up.

+ 216 - 0
internal/amneziawgnet/dns.go

@@ -0,0 +1,216 @@
+package amneziawgnet
+
+import (
+	"context"
+	"fmt"
+	"math/rand"
+	"net/netip"
+	"strings"
+	"sync"
+	"time"
+
+	"golang.org/x/net/dns/dnsmessage"
+
+	"gvisor.dev/gvisor/pkg/tcpip"
+	"gvisor.dev/gvisor/pkg/tcpip/adapters/gonet"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/logger"
+)
+
+// DefaultTunnelDNSServer resolves domain targets through outbound netstack.
+const (
+	DefaultTunnelDNSServer   = "1.1.1.1:53"
+	DefaultTunnelDNSServerV6 = "[2606:4700:4700::1111]:53"
+)
+
+func deviceHasV4(addrs []netip.Addr) bool {
+	for _, a := range addrs {
+		if a.Is4() {
+			return true
+		}
+	}
+	return false
+}
+
+func deviceHasV6(addrs []netip.Addr) bool {
+	for _, a := range addrs {
+		if a.Is6() && !a.Is4In6() {
+			return true
+		}
+	}
+	return false
+}
+
+// defaultDNSFor picks a resolver matching the tunnel address family:
+// IPv4 default (or empty), or IPv6 default when IPv6-only.
+func defaultDNSFor(addrs []netip.Addr) string {
+	if deviceHasV4(addrs) || len(addrs) == 0 {
+		return DefaultTunnelDNSServer
+	}
+	return DefaultTunnelDNSServerV6
+}
+
+const (
+	// tunnelResolveTimeout bounds one lookup inside a live connection handler.
+	tunnelResolveTimeout   = 4 * time.Second
+	tunnelDNSPacketTimeout = 1200 * time.Millisecond
+	tunnelDNSAttempts      = 3
+)
+
+type tunnelDNSCacheEntry struct {
+	addr netip.Addr
+	exp  time.Time
+}
+
+var tunnelDNSCache = struct {
+	mu sync.Mutex
+	m  map[string]tunnelDNSCacheEntry
+}{m: map[string]tunnelDNSCacheEntry{}}
+
+const (
+	tunnelDNSCacheTTL     = 60 * time.Second
+	tunnelDNSCacheMaxSize = 1024
+)
+
+// dnsCacheKey computes cache key scoped by outbound tag, server, and host.
+func dnsCacheKey(tag, dnsServer, host string) string {
+	return tag + "|" + dnsServer + "|" + host
+}
+
+func resolveTunnelVia(ctx context.Context, dev *Device, tag string, dnsServer string, host string) (netip.Addr, error) {
+	normDNS := normalizeDNSServer(dnsServer)
+	if normDNS == "" {
+		normDNS = defaultDNSFor(dev.LocalAddresses())
+	}
+	key := dnsCacheKey(tag, normDNS, host)
+	now := time.Now()
+	tunnelDNSCache.mu.Lock()
+	if e, ok := tunnelDNSCache.m[key]; ok && now.Before(e.exp) {
+		tunnelDNSCache.mu.Unlock()
+		return e.addr, nil
+	}
+	tunnelDNSCache.mu.Unlock()
+
+	server, err := netip.ParseAddrPort(normDNS)
+	if err != nil {
+		return netip.Addr{}, fmt.Errorf("bad tunnel DNS server %q: %w", normDNS, err)
+	}
+	raddr := tcpip.FullAddress{
+		NIC:  1,
+		Addr: tcpip.AddrFromSlice(server.Addr().AsSlice()),
+		Port: server.Port(),
+	}
+	conn, derr := gonet.DialUDP(dev.Stack, nil, &raddr, tunnelNetwork(server.Addr()))
+	if derr != nil {
+		logger.Warningf("amneziawgnet: resolveTunnel tag=%q host=%q server=%s localAddrs=%v err=%v", tag, host, server, dev.LocalAddresses(), derr)
+		return netip.Addr{}, fmt.Errorf("dns dial %s: %w", server, derr)
+	}
+	defer conn.Close()
+
+	addr, rerr := exchangeTunnelDNSWithFallback(ctx, conn, dev.LocalAddresses(), host)
+	if rerr != nil {
+		return netip.Addr{}, rerr
+	}
+
+	tunnelDNSCache.mu.Lock()
+	if len(tunnelDNSCache.m) >= tunnelDNSCacheMaxSize {
+		tunnelDNSCache.m = map[string]tunnelDNSCacheEntry{}
+	}
+	tunnelDNSCache.m[key] = tunnelDNSCacheEntry{addr: addr, exp: now.Add(tunnelDNSCacheTTL)}
+	tunnelDNSCache.mu.Unlock()
+	logger.Debugf("amneziawgnet: resolved tag=%q %q -> %s via tunnel", tag, host, addr)
+	return addr, nil
+}
+
+// flushTunnelDNSCacheForTag purges all cached DNS entries for an outbound tag.
+func flushTunnelDNSCacheForTag(tag string) {
+	tunnelDNSCache.mu.Lock()
+	defer tunnelDNSCache.mu.Unlock()
+	prefix := tag + "|"
+	for k := range tunnelDNSCache.m {
+		if strings.HasPrefix(k, prefix) {
+			delete(tunnelDNSCache.m, k)
+		}
+	}
+}
+
+// exchangeTunnelDNSWithFallback queries A and/or AAAA depending on the local
+// address families configured on the device stack.
+func exchangeTunnelDNSWithFallback(ctx context.Context, conn *gonet.UDPConn, addrs []netip.Addr, host string) (netip.Addr, error) {
+	hasV4 := deviceHasV4(addrs)
+	hasV6 := deviceHasV6(addrs)
+
+	// If the tunnel is IPv6-only, query AAAA first; else query A first.
+	types := []dnsmessage.Type{dnsmessage.TypeA, dnsmessage.TypeAAAA}
+	if hasV6 && !hasV4 {
+		types = []dnsmessage.Type{dnsmessage.TypeAAAA, dnsmessage.TypeA}
+	}
+
+	var firstErr error
+	for _, qType := range types {
+		// Skip AAAA if device has no IPv6 capability and has IPv4, unless A failed.
+		addr, err := exchangeTunnelDNSQuery(ctx, conn, host, qType)
+		if err == nil {
+			return addr, nil
+		}
+		if firstErr == nil {
+			firstErr = err
+		}
+	}
+	return netip.Addr{}, firstErr
+}
+
+func exchangeTunnelDNSQuery(ctx context.Context, conn *gonet.UDPConn, host string, qType dnsmessage.Type) (netip.Addr, error) {
+	name, err := dnsmessage.NewName(host + ".")
+	if err != nil {
+		return netip.Addr{}, fmt.Errorf("dns name %q: %w", host, err)
+	}
+	id := uint16(rand.Intn(1 << 16))
+	query := dnsmessage.Message{
+		Header: dnsmessage.Header{ID: id, RecursionDesired: true},
+		Questions: []dnsmessage.Question{{
+			Name:  name,
+			Type:  qType,
+			Class: dnsmessage.ClassINET,
+		}},
+	}
+	wire, err := query.Pack()
+	if err != nil {
+		return netip.Addr{}, fmt.Errorf("dns pack %q: %w", host, err)
+	}
+
+	buf := make([]byte, 512)
+	for attempt := 0; attempt < tunnelDNSAttempts; attempt++ {
+		select {
+		case <-ctx.Done():
+			return netip.Addr{}, ctx.Err()
+		default:
+		}
+		if _, werr := conn.Write(wire); werr != nil {
+			return netip.Addr{}, fmt.Errorf("dns send %q: %w", host, werr)
+		}
+		if derr := conn.SetReadDeadline(time.Now().Add(tunnelDNSPacketTimeout)); derr != nil {
+			return netip.Addr{}, fmt.Errorf("dns deadline %q: %w", host, derr)
+		}
+		for {
+			n, rerr := conn.Read(buf)
+			if rerr != nil {
+				break // per-attempt timeout -> next attempt
+			}
+			var resp dnsmessage.Message
+			if uerr := resp.Unpack(buf[:n]); uerr != nil || resp.ID != id {
+				continue
+			}
+			for _, ans := range resp.Answers {
+				if a, ok := ans.Body.(*dnsmessage.AResource); ok && qType == dnsmessage.TypeA {
+					return netip.AddrFrom4(a.A), nil
+				}
+				if aaaa, ok := ans.Body.(*dnsmessage.AAAAResource); ok && qType == dnsmessage.TypeAAAA {
+					return netip.AddrFrom16(aaaa.AAAA), nil
+				}
+			}
+			return netip.Addr{}, fmt.Errorf("dns %q (type %v): rcode=%d answers=%d", host, qType, resp.RCode, len(resp.Answers))
+		}
+	}
+	return netip.Addr{}, fmt.Errorf("dns lookup %q (type %v): no answer after %d attempts", host, qType, tunnelDNSAttempts)
+}

+ 652 - 0
internal/amneziawgnet/egress.go

@@ -0,0 +1,652 @@
+package amneziawgnet
+
+import (
+	"context"
+	"crypto/hmac"
+	"encoding/binary"
+	"fmt"
+	"io"
+	"net"
+	"net/netip"
+	"sync"
+	"time"
+
+	"gvisor.dev/gvisor/pkg/tcpip"
+	"gvisor.dev/gvisor/pkg/tcpip/adapters/gonet"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/logger"
+)
+
+// EgressBasePort is the fixed loopback port of the panel's SOCKS5 egress
+// server; it appears in every generated amneziawg socks bridge.
+const EgressBasePort = 64900
+
+// socks5EgressServer is a minimal loopback SOCKS5 server routing Xray's
+// bridged amneziawg outbounds into their embedded devices' netstacks.
+type socks5EgressServer struct {
+	mu      sync.Mutex
+	stacks  map[string]*Device // outbound tag -> its device
+	dns     map[string]string  // outbound tag -> its DNS server
+	tracked map[net.Conn]struct{}
+
+	// dnsServer resolves domain targets through the outbound netstack.
+	dnsServer string
+
+	listener net.Listener  // nil when stopped; acceptLoop takes it as an arg
+	closing  chan struct{} // per-listener lifetime signal, rearmed by Listen
+	wg       sync.WaitGroup
+}
+
+var (
+	egressOnce   sync.Once
+	egressServer *socks5EgressServer
+)
+
+// GetEgressServer returns the process-wide SOCKS5 egress server singleton.
+func GetEgressServer() *socks5EgressServer {
+	egressOnce.Do(func() {
+		egressServer = &socks5EgressServer{
+			stacks:  map[string]*Device{},
+			dns:     map[string]string{},
+			tracked: map[net.Conn]struct{}{},
+		}
+	})
+	return egressServer
+}
+
+// currentDNSServer reads dnsServer under lock -- custom per-tag DNS preferred.
+func (s *socks5EgressServer) currentDNSServer(tag ...string) string {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	if len(tag) > 0 && tag[0] != "" {
+		if custom, ok := s.dns[tag[0]]; ok && custom != "" {
+			return custom
+		}
+	}
+	return s.dnsServer
+}
+
+// SetDNSServer overrides the domain-target resolver (tests).
+func (s *socks5EgressServer) SetDNSServer(addr string) {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	s.dnsServer = addr
+}
+
+// SetStack registers or replaces the device backing an outbound tag.
+func (s *socks5EgressServer) SetStack(tag string, dev *Device, dnsServer ...string) {
+	norm := ""
+	if len(dnsServer) > 0 && dnsServer[0] != "" {
+		norm = normalizeDNSServer(dnsServer[0])
+	}
+	s.mu.Lock()
+	prevDev := s.stacks[tag]
+	prevDNS := s.dns[tag]
+	s.stacks[tag] = dev
+	if norm != "" {
+		s.dns[tag] = norm
+	} else {
+		delete(s.dns, tag)
+	}
+	changed := prevDev != dev || prevDNS != norm
+	s.mu.Unlock()
+	if changed {
+		flushTunnelDNSCacheForTag(tag)
+	}
+}
+
+// DeleteStack drops an outbound tag's registration (outbound removed).
+func (s *socks5EgressServer) DeleteStack(tag string) {
+	s.mu.Lock()
+	delete(s.stacks, tag)
+	delete(s.dns, tag)
+	s.mu.Unlock()
+	flushTunnelDNSCacheForTag(tag)
+}
+
+// Listen starts accepting on the loopback listener. Idempotent; a bind
+// failure is returned and retried by the caller's reconcile tick.
+func (s *socks5EgressServer) Listen() error {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	if s.listener != nil {
+		return nil
+	}
+	ln, err := (&net.ListenConfig{}).Listen(context.Background(), "tcp", fmt.Sprintf("127.0.0.1:%d", EgressBasePort))
+	if err != nil {
+		return fmt.Errorf("amneziawgnet: egress listen: %w", err)
+	}
+	s.listener = ln
+	s.closing = make(chan struct{})
+	logger.Infof("amneziawgnet: egress socks listening on %s", ln.Addr())
+	s.wg.Add(1)
+	go s.acceptLoop(ln, s.closing)
+	return nil
+}
+
+// Close stops the listener and in-flight handlers; signal first so an accept
+// error always observes closing.
+func (s *socks5EgressServer) Close() {
+	s.mu.Lock()
+	ln := s.listener
+	s.listener = nil
+	if ln == nil {
+		s.mu.Unlock()
+		return
+	}
+	close(s.closing)
+	tracked := s.tracked
+	s.tracked = map[net.Conn]struct{}{}
+	s.mu.Unlock()
+
+	ln.Close()
+	for conn := range tracked {
+		conn.Close()
+	}
+	s.wg.Wait()
+}
+
+func (s *socks5EgressServer) acceptLoop(ln net.Listener, closing chan struct{}) {
+	defer s.wg.Done()
+	for {
+		conn, err := ln.Accept()
+		if err != nil {
+			select {
+			case <-closing:
+				return
+			default:
+			}
+			logger.Warningf("amneziawgnet: egress accept: %v", err)
+			continue
+		}
+		select {
+		case <-closing:
+			conn.Close()
+			return
+		default:
+		}
+		s.mu.Lock()
+		if s.listener == nil {
+			s.mu.Unlock()
+			conn.Close()
+			return
+		}
+		s.tracked[conn] = struct{}{}
+		s.wg.Add(1)
+		s.mu.Unlock()
+		go func(c net.Conn) {
+			defer s.wg.Done()
+			defer func() {
+				s.mu.Lock()
+				delete(s.tracked, c)
+				s.mu.Unlock()
+			}()
+			s.handleConn(c)
+		}(conn)
+	}
+}
+
+// stackFor resolves a tag to its live device at use time, so rebuilds take
+// effect for new connections without touching the listener.
+func (s *socks5EgressServer) stackFor(tag string) (*Device, bool) {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	dev, ok := s.stacks[tag]
+	return dev, ok
+}
+
+func (s *socks5EgressServer) handleConn(conn net.Conn) {
+	defer conn.Close()
+
+	// Bound the pre-auth handshake so a silent client never pins a handler
+	// indefinitely across Close() and wg.Wait().
+	_ = conn.SetDeadline(time.Now().Add(portForwardDialTimeout))
+	method, err := socks5Greeting(conn)
+	if err != nil || method == 0xFF {
+		return
+	}
+	user := ""
+	if method == 0x02 {
+		// RFC 1929 sub-negotiation: VER(1) | ULEN(1) | UNAME | PLEN(1) |
+		// PASSWD -- the leading 0x01 version byte must be consumed first.
+		var ver [1]byte
+		if _, err := io.ReadFull(conn, ver[:]); err != nil {
+			return
+		}
+		var ulen [1]byte
+		if _, err := io.ReadFull(conn, ulen[:]); err != nil {
+			return
+		}
+		uname := make([]byte, ulen[0])
+		if _, err := io.ReadFull(conn, uname); err != nil {
+			return
+		}
+		user = string(uname)
+		var plen [1]byte
+		if _, err := io.ReadFull(conn, plen[:]); err != nil {
+			return
+		}
+		pass := make([]byte, plen[0])
+		if _, err := io.ReadFull(conn, pass); err != nil {
+			return
+		}
+		if !hmac.Equal(pass, []byte(SocksPassword())) {
+			_, _ = conn.Write([]byte{0x01, 0x01})
+			return
+		}
+		if _, err := conn.Write([]byte{0x01, 0x00}); err != nil {
+			return
+		}
+	}
+
+	var req [4]byte
+	if _, err := io.ReadFull(conn, req[:]); err != nil {
+		return
+	}
+	// Handshake complete: clear deadline for the relay phase.
+	_ = conn.SetDeadline(time.Time{})
+	target, err := readSocksRequestTarget(conn, req[3])
+	if err != nil {
+		writeSocksReply(conn, 0x01, netip.AddrPort{})
+		return
+	}
+
+	switch req[1] {
+	case 0x01: // CONNECT
+		dev, ok := s.stackFor(user)
+		if !ok {
+			writeSocksReply(conn, 0x05, netip.AddrPort{})
+			return
+		}
+		dest, err := target.resolveTunnelVia(s.currentDNSServer(user), user, dev)
+		if err != nil {
+			logger.Warningf("amneziawgnet: egress %q: resolve %s: %v", user, target, err)
+			writeSocksReply(conn, 0x04, netip.AddrPort{})
+			return
+		}
+		s.relayTCP(dev, user, conn, dest)
+	case 0x03: // UDP ASSOCIATE
+		dev, ok := s.stackFor(user)
+		if !ok {
+			writeSocksReply(conn, 0x05, netip.AddrPort{})
+			return
+		}
+		s.relayUDP(dev, user, udpControl{conn: conn}, target)
+	default:
+		writeSocksReply(conn, 0x07, netip.AddrPort{})
+	}
+}
+
+// socks5Greeting requires RFC 1929 username/password auth (0x02).
+// Returns 0xFF when unauthenticated or unsupported.
+func socks5Greeting(conn net.Conn) (byte, error) {
+	var hdr [2]byte
+	if _, err := io.ReadFull(conn, hdr[:]); err != nil {
+		return 0xFF, err
+	}
+	methods := make([]byte, hdr[1])
+	if _, err := io.ReadFull(conn, methods); err != nil {
+		return 0xFF, err
+	}
+	hasUserPass := false
+	for _, m := range methods {
+		if m == 0x02 {
+			hasUserPass = true
+			break
+		}
+	}
+	if !hasUserPass {
+		_, _ = conn.Write([]byte{0x05, 0xFF})
+		return 0xFF, nil
+	}
+	if _, err := conn.Write([]byte{0x05, 0x02}); err != nil {
+		return 0xFF, err
+	}
+	return 0x02, nil
+}
+
+// socksTarget is a parsed SOCKS5 request address: an IP, or the raw hostname
+// for ATYP 0x03 (resolved through the outbound's tunnel, never host-side).
+type socksTarget struct {
+	host string
+	ip   netip.Addr
+	port uint16
+}
+
+func readSocksRequestTarget(r io.Reader, atyp byte) (socksTarget, error) {
+	var t socksTarget
+	switch atyp {
+	case 0x01:
+		var b [4]byte
+		if _, err := io.ReadFull(r, b[:]); err != nil {
+			return t, err
+		}
+		t.ip = netip.AddrFrom4(b)
+	case 0x04:
+		var b [16]byte
+		if _, err := io.ReadFull(r, b[:]); err != nil {
+			return t, err
+		}
+		t.ip = netip.AddrFrom16(b)
+	case 0x03:
+		var l [1]byte
+		if _, err := io.ReadFull(r, l[:]); err != nil {
+			return t, err
+		}
+		name := make([]byte, l[0])
+		if _, err := io.ReadFull(r, name); err != nil {
+			return t, err
+		}
+		t.host = string(name)
+	default:
+		return t, fmt.Errorf("unsupported SOCKS5 request address type %d", atyp)
+	}
+	var portBytes [2]byte
+	if _, err := io.ReadFull(r, portBytes[:]); err != nil {
+		return t, err
+	}
+	t.port = binary.BigEndian.Uint16(portBytes[:])
+	return t, nil
+}
+
+func (t socksTarget) String() string {
+	if t.ip.IsValid() {
+		return netip.AddrPortFrom(t.ip, t.port).String()
+	}
+	return fmt.Sprintf("%s:%d", t.host, t.port)
+}
+
+// Domain targets resolve via the tunnel; reply-side helper must not be used here.
+func (t socksTarget) resolveTunnelVia(dnsServer, tag string, dev *Device) (netip.AddrPort, error) {
+	if t.ip.IsValid() {
+		return netip.AddrPortFrom(t.ip, t.port), nil
+	}
+	ctx, cancel := context.WithTimeout(context.Background(), tunnelResolveTimeout)
+	defer cancel()
+	addr, err := resolveTunnelVia(ctx, dev, tag, dnsServer, t.host)
+	if err != nil {
+		return netip.AddrPort{}, err
+	}
+	return netip.AddrPortFrom(addr, t.port), nil
+}
+
+// writeSocksReply emits a reply with an empty v4 bind address; Xray only
+// reads the code byte.
+func writeSocksReply(w io.Writer, code byte, _ netip.AddrPort) {
+	out := []byte{0x05, code, 0x00, 0x01, 0, 0, 0, 0, 0, 0}
+	_, _ = w.Write(out)
+}
+
+// relayTCP dials dest inside the tagged outbound's netstack and pipes both
+// directions until either side closes.
+func (s *socks5EgressServer) relayTCP(dev *Device, tag string, upstream net.Conn, dest netip.AddrPort) {
+	fa := tcpip.FullAddress{
+		NIC:  1,
+		Addr: tcpip.AddrFromSlice(dest.Addr().AsSlice()),
+		Port: dest.Port(),
+	}
+	// Bound dial with portForwardDialTimeout so unreachable peers do not
+	// pin goroutines and netstack endpoints in s.tracked.
+	dctx, dcancel := context.WithTimeout(context.Background(), portForwardDialTimeout)
+	defer dcancel()
+	tunnelConn, err := gonet.DialContextTCP(dctx, dev.Stack, fa, tunnelNetwork(dest.Addr()))
+	if err != nil {
+		logger.Warningf("amneziawgnet: egress %q: dial tunnel %s: %v", tag, dest, err)
+		writeSocksReply(upstream, 0x01, netip.AddrPort{})
+		return
+	}
+	defer tunnelConn.Close()
+
+	if _, err := upstream.Write([]byte{0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0}); err != nil {
+		return
+	}
+	done := make(chan struct{}, 2)
+	go func() { _, _ = io.Copy(tunnelConn, upstream); done <- struct{}{} }()
+	go func() { _, _ = io.Copy(upstream, tunnelConn); done <- struct{}{} }()
+	<-done
+}
+
+// udpControl is the control half of one UDP ASSOCIATE: the TCP connection
+// whose lifetime bounds the association (RFC 1928).
+type udpControl struct{ conn net.Conn }
+
+// egressUDPSession is one UDP ASSOCIATE flow: host-facing socket plus a
+// connected tunnel endpoint whose source port makes replies answerable.
+type egressUDPSession struct {
+	dst  netip.AddrPort
+	conn *gonet.UDPConn
+}
+
+// relayUDP answers the associate request and relays datagrams to
+// per-destination tunnel endpoints until the control connection closes.
+func (s *socks5EgressServer) relayUDP(dev *Device, tag string, ctl udpControl, _ socksTarget) {
+	udpConn, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)})
+	if err != nil {
+		logger.Warningf("amneziawgnet: egress %q: udp bind: %v", tag, err)
+		writeSocksReply(ctl.conn, 0x01, netip.AddrPort{})
+		return
+	}
+	defer udpConn.Close()
+
+	local := udpConn.LocalAddr().(*net.UDPAddr)
+	ip4 := local.IP.To4()
+	reply := []byte{
+		0x05, 0x00, 0x00, 0x01, ip4[0], ip4[1], ip4[2], ip4[3],
+		byte(local.Port >> 8), byte(local.Port),
+	}
+	if _, err := ctl.conn.Write(reply); err != nil {
+		return
+	}
+
+	sessions := &udpEgressSessions{m: map[netip.AddrPort]*egressUDPSession{}}
+
+	// Reader: strip per-datagram SOCKS5 headers and forward into the tunnel;
+	// only the associated client's address is accepted.
+	go func() {
+		var client netip.AddrPort
+		buf := make([]byte, 65536)
+		for {
+			n, from, err := udpConn.ReadFrom(buf)
+			if err != nil {
+				return
+			}
+			if src, ok := udpAddrPort(from); ok {
+				if client.IsValid() && src != client {
+					continue // RFC 1928: only the associated client may send
+				}
+				client = src
+			}
+			data := buf[:n]
+			if len(data) < 4 {
+				continue
+			}
+			atyp := data[3]
+			var dst netip.AddrPort
+			var payloadOff int
+			if atyp == 0x03 {
+				name, port, hdrLen, perr := parseDatagramDomainHeader(data)
+				if perr != nil {
+					continue
+				}
+				// Resolve off reader loop so slow tunnel DNS lookups do not
+				// stall other destinations on this association.
+				go func(client netip.AddrPort, name string, port uint16, hdrLen int, datagram []byte) {
+					dnsSrv := s.currentDNSServer(tag)
+					rctx, rcancel := context.WithTimeout(context.Background(), tunnelResolveTimeout)
+					daddr, rerr := resolveTunnelVia(rctx, dev, tag, dnsSrv, name)
+					rcancel()
+					if rerr != nil {
+						logger.Warningf("amneziawgnet: egress %q: resolve udp %q (dns=%s): %v", tag, name, dnsSrv, rerr)
+						return
+					}
+					s.deliverUDPDatagram(dev, tag, udpConn, client, sessions, netip.AddrPortFrom(daddr, port), datagram[hdrLen:])
+				}(client, name, port, hdrLen, append([]byte(nil), data...))
+				continue
+			} else {
+				hdrLen := 4 + addrLen(atyp) + 2
+				if hdrLen <= 6 || len(data) < hdrLen {
+					continue
+				}
+				d, derr := parseDatagramHeader(data[:hdrLen])
+				if derr != nil {
+					logger.Warningf("amneziawgnet: egress %q: udp header: %v", tag, derr)
+					continue
+				}
+				dst = d
+				payloadOff = hdrLen
+			}
+			s.deliverUDPDatagram(dev, tag, udpConn, client, sessions, dst, data[payloadOff:])
+		}
+	}()
+
+	// Control-conn close tears down relaying -- relay.go UDPRelay contract.
+	buf := make([]byte, 512)
+	for {
+		if _, err := ctl.conn.Read(buf); err != nil {
+			sessions.closeAll()
+			return
+		}
+	}
+}
+
+// udpEgressSessions guards the association's session map: the reader
+// goroutine inserts while the control-conn teardown iterates.
+type udpEgressSessions struct {
+	mu sync.Mutex
+	m  map[netip.AddrPort]*egressUDPSession
+}
+
+func (s *udpEgressSessions) getOrDial(dev *Device, tag string, udpConn *net.UDPConn, client netip.AddrPort, dst netip.AddrPort) *egressUDPSession {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	if sess, ok := s.m[dst]; ok {
+		return sess
+	}
+	raddr := tcpip.FullAddress{
+		NIC:  1,
+		Addr: tcpip.AddrFromSlice(dst.Addr().AsSlice()),
+		Port: dst.Port(),
+	}
+	conn, err := gonet.DialUDP(dev.Stack, nil, &raddr, tunnelNetwork(dst.Addr()))
+	if err != nil {
+		logger.Warningf("amneziawgnet: egress %q: dial udp %s: %v", tag, dst, err)
+		return nil
+	}
+	sess := &egressUDPSession{dst: dst, conn: conn}
+	s.m[dst] = sess
+	go pumpUDPEgress(udpConn, client, sess, s)
+	return sess
+}
+
+func (s *udpEgressSessions) closeAll() {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	for _, sess := range s.m {
+		sess.conn.Close()
+	}
+}
+
+// deliverUDPDatagram forwards one payload to dst through the tunnel endpoint.
+// Safe for concurrent use across resolver and direct-path goroutines.
+func (s *socks5EgressServer) deliverUDPDatagram(dev *Device, tag string, udpConn *net.UDPConn, client netip.AddrPort, sessions *udpEgressSessions, dst netip.AddrPort, payload []byte) {
+	if !client.IsValid() {
+		return // nothing to reply to yet
+	}
+	sess := sessions.getOrDial(dev, tag, udpConn, client, dst)
+	if sess == nil {
+		return
+	}
+	if _, werr := sess.conn.Write(payload); werr != nil {
+		logger.Warningf("amneziawgnet: egress %q: send udp to %s: %v", tag, dst, werr)
+	}
+}
+
+// pumpUDPEgress reads replies from one connected tunnel endpoint and writes
+// them back to the associated client as SOCKS5 UDP datagrams.
+func pumpUDPEgress(udpConn *net.UDPConn, client netip.AddrPort, sess *egressUDPSession, sessions *udpEgressSessions) {
+	defer func() {
+		sessions.mu.Lock()
+		delete(sessions.m, sess.dst)
+		sessions.mu.Unlock()
+		sess.conn.Close()
+	}()
+	buf := make([]byte, 65536)
+	for {
+		// Reap idle egress sessions to avoid holding them indefinitely.
+		_ = sess.conn.SetReadDeadline(time.Now().Add(portForwardUDPIdleTimeout))
+		n, err := sess.conn.Read(buf)
+		if err != nil {
+			return
+		}
+		hdr := make([]byte, 0, 3+1+16+2+n)
+		hdr = append(hdr, 0x00, 0x00, 0x00) // RSV RSV FRAG(=0)
+		if sess.dst.Addr().Is4() {
+			b := sess.dst.Addr().As4()
+			hdr = append(hdr, 0x01)
+			hdr = append(hdr, b[:]...)
+		} else {
+			b := sess.dst.Addr().As16()
+			hdr = append(hdr, 0x04)
+			hdr = append(hdr, b[:]...)
+		}
+		var portBytes [2]byte
+		binary.BigEndian.PutUint16(portBytes[:], sess.dst.Port())
+		hdr = append(hdr, portBytes[:]...)
+		hdr = append(hdr, buf[:n]...)
+		if _, err := udpConn.WriteTo(hdr, net.UDPAddrFromAddrPort(client)); err != nil {
+			return
+		}
+	}
+}
+
+// parseDatagramDomainHeader decodes a domain SOCKS5 UDP header (RSV RSV FRAG
+// 0x03 LEN NAME PORT) into name, port, and header length.
+func parseDatagramDomainHeader(data []byte) (name string, port uint16, hdrLen int, err error) {
+	if len(data) < 5 {
+		return "", 0, 0, fmt.Errorf("short domain header")
+	}
+	l := int(data[4])
+	hdrLen = 4 + 1 + l + 2
+	if l == 0 || len(data) < hdrLen {
+		return "", 0, 0, fmt.Errorf("short domain payload")
+	}
+	name = string(data[5 : 5+l])
+	port = binary.BigEndian.Uint16(data[5+l : 7+l])
+	return name, port, hdrLen, nil
+}
+
+// addrLen returns the wire length of a SOCKS5 address of the given ATYP.
+func addrLen(atyp byte) int {
+	switch atyp {
+	case 0x01:
+		return 4
+	case 0x04:
+		return 16
+	default:
+		return -1
+	}
+}
+
+// parseDatagramHeader decodes the destination from the front of a SOCKS5 UDP
+// datagram header block (RSV RSV FRAG ATYP ADDR PORT).
+func parseDatagramHeader(hdr []byte) (netip.AddrPort, error) {
+	if len(hdr) < 4 {
+		return netip.AddrPort{}, fmt.Errorf("short header")
+	}
+	atyp := hdr[3]
+	body := hdr[4:]
+	switch atyp {
+	case 0x01:
+		if len(body) < 6 {
+			return netip.AddrPort{}, fmt.Errorf("short v4")
+		}
+		return netip.AddrPortFrom(netip.AddrFrom4([4]byte(body[:4])), binary.BigEndian.Uint16(body[4:6])), nil
+	case 0x04:
+		if len(body) < 18 {
+			return netip.AddrPort{}, fmt.Errorf("short v6")
+		}
+		return netip.AddrPortFrom(netip.AddrFrom16([16]byte(body[:16])), binary.BigEndian.Uint16(body[16:18])), nil
+	default:
+		return netip.AddrPort{}, fmt.Errorf("unsupported atyp %d", atyp)
+	}
+}

+ 741 - 0
internal/amneziawgnet/egress_domain_test.go

@@ -0,0 +1,741 @@
+package amneziawgnet
+
+import (
+	"bytes"
+	"encoding/binary"
+	"fmt"
+	"io"
+	"net"
+	"net/netip"
+	"strconv"
+	"strings"
+	"testing"
+	"time"
+
+	"gvisor.dev/gvisor/pkg/tcpip"
+	"gvisor.dev/gvisor/pkg/tcpip/adapters/gonet"
+	"gvisor.dev/gvisor/pkg/tcpip/network/ipv4"
+	"gvisor.dev/gvisor/pkg/tcpip/network/ipv6"
+
+	"github.com/amnezia-vpn/amneziawg-go/v3/device"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
+	wgutil "github.com/mhsanaei/3x-ui/v3/internal/util/wireguard"
+)
+
+func verboseLoggerForTest(prefix string) *device.Logger {
+	return device.NewLogger(device.LogLevelVerbose, prefix)
+}
+
+const (
+	tunnelTestClientAddr   = "10.203.0.2"
+	tunnelTestServerAddr   = "10.203.0.1"
+	tunnelTestClientAddrV6 = "fd00:203::2"
+	tunnelTestServerAddrV6 = "fd00:203::1"
+	egressTestDialTimeout  = 5 * time.Second
+)
+
+// pairedTunnel wires an outbound client device to an embedded server device
+// over host UDP; the server stack hosts the far-end services under test.
+type pairedTunnel struct {
+	client   *Device
+	server   *Device
+	serverIP netip.Addr
+}
+
+func newPairedTunnelForTest(t *testing.T) *pairedTunnel {
+	t.Helper()
+	slog := verboseLoggerForTest("(tsrv) ")
+	serverPriv, serverPub, err := wgutil.GenerateWireguardKeypair()
+	if err != nil {
+		t.Fatal(err)
+	}
+	clientPriv, clientPub, err := wgutil.GenerateWireguardKeypair()
+	if err != nil {
+		t.Fatal(err)
+	}
+	pc, err := net.ListenPacket("udp", "127.0.0.1:0")
+	if err != nil {
+		t.Fatal(err)
+	}
+	listenPort := pc.LocalAddr().(*net.UDPAddr).Port
+	pc.Close()
+
+	obf := amneziawg.Obfuscation31{Jc: 4, Jmin: 40, Jmax: 70, S1: 20, S2: 30, S3: 20, S4: 20}
+	serverInst := amneziawg.Instance{
+		Id:            1,
+		InterfaceName: "awg-dnstest",
+		ListenPort:    listenPort,
+		PrivateKey:    serverPriv,
+		PublicKey:     serverPub,
+		Address:       []string{tunnelTestServerAddr + "/24"},
+		MTU:           1420,
+		Obfuscation:   obf,
+		Peers: []amneziawg.Peer{{
+			PublicKey:  clientPub,
+			AllowedIPs: []string{tunnelTestClientAddr + "/32"},
+		}},
+	}
+	server, err := newUnconfiguredDevice(serverInst, DeviceOptions{Logger: slog})
+	if err != nil {
+		t.Fatalf("server device: %v", err)
+	}
+	t.Cleanup(server.Close)
+
+	// Server Up before client exists: the first handshake fires at
+	// ConfigureClient; a missed initiation costs a 5s REKEY_TIMEOUT.
+	if err := server.Configure(serverInst, DeviceOptions{Logger: slog}); err != nil {
+		t.Fatalf("server Configure: %v", err)
+	}
+
+	clientInst := amneziawg.OutboundInstance{
+		Tag:         "awg-dom-test",
+		Address:     []string{tunnelTestClientAddr + "/32"},
+		MTU:         1420,
+		PrivateKey:  clientPriv,
+		Obfuscation: obf,
+		Peers: []amneziawg.OutboundPeer{{
+			PublicKey:  serverPub,
+			Endpoint:   net.JoinHostPort("127.0.0.1", strconv.Itoa(listenPort)),
+			AllowedIPs: []string{"0.0.0.0/0", "::/0"},
+			KeepAlive:  1,
+		}},
+	}
+	clog := verboseLoggerForTest("(tcli) ")
+	client, err := newUnconfiguredClientDevice(clientInst, DeviceOptions{Logger: clog})
+	if err != nil {
+		t.Fatalf("client device: %v", err)
+	}
+	if err := client.ConfigureClient(clientInst, DeviceOptions{Logger: clog}); err != nil {
+		client.Close()
+		t.Fatalf("ConfigureClient: %v", err)
+	}
+	t.Cleanup(client.Close)
+
+	return &pairedTunnel{
+		client:   client,
+		server:   server,
+		serverIP: netip.MustParseAddr(tunnelTestServerAddr),
+	}
+}
+
+func newPairedTunnelV6ForTest(t *testing.T) *pairedTunnel {
+	t.Helper()
+	slog := verboseLoggerForTest("(tsrv6) ")
+	serverPriv, serverPub, err := wgutil.GenerateWireguardKeypair()
+	if err != nil {
+		t.Fatal(err)
+	}
+	clientPriv, clientPub, err := wgutil.GenerateWireguardKeypair()
+	if err != nil {
+		t.Fatal(err)
+	}
+	pc, err := net.ListenPacket("udp", "127.0.0.1:0")
+	if err != nil {
+		t.Fatal(err)
+	}
+	listenPort := pc.LocalAddr().(*net.UDPAddr).Port
+	pc.Close()
+
+	obf := amneziawg.Obfuscation31{Jc: 4, Jmin: 40, Jmax: 70, S1: 20, S2: 30, S3: 20, S4: 20}
+	serverInst := amneziawg.Instance{
+		Id:            2,
+		InterfaceName: "awg-dnstest6",
+		ListenPort:    listenPort,
+		PrivateKey:    serverPriv,
+		PublicKey:     serverPub,
+		Address:       []string{tunnelTestServerAddrV6 + "/64", "2606:4700:4700::1111/128"},
+		MTU:           1420,
+		Obfuscation:   obf,
+		Peers: []amneziawg.Peer{{
+			PublicKey:  clientPub,
+			AllowedIPs: []string{tunnelTestClientAddrV6 + "/128"},
+		}},
+	}
+	server, err := newUnconfiguredDevice(serverInst, DeviceOptions{Logger: slog})
+	if err != nil {
+		t.Fatalf("server device: %v", err)
+	}
+	t.Cleanup(server.Close)
+
+	if err := server.Configure(serverInst, DeviceOptions{Logger: slog}); err != nil {
+		t.Fatalf("server Configure: %v", err)
+	}
+
+	clientInst := amneziawg.OutboundInstance{
+		Tag:         "awg-dom-v6-test",
+		Address:     []string{tunnelTestClientAddrV6 + "/128"},
+		MTU:         1420,
+		PrivateKey:  clientPriv,
+		Obfuscation: obf,
+		Peers: []amneziawg.OutboundPeer{{
+			PublicKey:  serverPub,
+			Endpoint:   net.JoinHostPort("127.0.0.1", strconv.Itoa(listenPort)),
+			AllowedIPs: []string{"::/0"},
+			KeepAlive:  1,
+		}},
+	}
+	clog := verboseLoggerForTest("(tcli6) ")
+	client, err := newUnconfiguredClientDevice(clientInst, DeviceOptions{Logger: clog})
+	if err != nil {
+		t.Fatalf("client device: %v", err)
+	}
+	if err := client.ConfigureClient(clientInst, DeviceOptions{Logger: clog}); err != nil {
+		client.Close()
+		t.Fatalf("ConfigureClient: %v", err)
+	}
+	t.Cleanup(client.Close)
+
+	return &pairedTunnel{
+		client:   client,
+		server:   server,
+		serverIP: netip.MustParseAddr(tunnelTestServerAddrV6),
+	}
+}
+
+func registerEgressDeviceForTest(t *testing.T, dev *Device) {
+	t.Helper()
+	srv := GetEgressServer()
+	srv.SetStack("awg-dom-test", dev)
+	if err := srv.Listen(); err != nil {
+		t.Fatal(err)
+	}
+	t.Cleanup(func() { srv.DeleteStack("awg-dom-test") })
+}
+
+// startTunnelDNS answers A/AAAA queries from INSIDE the server's netstack;
+// reaching it proves DNS rode the tunnel, not the host resolver.
+func (p *pairedTunnel) startDNS(t *testing.T, answer netip.Addr) chan string {
+	t.Helper()
+	proto := ipv4.ProtocolNumber
+	if p.serverIP.Is6() {
+		proto = ipv6.ProtocolNumber
+	}
+	ln, err := gonet.DialUDP(p.server.Stack, &tcpip.FullAddress{NIC: 1, Port: 53}, nil, proto)
+	if err != nil {
+		t.Fatalf("bind fake dns in server stack: %v", err)
+	}
+	got := make(chan string, 8)
+	go func() {
+		defer ln.Close()
+		buf := make([]byte, 512)
+		for {
+			n, from, rerr := ln.ReadFrom(buf)
+			if rerr != nil {
+				return
+			}
+			q := buf[:n]
+			if name := dnsQuestionName(q); name != "" {
+				select {
+				case got <- name:
+				default:
+				}
+			}
+			if resp := buildARecordReply(q, answer); resp != nil {
+				if _, werr := ln.WriteTo(resp, from); werr != nil {
+					return
+				}
+			}
+		}
+	}()
+	t.Cleanup(func() { ln.Close() })
+	return got
+}
+
+func (p *pairedTunnel) overrideDNS(t *testing.T, answer netip.Addr) chan string {
+	t.Helper()
+	srv := GetEgressServer()
+	prev := srv.currentDNSServer()
+	srv.SetDNSServer(net.JoinHostPort(p.serverIP.String(), "53"))
+	t.Cleanup(func() { srv.SetDNSServer(prev) })
+	resetTunnelDNSCacheForTest()
+	return p.startDNS(t, answer)
+}
+
+func resetTunnelDNSCacheForTest() {
+	tunnelDNSCache.mu.Lock()
+	tunnelDNSCache.m = map[string]tunnelDNSCacheEntry{}
+	tunnelDNSCache.mu.Unlock()
+}
+
+func dnsQuestionName(q []byte) string {
+	if len(q) < 12 {
+		return ""
+	}
+	i := 12
+	var parts []byte
+	for i < len(q) {
+		l := int(q[i])
+		i++
+		if l == 0 {
+			break
+		}
+		if i+l > len(q) || l > 63 {
+			return ""
+		}
+		parts = append(parts, q[i:i+l]...)
+		parts = append(parts, '.')
+		i += l
+	}
+	for len(parts) > 0 && parts[len(parts)-1] == '.' {
+		parts = parts[:len(parts)-1]
+	}
+	return string(parts)
+}
+
+func buildARecordReply(q []byte, answer netip.Addr) []byte {
+	if len(q) < 17 {
+		return nil
+	}
+	out := make([]byte, 0, len(q)+16)
+	header := make([]byte, 12)
+	copy(header[0:2], q[0:2])
+	header[2] = 0x81 // QR=1 RD=1
+	header[3] = 0x80 // RA=1 RCODE=0
+	binary.BigEndian.PutUint16(header[4:], 1)
+	binary.BigEndian.PutUint16(header[6:], 1)
+	out = append(out, header...)
+	end := len(q)
+	for end >= 5 && q[end-4] == 0 && q[end-3] == 0 && q[end-2] == 0 && q[end-1] == 0 {
+		end -= 4
+	}
+	out = append(out, q[12:end]...)
+	if answer.Is4() {
+		a := answer.As4()
+		rr := make([]byte, 16)
+		rr[0], rr[1] = 0xc0, 0x0c
+		binary.BigEndian.PutUint16(rr[2:], 1)  // Type A
+		binary.BigEndian.PutUint16(rr[4:], 1)  // IN
+		binary.BigEndian.PutUint32(rr[6:], 30) // TTL
+		binary.BigEndian.PutUint16(rr[10:], 4)
+		copy(rr[12:], a[:])
+		out = append(out, rr...)
+	} else if answer.Is6() {
+		a16 := answer.As16()
+		rr := make([]byte, 28)
+		rr[0], rr[1] = 0xc0, 0x0c
+		binary.BigEndian.PutUint16(rr[2:], 28) // Type AAAA
+		binary.BigEndian.PutUint16(rr[4:], 1)  // IN
+		binary.BigEndian.PutUint32(rr[6:], 30) // TTL
+		binary.BigEndian.PutUint16(rr[10:], 16)
+		copy(rr[12:], a16[:])
+		out = append(out, rr...)
+	}
+	return out
+}
+
+func socksAuthUser(t *testing.T, ctl net.Conn, user string) {
+	t.Helper()
+	ctl.SetDeadline(time.Now().Add(egressTestDialTimeout))
+	if _, err := ctl.Write([]byte{0x05, 0x02, 0x00, 0x02}); err != nil {
+		t.Fatal(err)
+	}
+	r := make([]byte, 2)
+	if _, err := io.ReadFull(ctl, r); err != nil {
+		t.Fatalf("greeting read: %v", err)
+	}
+	pass := SocksPassword()
+	req := make([]byte, 0, 3+len(user)+len(pass))
+	req = append(req, 0x01, byte(len(user)))
+	req = append(req, user...)
+	req = append(req, byte(len(pass)))
+	req = append(req, pass...)
+	if _, err := ctl.Write(req); err != nil {
+		t.Fatal(err)
+	}
+	auth := make([]byte, 2)
+	if _, err := io.ReadFull(ctl, auth); err != nil || auth[1] != 0x00 {
+		t.Fatalf("auth rejected: %v %v", err, auth)
+	}
+}
+
+func socksAuth(t *testing.T, ctl net.Conn) {
+	t.Helper()
+	socksAuthUser(t, ctl, "awg-dom-test")
+}
+
+func TestEgressGreetingRejectsNoAuthClient(t *testing.T) {
+	tun := newPairedTunnelForTest(t)
+	registerEgressDeviceForTest(t, tun.client)
+
+	ctl, err := (&net.Dialer{Timeout: egressTestDialTimeout}).Dial("tcp", net.JoinHostPort("127.0.0.1", strconv.Itoa(EgressBasePort)))
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer ctl.Close()
+	ctl.SetDeadline(time.Now().Add(egressTestDialTimeout))
+	// Client offers only NO-AUTH; server must answer 0xFF.
+	if _, err := ctl.Write([]byte{0x05, 0x01, 0x00}); err != nil {
+		t.Fatal(err)
+	}
+	r := make([]byte, 2)
+	if _, err := io.ReadFull(ctl, r); err != nil {
+		t.Fatalf("greeting read: %v", err)
+	}
+	if r[0] != 0x05 || r[1] != 0xFF {
+		t.Fatalf("greeting reply = %v, want 05 FF (auth required)", r)
+	}
+}
+
+func TestEgressConnectDomainResolvesThroughTunnel(t *testing.T) {
+	tun := newPairedTunnelForTest(t)
+	registerEgressDeviceForTest(t, tun.client)
+	// Resolving to the server's own tunnel address makes the follow-up dial
+	// fail fast (nothing listens on :80), while proving resolution happened.
+	gotQuery := tun.overrideDNS(t, tun.serverIP)
+
+	ctl, err := (&net.Dialer{Timeout: egressTestDialTimeout}).Dial("tcp", net.JoinHostPort("127.0.0.1", strconv.Itoa(EgressBasePort)))
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer ctl.Close()
+
+	socksAuth(t, ctl)
+	name := "example.internal"
+	req := make([]byte, 0, 7+len(name))
+	req = append(req, 0x05, 0x01, 0x00, 0x03, byte(len(name)))
+	req = append(req, name...)
+	req = append(req, 0x00, 0x50)
+	if _, err := ctl.Write(req); err != nil {
+		t.Fatal(err)
+	}
+
+	select {
+	case queried := <-gotQuery:
+		if len(queried) < len(name) || queried[:len(name)] != name {
+			t.Fatalf("resolver queried %q, want prefix %q -- DNS did not ride the tunnel", queried, name)
+		}
+	case <-time.After(egressTestDialTimeout):
+		t.Fatal("no DNS query reached the in-tunnel resolver")
+	}
+
+	reply := make([]byte, 10)
+	ctl.SetDeadline(time.Now().Add(egressTestDialTimeout))
+	if _, err := io.ReadFull(ctl, reply); err != nil {
+		t.Fatalf("read reply: %v", err)
+	}
+	if reply[1] == 0x00 {
+		t.Fatal("unexpected success: nothing should be listening on the resolved address")
+	}
+}
+
+func TestEgressConnectDomainIPv6OnlyTunnelResolvesThroughTunnel(t *testing.T) {
+	tun := newPairedTunnelV6ForTest(t)
+	srv := GetEgressServer()
+	srv.SetStack("awg-dom-v6-test", tun.client)
+	if err := srv.Listen(); err != nil {
+		t.Fatal(err)
+	}
+	t.Cleanup(func() { srv.DeleteStack("awg-dom-v6-test") })
+
+	// No override: a blank dns has to fall through currentDNSServer to
+	// defaultDNSFor, which the server stack answers on its own v6 /128.
+	prevDNS := srv.currentDNSServer()
+	srv.SetDNSServer("")
+	t.Cleanup(func() { srv.SetDNSServer(prevDNS) })
+	resetTunnelDNSCacheForTest()
+	gotQuery := tun.startDNS(t, tun.serverIP)
+
+	ctl, err := (&net.Dialer{Timeout: egressTestDialTimeout}).Dial("tcp", net.JoinHostPort("127.0.0.1", strconv.Itoa(EgressBasePort)))
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer ctl.Close()
+
+	socksAuthUser(t, ctl, "awg-dom-v6-test")
+	name := "v6.example.internal"
+	req := make([]byte, 0, 7+len(name))
+	req = append(req, 0x05, 0x01, 0x00, 0x03, byte(len(name)))
+	req = append(req, name...)
+	req = append(req, 0x00, 0x50)
+	if _, err := ctl.Write(req); err != nil {
+		t.Fatal(err)
+	}
+
+	select {
+	case queried := <-gotQuery:
+		if len(queried) < len(name) || queried[:len(name)] != name {
+			t.Fatalf("resolver queried %q, want prefix %q -- DNS did not ride the v6 tunnel", queried, name)
+		}
+	case <-time.After(egressTestDialTimeout):
+		t.Fatal("no DNS query reached the in-tunnel v6 resolver")
+	}
+
+	reply := make([]byte, 10)
+	ctl.SetDeadline(time.Now().Add(egressTestDialTimeout))
+	if _, err := io.ReadFull(ctl, reply); err != nil {
+		t.Fatalf("read reply: %v", err)
+	}
+	if reply[1] == 0x00 {
+		t.Fatal("unexpected success: nothing should be listening on the resolved address")
+	}
+}
+
+func TestEgressUDPDatagramDomainForwardedIntoTunnel(t *testing.T) {
+	tun := newPairedTunnelForTest(t)
+	registerEgressDeviceForTest(t, tun.client)
+	gotQuery := tun.overrideDNS(t, tun.serverIP)
+
+	in, err := gonet.DialUDP(tun.server.Stack, &tcpip.FullAddress{NIC: 1, Port: 9999}, nil, ipv4.ProtocolNumber)
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer in.Close()
+
+	ctl, err := (&net.Dialer{Timeout: egressTestDialTimeout}).Dial("tcp", net.JoinHostPort("127.0.0.1", strconv.Itoa(EgressBasePort)))
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer ctl.Close()
+
+	socksAuth(t, ctl)
+	if _, err := ctl.Write([]byte{0x05, 0x03, 0x00, 0x01, 0, 0, 0, 0, 0, 0}); err != nil {
+		t.Fatal(err)
+	}
+	reply := make([]byte, 10)
+	if _, err := io.ReadFull(ctl, reply); err != nil || reply[1] != 0x00 {
+		t.Fatalf("associate failed: %v %v", err, reply)
+	}
+	bindPort := binary.BigEndian.Uint16(reply[8:10])
+
+	udp, err := net.DialUDP("udp", nil, &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: int(bindPort)})
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer udp.Close()
+	udp.SetDeadline(time.Now().Add(egressTestDialTimeout))
+
+	// Plain-IP control datagram isolates domain parsing from transport.
+	// Retried to avoid warmup race on slow -race runners.
+	ctrl := []byte{0x00, 0x00, 0x00, 0x01, 10, 203, 0, 1, 0x27, 0x0f, 'c', 't', 'r', 'l'}
+	rcv := make([]byte, 64)
+	var nr int
+	var rerr error
+	for attempt := 0; attempt < 3; attempt++ {
+		if _, err := udp.Write(ctrl); err != nil {
+			t.Fatal(err)
+		}
+		in.SetReadDeadline(time.Now().Add(3 * time.Second))
+		nr, _, rerr = in.ReadFrom(rcv)
+		if rerr == nil {
+			break
+		}
+	}
+	if rerr != nil {
+		t.Fatalf("CONTROL datagram never reached the tunnel target: %v", rerr)
+	}
+	if string(rcv[:nr]) != "ctrl" {
+		t.Fatalf("control payload = %q", rcv[:nr])
+	}
+
+	name := "quic.internal"
+	dgram := make([]byte, 0, 5+len(name)+2+4)
+	dgram = append(dgram, 0x00, 0x00, 0x00, 0x03, byte(len(name)))
+	dgram = append(dgram, name...)
+	dgram = append(dgram, 0x27, 0x0f)
+	dgram = append(dgram, 'p', 'i', 'n', 'g')
+
+	var queried string
+	for attempt := 0; attempt < 3 && queried == ""; attempt++ {
+		if _, err := udp.Write(dgram); err != nil {
+			t.Fatal(err)
+		}
+		select {
+		case q := <-gotQuery:
+			queried = q
+		case <-time.After(1500 * time.Millisecond):
+		}
+	}
+	if len(queried) < len(name) || queried[:len(name)] != name {
+		t.Fatalf("resolver queried %q, want prefix %q -- DNS did not ride the tunnel", queried, name)
+	}
+
+	in.SetReadDeadline(time.Now().Add(3 * time.Second))
+	nr, _, rerr = in.ReadFrom(rcv)
+	if rerr != nil {
+		t.Fatalf("domain datagram never reached the tunnel target: %v", rerr)
+	}
+	if nr < 4 || string(rcv[:4]) != "ping" {
+		t.Fatalf("payload = %q (n=%d)", rcv[:nr], nr)
+	}
+}
+
+// TestEgressUDPDatagramDomainInterleavedClients ensures datagrams pass client
+// address by value into resolver goroutines so responses route correctly.
+func TestEgressUDPDatagramDomainInterleavedClients(t *testing.T) {
+	tun := newPairedTunnelForTest(t)
+	registerEgressDeviceForTest(t, tun.client)
+	gotQuery := tun.overrideDNS(t, tun.serverIP)
+
+	in, err := gonet.DialUDP(tun.server.Stack, &tcpip.FullAddress{NIC: 1, Port: 9999}, nil, ipv4.ProtocolNumber)
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer in.Close()
+
+	go func() {
+		buf := make([]byte, 512)
+		for {
+			n, from, rerr := in.ReadFrom(buf)
+			if rerr != nil {
+				return
+			}
+			_, _ = in.WriteTo(append([]byte("echo:"), buf[:n]...), from)
+		}
+	}()
+
+	dialUDP := func() *net.UDPConn {
+		ctl, err := (&net.Dialer{Timeout: egressTestDialTimeout}).Dial("tcp", net.JoinHostPort("127.0.0.1", strconv.Itoa(EgressBasePort)))
+		if err != nil {
+			t.Fatal(err)
+		}
+		t.Cleanup(func() { ctl.Close() })
+		socksAuth(t, ctl)
+		if _, err := ctl.Write([]byte{0x05, 0x03, 0x00, 0x01, 0, 0, 0, 0, 0, 0}); err != nil {
+			t.Fatal(err)
+		}
+		reply := make([]byte, 10)
+		if _, err := io.ReadFull(ctl, reply); err != nil || reply[1] != 0x00 {
+			t.Fatalf("associate failed: %v %v", err, reply)
+		}
+		bindPort := binary.BigEndian.Uint16(reply[8:10])
+		udp, err := net.DialUDP("udp", nil, &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: int(bindPort)})
+		if err != nil {
+			t.Fatal(err)
+		}
+		t.Cleanup(func() { udp.Close() })
+		udp.SetDeadline(time.Now().Add(egressTestDialTimeout))
+		return udp
+	}
+
+	name := func(i int) string { return fmt.Sprintf("interleaved-%d.internal", i) }
+	dgram := func(i int, payload string) []byte {
+		n := name(i)
+		d := make([]byte, 0, 5+len(n)+2+len(payload))
+		d = append(d, 0x00, 0x00, 0x00, 0x03, byte(len(n)))
+		d = append(d, n...)
+		d = append(d, 0x27, 0x0f)
+		return append(d, payload...)
+	}
+
+	for seq := 0; seq < 4; seq++ {
+		udp := dialUDP()
+		payload := fmt.Sprintf("p-%d", seq)
+		if _, err := udp.Write(dgram(seq, payload)); err != nil {
+			t.Fatal(err)
+		}
+		select {
+		case q := <-gotQuery:
+			if !strings.HasPrefix(q, "interleaved-") {
+				t.Fatalf("resolver queried %q, want an interleaved-* name", q)
+			}
+		case <-time.After(4 * time.Second):
+			t.Fatalf("query %d not observed", seq)
+		}
+		rcv := make([]byte, 512)
+		nr, _, rerr := udp.ReadFrom(rcv)
+		if rerr != nil {
+			t.Fatalf("reply %d never reached client: %v", seq, rerr)
+		}
+		if nr < 10 || !strings.Contains(string(rcv[:nr]), "echo:"+payload) {
+			t.Fatalf("reply payload = %q, want echo:%s", rcv[:nr], payload)
+		}
+	}
+}
+
+func TestDefaultDNSFor(t *testing.T) {
+	v4 := netip.MustParseAddr("10.8.0.2")
+	v6 := netip.MustParseAddr("2001:db8::2")
+
+	if got := defaultDNSFor([]netip.Addr{v4}); got != DefaultTunnelDNSServer {
+		t.Errorf("defaultDNSFor(v4) = %q, want %q", got, DefaultTunnelDNSServer)
+	}
+	if got := defaultDNSFor([]netip.Addr{v4, v6}); got != DefaultTunnelDNSServer {
+		t.Errorf("defaultDNSFor(dual) = %q, want %q", got, DefaultTunnelDNSServer)
+	}
+	if got := defaultDNSFor([]netip.Addr{v6}); got != DefaultTunnelDNSServerV6 {
+		t.Errorf("defaultDNSFor(v6-only) = %q, want %q", got, DefaultTunnelDNSServerV6)
+	}
+	if got := defaultDNSFor(nil); got != DefaultTunnelDNSServer {
+		t.Errorf("defaultDNSFor(nil) = %q, want %q", got, DefaultTunnelDNSServer)
+	}
+}
+
+func TestParseDatagramDomainHeader(t *testing.T) {
+	hdr := []byte{0, 0, 0, 0x03, 4, 'a', 'b', '.', 'd', 0x00, 0x35, 'x'}
+	name, port, hdrLen, err := parseDatagramDomainHeader(hdr)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if name != "ab.d" || port != 53 || hdrLen != 11 {
+		t.Fatalf("name=%q port=%d hdrLen=%d", name, port, hdrLen)
+	}
+	truncated := []byte{0, 0, 0, 0x03, 200, 'a'}
+	if _, _, _, err := parseDatagramDomainHeader(truncated); err == nil {
+		t.Fatal("truncated domain accepted")
+	}
+	empty := []byte{0, 0, 0, 0x03, 0, 0x00, 0x35}
+	if _, _, _, err := parseDatagramDomainHeader(empty); err == nil {
+		t.Fatal("empty domain accepted")
+	}
+}
+
+func TestReadSocksRequestTargetKeepsHostnameUnresolved(t *testing.T) {
+	payload := append([]byte{byte(len("invalid."))}, []byte("invalid.")...)
+	payload = append(payload, 0x01, 0xbb)
+	tr, err := readSocksRequestTarget(bytes.NewReader(payload), 0x03)
+	if err != nil {
+		t.Fatalf("domain request rejected: %v", err)
+	}
+	if tr.host != "invalid." || tr.port != 443 || tr.ip.IsValid() {
+		t.Fatalf("target = %+v", tr)
+	}
+}
+
+func TestTunnelDNSCache_ScopedPerTagAndServer(t *testing.T) {
+	resetTunnelDNSCacheForTest()
+	tagA, tagB := "out-a", "out-b"
+	dns1, dns2 := "1.1.1.1:53", "8.8.8.8:53"
+	host := "example.com"
+
+	addrA := netip.MustParseAddr("10.0.0.1")
+	addrB := netip.MustParseAddr("10.0.0.2")
+
+	keyA := dnsCacheKey(tagA, dns1, host)
+	keyB := dnsCacheKey(tagB, dns1, host)
+	keyA2 := dnsCacheKey(tagA, dns2, host)
+
+	tunnelDNSCache.mu.Lock()
+	tunnelDNSCache.m[keyA] = tunnelDNSCacheEntry{addr: addrA, exp: time.Now().Add(time.Hour)}
+	tunnelDNSCache.m[keyB] = tunnelDNSCacheEntry{addr: addrB, exp: time.Now().Add(time.Hour)}
+	tunnelDNSCache.mu.Unlock()
+
+	tunnelDNSCache.mu.Lock()
+	eA, okA := tunnelDNSCache.m[keyA]
+	eB, okB := tunnelDNSCache.m[keyB]
+	_, okA2 := tunnelDNSCache.m[keyA2]
+	tunnelDNSCache.mu.Unlock()
+
+	if !okA || eA.addr != addrA {
+		t.Fatalf("tagA cache entry mismatch: %v, %v", okA, eA)
+	}
+	if !okB || eB.addr != addrB {
+		t.Fatalf("tagB cache entry mismatch: %v, %v", okB, eB)
+	}
+	if okA2 {
+		t.Fatal("key with different DNS server should not match")
+	}
+
+	flushTunnelDNSCacheForTag(tagA)
+	tunnelDNSCache.mu.Lock()
+	_, okAAfter := tunnelDNSCache.m[keyA]
+	_, okBAfter := tunnelDNSCache.m[keyB]
+	tunnelDNSCache.mu.Unlock()
+
+	if okAAfter {
+		t.Fatal("tagA entry should be flushed")
+	}
+	if !okBAfter {
+		t.Fatal("tagB entry should survive flush of tagA")
+	}
+}

+ 194 - 0
internal/amneziawgnet/outbound_manager.go

@@ -0,0 +1,194 @@
+package amneziawgnet
+
+import (
+	"fmt"
+	"net/netip"
+	"strings"
+	"sync"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
+	"github.com/mhsanaei/3x-ui/v3/internal/logger"
+)
+
+// OutboundDesired pairs an instance with inbound-path DeviceOptions; AWG
+// parameters must be identical on both ends of a tunnel.
+type OutboundDesired struct {
+	Instance amneziawg.OutboundInstance
+	Options  DeviceOptions
+}
+
+// managedOutbound is one running interface plus its rendered UAPI config
+// (no-op/reconfigure decision) and an address/MTU fingerprint.
+type managedOutbound struct {
+	dev        *Device
+	uapiConfig string
+	structFP   string
+}
+
+// OutboundManager owns the running AmneziaWG client interfaces keyed by tag,
+// keeping the egress registry and listener current; callers just Reconcile.
+type OutboundManager struct {
+	mu    sync.Mutex
+	iface map[string]*managedOutbound
+}
+
+var (
+	outboundManagerOnce sync.Once
+	outboundManager     *OutboundManager
+)
+
+// GetOutboundManager returns the process-wide outbound manager singleton.
+func GetOutboundManager() *OutboundManager {
+	outboundManagerOnce.Do(func() {
+		outboundManager = &OutboundManager{iface: map[string]*managedOutbound{}}
+	})
+	return outboundManager
+}
+
+// outboundFingerprint captures what IpcSet can't change on a running Device,
+// fixed when the netstack is built: address, and the S4-derived effective MTU.
+func outboundFingerprint(inst amneziawg.OutboundInstance) string {
+	return fmt.Sprintf("%d|%s",
+		amneziawg.EffectiveMTU(inst.MTU, inst.Obfuscation.S4),
+		strings.Join(inst.Address, ","))
+}
+
+// normalizeDNSServer normalizes a configured DNS server to host:port.
+func normalizeDNSServer(s string) string {
+	s = strings.TrimSpace(s)
+	if s == "" {
+		return ""
+	}
+	if addr, err := netip.ParseAddr(s); err == nil {
+		return netip.AddrPortFrom(addr, 53).String()
+	}
+	if ap, err := netip.ParseAddrPort(s); err == nil {
+		return ap.String()
+	}
+	return s
+}
+
+// Reconcile converges devices to desired and stops removed tags; per-tick
+// contract of Manager.Reconcile -- errors log, never abort the batch.
+func (m *OutboundManager) Reconcile(desired []OutboundDesired) {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+
+	// Empty desired converges to "no tunnels": close egress listener so
+	// 127.0.0.1:64900 stays free on installs without AWG outbounds.
+	if len(desired) == 0 {
+		for tag, cur := range m.iface {
+			cur.dev.Close()
+			GetEgressServer().DeleteStack(tag)
+			delete(m.iface, tag)
+			logger.Infof("amneziawgnet: stopped embedded outbound %q", tag)
+		}
+		GetEgressServer().Close()
+		return
+	}
+
+	if err := GetEgressServer().Listen(); err != nil {
+		logger.Warningf("amneziawgnet: egress listener unavailable: %v", err)
+	}
+
+	want := make(map[string]struct{}, len(desired))
+	for _, d := range desired {
+		want[d.Instance.Tag] = struct{}{}
+	}
+	for tag, cur := range m.iface {
+		if _, ok := want[tag]; ok {
+			continue
+		}
+		cur.dev.Close()
+		GetEgressServer().DeleteStack(tag)
+		delete(m.iface, tag)
+		logger.Infof("amneziawgnet: stopped embedded outbound %q", tag)
+	}
+
+	for _, d := range desired {
+		if err := m.ensureLocked(d); err != nil {
+			logger.Warningf("amneziawgnet: reconcile failed for outbound %q: %v", d.Instance.Tag, err)
+		}
+	}
+}
+
+// ensureLocked picks no-op / reconfigure-in-place / rebuild for one desired
+// outbound (address/MTU are fixed at netstack build time).
+func (m *OutboundManager) ensureLocked(d OutboundDesired) error {
+	inst, opts := d.Instance, d.Options
+	if opts.Logger == nil {
+		opts.Logger = verboseLoggerIfEnabled(0)
+	}
+
+	fp := outboundFingerprint(inst)
+	conf, err := buildClientUAPIConfig(inst, opts)
+	if err != nil {
+		return fmt.Errorf("render UAPI config: %w", err)
+	}
+
+	cur, exists := m.iface[inst.Tag]
+	if exists && cur.structFP == fp {
+		if conf == cur.uapiConfig {
+			GetEgressServer().SetStack(inst.Tag, cur.dev, inst.DNS)
+			return nil
+		}
+		if err := cur.dev.IpcSet(conf); err != nil {
+			return fmt.Errorf("reconfigure outbound %q: %w", inst.Tag, err)
+		}
+		cur.uapiConfig = conf
+		GetEgressServer().SetStack(inst.Tag, cur.dev, inst.DNS)
+		return nil
+	}
+
+	if exists {
+		cur.dev.Close()
+		// A failed rebuild must not leave stackFor handing out a closed device.
+		GetEgressServer().DeleteStack(inst.Tag)
+		delete(m.iface, inst.Tag)
+	}
+	dev, err := newUnconfiguredClientDevice(inst, opts)
+	if err != nil {
+		return err
+	}
+	if err := dev.ConfigureClient(inst, opts); err != nil {
+		return err
+	}
+	m.iface[inst.Tag] = &managedOutbound{dev: dev, uapiConfig: conf, structFP: fp}
+	GetEgressServer().SetStack(inst.Tag, dev, inst.DNS)
+	logger.Infof("amneziawgnet: started embedded outbound %s (%d peers)", inst.Tag, len(inst.Peers))
+	return nil
+}
+
+// Remove tears down one outbound's device by tag.
+func (m *OutboundManager) Remove(tag string) {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+	cur, exists := m.iface[tag]
+	if !exists {
+		return
+	}
+	cur.dev.Close()
+	GetEgressServer().DeleteStack(tag)
+	delete(m.iface, tag)
+	logger.Infof("amneziawgnet: stopped embedded outbound %q", tag)
+}
+
+// StopAll tears down every managed outbound device and the egress listener;
+// m.mu stays held across Close so a cron tick cannot re-bind mid-teardown.
+func (m *OutboundManager) StopAll() {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+	for tag, cur := range m.iface {
+		cur.dev.Close()
+		GetEgressServer().DeleteStack(tag)
+		delete(m.iface, tag)
+	}
+	GetEgressServer().Close()
+}
+
+// HasRunning reports whether any outbound device is currently managed.
+func (m *OutboundManager) HasRunning() bool {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+	return len(m.iface) > 0
+}

+ 145 - 0
internal/amneziawgnet/outbound_manager_test.go

@@ -0,0 +1,145 @@
+package amneziawgnet
+
+import (
+	"net"
+	"sync"
+	"testing"
+	"time"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
+	"github.com/mhsanaei/3x-ui/v3/internal/util/wireguard"
+)
+
+// egressPortBound reports whether 127.0.0.1:<EgressBasePort> accepts TCP.
+func egressPortBound(t *testing.T) bool {
+	t.Helper()
+	conn, err := net.DialTimeout("tcp", net.JoinHostPort("127.0.0.1", itoa(int(EgressBasePort))), 500*time.Millisecond)
+	if err != nil {
+		return false
+	}
+	conn.Close()
+	return true
+}
+
+func itoa(n int) string {
+	if n == 0 {
+		return "0"
+	}
+	var b [8]byte
+	i := len(b)
+	for n > 0 {
+		i--
+		b[i] = byte('0' + n%10)
+		n /= 10
+	}
+	return string(b[i:])
+}
+
+// newTestOutboundDesired builds one runnable outbound desired state.
+func newTestOutboundDesired(t *testing.T, tag string) OutboundDesired {
+	t.Helper()
+	priv, _, err := wireguard.GenerateWireguardKeypair()
+	if err != nil {
+		t.Fatalf("generate keypair: %v", err)
+	}
+	return OutboundDesired{
+		Instance: amneziawg.OutboundInstance{
+			Tag:        tag,
+			Address:    []string{"10.204.0.1/24"},
+			MTU:        1420,
+			PrivateKey: priv,
+			ListenPort: 0,
+		},
+	}
+}
+
+// TestOutboundManagerReconcileEmptyDesiredClosesEgress verifies that an empty
+// desired set tears down interfaces and releases 127.0.0.1:64900.
+func TestOutboundManagerReconcileEmptyDesiredClosesEgress(t *testing.T) {
+	m := &OutboundManager{iface: map[string]*managedOutbound{}}
+	defer m.Reconcile(nil)
+
+	// Other tests in this package may leave the process-wide egress
+	// singleton bound; converge to a known-free state before pinning.
+	GetEgressServer().Close()
+	if egressPortBound(t) {
+		t.Fatal("egress port still bound after Close; Close() failed to release it")
+	}
+
+	// Non-empty: listener must come up.
+	d := newTestOutboundDesired(t, "t1")
+	m.Reconcile([]OutboundDesired{d})
+	if !egressPortBound(t) {
+		t.Fatal("egress port not bound after Reconcile with a desired outbound")
+	}
+
+	// Empty: listener must be released so other listeners can take the port.
+	m.Reconcile(nil)
+	if egressPortBound(t) {
+		t.Fatal("egress port still bound after Reconcile(nil)")
+	}
+	ln, err := net.Listen("tcp", net.JoinHostPort("127.0.0.1", itoa(int(EgressBasePort))))
+	if err != nil {
+		t.Fatalf("egress port must be free after Reconcile(nil): %v", err)
+	}
+	ln.Close()
+
+	// Back to non-empty and empty again: Close/Listen must be repeatable.
+	m.Reconcile([]OutboundDesired{d})
+	if !egressPortBound(t) {
+		t.Fatal("egress port not re-bound after a second non-empty Reconcile")
+	}
+	m.Reconcile(nil)
+	if egressPortBound(t) {
+		t.Fatal("egress port still bound after a second Reconcile(nil)")
+	}
+}
+
+// TestEgressServerCloseDuringConcurrentAccepts ensures Close during
+// concurrent accepts shuts down cleanly without hanging wg.Wait().
+func TestEgressServerCloseDuringConcurrentAccepts(t *testing.T) {
+	srv := GetEgressServer()
+	if err := srv.Listen(); err != nil {
+		t.Fatal(err)
+	}
+
+	stop := make(chan struct{})
+	done := make(chan struct{})
+	var clientWg sync.WaitGroup
+	go func() {
+		defer close(done)
+		for {
+			select {
+			case <-stop:
+				return
+			default:
+				c, err := net.DialTimeout("tcp", net.JoinHostPort("127.0.0.1", itoa(int(EgressBasePort))), 50*time.Millisecond)
+				if err == nil {
+					clientWg.Add(1)
+					go func(conn net.Conn) {
+						defer clientWg.Done()
+						time.Sleep(20 * time.Millisecond)
+						conn.Close()
+					}(c)
+				}
+				time.Sleep(2 * time.Millisecond)
+			}
+		}
+	}()
+
+	time.Sleep(20 * time.Millisecond)
+	closeChan := make(chan struct{})
+	go func() {
+		srv.Close()
+		close(closeChan)
+	}()
+
+	select {
+	case <-closeChan:
+	case <-time.After(3 * time.Second):
+		t.Fatal("srv.Close() hung waiting for connection handlers to exit")
+	}
+	close(stop)
+	<-done
+	clientWg.Wait()
+}

+ 67 - 0
internal/amneziawgnet/resolving_bind.go

@@ -0,0 +1,67 @@
+package amneziawgnet
+
+import (
+	"context"
+	"fmt"
+	"net"
+	"net/netip"
+	"strconv"
+	"strings"
+	"time"
+
+	awgconn "github.com/amnezia-vpn/amneziawg-go/v3/conn"
+)
+
+// endpointResolveTimeout bounds the one-time DNS lookup in ParseEndpoint.
+const endpointResolveTimeout = 5 * time.Second
+
+// resolvingBind lets peer endpoints be hostnames: StdNetBind has no DNS and
+// an unresolved name kills the whole IpcSet. Resolved once at configure.
+type resolvingBind struct {
+	awgconn.Bind
+}
+
+var lookupEndpointHost = defaultLookupEndpointHost
+
+func defaultLookupEndpointHost(ctx context.Context, host string) ([]netip.Addr, error) {
+	addrs, err := net.DefaultResolver.LookupNetIP(ctx, "ip", host)
+	if err != nil {
+		return nil, err
+	}
+	out := make([]netip.Addr, 0, len(addrs))
+	for _, a := range addrs {
+		out = append(out, a.Unmap())
+	}
+	return out, nil
+}
+
+func newResolvingBind() *resolvingBind {
+	return &resolvingBind{Bind: awgconn.NewDefaultBind()}
+}
+
+// ParseEndpoint resolves hostnames before handing the address to amneziawg-go
+// (whose own implementation accepts literal IPs only).
+func (b *resolvingBind) ParseEndpoint(s string) (awgconn.Endpoint, error) {
+	host, portStr, err := net.SplitHostPort(strings.TrimSpace(s))
+	if err != nil {
+		return nil, fmt.Errorf("endpoint %q: %w", s, err)
+	}
+	port64, err := strconv.ParseUint(portStr, 10, 16)
+	if err != nil || port64 == 0 {
+		return nil, fmt.Errorf("endpoint %q: bad port", s)
+	}
+	addr, err := netip.ParseAddr(host)
+	if err != nil {
+		ctx, cancel := context.WithTimeout(context.Background(), endpointResolveTimeout)
+		defer cancel()
+		addrs, rerr := lookupEndpointHost(ctx, host)
+		if rerr != nil {
+			return nil, fmt.Errorf("endpoint %q: resolve host: %w", s, rerr)
+		}
+		if len(addrs) == 0 {
+			return nil, fmt.Errorf("endpoint %q: host resolved to no addresses", s)
+		}
+		addr = addrs[0]
+	}
+	return &awgconn.StdNetEndpoint{AddrPort: netip.AddrPortFrom(addr.Unmap(), uint16(port64))}, nil
+}

+ 70 - 0
internal/amneziawgnet/resolving_bind_test.go

@@ -0,0 +1,70 @@
+package amneziawgnet
+
+import (
+	"context"
+	"errors"
+	"net/netip"
+	"testing"
+
+	awgconn "github.com/amnezia-vpn/amneziawg-go/v3/conn"
+)
+
+func endpointAddrPort(ep awgconn.Endpoint) netip.AddrPort {
+	std, ok := ep.(*awgconn.StdNetEndpoint)
+	if !ok {
+		panic("unexpected endpoint type")
+	}
+	return std.AddrPort
+}
+
+func TestResolvingBind_ParseEndpointIPLiteral(t *testing.T) {
+	b := newResolvingBind()
+	ep, err := b.ParseEndpoint("203.0.113.7:51820")
+	if err != nil {
+		t.Fatalf("IP endpoint rejected: %v", err)
+	}
+	got := endpointAddrPort(ep)
+	if got.Addr().String() != "203.0.113.7" || got.Port() != 51820 {
+		t.Fatalf("endpoint = %v, want 203.0.113.7:51820", got)
+	}
+}
+
+func TestResolvingBind_ParseEndpointHostnameResolves(t *testing.T) {
+	orig := lookupEndpointHost
+	lookupEndpointHost = func(ctx context.Context, host string) ([]netip.Addr, error) {
+		if host != "peer.example.test" {
+			t.Errorf("unexpected lookup host %q", host)
+		}
+		return []netip.Addr{netip.MustParseAddr("198.51.100.9")}, nil
+	}
+	defer func() { lookupEndpointHost = orig }()
+
+	b := newResolvingBind()
+	ep, err := b.ParseEndpoint("peer.example.test:443")
+	if err != nil {
+		t.Fatalf("hostname endpoint rejected: %v", err)
+	}
+	if got := endpointAddrPort(ep); got.Addr().String() != "198.51.100.9" || got.Port() != 443 {
+		t.Fatalf("endpoint = %v, want 198.51.100.9:443", got)
+	}
+}
+
+func TestResolvingBind_ParseEndpointResolveFailureIsAnError(t *testing.T) {
+	orig := lookupEndpointHost
+	lookupEndpointHost = func(ctx context.Context, host string) ([]netip.Addr, error) {
+		return nil, errors.New("no such host")
+	}
+	defer func() { lookupEndpointHost = orig }()
+
+	b := newResolvingBind()
+	if _, err := b.ParseEndpoint("missing.example.test:80"); err == nil {
+		t.Fatal("expected resolve failure to surface as an error")
+	}
+}
+
+func TestResolvingBind_ParseEndpointBadPortRejected(t *testing.T) {
+	b := newResolvingBind()
+	if _, err := b.ParseEndpoint("203.0.113.7:none"); err == nil {
+		t.Fatal("expected bad port to be rejected")
+	}
+}

+ 33 - 0
internal/amneziawgnet/socks_bridge.go

@@ -0,0 +1,33 @@
+package amneziawgnet
+
+import "encoding/json"
+
+// BuildSocksBridge swaps an "amneziawg" outbound for its loopback socks
+// form, preserving sibling keys; false = unbridgeable, fail loudly upstream.
+func BuildSocksBridge(raw []byte) ([]byte, bool) {
+	var ob map[string]any
+	if err := json.Unmarshal(raw, &ob); err != nil {
+		return nil, false
+	}
+	tag, _ := ob["tag"].(string)
+	if tag == "" {
+		return nil, false
+	}
+	settings := map[string]any{
+		"address": "127.0.0.1",
+		"port":    EgressBasePort,
+		"user":    tag,
+		"pass":    SocksPassword(),
+	}
+	bs, err := json.Marshal(settings)
+	if err != nil {
+		return nil, false
+	}
+	ob["protocol"] = "socks"
+	ob["settings"] = json.RawMessage(bs)
+	out, err := json.Marshal(ob)
+	if err != nil {
+		return nil, false
+	}
+	return out, true
+}

+ 52 - 0
internal/amneziawgnet/socks_bridge_test.go

@@ -0,0 +1,52 @@
+package amneziawgnet
+
+import (
+	"encoding/json"
+	"testing"
+)
+
+func TestBuildSocksBridge_BridgesAndPreservesSiblings(t *testing.T) {
+	raw := []byte(`{
+		"protocol": "amneziawg",
+		"tag": "awg-hop",
+		"sendThrough": "0.0.0.0",
+		"targetStrategy": {"strategy": "UseIPv4"},
+		"mux": {"enabled": false},
+		"streamSettings": {"sockopt": {"tcpFastOpen": true}},
+		"settings": {"secretKey": "x"}
+	}`)
+	out, ok := BuildSocksBridge(raw)
+	if !ok {
+		t.Fatal("valid entry rejected")
+	}
+	var got map[string]any
+	if err := json.Unmarshal(out, &got); err != nil {
+		t.Fatal(err)
+	}
+	if got["protocol"] != "socks" {
+		t.Fatalf("protocol = %v", got["protocol"])
+	}
+	if got["tag"] != "awg-hop" || got["sendThrough"] != "0.0.0.0" {
+		t.Fatalf("siblings dropped: %v", got)
+	}
+	if _, ok := got["targetStrategy"].(map[string]any); !ok {
+		t.Fatalf("targetStrategy dropped: %v", got["targetStrategy"])
+	}
+	settings, _ := got["settings"].(map[string]any)
+	if settings == nil || settings["user"] != "awg-hop" || settings["address"] != "127.0.0.1" {
+		t.Fatalf("bridge settings wrong: %v", settings)
+	}
+}
+
+func TestBuildSocksBridge_RejectsUnusableTags(t *testing.T) {
+	for name, raw := range map[string][]byte{
+		"missing tag":    []byte(`{"protocol":"amneziawg","settings":{}}`),
+		"empty tag":      []byte(`{"protocol":"amneziawg","tag":"","settings":{}}`),
+		"non-string tag": []byte(`{"protocol":"amneziawg","tag":123,"settings":{}}`),
+		"not an object":  []byte(`[1,2,3]`),
+	} {
+		if _, ok := BuildSocksBridge(raw); ok {
+			t.Fatalf("%s: expected rejection", name)
+		}
+	}
+}

+ 185 - 0
internal/sub/clash_service.go

@@ -4,12 +4,14 @@ import (
 	"errors"
 	"errors"
 	"fmt"
 	"fmt"
 	"maps"
 	"maps"
+	"net/netip"
 	"slices"
 	"slices"
 	"strings"
 	"strings"
 
 
 	"github.com/goccy/go-json"
 	"github.com/goccy/go-json"
 	yaml "github.com/goccy/go-yaml"
 	yaml "github.com/goccy/go-yaml"
 
 
+	"github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
 	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
 	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
 	wgutil "github.com/mhsanaei/3x-ui/v3/internal/util/wireguard"
 	wgutil "github.com/mhsanaei/3x-ui/v3/internal/util/wireguard"
 )
 )
@@ -285,6 +287,9 @@ func (s *SubClashService) buildProxy(subReq *SubService, inbound *model.Inbound,
 	if inbound.Protocol == model.WireGuard {
 	if inbound.Protocol == model.WireGuard {
 		return s.buildWireguardProxy(subReq, inbound, client, ep)
 		return s.buildWireguardProxy(subReq, inbound, client, ep)
 	}
 	}
+	if inbound.Protocol == model.AmneziaWG {
+		return s.buildAmneziaWGProxy(subReq, inbound, client, ep)
+	}
 
 
 	network, _ := stream["network"].(string)
 	network, _ := stream["network"].(string)
 
 
@@ -492,6 +497,186 @@ func (s *SubClashService) buildWireguardProxy(subReq *SubService, inbound *model
 	return proxy
 	return proxy
 }
 }
 
 
+// amneziaWGClientAddresses prefers this inbound's own settings entry over the
+// shared clients.wg_allowed_ips column, which for an identity attached to both
+// a wireguard and an amneziawg inbound holds the other one's address.
+func amneziaWGClientAddresses(settingsClients []model.Client, client model.Client) []string {
+	for i := range settingsClients {
+		if !strings.EqualFold(settingsClients[i].Email, client.Email) {
+			continue
+		}
+		if len(settingsClients[i].AllowedIPs) > 0 {
+			return settingsClients[i].AllowedIPs
+		}
+		break
+	}
+	return client.AllowedIPs
+}
+
+// allBareIPs reports whether every entry is a plain IP address — no port,
+// scheme, and no zone, which mihomo brackets into a udp:// URL it then rejects.
+func allBareIPs(servers []string) bool {
+	for _, s := range servers {
+		addr, err := netip.ParseAddr(s)
+		if err != nil || addr.Zone() != "" {
+			return false
+		}
+	}
+	return true
+}
+
+// buildAmneziaWGProxy emits a mihomo Clash entry for an AmneziaWG inbound:
+// type stays "wireguard", the obfuscation rides in amnezia-wg-option.
+func (s *SubClashService) buildAmneziaWGProxy(subReq *SubService, inbound *model.Inbound, client model.Client, ep map[string]any) map[string]any {
+	if client.PrivateKey == "" {
+		return nil
+	}
+
+	var parsed amneziawg.InboundSettings
+	if err := json.Unmarshal([]byte(inbound.Settings), &parsed); err != nil || parsed.Server == nil {
+		return nil
+	}
+	server := parsed.Server
+
+	proxy := map[string]any{
+		"name":        subReq.endpointRemark(inbound, client.Email, ep, ""),
+		"type":        "wireguard",
+		"server":      inbound.Listen,
+		"port":        inbound.Port,
+		"udp":         true,
+		"private-key": client.PrivateKey,
+	}
+
+	if server.PublicKey != "" {
+		proxy["public-key"] = server.PublicKey
+	}
+	if client.PreSharedKey != "" {
+		proxy["pre-shared-key"] = client.PreSharedKey
+	}
+	if client.KeepAlive > 0 {
+		proxy["persistent-keepalive"] = client.KeepAlive
+	}
+
+	for _, addr := range amneziaWGClientAddresses(parsed.Clients, client) {
+		ip := stripCIDR(addr)
+		if ip == "" {
+			continue
+		}
+		if strings.Contains(ip, ":") {
+			proxy["ipv6"] = ip
+		} else {
+			proxy["ip"] = ip
+		}
+	}
+
+	// Always emitted: mihomo's own 1408 default sits above the interface
+	// amneziawgnet actually runs once s4 passes 12, so the tunnel fragments.
+	proxy["mtu"] = amneziawg.EffectiveMTU(server.MTU, server.S4)
+
+	var dns []string
+	if server.PrimaryDNS != "" {
+		dns = append(dns, server.PrimaryDNS)
+	}
+	if server.SecondaryDNS != "" {
+		dns = append(dns, server.SecondaryDNS)
+	}
+	if len(dns) > 0 {
+		proxy["dns"] = dns
+		// mihomo ignores dns without this flag, but aborts the whole config on
+		// a value its dns.ParseNameServer rejects, so only bare IPs opt in.
+		if allBareIPs(dns) {
+			proxy["remote-dns-resolve"] = true
+		}
+	}
+
+	awg := map[string]any{}
+	if server.Jc != 0 {
+		awg["jc"] = server.Jc
+	}
+	if server.Jmin != 0 {
+		awg["jmin"] = server.Jmin
+	}
+	if server.Jmax != 0 {
+		awg["jmax"] = server.Jmax
+	}
+	if server.S1 != 0 {
+		awg["s1"] = server.S1
+	}
+	if server.S2 != 0 {
+		awg["s2"] = server.S2
+	}
+	if server.S3 != 0 {
+		awg["s3"] = server.S3
+	}
+	if server.S4 != 0 {
+		awg["s4"] = server.S4
+	}
+	if server.H1 != "" {
+		awg["h1"] = server.H1
+	}
+	if server.H2 != "" {
+		awg["h2"] = server.H2
+	}
+	if server.H3 != "" {
+		awg["h3"] = server.H3
+	}
+	if server.H4 != "" {
+		awg["h4"] = server.H4
+	}
+	for i, v := range []string{server.I1, server.I2, server.I3, server.I4, server.I5} {
+		if v != "" {
+			awg[fmt.Sprintf("i%d", i+1)] = v
+		}
+	}
+
+	needsV3 := false
+	if server.HeaderProtectionKey != "" {
+		awg["header-protection-key"] = server.HeaderProtectionKey
+		needsV3 = true
+	}
+	if server.ContentPaddingAddition != "" {
+		awg["content-padding-addition"] = server.ContentPaddingAddition
+		needsV3 = true
+	}
+	if server.RekeyAfterTime != "" {
+		awg["rekey-after-time"] = server.RekeyAfterTime
+		needsV3 = true
+	}
+	if server.RekeyTimeout != "" {
+		awg["rekey-timeout"] = server.RekeyTimeout
+		needsV3 = true
+	}
+	if server.RejectAfterTime != "" {
+		awg["reject-after-time"] = server.RejectAfterTime
+		needsV3 = true
+	}
+	if server.KeepaliveTimeout != "" {
+		awg["keepalive-timeout"] = server.KeepaliveTimeout
+		needsV3 = true
+	}
+	if server.MaxHandshakeAttempts != "" {
+		awg["max-handshake-attempts"] = server.MaxHandshakeAttempts
+		needsV3 = true
+	}
+	if server.RandomTrailers {
+		awg["random-trailers"] = true
+		needsV3 = true
+	}
+	if server.DisableCookies {
+		awg["disable-cookies"] = true
+		needsV3 = true
+	}
+	if needsV3 {
+		awg["version"] = 3
+	}
+
+	if len(awg) > 0 {
+		proxy["amnezia-wg-option"] = awg
+	}
+
+	return proxy
+}
+
 // buildXhttpClashOpts converts xhttpSettings from 3x-ui's camelCase JSON
 // buildXhttpClashOpts converts xhttpSettings from 3x-ui's camelCase JSON
 // storage into the kebab-case map that Mihomo expects under xhttp-opts.
 // storage into the kebab-case map that Mihomo expects under xhttp-opts.
 //
 //

+ 399 - 0
internal/sub/clash_service_test.go

@@ -1,9 +1,11 @@
 package sub
 package sub
 
 
 import (
 import (
+	"fmt"
 	"reflect"
 	"reflect"
 	"testing"
 	"testing"
 
 
+	"github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
 	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
 	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
 	wgutil "github.com/mhsanaei/3x-ui/v3/internal/util/wireguard"
 	wgutil "github.com/mhsanaei/3x-ui/v3/internal/util/wireguard"
 )
 )
@@ -884,6 +886,353 @@ func TestBuildWireguardProxyForClashNoKey(t *testing.T) {
 	}
 	}
 }
 }
 
 
+func TestBuildAmneziaWGProxyForClash(t *testing.T) {
+	serverPriv, serverPub, err := wgutil.GenerateWireguardKeypair()
+	if err != nil {
+		t.Fatalf("server keypair: %v", err)
+	}
+	clientPriv, _, err := wgutil.GenerateWireguardKeypair()
+	if err != nil {
+		t.Fatalf("client keypair: %v", err)
+	}
+
+	settings := `{"server":{"privateKey":"` + serverPriv + `","publicKey":"` + serverPub + `","mtu":1420,"primaryDns":"8.8.8.8","secondaryDns":"8.8.4.4","jc":3,"jmin":66,"jmax":150,"s1":147,"s2":146,"s3":28,"s4":27,"h1":"364198942-470015235","h2":"1041963382-1068354159","h3":"1313106728-1361756201","h4":"1801896583-1875457201","i1":"10-20","i2":"30-40"}}`
+
+	svc := &SubClashService{SubService: &SubService{}}
+	inbound := &model.Inbound{
+		Listen:   "203.0.113.7",
+		Port:     51820,
+		Protocol: model.AmneziaWG,
+		Remark:   "amneziawg",
+		Settings: settings,
+	}
+	client := model.Client{
+		Email:        "user",
+		PrivateKey:   clientPriv,
+		PreSharedKey: "psk-value",
+		KeepAlive:    25,
+		AllowedIPs:   []string{"10.8.1.2/32", "fd00::2/128"},
+	}
+
+	proxy := svc.buildProxy(svc.SubService, inbound, client, nil, nil)
+	if proxy == nil {
+		t.Fatal("buildProxy returned nil for a valid amneziawg client")
+	}
+	if proxy["type"] != "wireguard" {
+		t.Fatalf("type = %v, want wireguard", proxy["type"])
+	}
+	if proxy["server"] != "203.0.113.7" {
+		t.Fatalf("server = %v, want 203.0.113.7", proxy["server"])
+	}
+	if proxy["port"] != 51820 {
+		t.Fatalf("port = %v, want 51820", proxy["port"])
+	}
+	if proxy["private-key"] != clientPriv {
+		t.Fatalf("private-key = %v, want %v", proxy["private-key"], clientPriv)
+	}
+	if proxy["public-key"] != serverPub {
+		t.Fatalf("public-key = %v, want %v", proxy["public-key"], serverPub)
+	}
+	if proxy["pre-shared-key"] != "psk-value" {
+		t.Fatalf("pre-shared-key = %v, want psk-value", proxy["pre-shared-key"])
+	}
+	if proxy["persistent-keepalive"] != 25 {
+		t.Fatalf("persistent-keepalive = %v, want 25", proxy["persistent-keepalive"])
+	}
+	if proxy["ip"] != "10.8.1.2" {
+		t.Fatalf("ip = %v, want 10.8.1.2", proxy["ip"])
+	}
+	if proxy["ipv6"] != "fd00::2" {
+		t.Fatalf("ipv6 = %v, want fd00::2", proxy["ipv6"])
+	}
+	if proxy["mtu"] != 1420 {
+		t.Fatalf("mtu = %v, want 1420", proxy["mtu"])
+	}
+	if proxy["udp"] != true {
+		t.Fatalf("udp = %v, want true", proxy["udp"])
+	}
+	if dns, ok := proxy["dns"].([]string); !ok || !reflect.DeepEqual(dns, []string{"8.8.8.8", "8.8.4.4"}) {
+		t.Fatalf("dns = %v, want [8.8.8.8 8.8.4.4]", proxy["dns"])
+	}
+
+	awg, ok := proxy["amnezia-wg-option"].(map[string]any)
+	if !ok {
+		t.Fatal("amnezia-wg-option missing")
+	}
+	if awg["jc"] != 3 {
+		t.Fatalf("jc = %v, want 3", awg["jc"])
+	}
+	if awg["jmin"] != 66 {
+		t.Fatalf("jmin = %v, want 66", awg["jmin"])
+	}
+	if awg["jmax"] != 150 {
+		t.Fatalf("jmax = %v, want 150", awg["jmax"])
+	}
+	if awg["s1"] != 147 {
+		t.Fatalf("s1 = %v, want 147", awg["s1"])
+	}
+	if awg["s2"] != 146 {
+		t.Fatalf("s2 = %v, want 146", awg["s2"])
+	}
+	if awg["s3"] != 28 {
+		t.Fatalf("s3 = %v, want 28", awg["s3"])
+	}
+	if awg["s4"] != 27 {
+		t.Fatalf("s4 = %v, want 27", awg["s4"])
+	}
+	if awg["h1"] != "364198942-470015235" {
+		t.Fatalf("h1 = %v, want 364198942-470015235", awg["h1"])
+	}
+	if awg["h2"] != "1041963382-1068354159" {
+		t.Fatalf("h2 = %v, want 1041963382-1068354159", awg["h2"])
+	}
+	if awg["h3"] != "1313106728-1361756201" {
+		t.Fatalf("h3 = %v, want 1313106728-1361756201", awg["h3"])
+	}
+	if awg["h4"] != "1801896583-1875457201" {
+		t.Fatalf("h4 = %v, want 1801896583-1875457201", awg["h4"])
+	}
+	if awg["i1"] != "10-20" {
+		t.Fatalf("i1 = %v, want 10-20", awg["i1"])
+	}
+	if awg["i2"] != "30-40" {
+		t.Fatalf("i2 = %v, want 30-40", awg["i2"])
+	}
+	// v1.0 fields must NOT set version
+	if _, ok := awg["version"]; ok {
+		t.Fatalf("version should not be set for v1.0 obfuscation fields")
+	}
+}
+
+func TestBuildAmneziaWGProxyForClashV3(t *testing.T) {
+	serverPriv, serverPub, err := wgutil.GenerateWireguardKeypair()
+	if err != nil {
+		t.Fatalf("server keypair: %v", err)
+	}
+	clientPriv, _, err := wgutil.GenerateWireguardKeypair()
+	if err != nil {
+		t.Fatalf("client keypair: %v", err)
+	}
+
+	settings := `{"server":{"privateKey":"` + serverPriv + `","publicKey":"` + serverPub + `","mtu":1280,"primaryDns":"1.1.1.1","jc":3,"jmin":66,"jmax":150,"s1":147,"s2":146,"s3":28,"s4":27,"h1":"364198942-470015235","h2":"1041963382-1068354159","h3":"1313106728-1361756201","h4":"1801896583-1875457201","headerProtectionKey":"DmVT7JtmJM8YoHiA2Wp3xPKI5dTXFx83y2JUQkKg1p8=","contentPaddingAddition":"9-31","rekeyAfterTime":"105-125","rekeyTimeout":"3-5","rejectAfterTime":"176-239","keepaliveTimeout":"11-16","maxHandshakeAttempts":"24-41","randomTrailers":true,"disableCookies":true}}`
+
+	svc := &SubClashService{SubService: &SubService{}}
+	inbound := &model.Inbound{
+		Listen:   "203.0.113.7",
+		Port:     51820,
+		Protocol: model.AmneziaWG,
+		Remark:   "amneziawg",
+		Settings: settings,
+	}
+	client := model.Client{
+		Email:      "user",
+		PrivateKey: clientPriv,
+		AllowedIPs: []string{"10.8.1.2/32"},
+	}
+
+	proxy := svc.buildProxy(svc.SubService, inbound, client, nil, nil)
+	if proxy == nil {
+		t.Fatal("buildProxy returned nil for a valid amneziawg v3 client")
+	}
+
+	awg, ok := proxy["amnezia-wg-option"].(map[string]any)
+	if !ok {
+		t.Fatal("amnezia-wg-option missing")
+	}
+	if awg["version"] != 3 {
+		t.Fatalf("version = %v, want 3", awg["version"])
+	}
+	if awg["header-protection-key"] != "DmVT7JtmJM8YoHiA2Wp3xPKI5dTXFx83y2JUQkKg1p8=" {
+		t.Fatalf("header-protection-key = %v", awg["header-protection-key"])
+	}
+	if awg["content-padding-addition"] != "9-31" {
+		t.Fatalf("content-padding-addition = %v", awg["content-padding-addition"])
+	}
+	if awg["rekey-after-time"] != "105-125" {
+		t.Fatalf("rekey-after-time = %v", awg["rekey-after-time"])
+	}
+	if awg["rekey-timeout"] != "3-5" {
+		t.Fatalf("rekey-timeout = %v", awg["rekey-timeout"])
+	}
+	if awg["reject-after-time"] != "176-239" {
+		t.Fatalf("reject-after-time = %v", awg["reject-after-time"])
+	}
+	if awg["keepalive-timeout"] != "11-16" {
+		t.Fatalf("keepalive-timeout = %v", awg["keepalive-timeout"])
+	}
+	if awg["max-handshake-attempts"] != "24-41" {
+		t.Fatalf("max-handshake-attempts = %v", awg["max-handshake-attempts"])
+	}
+	if awg["random-trailers"] != true {
+		t.Fatalf("random-trailers = %v, want true", awg["random-trailers"])
+	}
+	if awg["disable-cookies"] != true {
+		t.Fatalf("disable-cookies = %v, want true", awg["disable-cookies"])
+	}
+}
+
+func TestBuildAmneziaWGProxyForClashNoKey(t *testing.T) {
+	svc := &SubClashService{SubService: &SubService{}}
+	settings := `{"server":{"privateKey":"abc","publicKey":"def","jc":3,"jmin":66,"jmax":150}}`
+	inbound := &model.Inbound{
+		Listen:   "203.0.113.7",
+		Port:     51820,
+		Protocol: model.AmneziaWG,
+		Settings: settings,
+	}
+	client := model.Client{Email: "user"}
+
+	if proxy := svc.buildAmneziaWGProxy(svc.SubService, inbound, client, nil); proxy != nil {
+		t.Fatalf("buildAmneziaWGProxy = %v, want nil for a keyless amneziawg client", proxy)
+	}
+}
+
+// TestBuildAmneziaWGProxyForClashPerInboundAddress pins the tunnel address to
+// this inbound's own settings.clients[] entry, the one InstanceFromInbound
+// turns into the running peer's AllowedIPs. model.Client here is what
+// matchingClients hands buildProxy: the shared clients.wg_allowed_ips column,
+// which for an identity attached to both wireguard and amneziawg holds the
+// other protocol's address.
+func TestBuildAmneziaWGProxyForClashPerInboundAddress(t *testing.T) {
+	serverPriv, serverPub, err := wgutil.GenerateWireguardKeypair()
+	if err != nil {
+		t.Fatalf("server keypair: %v", err)
+	}
+	clientPriv, clientPub, err := wgutil.GenerateWireguardKeypair()
+	if err != nil {
+		t.Fatalf("client keypair: %v", err)
+	}
+
+	settings := `{"server":{"privateKey":"` + serverPriv + `","publicKey":"` + serverPub +
+		`","jc":3,"jmin":66,"jmax":150},"clients":[{"email":"dual@x","publicKey":"` + clientPub +
+		`","allowedIPs":["10.8.1.5/32","fd00::5/128"],"enable":true}]}`
+
+	svc := &SubClashService{SubService: &SubService{}}
+	inbound := &model.Inbound{
+		Listen:   "203.0.113.7",
+		Port:     51820,
+		Protocol: model.AmneziaWG,
+		Remark:   "amneziawg",
+		Settings: settings,
+	}
+	client := model.Client{
+		Email:      "dual@x",
+		PrivateKey: clientPriv,
+		AllowedIPs: []string{"10.0.0.5/32"},
+	}
+
+	proxy := svc.buildProxy(svc.SubService, inbound, client, nil, nil)
+	if proxy == nil {
+		t.Fatal("buildProxy returned nil for a valid amneziawg client")
+	}
+	if proxy["ip"] != "10.8.1.5" {
+		t.Fatalf("ip = %v, want 10.8.1.5 (this inbound's own address, not the shared column's 10.0.0.5)", proxy["ip"])
+	}
+	if proxy["ipv6"] != "fd00::5" {
+		t.Fatalf("ipv6 = %v, want fd00::5", proxy["ipv6"])
+	}
+}
+
+// TestBuildAmneziaWGProxyForClashFallsBackToClientAddress covers an inbound
+// whose settings.clients[] has no entry for this email: the shared column is
+// then the only address there is.
+func TestBuildAmneziaWGProxyForClashFallsBackToClientAddress(t *testing.T) {
+	serverPriv, serverPub, err := wgutil.GenerateWireguardKeypair()
+	if err != nil {
+		t.Fatalf("server keypair: %v", err)
+	}
+	clientPriv, _, err := wgutil.GenerateWireguardKeypair()
+	if err != nil {
+		t.Fatalf("client keypair: %v", err)
+	}
+
+	settings := `{"server":{"privateKey":"` + serverPriv + `","publicKey":"` + serverPub +
+		`","jc":3,"jmin":66,"jmax":150},"clients":[{"email":"someone-else@x","allowedIPs":["10.8.1.9/32"]}]}`
+
+	svc := &SubClashService{SubService: &SubService{}}
+	inbound := &model.Inbound{
+		Listen:   "203.0.113.7",
+		Port:     51820,
+		Protocol: model.AmneziaWG,
+		Settings: settings,
+	}
+	client := model.Client{Email: "user@x", PrivateKey: clientPriv, AllowedIPs: []string{"10.8.1.2/32"}}
+
+	proxy := svc.buildProxy(svc.SubService, inbound, client, nil, nil)
+	if proxy == nil {
+		t.Fatal("buildProxy returned nil for a valid amneziawg client")
+	}
+	if proxy["ip"] != "10.8.1.2" {
+		t.Fatalf("ip = %v, want 10.8.1.2", proxy["ip"])
+	}
+}
+
+// TestBuildAmneziaWGProxyForClashRemoteDNSResolve pins the flag mihomo gates
+// its `dns` list on, and the guard that keeps a non-IP entry from turning an
+// inert key into a whole-config parse abort.
+func TestBuildAmneziaWGProxyForClashRemoteDNSResolve(t *testing.T) {
+	serverPriv, serverPub, err := wgutil.GenerateWireguardKeypair()
+	if err != nil {
+		t.Fatalf("server keypair: %v", err)
+	}
+	clientPriv, _, err := wgutil.GenerateWireguardKeypair()
+	if err != nil {
+		t.Fatalf("client keypair: %v", err)
+	}
+
+	build := func(t *testing.T, primary, secondary string) map[string]any {
+		t.Helper()
+		settings := `{"server":{"privateKey":"` + serverPriv + `","publicKey":"` + serverPub +
+			`","primaryDns":"` + primary + `","secondaryDns":"` + secondary + `"}}`
+		svc := &SubClashService{SubService: &SubService{}}
+		inbound := &model.Inbound{
+			Listen:   "203.0.113.7",
+			Port:     51820,
+			Protocol: model.AmneziaWG,
+			Settings: settings,
+		}
+		client := model.Client{Email: "user", PrivateKey: clientPriv, AllowedIPs: []string{"10.8.1.2/32"}}
+		proxy := svc.buildProxy(svc.SubService, inbound, client, nil, nil)
+		if proxy == nil {
+			t.Fatal("buildProxy returned nil for a valid amneziawg client")
+		}
+		return proxy
+	}
+
+	t.Run("bare IPs", func(t *testing.T) {
+		proxy := build(t, "8.8.8.8", "fd00::1")
+		if proxy["remote-dns-resolve"] != true {
+			t.Fatalf("remote-dns-resolve = %v, want true: mihomo ignores dns without it", proxy["remote-dns-resolve"])
+		}
+	})
+
+	// netip.ParseAddr accepts a zone, but mihomo brackets the address into a
+	// udp:// URL whose url.Parse then rejects "%eth0" as a bad escape.
+	t.Run("zoned IPv6", func(t *testing.T) {
+		proxy := build(t, "8.8.8.8", "fe80::1%eth0")
+		if _, ok := proxy["remote-dns-resolve"]; ok {
+			t.Fatalf("remote-dns-resolve must stay unset for a zoned address, got %v", proxy["remote-dns-resolve"])
+		}
+	})
+
+	t.Run("non-IP entry", func(t *testing.T) {
+		proxy := build(t, "8.8.8.8", "dns.example.com")
+		if dns, ok := proxy["dns"].([]string); !ok || !reflect.DeepEqual(dns, []string{"8.8.8.8", "dns.example.com"}) {
+			t.Fatalf("dns = %v, want both entries kept", proxy["dns"])
+		}
+		if _, ok := proxy["remote-dns-resolve"]; ok {
+			t.Fatalf("remote-dns-resolve must stay unset when an entry is not a bare IP, got %v", proxy["remote-dns-resolve"])
+		}
+	})
+
+	t.Run("no DNS", func(t *testing.T) {
+		proxy := build(t, "", "")
+		if _, ok := proxy["remote-dns-resolve"]; ok {
+			t.Fatal("remote-dns-resolve must stay unset when there is no dns list")
+		}
+	})
+}
+
 // TestGetProxies_CustomIPv6ShareAddrIsUnbracketed pins that a Clash "server" is a
 // TestGetProxies_CustomIPv6ShareAddrIsUnbracketed pins that a Clash "server" is a
 // bare host: the custom share address stores IPv6 literals bracketed, and mihomo
 // bare host: the custom share address stores IPv6 literals bracketed, and mihomo
 // rejects "[2001:db8::1]" there.
 // rejects "[2001:db8::1]" there.
@@ -908,3 +1257,53 @@ func TestGetProxies_CustomIPv6ShareAddrIsUnbracketed(t *testing.T) {
 		t.Fatalf("server = %v, want 2001:db8::1", got)
 		t.Fatalf("server = %v, want 2001:db8::1", got)
 	}
 	}
 }
 }
+
+// TestBuildAmneziaWGProxyForClashEffectiveMTU pins the Clash mtu to the same
+// amneziawg.EffectiveMTU every other emitter uses -- the running interface
+// (amneziawgnet), the vpn:// .conf and both TS builders. Omitting the key
+// leaves mihomo on its own 1408 default, above the tunnel once s4 > 12.
+func TestBuildAmneziaWGProxyForClashEffectiveMTU(t *testing.T) {
+	serverPriv, serverPub, err := wgutil.GenerateWireguardKeypair()
+	if err != nil {
+		t.Fatalf("server keypair: %v", err)
+	}
+	clientPriv, _, err := wgutil.GenerateWireguardKeypair()
+	if err != nil {
+		t.Fatalf("client keypair: %v", err)
+	}
+
+	build := func(t *testing.T, mtu, s4 int) map[string]any {
+		t.Helper()
+		settings := fmt.Sprintf(
+			`{"server":{"privateKey":%q,"publicKey":%q,"mtu":%d,"s4":%d}}`,
+			serverPriv, serverPub, mtu, s4)
+		svc := &SubClashService{SubService: &SubService{}}
+		inbound := &model.Inbound{
+			Listen:   "203.0.113.7",
+			Port:     51820,
+			Protocol: model.AmneziaWG,
+			Settings: settings,
+		}
+		client := model.Client{Email: "user", PrivateKey: clientPriv, AllowedIPs: []string{"10.8.1.2/32"}}
+		proxy := svc.buildProxy(svc.SubService, inbound, client, nil, nil)
+		if proxy == nil {
+			t.Fatal("buildProxy returned nil for a valid amneziawg client")
+		}
+		return proxy
+	}
+
+	t.Run("unset MTU falls back to 1420-s4", func(t *testing.T) {
+		proxy := build(t, 0, 27)
+		want := amneziawg.EffectiveMTU(0, 27)
+		if proxy["mtu"] != want {
+			t.Fatalf("mtu = %v, want %d (amneziawg.EffectiveMTU)", proxy["mtu"], want)
+		}
+	})
+
+	t.Run("explicit MTU wins", func(t *testing.T) {
+		proxy := build(t, 1380, 27)
+		if proxy["mtu"] != 1380 {
+			t.Fatalf("mtu = %v, want 1380", proxy["mtu"])
+		}
+	})
+}

+ 43 - 17
internal/sub/controller.go

@@ -45,13 +45,15 @@ type cachedSubTemplate struct {
 
 
 // SUBController handles HTTP requests for subscription links and JSON configurations.
 // SUBController handles HTTP requests for subscription links and JSON configurations.
 type SUBController struct {
 type SUBController struct {
-	subTitle         string
-	subSupportUrl    string
-	subProfileUrl    string
-	subAnnounce      string
-	subEnableRouting bool
-	subRoutingRules  string
-	subHideSettings  bool
+	subTitle            string
+	subSupportUrl       string
+	subProfileUrl       string
+	subAnnounce         string
+	subEnableRouting    bool
+	subRoutingRules     string
+	subJsonRoutingRules string
+	subHideSettings     bool
+	happConfig          HappConfig
 
 
 	subIncyEnableRouting bool
 	subIncyEnableRouting bool
 	subIncyRoutingRules  string
 	subIncyRoutingRules  string
@@ -98,6 +100,7 @@ type subControllerConfig struct {
 
 
 	subJsonMux            string
 	subJsonMux            string
 	subJsonRules          string
 	subJsonRules          string
+	subJsonRoutingRules   string
 	subJsonFinalMask      string
 	subJsonFinalMask      string
 	subJsonObservatory    string
 	subJsonObservatory    string
 	subClashEnableRouting bool
 	subClashEnableRouting bool
@@ -110,6 +113,7 @@ type subControllerConfig struct {
 	subEnableRouting bool
 	subEnableRouting bool
 	subRoutingRules  string
 	subRoutingRules  string
 	subHideSettings  bool
 	subHideSettings  bool
+	happConfig       HappConfig
 
 
 	subIncyEnableRouting bool
 	subIncyEnableRouting bool
 	subIncyRoutingRules  string
 	subIncyRoutingRules  string
@@ -177,6 +181,10 @@ func WithSUBJsonRules(value string) SUBControllerOption {
 	return func(config *subControllerConfig) { config.subJsonRules = value }
 	return func(config *subControllerConfig) { config.subJsonRules = value }
 }
 }
 
 
+func WithSUBJsonRoutingRules(value string) SUBControllerOption {
+	return func(config *subControllerConfig) { config.subJsonRoutingRules = value }
+}
+
 func WithSUBJsonFinalMask(value string) SUBControllerOption {
 func WithSUBJsonFinalMask(value string) SUBControllerOption {
 	return func(config *subControllerConfig) { config.subJsonFinalMask = value }
 	return func(config *subControllerConfig) { config.subJsonFinalMask = value }
 }
 }
@@ -229,6 +237,10 @@ func WithSUBIncyRoutingRules(value string) SUBControllerOption {
 	return func(config *subControllerConfig) { config.subIncyRoutingRules = value }
 	return func(config *subControllerConfig) { config.subIncyRoutingRules = value }
 }
 }
 
 
+func WithSUBHappConfig(value HappConfig) SUBControllerOption {
+	return func(config *subControllerConfig) { config.happConfig = value }
+}
+
 func defaultSUBControllerConfig() subControllerConfig {
 func defaultSUBControllerConfig() subControllerConfig {
 	return subControllerConfig{
 	return subControllerConfig{
 		subPath:        "/sub/",
 		subPath:        "/sub/",
@@ -248,16 +260,18 @@ func NewSUBController(g *gin.RouterGroup, options ...SUBControllerOption) *SUBCo
 	}
 	}
 
 
 	sub := NewSubService(config.remarkTemplate)
 	sub := NewSubService(config.remarkTemplate)
-	subJsonSvc := NewSubJsonService(config.subJsonMux, config.subJsonRules, config.subJsonFinalMask, sub)
+	subJsonSvc := NewSubJsonService(config.subJsonMux, config.subJsonRules, config.subJsonFinalMask, config.subJsonRoutingRules, sub)
 	subJsonSvc.SetObservatoryConfig(config.subJsonObservatory)
 	subJsonSvc.SetObservatoryConfig(config.subJsonObservatory)
 	a := &SUBController{
 	a := &SUBController{
-		subTitle:         config.subTitle,
-		subSupportUrl:    config.subSupportURL,
-		subProfileUrl:    config.subProfileURL,
-		subAnnounce:      config.subAnnounce,
-		subEnableRouting: config.subEnableRouting,
-		subRoutingRules:  config.subRoutingRules,
-		subHideSettings:  config.subHideSettings,
+		subTitle:            config.subTitle,
+		subSupportUrl:       config.subSupportURL,
+		subProfileUrl:       config.subProfileURL,
+		subAnnounce:         config.subAnnounce,
+		subEnableRouting:    config.subEnableRouting,
+		subRoutingRules:     config.subRoutingRules,
+		subJsonRoutingRules: config.subJsonRoutingRules,
+		subHideSettings:     config.subHideSettings,
+		happConfig:          config.happConfig,
 
 
 		subIncyEnableRouting: config.subIncyEnableRouting,
 		subIncyEnableRouting: config.subIncyEnableRouting,
 		subIncyRoutingRules:  config.subIncyRoutingRules,
 		subIncyRoutingRules:  config.subIncyRoutingRules,
@@ -845,16 +859,28 @@ func (a *SUBController) ApplyCommonHeaders(
 		c.Writer.Header().Set("Announce", "base64:"+base64.StdEncoding.EncodeToString([]byte(profileAnnounce)))
 		c.Writer.Header().Set("Announce", "base64:"+base64.StdEncoding.EncodeToString([]byte(profileAnnounce)))
 	}
 	}
 
 
-	// Advanced (Happ). Routing stays independent of the enable flag; remote
-	// values come only from the validated cache and never delay this response.
 	rules, remote, routingErr := resolveRoutingSource(remoteRoutingHapp, profileRoutingRules)
 	rules, remote, routingErr := resolveRoutingSource(remoteRoutingHapp, profileRoutingRules)
+	if strings.TrimSpace(profileRoutingRules) == "" {
+		// Happ/INCY fetch the geo files the baked JSON rules reference through
+		// this header, so a blank Happ setting falls back to the JSON profile.
+		rules, remote, routingErr = jsonRoutingHeaderSource(a.subJsonRoutingRules), false, nil
+	}
+	// The off values undo a previously pushed setting, so they ride the same
+	// opt-in as every other Happ header rather than reaching every Happ client.
+	happManaged := a.happConfig.AutoDetect && c.Request != nil && IsHappClient(c.GetHeader("User-Agent"))
 	if profileEnableRouting {
 	if profileEnableRouting {
 		c.Writer.Header().Set("Routing-Enable", "true")
 		c.Writer.Header().Set("Routing-Enable", "true")
+	} else if happManaged {
+		c.Writer.Header().Set("Routing-Enable", "0")
 	}
 	}
 	if (routingErr == nil || !remote) && strings.TrimSpace(rules) != "" {
 	if (routingErr == nil || !remote) && strings.TrimSpace(rules) != "" {
 		c.Writer.Header().Set("Routing", rules)
 		c.Writer.Header().Set("Routing", rules)
 	}
 	}
 	if profileHideSettings {
 	if profileHideSettings {
 		c.Writer.Header().Set("Hide-Settings", "1")
 		c.Writer.Header().Set("Hide-Settings", "1")
+	} else if happManaged {
+		c.Writer.Header().Set("Hide-Settings", "0")
 	}
 	}
+
+	ApplyHappHeaders(c, a.happConfig, happManaged)
 }
 }

+ 26 - 0
internal/sub/endpoint_test.go

@@ -116,6 +116,32 @@ func TestBuildEndpointVmessLinks(t *testing.T) {
 	}
 	}
 }
 }
 
 
+// happ.su documents serverDescription as a "#title?serverDescription=<base64>"
+// link parameter, never a key of the VMess object, so nothing may leak into it.
+func TestBuildEndpointVmessLinks_HostServerDescription(t *testing.T) {
+	s := &SubService{}
+	in := &model.Inbound{Remark: "ib"}
+	baseObj := map[string]any{"v": "2", "add": "base.example.com", "port": 443, "type": "none", "id": "uid", "scy": "auto", "net": "tcp", "tls": "none"}
+	host := &model.Host{Address: "a.example.com", Port: 8443, ServerDescription: "Berlin premium"}
+	eps := []ShareEndpoint{externalProxyToEndpoint(hostToExternalProxyMap(host, "a.example.com", 8443))}
+
+	got := s.buildEndpointVmessLinks(eps, baseObj, in, "user", "tcp")
+	raw, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(got, "vmess://"))
+	if err != nil {
+		t.Fatalf("decode vmess link: %v", err)
+	}
+	var obj map[string]any
+	if err := json.Unmarshal(raw, &obj); err != nil {
+		t.Fatalf("unmarshal vmess object: %v", err)
+	}
+	if obj["add"] != "a.example.com" {
+		t.Fatalf("host endpoint not applied: add = %v", obj["add"])
+	}
+	if value, ok := obj["serverDescription"]; ok {
+		t.Fatalf("VMess object carries serverDescription = %v; it is not a VMess object key", value)
+	}
+}
+
 // N5 — a host's Final Mask is appended to the inbound's own fm param (#5831).
 // N5 — a host's Final Mask is appended to the inbound's own fm param (#5831).
 func TestBuildEndpointLinks_HostFinalMaskMerge(t *testing.T) {
 func TestBuildEndpointLinks_HostFinalMaskMerge(t *testing.T) {
 	s := &SubService{}
 	s := &SubService{}

+ 1 - 1
internal/sub/external_only_sub_test.go

@@ -28,7 +28,7 @@ func TestJsonAndClashServeExternalLinkOnlySub(t *testing.T) {
 
 
 	base := NewSubService("")
 	base := NewSubService("")
 
 
-	jsonService := NewSubJsonService("", "", "", base)
+	jsonService := NewSubJsonService("", "", "", "", base)
 	jsonOut, _, err := jsonService.GetJson("ext-only", "sub.example.com", false)
 	jsonOut, _, err := jsonService.GetJson("ext-only", "sub.example.com", false)
 	if err != nil {
 	if err != nil {
 		t.Fatalf("GetJson err = %v", err)
 		t.Fatalf("GetJson err = %v", err)

+ 141 - 0
internal/sub/happ.go

@@ -0,0 +1,141 @@
+package sub
+
+import (
+	"regexp"
+	"strings"
+
+	"github.com/gin-gonic/gin"
+)
+
+var happUserAgentRegex = regexp.MustCompile(`(?i)\bhapp\b`)
+
+// HappConfig holds all Happ client customization parameters.
+type HappConfig struct {
+	AutoDetect          bool
+	ProviderId          string
+	NewUrl              string
+	FallbackUrl         string
+	SubInfoColor        string
+	SubInfoText         string
+	SubInfoButtonText   string
+	SubInfoButtonLink   string
+	SubExpire           bool
+	SubExpireButtonLink string
+	NotificationExpire  bool
+	NoLimit             bool
+	AlwaysHwid          bool
+	TunMode             string
+	TunType             string
+	ExcludeRoutes       string
+	ExcludeApns         bool
+	ColorProfile        string
+	PingType            string
+	AutoConnect         bool
+	AutoConnectType     string
+	PerAppMode          string
+	PerAppList          string
+}
+
+// IsHappClient checks if the client user-agent identifies as Happ.
+func IsHappClient(userAgent string) bool {
+	return happUserAgentRegex.MatchString(userAgent)
+}
+
+// ApplyHappHeaders sets standard and advanced Happ subscription headers.
+func ApplyHappHeaders(c *gin.Context, cfg HappConfig, isHapp bool) {
+	if c == nil || c.Writer == nil || !cfg.AutoDetect || !isHapp {
+		return
+	}
+	if cfg.ProviderId != "" {
+		c.Writer.Header().Set("ProviderID", cfg.ProviderId)
+	}
+	if cfg.NewUrl != "" {
+		c.Writer.Header().Set("New-Url", cfg.NewUrl)
+	}
+	if cfg.FallbackUrl != "" {
+		c.Writer.Header().Set("Fallback-Url", cfg.FallbackUrl)
+	}
+	if text := strings.TrimSpace(cfg.SubInfoText); text != "" {
+		color := strings.TrimSpace(cfg.SubInfoColor)
+		switch strings.ToLower(color) {
+		case "primary", "info":
+			color = "blue"
+		case "success":
+			color = "green"
+		case "warning", "danger":
+			color = "red"
+		case "":
+			color = "blue"
+		}
+		c.Writer.Header().Set("Sub-Info-Color", color)
+		c.Writer.Header().Set("Sub-Info-Text", text)
+		if btnText := strings.TrimSpace(cfg.SubInfoButtonText); btnText != "" {
+			c.Writer.Header().Set("Sub-Info-Button-Text", btnText)
+		}
+		if btnLink := strings.TrimSpace(cfg.SubInfoButtonLink); btnLink != "" {
+			c.Writer.Header().Set("Sub-Info-Button-Link", btnLink)
+		}
+	}
+	if cfg.SubExpire {
+		c.Writer.Header().Set("Sub-Expire", "1")
+		if link := strings.TrimSpace(cfg.SubExpireButtonLink); link != "" {
+			c.Writer.Header().Set("Sub-Expire-Button-Link", link)
+		}
+	}
+	if cfg.NotificationExpire {
+		c.Writer.Header().Set("Notification-Subs-Expire", "1")
+	}
+	if cfg.NoLimit {
+		c.Writer.Header().Set("No-Limit-Enabled", "1")
+	}
+	if cfg.AlwaysHwid {
+		c.Writer.Header().Set("Subscription-Always-Hwid-Enable", "1")
+	}
+	if cfg.TunMode != "" {
+		c.Writer.Header().Set("Tun-Mode", cfg.TunMode)
+	}
+	if cfg.TunType != "" {
+		c.Writer.Header().Set("Tun-Type", cfg.TunType)
+	}
+	if routes := strings.TrimSpace(cfg.ExcludeRoutes); routes != "" {
+		c.Writer.Header().Set("Exclude-Routes", routes)
+	}
+	if cfg.ExcludeApns {
+		c.Writer.Header().Set("Exclude-Apns-Enable", "true")
+	}
+	if profile := strings.TrimSpace(cfg.ColorProfile); profile != "" {
+		profile = strings.ReplaceAll(strings.ReplaceAll(profile, "\r", ""), "\n", "")
+		c.Writer.Header().Set("Color-Profile", profile)
+	}
+	if ping := strings.TrimSpace(cfg.PingType); ping != "" {
+		if strings.EqualFold(ping, "http") {
+			ping = "proxy"
+		}
+		c.Writer.Header().Set("Ping-Type", ping)
+	}
+	if cfg.AutoConnect {
+		c.Writer.Header().Set("Subscription-Autoconnect", "1")
+		autoType := strings.TrimSpace(cfg.AutoConnectType)
+		switch strings.ToLower(autoType) {
+		case "fastest":
+			autoType = "lowestdelay"
+		case "last":
+			autoType = "lastused"
+		}
+		if autoType != "" {
+			c.Writer.Header().Set("Subscription-Autoconnect-Type", autoType)
+		}
+	}
+	if mode := strings.TrimSpace(cfg.PerAppMode); mode != "" && mode != "off" {
+		switch strings.ToLower(mode) {
+		case "include":
+			mode = "on"
+		case "exclude":
+			mode = "bypass"
+		}
+		c.Writer.Header().Set("Per-App-Proxy-Mode", mode)
+		if list := strings.TrimSpace(cfg.PerAppList); list != "" {
+			c.Writer.Header().Set("Per-App-Proxy-List", list)
+		}
+	}
+}

+ 227 - 0
internal/sub/happ_test.go

@@ -0,0 +1,227 @@
+package sub
+
+import (
+	"net/http"
+	"net/http/httptest"
+	"testing"
+
+	"github.com/gin-gonic/gin"
+)
+
+func TestNormalizeHappRouting_Off(t *testing.T) {
+	got, err := normalizeHappRouting([]byte("happ://routing/off"))
+	if err != nil {
+		t.Fatalf("normalizeHappRouting(off) error: %v", err)
+	}
+	if got != "happ://routing/off" {
+		t.Fatalf("normalizeHappRouting(off) = %q, want happ://routing/off", got)
+	}
+}
+
+func TestApplyCommonHeaders_HappClientHeaders(t *testing.T) {
+	gin.SetMode(gin.TestMode)
+
+	cfg := HappConfig{
+		AutoDetect:          true,
+		ProviderId:          "pid-test-123",
+		NewUrl:              "https://new.example.com/sub",
+		FallbackUrl:         "https://backup.example.com/sub",
+		SubInfoColor:        "primary",
+		SubInfoText:         "Welcome to VIP Network",
+		SubInfoButtonText:   "Telegram",
+		SubInfoButtonLink:   "https://t.me/example",
+		SubExpire:           true,
+		SubExpireButtonLink: "https://renew.example.com",
+		NotificationExpire:  true,
+		NoLimit:             true,
+		AlwaysHwid:          true,
+		TunMode:             "gvisor",
+		TunType:             "singbox",
+		ExcludeRoutes:       "192.168.1.0/24, 10.0.0.0/8",
+		ExcludeApns:         true,
+		ColorProfile:        "{\"serverRowBackgroundColor\":\n\"#21003D67\"}",
+		PingType:            "http",
+		AutoConnect:         true,
+		AutoConnectType:     "fastest",
+		PerAppMode:          "include",
+		PerAppList:          "com.google.chrome,com.meta.instagram",
+	}
+
+	controller := &SUBController{happConfig: cfg}
+	recorder := httptest.NewRecorder()
+	ctx, _ := gin.CreateTestContext(recorder)
+	ctx.Request = httptest.NewRequest(http.MethodGet, "/sub/test", nil)
+	ctx.Request.Header.Set("User-Agent", "Happ/1.2.0 (iPhone; iOS 17.5)")
+
+	controller.ApplyCommonHeaders(ctx, "upload=0; download=100; total=1000; expire=1800000000", "12", "MyTitle", "", "", "", false, "", false)
+
+	h := recorder.Header()
+	if h.Get("Routing-Enable") != "0" {
+		t.Fatalf("Routing-Enable = %q, want 0 for Happ with disabled routing", h.Get("Routing-Enable"))
+	}
+	if h.Get("Hide-Settings") != "0" {
+		t.Fatalf("Hide-Settings = %q, want 0 for Happ with disabled hideSettings", h.Get("Hide-Settings"))
+	}
+	if h.Get("ProviderID") != "pid-test-123" {
+		t.Fatalf("ProviderID = %q, want pid-test-123", h.Get("ProviderID"))
+	}
+	if h.Get("New-Url") != "https://new.example.com/sub" {
+		t.Fatalf("New-Url = %q", h.Get("New-Url"))
+	}
+	if h.Get("Fallback-Url") != "https://backup.example.com/sub" {
+		t.Fatalf("Fallback-Url = %q", h.Get("Fallback-Url"))
+	}
+	if h.Get("Sub-Info-Color") != "blue" || h.Get("Sub-Info-Text") != "Welcome to VIP Network" {
+		t.Fatalf("Sub-Info = %s / %s, want blue / Welcome to VIP Network", h.Get("Sub-Info-Color"), h.Get("Sub-Info-Text"))
+	}
+	if h.Get("Sub-Info-Button-Text") != "Telegram" || h.Get("Sub-Info-Button-Link") != "https://t.me/example" {
+		t.Fatalf("Sub-Info button = %s / %s", h.Get("Sub-Info-Button-Text"), h.Get("Sub-Info-Button-Link"))
+	}
+	if h.Get("Sub-Expire") != "1" || h.Get("Sub-Expire-Button-Link") != "https://renew.example.com" {
+		t.Fatalf("Sub-Expire = %s / %s", h.Get("Sub-Expire"), h.Get("Sub-Expire-Button-Link"))
+	}
+	if h.Get("Notification-Subs-Expire") != "1" {
+		t.Fatalf("Notification-Subs-Expire = %q", h.Get("Notification-Subs-Expire"))
+	}
+	if h.Get("No-Limit-Enabled") != "1" {
+		t.Fatalf("No-Limit-Enabled = %q", h.Get("No-Limit-Enabled"))
+	}
+	if h.Get("Subscription-Always-Hwid-Enable") != "1" {
+		t.Fatalf("Subscription-Always-Hwid-Enable = %q", h.Get("Subscription-Always-Hwid-Enable"))
+	}
+	if h.Get("Tun-Mode") != "gvisor" || h.Get("Tun-Type") != "singbox" {
+		t.Fatalf("Tun mode/type = %s / %s", h.Get("Tun-Mode"), h.Get("Tun-Type"))
+	}
+	if h.Get("Exclude-Routes") != "192.168.1.0/24, 10.0.0.0/8" || h.Get("Exclude-Apns-Enable") != "true" {
+		t.Fatalf("Exclude routes/apns = %s / %s", h.Get("Exclude-Routes"), h.Get("Exclude-Apns-Enable"))
+	}
+	if wantProfile := "{\"serverRowBackgroundColor\":\"#21003D67\"}"; h.Get("Color-Profile") != wantProfile {
+		t.Fatalf("Color-Profile = %q, want %q", h.Get("Color-Profile"), wantProfile)
+	}
+	if h.Get("Ping-Type") != "proxy" {
+		t.Fatalf("Ping-Type = %q, want proxy for http alias", h.Get("Ping-Type"))
+	}
+	if h.Get("Subscription-Autoconnect") != "1" || h.Get("Subscription-Autoconnect-Type") != "lowestdelay" {
+		t.Fatalf("Autoconnect = %s / %s, want 1 / lowestdelay for fastest alias", h.Get("Subscription-Autoconnect"), h.Get("Subscription-Autoconnect-Type"))
+	}
+	if h.Get("Per-App-Proxy-Mode") != "on" || h.Get("Per-App-Proxy-List") != "com.google.chrome,com.meta.instagram" {
+		t.Fatalf("Per-App = %s / %s, want on / com.google.chrome,com.meta.instagram", h.Get("Per-App-Proxy-Mode"), h.Get("Per-App-Proxy-List"))
+	}
+}
+
+func TestApplyHappHeaders_Gating(t *testing.T) {
+	gin.SetMode(gin.TestMode)
+
+	cfg := HappConfig{
+		AutoDetect:  true,
+		ProviderId:  "pid-secret",
+		SubInfoText: "Banner",
+		TunMode:     "system",
+	}
+
+	t.Run("non-Happ User-Agent receives no headers", func(t *testing.T) {
+		recorder := httptest.NewRecorder()
+		ctx, _ := gin.CreateTestContext(recorder)
+		ctx.Request = httptest.NewRequest(http.MethodGet, "/sub/test", nil)
+		ctx.Request.Header.Set("User-Agent", "v2rayNG/1.8.5")
+
+		controller := &SUBController{happConfig: cfg}
+		controller.ApplyCommonHeaders(ctx, "", "", "Title", "", "", "", false, "", false)
+
+		if got := recorder.Header().Get("ProviderID"); got != "" {
+			t.Fatalf("ProviderID emitted to non-Happ client: %q", got)
+		}
+		if got := recorder.Header().Get("Sub-Info-Text"); got != "" {
+			t.Fatalf("Sub-Info-Text emitted to non-Happ client: %q", got)
+		}
+		if got := recorder.Header().Get("Tun-Mode"); got != "" {
+			t.Fatalf("Tun-Mode emitted to non-Happ client: %q", got)
+		}
+	})
+
+	t.Run("AutoDetect disabled suppresses Happ headers", func(t *testing.T) {
+		recorder := httptest.NewRecorder()
+		ctx, _ := gin.CreateTestContext(recorder)
+		ctx.Request = httptest.NewRequest(http.MethodGet, "/sub/test", nil)
+		ctx.Request.Header.Set("User-Agent", "Happ/1.2.0 (Android)")
+
+		disabledCfg := cfg
+		disabledCfg.AutoDetect = false
+
+		controller := &SUBController{happConfig: disabledCfg}
+		controller.ApplyCommonHeaders(ctx, "", "", "Title", "", "", "", false, "", false)
+
+		if got := recorder.Header().Get("ProviderID"); got != "" {
+			t.Fatalf("ProviderID emitted when AutoDetect is false: %q", got)
+		}
+		if got := recorder.Header().Get("Sub-Info-Text"); got != "" {
+			t.Fatalf("Sub-Info-Text emitted when AutoDetect is false: %q", got)
+		}
+		// happ.su documents routing-enable 0/false as "disables routing
+		// globally", so it must stay behind the same opt-in as the rest.
+		if got := recorder.Header().Get("Routing-Enable"); got != "" {
+			t.Fatalf("Routing-Enable emitted when AutoDetect is false: %q", got)
+		}
+		if got := recorder.Header().Get("Hide-Settings"); got != "" {
+			t.Fatalf("Hide-Settings emitted when AutoDetect is false: %q", got)
+		}
+	})
+}
+
+func TestApplyHappHeaders_Aliases(t *testing.T) {
+	gin.SetMode(gin.TestMode)
+
+	tests := []struct {
+		name       string
+		cfg        HappConfig
+		wantHeader string
+		wantValue  string
+	}{
+		{
+			name:       "color warning maps to red",
+			cfg:        HappConfig{AutoDetect: true, SubInfoText: "Alert", SubInfoColor: "warning"},
+			wantHeader: "Sub-Info-Color",
+			wantValue:  "red",
+		},
+		{
+			name:       "color danger maps to red",
+			cfg:        HappConfig{AutoDetect: true, SubInfoText: "Alert", SubInfoColor: "danger"},
+			wantHeader: "Sub-Info-Color",
+			wantValue:  "red",
+		},
+		{
+			name:       "color success maps to green",
+			cfg:        HappConfig{AutoDetect: true, SubInfoText: "Ok", SubInfoColor: "success"},
+			wantHeader: "Sub-Info-Color",
+			wantValue:  "green",
+		},
+		{
+			name:       "autoconnect last maps to lastused",
+			cfg:        HappConfig{AutoDetect: true, AutoConnect: true, AutoConnectType: "last"},
+			wantHeader: "Subscription-Autoconnect-Type",
+			wantValue:  "lastused",
+		},
+		{
+			name:       "per-app exclude maps to bypass",
+			cfg:        HappConfig{AutoDetect: true, PerAppMode: "exclude", PerAppList: "app.id"},
+			wantHeader: "Per-App-Proxy-Mode",
+			wantValue:  "bypass",
+		},
+	}
+
+	for _, tc := range tests {
+		t.Run(tc.name, func(t *testing.T) {
+			recorder := httptest.NewRecorder()
+			ctx, _ := gin.CreateTestContext(recorder)
+			ctx.Request = httptest.NewRequest(http.MethodGet, "/sub/test", nil)
+			ctx.Request.Header.Set("User-Agent", "Happ/1.2.0 (iOS)")
+
+			controller := &SUBController{happConfig: tc.cfg}
+			controller.ApplyCommonHeaders(ctx, "", "", "Title", "", "", "", false, "", false)
+
+			if got := recorder.Header().Get(tc.wantHeader); got != tc.wantValue {
+				t.Fatalf("%s = %q, want %q", tc.wantHeader, got, tc.wantValue)
+			}
+		})
+	}
+}

+ 3 - 3
internal/sub/host_sub_test.go

@@ -294,7 +294,7 @@ func TestSub_HostHeaderReachesClashAndJson(t *testing.T) {
 		t.Fatalf("clash ws-opts should carry the host record's path:\n%s", yaml)
 		t.Fatalf("clash ws-opts should carry the host record's path:\n%s", yaml)
 	}
 	}
 
 
-	js := NewSubJsonService("", "", "", NewSubService(""))
+	js := NewSubJsonService("", "", "", "", NewSubService(""))
 	out, _, err := js.GetJson("s1", "req.example.com", false)
 	out, _, err := js.GetJson("s1", "req.example.com", false)
 	if err != nil {
 	if err != nil {
 		t.Fatalf("GetJson: %v", err)
 		t.Fatalf("GetJson: %v", err)
@@ -336,7 +336,7 @@ func TestSub_HostSockoptJSON(t *testing.T) {
 		InboundId: ib.Id, SortOrder: 0, Remark: "SO", Address: "so.cdn.com", Port: 8443, Security: "tls",
 		InboundId: ib.Id, SortOrder: 0, Remark: "SO", Address: "so.cdn.com", Port: 8443, Security: "tls",
 		SockoptParams: `{"tcpFastOpen":true}`,
 		SockoptParams: `{"tcpFastOpen":true}`,
 	})
 	})
-	js := NewSubJsonService("", "", "", NewSubService(""))
+	js := NewSubJsonService("", "", "", "", NewSubService(""))
 	out, _, err := js.GetJson("s1", "req.example.com", false)
 	out, _, err := js.GetJson("s1", "req.example.com", false)
 	if err != nil {
 	if err != nil {
 		t.Fatalf("GetJson: %v", err)
 		t.Fatalf("GetJson: %v", err)
@@ -354,7 +354,7 @@ func TestSub_HostMuxJSON(t *testing.T) {
 		InboundId: ib.Id, SortOrder: 0, Remark: "MX", Address: "mx.cdn.com", Port: 8443, Security: "tls",
 		InboundId: ib.Id, SortOrder: 0, Remark: "MX", Address: "mx.cdn.com", Port: 8443, Security: "tls",
 		MuxParams: `{"enabled":true,"concurrency":8}`,
 		MuxParams: `{"enabled":true,"concurrency":8}`,
 	})
 	})
-	js := NewSubJsonService("", "", "", NewSubService(""))
+	js := NewSubJsonService("", "", "", "", NewSubService(""))
 	out, _, err := js.GetJson("s1", "req.example.com", false)
 	out, _, err := js.GetJson("s1", "req.example.com", false)
 	if err != nil {
 	if err != nil {
 		t.Fatalf("GetJson: %v", err)
 		t.Fatalf("GetJson: %v", err)

+ 2 - 2
internal/sub/json_flow_gate_test.go

@@ -60,7 +60,7 @@ func TestSub_JSONStripsFlowOnUnsupportedTransport(t *testing.T) {
 		t.Fatalf("clash proxy must not carry a flow on ws+tls:\n%s", yaml)
 		t.Fatalf("clash proxy must not carry a flow on ws+tls:\n%s", yaml)
 	}
 	}
 
 
-	js := NewSubJsonService("", "", "", NewSubService(""))
+	js := NewSubJsonService("", "", "", "", NewSubService(""))
 	out, _, err := js.GetJson("s1", "req.example.com", false)
 	out, _, err := js.GetJson("s1", "req.example.com", false)
 	if err != nil {
 	if err != nil {
 		t.Fatalf("GetJson: %v", err)
 		t.Fatalf("GetJson: %v", err)
@@ -76,7 +76,7 @@ func TestSub_JSONKeepsFlowOnTcpTLS(t *testing.T) {
 	seedFlowInbound(t, "s1", "tcpflow", 4602,
 	seedFlowInbound(t, "s1", "tcpflow", 4602,
 		`{"network":"tcp","security":"tls","tlsSettings":{"serverName":"base.sni"}}`)
 		`{"network":"tcp","security":"tls","tlsSettings":{"serverName":"base.sni"}}`)
 
 
-	js := NewSubJsonService("", "", "", NewSubService(""))
+	js := NewSubJsonService("", "", "", "", NewSubService(""))
 	out, _, err := js.GetJson("s1", "req.example.com", false)
 	out, _, err := js.GetJson("s1", "req.example.com", false)
 	if err != nil {
 	if err != nil {
 		t.Fatalf("GetJson: %v", err)
 		t.Fatalf("GetJson: %v", err)

+ 4 - 4
internal/sub/json_info_node_test.go

@@ -56,7 +56,7 @@ func TestSubJson_InfoNode_Active(t *testing.T) {
 
 
 	sub := NewSubService("{{EMAIL}}|📊{{TRAFFIC_LEFT}}")
 	sub := NewSubService("{{EMAIL}}|📊{{TRAFFIC_LEFT}}")
 	sub.subInfoNodeEnable = true
 	sub.subInfoNodeEnable = true
-	jsonSvc := NewSubJsonService("", "", "", sub)
+	jsonSvc := NewSubJsonService("", "", "", "", sub)
 
 
 	out, _, err := jsonSvc.GetJson("sub-json", "sub.example.com", false)
 	out, _, err := jsonSvc.GetJson("sub-json", "sub.example.com", false)
 	if err != nil {
 	if err != nil {
@@ -115,7 +115,7 @@ func TestSubJson_InfoNode_Expired(t *testing.T) {
 	sub := NewSubService("{{INBOUND}}")
 	sub := NewSubService("{{INBOUND}}")
 	sub.subInfoNodeEnable = true
 	sub.subInfoNodeEnable = true
 	sub.subExpiredTemplate = service.DefaultSubExpiredTemplate
 	sub.subExpiredTemplate = service.DefaultSubExpiredTemplate
-	jsonSvc := NewSubJsonService("", "", "", sub)
+	jsonSvc := NewSubJsonService("", "", "", "", sub)
 
 
 	out, _, err := jsonSvc.GetJson("sub-json-exp", "sub.example.com", false)
 	out, _, err := jsonSvc.GetJson("sub-json-exp", "sub.example.com", false)
 	if err != nil {
 	if err != nil {
@@ -175,7 +175,7 @@ func TestSubJson_InfoNode_Depleted(t *testing.T) {
 	sub := NewSubService("{{INBOUND}}")
 	sub := NewSubService("{{INBOUND}}")
 	sub.subInfoNodeEnable = true
 	sub.subInfoNodeEnable = true
 	sub.subTrafficDepletedTemplate = service.DefaultSubTrafficDepletedTemplate
 	sub.subTrafficDepletedTemplate = service.DefaultSubTrafficDepletedTemplate
-	jsonSvc := NewSubJsonService("", "", "", sub)
+	jsonSvc := NewSubJsonService("", "", "", "", sub)
 
 
 	out, _, err := jsonSvc.GetJson("sub-json-dep", "sub.example.com", false)
 	out, _, err := jsonSvc.GetJson("sub-json-dep", "sub.example.com", false)
 	if err != nil {
 	if err != nil {
@@ -234,7 +234,7 @@ func TestSubJson_InfoNode_StatusActive(t *testing.T) {
 
 
 	sub := NewSubService("{{EMAIL}}|{{STATUS_EMOJI}} {{STATUS}}")
 	sub := NewSubService("{{EMAIL}}|{{STATUS_EMOJI}} {{STATUS}}")
 	sub.subInfoNodeEnable = true
 	sub.subInfoNodeEnable = true
-	jsonSvc := NewSubJsonService("", "", "", sub)
+	jsonSvc := NewSubJsonService("", "", "", "", sub)
 
 
 	out, _, err := jsonSvc.GetJson("sub-json-status", "sub.example.com", false)
 	out, _, err := jsonSvc.GetJson("sub-json-status", "sub.example.com", false)
 	if err != nil {
 	if err != nil {

+ 366 - 0
internal/sub/json_routing.go

@@ -0,0 +1,366 @@
+package sub
+
+import (
+	"encoding/json"
+	"errors"
+	"fmt"
+	"maps"
+	"slices"
+	"strings"
+	"sync/atomic"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/logger"
+	"github.com/mhsanaei/3x-ui/v3/internal/util/common"
+)
+
+// jsonRoutingSpec is the canonical form of the generic Happ/INCY routing
+// payload (inline JSON, happ:// or incy:// deeplink, or remote URL).
+type jsonRoutingSpec struct {
+	DomainStrategy    string
+	RemoteDNSDomain   string
+	RemoteDNSIP       string
+	DomesticDNSDomain string
+	DomesticDNSIP     string
+	DnsHosts          map[string]string
+	RouteOrder        []string // e.g. {"block","proxy","direct"}; default {"block","direct","proxy"}
+	DirectSites       []string
+	DirectIp          []string
+	ProxySites        []string
+	ProxyIp           []string
+	BlockSites        []string
+	BlockIp           []string
+}
+
+func (s jsonRoutingSpec) empty() bool {
+	return s.DomainStrategy == "" && s.RemoteDNSDomain == "" && s.RemoteDNSIP == "" &&
+		s.DomesticDNSDomain == "" && s.DomesticDNSIP == "" && len(s.DnsHosts) == 0 &&
+		len(s.RouteOrder) == 0 && len(s.DirectSites) == 0 && len(s.DirectIp) == 0 &&
+		len(s.ProxySites) == 0 && len(s.ProxyIp) == 0 && len(s.BlockSites) == 0 && len(s.BlockIp) == 0
+}
+
+// equal reports whether two specs carry the same routing payload, so a
+// rebuilt template only replaces the memoised one when the profile changed.
+func (s jsonRoutingSpec) equal(other jsonRoutingSpec) bool {
+	return s.DomainStrategy == other.DomainStrategy &&
+		s.RemoteDNSDomain == other.RemoteDNSDomain && s.RemoteDNSIP == other.RemoteDNSIP &&
+		s.DomesticDNSDomain == other.DomesticDNSDomain && s.DomesticDNSIP == other.DomesticDNSIP &&
+		maps.Equal(s.DnsHosts, other.DnsHosts) && slices.Equal(s.RouteOrder, other.RouteOrder) &&
+		slices.Equal(s.DirectSites, other.DirectSites) && slices.Equal(s.DirectIp, other.DirectIp) &&
+		slices.Equal(s.ProxySites, other.ProxySites) && slices.Equal(s.ProxyIp, other.ProxyIp) &&
+		slices.Equal(s.BlockSites, other.BlockSites) && slices.Equal(s.BlockIp, other.BlockIp)
+}
+
+// Both happ forms appear in the wild; normalizeHappRouting accepts each.
+var jsonRoutingDeeplinkPrefixes = []string{
+	"happ://routing/onadd/", "happ://routing/add/", "incy://routing/onadd/",
+}
+
+// bakedTemplate resolves once per emitted document, so an unusable profile
+// must not write one identical warning per document on every public fetch.
+var lastJsonRoutingWarning atomic.Value
+
+// resolveJsonRoutingSpec parses the routing payload, degrading to an empty
+// spec on error — a bad setting must never take the subscription server down.
+func resolveJsonRoutingSpec(raw string) jsonRoutingSpec {
+	spec, remote, err := parseJsonRoutingSpec(raw)
+	if err != nil {
+		warning := "subJsonRoutingRules: " + err.Error()
+		if remote {
+			warning = "subJsonRoutingRules: remote source unavailable, emitting default routing"
+		}
+		if previous, _ := lastJsonRoutingWarning.Load().(string); previous != warning {
+			lastJsonRoutingWarning.Store(warning)
+			logger.Warning(warning)
+		}
+		return jsonRoutingSpec{}
+	}
+	lastJsonRoutingWarning.Store("")
+	return spec
+}
+
+// jsonRoutingHeaderSource turns the JSON routing setting into a Routing header
+// value; blank means unusable, and the header then stays unset.
+func jsonRoutingHeaderSource(raw string) string {
+	trimmed := strings.TrimSpace(raw)
+	if trimmed == "" {
+		return ""
+	}
+	if strings.HasPrefix(trimmed, "incy://") {
+		_, rest, ok := cutAnyPrefix(trimmed, jsonRoutingDeeplinkPrefixes)
+		if !ok {
+			return ""
+		}
+		decoded, err := decodeRoutingBase64(rest)
+		if err != nil {
+			return ""
+		}
+		if _, err := validateAndCompactJSONObject(decoded); err != nil || len(trimmed) > remoteRoutingHappMaxValue {
+			return ""
+		}
+		return trimmed
+	}
+	resolved, _, err := resolveRoutingSource(remoteRoutingJson, trimmed)
+	if err != nil {
+		return ""
+	}
+	content, err := normalizeHappRouting([]byte(resolved))
+	if err != nil || len(content) > remoteRoutingHappMaxValue {
+		return ""
+	}
+	return content
+}
+
+// parseJsonRoutingSpec resolves raw (inline JSON, happ:// or incy:// deeplink,
+// or https:// URL) into a spec; the caller degrades on error, never fails.
+func parseJsonRoutingSpec(raw string) (jsonRoutingSpec, bool, error) {
+	trimmed := strings.TrimSpace(raw)
+	if trimmed == "" {
+		return jsonRoutingSpec{}, false, nil
+	}
+
+	if _, remote, err := common.ParseRemoteRoutingURL(trimmed); remote {
+		if err != nil {
+			return jsonRoutingSpec{}, true, err
+		}
+		resolved, remote, err := resolveRoutingSource(remoteRoutingJson, trimmed)
+		if err != nil || !remote {
+			return jsonRoutingSpec{}, true, err
+		}
+		trimmed = resolved
+	}
+
+	payload := []byte(trimmed)
+	if _, rest, ok := cutAnyPrefix(trimmed, jsonRoutingDeeplinkPrefixes); ok {
+		decoded, err := decodeRoutingBase64(rest)
+		if err != nil {
+			return jsonRoutingSpec{}, false, fmt.Errorf("invalid routing deeplink payload: %w", err)
+		}
+		payload = decoded
+	} else if !strings.HasPrefix(trimmed, "{") {
+		return jsonRoutingSpec{}, false, errors.New("routing payload must be a JSON object or a happ/incy deeplink")
+	}
+
+	var object map[string]any
+	if err := json.Unmarshal(payload, &object); err != nil {
+		return jsonRoutingSpec{}, false, fmt.Errorf("invalid routing payload JSON: %w", err)
+	}
+	if object == nil {
+		return jsonRoutingSpec{}, false, errors.New("routing payload must be a JSON object")
+	}
+
+	spec, err := buildJsonRoutingSpec(object)
+	if err != nil {
+		return jsonRoutingSpec{}, false, err
+	}
+	return spec, false, nil
+}
+
+func cutAnyPrefix(s string, prefixes []string) (string, string, bool) {
+	for _, prefix := range prefixes {
+		if after, ok := strings.CutPrefix(s, prefix); ok {
+			return prefix, after, true
+		}
+	}
+	return "", "", false
+}
+
+func buildJsonRoutingSpec(object map[string]any) (jsonRoutingSpec, error) {
+	spec := jsonRoutingSpec{}
+	var err error
+	if spec.DomainStrategy, err = routingString(object, "DomainStrategy"); err != nil {
+		return spec, err
+	}
+	if spec.RemoteDNSDomain, err = routingString(object, "RemoteDNSDomain"); err != nil {
+		return spec, err
+	}
+	if spec.RemoteDNSIP, err = routingString(object, "RemoteDNSIP"); err != nil {
+		return spec, err
+	}
+	if spec.DomesticDNSDomain, err = routingString(object, "DomesticDNSDomain"); err != nil {
+		return spec, err
+	}
+	if spec.DomesticDNSIP, err = routingString(object, "DomesticDNSIP"); err != nil {
+		return spec, err
+	}
+	if spec.DirectSites, err = routingList(object, "DirectSites"); err != nil {
+		return spec, err
+	}
+	if spec.DirectIp, err = routingList(object, "DirectIp"); err != nil {
+		return spec, err
+	}
+	if spec.ProxySites, err = routingList(object, "ProxySites"); err != nil {
+		return spec, err
+	}
+	if spec.ProxyIp, err = routingList(object, "ProxyIp"); err != nil {
+		return spec, err
+	}
+	if spec.BlockSites, err = routingList(object, "BlockSites"); err != nil {
+		return spec, err
+	}
+	if spec.BlockIp, err = routingList(object, "BlockIp"); err != nil {
+		return spec, err
+	}
+	if spec.DnsHosts, err = routingHosts(object, "DnsHosts"); err != nil {
+		return spec, err
+	}
+	order, err := routingString(object, "RouteOrder")
+	if err != nil {
+		return spec, err
+	}
+	for _, segment := range strings.Split(order, "-") {
+		switch segment {
+		case "block", "proxy", "direct":
+			spec.RouteOrder = append(spec.RouteOrder, segment)
+		}
+	}
+	return spec, nil
+}
+
+func routingString(object map[string]any, key string) (string, error) {
+	value, ok := object[key]
+	if !ok || value == nil {
+		return "", nil
+	}
+	text, ok := value.(string)
+	if !ok {
+		return "", fmt.Errorf("routing field %q must be a string", key)
+	}
+	return text, nil
+}
+
+func routingList(object map[string]any, key string) ([]string, error) {
+	value, ok := object[key]
+	if !ok || value == nil {
+		return nil, nil
+	}
+	entries, ok := value.([]any)
+	if !ok {
+		return nil, fmt.Errorf("routing field %q must be an array of strings", key)
+	}
+	list := make([]string, 0, len(entries))
+	for _, entry := range entries {
+		text, ok := entry.(string)
+		if !ok {
+			return nil, fmt.Errorf("routing field %q must be an array of strings", key)
+		}
+		list = append(list, text)
+	}
+	return list, nil
+}
+
+func routingHosts(object map[string]any, key string) (map[string]string, error) {
+	value, ok := object[key]
+	if !ok || value == nil {
+		return nil, nil
+	}
+	raw, ok := value.(map[string]any)
+	if !ok {
+		return nil, fmt.Errorf("routing field %q must be a string-to-string map", key)
+	}
+	hosts := make(map[string]string, len(raw))
+	for name, entry := range raw {
+		address, ok := entry.(string)
+		if !ok {
+			return nil, fmt.Errorf("routing field %q must be a string-to-string map", key)
+		}
+		hosts[name] = address
+	}
+	return hosts, nil
+}
+
+func routeOrderGroups(order []string) []string {
+	if len(order) == 0 {
+		return []string{"block", "direct", "proxy"}
+	}
+	return order
+}
+
+// applyJsonRouting patches the base template with the spec's dns and routing
+// subtrees, mirroring the njs patcher panel admins previously ran behind nginx.
+func applyJsonRouting(configJson map[string]any, spec jsonRoutingSpec) {
+	domestic := spec.DomesticDNSDomain
+	if domestic == "" {
+		ip := spec.DomesticDNSIP
+		if ip == "" {
+			ip = "77.88.8.8"
+		}
+		domestic = "https://" + ip + "/dns-query"
+	}
+	remote := spec.RemoteDNSDomain
+	if remote == "" {
+		ip := spec.RemoteDNSIP
+		if ip == "" {
+			ip = "8.8.8.8"
+		}
+		remote = "https://" + ip + "/dns-query"
+	}
+
+	dns := map[string]any{
+		"tag":           "dns_out",
+		"queryStrategy": "UseIP",
+		"servers":       []any{},
+	}
+	if len(spec.DirectSites) > 0 {
+		dns["servers"] = append(dns["servers"].([]any), map[string]any{
+			"address": domestic,
+			"domains": spec.DirectSites,
+		})
+	}
+	dns["servers"] = append(dns["servers"].([]any), map[string]any{
+		"address":      remote,
+		"skipFallback": false,
+	})
+	if len(spec.DnsHosts) > 0 {
+		dns["hosts"] = spec.DnsHosts
+	}
+
+	domainStrategy := spec.DomainStrategy
+	if domainStrategy == "" {
+		domainStrategy = "IPIfNonMatch"
+	}
+
+	groups := map[string][]map[string]any{
+		"block": {
+			{"domain": stringList(spec.BlockSites), "outboundTag": "block"},
+			{"ip": stringList(spec.BlockIp), "outboundTag": "block"},
+		},
+		"direct": {
+			{"domain": stringList(spec.DirectSites), "outboundTag": "direct"},
+			{"ip": stringList(spec.DirectIp), "outboundTag": "direct"},
+		},
+		"proxy": {
+			{"domain": stringList(spec.ProxySites), "outboundTag": "proxy"},
+			{"ip": stringList(spec.ProxyIp), "outboundTag": "proxy"},
+		},
+	}
+	rules := make([]any, 0, len(routeOrderGroups(spec.RouteOrder))*2+1)
+	for _, group := range routeOrderGroups(spec.RouteOrder) {
+		for _, rule := range groups[group] {
+			var key string
+			if _, ok := rule["domain"]; ok {
+				key = "domain"
+			} else {
+				key = "ip"
+			}
+			if len(rule[key].([]string)) == 0 {
+				continue
+			}
+			entry := map[string]any{"type": "field", key: rule[key], "outboundTag": rule["outboundTag"]}
+			rules = append(rules, entry)
+		}
+	}
+	rules = append(rules, map[string]any{"type": "field", "network": "tcp,udp", "outboundTag": "proxy"})
+
+	configJson["dns"] = dns
+	configJson["routing"] = map[string]any{
+		"domainStrategy": domainStrategy,
+		"rules":          rules,
+	}
+}
+
+func stringList(list []string) []string {
+	if len(list) == 0 {
+		return nil
+	}
+	return list
+}

+ 447 - 0
internal/sub/json_routing_baked_test.go

@@ -0,0 +1,447 @@
+package sub
+
+import (
+	"encoding/base64"
+	"encoding/json"
+	"net/http"
+	"net/http/httptest"
+	"strings"
+	"testing"
+	"time"
+
+	"github.com/gin-gonic/gin"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/logger"
+)
+
+const bakedRoutingPayload = `{
+	"DomainStrategy": "IPIfNonMatch",
+	"RemoteDNSDomain": "https://8.8.8.8/dns-query",
+	"RemoteDNSIP": "8.8.8.8",
+	"DomesticDNSDomain": "https://77.88.8.8/dns-query",
+	"DomesticDNSIP": "77.88.8.8",
+	"DnsHosts": {"lknpd.nalog.ru": "213.24.64.181"},
+	"RouteOrder": "block-proxy-direct",
+	"DirectSites": ["geosite:category-ru"],
+	"DirectIp": ["geoip:private"],
+	"ProxySites": ["geosite:youtube"],
+	"BlockSites": ["geosite:category-ads"]
+}`
+
+func ruleSignatures(t *testing.T, doc map[string]any) []string {
+	t.Helper()
+	routing, _ := doc["routing"].(map[string]any)
+	rules, _ := routing["rules"].([]any)
+	signatures := make([]string, 0, len(rules))
+	for _, rule := range rules {
+		m, _ := rule.(map[string]any)
+		target, _ := m["outboundTag"].(string)
+		if target == "" {
+			target = "balancer:" + m["balancerTag"].(string)
+		}
+		kind := "ip"
+		if _, has := m["domain"]; has {
+			kind = "domain"
+		}
+		if _, has := m["network"]; has {
+			kind = "network"
+		}
+		signatures = append(signatures, kind+"->"+target)
+	}
+	return signatures
+}
+
+func assertBakedRouting(t *testing.T, doc map[string]any, wantRules []string, proxyTag string) {
+	t.Helper()
+	dns, _ := doc["dns"].(map[string]any)
+	if dns == nil {
+		t.Fatalf("doc has no dns:\n%v", doc)
+	}
+	if dns["tag"] != "dns_out" || dns["queryStrategy"] != "UseIP" {
+		t.Fatalf("dns header = %v", dns)
+	}
+	servers, _ := dns["servers"].([]any)
+	if len(servers) != 2 {
+		t.Fatalf("dns servers = %d, want 2 (domestic + remote): %v", len(servers), servers)
+	}
+	first, _ := servers[0].(map[string]any)
+	if first["address"] != "https://77.88.8.8/dns-query" {
+		t.Fatalf("domestic dns = %v", first)
+	}
+	if domains, _ := first["domains"].([]any); strings.Join(stringify(domains), ",") != "geosite:category-ru" {
+		t.Fatalf("domestic dns domains = %v", first["domains"])
+	}
+	second, _ := servers[1].(map[string]any)
+	if second["address"] != "https://8.8.8.8/dns-query" {
+		t.Fatalf("remote dns = %v", second)
+	}
+	hosts, _ := dns["hosts"].(map[string]any)
+	if hosts["lknpd.nalog.ru"] != "213.24.64.181" {
+		t.Fatalf("dns hosts = %v", dns["hosts"])
+	}
+
+	routing, _ := doc["routing"].(map[string]any)
+	if routing["domainStrategy"] != "IPIfNonMatch" {
+		t.Fatalf("domainStrategy = %v", routing["domainStrategy"])
+	}
+	want := make([]string, 0, len(wantRules))
+	for _, rule := range wantRules {
+		want = append(want, strings.Replace(rule, "PROXY", proxyTag, 1))
+	}
+	got := ruleSignatures(t, doc)
+	if strings.Join(got, ",") != strings.Join(want, ",") {
+		t.Fatalf("rules = %v\nwant %v", got, want)
+	}
+}
+
+func TestSubJson_BakedRoutingInEveryDocument(t *testing.T) {
+	seedSubDB(t)
+	seedSubInbound(t, "s1", "tcpin", 4801, 1, `{"network":"tcp","security":"tls","tlsSettings":{"serverName":"base.sni"}}`)
+
+	js := NewSubJsonService("", "", "", bakedRoutingPayload, NewSubService(""))
+	out, _, err := js.GetJson("s1", "req.example.com", true)
+	if err != nil {
+		t.Fatalf("GetJson: %v", err)
+	}
+	docs := parseSubJsonDocs(t, out)
+	if len(docs) != 1 {
+		t.Fatalf("docs = %d, want 1:\n%s", len(docs), out)
+	}
+	want := []string{"domain->block", "domain->PROXY", "domain->direct", "ip->direct", "network->PROXY"}
+	assertBakedRouting(t, docs[0], want, "proxy")
+}
+
+func TestSubJson_BakedRoutingReplacesLegacyRules(t *testing.T) {
+	seedSubDB(t)
+	seedSubInbound(t, "s1", "tcpin", 4802, 1, `{"network":"tcp","security":"tls","tlsSettings":{"serverName":"base.sni"}}`)
+
+	legacy := `[{"type":"field","domain":["geosite:example"],"outboundTag":"proxy"}]`
+	js := NewSubJsonService("", legacy, "", bakedRoutingPayload, NewSubService(""))
+	out, _, err := js.GetJson("s1", "req.example.com", true)
+	if err != nil {
+		t.Fatalf("GetJson: %v", err)
+	}
+	docs := parseSubJsonDocs(t, out)
+	routing, _ := docs[0]["routing"].(map[string]any)
+	ruleJSON, _ := json.Marshal(routing["rules"])
+	if strings.Contains(string(ruleJSON), "geosite:example") {
+		t.Fatalf("legacy subJsonRules must not leak into baked docs: %s", ruleJSON)
+	}
+}
+
+func TestSubJson_BakedRoutingWithBalancer(t *testing.T) {
+	seedSubDB(t)
+	tcp := seedSubInbound(t, "s1", "tcpin", 4803, 1, `{"network":"tcp","security":"tls","tlsSettings":{"serverName":"base.sni"}}`)
+	seedSubBalancer(t, &model.SubBalancer{
+		Remark: "auto", Strategy: "leastLoad", InboundIds: []int{tcp.Id}, SortOrder: 1, Enabled: true,
+	})
+
+	js := NewSubJsonService("", "", "", bakedRoutingPayload, NewSubService(""))
+	out, _, err := js.GetJson("s1", "req.example.com", true)
+	if err != nil {
+		t.Fatalf("GetJson: %v", err)
+	}
+	docs := parseSubJsonDocs(t, out)
+	if len(docs) != 2 {
+		t.Fatalf("docs = %d, want 2 (inbound + balancer):\n%s", len(docs), out)
+	}
+
+	// Manual doc keeps the plain proxy tag.
+	assertBakedRouting(t, findDocByRemarks(docs, "tcpin-tcpin@e"), []string{
+		"domain->block", "domain->PROXY", "domain->direct", "ip->direct", "network->PROXY",
+	}, "proxy")
+
+	// Balancer doc routes proxy groups into the balancer.
+	balancerDoc := findDocByRemarks(docs, "auto")
+	want := []string{"domain->block", "domain->balancer:balancer", "domain->direct", "ip->direct", "network->balancer:balancer"}
+	assertBakedRouting(t, balancerDoc, want, "balancer:balancer")
+}
+
+func TestSubJson_BakedRoutingInvalidFallsBackToDefault(t *testing.T) {
+	seedSubDB(t)
+	seedSubInbound(t, "s1", "tcpin", 4804, 1, `{"network":"tcp","security":"tls","tlsSettings":{"serverName":"base.sni"}}`)
+
+	js := NewSubJsonService("", "", "", "not json at all", NewSubService(""))
+	out, _, err := js.GetJson("s1", "req.example.com", true)
+	if err != nil {
+		t.Fatalf("GetJson must survive a bad routing payload: %v", err)
+	}
+	docs := parseSubJsonDocs(t, out)
+	routing, _ := docs[0]["routing"].(map[string]any)
+	rules, _ := json.Marshal(routing["rules"])
+	if !strings.Contains(string(rules), `"outboundTag":"proxy"`) {
+		t.Fatalf("default routing missing: %s", rules)
+	}
+}
+
+func TestSubJson_LegacyRulesStillWorkWithoutBakedRouting(t *testing.T) {
+	seedSubDB(t)
+	seedSubInbound(t, "s1", "tcpin", 4805, 1, `{"network":"tcp","security":"tls","tlsSettings":{"serverName":"base.sni"}}`)
+
+	legacy := `[{"type":"field","domain":["geosite:example"],"outboundTag":"proxy"}]`
+	js := NewSubJsonService("", legacy, "", "", NewSubService(""))
+	out, _, err := js.GetJson("s1", "req.example.com", true)
+	if err != nil {
+		t.Fatalf("GetJson: %v", err)
+	}
+	docs := parseSubJsonDocs(t, out)
+	routing, _ := docs[0]["routing"].(map[string]any)
+	ruleJSON, _ := json.Marshal(routing["rules"])
+	if !strings.Contains(string(ruleJSON), "geosite:example") {
+		t.Fatalf("legacy rules missing: %s", ruleJSON)
+	}
+}
+
+func TestSubJson_BakedRoutingRemoteWarmsAfterColdStart(t *testing.T) {
+	seedSubDB(t)
+	seedSubInbound(t, "s1", "tcpin", 4806, 1, `{"network":"tcp","security":"tls","tlsSettings":{"serverName":"base.sni"}}`)
+
+	oldResolver := routingSourceResolver
+	t.Cleanup(func() { routingSourceResolver = oldResolver })
+	const source = "https://example.com/DEFAULT.JSON"
+	routingSourceResolver = newRemoteRoutingResolver(remoteRoutingTestClient(func(*http.Request) (*http.Response, error) {
+		return remoteRoutingResponse(200, mustMarshal(t, fullRoutingPayload())), nil
+	}), false)
+
+	js := NewSubJsonService("", "", "", source, NewSubService(""))
+	// Cold: no request has primed the resolver cache yet.
+	out, _, err := js.GetJson("s1", "req.example.com", true)
+	if err != nil {
+		t.Fatalf("GetJson: %v", err)
+	}
+	docs := parseSubJsonDocs(t, out)
+	routing, _ := docs[0]["routing"].(map[string]any)
+	if routing["domainStrategy"] != "AsIs" {
+		t.Fatalf("cold doc must keep default routing: %v", routing["domainStrategy"])
+	}
+
+	// The cron job warms the cache; the next request must bake the profile.
+	primeRemoteRouting(t, routingSourceResolver, remoteRoutingJson, source)
+	out, _, err = js.GetJson("s1", "req.example.com", true)
+	if err != nil {
+		t.Fatalf("GetJson: %v", err)
+	}
+	docs = parseSubJsonDocs(t, out)
+	routing, _ = docs[0]["routing"].(map[string]any)
+	if routing["domainStrategy"] != "IPIfNonMatch" {
+		t.Fatalf("warm doc must carry the profile: %v", routing["domainStrategy"])
+	}
+	dns, _ := docs[0]["dns"].(map[string]any)
+	servers, _ := dns["servers"].([]any)
+	if len(servers) != 2 {
+		t.Fatalf("warm doc dns servers = %v", servers)
+	}
+}
+
+func TestApplyCommonHeadersFallsBackToJsonRoutingProfile(t *testing.T) {
+	gin.SetMode(gin.TestMode)
+
+	var object map[string]any
+	if err := json.Unmarshal([]byte(bakedRoutingPayload), &object); err != nil {
+		t.Fatalf("payload: %v", err)
+	}
+	happDeeplink := "happ://routing/onadd/" + base64.StdEncoding.EncodeToString([]byte(mustMarshal(t, object)))
+	incyDeeplink := "incy://routing/onadd/" + base64.StdEncoding.EncodeToString([]byte(mustMarshal(t, map[string]any{"Name": "RoscomVPN"})))
+
+	cases := []struct {
+		name      string
+		jsonRules string
+		happRules string
+		want      string
+	}{
+		{name: "inline json becomes a happ deeplink", jsonRules: bakedRoutingPayload, want: happDeeplink},
+		{name: "happ deeplink passes through", jsonRules: happDeeplink, want: happDeeplink},
+		{name: "incy deeplink passes through", jsonRules: incyDeeplink, want: incyDeeplink},
+		{name: "blank profile keeps the header unset", jsonRules: "", want: ""},
+		{name: "unusable profile keeps the header unset", jsonRules: "happ://routing/onadd/%%%", want: ""},
+		{name: "explicit happ rules take precedence", jsonRules: bakedRoutingPayload, happRules: "happ://routing/onadd/" + base64.StdEncoding.EncodeToString([]byte(`{"A":1}`)), want: "happ://routing/onadd/" + base64.StdEncoding.EncodeToString([]byte(`{"A":1}`))},
+	}
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			recorder := httptest.NewRecorder()
+			ctx, _ := gin.CreateTestContext(recorder)
+			(&SUBController{subJsonRoutingRules: tc.jsonRules}).ApplyCommonHeaders(ctx, "", "12", "", "", "", "", false, tc.happRules, false)
+			if got := recorder.Header().Get("Routing"); got != tc.want {
+				t.Fatalf("Routing = %q, want %q", got, tc.want)
+			}
+		})
+	}
+}
+
+func TestApplyCommonHeadersJsonRoutingRemoteFailsClosed(t *testing.T) {
+	gin.SetMode(gin.TestMode)
+	oldResolver := routingSourceResolver
+	t.Cleanup(func() { routingSourceResolver = oldResolver })
+
+	const source = "https://example.com/DEFAULT.JSON"
+	routingSourceResolver = newRemoteRoutingResolver(remoteRoutingTestClient(func(*http.Request) (*http.Response, error) {
+		return remoteRoutingResponse(200, mustMarshal(t, fullRoutingPayload())), nil
+	}), false)
+
+	recorder := httptest.NewRecorder()
+	ctx, _ := gin.CreateTestContext(recorder)
+	(&SUBController{subJsonRoutingRules: source}).ApplyCommonHeaders(ctx, "", "12", "", "", "", "", false, "", false)
+	if got := recorder.Header().Get("Routing"); got != "" {
+		t.Fatalf("cold cache must keep the header unset, got %q", got)
+	}
+
+	primeRemoteRouting(t, routingSourceResolver, remoteRoutingJson, source)
+	recorder = httptest.NewRecorder()
+	ctx, _ = gin.CreateTestContext(recorder)
+	(&SUBController{subJsonRoutingRules: source}).ApplyCommonHeaders(ctx, "", "12", "", "", "", "", false, "", false)
+	got := recorder.Header().Get("Routing")
+	if !strings.HasPrefix(got, "happ://routing/onadd/") {
+		t.Fatalf("warm cache Routing = %q", got)
+	}
+	decoded, err := decodeRoutingBase64(strings.TrimPrefix(got, "happ://routing/onadd/"))
+	if err != nil {
+		t.Fatalf("deeplink payload: %v", err)
+	}
+	var payload map[string]any
+	if err := json.Unmarshal(decoded, &payload); err != nil {
+		t.Fatalf("deeplink JSON: %v", err)
+	}
+	if payload["Name"] != "RoscomVPN" {
+		t.Fatalf("deeplink payload = %v", payload)
+	}
+	waitRemoteRoutingIdle(t, routingSourceResolver)
+}
+
+func TestSubJson_BakedRoutingRemoteUpdateReachesDocuments(t *testing.T) {
+	seedSubDB(t)
+	seedSubInbound(t, "s1", "tcpin", 4807, 1, `{"network":"tcp","security":"tls","tlsSettings":{"serverName":"base.sni"}}`)
+
+	oldResolver := routingSourceResolver
+	t.Cleanup(func() { routingSourceResolver = oldResolver })
+	const source = "https://example.com/DEFAULT.JSON"
+
+	current := mustMarshal(t, fullRoutingPayload())
+	routingSourceResolver = newRemoteRoutingResolver(remoteRoutingTestClient(func(*http.Request) (*http.Response, error) {
+		return remoteRoutingResponse(200, current), nil
+	}), false)
+
+	js := NewSubJsonService("", "", "", source, NewSubService(""))
+	// A cold resolver fails closed (default routing); prime the cache first.
+	primeRemoteRouting(t, routingSourceResolver, remoteRoutingJson, source)
+	out, _, err := js.GetJson("s1", "req.example.com", true)
+	if err != nil {
+		t.Fatalf("GetJson: %v", err)
+	}
+	docs := parseSubJsonDocs(t, out)
+	routing, _ := docs[0]["routing"].(map[string]any)
+	if routing["domainStrategy"] != "IPIfNonMatch" {
+		t.Fatalf("first doc must carry the profile: %v", routing["domainStrategy"])
+	}
+
+	// The operator edits the published profile; after the cache TTL expires,
+	// the next request must re-bake the template with the new payload.
+	updated := fullRoutingPayload()
+	updated["DomainStrategy"] = "AsIs"
+	current = mustMarshal(t, updated)
+	waitRemoteRoutingIdle(t, routingSourceResolver)
+	staleKey := remoteRoutingKey{kind: remoteRoutingJson, source: source}
+	routingSourceResolver.mu.Lock()
+	entry := routingSourceResolver.entries[staleKey]
+	entry.FetchedAt = time.Now().Add(-remoteRoutingCacheTTL - time.Minute).Unix()
+	routingSourceResolver.entries[staleKey] = entry
+	delete(routingSourceResolver.lastAttempt, staleKey)
+	routingSourceResolver.mu.Unlock()
+	primeRemoteRouting(t, routingSourceResolver, remoteRoutingJson, source)
+	out, _, err = js.GetJson("s1", "req.example.com", true)
+	if err != nil {
+		t.Fatalf("GetJson: %v", err)
+	}
+	docs = parseSubJsonDocs(t, out)
+	routing, _ = docs[0]["routing"].(map[string]any)
+	if routing["domainStrategy"] != "AsIs" {
+		t.Fatalf("updated profile must reach the documents without a restart: %v", routing["domainStrategy"])
+	}
+	waitRemoteRoutingIdle(t, routingSourceResolver)
+}
+
+func TestRemoteRoutingJsonHasItsOwnPersistedRow(t *testing.T) {
+	seedSubDB(t)
+	const source = "https://example.com/DEFAULT.JSON"
+
+	// Two happ-payload settings pointing at different sources must not
+	// overwrite each other's persisted cache rows.
+	happSource := "https://example.com/HAPP.json"
+	for _, tc := range []struct {
+		kind    remoteRoutingKind
+		source  string
+		payload string
+	}{
+		{kind: remoteRoutingHapp, source: happSource, payload: `{"Name":"happ-profile"}`},
+		{kind: remoteRoutingJson, source: source, payload: `{"Name":"json-profile"}`},
+	} {
+		deeplink, err := normalizeHappRouting([]byte(tc.payload))
+		if err != nil {
+			t.Fatalf("normalize: %v", err)
+		}
+		newRemoteRoutingResolver(nil, false).persistEntry(tc.kind, remoteRoutingCacheEntry{
+			Source: tc.source, Content: deeplink, FetchedAt: time.Now().Unix(),
+		})
+	}
+
+	for _, tc := range []struct {
+		kind   remoteRoutingKind
+		source string
+		want   string
+	}{
+		{kind: remoteRoutingHapp, source: happSource, want: "happ-profile"},
+		{kind: remoteRoutingJson, source: source, want: "json-profile"},
+	} {
+		resolver := newRemoteRoutingResolver(nil, true)
+		resolver.now = func() time.Time { return time.Unix(1_800_000_000, 0) }
+		resolver.ensurePersistedLoaded()
+		got, remote, err := resolver.resolve(tc.kind, tc.source)
+		if err != nil || !remote {
+			t.Fatalf("resolve kind=%s: remote=%v err=%v", tc.kind, remote, err)
+		}
+		decoded, err := decodeRoutingBase64(strings.TrimPrefix(got, "happ://routing/onadd/"))
+		if err != nil {
+			t.Fatalf("decode kind=%s: %v", tc.kind, err)
+		}
+		var payload map[string]any
+		if json.Unmarshal(decoded, &payload) != nil || payload["Name"] != tc.want {
+			t.Fatalf("kind=%s payload = %s", tc.kind, decoded)
+		}
+	}
+}
+
+const maxSubLogScan = 10240
+
+func routingWarningCount(t *testing.T) int {
+	t.Helper()
+	n := 0
+	for _, line := range logger.GetLogs(maxSubLogScan, "warning") {
+		if strings.Contains(line, "subJsonRoutingRules") {
+			n++
+		}
+	}
+	return n
+}
+
+// A public subscription fetch must not write one warning per emitted document:
+// the 10k in-memory buffer the panel's log view reads is evicted by the flood.
+func TestSubJson_BadRoutingProfileWarnsOncePerRequest(t *testing.T) {
+	seedSubDB(t)
+	for i, name := range []string{"w1", "w2", "w3", "w4", "w5", "w6"} {
+		seedSubInbound(t, "s1", name, 4870+i, 1, `{"network":"tcp","security":"none"}`)
+	}
+
+	js := NewSubJsonService("", "", "", "not json at all", NewSubService(""))
+	before := routingWarningCount(t)
+	out, _, err := js.GetJson("s1", "req.example.com", true)
+	if err != nil {
+		t.Fatalf("GetJson: %v", err)
+	}
+	docs := parseSubJsonDocs(t, out)
+	if len(docs) < 6 {
+		t.Fatalf("docs = %d, want >= 6:\n%s", len(docs), out)
+	}
+	if got := routingWarningCount(t) - before; got > 1 {
+		t.Fatalf("one request emitting %d documents logged %d warnings, want at most 1", len(docs), got)
+	}
+}

+ 192 - 0
internal/sub/json_routing_test.go

@@ -0,0 +1,192 @@
+package sub
+
+import (
+	"encoding/base64"
+	"encoding/json"
+	"net/http"
+	"strings"
+	"testing"
+)
+
+func mustMarshal(t *testing.T, v any) string {
+	t.Helper()
+	data, err := json.Marshal(v)
+	if err != nil {
+		t.Fatalf("marshal: %v", err)
+	}
+	return string(data)
+}
+
+func b64Std(s string) string { return base64.StdEncoding.EncodeToString([]byte(s)) }
+func b64URL(s string) string { return base64.RawURLEncoding.EncodeToString([]byte(s)) }
+
+func fullRoutingPayload() map[string]any {
+	return map[string]any{
+		"Name":              "RoscomVPN",
+		"DomainStrategy":    "IPIfNonMatch",
+		"RemoteDNSDomain":   "https://8.8.8.8/dns-query",
+		"RemoteDNSIP":       "8.8.8.8",
+		"DomesticDNSDomain": "https://77.88.8.8/dns-query",
+		"DomesticDNSIP":     "77.88.8.8",
+		"DnsHosts":          map[string]any{"lknpd.nalog.ru": "213.24.64.181"},
+		"RouteOrder":        "block-proxy-direct",
+		"DirectSites":       []any{"geosite:category-ru", "geosite:private"},
+		"DirectIp":          []any{"geoip:private"},
+		"ProxySites":        []any{"geosite:youtube"},
+		"ProxyIp":           []any{},
+		"BlockSites":        []any{"geosite:category-ads"},
+		"BlockIp":           []any{},
+	}
+}
+
+func TestParseJsonRoutingSpecMapsAllFields(t *testing.T) {
+	spec, remote, err := parseJsonRoutingSpec(mustMarshal(t, fullRoutingPayload()))
+	if err != nil || remote {
+		t.Fatalf("parse: err=%v remote=%v", err, remote)
+	}
+	want := jsonRoutingSpec{
+		DomainStrategy:    "IPIfNonMatch",
+		RemoteDNSDomain:   "https://8.8.8.8/dns-query",
+		RemoteDNSIP:       "8.8.8.8",
+		DomesticDNSDomain: "https://77.88.8.8/dns-query",
+		DomesticDNSIP:     "77.88.8.8",
+		DnsHosts:          map[string]string{"lknpd.nalog.ru": "213.24.64.181"},
+		RouteOrder:        []string{"block", "proxy", "direct"},
+		DirectSites:       []string{"geosite:category-ru", "geosite:private"},
+		DirectIp:          []string{"geoip:private"},
+		ProxySites:        []string{"geosite:youtube"},
+		BlockSites:        []string{"geosite:category-ads"},
+	}
+	if spec.DomainStrategy != want.DomainStrategy || spec.RemoteDNSIP != want.RemoteDNSIP ||
+		spec.DomesticDNSDomain != want.DomesticDNSDomain || len(spec.DnsHosts) != 1 || spec.DnsHosts["lknpd.nalog.ru"] != "213.24.64.181" ||
+		strings.Join(spec.RouteOrder, ",") != strings.Join(want.RouteOrder, ",") ||
+		strings.Join(spec.DirectSites, ",") != strings.Join(want.DirectSites, ",") ||
+		strings.Join(spec.DirectIp, ",") != strings.Join(want.DirectIp, ",") ||
+		strings.Join(spec.ProxySites, ",") != strings.Join(want.ProxySites, ",") ||
+		strings.Join(spec.BlockSites, ",") != strings.Join(want.BlockSites, ",") {
+		t.Fatalf("spec = %+v\nwant %+v", spec, want)
+	}
+}
+
+func TestParseJsonRoutingSpecPartialPayload(t *testing.T) {
+	spec, _, err := parseJsonRoutingSpec(`{"DirectSites":["geosite:private"],"DomainStrategy":"AsIs"}`)
+	if err != nil {
+		t.Fatalf("parse: %v", err)
+	}
+	if spec.DomainStrategy != "AsIs" || len(spec.DirectSites) != 1 || spec.DirectSites[0] != "geosite:private" {
+		t.Fatalf("spec = %+v", spec)
+	}
+	if len(spec.RouteOrder) != 0 || len(spec.DnsHosts) != 0 || spec.RemoteDNSIP != "" {
+		t.Fatalf("unset fields must stay zero: %+v", spec)
+	}
+	if spec.empty() {
+		t.Fatalf("empty() must report false when any field is set: %+v", spec)
+	}
+}
+
+func TestParseJsonRoutingSpecRouteOrderUnknownSegments(t *testing.T) {
+	spec, _, err := parseJsonRoutingSpec(`{"RouteOrder":"block-foo-direct"}`)
+	if err != nil {
+		t.Fatalf("parse: %v", err)
+	}
+	if strings.Join(spec.RouteOrder, ",") != "block,direct" {
+		t.Fatalf("RouteOrder = %v", spec.RouteOrder)
+	}
+}
+
+func TestParseJsonRoutingSpecDeeplinks(t *testing.T) {
+	payload := mustMarshal(t, fullRoutingPayload())
+	cases := []string{
+		"happ://routing/onadd/" + b64Std(payload),
+		"incy://routing/onadd/" + b64Std(payload),
+		"happ://routing/onadd/" + b64URL(payload),
+	}
+	for _, raw := range cases {
+		spec, _, err := parseJsonRoutingSpec(raw)
+		if err != nil {
+			t.Fatalf("parse %q: %v", raw[:32], err)
+		}
+		if spec.DomainStrategy != "IPIfNonMatch" || len(spec.DirectSites) != 2 || spec.RouteOrder[1] != "proxy" {
+			t.Fatalf("spec from %q = %+v", raw[:32], spec)
+		}
+	}
+}
+
+func TestParseJsonRoutingSpecRejectsBadPayloads(t *testing.T) {
+	cases := []string{
+		"not json at all",
+		"[1,2,3]",
+		`{"DirectSites":"geosite:private"}`,
+		`{"DirectSites":["a",1]}`,
+		`{"DnsHosts":{"a":1}}`,
+		`{"DomainStrategy":5}`,
+		"happ://routing/onadd/!!!!not-base64!!!!",
+	}
+	for _, raw := range cases {
+		if _, _, err := parseJsonRoutingSpec(raw); err == nil {
+			t.Fatalf("payload %q was accepted", raw)
+		}
+	}
+}
+
+func TestParseJsonRoutingSpecEmpty(t *testing.T) {
+	for _, raw := range []string{"", "   ", "\n"} {
+		spec, remote, err := parseJsonRoutingSpec(raw)
+		if err != nil || remote || !spec.empty() {
+			t.Fatalf("raw=%q spec=%+v remote=%v err=%v", raw, spec, remote, err)
+		}
+	}
+}
+
+func TestParseJsonRoutingSpecRemoteURL(t *testing.T) {
+	oldResolver := routingSourceResolver
+	t.Cleanup(func() { routingSourceResolver = oldResolver })
+	routingSourceResolver = newRemoteRoutingResolver(remoteRoutingTestClient(func(*http.Request) (*http.Response, error) {
+		return remoteRoutingResponse(200, mustMarshal(t, fullRoutingPayload())), nil
+	}), false)
+
+	const source = "https://example.com/DEFAULT.JSON"
+	primeRemoteRouting(t, routingSourceResolver, remoteRoutingJson, source)
+	spec, _, err := parseJsonRoutingSpec(source)
+	if err != nil {
+		t.Fatalf("parse: err=%v", err)
+	}
+	if spec.DomainStrategy != "IPIfNonMatch" || len(spec.BlockSites) != 1 {
+		t.Fatalf("spec = %+v", spec)
+	}
+}
+
+func TestParseJsonRoutingSpecRemoteUnavailable(t *testing.T) {
+	oldResolver := routingSourceResolver
+	t.Cleanup(func() { routingSourceResolver = oldResolver })
+	routingSourceResolver = newRemoteRoutingResolver(remoteRoutingTestClient(func(*http.Request) (*http.Response, error) {
+		return remoteRoutingResponse(200, "routing.help"), nil
+	}), false)
+	if _, _, err := parseJsonRoutingSpec("https://example.com/bad"); err == nil {
+		t.Fatal("unavailable remote source must error")
+	}
+}
+
+// normalizeHappRouting accepts happ://routing/add/ as a routing deeplink
+// (remote_routing.go), so the baked-JSON parser has to accept it too.
+func TestParseJsonRoutingSpecAcceptsAddDeeplink(t *testing.T) {
+	payload := mustMarshal(t, fullRoutingPayload())
+	for _, prefix := range []string{"happ://routing/onadd/", "happ://routing/add/", "incy://routing/onadd/"} {
+		t.Run(prefix, func(t *testing.T) {
+			if _, err := normalizeHappRouting([]byte(prefix + b64Std(payload))); err != nil &&
+				!strings.HasPrefix(prefix, "incy://") {
+				t.Fatalf("normalizeHappRouting rejects %s: %v", prefix, err)
+			}
+			spec, remote, err := parseJsonRoutingSpec(prefix + b64Std(payload))
+			if err != nil || remote {
+				t.Fatalf("parse %s: err=%v remote=%v", prefix, err, remote)
+			}
+			if spec.empty() {
+				t.Fatalf("parse %s: spec is empty, routing would not be baked", prefix)
+			}
+			if spec.DomainStrategy != "IPIfNonMatch" {
+				t.Fatalf("parse %s: DomainStrategy = %q", prefix, spec.DomainStrategy)
+			}
+		})
+	}
+}

+ 50 - 9
internal/sub/json_service.go

@@ -9,6 +9,7 @@ import (
 	"slices"
 	"slices"
 	"sort"
 	"sort"
 	"strings"
 	"strings"
+	"sync"
 	"time"
 	"time"
 
 
 	"github.com/mhsanaei/3x-ui/v3/internal/database"
 	"github.com/mhsanaei/3x-ui/v3/internal/database"
@@ -30,11 +31,22 @@ type SubJsonService struct {
 	mux              string
 	mux              string
 	observatory      subBalancerObservatoryConfig
 	observatory      subBalancerObservatoryConfig
 
 
+	// bakedRouting is re-resolved per request: a remote URL may be cold at
+	// construction time and warm up later via the cron job.
+	routingRules   string
+	bakedRoutingMu sync.Mutex
+	bakedRouting   *bakedRoutingState
+
 	SubService *SubService
 	SubService *SubService
 }
 }
 
 
+type bakedRoutingState struct {
+	spec       jsonRoutingSpec
+	configJson map[string]any
+}
+
 // NewSubJsonService creates a new JSON subscription service with the given configuration.
 // NewSubJsonService creates a new JSON subscription service with the given configuration.
-func NewSubJsonService(mux string, rules string, finalMask string, subService *SubService) *SubJsonService {
+func NewSubJsonService(mux string, rules string, finalMask string, routingRules string, subService *SubService) *SubJsonService {
 	var configJson map[string]any
 	var configJson map[string]any
 	var defaultOutbounds []json_util.RawMessage
 	var defaultOutbounds []json_util.RawMessage
 	_ = json.Unmarshal([]byte(defaultJson), &configJson)
 	_ = json.Unmarshal([]byte(defaultJson), &configJson)
@@ -45,7 +57,9 @@ func NewSubJsonService(mux string, rules string, finalMask string, subService *S
 		}
 		}
 	}
 	}
 
 
-	if rules != "" {
+	// A baked routing profile replaces the template's dns and routing subtrees
+	// outright; the legacy simple-rules setting only applies without a profile.
+	if routingRules == "" && rules != "" {
 		var newRules []any
 		var newRules []any
 		routing, _ := configJson["routing"].(map[string]any)
 		routing, _ := configJson["routing"].(map[string]any)
 		defaultRules, _ := routing["rules"].([]any)
 		defaultRules, _ := routing["rules"].([]any)
@@ -60,11 +74,35 @@ func NewSubJsonService(mux string, rules string, finalMask string, subService *S
 		defaultOutbounds: defaultOutbounds,
 		defaultOutbounds: defaultOutbounds,
 		finalMask:        finalMask,
 		finalMask:        finalMask,
 		mux:              mux,
 		mux:              mux,
+		routingRules:     routingRules,
 		observatory:      defaultSubBalancerObservatoryConfig(),
 		observatory:      defaultSubBalancerObservatoryConfig(),
 		SubService:       subService,
 		SubService:       subService,
 	}
 	}
 }
 }
 
 
+// Re-resolved per call so an upstream edit reaches the documents without a
+// restart; a failed resolve keeps the last good template.
+func (s *SubJsonService) bakedTemplate() map[string]any {
+	if s.routingRules == "" {
+		return s.configJson
+	}
+	spec := resolveJsonRoutingSpec(s.routingRules)
+	s.bakedRoutingMu.Lock()
+	defer s.bakedRoutingMu.Unlock()
+	if s.bakedRouting != nil {
+		if spec.empty() || spec.equal(s.bakedRouting.spec) {
+			return s.bakedRouting.configJson
+		}
+	} else if spec.empty() {
+		return s.configJson
+	}
+	template := make(map[string]any, len(s.configJson)+2)
+	maps.Copy(template, s.configJson)
+	applyJsonRouting(template, spec)
+	s.bakedRouting = &bakedRoutingState{spec: spec, configJson: template}
+	return template
+}
+
 // GetJson generates a JSON subscription configuration for the given subscription ID and host.
 // GetJson generates a JSON subscription configuration for the given subscription ID and host.
 func (s *SubJsonService) GetJson(subId string, host string, alwaysReturnArray bool) (string, string, error) {
 func (s *SubJsonService) GetJson(subId string, host string, alwaysReturnArray bool) (string, string, error) {
 	subReq := s.SubService.ForRequest(host)
 	subReq := s.SubService.ForRequest(host)
@@ -153,7 +191,7 @@ func (s *SubJsonService) GetJson(subId string, host string, alwaysReturnArray bo
 			newOutbounds := []json_util.RawMessage{outbound}
 			newOutbounds := []json_util.RawMessage{outbound}
 			newOutbounds = append(newOutbounds, s.defaultOutbounds...)
 			newOutbounds = append(newOutbounds, s.defaultOutbounds...)
 			newConfigJson := make(map[string]any)
 			newConfigJson := make(map[string]any)
-			maps.Copy(newConfigJson, s.configJson)
+			maps.Copy(newConfigJson, s.bakedTemplate())
 			newConfigJson["outbounds"] = newOutbounds
 			newConfigJson["outbounds"] = newOutbounds
 			newConfigJson["remarks"] = remark
 			newConfigJson["remarks"] = remark
 			newConfig, _ := json.MarshalIndent(newConfigJson, "", "  ")
 			newConfig, _ := json.MarshalIndent(newConfigJson, "", "  ")
@@ -455,9 +493,12 @@ func (s *SubJsonService) buildBalancerConfig(balancer *model.SubBalancer, entrie
 	outbounds := append([]json_util.RawMessage{}, proxies...)
 	outbounds := append([]json_util.RawMessage{}, proxies...)
 	outbounds = append(outbounds, s.defaultOutbounds...)
 	outbounds = append(outbounds, s.defaultOutbounds...)
 
 
-	// The routing subtree in s.configJson is shared by every emitted document;
-	// clone it (and each rule map) before pointing rules at the balancer.
-	baseRouting, _ := s.configJson["routing"].(map[string]any)
+	// One template per document: two resolves could straddle a profile refresh
+	// and pair this document's dns with the other revision's routing.
+	template := s.bakedTemplate()
+	// Clone the shared routing subtree (and each rule map) before pointing
+	// rules at the balancer.
+	baseRouting, _ := template["routing"].(map[string]any)
 	routing := make(map[string]any, len(baseRouting)+1)
 	routing := make(map[string]any, len(baseRouting)+1)
 	maps.Copy(routing, baseRouting)
 	maps.Copy(routing, baseRouting)
 	baseRules, _ := baseRouting["rules"].([]any)
 	baseRules, _ := baseRouting["rules"].([]any)
@@ -493,8 +534,8 @@ func (s *SubJsonService) buildBalancerConfig(balancer *model.SubBalancer, entrie
 	}
 	}
 	routing["balancers"] = []any{balancerEntry}
 	routing["balancers"] = []any{balancerEntry}
 
 
-	newConfigJson := make(map[string]any, len(s.configJson)+2)
-	maps.Copy(newConfigJson, s.configJson)
+	newConfigJson := make(map[string]any, len(template)+2)
+	maps.Copy(newConfigJson, template)
 	newConfigJson["outbounds"] = outbounds
 	newConfigJson["outbounds"] = outbounds
 	newConfigJson["remarks"] = balancer.Remark
 	newConfigJson["remarks"] = balancer.Remark
 	newConfigJson["routing"] = routing
 	newConfigJson["routing"] = routing
@@ -614,7 +655,7 @@ func (s *SubJsonService) getConfig(subReq *SubService, inbound *model.Inbound, c
 
 
 		newOutbounds = append(newOutbounds, s.defaultOutbounds...)
 		newOutbounds = append(newOutbounds, s.defaultOutbounds...)
 		newConfigJson := make(map[string]any)
 		newConfigJson := make(map[string]any)
-		maps.Copy(newConfigJson, s.configJson)
+		maps.Copy(newConfigJson, s.bakedTemplate())
 
 
 		transport, _ := newStream["network"].(string)
 		transport, _ := newStream["network"].(string)
 		newConfigJson["outbounds"] = newOutbounds
 		newConfigJson["outbounds"] = newOutbounds

+ 21 - 21
internal/sub/json_service_test.go

@@ -36,7 +36,7 @@ func outboundSettings(t *testing.T, raw []byte) map[string]any {
 }
 }
 
 
 func TestDefaultJSONUsesCompatibleLocalInbounds(t *testing.T) {
 func TestDefaultJSONUsesCompatibleLocalInbounds(t *testing.T) {
-	svc := NewSubJsonService("", "", "", nil)
+	svc := NewSubJsonService("", "", "", "", nil)
 	inbounds, ok := svc.configJson["inbounds"].([]any)
 	inbounds, ok := svc.configJson["inbounds"].([]any)
 	if !ok {
 	if !ok {
 		t.Fatalf("default JSON inbounds = %#v, want array", svc.configJson["inbounds"])
 		t.Fatalf("default JSON inbounds = %#v, want array", svc.configJson["inbounds"])
@@ -81,7 +81,7 @@ func TestDefaultJSONUsesCompatibleLocalInbounds(t *testing.T) {
 
 
 func TestSubJsonServiceVisionFlowDisablesTCPMuxOnly(t *testing.T) {
 func TestSubJsonServiceVisionFlowDisablesTCPMuxOnly(t *testing.T) {
 	globalMux := `{"enabled":true,"concurrency":8,"xudpConcurrency":16,"xudpProxyUDP443":"reject"}`
 	globalMux := `{"enabled":true,"concurrency":8,"xudpConcurrency":16,"xudpProxyUDP443":"reject"}`
-	svc := NewSubJsonService(globalMux, "", "", nil)
+	svc := NewSubJsonService(globalMux, "", "", "", nil)
 	inbound := &model.Inbound{Listen: "1.2.3.4", Port: 443, Protocol: model.VLESS, Settings: `{"encryption":"none"}`}
 	inbound := &model.Inbound{Listen: "1.2.3.4", Port: 443, Protocol: model.VLESS, Settings: `{"encryption":"none"}`}
 
 
 	decode := func(raw []byte) map[string]any {
 	decode := func(raw []byte) map[string]any {
@@ -119,7 +119,7 @@ func TestSubJsonServiceVisionFlowDisablesTCPMuxOnly(t *testing.T) {
 
 
 func TestSubJsonServiceInjectsGlobalFinalMask(t *testing.T) {
 func TestSubJsonServiceInjectsGlobalFinalMask(t *testing.T) {
 	finalMask := `{"tcp":[{"type":"fragment","settings":{"packets":"tlshello","length":"100-200","delay":"10-20"}}],"udp":[{"type":"noise","settings":{"noise":[{"type":"base64","packet":"SGVsbG8="}]}}],"quicParams":{"congestion":"bbr"}}`
 	finalMask := `{"tcp":[{"type":"fragment","settings":{"packets":"tlshello","length":"100-200","delay":"10-20"}}],"udp":[{"type":"noise","settings":{"noise":[{"type":"base64","packet":"SGVsbG8="}]}}],"quicParams":{"congestion":"bbr"}}`
-	svc := NewSubJsonService("", "", finalMask, nil)
+	svc := NewSubJsonService("", "", finalMask, "", nil)
 
 
 	if hasDirectOutOutbound(svc) {
 	if hasDirectOutOutbound(svc) {
 		t.Fatal("direct_out outbound must never be emitted")
 		t.Fatal("direct_out outbound must never be emitted")
@@ -156,7 +156,7 @@ func TestSubJsonServiceInjectsGlobalFinalMask(t *testing.T) {
 
 
 func TestSubJsonServiceMergesWithExistingFinalMask(t *testing.T) {
 func TestSubJsonServiceMergesWithExistingFinalMask(t *testing.T) {
 	finalMask := `{"tcp":[{"type":"fragment","settings":{"packets":"tlshello"}}]}`
 	finalMask := `{"tcp":[{"type":"fragment","settings":{"packets":"tlshello"}}]}`
-	svc := NewSubJsonService("", "", finalMask, nil)
+	svc := NewSubJsonService("", "", finalMask, "", nil)
 
 
 	stream := svc.streamData(`{
 	stream := svc.streamData(`{
 		"network":"tcp","security":"none","tcpSettings":{"header":{"type":"none"}},
 		"network":"tcp","security":"none","tcpSettings":{"header":{"type":"none"}},
@@ -176,7 +176,7 @@ func TestSubJsonServiceMergesWithExistingFinalMask(t *testing.T) {
 }
 }
 
 
 func TestSubJsonServiceNoFinalMaskWhenEmpty(t *testing.T) {
 func TestSubJsonServiceNoFinalMaskWhenEmpty(t *testing.T) {
-	svc := NewSubJsonService("", "", "", nil)
+	svc := NewSubJsonService("", "", "", "", nil)
 	stream := svc.streamData(`{"network":"tcp","security":"none","tcpSettings":{"header":{"type":"none"}}}`, "")
 	stream := svc.streamData(`{"network":"tcp","security":"none","tcpSettings":{"header":{"type":"none"}}}`, "")
 	if _, ok := stream["finalmask"]; ok {
 	if _, ok := stream["finalmask"]; ok {
 		t.Fatal("no finalmask should be emitted when subJsonFinalMask is empty")
 		t.Fatal("no finalmask should be emitted when subJsonFinalMask is empty")
@@ -190,7 +190,7 @@ func TestSubJsonServiceNoFinalMaskWhenEmpty(t *testing.T) {
 // the JSON subscription must emit that form, not an array, or v2ray clients fail
 // the JSON subscription must emit that form, not an array, or v2ray clients fail
 // to import the config (#5401).
 // to import the config (#5401).
 func TestSubJsonServicePinnedCertJoinedToString(t *testing.T) {
 func TestSubJsonServicePinnedCertJoinedToString(t *testing.T) {
-	svc := NewSubJsonService("", "", "", nil)
+	svc := NewSubJsonService("", "", "", "", nil)
 	stream := svc.streamData(`{"network":"tcp","security":"tls","tlsSettings":{"serverName":"a.example.com","settings":{"pinnedPeerCertSha256":["aa11","bb22"]}}}`, "")
 	stream := svc.streamData(`{"network":"tcp","security":"tls","tlsSettings":{"serverName":"a.example.com","settings":{"pinnedPeerCertSha256":["aa11","bb22"]}}}`, "")
 
 
 	tls, _ := stream["tlsSettings"].(map[string]any)
 	tls, _ := stream["tlsSettings"].(map[string]any)
@@ -203,7 +203,7 @@ func TestSubJsonServicePinnedCertJoinedToString(t *testing.T) {
 }
 }
 
 
 func TestSubJsonServiceTLSCipherSuitesForwarded(t *testing.T) {
 func TestSubJsonServiceTLSCipherSuitesForwarded(t *testing.T) {
-	svc := NewSubJsonService("", "", "", nil)
+	svc := NewSubJsonService("", "", "", "", nil)
 	stream := svc.streamData(`{"network":"tcp","security":"tls","tlsSettings":{"serverName":"a.example.com","cipherSuites":"TLS_AES_256_GCM_SHA384","settings":{}}}`, "")
 	stream := svc.streamData(`{"network":"tcp","security":"tls","tlsSettings":{"serverName":"a.example.com","cipherSuites":"TLS_AES_256_GCM_SHA384","settings":{}}}`, "")
 
 
 	tls, _ := stream["tlsSettings"].(map[string]any)
 	tls, _ := stream["tlsSettings"].(map[string]any)
@@ -222,7 +222,7 @@ func TestSubJsonServiceVlessFlattened(t *testing.T) {
 	inbound := &model.Inbound{Listen: "1.2.3.4", Port: 443, Protocol: model.VLESS, Settings: `{"encryption":"none"}`}
 	inbound := &model.Inbound{Listen: "1.2.3.4", Port: 443, Protocol: model.VLESS, Settings: `{"encryption":"none"}`}
 	client := model.Client{ID: "uuid-1", Flow: "xtls-rprx-vision"}
 	client := model.Client{ID: "uuid-1", Flow: "xtls-rprx-vision"}
 
 
-	settings := outboundSettings(t, NewSubJsonService("", "", "", nil).genVless(&SubService{}, inbound, nil, client, ""))
+	settings := outboundSettings(t, NewSubJsonService("", "", "", "", nil).genVless(&SubService{}, inbound, nil, client, ""))
 	if _, ok := settings["vnext"]; ok {
 	if _, ok := settings["vnext"]; ok {
 		t.Fatal("vless outbound must not use vnext")
 		t.Fatal("vless outbound must not use vnext")
 	}
 	}
@@ -235,7 +235,7 @@ func TestSubJsonServiceVlessFlowSuppressedByDisableFlow(t *testing.T) {
 	inbound := &model.Inbound{Listen: "1.2.3.4", Port: 443, Protocol: model.VLESS, Settings: `{"encryption":"none"}`, DisableFlow: true}
 	inbound := &model.Inbound{Listen: "1.2.3.4", Port: 443, Protocol: model.VLESS, Settings: `{"encryption":"none"}`, DisableFlow: true}
 	client := model.Client{ID: "uuid-1", Flow: "xtls-rprx-vision"}
 	client := model.Client{ID: "uuid-1", Flow: "xtls-rprx-vision"}
 
 
-	settings := outboundSettings(t, NewSubJsonService("", "", "", nil).genVless(&SubService{}, inbound, nil, client, ""))
+	settings := outboundSettings(t, NewSubJsonService("", "", "", "", nil).genVless(&SubService{}, inbound, nil, client, ""))
 	if _, ok := settings["flow"]; ok {
 	if _, ok := settings["flow"]; ok {
 		t.Fatalf("DisableFlow inbound must not carry a flow in the JSON outbound: %#v", settings)
 		t.Fatalf("DisableFlow inbound must not carry a flow in the JSON outbound: %#v", settings)
 	}
 	}
@@ -245,7 +245,7 @@ func TestSubJsonServiceVmessFlattened(t *testing.T) {
 	inbound := &model.Inbound{Listen: "1.2.3.4", Port: 443, Protocol: model.VMESS, Settings: `{}`}
 	inbound := &model.Inbound{Listen: "1.2.3.4", Port: 443, Protocol: model.VMESS, Settings: `{}`}
 	client := model.Client{ID: "uuid-2"}
 	client := model.Client{ID: "uuid-2"}
 
 
-	settings := outboundSettings(t, NewSubJsonService("", "", "", nil).genVnext(inbound, nil, client, ""))
+	settings := outboundSettings(t, NewSubJsonService("", "", "", "", nil).genVnext(inbound, nil, client, ""))
 	if _, ok := settings["vnext"]; ok {
 	if _, ok := settings["vnext"]; ok {
 		t.Fatal("vmess outbound must not use vnext")
 		t.Fatal("vmess outbound must not use vnext")
 	}
 	}
@@ -261,7 +261,7 @@ func TestSubJsonServiceServerUsesServersArray(t *testing.T) {
 	trojan := &model.Inbound{Listen: "1.2.3.4", Port: 443, Protocol: model.Trojan, Settings: `{}`}
 	trojan := &model.Inbound{Listen: "1.2.3.4", Port: 443, Protocol: model.Trojan, Settings: `{}`}
 	client := model.Client{Password: "p4ss"}
 	client := model.Client{Password: "p4ss"}
 
 
-	settings := outboundSettings(t, NewSubJsonService("", "", "", nil).genServer(&SubService{}, trojan, nil, client, ""))
+	settings := outboundSettings(t, NewSubJsonService("", "", "", "", nil).genServer(&SubService{}, trojan, nil, client, ""))
 	server := firstServer(settings)
 	server := firstServer(settings)
 	if server == nil {
 	if server == nil {
 		t.Fatalf("trojan outbound must use a servers array, got: %#v", settings)
 		t.Fatalf("trojan outbound must use a servers array, got: %#v", settings)
@@ -274,7 +274,7 @@ func TestSubJsonServiceServerUsesServersArray(t *testing.T) {
 	}
 	}
 
 
 	ss := &model.Inbound{Listen: "1.2.3.4", Port: 443, Protocol: model.Shadowsocks, Settings: `{"method":"aes-256-gcm"}`}
 	ss := &model.Inbound{Listen: "1.2.3.4", Port: 443, Protocol: model.Shadowsocks, Settings: `{"method":"aes-256-gcm"}`}
-	ssSettings := outboundSettings(t, NewSubJsonService("", "", "", nil).genServer(&SubService{}, ss, nil, client, ""))
+	ssSettings := outboundSettings(t, NewSubJsonService("", "", "", "", nil).genServer(&SubService{}, ss, nil, client, ""))
 	ssServer := firstServer(ssSettings)
 	ssServer := firstServer(ssSettings)
 	if ssServer == nil {
 	if ssServer == nil {
 		t.Fatalf("shadowsocks outbound must use a servers array, got: %#v", ssSettings)
 		t.Fatalf("shadowsocks outbound must use a servers array, got: %#v", ssSettings)
@@ -286,7 +286,7 @@ func TestSubJsonServiceServerUsesServersArray(t *testing.T) {
 
 
 func TestSubJsonServiceXmuxSuppressesGlobalMux(t *testing.T) {
 func TestSubJsonServiceXmuxSuppressesGlobalMux(t *testing.T) {
 	globalMux := `{"enabled":true,"concurrency":8}`
 	globalMux := `{"enabled":true,"concurrency":8}`
-	svc := NewSubJsonService(globalMux, "", "", nil)
+	svc := NewSubJsonService(globalMux, "", "", "", nil)
 
 
 	// When xmux is present in xhttpSettings, the per-inbound xmux handles
 	// When xmux is present in xhttpSettings, the per-inbound xmux handles
 	// multiplexing and the legacy outbound.Mux must NOT be set.
 	// multiplexing and the legacy outbound.Mux must NOT be set.
@@ -333,7 +333,7 @@ func TestSubJsonServiceXmuxSuppressesGlobalMux(t *testing.T) {
 
 
 func TestSubJsonServiceGlobalMuxWhenNoXmux(t *testing.T) {
 func TestSubJsonServiceGlobalMuxWhenNoXmux(t *testing.T) {
 	globalMux := `{"enabled":true,"concurrency":8}`
 	globalMux := `{"enabled":true,"concurrency":8}`
-	svc := NewSubJsonService(globalMux, "", "", nil)
+	svc := NewSubJsonService(globalMux, "", "", "", nil)
 
 
 	// When no xmux is present, the global subJsonMux should be used.
 	// When no xmux is present, the global subJsonMux should be used.
 	stream := `{"network":"xhttp","security":"tls","tlsSettings":{"serverName":"example.com"},"xhttpSettings":{"path":"/api","mode":"packet-up"}}`
 	stream := `{"network":"xhttp","security":"tls","tlsSettings":{"serverName":"example.com"},"xhttpSettings":{"path":"/api","mode":"packet-up"}}`
@@ -387,7 +387,7 @@ func realitySpiderXFromStream(t *testing.T, svc *SubJsonService, clientKey strin
 }
 }
 
 
 func TestSubJsonServiceRealityDataDerivesPerClientSpiderX(t *testing.T) {
 func TestSubJsonServiceRealityDataDerivesPerClientSpiderX(t *testing.T) {
-	svc := NewSubJsonService("", "", "", nil)
+	svc := NewSubJsonService("", "", "", "", nil)
 
 
 	alice := realitySpiderXFromStream(t, svc, "subAlice")
 	alice := realitySpiderXFromStream(t, svc, "subAlice")
 	if again := realitySpiderXFromStream(t, svc, "subAlice"); again != alice {
 	if again := realitySpiderXFromStream(t, svc, "subAlice"); again != alice {
@@ -403,13 +403,13 @@ func TestSubJsonServiceRealityDataDerivesPerClientSpiderX(t *testing.T) {
 // security whose settings key is missing or null previously panicked the
 // security whose settings key is missing or null previously panicked the
 // subscription request.
 // subscription request.
 func TestSubJsonServiceStreamDataMalformedInputs(t *testing.T) {
 func TestSubJsonServiceStreamDataMalformedInputs(t *testing.T) {
-	withMask := NewSubJsonService("", "", `{"tcp":[{"type":"fragment"}]}`, nil)
+	withMask := NewSubJsonService("", "", `{"tcp":[{"type":"fragment"}]}`, "", nil)
 	stream := withMask.streamData("not-json", "clientKey")
 	stream := withMask.streamData("not-json", "clientKey")
 	if _, ok := stream["finalmask"]; !ok {
 	if _, ok := stream["finalmask"]; !ok {
 		t.Fatal("finalMask must still apply when stream settings fail to parse")
 		t.Fatal("finalMask must still apply when stream settings fail to parse")
 	}
 	}
 
 
-	svc := NewSubJsonService("", "", "", nil)
+	svc := NewSubJsonService("", "", "", "", nil)
 	noReality := svc.streamData(`{"network":"tcp","security":"reality"}`, "clientKey")
 	noReality := svc.streamData(`{"network":"tcp","security":"reality"}`, "clientKey")
 	if v, ok := noReality["realitySettings"]; ok {
 	if v, ok := noReality["realitySettings"]; ok {
 		t.Fatalf("missing realitySettings must stay absent, got %v", v)
 		t.Fatalf("missing realitySettings must stay absent, got %v", v)
@@ -421,7 +421,7 @@ func TestSubJsonServiceStreamDataMalformedInputs(t *testing.T) {
 }
 }
 
 
 func TestSubJsonServiceRealityDataSpiderXFallsBackWhenNoClientKey(t *testing.T) {
 func TestSubJsonServiceRealityDataSpiderXFallsBackWhenNoClientKey(t *testing.T) {
-	svc := NewSubJsonService("", "", "", nil)
+	svc := NewSubJsonService("", "", "", "", nil)
 
 
 	stream := svc.streamData(`{
 	stream := svc.streamData(`{
 		"network":"tcp","security":"reality","tcpSettings":{"header":{"type":"none"}},
 		"network":"tcp","security":"reality","tcpSettings":{"header":{"type":"none"}},
@@ -466,7 +466,7 @@ func TestSubJsonServiceWireguard(t *testing.T) {
 		AllowedIPs:   []string{"10.0.0.2/32", "fd00::2/128"},
 		AllowedIPs:   []string{"10.0.0.2/32", "fd00::2/128"},
 	}
 	}
 
 
-	raw := NewSubJsonService("", "", "", nil).genWireguard(inbound, client)
+	raw := NewSubJsonService("", "", "", "", nil).genWireguard(inbound, client)
 	if raw == nil {
 	if raw == nil {
 		t.Fatal("genWireguard returned nil for a valid wireguard client")
 		t.Fatal("genWireguard returned nil for a valid wireguard client")
 	}
 	}
@@ -510,13 +510,13 @@ func TestSubJsonServiceWireguardNoKey(t *testing.T) {
 	inbound := &model.Inbound{Listen: "203.0.113.9", Port: 51820, Protocol: model.WireGuard, Settings: `{}`}
 	inbound := &model.Inbound{Listen: "203.0.113.9", Port: 51820, Protocol: model.WireGuard, Settings: `{}`}
 	client := model.Client{Email: "user"}
 	client := model.Client{Email: "user"}
 
 
-	if raw := NewSubJsonService("", "", "", nil).genWireguard(inbound, client); raw != nil {
+	if raw := NewSubJsonService("", "", "", "", nil).genWireguard(inbound, client); raw != nil {
 		t.Fatalf("genWireguard = %s, want nil for a keyless wireguard client", raw)
 		t.Fatalf("genWireguard = %s, want nil for a keyless wireguard client", raw)
 	}
 	}
 }
 }
 
 
 func TestSubJsonServiceSkipsAmneziaWG(t *testing.T) {
 func TestSubJsonServiceSkipsAmneziaWG(t *testing.T) {
-	if got := NewSubJsonService("", "", "", nil).getConfig(&SubService{address: "sub.example.com"}, &model.Inbound{Listen: "203.0.113.8", Port: 51820, Protocol: model.AmneziaWG}, model.Client{}, "sub.example.com"); len(got) != 0 {
+	if got := NewSubJsonService("", "", "", "", nil).getConfig(&SubService{address: "sub.example.com"}, &model.Inbound{Listen: "203.0.113.8", Port: 51820, Protocol: model.AmneziaWG}, model.Client{}, "sub.example.com"); len(got) != 0 {
 		t.Fatalf("getConfig emitted %d unsupported AmneziaWG Xray config(s)", len(got))
 		t.Fatalf("getConfig emitted %d unsupported AmneziaWG Xray config(s)", len(got))
 	}
 	}
 }
 }

+ 10 - 10
internal/sub/mutation_audit_test.go

@@ -29,7 +29,7 @@ func initMutDB(t *testing.T) {
 
 
 func TestSubJsonService_CustomRulesPrepended(t *testing.T) {
 func TestSubJsonService_CustomRulesPrepended(t *testing.T) {
 	rules := `[{"type":"field","domain":["geosite:ads"],"outboundTag":"block"}]`
 	rules := `[{"type":"field","domain":["geosite:ads"],"outboundTag":"block"}]`
-	svc := NewSubJsonService("", rules, "", nil)
+	svc := NewSubJsonService("", rules, "", "", nil)
 
 
 	routing, ok := svc.configJson["routing"].(map[string]any)
 	routing, ok := svc.configJson["routing"].(map[string]any)
 	if !ok {
 	if !ok {
@@ -47,7 +47,7 @@ func TestSubJsonService_CustomRulesPrepended(t *testing.T) {
 }
 }
 
 
 func TestSubJsonService_EmptyRulesLeavesDefault(t *testing.T) {
 func TestSubJsonService_EmptyRulesLeavesDefault(t *testing.T) {
-	svc := NewSubJsonService("", "", "", nil)
+	svc := NewSubJsonService("", "", "", "", nil)
 	routing, _ := svc.configJson["routing"].(map[string]any)
 	routing, _ := svc.configJson["routing"].(map[string]any)
 	got, _ := routing["rules"].([]any)
 	got, _ := routing["rules"].([]any)
 	if len(got) != 1 {
 	if len(got) != 1 {
@@ -67,12 +67,12 @@ func TestSubJsonService_MuxAttachedWhenConfigured(t *testing.T) {
 		wantMux  bool
 		wantMux  bool
 		protocol model.Protocol
 		protocol model.Protocol
 	}{
 	}{
-		{"vmess mux", NewSubJsonService(mux, "", "", nil).genVnext(&model.Inbound{Protocol: model.VMESS, Settings: `{}`}, nil, client, mux), true, model.VMESS},
-		{"vless mux", NewSubJsonService(mux, "", "", nil).genVless(&SubService{}, &model.Inbound{Protocol: model.VLESS, Settings: `{}`}, nil, client, mux), true, model.VLESS},
-		{"server mux", NewSubJsonService(mux, "", "", nil).genServer(&SubService{}, &model.Inbound{Protocol: model.Trojan, Settings: `{}`}, nil, client, mux), true, model.Trojan},
-		{"vmess no mux", NewSubJsonService("", "", "", nil).genVnext(&model.Inbound{Protocol: model.VMESS, Settings: `{}`}, nil, client, ""), false, model.VMESS},
-		{"vless no mux", NewSubJsonService("", "", "", nil).genVless(&SubService{}, &model.Inbound{Protocol: model.VLESS, Settings: `{}`}, nil, client, ""), false, model.VLESS},
-		{"server no mux", NewSubJsonService("", "", "", nil).genServer(&SubService{}, &model.Inbound{Protocol: model.Trojan, Settings: `{}`}, nil, client, ""), false, model.Trojan},
+		{"vmess mux", NewSubJsonService(mux, "", "", "", nil).genVnext(&model.Inbound{Protocol: model.VMESS, Settings: `{}`}, nil, client, mux), true, model.VMESS},
+		{"vless mux", NewSubJsonService(mux, "", "", "", nil).genVless(&SubService{}, &model.Inbound{Protocol: model.VLESS, Settings: `{}`}, nil, client, mux), true, model.VLESS},
+		{"server mux", NewSubJsonService(mux, "", "", "", nil).genServer(&SubService{}, &model.Inbound{Protocol: model.Trojan, Settings: `{}`}, nil, client, mux), true, model.Trojan},
+		{"vmess no mux", NewSubJsonService("", "", "", "", nil).genVnext(&model.Inbound{Protocol: model.VMESS, Settings: `{}`}, nil, client, ""), false, model.VMESS},
+		{"vless no mux", NewSubJsonService("", "", "", "", nil).genVless(&SubService{}, &model.Inbound{Protocol: model.VLESS, Settings: `{}`}, nil, client, ""), false, model.VLESS},
+		{"server no mux", NewSubJsonService("", "", "", "", nil).genServer(&SubService{}, &model.Inbound{Protocol: model.Trojan, Settings: `{}`}, nil, client, ""), false, model.Trojan},
 	}
 	}
 	for _, tc := range cases {
 	for _, tc := range cases {
 		t.Run(tc.name, func(t *testing.T) {
 		t.Run(tc.name, func(t *testing.T) {
@@ -103,7 +103,7 @@ func TestSubJsonService_FinalMaskMergingToEmptyNotAdded(t *testing.T) {
 	// finalMask is non-empty (passes the len(fm)==0 early return) but its only
 	// finalMask is non-empty (passes the len(fm)==0 early return) but its only
 	// key is an empty tcp slice, which mergeFinalMask drops → merged is empty,
 	// key is an empty tcp slice, which mergeFinalMask drops → merged is empty,
 	// so applyGlobalFinalMask must NOT set finalmask.
 	// so applyGlobalFinalMask must NOT set finalmask.
-	svc := NewSubJsonService("", "", `{"tcp":[]}`, nil)
+	svc := NewSubJsonService("", "", `{"tcp":[]}`, "", nil)
 	stream := svc.streamData(`{"network":"tcp","security":"none","tcpSettings":{"header":{"type":"none"}}}`, "")
 	stream := svc.streamData(`{"network":"tcp","security":"none","tcpSettings":{"header":{"type":"none"}}}`, "")
 	if _, ok := stream["finalmask"]; ok {
 	if _, ok := stream["finalmask"]; ok {
 		t.Fatalf("finalMask merging to empty must not add a finalmask key: %#v", stream["finalmask"])
 		t.Fatalf("finalMask merging to empty must not add a finalmask key: %#v", stream["finalmask"])
@@ -111,7 +111,7 @@ func TestSubJsonService_FinalMaskMergingToEmptyNotAdded(t *testing.T) {
 
 
 	// Sanity: a finalMask that DOES merge to something still gets set, so the
 	// Sanity: a finalMask that DOES merge to something still gets set, so the
 	// guard is the only distinguishing factor.
 	// guard is the only distinguishing factor.
-	svc2 := NewSubJsonService("", "", `{"tcp":[{"type":"fragment"}]}`, nil)
+	svc2 := NewSubJsonService("", "", `{"tcp":[{"type":"fragment"}]}`, "", nil)
 	stream2 := svc2.streamData(`{"network":"tcp","security":"none","tcpSettings":{"header":{"type":"none"}}}`, "")
 	stream2 := svc2.streamData(`{"network":"tcp","security":"none","tcpSettings":{"header":{"type":"none"}}}`, "")
 	if _, ok := stream2["finalmask"]; !ok {
 	if _, ok := stream2["finalmask"]; !ok {
 		t.Fatal("non-empty finalMask must be set")
 		t.Fatal("non-empty finalMask must be set")

+ 30 - 17
internal/sub/remote_routing.go

@@ -30,6 +30,7 @@ type remoteRoutingKind string
 
 
 const (
 const (
 	remoteRoutingHapp  remoteRoutingKind = "happ"
 	remoteRoutingHapp  remoteRoutingKind = "happ"
+	remoteRoutingJson  remoteRoutingKind = "jsonhapp"
 	remoteRoutingClash remoteRoutingKind = "clash"
 	remoteRoutingClash remoteRoutingKind = "clash"
 
 
 	remoteRoutingCacheTTL     = 10 * time.Minute
 	remoteRoutingCacheTTL     = 10 * time.Minute
@@ -40,6 +41,12 @@ const (
 	remoteRoutingClashMaxBody = 2 << 20  // 2 MiB
 	remoteRoutingClashMaxBody = 2 << 20  // 2 MiB
 )
 )
 
 
+// isHappPayloadKind reports whether the kind carries a happ-payload source:
+// same validation and size caps, but a separate persisted cache row.
+func isHappPayloadKind(kind remoteRoutingKind) bool {
+	return kind == remoteRoutingHapp || kind == remoteRoutingJson
+}
+
 var errRemoteRoutingUnavailable = errors.New("remote routing source is temporarily unavailable")
 var errRemoteRoutingUnavailable = errors.New("remote routing source is temporarily unavailable")
 
 
 type remoteRoutingKey struct {
 type remoteRoutingKey struct {
@@ -167,19 +174,22 @@ func (r *remoteRoutingResolver) resolveEntry(kind remoteRoutingKind, raw string)
 }
 }
 
 
 // RefreshRemoteRoutingSources warms and refreshes configured remote sources
 // RefreshRemoteRoutingSources warms and refreshes configured remote sources
-// from the cron job. Concurrent resolver reads are safe; fetches coalesce.
-func RefreshRemoteRoutingSources(happ, clash string) {
-	for kind, raw := range map[remoteRoutingKind]string{
-		remoteRoutingHapp:  happ,
-		remoteRoutingClash: clash,
+// from the cron job; concurrent resolver reads are safe, fetches coalesce.
+func RefreshRemoteRoutingSources(happ, clash, jsonRouting string) {
+	for kind, raw := range map[remoteRoutingKind][]string{
+		remoteRoutingHapp:  {happ},
+		remoteRoutingJson:  {jsonRouting},
+		remoteRoutingClash: {clash},
 	} {
 	} {
-		_, remote, parseErr := common.ParseRemoteRoutingURL(raw)
-		if parseErr != nil {
-			logger.Warningf("Remote %s routing source is invalid", kind)
-			continue
-		}
-		if remote {
-			_ = routingSourceResolver.refreshSource(kind, raw)
+		for _, source := range raw {
+			_, remote, parseErr := common.ParseRemoteRoutingURL(source)
+			if parseErr != nil {
+				logger.Warningf("Remote %s routing source is invalid", kind)
+				continue
+			}
+			if remote {
+				_ = routingSourceResolver.refreshSource(kind, source)
+			}
 		}
 		}
 	}
 	}
 }
 }
@@ -286,7 +296,7 @@ func (r *remoteRoutingResolver) fetch(key remoteRoutingKey, previous remoteRouti
 		}
 		}
 		return previous, nil
 		return previous, nil
 	}
 	}
-	if key.kind == remoteRoutingHapp && isRemoteHappRedirect(resp.StatusCode) {
+	if isHappPayloadKind(key.kind) && isRemoteHappRedirect(resp.StatusCode) {
 		location := strings.TrimSpace(resp.Header.Get("Location"))
 		location := strings.TrimSpace(resp.Header.Get("Location"))
 		content, locationErr := normalizeHappRouting([]byte(location))
 		content, locationErr := normalizeHappRouting([]byte(location))
 		if locationErr != nil {
 		if locationErr != nil {
@@ -321,7 +331,7 @@ func (r *remoteRoutingResolver) fetch(key remoteRoutingKey, previous remoteRouti
 	if err != nil {
 	if err != nil {
 		return remoteRoutingCacheEntry{}, err
 		return remoteRoutingCacheEntry{}, err
 	}
 	}
-	if key.kind == remoteRoutingHapp && len(content) > remoteRoutingHappMaxValue {
+	if isHappPayloadKind(key.kind) && len(content) > remoteRoutingHappMaxValue {
 		return remoteRoutingCacheEntry{}, errors.New("Happ routing header exceeds the size limit")
 		return remoteRoutingCacheEntry{}, errors.New("Happ routing header exceeds the size limit")
 	}
 	}
 	return remoteRoutingCacheEntry{
 	return remoteRoutingCacheEntry{
@@ -346,7 +356,7 @@ func isRemoteHappRedirect(status int) bool {
 
 
 func normalizeRemoteRoutingContent(kind remoteRoutingKind, body []byte) (string, map[string]any, error) {
 func normalizeRemoteRoutingContent(kind remoteRoutingKind, body []byte) (string, map[string]any, error) {
 	switch kind {
 	switch kind {
-	case remoteRoutingHapp:
+	case remoteRoutingHapp, remoteRoutingJson:
 		content, err := normalizeHappRouting(body)
 		content, err := normalizeHappRouting(body)
 		return content, nil, err
 		return content, nil, err
 	case remoteRoutingClash:
 	case remoteRoutingClash:
@@ -369,6 +379,9 @@ func normalizeHappRouting(body []byte) (string, error) {
 		}
 		}
 		return "happ://routing/onadd/" + base64.StdEncoding.EncodeToString(compact), nil
 		return "happ://routing/onadd/" + base64.StdEncoding.EncodeToString(compact), nil
 	}
 	}
+	if text == "happ://routing/off" {
+		return text, nil
+	}
 	if strings.ContainsAny(text, "\r\n") {
 	if strings.ContainsAny(text, "\r\n") {
 		return "", errors.New("Happ deeplink must be a single line")
 		return "", errors.New("Happ deeplink must be a single line")
 	}
 	}
@@ -570,7 +583,7 @@ func (r *remoteRoutingResolver) triggerPersistedLoad() {
 
 
 func (r *remoteRoutingResolver) loadPersisted() {
 func (r *remoteRoutingResolver) loadPersisted() {
 	loaded := make(map[remoteRoutingKey]remoteRoutingCacheEntry, 2)
 	loaded := make(map[remoteRoutingKey]remoteRoutingCacheEntry, 2)
-	for _, kind := range []remoteRoutingKind{remoteRoutingHapp, remoteRoutingClash} {
+	for _, kind := range []remoteRoutingKind{remoteRoutingHapp, remoteRoutingJson, remoteRoutingClash} {
 		var setting model.Setting
 		var setting model.Setting
 		err := database.GetDB().Where("key = ?", remoteRoutingSettingKey(kind)).First(&setting).Error
 		err := database.GetDB().Where("key = ?", remoteRoutingSettingKey(kind)).First(&setting).Error
 		if err != nil {
 		if err != nil {
@@ -587,7 +600,7 @@ func (r *remoteRoutingResolver) loadPersisted() {
 		if err != nil {
 		if err != nil {
 			continue
 			continue
 		}
 		}
-		if kind == remoteRoutingHapp && len(normalized) > remoteRoutingHappMaxValue {
+		if isHappPayloadKind(kind) && len(normalized) > remoteRoutingHappMaxValue {
 			continue
 			continue
 		}
 		}
 		entry.Content = normalized
 		entry.Content = normalized

+ 1 - 2
internal/sub/service.go

@@ -2166,8 +2166,7 @@ func appendQueryAndFragment(link string, params map[string]string, fragment, sec
 
 
 	if fragment != "" {
 	if fragment != "" {
 		sb.WriteByte('#')
 		sb.WriteByte('#')
-		// Match the frontend's encodeURIComponent(remark): spaces become
-		// %20 (not + as in query strings).
+		// Match the frontend's encodeURIComponent(remark): spaces become %20.
 		sb.WriteString(strings.ReplaceAll(url.QueryEscape(fragment), "+", "%20"))
 		sb.WriteString(strings.ReplaceAll(url.QueryEscape(fragment), "+", "%20"))
 	}
 	}
 	return sb.String()
 	return sb.String()

+ 32 - 0
internal/sub/sub.go

@@ -150,6 +150,11 @@ func (s *Server) initRouter() (*gin.Engine, error) {
 		SubJsonRules = ""
 		SubJsonRules = ""
 	}
 	}
 
 
+	SubJsonRoutingRules, err := s.settingService.GetSubJsonRoutingRules()
+	if err != nil {
+		SubJsonRoutingRules = ""
+	}
+
 	SubJsonFinalMask, err := s.settingService.GetSubJsonFinalMask()
 	SubJsonFinalMask, err := s.settingService.GetSubJsonFinalMask()
 	if err != nil {
 	if err != nil {
 		SubJsonFinalMask = ""
 		SubJsonFinalMask = ""
@@ -215,6 +220,31 @@ func (s *Server) initRouter() (*gin.Engine, error) {
 		SubIncyRoutingRules = ""
 		SubIncyRoutingRules = ""
 	}
 	}
 
 
+	happCfg := HappConfig{}
+	happCfg.AutoDetect, _ = s.settingService.GetSubHappAutoDetect()
+	happCfg.ProviderId, _ = s.settingService.GetSubHappProviderId()
+	happCfg.NewUrl, _ = s.settingService.GetSubHappNewUrl()
+	happCfg.FallbackUrl, _ = s.settingService.GetSubHappFallbackUrl()
+	happCfg.SubInfoColor, _ = s.settingService.GetSubHappSubInfoColor()
+	happCfg.SubInfoText, _ = s.settingService.GetSubHappSubInfoText()
+	happCfg.SubInfoButtonText, _ = s.settingService.GetSubHappSubInfoButtonText()
+	happCfg.SubInfoButtonLink, _ = s.settingService.GetSubHappSubInfoButtonLink()
+	happCfg.SubExpire, _ = s.settingService.GetSubHappSubExpire()
+	happCfg.SubExpireButtonLink, _ = s.settingService.GetSubHappSubExpireButtonLink()
+	happCfg.NotificationExpire, _ = s.settingService.GetSubHappNotificationExpire()
+	happCfg.NoLimit, _ = s.settingService.GetSubHappNoLimit()
+	happCfg.AlwaysHwid, _ = s.settingService.GetSubHappAlwaysHwid()
+	happCfg.TunMode, _ = s.settingService.GetSubHappTunMode()
+	happCfg.TunType, _ = s.settingService.GetSubHappTunType()
+	happCfg.ExcludeRoutes, _ = s.settingService.GetSubHappExcludeRoutes()
+	happCfg.ExcludeApns, _ = s.settingService.GetSubHappExcludeApns()
+	happCfg.ColorProfile, _ = s.settingService.GetSubHappColorProfile()
+	happCfg.PingType, _ = s.settingService.GetSubHappPingType()
+	happCfg.AutoConnect, _ = s.settingService.GetSubHappAutoConnect()
+	happCfg.AutoConnectType, _ = s.settingService.GetSubHappAutoConnectType()
+	happCfg.PerAppMode, _ = s.settingService.GetSubHappPerAppMode()
+	happCfg.PerAppList, _ = s.settingService.GetSubHappPerAppList()
+
 	// set per-request localizer from headers/cookies
 	// set per-request localizer from headers/cookies
 	engine.Use(locale.LocalizerMiddleware())
 	engine.Use(locale.LocalizerMiddleware())
 
 
@@ -285,6 +315,7 @@ func (s *Server) initRouter() (*gin.Engine, error) {
 		WithSUBUpdateInterval(SubUpdates),
 		WithSUBUpdateInterval(SubUpdates),
 		WithSUBJsonMux(SubJsonMux),
 		WithSUBJsonMux(SubJsonMux),
 		WithSUBJsonRules(SubJsonRules),
 		WithSUBJsonRules(SubJsonRules),
+		WithSUBJsonRoutingRules(SubJsonRoutingRules),
 		WithSUBJsonFinalMask(SubJsonFinalMask),
 		WithSUBJsonFinalMask(SubJsonFinalMask),
 		WithSUBJsonObservatory(SubJsonObservatory),
 		WithSUBJsonObservatory(SubJsonObservatory),
 		WithSUBClashEnableRouting(SubClashEnableRouting),
 		WithSUBClashEnableRouting(SubClashEnableRouting),
@@ -296,6 +327,7 @@ func (s *Server) initRouter() (*gin.Engine, error) {
 		WithSUBEnableRouting(SubEnableRouting),
 		WithSUBEnableRouting(SubEnableRouting),
 		WithSUBRoutingRules(SubRoutingRules),
 		WithSUBRoutingRules(SubRoutingRules),
 		WithSUBHideSettings(SubHideSettings),
 		WithSUBHideSettings(SubHideSettings),
+		WithSUBHappConfig(happCfg),
 		WithSUBIncyEnableRouting(SubIncyEnableRouting),
 		WithSUBIncyEnableRouting(SubIncyEnableRouting),
 		WithSUBIncyRoutingRules(SubIncyRoutingRules),
 		WithSUBIncyRoutingRules(SubIncyRoutingRules),
 	)
 	)

+ 1 - 1
internal/sub/sub_balancer_protocol_tag_test.go

@@ -44,7 +44,7 @@ func TestSubJson_BalancerMemberTagUsesProtocol(t *testing.T) {
 		Remark: "proto", Strategy: "random", InboundIds: []int{vm.Id}, SortOrder: 1, Enabled: true,
 		Remark: "proto", Strategy: "random", InboundIds: []int{vm.Id}, SortOrder: 1, Enabled: true,
 	})
 	})
 
 
-	js := NewSubJsonService("", "", "", NewSubService(""))
+	js := NewSubJsonService("", "", "", "", NewSubService(""))
 	out, _, err := js.GetJson("s1", "req.example.com", true)
 	out, _, err := js.GetJson("s1", "req.example.com", true)
 	if err != nil {
 	if err != nil {
 		t.Fatalf("GetJson: %v", err)
 		t.Fatalf("GetJson: %v", err)

+ 12 - 12
internal/sub/sub_balancer_test.go

@@ -57,7 +57,7 @@ func TestSubJson_BalancerDocument(t *testing.T) {
 	})
 	})
 
 
 	rules := `[{"type":"field","domain":["geosite:example"],"outboundTag":"proxy"}]`
 	rules := `[{"type":"field","domain":["geosite:example"],"outboundTag":"proxy"}]`
-	js := NewSubJsonService("", rules, "", NewSubService(""))
+	js := NewSubJsonService("", rules, "", "", NewSubService(""))
 	out, _, err := js.GetJson("s1", "req.example.com", true)
 	out, _, err := js.GetJson("s1", "req.example.com", true)
 	if err != nil {
 	if err != nil {
 		t.Fatalf("GetJson: %v", err)
 		t.Fatalf("GetJson: %v", err)
@@ -155,7 +155,7 @@ func TestSubJson_BalancerOrderInterleavesWithInbounds(t *testing.T) {
 		Remark: "bal", Strategy: "roundRobin", InboundIds: []int{later.Id, first.Id}, SortOrder: 1, Enabled: true,
 		Remark: "bal", Strategy: "roundRobin", InboundIds: []int{later.Id, first.Id}, SortOrder: 1, Enabled: true,
 	})
 	})
 
 
-	js := NewSubJsonService("", "", "", NewSubService(""))
+	js := NewSubJsonService("", "", "", "", NewSubService(""))
 	out, _, err := js.GetJson("s1", "req.example.com", true)
 	out, _, err := js.GetJson("s1", "req.example.com", true)
 	if err != nil {
 	if err != nil {
 		t.Fatalf("GetJson: %v", err)
 		t.Fatalf("GetJson: %v", err)
@@ -189,7 +189,7 @@ func TestSubJson_BalancerDisabledAndEmptySkipped(t *testing.T) {
 		Remark: "nomembers", Strategy: "random", InboundIds: []int{inbound.Id + 100}, SortOrder: 1, Enabled: true,
 		Remark: "nomembers", Strategy: "random", InboundIds: []int{inbound.Id + 100}, SortOrder: 1, Enabled: true,
 	})
 	})
 
 
-	js := NewSubJsonService("", "", "", NewSubService(""))
+	js := NewSubJsonService("", "", "", "", NewSubService(""))
 	out, _, err := js.GetJson("s1", "req.example.com", true)
 	out, _, err := js.GetJson("s1", "req.example.com", true)
 	if err != nil {
 	if err != nil {
 		t.Fatalf("GetJson: %v", err)
 		t.Fatalf("GetJson: %v", err)
@@ -213,7 +213,7 @@ func TestSubJson_BalancerTagDedup(t *testing.T) {
 		Remark: "dedup", Strategy: "leastPing", InboundIds: []int{a.Id, b.Id}, SortOrder: 1, Enabled: true,
 		Remark: "dedup", Strategy: "leastPing", InboundIds: []int{a.Id, b.Id}, SortOrder: 1, Enabled: true,
 	})
 	})
 
 
-	js := NewSubJsonService("", "", "", NewSubService(""))
+	js := NewSubJsonService("", "", "", "", NewSubService(""))
 	out, _, err := js.GetJson("s1", "req.example.com", true)
 	out, _, err := js.GetJson("s1", "req.example.com", true)
 	if err != nil {
 	if err != nil {
 		t.Fatalf("GetJson: %v", err)
 		t.Fatalf("GetJson: %v", err)
@@ -241,7 +241,7 @@ func TestSubJson_BalancerObservatoryConditional(t *testing.T) {
 		Remark: "pinger", Strategy: "leastPing", InboundIds: []int{lp.Id}, SortOrder: 2, Enabled: true,
 		Remark: "pinger", Strategy: "leastPing", InboundIds: []int{lp.Id}, SortOrder: 2, Enabled: true,
 	})
 	})
 
 
-	js := NewSubJsonService("", "", "", NewSubService(""))
+	js := NewSubJsonService("", "", "", "", NewSubService(""))
 	js.SetObservatoryConfig(`{"destination":"https://probe.example/204","httpMethod":"GET","sampling":5}`)
 	js.SetObservatoryConfig(`{"destination":"https://probe.example/204","httpMethod":"GET","sampling":5}`)
 	out, _, err := js.GetJson("s1", "req.example.com", true)
 	out, _, err := js.GetJson("s1", "req.example.com", true)
 	if err != nil {
 	if err != nil {
@@ -287,7 +287,7 @@ func TestSubJson_BalancerExcludesDisabledInbound(t *testing.T) {
 		Remark: "bal", Strategy: "random", InboundIds: []int{a.Id, b.Id}, SortOrder: 1, Enabled: true,
 		Remark: "bal", Strategy: "random", InboundIds: []int{a.Id, b.Id}, SortOrder: 1, Enabled: true,
 	})
 	})
 
 
-	js := NewSubJsonService("", "", "", NewSubService(""))
+	js := NewSubJsonService("", "", "", "", NewSubService(""))
 	out, _, err := js.GetJson("s1", "req.example.com", true)
 	out, _, err := js.GetJson("s1", "req.example.com", true)
 	if err != nil {
 	if err != nil {
 		t.Fatalf("GetJson: %v", err)
 		t.Fatalf("GetJson: %v", err)
@@ -322,7 +322,7 @@ func TestSubJson_BalancerSkippedWhenAllMembersDisabled(t *testing.T) {
 		Remark: "empty", Strategy: "random", InboundIds: []int{only.Id}, SortOrder: 1, Enabled: true,
 		Remark: "empty", Strategy: "random", InboundIds: []int{only.Id}, SortOrder: 1, Enabled: true,
 	})
 	})
 
 
-	js := NewSubJsonService("", "", "", NewSubService(""))
+	js := NewSubJsonService("", "", "", "", NewSubService(""))
 	out, _, err := js.GetJson("s1", "req.example.com", true)
 	out, _, err := js.GetJson("s1", "req.example.com", true)
 	if err != nil {
 	if err != nil {
 		t.Fatalf("GetJson: %v", err)
 		t.Fatalf("GetJson: %v", err)
@@ -345,7 +345,7 @@ func TestSubJson_BalancerObservatoryConnectivityDefaultEmpty(t *testing.T) {
 		Remark: "pinger", Strategy: "leastPing", InboundIds: []int{inb.Id}, SortOrder: 1, Enabled: true,
 		Remark: "pinger", Strategy: "leastPing", InboundIds: []int{inb.Id}, SortOrder: 1, Enabled: true,
 	})
 	})
 
 
-	js := NewSubJsonService("", "", "", NewSubService(""))
+	js := NewSubJsonService("", "", "", "", NewSubService(""))
 	out, _, err := js.GetJson("s1", "req.example.com", true)
 	out, _, err := js.GetJson("s1", "req.example.com", true)
 	if err != nil {
 	if err != nil {
 		t.Fatalf("GetJson: %v", err)
 		t.Fatalf("GetJson: %v", err)
@@ -386,7 +386,7 @@ func TestSubJson_BalancerObservatoryAlwaysEmittedForProbingStrategies(t *testing
 		Remark: "pinger", Strategy: "leastPing", InboundIds: []int{a.Id, b.Id}, SortOrder: 1, Enabled: true,
 		Remark: "pinger", Strategy: "leastPing", InboundIds: []int{a.Id, b.Id}, SortOrder: 1, Enabled: true,
 	})
 	})
 
 
-	js := NewSubJsonService("", "", "", NewSubService(""))
+	js := NewSubJsonService("", "", "", "", NewSubService(""))
 	js.SetObservatoryConfig(`{"enabled":false}`)
 	js.SetObservatoryConfig(`{"enabled":false}`)
 	out, _, err := js.GetJson("s1", "req.example.com", true)
 	out, _, err := js.GetJson("s1", "req.example.com", true)
 	if err != nil {
 	if err != nil {
@@ -444,7 +444,7 @@ func TestSubJson_BalancerLeastLoadCosts(t *testing.T) {
 		MemberWeights: map[int]float64{fast.Id: 0.2}, SortOrder: 1, Enabled: true,
 		MemberWeights: map[int]float64{fast.Id: 0.2}, SortOrder: 1, Enabled: true,
 	})
 	})
 
 
-	js := NewSubJsonService("", "", "", NewSubService(""))
+	js := NewSubJsonService("", "", "", "", NewSubService(""))
 	out, _, err := js.GetJson("s1", "req.example.com", true)
 	out, _, err := js.GetJson("s1", "req.example.com", true)
 	if err != nil {
 	if err != nil {
 		t.Fatalf("GetJson: %v", err)
 		t.Fatalf("GetJson: %v", err)
@@ -476,7 +476,7 @@ func TestSubJson_BalancerLeastLoadWithoutWeightsOmitsCosts(t *testing.T) {
 		Remark: "plain", Strategy: "leastLoad", InboundIds: []int{a.Id, b.Id}, SortOrder: 1, Enabled: true,
 		Remark: "plain", Strategy: "leastLoad", InboundIds: []int{a.Id, b.Id}, SortOrder: 1, Enabled: true,
 	})
 	})
 
 
-	js := NewSubJsonService("", "", "", NewSubService(""))
+	js := NewSubJsonService("", "", "", "", NewSubService(""))
 	out, _, err := js.GetJson("s1", "req.example.com", true)
 	out, _, err := js.GetJson("s1", "req.example.com", true)
 	if err != nil {
 	if err != nil {
 		t.Fatalf("GetJson: %v", err)
 		t.Fatalf("GetJson: %v", err)
@@ -498,7 +498,7 @@ func TestSubJson_BalancerCostsSkippedForNonLeastLoadStrategy(t *testing.T) {
 		MemberWeights: map[int]float64{a.Id: 0.5}, SortOrder: 1, Enabled: true,
 		MemberWeights: map[int]float64{a.Id: 0.5}, SortOrder: 1, Enabled: true,
 	})
 	})
 
 
-	js := NewSubJsonService("", "", "", NewSubService(""))
+	js := NewSubJsonService("", "", "", "", NewSubService(""))
 	out, _, err := js.GetJson("s1", "req.example.com", true)
 	out, _, err := js.GetJson("s1", "req.example.com", true)
 	if err != nil {
 	if err != nil {
 		t.Fatalf("GetJson: %v", err)
 		t.Fatalf("GetJson: %v", err)

+ 1 - 1
internal/sub/sub_json_observatory_test.go

@@ -29,7 +29,7 @@ func TestSubJson_ObservatoryConfigInvalidValuesFallBack(t *testing.T) {
 	}
 	}
 	for _, tc := range cases {
 	for _, tc := range cases {
 		t.Run(tc.name, func(t *testing.T) {
 		t.Run(tc.name, func(t *testing.T) {
-			js := NewSubJsonService("", "", "", NewSubService(""))
+			js := NewSubJsonService("", "", "", "", NewSubService(""))
 			js.SetObservatoryConfig(tc.cfg)
 			js.SetObservatoryConfig(tc.cfg)
 			out, _, err := js.GetJson("s1", "req.example.com", true)
 			out, _, err := js.GetJson("s1", "req.example.com", true)
 			if err != nil {
 			if err != nil {

+ 3 - 3
internal/sub/sub_panic_test.go

@@ -68,7 +68,7 @@ func TestGetJsonToleratesHysteriaWithoutHysteriaSettings(t *testing.T) {
 		t.Fatalf("seed client_inbound: %v", err)
 		t.Fatalf("seed client_inbound: %v", err)
 	}
 	}
 
 
-	jsonService := NewSubJsonService("", "", "", NewSubService(""))
+	jsonService := NewSubJsonService("", "", "", "", NewSubService(""))
 	out, _, err := jsonService.GetJson(subId, "sub.example.com", true)
 	out, _, err := jsonService.GetJson(subId, "sub.example.com", true)
 	if err != nil {
 	if err != nil {
 		t.Fatalf("GetJson: %v", err)
 		t.Fatalf("GetJson: %v", err)
@@ -83,7 +83,7 @@ func TestGetJsonToleratesNonStringRealityShortId(t *testing.T) {
 	stream := `{"network":"tcp","security":"reality","realitySettings":{"serverNames":["sni.example.com"],"shortIds":[42],"settings":{"publicKey":"pk"}}}`
 	stream := `{"network":"tcp","security":"reality","realitySettings":{"serverNames":["sni.example.com"],"shortIds":[42],"settings":{"publicKey":"pk"}}}`
 	seedSubInbound(t, "rlty1", "rlty", 46400, 1, stream)
 	seedSubInbound(t, "rlty1", "rlty", 46400, 1, stream)
 
 
-	jsonService := NewSubJsonService("", "", "", NewSubService(""))
+	jsonService := NewSubJsonService("", "", "", "", NewSubService(""))
 	out, _, err := jsonService.GetJson("rlty1", "sub.example.com", true)
 	out, _, err := jsonService.GetJson("rlty1", "sub.example.com", true)
 	if err != nil {
 	if err != nil {
 		t.Fatalf("GetJson: %v", err)
 		t.Fatalf("GetJson: %v", err)
@@ -113,7 +113,7 @@ func TestJsonAndClashTolerateExternalProxyMissingPort(t *testing.T) {
 	stream := `{"network":"tcp","security":"none","externalProxy":[{"forceTls":"same","dest":"cdn.example.com"}]}`
 	stream := `{"network":"tcp","security":"none","externalProxy":[{"forceTls":"same","dest":"cdn.example.com"}]}`
 	seedSubInbound(t, "extp1", "extp", 46500, 1, stream)
 	seedSubInbound(t, "extp1", "extp", 46500, 1, stream)
 
 
-	jsonService := NewSubJsonService("", "", "", NewSubService(""))
+	jsonService := NewSubJsonService("", "", "", "", NewSubService(""))
 	jsonOut, _, err := jsonService.GetJson("extp1", "sub.example.com", true)
 	jsonOut, _, err := jsonService.GetJson("extp1", "sub.example.com", true)
 	if err != nil {
 	if err != nil {
 		t.Fatalf("GetJson: %v", err)
 		t.Fatalf("GetJson: %v", err)

+ 1 - 1
internal/sub/sub_scale_test.go

@@ -209,7 +209,7 @@ func TestGetSubsScale(t *testing.T) {
 				t.Fatalf("GetSubs links = %d, want 3", len(links))
 				t.Fatalf("GetSubs links = %d, want 3", len(links))
 			}
 			}
 
 
-			jsonSvc := NewSubJsonService("", "", "", &SubService{})
+			jsonSvc := NewSubJsonService("", "", "", "", &SubService{})
 			start = time.Now()
 			start = time.Now()
 			for range reps {
 			for range reps {
 				body, _, err := jsonSvc.GetJson(scaleTargetSubId, "sub.example.com", false)
 				body, _, err := jsonSvc.GetJson(scaleTargetSubId, "sub.example.com", false)

+ 1 - 1
internal/sub/vless_route_sub_test.go

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

+ 7 - 5
internal/web/controller/client.go

@@ -313,10 +313,12 @@ func (a *ClientController) resetAllTraffics(c *gin.Context) {
 }
 }
 
 
 type bulkAdjustRequest struct {
 type bulkAdjustRequest struct {
-	Emails   []string `json:"emails"`
-	AddDays  int      `json:"addDays"`
-	AddBytes int64    `json:"addBytes"`
-	Flow     string   `json:"flow"`
+	Emails    []string `json:"emails"`
+	AddDays   int      `json:"addDays"`
+	AddBytes  int64    `json:"addBytes"`
+	Flow      string   `json:"flow"`
+	LimitHwid *int     `json:"limitHwid"`
+	AdTag     string   `json:"adTag"`
 }
 }
 
 
 func (a *ClientController) bulkAdjust(c *gin.Context) {
 func (a *ClientController) bulkAdjust(c *gin.Context) {
@@ -325,7 +327,7 @@ func (a *ClientController) bulkAdjust(c *gin.Context) {
 		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
 		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
 		return
 		return
 	}
 	}
-	result, needRestart, err := a.clientService.BulkAdjust(&a.inboundService, req.Emails, req.AddDays, req.AddBytes, req.Flow)
+	result, needRestart, err := a.clientService.BulkAdjust(&a.inboundService, req.Emails, req.AddDays, req.AddBytes, req.Flow, req.LimitHwid, req.AdTag)
 	if err != nil {
 	if err != nil {
 		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
 		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
 		return
 		return

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

@@ -107,11 +107,37 @@ type AllSetting struct {
 	SubClashRules               string `json:"subClashRules" form:"subClashRules"`
 	SubClashRules               string `json:"subClashRules" form:"subClashRules"`
 	SubJsonMux                  string `json:"subJsonMux" form:"subJsonMux"`
 	SubJsonMux                  string `json:"subJsonMux" form:"subJsonMux"`
 	SubJsonRules                string `json:"subJsonRules" form:"subJsonRules"`
 	SubJsonRules                string `json:"subJsonRules" form:"subJsonRules"`
+	SubJsonRoutingRules         string `json:"subJsonRoutingRules" form:"subJsonRoutingRules"`
 	SubJsonFinalMask            string `json:"subJsonFinalMask" form:"subJsonFinalMask"`
 	SubJsonFinalMask            string `json:"subJsonFinalMask" form:"subJsonFinalMask"`
 	SubJsonObservatory          string `json:"subJsonObservatory" form:"subJsonObservatory"`
 	SubJsonObservatory          string `json:"subJsonObservatory" form:"subJsonObservatory"`
 	SubThemeDir                 string `json:"subThemeDir" form:"subThemeDir"`
 	SubThemeDir                 string `json:"subThemeDir" form:"subThemeDir"`
 	SubHideSettings             bool   `json:"subHideSettings" form:"subHideSettings"`
 	SubHideSettings             bool   `json:"subHideSettings" form:"subHideSettings"`
 
 
+	// Happ client customization settings (app-management / routing / UX).
+	SubHappAutoDetect          bool   `json:"subHappAutoDetect" form:"subHappAutoDetect"`
+	SubHappProviderId          string `json:"subHappProviderId" form:"subHappProviderId"`
+	SubHappNewUrl              string `json:"subHappNewUrl" form:"subHappNewUrl"`
+	SubHappFallbackUrl         string `json:"subHappFallbackUrl" form:"subHappFallbackUrl"`
+	SubHappSubInfoColor        string `json:"subHappSubInfoColor" form:"subHappSubInfoColor"`
+	SubHappSubInfoText         string `json:"subHappSubInfoText" form:"subHappSubInfoText"`
+	SubHappSubInfoButtonText   string `json:"subHappSubInfoButtonText" form:"subHappSubInfoButtonText"`
+	SubHappSubInfoButtonLink   string `json:"subHappSubInfoButtonLink" form:"subHappSubInfoButtonLink"`
+	SubHappSubExpire           bool   `json:"subHappSubExpire" form:"subHappSubExpire"`
+	SubHappSubExpireButtonLink string `json:"subHappSubExpireButtonLink" form:"subHappSubExpireButtonLink"`
+	SubHappNotificationExpire  bool   `json:"subHappNotificationExpire" form:"subHappNotificationExpire"`
+	SubHappNoLimit             bool   `json:"subHappNoLimit" form:"subHappNoLimit"`
+	SubHappAlwaysHwid          bool   `json:"subHappAlwaysHwid" form:"subHappAlwaysHwid"`
+	SubHappTunMode             string `json:"subHappTunMode" form:"subHappTunMode"`
+	SubHappTunType             string `json:"subHappTunType" form:"subHappTunType"`
+	SubHappExcludeRoutes       string `json:"subHappExcludeRoutes" form:"subHappExcludeRoutes"`
+	SubHappExcludeApns         bool   `json:"subHappExcludeApns" form:"subHappExcludeApns"`
+	SubHappColorProfile        string `json:"subHappColorProfile" form:"subHappColorProfile"`
+	SubHappPingType            string `json:"subHappPingType" form:"subHappPingType"`
+	SubHappAutoConnect         bool   `json:"subHappAutoConnect" form:"subHappAutoConnect"`
+	SubHappAutoConnectType     string `json:"subHappAutoConnectType" form:"subHappAutoConnectType"`
+	SubHappPerAppMode          string `json:"subHappPerAppMode" form:"subHappPerAppMode"`
+	SubHappPerAppList          string `json:"subHappPerAppList" form:"subHappPerAppList"`
+
 	LdapEnable             bool   `json:"ldapEnable" form:"ldapEnable"`
 	LdapEnable             bool   `json:"ldapEnable" form:"ldapEnable"`
 	LdapHost               string `json:"ldapHost" form:"ldapHost"`
 	LdapHost               string `json:"ldapHost" form:"ldapHost"`
 	LdapPort               int    `json:"ldapPort" form:"ldapPort" validate:"gte=0,lte=65535"`
 	LdapPort               int    `json:"ldapPort" form:"ldapPort" validate:"gte=0,lte=65535"`

+ 67 - 11
internal/web/job/amneziawg_job.go

@@ -1,24 +1,20 @@
 package job
 package job
 
 
 import (
 import (
+	"encoding/json"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
 	"github.com/mhsanaei/3x-ui/v3/internal/amneziawgnet"
 	"github.com/mhsanaei/3x-ui/v3/internal/amneziawgnet"
 	"github.com/mhsanaei/3x-ui/v3/internal/logger"
 	"github.com/mhsanaei/3x-ui/v3/internal/logger"
 	"github.com/mhsanaei/3x-ui/v3/internal/web/service"
 	"github.com/mhsanaei/3x-ui/v3/internal/web/service"
+	"github.com/mhsanaei/3x-ui/v3/internal/xray"
 )
 )
 
 
-// AmneziaWGJob reconciles the running embedded AmneziaWG interfaces
-// (internal/amneziawgnet -- amneziawg-go over a gVisor netstack, no kernel
-// module) against the enabled AmneziaWG inbounds in the database,
-// rebuilding/reconfiguring any that drifted. Unlike the retired
-// kernel-module Manager this job used to drive, there is no traffic/
-// online-status accounting here at all: once a peer's decapsulated traffic
-// is relayed into Xray's own SOCKS5 inbound (see
-// internal/web/service/xray.go's injectAmneziawgnetSocks, and
-// internal/amneziawgnet.Manager's automatic forwarder/relay wiring), it's
-// an ordinary Xray user, and XrayTrafficJob's existing, protocol-blind
-// stats/online-status polling already picks it up for free.
+// AmneziaWGJob converges embedded AmneziaWG interfaces (inbounds AND the
+// template's "amneziawg" outbounds) every 10s; stats stay with Xray.
 type AmneziaWGJob struct {
 type AmneziaWGJob struct {
 	inboundService service.InboundService
 	inboundService service.InboundService
+	settingService service.SettingService
 }
 }
 
 
 // NewAmneziaWGJob creates a new AmneziaWG reconcile job instance.
 // NewAmneziaWGJob creates a new AmneziaWG reconcile job instance.
@@ -52,4 +48,64 @@ func (j *AmneziaWGJob) Run() {
 		})
 		})
 	}
 	}
 	amneziawgnet.GetManager().Reconcile(wanted)
 	amneziawgnet.GetManager().Reconcile(wanted)
+
+	outboundDesired, err := j.desiredOutboundInstances()
+	if err != nil {
+		logger.Warning("amneziawg job: get desired outbound instances failed:", err)
+		return
+	}
+	amneziawgnet.GetOutboundManager().Reconcile(outboundDesired)
+}
+
+// desiredOutboundInstances derives client instances per template "amneziawg" outbound.
+func (j *AmneziaWGJob) desiredOutboundInstances() ([]amneziawgnet.OutboundDesired, error) {
+	template, err := j.settingService.GetXrayConfigTemplate()
+	if err != nil {
+		return nil, err
+	}
+	if template == "" {
+		return nil, nil
+	}
+	cfg := &xray.Config{}
+	if err := json.Unmarshal([]byte(template), cfg); err != nil {
+		return nil, err
+	}
+	if len(cfg.OutboundConfigs) == 0 {
+		return nil, nil
+	}
+	var raws []json.RawMessage
+	if err := json.Unmarshal(cfg.OutboundConfigs, &raws); err != nil {
+		return nil, err
+	}
+	out := make([]amneziawgnet.OutboundDesired, 0, len(raws))
+	for _, raw := range raws {
+		if !amneziawg.IsAmneziaWGOutbound(raw) {
+			continue
+		}
+		var probe struct {
+			Tag string `json:"tag"`
+		}
+		if err := json.Unmarshal(raw, &probe); err != nil || probe.Tag == "" {
+			continue
+		}
+		inst, ok := amneziawg.InstanceFromOutbound(probe.Tag, raw)
+		if !ok {
+			continue
+		}
+		out = append(out, amneziawgnet.OutboundDesired{
+			Instance: inst,
+			Options: amneziawgnet.DeviceOptions{
+				HeaderProtectionKey:    inst.Obfuscation.HeaderProtectionKey,
+				ContentPaddingAddition: inst.Obfuscation.ContentPaddingAddition,
+				RekeyAfterTime:         inst.Obfuscation.RekeyAfterTime,
+				RekeyTimeout:           inst.Obfuscation.RekeyTimeout,
+				RejectAfterTime:        inst.Obfuscation.RejectAfterTime,
+				KeepaliveTimeout:       inst.Obfuscation.KeepaliveTimeout,
+				MaxHandshakeAttempts:   inst.Obfuscation.MaxHandshakeAttempts,
+				RandomTrailers:         inst.Obfuscation.RandomTrailers,
+				DisableCookies:         inst.Obfuscation.DisableCookies,
+			},
+		})
+	}
+	return out, nil
 }
 }

+ 6 - 1
internal/web/job/remote_routing_job.go

@@ -27,5 +27,10 @@ func (j *RemoteRoutingJob) Run() {
 		logger.Warning("Could not read Clash routing source:", err)
 		logger.Warning("Could not read Clash routing source:", err)
 		return
 		return
 	}
 	}
-	sub.RefreshRemoteRoutingSources(happ, clash)
+	jsonRouting, err := j.settingService.GetSubJsonRoutingRules()
+	if err != nil {
+		logger.Warning("Could not read JSON subscription routing source:", err)
+		return
+	}
+	sub.RefreshRemoteRoutingSources(happ, clash, jsonRouting)
 }
 }

+ 130 - 36
internal/web/service/client_bulk.go

@@ -312,11 +312,13 @@ var bulkFlowAllowed = map[string]struct{}{
 // for every email in the list. Clients whose corresponding field is
 // for every email in the list. Clients whose corresponding field is
 // unlimited (0) are skipped — bulk extend should not accidentally
 // unlimited (0) are skipped — bulk extend should not accidentally
 // limit an unlimited client. addDays and addBytes may be negative.
 // limit an unlimited client. addDays and addBytes may be negative.
+// flow sets the XTLS flow, limitHwid the max registered devices (0 = unlimited)
+// and adTag the MTProto sponsor channel; "none" clears flow or adTag.
 //
 //
 // Like BulkDelete, the work is grouped by inbound so each inbound's
 // Like BulkDelete, the work is grouped by inbound so each inbound's
 // settings JSON is parsed and written exactly once regardless of how
 // settings JSON is parsed and written exactly once regardless of how
 // many target emails it contains.
 // many target emails it contains.
-func (s *ClientService) BulkAdjust(inboundSvc *InboundService, emails []string, addDays int, addBytes int64, flow string) (BulkAdjustResult, bool, error) {
+func (s *ClientService) BulkAdjust(inboundSvc *InboundService, emails []string, addDays int, addBytes int64, flow string, limitHwid *int, adTag string) (BulkAdjustResult, bool, error) {
 	result := BulkAdjustResult{}
 	result := BulkAdjustResult{}
 	if len(emails) == 0 {
 	if len(emails) == 0 {
 		return result, false, nil
 		return result, false, nil
@@ -325,8 +327,18 @@ func (s *ClientService) BulkAdjust(inboundSvc *InboundService, emails []string,
 	if _, ok := bulkFlowAllowed[flow]; !ok {
 	if _, ok := bulkFlowAllowed[flow]; !ok {
 		flow = "" // ignore unknown directives — "" means "leave flow untouched"
 		flow = "" // ignore unknown directives — "" means "leave flow untouched"
 	}
 	}
+	adTag = strings.TrimSpace(adTag)
+	if adTag != "" && adTag != bulkFlowClear && !model.ValidMtprotoAdTag(adTag) {
+		return result, false, common.NewError("mtproto client ad tag must be 32 hex characters")
+	}
+	if limitHwid != nil && *limitHwid < 0 {
+		zero := 0
+		limitHwid = &zero
+	}
 	adjustFlow := flow != ""
 	adjustFlow := flow != ""
-	if addDays == 0 && addBytes == 0 && !adjustFlow {
+	adjustHwid := limitHwid != nil
+	adjustAdTag := adTag != ""
+	if addDays == 0 && addBytes == 0 && !adjustFlow && !adjustHwid && !adjustAdTag {
 		return result, false, common.NewError("no adjustment specified")
 		return result, false, common.NewError("no adjustment specified")
 	}
 	}
 
 
@@ -419,7 +431,7 @@ func (s *ClientService) BulkAdjust(inboundSvc *InboundService, emails []string,
 				}
 				}
 			}
 			}
 		}
 		}
-		if entry.applyExpiry || entry.applyTotal || adjustFlow {
+		if entry.applyExpiry || entry.applyTotal || adjustFlow || adjustHwid || adjustAdTag {
 			plan[email] = entry
 			plan[email] = entry
 		}
 		}
 	}
 	}
@@ -434,8 +446,10 @@ func (s *ClientService) BulkAdjust(inboundSvc *InboundService, emails []string,
 	plannedIds := make([]int, 0, len(plan))
 	plannedIds := make([]int, 0, len(plan))
 	recordIdToEmail := make(map[int]string, len(plan))
 	recordIdToEmail := make(map[int]string, len(plan))
 	for email, entry := range plan {
 	for email, entry := range plan {
-		plannedIds = append(plannedIds, entry.record.Id)
-		recordIdToEmail[entry.record.Id] = email
+		if entry.applyExpiry || entry.applyTotal || adjustFlow || adjustAdTag {
+			plannedIds = append(plannedIds, entry.record.Id)
+			recordIdToEmail[entry.record.Id] = email
+		}
 	}
 	}
 
 
 	var mappings []model.ClientInbound
 	var mappings []model.ClientInbound
@@ -458,10 +472,12 @@ func (s *ClientService) BulkAdjust(inboundSvc *InboundService, emails []string,
 	needRestart := false
 	needRestart := false
 	flowHonored := map[string]bool{}
 	flowHonored := map[string]bool{}
 	flowIneligible := map[string]bool{}
 	flowIneligible := map[string]bool{}
+	adTagHonored := map[string]bool{}
+	adTagIneligible := map[string]bool{}
 	execFailed := map[string]bool{}
 	execFailed := map[string]bool{}
 	adjustIds := sortedInboundIds(emailsByInbound)
 	adjustIds := sortedInboundIds(emailsByInbound)
 	adjustResults, adjustPanics := fanoutInboundResults(adjustIds, inboundFanoutConcurrency, func(i int) bulkInboundAdjustResult {
 	adjustResults, adjustPanics := fanoutInboundResults(adjustIds, inboundFanoutConcurrency, func(i int) bulkInboundAdjustResult {
-		return s.bulkAdjustInboundClients(inboundSvc, adjustIds[i], emailsByInbound[adjustIds[i]], plan, flow)
+		return s.bulkAdjustInboundClients(inboundSvc, adjustIds[i], emailsByInbound[adjustIds[i]], plan, flow, adTag)
 	})
 	})
 	for i, ibRes := range adjustResults {
 	for i, ibRes := range adjustResults {
 		if adjustPanics[i] != nil {
 		if adjustPanics[i] != nil {
@@ -483,6 +499,12 @@ func (s *ClientService) BulkAdjust(inboundSvc *InboundService, emails []string,
 		for email := range ibRes.flowIneligible {
 		for email := range ibRes.flowIneligible {
 			flowIneligible[email] = true
 			flowIneligible[email] = true
 		}
 		}
+		for email := range ibRes.adTagHonored {
+			adTagHonored[email] = true
+		}
+		for email := range ibRes.adTagIneligible {
+			adTagIneligible[email] = true
+		}
 		for email, reason := range ibRes.perEmailSkipped {
 		for email, reason := range ibRes.perEmailSkipped {
 			execFailed[email] = true
 			execFailed[email] = true
 			if _, already := skippedReasons[email]; !already {
 			if _, already := skippedReasons[email]; !already {
@@ -511,6 +533,11 @@ func (s *ClientService) BulkAdjust(inboundSvc *InboundService, emails []string,
 		}
 		}
 	}
 	}
 
 
+	wantAdTag := ""
+	if adjustAdTag && adTag != bulkFlowClear {
+		wantAdTag = strings.ToLower(adTag)
+	}
+
 	adjusted := map[string]struct{}{}
 	adjusted := map[string]struct{}{}
 	for email, entry := range plan {
 	for email, entry := range plan {
 		if execFailed[email] {
 		if execFailed[email] {
@@ -531,9 +558,24 @@ func (s *ClientService) BulkAdjust(inboundSvc *InboundService, emails []string,
 				continue
 				continue
 			}
 			}
 		}
 		}
-		// Counted when expiry/total changed, or a flow directive was honored
-		// for this client (flow lives in the inbound JSON, not ClientTraffic).
-		if len(updates) > 0 || flowHonored[email] {
+		if adjustHwid {
+			if err := s.setClientLimitHwidByEmail(db, email, *limitHwid); err != nil {
+				if _, already := skippedReasons[email]; !already {
+					skippedReasons[email] = err.Error()
+				}
+				continue
+			}
+		}
+		if adjustAdTag && adTagHonored[email] {
+			if err := db.Model(&model.ClientRecord{}).Where("email = ?", email).UpdateColumn("ad_tag", wantAdTag).Error; err != nil {
+				if _, already := skippedReasons[email]; !already {
+					skippedReasons[email] = err.Error()
+				}
+				continue
+			}
+		}
+		// Counted when expiry/total changed, flow was honored, adTag was honored, or limitHwid was adjusted.
+		if len(updates) > 0 || flowHonored[email] || adTagHonored[email] || adjustHwid {
 			adjusted[email] = struct{}{}
 			adjusted[email] = struct{}{}
 		}
 		}
 	}
 	}
@@ -554,6 +596,15 @@ func (s *ClientService) BulkAdjust(inboundSvc *InboundService, emails []string,
 		}
 		}
 		result.Skipped = append(result.Skipped, BulkAdjustReport{Email: email, Reason: "flow not supported on inbound"})
 		result.Skipped = append(result.Skipped, BulkAdjustReport{Email: email, Reason: "flow not supported on inbound"})
 	}
 	}
+	for email := range adTagIneligible {
+		if adTagHonored[email] {
+			continue
+		}
+		if _, already := skippedReasons[email]; already {
+			continue
+		}
+		result.Skipped = append(result.Skipped, BulkAdjustReport{Email: email, Reason: "adTag not supported on inbound"})
+	}
 
 
 	if len(wasDisabledDepleted) > 0 {
 	if len(wasDisabledDepleted) > 0 {
 		stillDepleted := map[string]struct{}{}
 		stillDepleted := map[string]struct{}{}
@@ -599,8 +650,10 @@ type bulkInboundAdjustResult struct {
 	// that an inbound cannot carry must not suppress the expiry/total write for
 	// that an inbound cannot carry must not suppress the expiry/total write for
 	// the same client (which would diverge the inbound JSON / ClientRecord from
 	// the same client (which would diverge the inbound JSON / ClientRecord from
 	// ClientTraffic). It only feeds the final Skipped report.
 	// ClientTraffic). It only feeds the final Skipped report.
-	flowIneligible map[string]bool
-	needRestart    bool
+	flowIneligible  map[string]bool
+	adTagHonored    map[string]bool
+	adTagIneligible map[string]bool
+	needRestart     bool
 }
 }
 
 
 // bulkAdjustInboundClients applies expiry/total deltas to multiple clients
 // bulkAdjustInboundClients applies expiry/total deltas to multiple clients
@@ -615,8 +668,15 @@ func (s *ClientService) bulkAdjustInboundClients(
 	emails []string,
 	emails []string,
 	plan map[string]*bulkAdjustEntry,
 	plan map[string]*bulkAdjustEntry,
 	flow string,
 	flow string,
+	adTag string,
 ) bulkInboundAdjustResult {
 ) bulkInboundAdjustResult {
-	res := bulkInboundAdjustResult{perEmailSkipped: map[string]string{}, flowHonored: map[string]bool{}, flowIneligible: map[string]bool{}}
+	res := bulkInboundAdjustResult{
+		perEmailSkipped: map[string]string{},
+		flowHonored:     map[string]bool{},
+		flowIneligible:  map[string]bool{},
+		adTagHonored:    map[string]bool{},
+		adTagIneligible: map[string]bool{},
+	}
 
 
 	defer lockInbound(inboundId).Unlock()
 	defer lockInbound(inboundId).Unlock()
 
 
@@ -655,9 +715,16 @@ func (s *ClientService) bulkAdjustInboundClients(
 		(!oldInbound.DisableFlow &&
 		(!oldInbound.DisableFlow &&
 			inboundCanEnableTlsFlow(string(oldInbound.Protocol), oldInbound.StreamSettings, oldInbound.Settings))
 			inboundCanEnableTlsFlow(string(oldInbound.Protocol), oldInbound.StreamSettings, oldInbound.Settings))
 
 
+	wantAdTag := ""
+	if adTag != "" && adTag != bulkFlowClear {
+		wantAdTag = strings.ToLower(adTag)
+	}
+
 	interfaceClients, _ := settings["clients"].([]any)
 	interfaceClients, _ := settings["clients"].([]any)
 	foundEmails := map[string]bool{}
 	foundEmails := map[string]bool{}
 	flowChanged := false
 	flowChanged := false
+	adTagChanged := false
+	hasInboundChanges := false
 	nowMs := time.Now().Unix() * 1000
 	nowMs := time.Now().Unix() * 1000
 	for i, client := range interfaceClients {
 	for i, client := range interfaceClients {
 		c, ok := client.(map[string]any)
 		c, ok := client.(map[string]any)
@@ -668,12 +735,15 @@ func (s *ClientService) bulkAdjustInboundClients(
 		if _, want := wantedEmails[targetEmail]; !want || targetEmail == "" {
 		if _, want := wantedEmails[targetEmail]; !want || targetEmail == "" {
 			continue
 			continue
 		}
 		}
+		clientChanged := false
 		entry := plan[targetEmail]
 		entry := plan[targetEmail]
 		if entry.applyExpiry {
 		if entry.applyExpiry {
 			c["expiryTime"] = entry.newExpiry
 			c["expiryTime"] = entry.newExpiry
+			clientChanged = true
 		}
 		}
 		if entry.applyTotal {
 		if entry.applyTotal {
 			c["totalGB"] = entry.newTotal
 			c["totalGB"] = entry.newTotal
+			clientChanged = true
 		}
 		}
 		if flow != "" {
 		if flow != "" {
 			if flowEligible {
 			if flowEligible {
@@ -686,13 +756,29 @@ func (s *ClientService) bulkAdjustInboundClients(
 					flowChanged = true
 					flowChanged = true
 				}
 				}
 				res.flowHonored[targetEmail] = true
 				res.flowHonored[targetEmail] = true
+				clientChanged = true
 			} else {
 			} else {
 				// Record separately so this never suppresses the expiry/total
 				// Record separately so this never suppresses the expiry/total
 				// write for the same client (see flowIneligible doc).
 				// write for the same client (see flowIneligible doc).
 				res.flowIneligible[targetEmail] = true
 				res.flowIneligible[targetEmail] = true
 			}
 			}
 		}
 		}
-		c["updated_at"] = nowMs
+		if adTag != "" {
+			if oldInbound.Protocol == model.MTProto {
+				if cur, _ := c["adTag"].(string); cur != wantAdTag {
+					c["adTag"] = wantAdTag
+					adTagChanged = true
+				}
+				res.adTagHonored[targetEmail] = true
+				clientChanged = true
+			} else {
+				res.adTagIneligible[targetEmail] = true
+			}
+		}
+		if clientChanged {
+			c["updated_at"] = nowMs
+			hasInboundChanges = true
+		}
 		interfaceClients[i] = c
 		interfaceClients[i] = c
 		foundEmails[targetEmail] = true
 		foundEmails[targetEmail] = true
 	}
 	}
@@ -703,7 +789,7 @@ func (s *ClientService) bulkAdjustInboundClients(
 		}
 		}
 	}
 	}
 
 
-	if len(foundEmails) == 0 {
+	if len(foundEmails) == 0 || !hasInboundChanges {
 		return res
 		return res
 	}
 	}
 
 
@@ -748,28 +834,36 @@ func (s *ClientService) bulkAdjustInboundClients(
 				res.perEmailSkipped[email] = txErr.Error()
 				res.perEmailSkipped[email] = txErr.Error()
 			}
 			}
 		}
 		}
-	} else if oldInbound.NodeID != nil && !flowChanged && len(foundEmails) <= nodeBulkPushThreshold {
-		rt, push, _, perr := inboundSvc.nodePushPlan(oldInbound)
-		if perr != nil {
-			logger.Warning("BulkAdjust: node runtime lookup after commit failed:", perr)
-		} else if push {
-			for email := range foundEmails {
-				entry := plan[email]
-				updated := *entry.record.ToClient()
-				if entry.applyExpiry {
-					updated.ExpiryTime = entry.newExpiry
-				}
-				if entry.applyTotal {
-					updated.TotalGB = entry.newTotal
-				}
-				updated.UpdatedAt = nowMs
-				ctx, cancel := nodePushContext()
-				err1 := rt.UpdateUser(ctx, oldInbound, email, updated)
-				cancel()
-				if err1 != nil {
-					logger.Warning("Error in updating client on", rt.Name(), ":", err1)
-					// First failure ends the batch push; the reconcile converges the rest.
-					break
+	} else {
+		if adTagChanged && oldInbound.Protocol == model.MTProto && oldInbound.NodeID == nil {
+			inboundSvc.applyLocalMtproto(oldInbound.Id)
+		}
+		if oldInbound.NodeID != nil && !flowChanged && len(foundEmails) <= nodeBulkPushThreshold {
+			rt, push, _, perr := inboundSvc.nodePushPlan(oldInbound)
+			if perr != nil {
+				logger.Warning("BulkAdjust: node runtime lookup after commit failed:", perr)
+			} else if push {
+				for email := range foundEmails {
+					entry := plan[email]
+					updated := *entry.record.ToClient()
+					if entry.applyExpiry {
+						updated.ExpiryTime = entry.newExpiry
+					}
+					if entry.applyTotal {
+						updated.TotalGB = entry.newTotal
+					}
+					if adTag != "" && oldInbound.Protocol == model.MTProto {
+						updated.AdTag = wantAdTag
+					}
+					updated.UpdatedAt = nowMs
+					ctx, cancel := nodePushContext()
+					err1 := rt.UpdateUser(ctx, oldInbound, email, updated)
+					cancel()
+					if err1 != nil {
+						logger.Warning("Error in updating client on", rt.Name(), ":", err1)
+						// First failure ends the batch push; the reconcile converges the rest.
+						break
+					}
 				}
 				}
 			}
 			}
 		}
 		}

+ 1 - 1
internal/web/service/client_bulk_fanout_test.go

@@ -77,7 +77,7 @@ func TestBulkAdjustAcrossNodesPushesConcurrently(t *testing.T) {
 	}
 	}
 
 
 	bar.arm()
 	bar.arm()
-	if _, _, err := (&ClientService{}).BulkAdjust(&InboundService{}, []string{email}, 1, 0, ""); err != nil {
+	if _, _, err := (&ClientService{}).BulkAdjust(&InboundService{}, []string{email}, 1, 0, "", nil, ""); err != nil {
 		t.Fatalf("BulkAdjust across %d node inbounds: %v", nodes, err)
 		t.Fatalf("BulkAdjust across %d node inbounds: %v", nodes, err)
 	}
 	}
 	if got := bar.updateUser.Load(); got == 0 {
 	if got := bar.updateUser.Load(); got == 0 {

+ 323 - 7
internal/web/service/client_bulk_flow_test.go

@@ -1,6 +1,7 @@
 package service
 package service
 
 
 import (
 import (
+	"encoding/json"
 	"testing"
 	"testing"
 	"time"
 	"time"
 
 
@@ -64,7 +65,7 @@ func TestBulkAdjust_FlowSetAndClear(t *testing.T) {
 	emails := emailsOf(clients)
 	emails := emailsOf(clients)
 
 
 	// Set vision flow.
 	// Set vision flow.
-	res, restart, err := svc.BulkAdjust(inboundSvc, emails, 0, 0, "xtls-rprx-vision-udp443")
+	res, restart, err := svc.BulkAdjust(inboundSvc, emails, 0, 0, "xtls-rprx-vision-udp443", nil, "")
 	if err != nil {
 	if err != nil {
 		t.Fatalf("BulkAdjust set: %v", err)
 		t.Fatalf("BulkAdjust set: %v", err)
 	}
 	}
@@ -81,14 +82,14 @@ func TestBulkAdjust_FlowSetAndClear(t *testing.T) {
 	}
 	}
 
 
 	// Setting the same flow again is a no-op: honored (counted) but no restart.
 	// Setting the same flow again is a no-op: honored (counted) but no restart.
-	if _, restart2, err := svc.BulkAdjust(inboundSvc, emails, 0, 0, "xtls-rprx-vision-udp443"); err != nil {
+	if _, restart2, err := svc.BulkAdjust(inboundSvc, emails, 0, 0, "xtls-rprx-vision-udp443", nil, ""); err != nil {
 		t.Fatalf("BulkAdjust idempotent: %v", err)
 		t.Fatalf("BulkAdjust idempotent: %v", err)
 	} else if restart2 {
 	} else if restart2 {
 		t.Fatalf("re-setting identical flow should not request a restart")
 		t.Fatalf("re-setting identical flow should not request a restart")
 	}
 	}
 
 
 	// Clear flow.
 	// Clear flow.
-	cres, crestart, err := svc.BulkAdjust(inboundSvc, emails, 0, 0, "none")
+	cres, crestart, err := svc.BulkAdjust(inboundSvc, emails, 0, 0, "none", nil, "")
 	if err != nil {
 	if err != nil {
 		t.Fatalf("BulkAdjust clear: %v", err)
 		t.Fatalf("BulkAdjust clear: %v", err)
 	}
 	}
@@ -121,7 +122,7 @@ func TestBulkAdjust_FlowIneligibleSkipped(t *testing.T) {
 		t.Fatalf("seed: %v", err)
 		t.Fatalf("seed: %v", err)
 	}
 	}
 
 
-	res, restart, err := svc.BulkAdjust(inboundSvc, []string{"ws1@x"}, 0, 0, "xtls-rprx-vision")
+	res, restart, err := svc.BulkAdjust(inboundSvc, []string{"ws1@x"}, 0, 0, "xtls-rprx-vision", nil, "")
 	if err != nil {
 	if err != nil {
 		t.Fatalf("BulkAdjust: %v", err)
 		t.Fatalf("BulkAdjust: %v", err)
 	}
 	}
@@ -146,11 +147,11 @@ func TestBulkAdjust_NoDirectiveErrors(t *testing.T) {
 	svc := &ClientService{}
 	svc := &ClientService{}
 	inboundSvc := &InboundService{}
 	inboundSvc := &InboundService{}
 
 
-	if _, _, err := svc.BulkAdjust(inboundSvc, []string{"any@x"}, 0, 0, ""); err == nil {
+	if _, _, err := svc.BulkAdjust(inboundSvc, []string{"any@x"}, 0, 0, "", nil, ""); err == nil {
 		t.Fatalf("expected error when no adjustment is specified")
 		t.Fatalf("expected error when no adjustment is specified")
 	}
 	}
 	// An unknown flow directive is ignored (treated as ""), so it also errors.
 	// An unknown flow directive is ignored (treated as ""), so it also errors.
-	if _, _, err := svc.BulkAdjust(inboundSvc, []string{"any@x"}, 0, 0, "bogus-flow"); err == nil {
+	if _, _, err := svc.BulkAdjust(inboundSvc, []string{"any@x"}, 0, 0, "bogus-flow", nil, ""); err == nil {
 		t.Fatalf("unknown flow should be ignored and error like an empty directive")
 		t.Fatalf("unknown flow should be ignored and error like an empty directive")
 	}
 	}
 }
 }
@@ -182,7 +183,7 @@ func TestBulkAdjust_DaysApplyDespiteIneligibleFlow(t *testing.T) {
 		t.Fatalf("seed traffic: %v", err)
 		t.Fatalf("seed traffic: %v", err)
 	}
 	}
 
 
-	res, _, err := svc.BulkAdjust(inboundSvc, []string{"mix@x"}, 7, gb, "xtls-rprx-vision")
+	res, _, err := svc.BulkAdjust(inboundSvc, []string{"mix@x"}, 7, gb, "xtls-rprx-vision", nil, "")
 	if err != nil {
 	if err != nil {
 		t.Fatalf("BulkAdjust: %v", err)
 		t.Fatalf("BulkAdjust: %v", err)
 	}
 	}
@@ -217,3 +218,318 @@ func TestBulkAdjust_DaysApplyDespiteIneligibleFlow(t *testing.T) {
 		t.Fatalf("flow should stay empty on ineligible inbound, got %q", got)
 		t.Fatalf("flow should stay empty on ineligible inbound, got %q", got)
 	}
 	}
 }
 }
+
+// TestBulkAdjust_HwidLimit verifies setting and clearing HWID limit in bulk.
+func TestBulkAdjust_HwidLimit(t *testing.T) {
+	setupBulkDB(t)
+	svc := &ClientService{}
+	inboundSvc := &InboundService{}
+
+	clients := []model.Client{
+		{Email: "h1@x", ID: "11111111-1111-1111-1111-111111111111", SubID: "sub-h1", Enable: true},
+		{Email: "h2@x", ID: "22222222-2222-2222-2222-222222222222", SubID: "sub-h2", Enable: true},
+	}
+	ib := mkInbound(t, 30301, model.VLESS, clientsSettings(t, clients))
+	if err := svc.SyncInbound(nil, ib.Id, clients); err != nil {
+		t.Fatalf("seed: %v", err)
+	}
+	emails := emailsOf(clients)
+
+	limit2 := 2
+	res, restart, err := svc.BulkAdjust(inboundSvc, emails, 0, 0, "", &limit2, "")
+	if err != nil {
+		t.Fatalf("BulkAdjust hwid: %v", err)
+	}
+	if res.Adjusted != 2 {
+		t.Fatalf("expected 2 adjusted, got %d", res.Adjusted)
+	}
+	if restart {
+		t.Fatalf("hwid adjustment should not request xray restart")
+	}
+	for _, e := range emails {
+		rec, rErr := svc.GetRecordByEmail(nil, e)
+		if rErr != nil || rec.LimitHwid != 2 {
+			t.Fatalf("%s limitHwid = %d (err=%v), want 2", e, rec.LimitHwid, rErr)
+		}
+	}
+
+	// Reset to 0 (unlimited)
+	limit0 := 0
+	res0, _, err0 := svc.BulkAdjust(inboundSvc, emails, 0, 0, "", &limit0, "")
+	if err0 != nil || res0.Adjusted != 2 {
+		t.Fatalf("BulkAdjust hwid 0: err=%v, res=%+v", err0, res0)
+	}
+	for _, e := range emails {
+		rec, _ := svc.GetRecordByEmail(nil, e)
+		if rec.LimitHwid != 0 {
+			t.Fatalf("%s limitHwid = %d, want 0", e, rec.LimitHwid)
+		}
+	}
+}
+
+// TestBulkAdjust_MtprotoAdTagSetAndClear verifies ad-tag bulk update and clearing.
+func TestBulkAdjust_MtprotoAdTagSetAndClear(t *testing.T) {
+	setupBulkDB(t)
+	svc := &ClientService{}
+	inboundSvc := &InboundService{}
+
+	const tag1 = "0123456789abcdef0123456789abcdef"
+	clients := []model.Client{
+		{Email: "tg1@x", Secret: "ee00112233445566778899aabbccddeeff6578616d706c652e636f6d", Enable: true},
+		{Email: "tg2@x", Secret: "ee101112131415161718191a1b1c1d1e1f6578616d706c652e636f6d", Enable: true},
+	}
+	ib := &model.Inbound{
+		Tag:      "mtproto-bulk-test",
+		Enable:   true,
+		Port:     30401,
+		Protocol: model.MTProto,
+		Settings: clientsSettings(t, clients),
+	}
+	if err := database.GetDB().Create(ib).Error; err != nil {
+		t.Fatalf("create mtproto inbound: %v", err)
+	}
+	if err := svc.SyncInbound(nil, ib.Id, clients); err != nil {
+		t.Fatalf("seed mtproto: %v", err)
+	}
+	emails := emailsOf(clients)
+
+	// Set ad-tag
+	res, restart, err := svc.BulkAdjust(inboundSvc, emails, 0, 0, "", nil, tag1)
+	if err != nil {
+		t.Fatalf("BulkAdjust adTag: %v", err)
+	}
+	if res.Adjusted != 2 {
+		t.Fatalf("expected 2 adjusted, got %d", res.Adjusted)
+	}
+	if restart {
+		t.Fatalf("mtproto adTag update should not request xray restart")
+	}
+	for _, e := range emails {
+		rec, _ := svc.GetRecordByEmail(nil, e)
+		if rec.AdTag != tag1 {
+			t.Fatalf("%s adTag = %q, want %q", e, rec.AdTag, tag1)
+		}
+	}
+
+	// Clear ad-tag with "none"
+	cres, _, cerr := svc.BulkAdjust(inboundSvc, emails, 0, 0, "", nil, "none")
+	if cerr != nil || cres.Adjusted != 2 {
+		t.Fatalf("BulkAdjust clear adTag: err=%v, res=%+v", cerr, cres)
+	}
+	for _, e := range emails {
+		rec, _ := svc.GetRecordByEmail(nil, e)
+		if rec.AdTag != "" {
+			t.Fatalf("%s adTag = %q, want empty after clear", e, rec.AdTag)
+		}
+	}
+
+	// Invalid ad-tag errors
+	if _, _, err := svc.BulkAdjust(inboundSvc, emails, 0, 0, "", nil, "invalid-hex"); err == nil {
+		t.Fatalf("expected error for invalid hex ad tag")
+	}
+}
+
+// TestBulkAdjust_AdTagIneligibleSkipped verifies that non-MTProto clients are
+// refused adTag adjustment, reported as skipped, and their ClientRecord is untouched.
+func TestBulkAdjust_AdTagIneligibleSkipped(t *testing.T) {
+	setupBulkDB(t)
+	svc := &ClientService{}
+	inboundSvc := &InboundService{}
+
+	clients := []model.Client{
+		{Email: "vless-notg@x", ID: "55555555-5555-5555-5555-555555555555", SubID: "vless-notg", Enable: true},
+	}
+	ib := mkInbound(t, 30501, model.VLESS, clientsSettings(t, clients))
+	if err := svc.SyncInbound(nil, ib.Id, clients); err != nil {
+		t.Fatalf("seed: %v", err)
+	}
+
+	const tag1 = "0123456789abcdef0123456789abcdef"
+	res, restart, err := svc.BulkAdjust(inboundSvc, []string{"vless-notg@x"}, 0, 0, "", nil, tag1)
+	if err != nil {
+		t.Fatalf("BulkAdjust: %v", err)
+	}
+	if res.Adjusted != 0 {
+		t.Fatalf("ineligible protocol should adjust nothing, got %d", res.Adjusted)
+	}
+	if restart {
+		t.Fatalf("no change should not request restart")
+	}
+	if len(res.Skipped) != 1 || res.Skipped[0].Email != "vless-notg@x" || res.Skipped[0].Reason != "adTag not supported on inbound" {
+		t.Fatalf("expected vless-notg@x in skipped with 'adTag not supported on inbound', got %+v", res.Skipped)
+	}
+	rec, err := svc.GetRecordByEmail(nil, "vless-notg@x")
+	if err != nil {
+		t.Fatalf("GetRecordByEmail: %v", err)
+	}
+	if rec.AdTag != "" {
+		t.Fatalf("adTag on non-MTProto record should stay empty, got %q", rec.AdTag)
+	}
+}
+
+// TestBulkAdjust_DaysApplyDespiteIneligibleAdTag verifies that when a non-MTProto
+// client is adjusted with both days and adTag, days are applied but adTag is not
+// written to ClientRecord and is reported as skipped.
+func TestBulkAdjust_DaysApplyDespiteIneligibleAdTag(t *testing.T) {
+	setupBulkDB(t)
+	svc := &ClientService{}
+	inboundSvc := &InboundService{}
+
+	const day = int64(24 * 60 * 60 * 1000)
+	baseExpiry := time.Now().UnixMilli() + 30*day
+
+	clients := []model.Client{
+		{Email: "vless-days@x", ID: "66666666-6666-6666-6666-666666666666", SubID: "vless-days", Enable: true, ExpiryTime: baseExpiry},
+	}
+	ib := mkInbound(t, 30601, model.VLESS, clientsSettings(t, clients))
+	if err := svc.SyncInbound(nil, ib.Id, clients); err != nil {
+		t.Fatalf("seed: %v", err)
+	}
+	if err := database.GetDB().Create(&xray.ClientTraffic{Email: "vless-days@x", Enable: true, ExpiryTime: baseExpiry}).Error; err != nil {
+		t.Fatalf("seed traffic: %v", err)
+	}
+
+	const tag1 = "0123456789abcdef0123456789abcdef"
+	res, _, err := svc.BulkAdjust(inboundSvc, []string{"vless-days@x"}, 7, 0, "", nil, tag1)
+	if err != nil {
+		t.Fatalf("BulkAdjust: %v", err)
+	}
+	if res.Adjusted != 1 {
+		t.Fatalf("days should still be applied: Adjusted=%d skipped=%v", res.Adjusted, res.Skipped)
+	}
+	if len(res.Skipped) != 1 || res.Skipped[0].Email != "vless-days@x" || res.Skipped[0].Reason != "adTag not supported on inbound" {
+		t.Fatalf("expected vless-days@x reported for unhonored adTag, got %v", res.Skipped)
+	}
+
+	rec, err := svc.GetRecordByEmail(nil, "vless-days@x")
+	if err != nil {
+		t.Fatalf("record: %v", err)
+	}
+	if rec.ExpiryTime != baseExpiry+7*day {
+		t.Fatalf("expiry time not advanced: got %d, want %d", rec.ExpiryTime, baseExpiry+7*day)
+	}
+	if rec.AdTag != "" {
+		t.Fatalf("adTag should remain empty on ClientRecord for non-MTProto, got %q", rec.AdTag)
+	}
+}
+
+// TestBulkAdjust_MixedMtprotoAndVless_AdTag verifies bulk adjust over a mixed
+// MTProto and VLESS selection.
+func TestBulkAdjust_MixedMtprotoAndVless_AdTag(t *testing.T) {
+	setupBulkDB(t)
+	svc := &ClientService{}
+	inboundSvc := &InboundService{}
+
+	const tag1 = "0123456789abcdef0123456789abcdef"
+	tgClients := []model.Client{
+		{Email: "tg-mix@x", Secret: "ee00112233445566778899aabbccddeeff6578616d706c652e636f6d", Enable: true},
+	}
+	tgIb := &model.Inbound{
+		Tag:      "mtproto-mix",
+		Enable:   true,
+		Port:     30701,
+		Protocol: model.MTProto,
+		Settings: clientsSettings(t, tgClients),
+	}
+	if err := database.GetDB().Create(tgIb).Error; err != nil {
+		t.Fatalf("create mtproto: %v", err)
+	}
+	if err := svc.SyncInbound(nil, tgIb.Id, tgClients); err != nil {
+		t.Fatalf("sync mtproto: %v", err)
+	}
+
+	vlessClients := []model.Client{
+		{Email: "vless-mix@x", ID: "77777777-7777-7777-7777-777777777777", SubID: "vless-mix", Enable: true},
+	}
+	vlessIb := mkInbound(t, 30702, model.VLESS, clientsSettings(t, vlessClients))
+	if err := svc.SyncInbound(nil, vlessIb.Id, vlessClients); err != nil {
+		t.Fatalf("sync vless: %v", err)
+	}
+
+	emails := []string{"tg-mix@x", "vless-mix@x"}
+	res, restart, err := svc.BulkAdjust(inboundSvc, emails, 0, 0, "", nil, tag1)
+	if err != nil {
+		t.Fatalf("BulkAdjust: %v", err)
+	}
+	if res.Adjusted != 1 {
+		t.Fatalf("expected 1 adjusted (MTProto only), got %d", res.Adjusted)
+	}
+	if restart {
+		t.Fatalf("adTag should not restart xray")
+	}
+	if len(res.Skipped) != 1 || res.Skipped[0].Email != "vless-mix@x" || res.Skipped[0].Reason != "adTag not supported on inbound" {
+		t.Fatalf("expected vless-mix@x in skipped, got %+v", res.Skipped)
+	}
+
+	tgRec, _ := svc.GetRecordByEmail(nil, "tg-mix@x")
+	if tgRec.AdTag != tag1 {
+		t.Fatalf("tg-mix@x adTag = %q, want %q", tgRec.AdTag, tag1)
+	}
+	vlessRec, _ := svc.GetRecordByEmail(nil, "vless-mix@x")
+	if vlessRec.AdTag != "" {
+		t.Fatalf("vless-mix@x adTag = %q, want empty", vlessRec.AdTag)
+	}
+}
+
+// TestBulkAdjust_UnchangedClientKeepsUpdatedAt pins the updated_at stamp to the
+// client that actually changed: an untouched client must not be re-stamped only
+// because a client earlier in the same inbound's array was adjusted.
+func TestBulkAdjust_UnchangedClientKeepsUpdatedAt(t *testing.T) {
+	setupBulkDB(t)
+	svc := &ClientService{}
+	inboundSvc := &InboundService{}
+
+	const day = int64(24 * 60 * 60 * 1000)
+	const seeded = int64(1600000000000)
+	baseExpiry := time.Now().UnixMilli() + 30*day
+
+	// chg@x is listed first and takes the expiry bump; keep@x has unlimited
+	// expiry on a ws inbound, so the same call changes nothing for it.
+	clients := []model.Client{
+		{Email: "chg@x", ID: "88888888-8888-8888-8888-888888888888", SubID: "chg", Enable: true, ExpiryTime: baseExpiry, UpdatedAt: seeded},
+		{Email: "keep@x", ID: "99999999-9999-9999-9999-999999999999", SubID: "keep", Enable: true, UpdatedAt: seeded},
+	}
+	ib := mkInboundStream(t, 30801, model.VLESS, clientsSettings(t, clients), wsStream)
+	if err := svc.SyncInbound(nil, ib.Id, clients); err != nil {
+		t.Fatalf("seed: %v", err)
+	}
+	if err := database.GetDB().Create(&xray.ClientTraffic{Email: "chg@x", Enable: true, ExpiryTime: baseExpiry}).Error; err != nil {
+		t.Fatalf("seed traffic: %v", err)
+	}
+
+	// The flow directive is what keeps keep@x in the plan; the ws inbound cannot
+	// carry it, so the directive is not itself a change for either client.
+	if _, _, err := svc.BulkAdjust(inboundSvc, emailsOf(clients), 7, 0, "xtls-rprx-vision", nil, ""); err != nil {
+		t.Fatalf("BulkAdjust: %v", err)
+	}
+
+	stamps := settingsUpdatedAt(t, inboundSvc, ib.Id)
+	if stamps["chg@x"] <= seeded {
+		t.Fatalf("adjusted client should be re-stamped, updated_at = %d", stamps["chg@x"])
+	}
+	if stamps["keep@x"] != seeded {
+		t.Fatalf("untouched client updated_at = %d, want %d — a sibling's change must not re-stamp it", stamps["keep@x"], seeded)
+	}
+}
+
+func settingsUpdatedAt(t *testing.T, inboundSvc *InboundService, inboundId int) map[string]int64 {
+	t.Helper()
+	ib, err := inboundSvc.GetInbound(inboundId)
+	if err != nil {
+		t.Fatalf("GetInbound: %v", err)
+	}
+	var parsed struct {
+		Clients []struct {
+			Email     string `json:"email"`
+			UpdatedAt int64  `json:"updated_at"`
+		} `json:"clients"`
+	}
+	if err := json.Unmarshal([]byte(ib.Settings), &parsed); err != nil {
+		t.Fatalf("unmarshal settings: %v", err)
+	}
+	out := make(map[string]int64, len(parsed.Clients))
+	for _, c := range parsed.Clients {
+		out[c.Email] = c.UpdatedAt
+	}
+	return out
+}

+ 11 - 11
internal/web/service/client_bulk_reenable_test.go

@@ -96,7 +96,7 @@ func TestBulkAdjust_ReenablesExpiredThenExtended_AllThreeLocations(t *testing.T)
 	email := "exp@x"
 	email := "exp@x"
 	ib := seedLocalDisabledClient(t, svc, 52001, "", email, 0, now-reenableDay, 0, 0)
 	ib := seedLocalDisabledClient(t, svc, 52001, "", email, 0, now-reenableDay, 0, 0)
 
 
-	res, _, err := svc.BulkAdjust(inboundSvc, []string{email}, 30, 0, "")
+	res, _, err := svc.BulkAdjust(inboundSvc, []string{email}, 30, 0, "", nil, "")
 	if err != nil {
 	if err != nil {
 		t.Fatalf("BulkAdjust: %v", err)
 		t.Fatalf("BulkAdjust: %v", err)
 	}
 	}
@@ -118,7 +118,7 @@ func TestBulkAdjust_DoesNotReenable_ManuallyDisabledNotDepleted(t *testing.T) {
 	email := "man@x"
 	email := "man@x"
 	ib := seedLocalDisabledClient(t, svc, 52002, "", email, 0, now+30*reenableDay, 0, 0)
 	ib := seedLocalDisabledClient(t, svc, 52002, "", email, 0, now+30*reenableDay, 0, 0)
 
 
-	res, _, err := svc.BulkAdjust(inboundSvc, []string{email}, 30, 0, "")
+	res, _, err := svc.BulkAdjust(inboundSvc, []string{email}, 30, 0, "", nil, "")
 	if err != nil {
 	if err != nil {
 		t.Fatalf("BulkAdjust: %v", err)
 		t.Fatalf("BulkAdjust: %v", err)
 	}
 	}
@@ -137,7 +137,7 @@ func TestBulkAdjust_StaysDisabled_ExtensionTooSmall(t *testing.T) {
 	email := "sml@x"
 	email := "sml@x"
 	ib := seedLocalDisabledClient(t, svc, 52003, "", email, 0, now-10*reenableDay, 0, 0)
 	ib := seedLocalDisabledClient(t, svc, 52003, "", email, 0, now-10*reenableDay, 0, 0)
 
 
-	if _, _, err := svc.BulkAdjust(inboundSvc, []string{email}, 5, 0, ""); err != nil {
+	if _, _, err := svc.BulkAdjust(inboundSvc, []string{email}, 5, 0, "", nil, ""); err != nil {
 		t.Fatalf("BulkAdjust: %v", err)
 		t.Fatalf("BulkAdjust: %v", err)
 	}
 	}
 	assertEnableEverywhere(t, svc, inboundSvc, ib.Id, email, false)
 	assertEnableEverywhere(t, svc, inboundSvc, ib.Id, email, false)
@@ -151,7 +151,7 @@ func TestBulkAdjust_ReenablesOverQuota_WhenAddBytesClearsQuota(t *testing.T) {
 	email := "q@x"
 	email := "q@x"
 	ib := seedLocalDisabledClient(t, svc, 52004, "", email, 100, 0, 60, 40)
 	ib := seedLocalDisabledClient(t, svc, 52004, "", email, 100, 0, 60, 40)
 
 
-	res, _, err := svc.BulkAdjust(inboundSvc, []string{email}, 0, 200, "")
+	res, _, err := svc.BulkAdjust(inboundSvc, []string{email}, 0, 200, "", nil, "")
 	if err != nil {
 	if err != nil {
 		t.Fatalf("BulkAdjust: %v", err)
 		t.Fatalf("BulkAdjust: %v", err)
 	}
 	}
@@ -177,7 +177,7 @@ func TestBulkAdjust_QuotaReductionBelowZeroSkipsInsteadOfUnlimited(t *testing.T)
 	}
 	}
 	mkTraffic(t, ib.Id, email, 0, 0, 10, 0, true)
 	mkTraffic(t, ib.Id, email, 0, 0, 10, 0, true)
 
 
-	res, _, err := svc.BulkAdjust(inboundSvc, []string{email}, 0, -20, "")
+	res, _, err := svc.BulkAdjust(inboundSvc, []string{email}, 0, -20, "", nil, "")
 	if err != nil {
 	if err != nil {
 		t.Fatalf("BulkAdjust: %v", err)
 		t.Fatalf("BulkAdjust: %v", err)
 	}
 	}
@@ -202,7 +202,7 @@ func TestBulkAdjust_AppliedFieldReachesTrafficRowDespiteOtherFieldSkip(t *testin
 	}
 	}
 	mkTraffic(t, ib.Id, email, 0, 0, 100, 0, true)
 	mkTraffic(t, ib.Id, email, 0, 0, 100, 0, true)
 
 
-	if _, _, err := svc.BulkAdjust(inboundSvc, []string{email}, 30, 50, ""); err != nil {
+	if _, _, err := svc.BulkAdjust(inboundSvc, []string{email}, 30, 50, "", nil, ""); err != nil {
 		t.Fatalf("BulkAdjust: %v", err)
 		t.Fatalf("BulkAdjust: %v", err)
 	}
 	}
 	if got := trafficOf(t, email).Total; got != 150 {
 	if got := trafficOf(t, email).Total; got != 150 {
@@ -219,7 +219,7 @@ func TestBulkAdjust_OverQuota_DaysOnly_StaysDisabled(t *testing.T) {
 	email := "qd@x"
 	email := "qd@x"
 	ib := seedLocalDisabledClient(t, svc, 52005, "", email, 100, now-reenableDay, 60, 40)
 	ib := seedLocalDisabledClient(t, svc, 52005, "", email, 100, now-reenableDay, 60, 40)
 
 
-	if _, _, err := svc.BulkAdjust(inboundSvc, []string{email}, 60, 0, ""); err != nil {
+	if _, _, err := svc.BulkAdjust(inboundSvc, []string{email}, 60, 0, "", nil, ""); err != nil {
 		t.Fatalf("BulkAdjust: %v", err)
 		t.Fatalf("BulkAdjust: %v", err)
 	}
 	}
 	assertEnableEverywhere(t, svc, inboundSvc, ib.Id, email, false)
 	assertEnableEverywhere(t, svc, inboundSvc, ib.Id, email, false)
@@ -239,7 +239,7 @@ func TestBulkAdjust_NegativeReduction_DoesNotFlipEnable(t *testing.T) {
 	}
 	}
 	mkTraffic(t, ib.Id, email, 0, 0, 0, now+5*reenableDay, true)
 	mkTraffic(t, ib.Id, email, 0, 0, 0, now+5*reenableDay, true)
 
 
-	if _, _, err := svc.BulkAdjust(inboundSvc, []string{email}, -10, 0, ""); err != nil {
+	if _, _, err := svc.BulkAdjust(inboundSvc, []string{email}, -10, 0, "", nil, ""); err != nil {
 		t.Fatalf("BulkAdjust: %v", err)
 		t.Fatalf("BulkAdjust: %v", err)
 	}
 	}
 	assertEnableEverywhere(t, svc, inboundSvc, ib.Id, email, true)
 	assertEnableEverywhere(t, svc, inboundSvc, ib.Id, email, true)
@@ -254,7 +254,7 @@ func TestBulkAdjust_FlowOnly_NoEnableChange(t *testing.T) {
 	email := "flow@x"
 	email := "flow@x"
 	ib := seedLocalDisabledClient(t, svc, 52007, realityStream, email, 0, now-reenableDay, 0, 0)
 	ib := seedLocalDisabledClient(t, svc, 52007, realityStream, email, 0, now-reenableDay, 0, 0)
 
 
-	if _, _, err := svc.BulkAdjust(inboundSvc, []string{email}, 0, 0, "xtls-rprx-vision-udp443"); err != nil {
+	if _, _, err := svc.BulkAdjust(inboundSvc, []string{email}, 0, 0, "xtls-rprx-vision-udp443", nil, ""); err != nil {
 		t.Fatalf("BulkAdjust: %v", err)
 		t.Fatalf("BulkAdjust: %v", err)
 	}
 	}
 	assertEnableEverywhere(t, svc, inboundSvc, ib.Id, email, false)
 	assertEnableEverywhere(t, svc, inboundSvc, ib.Id, email, false)
@@ -271,7 +271,7 @@ func TestBulkAdjust_UnlimitedExpiry_QuotaCleared_Reenables(t *testing.T) {
 	email := "u@x"
 	email := "u@x"
 	ib := seedLocalDisabledClient(t, svc, 52008, "", email, 100, 0, 100, 0)
 	ib := seedLocalDisabledClient(t, svc, 52008, "", email, 100, 0, 100, 0)
 
 
-	res, _, err := svc.BulkAdjust(inboundSvc, []string{email}, 0, 200, "")
+	res, _, err := svc.BulkAdjust(inboundSvc, []string{email}, 0, 200, "", nil, "")
 	if err != nil {
 	if err != nil {
 		t.Fatalf("BulkAdjust: %v", err)
 		t.Fatalf("BulkAdjust: %v", err)
 	}
 	}
@@ -314,7 +314,7 @@ func TestBulkAdjust_NodeInbound_ReenablesDBLocations(t *testing.T) {
 	mkTraffic(t, ib.Id, email, 0, 0, 0, now-reenableDay, false)
 	mkTraffic(t, ib.Id, email, 0, 0, 0, now-reenableDay, false)
 	forceRecordDisabled(t, svc, email)
 	forceRecordDisabled(t, svc, email)
 
 
-	res, _, err := svc.BulkAdjust(inboundSvc, []string{email}, 30, 0, "")
+	res, _, err := svc.BulkAdjust(inboundSvc, []string{email}, 30, 0, "", nil, "")
 	if err != nil {
 	if err != nil {
 		t.Fatalf("BulkAdjust: %v", err)
 		t.Fatalf("BulkAdjust: %v", err)
 	}
 	}

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

@@ -334,6 +334,8 @@ type InboundOption struct {
 	Protocol       string `json:"protocol" example:"vless"`
 	Protocol       string `json:"protocol" example:"vless"`
 	Port           int    `json:"port" example:"443"`
 	Port           int    `json:"port" example:"443"`
 	Enable         bool   `json:"enable" example:"true"`
 	Enable         bool   `json:"enable" example:"true"`
+	Network        string `json:"network,omitempty"`
+	Security       string `json:"security,omitempty"`
 	TlsFlowCapable bool   `json:"tlsFlowCapable" example:"true"`
 	TlsFlowCapable bool   `json:"tlsFlowCapable" example:"true"`
 	SsMethod       string `json:"ssMethod"`
 	SsMethod       string `json:"ssMethod"`
 	WgPublicKey    string `json:"wgPublicKey,omitempty"`
 	WgPublicKey    string `json:"wgPublicKey,omitempty"`
@@ -389,6 +391,7 @@ func (s *InboundService) GetInboundOptions(userId int) ([]InboundOption, error)
 	out := make([]InboundOption, 0, len(rows))
 	out := make([]InboundOption, 0, len(rows))
 	for _, r := range rows {
 	for _, r := range rows {
 		wgPublicKey, wgMtu, wgDns := inboundWireguardHints(r.Protocol, r.Settings)
 		wgPublicKey, wgMtu, wgDns := inboundWireguardHints(r.Protocol, r.Settings)
+		netHint, secHint := inboundStreamHints(r.Protocol, r.StreamSettings, r.Settings)
 		shareAddrStrategy := r.ShareAddrStrategy
 		shareAddrStrategy := r.ShareAddrStrategy
 		if shareAddrStrategy == "node" {
 		if shareAddrStrategy == "node" {
 			shareAddrStrategy = ""
 			shareAddrStrategy = ""
@@ -400,6 +403,8 @@ func (s *InboundService) GetInboundOptions(userId int) ([]InboundOption, error)
 			Protocol:          r.Protocol,
 			Protocol:          r.Protocol,
 			Port:              r.Port,
 			Port:              r.Port,
 			Enable:            r.Enable,
 			Enable:            r.Enable,
+			Network:           netHint,
+			Security:          secHint,
 			TlsFlowCapable:    !r.DisableFlow && inboundCanEnableTlsFlow(r.Protocol, r.StreamSettings, r.Settings),
 			TlsFlowCapable:    !r.DisableFlow && inboundCanEnableTlsFlow(r.Protocol, r.StreamSettings, r.Settings),
 			SsMethod:          inboundShadowsocksMethod(r.Protocol, r.Settings),
 			SsMethod:          inboundShadowsocksMethod(r.Protocol, r.Settings),
 			WgPublicKey:       wgPublicKey,
 			WgPublicKey:       wgPublicKey,
@@ -417,6 +422,44 @@ func (s *InboundService) GetInboundOptions(userId int) ([]InboundOption, error)
 	return out, nil
 	return out, nil
 }
 }
 
 
+func inboundStreamHints(protocol string, streamSettings string, settings string) (string, string) {
+	p := strings.ToLower(protocol)
+	if p == "wireguard" || p == "amneziawg" || p == "hysteria" {
+		return "udp", ""
+	}
+	var netHint, secHint string
+	if strings.TrimSpace(streamSettings) != "" {
+		var raw struct {
+			Network  string `json:"network"`
+			Security string `json:"security"`
+		}
+		if err := json.Unmarshal([]byte(streamSettings), &raw); err == nil {
+			netHint = raw.Network
+			secHint = raw.Security
+		}
+	}
+	if netHint == "" && strings.TrimSpace(settings) != "" {
+		var raw struct {
+			Network        string `json:"network"`
+			AllowedNetwork string `json:"allowedNetwork"`
+			UDP            bool   `json:"udp"`
+		}
+		if err := json.Unmarshal([]byte(settings), &raw); err == nil {
+			if raw.Network != "" {
+				netHint = raw.Network
+			} else if raw.AllowedNetwork != "" {
+				netHint = raw.AllowedNetwork
+			} else if raw.UDP {
+				netHint = "tcp,udp"
+			}
+		}
+	}
+	if netHint == "" {
+		netHint = "tcp"
+	}
+	return netHint, secHint
+}
+
 func inboundWireguardHints(protocol string, settings string) (string, int, string) {
 func inboundWireguardHints(protocol string, settings string) (string, int, string) {
 	if protocol != string(model.WireGuard) || strings.TrimSpace(settings) == "" {
 	if protocol != string(model.WireGuard) || strings.TrimSpace(settings) == "" {
 		return "", 0, ""
 		return "", 0, ""

+ 1 - 1
internal/web/service/node_bulk_dispatch_test.go

@@ -218,7 +218,7 @@ func TestNodeBulkAdjustDoesNotPushBeforeFailedCommit(t *testing.T) {
 	}
 	}
 	t.Cleanup(func() { _ = db.Callback().Update().Remove(callbackName) })
 	t.Cleanup(func() { _ = db.Callback().Update().Remove(callbackName) })
 
 
-	result, _, err := (&ClientService{}).BulkAdjust(&InboundService{}, []string{client.Email}, 1, 0, "")
+	result, _, err := (&ClientService{}).BulkAdjust(&InboundService{}, []string{client.Email}, 1, 0, "", nil, "")
 	if err != nil {
 	if err != nil {
 		t.Fatalf("BulkAdjust: %v", err)
 		t.Fatalf("BulkAdjust: %v", err)
 	}
 	}

+ 1 - 1
internal/web/service/outbound/outbound.go

@@ -221,7 +221,7 @@ func probeTCPEndpoint(endpoint string, timeout time.Duration) TestEndpointResult
 // dial neither proves reachability nor measures latency. Such outbounds
 // dial neither proves reachability nor measures latency. Such outbounds
 // must go through the real xray handshake probe instead.
 // must go through the real xray handshake probe instead.
 func outboundTransportIsUDP(ob map[string]any) bool {
 func outboundTransportIsUDP(ob map[string]any) bool {
-	if protocol, _ := ob["protocol"].(string); protocol == "hysteria" || protocol == "wireguard" {
+	if protocol, _ := ob["protocol"].(string); protocol == "hysteria" || protocol == "wireguard" || protocol == "amneziawg" {
 		return true
 		return true
 	}
 	}
 	if stream, ok := ob["streamSettings"].(map[string]any); ok {
 	if stream, ok := ob["streamSettings"].(map[string]any); ok {

+ 28 - 0
internal/web/service/outbound/probe_http.go

@@ -18,6 +18,7 @@ import (
 	"sync"
 	"sync"
 	"time"
 	"time"
 
 
+	"github.com/mhsanaei/3x-ui/v3/internal/amneziawgnet"
 	"github.com/mhsanaei/3x-ui/v3/internal/config"
 	"github.com/mhsanaei/3x-ui/v3/internal/config"
 	"github.com/mhsanaei/3x-ui/v3/internal/util/json_util"
 	"github.com/mhsanaei/3x-ui/v3/internal/util/json_util"
 	"github.com/mhsanaei/3x-ui/v3/internal/xray"
 	"github.com/mhsanaei/3x-ui/v3/internal/xray"
@@ -384,6 +385,33 @@ func buildBatchTestConfig(items []*httpBatchItem, allOutbounds []any, ports []in
 			outbounds = append(outbounds, it.outbound)
 			outbounds = append(outbounds, it.outbound)
 		}
 		}
 	}
 	}
+	// Bridge amneziawg entries like GetXrayConfig does -- one raw entry fails
+	// the whole temp config; drop unbridgeable ones, not unrelated items.
+	bridged := make([]any, 0, len(outbounds))
+	for _, ob := range outbounds {
+		m, ok := ob.(map[string]any)
+		if !ok {
+			bridged = append(bridged, ob)
+			continue
+		}
+		if p, _ := m["protocol"].(string); p != "amneziawg" {
+			bridged = append(bridged, ob)
+			continue
+		}
+		raw, err := json.Marshal(m)
+		if err != nil {
+			continue
+		}
+		repl, ok := amneziawgnet.BuildSocksBridge(raw)
+		if !ok {
+			continue
+		}
+		var replacement any
+		if json.Unmarshal(repl, &replacement) == nil {
+			bridged = append(bridged, replacement)
+		}
+	}
+	outbounds = bridged
 	for _, ob := range outbounds {
 	for _, ob := range outbounds {
 		outbound, ok := ob.(map[string]any)
 		outbound, ok := ob.(map[string]any)
 		if !ok {
 		if !ok {

+ 27 - 0
internal/web/service/outbound/probe_http_test.go

@@ -557,6 +557,33 @@ func TestTestOutboundsTCPModeForcesUDPToHTTPProbe(t *testing.T) {
 	}
 	}
 }
 }
 
 
+func TestTestOutboundsTCPModeForcesAmneziaWGToHTTPProbe(t *testing.T) {
+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		w.WriteHeader(http.StatusNoContent)
+	}))
+	defer srv.Close()
+
+	withStubProcess(t, func(cfg *xray.Config, configPath string) batchProcess {
+		return &stubProcess{cfg: cfg, serveSocks: true}
+	})
+	withEgressTraceProbe(t, func(*url.URL) *TestEgressResult {
+		return &TestEgressResult{IPv4: "198.51.100.2", Country: "ZZ", Warp: "off"}
+	})
+
+	batch := mustJSON(t, []any{map[string]any{"tag": "awg", "protocol": "amneziawg"}})
+	results, err := (&OutboundService{}).TestOutbounds(batch, srv.URL, "", "tcp")
+	if err != nil {
+		t.Fatalf("TestOutbounds: %v", err)
+	}
+	r := results[0]
+	if !r.Success || r.Mode != "http" {
+		t.Errorf("amneziawg outbound in tcp mode = %+v, want success with mode %q", r, "http")
+	}
+	if r.Egress == nil || r.Egress.IPv4 != "198.51.100.2" {
+		t.Errorf("amneziawg outbound egress = %+v", r.Egress)
+	}
+}
+
 func TestProbeModeLabel(t *testing.T) {
 func TestProbeModeLabel(t *testing.T) {
 	cases := []struct{ mode, want string }{
 	cases := []struct{ mode, want string }{
 		{"tcp", "tcp"},
 		{"tcp", "tcp"},

+ 12 - 0
internal/web/service/port_conflict.go

@@ -185,6 +185,18 @@ func checkPortConflictTx(db *gorm.DB, inbound *model.Inbound, ignoreId int) (*po
 		}, nil
 		}, nil
 	}
 	}
 
 
+	// Egress SOCKS server holds loopback EgressBasePort when AWG outbounds are
+	// active; conflict check prevents inbounds from colliding with it.
+	if inbound.NodeID == nil && inbound.Port == int(amneziawgnet.EgressBasePort) &&
+		newBits&transportTCP != 0 && listenOverlaps("127.0.0.1", inbound.Listen) {
+		return &portConflictDetail{
+			Tag:        "amneziawg-egress",
+			Listen:     "127.0.0.1",
+			Port:       inbound.Port,
+			Transports: transportTCP,
+		}, nil
+	}
+
 	// Every enabled local AmneziaWG inbound gets its own automatic Xray
 	// Every enabled local AmneziaWG inbound gets its own automatic Xray
 	// SOCKS5 relay inbound (see injectAmneziawgnetSocks) on 127.0.0.1 at a
 	// SOCKS5 relay inbound (see injectAmneziawgnetSocks) on 127.0.0.1 at a
 	// port derived purely from its id (amneziawgnet.SOCKSPortForInbound) --
 	// port derived purely from its id (amneziawgnet.SOCKSPortForInbound) --

+ 21 - 4
internal/web/service/port_conflict_test.go

@@ -739,10 +739,27 @@ func TestCheckPortConflict_ReservedAPIPortUDPCoexists(t *testing.T) {
 // it's now ignored -- see the "RouteThroughXrayOff" test below.
 // it's now ignored -- see the "RouteThroughXrayOff" test below.
 const amneziawgRoutedSettings = `{"server":{"privateKey":"priv","publicKey":"pub","subnetIp":"10.8.1.0","subnetCidr":24,"routeThroughXray":true},"clients":[{"email":"a@x","enable":true,"publicKey":"pub-a","allowedIPs":["10.8.1.2/32"]}]}`
 const amneziawgRoutedSettings = `{"server":{"privateKey":"priv","publicKey":"pub","subnetIp":"10.8.1.0","subnetCidr":24,"routeThroughXray":true},"clients":[{"email":"a@x","enable":true,"publicKey":"pub-a","allowedIPs":["10.8.1.2/32"]}]}`
 
 
-// An enabled AmneziaWG inbound's automatic Xray SOCKS5 relay inbound
-// (injectAmneziawgnetSocks) is a synthetic loopback inbound, not a database
-// row, so checkPortConflict needs its own check to catch a collision --
-// exactly the same shape of problem as the reserved API port above.
+// A local TCP inbound on EgressBasePort must conflict with the AmneziaWG
+// egress SOCKS server (which is not in the database).
+func TestCheckPortConflict_EgressPortBlockedLocal(t *testing.T) {
+	setupConflictDB(t)
+
+	svc := &InboundService{}
+	candidate := &model.Inbound{
+		Tag:      "vless-bridge",
+		Listen:   "0.0.0.0",
+		Port:     int(amneziawgnet.EgressBasePort),
+		Protocol: model.VLESS,
+	}
+	got, err := svc.checkPortConflict(candidate, 0)
+	if err != nil {
+		t.Fatalf("checkPortConflict: %v", err)
+	}
+	if got == nil {
+		t.Fatalf("a local inbound on the egress port %d must conflict", amneziawgnet.EgressBasePort)
+	}
+}
+
 func TestCheckPortConflict_AmneziawgnetSocksRelayBlockedLocal(t *testing.T) {
 func TestCheckPortConflict_AmneziawgnetSocksRelayBlockedLocal(t *testing.T) {
 	setupConflictDB(t)
 	setupConflictDB(t)
 	seedInboundConflict(t, "awg-1", "0.0.0.0", 51820, model.AmneziaWG, ``, amneziawgRoutedSettings)
 	seedInboundConflict(t, "awg-1", "0.0.0.0", 51820, model.AmneziaWG, ``, amneziawgRoutedSettings)

+ 134 - 3
internal/web/service/setting.go

@@ -102,6 +102,29 @@ var defaultValueMap = map[string]string{
 	"subEnableRouting":            "false",
 	"subEnableRouting":            "false",
 	"subRoutingRules":             "",
 	"subRoutingRules":             "",
 	"subHideSettings":             "false",
 	"subHideSettings":             "false",
+	"subHappAutoDetect":           "false",
+	"subHappProviderId":           "",
+	"subHappNewUrl":               "",
+	"subHappFallbackUrl":          "",
+	"subHappSubInfoColor":         "blue",
+	"subHappSubInfoText":          "",
+	"subHappSubInfoButtonText":    "",
+	"subHappSubInfoButtonLink":    "",
+	"subHappSubExpire":            "false",
+	"subHappSubExpireButtonLink":  "",
+	"subHappNotificationExpire":   "false",
+	"subHappNoLimit":              "false",
+	"subHappAlwaysHwid":           "false",
+	"subHappTunMode":              "",
+	"subHappTunType":              "",
+	"subHappExcludeRoutes":        "",
+	"subHappExcludeApns":          "false",
+	"subHappColorProfile":         "",
+	"subHappPingType":             "",
+	"subHappAutoConnect":          "false",
+	"subHappAutoConnectType":      "lowestdelay",
+	"subHappPerAppMode":           "off",
+	"subHappPerAppList":           "",
 	"subIncyEnableRouting":        "false",
 	"subIncyEnableRouting":        "false",
 	"subIncyRoutingRules":         "",
 	"subIncyRoutingRules":         "",
 	"subListen":                   "",
 	"subListen":                   "",
@@ -122,6 +145,7 @@ var defaultValueMap = map[string]string{
 	"subClashRules":               "",
 	"subClashRules":               "",
 	"subJsonMux":                  "",
 	"subJsonMux":                  "",
 	"subJsonRules":                "",
 	"subJsonRules":                "",
+	"subJsonRoutingRules":         "",
 	"subJsonFinalMask":            "",
 	"subJsonFinalMask":            "",
 	"subJsonObservatory":          "",
 	"subJsonObservatory":          "",
 	"subThemeDir":                 "",
 	"subThemeDir":                 "",
@@ -820,6 +844,98 @@ func (s *SettingService) GetSubHideSettings() (bool, error) {
 	return s.getBool("subHideSettings")
 	return s.getBool("subHideSettings")
 }
 }
 
 
+func (s *SettingService) GetSubHappAutoDetect() (bool, error) {
+	return s.getBool("subHappAutoDetect")
+}
+
+func (s *SettingService) GetSubHappProviderId() (string, error) {
+	return s.getString("subHappProviderId")
+}
+
+func (s *SettingService) GetSubHappNewUrl() (string, error) {
+	return s.getString("subHappNewUrl")
+}
+
+func (s *SettingService) GetSubHappFallbackUrl() (string, error) {
+	return s.getString("subHappFallbackUrl")
+}
+
+func (s *SettingService) GetSubHappSubInfoColor() (string, error) {
+	return s.getString("subHappSubInfoColor")
+}
+
+func (s *SettingService) GetSubHappSubInfoText() (string, error) {
+	return s.getString("subHappSubInfoText")
+}
+
+func (s *SettingService) GetSubHappSubInfoButtonText() (string, error) {
+	return s.getString("subHappSubInfoButtonText")
+}
+
+func (s *SettingService) GetSubHappSubInfoButtonLink() (string, error) {
+	return s.getString("subHappSubInfoButtonLink")
+}
+
+func (s *SettingService) GetSubHappSubExpire() (bool, error) {
+	return s.getBool("subHappSubExpire")
+}
+
+func (s *SettingService) GetSubHappSubExpireButtonLink() (string, error) {
+	return s.getString("subHappSubExpireButtonLink")
+}
+
+func (s *SettingService) GetSubHappNotificationExpire() (bool, error) {
+	return s.getBool("subHappNotificationExpire")
+}
+
+func (s *SettingService) GetSubHappNoLimit() (bool, error) {
+	return s.getBool("subHappNoLimit")
+}
+
+func (s *SettingService) GetSubHappAlwaysHwid() (bool, error) {
+	return s.getBool("subHappAlwaysHwid")
+}
+
+func (s *SettingService) GetSubHappTunMode() (string, error) {
+	return s.getString("subHappTunMode")
+}
+
+func (s *SettingService) GetSubHappTunType() (string, error) {
+	return s.getString("subHappTunType")
+}
+
+func (s *SettingService) GetSubHappExcludeRoutes() (string, error) {
+	return s.getString("subHappExcludeRoutes")
+}
+
+func (s *SettingService) GetSubHappExcludeApns() (bool, error) {
+	return s.getBool("subHappExcludeApns")
+}
+
+func (s *SettingService) GetSubHappColorProfile() (string, error) {
+	return s.getString("subHappColorProfile")
+}
+
+func (s *SettingService) GetSubHappPingType() (string, error) {
+	return s.getString("subHappPingType")
+}
+
+func (s *SettingService) GetSubHappAutoConnect() (bool, error) {
+	return s.getBool("subHappAutoConnect")
+}
+
+func (s *SettingService) GetSubHappAutoConnectType() (string, error) {
+	return s.getString("subHappAutoConnectType")
+}
+
+func (s *SettingService) GetSubHappPerAppMode() (string, error) {
+	return s.getString("subHappPerAppMode")
+}
+
+func (s *SettingService) GetSubHappPerAppList() (string, error) {
+	return s.getString("subHappPerAppList")
+}
+
 func (s *SettingService) GetSubIncyEnableRouting() (bool, error) {
 func (s *SettingService) GetSubIncyEnableRouting() (bool, error) {
 	return s.getBool("subIncyEnableRouting")
 	return s.getBool("subIncyEnableRouting")
 }
 }
@@ -912,6 +1028,10 @@ func (s *SettingService) GetSubJsonRules() (string, error) {
 	return s.getString("subJsonRules")
 	return s.getString("subJsonRules")
 }
 }
 
 
+func (s *SettingService) GetSubJsonRoutingRules() (string, error) {
+	return s.getString("subJsonRoutingRules")
+}
+
 func (s *SettingService) GetSubJsonFinalMask() (string, error) {
 func (s *SettingService) GetSubJsonFinalMask() (string, error) {
 	return s.getString("subJsonFinalMask")
 	return s.getString("subJsonFinalMask")
 }
 }
@@ -1363,10 +1483,21 @@ func validateSettingsURLs(allSetting *entity.AllSetting) error {
 	// the scheme instead of forcing SanitizeHTTPURL's http(s)-only rule.
 	// the scheme instead of forcing SanitizeHTTPURL's http(s)-only rule.
 	allSetting.SubSupportUrl = common.EnsureURLScheme(allSetting.SubSupportUrl)
 	allSetting.SubSupportUrl = common.EnsureURLScheme(allSetting.SubSupportUrl)
 	allSetting.SubProfileUrl = common.EnsureURLScheme(allSetting.SubProfileUrl)
 	allSetting.SubProfileUrl = common.EnsureURLScheme(allSetting.SubProfileUrl)
+	for _, ptr := range []*string{
+		&allSetting.SubHappNewUrl,
+		&allSetting.SubHappFallbackUrl,
+		&allSetting.SubHappSubInfoButtonLink,
+		&allSetting.SubHappSubExpireButtonLink,
+	} {
+		if strings.TrimSpace(*ptr) != "" {
+			*ptr = common.EnsureURLScheme(strings.TrimSpace(*ptr))
+		}
+	}
 	for name, value := range map[string]*string{
 	for name, value := range map[string]*string{
-		"Happ routing source":         &allSetting.SubRoutingRules,
-		"Clash/Mihomo routing source": &allSetting.SubClashRules,
-		"Incy routing source":         &allSetting.SubIncyRoutingRules,
+		"Happ routing source":              &allSetting.SubRoutingRules,
+		"Clash/Mihomo routing source":      &allSetting.SubClashRules,
+		"Incy routing source":              &allSetting.SubIncyRoutingRules,
+		"JSON subscription routing source": &allSetting.SubJsonRoutingRules,
 	} {
 	} {
 		if err := validateRemoteRoutingURLSetting(name, value); err != nil {
 		if err := validateRemoteRoutingURLSetting(name, value); err != nil {
 			return err
 			return err

+ 29 - 0
internal/web/service/setting_remote_routing_test.go

@@ -36,3 +36,32 @@ func TestValidateRemoteRoutingURLSettings(t *testing.T) {
 		})
 		})
 	}
 	}
 }
 }
+
+func TestValidateSubJsonRoutingRulesSetting(t *testing.T) {
+	tests := []struct {
+		name      string
+		value     string
+		want      string
+		wantError string
+	}{
+		{name: "remote URL is canonicalised", value: " https://example.com/DEFAULT.JSON#frag ", want: "https://example.com/DEFAULT.JSON"},
+		{name: "remote URL with credentials is rejected", value: "https://user:[email protected]/DEFAULT.JSON", wantError: "JSON subscription routing source"},
+		{name: "inline JSON passes through", value: "{\"DirectSites\":[\"geosite:cat\"]}", want: "{\"DirectSites\":[\"geosite:cat\"]}"},
+		{name: "blank stays blank", value: "", want: ""},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			settings := &entity.AllSetting{SubJsonRoutingRules: tt.value}
+			err := validateSettingsURLs(settings)
+			if tt.wantError != "" {
+				if err == nil || !strings.Contains(err.Error(), tt.wantError) {
+					t.Fatalf("err=%v, want %q", err, tt.wantError)
+				}
+				return
+			}
+			if err != nil || settings.SubJsonRoutingRules != tt.want {
+				t.Fatalf("value=%q err=%v", settings.SubJsonRoutingRules, err)
+			}
+		})
+	}
+}

+ 1 - 1
internal/web/service/sync_scale_postgres_test.go

@@ -347,7 +347,7 @@ func TestBulkOpsPostgresScale(t *testing.T) {
 			}
 			}
 
 
 			t0 := time.Now()
 			t0 := time.Now()
-			if _, _, err := svc.BulkAdjust(inboundSvc, emailsM, 7, 1<<30, ""); err != nil {
+			if _, _, err := svc.BulkAdjust(inboundSvc, emailsM, 7, 1<<30, "", nil, ""); err != nil {
 				t.Fatalf("BulkAdjust: %v", err)
 				t.Fatalf("BulkAdjust: %v", err)
 			}
 			}
 			adjustDur := time.Since(t0)
 			adjustDur := time.Since(t0)

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

@@ -161,6 +161,11 @@ func (s *XrayService) GetXrayConfig() (*xray.Config, error) {
 	// still carry sessionPlacement/sessionKey; lift them too (same reason as
 	// still carry sessionPlacement/sessionKey; lift them too (same reason as
 	// the per-inbound lift below).
 	// the per-inbound lift below).
 	xrayConfig.OutboundConfigs = liftOutboundsXhttpSessionIDKeys(xrayConfig.OutboundConfigs)
 	xrayConfig.OutboundConfigs = liftOutboundsXhttpSessionIDKeys(xrayConfig.OutboundConfigs)
+	// Bridge amneziawg outbounds before anything else reads OutboundConfigs;
+	// the core has no amneziawg proxy and would reject the raw entry.
+	if err := transformAmneziaWGOutbounds(xrayConfig); err != nil {
+		return nil, err
+	}
 
 
 	_, _, _ = s.inboundService.AddTraffic(nil, nil)
 	_, _, _ = s.inboundService.AddTraffic(nil, nil)
 
 

Some files were not shown because too many files changed in this diff