6 Komitmen 8076d5edfa ... fc08b53395

Pembuat SHA1 Pesan Tanggal
  MRVX fc08b53395 feat(ui): add global command palette (Ctrl+K) for fast navigation and search (#6352) 7 jam lalu
  DIMFLIX 2dd903ea8e feat(sub): bake Happ/INCY routing profiles into the JSON subscription (#6402) 8 jam lalu
  Rouzbeh† ed5465d0f2 feat(clients): support setting HWID limit and MTProto ad-tag in bulk adjust (#6399) 9 jam lalu
  Pejman Yousefi 1456658028 feat(sub): add Happ client integration, routing presets, and app management (#6434) 9 jam lalu
  Rouzbeh† d5ab84e8d5 feat(amneziawg): add AmneziaWG as an outbound protocol (#6320) 10 jam lalu
  VibeProgramm 876497db6e feat(sub): add AmneziaWG proxy generation for Clash subscriptions (#6326) 11 jam lalu
100 mengubah file dengan 10428 tambahan dan 268 penghapusan
  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
   `frontend/src/test/golden/fixtures/` for config generation — update fixtures intentionally,
   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
         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
+        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.'
-      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
       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
@@ -276,9 +278,9 @@ _openapi:
       title: '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
-        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'
   structuredData:
     headings:
@@ -364,9 +366,11 @@ _openapi:
           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.'
-        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
           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
@@ -508,12 +512,12 @@ _openapi:
           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
-          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
     contents:
       - content: >-

+ 206 - 2
docs/public/openapi.json

@@ -220,6 +220,76 @@
           "subExpiredTemplate": {
             "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": {
             "type": "boolean"
           },
@@ -253,6 +323,9 @@
           "subJsonPath": {
             "type": "string"
           },
+          "subJsonRoutingRules": {
+            "type": "string"
+          },
           "subJsonRules": {
             "type": "string"
           },
@@ -443,6 +516,29 @@
           "subEnableRouting",
           "subEncrypt",
           "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",
           "subIncyEnableRouting",
           "subIncyRoutingRules",
@@ -454,6 +550,7 @@
           "subJsonMux",
           "subJsonObservatory",
           "subJsonPath",
+          "subJsonRoutingRules",
           "subJsonRules",
           "subJsonURI",
           "subJsonUserAgentRegex",
@@ -711,6 +808,76 @@
           "subExpiredTemplate": {
             "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": {
             "type": "boolean"
           },
@@ -744,6 +911,9 @@
           "subJsonPath": {
             "type": "string"
           },
+          "subJsonRoutingRules": {
+            "type": "string"
+          },
           "subJsonRules": {
             "type": "string"
           },
@@ -941,6 +1111,29 @@
           "subEnableRouting",
           "subEncrypt",
           "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",
           "subIncyEnableRouting",
           "subIncyRoutingRules",
@@ -952,6 +1145,7 @@
           "subJsonMux",
           "subJsonObservatory",
           "subJsonPath",
+          "subJsonRoutingRules",
           "subJsonRules",
           "subJsonURI",
           "subJsonUserAgentRegex",
@@ -2550,6 +2744,9 @@
           "mtprotoDomain": {
             "type": "string"
           },
+          "network": {
+            "type": "string"
+          },
           "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.",
             "type": "string"
@@ -2571,6 +2768,9 @@
             "example": "VLESS-443",
             "type": "string"
           },
+          "security": {
+            "type": "string"
+          },
           "shareAddr": {
             "type": "string"
           },
@@ -4321,11 +4521,13 @@
                       "id": 1,
                       "listen": "",
                       "mtprotoDomain": "",
+                      "network": "",
                       "nodeAddress": "",
                       "nodeId": null,
                       "port": 443,
                       "protocol": "vless",
                       "remark": "VLESS-443",
+                      "security": "",
                       "shareAddr": "",
                       "shareAddrStrategy": "",
                       "ssMethod": "",
@@ -8180,7 +8382,7 @@
         "tags": [
           "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",
         "requestBody": {
           "required": true,
@@ -8196,7 +8398,9 @@
                 ],
                 "addDays": 30,
                 "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>
+  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('isUltraDarkThemeEnabled') === null) {
     localStorage.setItem('isUltraDarkThemeEnabled', 'false');

+ 206 - 2
frontend/public/openapi.json

@@ -220,6 +220,76 @@
           "subExpiredTemplate": {
             "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": {
             "type": "boolean"
           },
@@ -253,6 +323,9 @@
           "subJsonPath": {
             "type": "string"
           },
+          "subJsonRoutingRules": {
+            "type": "string"
+          },
           "subJsonRules": {
             "type": "string"
           },
@@ -443,6 +516,29 @@
           "subEnableRouting",
           "subEncrypt",
           "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",
           "subIncyEnableRouting",
           "subIncyRoutingRules",
@@ -454,6 +550,7 @@
           "subJsonMux",
           "subJsonObservatory",
           "subJsonPath",
+          "subJsonRoutingRules",
           "subJsonRules",
           "subJsonURI",
           "subJsonUserAgentRegex",
@@ -711,6 +808,76 @@
           "subExpiredTemplate": {
             "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": {
             "type": "boolean"
           },
@@ -744,6 +911,9 @@
           "subJsonPath": {
             "type": "string"
           },
+          "subJsonRoutingRules": {
+            "type": "string"
+          },
           "subJsonRules": {
             "type": "string"
           },
@@ -941,6 +1111,29 @@
           "subEnableRouting",
           "subEncrypt",
           "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",
           "subIncyEnableRouting",
           "subIncyRoutingRules",
@@ -952,6 +1145,7 @@
           "subJsonMux",
           "subJsonObservatory",
           "subJsonPath",
+          "subJsonRoutingRules",
           "subJsonRules",
           "subJsonURI",
           "subJsonUserAgentRegex",
@@ -2550,6 +2744,9 @@
           "mtprotoDomain": {
             "type": "string"
           },
+          "network": {
+            "type": "string"
+          },
           "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.",
             "type": "string"
@@ -2571,6 +2768,9 @@
             "example": "VLESS-443",
             "type": "string"
           },
+          "security": {
+            "type": "string"
+          },
           "shareAddr": {
             "type": "string"
           },
@@ -4321,11 +4521,13 @@
                       "id": 1,
                       "listen": "",
                       "mtprotoDomain": "",
+                      "network": "",
                       "nodeAddress": "",
                       "nodeId": null,
                       "port": 443,
                       "protocol": "vless",
                       "remark": "VLESS-443",
+                      "security": "",
                       "shareAddr": "",
                       "shareAddrStrategy": "",
                       "ssMethod": "",
@@ -8180,7 +8382,7 @@
         "tags": [
           "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",
         "requestBody": {
           "required": true,
@@ -8196,7 +8398,9 @@
                 ],
                 "addDays": 30,
                 "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,
     "subEncrypt": false,
     "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,
     "subIncyEnableRouting": false,
     "subIncyRoutingRules": "",
@@ -70,6 +93,7 @@ export const EXAMPLES: Record<string, unknown> = {
     "subJsonMux": "",
     "subJsonObservatory": "",
     "subJsonPath": "",
+    "subJsonRoutingRules": "",
     "subJsonRules": "",
     "subJsonURI": "",
     "subJsonUserAgentRegex": "",
@@ -176,6 +200,29 @@ export const EXAMPLES: Record<string, unknown> = {
     "subEnableRouting": false,
     "subEncrypt": false,
     "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,
     "subIncyEnableRouting": false,
     "subIncyRoutingRules": "",
@@ -187,6 +234,7 @@ export const EXAMPLES: Record<string, unknown> = {
     "subJsonMux": "",
     "subJsonObservatory": "",
     "subJsonPath": "",
+    "subJsonRoutingRules": "",
     "subJsonRules": "",
     "subJsonURI": "",
     "subJsonUserAgentRegex": "",
@@ -664,11 +712,13 @@ export const EXAMPLES: Record<string, unknown> = {
     "id": 1,
     "listen": "",
     "mtprotoDomain": "",
+    "network": "",
     "nodeAddress": "",
     "nodeId": null,
     "port": 443,
     "protocol": "vless",
     "remark": "VLESS-443",
+    "security": "",
     "shareAddr": "",
     "shareAddrStrategy": "",
     "ssMethod": "",

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

@@ -194,6 +194,76 @@ export const SCHEMAS: Record<string, unknown> = {
       "subExpiredTemplate": {
         "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": {
         "type": "boolean"
       },
@@ -227,6 +297,9 @@ export const SCHEMAS: Record<string, unknown> = {
       "subJsonPath": {
         "type": "string"
       },
+      "subJsonRoutingRules": {
+        "type": "string"
+      },
       "subJsonRules": {
         "type": "string"
       },
@@ -417,6 +490,29 @@ export const SCHEMAS: Record<string, unknown> = {
       "subEnableRouting",
       "subEncrypt",
       "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",
       "subIncyEnableRouting",
       "subIncyRoutingRules",
@@ -428,6 +524,7 @@ export const SCHEMAS: Record<string, unknown> = {
       "subJsonMux",
       "subJsonObservatory",
       "subJsonPath",
+      "subJsonRoutingRules",
       "subJsonRules",
       "subJsonURI",
       "subJsonUserAgentRegex",
@@ -685,6 +782,76 @@ export const SCHEMAS: Record<string, unknown> = {
       "subExpiredTemplate": {
         "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": {
         "type": "boolean"
       },
@@ -718,6 +885,9 @@ export const SCHEMAS: Record<string, unknown> = {
       "subJsonPath": {
         "type": "string"
       },
+      "subJsonRoutingRules": {
+        "type": "string"
+      },
       "subJsonRules": {
         "type": "string"
       },
@@ -915,6 +1085,29 @@ export const SCHEMAS: Record<string, unknown> = {
       "subEnableRouting",
       "subEncrypt",
       "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",
       "subIncyEnableRouting",
       "subIncyRoutingRules",
@@ -926,6 +1119,7 @@ export const SCHEMAS: Record<string, unknown> = {
       "subJsonMux",
       "subJsonObservatory",
       "subJsonPath",
+      "subJsonRoutingRules",
       "subJsonRules",
       "subJsonURI",
       "subJsonUserAgentRegex",
@@ -2524,6 +2718,9 @@ export const SCHEMAS: Record<string, unknown> = {
       "mtprotoDomain": {
         "type": "string"
       },
+      "network": {
+        "type": "string"
+      },
       "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.",
         "type": "string"
@@ -2545,6 +2742,9 @@ export const SCHEMAS: Record<string, unknown> = {
         "example": "VLESS-443",
         "type": "string"
       },
+      "security": {
+        "type": "string"
+      },
       "shareAddr": {
         "type": "string"
       },

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

@@ -67,6 +67,29 @@ export interface AllSetting {
   subEnableRouting: boolean;
   subEncrypt: boolean;
   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;
   subIncyEnableRouting: boolean;
   subIncyRoutingRules: string;
@@ -78,6 +101,7 @@ export interface AllSetting {
   subJsonMux: string;
   subJsonObservatory: string;
   subJsonPath: string;
+  subJsonRoutingRules: string;
   subJsonRules: string;
   subJsonURI: string;
   subJsonUserAgentRegex: string;
@@ -185,6 +209,29 @@ export interface AllSettingView {
   subEnableRouting: boolean;
   subEncrypt: boolean;
   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;
   subIncyEnableRouting: boolean;
   subIncyRoutingRules: string;
@@ -196,6 +243,7 @@ export interface AllSettingView {
   subJsonMux: string;
   subJsonObservatory: string;
   subJsonPath: string;
+  subJsonRoutingRules: string;
   subJsonRules: string;
   subJsonURI: string;
   subJsonUserAgentRegex: string;
@@ -574,11 +622,13 @@ export interface InboundOption {
   id: number;
   listen?: string;
   mtprotoDomain?: string;
+  network?: string;
   nodeAddress?: string;
   nodeId?: number | null;
   port: number;
   protocol: string;
   remark: string;
+  security?: string;
   shareAddr?: string;
   shareAddrStrategy?: string;
   ssMethod: string;

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

@@ -83,6 +83,29 @@ export const AllSettingSchema = z.object({
   subEnableRouting: z.boolean(),
   subEncrypt: z.boolean(),
   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(),
   subIncyEnableRouting: z.boolean(),
   subIncyRoutingRules: z.string(),
@@ -94,6 +117,7 @@ export const AllSettingSchema = z.object({
   subJsonMux: z.string(),
   subJsonObservatory: z.string(),
   subJsonPath: z.string(),
+  subJsonRoutingRules: z.string(),
   subJsonRules: z.string(),
   subJsonURI: z.string(),
   subJsonUserAgentRegex: z.string(),
@@ -202,6 +226,29 @@ export const AllSettingViewSchema = z.object({
   subEnableRouting: z.boolean(),
   subEncrypt: z.boolean(),
   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(),
   subIncyEnableRouting: z.boolean(),
   subIncyRoutingRules: z.string(),
@@ -213,6 +260,7 @@ export const AllSettingViewSchema = z.object({
   subJsonMux: z.string(),
   subJsonObservatory: z.string(),
   subJsonPath: z.string(),
+  subJsonRoutingRules: z.string(),
   subJsonRules: z.string(),
   subJsonURI: z.string(),
   subJsonUserAgentRegex: z.string(),
@@ -616,11 +664,13 @@ export const InboundOptionSchema = z.object({
   id: z.number().int(),
   listen: z.string().optional(),
   mtprotoDomain: z.string().optional(),
+  network: z.string().optional(),
   nodeAddress: z.string().optional(),
   nodeId: z.number().int().nullable().optional(),
   port: z.number().int(),
   protocol: z.string(),
   remark: z.string(),
+  security: z.string().optional(),
   shareAddr: z.string().optional(),
   shareAddrStrategy: z.string().optional(),
   ssMethod: z.string(),

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

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

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

@@ -32,7 +32,14 @@ export function isUdpOutbound(outbound: unknown): boolean {
     | undefined;
   const p = o?.protocol;
   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';

+ 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 { useLocation, useNavigate } from 'react-router';
 import { useTranslation } from 'react-i18next';
-import { Drawer, Layout, Menu } from 'antd';
+import { Drawer, Layout, Menu, Tooltip } from 'antd';
 import type { MenuProps } from 'antd';
 import {
   ApiOutlined,
@@ -28,6 +28,7 @@ import {
   PushpinOutlined,
   ReadOutlined,
   SafetyOutlined,
+  SearchOutlined,
   SettingOutlined,
   SunOutlined,
   SwapOutlined,
@@ -40,9 +41,13 @@ import { HttpUtil } from '@/utils';
 import { formatPanelVersion } from '@/lib/panel-version';
 import { pauseAnimationsUntilLeave, useTheme } from '@/hooks/useTheme';
 import { useAllSettings } from '@/api/queries/useAllSettings';
+import { useCommandPalette } from '@/components/command-palette/useCommandPalette';
 import './AppSidebar.css';
 
 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 REPO_URL = 'https://github.com/MHSanaei/3x-ui';
 const LOGOUT_KEY = '__logout__';
@@ -174,6 +179,7 @@ function saveSidebarPinned(pinned: boolean) {
 export default function AppSidebar() {
   const { t } = useTranslation();
   const { isDark, isUltra, toggleTheme, toggleUltra } = useTheme();
+  const { open: openCommandPalette } = useCommandPalette();
   const navigate = useNavigate();
   const { pathname, hash } = useLocation();
   const { allSetting } = useAllSettings();
@@ -392,6 +398,30 @@ export default function AppSidebar() {
             </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
           theme={currentTheme}
           mode="inline"
@@ -452,6 +482,25 @@ export default function AppSidebar() {
             </button>
           </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
           theme={currentTheme}
           mode="inline"

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

@@ -2,9 +2,15 @@ import { Outlet } from 'react-router';
 
 import { useWebSocketBridge } from '@/api/websocketBridge';
 import { usePageTitle } from '@/hooks/usePageTitle';
+import CommandPalette from '@/components/command-palette/CommandPalette';
 
 export default function PanelLayout() {
   useWebSocketBridge();
   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 { OutboundDomainStrategySchema } from '@/schemas/protocols/outbound';
+import { AmneziaWGOutboundSettingsSchema } from '@/schemas/protocols/outbound';
 import { normalizeStreamSettingsForWire } from '@/lib/xray/stream-wire-normalize';
 import { Wireguard } from '@/utils';
 import type { Sniffing, SniffingDest } from '@/schemas/primitives';
 import type { OutboundDomainStrategy } from '@/schemas/protocols/outbound';
 
 import type {
+  AmneziaWGOutboundFormSettings,
   BlackholeOutboundFormSettings,
   DnsOutboundFormSettings,
   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 {
   const m = asObject(raw);
   return {
@@ -457,6 +557,9 @@ export function rawOutboundToFormValues(raw: RawOutboundRow): OutboundFormValues
     case 'wireguard':
       typed = { protocol: 'wireguard', settings: wireguardFromWire(settings) };
       break;
+    case 'amneziawg':
+      typed = { protocol: 'amneziawg', settings: amneziawgFromWire(settings) };
+      break;
     case 'hysteria':
       typed = { protocol: 'hysteria', settings: hysteriaFromWire(settings) };
       break;
@@ -753,6 +856,9 @@ export function formValuesToWirePayload(values: OutboundFormValues): WireOutboun
     case 'wireguard':
       settings = wireguardToWire(values.settings);
       break;
+    case 'amneziawg':
+      settings = amneziawgToWire(values.settings);
+      break;
     case 'hysteria':
       settings = hysteriaToWire(values.settings);
       break;

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

@@ -69,10 +69,34 @@ export class AllSetting {
   subClashRules = '';
   subJsonMux = '';
   subJsonRules = '';
+  subJsonRoutingRules = '';
   subJsonFinalMask = '';
   subJsonObservatory = '';
   subThemeDir = '';
   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';
 

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

@@ -1280,8 +1280,8 @@ export const sections: readonly Section[] = [
         method: 'POST',
         path: '/panel/api/clients/bulkAdjust',
         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:
           '{\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 { 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 { ClientBulkAdjustFormSchema, type ClientBulkAdjustFormValues } from '@/schemas/client';
@@ -11,7 +11,13 @@ const GB = 1024 * 1024 * 1024;
 
 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 {
   open: boolean;
@@ -21,6 +27,8 @@ interface ClientBulkAdjustModalProps {
     addDays: number,
     addBytes: number,
     flow: string,
+    limitHwid?: number | null,
+    adTag?: string,
   ) => Promise<{ adjusted: number; skipped?: { email: string; reason: string }[] } | null>;
 }
 
@@ -45,16 +53,23 @@ export default function ClientBulkAdjustModal({
       addDays: Math.trunc(Number(values.addDays) || 0),
       addGB: Number(values.addGB) || 0,
       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) {
       messageApi.warning(t(validated.error.issues[0]?.message ?? 'somethingWentWrong'));
       return;
     }
-    const { addDays: days, addGB: gb, flow: flowValue } = validated.data;
+    const { addDays: days, addGB: gb, flow: flowValue, limitHwid, adTag } = validated.data;
     setSubmitting(true);
     try {
       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;
       const ok = result.adjusted ?? 0;
       const skipped = result.skipped?.length ?? 0;
@@ -111,6 +126,26 @@ export default function ClientBulkAdjustModal({
                 ]}
               />
             </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>
         </FormProvider>
       </Modal>

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

@@ -1,4 +1,5 @@
 import { lazy, useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { useLocation, useSearchParams } from 'react-router';
 import { useTranslation } from 'react-i18next';
 import {
   Badge,
@@ -382,7 +383,12 @@ export default function ClientsPage() {
   >(null);
 
   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 [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
   // keystroke; the search box still feels instant locally.
   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(() => {
     localStorage.setItem(
@@ -1893,8 +1908,15 @@ export default function ClientsPage() {
             open={bulkAdjustOpen}
             count={selectedRowKeys.length}
             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) {
                 setSelectedRowKeys([]);
                 return msg.obj ?? { adjusted: 0 };

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

@@ -654,8 +654,8 @@ export default function GroupsPage() {
             open={adjustOpen}
             count={groupEmails.length}
             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) {
                 const obj = msg.obj ?? { adjusted: 0 };
                 messageApi.success(

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

@@ -1,4 +1,5 @@
 import { useCallback, useMemo, useState, type Key } from 'react';
+import { useLocation, useSearchParams } from 'react-router';
 import { useTranslation } from 'react-i18next';
 import {
   Button,
@@ -58,7 +59,18 @@ export default function InboundList({
   // Node filter (#4997): 'all' shows everything, 0 is the local-panel
   // sentinel (inbounds without a nodeId), otherwise a node id. Session-only.
   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(
     () => 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 { catTabLabel } from './catTabLabel';
 import { sanitizePath, normalizePath } from './uriPath';
+import { remoteSourceBadge } from './subscriptionShared';
 import SubJsonFinalMaskForm from './SubJsonFinalMaskForm';
 import './SubscriptionFormatsTab.css';
 
@@ -220,6 +221,19 @@ export default function SubscriptionFormatsTab({
                       onChange={(value) => updateSetting({ subJsonUserAgentRegex: value })}
                     />
                   </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>
               )}
               {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 {
   BranchesOutlined,
   CompassOutlined,
@@ -17,17 +17,14 @@ import { RemarkTemplateField } from '@/components/form';
 import { useMediaQuery } from '@/hooks/useMediaQuery';
 import { catTabLabel } from './catTabLabel';
 import { sanitizePath, normalizePath } from './uriPath';
+import HappSettingsContent from './HappSettingsContent';
+import { remoteSourceBadge } from './subscriptionShared';
 
 interface SubscriptionGeneralTabProps {
   allSetting: AllSetting;
   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({
   allSetting,
   updateSetting,
@@ -344,40 +341,12 @@ export default function SubscriptionGeneralTab({
           key: '5',
           label: catTabLabel(<BranchesOutlined />, 'Happ', isMobile),
           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,
   WireguardFields,
 } from './protocols';
+import { AmneziawgFields } from './protocols';
 import {
   GrpcForm,
   HttpUpgradeForm,
@@ -453,6 +454,7 @@ export default function OutboundFormModal({
                       )}
 
                       {protocol === 'wireguard' && <WireguardFields />}
+                      {protocol === 'amneziawg' && <AmneziawgFields />}
 
                       {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 SocksFields } from './socks';
 export { default as WireguardFields } from './wireguard';
+export { default as AmneziawgFields } from './amneziawg';
 export { default as FreedomFields } from './freedom';
 export { default as LoopbackFields } from './loopback';
 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(),
     protocol: z.string().optional(),
     port: z.number().optional(),
+    network: z.string().optional(),
+    security: z.string().optional(),
     tlsFlowCapable: z.boolean().optional(),
     ssMethod: z.string().optional(),
     wgPublicKey: z.string().optional(),
@@ -329,10 +331,31 @@ export const ClientBulkAdjustFormSchema = z
     addDays: z.number().int(),
     addGB: z.number(),
     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({
   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 { NetworkSettingsSchema, StreamExtrasSchema } from '@/schemas/protocols/stream';
 import {
+  AmneziaWGOutboundSettingsSchema,
   BlackholeResponseTypeSchema,
   DNSRuleActionSchema,
   FreedomFinalRuleActionSchema,
@@ -112,6 +113,11 @@ export const WireguardOutboundFormSettingsSchema = z.object({
 });
 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
 // (auth, congestion, up/down, hop port, timeouts) ride on stream.hysteria.
 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('http'), settings: HttpOutboundFormSettingsSchema }),
   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('freedom'), settings: FreedomOutboundFormSettingsSchema }),
   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',
   Shadowsocks: 'shadowsocks',
   Wireguard: 'wireguard',
+  AmneziaWG: 'amneziawg',
   Hysteria: 'hysteria',
   Socks: 'socks',
   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 { BlackholeOutboundSettingsSchema } from './blackhole';
+import { AmneziaWGOutboundSettingsSchema } from './amneziawg';
 import { DNSOutboundSettingsSchema } from './dns';
 import { FreedomOutboundSettingsSchema } from './freedom';
 import { HttpOutboundSettingsSchema } from './http';
@@ -14,6 +15,7 @@ import { VmessOutboundSettingsSchema } from './vmess';
 import { WireguardOutboundSettingsSchema } from './wireguard';
 
 export * from './blackhole';
+export * from './amneziawg';
 export * from './dns';
 export * from './freedom';
 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('shadowsocks'), settings: ShadowsocksOutboundSettingsSchema }),
   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('http'), settings: HttpOutboundSettingsSchema }),
   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(),
     subJsonMux: z.string().optional(),
     subJsonRules: z.string().optional(),
+    subJsonRoutingRules: z.string().optional(),
     subJsonFinalMask: z.string().optional(),
     subJsonObservatory: z.string().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(),
     ldapEnable: z.boolean().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(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"
 	"strings"
 
-	awgconn "github.com/amnezia-vpn/amneziawg-go/v3/conn"
 	"github.com/amnezia-vpn/amneziawg-go/v3/device"
 	"gvisor.dev/gvisor/pkg/tcpip/stack"
 
@@ -69,8 +68,15 @@ type DeviceOptions struct {
 type Device struct {
 	*device.Device
 	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
 // interface for inst in one call: a gVisor-backed tun.Device sized to
 // amneziawg.EffectiveMTU, addressed with inst.Address, configured via
@@ -128,9 +134,9 @@ func newUnconfiguredDevice(inst amneziawg.Instance, opts DeviceOptions) (*Device
 	if logger == nil {
 		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.

+ 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"
 	"fmt"
 	"maps"
+	"net/netip"
 	"slices"
 	"strings"
 
 	"github.com/goccy/go-json"
 	yaml "github.com/goccy/go-yaml"
 
+	"github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
 	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
 	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 {
 		return s.buildWireguardProxy(subReq, inbound, client, ep)
 	}
+	if inbound.Protocol == model.AmneziaWG {
+		return s.buildAmneziaWGProxy(subReq, inbound, client, ep)
+	}
 
 	network, _ := stream["network"].(string)
 
@@ -492,6 +497,186 @@ func (s *SubClashService) buildWireguardProxy(subReq *SubService, inbound *model
 	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
 // 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
 
 import (
+	"fmt"
 	"reflect"
 	"testing"
 
+	"github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
 	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
 	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
 // bare host: the custom share address stores IPv6 literals bracketed, and mihomo
 // rejects "[2001:db8::1]" there.
@@ -908,3 +1257,53 @@ func TestGetProxies_CustomIPv6ShareAddrIsUnbracketed(t *testing.T) {
 		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.
 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
 	subIncyRoutingRules  string
@@ -98,6 +100,7 @@ type subControllerConfig struct {
 
 	subJsonMux            string
 	subJsonRules          string
+	subJsonRoutingRules   string
 	subJsonFinalMask      string
 	subJsonObservatory    string
 	subClashEnableRouting bool
@@ -110,6 +113,7 @@ type subControllerConfig struct {
 	subEnableRouting bool
 	subRoutingRules  string
 	subHideSettings  bool
+	happConfig       HappConfig
 
 	subIncyEnableRouting bool
 	subIncyRoutingRules  string
@@ -177,6 +181,10 @@ func WithSUBJsonRules(value string) SUBControllerOption {
 	return func(config *subControllerConfig) { config.subJsonRules = value }
 }
 
+func WithSUBJsonRoutingRules(value string) SUBControllerOption {
+	return func(config *subControllerConfig) { config.subJsonRoutingRules = value }
+}
+
 func WithSUBJsonFinalMask(value string) SUBControllerOption {
 	return func(config *subControllerConfig) { config.subJsonFinalMask = value }
 }
@@ -229,6 +237,10 @@ func WithSUBIncyRoutingRules(value string) SUBControllerOption {
 	return func(config *subControllerConfig) { config.subIncyRoutingRules = value }
 }
 
+func WithSUBHappConfig(value HappConfig) SUBControllerOption {
+	return func(config *subControllerConfig) { config.happConfig = value }
+}
+
 func defaultSUBControllerConfig() subControllerConfig {
 	return subControllerConfig{
 		subPath:        "/sub/",
@@ -248,16 +260,18 @@ func NewSUBController(g *gin.RouterGroup, options ...SUBControllerOption) *SUBCo
 	}
 
 	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)
 	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,
 		subIncyRoutingRules:  config.subIncyRoutingRules,
@@ -845,16 +859,28 @@ func (a *SUBController) ApplyCommonHeaders(
 		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)
+	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 {
 		c.Writer.Header().Set("Routing-Enable", "true")
+	} else if happManaged {
+		c.Writer.Header().Set("Routing-Enable", "0")
 	}
 	if (routingErr == nil || !remote) && strings.TrimSpace(rules) != "" {
 		c.Writer.Header().Set("Routing", rules)
 	}
 	if profileHideSettings {
 		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).
 func TestBuildEndpointLinks_HostFinalMaskMerge(t *testing.T) {
 	s := &SubService{}

+ 1 - 1
internal/sub/external_only_sub_test.go

@@ -28,7 +28,7 @@ func TestJsonAndClashServeExternalLinkOnlySub(t *testing.T) {
 
 	base := NewSubService("")
 
-	jsonService := NewSubJsonService("", "", "", base)
+	jsonService := NewSubJsonService("", "", "", "", base)
 	jsonOut, _, err := jsonService.GetJson("ext-only", "sub.example.com", false)
 	if err != nil {
 		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)
 	}
 
-	js := NewSubJsonService("", "", "", NewSubService(""))
+	js := NewSubJsonService("", "", "", "", NewSubService(""))
 	out, _, err := js.GetJson("s1", "req.example.com", false)
 	if err != nil {
 		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",
 		SockoptParams: `{"tcpFastOpen":true}`,
 	})
-	js := NewSubJsonService("", "", "", NewSubService(""))
+	js := NewSubJsonService("", "", "", "", NewSubService(""))
 	out, _, err := js.GetJson("s1", "req.example.com", false)
 	if err != nil {
 		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",
 		MuxParams: `{"enabled":true,"concurrency":8}`,
 	})
-	js := NewSubJsonService("", "", "", NewSubService(""))
+	js := NewSubJsonService("", "", "", "", NewSubService(""))
 	out, _, err := js.GetJson("s1", "req.example.com", false)
 	if err != nil {
 		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)
 	}
 
-	js := NewSubJsonService("", "", "", NewSubService(""))
+	js := NewSubJsonService("", "", "", "", NewSubService(""))
 	out, _, err := js.GetJson("s1", "req.example.com", false)
 	if err != nil {
 		t.Fatalf("GetJson: %v", err)
@@ -76,7 +76,7 @@ func TestSub_JSONKeepsFlowOnTcpTLS(t *testing.T) {
 	seedFlowInbound(t, "s1", "tcpflow", 4602,
 		`{"network":"tcp","security":"tls","tlsSettings":{"serverName":"base.sni"}}`)
 
-	js := NewSubJsonService("", "", "", NewSubService(""))
+	js := NewSubJsonService("", "", "", "", NewSubService(""))
 	out, _, err := js.GetJson("s1", "req.example.com", false)
 	if err != nil {
 		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.subInfoNodeEnable = true
-	jsonSvc := NewSubJsonService("", "", "", sub)
+	jsonSvc := NewSubJsonService("", "", "", "", sub)
 
 	out, _, err := jsonSvc.GetJson("sub-json", "sub.example.com", false)
 	if err != nil {
@@ -115,7 +115,7 @@ func TestSubJson_InfoNode_Expired(t *testing.T) {
 	sub := NewSubService("{{INBOUND}}")
 	sub.subInfoNodeEnable = true
 	sub.subExpiredTemplate = service.DefaultSubExpiredTemplate
-	jsonSvc := NewSubJsonService("", "", "", sub)
+	jsonSvc := NewSubJsonService("", "", "", "", sub)
 
 	out, _, err := jsonSvc.GetJson("sub-json-exp", "sub.example.com", false)
 	if err != nil {
@@ -175,7 +175,7 @@ func TestSubJson_InfoNode_Depleted(t *testing.T) {
 	sub := NewSubService("{{INBOUND}}")
 	sub.subInfoNodeEnable = true
 	sub.subTrafficDepletedTemplate = service.DefaultSubTrafficDepletedTemplate
-	jsonSvc := NewSubJsonService("", "", "", sub)
+	jsonSvc := NewSubJsonService("", "", "", "", sub)
 
 	out, _, err := jsonSvc.GetJson("sub-json-dep", "sub.example.com", false)
 	if err != nil {
@@ -234,7 +234,7 @@ func TestSubJson_InfoNode_StatusActive(t *testing.T) {
 
 	sub := NewSubService("{{EMAIL}}|{{STATUS_EMOJI}} {{STATUS}}")
 	sub.subInfoNodeEnable = true
-	jsonSvc := NewSubJsonService("", "", "", sub)
+	jsonSvc := NewSubJsonService("", "", "", "", sub)
 
 	out, _, err := jsonSvc.GetJson("sub-json-status", "sub.example.com", false)
 	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"
 	"sort"
 	"strings"
+	"sync"
 	"time"
 
 	"github.com/mhsanaei/3x-ui/v3/internal/database"
@@ -30,11 +31,22 @@ type SubJsonService struct {
 	mux              string
 	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
 }
 
+type bakedRoutingState struct {
+	spec       jsonRoutingSpec
+	configJson map[string]any
+}
+
 // 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 defaultOutbounds []json_util.RawMessage
 	_ = 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
 		routing, _ := configJson["routing"].(map[string]any)
 		defaultRules, _ := routing["rules"].([]any)
@@ -60,11 +74,35 @@ func NewSubJsonService(mux string, rules string, finalMask string, subService *S
 		defaultOutbounds: defaultOutbounds,
 		finalMask:        finalMask,
 		mux:              mux,
+		routingRules:     routingRules,
 		observatory:      defaultSubBalancerObservatoryConfig(),
 		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.
 func (s *SubJsonService) GetJson(subId string, host string, alwaysReturnArray bool) (string, string, error) {
 	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 = append(newOutbounds, s.defaultOutbounds...)
 			newConfigJson := make(map[string]any)
-			maps.Copy(newConfigJson, s.configJson)
+			maps.Copy(newConfigJson, s.bakedTemplate())
 			newConfigJson["outbounds"] = newOutbounds
 			newConfigJson["remarks"] = remark
 			newConfig, _ := json.MarshalIndent(newConfigJson, "", "  ")
@@ -455,9 +493,12 @@ func (s *SubJsonService) buildBalancerConfig(balancer *model.SubBalancer, entrie
 	outbounds := append([]json_util.RawMessage{}, proxies...)
 	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)
 	maps.Copy(routing, baseRouting)
 	baseRules, _ := baseRouting["rules"].([]any)
@@ -493,8 +534,8 @@ func (s *SubJsonService) buildBalancerConfig(balancer *model.SubBalancer, entrie
 	}
 	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["remarks"] = balancer.Remark
 	newConfigJson["routing"] = routing
@@ -614,7 +655,7 @@ func (s *SubJsonService) getConfig(subReq *SubService, inbound *model.Inbound, c
 
 		newOutbounds = append(newOutbounds, s.defaultOutbounds...)
 		newConfigJson := make(map[string]any)
-		maps.Copy(newConfigJson, s.configJson)
+		maps.Copy(newConfigJson, s.bakedTemplate())
 
 		transport, _ := newStream["network"].(string)
 		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) {
-	svc := NewSubJsonService("", "", "", nil)
+	svc := NewSubJsonService("", "", "", "", nil)
 	inbounds, ok := svc.configJson["inbounds"].([]any)
 	if !ok {
 		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) {
 	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"}`}
 
 	decode := func(raw []byte) map[string]any {
@@ -119,7 +119,7 @@ func TestSubJsonServiceVisionFlowDisablesTCPMuxOnly(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"}}`
-	svc := NewSubJsonService("", "", finalMask, nil)
+	svc := NewSubJsonService("", "", finalMask, "", nil)
 
 	if hasDirectOutOutbound(svc) {
 		t.Fatal("direct_out outbound must never be emitted")
@@ -156,7 +156,7 @@ func TestSubJsonServiceInjectsGlobalFinalMask(t *testing.T) {
 
 func TestSubJsonServiceMergesWithExistingFinalMask(t *testing.T) {
 	finalMask := `{"tcp":[{"type":"fragment","settings":{"packets":"tlshello"}}]}`
-	svc := NewSubJsonService("", "", finalMask, nil)
+	svc := NewSubJsonService("", "", finalMask, "", nil)
 
 	stream := svc.streamData(`{
 		"network":"tcp","security":"none","tcpSettings":{"header":{"type":"none"}},
@@ -176,7 +176,7 @@ func TestSubJsonServiceMergesWithExistingFinalMask(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"}}}`, "")
 	if _, ok := stream["finalmask"]; ok {
 		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
 // to import the config (#5401).
 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"]}}}`, "")
 
 	tls, _ := stream["tlsSettings"].(map[string]any)
@@ -203,7 +203,7 @@ func TestSubJsonServicePinnedCertJoinedToString(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":{}}}`, "")
 
 	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"}`}
 	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 {
 		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}
 	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 {
 		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: `{}`}
 	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 {
 		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: `{}`}
 	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)
 	if server == nil {
 		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"}`}
-	ssSettings := outboundSettings(t, NewSubJsonService("", "", "", nil).genServer(&SubService{}, ss, nil, client, ""))
+	ssSettings := outboundSettings(t, NewSubJsonService("", "", "", "", nil).genServer(&SubService{}, ss, nil, client, ""))
 	ssServer := firstServer(ssSettings)
 	if ssServer == nil {
 		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) {
 	globalMux := `{"enabled":true,"concurrency":8}`
-	svc := NewSubJsonService(globalMux, "", "", nil)
+	svc := NewSubJsonService(globalMux, "", "", "", nil)
 
 	// When xmux is present in xhttpSettings, the per-inbound xmux handles
 	// multiplexing and the legacy outbound.Mux must NOT be set.
@@ -333,7 +333,7 @@ func TestSubJsonServiceXmuxSuppressesGlobalMux(t *testing.T) {
 
 func TestSubJsonServiceGlobalMuxWhenNoXmux(t *testing.T) {
 	globalMux := `{"enabled":true,"concurrency":8}`
-	svc := NewSubJsonService(globalMux, "", "", nil)
+	svc := NewSubJsonService(globalMux, "", "", "", nil)
 
 	// 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"}}`
@@ -387,7 +387,7 @@ func realitySpiderXFromStream(t *testing.T, svc *SubJsonService, clientKey strin
 }
 
 func TestSubJsonServiceRealityDataDerivesPerClientSpiderX(t *testing.T) {
-	svc := NewSubJsonService("", "", "", nil)
+	svc := NewSubJsonService("", "", "", "", nil)
 
 	alice := realitySpiderXFromStream(t, svc, "subAlice")
 	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
 // subscription request.
 func TestSubJsonServiceStreamDataMalformedInputs(t *testing.T) {
-	withMask := NewSubJsonService("", "", `{"tcp":[{"type":"fragment"}]}`, nil)
+	withMask := NewSubJsonService("", "", `{"tcp":[{"type":"fragment"}]}`, "", nil)
 	stream := withMask.streamData("not-json", "clientKey")
 	if _, ok := stream["finalmask"]; !ok {
 		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")
 	if v, ok := noReality["realitySettings"]; ok {
 		t.Fatalf("missing realitySettings must stay absent, got %v", v)
@@ -421,7 +421,7 @@ func TestSubJsonServiceStreamDataMalformedInputs(t *testing.T) {
 }
 
 func TestSubJsonServiceRealityDataSpiderXFallsBackWhenNoClientKey(t *testing.T) {
-	svc := NewSubJsonService("", "", "", nil)
+	svc := NewSubJsonService("", "", "", "", nil)
 
 	stream := svc.streamData(`{
 		"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"},
 	}
 
-	raw := NewSubJsonService("", "", "", nil).genWireguard(inbound, client)
+	raw := NewSubJsonService("", "", "", "", nil).genWireguard(inbound, client)
 	if raw == nil {
 		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: `{}`}
 	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)
 	}
 }
 
 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))
 	}
 }

+ 10 - 10
internal/sub/mutation_audit_test.go

@@ -29,7 +29,7 @@ func initMutDB(t *testing.T) {
 
 func TestSubJsonService_CustomRulesPrepended(t *testing.T) {
 	rules := `[{"type":"field","domain":["geosite:ads"],"outboundTag":"block"}]`
-	svc := NewSubJsonService("", rules, "", nil)
+	svc := NewSubJsonService("", rules, "", "", nil)
 
 	routing, ok := svc.configJson["routing"].(map[string]any)
 	if !ok {
@@ -47,7 +47,7 @@ func TestSubJsonService_CustomRulesPrepended(t *testing.T) {
 }
 
 func TestSubJsonService_EmptyRulesLeavesDefault(t *testing.T) {
-	svc := NewSubJsonService("", "", "", nil)
+	svc := NewSubJsonService("", "", "", "", nil)
 	routing, _ := svc.configJson["routing"].(map[string]any)
 	got, _ := routing["rules"].([]any)
 	if len(got) != 1 {
@@ -67,12 +67,12 @@ func TestSubJsonService_MuxAttachedWhenConfigured(t *testing.T) {
 		wantMux  bool
 		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 {
 		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
 	// key is an empty tcp slice, which mergeFinalMask drops → merged is empty,
 	// 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"}}}`, "")
 	if _, ok := stream["finalmask"]; ok {
 		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
 	// 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"}}}`, "")
 	if _, ok := stream2["finalmask"]; !ok {
 		t.Fatal("non-empty finalMask must be set")

+ 30 - 17
internal/sub/remote_routing.go

@@ -30,6 +30,7 @@ type remoteRoutingKind string
 
 const (
 	remoteRoutingHapp  remoteRoutingKind = "happ"
+	remoteRoutingJson  remoteRoutingKind = "jsonhapp"
 	remoteRoutingClash remoteRoutingKind = "clash"
 
 	remoteRoutingCacheTTL     = 10 * time.Minute
@@ -40,6 +41,12 @@ const (
 	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")
 
 type remoteRoutingKey struct {
@@ -167,19 +174,22 @@ func (r *remoteRoutingResolver) resolveEntry(kind remoteRoutingKind, raw string)
 }
 
 // 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
 	}
-	if key.kind == remoteRoutingHapp && isRemoteHappRedirect(resp.StatusCode) {
+	if isHappPayloadKind(key.kind) && isRemoteHappRedirect(resp.StatusCode) {
 		location := strings.TrimSpace(resp.Header.Get("Location"))
 		content, locationErr := normalizeHappRouting([]byte(location))
 		if locationErr != nil {
@@ -321,7 +331,7 @@ func (r *remoteRoutingResolver) fetch(key remoteRoutingKey, previous remoteRouti
 	if err != nil {
 		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{
@@ -346,7 +356,7 @@ func isRemoteHappRedirect(status int) bool {
 
 func normalizeRemoteRoutingContent(kind remoteRoutingKind, body []byte) (string, map[string]any, error) {
 	switch kind {
-	case remoteRoutingHapp:
+	case remoteRoutingHapp, remoteRoutingJson:
 		content, err := normalizeHappRouting(body)
 		return content, nil, err
 	case remoteRoutingClash:
@@ -369,6 +379,9 @@ func normalizeHappRouting(body []byte) (string, error) {
 		}
 		return "happ://routing/onadd/" + base64.StdEncoding.EncodeToString(compact), nil
 	}
+	if text == "happ://routing/off" {
+		return text, nil
+	}
 	if strings.ContainsAny(text, "\r\n") {
 		return "", errors.New("Happ deeplink must be a single line")
 	}
@@ -570,7 +583,7 @@ func (r *remoteRoutingResolver) triggerPersistedLoad() {
 
 func (r *remoteRoutingResolver) loadPersisted() {
 	loaded := make(map[remoteRoutingKey]remoteRoutingCacheEntry, 2)
-	for _, kind := range []remoteRoutingKind{remoteRoutingHapp, remoteRoutingClash} {
+	for _, kind := range []remoteRoutingKind{remoteRoutingHapp, remoteRoutingJson, remoteRoutingClash} {
 		var setting model.Setting
 		err := database.GetDB().Where("key = ?", remoteRoutingSettingKey(kind)).First(&setting).Error
 		if err != nil {
@@ -587,7 +600,7 @@ func (r *remoteRoutingResolver) loadPersisted() {
 		if err != nil {
 			continue
 		}
-		if kind == remoteRoutingHapp && len(normalized) > remoteRoutingHappMaxValue {
+		if isHappPayloadKind(kind) && len(normalized) > remoteRoutingHappMaxValue {
 			continue
 		}
 		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 != "" {
 		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"))
 	}
 	return sb.String()

+ 32 - 0
internal/sub/sub.go

@@ -150,6 +150,11 @@ func (s *Server) initRouter() (*gin.Engine, error) {
 		SubJsonRules = ""
 	}
 
+	SubJsonRoutingRules, err := s.settingService.GetSubJsonRoutingRules()
+	if err != nil {
+		SubJsonRoutingRules = ""
+	}
+
 	SubJsonFinalMask, err := s.settingService.GetSubJsonFinalMask()
 	if err != nil {
 		SubJsonFinalMask = ""
@@ -215,6 +220,31 @@ func (s *Server) initRouter() (*gin.Engine, error) {
 		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
 	engine.Use(locale.LocalizerMiddleware())
 
@@ -285,6 +315,7 @@ func (s *Server) initRouter() (*gin.Engine, error) {
 		WithSUBUpdateInterval(SubUpdates),
 		WithSUBJsonMux(SubJsonMux),
 		WithSUBJsonRules(SubJsonRules),
+		WithSUBJsonRoutingRules(SubJsonRoutingRules),
 		WithSUBJsonFinalMask(SubJsonFinalMask),
 		WithSUBJsonObservatory(SubJsonObservatory),
 		WithSUBClashEnableRouting(SubClashEnableRouting),
@@ -296,6 +327,7 @@ func (s *Server) initRouter() (*gin.Engine, error) {
 		WithSUBEnableRouting(SubEnableRouting),
 		WithSUBRoutingRules(SubRoutingRules),
 		WithSUBHideSettings(SubHideSettings),
+		WithSUBHappConfig(happCfg),
 		WithSUBIncyEnableRouting(SubIncyEnableRouting),
 		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,
 	})
 
-	js := NewSubJsonService("", "", "", NewSubService(""))
+	js := NewSubJsonService("", "", "", "", NewSubService(""))
 	out, _, err := js.GetJson("s1", "req.example.com", true)
 	if err != nil {
 		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"}]`
-	js := NewSubJsonService("", rules, "", NewSubService(""))
+	js := NewSubJsonService("", rules, "", "", NewSubService(""))
 	out, _, err := js.GetJson("s1", "req.example.com", true)
 	if err != nil {
 		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,
 	})
 
-	js := NewSubJsonService("", "", "", NewSubService(""))
+	js := NewSubJsonService("", "", "", "", NewSubService(""))
 	out, _, err := js.GetJson("s1", "req.example.com", true)
 	if err != nil {
 		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,
 	})
 
-	js := NewSubJsonService("", "", "", NewSubService(""))
+	js := NewSubJsonService("", "", "", "", NewSubService(""))
 	out, _, err := js.GetJson("s1", "req.example.com", true)
 	if err != nil {
 		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,
 	})
 
-	js := NewSubJsonService("", "", "", NewSubService(""))
+	js := NewSubJsonService("", "", "", "", NewSubService(""))
 	out, _, err := js.GetJson("s1", "req.example.com", true)
 	if err != nil {
 		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,
 	})
 
-	js := NewSubJsonService("", "", "", NewSubService(""))
+	js := NewSubJsonService("", "", "", "", NewSubService(""))
 	js.SetObservatoryConfig(`{"destination":"https://probe.example/204","httpMethod":"GET","sampling":5}`)
 	out, _, err := js.GetJson("s1", "req.example.com", true)
 	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,
 	})
 
-	js := NewSubJsonService("", "", "", NewSubService(""))
+	js := NewSubJsonService("", "", "", "", NewSubService(""))
 	out, _, err := js.GetJson("s1", "req.example.com", true)
 	if err != nil {
 		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,
 	})
 
-	js := NewSubJsonService("", "", "", NewSubService(""))
+	js := NewSubJsonService("", "", "", "", NewSubService(""))
 	out, _, err := js.GetJson("s1", "req.example.com", true)
 	if err != nil {
 		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,
 	})
 
-	js := NewSubJsonService("", "", "", NewSubService(""))
+	js := NewSubJsonService("", "", "", "", NewSubService(""))
 	out, _, err := js.GetJson("s1", "req.example.com", true)
 	if err != nil {
 		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,
 	})
 
-	js := NewSubJsonService("", "", "", NewSubService(""))
+	js := NewSubJsonService("", "", "", "", NewSubService(""))
 	js.SetObservatoryConfig(`{"enabled":false}`)
 	out, _, err := js.GetJson("s1", "req.example.com", true)
 	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,
 	})
 
-	js := NewSubJsonService("", "", "", NewSubService(""))
+	js := NewSubJsonService("", "", "", "", NewSubService(""))
 	out, _, err := js.GetJson("s1", "req.example.com", true)
 	if err != nil {
 		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,
 	})
 
-	js := NewSubJsonService("", "", "", NewSubService(""))
+	js := NewSubJsonService("", "", "", "", NewSubService(""))
 	out, _, err := js.GetJson("s1", "req.example.com", true)
 	if err != nil {
 		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,
 	})
 
-	js := NewSubJsonService("", "", "", NewSubService(""))
+	js := NewSubJsonService("", "", "", "", NewSubService(""))
 	out, _, err := js.GetJson("s1", "req.example.com", true)
 	if err != nil {
 		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 {
 		t.Run(tc.name, func(t *testing.T) {
-			js := NewSubJsonService("", "", "", NewSubService(""))
+			js := NewSubJsonService("", "", "", "", NewSubService(""))
 			js.SetObservatoryConfig(tc.cfg)
 			out, _, err := js.GetJson("s1", "req.example.com", true)
 			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)
 	}
 
-	jsonService := NewSubJsonService("", "", "", NewSubService(""))
+	jsonService := NewSubJsonService("", "", "", "", NewSubService(""))
 	out, _, err := jsonService.GetJson(subId, "sub.example.com", true)
 	if err != nil {
 		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"}}}`
 	seedSubInbound(t, "rlty1", "rlty", 46400, 1, stream)
 
-	jsonService := NewSubJsonService("", "", "", NewSubService(""))
+	jsonService := NewSubJsonService("", "", "", "", NewSubService(""))
 	out, _, err := jsonService.GetJson("rlty1", "sub.example.com", true)
 	if err != nil {
 		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"}]}`
 	seedSubInbound(t, "extp1", "extp", 46500, 1, stream)
 
-	jsonService := NewSubJsonService("", "", "", NewSubService(""))
+	jsonService := NewSubJsonService("", "", "", "", NewSubService(""))
 	jsonOut, _, err := jsonService.GetJson("extp1", "sub.example.com", true)
 	if err != nil {
 		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))
 			}
 
-			jsonSvc := NewSubJsonService("", "", "", &SubService{})
+			jsonSvc := NewSubJsonService("", "", "", "", &SubService{})
 			start = time.Now()
 			for range reps {
 				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)
 	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)
 	if err != nil {
 		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 {
-	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) {
@@ -325,7 +327,7 @@ func (a *ClientController) bulkAdjust(c *gin.Context) {
 		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
 		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 {
 		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
 		return

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

@@ -107,11 +107,37 @@ type AllSetting struct {
 	SubClashRules               string `json:"subClashRules" form:"subClashRules"`
 	SubJsonMux                  string `json:"subJsonMux" form:"subJsonMux"`
 	SubJsonRules                string `json:"subJsonRules" form:"subJsonRules"`
+	SubJsonRoutingRules         string `json:"subJsonRoutingRules" form:"subJsonRoutingRules"`
 	SubJsonFinalMask            string `json:"subJsonFinalMask" form:"subJsonFinalMask"`
 	SubJsonObservatory          string `json:"subJsonObservatory" form:"subJsonObservatory"`
 	SubThemeDir                 string `json:"subThemeDir" form:"subThemeDir"`
 	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"`
 	LdapHost               string `json:"ldapHost" form:"ldapHost"`
 	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
 
 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/logger"
 	"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 {
 	inboundService service.InboundService
+	settingService service.SettingService
 }
 
 // NewAmneziaWGJob creates a new AmneziaWG reconcile job instance.
@@ -52,4 +48,64 @@ func (j *AmneziaWGJob) Run() {
 		})
 	}
 	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)
 		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
 // unlimited (0) are skipped — bulk extend should not accidentally
 // 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
 // settings JSON is parsed and written exactly once regardless of how
 // 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{}
 	if len(emails) == 0 {
 		return result, false, nil
@@ -325,8 +327,18 @@ func (s *ClientService) BulkAdjust(inboundSvc *InboundService, emails []string,
 	if _, ok := bulkFlowAllowed[flow]; !ok {
 		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 != ""
-	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")
 	}
 
@@ -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
 		}
 	}
@@ -434,8 +446,10 @@ func (s *ClientService) BulkAdjust(inboundSvc *InboundService, emails []string,
 	plannedIds := make([]int, 0, len(plan))
 	recordIdToEmail := make(map[int]string, len(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
@@ -458,10 +472,12 @@ func (s *ClientService) BulkAdjust(inboundSvc *InboundService, emails []string,
 	needRestart := false
 	flowHonored := map[string]bool{}
 	flowIneligible := map[string]bool{}
+	adTagHonored := map[string]bool{}
+	adTagIneligible := map[string]bool{}
 	execFailed := map[string]bool{}
 	adjustIds := sortedInboundIds(emailsByInbound)
 	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 {
 		if adjustPanics[i] != nil {
@@ -483,6 +499,12 @@ func (s *ClientService) BulkAdjust(inboundSvc *InboundService, emails []string,
 		for email := range ibRes.flowIneligible {
 			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 {
 			execFailed[email] = true
 			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{}{}
 	for email, entry := range plan {
 		if execFailed[email] {
@@ -531,9 +558,24 @@ func (s *ClientService) BulkAdjust(inboundSvc *InboundService, emails []string,
 				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{}{}
 		}
 	}
@@ -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"})
 	}
+	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 {
 		stillDepleted := map[string]struct{}{}
@@ -599,8 +650,10 @@ type bulkInboundAdjustResult struct {
 	// that an inbound cannot carry must not suppress the expiry/total write for
 	// the same client (which would diverge the inbound JSON / ClientRecord from
 	// 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
@@ -615,8 +668,15 @@ func (s *ClientService) bulkAdjustInboundClients(
 	emails []string,
 	plan map[string]*bulkAdjustEntry,
 	flow string,
+	adTag string,
 ) 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()
 
@@ -655,9 +715,16 @@ func (s *ClientService) bulkAdjustInboundClients(
 		(!oldInbound.DisableFlow &&
 			inboundCanEnableTlsFlow(string(oldInbound.Protocol), oldInbound.StreamSettings, oldInbound.Settings))
 
+	wantAdTag := ""
+	if adTag != "" && adTag != bulkFlowClear {
+		wantAdTag = strings.ToLower(adTag)
+	}
+
 	interfaceClients, _ := settings["clients"].([]any)
 	foundEmails := map[string]bool{}
 	flowChanged := false
+	adTagChanged := false
+	hasInboundChanges := false
 	nowMs := time.Now().Unix() * 1000
 	for i, client := range interfaceClients {
 		c, ok := client.(map[string]any)
@@ -668,12 +735,15 @@ func (s *ClientService) bulkAdjustInboundClients(
 		if _, want := wantedEmails[targetEmail]; !want || targetEmail == "" {
 			continue
 		}
+		clientChanged := false
 		entry := plan[targetEmail]
 		if entry.applyExpiry {
 			c["expiryTime"] = entry.newExpiry
+			clientChanged = true
 		}
 		if entry.applyTotal {
 			c["totalGB"] = entry.newTotal
+			clientChanged = true
 		}
 		if flow != "" {
 			if flowEligible {
@@ -686,13 +756,29 @@ func (s *ClientService) bulkAdjustInboundClients(
 					flowChanged = true
 				}
 				res.flowHonored[targetEmail] = true
+				clientChanged = true
 			} else {
 				// Record separately so this never suppresses the expiry/total
 				// write for the same client (see flowIneligible doc).
 				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
 		foundEmails[targetEmail] = true
 	}
@@ -703,7 +789,7 @@ func (s *ClientService) bulkAdjustInboundClients(
 		}
 	}
 
-	if len(foundEmails) == 0 {
+	if len(foundEmails) == 0 || !hasInboundChanges {
 		return res
 	}
 
@@ -748,28 +834,36 @@ func (s *ClientService) bulkAdjustInboundClients(
 				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()
-	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)
 	}
 	if got := bar.updateUser.Load(); got == 0 {

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

@@ -1,6 +1,7 @@
 package service
 
 import (
+	"encoding/json"
 	"testing"
 	"time"
 
@@ -64,7 +65,7 @@ func TestBulkAdjust_FlowSetAndClear(t *testing.T) {
 	emails := emailsOf(clients)
 
 	// 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 {
 		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.
-	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)
 	} else if restart2 {
 		t.Fatalf("re-setting identical flow should not request a restart")
 	}
 
 	// 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 {
 		t.Fatalf("BulkAdjust clear: %v", err)
 	}
@@ -121,7 +122,7 @@ func TestBulkAdjust_FlowIneligibleSkipped(t *testing.T) {
 		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 {
 		t.Fatalf("BulkAdjust: %v", err)
 	}
@@ -146,11 +147,11 @@ func TestBulkAdjust_NoDirectiveErrors(t *testing.T) {
 	svc := &ClientService{}
 	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")
 	}
 	// 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")
 	}
 }
@@ -182,7 +183,7 @@ func TestBulkAdjust_DaysApplyDespiteIneligibleFlow(t *testing.T) {
 		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 {
 		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)
 	}
 }
+
+// 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"
 	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 {
 		t.Fatalf("BulkAdjust: %v", err)
 	}
@@ -118,7 +118,7 @@ func TestBulkAdjust_DoesNotReenable_ManuallyDisabledNotDepleted(t *testing.T) {
 	email := "man@x"
 	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 {
 		t.Fatalf("BulkAdjust: %v", err)
 	}
@@ -137,7 +137,7 @@ func TestBulkAdjust_StaysDisabled_ExtensionTooSmall(t *testing.T) {
 	email := "sml@x"
 	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)
 	}
 	assertEnableEverywhere(t, svc, inboundSvc, ib.Id, email, false)
@@ -151,7 +151,7 @@ func TestBulkAdjust_ReenablesOverQuota_WhenAddBytesClearsQuota(t *testing.T) {
 	email := "q@x"
 	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 {
 		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)
 
-	res, _, err := svc.BulkAdjust(inboundSvc, []string{email}, 0, -20, "")
+	res, _, err := svc.BulkAdjust(inboundSvc, []string{email}, 0, -20, "", nil, "")
 	if err != nil {
 		t.Fatalf("BulkAdjust: %v", err)
 	}
@@ -202,7 +202,7 @@ func TestBulkAdjust_AppliedFieldReachesTrafficRowDespiteOtherFieldSkip(t *testin
 	}
 	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)
 	}
 	if got := trafficOf(t, email).Total; got != 150 {
@@ -219,7 +219,7 @@ func TestBulkAdjust_OverQuota_DaysOnly_StaysDisabled(t *testing.T) {
 	email := "qd@x"
 	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)
 	}
 	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)
 
-	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)
 	}
 	assertEnableEverywhere(t, svc, inboundSvc, ib.Id, email, true)
@@ -254,7 +254,7 @@ func TestBulkAdjust_FlowOnly_NoEnableChange(t *testing.T) {
 	email := "flow@x"
 	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)
 	}
 	assertEnableEverywhere(t, svc, inboundSvc, ib.Id, email, false)
@@ -271,7 +271,7 @@ func TestBulkAdjust_UnlimitedExpiry_QuotaCleared_Reenables(t *testing.T) {
 	email := "u@x"
 	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 {
 		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)
 	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 {
 		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"`
 	Port           int    `json:"port" example:"443"`
 	Enable         bool   `json:"enable" example:"true"`
+	Network        string `json:"network,omitempty"`
+	Security       string `json:"security,omitempty"`
 	TlsFlowCapable bool   `json:"tlsFlowCapable" example:"true"`
 	SsMethod       string `json:"ssMethod"`
 	WgPublicKey    string `json:"wgPublicKey,omitempty"`
@@ -389,6 +391,7 @@ func (s *InboundService) GetInboundOptions(userId int) ([]InboundOption, error)
 	out := make([]InboundOption, 0, len(rows))
 	for _, r := range rows {
 		wgPublicKey, wgMtu, wgDns := inboundWireguardHints(r.Protocol, r.Settings)
+		netHint, secHint := inboundStreamHints(r.Protocol, r.StreamSettings, r.Settings)
 		shareAddrStrategy := r.ShareAddrStrategy
 		if shareAddrStrategy == "node" {
 			shareAddrStrategy = ""
@@ -400,6 +403,8 @@ func (s *InboundService) GetInboundOptions(userId int) ([]InboundOption, error)
 			Protocol:          r.Protocol,
 			Port:              r.Port,
 			Enable:            r.Enable,
+			Network:           netHint,
+			Security:          secHint,
 			TlsFlowCapable:    !r.DisableFlow && inboundCanEnableTlsFlow(r.Protocol, r.StreamSettings, r.Settings),
 			SsMethod:          inboundShadowsocksMethod(r.Protocol, r.Settings),
 			WgPublicKey:       wgPublicKey,
@@ -417,6 +422,44 @@ func (s *InboundService) GetInboundOptions(userId int) ([]InboundOption, error)
 	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) {
 	if protocol != string(model.WireGuard) || strings.TrimSpace(settings) == "" {
 		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) })
 
-	result, _, err := (&ClientService{}).BulkAdjust(&InboundService{}, []string{client.Email}, 1, 0, "")
+	result, _, err := (&ClientService{}).BulkAdjust(&InboundService{}, []string{client.Email}, 1, 0, "", nil, "")
 	if err != nil {
 		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
 // must go through the real xray handshake probe instead.
 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
 	}
 	if stream, ok := ob["streamSettings"].(map[string]any); ok {

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

@@ -18,6 +18,7 @@ import (
 	"sync"
 	"time"
 
+	"github.com/mhsanaei/3x-ui/v3/internal/amneziawgnet"
 	"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/xray"
@@ -384,6 +385,33 @@ func buildBatchTestConfig(items []*httpBatchItem, allOutbounds []any, ports []in
 			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 {
 		outbound, ok := ob.(map[string]any)
 		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) {
 	cases := []struct{ mode, want string }{
 		{"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
 	}
 
+	// 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
 	// SOCKS5 relay inbound (see injectAmneziawgnetSocks) on 127.0.0.1 at a
 	// 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.
 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) {
 	setupConflictDB(t)
 	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",
 	"subRoutingRules":             "",
 	"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",
 	"subIncyRoutingRules":         "",
 	"subListen":                   "",
@@ -122,6 +145,7 @@ var defaultValueMap = map[string]string{
 	"subClashRules":               "",
 	"subJsonMux":                  "",
 	"subJsonRules":                "",
+	"subJsonRoutingRules":         "",
 	"subJsonFinalMask":            "",
 	"subJsonObservatory":          "",
 	"subThemeDir":                 "",
@@ -820,6 +844,98 @@ func (s *SettingService) GetSubHideSettings() (bool, error) {
 	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) {
 	return s.getBool("subIncyEnableRouting")
 }
@@ -912,6 +1028,10 @@ func (s *SettingService) GetSubJsonRules() (string, error) {
 	return s.getString("subJsonRules")
 }
 
+func (s *SettingService) GetSubJsonRoutingRules() (string, error) {
+	return s.getString("subJsonRoutingRules")
+}
+
 func (s *SettingService) GetSubJsonFinalMask() (string, error) {
 	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.
 	allSetting.SubSupportUrl = common.EnsureURLScheme(allSetting.SubSupportUrl)
 	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{
-		"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 {
 			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()
-			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)
 			}
 			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
 	// the per-inbound lift below).
 	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)
 

Beberapa file tidak ditampilkan karena terlalu banyak file yang berubah dalam diff ini