17 Commits ad32144c42 ... 43bc915397

Autor SHA1 Mensagem Data
  n0ctal 43bc915397 fix(sub): serve a copy-only page when a subscription URL is opened in a browser (#6183) há 4 horas atrás
  n0ctal dafd3c0e64 feat(sub): warn when salamander settings cannot reach the client (#6177) há 4 horas atrás
  Sanaei acbf09e710 fix(frontend): restore responsive table height há 4 horas atrás
  isultanov99 be70535b94 feat(inbounds): improve multi-node online attribution (#6164) há 5 horas atrás
  isultanov99 2d669fa4b7 feat(sub): add template variables to subscription metadata (#6163) há 5 horas atrás
  Lex Rivera 8c8556ab32 feat(frontend): multi-node cloning initial implementation (#6216) há 5 horas atrás
  Sanaei 03950b1295 fix(frontend): disable table virtualization há 5 horas atrás
  Grigoriy d7698ec7aa feat(xray): browse geosite/geoip categories from routing rules (#6165) há 5 horas atrás
  Kobi Hikri 7c8a9a6909 ci: attach provenance and SBOM attestations to the published images (#6130) há 5 horas atrás
  Rouzbeh† 694ad6deae feat(sub): add per-client subscription HWID limits (#5802) há 5 horas atrás
  n0ctal 1793a9b8b4 feat(nodes): opt-in encryption at rest for the outbound node API token (#6186) há 6 horas atrás
  PathGao 8e7fb144ee perf(frontend): replace blank Suspense fallbacks with Spin, switch to matchMedia hook, add virtual table scrolling (#6187) há 6 horas atrás
  Dan Liutko 0f14ce7551 fix(web): fallback to default secret when database setting is empty (#6189) há 6 horas atrás
  n0ctal 7ecd88b9e3 fix(nodes): apply a rotated master mTLS certificate without restarting the panel (#6194) há 6 horas atrás
  n0ctal 1230559e69 feat(api): scoped, optionally expiring API tokens (#6201) há 7 horas atrás
  n0ctal aecbad3ab1 test(tgbot): detect open-coded keypad transitions (#6214) há 7 horas atrás
  Maxim Myalin 2217213e9f feat(sub): expose last subscription fetch time (#6217) há 7 horas atrás
100 ficheiros alterados com 8454 adições e 465 exclusões
  1. 2 0
      .github/workflows/docker.yml
  2. 2 0
      CLAUDE.md
  3. 8 3
      docs/architecture.md
  4. 27 46
      docs/content/docs/en/reference/api/api-tokens.mdx
  5. 56 88
      docs/content/docs/en/reference/api/nodes.mdx
  6. 100 6
      docs/public/openapi.json
  7. 578 8
      frontend/public/openapi.json
  8. 102 0
      frontend/src/api/queries/useGeodata.ts
  9. 7 0
      frontend/src/api/queryKeys.ts
  10. 53 24
      frontend/src/components/form/RemarkTemplateField.tsx
  11. 5 3
      frontend/src/components/form/RemarkVarPicker.tsx
  12. 221 0
      frontend/src/components/geodata/GeoBrowserModal.css
  13. 457 0
      frontend/src/components/geodata/GeoBrowserModal.stories.tsx
  14. 413 0
      frontend/src/components/geodata/GeoBrowserModal.tsx
  15. 247 0
      frontend/src/components/geodata/GeoTokenInput.stories.tsx
  16. 126 0
      frontend/src/components/geodata/GeoTokenInput.tsx
  17. 4 0
      frontend/src/components/geodata/index.ts
  18. 2 1
      frontend/src/components/utility/LazyMount.tsx
  19. 55 0
      frontend/src/generated/examples.ts
  20. 182 1
      frontend/src/generated/schemas.ts
  21. 44 0
      frontend/src/generated/types.ts
  22. 52 0
      frontend/src/generated/zod.ts
  23. 1 0
      frontend/src/hooks/useClients.ts
  24. 18 6
      frontend/src/hooks/useMediaQuery.ts
  25. 16 3
      frontend/src/lib/remark/remarkVariables.ts
  26. 77 0
      frontend/src/lib/xray/geoTokens.ts
  27. 66 0
      frontend/src/lib/xray/inbound-clone.ts
  28. 16 0
      frontend/src/lib/xray/node-protocols.ts
  29. 76 11
      frontend/src/pages/api-docs/endpoints.ts
  30. 11 0
      frontend/src/pages/clients/ClientBulkAddModal.tsx
  31. 117 3
      frontend/src/pages/clients/ClientFormModal.tsx
  32. 4 1
      frontend/src/pages/clients/ClientInfoModal.tsx
  33. 2 1
      frontend/src/pages/clients/ClientsPage.tsx
  34. 136 0
      frontend/src/pages/inbounds/CloneInboundModal.tsx
  35. 42 34
      frontend/src/pages/inbounds/InboundsPage.tsx
  36. 7 12
      frontend/src/pages/inbounds/form/InboundFormModal.tsx
  37. 4 2
      frontend/src/pages/settings/SecurityTab.tsx
  38. 24 7
      frontend/src/pages/settings/SubscriptionGeneralTab.tsx
  39. 2 8
      frontend/src/pages/sub/SubPage.tsx
  40. 4 3
      frontend/src/pages/xray/routing/RuleFormModal.tsx
  41. 12 1
      frontend/src/routes.tsx
  42. 4 0
      frontend/src/schemas/client.ts
  43. 219 0
      frontend/src/test/clone-inbound-modal.test.tsx
  44. 207 0
      frontend/src/test/geo-browser-selection.test.tsx
  45. 215 0
      frontend/src/test/geo-tokens.test.ts
  46. 85 0
      frontend/src/test/inbound-clone.test.ts
  47. 29 0
      frontend/src/test/remark-template-field.test.tsx
  48. 17 0
      frontend/src/utils/index.ts
  49. 21 0
      internal/config/config.go
  50. 115 0
      internal/crypto/nodetoken/keysource.go
  51. 236 0
      internal/crypto/nodetoken/nodetoken.go
  52. 226 0
      internal/crypto/nodetoken/nodetoken_test.go
  53. 31 0
      internal/database/api_token_timestamp_test.go
  54. 62 0
      internal/database/client_hwid_schema_test.go
  55. 31 0
      internal/database/db.go
  56. 1 0
      internal/database/migrate_data.go
  57. 37 0
      internal/database/model/model.go
  58. 188 98
      internal/sub/controller.go
  59. 133 0
      internal/sub/controller_browser_test.go
  60. 134 0
      internal/sub/hwid_controller_test.go
  61. 27 4
      internal/sub/info_endpoint_test.go
  62. 118 0
      internal/sub/placeholders.go
  63. 132 0
      internal/sub/placeholders_test.go
  64. 55 0
      internal/sub/salamander_uri_test.go
  65. 34 0
      internal/sub/service.go
  66. 99 0
      internal/sub/sub_fetch_test.go
  67. 96 1
      internal/web/controller/api.go
  68. 95 7
      internal/web/controller/api_auth_test.go
  69. 21 3
      internal/web/controller/client.go
  70. 278 0
      internal/web/controller/geodata_test.go
  71. 11 0
      internal/web/controller/node.go
  72. 17 5
      internal/web/controller/setting.go
  73. 42 0
      internal/web/controller/setting_test.go
  74. 73 0
      internal/web/controller/xray_setting.go
  75. 20 3
      internal/web/runtime/remote.go
  76. 131 0
      internal/web/runtime/tls_client.go
  77. 233 0
      internal/web/runtime/tls_client_test.go
  78. 162 0
      internal/web/runtime/tls_client_wire_test.go
  79. 1 1
      internal/web/service/api_scale_postgres_test.go
  80. 30 0
      internal/web/service/client.go
  81. 13 3
      internal/web/service/client_bulk.go
  82. 13 3
      internal/web/service/client_crud.go
  83. 1 1
      internal/web/service/client_group_node_sync_test.go
  84. 271 0
      internal/web/service/client_hwid.go
  85. 172 0
      internal/web/service/client_hwid_test.go
  86. 25 15
      internal/web/service/client_paging.go
  87. 9 1
      internal/web/service/client_portable.go
  88. 1 1
      internal/web/service/client_stat_reuse_test.go
  89. 2 2
      internal/web/service/client_traffic.go
  90. 3 3
      internal/web/service/client_update_enable_test.go
  91. 2 2
      internal/web/service/client_update_no_inbound_test.go
  92. 4 4
      internal/web/service/client_update_rename_test.go
  93. 156 0
      internal/web/service/geodata.go
  94. 99 14
      internal/web/service/inbound_node.go
  95. 118 8
      internal/web/service/node.go
  96. 18 0
      internal/web/service/node_mtls.go
  97. 26 0
      internal/web/service/node_mtls_test.go
  98. 181 0
      internal/web/service/node_origin_guid_test.go
  99. 180 0
      internal/web/service/node_token_encryption_test.go
  100. 116 14
      internal/web/service/panel/api_token.go

+ 2 - 0
.github/workflows/docker.yml

@@ -55,6 +55,8 @@ jobs:
         with:
           context: .
           push: true
+          provenance: mode=max
+          sbom: true
           platforms: linux/amd64,linux/arm64/v8,linux/arm/v7,linux/arm/v6,linux/386
           tags: ${{ steps.meta.outputs.tags }}
           labels: ${{ steps.meta.outputs.labels }}

+ 2 - 0
CLAUDE.md

@@ -38,6 +38,8 @@ file locations when it can answer in one hop.
   Inbound, Client, Setting, User are the core), inbound Protocol enum,
   AutoMigrate + hand-written migrations in `db.go`.
 - `internal/xray/` — Xray child-process lifecycle, config generation, gRPC API.
+- `internal/xray/geodata/` — streaming geosite/geoip `.dat` reader (cached
+  category index + paged entries) and `geosite:`/`geoip:`/`ext:` token parsing.
 - `internal/mtproto/` — MTProto inbounds via the bundled `mtg-multi` binary.
 - `internal/sub/` — subscription server (raw / JSON / Clash).
 - `internal/eventbus/` — in-process pub/sub (outbound/node health, xray.crash,

+ 8 - 3
docs/architecture.md

@@ -147,7 +147,9 @@ node heartbeat every 5s, periodic traffic resets (hourly/daily/weekly/monthly).
 │   │   ├── inbound.go          # Inbound JSON shaping
 │   │   ├── client_traffic.go   # ClientTraffic model (persisted as client_traffics)
 │   │   ├── traffic.go          # Traffic type helpers
-│   │   └── log_writer.go       # Pipe Xray stdout/stderr into the panel logger
+│   │   ├── log_writer.go       # Pipe Xray stdout/stderr into the panel logger
+│   │   └── geodata/            # Browse geosite/geoip .dat: streaming protowire reader,
+│   │                           #   cached category index, routing-token parsing (token.go)
 │   │
 │   ├── web/                    # The panel server
 │   │   ├── web.go              # ⭐ Server bootstrap: initRouter (all routes) + startTask (all cron jobs)
@@ -159,7 +161,7 @@ node heartbeat every 5s, periodic traffic resets (hourly/daily/weekly/monthly).
 │   │   │   ├── host.go         #   /panel/api/hosts   (per-inbound subscription host overrides)
 │   │   │   ├── server.go       #   /panel/api/server  (status, xray version, certs, logs, DB import/export)
 │   │   │   ├── setting.go      #   /panel/api/setting (settings + API tokens)
-│   │   │   ├── xray_setting.go #   /panel/api/xray    (raw Xray config editor, WARP/Nord)
+│   │   │   ├── xray_setting.go #   /panel/api/xray    (raw Xray config editor, WARP/Nord, geodata)
 │   │   │   ├── api.go          #   /panel/api gateway (token auth, envelope + CSRF wiring)
 │   │   │   ├── index.go        #   login/logout/csrf/2FA
 │   │   │   ├── spa.go          #   SPA fallback for /panel UI routes
@@ -189,6 +191,7 @@ node heartbeat every 5s, periodic traffic resets (hourly/daily/weekly/monthly).
 │   │   │   ├── traffic_writer.go       # Batched persistence of traffic deltas to the DB
 │   │   │   ├── xray.go                 # ⭐ XrayService: config gen + restart/hot-apply (~1.2k lines)
 │   │   │   ├── xray_setting.go         # Raw Xray config persistence
+│   │   │   ├── geodata.go              # Geo database browsing + routing-token validation
 │   │   │   ├── xray_metrics.go         # Xray observability metrics
 │   │   │   ├── metric_history.go       # Historical system/xray metrics
 │   │   │   ├── reality_scan.go         # REALITY target scanner
@@ -265,7 +268,7 @@ node heartbeat every 5s, periodic traffic resets (hourly/daily/weekly/monthly).
 │       │   └── queries/      #   TanStack Query hooks (useNodesQuery, useStatusQuery, …)
 │       ├── schemas/          # Zod schemas: protocols, forms, api, primitives
 │       ├── generated/        # ⚠️ GENERATED from Go (see §5.5): schemas.ts, types.ts, zod.ts, examples.ts
-│       ├── components/       # Reusable UI (clients/ form/ ui/ viz/ feedback/ utility/)
+│       ├── components/       # Reusable UI (clients/ form/ geodata/ ui/ viz/ feedback/ utility/)
 │       ├── lib/              # Frontend domain logic (xray/ inbounds/ clients/)
 │       ├── hooks/, models/, layouts/, i18n/, utils/, styles/
 │       └── test/             # Vitest + golden fixtures (config-generation snapshot tests)
@@ -484,6 +487,8 @@ for AutoMigrate in `internal/database/db.go`.
 | **API tokens** | `service/panel/api_token.go`, `controller/setting.go` | model `ApiToken` |
 | **Port conflict** on inbound add | `service/port_conflict.go` | `controller/inbound.go` |
 | **Fallbacks** (shared 443, SNI routing) | `service/fallback.go`, `controller/inbound.go` | model `InboundFallback` |
+| **Geo category browser** empty / won't open | `xray/geodata/` (`Store`, `reader.go`), `service/geodata.go` | `controller/xray_setting.go` (`/panel/api/xray/geodata/*`), asset dir = `config.GetBinFolderPath()` |
+| **`geosite:`/`geoip:` token** reported unknown in a routing rule | `xray/geodata/token.go`, `service/geodata.go` (`Validate`) | `frontend/src/lib/xray/geoTokens.ts`, `frontend/src/components/geodata/` |
 | **Telegram bot** commands | `service/tgbot/` | `job/stats_notify_job.go` |
 | **Email notifications** | `service/email/` | `internal/eventbus/` (consumers) |
 | **CPU / memory alerts** not firing | `job/check_cpu_usage.go`, `job/check_memory_usage.go` | `internal/eventbus/`, notifier settings in `service/setting.go` |

+ 27 - 46
docs/content/docs/en/reference/api/api-tokens.mdx

@@ -1,66 +1,47 @@
 ---
 title: API Tokens
-description: >-
-  Manage Bearer tokens used for programmatic auth (bots, central panels acting
-  on this node, CI). Each token has a unique name and an enabled flag — disable
-  to revoke without deleting, delete to revoke permanently. Tokens are stored as
-  SHA-256 hashes and the plaintext is returned only once, in the create response
-  — it cannot be retrieved afterwards, so copy it then. Send one as
-  <code>Authorization: Bearer &lt;token&gt;</code> on any /panel/api/* request —
-  the token is a full-admin credential.
+description: 'Manage Bearer tokens used for programmatic auth (bots, central
+  panels acting on this node, CI). Each token has a unique name and an enabled
+  flag — disable to revoke without deleting, delete to revoke permanently.
+  Tokens are stored as SHA-256 hashes and the plaintext is returned only once,
+  in the create response — it cannot be retrieved afterwards, so copy it then.
+  Send one as <code>Authorization: Bearer &lt;token&gt;</code> on any
+  /panel/api/* request — the token is a full-admin credential.'
 full: true
 _openapi:
   preload:
     - ./public/openapi.json
   toc:
     - depth: 2
-      title: >-
-        List every API token, enabled or not. The token value is never returned
-        — only metadata.
-      url: >-
-        #list-every-api-token-enabled-or-not-the-token-value-is-never-returned--only-metadata
+      title: List every API token, enabled or not. The token value is never returned —
+        only metadata.
+      url: '#list-every-api-token-enabled-or-not-the-token-value-is-never-returned--only-metadata'
     - depth: 2
-      title: >-
-        Mint a new API token. Name must be unique and 1-64 characters; the token
-        string is server-generated and returned only in this response — it is
-        stored hashed and cannot be retrieved later.
-      url: >-
-        #mint-a-new-api-token-name-must-be-unique-and-1-64-characters-the-token-string-is-server-generated-and-returned-only-in-this-response--it-is-stored-hashed-and-cannot-be-retrieved-later
+      title: Mint a scoped API token. The server-generated plaintext is returned only
+        once and stored as a hash.
+      url: '#mint-a-scoped-api-token-the-server-generated-plaintext-is-returned-only-once-and-stored-as-a-hash'
     - depth: 2
-      title: >-
-        Permanently delete a token. Any caller using it stops authenticating
+      title: Permanently delete a token. Any caller using it stops authenticating
         immediately.
-      url: >-
-        #permanently-delete-a-token-any-caller-using-it-stops-authenticating-immediately
+      url: '#permanently-delete-a-token-any-caller-using-it-stops-authenticating-immediately'
     - depth: 2
-      title: >-
-        Toggle a token enabled/disabled without deleting it. Disabled tokens are
+      title: Toggle a token enabled/disabled without deleting it. Disabled tokens are
         rejected by checkAPIAuth on the next request.
-      url: >-
-        #toggle-a-token-enableddisabled-without-deleting-it-disabled-tokens-are-rejected-by-checkapiauth-on-the-next-request
+      url: '#toggle-a-token-enableddisabled-without-deleting-it-disabled-tokens-are-rejected-by-checkapiauth-on-the-next-request'
   structuredData:
     headings:
-      - content: >-
-          List every API token, enabled or not. The token value is never
-          returned — only metadata.
-        id: >-
-          list-every-api-token-enabled-or-not-the-token-value-is-never-returned--only-metadata
-      - content: >-
-          Mint a new API token. Name must be unique and 1-64 characters; the
-          token string is server-generated and returned only in this response —
-          it is stored hashed and cannot be retrieved later.
-        id: >-
-          mint-a-new-api-token-name-must-be-unique-and-1-64-characters-the-token-string-is-server-generated-and-returned-only-in-this-response--it-is-stored-hashed-and-cannot-be-retrieved-later
-      - content: >-
-          Permanently delete a token. Any caller using it stops authenticating
+      - content: List every API token, enabled or not. The token value is never returned
+          — only metadata.
+        id: list-every-api-token-enabled-or-not-the-token-value-is-never-returned--only-metadata
+      - content: Mint a scoped API token. The server-generated plaintext is returned
+          only once and stored as a hash.
+        id: mint-a-scoped-api-token-the-server-generated-plaintext-is-returned-only-once-and-stored-as-a-hash
+      - content: Permanently delete a token. Any caller using it stops authenticating
           immediately.
-        id: >-
-          permanently-delete-a-token-any-caller-using-it-stops-authenticating-immediately
-      - content: >-
-          Toggle a token enabled/disabled without deleting it. Disabled tokens
+        id: permanently-delete-a-token-any-caller-using-it-stops-authenticating-immediately
+      - content: Toggle a token enabled/disabled without deleting it. Disabled tokens
           are rejected by checkAPIAuth on the next request.
-        id: >-
-          toggle-a-token-enableddisabled-without-deleting-it-disabled-tokens-are-rejected-by-checkapiauth-on-the-next-request
+        id: toggle-a-token-enableddisabled-without-deleting-it-disabled-tokens-are-rejected-by-checkapiauth-on-the-next-request
     contents: []
 ---
 

+ 56 - 88
docs/content/docs/en/reference/api/nodes.mdx

@@ -1,51 +1,40 @@
 ---
 title: Nodes
-description: >-
-  Manage remote 3x-ui panels acting as nodes for a central panel. All endpoints
-  under /panel/api/nodes.
+description: Manage remote 3x-ui panels acting as nodes for a central panel. All
+  endpoints under /panel/api/nodes.
 full: true
 _openapi:
   preload:
     - ./public/openapi.json
   toc:
     - depth: 2
-      title: >-
-        List every configured node with its connection details, health, and last
+      title: List every configured node with its connection details, health, and last
         heartbeat patch.
-      url: >-
-        #list-every-configured-node-with-its-connection-details-health-and-last-heartbeat-patch
+      url: '#list-every-configured-node-with-its-connection-details-health-and-last-heartbeat-patch'
     - depth: 2
-      title: >-
-        This panel's node-auth CA certificate (public, PEM) to paste into a
+      title: This panel's node-auth CA certificate (public, PEM) to paste into a
         node's mTLS trust setting. Lazily mints the CA and the master client
         cert on first call. Pair with setting tlsVerifyMode=mtls on the node.
-      url: >-
-        #this-panels-node-auth-ca-certificate-public-pem-to-paste-into-a-nodes-mtls-trust-setting-lazily-mints-the-ca-and-the-master-client-cert-on-first-call-pair-with-setting-tlsverifymodemtls-on-the-node
+      url: '#this-panels-node-auth-ca-certificate-public-pem-to-paste-into-a-nodes-mtls-trust-setting-lazily-mints-the-ca-and-the-master-client-cert-on-first-call-pair-with-setting-tlsverifymodemtls-on-the-node'
     - depth: 2
-      title: >-
-        Set the CA certificate this panel trusts for incoming node-API client
+      title: Set the CA certificate this panel trusts for incoming node-API client
         certificates (this panel acting as a node). Paste the managing panel's
         CA (from nodes/mtls/ca). An empty caCert disables it. A non-empty value
         must be a PEM certificate. Applied on the next panel restart.
-      url: >-
-        #set-the-ca-certificate-this-panel-trusts-for-incoming-node-api-client-certificates-this-panel-acting-as-a-node-paste-the-managing-panels-ca-from-nodesmtlsca-an-empty-cacert-disables-it-a-non-empty-value-must-be-a-pem-certificate-applied-on-the-next-panel-restart
+      url: '#set-the-ca-certificate-this-panel-trusts-for-incoming-node-api-client-certificates-this-panel-acting-as-a-node-paste-the-managing-panels-ca-from-nodesmtlsca-an-empty-cacert-disables-it-a-non-empty-value-must-be-a-pem-certificate-applied-on-the-next-panel-restart'
     - depth: 2
       title: Fetch a single node by ID.
       url: '#fetch-a-single-node-by-id'
     - depth: 2
-      title: >-
-        Fetch a node's own web TLS certificate/key file paths (proxied to the
+      title: Fetch a node's own web TLS certificate/key file paths (proxied to the
         node). Used by the inbound form's "Set Cert from Panel" so a
         node-assigned inbound gets paths that exist on the node, not the central
         panel.
-      url: >-
-        #fetch-a-nodes-own-web-tls-certificatekey-file-paths-proxied-to-the-node-used-by-the-inbound-forms-set-cert-from-panel-so-a-node-assigned-inbound-gets-paths-that-exist-on-the-node-not-the-central-panel
+      url: '#fetch-a-nodes-own-web-tls-certificatekey-file-paths-proxied-to-the-node-used-by-the-inbound-forms-set-cert-from-panel-so-a-node-assigned-inbound-gets-paths-that-exist-on-the-node-not-the-central-panel'
     - depth: 2
-      title: >-
-        Register a new remote node. Provide its URL, apiToken, and optional
+      title: Register a new remote node. Provide its URL, apiToken, and optional
         remark / allowPrivateAddress flag.
-      url: >-
-        #register-a-new-remote-node-provide-its-url-apitoken-and-optional-remark--allowprivateaddress-flag
+      url: '#register-a-new-remote-node-provide-its-url-apitoken-and-optional-remark--allowprivateaddress-flag'
     - depth: 2
       title: Replace a node’s connection details. Same body shape as /add.
       url: '#replace-a-nodes-connection-details-same-body-shape-as-add'
@@ -56,115 +45,94 @@ _openapi:
       title: Pause or resume traffic sync with this node.
       url: '#pause-or-resume-traffic-sync-with-this-node'
     - depth: 2
-      title: >-
-        Probe a node without saving it. Uses the body as connection details and
+      title: Probe a node without saving it. Uses the body as connection details and
         returns the same heartbeat snapshot a registered node would have.
-      url: >-
-        #probe-a-node-without-saving-it-uses-the-body-as-connection-details-and-returns-the-same-heartbeat-snapshot-a-registered-node-would-have
+      url: '#probe-a-node-without-saving-it-uses-the-body-as-connection-details-and-returns-the-same-heartbeat-snapshot-a-registered-node-would-have'
     - depth: 2
-      title: >-
-        Connect to the node over HTTPS without verifying its certificate and
+      title: Connect to the node over HTTPS without verifying its certificate and
         return the leaf certificate's SHA-256 (base64). Used by the Add/Edit
         Node dialog to fetch and pin a self-signed certificate. Uses the same
         body as /test.
-      url: >-
-        #connect-to-the-node-over-https-without-verifying-its-certificate-and-return-the-leaf-certificates-sha-256-base64-used-by-the-addedit-node-dialog-to-fetch-and-pin-a-self-signed-certificate-uses-the-same-body-as-test
+      url: '#connect-to-the-node-over-https-without-verifying-its-certificate-and-return-the-leaf-certificates-sha-256-base64-used-by-the-addedit-node-dialog-to-fetch-and-pin-a-self-signed-certificate-uses-the-same-body-as-test'
     - depth: 2
-      title: >-
-        Use unsaved node connection details to list the remote inbounds
-        available for selective import.
-      url: >-
-        #use-unsaved-node-connection-details-to-list-the-remote-inbounds-available-for-selective-import
+      title: Use unsaved node connection details to list the remote inbounds available
+        for selective import.
+      url: '#use-unsaved-node-connection-details-to-list-the-remote-inbounds-available-for-selective-import'
     - depth: 2
       title: Probe an existing node, updating its cached health state.
       url: '#probe-an-existing-node-updating-its-cached-health-state'
     - depth: 2
-      title: >-
-        Trigger the official panel self-updater on each given node (downloads
+      title: 'Trigger the official panel self-updater on each given node (downloads
         the latest release and restarts). Only enabled, online nodes are
         updated; offline/disabled ones are reported as skipped. Set "dev": true
         to move the nodes to the rolling per-commit dev channel instead of the
-        latest stable release. Returns a per-node result list.
-      url: >-
-        #trigger-the-official-panel-self-updater-on-each-given-node-downloads-the-latest-release-and-restarts-only-enabled-online-nodes-are-updated-offlinedisabled-ones-are-reported-as-skipped-set-dev-true-to-move-the-nodes-to-the-rolling-per-commit-dev-channel-instead-of-the-latest-stable-release-returns-a-per-node-result-list
+        latest stable release. Returns a per-node result list.'
+      url: '#trigger-the-official-panel-self-updater-on-each-given-node-downloads-the-latest-release-and-restarts-only-enabled-online-nodes-are-updated-offlinedisabled-ones-are-reported-as-skipped-set-dev-true-to-move-the-nodes-to-the-rolling-per-commit-dev-channel-instead-of-the-latest-stable-release-returns-a-per-node-result-list'
     - depth: 2
-      title: >-
-        Aggregated metric history for a node — same shape as /server/history,
+      title: Aggregated metric history for a node — same shape as /server/history,
         scoped to one node.
-      url: >-
-        #aggregated-metric-history-for-a-node--same-shape-as-serverhistory-scoped-to-one-node
+      url: '#aggregated-metric-history-for-a-node--same-shape-as-serverhistory-scoped-to-one-node'
+    - depth: 2
+      title: Validate the stored master mTLS client credential and invalidate cached
+        transports. Each transport closes its old idle pool and rebuilds with
+        the rotated certificate before its next request.
+      url: '#validate-the-stored-master-mtls-client-credential-and-invalidate-cached-transports-each-transport-closes-its-old-idle-pool-and-rebuilds-with-the-rotated-certificate-before-its-next-request'
   structuredData:
     headings:
-      - content: >-
-          List every configured node with its connection details, health, and
+      - content: List every configured node with its connection details, health, and
           last heartbeat patch.
-        id: >-
-          list-every-configured-node-with-its-connection-details-health-and-last-heartbeat-patch
-      - content: >-
-          This panel's node-auth CA certificate (public, PEM) to paste into a
+        id: list-every-configured-node-with-its-connection-details-health-and-last-heartbeat-patch
+      - content: This panel's node-auth CA certificate (public, PEM) to paste into a
           node's mTLS trust setting. Lazily mints the CA and the master client
           cert on first call. Pair with setting tlsVerifyMode=mtls on the node.
-        id: >-
-          this-panels-node-auth-ca-certificate-public-pem-to-paste-into-a-nodes-mtls-trust-setting-lazily-mints-the-ca-and-the-master-client-cert-on-first-call-pair-with-setting-tlsverifymodemtls-on-the-node
-      - content: >-
-          Set the CA certificate this panel trusts for incoming node-API client
+        id: this-panels-node-auth-ca-certificate-public-pem-to-paste-into-a-nodes-mtls-trust-setting-lazily-mints-the-ca-and-the-master-client-cert-on-first-call-pair-with-setting-tlsverifymodemtls-on-the-node
+      - content: Set the CA certificate this panel trusts for incoming node-API client
           certificates (this panel acting as a node). Paste the managing panel's
           CA (from nodes/mtls/ca). An empty caCert disables it. A non-empty
           value must be a PEM certificate. Applied on the next panel restart.
-        id: >-
-          set-the-ca-certificate-this-panel-trusts-for-incoming-node-api-client-certificates-this-panel-acting-as-a-node-paste-the-managing-panels-ca-from-nodesmtlsca-an-empty-cacert-disables-it-a-non-empty-value-must-be-a-pem-certificate-applied-on-the-next-panel-restart
+        id: set-the-ca-certificate-this-panel-trusts-for-incoming-node-api-client-certificates-this-panel-acting-as-a-node-paste-the-managing-panels-ca-from-nodesmtlsca-an-empty-cacert-disables-it-a-non-empty-value-must-be-a-pem-certificate-applied-on-the-next-panel-restart
       - content: Fetch a single node by ID.
         id: fetch-a-single-node-by-id
-      - content: >-
-          Fetch a node's own web TLS certificate/key file paths (proxied to the
+      - content: Fetch a node's own web TLS certificate/key file paths (proxied to the
           node). Used by the inbound form's "Set Cert from Panel" so a
           node-assigned inbound gets paths that exist on the node, not the
           central panel.
-        id: >-
-          fetch-a-nodes-own-web-tls-certificatekey-file-paths-proxied-to-the-node-used-by-the-inbound-forms-set-cert-from-panel-so-a-node-assigned-inbound-gets-paths-that-exist-on-the-node-not-the-central-panel
-      - content: >-
-          Register a new remote node. Provide its URL, apiToken, and optional
+        id: fetch-a-nodes-own-web-tls-certificatekey-file-paths-proxied-to-the-node-used-by-the-inbound-forms-set-cert-from-panel-so-a-node-assigned-inbound-gets-paths-that-exist-on-the-node-not-the-central-panel
+      - content: Register a new remote node. Provide its URL, apiToken, and optional
           remark / allowPrivateAddress flag.
-        id: >-
-          register-a-new-remote-node-provide-its-url-apitoken-and-optional-remark--allowprivateaddress-flag
+        id: register-a-new-remote-node-provide-its-url-apitoken-and-optional-remark--allowprivateaddress-flag
       - content: Replace a node’s connection details. Same body shape as /add.
         id: replace-a-nodes-connection-details-same-body-shape-as-add
       - content: Delete a node. Inbounds bound to it are not auto-migrated.
         id: delete-a-node-inbounds-bound-to-it-are-not-auto-migrated
       - content: Pause or resume traffic sync with this node.
         id: pause-or-resume-traffic-sync-with-this-node
-      - content: >-
-          Probe a node without saving it. Uses the body as connection details
-          and returns the same heartbeat snapshot a registered node would have.
-        id: >-
-          probe-a-node-without-saving-it-uses-the-body-as-connection-details-and-returns-the-same-heartbeat-snapshot-a-registered-node-would-have
-      - content: >-
-          Connect to the node over HTTPS without verifying its certificate and
+      - content: Probe a node without saving it. Uses the body as connection details and
+          returns the same heartbeat snapshot a registered node would have.
+        id: probe-a-node-without-saving-it-uses-the-body-as-connection-details-and-returns-the-same-heartbeat-snapshot-a-registered-node-would-have
+      - content: Connect to the node over HTTPS without verifying its certificate and
           return the leaf certificate's SHA-256 (base64). Used by the Add/Edit
           Node dialog to fetch and pin a self-signed certificate. Uses the same
           body as /test.
-        id: >-
-          connect-to-the-node-over-https-without-verifying-its-certificate-and-return-the-leaf-certificates-sha-256-base64-used-by-the-addedit-node-dialog-to-fetch-and-pin-a-self-signed-certificate-uses-the-same-body-as-test
-      - content: >-
-          Use unsaved node connection details to list the remote inbounds
+        id: connect-to-the-node-over-https-without-verifying-its-certificate-and-return-the-leaf-certificates-sha-256-base64-used-by-the-addedit-node-dialog-to-fetch-and-pin-a-self-signed-certificate-uses-the-same-body-as-test
+      - content: Use unsaved node connection details to list the remote inbounds
           available for selective import.
-        id: >-
-          use-unsaved-node-connection-details-to-list-the-remote-inbounds-available-for-selective-import
+        id: use-unsaved-node-connection-details-to-list-the-remote-inbounds-available-for-selective-import
       - content: Probe an existing node, updating its cached health state.
         id: probe-an-existing-node-updating-its-cached-health-state
-      - content: >-
-          Trigger the official panel self-updater on each given node (downloads
+      - content: 'Trigger the official panel self-updater on each given node (downloads
           the latest release and restarts). Only enabled, online nodes are
           updated; offline/disabled ones are reported as skipped. Set "dev":
           true to move the nodes to the rolling per-commit dev channel instead
-          of the latest stable release. Returns a per-node result list.
-        id: >-
-          trigger-the-official-panel-self-updater-on-each-given-node-downloads-the-latest-release-and-restarts-only-enabled-online-nodes-are-updated-offlinedisabled-ones-are-reported-as-skipped-set-dev-true-to-move-the-nodes-to-the-rolling-per-commit-dev-channel-instead-of-the-latest-stable-release-returns-a-per-node-result-list
-      - content: >-
-          Aggregated metric history for a node — same shape as /server/history,
+          of the latest stable release. Returns a per-node result list.'
+        id: trigger-the-official-panel-self-updater-on-each-given-node-downloads-the-latest-release-and-restarts-only-enabled-online-nodes-are-updated-offlinedisabled-ones-are-reported-as-skipped-set-dev-true-to-move-the-nodes-to-the-rolling-per-commit-dev-channel-instead-of-the-latest-stable-release-returns-a-per-node-result-list
+      - content: Aggregated metric history for a node — same shape as /server/history,
           scoped to one node.
-        id: >-
-          aggregated-metric-history-for-a-node--same-shape-as-serverhistory-scoped-to-one-node
+        id: aggregated-metric-history-for-a-node--same-shape-as-serverhistory-scoped-to-one-node
+      - content: Validate the stored master mTLS client credential and invalidate cached
+          transports. Each transport closes its old idle pool and rebuilds with
+          the rotated certificate before its next request.
+        id: validate-the-stored-master-mtls-client-credential-and-invalidate-cached-transports-each-transport-closes-its-old-idle-pool-and-rebuilds-with-the-rotated-certificate-before-its-next-request
     contents: []
 ---
 
@@ -177,7 +145,7 @@ export default function Layout(props) {
   return (
     <>
       {props.children}
-      <Comp document="./public/openapi.json" webhooks={[]} operations={[{"path":"/panel/api/nodes/list","method":"get"},{"path":"/panel/api/nodes/mtls/ca","method":"post"},{"path":"/panel/api/nodes/mtls/trustCA","method":"post"},{"path":"/panel/api/nodes/get/{id}","method":"get"},{"path":"/panel/api/nodes/webCert/{id}","method":"get"},{"path":"/panel/api/nodes/add","method":"post"},{"path":"/panel/api/nodes/update/{id}","method":"post"},{"path":"/panel/api/nodes/del/{id}","method":"post"},{"path":"/panel/api/nodes/setEnable/{id}","method":"post"},{"path":"/panel/api/nodes/test","method":"post"},{"path":"/panel/api/nodes/certFingerprint","method":"post"},{"path":"/panel/api/nodes/inbounds","method":"post"},{"path":"/panel/api/nodes/probe/{id}","method":"post"},{"path":"/panel/api/nodes/updatePanel","method":"post"},{"path":"/panel/api/nodes/history/{id}/{metric}/{bucket}","method":"get"}]} showTitle />
+      <Comp document="./public/openapi.json" webhooks={[]} operations={[{"path":"/panel/api/nodes/list","method":"get"},{"path":"/panel/api/nodes/mtls/ca","method":"post"},{"path":"/panel/api/nodes/mtls/trustCA","method":"post"},{"path":"/panel/api/nodes/get/{id}","method":"get"},{"path":"/panel/api/nodes/webCert/{id}","method":"get"},{"path":"/panel/api/nodes/add","method":"post"},{"path":"/panel/api/nodes/update/{id}","method":"post"},{"path":"/panel/api/nodes/del/{id}","method":"post"},{"path":"/panel/api/nodes/setEnable/{id}","method":"post"},{"path":"/panel/api/nodes/test","method":"post"},{"path":"/panel/api/nodes/certFingerprint","method":"post"},{"path":"/panel/api/nodes/inbounds","method":"post"},{"path":"/panel/api/nodes/probe/{id}","method":"post"},{"path":"/panel/api/nodes/updatePanel","method":"post"},{"path":"/panel/api/nodes/history/{id}/{metric}/{bucket}","method":"get"},{"path":"/panel/api/nodes/mtls/reloadClient","method":"post"}]} showTitle />
     </>
   );
 }

+ 100 - 6
docs/public/openapi.json

@@ -1033,17 +1033,25 @@
       "ApiToken": {
         "properties": {
           "createdAt": {
+            "format": "int64",
             "type": "integer"
           },
           "enabled": {
             "type": "boolean"
           },
+          "expiresAt": {
+            "format": "int64",
+            "type": "integer"
+          },
           "id": {
             "type": "integer"
           },
           "name": {
             "type": "string"
           },
+          "scope": {
+            "type": "string"
+          },
           "token": {
             "description": "SHA-256 hash; the plaintext is shown only once at creation",
             "type": "string"
@@ -1052,8 +1060,10 @@
         "required": [
           "createdAt",
           "enabled",
+          "expiresAt",
           "id",
           "name",
+          "scope",
           "token"
         ],
         "type": "object"
@@ -1062,12 +1072,18 @@
         "properties": {
           "createdAt": {
             "example": 1736000000,
+            "format": "int64",
             "type": "integer"
           },
           "enabled": {
             "example": true,
             "type": "boolean"
           },
+          "expiresAt": {
+            "example": 0,
+            "format": "int64",
+            "type": "integer"
+          },
           "id": {
             "example": 2,
             "type": "integer"
@@ -1076,6 +1092,10 @@
             "example": "central-panel-a",
             "type": "string"
           },
+          "scope": {
+            "example": "admin",
+            "type": "string"
+          },
           "token": {
             "example": "new-token-string",
             "type": "string"
@@ -1084,8 +1104,10 @@
         "required": [
           "createdAt",
           "enabled",
+          "expiresAt",
           "id",
-          "name"
+          "name",
+          "scope"
         ],
         "type": "object"
       },
@@ -8817,7 +8839,7 @@
         "tags": [
           "API Tokens"
         ],
-        "summary": "Mint a new API token. Name must be unique and 1-64 characters; the token string is server-generated and returned only in this response — it is stored hashed and cannot be retrieved later.",
+        "summary": "Mint a scoped API token. The server-generated plaintext is returned only once and stored as a hash.",
         "operationId": "post_panel_api_setting_apiTokens_create",
         "requestBody": {
           "required": true,
@@ -8829,14 +8851,26 @@
                   "name": {
                     "type": "string",
                     "description": "Human-readable label, e.g. \"central-panel-a\"."
+                  },
+                  "scope": {
+                    "type": "string",
+                    "description": "admin (default), monitor, or node-sync."
+                  },
+                  "expiresAt": {
+                    "type": "integer",
+                    "description": "Future Unix milliseconds, or 0 for no expiry."
                   }
                 },
                 "required": [
-                  "name"
+                  "name",
+                  "scope",
+                  "expiresAt"
                 ]
               },
               "example": {
-                "name": "central-panel-a"
+                "name": "central-panel-a",
+                "scope": "node-sync",
+                "expiresAt": 1798761600000
               }
             }
           }
@@ -8865,8 +8899,10 @@
                   "obj": {
                     "createdAt": 1736000000,
                     "enabled": true,
+                    "expiresAt": 0,
                     "id": 2,
                     "name": "central-panel-a",
+                    "scope": "admin",
                     "token": "new-token-string"
                   }
                 }
@@ -8916,6 +8952,28 @@
             }
           }
         ],
+        "requestBody": {
+          "required": true,
+          "content": {
+            "application/json": {
+              "schema": {
+                "type": "object",
+                "properties": {
+                  "expectedScope": {
+                    "type": "string",
+                    "description": "Stored scope expected by the operator."
+                  }
+                },
+                "required": [
+                  "expectedScope"
+                ]
+              },
+              "example": {
+                "expectedScope": "node-sync"
+              }
+            }
+          }
+        },
         "responses": {
           "200": {
             "description": "Successful response",
@@ -8970,14 +9028,20 @@
                   "enabled": {
                     "type": "boolean",
                     "description": "New enabled state."
+                  },
+                  "expectedScope": {
+                    "type": "string",
+                    "description": "Stored scope expected by the operator."
                   }
                 },
                 "required": [
-                  "enabled"
+                  "enabled",
+                  "expectedScope"
                 ]
               },
               "example": {
-                "enabled": false
+                "enabled": false,
+                "expectedScope": "node-sync"
               }
             }
           }
@@ -10106,6 +10170,36 @@
           }
         }
       }
+    },
+    "/panel/api/nodes/mtls/reloadClient": {
+      "post": {
+        "tags": [
+          "Nodes"
+        ],
+        "summary": "Validate the stored master mTLS client credential and invalidate cached transports. Each transport closes its old idle pool and rebuilds with the rotated certificate before its next request.",
+        "operationId": "post_panel_api_nodes_mtls_reloadClient",
+        "responses": {
+          "200": {
+            "description": "Successful response",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "object",
+                  "properties": {
+                    "success": {
+                      "type": "boolean"
+                    },
+                    "msg": {
+                      "type": "string"
+                    },
+                    "obj": {}
+                  }
+                }
+              }
+            }
+          }
+        }
+      }
     }
   }
 }

+ 578 - 8
frontend/public/openapi.json

@@ -963,12 +963,19 @@
           "enabled": {
             "type": "boolean"
           },
+          "expiresAt": {
+            "format": "int64",
+            "type": "integer"
+          },
           "id": {
             "type": "integer"
           },
           "name": {
             "type": "string"
           },
+          "scope": {
+            "type": "string"
+          },
           "token": {
             "description": "SHA-256 hash; the plaintext is shown only once at creation",
             "type": "string"
@@ -977,8 +984,10 @@
         "required": [
           "createdAt",
           "enabled",
+          "expiresAt",
           "id",
           "name",
+          "scope",
           "token"
         ],
         "type": "object"
@@ -994,6 +1003,11 @@
             "example": true,
             "type": "boolean"
           },
+          "expiresAt": {
+            "example": 0,
+            "format": "int64",
+            "type": "integer"
+          },
           "id": {
             "example": 2,
             "type": "integer"
@@ -1002,6 +1016,10 @@
             "example": "central-panel-a",
             "type": "string"
           },
+          "scope": {
+            "example": "admin",
+            "type": "string"
+          },
           "token": {
             "example": "new-token-string",
             "type": "string"
@@ -1010,8 +1028,10 @@
         "required": [
           "createdAt",
           "enabled",
+          "expiresAt",
           "id",
-          "name"
+          "name",
+          "scope"
         ],
         "type": "object"
       },
@@ -1205,6 +1225,9 @@
           "keepAlive": {
             "type": "integer"
           },
+          "limitHwid": {
+            "type": "integer"
+          },
           "limitIp": {
             "type": "integer"
           },
@@ -1262,6 +1285,7 @@
           "group",
           "id",
           "keepAlive",
+          "limitHwid",
           "limitIp",
           "password",
           "preSharedKey",
@@ -1324,6 +1348,11 @@
             "format": "int64",
             "type": "integer"
           },
+          "lastSubFetch": {
+            "example": 1735680000000,
+            "format": "int64",
+            "type": "integer"
+          },
           "reset": {
             "example": 0,
             "type": "integer"
@@ -1355,6 +1384,7 @@
           "id",
           "inboundId",
           "lastOnline",
+          "lastSubFetch",
           "reset",
           "subId",
           "total",
@@ -1378,6 +1408,157 @@
         ],
         "type": "object"
       },
+      "GeoCategory": {
+        "description": "GeoCategory is one code inside a database, such as geosite's \"google\".",
+        "properties": {
+          "attributes": {
+            "example": [
+              "ads",
+              "cn"
+            ],
+            "items": {
+              "type": "string"
+            },
+            "type": "array"
+          },
+          "code": {
+            "example": "google",
+            "type": "string"
+          },
+          "entries": {
+            "example": 1284,
+            "type": "integer"
+          }
+        },
+        "required": [
+          "attributes",
+          "code",
+          "entries"
+        ],
+        "type": "object"
+      },
+      "GeoCategoryPage": {
+        "description": "GeoCategoryPage is one page of categories plus the unpaged total.",
+        "properties": {
+          "items": {
+            "items": {
+              "$ref": "#/components/schemas/GeoCategory"
+            },
+            "type": "array"
+          },
+          "total": {
+            "example": 1043,
+            "type": "integer"
+          }
+        },
+        "required": [
+          "items",
+          "total"
+        ],
+        "type": "object"
+      },
+      "GeoEntry": {
+        "description": "GeoEntry is a single rule inside a category: a domain rule for geosite\ndatabases, a CIDR for geoip ones.",
+        "properties": {
+          "kind": {
+            "example": "domain",
+            "type": "string"
+          },
+          "value": {
+            "example": "google.com",
+            "type": "string"
+          }
+        },
+        "required": [
+          "kind",
+          "value"
+        ],
+        "type": "object"
+      },
+      "GeoEntryPage": {
+        "description": "GeoEntryPage is one page of category entries plus the unpaged total.",
+        "properties": {
+          "items": {
+            "items": {
+              "$ref": "#/components/schemas/GeoEntry"
+            },
+            "type": "array"
+          },
+          "total": {
+            "example": 1284,
+            "type": "integer"
+          }
+        },
+        "required": [
+          "items",
+          "total"
+        ],
+        "type": "object"
+      },
+      "GeoFile": {
+        "description": "GeoFile describes one .dat database found in the asset directory.",
+        "properties": {
+          "categories": {
+            "example": 1043,
+            "type": "integer"
+          },
+          "error": {
+            "type": "string"
+          },
+          "kind": {
+            "example": "site",
+            "type": "string"
+          },
+          "modifiedAt": {
+            "example": 1769558400000,
+            "format": "int64",
+            "type": "integer"
+          },
+          "name": {
+            "example": "geosite.dat",
+            "type": "string"
+          },
+          "size": {
+            "example": 1467392,
+            "format": "int64",
+            "type": "integer"
+          }
+        },
+        "required": [
+          "categories",
+          "kind",
+          "modifiedAt",
+          "name",
+          "size"
+        ],
+        "type": "object"
+      },
+      "GeodataTokenIssue": {
+        "description": "GeodataTokenIssue reports a routing token the running core would reject,\nor would silently match nothing against.",
+        "properties": {
+          "code": {
+            "example": "blabla",
+            "type": "string"
+          },
+          "file": {
+            "example": "geosite.dat",
+            "type": "string"
+          },
+          "reason": {
+            "example": "categoryMissing",
+            "type": "string"
+          },
+          "token": {
+            "example": "geosite:blabla",
+            "type": "string"
+          }
+        },
+        "required": [
+          "reason",
+          "token"
+        ],
+        "type": "object"
+      },
       "HistoryOfSeeders": {
         "description": "HistoryOfSeeders tracks which database seeders have been executed to prevent re-running.",
         "properties": {
@@ -2890,7 +3071,7 @@
     },
     {
       "name": "API Tokens",
-      "description": "Manage Bearer tokens used for programmatic auth (bots, central panels acting on this node, CI). Each token has a unique name and an enabled flag — disable to revoke without deleting, delete to revoke permanently. Tokens are stored as SHA-256 hashes and the plaintext is returned only once, in the create response — it cannot be retrieved afterwards, so copy it then. Send one as <code>Authorization: Bearer &lt;token&gt;</code> on any /panel/api/* request — the token is a full-admin credential."
+      "description": "Manage scoped Bearer tokens for programmatic auth. Tokens grant admin, monitor, or node-sync access, may expire, and are stored as SHA-256 hashes. The plaintext is returned only once at creation."
     },
     {
       "name": "Xray Settings",
@@ -3139,6 +3320,7 @@
                           "id": 14825,
                           "inboundId": 1,
                           "lastOnline": 1735680000000,
+                          "lastSubFetch": 1735680000000,
                           "reset": 0,
                           "subId": "i7tvdpeffi0hvvf1",
                           "total": 10737418240,
@@ -5786,6 +5968,7 @@
                         "totalGB": 53687091200,
                         "expiryTime": 1735689600000,
                         "limitIp": 0,
+                        "limitHwid": 0,
                         "reset": 0,
                         "inboundIds": [
                           3,
@@ -5931,6 +6114,7 @@
                   "expiryTime": 1735689600000,
                   "tgId": 0,
                   "limitIp": 0,
+                  "limitHwid": 0,
                   "enable": true
                 },
                 "inboundIds": [
@@ -5997,6 +6181,7 @@
                 "email": "[email protected]",
                 "totalGB": 107374182400,
                 "expiryTime": 1767225600000,
+                "limitHwid": 2,
                 "tgId": 123456789,
                 "enable": true
               }
@@ -6345,7 +6530,7 @@
         "tags": [
           "Clients"
         ],
-        "summary": "Delete every client that is not attached to any inbound, along with its traffic record, IP log, and external links. Useful for clearing clients left unattached after their inbounds were removed. Returns the deleted count. Cannot be undone.",
+        "summary": "Delete every client that is not attached to any inbound, along with its traffic record, IP log, HWID devices, and external links. Useful for clearing clients left unattached after their inbounds were removed. Returns the deleted count. Cannot be undone.",
         "operationId": "post_panel_api_clients_delOrphans",
         "responses": {
           "200": {
@@ -6409,6 +6594,7 @@
                         "id": "...",
                         "totalGB": 53687091200,
                         "expiryTime": 0,
+                        "limitHwid": 2,
                         "enable": true,
                         "subId": "..."
                       },
@@ -6736,6 +6922,7 @@
                     "email": "[email protected]",
                     "totalGB": 53687091200,
                     "expiryTime": 0,
+                    "limitHwid": 2,
                     "enable": true
                   },
                   "inboundIds": [
@@ -6747,6 +6934,7 @@
                     "email": "[email protected]",
                     "totalGB": 53687091200,
                     "expiryTime": 0,
+                    "limitHwid": 0,
                     "enable": true
                   },
                   "inboundIds": [
@@ -7541,6 +7729,100 @@
         }
       }
     },
+    "/panel/api/clients/hwids/{email}": {
+      "post": {
+        "tags": [
+          "Clients"
+        ],
+        "summary": "List registered HWID devices for a client. Hashes are not exposed.",
+        "operationId": "post_panel_api_clients_hwids_email",
+        "parameters": [
+          {
+            "name": "email",
+            "in": "path",
+            "required": true,
+            "description": "Client email.",
+            "schema": {
+              "type": "string"
+            }
+          }
+        ],
+        "responses": {
+          "200": {
+            "description": "Successful response",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "object",
+                  "properties": {
+                    "success": {
+                      "type": "boolean"
+                    },
+                    "msg": {
+                      "type": "string"
+                    },
+                    "obj": {}
+                  }
+                },
+                "example": {
+                  "success": true,
+                  "obj": [
+                    {
+                      "id": 1,
+                      "firstSeen": 1735000000000,
+                      "lastSeen": 1735100000000,
+                      "userAgent": "Happ/1.0",
+                      "deviceOs": "android",
+                      "osVersion": "15",
+                      "deviceModel": "Pixel 9"
+                    }
+                  ]
+                }
+              }
+            }
+          }
+        }
+      },
+      "delete": {
+        "tags": [
+          "Clients"
+        ],
+        "summary": "Clear all registered HWID devices for a client so new devices can register again.",
+        "operationId": "delete_panel_api_clients_hwids_email",
+        "parameters": [
+          {
+            "name": "email",
+            "in": "path",
+            "required": true,
+            "description": "Client email.",
+            "schema": {
+              "type": "string"
+            }
+          }
+        ],
+        "responses": {
+          "200": {
+            "description": "Successful response",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "object",
+                  "properties": {
+                    "success": {
+                      "type": "boolean"
+                    },
+                    "msg": {
+                      "type": "string"
+                    },
+                    "obj": {}
+                  }
+                }
+              }
+            }
+          }
+        }
+      }
+    },
     "/panel/api/clients/onlines": {
       "post": {
         "tags": [
@@ -7786,6 +8068,7 @@
                     "id": 14825,
                     "inboundId": 1,
                     "lastOnline": 1735680000000,
+                    "lastSubFetch": 1735680000000,
                     "reset": 0,
                     "subId": "i7tvdpeffi0hvvf1",
                     "total": 10737418240,
@@ -8058,6 +8341,36 @@
         }
       }
     },
+    "/panel/api/nodes/mtls/reloadClient": {
+      "post": {
+        "tags": [
+          "Nodes"
+        ],
+        "summary": "Validate the stored master mTLS client credential and invalidate cached transports. Each transport closes its old idle pool and rebuilds with the rotated certificate before its next request.",
+        "operationId": "post_panel_api_nodes_mtls_reloadClient",
+        "responses": {
+          "200": {
+            "description": "Successful response",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "object",
+                  "properties": {
+                    "success": {
+                      "type": "boolean"
+                    },
+                    "msg": {
+                      "type": "string"
+                    },
+                    "obj": {}
+                  }
+                }
+              }
+            }
+          }
+        }
+      }
+    },
     "/panel/api/nodes/get/{id}": {
       "get": {
         "tags": [
@@ -10137,7 +10450,7 @@
         "tags": [
           "API Tokens"
         ],
-        "summary": "Mint a new API token. Name must be unique and 1-64 characters; the token string is server-generated and returned only in this response — it is stored hashed and cannot be retrieved later.",
+        "summary": "Mint a scoped API token. The server-generated plaintext is returned only once and stored as a hash.",
         "operationId": "post_panel_api_setting_apiTokens_create",
         "requestBody": {
           "required": true,
@@ -10149,14 +10462,26 @@
                   "name": {
                     "type": "string",
                     "description": "Human-readable label, e.g. \"central-panel-a\"."
+                  },
+                  "scope": {
+                    "type": "string",
+                    "description": "admin (default), monitor, or node-sync."
+                  },
+                  "expiresAt": {
+                    "type": "integer",
+                    "description": "Future Unix milliseconds, or 0 for no expiry."
                   }
                 },
                 "required": [
-                  "name"
+                  "name",
+                  "scope",
+                  "expiresAt"
                 ]
               },
               "example": {
-                "name": "central-panel-a"
+                "name": "central-panel-a",
+                "scope": "node-sync",
+                "expiresAt": 1798761600000
               }
             }
           }
@@ -10185,8 +10510,10 @@
                   "obj": {
                     "createdAt": 1736000000,
                     "enabled": true,
+                    "expiresAt": 0,
                     "id": 2,
                     "name": "central-panel-a",
+                    "scope": "admin",
                     "token": "new-token-string"
                   }
                 }
@@ -10236,6 +10563,28 @@
             }
           }
         ],
+        "requestBody": {
+          "required": true,
+          "content": {
+            "application/json": {
+              "schema": {
+                "type": "object",
+                "properties": {
+                  "expectedScope": {
+                    "type": "string",
+                    "description": "Stored scope expected by the operator."
+                  }
+                },
+                "required": [
+                  "expectedScope"
+                ]
+              },
+              "example": {
+                "expectedScope": "node-sync"
+              }
+            }
+          }
+        },
         "responses": {
           "200": {
             "description": "Successful response",
@@ -10290,14 +10639,20 @@
                   "enabled": {
                     "type": "boolean",
                     "description": "New enabled state."
+                  },
+                  "expectedScope": {
+                    "type": "string",
+                    "description": "Stored scope expected by the operator."
                   }
                 },
                 "required": [
-                  "enabled"
+                  "enabled",
+                  "expectedScope"
                 ]
               },
               "example": {
-                "enabled": false
+                "enabled": false,
+                "expectedScope": "node-sync"
               }
             }
           }
@@ -10809,6 +11164,221 @@
         }
       }
     },
+    "/panel/api/xray/geodata/files": {
+      "get": {
+        "tags": [
+          "Xray Settings"
+        ],
+        "summary": "List the geo databases (.dat files) in the Xray asset folder, with the layout detected from their contents, size, modification time and category count. A database that fails to parse is still listed, with the reason in \"error\".",
+        "operationId": "get_panel_api_xray_geodata_files",
+        "responses": {
+          "200": {
+            "description": "Successful response",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "object",
+                  "properties": {
+                    "success": {
+                      "type": "boolean"
+                    },
+                    "msg": {
+                      "type": "string"
+                    },
+                    "obj": {}
+                  }
+                }
+              }
+            }
+          }
+        }
+      }
+    },
+    "/panel/api/xray/geodata/categories": {
+      "get": {
+        "tags": [
+          "Xray Settings"
+        ],
+        "summary": "One page of a database's categories, each with its entry count and the attributes its domains carry (e.g. \"ads\", \"cn\").",
+        "operationId": "get_panel_api_xray_geodata_categories",
+        "parameters": [
+          {
+            "name": "file",
+            "in": "query",
+            "required": true,
+            "description": "Database file name inside the asset folder, e.g. geosite.dat (required).",
+            "schema": {
+              "type": "string"
+            }
+          },
+          {
+            "name": "q",
+            "in": "query",
+            "required": false,
+            "description": "Case-insensitive substring filter on the category code.",
+            "schema": {
+              "type": "string"
+            }
+          },
+          {
+            "name": "offset",
+            "in": "query",
+            "required": false,
+            "description": "Rows to skip. Defaults to 0.",
+            "schema": {
+              "type": "integer"
+            }
+          },
+          {
+            "name": "limit",
+            "in": "query",
+            "required": false,
+            "description": "Rows to return, capped at 500. Omit it to return every category — the index is small and the panel filters it client-side.",
+            "schema": {
+              "type": "integer"
+            }
+          }
+        ],
+        "responses": {
+          "200": {
+            "description": "Successful response",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "object",
+                  "properties": {
+                    "success": {
+                      "type": "boolean"
+                    },
+                    "msg": {
+                      "type": "string"
+                    },
+                    "obj": {}
+                  }
+                }
+              }
+            }
+          }
+        }
+      }
+    },
+    "/panel/api/xray/geodata/entries": {
+      "get": {
+        "tags": [
+          "Xray Settings"
+        ],
+        "summary": "One page of the rules inside a category — domain rules typed as domain/full/keyword/regexp for geosite databases, CIDRs for geoip ones.",
+        "operationId": "get_panel_api_xray_geodata_entries",
+        "parameters": [
+          {
+            "name": "file",
+            "in": "query",
+            "required": true,
+            "description": "Database file name inside the asset folder (required).",
+            "schema": {
+              "type": "string"
+            }
+          },
+          {
+            "name": "code",
+            "in": "query",
+            "required": true,
+            "description": "Category code, case-insensitive, e.g. google (required).",
+            "schema": {
+              "type": "string"
+            }
+          },
+          {
+            "name": "q",
+            "in": "query",
+            "required": false,
+            "description": "Case-insensitive substring filter on the rule value.",
+            "schema": {
+              "type": "string"
+            }
+          },
+          {
+            "name": "offset",
+            "in": "query",
+            "required": false,
+            "description": "Rows to skip. Defaults to 0.",
+            "schema": {
+              "type": "integer"
+            }
+          },
+          {
+            "name": "limit",
+            "in": "query",
+            "required": false,
+            "description": "Rows to return, capped at 500. Defaults to the cap.",
+            "schema": {
+              "type": "integer"
+            }
+          }
+        ],
+        "responses": {
+          "200": {
+            "description": "Successful response",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "object",
+                  "properties": {
+                    "success": {
+                      "type": "boolean"
+                    },
+                    "msg": {
+                      "type": "string"
+                    },
+                    "obj": {}
+                  }
+                }
+              }
+            }
+          }
+        }
+      }
+    },
+    "/panel/api/xray/geodata/validate": {
+      "post": {
+        "tags": [
+          "Xray Settings"
+        ],
+        "summary": "Check routing tokens against the databases on disk and return only the ones that do not resolve. Plain domains and CIDRs are ignored. Each issue carries a reason: syntax, fileMissing or categoryMissing.",
+        "operationId": "post_panel_api_xray_geodata_validate",
+        "requestBody": {
+          "required": true,
+          "content": {
+            "application/json": {
+              "schema": {
+                "type": "object"
+              }
+            }
+          }
+        },
+        "responses": {
+          "200": {
+            "description": "Successful response",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "object",
+                  "properties": {
+                    "success": {
+                      "type": "boolean"
+                    },
+                    "msg": {
+                      "type": "string"
+                    },
+                    "obj": {}
+                  }
+                }
+              }
+            }
+          }
+        }
+      }
+    },
     "/panel/api/xray/outbound-subs": {
       "get": {
         "tags": [

+ 102 - 0
frontend/src/api/queries/useGeodata.ts

@@ -0,0 +1,102 @@
+import { keepPreviousData, useMutation, useQuery } from '@tanstack/react-query';
+import { z } from 'zod';
+
+import { keys } from '@/api/queryKeys';
+import { GeoCategoryPageSchema, GeoEntryPageSchema, GeoFileSchema, GeodataTokenIssueSchema } from '@/generated/zod';
+import type { GeoCategoryPage, GeoEntryPage, GeoFile, GeodataTokenIssue } from '@/generated/types';
+import { HttpUtil } from '@/utils';
+import { parseMsg } from '@/utils/zodValidate';
+
+const GeoFileListSchema = z.array(GeoFileSchema);
+const GeodataTokenIssueListSchema = z.array(GeodataTokenIssueSchema);
+
+const EMPTY_CATEGORY_PAGE: GeoCategoryPage = { total: 0, items: [] };
+const EMPTY_ENTRY_PAGE: GeoEntryPage = { total: 0, items: [] };
+
+export type GeoTokenKind = 'ip' | 'domain';
+
+export interface ValidateGeoTokensInput {
+  tokens: string[];
+  kind: GeoTokenKind;
+}
+
+async function fetchGeodataFiles(): Promise<GeoFile[]> {
+  const msg = await HttpUtil.get('/panel/api/xray/geodata/files', undefined, { silent: true });
+  if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch geodata files');
+  const validated = parseMsg(msg, GeoFileListSchema, 'xray/geodata/files');
+  return Array.isArray(validated.obj) ? validated.obj : [];
+}
+
+async function fetchGeodataCategories(file: string, query: string): Promise<GeoCategoryPage> {
+  const msg = await HttpUtil.get('/panel/api/xray/geodata/categories', { file, q: query }, { silent: true });
+  if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch geodata categories');
+  const validated = parseMsg(msg, GeoCategoryPageSchema, 'xray/geodata/categories');
+  return validated.obj ?? EMPTY_CATEGORY_PAGE;
+}
+
+async function fetchGeodataEntries(
+  file: string,
+  code: string,
+  query: string,
+  offset: number,
+  limit: number,
+): Promise<GeoEntryPage> {
+  const msg = await HttpUtil.get(
+    '/panel/api/xray/geodata/entries',
+    { file, code, q: query, offset, limit },
+    { silent: true },
+  );
+  if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch geodata entries');
+  const validated = parseMsg(msg, GeoEntryPageSchema, 'xray/geodata/entries');
+  return validated.obj ?? EMPTY_ENTRY_PAGE;
+}
+
+export function useGeodataFiles(enabled: boolean) {
+  return useQuery({
+    queryKey: keys.xray.geodata.files(),
+    queryFn: fetchGeodataFiles,
+    enabled,
+    staleTime: 5 * 60 * 1000,
+  });
+}
+
+export function useGeodataCategories(file: string | undefined, query: string, enabled: boolean) {
+  return useQuery({
+    queryKey: keys.xray.geodata.categories(file ?? '', query),
+    queryFn: () => fetchGeodataCategories(file ?? '', query),
+    enabled: enabled && !!file,
+    staleTime: 5 * 60 * 1000,
+    placeholderData: keepPreviousData,
+  });
+}
+
+export function useGeodataEntries(
+  file: string | undefined,
+  code: string | undefined,
+  query: string,
+  offset: number,
+  limit: number,
+  enabled: boolean,
+) {
+  return useQuery({
+    queryKey: keys.xray.geodata.entries(file ?? '', code ?? '', query, offset, limit),
+    queryFn: () => fetchGeodataEntries(file ?? '', code ?? '', query, offset, limit),
+    enabled: enabled && !!file && !!code,
+    placeholderData: keepPreviousData,
+  });
+}
+
+export function useValidateGeoTokens() {
+  return useMutation<GeodataTokenIssue[], Error, ValidateGeoTokensInput>({
+    mutationFn: async ({ tokens, kind }) => {
+      const msg = await HttpUtil.post(
+        '/panel/api/xray/geodata/validate',
+        { tokens: tokens.join(','), kind },
+        { silent: true },
+      );
+      if (!msg?.success) throw new Error(msg?.msg || 'Failed to validate geodata tokens');
+      const validated = parseMsg(msg, GeodataTokenIssueListSchema, 'xray/geodata/validate');
+      return Array.isArray(validated.obj) ? validated.obj : [];
+    },
+  });
+}

+ 7 - 0
frontend/src/api/queryKeys.ts

@@ -38,5 +38,12 @@ export const keys = {
     root: () => ['xray'] as const,
     config: () => ['xray', 'config'] as const,
     outboundsTraffic: () => ['xray', 'outboundsTraffic'] as const,
+    geodata: {
+      root: () => ['xray', 'geodata'] as const,
+      files: () => ['xray', 'geodata', 'files'] as const,
+      categories: (file: string, query: string) => ['xray', 'geodata', 'categories', file, query] as const,
+      entries: (file: string, code: string, query: string, offset: number, limit: number) =>
+        ['xray', 'geodata', 'entries', file, code, query, offset, limit] as const,
+    },
   },
 } as const;

+ 53 - 24
frontend/src/components/form/RemarkTemplateField.tsx

@@ -1,10 +1,11 @@
 import { useRef } from 'react';
 import { Button, Input, Popover, Tooltip } from 'antd';
 import type { InputRef } from 'antd';
+import type { TextAreaRef } from 'antd/es/input/TextArea';
 import { CodeOutlined } from '@ant-design/icons';
 import { useTranslation } from 'react-i18next';
 
-import { hasRemarkTokens, previewRemark, wrapToken } from '@/lib/remark/remarkVariables';
+import { hasRemarkTokens, previewRemark, SUBSCRIPTION_METADATA_VARIABLES, wrapToken } from '@/lib/remark/remarkVariables';
 import RemarkVarPicker from './RemarkVarPicker';
 
 interface RemarkTemplateFieldProps {
@@ -13,19 +14,31 @@ interface RemarkTemplateFieldProps {
   onChange?: (value: string) => void;
   maxLength?: number;
   placeholder?: string;
+  multiline?: boolean;
+  rows?: number;
+  metadataOnly?: boolean;
 }
 
 /**
  * RemarkTemplateField is a text input augmented with a {{VAR}} template picker
  * (insert-at-caret) and a live, sample-based preview of the expanded result.
- * Used for the global subscription Remark Template.
+ * Used for subscription text fields that support Remark Template variables.
  */
-export default function RemarkTemplateField({ value = '', onChange, maxLength, placeholder }: RemarkTemplateFieldProps) {
+export default function RemarkTemplateField({ value = '', onChange, maxLength, placeholder, multiline = false, rows, metadataOnly = false }: RemarkTemplateFieldProps) {
   const { t } = useTranslation();
   const inputRef = useRef<InputRef>(null);
+  const textAreaRef = useRef<TextAreaRef>(null);
+  const variables = metadataOnly ? SUBSCRIPTION_METADATA_VARIABLES : undefined;
+
+  function getTextElement() {
+    if (multiline) {
+      return textAreaRef.current?.resizableTextArea?.textArea ?? null;
+    }
+    return inputRef.current?.input ?? null;
+  }
 
   function insertToken(token: string) {
-    const el = inputRef.current?.input;
+    const el = getTextElement();
     const start = el?.selectionStart ?? value.length;
     const end = el?.selectionEnd ?? value.length;
     const insert = wrapToken(token);
@@ -39,31 +52,47 @@ export default function RemarkTemplateField({ value = '', onChange, maxLength, p
     });
   }
 
+  const pickerButton = (
+    <Popover
+      content={<RemarkVarPicker onPick={insertToken} variables={variables} />}
+      trigger="click"
+      placement="bottomRight"
+      title={t('pages.hosts.remarkVars.title')}
+    >
+      <Tooltip title={t('pages.hosts.remarkVars.title')}>
+        <Button type="text" size="small" icon={<CodeOutlined />} aria-label={t('pages.hosts.remarkVars.title')} style={{ marginInlineEnd: -7 }} />
+      </Tooltip>
+    </Popover>
+  );
+
   return (
     <div>
-      <Input
-        ref={inputRef}
-        value={value}
-        maxLength={maxLength}
-        placeholder={placeholder}
-        onChange={(e) => onChange?.(e.target.value)}
-        suffix={
-          <Popover
-            content={<RemarkVarPicker onPick={insertToken} />}
-            trigger="click"
-            placement="bottomRight"
-            title={t('pages.hosts.remarkVars.title')}
-          >
-            <Tooltip title={t('pages.hosts.remarkVars.title')}>
-              <Button type="text" size="small" icon={<CodeOutlined />} aria-label={t('pages.hosts.remarkVars.title')} style={{ marginInlineEnd: -7 }} />
-            </Tooltip>
-          </Popover>
-        }
-      />
+      {multiline ? (
+        <div style={{ display: 'flex', gap: 8, alignItems: 'flex-start' }}>
+          <Input.TextArea
+            ref={textAreaRef}
+            value={value}
+            maxLength={maxLength}
+            placeholder={placeholder}
+            rows={rows}
+            onChange={(e) => onChange?.(e.target.value)}
+          />
+          {pickerButton}
+        </div>
+      ) : (
+        <Input
+          ref={inputRef}
+          value={value}
+          maxLength={maxLength}
+          placeholder={placeholder}
+          onChange={(e) => onChange?.(e.target.value)}
+          suffix={pickerButton}
+        />
+      )}
       {hasRemarkTokens(value) && (
         <div style={{ fontSize: 12, marginTop: 4, opacity: 0.7 }}>
           {t('pages.hosts.remarkVars.preview')}:{' '}
-          <span style={{ fontFamily: 'monospace' }}>{previewRemark(value) || '—'}</span>
+          <span style={{ fontFamily: 'monospace' }}>{previewRemark(value, variables, metadataOnly) || '—'}</span>
         </div>
       )}
     </div>

+ 5 - 3
frontend/src/components/form/RemarkVarPicker.tsx

@@ -2,31 +2,33 @@ import { Tag, Tooltip, Typography } from 'antd';
 import { useTranslation } from 'react-i18next';
 
 import { REMARK_VARIABLES, REMARK_VAR_GROUPS, wrapToken } from '@/lib/remark/remarkVariables';
+import type { RemarkVar } from '@/lib/remark/remarkVariables';
 import { activateOnKey } from '@/utils/a11y';
 
 interface RemarkVarPickerProps {
   /** Called with the bare token (e.g. "EMAIL") when a chip is clicked. */
   onPick: (token: string) => void;
+  variables?: RemarkVar[];
 }
 
 /**
  * RemarkVarPicker is the grouped, tooltipped chip list of {{VAR}} tokens used by
  * the global remark-template field.
  */
-export default function RemarkVarPicker({ onPick }: RemarkVarPickerProps) {
+export default function RemarkVarPicker({ onPick, variables = REMARK_VARIABLES }: RemarkVarPickerProps) {
   const { t } = useTranslation();
   return (
     <div style={{ maxWidth: 460, maxHeight: 'min(70vh, 640px)', overflowY: 'auto' }}>
       <Typography.Paragraph type="secondary" style={{ fontSize: 12, marginBottom: 8 }}>
         {t('pages.hosts.remarkVars.intro')}
       </Typography.Paragraph>
-      {REMARK_VAR_GROUPS.map((group) => (
+      {REMARK_VAR_GROUPS.filter((group) => variables.some((v) => v.group === group)).map((group) => (
         <div key={group} style={{ marginBottom: 8 }}>
           <div style={{ fontSize: 11, fontWeight: 600, textTransform: 'uppercase', opacity: 0.6, marginBottom: 4 }}>
             {t(`pages.hosts.remarkVars.groups.${group}`)}
           </div>
           <div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
-            {REMARK_VARIABLES.filter((v) => v.group === group).map((v) => (
+            {variables.filter((v) => v.group === group).map((v) => (
               <Tooltip key={v.token} title={t(`pages.hosts.remarkVars.desc${v.token}`)}>
                 <Tag
                   role="button"

+ 221 - 0
frontend/src/components/geodata/GeoBrowserModal.css

@@ -0,0 +1,221 @@
+.geo-browser-modal .geo-toolbar {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  flex-wrap: wrap;
+  margin-bottom: 12px;
+}
+
+.geo-browser-modal .geo-toolbar .ant-input-search {
+  flex: 1;
+  min-width: 180px;
+}
+
+.geo-browser-modal .geo-meta {
+  margin-inline-start: auto;
+  font-size: 12px;
+  color: var(--ant-color-text-tertiary);
+  font-variant-numeric: tabular-nums;
+}
+
+.geo-browser-modal .geo-columns {
+  display: grid;
+  grid-template-columns: minmax(240px, 340px) minmax(0, 1fr);
+  gap: 12px;
+  height: 440px;
+}
+
+/* Both panes are the same fixed height, and the pager sits on the pane's floor
+   rather than under the last row, so neither the dialog nor its controls move
+   as the user steps between categories with wildly different rule counts. */
+.geo-browser-modal .geo-panel {
+  height: 100%;
+  min-height: 0;
+  display: flex;
+  flex-direction: column;
+  overflow: hidden;
+  border: 1px solid var(--ant-color-border-secondary);
+  border-radius: 8px;
+}
+
+.geo-browser-modal .geo-panel .ant-table-wrapper,
+.geo-browser-modal .geo-panel .ant-spin-nested-loading,
+.geo-browser-modal .geo-panel .ant-spin-container {
+  display: flex;
+  flex-direction: column;
+  flex: 1;
+  min-height: 0;
+  width: 100%;
+}
+
+.geo-browser-modal .geo-panel .ant-table {
+  flex: 1;
+  min-height: 0;
+}
+
+/* The rules table fills whatever is left between the header and the pager
+   instead of carrying a hardcoded scroll height, so there is no dead strip
+   above the pager and short categories do not scroll needlessly. */
+.geo-browser-modal .geo-preview-body {
+  flex: 1;
+  min-height: 0;
+  overflow-y: auto;
+}
+
+.geo-browser-modal .geo-pager {
+  margin-top: auto;
+  display: flex;
+  align-items: center;
+  justify-content: flex-end;
+  flex-wrap: wrap;
+  padding: 6px 12px;
+  border-top: 1px solid var(--ant-color-border-secondary);
+  font-variant-numeric: tabular-nums;
+}
+
+.geo-browser-modal .geo-pager .ant-pagination-total-text {
+  font-size: 12px;
+  color: var(--ant-color-text-tertiary);
+}
+
+.geo-browser-modal .geo-categories .ant-table-row {
+  cursor: pointer;
+}
+
+.geo-browser-modal .geo-row-active > td {
+  background: var(--ant-color-primary-bg);
+}
+
+.geo-browser-modal .geo-category {
+  display: flex;
+  flex-direction: column;
+  gap: 2px;
+  min-width: 0;
+}
+
+.geo-browser-modal .geo-code,
+.geo-browser-modal .geo-entry-value,
+.geo-browser-modal .geo-preview-title {
+  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+  font-size: 13px;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.geo-browser-modal .geo-attrs .ant-tag {
+  font-size: 10px;
+  line-height: 16px;
+  margin-inline-end: 4px;
+  padding-inline: 4px;
+}
+
+.geo-browser-modal .geo-count {
+  font-variant-numeric: tabular-nums;
+  color: var(--ant-color-text-tertiary);
+  font-size: 12px;
+}
+
+.geo-browser-modal .geo-preview-head {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  padding: 8px 12px;
+  border-bottom: 1px solid var(--ant-color-border-secondary);
+}
+
+/* The title is the part that gives way: without min-width it refuses to
+   shrink, and the filter is pushed onto a second line instead of the long
+   category name being clipped. */
+.geo-browser-modal .geo-preview-title {
+  flex: 0 1 auto;
+  min-width: 0;
+}
+
+.geo-browser-modal .geo-preview-head .ant-typography {
+  flex: none;
+  white-space: nowrap;
+}
+
+.geo-browser-modal .geo-entry-filter {
+  flex: none;
+  width: 200px;
+  margin-inline-start: auto;
+}
+
+@media (max-width: 520px) {
+  .geo-browser-modal .geo-preview-head {
+    flex-wrap: wrap;
+  }
+
+  .geo-browser-modal .geo-entry-filter {
+    width: 100%;
+  }
+}
+
+.geo-browser-modal .geo-kind {
+  font-size: 10px;
+  text-transform: uppercase;
+  letter-spacing: 0.04em;
+}
+
+.geo-browser-modal .geo-kind-full {
+  color: var(--ant-color-success);
+}
+
+.geo-browser-modal .geo-kind-keyword {
+  color: var(--ant-color-warning);
+}
+
+.geo-browser-modal .geo-kind-regexp {
+  color: var(--ant-color-primary);
+}
+
+.geo-browser-modal .geo-placeholder {
+  flex: 1;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  padding: 40px 20px;
+  text-align: center;
+}
+
+.geo-browser-modal .geo-footer {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  flex-wrap: wrap;
+  margin-top: 12px;
+  padding-top: 12px;
+  border-top: 1px solid var(--ant-color-border-secondary);
+}
+
+.geo-browser-modal .geo-chips {
+  flex: 1;
+  max-height: 76px;
+  overflow-y: auto;
+}
+
+.geo-browser-modal .geo-selected-count {
+  font-size: 12px;
+  color: var(--ant-color-text-tertiary);
+  font-variant-numeric: tabular-nums;
+  white-space: nowrap;
+}
+
+@media (max-width: 720px) {
+  .geo-browser-modal .geo-columns {
+    grid-template-columns: minmax(0, 1fr);
+    height: auto;
+  }
+
+  .geo-browser-modal .geo-panel {
+    height: 320px;
+  }
+}
+
+.geo-unknown-hint {
+  display: block;
+  margin-top: 4px;
+  font-size: 12px;
+}

+ 457 - 0
frontend/src/components/geodata/GeoBrowserModal.stories.tsx

@@ -0,0 +1,457 @@
+import { useEffect, useState, type ReactNode } from 'react';
+import type { Decorator, Meta, StoryObj } from '@storybook/react-vite';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { expect, within } from 'storybook/test';
+import { Button, Space, Typography } from 'antd';
+
+import type { GeoCategory, GeoEntry, GeoFile } from '@/generated/types';
+
+import GeoBrowserModal, { type GeoBrowserModalProps } from './GeoBrowserModal';
+
+type GeoResponder = (query: URLSearchParams) => unknown;
+type GeoRoutes = Record<string, GeoResponder>;
+
+const realFetch = window.fetch.bind(window);
+let activeRoutes: GeoRoutes = {};
+
+function requestUrl(input: RequestInfo | URL): URL {
+  if (typeof input === 'string') return new URL(input, window.location.origin);
+  if (input instanceof URL) return input;
+  return new URL(input.url, window.location.origin);
+}
+
+function geoFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
+  const url = requestUrl(input);
+  const responder = activeRoutes[url.pathname];
+  if (!responder) return realFetch(input, init);
+  const body = JSON.stringify({ success: true, msg: '', obj: responder(url.searchParams) });
+  return Promise.resolve(
+    new Response(body, { status: 200, headers: { 'content-type': 'application/json' } }),
+  );
+}
+
+function activate(routes: GeoRoutes): void {
+  activeRoutes = routes;
+  window.fetch = geoFetch;
+}
+
+function deactivate(routes: GeoRoutes): void {
+  if (activeRoutes === routes) activeRoutes = {};
+}
+
+function GeoApi({ routes, children }: { routes: GeoRoutes; children: ReactNode }) {
+  const [client] = useState(() => {
+    activate(routes);
+    return new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } });
+  });
+  useEffect(() => {
+    activate(routes);
+    return () => deactivate(routes);
+  }, [routes]);
+  return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
+}
+
+const domain = (value: string): GeoEntry => ({ kind: 'domain', value });
+const full = (value: string): GeoEntry => ({ kind: 'full', value });
+const keyword = (value: string): GeoEntry => ({ kind: 'keyword', value });
+const regexp = (value: string): GeoEntry => ({ kind: 'regexp', value });
+const cidr = (value: string): GeoEntry => ({ kind: 'cidr', value });
+
+const cross = (names: string[], suffixes: string[]): GeoEntry[] =>
+  names.flatMap((name) => suffixes.map((suffix) => domain(`${name}.${suffix}`)));
+
+const CC_TLDS = [
+  'ae', 'al', 'am', 'at', 'az', 'ba', 'be', 'bg', 'bi', 'bj', 'ca', 'cat', 'cd', 'cf', 'cg', 'ch',
+  'ci', 'cl', 'cm', 'co.id', 'co.il', 'co.in', 'co.jp', 'co.ke', 'co.kr', 'co.ma', 'co.nz', 'co.th',
+  'co.uk', 'co.uz', 'co.ve', 'co.za', 'com.ar', 'com.au', 'com.bd', 'com.br', 'com.co', 'com.cu',
+  'com.eg', 'com.gt', 'com.hk', 'com.mx', 'com.my', 'com.ng', 'com.pe', 'com.ph', 'com.pk',
+  'com.sa', 'com.sg', 'com.tr', 'com.tw', 'com.ua', 'com.uy', 'com.vn', 'cz', 'de', 'dj', 'dk',
+  'dz', 'ee', 'es', 'fi', 'fr', 'ga', 'ge', 'gl', 'gm', 'gr', 'hn', 'hr', 'ht', 'hu', 'ie', 'iq',
+  'is', 'it', 'je', 'jo', 'kg', 'kz', 'la', 'li', 'lk', 'lt', 'lu', 'lv', 'ly', 'md', 'me', 'mg',
+  'mk', 'ml', 'mn', 'mu', 'mv', 'mw', 'ne', 'nl', 'no', 'nu', 'pl', 'pt', 'ro', 'rs', 'ru', 'rw',
+  'se', 'sh', 'si', 'sk', 'sm', 'sn', 'so', 'sr', 'st', 'td', 'tg', 'tk', 'tl', 'tm', 'tn', 'to',
+  'tt', 'vg', 'vu', 'ws',
+];
+
+const AD_HOSTS = [
+  'adform', 'adnxs', 'adroll', 'adsrvr', 'amplitude', 'appsflyer', 'bluekai', 'branch',
+  'casalemedia', 'criteo', 'flurry', 'moatads', 'mopub', 'openx', 'outbrain', 'pubmatic',
+  'quantserve', 'rubiconproject', 'scorecardresearch', 'sharethrough', 'smartadserver', 'taboola',
+  'teads', 'yieldmo', 'zemanta',
+];
+
+const CN_BRANDS = [
+  '58', 'alibaba', 'alipay', 'aliyun', 'baidu', 'bilibili', 'cnblogs', 'csdn', 'ctrip', 'douban',
+  'gitee', 'huawei', 'iqiyi', 'jd', 'kuaishou', 'meituan', 'netease', 'pinduoduo', 'qq', 'sina',
+  'sohu', 'taobao', 'tencent', 'tmall', 'toutiao', 'weibo', 'xiaomi', 'youku', 'zhihu',
+];
+
+const SITE_ENTRIES: Record<string, GeoEntry[]> = {
+  amazon: [
+    domain('amazon.com'), domain('amazonaws.com'), domain('media-amazon.com'),
+    domain('ssl-images-amazon.com'), domain('primevideo.com'), domain('awsstatic.com'),
+    domain('cloudfront.net'), full('www.amazon.co.jp'),
+  ],
+  apple: [
+    domain('apple.com'), domain('icloud.com'), domain('cdn-apple.com'), domain('mzstatic.com'),
+    domain('apple-cloudkit.com'), domain('itunes.com'), domain('me.com'), domain('appstore.com'),
+  ],
+  'category-ads': [
+    domain('adcolony.com'), domain('applovin.com'), domain('chartboost.com'),
+    domain('inmobi.com'), domain('unityads.unity3d.com'), keyword('banner-ad'),
+  ],
+  'category-ads-all': [
+    domain('doubleclick.net'), domain('googleadservices.com'), domain('googlesyndication.com'),
+    domain('adservice.google.com'), full('ads.yahoo.com'), keyword('adservice'),
+    keyword('advertising'), regexp('^ad[0-9]{1,3}\\.'), ...cross(AD_HOSTS, ['com', 'net', 'io', 'ru']),
+  ],
+  cloudflare: [
+    domain('cloudflare.com'), domain('cloudflare-dns.com'), domain('cloudflareinsights.com'),
+    domain('workers.dev'), domain('pages.dev'), domain('cf-ipfs.com'),
+  ],
+  cn: [full('www.gov.cn'), keyword('chinanet'), ...cross(CN_BRANDS, ['com', 'cn', 'com.cn'])],
+  discord: [
+    domain('discord.com'), domain('discord.gg'), domain('discordapp.com'),
+    domain('discordapp.net'), domain('discord.media'),
+  ],
+  facebook: [
+    domain('facebook.com'), domain('fbcdn.net'), domain('fb.com'), domain('messenger.com'),
+    domain('fbsbx.com'), domain('facebook.net'), full('m.facebook.com'),
+  ],
+  'geolocation-!cn': [
+    keyword('proxy'), regexp('.*\\.onion$'), domain('wikipedia.org'), domain('bbc.com'),
+    domain('nytimes.com'), domain('reuters.com'), domain('medium.com'), domain('reddit.com'),
+  ],
+  'geolocation-cn': [
+    domain('gov.cn'), domain('edu.cn'), domain('org.cn'), domain('net.cn'),
+    ...cross(CN_BRANDS.slice(0, 18), ['cn']),
+  ],
+  github: [
+    domain('github.com'), domain('githubusercontent.com'), domain('githubassets.com'),
+    domain('github.io'), domain('ghcr.io'), domain('git.io'),
+  ],
+  google: [
+    domain('google.com'), domain('googleapis.com'), domain('gstatic.com'),
+    domain('googleusercontent.com'), domain('google-analytics.com'), domain('googletagmanager.com'),
+    domain('ggpht.com'), domain('withgoogle.com'), domain('android.com'), domain('chromium.org'),
+    domain('abc.xyz'), full('dl.google.com'), ...CC_TLDS.map((tld) => domain(`google.${tld}`)),
+  ],
+  instagram: [domain('instagram.com'), domain('cdninstagram.com'), domain('ig.me')],
+  microsoft: [
+    domain('microsoft.com'), domain('live.com'), domain('office.com'), domain('office365.com'),
+    domain('windows.net'), domain('windowsupdate.com'), domain('msn.com'), domain('azure.com'),
+    domain('sharepoint.com'), domain('skype.com'), domain('bing.com'),
+  ],
+  netflix: [
+    domain('netflix.com'), domain('netflix.net'), domain('nflximg.com'), domain('nflximg.net'),
+    domain('nflxvideo.net'), domain('nflxso.net'), domain('nflxext.com'), full('fast.com'),
+  ],
+  openai: [
+    domain('openai.com'), domain('chatgpt.com'), domain('oaistatic.com'),
+    domain('oaiusercontent.com'), domain('sora.com'),
+  ],
+  spotify: [
+    domain('spotify.com'), domain('scdn.co'), domain('spotifycdn.com'), domain('spoti.fi'),
+    domain('spotifycdn.net'),
+  ],
+  steam: [
+    domain('steampowered.com'), domain('steamcommunity.com'), domain('steamstatic.com'),
+    domain('steamcontent.com'), domain('valvesoftware.com'),
+  ],
+  telegram: [
+    domain('telegram.org'), domain('telegram.me'), domain('t.me'), domain('telesco.pe'),
+    domain('tdesktop.com'), domain('telegra.ph'), domain('cdn-telegram.org'),
+    full('comments.app'), keyword('telegram'),
+  ],
+  tiktok: [
+    domain('tiktok.com'), domain('tiktokcdn.com'), domain('tiktokv.com'),
+    domain('byteoversea.com'), domain('ibytedtos.com'), domain('musical.ly'),
+  ],
+  twitch: [domain('twitch.tv'), domain('ttvnw.net'), domain('jtvnw.net'), domain('twitchcdn.net')],
+  twitter: [
+    domain('twitter.com'), domain('x.com'), domain('t.co'), domain('twimg.com'),
+    domain('periscope.tv'),
+  ],
+  whatsapp: [domain('whatsapp.com'), domain('whatsapp.net'), domain('wa.me')],
+  youtube: [
+    domain('youtube.com'), domain('youtu.be'), domain('ytimg.com'), domain('googlevideo.com'),
+    domain('youtube-nocookie.com'), domain('yt.be'),
+  ],
+};
+
+const SITE_ATTRIBUTES: Record<string, string[]> = {
+  amazon: ['ads'],
+  apple: ['cn'],
+  facebook: ['ads'],
+  google: ['ads', 'cn'],
+  instagram: ['ads'],
+  microsoft: ['cn'],
+  tiktok: ['ads', 'cn'],
+  twitter: ['ads'],
+  youtube: ['ads'],
+};
+
+const CN_BLOCKS = [
+  '1.0.1.0/24', '1.0.2.0/23', '1.0.8.0/21', '14.0.12.0/22', '27.0.128.0/21', '36.0.0.0/22',
+  '39.0.0.0/24', '42.0.0.0/22', '58.14.0.0/15', '59.32.0.0/11', '61.128.0.0/10', '101.16.0.0/12',
+  '103.1.8.0/22', '106.0.0.0/10', '110.6.0.0/15', '111.0.0.0/10', '112.0.0.0/10', '113.0.0.0/9',
+  '114.28.0.0/16', '116.0.0.0/9', '117.8.0.0/13', '118.24.0.0/15', '119.0.0.0/9', '120.0.0.0/10',
+  '121.0.0.0/8', '124.0.0.0/8', '125.32.0.0/11', '139.196.0.0/14', '140.75.0.0/16', '175.0.0.0/12',
+  '180.76.0.0/16', '182.16.0.0/12', '183.0.0.0/10', '202.0.0.0/12', '203.0.0.0/12', '210.0.0.0/12',
+  '211.64.0.0/11', '218.0.0.0/9', '219.72.0.0/14', '220.112.0.0/12', '221.0.0.0/9', '222.16.0.0/12',
+  '2001:250::/35', '2400:3200::/32', '2408:8000::/20',
+];
+
+const CN_EXTRA_BLOCKS = Array.from({ length: 96 }, (_, index) =>
+  `${39 + Math.floor(index / 16)}.${(index % 16) * 16}.0.0/12`,
+);
+
+const IP_ENTRIES: Record<string, GeoEntry[]> = {
+  cloudflare: [
+    '103.21.244.0/22', '103.22.200.0/22', '103.31.4.0/22', '104.16.0.0/13', '104.24.0.0/14',
+    '108.162.192.0/18', '131.0.72.0/22', '141.101.64.0/18', '162.158.0.0/15', '172.64.0.0/13',
+    '173.245.48.0/20', '188.114.96.0/20', '190.93.240.0/20', '197.234.240.0/22', '198.41.128.0/17',
+    '2400:cb00::/32', '2606:4700::/32',
+  ].map(cidr),
+  cn: [...CN_BLOCKS, ...CN_EXTRA_BLOCKS].map(cidr),
+  facebook: [
+    '31.13.24.0/21', '31.13.64.0/18', '66.220.144.0/20', '69.63.176.0/20', '69.171.224.0/19',
+    '157.240.0.0/16', '179.60.192.0/22', '185.60.216.0/22', '2a03:2880::/32',
+  ].map(cidr),
+  google: [
+    '8.8.4.0/24', '8.8.8.0/24', '34.64.0.0/10', '35.184.0.0/13', '64.233.160.0/19', '66.102.0.0/20',
+    '72.14.192.0/18', '74.125.0.0/16', '108.177.8.0/21', '142.250.0.0/15', '172.217.0.0/16',
+    '216.58.192.0/19', '2404:6800::/32', '2607:f8b0::/32',
+  ].map(cidr),
+  ir: [
+    '2.144.0.0/14', '5.22.0.0/17', '31.2.128.0/17', '37.32.0.0/19', '46.32.0.0/19', '78.38.0.0/15',
+    '80.191.0.0/16', '85.15.0.0/18', '91.98.0.0/15', '178.22.72.0/21', '185.8.172.0/22',
+    '188.34.0.0/17', '217.218.0.0/15',
+  ].map(cidr),
+  netflix: [
+    '23.246.0.0/18', '37.77.184.0/21', '45.57.0.0/17', '64.120.128.0/17', '66.197.128.0/17',
+    '108.175.32.0/20', '185.2.220.0/22', '192.173.64.0/18', '198.38.96.0/19', '198.45.48.0/20',
+  ].map(cidr),
+  private: [
+    '0.0.0.0/8', '10.0.0.0/8', '100.64.0.0/10', '127.0.0.0/8', '169.254.0.0/16', '172.16.0.0/12',
+    '192.0.0.0/24', '192.0.2.0/24', '192.168.0.0/16', '198.18.0.0/15', '198.51.100.0/24',
+    '203.0.113.0/24', '224.0.0.0/4', '240.0.0.0/4', '255.255.255.255/32', '::1/128', 'fc00::/7',
+    'fe80::/10',
+  ].map(cidr),
+  ru: [
+    '2.60.0.0/14', '5.8.0.0/19', '31.6.0.0/17', '37.9.0.0/19', '46.16.0.0/21', '62.76.0.0/18',
+    '77.37.128.0/17', '78.24.216.0/21', '79.104.0.0/15', '80.64.128.0/19', '81.16.96.0/19',
+    '82.140.128.0/18', '85.113.0.0/16', '87.226.0.0/16', '91.77.0.0/16', '93.157.0.0/17',
+    '94.19.0.0/16', '95.24.0.0/13', '178.176.0.0/13', '188.128.0.0/13', '213.87.0.0/16',
+    '217.66.152.0/21', '2a00:1148::/32',
+  ].map(cidr),
+  telegram: [
+    '91.108.4.0/22', '91.108.8.0/22', '91.108.12.0/22', '91.108.16.0/22', '91.108.20.0/22',
+    '91.108.56.0/22', '149.154.160.0/20', '2001:67c:4e8::/48', '2001:b28:f23d::/48',
+    '2001:b28:f23f::/48',
+  ].map(cidr),
+  us: [
+    '3.0.0.0/9', '12.0.0.0/8', '23.192.0.0/11', '34.192.0.0/10', '50.16.0.0/14', '52.0.0.0/10',
+    '63.64.0.0/11', '65.0.0.0/10', '68.32.0.0/11', '71.0.0.0/11', '96.0.0.0/9', '128.0.0.0/10',
+    '199.0.0.0/12', '208.64.0.0/12', '2600:1f00::/24',
+  ].map(cidr),
+};
+
+function categoriesOf(
+  entries: Record<string, GeoEntry[]>,
+  attributes: Record<string, string[]> = {},
+): GeoCategory[] {
+  return Object.keys(entries)
+    .sort()
+    .map((code) => ({ code, entries: entries[code].length, attributes: attributes[code] ?? [] }));
+}
+
+const SITE_CATEGORIES = categoriesOf(SITE_ENTRIES, SITE_ATTRIBUTES);
+const IP_CATEGORIES = categoriesOf(IP_ENTRIES);
+
+const UPDATED_AT = Date.UTC(2026, 6, 24, 3, 12);
+
+const GEOSITE_FILE: GeoFile = {
+  name: 'geosite.dat',
+  kind: 'site',
+  size: 4_812_544,
+  modifiedAt: UPDATED_AT,
+  categories: SITE_CATEGORIES.length,
+};
+
+const GEOIP_FILE: GeoFile = {
+  name: 'geoip.dat',
+  kind: 'ip',
+  size: 8_694_272,
+  modifiedAt: UPDATED_AT,
+  categories: IP_CATEGORIES.length,
+};
+
+const DAMAGED_FILE: GeoFile = {
+  name: 'geosite-custom.dat',
+  kind: 'site',
+  size: 262_144,
+  modifiedAt: Date.UTC(2026, 5, 2, 19, 45),
+  categories: 0,
+  error: 'proto: cannot parse invalid wire-format data',
+};
+
+const OVERSIZED_FILE: GeoFile = {
+  name: 'geoip-full.dat',
+  kind: 'ip',
+  size: 96_468_992,
+  modifiedAt: Date.UTC(2026, 6, 20, 8, 5),
+  categories: 0,
+  error: 'geodata file is too large to browse',
+};
+
+const DATASETS: Record<string, { categories: GeoCategory[]; entries: Record<string, GeoEntry[]> }> = {
+  'geosite.dat': { categories: SITE_CATEGORIES, entries: SITE_ENTRIES },
+  'geoip.dat': { categories: IP_CATEGORIES, entries: IP_ENTRIES },
+};
+
+function routesFor(files: GeoFile[]): GeoRoutes {
+  return {
+    '/panel/api/xray/geodata/files': () => files,
+    '/panel/api/xray/geodata/categories': (query) => {
+      const dataset = DATASETS[query.get('file') ?? ''];
+      const needle = (query.get('q') ?? '').trim().toLowerCase();
+      const items = (dataset?.categories ?? []).filter((category) => category.code.includes(needle));
+      return { total: items.length, items };
+    },
+    '/panel/api/xray/geodata/entries': (query) => {
+      const dataset = DATASETS[query.get('file') ?? ''];
+      const needle = (query.get('q') ?? '').trim().toLowerCase();
+      const matched = (dataset?.entries[query.get('code') ?? ''] ?? []).filter((entry) =>
+        entry.value.toLowerCase().includes(needle),
+      );
+      const offset = Number(query.get('offset') ?? 0);
+      const limit = Number(query.get('limit') ?? 100);
+      return { total: matched.length, items: matched.slice(offset, offset + limit) };
+    },
+  };
+}
+
+function withFiles(files: GeoFile[]): Decorator {
+  const routes = routesFor(files);
+  return function GeodataBackend(Story) {
+    return (
+      <GeoApi routes={routes}>
+        <Story />
+      </GeoApi>
+    );
+  };
+}
+
+const withDatabases = withFiles([GEOSITE_FILE, GEOIP_FILE]);
+
+function BrowserDemo(props: GeoBrowserModalProps) {
+  const [open, setOpen] = useState(props.open);
+  const [value, setValue] = useState(props.value);
+  useEffect(() => setOpen(props.open), [props.open]);
+  useEffect(() => setValue(props.value), [props.value]);
+  return (
+    <Space direction="vertical" size={12}>
+      <Space size={8}>
+        <Button onClick={() => setOpen(true)}>Open geo browser</Button>
+        <Typography.Text code>{value || 'no rule yet'}</Typography.Text>
+      </Space>
+      <GeoBrowserModal
+        {...props}
+        open={open}
+        value={value}
+        onApply={(next) => {
+          setValue(next);
+          setOpen(false);
+        }}
+        onClose={() => setOpen(false)}
+      />
+    </Space>
+  );
+}
+
+const meta = {
+  title: 'Geodata/GeoBrowserModal',
+  component: GeoBrowserModal,
+  tags: ['autodocs'],
+  parameters: {
+    layout: 'padded',
+    a11y: {
+      config: {
+        rules: [{ id: 'color-contrast', enabled: false }],
+      },
+    },
+    docs: {
+      description: {
+        component:
+          'Browser for the geosite/geoip `.dat` databases Xray resolves `geosite:` and `geoip:` routing tokens against: pick a database, search its categories, tick the ones a rule needs, and preview the domains or CIDRs inside the highlighted category. Applying merges the ticked categories back into the rule string, keeping hand-typed domains untouched. The stories serve `/panel/api/xray/geodata/*` from an in-memory fixture, so search, paging and selection all work without a panel backend.',
+      },
+    },
+  },
+  args: {
+    open: true,
+    kind: 'site',
+    value: '',
+    onApply: () => undefined,
+    onClose: () => undefined,
+  },
+  argTypes: {
+    open: { description: 'Whether the modal is visible.' },
+    kind: {
+      description: 'Which database layout the rule targets: `site` for domain rules, `ip` for CIDR rules. Decides the preselected database and the token prefix.',
+      control: 'inline-radio',
+      options: ['site', 'ip'],
+    },
+    value: {
+      description: 'Current rule string, comma separated. Tokens that match a category in the opened database come back preselected.',
+    },
+    onApply: { description: 'Called with the merged rule string when Apply is pressed.' },
+    onClose: { description: 'Called when the modal is dismissed.' },
+  },
+  render: (args) => <BrowserDemo {...args} />,
+} satisfies Meta<typeof GeoBrowserModal>;
+
+export default meta;
+
+type Story = StoryObj<typeof meta>;
+
+export const SiteDatabase: Story = {
+  decorators: [withDatabases],
+  args: { kind: 'site', value: 'geosite:google, geosite:telegram, ads.example.com' },
+};
+
+export const CategoryPreview: Story = {
+  decorators: [withDatabases],
+  args: { kind: 'site', value: 'geosite:google' },
+  parameters: {
+    a11y: {
+      config: {
+        rules: [
+          { id: 'color-contrast', enabled: false },
+          { id: 'scrollable-region-focusable', enabled: false },
+        ],
+      },
+    },
+  },
+  play: async ({ canvasElement, userEvent }) => {
+    const body = within(canvasElement.ownerDocument.body);
+    await userEvent.type(await body.findByPlaceholderText('Search category'), 'telegram');
+    await userEvent.click(await body.findByText('telegram'));
+    await expect(await body.findByText('t.me')).toBeVisible();
+  },
+};
+
+export const IpDatabase: Story = {
+  decorators: [withDatabases],
+  args: { kind: 'ip', value: 'geoip:private, 10.0.0.0/8' },
+};
+
+export const NoDatabases: Story = {
+  decorators: [withFiles([])],
+  args: { kind: 'site', value: 'geosite:google' },
+};
+
+export const DamagedDatabase: Story = {
+  decorators: [withFiles([GEOSITE_FILE, DAMAGED_FILE, OVERSIZED_FILE])],
+  args: { kind: 'site', value: '' },
+};

+ 413 - 0
frontend/src/components/geodata/GeoBrowserModal.tsx

@@ -0,0 +1,413 @@
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { Alert, Button, Empty, Input, Modal, Pagination, Select, Space, Table, Tag, Tooltip, Typography } from 'antd';
+import type { ColumnsType } from 'antd/es/table';
+
+import { useGeodataCategories, useGeodataEntries, useGeodataFiles } from '@/api/queries/useGeodata';
+import { canonicalToken, mergeSelection, selectionFromValue, tokenFor } from '@/lib/xray/geoTokens';
+import { SizeFormatter } from '@/utils';
+import type { GeoCategory, GeoEntry, GeoFile, GeoKind } from '@/generated/types';
+
+import './GeoBrowserModal.css';
+
+const ENTRY_PAGE_SIZE = 100;
+const CATEGORY_SCROLL_HEIGHT = 438;
+const ENTRY_FILTER_DELAY = 500;
+
+export interface GeoBrowserModalProps {
+  open: boolean;
+  kind: GeoKind;
+  value: string;
+  onApply: (value: string) => void;
+  onClose: () => void;
+}
+
+// A geosite category inside an ip rule (or the reverse) is a config Xray will
+// reject, so a field only ever offers databases of its own kind.
+function databasesFor(files: GeoFile[], kind: GeoKind): GeoFile[] {
+  return files.filter((file) => file.kind === kind || (file.error && namePrefersKind(file.name, kind)));
+}
+
+function namePrefersKind(name: string, kind: GeoKind): boolean {
+  return name.toLowerCase().includes('ip') === (kind === 'ip');
+}
+
+function preferredFile(files: GeoFile[], kind: GeoKind): string | undefined {
+  const usable = databasesFor(files, kind).filter((file) => !file.error);
+  const preferredName = kind === 'ip' ? 'geoip.dat' : 'geosite.dat';
+  return usable.find((file) => file.name === preferredName)?.name ?? usable[0]?.name;
+}
+
+export default function GeoBrowserModal({ open, kind, value, onApply, onClose }: GeoBrowserModalProps) {
+  const { t } = useTranslation();
+  const [file, setFile] = useState<string | undefined>(undefined);
+  const [categoryQuery, setCategoryQuery] = useState('');
+  const [activeCode, setActiveCode] = useState<string | undefined>(undefined);
+  const [entryQuery, setEntryQuery] = useState('');
+  const [entryFilter, setEntryFilter] = useState('');
+  const [entryPage, setEntryPage] = useState(1);
+  const [selected, setSelected] = useState<string[]>([]);
+
+  const knownRef = useRef<Set<string>>(new Set());
+  const seededFilesRef = useRef<Set<string>>(new Set());
+
+  const filesQuery = useGeodataFiles(open);
+  const files = useMemo(() => databasesFor(filesQuery.data ?? [], kind), [filesQuery.data, kind]);
+  const activeFile = files.find((candidate) => candidate.name === file);
+  const fileKind: GeoKind = activeFile?.kind ?? kind;
+
+  const categoriesQuery = useGeodataCategories(file, '', open && !!file);
+  // While a newly picked database loads, the query still serves the previous
+  // one's categories; seeding or filtering against those would attribute one
+  // database's codes to another.
+  const categoriesLoaded = !categoriesQuery.isPlaceholderData && !categoriesQuery.isLoading;
+  const categories = useMemo(
+    () => (categoriesLoaded ? (categoriesQuery.data?.items ?? []) : []),
+    [categoriesLoaded, categoriesQuery.data],
+  );
+
+  // Only the settled filter reaches the query key: every request rescans the
+  // whole .dat file server-side, so a per-keystroke fetch would be one full
+  // scan per character while the box itself stays instant.
+  const entriesQuery = useGeodataEntries(
+    file,
+    activeCode,
+    entryFilter,
+    (entryPage - 1) * ENTRY_PAGE_SIZE,
+    ENTRY_PAGE_SIZE,
+    open && !!file && !!activeCode,
+  );
+
+  // Resets clear both halves at once so a switch of database or category never
+  // renders with the previous filter still in the key, which would fire the
+  // very request the debounce exists to avoid.
+  const clearEntryFilter = useCallback(() => {
+    setEntryQuery('');
+    setEntryFilter('');
+    setEntryPage(1);
+  }, []);
+
+  useEffect(() => {
+    if (entryQuery === entryFilter) return;
+    const handle = window.setTimeout(() => {
+      setEntryFilter(entryQuery);
+      setEntryPage(1);
+    }, ENTRY_FILTER_DELAY);
+    return () => window.clearTimeout(handle);
+  }, [entryQuery, entryFilter]);
+
+  useEffect(() => {
+    if (!open) return;
+    knownRef.current = new Set();
+    seededFilesRef.current = new Set();
+    setCategoryQuery('');
+    setEntryQuery('');
+    setEntryFilter('');
+    setActiveCode(undefined);
+    setEntryPage(1);
+    setSelected([]);
+  }, [open]);
+
+  useEffect(() => {
+    if (!open || file || files.length === 0) return;
+    setFile(preferredFile(files, kind));
+  }, [open, file, files, kind]);
+
+  useEffect(() => {
+    if (!open || !file || categories.length === 0 || seededFilesRef.current.has(file)) return;
+    const tokens = categories.map((category) => tokenFor(file, category.code, fileKind));
+    for (const token of tokens) knownRef.current.add(token);
+    seededFilesRef.current.add(file);
+    const fromValue = selectionFromValue(value, new Set(tokens));
+    if (fromValue.length > 0) {
+      setSelected((previous) => [...previous, ...fromValue.filter((token) => !previous.includes(token))]);
+    }
+  }, [open, file, categories, fileKind, value]);
+
+  const visibleCategories = useMemo(() => {
+    const query = categoryQuery.trim().toLowerCase();
+    if (!query) return categories;
+    return categories.filter((category) => category.code.includes(query));
+  }, [categories, categoryQuery]);
+
+  // Comparisons run through the canonical form: a field may hold the long
+  // ext:geosite.dat:cn spelling or a different case, and those name the same
+  // category as the geosite:cn this modal generates.
+  const selectedCodes = useMemo(() => {
+    if (!file) return [];
+    const chosen = new Set(selected.map(canonicalToken));
+    return categories
+      .filter((category) => chosen.has(canonicalToken(tokenFor(file, category.code, fileKind))))
+      .map((category) => category.code);
+  }, [categories, file, fileKind, selected]);
+
+  const toggle = useCallback(
+    (codes: string[]) => {
+      if (!file) return;
+      const chosen = new Set(codes.map((code) => tokenFor(file, code, fileKind)));
+      const chosenCanonical = new Set([...chosen].map(canonicalToken));
+      // The table reports keys for the rows it currently shows, so a selection
+      // made before the search box was narrowed must survive untouched.
+      const shown = new Set(
+        visibleCategories.map((category) => canonicalToken(tokenFor(file, category.code, fileKind))),
+      );
+      setSelected((previous) => {
+        const kept = previous.filter((token) => {
+          const canonical = canonicalToken(token);
+          return !shown.has(canonical) || chosenCanonical.has(canonical);
+        });
+        const keptCanonical = new Set(kept.map(canonicalToken));
+        return [...kept, ...[...chosen].filter((token) => !keptCanonical.has(canonicalToken(token)))];
+      });
+    },
+    [visibleCategories, file, fileKind],
+  );
+
+  const categoryColumns: ColumnsType<GeoCategory> = useMemo(
+    () => [
+      {
+        title: t('pages.xray.geoBrowser.searchCategory'),
+        dataIndex: 'code',
+        render: (code: string, category: GeoCategory) => (
+          <span className="geo-category">
+            <span className="geo-code">{code}</span>
+            {category.attributes?.length > 0 && (
+              <span className="geo-attrs">
+                {category.attributes.map((attribute) => (
+                  <Tag key={attribute} bordered={false}>
+                    @{attribute}
+                  </Tag>
+                ))}
+              </span>
+            )}
+          </span>
+        ),
+      },
+      {
+        dataIndex: 'entries',
+        align: 'right',
+        width: 90,
+        render: (entries: number) => <span className="geo-count">{entries.toLocaleString()}</span>,
+      },
+    ],
+    [t],
+  );
+
+  const entryColumns: ColumnsType<GeoEntry> = useMemo(
+    () => [
+      {
+        dataIndex: 'kind',
+        width: 88,
+        render: (entryKind: string) => (
+          <Tag bordered={false} className={`geo-kind geo-kind-${entryKind}`}>
+            {entryKind}
+          </Tag>
+        ),
+      },
+      {
+        dataIndex: 'value',
+        render: (entryValue: string) => <span className="geo-entry-value">{entryValue}</span>,
+      },
+    ],
+    [],
+  );
+
+  const fileOptions = files.map((candidate) => ({
+    value: candidate.name,
+    label: candidate.error ? `${candidate.name} — ${describeFileError(candidate.error, t)}` : candidate.name,
+    disabled: !!candidate.error,
+  }));
+
+  const meta = activeFile
+    ? t('pages.xray.geoBrowser.fileMeta', {
+        count: activeFile.categories.toLocaleString(),
+        size: SizeFormatter.sizeFormat(activeFile.size),
+        date: new Date(activeFile.modifiedAt).toLocaleString(),
+      })
+    : '';
+
+  const entriesTotal = entriesQuery.data?.total ?? 0;
+  const activeCategory = categories.find((category) => category.code === activeCode);
+  const countLabel = activeCategory
+    ? t(fileKind === 'ip' ? 'pages.xray.geoBrowser.subnetsCount' : 'pages.xray.geoBrowser.entriesCount', {
+        count: activeCategory.entries.toLocaleString(),
+      })
+    : '';
+
+  return (
+    <Modal
+      open={open}
+      title={t('pages.xray.geoBrowser.title')}
+      width={880}
+      onCancel={onClose}
+      onOk={() => onApply(mergeSelection(value, selected, knownRef.current))}
+      okText={t('pages.xray.geoBrowser.apply')}
+      cancelText={t('close')}
+      className="geo-browser-modal"
+    >
+      {filesQuery.isError && <Alert type="error" showIcon title={t('pages.xray.geoBrowser.loadFailed')} className="mb-12" />}
+
+      {!filesQuery.isError && !filesQuery.isLoading && files.length === 0 ? (
+        <Empty
+          description={
+            <span>
+              {t('pages.xray.geoBrowser.noFiles')}
+              <br />
+              <Typography.Text type="secondary">{t('pages.xray.geoBrowser.noFilesHint')}</Typography.Text>
+            </span>
+          }
+        />
+      ) : (
+        <>
+          <div className="geo-toolbar">
+            <Select
+              value={file}
+              options={fileOptions}
+              onChange={(next) => {
+                setFile(next);
+                setActiveCode(undefined);
+                setCategoryQuery('');
+                clearEntryFilter();
+              }}
+              style={{ minWidth: 200 }}
+              aria-label={t('pages.xray.geoBrowser.database')}
+            />
+            <Input.Search
+              value={categoryQuery}
+              onChange={(event) => setCategoryQuery(event.target.value)}
+              placeholder={t('pages.xray.geoBrowser.searchCategory')}
+              allowClear
+            />
+            <Button
+              onClick={() => toggle([...new Set([...selectedCodes, ...visibleCategories.map((c) => c.code)])])}
+              disabled={visibleCategories.length === 0}
+            >
+              {`${t('pages.xray.geoBrowser.selectFound')} (${visibleCategories.length.toLocaleString()})`}
+            </Button>
+            <span className="geo-meta">{meta}</span>
+          </div>
+
+          <div className="geo-columns">
+            <div className="geo-panel geo-categories">
+              <Table
+                size="small"
+                virtual
+                showHeader={false}
+                rowKey="code"
+                columns={categoryColumns}
+                dataSource={visibleCategories}
+                loading={filesQuery.isLoading || categoriesQuery.isLoading || categoriesQuery.isPlaceholderData}
+                pagination={false}
+                scroll={{ y: CATEGORY_SCROLL_HEIGHT }}
+                locale={{ emptyText: t('pages.xray.geoBrowser.noMatches') }}
+                rowSelection={{
+                  columnWidth: 42,
+                  preserveSelectedRowKeys: true,
+                  selectedRowKeys: selectedCodes,
+                  onChange: (keys) => toggle(keys as string[]),
+                }}
+                onRow={(category) => ({
+                  onClick: (event) => {
+                    if ((event.target as HTMLElement).closest('.ant-table-selection-column')) return;
+                    setActiveCode(category.code);
+                    clearEntryFilter();
+                  },
+                })}
+                rowClassName={(category) => (category.code === activeCode ? 'geo-row-active' : '')}
+              />
+            </div>
+
+            <div className="geo-panel geo-preview">
+              {activeCode ? (
+                <>
+                  <div className="geo-preview-head">
+                    <Tooltip title={file ? tokenFor(file, activeCode, fileKind) : activeCode}>
+                      <span className="geo-preview-title">{activeCode}</span>
+                    </Tooltip>
+                    <Typography.Text type="secondary">{countLabel}</Typography.Text>
+                    <Input
+                      value={entryQuery}
+                      onChange={(event) => setEntryQuery(event.target.value)}
+                      placeholder={t('pages.xray.geoBrowser.searchEntries')}
+                      allowClear
+                      className="geo-entry-filter"
+                    />
+                  </div>
+                  <div className="geo-preview-body">
+                    <Table
+                      size="small"
+                      showHeader={false}
+                      rowKey={(entry, index) => `${entry.value}-${index}`}
+                      columns={entryColumns}
+                      dataSource={entriesQuery.data?.items ?? []}
+                      loading={entriesQuery.isLoading}
+                      locale={{
+                        emptyText: entriesQuery.isError
+                          ? t('pages.xray.geoBrowser.loadFailed')
+                          : t('pages.xray.geoBrowser.noMatches'),
+                      }}
+                      pagination={false}
+                    />
+                  </div>
+                  <div className="geo-pager">
+                    <Pagination
+                      current={entryPage}
+                      pageSize={ENTRY_PAGE_SIZE}
+                      total={entriesTotal}
+                      size="small"
+                      showSizeChanger={false}
+                      onChange={setEntryPage}
+                      showTotal={(total, range) =>
+                        t('pages.xray.geoBrowser.shownRange', {
+                          from: range[0].toLocaleString(),
+                          to: range[1].toLocaleString(),
+                          total: total.toLocaleString(),
+                        })
+                      }
+                    />
+                  </div>
+                </>
+              ) : (
+                <div className="geo-placeholder">
+                  <Typography.Text type="secondary">{t('pages.xray.geoBrowser.pickCategory')}</Typography.Text>
+                </div>
+              )}
+            </div>
+          </div>
+
+          <div className="geo-footer">
+            {selected.length === 0 ? (
+              <Typography.Text type="secondary">{t('pages.xray.geoBrowser.emptySelection')}</Typography.Text>
+            ) : (
+              <>
+                <Space size={4} wrap className="geo-chips">
+                  {selected.map((token) => (
+                    <Tag
+                      key={token}
+                      closable
+                      color="processing"
+                      onClose={() => setSelected((previous) => previous.filter((item) => item !== token))}
+                    >
+                      {token}
+                    </Tag>
+                  ))}
+                </Space>
+                <span className="geo-selected-count">
+                  {t('pages.xray.geoBrowser.selected', { count: selected.length })}
+                </span>
+                <Button type="link" size="small" onClick={() => setSelected([])}>
+                  {t('pages.xray.geoBrowser.clearAll')}
+                </Button>
+              </>
+            )}
+          </div>
+        </>
+      )}
+    </Modal>
+  );
+}
+
+function describeFileError(error: string, t: (key: string) => string): string {
+  if (error.includes('too large')) return t('pages.xray.geoBrowser.tooLarge');
+  return t('pages.xray.geoBrowser.parseFailed');
+}

+ 247 - 0
frontend/src/components/geodata/GeoTokenInput.stories.tsx

@@ -0,0 +1,247 @@
+import { useEffect, useState, type ReactNode } from 'react';
+import type { Decorator, Meta, StoryObj } from '@storybook/react-vite';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { expect, within } from 'storybook/test';
+import { Space } from 'antd';
+
+import { parseTokens } from '@/lib/xray/geoTokens';
+import type { GeoCategory, GeoEntry, GeoFile, GeodataTokenIssue } from '@/generated/types';
+
+import GeoTokenInput, { type GeoTokenInputProps } from './GeoTokenInput';
+
+type GeoResponder = (query: URLSearchParams, body: URLSearchParams) => unknown;
+type GeoRoutes = Record<string, GeoResponder>;
+
+const realFetch = window.fetch.bind(window);
+let activeRoutes: GeoRoutes = {};
+
+function requestUrl(input: RequestInfo | URL): URL {
+  if (typeof input === 'string') return new URL(input, window.location.origin);
+  if (input instanceof URL) return input;
+  return new URL(input.url, window.location.origin);
+}
+
+function geoFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
+  const url = requestUrl(input);
+  const responder = activeRoutes[url.pathname];
+  if (!responder) return realFetch(input, init);
+  const form = new URLSearchParams(typeof init?.body === 'string' ? init.body : '');
+  const body = JSON.stringify({ success: true, msg: '', obj: responder(url.searchParams, form) });
+  return Promise.resolve(
+    new Response(body, { status: 200, headers: { 'content-type': 'application/json' } }),
+  );
+}
+
+function activate(routes: GeoRoutes): void {
+  activeRoutes = routes;
+  window.fetch = geoFetch;
+}
+
+function deactivate(routes: GeoRoutes): void {
+  if (activeRoutes === routes) activeRoutes = {};
+}
+
+function GeoApi({ routes, children }: { routes: GeoRoutes; children: ReactNode }) {
+  const [client] = useState(() => {
+    activate(routes);
+    return new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } });
+  });
+  useEffect(() => {
+    activate(routes);
+    return () => deactivate(routes);
+  }, [routes]);
+  return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
+}
+
+const domain = (value: string): GeoEntry => ({ kind: 'domain', value });
+const cidr = (value: string): GeoEntry => ({ kind: 'cidr', value });
+
+const SITE_ENTRIES: Record<string, GeoEntry[]> = {
+  'category-ads-all': [
+    domain('doubleclick.net'), domain('googleadservices.com'), domain('googlesyndication.com'),
+    domain('criteo.com'), domain('taboola.com'), domain('outbrain.com'),
+  ],
+  cn: [domain('baidu.com'), domain('qq.com'), domain('taobao.com'), domain('weibo.com'), domain('bilibili.com')],
+  google: [
+    domain('google.com'), domain('googleapis.com'), domain('gstatic.com'),
+    domain('googleusercontent.com'), domain('ggpht.com'), domain('android.com'),
+  ],
+  netflix: [domain('netflix.com'), domain('nflximg.net'), domain('nflxvideo.net'), domain('fast.com')],
+  telegram: [domain('telegram.org'), domain('t.me'), domain('telesco.pe'), domain('telegra.ph')],
+  youtube: [domain('youtube.com'), domain('youtu.be'), domain('ytimg.com'), domain('googlevideo.com')],
+};
+
+const IP_ENTRIES: Record<string, GeoEntry[]> = {
+  cloudflare: ['104.16.0.0/13', '172.64.0.0/13', '2606:4700::/32'].map(cidr),
+  cn: ['1.0.1.0/24', '36.0.0.0/22', '116.0.0.0/9', '2408:8000::/20'].map(cidr),
+  private: [
+    '10.0.0.0/8', '127.0.0.0/8', '169.254.0.0/16', '172.16.0.0/12', '192.168.0.0/16',
+    '::1/128', 'fc00::/7', 'fe80::/10',
+  ].map(cidr),
+  telegram: ['91.108.4.0/22', '149.154.160.0/20', '2001:b28:f23d::/48'].map(cidr),
+};
+
+const SITE_ATTRIBUTES: Record<string, string[]> = {
+  google: ['ads', 'cn'],
+  youtube: ['ads'],
+};
+
+function categoriesOf(
+  entries: Record<string, GeoEntry[]>,
+  attributes: Record<string, string[]> = {},
+): GeoCategory[] {
+  return Object.keys(entries)
+    .sort()
+    .map((code) => ({ code, entries: entries[code].length, attributes: attributes[code] ?? [] }));
+}
+
+const DATASETS: Record<string, { categories: GeoCategory[]; entries: Record<string, GeoEntry[]> }> = {
+  'geosite.dat': { categories: categoriesOf(SITE_ENTRIES, SITE_ATTRIBUTES), entries: SITE_ENTRIES },
+  'geoip.dat': { categories: categoriesOf(IP_ENTRIES), entries: IP_ENTRIES },
+};
+
+const UPDATED_AT = Date.UTC(2026, 6, 24, 3, 12);
+
+const FILES: GeoFile[] = [
+  {
+    name: 'geosite.dat',
+    kind: 'site',
+    size: 4_812_544,
+    modifiedAt: UPDATED_AT,
+    categories: DATASETS['geosite.dat'].categories.length,
+  },
+  {
+    name: 'geoip.dat',
+    kind: 'ip',
+    size: 8_694_272,
+    modifiedAt: UPDATED_AT,
+    categories: DATASETS['geoip.dat'].categories.length,
+  },
+];
+
+function referenceOf(token: string, isIP: boolean): { file: string; code: string } | null {
+  const [prefix, ...rest] = token.split(':');
+  const code = (value: string) => value.split('@')[0].toLowerCase();
+  if (prefix === 'geosite') return { file: 'geosite.dat', code: code(rest.join(':')) };
+  if (prefix === 'geoip') return { file: 'geoip.dat', code: code(rest.join(':')) };
+  if (prefix === 'ext') return { file: rest[0] ?? '', code: code(rest.slice(1).join(':')) };
+  return isIP && prefix === 'ext-ip' ? { file: rest[0] ?? '', code: code(rest.slice(1).join(':')) } : null;
+}
+
+function validate(tokens: string[], isIP: boolean): GeodataTokenIssue[] {
+  const issues: GeodataTokenIssue[] = [];
+  for (const token of tokens) {
+    const reference = referenceOf(token, isIP);
+    if (!reference) continue;
+    const dataset = DATASETS[reference.file];
+    if (!dataset) {
+      issues.push({ token, reason: 'fileMissing', file: reference.file, code: reference.code });
+      continue;
+    }
+    if (!dataset.categories.some((category) => category.code === reference.code)) {
+      issues.push({ token, reason: 'categoryMissing', file: reference.file, code: reference.code });
+    }
+  }
+  return issues;
+}
+
+const routes: GeoRoutes = {
+  '/csrf-token': () => 'storybook-csrf-token',
+  '/panel/api/xray/geodata/files': () => FILES,
+  '/panel/api/xray/geodata/categories': (query) => {
+    const dataset = DATASETS[query.get('file') ?? ''];
+    const needle = (query.get('q') ?? '').trim().toLowerCase();
+    const items = (dataset?.categories ?? []).filter((category) => category.code.includes(needle));
+    return { total: items.length, items };
+  },
+  '/panel/api/xray/geodata/entries': (query) => {
+    const dataset = DATASETS[query.get('file') ?? ''];
+    const needle = (query.get('q') ?? '').trim().toLowerCase();
+    const matched = (dataset?.entries[query.get('code') ?? ''] ?? []).filter((entry) =>
+      entry.value.toLowerCase().includes(needle),
+    );
+    const offset = Number(query.get('offset') ?? 0);
+    const limit = Number(query.get('limit') ?? 100);
+    return { total: matched.length, items: matched.slice(offset, offset + limit) };
+  },
+  '/panel/api/xray/geodata/validate': (_query, form) =>
+    validate(parseTokens(form.get('tokens') ?? ''), form.get('kind') === 'ip'),
+};
+
+const withGeodata: Decorator = function GeodataBackend(Story) {
+  return (
+    <GeoApi routes={routes}>
+      <Story />
+    </GeoApi>
+  );
+};
+
+function ControlledTokenInput({ value = '', id = 'geo-rule', ...rest }: GeoTokenInputProps) {
+  const [current, setCurrent] = useState(value);
+  useEffect(() => setCurrent(value), [value]);
+  return (
+    <Space direction="vertical" size={4} style={{ width: 460 }}>
+      <label htmlFor={id}>{rest.kind === 'ip' ? 'Target IP' : 'Target domain'}</label>
+      <GeoTokenInput {...rest} id={id} value={current} onChange={setCurrent} />
+    </Space>
+  );
+}
+
+const meta = {
+  title: 'Geodata/GeoTokenInput',
+  component: GeoTokenInput,
+  tags: ['autodocs'],
+  parameters: {
+    layout: 'padded',
+    a11y: {
+      config: {
+        rules: [{ id: 'color-contrast', enabled: false }],
+      },
+    },
+    docs: {
+      description: {
+        component:
+          'Routing rule field for the xray rule editor: a comma separated list of domains/CIDRs and `geosite:` / `geoip:` tokens, with a database button in the addon that opens the geo category browser. Typed tokens are validated against the databases on disk after a short pause, and anything the running core would not resolve is called out under the field. The stories answer `/panel/api/xray/geodata/*` from an in-memory fixture, so validation and the browser both work without a panel backend.',
+      },
+    },
+  },
+  decorators: [withGeodata],
+  args: { kind: 'domain' },
+  argTypes: {
+    value: { description: 'Comma separated rule string held by the parent form.' },
+    onChange: { description: 'Called with the full rule string on every edit and on Apply from the browser.' },
+    onBlur: { description: 'Forwarded to the input; used by React Hook Form to mark the field touched.' },
+    kind: {
+      description: 'Which database the tokens are validated against: `domain` for geosite, `ip` for geoip.',
+      control: 'inline-radio',
+      options: ['domain', 'ip'],
+    },
+    placeholder: { description: 'Placeholder shown while the field is empty.' },
+    id: { description: 'Input id, linked to the label rendered by the surrounding form field.' },
+  },
+  render: (args) => <ControlledTokenInput {...args} />,
+} satisfies Meta<typeof GeoTokenInput>;
+
+export default meta;
+
+type Story = StoryObj<typeof meta>;
+
+export const Empty: Story = {
+  args: { kind: 'domain', value: '', placeholder: 'geosite:google, example.com' },
+};
+
+export const DomainTokens: Story = {
+  args: { kind: 'domain', value: 'geosite:google, google.com' },
+};
+
+export const IpTokens: Story = {
+  args: { kind: 'ip', value: 'geoip:private' },
+};
+
+export const UnknownCategory: Story = {
+  args: { kind: 'domain', value: 'geosite:blabla, geosite:google' },
+  play: async ({ canvasElement }) => {
+    const canvas = within(canvasElement);
+    await expect(await canvas.findByText(/geosite:blabla/, undefined, { timeout: 3000 })).toBeVisible();
+  },
+};

+ 126 - 0
frontend/src/components/geodata/GeoTokenInput.tsx

@@ -0,0 +1,126 @@
+import { useEffect, useState } from 'react';
+import type { Ref } from 'react';
+import { useTranslation } from 'react-i18next';
+import { Button, Input, Tooltip, Typography } from 'antd';
+import type { InputRef } from 'antd';
+import { DatabaseOutlined } from '@ant-design/icons';
+
+import { useValidateGeoTokens, type GeoTokenKind } from '@/api/queries/useGeodata';
+import { parseTokens } from '@/lib/xray/geoTokens';
+import type { GeodataTokenIssue, GeoKind } from '@/generated/types';
+
+import GeoBrowserModal from './GeoBrowserModal';
+
+const VALIDATION_DELAY = 600;
+
+// Each reason needs its own wording: a missing database is fixed under Geodata,
+// a missing category by picking another one, and a bad token by editing it.
+const REASON_KEYS: Record<string, string> = {
+  fileMissing: 'pages.xray.geoBrowser.missingDatabase',
+  categoryMissing: 'pages.xray.geoBrowser.unknownCategories',
+  attributeMissing: 'pages.xray.geoBrowser.unknownAttribute',
+  syntax: 'pages.xray.geoBrowser.invalidToken',
+  wrongKind: 'pages.xray.geoBrowser.wrongKind',
+};
+
+export interface GeoTokenInputProps {
+  value?: string;
+  onChange?: (value: string) => void;
+  onBlur?: () => void;
+  kind: GeoTokenKind;
+  placeholder?: string;
+  id?: string;
+  ref?: Ref<InputRef>;
+}
+
+export default function GeoTokenInput({ value = '', onChange, onBlur, kind, placeholder, id, ref }: GeoTokenInputProps) {
+  const { t } = useTranslation();
+  const [browsing, setBrowsing] = useState(false);
+  const [issues, setIssues] = useState<GeodataTokenIssue[]>([]);
+  const [checkFailed, setCheckFailed] = useState(false);
+  const validate = useValidateGeoTokens();
+  const { mutateAsync } = validate;
+
+  useEffect(() => {
+    const tokens = parseTokens(value);
+    if (tokens.length === 0) {
+      setIssues([]);
+      setCheckFailed(false);
+      return;
+    }
+    let cancelled = false;
+    const timer = setTimeout(() => {
+      mutateAsync({ tokens, kind })
+        .then((found) => {
+          if (cancelled) return;
+          setIssues(found);
+          setCheckFailed(false);
+        })
+        // A rejected check says nothing about the tokens, so the warnings are
+        // dropped but replaced by a notice — silence here reads as "all valid".
+        .catch(() => {
+          if (cancelled) return;
+          setIssues([]);
+          setCheckFailed(true);
+        });
+    }, VALIDATION_DELAY);
+    return () => {
+      cancelled = true;
+      clearTimeout(timer);
+    };
+  }, [value, kind, mutateAsync]);
+
+  return (
+    <>
+      <Input
+        ref={ref}
+        id={id}
+        value={value}
+        placeholder={placeholder}
+        onChange={(event) => onChange?.(event.target.value)}
+        onBlur={onBlur}
+        addonAfter={
+          <Tooltip title={t('pages.xray.geoBrowser.openTooltip')}>
+            <Button
+              type="text"
+              size="small"
+              icon={<DatabaseOutlined />}
+              aria-label={t('pages.xray.geoBrowser.openTooltip')}
+              onClick={() => setBrowsing(true)}
+            />
+          </Tooltip>
+        }
+      />
+      {groupByReason(issues).map(([reason, tokens]) => (
+        <Typography.Text key={reason} type="warning" className="geo-unknown-hint">
+          {t(REASON_KEYS[reason] ?? REASON_KEYS.categoryMissing, { tokens: tokens.join(', ') })}
+        </Typography.Text>
+      ))}
+      {checkFailed && (
+        <Typography.Text type="secondary" className="geo-unknown-hint">
+          {t('pages.xray.geoBrowser.checkFailed')}
+        </Typography.Text>
+      )}
+      <GeoBrowserModal
+        open={browsing}
+        kind={(kind === 'ip' ? 'ip' : 'site') as GeoKind}
+        value={value}
+        onApply={(next) => {
+          onChange?.(next);
+          setBrowsing(false);
+        }}
+        onClose={() => setBrowsing(false)}
+      />
+    </>
+  );
+}
+
+function groupByReason(issues: GeodataTokenIssue[]): Array<[string, string[]]> {
+  const grouped = new Map<string, string[]>();
+  for (const issue of issues) {
+    const tokens = grouped.get(issue.reason) ?? [];
+    tokens.push(issue.token);
+    grouped.set(issue.reason, tokens);
+  }
+  return [...grouped];
+}

+ 4 - 0
frontend/src/components/geodata/index.ts

@@ -0,0 +1,4 @@
+export { default as GeoBrowserModal } from './GeoBrowserModal';
+export type { GeoBrowserModalProps } from './GeoBrowserModal';
+export { default as GeoTokenInput } from './GeoTokenInput';
+export type { GeoTokenInputProps } from './GeoTokenInput';

+ 2 - 1
frontend/src/components/utility/LazyMount.tsx

@@ -1,4 +1,5 @@
 import { Suspense, useEffect, useState, type ReactNode } from 'react';
+import { Spin } from 'antd';
 
 interface LazyMountProps {
   when: boolean;
@@ -10,7 +11,7 @@ interface LazyMountProps {
 // thereafter, so React.lazy modals get loaded on demand but their close
 // animations still play out. Pair with `lazy(() => import(...))` modal imports
 // on heavy list pages to keep the initial bundle small.
-export default function LazyMount({ when, fallback = null, children }: LazyMountProps) {
+export default function LazyMount({ when, fallback = <Spin />, children }: LazyMountProps) {
   const [mounted, setMounted] = useState(when);
   useEffect(() => {
     if (when && !mounted) setMounted(true);

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

@@ -220,15 +220,19 @@ export const EXAMPLES: Record<string, unknown> = {
   "ApiToken": {
     "createdAt": 0,
     "enabled": false,
+    "expiresAt": 0,
     "id": 0,
     "name": "",
+    "scope": "",
     "token": ""
   },
   "ApiTokenView": {
     "createdAt": 1736000000,
     "enabled": true,
+    "expiresAt": 0,
     "id": 2,
     "name": "central-panel-a",
+    "scope": "admin",
     "token": "new-token-string"
   },
   "Client": {
@@ -279,6 +283,7 @@ export const EXAMPLES: Record<string, unknown> = {
     "group": "",
     "id": 0,
     "keepAlive": 0,
+    "limitHwid": 0,
     "limitIp": 0,
     "password": "",
     "preSharedKey": "",
@@ -305,6 +310,7 @@ export const EXAMPLES: Record<string, unknown> = {
     "id": 14825,
     "inboundId": 1,
     "lastOnline": 1735680000000,
+    "lastSubFetch": 1735680000000,
     "reset": 0,
     "subId": "i7tvdpeffi0hvvf1",
     "total": 10737418240,
@@ -315,6 +321,54 @@ export const EXAMPLES: Record<string, unknown> = {
     "masterId": 0,
     "path": ""
   },
+  "GeoCategory": {
+    "attributes": [
+      "ads",
+      "cn"
+    ],
+    "code": "google",
+    "entries": 1284
+  },
+  "GeoCategoryPage": {
+    "items": [
+      {
+        "attributes": [
+          "ads",
+          "cn"
+        ],
+        "code": "google",
+        "entries": 1284
+      }
+    ],
+    "total": 1043
+  },
+  "GeoEntry": {
+    "kind": "domain",
+    "value": "google.com"
+  },
+  "GeoEntryPage": {
+    "items": [
+      {
+        "kind": "domain",
+        "value": "google.com"
+      }
+    ],
+    "total": 1284
+  },
+  "GeoFile": {
+    "categories": 1043,
+    "error": "",
+    "kind": "site",
+    "modifiedAt": 1769558400000,
+    "name": "geosite.dat",
+    "size": 1467392
+  },
+  "GeodataTokenIssue": {
+    "code": "blabla",
+    "file": "geosite.dat",
+    "reason": "categoryMissing",
+    "token": "geosite:blabla"
+  },
   "HistoryOfSeeders": {
     "id": 0,
     "seederName": ""
@@ -422,6 +476,7 @@ export const EXAMPLES: Record<string, unknown> = {
         "id": 14825,
         "inboundId": 1,
         "lastOnline": 1735680000000,
+        "lastSubFetch": 1735680000000,
         "reset": 0,
         "subId": "i7tvdpeffi0hvvf1",
         "total": 10737418240,

+ 182 - 1
frontend/src/generated/schemas.ts

@@ -937,12 +937,19 @@ export const SCHEMAS: Record<string, unknown> = {
       "enabled": {
         "type": "boolean"
       },
+      "expiresAt": {
+        "format": "int64",
+        "type": "integer"
+      },
       "id": {
         "type": "integer"
       },
       "name": {
         "type": "string"
       },
+      "scope": {
+        "type": "string"
+      },
       "token": {
         "description": "SHA-256 hash; the plaintext is shown only once at creation",
         "type": "string"
@@ -951,8 +958,10 @@ export const SCHEMAS: Record<string, unknown> = {
     "required": [
       "createdAt",
       "enabled",
+      "expiresAt",
       "id",
       "name",
+      "scope",
       "token"
     ],
     "type": "object"
@@ -968,6 +977,11 @@ export const SCHEMAS: Record<string, unknown> = {
         "example": true,
         "type": "boolean"
       },
+      "expiresAt": {
+        "example": 0,
+        "format": "int64",
+        "type": "integer"
+      },
       "id": {
         "example": 2,
         "type": "integer"
@@ -976,6 +990,10 @@ export const SCHEMAS: Record<string, unknown> = {
         "example": "central-panel-a",
         "type": "string"
       },
+      "scope": {
+        "example": "admin",
+        "type": "string"
+      },
       "token": {
         "example": "new-token-string",
         "type": "string"
@@ -984,8 +1002,10 @@ export const SCHEMAS: Record<string, unknown> = {
     "required": [
       "createdAt",
       "enabled",
+      "expiresAt",
       "id",
-      "name"
+      "name",
+      "scope"
     ],
     "type": "object"
   },
@@ -1179,6 +1199,9 @@ export const SCHEMAS: Record<string, unknown> = {
       "keepAlive": {
         "type": "integer"
       },
+      "limitHwid": {
+        "type": "integer"
+      },
       "limitIp": {
         "type": "integer"
       },
@@ -1236,6 +1259,7 @@ export const SCHEMAS: Record<string, unknown> = {
       "group",
       "id",
       "keepAlive",
+      "limitHwid",
       "limitIp",
       "password",
       "preSharedKey",
@@ -1298,6 +1322,11 @@ export const SCHEMAS: Record<string, unknown> = {
         "format": "int64",
         "type": "integer"
       },
+      "lastSubFetch": {
+        "example": 1735680000000,
+        "format": "int64",
+        "type": "integer"
+      },
       "reset": {
         "example": 0,
         "type": "integer"
@@ -1329,6 +1358,7 @@ export const SCHEMAS: Record<string, unknown> = {
       "id",
       "inboundId",
       "lastOnline",
+      "lastSubFetch",
       "reset",
       "subId",
       "total",
@@ -1352,6 +1382,157 @@ export const SCHEMAS: Record<string, unknown> = {
     ],
     "type": "object"
   },
+  "GeoCategory": {
+    "description": "GeoCategory is one code inside a database, such as geosite's \"google\".",
+    "properties": {
+      "attributes": {
+        "example": [
+          "ads",
+          "cn"
+        ],
+        "items": {
+          "type": "string"
+        },
+        "type": "array"
+      },
+      "code": {
+        "example": "google",
+        "type": "string"
+      },
+      "entries": {
+        "example": 1284,
+        "type": "integer"
+      }
+    },
+    "required": [
+      "attributes",
+      "code",
+      "entries"
+    ],
+    "type": "object"
+  },
+  "GeoCategoryPage": {
+    "description": "GeoCategoryPage is one page of categories plus the unpaged total.",
+    "properties": {
+      "items": {
+        "items": {
+          "$ref": "#/components/schemas/GeoCategory"
+        },
+        "type": "array"
+      },
+      "total": {
+        "example": 1043,
+        "type": "integer"
+      }
+    },
+    "required": [
+      "items",
+      "total"
+    ],
+    "type": "object"
+  },
+  "GeoEntry": {
+    "description": "GeoEntry is a single rule inside a category: a domain rule for geosite\ndatabases, a CIDR for geoip ones.",
+    "properties": {
+      "kind": {
+        "example": "domain",
+        "type": "string"
+      },
+      "value": {
+        "example": "google.com",
+        "type": "string"
+      }
+    },
+    "required": [
+      "kind",
+      "value"
+    ],
+    "type": "object"
+  },
+  "GeoEntryPage": {
+    "description": "GeoEntryPage is one page of category entries plus the unpaged total.",
+    "properties": {
+      "items": {
+        "items": {
+          "$ref": "#/components/schemas/GeoEntry"
+        },
+        "type": "array"
+      },
+      "total": {
+        "example": 1284,
+        "type": "integer"
+      }
+    },
+    "required": [
+      "items",
+      "total"
+    ],
+    "type": "object"
+  },
+  "GeoFile": {
+    "description": "GeoFile describes one .dat database found in the asset directory.",
+    "properties": {
+      "categories": {
+        "example": 1043,
+        "type": "integer"
+      },
+      "error": {
+        "type": "string"
+      },
+      "kind": {
+        "example": "site",
+        "type": "string"
+      },
+      "modifiedAt": {
+        "example": 1769558400000,
+        "format": "int64",
+        "type": "integer"
+      },
+      "name": {
+        "example": "geosite.dat",
+        "type": "string"
+      },
+      "size": {
+        "example": 1467392,
+        "format": "int64",
+        "type": "integer"
+      }
+    },
+    "required": [
+      "categories",
+      "kind",
+      "modifiedAt",
+      "name",
+      "size"
+    ],
+    "type": "object"
+  },
+  "GeodataTokenIssue": {
+    "description": "GeodataTokenIssue reports a routing token the running core would reject,\nor would silently match nothing against.",
+    "properties": {
+      "code": {
+        "example": "blabla",
+        "type": "string"
+      },
+      "file": {
+        "example": "geosite.dat",
+        "type": "string"
+      },
+      "reason": {
+        "example": "categoryMissing",
+        "type": "string"
+      },
+      "token": {
+        "example": "geosite:blabla",
+        "type": "string"
+      }
+    },
+    "required": [
+      "reason",
+      "token"
+    ],
+    "type": "object"
+  },
   "HistoryOfSeeders": {
     "description": "HistoryOfSeeders tracks which database seeders have been executed to prevent re-running.",
     "properties": {

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

@@ -1,4 +1,5 @@
 // Code generated by tools/openapigen. DO NOT EDIT.
+export type GeoKind = string;
 export type OnlineAPISupport = number;
 export type ProcessState = string;
 export type Protocol = string;
@@ -229,16 +230,20 @@ export interface AllSettingView {
 export interface ApiToken {
   createdAt: number;
   enabled: boolean;
+  expiresAt: number;
   id: number;
   name: string;
+  scope: string;
   token: string;
 }
 
 export interface ApiTokenView {
   createdAt: number;
   enabled: boolean;
+  expiresAt: number;
   id: number;
   name: string;
+  scope: string;
   token?: string;
 }
 
@@ -290,6 +295,7 @@ export interface ClientRecord {
   group: string;
   id: number;
   keepAlive: number;
+  limitHwid: number;
   limitIp: number;
   password: string;
   preSharedKey: string;
@@ -318,6 +324,7 @@ export interface ClientTraffic {
   id: number;
   inboundId: number;
   lastOnline: number;
+  lastSubFetch: number;
   reset: number;
   subId: string;
   total: number;
@@ -330,6 +337,43 @@ export interface FallbackParentInfo {
   path?: string;
 }
 
+export interface GeoCategory {
+  attributes: string[];
+  code: string;
+  entries: number;
+}
+
+export interface GeoCategoryPage {
+  items: GeoCategory[];
+  total: number;
+}
+
+export interface GeoEntry {
+  kind: string;
+  value: string;
+}
+
+export interface GeoEntryPage {
+  items: GeoEntry[];
+  total: number;
+}
+
+export interface GeoFile {
+  categories: number;
+  error?: string;
+  kind: GeoKind;
+  modifiedAt: number;
+  name: string;
+  size: number;
+}
+
+export interface GeodataTokenIssue {
+  code?: string;
+  file?: string;
+  reason: string;
+  token: string;
+}
+
 export interface HistoryOfSeeders {
   id: number;
   seederName: string;

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

@@ -1,5 +1,8 @@
 // Code generated by tools/openapigen. DO NOT EDIT.
 import { z } from 'zod';
+export const GeoKindSchema = z.string();
+export type GeoKind = z.infer<typeof GeoKindSchema>;
+
 export const OnlineAPISupportSchema = z.number().int();
 export type OnlineAPISupport = z.infer<typeof OnlineAPISupportSchema>;
 
@@ -245,8 +248,10 @@ export type AllSettingView = z.infer<typeof AllSettingViewSchema>;
 export const ApiTokenSchema = z.object({
   createdAt: z.number().int(),
   enabled: z.boolean(),
+  expiresAt: z.number().int(),
   id: z.number().int(),
   name: z.string(),
+  scope: z.string(),
   token: z.string(),
 });
 export type ApiToken = z.infer<typeof ApiTokenSchema>;
@@ -254,8 +259,10 @@ export type ApiToken = z.infer<typeof ApiTokenSchema>;
 export const ApiTokenViewSchema = z.object({
   createdAt: z.number().int(),
   enabled: z.boolean(),
+  expiresAt: z.number().int(),
   id: z.number().int(),
   name: z.string(),
+  scope: z.string(),
   token: z.string().optional(),
 });
 export type ApiTokenView = z.infer<typeof ApiTokenViewSchema>;
@@ -310,6 +317,7 @@ export const ClientRecordSchema = z.object({
   group: z.string(),
   id: z.number().int(),
   keepAlive: z.number().int(),
+  limitHwid: z.number().int(),
   limitIp: z.number().int(),
   password: z.string(),
   preSharedKey: z.string(),
@@ -340,6 +348,7 @@ export const ClientTrafficSchema = z.object({
   id: z.number().int(),
   inboundId: z.number().int(),
   lastOnline: z.number().int(),
+  lastSubFetch: z.number().int(),
   reset: z.number().int(),
   subId: z.string(),
   total: z.number().int(),
@@ -354,6 +363,49 @@ export const FallbackParentInfoSchema = z.object({
 });
 export type FallbackParentInfo = z.infer<typeof FallbackParentInfoSchema>;
 
+export const GeoCategorySchema = z.object({
+  attributes: z.array(z.string()),
+  code: z.string(),
+  entries: z.number().int(),
+});
+export type GeoCategory = z.infer<typeof GeoCategorySchema>;
+
+export const GeoCategoryPageSchema = z.object({
+  items: z.array(z.lazy(() => GeoCategorySchema)),
+  total: z.number().int(),
+});
+export type GeoCategoryPage = z.infer<typeof GeoCategoryPageSchema>;
+
+export const GeoEntrySchema = z.object({
+  kind: z.string(),
+  value: z.string(),
+});
+export type GeoEntry = z.infer<typeof GeoEntrySchema>;
+
+export const GeoEntryPageSchema = z.object({
+  items: z.array(z.lazy(() => GeoEntrySchema)),
+  total: z.number().int(),
+});
+export type GeoEntryPage = z.infer<typeof GeoEntryPageSchema>;
+
+export const GeoFileSchema = z.object({
+  categories: z.number().int(),
+  error: z.string().optional(),
+  kind: z.lazy(() => GeoKindSchema),
+  modifiedAt: z.number().int(),
+  name: z.string(),
+  size: z.number().int(),
+});
+export type GeoFile = z.infer<typeof GeoFileSchema>;
+
+export const GeodataTokenIssueSchema = z.object({
+  code: z.string().optional(),
+  file: z.string().optional(),
+  reason: z.string(),
+  token: z.string(),
+});
+export type GeodataTokenIssue = z.infer<typeof GeodataTokenIssueSchema>;
+
 export const HistoryOfSeedersSchema = z.object({
   id: z.number().int(),
   seederName: z.string(),

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

@@ -529,6 +529,7 @@ export function useClients(options: UseClientsOptions = {}) {
       totalGB: base.totalGB || 0,
       expiryTime: base.expiryTime || 0,
       limitIp: base.limitIp || 0,
+      limitHwid: base.limitHwid || 0,
       tgId: Number(base.tgId) || 0,
       reset: Number(base.reset) || 0,
       group: base.group || '',

+ 18 - 6
frontend/src/hooks/useMediaQuery.ts

@@ -1,15 +1,27 @@
 import { useEffect, useState } from 'react';
 
-const MOBILE_BREAKPOINT_PX = 768;
+export const MOBILE_BREAKPOINT_PX = 768;
 
+/**
+ * Tracks whether the viewport is narrower than `breakpoint`.
+ *
+ * Uses the native `matchMedia` change event instead of the `resize` event so
+ * that state updates fire only when the query actually flips, not on every
+ * pixel change during a window drag.
+ */
 export function useMediaQuery(breakpoint: number = MOBILE_BREAKPOINT_PX) {
-  const [isMobile, setIsMobile] = useState<boolean>(() => window.innerWidth <= breakpoint);
+  const query = `(max-width: ${breakpoint}px)`;
+  const [isMobile, setIsMobile] = useState<boolean>(() =>
+    typeof window !== 'undefined' ? window.matchMedia(query).matches : false,
+  );
 
   useEffect(() => {
-    const onResize = () => setIsMobile(window.innerWidth <= breakpoint);
-    window.addEventListener('resize', onResize);
-    return () => window.removeEventListener('resize', onResize);
-  }, [breakpoint]);
+    const mql = window.matchMedia(query);
+    const onChange = (e: MediaQueryListEvent) => setIsMobile(e.matches);
+    mql.addEventListener('change', onChange);
+    setIsMobile(mql.matches);
+    return () => mql.removeEventListener('change', onChange);
+  }, [query]);
 
   return { isMobile };
 }

+ 16 - 3
frontend/src/lib/remark/remarkVariables.ts

@@ -51,6 +51,14 @@ export const REMARK_VARIABLES: RemarkVar[] = [
   { token: 'SECURITY', group: 'connection', sample: 'TLS' },
 ];
 
+export const SUBSCRIPTION_METADATA_VARIABLES: RemarkVar[] = REMARK_VARIABLES.filter((v) => (
+  v.token === 'EMAIL'
+  || v.token === 'ID'
+  || v.token === 'SHORT_ID'
+  || v.token === 'TELEGRAM_ID'
+  || v.token === 'SUB_ID'
+));
+
 const SAMPLE_BY_TOKEN: Record<string, string> = Object.fromEntries(
   REMARK_VARIABLES.map((v) => [v.token, v.sample]),
 );
@@ -70,9 +78,14 @@ export function hasRemarkTokens(template: string): boolean {
 /**
  * previewRemark renders a template against the sample values, mirroring the
  * backend substitution closely enough for an at-a-glance preview. Unknown
- * tokens collapse to empty, just like the server.
+ * tokens collapse to empty by default; metadata fields can keep unsupported
+ * tokens literal because the backend does the same for backwards compatibility.
  */
-export function previewRemark(template: string): string {
+export function previewRemark(template: string, variables: RemarkVar[] = REMARK_VARIABLES, keepUnknown = false): string {
   if (!hasRemarkTokens(template)) return template;
-  return template.replace(TOKEN_RE, (_m, tok: string) => SAMPLE_BY_TOKEN[tok] ?? '');
+  const allowed = new Set(variables.map((v) => v.token));
+  return template.replace(TOKEN_RE, (match, tok: string) => {
+    if (!allowed.has(tok)) return keepUnknown ? match : '';
+    return SAMPLE_BY_TOKEN[tok] ?? '';
+  });
 }

+ 77 - 0
frontend/src/lib/xray/geoTokens.ts

@@ -0,0 +1,77 @@
+import type { GeoKind } from '@/generated/types';
+
+const DEFAULT_SITE_FILE = 'geosite.dat';
+const DEFAULT_IP_FILE = 'geoip.dat';
+
+const LONG_FORMS: Array<[RegExp, string]> = [
+  [/^ext(?:-domain|-site)?:geosite\.dat:/, 'geosite:'],
+  [/^ext(?:-ip)?:geoip\.dat:/, 'geoip:'],
+];
+
+export function parseTokens(value: string): string[] {
+  return value
+    .split(',')
+    .map((token) => token.trim())
+    .filter((token) => token !== '');
+}
+
+export function formatTokens(tokens: string[]): string {
+  return tokens.join(', ');
+}
+
+export function tokenFor(file: string, code: string, kind: GeoKind): string {
+  if (kind === 'ip' && file === DEFAULT_IP_FILE) return `geoip:${code}`;
+  if (kind === 'site' && file === DEFAULT_SITE_FILE) return `geosite:${code}`;
+  return `ext:${file}:${code}`;
+}
+
+/**
+ * Xray treats category codes case-insensitively and accepts both the
+ * `geosite:cn` shorthand and its `ext:geosite.dat:cn` long form, so tokens are
+ * compared through this normal form. Only comparison uses it — whatever the
+ * user typed is what stays in the rule.
+ */
+export function canonicalToken(token: string): string {
+  const lowered = token.trim().toLowerCase();
+  for (const [pattern, shorthand] of LONG_FORMS) {
+    if (pattern.test(lowered)) return lowered.replace(pattern, shorthand);
+  }
+  return lowered;
+}
+
+export function selectionFromValue(value: string, known: ReadonlySet<string>): string[] {
+  const canonicalKnown = new Set([...known].map(canonicalToken));
+  const selection: string[] = [];
+  const seen = new Set<string>();
+  for (const token of parseTokens(value)) {
+    const canonical = canonicalToken(token);
+    if (!canonicalKnown.has(canonical) || seen.has(canonical)) continue;
+    seen.add(canonical);
+    selection.push(token);
+  }
+  return selection;
+}
+
+export function mergeSelection(value: string, selected: string[], known: ReadonlySet<string>): string {
+  const canonicalKnown = new Set([...known].map(canonicalToken));
+  const kept = new Set(
+    selected.map((token) => canonicalToken(token)).filter((token) => token !== ''),
+  );
+  const merged: string[] = [];
+  const seen = new Set<string>();
+  const append = (token: string) => {
+    const canonical = canonicalToken(token);
+    if (canonical === '' || seen.has(canonical)) return;
+    seen.add(canonical);
+    merged.push(token);
+  };
+  for (const token of parseTokens(value)) {
+    const canonical = canonicalToken(token);
+    if (canonicalKnown.has(canonical) && !kept.has(canonical)) continue;
+    append(token);
+  }
+  for (const token of selected) {
+    append(token.trim());
+  }
+  return formatTokens(merged);
+}

+ 66 - 0
frontend/src/lib/xray/inbound-clone.ts

@@ -0,0 +1,66 @@
+import { RandomUtil } from '@/utils';
+import { createDefaultInboundSettings } from '@/lib/xray/inbound-defaults';
+import { coerceInboundJsonField, type DBInbound } from '@/models/dbinbound';
+
+/*
+ * Payload for POST /panel/api/inbounds/add reproducing `dbInbound` as a
+ * staged copy: fresh port, empty client list (emails are unique panel-wide
+ * and UUIDs must not repeat across nodes), disabled, no tag (the backend
+ * regenerates one with the correct per-node prefix), cleared listen (listen
+ * addresses are node-local). `nodeId === null` targets the local panel; the
+ * field is omitted from the wire payload then, matching the add-form adapter.
+ */
+export function buildClonePayload(dbInbound: DBInbound, port: number, nodeId: number | null) {
+  let clonedSettings: string;
+  try {
+    const raw = { ...coerceInboundJsonField(dbInbound.settings) };
+    raw.clients = [];
+    clonedSettings = JSON.stringify(raw);
+  } catch {
+    const fallback = createDefaultInboundSettings(dbInbound.protocol);
+    clonedSettings = fallback ? JSON.stringify(fallback, null, 2) : '{}';
+  }
+  const streamSettingsString = typeof dbInbound.streamSettings === 'string'
+    ? dbInbound.streamSettings
+    : JSON.stringify(dbInbound.streamSettings ?? {});
+  const sniffingString = typeof dbInbound.sniffing === 'string'
+    ? dbInbound.sniffing
+    : JSON.stringify(dbInbound.sniffing ?? {});
+  return {
+    up: 0,
+    down: 0,
+    total: 0,
+    remark: `${dbInbound.remark} (clone)`,
+    enable: false,
+    expiryTime: 0,
+    listen: '',
+    port,
+    protocol: dbInbound.protocol,
+    settings: clonedSettings,
+    streamSettings: streamSettingsString,
+    sniffing: sniffingString,
+    shareAddrStrategy: dbInbound.shareAddrStrategy,
+    shareAddr: dbInbound.shareAddr,
+    ...(nodeId != null ? { nodeId } : {}),
+  };
+}
+
+/*
+ * Random clone port in the add-form's range, avoiding ports already bound on
+ * the target node (client-side pre-check; the backend's node-scoped conflict
+ * check stays the final arbiter). A few random tries cover the common sparse
+ * case; a target so dense that those all miss falls back to a deterministic
+ * scan so a free port is always found when one exists.
+ */
+export function pickClonePort(used: Set<number> | undefined): number {
+  let port = RandomUtil.randomInteger(10000, 60000);
+  if (!used) return port;
+  for (let attempts = 0; attempts < 20 && used.has(port); attempts++) {
+    port = RandomUtil.randomInteger(10000, 60000);
+  }
+  if (used.has(port)) {
+    for (port = 10000; port <= 60000 && used.has(port); port++) { /* dense-range scan */ }
+    if (port > 60000) port = RandomUtil.randomInteger(10000, 60000);
+  }
+  return port;
+}

+ 16 - 0
frontend/src/lib/xray/node-protocols.ts

@@ -0,0 +1,16 @@
+import { Protocols } from '@/schemas/primitives';
+
+/*
+ * Protocols whose inbounds can live on a sub-node (the "Deploy To" set).
+ * Everything else (http, mixed, tunnel, tun, mtproto) is panel-local only.
+ * Shared by the inbound form's Deploy To selector and the clone dialog's
+ * target picker so the two surfaces can never drift apart.
+ */
+export const NODE_ELIGIBLE_PROTOCOLS: Readonly<Record<string, true>> = {
+  [Protocols.VLESS]: true,
+  [Protocols.VMESS]: true,
+  [Protocols.TROJAN]: true,
+  [Protocols.SHADOWSOCKS]: true,
+  [Protocols.HYSTERIA]: true,
+  [Protocols.WIREGUARD]: true,
+};

+ 76 - 11
frontend/src/pages/api-docs/endpoints.ts

@@ -575,7 +575,7 @@ export const sections: readonly Section[] = [
           { name: 'order', in: 'query', type: 'string', desc: 'ascend or descend.' },
         ],
         response:
-          '{\n  "success": true,\n  "obj": {\n    "items": [\n      {\n        "email": "[email protected]",\n        "subId": "abcd1234",\n        "enable": true,\n        "totalGB": 53687091200,\n        "expiryTime": 1735689600000,\n        "limitIp": 0,\n        "reset": 0,\n        "inboundIds": [3, 5],\n        "traffic": { "up": 1024, "down": 4096, "enable": true },\n        "createdAt": 1735000000000,\n        "updatedAt": 1735100000000\n      }\n    ],\n    "total": 2000,\n    "filtered": 47,\n    "page": 1,\n    "pageSize": 25,\n    "summary": {\n      "total": 2000,\n      "active": 1850,\n      "onlineCount": 1,\n      "depletedCount": 0,\n      "expiringCount": 0,\n      "deactiveCount": 150,\n      "online": ["[email protected]"],\n      "depleted": [],\n      "expiring": [],\n      "deactive": ["[email protected]"]\n    }\n  }\n}',
+'{\n  "success": true,\n  "obj": {\n    "items": [\n      {\n        "email": "[email protected]",\n        "subId": "abcd1234",\n        "enable": true,\n        "totalGB": 53687091200,\n        "expiryTime": 1735689600000,\n        "limitIp": 0,\n        "limitHwid": 0,\n        "reset": 0,\n        "inboundIds": [3, 5],\n        "traffic": { "up": 1024, "down": 4096, "enable": true },\n        "createdAt": 1735000000000,\n        "updatedAt": 1735100000000\n      }\n    ],\n    "total": 2000,\n    "filtered": 47,\n    "page": 1,\n    "pageSize": 25,\n    "summary": {\n      "total": 2000,\n      "active": 1850,\n      "onlineCount": 1,\n      "depletedCount": 0,\n      "expiringCount": 0,\n      "deactiveCount": 150,\n      "online": ["[email protected]"],\n      "depleted": [],\n      "expiring": [],\n      "deactive": ["[email protected]"]\n    }\n  }\n}',
       },
       {
         method: 'GET',
@@ -602,10 +602,10 @@ export const sections: readonly Section[] = [
         path: '/panel/api/clients/add',
         summary: 'Create a new client and attach it to one or more inbounds in a single call. Body is JSON. Per-protocol secrets (UUID for VLESS/VMess, password for Trojan/Shadowsocks, auth for Hysteria) are generated server-side when omitted, so callers can send only the universal fields.',
         params: [
-          { name: 'client', in: 'body (json)', type: 'object', desc: 'Client fields: email, subId, id (uuid), password, auth, flow, totalGB, expiryTime, limitIp, tgId (numeric Telegram user ID, 0 = none), comment, enable.' },
+          { name: 'client', in: 'body (json)', type: 'object', desc: 'Client fields: email, subId, id (uuid), password, auth, flow, totalGB, expiryTime, limitIp, limitHwid, tgId (numeric Telegram user ID, 0 = none), comment, enable.' },
           { name: 'inboundIds', in: 'body (json)', type: 'integer[]', desc: 'Inbound IDs to attach the client to. At least one required.' },
         ],
-        body: '{\n  "client": {\n    "email": "[email protected]",\n    "totalGB": 53687091200,\n    "expiryTime": 1735689600000,\n    "tgId": 0,\n    "limitIp": 0,\n    "enable": true\n  },\n  "inboundIds": [3, 5]\n}',
+        body: '{\n  "client": {\n    "email": "[email protected]",\n    "totalGB": 53687091200,\n    "expiryTime": 1735689600000,\n    "tgId": 0,\n    "limitIp": 0,\n    "limitHwid": 0,\n    "enable": true\n  },\n  "inboundIds": [3, 5]\n}',
         response: '{\n  "success": true,\n  "msg": "Client added"\n}',
       },
       {
@@ -615,7 +615,7 @@ export const sections: readonly Section[] = [
         params: [
           { name: 'email', in: 'path', type: 'string', desc: 'Current client email (unique identifier).' },
         ],
-        body: '{\n  "email": "[email protected]",\n  "totalGB": 107374182400,\n  "expiryTime": 1767225600000,\n  "tgId": 123456789,\n  "enable": true\n}',
+        body: '{\n  "email": "[email protected]",\n  "totalGB": 107374182400,\n  "expiryTime": 1767225600000,\n  "limitHwid": 2,\n  "tgId": 123456789,\n  "enable": true\n}',
         response: '{\n  "success": true,\n  "msg": "Client updated"\n}',
       },
       {
@@ -676,14 +676,14 @@ export const sections: readonly Section[] = [
       {
         method: 'POST',
         path: '/panel/api/clients/delOrphans',
-        summary: 'Delete every client that is not attached to any inbound, along with its traffic record, IP log, and external links. Useful for clearing clients left unattached after their inbounds were removed. Returns the deleted count. Cannot be undone.',
+        summary: 'Delete every client that is not attached to any inbound, along with its traffic record, IP log, HWID devices, and external links. Useful for clearing clients left unattached after their inbounds were removed. Returns the deleted count. Cannot be undone.',
         response: '{\n  "success": true,\n  "obj": {\n    "deleted": 0\n  }\n}',
       },
       {
         method: 'GET',
         path: '/panel/api/clients/export',
         summary: 'Return every client as a {client, inboundIds} array — the same shape /bulkCreate and /import accept — so the payload round-trips straight back through /import. Clients with no inbound attachment are included with an empty inboundIds list. The UI shows this in a CodeMirror viewer (copy / download); programmatic callers get the array in obj.',
-        response: '{\n  "success": true,\n  "obj": [\n    {\n      "client": {\n        "email": "[email protected]",\n        "id": "...",\n        "totalGB": 53687091200,\n        "expiryTime": 0,\n        "enable": true,\n        "subId": "..."\n      },\n      "inboundIds": [7, 9]\n    }\n  ]\n}',
+        response: '{\n  "success": true,\n  "obj": [\n    {\n      "client": {\n        "email": "[email protected]",\n        "id": "...",\n        "totalGB": 53687091200,\n        "expiryTime": 0,\n        "limitHwid": 2,\n        "enable": true,\n        "subId": "..."\n      },\n      "inboundIds": [7, 9]\n    }\n  ]\n}',
       },
       {
         method: 'POST',
@@ -724,7 +724,7 @@ export const sections: readonly Section[] = [
         method: 'POST',
         path: '/panel/api/clients/bulkCreate',
         summary: 'Create many clients in one call. Body is a JSON array of {client, inboundIds} payloads — the same shape /add accepts. Items are processed sequentially; per-email skip reasons are returned for items that fail (e.g., duplicate email). Triggers a single Xray restart at the end if any inbound was running.',
-        body: '[\n  {\n    "client": {\n      "email": "[email protected]",\n      "totalGB": 53687091200,\n      "expiryTime": 0,\n      "enable": true\n    },\n    "inboundIds": [7]\n  },\n  {\n    "client": {\n      "email": "[email protected]",\n      "totalGB": 53687091200,\n      "expiryTime": 0,\n      "enable": true\n    },\n    "inboundIds": [7, 9]\n  }\n]',
+        body: '[\n  {\n    "client": {\n      "email": "[email protected]",\n      "totalGB": 53687091200,\n      "expiryTime": 0,\n      "limitHwid": 2,\n      "enable": true\n    },\n    "inboundIds": [7]\n  },\n  {\n    "client": {\n      "email": "[email protected]",\n      "totalGB": 53687091200,\n      "expiryTime": 0,\n      "limitHwid": 0,\n      "enable": true\n    },\n    "inboundIds": [7, 9]\n  }\n]',
         response: '{\n  "success": true,\n  "obj": {\n    "created": 2,\n    "skipped": [\n      { "email": "[email protected]", "reason": "email already in use" }\n    ]\n  }\n}',
       },
       {
@@ -846,6 +846,23 @@ export const sections: readonly Section[] = [
           { name: 'email', in: 'path', type: 'string', desc: 'Client email.' },
         ],
       },
+      {
+        method: 'POST',
+        path: '/panel/api/clients/hwids/:email',
+        summary: 'List registered HWID devices for a client. Hashes are not exposed.',
+        params: [
+          { name: 'email', in: 'path', type: 'string', desc: 'Client email.' },
+        ],
+        response: '{\n  "success": true,\n  "obj": [\n    {\n      "id": 1,\n      "firstSeen": 1735000000000,\n      "lastSeen": 1735100000000,\n      "userAgent": "Happ/1.0",\n      "deviceOs": "android",\n      "osVersion": "15",\n      "deviceModel": "Pixel 9"\n    }\n  ]\n}',
+      },
+      {
+        method: 'DELETE',
+        path: '/panel/api/clients/hwids/:email',
+        summary: 'Clear all registered HWID devices for a client so new devices can register again.',
+        params: [
+          { name: 'email', in: 'path', type: 'string', desc: 'Client email.' },
+        ],
+      },
       {
         method: 'POST',
         path: '/panel/api/clients/onlines',
@@ -935,6 +952,11 @@ export const sections: readonly Section[] = [
         summary: "Set the CA certificate this panel trusts for incoming node-API client certificates (this panel acting as a node). Paste the managing panel's CA (from nodes/mtls/ca). An empty caCert disables it. A non-empty value must be a PEM certificate. Applied on the next panel restart.",
         body: '{\n  "caCert": "-----BEGIN CERTIFICATE-----\\n...\\n-----END CERTIFICATE-----\\n"\n}',
       },
+      {
+        method: 'POST',
+        path: '/panel/api/nodes/mtls/reloadClient',
+        summary: 'Validate the stored master mTLS client credential and invalidate cached transports. Each transport closes its old idle pool and rebuilds with the rotated certificate before its next request.',
+      },
       {
         method: 'GET',
         path: '/panel/api/nodes/get/:id',
@@ -1228,7 +1250,7 @@ export const sections: readonly Section[] = [
     id: 'api-tokens',
     title: 'API Tokens',
     description:
-      'Manage Bearer tokens used for programmatic auth (bots, central panels acting on this node, CI). Each token has a unique name and an enabled flag — disable to revoke without deleting, delete to revoke permanently. Tokens are stored as SHA-256 hashes and the plaintext is returned only once, in the create response — it cannot be retrieved afterwards, so copy it then. Send one as <code>Authorization: Bearer &lt;token&gt;</code> on any /panel/api/* request — the token is a full-admin credential.',
+      'Manage scoped Bearer tokens for programmatic auth. Tokens grant admin, monitor, or node-sync access, may expire, and are stored as SHA-256 hashes. The plaintext is returned only once at creation.',
     endpoints: [
       {
         method: 'GET',
@@ -1239,11 +1261,13 @@ export const sections: readonly Section[] = [
       {
         method: 'POST',
         path: '/panel/api/setting/apiTokens/create',
-        summary: 'Mint a new API token. Name must be unique and 1-64 characters; the token string is server-generated and returned only in this response — it is stored hashed and cannot be retrieved later.',
+        summary: 'Mint a scoped API token. The server-generated plaintext is returned only once and stored as a hash.',
         params: [
           { name: 'name', in: 'body', type: 'string', desc: 'Human-readable label, e.g. "central-panel-a".' },
+          { name: 'scope', in: 'body', type: 'string', desc: 'admin (default), monitor, or node-sync.' },
+          { name: 'expiresAt', in: 'body', type: 'number', desc: 'Future Unix milliseconds, or 0 for no expiry.' },
         ],
-        body: '{\n  "name": "central-panel-a"\n}',
+        body: '{\n  "name": "central-panel-a",\n  "scope": "node-sync",\n  "expiresAt": 1798761600000\n}',
         responseSchema: 'ApiTokenView',
         errorResponse: '{\n  "success": false,\n  "msg": "a token with that name already exists"\n}',
       },
@@ -1253,7 +1277,9 @@ export const sections: readonly Section[] = [
         summary: 'Permanently delete a token. Any caller using it stops authenticating immediately.',
         params: [
           { name: 'id', in: 'path', type: 'number', desc: 'Token row ID.' },
+          { name: 'expectedScope', in: 'body', type: 'string', desc: 'Stored scope expected by the operator.' },
         ],
+        body: '{\n  "expectedScope": "node-sync"\n}',
         response: '{\n  "success": true\n}',
       },
       {
@@ -1263,8 +1289,9 @@ export const sections: readonly Section[] = [
         params: [
           { name: 'id', in: 'path', type: 'number', desc: 'Token row ID.' },
           { name: 'enabled', in: 'body', type: 'boolean', desc: 'New enabled state.' },
+          { name: 'expectedScope', in: 'body', type: 'string', desc: 'Stored scope expected by the operator.' },
         ],
-        body: '{\n  "enabled": false\n}',
+        body: '{\n  "enabled": false,\n  "expectedScope": "node-sync"\n}',
         response: '{\n  "success": true\n}',
       },
     ],
@@ -1393,6 +1420,44 @@ export const sections: readonly Section[] = [
         ],
         body: 'domain=example.com&port=443&network=tcp',
       },
+      {
+        method: 'GET',
+        path: '/panel/api/xray/geodata/files',
+        summary: 'List the geo databases (.dat files) in the Xray asset folder, with the layout detected from their contents, size, modification time and category count. A database that fails to parse is still listed, with the reason in "error".',
+      },
+      {
+        method: 'GET',
+        path: '/panel/api/xray/geodata/categories',
+        summary: 'One page of a database\'s categories, each with its entry count and the attributes its domains carry (e.g. "ads", "cn").',
+        params: [
+          { name: 'file', in: 'query', type: 'string', desc: 'Database file name inside the asset folder, e.g. geosite.dat (required).' },
+          { name: 'q', in: 'query', type: 'string', optional: true, desc: 'Case-insensitive substring filter on the category code.' },
+          { name: 'offset', in: 'query', type: 'integer', optional: true, desc: 'Rows to skip. Defaults to 0.' },
+          { name: 'limit', in: 'query', type: 'integer', optional: true, desc: 'Rows to return, capped at 500. Omit it to return every category — the index is small and the panel filters it client-side.' },
+        ],
+      },
+      {
+        method: 'GET',
+        path: '/panel/api/xray/geodata/entries',
+        summary: 'One page of the rules inside a category — domain rules typed as domain/full/keyword/regexp for geosite databases, CIDRs for geoip ones.',
+        params: [
+          { name: 'file', in: 'query', type: 'string', desc: 'Database file name inside the asset folder (required).' },
+          { name: 'code', in: 'query', type: 'string', desc: 'Category code, case-insensitive, e.g. google (required).' },
+          { name: 'q', in: 'query', type: 'string', optional: true, desc: 'Case-insensitive substring filter on the rule value.' },
+          { name: 'offset', in: 'query', type: 'integer', optional: true, desc: 'Rows to skip. Defaults to 0.' },
+          { name: 'limit', in: 'query', type: 'integer', optional: true, desc: 'Rows to return, capped at 500. Defaults to the cap.' },
+        ],
+      },
+      {
+        method: 'POST',
+        path: '/panel/api/xray/geodata/validate',
+        summary: 'Check routing tokens against the databases on disk and return only the ones that do not resolve. Plain domains and CIDRs are ignored. Each issue carries a reason: syntax, fileMissing or categoryMissing.',
+        params: [
+          { name: 'tokens', in: 'body (form)', type: 'string', desc: 'Comma-separated routing tokens, e.g. "geosite:google,geosite:blabla". Max 500 per request.' },
+          { name: 'kind', in: 'body (form)', type: 'string', desc: '"ip" to parse the tokens as IP rules (geoip:, ext-ip:, leading !). Anything else parses them as domain rules (geosite:, ext-site:).' },
+        ],
+        body: 'kind=domain&tokens=geosite:google,geosite:blabla',
+      },
       {
         method: 'GET',
         path: '/panel/api/xray/outbound-subs',

+ 11 - 0
frontend/src/pages/clients/ClientBulkAddModal.tsx

@@ -33,6 +33,7 @@ const EMPTY: ClientBulkAddFormValues = {
   comment: '',
   flow: '',
   limitIp: 0,
+  limitHwid: 0,
   totalGB: 0,
   expiryTime: 0,
   reset: 0,
@@ -176,6 +177,7 @@ export default function ClientBulkAddModal({
           expiryTime: current.expiryTime,
           reset: Number(current.reset) || 0,
           limitIp: Number(current.limitIp) || 0,
+          limitHwid: Number(current.limitHwid) || 0,
           group: current.group,
           comment: current.comment,
           enable: true,
@@ -301,6 +303,15 @@ export default function ClientBulkAddModal({
               />
             </FormField>
 
+            <FormField
+              name="limitHwid"
+              label={t('pages.clients.limitHwid')}
+              tooltip={t('pages.clients.limitHwidDesc')}
+              transform={{ output: (v) => Number(v) || 0 }}
+            >
+              <InputNumber min={0} />
+            </FormField>
+
             <FormField name="comment" label={t('comment')}>
               <Input />
             </FormField>

+ 117 - 3
frontend/src/pages/clients/ClientFormModal.tsx

@@ -57,6 +57,16 @@ interface ApiMsg<T = unknown> {
   obj?: T;
 }
 
+interface ClientHwidInfo {
+  id: number;
+  firstSeen: number;
+  lastSeen: number;
+  userAgent: string;
+  deviceOs: string;
+  osVersion: string;
+  deviceModel: string;
+}
+
 type Mode = 'add' | 'edit';
 
 interface SaveMetaEdit {
@@ -97,6 +107,7 @@ interface ClientFormModalProps {
 
 type Values = ClientFormValues & {
   expiryDate: number;
+  limitHwid: number;
   externalLinks: ExternalLinkRow[];
   wgPrivateKey: string;
   wgPublicKey: string;
@@ -121,6 +132,7 @@ const EMPTY: Values = {
   delayedDays: 0,
   reset: 0,
   limitIp: 0,
+  limitHwid: 0,
   tgId: 0,
   group: '',
   comment: '',
@@ -189,6 +201,7 @@ export default function ClientFormModal({
   const uuid = useWatch({ control: methods.control, name: 'uuid' });
   const password = useWatch({ control: methods.control, name: 'password' });
   const subId = useWatch({ control: methods.control, name: 'subId' });
+  const limitHwid = useWatch({ control: methods.control, name: 'limitHwid' });
   const auth = useWatch({ control: methods.control, name: 'auth' });
   const wgPrivateKey = useWatch({ control: methods.control, name: 'wgPrivateKey' });
   const limitIp = useWatch({ control: methods.control, name: 'limitIp' });
@@ -204,6 +217,10 @@ export default function ClientFormModal({
   const [ipsLoading, setIpsLoading] = useState(false);
   const [ipsClearing, setIpsClearing] = useState(false);
   const [ipsModalOpen, setIpsModalOpen] = useState(false);
+  const [clientHwids, setClientHwids] = useState<ClientHwidInfo[]>([]);
+  const [hwidsLoading, setHwidsLoading] = useState(false);
+  const [hwidsClearing, setHwidsClearing] = useState(false);
+  const [hwidsModalOpen, setHwidsModalOpen] = useState(false);
   const fail2ban = useFail2banStatusQuery();
   const limitIpDisabled = !fail2ban.usable;
   const limitIpNotice = getLimitIpNotice(fail2ban, t);
@@ -215,6 +232,7 @@ export default function ClientFormModal({
   useEffect(() => {
     if (!open) return;
     setIpsModalOpen(false);
+    setHwidsModalOpen(false);
 
     if (isEdit && client) {
       const et = Number(client.expiryTime) || 0;
@@ -233,6 +251,7 @@ export default function ClientFormModal({
         totalGB: bytesToGB(client.totalGB || 0),
         reset: Number(client.reset) || 0,
         limitIp: client.limitIp || 0,
+        limitHwid: client.limitHwid || 0,
         tgId: Number(client.tgId) || 0,
         group: client.group || '',
         comment: client.comment || '',
@@ -257,6 +276,7 @@ export default function ClientFormModal({
       }
       methods.reset(seed);
       void loadIps();
+      void loadHwids();
     } else {
       const wgKeypair = Wireguard.generateKeypair();
       methods.reset({
@@ -455,6 +475,34 @@ export default function ClientFormModal({
     }
   }
 
+  async function loadHwids() {
+    if (!isEdit || !client?.email) return;
+    setHwidsLoading(true);
+    try {
+      const msg = await HttpUtil.post(`/panel/api/clients/hwids/${encodeURIComponent(client.email)}`) as ApiMsg<unknown[]>;
+      if (!msg?.success || !Array.isArray(msg.obj)) { setClientHwids([]); return; }
+      setClientHwids(msg.obj.filter((x): x is ClientHwidInfo => !!x && typeof x === 'object' && typeof (x as ClientHwidInfo).id === 'number'));
+    } finally {
+      setHwidsLoading(false);
+    }
+  }
+
+  function openHwidsModal() {
+    setHwidsModalOpen(true);
+    if (clientHwids.length === 0) void loadHwids();
+  }
+
+  async function clearHwids() {
+    if (!isEdit || !client?.email) return;
+    setHwidsClearing(true);
+    try {
+      const msg = await HttpUtil.delete(`/panel/api/clients/hwids/${encodeURIComponent(client.email)}`) as ApiMsg;
+      if (msg?.success) setClientHwids([]);
+    } finally {
+      setHwidsClearing(false);
+    }
+  }
+
   function close() {
     onOpenChange(false);
   }
@@ -478,7 +526,7 @@ export default function ClientFormModal({
     const values = methods.getValues();
     const schema = isEdit ? ClientFormSchema : ClientCreateFormSchema;
     const validated = schema.safeParse({
-      email: values.email,
+email: values.email,
       subId: values.subId,
       uuid: values.uuid,
       password: values.password,
@@ -491,6 +539,7 @@ export default function ClientFormModal({
       delayedDays: values.delayedDays,
       reset: values.reset,
       limitIp: values.limitIp,
+      limitHwid: values.limitHwid,
       tgId: values.tgId,
       group: values.group,
       comment: values.comment,
@@ -516,8 +565,9 @@ export default function ClientFormModal({
       security: showSecurity ? (values.security || 'auto') : 'auto',
       totalGB: totalBytes,
       expiryTime,
-      reset: Number(values.reset) || 0,
+reset: Number(values.reset) || 0,
       limitIp: Number(values.limitIp) || 0,
+      limitHwid: Number(values.limitHwid) || 0,
       tgId: Number(values.tgId) || 0,
       group: values.group,
       comment: values.comment,
@@ -621,7 +671,7 @@ export default function ClientFormModal({
           </div>
         }
       >
-        <FormProvider {...methods}>
+<FormProvider {...methods}>
           <Form layout="vertical">
             <Tabs
               defaultActiveKey="basic"
@@ -677,6 +727,21 @@ export default function ClientFormModal({
                             </Tooltip>
                           </Form.Item>
                         </Col>
+                        <Col xs={24} md={6}>
+                          <Form.Item label={t('pages.clients.limitHwid')} tooltip={t('pages.clients.limitHwidDesc')}>
+                            <Space.Compact style={{ display: 'flex' }}>
+                              <InputNumber value={limitHwid} min={0} style={{ flex: 1 }}
+                                onChange={(v) => methods.setValue('limitHwid', Number(v) || 0)} />
+                              {isEdit && (
+                                <Tooltip title={t('pages.clients.hwidLog')}>
+                                  <Button aria-label={t('pages.clients.hwidLog')} icon={<EyeOutlined />} loading={hwidsLoading} onClick={openHwidsModal}>
+                                    {clientHwids.length > 0 ? clientHwids.length : ''}
+                                  </Button>
+                                </Tooltip>
+                              )}
+                            </Space.Compact>
+                          </Form.Item>
+                        </Col>
                       </Row>
 
                       <Row gutter={16}>
@@ -1012,6 +1077,55 @@ export default function ClientFormModal({
           <Tag>{t('tgbot.noIpRecord')}</Tag>
         )}
       </Modal>
+
+      <Modal
+        open={hwidsModalOpen}
+        title={`${t('pages.clients.hwidLog')}${client?.email ? ` — ${client.email}` : ''}`}
+        width={520}
+        zIndex={CLIENT_IP_LOG_MODAL_Z_INDEX}
+        onCancel={() => setHwidsModalOpen(false)}
+        footer={[
+          <Button key="refresh" icon={<ReloadOutlined />} loading={hwidsLoading} onClick={loadHwids}>
+            {t('refresh')}
+          </Button>,
+          <Button key="clear" danger loading={hwidsClearing} disabled={clientHwids.length === 0} onClick={clearHwids}>
+            {t('pages.clients.clearAll')}
+          </Button>,
+          <Button key="close" type="primary" onClick={() => setHwidsModalOpen(false)}>
+            {t('close')}
+          </Button>,
+        ]}
+      >
+        {clientHwids.length > 0 ? (
+          <div style={{ maxHeight: 360, overflowY: 'auto' }}>
+            {clientHwids.map((entry) => (
+              <div key={entry.id} style={{ borderBottom: '1px solid var(--ant-color-border-secondary)', padding: '8px 0' }}>
+                <Typography.Text strong>{entry.deviceModel || entry.userAgent || t('pages.clients.hwidDevice')}</Typography.Text>
+                <br />
+                <Typography.Text type="secondary">
+                  {[entry.deviceOs, entry.osVersion].filter(Boolean).join(' ')}
+                </Typography.Text>
+                <br />
+                <Typography.Text type="secondary">
+                  {t('pages.clients.firstSeen')}: {entry.firstSeen ? dayjs(entry.firstSeen).format('YYYY-MM-DD HH:mm') : '-'}
+                </Typography.Text>
+                <br />
+                <Typography.Text type="secondary">
+                  {t('pages.clients.lastSeen')}: {entry.lastSeen ? dayjs(entry.lastSeen).format('YYYY-MM-DD HH:mm') : '-'}
+                </Typography.Text>
+                {entry.userAgent && (
+                  <>
+                    <br />
+                    <Typography.Text type="secondary" style={{ wordBreak: 'break-all' }}>{entry.userAgent}</Typography.Text>
+                  </>
+                )}
+              </div>
+            ))}
+          </div>
+        ) : (
+          <Tag>{t('pages.clients.noHwids')}</Tag>
+        )}
+      </Modal>
     </>
   );
 }

+ 4 - 1
frontend/src/pages/clients/ClientInfoModal.tsx

@@ -218,7 +218,10 @@ export default function ClientInfoModal({
                     {client.enable && isOnline
                       ? <Tag color="green">{t('pages.clients.online')}</Tag>
                       : <Tag>{t('pages.clients.offline')}</Tag>}
-                    <span className="hint">{t('lastOnline')}: {dateLabel(traffic?.lastOnline)}</span>
+                    <span className="hint">
+                      {t('lastOnline')}: {dateLabel(traffic?.lastOnline)}
+                      {' · '}{t('lastSubFetch')}: {dateLabel(traffic?.lastSubFetch)}
+                    </span>
                   </td>
                 </tr>
                 <tr>

+ 2 - 1
frontend/src/pages/clients/ClientsPage.tsx

@@ -852,7 +852,8 @@ export default function ClientsPage() {
       render: (_v, record) => {
         const bucket = clientBucket(record);
         const lastOnline = record.traffic?.lastOnline ?? 0;
-        const lastOnlineTitle = `${t('lastOnline')}: ${lastOnline > 0 ? IntlUtil.formatDate(lastOnline, datepicker) : '-'}`;
+        const lastSubFetch = record.traffic?.lastSubFetch ?? 0;
+        const lastOnlineTitle = `${t('lastOnline')}: ${lastOnline > 0 ? IntlUtil.formatDate(lastOnline, datepicker) : '-'}\n${t('lastSubFetch')}: ${lastSubFetch > 0 ? IntlUtil.formatDate(lastSubFetch, datepicker) : '-'}`;
         if (bucket === 'depleted') return (
           <Tooltip title={lastOnlineTitle}>
             <Tag color="red">{t('depleted')}</Tag>

+ 136 - 0
frontend/src/pages/inbounds/CloneInboundModal.tsx

@@ -0,0 +1,136 @@
+import { useEffect, useMemo, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { Modal, Select, Typography, message } from 'antd';
+
+import { HttpUtil } from '@/utils';
+import { SelectAllClearButtons } from '@/components/form';
+import { buildClonePayload, pickClonePort } from '@/lib/xray/inbound-clone';
+import type { NodeRecord } from '@/api/queries/useNodesQuery';
+import type { DBInbound } from '@/models/dbinbound';
+
+// 0 is the "local panel" sentinel (inbounds without a nodeId) — the same
+// convention as the clients page node filter (#4997).
+const LOCAL_PANEL = 0;
+
+interface CloneInboundModalProps {
+  open: boolean;
+  dbInbound: DBInbound | null;
+  nodes: NodeRecord[];
+  portsInUse: Map<number, Set<number>>;
+  onClose: () => void;
+  onCloned: () => void | Promise<void>;
+}
+
+export default function CloneInboundModal({
+  open,
+  dbInbound,
+  nodes,
+  portsInUse,
+  onClose,
+  onCloned,
+}: CloneInboundModalProps) {
+  const { t } = useTranslation();
+  const [messageApi, messageContextHolder] = message.useMessage();
+  const [targets, setTargets] = useState<number[]>([LOCAL_PANEL]);
+  const [submitting, setSubmitting] = useState(false);
+
+  const targetOptions = useMemo(() => [
+    { value: LOCAL_PANEL, label: t('pages.inbounds.localPanel'), disabled: false },
+    ...(nodes || []).filter((n) => n.enable).map((n) => ({
+      value: n.id,
+      // Only online nodes are deployable targets: nodes report `unknown`
+      // until their first heartbeat, and the backend refuses any status
+      // other than online.
+      label: `${n.name}${n.status === 'online' ? '' : ` (${n.status || 'offline'})`}`,
+      disabled: n.status !== 'online',
+    })),
+  ], [nodes, t]);
+
+  // "Select all" must not pick targets the user can't pick manually —
+  // offline nodes are disabled options in the dropdown.
+  const selectableOptions = useMemo(() => targetOptions.filter((o) => !o.disabled), [targetOptions]);
+
+  // Reset the selection when the dialog OPENS: pre-select the source
+  // inbound's own node when it is a selectable target, otherwise the local
+  // panel (the only destination the clone action had before this picker).
+  // Deps are deliberately `[open]` only — `nodes` gets a new identity on every
+  // background refetch (heartbeats bump latency/status), and keying the reset
+  // on it would clobber the user's selection mid-dialog.
+  useEffect(() => {
+    if (!open || !dbInbound) return;
+    const src = dbInbound.nodeId ?? LOCAL_PANEL;
+    const srcNode = (nodes || []).find((n) => n.id === src);
+    const selectable = !!srcNode && !!srcNode.enable && srcNode.status === 'online';
+    setTargets([selectable ? src : LOCAL_PANEL]);
+    /* eslint-disable-next-line react-hooks/exhaustive-deps */
+  }, [open]);
+
+  async function submit() {
+    if (!dbInbound || targets.length === 0) return;
+    setSubmitting(true);
+    try {
+      // Sequential posts keep per-target results in selection order; every
+      // target gets its own fresh port because ports are only node-scoped.
+      const results: { ok: boolean; reason: string }[] = [];
+      for (const target of targets) {
+        const msg = await HttpUtil.post(
+          '/panel/api/inbounds/add',
+          buildClonePayload(dbInbound, pickClonePort(portsInUse.get(target)), target === LOCAL_PANEL ? null : target),
+          { silent: true },
+        );
+        results.push({ ok: !!msg?.success, reason: msg?.success ? '' : (msg?.msg || '') });
+      }
+      const okCount = results.filter((r) => r.ok).length;
+      const failed = results.length - okCount;
+      if (failed === 0) {
+        messageApi.success(okCount === 1
+          ? t('pages.inbounds.toasts.inboundCreateSuccess')
+          : t('pages.inbounds.toasts.clonedMany', { count: okCount }));
+      } else {
+        const firstError = results.find((r) => !r.ok)?.reason ?? '';
+        const base = t('pages.inbounds.toasts.clonedMixed', { ok: okCount, failed });
+        messageApi.warning(firstError ? `${base} — ${firstError}` : base);
+      }
+      if (okCount > 0) await onCloned();
+      onClose();
+    } finally {
+      setSubmitting(false);
+    }
+  }
+
+  return (
+    <>
+      {messageContextHolder}
+      <Modal
+        open={open}
+        title={t('pages.inbounds.cloneConfirmTitle', { remark: dbInbound?.remark ?? '' })}
+        okText={t('pages.inbounds.clone')}
+        cancelText={t('cancel')}
+        okButtonProps={{ disabled: targets.length === 0, loading: submitting }}
+        onCancel={onClose}
+        onOk={submit}
+        destroyOnHidden
+      >
+        <Typography.Paragraph type="secondary">
+          {t('pages.inbounds.cloneConfirmContent')}
+        </Typography.Paragraph>
+        <SelectAllClearButtons
+          options={selectableOptions}
+          value={targets}
+          onChange={setTargets}
+        />
+        <Select
+          aria-label={t('pages.inbounds.deployTo')}
+          mode="multiple"
+          style={{ width: '100%' }}
+          value={targets}
+          onChange={setTargets}
+          options={targetOptions}
+          placeholder={t('pages.inbounds.deployTo')}
+          showSearch={{ optionFilterProp: 'label' }}
+          autoFocus
+        />
+      </Modal>
+    </>
+  );
+}

+ 42 - 34
frontend/src/pages/inbounds/InboundsPage.tsx

@@ -23,7 +23,8 @@ import {
 } from '@ant-design/icons';
 
 import { HttpUtil, SizeFormatter, RandomUtil } from '@/utils';
-import { createDefaultInboundSettings } from '@/lib/xray/inbound-defaults';
+import { buildClonePayload } from '@/lib/xray/inbound-clone';
+import { NODE_ELIGIBLE_PROTOCOLS } from '@/lib/xray/node-protocols';
 import { genInboundLinks, genWireguardLinks, preferPublicHost } from '@/lib/xray/inbound-link';
 import { inboundFromDb } from '@/lib/xray/inbound-from-db';
 import { coerceInboundJsonField, type DBInbound } from '@/models/dbinbound';
@@ -40,6 +41,7 @@ import { useInbounds } from './useInbounds';
 import { InboundList } from './list';
 import { LazyMount } from '@/components/utility';
 const InboundFormModal = lazy(() => import('./form/InboundFormModal'));
+const CloneInboundModal = lazy(() => import('./CloneInboundModal'));
 const InboundInfoModal = lazy(() => import('./info/InboundInfoModal'));
 const QrCodeModal = lazy(() => import('./qr/QrCodeModal'));
 const AttachClientsModal = lazy(() => import('./clients/AttachClientsModal'));
@@ -118,6 +120,20 @@ export default function InboundsPage() {
   );
   const showNodeInfo = hasNodeAttachedInbound || hasActiveNode;
 
+  // Ports already bound per clone target (0 = local panel, matching the
+  // clients page node-filter sentinel), for the clone dialog's client-side
+  // conflict pre-check.
+  const clonePortsInUse = useMemo(() => {
+    const map = new Map<number, Set<number>>();
+    for (const ib of dbInbounds || []) {
+      const key = ib.nodeId ?? 0;
+      const ports = map.get(key) ?? new Set<number>();
+      ports.add(ib.port);
+      map.set(key, ports);
+    }
+    return map;
+  }, [dbInbounds]);
+
   useWebSocket({
     traffic: applyTrafficEvent,
     client_stats: applyClientStatsEvent,
@@ -144,6 +160,9 @@ export default function InboundsPage() {
   const [groupOpen, setGroupOpen] = useState(false);
   const [groupSource, setGroupSource] = useState<DBInbound | null>(null);
 
+  const [cloneOpen, setCloneOpen] = useState(false);
+  const [cloneSource, setCloneSource] = useState<DBInbound | null>(null);
+
   const [textOpen, setTextOpen] = useState(false);
   const [textTitle, setTextTitle] = useState('');
   const [textContent, setTextContent] = useState('');
@@ -429,48 +448,27 @@ export default function InboundsPage() {
   }, [modal, refresh, t, clientCount]);
 
   const confirmClone = useCallback((dbInbound: DBInbound) => {
+    // Node-eligible protocol with at least one deployable node → open the
+    // target picker; anything else keeps the original one-click local clone.
+    if (NODE_ELIGIBLE_PROTOCOLS[dbInbound.protocol] && (nodesList || []).some((n) => n.enable && n.status === 'online')) {
+      setCloneSource(dbInbound);
+      setCloneOpen(true);
+      return;
+    }
     modal.confirm({
       title: t('pages.inbounds.cloneConfirmTitle', { remark: dbInbound.remark }),
       content: t('pages.inbounds.cloneConfirmContent'),
       okText: t('pages.inbounds.clone'),
       cancelText: t('cancel'),
       onOk: async () => {
-        let clonedSettings: string;
-        try {
-          const raw = coerceInboundJsonField(dbInbound.settings);
-          raw.clients = [];
-          clonedSettings = JSON.stringify(raw);
-        } catch {
-          const fallback = createDefaultInboundSettings(dbInbound.protocol);
-          clonedSettings = fallback ? JSON.stringify(fallback, null, 2) : '{}';
-        }
-        const streamSettingsString = typeof dbInbound.streamSettings === 'string'
-          ? dbInbound.streamSettings
-          : JSON.stringify(dbInbound.streamSettings ?? {});
-        const sniffingString = typeof dbInbound.sniffing === 'string'
-          ? dbInbound.sniffing
-          : JSON.stringify(dbInbound.sniffing ?? {});
-        const data = {
-          up: 0,
-          down: 0,
-          total: 0,
-          remark: `${dbInbound.remark} (clone)`,
-          enable: false,
-          expiryTime: 0,
-          listen: '',
-          port: RandomUtil.randomInteger(10000, 60000),
-          protocol: dbInbound.protocol,
-          settings: clonedSettings,
-          streamSettings: streamSettingsString,
-          sniffing: sniffingString,
-          shareAddrStrategy: dbInbound.shareAddrStrategy,
-          shareAddr: dbInbound.shareAddr,
-        };
-        const msg = await HttpUtil.post('/panel/api/inbounds/add', data);
+        const msg = await HttpUtil.post(
+          '/panel/api/inbounds/add',
+          buildClonePayload(dbInbound, RandomUtil.randomInteger(10000, 60000), null),
+        );
         if (msg?.success) await refresh();
       },
     });
-  }, [modal, refresh, t]);
+  }, [modal, nodesList, refresh, t]);
 
   const onGeneralAction = useCallback((key: GeneralAction) => {
     switch (key) {
@@ -709,6 +707,16 @@ export default function InboundsPage() {
             source={groupSource}
           />
         </LazyMount>
+        <LazyMount when={cloneOpen}>
+          <CloneInboundModal
+            open={cloneOpen}
+            onClose={() => setCloneOpen(false)}
+            onCloned={refresh}
+            dbInbound={cloneSource}
+            nodes={nodesList || []}
+            portsInUse={clonePortsInUse}
+          />
+        </LazyMount>
 
         <LazyMount when={textOpen}>
           <TextModal

+ 7 - 12
frontend/src/pages/inbounds/form/InboundFormModal.tsx

@@ -43,6 +43,7 @@ import { Protocols } from '@/schemas/primitives';
 import { SockoptStreamSettingsSchema } from '@/schemas/protocols/stream/sockopt';
 import { HysteriaStreamSettingsSchema } from '@/schemas/protocols/stream/hysteria';
 import { createHysteriaTlsSettingsWithDefaultCert } from '@/lib/xray/inbound-tls-defaults';
+import { NODE_ELIGIBLE_PROTOCOLS } from '@/lib/xray/node-protocols';
 import { VLESS_AUTH_LABEL_KEYS, vlessEncryptionAuthKind } from '@/lib/xray/vless-encryption';
 import { SniffingSchema } from '@/schemas/primitives/sniffing';
 import { TcpStreamSettingsSchema } from '@/schemas/protocols/stream/tcp';
@@ -101,14 +102,6 @@ const PROTOCOL_OPTIONS = Object.values(Protocols).map((p) => ({ value: p, label:
 const TRAFFIC_RESETS = ['never', 'hourly', 'daily', 'weekly', 'monthly'] as const;
 const SHARE_ADDR_STRATEGIES = ['node', 'listen', 'custom'] as const;
 const SHARE_ADDR_HOSTNAME_RE = /^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)*$/;
-const NODE_ELIGIBLE_PROTOCOLS = new Set<string>([
-  Protocols.VLESS,
-  Protocols.VMESS,
-  Protocols.TROJAN,
-  Protocols.SHADOWSOCKS,
-  Protocols.HYSTERIA,
-  Protocols.WIREGUARD,
-]);
 
 function isValidShareAddrInput(value: string): boolean {
   const v = value.trim();
@@ -216,7 +209,7 @@ export default function InboundFormModal({
 
   const selectableNodes = (availableNodes || []).filter((n) => n.enable);
   const protocol = (useWatch({ control, name: 'protocol' }) ?? '') as string;
-  const isNodeEligible = NODE_ELIGIBLE_PROTOCOLS.has(protocol);
+  const isNodeEligible = !!NODE_ELIGIBLE_PROTOCOLS[protocol];
   /*
    * The `node` share-address strategy only means something when the inbound can
    * actually live on a node — otherwise the node address it would resolve to is
@@ -434,7 +427,7 @@ export default function InboundFormModal({
       const next = getV('protocol') as string;
       const settings = createDefaultInboundSettings(next) ?? undefined;
       setV('settings', settings);
-      if (!NODE_ELIGIBLE_PROTOCOLS.has(next)) {
+      if (!NODE_ELIGIBLE_PROTOCOLS[next]) {
         setV('nodeId', null);
       }
       if (next === Protocols.HYSTERIA) {
@@ -534,8 +527,10 @@ export default function InboundFormModal({
             allowClear
             options={selectableNodes.map((n) => ({
               value: n.id,
-              label: `${n.name}${n.status === 'offline' ? ' (offline)' : ''}`,
-              disabled: n.status === 'offline',
+              // Same rule as the clone target picker: only online is
+              // deployable (`unknown` = no heartbeat yet).
+              label: `${n.name}${n.status === 'online' ? '' : ` (${n.status || 'offline'})`}`,
+              disabled: n.status !== 'online',
             }))}
           />
         </FormField>

+ 4 - 2
frontend/src/pages/settings/SecurityTab.tsx

@@ -32,6 +32,8 @@ interface ApiTokenRow {
   name: string;
   enabled: boolean;
   createdAt: number;
+  scope: 'admin' | 'monitor' | 'node-sync';
+  expiresAt: number;
 }
 
 interface SecurityTabProps {
@@ -187,7 +189,7 @@ export default function SecurityTab({ allSetting, updateSetting, saveSetting }:
       cancelText: t('cancel'),
       okType: 'danger',
       onOk: async () => {
-        const msg = await HttpUtil.post(`/panel/api/setting/apiTokens/delete/${row.id}`) as ApiMsg;
+        const msg = await HttpUtil.post(`/panel/api/setting/apiTokens/delete/${row.id}`, { expectedScope: row.scope }) as ApiMsg;
         if (msg?.success) await loadApiTokens();
       },
     });
@@ -195,7 +197,7 @@ export default function SecurityTab({ allSetting, updateSetting, saveSetting }:
 
   async function toggleTokenEnabled(row: ApiTokenRow) {
     const target = !row.enabled;
-    const msg = await HttpUtil.post(`/panel/api/setting/apiTokens/setEnabled/${row.id}`, { enabled: target }) as ApiMsg;
+    const msg = await HttpUtil.post(`/panel/api/setting/apiTokens/setEnabled/${row.id}`, { enabled: target, expectedScope: row.scope }) as ApiMsg;
     if (msg?.success) {
       setApiTokens((prev) => prev.map((r) => (r.id === row.id ? { ...r, enabled: target } : r)));
     }

+ 24 - 7
frontend/src/pages/settings/SubscriptionGeneralTab.tsx

@@ -118,19 +118,36 @@ export default function SubscriptionGeneralTab({ allSetting, updateSetting }: Su
         children: (
           <>
             <SettingListItem paddings="small" title={t('pages.settings.subTitle')} description={t('pages.settings.subTitleDesc')}>
-              <Input value={allSetting.subTitle} onChange={(e) => updateSetting({ subTitle: e.target.value })} />
+              <RemarkTemplateField
+                value={allSetting.subTitle}
+                onChange={(v) => updateSetting({ subTitle: v })}
+                metadataOnly
+              />
             </SettingListItem>
             <SettingListItem paddings="small" title={t('pages.settings.subSupportUrl')} description={t('pages.settings.subSupportUrlDesc')}>
-              <Input value={allSetting.subSupportUrl} placeholder="https://example.com"
-                onChange={(e) => updateSetting({ subSupportUrl: e.target.value })} />
+              <RemarkTemplateField
+                value={allSetting.subSupportUrl}
+                placeholder="https://example.com"
+                onChange={(v) => updateSetting({ subSupportUrl: v })}
+                metadataOnly
+              />
             </SettingListItem>
             <SettingListItem paddings="small" title={t('pages.settings.subProfileUrl')} description={t('pages.settings.subProfileUrlDesc')}>
-              <Input value={allSetting.subProfileUrl} placeholder="https://example.com"
-                onChange={(e) => updateSetting({ subProfileUrl: e.target.value })} />
+              <RemarkTemplateField
+                value={allSetting.subProfileUrl}
+                placeholder="https://example.com"
+                onChange={(v) => updateSetting({ subProfileUrl: v })}
+                metadataOnly
+              />
             </SettingListItem>
             <SettingListItem paddings="small" title={t('pages.settings.subAnnounce')} description={t('pages.settings.subAnnounceDesc')}>
-              <Input.TextArea value={allSetting.subAnnounce}
-                onChange={(e) => updateSetting({ subAnnounce: e.target.value })} />
+              <RemarkTemplateField
+                value={allSetting.subAnnounce}
+                onChange={(v) => updateSetting({ subAnnounce: v })}
+                multiline
+                rows={3}
+                metadataOnly
+              />
             </SettingListItem>
             <SettingListItem
               paddings="small"

+ 2 - 8
frontend/src/pages/sub/SubPage.tsx

@@ -38,6 +38,7 @@ import { LinkTags, parseLinkParts } from '@/lib/xray/link-label';
 import ConfigBlock from '@/components/clients/ConfigBlock';
 import { setMessageInstance } from '@/utils/messageBus';
 import { pauseAnimationsUntilLeave, useTheme } from '@/hooks/useTheme';
+import { useMediaQuery } from '@/hooks/useMediaQuery';
 import SubUsageSummary from './SubUsageSummary';
 import './SubPage.css';
 
@@ -84,16 +85,9 @@ export default function SubPage() {
   const { isDark, isUltra, toggleTheme, toggleUltra, antdThemeConfig } = useTheme();
   const [messageApi, messageContextHolder] = message.useMessage();
   useEffect(() => { setMessageInstance(messageApi); }, [messageApi]);
-
-  const [isMobile, setIsMobile] = useState<boolean>(() => window.innerWidth < 576);
+  const { isMobile } = useMediaQuery(576);
   const [lang, setLang] = useState<string>(() => LanguageManager.getLanguage());
 
-  useEffect(() => {
-    const onResize = () => setIsMobile(window.innerWidth < 576);
-    window.addEventListener('resize', onResize);
-    return () => window.removeEventListener('resize', onResize);
-  }, []);
-
   const onLangChange = useCallback((next: string) => {
     setLang(next);
     LanguageManager.setLanguage(next);

+ 4 - 3
frontend/src/pages/xray/routing/RuleFormModal.tsx

@@ -4,6 +4,7 @@ import { Button, Form, Input, Modal, Select, Space, Switch, Tooltip } from 'antd
 import { PlusOutlined, MinusOutlined, QuestionCircleOutlined } from '@ant-design/icons';
 import { FormProvider, useForm, useWatch } from 'react-hook-form';
 import { InputAddon } from '@/components/ui';
+import { GeoTokenInput } from '@/components/geodata';
 import { FormField } from '@/components/form/rhf';
 import { useInboundOptions } from '@/api/queries/useInboundOptions';
 import { RuleFormSchema, type RuleFormValues } from '@/schemas/xray';
@@ -173,7 +174,7 @@ export default function RuleFormModal({
               </Tooltip>
             }
           >
-            <Input placeholder="0.0.0.0/8, fc00::/7, geoip:ir" />
+            <GeoTokenInput kind="ip" placeholder="0.0.0.0/8, fc00::/7, geoip:ir" />
           </FormField>
 
           <FormField
@@ -253,7 +254,7 @@ export default function RuleFormModal({
               </Tooltip>
             }
           >
-            <Input placeholder="0.0.0.0/8, fc00::/7, geoip:ir" />
+            <GeoTokenInput kind="ip" placeholder="0.0.0.0/8, fc00::/7, geoip:ir" />
           </FormField>
 
           <FormField
@@ -264,7 +265,7 @@ export default function RuleFormModal({
               </Tooltip>
             }
           >
-            <Input placeholder="google.com, geosite:cn" />
+            <GeoTokenInput kind="domain" placeholder="google.com, geosite:cn" />
           </FormField>
 
           <FormField

+ 12 - 1
frontend/src/routes.tsx

@@ -1,5 +1,6 @@
 import { lazy, Suspense } from 'react';
 import { createBrowserRouter, type RouteObject } from 'react-router';
+import { Spin } from 'antd';
 
 import PanelLayout from '@/layouts/PanelLayout';
 
@@ -14,7 +15,17 @@ const XrayPage = lazy(() => import('@/pages/xray/XrayPage'));
 const ApiDocsPage = lazy(() => import('@/pages/api-docs/ApiDocsPage'));
 
 function withSuspense(node: React.ReactNode) {
-  return <Suspense fallback={null}>{node}</Suspense>;
+  return (
+    <Suspense
+      fallback={
+        <div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '60vh' }}>
+          <Spin size="large" />
+        </div>
+      }
+    >
+      {node}
+    </Suspense>
+  );
 }
 
 const routes: RouteObject[] = [

+ 4 - 0
frontend/src/schemas/client.ts

@@ -10,6 +10,7 @@ export const ClientTrafficSchema = z.object({
   expiryTime: z.number().optional(),
   enable: z.boolean().optional(),
   lastOnline: z.number().optional(),
+  lastSubFetch: z.number().optional(),
 });
 
 export const ClientRecordSchema = z.object({
@@ -24,6 +25,7 @@ export const ClientRecordSchema = z.object({
   totalGB: z.number().optional(),
   expiryTime: z.number().optional(),
   limitIp: z.number().optional(),
+  limitHwid: z.number().optional(),
   tgId: z.union([z.number(), z.string()]).optional(),
   group: z.string().optional(),
   comment: z.string().optional(),
@@ -204,6 +206,7 @@ export const ClientFormSchema = z.object({
   delayedDays: z.number().int().min(0),
   reset: z.number().int().min(0),
   limitIp: z.number().int().min(0),
+  limitHwid: z.number().int().min(0),
   tgId: z.number().int().min(0),
   group: z.string(),
   comment: z.string(),
@@ -237,6 +240,7 @@ export const ClientBulkAddFormSchema = z.object({
   comment: z.string(),
   flow: z.string(),
   limitIp: z.number().int().min(0),
+  limitHwid: z.number().int().min(0),
   totalGB: z.number().min(0),
   expiryTime: z.number(),
   reset: z.number().int().min(0),

+ 219 - 0
frontend/src/test/clone-inbound-modal.test.tsx

@@ -0,0 +1,219 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { fireEvent, render, screen, waitFor } from '@testing-library/react';
+
+import CloneInboundModal from '@/pages/inbounds/CloneInboundModal';
+import { HttpUtil } from '@/utils';
+import { DBInbound } from '@/models/dbinbound';
+import { ThemeProvider } from '@/hooks/useTheme';
+import type { NodeRecord } from '@/api/queries/useNodesQuery';
+
+import { renderWithProviders } from './test-utils';
+
+const postSpy = vi.mocked(HttpUtil.post);
+
+const NODES = [
+  { id: 2, name: 'arm2', enable: true, status: 'online' },
+  { id: 3, name: 'arm3', enable: true, status: 'offline' },
+  { id: 4, name: 'retired', enable: false, status: 'online' },
+  { id: 5, name: 'arm5', enable: true, status: 'unknown' },
+] as unknown as NodeRecord[];
+
+function sourceInbound() {
+  return new DBInbound({
+    id: 7,
+    port: 443,
+    listen: '',
+    protocol: 'vless',
+    remark: 'edge',
+    enable: true,
+    settings: JSON.stringify({ clients: [{ id: 'uuid-1', email: 'a@test' }], decryption: 'none' }),
+    streamSettings: JSON.stringify({ network: 'tcp', security: 'none' }),
+    sniffing: '',
+    nodeId: 2,
+    shareAddrStrategy: 'node',
+    shareAddr: '',
+  });
+}
+
+function renderModal(onCloned = vi.fn(), onClose = vi.fn()) {
+  renderWithProviders(
+    <CloneInboundModal
+      open
+      dbInbound={sourceInbound()}
+      nodes={NODES}
+      portsInUse={new Map([[2, new Set([443])]])}
+      onClose={onClose}
+      onCloned={onCloned}
+    />,
+  );
+  return { onCloned, onClose };
+}
+
+function openTargetDropdown() {
+  // antd v6 Select has no .ant-select-selector; mouseDown on the root opens it.
+  const selector = document.querySelector('.ant-select');
+  if (!selector) throw new Error('target select not rendered');
+  fireEvent.mouseDown(selector);
+}
+
+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);
+}
+
+function clickOk() {
+  fireEvent.click(screen.getByRole('button', { name: 'Clone' }));
+}
+
+type PostBody = Record<string, unknown> & { nodeId?: number };
+const postedBodies = () => postSpy.mock.calls.map((c) => c[1] as PostBody);
+
+const selectedTitles = () => Array.from(document.querySelectorAll('.ant-select-selection-item[title]'))
+  .map((el) => el.getAttribute('title'));
+
+beforeEach(() => {
+  postSpy.mockClear();
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  postSpy.mockResolvedValue({ success: true, obj: {} } as any);
+});
+
+describe('CloneInboundModal', () => {
+  it('pre-selects the source node and clones onto it with a fresh port and no clients', async () => {
+    const { onCloned, onClose } = renderModal();
+
+    expect(document.querySelector('.ant-select-selection-item[title="arm2"]')).toBeTruthy();
+
+    clickOk();
+    await waitFor(() => expect(postSpy).toHaveBeenCalledTimes(1));
+
+    expect(postSpy.mock.calls[0][0]).toBe('/panel/api/inbounds/add');
+    const body = postedBodies()[0];
+    expect(body.nodeId).toBe(2);
+    expect(body.enable).toBe(false);
+    expect(body.remark).toBe('edge (clone)');
+    expect(body.port).not.toBe(443);
+    expect(body).not.toHaveProperty('tag');
+    expect(JSON.parse(body.settings as string).clients).toEqual([]);
+
+    await waitFor(() => expect(onCloned).toHaveBeenCalledTimes(1));
+    expect(onClose).toHaveBeenCalledTimes(1);
+  });
+
+  it('posts once per selected target and omits nodeId for the local panel', async () => {
+    renderModal();
+    openTargetDropdown();
+    clickOption('Local panel');
+
+    clickOk();
+    await waitFor(() => expect(postSpy).toHaveBeenCalledTimes(2));
+
+    const [nodeBody, localBody] = postedBodies();
+    expect(nodeBody.nodeId).toBe(2);
+    expect(localBody).not.toHaveProperty('nodeId');
+    expect(nodeBody.port).not.toBe(443);
+  });
+
+  it('disables non-online nodes and hides disabled nodes from the target list', () => {
+    renderModal();
+    openTargetDropdown();
+
+    const option = (text: string) => Array.from(document.querySelectorAll('.ant-select-item-option'))
+      .find((o) => (o.textContent ?? '').trim() === text);
+    // Only `online` is selectable — `offline` and `unknown` (no heartbeat
+    // yet) are both shown but disabled.
+    expect(option('arm3 (offline)')?.className).toContain('ant-select-item-option-disabled');
+    expect(option('arm5 (unknown)')?.className).toContain('ant-select-item-option-disabled');
+    expect(option('arm2')?.className).not.toContain('ant-select-item-option-disabled');
+
+    const labels = Array.from(document.querySelectorAll('.ant-select-item-option'))
+      .map((o) => (o.textContent ?? '').trim());
+    expect(labels).toEqual(['Local panel', 'arm2', 'arm3 (offline)', 'arm5 (unknown)']);
+  });
+
+  it('select-all picks only selectable targets and clear-all blocks submit', () => {
+    renderModal();
+
+    const selectAll = screen.getByRole('button', { name: 'Select all' });
+    fireEvent.click(selectAll);
+
+    // Local panel + online node; offline/unknown nodes stay unpickable.
+    expect(selectedTitles().sort()).toEqual(['Local panel', 'arm2']);
+    expect((selectAll as HTMLButtonElement).disabled).toBe(true);
+
+    // Clear all empties the selection and disables OK.
+    fireEvent.click(screen.getByRole('button', { name: 'Clear all' }));
+    expect(selectedTitles()).toEqual([]);
+    expect((screen.getByRole('button', { name: 'Clone' }) as HTMLButtonElement).disabled).toBe(true);
+  });
+
+  it('keeps a cleared selection when the nodes list refetches mid-dialog', () => {
+    // The page LazyMounts the modal once and keeps it mounted; heartbeats give
+    // `nodes` a new array identity on every refetch. The reset effect must not
+    // refire on that — only on the open transition.
+    const modal = (nodes: NodeRecord[], open = true) => (
+      <ThemeProvider>
+        <CloneInboundModal
+          open={open}
+          dbInbound={sourceInbound()}
+          nodes={nodes}
+          portsInUse={new Map()}
+          onClose={() => {}}
+          onCloned={() => {}}
+        />
+      </ThemeProvider>
+    );
+    const { rerender } = render(modal(NODES));
+
+    fireEvent.click(screen.getByRole('button', { name: 'Clear all' }));
+    expect(selectedTitles()).toEqual([]);
+
+    rerender(modal(NODES.map((n) => ({ ...n, latencyMs: 42 })) as unknown as NodeRecord[]));
+    expect(selectedTitles()).toEqual([]);
+  });
+
+  it('resets the selection to the source node on each reopen', () => {
+    const modal = (open: boolean) => (
+      <ThemeProvider>
+        <CloneInboundModal
+          open={open}
+          dbInbound={sourceInbound()}
+          nodes={NODES}
+          portsInUse={new Map()}
+          onClose={() => {}}
+          onCloned={() => {}}
+        />
+      </ThemeProvider>
+    );
+    const { rerender } = render(modal(true));
+
+    fireEvent.click(screen.getByRole('button', { name: 'Clear all' }));
+    expect(selectedTitles()).toEqual([]);
+
+    rerender(modal(false));
+    rerender(modal(true));
+    expect(selectedTitles()).toEqual(['arm2']);
+  });
+
+  it('reports a partial failure with the backend reason and still closes', async () => {
+    const { onCloned, onClose } = renderModal();
+    postSpy.mockImplementation(async (_url, data) => {
+      const body = data as PostBody;
+      if (body.nodeId === 2) {
+        // eslint-disable-next-line @typescript-eslint/no-explicit-any
+        return { success: false, msg: "port 23456 (tcp) already used by inbound 'x' (#1) on *" } as any;
+      }
+      // eslint-disable-next-line @typescript-eslint/no-explicit-any
+      return { success: true, obj: {} } as any;
+    });
+
+    openTargetDropdown();
+    clickOption('Local panel');
+    clickOk();
+
+    await screen.findByText(/port 23456 \(tcp\) already used/);
+    await waitFor(() => expect(onCloned).toHaveBeenCalledTimes(1));
+    expect(onClose).toHaveBeenCalledTimes(1);
+  });
+});

+ 207 - 0
frontend/src/test/geo-browser-selection.test.tsx

@@ -0,0 +1,207 @@
+import type { ReactNode } from 'react';
+import { render, screen, waitFor, within } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { QueryClientProvider } from '@tanstack/react-query';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+import GeoBrowserModal from '@/components/geodata/GeoBrowserModal';
+import GeoTokenInput from '@/components/geodata/GeoTokenInput';
+import { makeTestQueryClient } from '@/test/test-utils';
+import { HttpUtil, Msg } from '@/utils';
+
+afterEach(() => {
+  vi.restoreAllMocks();
+});
+
+const FILES = [{ name: 'geosite.dat', kind: 'site', size: 1024, modifiedAt: 1785428467270, categories: 3 }];
+
+const IP_FILE = { name: 'geoip.dat', kind: 'ip', size: 2048, modifiedAt: 1785428467270, categories: 1 };
+
+const IP_CATEGORIES = { total: 1, items: [{ code: 'private', entries: 1, attributes: [] }] };
+
+const CATEGORIES = {
+  total: 3,
+  items: [
+    { code: 'cn', entries: 2, attributes: [] },
+    { code: 'google', entries: 2, attributes: ['ads'] },
+    { code: 'telegram', entries: 1, attributes: [] },
+  ],
+};
+
+function mockGeodata(files: unknown[] = FILES) {
+  const get = vi.spyOn(HttpUtil, 'get').mockImplementation(async (url: string, params?: unknown) => {
+    const requestedFile = (params as { file?: string } | undefined)?.file;
+    if (url.includes('/geodata/files')) return new Msg(true, '', files);
+    if (url.includes('/geodata/categories')) {
+      return new Msg(true, '', requestedFile === 'geoip.dat' ? IP_CATEGORIES : CATEGORIES);
+    }
+    if (url.includes('/geodata/entries')) return new Msg(true, '', { total: 0, items: [] });
+    return new Msg(true, '', null);
+  });
+  vi.spyOn(HttpUtil, 'post').mockImplementation(async () => new Msg(true, '', []));
+  return get;
+}
+
+type GetSpy = ReturnType<typeof mockGeodata>;
+
+function entryFilters(get: GetSpy): string[] {
+  return get.mock.calls
+    .filter(([url]) => String(url).includes('/geodata/entries'))
+    .map(([, params]) => (params as { q?: string } | undefined)?.q ?? '');
+}
+
+function wrapper({ children }: { children: ReactNode }) {
+  return <QueryClientProvider client={makeTestQueryClient()}>{children}</QueryClientProvider>;
+}
+
+async function checkboxFor(code: string) {
+  const cell = await screen.findByText(code);
+  const row = cell.closest('.ant-table-row');
+  if (!row) throw new Error(`row for ${code} not found`);
+  return within(row as HTMLElement).getByRole('checkbox') as HTMLInputElement;
+}
+
+describe('GeoBrowserModal selection', () => {
+  it('seeds the selection from the field every time it opens', async () => {
+    mockGeodata();
+    const view = render(
+      <GeoBrowserModal open kind="site" value="geosite:google" onApply={vi.fn()} onClose={vi.fn()} />,
+      { wrapper },
+    );
+
+    await waitFor(async () => expect((await checkboxFor('google')).checked).toBe(true));
+
+    view.rerender(
+      <GeoBrowserModal open={false} kind="site" value="geosite:google" onApply={vi.fn()} onClose={vi.fn()} />,
+    );
+    view.rerender(
+      <GeoBrowserModal open kind="site" value="geosite:google" onApply={vi.fn()} onClose={vi.fn()} />,
+    );
+
+    await waitFor(async () => expect((await checkboxFor('google')).checked).toBe(true));
+    expect((await checkboxFor('cn')).checked).toBe(false);
+  });
+
+  it('keeps selections that the search box has filtered out of view', async () => {
+    mockGeodata();
+    const user = userEvent.setup();
+    const onApply = vi.fn();
+    render(<GeoBrowserModal open kind="site" value="" onApply={onApply} onClose={vi.fn()} />, { wrapper });
+
+    await user.click(await checkboxFor('google'));
+    await user.type(screen.getByPlaceholderText(/search category|поиск категории/i), 'cn');
+    await waitFor(() => expect(screen.queryByText('google')).toBeNull());
+    await user.click(await checkboxFor('cn'));
+
+    await user.click(screen.getByRole('button', { name: /apply|применить/i }));
+
+    expect(onApply).toHaveBeenCalledTimes(1);
+    const applied = String(onApply.mock.calls[0][0]);
+    expect(applied.split(',').map((token) => token.trim()).sort()).toEqual(['geosite:cn', 'geosite:google']);
+  });
+
+  it('drops a category from the field when its checkbox is cleared', async () => {
+    mockGeodata();
+    const user = userEvent.setup();
+    const onApply = vi.fn();
+    render(
+      <GeoBrowserModal open kind="site" value="google.com, geosite:google, geosite:blabla" onApply={onApply} onClose={vi.fn()} />,
+      { wrapper },
+    );
+
+    await waitFor(async () => expect((await checkboxFor('google')).checked).toBe(true));
+    await user.click(await checkboxFor('google'));
+    await user.click(screen.getByRole('button', { name: /apply|применить/i }));
+
+    expect(onApply).toHaveBeenCalledWith('google.com, geosite:blabla');
+  });
+
+  it('offers only databases matching the field kind', async () => {
+    mockGeodata([...FILES, IP_FILE]);
+    render(<GeoBrowserModal open kind="ip" value="" onApply={vi.fn()} onClose={vi.fn()} />, { wrapper });
+
+    await screen.findByText('private');
+    expect(screen.getByTitle('geoip.dat')).toBeTruthy();
+    expect(screen.queryByText('google')).toBeNull();
+  });
+
+  it('does not seed one database from another database categories', async () => {
+    mockGeodata([...FILES, IP_FILE]);
+    const user = userEvent.setup();
+    render(
+      <GeoBrowserModal open kind="site" value="geosite:cn" onApply={vi.fn()} onClose={vi.fn()} />,
+      { wrapper },
+    );
+
+    await waitFor(async () => expect((await checkboxFor('cn')).checked).toBe(true));
+    await user.click(await checkboxFor('google'));
+    await waitFor(async () => expect((await checkboxFor('google')).checked).toBe(true));
+    expect(screen.queryByText('private')).toBeNull();
+  });
+
+  it('waits for the entry filter to settle instead of querying every keystroke', async () => {
+    const get = mockGeodata();
+    const user = userEvent.setup({ delay: null });
+    render(<GeoBrowserModal open kind="site" value="" onApply={vi.fn()} onClose={vi.fn()} />, { wrapper });
+
+    await user.click(await screen.findByText('cn'));
+    await waitFor(() => expect(entryFilters(get)).toEqual(['']));
+
+    await user.type(screen.getByPlaceholderText('Filter inside category'), 'abcd');
+    expect(entryFilters(get)).toEqual(['']);
+
+    await waitFor(() => expect(entryFilters(get)).toEqual(['', 'abcd']), { timeout: 3000 });
+  });
+
+  it('drops the pending filter when another category is opened', async () => {
+    const get = mockGeodata();
+    const user = userEvent.setup({ delay: null });
+    render(<GeoBrowserModal open kind="site" value="" onApply={vi.fn()} onClose={vi.fn()} />, { wrapper });
+
+    await user.click(await screen.findByText('cn'));
+    await user.type(screen.getByPlaceholderText('Filter inside category'), 'abcd');
+    await user.click(screen.getByText('telegram'));
+
+    await new Promise((resolve) => setTimeout(resolve, 800));
+    expect(entryFilters(get)).toEqual(['', '']);
+  });
+
+  it('ticks and unticks a category written in its long ext form', async () => {
+    mockGeodata();
+    const user = userEvent.setup();
+    const onApply = vi.fn();
+    render(
+      <GeoBrowserModal
+        open
+        kind="site"
+        value="ext:geosite.dat:cn, google.com"
+        onApply={onApply}
+        onClose={vi.fn()}
+      />,
+      { wrapper },
+    );
+
+    await waitFor(async () => expect((await checkboxFor('cn')).checked).toBe(true));
+    await user.click(await checkboxFor('cn'));
+    await user.click(screen.getByRole('button', { name: /apply|применить/i }));
+
+    expect(onApply).toHaveBeenCalledWith('google.com');
+  });
+});
+
+describe('GeoTokenInput validation feedback', () => {
+  it('says the check failed instead of dropping the warnings silently', async () => {
+    vi.spyOn(HttpUtil, 'get').mockResolvedValue(new Msg(true, '', []));
+    vi.spyOn(HttpUtil, 'post')
+      .mockResolvedValueOnce(new Msg(true, '', [{ token: 'geosite:nope', reason: 'categoryMissing' }]))
+      .mockResolvedValue(new Msg(false, 'too many tokens'));
+
+    const view = render(<GeoTokenInput kind="domain" value="geosite:nope" />, { wrapper });
+    await screen.findByText(/Not in the database/, {}, { timeout: 3000 });
+
+    view.rerender(<GeoTokenInput kind="domain" value="geosite:nope, geosite:other" />);
+
+    await screen.findByText('Could not check these values against the geo databases', {}, { timeout: 3000 });
+    expect(screen.queryByText(/Not in the database/)).toBeNull();
+  });
+});

+ 215 - 0
frontend/src/test/geo-tokens.test.ts

@@ -0,0 +1,215 @@
+import { describe, expect, it } from 'vitest';
+
+import {
+  formatTokens,
+  mergeSelection,
+  parseTokens,
+  selectionFromValue,
+  tokenFor,
+} from '@/lib/xray/geoTokens';
+
+const siteKnown = new Set(['geosite:google', 'geosite:google@ads', 'geosite:cn', 'ext:my_rules.dat:corp']);
+const ipKnown = new Set(['geoip:cn', 'geoip:private', 'ext:my_ips.dat:office']);
+
+describe('parseTokens / formatTokens', () => {
+  const cases: Array<[string, string, string[]]> = [
+    ['empty value', '', []],
+    ['single token', 'geosite:google', ['geosite:google']],
+    ['trims and drops blanks', ' geosite:google , , google.com ,', ['geosite:google', 'google.com']],
+    ['keeps negation', '!geoip:cn, 10.0.0.0/8', ['!geoip:cn', '10.0.0.0/8']],
+  ];
+
+  it.each(cases)('%s', (_name, value, expected) => {
+    expect(parseTokens(value)).toEqual(expected);
+  });
+
+  it('joins with a comma and a space', () => {
+    expect(formatTokens(['geosite:google', 'google.com'])).toBe('geosite:google, google.com');
+    expect(formatTokens([])).toBe('');
+  });
+});
+
+describe('tokenFor', () => {
+  const cases: Array<[string, string, string, 'site' | 'ip', string]> = [
+    ['default site database uses the geosite shorthand', 'geosite.dat', 'google', 'site', 'geosite:google'],
+    ['default ip database uses the geoip shorthand', 'geoip.dat', 'cn', 'ip', 'geoip:cn'],
+    ['custom site database falls back to ext', 'my_rules.dat', 'corp', 'site', 'ext:my_rules.dat:corp'],
+    ['custom ip database falls back to ext', 'my_ips.dat', 'office', 'ip', 'ext:my_ips.dat:office'],
+    ['ip kind on the site database is not shorthand', 'geosite.dat', 'cn', 'ip', 'ext:geosite.dat:cn'],
+    ['site kind on the ip database is not shorthand', 'geoip.dat', 'cn', 'site', 'ext:geoip.dat:cn'],
+  ];
+
+  it.each(cases)('%s', (_name, file, code, kind, expected) => {
+    expect(tokenFor(file, code, kind)).toBe(expected);
+  });
+});
+
+describe('selectionFromValue', () => {
+  const cases: Array<[string, string, ReadonlySet<string>, string[]]> = [
+    ['empty value selects nothing', '', siteKnown, []],
+    ['plain values are not selectable', 'google.com, keyword:ads', siteKnown, []],
+    ['picks known tokens only', 'google.com, geosite:google, geosite:blabla', siteKnown, ['geosite:google']],
+    [
+      'keeps the value order',
+      'geosite:cn, google.com, geosite:google',
+      siteKnown,
+      ['geosite:cn', 'geosite:google'],
+    ],
+    ['drops duplicates', 'geosite:google, geosite:google', siteKnown, ['geosite:google']],
+    ['attributes are distinct tokens', 'geosite:google@ads', siteKnown, ['geosite:google@ads']],
+    ['ext tokens are selectable', 'ext:my_rules.dat:corp, ext:other.dat:x', siteKnown, ['ext:my_rules.dat:corp']],
+    ['negated ip tokens stay unselected', '!geoip:cn, geoip:private', ipKnown, ['geoip:private']],
+  ];
+
+  it.each(cases)('%s', (_name, value, known, expected) => {
+    expect(selectionFromValue(value, known)).toEqual(expected);
+  });
+});
+
+describe('mergeSelection', () => {
+  const cases: Array<[string, string, string[], ReadonlySet<string>, string]> = [
+    ['adds to an empty field', '', ['geosite:google'], siteKnown, 'geosite:google'],
+    [
+      'adds after a plain domain',
+      'google.com',
+      ['geosite:cn'],
+      siteKnown,
+      'google.com, geosite:cn',
+    ],
+    [
+      'keeps plain and unknown tokens when a category is unchecked',
+      'google.com, geosite:google, geosite:blabla',
+      [],
+      siteKnown,
+      'google.com, geosite:blabla',
+    ],
+    [
+      'unchecking one known token leaves the other known token',
+      'geosite:google, geosite:cn',
+      ['geosite:cn'],
+      siteKnown,
+      'geosite:cn',
+    ],
+    [
+      'preserves the original order of surviving tokens',
+      'geosite:cn, google.com, geosite:google',
+      ['geosite:google', 'geosite:cn'],
+      siteKnown,
+      'geosite:cn, google.com, geosite:google',
+    ],
+    [
+      'appends new selections in selection order',
+      'google.com',
+      ['geosite:cn', 'geosite:google'],
+      siteKnown,
+      'google.com, geosite:cn, geosite:google',
+    ],
+    [
+      'never duplicates an already present token',
+      'geosite:google, google.com',
+      ['geosite:google'],
+      siteKnown,
+      'geosite:google, google.com',
+    ],
+    [
+      'collapses duplicates already in the field',
+      'google.com, google.com, geosite:google',
+      ['geosite:google'],
+      siteKnown,
+      'google.com, geosite:google',
+    ],
+    [
+      'handles ext tokens like shorthand ones',
+      'ext:my_rules.dat:corp, google.com',
+      [],
+      siteKnown,
+      'google.com',
+    ],
+    [
+      'adds an ext token from a custom database',
+      '10.0.0.0/8',
+      ['ext:my_ips.dat:office'],
+      ipKnown,
+      '10.0.0.0/8, ext:my_ips.dat:office',
+    ],
+    [
+      'leaves a negated ip token untouched while dropping a plain one',
+      '!geoip:cn, geoip:private, 192.168.0.0/16',
+      [],
+      ipKnown,
+      '!geoip:cn, 192.168.0.0/16',
+    ],
+    [
+      'adds a geoip token next to an existing negation',
+      '!geoip:cn',
+      ['geoip:private'],
+      ipKnown,
+      '!geoip:cn, geoip:private',
+    ],
+    [
+      'ignores whitespace around field tokens',
+      '  google.com ,  geosite:google  ',
+      ['geosite:google'],
+      siteKnown,
+      'google.com, geosite:google',
+    ],
+    ['clearing every known token can empty the field', 'geosite:google', [], siteKnown, ''],
+  ];
+
+  it.each(cases)('%s', (_name, value, selected, known, expected) => {
+    expect(mergeSelection(value, selected, known)).toBe(expected);
+  });
+
+  it('round-trips with selectionFromValue', () => {
+    const value = mergeSelection('google.com, geosite:blabla', ['geosite:google', 'geosite:cn'], siteKnown);
+    expect(value).toBe('google.com, geosite:blabla, geosite:google, geosite:cn');
+    expect(selectionFromValue(value, siteKnown)).toEqual(['geosite:google', 'geosite:cn']);
+  });
+});
+
+describe('token matching tolerates the spellings Xray accepts', () => {
+  const cases: Array<[string, string, string[], string]> = [
+    [
+      'an uppercase token is recognised instead of duplicated',
+      'GEOSITE:GOOGLE',
+      ['geosite:google'],
+      'GEOSITE:GOOGLE',
+    ],
+    [
+      'the long ext form of a default database is the same token as its shorthand',
+      'ext:geosite.dat:google',
+      ['geosite:google'],
+      'ext:geosite.dat:google',
+    ],
+    [
+      'clearing a category written in its long form removes it',
+      'google.com, ext:geosite.dat:google',
+      [],
+      'google.com',
+    ],
+    [
+      'clearing a category written in uppercase removes it',
+      'GEOSITE:GOOGLE, google.com',
+      [],
+      'google.com',
+    ],
+    [
+      'a token from a database that was never opened survives untouched',
+      'ext:other.dat:x, geosite:google',
+      ['geosite:cn'],
+      'ext:other.dat:x, geosite:cn',
+    ],
+    ['a value of separators alone collapses to empty', ',,, ,', [], ''],
+  ];
+
+  it.each(cases)('%s', (_name, value, selected, expected) => {
+    expect(mergeSelection(value, selected, siteKnown)).toBe(expected);
+  });
+
+  it('seeds the selection from tokens written in another spelling', () => {
+    expect(selectionFromValue('GEOSITE:GOOGLE, ext:geosite.dat:cn', siteKnown)).toEqual([
+      'GEOSITE:GOOGLE',
+      'ext:geosite.dat:cn',
+    ]);
+  });
+});

+ 85 - 0
frontend/src/test/inbound-clone.test.ts

@@ -0,0 +1,85 @@
+import { describe, expect, it } from 'vitest';
+
+import { buildClonePayload, pickClonePort } from '@/lib/xray/inbound-clone';
+import { DBInbound } from '@/models/dbinbound';
+
+function sourceInbound() {
+  return new DBInbound({
+    id: 7,
+    port: 443,
+    listen: '0.0.0.0',
+    protocol: 'vless',
+    remark: 'edge',
+    enable: true,
+    settings: JSON.stringify({
+      clients: [{ id: 'uuid-1', email: 'a@test', flow: 'xtls-rprx-vision' }],
+      decryption: 'none',
+    }),
+    streamSettings: { network: 'tcp', security: 'reality', realitySettings: { dest: 'www.lovelive-anime.jp:443' } },
+    sniffing: { enabled: true },
+    nodeId: 2,
+    shareAddrStrategy: 'node',
+    shareAddr: '',
+  });
+}
+
+describe('buildClonePayload', () => {
+  it('omits nodeId for a local-panel target so the row stays panel-local', () => {
+    const payload = buildClonePayload(sourceInbound(), 23456, null);
+    expect(payload).not.toHaveProperty('nodeId');
+  });
+
+  it('carries nodeId for a node target', () => {
+    const payload = buildClonePayload(sourceInbound(), 23456, 5);
+    expect(payload.nodeId).toBe(5);
+  });
+
+  it('stages the clone disabled with cleared clients, fresh port, and no tag', () => {
+    const payload = buildClonePayload(sourceInbound(), 23456, 3);
+    expect(payload.enable).toBe(false);
+    expect(payload.port).toBe(23456);
+    expect(payload.listen).toBe('');
+    expect(payload).not.toHaveProperty('tag');
+    expect(payload.remark).toBe('edge (clone)');
+
+    const settings = JSON.parse(payload.settings);
+    // Clients are dropped (emails are unique panel-wide, UUIDs must not
+    // repeat across nodes) while the rest of the settings survive verbatim.
+    expect(settings.clients).toEqual([]);
+    expect(settings.decryption).toBe('none');
+  });
+
+  it('stringifies object-shaped streamSettings and sniffing from hydrated rows', () => {
+    const payload = buildClonePayload(sourceInbound(), 23456, null);
+    expect(JSON.parse(payload.streamSettings)).toEqual({
+      network: 'tcp',
+      security: 'reality',
+      realitySettings: { dest: 'www.lovelive-anime.jp:443' },
+    });
+    expect(JSON.parse(payload.sniffing)).toEqual({ enabled: true });
+  });
+
+  it('survives malformed settings JSON with an empty client list fallback', () => {
+    const broken = sourceInbound();
+    broken.settings = '{not json';
+    const payload = buildClonePayload(broken, 23456, null);
+    const settings = JSON.parse(payload.settings);
+    expect(settings.clients ?? []).toEqual([]);
+  });
+});
+
+describe('pickClonePort', () => {
+  it('never returns a port already bound on the target', () => {
+    const used = new Set<number>();
+    for (let p = 10000; p <= 60000; p++) if (p !== 23456) used.add(p);
+    expect(pickClonePort(used)).toBe(23456);
+  });
+
+  it('stops probing when the range looks exhausted instead of spinning', () => {
+    const used = new Set<number>();
+    for (let p = 10000; p <= 60000; p++) used.add(p);
+    const port = pickClonePort(used);
+    expect(port).toBeGreaterThanOrEqual(10000);
+    expect(port).toBeLessThanOrEqual(60000);
+  });
+});

+ 29 - 0
frontend/src/test/remark-template-field.test.tsx

@@ -2,6 +2,7 @@ import { describe, it, expect, vi } from 'vitest';
 import { fireEvent, render, screen } from '@testing-library/react';
 
 import RemarkTemplateField from '@/components/form/RemarkTemplateField';
+import { previewRemark, SUBSCRIPTION_METADATA_VARIABLES } from '@/lib/remark/remarkVariables';
 
 describe('RemarkTemplateField', () => {
   it('inserts a {{TOKEN}} when a variable chip is clicked', async () => {
@@ -23,4 +24,32 @@ describe('RemarkTemplateField', () => {
     // Sample expansion of {{EMAIL}} is "john".
     expect(screen.getByText('john')).toBeTruthy();
   });
+
+  it('supports token insertion in multiline fields', async () => {
+    const onChange = vi.fn();
+    render(<RemarkTemplateField value="Hello " onChange={onChange} multiline rows={3} />);
+    const textarea = screen.getByRole('textbox') as HTMLTextAreaElement;
+    textarea.focus();
+    textarea.setSelectionRange(textarea.value.length, textarea.value.length);
+
+    fireEvent.click(screen.getByRole('button'));
+    fireEvent.click(await screen.findByText('{{SUB_ID}}'));
+
+    expect(onChange).toHaveBeenCalledTimes(1);
+    expect(onChange.mock.calls[0][0]).toBe('Hello {{SUB_ID}}');
+  });
+
+  it('limits the picker to client identity tokens for metadata fields', async () => {
+    render(<RemarkTemplateField value="" onChange={() => {}} metadataOnly />);
+
+    fireEvent.click(screen.getByRole('button'));
+
+    expect(await screen.findByText('{{EMAIL}}')).toBeTruthy();
+    expect(screen.queryByText('{{INBOUND}}')).toBeNull();
+    expect(screen.queryByText('{{TRAFFIC_LEFT}}')).toBeNull();
+  });
+
+  it('previews metadata fields with metadata-safe tokens only', () => {
+    expect(previewRemark('{{EMAIL}}/{{TRAFFIC_LEFT}}', SUBSCRIPTION_METADATA_VARIABLES, true)).toBe('john/{{TRAFFIC_LEFT}}');
+  });
 });

+ 17 - 0
frontend/src/utils/index.ts

@@ -105,6 +105,23 @@ export class HttpUtil {
     }
   }
 
+  static async delete<T = unknown>(url: string, options: HttpOptions = {}): Promise<Msg<T>> {
+    const { silent, silentSuccess, ...rest } = options;
+    try {
+      const resp = await httpRequest('DELETE', url, undefined, rest);
+      const msg = this._respToMsg(resp) as Msg<T>;
+      if (!silent) this._handleMsg(msg, silentSuccess);
+      return msg;
+    } catch (error) {
+      console.error('DELETE request failed:', error);
+      const err = error as { response?: { data?: { msg?: string; message?: string } }; message?: string };
+      const data = err.response?.data;
+      const errorMsg = new Msg<T>(false, data?.msg || data?.message || err.message || 'Request failed');
+      if (!silent) this._handleMsg(errorMsg);
+      return errorMsg;
+    }
+  }
+
   static async postWithModal<T = unknown>(url: string, data?: unknown, modal?: HttpModal | null): Promise<Msg<T>> {
     if (modal) {
       modal.loading(true);

+ 21 - 0
internal/config/config.go

@@ -193,6 +193,27 @@ func GetDBDSN() string {
 	return strings.TrimSpace(os.Getenv("XUI_DB_DSN"))
 }
 
+// GetNodeTokenEncryptionMode returns off, migration, or required. Explicit
+// policy prevents a missing key from silently downgrading encrypted storage.
+func GetNodeTokenEncryptionMode() string {
+	return strings.TrimSpace(os.Getenv("NODE_TOKEN_ENCRYPTION"))
+}
+
+// GetNodeTokenKeyFile returns the mode-0600 keyring path, configurable through
+// XUI_NODE_TOKEN_KEY_FILE.
+func GetNodeTokenKeyFile() string {
+	if p := strings.TrimSpace(os.Getenv("XUI_NODE_TOKEN_KEY_FILE")); p != "" {
+		return p
+	}
+	return "/etc/x-ui/node_token_key.json"
+}
+
+// GetNodeTokenKeyEnv returns the name of the env var holding a single base64
+// 32-byte node-token key (secondary to the key file). Empty value => unused.
+func GetNodeTokenKeyEnv() string {
+	return "XUI_NODE_TOKEN_KEY"
+}
+
 // GetEnvFilePaths returns the candidate service environment file paths (the file
 // systemd loads via EnvironmentFile) across the supported distro families.
 func GetEnvFilePaths() []string {

+ 115 - 0
internal/crypto/nodetoken/keysource.go

@@ -0,0 +1,115 @@
+package nodetoken
+
+import (
+	"encoding/base64"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"os"
+	"strings"
+)
+
+// KeySource loads a startup keyring from a protected file or environment.
+// Keys are never accepted on the command line.
+type KeySource interface {
+	Load() (*Keyring, error)
+}
+
+// keyFile identifies the active key and all base64-encoded rotation keys.
+type keyFile struct {
+	Active string            `json:"active"`
+	Keys   map[string]string `json:"keys"`
+}
+
+func parseKeyring(active string, b64keys map[string]string) (*Keyring, error) {
+	if err := validateKeyID(active); err != nil {
+		return nil, fmt.Errorf("nodetoken: active key id: %w", err)
+	}
+	if active == "" {
+		return nil, errors.New("nodetoken: key source has no active key id")
+	}
+	if len(b64keys) == 0 {
+		return nil, errors.New("nodetoken: key source has no keys")
+	}
+	kr := &Keyring{ActiveID: active, Keys: make(map[string][keyLen]byte, len(b64keys))}
+	for id, b64 := range b64keys {
+		if err := validateKeyID(id); err != nil {
+			return nil, fmt.Errorf("nodetoken: key id %q: %w", id, err)
+		}
+		raw, err := decodeKey(b64)
+		if err != nil {
+			return nil, fmt.Errorf("nodetoken: key %q: %w", id, err)
+		}
+		kr.Keys[id] = raw
+	}
+	if _, ok := kr.Keys[active]; !ok {
+		return nil, fmt.Errorf("nodetoken: active key %q absent from keys", active)
+	}
+	return kr, nil
+}
+
+func validateKeyID(id string) error {
+	if id == "" {
+		return errors.New("must not be empty")
+	}
+	if strings.Contains(id, ":") {
+		return errors.New("must not contain ':'")
+	}
+	return nil
+}
+
+func decodeKey(b64 string) ([keyLen]byte, error) {
+	var out [keyLen]byte
+	raw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(b64))
+	if err != nil {
+		// tolerate url-safe / unpadded encodings too
+		if raw2, err2 := base64.RawStdEncoding.DecodeString(strings.TrimSpace(b64)); err2 == nil {
+			raw = raw2
+		} else {
+			return out, fmt.Errorf("base64 decode: %w", err)
+		}
+	}
+	if len(raw) != keyLen {
+		return out, fmt.Errorf("key must be %d bytes, got %d", keyLen, len(raw))
+	}
+	copy(out[:], raw)
+	return out, nil
+}
+
+// FileKeySource accepts only key files that are mode 0600 or stricter.
+type FileKeySource struct {
+	Path string
+}
+
+func (f FileKeySource) Load() (*Keyring, error) {
+	info, err := os.Stat(f.Path)
+	if err != nil {
+		return nil, fmt.Errorf("nodetoken: stat key file %s: %w", f.Path, err)
+	}
+	if perm := info.Mode().Perm(); perm&0o077 != 0 {
+		return nil, fmt.Errorf("nodetoken: key file %s has insecure mode %#o (want 0600)", f.Path, perm)
+	}
+	data, err := os.ReadFile(f.Path)
+	if err != nil {
+		return nil, fmt.Errorf("nodetoken: read key file %s: %w", f.Path, err)
+	}
+	var kf keyFile
+	if err := json.Unmarshal(data, &kf); err != nil {
+		return nil, fmt.Errorf("nodetoken: parse key file %s: %w", f.Path, err)
+	}
+	return parseKeyring(kf.Active, kf.Keys)
+}
+
+// EnvKeySource reads a single base64 32-byte key from an environment variable.
+// The key id is fixed ("env"); for multi-key rotation prefer a key file.
+type EnvKeySource struct {
+	Var string
+}
+
+func (e EnvKeySource) Load() (*Keyring, error) {
+	v := strings.TrimSpace(os.Getenv(e.Var))
+	if v == "" {
+		return nil, fmt.Errorf("nodetoken: env %s is empty", e.Var)
+	}
+	return parseKeyring("env", map[string]string{"env": v})
+}

+ 236 - 0
internal/crypto/nodetoken/nodetoken.go

@@ -0,0 +1,236 @@
+// Package nodetoken encrypts replayable per-node bearer tokens at rest with
+// row-bound AES-GCM and versioned key IDs.
+package nodetoken
+
+import (
+	"crypto/aes"
+	"crypto/cipher"
+	"crypto/rand"
+	"encoding/base64"
+	"errors"
+	"fmt"
+	"strings"
+	"sync"
+)
+
+// Mode is explicit so a missing key cannot silently downgrade encrypted
+// deployments to plaintext.
+type Mode int
+
+const (
+	// ModeOff: legacy plaintext operation. Writes store plaintext; an encrypted
+	// value cannot be interpreted (no key) and is rejected rather than guessed.
+	ModeOff Mode = iota
+	// ModeMigration: key required. Reads accept plaintext OR ciphertext; writes
+	// always produce ciphertext. Used while migrating existing rows.
+	ModeMigration
+	// ModeRequired: key required (startup fails if it cannot load). Reads decrypt
+	// ciphertext (error on failure) and accept any still-unmigrated plaintext;
+	// writes always produce ciphertext.
+	ModeRequired
+)
+
+const (
+	encPrefix    = "enc:"
+	encScheme    = "enc:v1:"
+	keyLen       = 32 // AES-256
+	nonceLen     = 12 // GCM standard nonce
+	aadKeyFormat = "nodes/api_token/%d"
+)
+
+// ParseMode maps the NODE_TOKEN_ENCRYPTION env value to a Mode.
+func ParseMode(s string) (Mode, error) {
+	switch strings.ToLower(strings.TrimSpace(s)) {
+	case "", "off":
+		return ModeOff, nil
+	case "migration":
+		return ModeMigration, nil
+	case "required":
+		return ModeRequired, nil
+	default:
+		return ModeOff, fmt.Errorf("nodetoken: unknown NODE_TOKEN_ENCRYPTION %q (want off|migration|required)", s)
+	}
+}
+
+// Keyring holds the active write key and previous decryption keys.
+type Keyring struct {
+	ActiveID string
+	Keys     map[string][keyLen]byte
+}
+
+func (kr *Keyring) active() ([keyLen]byte, error) {
+	k, ok := kr.Keys[kr.ActiveID]
+	if !ok {
+		return [keyLen]byte{}, fmt.Errorf("nodetoken: active key %q not in keyring", kr.ActiveID)
+	}
+	return k, nil
+}
+
+// Codec encrypts/decrypts node tokens under a fixed policy and keyring.
+type Codec struct {
+	mode Mode
+	ring *Keyring // nil only in ModeOff
+}
+
+// NewCodec requires an active key outside ModeOff.
+func NewCodec(mode Mode, ring *Keyring) (*Codec, error) {
+	if mode == ModeOff {
+		return &Codec{mode: ModeOff}, nil
+	}
+	if ring == nil || len(ring.Keys) == 0 {
+		return nil, errors.New("nodetoken: encryption mode requires a key, but none was loaded")
+	}
+	if _, err := ring.active(); err != nil {
+		return nil, err
+	}
+	return &Codec{mode: mode, ring: ring}, nil
+}
+
+// Enabled reports whether the codec writes ciphertext (mode != off).
+func (c *Codec) Enabled() bool { return c.mode != ModeOff }
+
+func aad(nodeID int) []byte { return []byte(fmt.Sprintf(aadKeyFormat, nodeID)) }
+
+// IsEncrypted reports whether a stored value is in this package's ciphertext form.
+func IsEncrypted(stored string) bool { return strings.HasPrefix(stored, encPrefix) }
+
+// Encrypt returns plaintext in ModeOff or row-bound enc:v1 ciphertext otherwise.
+// Empty and already-valid encrypted values remain unchanged.
+func (c *Codec) Encrypt(nodeID int, plaintext string) (string, error) {
+	if c.mode == ModeOff || plaintext == "" {
+		return plaintext, nil
+	}
+	if IsEncrypted(plaintext) {
+		// Validate it actually decrypts for this node; if so keep verbatim.
+		if _, err := c.Decrypt(nodeID, plaintext); err != nil {
+			return "", fmt.Errorf("nodetoken: refusing to store undecryptable ciphertext: %w", err)
+		}
+		return plaintext, nil
+	}
+	key, err := c.ring.active()
+	if err != nil {
+		return "", err
+	}
+	gcm, err := newGCM(key)
+	if err != nil {
+		return "", err
+	}
+	nonce := make([]byte, nonceLen)
+	if _, err := rand.Read(nonce); err != nil {
+		return "", err
+	}
+	ct := gcm.Seal(nil, nonce, []byte(plaintext), aad(nodeID))
+	blob := append(nonce, ct...)
+	return encScheme + c.ring.ActiveID + ":" + base64.RawURLEncoding.EncodeToString(blob), nil
+}
+
+// Decrypt passes legacy plaintext through; enc: values must authenticate and
+// are never reinterpreted as plaintext after an error.
+func (c *Codec) Decrypt(nodeID int, stored string) (string, error) {
+	if c.mode == ModeOff {
+		return stored, nil
+	}
+	if !IsEncrypted(stored) {
+		return stored, nil
+	}
+	rest, ok := strings.CutPrefix(stored, encScheme)
+	if !ok {
+		return "", fmt.Errorf("nodetoken: unsupported ciphertext scheme in %q", firstN(stored, 12))
+	}
+	keyID, b64, ok := strings.Cut(rest, ":")
+	if !ok || keyID == "" {
+		return "", errors.New("nodetoken: malformed ciphertext (missing key id)")
+	}
+	if c.ring == nil {
+		return "", errors.New("nodetoken: encrypted token encountered but encryption is disabled (no key)")
+	}
+	key, ok := c.ring.Keys[keyID]
+	if !ok {
+		return "", fmt.Errorf("nodetoken: no key %q in keyring to decrypt token", keyID)
+	}
+	blob, err := base64.RawURLEncoding.DecodeString(b64)
+	if err != nil {
+		return "", fmt.Errorf("nodetoken: base64 decode: %w", err)
+	}
+	if len(blob) < nonceLen {
+		return "", errors.New("nodetoken: ciphertext too short")
+	}
+	gcm, err := newGCM(key)
+	if err != nil {
+		return "", err
+	}
+	pt, err := gcm.Open(nil, blob[:nonceLen], blob[nonceLen:], aad(nodeID))
+	if err != nil {
+		return "", fmt.Errorf("nodetoken: authentication failed for node %d: %w", nodeID, err)
+	}
+	return string(pt), nil
+}
+
+// ActiveKeyID returns the id new writes use (empty in ModeOff).
+func (c *Codec) ActiveKeyID() string {
+	if c.ring == nil {
+		return ""
+	}
+	return c.ring.ActiveID
+}
+
+// EncryptedWithActive reports whether migration can skip a ciphertext row.
+func (c *Codec) EncryptedWithActive(stored string) bool {
+	if c.ring == nil || !IsEncrypted(stored) {
+		return false
+	}
+	rest, ok := strings.CutPrefix(stored, encScheme)
+	if !ok {
+		return false
+	}
+	keyID, _, ok := strings.Cut(rest, ":")
+	return ok && keyID == c.ring.ActiveID
+}
+
+func newGCM(key [keyLen]byte) (cipher.AEAD, error) {
+	block, err := aes.NewCipher(key[:])
+	if err != nil {
+		return nil, err
+	}
+	return cipher.NewGCM(block)
+}
+
+func firstN(s string, n int) string {
+	if len(s) <= n {
+		return s
+	}
+	return s[:n]
+}
+
+// --- package singleton, initialized once at startup ---
+
+var (
+	mu      sync.RWMutex
+	current *Codec
+)
+
+// Init installs the process-wide codec. Call once during startup after building
+// the keyring; in ModeOff a nil keyring is fine.
+func Init(c *Codec) {
+	mu.Lock()
+	defer mu.Unlock()
+	current = c
+}
+
+// get returns the installed codec, or a permissive ModeOff codec if Init was
+// never called (e.g. unit tests / sqlite dev) so callers never nil-panic.
+func get() *Codec {
+	mu.RLock()
+	c := current
+	mu.RUnlock()
+	if c == nil {
+		return &Codec{mode: ModeOff}
+	}
+	return c
+}
+
+// Encrypt/Decrypt/Enabled operate on the process-wide codec.
+func Encrypt(nodeID int, plaintext string) (string, error) { return get().Encrypt(nodeID, plaintext) }
+func Decrypt(nodeID int, stored string) (string, error)    { return get().Decrypt(nodeID, stored) }
+func Enabled() bool                                        { return get().Enabled() }
+func Active() *Codec                                       { return get() }

+ 226 - 0
internal/crypto/nodetoken/nodetoken_test.go

@@ -0,0 +1,226 @@
+package nodetoken
+
+import (
+	"encoding/base64"
+	"encoding/json"
+	"os"
+	"path/filepath"
+	"strings"
+	"testing"
+)
+
+func testRing(t *testing.T, activeID string, ids ...string) *Keyring {
+	t.Helper()
+	kr := &Keyring{ActiveID: activeID, Keys: map[string][keyLen]byte{}}
+	for _, id := range ids {
+		var k [keyLen]byte
+		for i := range k {
+			k[i] = byte(i) + id[len(id)-1] // deterministic and distinct for k1/k2
+		}
+		kr.Keys[id] = k
+	}
+	return kr
+}
+
+func TestRoundTrip(t *testing.T) {
+	c, err := NewCodec(ModeRequired, testRing(t, "k1", "k1"))
+	if err != nil {
+		t.Fatal(err)
+	}
+	enc, err := c.Encrypt(7, "s3cret-token")
+	if err != nil {
+		t.Fatal(err)
+	}
+	if !IsEncrypted(enc) || !strings.HasPrefix(enc, "enc:v1:k1:") {
+		t.Fatalf("unexpected ciphertext form: %q", enc)
+	}
+	pt, err := c.Decrypt(7, enc)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if pt != "s3cret-token" {
+		t.Fatalf("round-trip mismatch: %q", pt)
+	}
+}
+
+func TestAADBindsToNode(t *testing.T) {
+	c, _ := NewCodec(ModeRequired, testRing(t, "k1", "k1"))
+	enc, _ := c.Encrypt(7, "tok")
+	// Decrypting under a different node id must fail (ciphertext bound to row).
+	if _, err := c.Decrypt(8, enc); err == nil {
+		t.Fatal("expected AAD mismatch error decrypting under wrong node id")
+	}
+}
+
+func TestNonceIsRandom(t *testing.T) {
+	c, _ := NewCodec(ModeRequired, testRing(t, "k1", "k1"))
+	a, _ := c.Encrypt(1, "same")
+	b, _ := c.Encrypt(1, "same")
+	if a == b {
+		t.Fatal("two encryptions of the same value produced identical ciphertext (nonce reuse)")
+	}
+}
+
+func TestPlaintextPassThrough(t *testing.T) {
+	// ModeOff: encrypt is a no-op, decrypt returns plaintext.
+	c, _ := NewCodec(ModeOff, nil)
+	enc, err := c.Encrypt(1, "plain")
+	if err != nil || enc != "plain" {
+		t.Fatalf("off-mode encrypt should be no-op, got %q err=%v", enc, err)
+	}
+	// A legacy plaintext row decrypts (passes through) in any mode.
+	c2, _ := NewCodec(ModeRequired, testRing(t, "k1", "k1"))
+	if pt, err := c2.Decrypt(1, "legacy-plain"); err != nil || pt != "legacy-plain" {
+		t.Fatalf("legacy plaintext should pass through, got %q err=%v", pt, err)
+	}
+}
+
+func TestEncryptedNeverFallsBackToPlaintext(t *testing.T) {
+	c, _ := NewCodec(ModeRequired, testRing(t, "k1", "k1"))
+	enc, _ := c.Encrypt(1, "tok")
+	// Corrupt the ciphertext body — must error, never return raw bytes.
+	bad := enc[:len(enc)-2] + "AA"
+	if _, err := c.Decrypt(1, bad); err == nil {
+		t.Fatal("corrupted ciphertext must fail, not fall back to plaintext")
+	}
+	// Unknown key id must error.
+	c2, _ := NewCodec(ModeRequired, testRing(t, "k1", "k1"))
+	other := strings.Replace(enc, "enc:v1:k1:", "enc:v1:zz:", 1)
+	if _, err := c2.Decrypt(1, other); err == nil {
+		t.Fatal("unknown key id must fail")
+	}
+}
+
+func TestEncryptionMarkerPassesThroughWhenDisabled(t *testing.T) {
+	c, _ := NewCodec(ModeOff, nil)
+	stored := "enc:v1:not-ciphertext"
+	if got, err := c.Decrypt(1, stored); err != nil || got != stored {
+		t.Fatalf("off-mode changed a legacy token: got %q err=%v", got, err)
+	}
+}
+
+func TestParseKeyringRejectsDelimiterInKeyID(t *testing.T) {
+	key := base64.StdEncoding.EncodeToString(make([]byte, keyLen))
+	for _, tc := range []struct {
+		name, active string
+		keys         map[string]string
+	}{
+		{"active delimiter", "region:k1", map[string]string{"region:k1": key}},
+		{"key delimiter", "k1", map[string]string{"k1": key, "old:k0": key}},
+		{"empty key", "k1", map[string]string{"k1": key, "": key}},
+	} {
+		t.Run(tc.name, func(t *testing.T) {
+			if _, err := parseKeyring(tc.active, tc.keys); err == nil {
+				t.Fatal("invalid key id was accepted")
+			}
+		})
+	}
+}
+
+func TestEncryptRoundTripSafe(t *testing.T) {
+	// Re-submitting stored ciphertext (UI round-trip) must not double-encrypt.
+	c, _ := NewCodec(ModeRequired, testRing(t, "k1", "k1"))
+	enc, _ := c.Encrypt(5, "tok")
+	again, err := c.Encrypt(5, enc)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if again != enc {
+		t.Fatal("re-encrypting stored ciphertext changed it (double-encrypt)")
+	}
+	if pt, _ := c.Decrypt(5, again); pt != "tok" {
+		t.Fatalf("round-trip-safe encrypt corrupted token: %q", pt)
+	}
+}
+
+func TestEmptyTokenNeverEncrypted(t *testing.T) {
+	c, _ := NewCodec(ModeRequired, testRing(t, "k1", "k1"))
+	if v, _ := c.Encrypt(1, ""); v != "" {
+		t.Fatalf("empty token must stay empty, got %q", v)
+	}
+}
+
+func TestRotation(t *testing.T) {
+	// k2 active, k1 retained. Old-key value still decrypts; new writes use k2.
+	ring := testRing(t, "k2", "k1", "k2")
+	if ring.Keys["k1"] == ring.Keys["k2"] {
+		t.Fatal("rotation fixture keys k1 and k2 are identical")
+	}
+	c, _ := NewCodec(ModeRequired, ring)
+	// produce a k1 value via a codec whose active is k1
+	c1, _ := NewCodec(ModeRequired, testRing(t, "k1", "k1", "k2"))
+	old, _ := c1.Encrypt(3, "tok")
+	if pt, err := c.Decrypt(3, old); err != nil || pt != "tok" {
+		t.Fatalf("retained old key must decrypt, got %q err=%v", pt, err)
+	}
+	if c.EncryptedWithActive(old) {
+		t.Fatal("k1 value should not count as encrypted-with-active(k2)")
+	}
+	neu, _ := c.Encrypt(3, "tok")
+	if !c.EncryptedWithActive(neu) {
+		t.Fatal("new write should be encrypted with active key")
+	}
+}
+
+func TestNewCodecRequiresKey(t *testing.T) {
+	if _, err := NewCodec(ModeRequired, nil); err == nil {
+		t.Fatal("required mode without a key must fail (fail-closed)")
+	}
+	if _, err := NewCodec(ModeMigration, &Keyring{ActiveID: "x", Keys: nil}); err == nil {
+		t.Fatal("migration mode with empty keyring must fail")
+	}
+}
+
+func TestParseMode(t *testing.T) {
+	for in, want := range map[string]Mode{"": ModeOff, "off": ModeOff, "Migration": ModeMigration, "REQUIRED": ModeRequired} {
+		if m, err := ParseMode(in); err != nil || m != want {
+			t.Fatalf("ParseMode(%q)=%v err=%v, want %v", in, m, err, want)
+		}
+	}
+	if _, err := ParseMode("bogus"); err == nil {
+		t.Fatal("unknown mode must error")
+	}
+}
+
+func TestFileKeySourceRejectsLoosePerms(t *testing.T) {
+	dir := t.TempDir()
+	p := filepath.Join(dir, "k.json")
+	key := make([]byte, keyLen)
+	body, _ := json.Marshal(keyFile{Active: "k1", Keys: map[string]string{"k1": base64.StdEncoding.EncodeToString(key)}})
+	if err := os.WriteFile(p, body, 0o644); err != nil {
+		t.Fatal(err)
+	}
+	if _, err := (FileKeySource{Path: p}).Load(); err == nil {
+		t.Fatal("0644 key file must be rejected")
+	}
+	if err := os.Chmod(p, 0o600); err != nil {
+		t.Fatal(err)
+	}
+	kr, err := (FileKeySource{Path: p}).Load()
+	if err != nil {
+		t.Fatalf("0600 key file should load: %v", err)
+	}
+	if kr.ActiveID != "k1" || len(kr.Keys) != 1 {
+		t.Fatalf("unexpected keyring %+v", kr)
+	}
+}
+
+func TestEnvKeySource(t *testing.T) {
+	key := make([]byte, keyLen)
+	for i := range key {
+		key[i] = byte(i)
+	}
+	t.Setenv("XUI_NODE_TOKEN_KEY_TEST", base64.StdEncoding.EncodeToString(key))
+	kr, err := (EnvKeySource{Var: "XUI_NODE_TOKEN_KEY_TEST"}).Load()
+	if err != nil {
+		t.Fatal(err)
+	}
+	if kr.ActiveID != "env" {
+		t.Fatalf("env key id should be 'env', got %q", kr.ActiveID)
+	}
+	c, _ := NewCodec(ModeRequired, kr)
+	enc, _ := c.Encrypt(1, "x")
+	if pt, _ := c.Decrypt(1, enc); pt != "x" {
+		t.Fatal("env-sourced key failed round trip")
+	}
+}

+ 31 - 0
internal/database/api_token_timestamp_test.go

@@ -48,3 +48,34 @@ func TestNormalizeApiTokenCreatedAtSeconds(t *testing.T) {
 		}
 	}
 }
+
+func TestMigrateApiTokenScopeAndExpiryFromLegacyTable(t *testing.T) {
+	originalDB := db
+	t.Cleanup(func() { db = originalDB })
+	var err error
+	db, err = gorm.Open(sqlite.Open(":memory:"), &gorm.Config{Logger: logger.Discard})
+	if err != nil {
+		t.Fatalf("open sqlite: %v", err)
+	}
+	if err := db.Exec(`CREATE TABLE api_tokens (
+		id integer primary key autoincrement, name text, token text, enabled numeric, created_at integer
+	)`).Error; err != nil {
+		t.Fatalf("create legacy table: %v", err)
+	}
+	if err := db.Exec("INSERT INTO api_tokens(name, token, enabled, created_at) VALUES ('legacy','hash',1,1)").Error; err != nil {
+		t.Fatalf("seed legacy row: %v", err)
+	}
+	if err := migrateApiTokenScopeAndExpiry(); err != nil {
+		t.Fatalf("migrate: %v", err)
+	}
+	if err := migrateApiTokenScopeAndExpiry(); err != nil {
+		t.Fatalf("idempotent migrate: %v", err)
+	}
+	var row model.ApiToken
+	if err := db.First(&row).Error; err != nil {
+		t.Fatalf("read migrated row: %v", err)
+	}
+	if row.Scope != model.ApiScopeAdmin || row.ExpiresAt != 0 {
+		t.Fatalf("legacy defaults = %q/%d, want admin/0", row.Scope, row.ExpiresAt)
+	}
+}

+ 62 - 0
internal/database/client_hwid_schema_test.go

@@ -0,0 +1,62 @@
+package database
+
+import (
+	"os"
+	"path/filepath"
+	"strings"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+
+	"gorm.io/driver/postgres"
+	"gorm.io/gorm"
+	"gorm.io/gorm/logger"
+)
+
+func assertClientHwidSchema(t *testing.T, db *gorm.DB) {
+	t.Helper()
+	if !db.Migrator().HasColumn(&model.ClientRecord{}, "limit_hwid") {
+		t.Fatalf("clients.limit_hwid missing")
+	}
+	if !db.Migrator().HasTable(&model.ClientHwid{}) {
+		t.Fatalf("client_hwids table missing")
+	}
+	for _, col := range []string{"sub_id", "hwid_hash", "first_seen", "last_seen", "user_agent", "device_os", "os_version", "device_model"} {
+		if !db.Migrator().HasColumn(&model.ClientHwid{}, col) {
+			t.Fatalf("client_hwids.%s missing", col)
+		}
+	}
+	if !db.Migrator().HasIndex(&model.ClientHwid{}, "idx_client_hwids_sub_hash") {
+		t.Fatalf("client_hwids unique hash index missing")
+	}
+}
+
+func TestClientHwidSchemaSQLite(t *testing.T) {
+	dbDir := t.TempDir()
+	t.Setenv("XUI_DB_FOLDER", dbDir)
+	if err := InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil {
+		t.Fatalf("InitDB: %v", err)
+	}
+	t.Cleanup(func() { _ = CloseDB() })
+	assertClientHwidSchema(t, GetDB())
+}
+
+func TestClientHwidSchemaPostgres(t *testing.T) {
+	dsn := strings.TrimSpace(os.Getenv("XUI_TEST_PG_DSN"))
+	if dsn == "" {
+		t.Skip("set XUI_TEST_PG_DSN to a reachable Postgres to run this test")
+	}
+	db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: logger.Discard})
+	if err != nil {
+		t.Fatalf("open postgres: %v", err)
+	}
+	sqlDB, err := db.DB()
+	if err != nil {
+		t.Fatalf("postgres db handle: %v", err)
+	}
+	t.Cleanup(func() { _ = sqlDB.Close() })
+	if err := db.AutoMigrate(&model.ClientRecord{}, &model.ClientHwid{}); err != nil {
+		t.Fatalf("automigrate postgres: %v", err)
+	}
+	assertClientHwidSchema(t, db)
+}

+ 31 - 0
internal/database/db.go

@@ -75,6 +75,7 @@ func allModels() []any {
 		&model.ApiToken{},
 		&model.ClientRecord{},
 		&model.ClientInbound{},
+		&model.ClientHwid{},
 		&model.ClientExternalLink{},
 		&model.ClientGroup{},
 		&model.InboundFallback{},
@@ -86,7 +87,18 @@ func allModels() []any {
 	}
 }
 
+func migrateClientTrafficLastSubFetchColumn() error {
+	migrator := db.Migrator()
+	if !migrator.HasTable(&xray.ClientTraffic{}) || migrator.HasColumn(&xray.ClientTraffic{}, "last_sub_fetch") {
+		return nil
+	}
+	return migrator.AddColumn(&xray.ClientTraffic{}, "LastSubFetch")
+}
+
 func initModels() error {
+	if err := migrateClientTrafficLastSubFetchColumn(); err != nil {
+		return err
+	}
 	models := allModels()
 	for _, mdl := range models {
 		if IsPostgres() && postgresModelSettled(mdl) {
@@ -110,6 +122,9 @@ func initModels() error {
 	if err := normalizeApiTokenCreatedAtSeconds(); err != nil {
 		return err
 	}
+	if err := migrateApiTokenScopeAndExpiry(); err != nil {
+		return err
+	}
 	if err := dropLegacyForeignKeys(); err != nil {
 		return err
 	}
@@ -2064,6 +2079,22 @@ func normalizeApiTokenCreatedAtSeconds() error {
 		UpdateColumn("created_at", gorm.Expr("created_at / ?", 1000)).Error
 }
 
+func migrateApiTokenScopeAndExpiry() error {
+	m := db.Migrator()
+	if !m.HasColumn(&model.ApiToken{}, "Scope") {
+		if err := m.AddColumn(&model.ApiToken{}, "Scope"); err != nil {
+			return err
+		}
+	}
+	if !m.HasColumn(&model.ApiToken{}, "ExpiresAt") {
+		if err := m.AddColumn(&model.ApiToken{}, "ExpiresAt"); err != nil {
+			return err
+		}
+	}
+	return db.Model(&model.ApiToken{}).Where("scope IS NULL OR TRIM(scope) = ''").
+		Updates(map[string]any{"scope": model.ApiScopeAdmin, "expires_at": 0}).Error
+}
+
 // openPostgresWithRetry retries the initial PostgreSQL connection with
 // backoff so a database that starts slower than the panel (or drops out
 // briefly) does not immediately kill the process and trip systemd's

+ 1 - 0
internal/database/migrate_data.go

@@ -48,6 +48,7 @@ func migrationModels() []any {
 		&model.InboundClientIps{},
 		&model.ClientRecord{},
 		&model.ClientInbound{},
+		&model.ClientHwid{},
 		&model.ClientExternalLink{},
 		&model.ClientGroup{},
 		&model.InboundFallback{},

+ 37 - 0
internal/database/model/model.go

@@ -154,12 +154,24 @@ type HistoryOfSeeders struct {
 // from the seconds-based API token timestamp contract.
 const ApiTokenUnixMillisecondsThreshold int64 = 100_000_000_000
 
+const (
+	ApiScopeAdmin    = "admin"
+	ApiScopeMonitor  = "monitor"
+	ApiScopeNodeSync = "node-sync"
+)
+
+func IsKnownApiScope(s string) bool {
+	return s == ApiScopeAdmin || s == ApiScopeMonitor || s == ApiScopeNodeSync
+}
+
 type ApiToken struct {
 	Id        int    `json:"id" gorm:"primaryKey;autoIncrement"`
 	Name      string `json:"name" gorm:"uniqueIndex;not null"`
 	Token     string `json:"token" gorm:"not null"` // SHA-256 hash; the plaintext is shown only once at creation
 	Enabled   bool   `json:"enabled" gorm:"default:true"`
 	CreatedAt int64  `json:"createdAt" gorm:"autoCreateTime"`
+	Scope     string `json:"scope" gorm:"not null;default:admin"`
+	ExpiresAt int64  `json:"expiresAt" gorm:"not null;default:0"`
 }
 
 // MarshalJSON emits settings, streamSettings, and sniffing as nested JSON
@@ -902,6 +914,7 @@ type ClientRecord struct {
 	Secret       string `json:"secret" gorm:"column:secret"`
 	AdTag        string `json:"adTag" gorm:"column:ad_tag;default:''"`
 	LimitIP      int    `json:"limitIp" gorm:"column:limit_ip"`
+	LimitHwid    int    `json:"limitHwid" gorm:"column:limit_hwid;default:0"`
 	TotalGB      int64  `json:"totalGB" gorm:"column:total_gb"`
 	ExpiryTime   int64  `json:"expiryTime" gorm:"column:expiry_time"`
 	Enable       bool   `json:"enable" gorm:"default:true"`
@@ -969,6 +982,20 @@ type ClientInbound struct {
 
 func (ClientInbound) TableName() string { return "client_inbounds" }
 
+type ClientHwid struct {
+	Id          int    `json:"id" gorm:"primaryKey;autoIncrement"`
+	SubID       string `json:"subId" gorm:"column:sub_id;not null;index;uniqueIndex:idx_client_hwids_sub_hash,priority:1"`
+	HwidHash    string `json:"-" gorm:"column:hwid_hash;size:64;not null;uniqueIndex:idx_client_hwids_sub_hash,priority:2"`
+	FirstSeen   int64  `json:"firstSeen" gorm:"column:first_seen;not null"`
+	LastSeen    int64  `json:"lastSeen" gorm:"column:last_seen;not null;index"`
+	UserAgent   string `json:"userAgent" gorm:"column:user_agent"`
+	DeviceOS    string `json:"deviceOs" gorm:"column:device_os"`
+	OsVersion   string `json:"osVersion" gorm:"column:os_version"`
+	DeviceModel string `json:"deviceModel" gorm:"column:device_model"`
+}
+
+func (ClientHwid) TableName() string { return "client_hwids" }
+
 // ClientExternalLink is a per-client entry surfaced in the client's
 // subscription. Two kinds:
 //   - "link": a single third-party share link (vless://, vmess://, trojan://,
@@ -1255,6 +1282,16 @@ func MergeClientRecord(existing *ClientRecord, incoming *ClientRecord) []ClientM
 			existing.LimitIP = picked
 		}
 	}
+	if existing.LimitHwid != incoming.LimitHwid && incoming.LimitHwid != 0 {
+		picked := existing.LimitHwid
+		if existing.LimitHwid == 0 || incoming.LimitHwid > existing.LimitHwid {
+			picked = incoming.LimitHwid
+		}
+		if picked != existing.LimitHwid {
+			keep("limitHwid", existing.LimitHwid, incoming.LimitHwid, picked)
+			existing.LimitHwid = picked
+		}
+	}
 	if existing.TgID != incoming.TgID && incoming.TgID != 0 {
 		if incomingNewer || existing.TgID == 0 {
 			keep("tgId", existing.TgID, incoming.TgID, incoming.TgID)

+ 188 - 98
internal/sub/controller.go

@@ -1,12 +1,10 @@
 package sub
 
 import (
-	"bytes"
 	"encoding/base64"
-	"encoding/json"
 	"fmt"
+	stdhtml "html"
 	"html/template"
-	"io/fs"
 	"net/http"
 	"net/url"
 	"os"
@@ -18,6 +16,8 @@ import (
 	"unicode"
 
 	"github.com/gin-gonic/gin"
+	"github.com/nicksnyder/go-i18n/v2/i18n"
+	"golang.org/x/text/language"
 
 	"github.com/mhsanaei/3x-ui/v3/internal/logger"
 	"github.com/mhsanaei/3x-ui/v3/internal/web/service"
@@ -72,6 +72,7 @@ type SUBController struct {
 	subService      *SubService
 	subJsonService  *SubJsonService
 	subClashService *SubClashService
+	clientService   service.ClientService
 	settingService  service.SettingService
 
 	subTemplateMu    sync.RWMutex
@@ -295,23 +296,18 @@ func (a *SUBController) initRouter(g *gin.RouterGroup) {
 	}
 }
 
-// maybeServeSubPage renders the HTML info page when the request comes from a
-// browser (Accept: text/html) or explicitly asks for it (?html=1 or ?view=html).
-// It reports whether the request was handled. The remark template's per-client
-// info is for the content a client app imports — the raw subscription body. A
-// browser viewing the HTML info page gets clean, name-only remarks (usage is
-// shown in the page summary).
+// maybeServeSubPage validates the subscription and renders a copy-only page.
+// The full page embeds share links and must never handle browser navigation.
 func (a *SUBController) maybeServeSubPage(c *gin.Context) bool {
-	accept := c.GetHeader("Accept")
-	wantsHTML := strings.Contains(strings.ToLower(accept), "text/html") || c.Query("html") == "1" || strings.EqualFold(c.Query("view"), "html")
-	if !wantsHTML {
+	explicit := explicitSubPageRequest(c)
+	if !explicit && !a.isBrowserSubscriptionRequest(c) {
 		return false
 	}
-	page, ok := a.buildSubPageData(c)
+	_, ok := a.buildSubPageData(c)
 	if !ok {
 		return true
 	}
-	a.serveSubPage(c, page.BasePath, page)
+	a.serveSubscriptionCopyPage(c)
 	return true
 }
 
@@ -353,7 +349,9 @@ func (a *SUBController) buildSubPageData(c *gin.Context) (PageData, bool) {
 		basePath = "/"
 	}
 	basePathStr := basePath.(string)
-	page := subReq.BuildPageData(subId, hostHeader, traffic, lastOnline, subs, emails, subURL, subJsonURL, subClashURL, basePathStr, a.subTitle, a.subSupportUrl)
+	metadata := a.metadataForSubRequest(func() *SubService { return subReq }, subId, "")
+	page := subReq.BuildPageData(subId, hostHeader, traffic, lastOnline, subs, emails, subURL, subJsonURL, subClashURL, basePathStr, metadata.Title, metadata.SupportURL)
+	page.SubAnnounce = metadata.Announce
 	return page, true
 }
 
@@ -384,11 +382,16 @@ func (a *SUBController) subs(c *gin.Context) {
 		logSubscriptionRoute(userAgent, "html")
 		return
 	}
+	if !a.enforceHwid(c) {
+		return
+	}
 	if shouldAutoServeClash(a.subClashAutoDetect, a.clashEnabled, false, userAgent, a.clashUserAgent) && a.serveClashBody(c, false) {
+		a.recordSubscriptionFetch(c)
 		logSubscriptionRoute(userAgent, "clash")
 		return
 	}
 	if shouldAutoServeJson(a.jsonAutoDetect, a.jsonEnabled, false, userAgent, a.jsonUserAgent) && a.serveJsonBody(c, true, "application/json; charset=utf-8", false) {
+		a.recordSubscriptionFetch(c)
 		logSubscriptionRoute(userAgent, "json")
 		return
 	}
@@ -409,11 +412,9 @@ func (a *SUBController) subs(c *gin.Context) {
 
 		// Add headers
 		header := fmt.Sprintf("upload=%d; download=%d; total=%d; expire=%d", traffic.Up, traffic.Down, traffic.Total, traffic.ExpiryTime/1000)
-		profileUrl := a.subProfileUrl
-		if profileUrl == "" {
-			profileUrl = fmt.Sprintf("%s://%s%s", scheme, hostWithPort, c.Request.RequestURI)
-		}
-		a.ApplyCommonHeaders(c, header, a.updateInterval, a.subTitle, a.subSupportUrl, profileUrl, a.subAnnounce, a.subEnableRouting, a.subRoutingRules, a.subHideSettings)
+		profileURL := fmt.Sprintf("%s://%s%s", scheme, hostWithPort, c.Request.RequestURI)
+		metadata := a.metadataForSubRequest(func() *SubService { return subReq }, subId, profileURL)
+		a.ApplyCommonHeaders(c, header, a.updateInterval, metadata.Title, metadata.SupportURL, metadata.ProfileURL, metadata.Announce, a.subEnableRouting, a.subRoutingRules, a.subHideSettings)
 
 		if a.subIncyEnableRouting && a.subIncyRoutingRules != "" {
 			result.WriteString(a.subIncyRoutingRules)
@@ -425,6 +426,16 @@ func (a *SUBController) subs(c *gin.Context) {
 		} else {
 			c.String(200, result.String())
 		}
+		a.recordSubscriptionFetch(c)
+	}
+}
+
+func (a *SUBController) recordSubscriptionFetch(c *gin.Context) {
+	if c.Request == nil || c.Request.Method != http.MethodGet || c.Writer.Status() != http.StatusOK {
+		return
+	}
+	if err := a.subService.RecordSubscriptionFetch(c.Param("subid")); err != nil {
+		logger.Warning("Failed to record subscription fetch:", err)
 	}
 }
 
@@ -480,80 +491,106 @@ func compileUserAgentRegex(name, pattern, defaultPattern string) *regexp.Regexp
 	return regexp.MustCompile(defaultPattern)
 }
 
-// serveSubPage renders internal/web/dist/subpage.html for the current subscription
-// request. The Vite-built SPA reads window.__SUB_PAGE_DATA__ on mount —
-// we inject that here, along with window.X_UI_BASE_PATH so the
-// page's static asset references resolve correctly when the panel runs
-// behind a URL prefix.
-func (a *SUBController) serveSubPage(c *gin.Context, basePath string, page PageData) {
-	var body []byte
-	if diskBody, diskErr := os.ReadFile("internal/web/dist/subpage.html"); diskErr == nil {
-		body = diskBody
-	} else {
-		readBody, err := fs.ReadFile(distFS, "dist/subpage.html")
-		if err != nil {
-			c.String(http.StatusInternalServerError, "missing embedded subpage")
-			return
-		}
-		body = readBody
-	}
+// explicitSubPageRequest reports whether the caller explicitly asked for HTML.
+func explicitSubPageRequest(c *gin.Context) bool {
+	return c.Query("html") == "1" || strings.EqualFold(c.Query("view"), "html")
+}
 
-	// Vite emits absolute asset URLs (`/assets/...`); when the panel is
-	// installed under a custom URL prefix, rewrite them so the bundle
-	// loads from `<basePath>assets/...` where the static handler is
-	// actually mounted.
-	if basePath != "/" && basePath != "" {
-		body = bytes.ReplaceAll(body, []byte(`src="/assets/`), []byte(`src="`+basePath+`assets/`))
-		body = bytes.ReplaceAll(body, []byte(`href="/assets/`), []byte(`href="`+basePath+`assets/`))
+func (a *SUBController) isBrowserSubscriptionRequest(c *gin.Context) bool {
+	accept := strings.ToLower(c.GetHeader("Accept"))
+	if strings.Contains(accept, "text/html") {
+		return true
 	}
 
-	subData := a.subPageContext(page)
+	fetchDest := strings.ToLower(c.GetHeader("Sec-Fetch-Dest"))
+	fetchMode := strings.ToLower(c.GetHeader("Sec-Fetch-Mode"))
+	if fetchDest == "document" || fetchMode == "navigate" {
+		return true
+	}
 
-	// When an admin has configured a custom subscription theme, render it
-	// instead of the default SPA. We render into a buffer first so a template
-	// that fails mid-execution can't leave a partially-written (corrupt)
-	// response — on any error we log and fall through to the default page.
-	if themeDir, _ := a.settingService.GetSubThemeDir(); themeDir != "" {
-		if tmpl, err := a.loadSubTemplate(themeDir); err != nil {
-			logger.Error("sub: custom template parse failed, using default page:", err)
-		} else if tmpl == nil {
-			logger.Warning("sub: subThemeDir set but no usable template found, using default page:", themeDir)
-		} else {
-			var buf bytes.Buffer
-			if execErr := tmpl.Execute(&buf, subData); execErr != nil {
-				logger.Error("sub: custom template execution failed, using default page:", execErr)
-			} else {
-				setNoCacheHeaders(c)
-				c.Data(http.StatusOK, "text/html; charset=utf-8", buf.Bytes())
-				return
+	rawUA := c.GetHeader("User-Agent")
+	ua := strings.ToLower(rawUA)
+	if rawUA == "" {
+		return false
+	}
+	if shouldAutoServeClash(a.subClashAutoDetect, a.clashEnabled, false, rawUA, a.clashUserAgent) ||
+		shouldAutoServeJson(a.jsonAutoDetect, a.jsonEnabled, false, rawUA, a.jsonUserAgent) {
+		return false
+	}
+	if strings.Contains(ua, "mozilla/") {
+		vpnClients := []string{
+			"clash", "mihomo", "sing-box", "v2ray", "xray", "hiddify",
+			"nekobox", "shadowrocket", "streisand", "v2box", "incy", "happ",
+		}
+		for _, client := range vpnClients {
+			if strings.Contains(ua, client) {
+				return false
 			}
 		}
+		return true
 	}
+	return false
+}
 
-	subDataJSON, err := json.Marshal(subData)
-	if err != nil {
-		subDataJSON = []byte("{}")
-	}
-
-	// Defense-in-depth string-escape for the basePath embed — admin-
-	// controlled but cheap to harden.
-	jsEscape := strings.NewReplacer(
-		`\`, `\\`,
-		`"`, `\"`,
-		"\n", `\n`,
-		"\r", `\r`,
-		"<", `<`,
-		">", `>`,
-		"&", `&`,
-	)
-	escapedBase := jsEscape.Replace(basePath)
-
-	inject := []byte(`<script>window.X_UI_BASE_PATH="` + escapedBase + `";` +
-		`window.__SUB_PAGE_DATA__=` + string(subDataJSON) + `;</script></head>`)
-	out := bytes.Replace(body, []byte("</head>"), inject, 1)
-
+func (a *SUBController) serveSubscriptionCopyPage(c *gin.Context) {
 	setNoCacheHeaders(c)
-	c.Data(http.StatusOK, "text/html; charset=utf-8", out)
+	title := localizeRequest(c, "subCopyPageTitle")
+	heading := localizeRequest(c, "subCopyPageHeading")
+	instructions := localizeRequest(c, "subCopyPageInstructions")
+	lang := requestLanguage(c)
+	page := `<!doctype html>
+<html lang="{{LANG}}">
+<head>
+  <meta charset="utf-8">
+  <meta name="viewport" content="width=device-width, initial-scale=1">
+  <meta name="robots" content="noindex,nofollow">
+  <title>{{TITLE}}</title>
+  <style>
+    html, body { margin: 0; min-height: 100%; background: #050505; color: #f2f2f2; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
+    body { min-height: 100vh; display: flex; align-items: center; justify-content: center; text-align: center; }
+    main { max-width: 520px; padding: 32px; }
+    h1 { margin: 0 0 14px; font-size: 24px; font-weight: 650; letter-spacing: -0.02em; }
+    p { margin: 0; color: #b8b8b8; font-size: 16px; line-height: 1.55; }
+  </style>
+</head>
+<body>
+  <main>
+    <h1>{{HEADING}}</h1>
+    <p>{{INSTRUCTIONS}}</p>
+  </main>
+</body>
+</html>`
+	page = strings.NewReplacer(
+		"{{LANG}}", stdhtml.EscapeString(lang),
+		"{{TITLE}}", stdhtml.EscapeString(title),
+		"{{HEADING}}", stdhtml.EscapeString(heading),
+		"{{INSTRUCTIONS}}", stdhtml.EscapeString(instructions),
+	).Replace(page)
+	c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(page))
+}
+
+func localizeRequest(c *gin.Context, key string) string {
+	if value, ok := c.Get("localizer"); ok {
+		if localizer, ok := value.(*i18n.Localizer); ok {
+			if msg, err := localizer.Localize(&i18n.LocalizeConfig{MessageID: key}); err == nil {
+				return msg
+			}
+		}
+	}
+	fallbacks := map[string]string{
+		"subCopyPageTitle":        "Subscription link",
+		"subCopyPageHeading":      "This is a subscription link",
+		"subCopyPageInstructions": "You do not need to open it in a browser. Copy this page address and paste it into the app.",
+	}
+	return fallbacks[key]
+}
+
+func requestLanguage(c *gin.Context) string {
+	tag, _, _ := language.ParseAcceptLanguage(c.GetHeader("Accept-Language"))
+	if len(tag) == 0 {
+		return "en-US"
+	}
+	return tag[0].String()
 }
 
 // subPageContext builds the shared view-model map: the template context for
@@ -589,7 +626,42 @@ func (a *SUBController) subPageContext(page PageData) map[string]any {
 		"links":         page.Result,
 		"emails":        page.Emails,
 		"datepicker":    datepicker,
-		"announce":      a.subAnnounce,
+		"announce":      page.SubAnnounce,
+	}
+}
+
+func (a *SUBController) enforceHwid(c *gin.Context) bool {
+	result, err := a.clientService.EnforceHwidForSubID(c.Param("subid"), service.HwidRequest{
+		Hwid:        c.GetHeader("X-HWID"),
+		UserAgent:   c.GetHeader("User-Agent"),
+		DeviceOS:    c.GetHeader("X-Device-OS"),
+		OsVersion:   c.GetHeader("X-Ver-OS"),
+		DeviceModel: c.GetHeader("X-Device-Model"),
+	})
+	if err != nil {
+		writeSubError(c, err)
+		return false
+	}
+	applyHwidHeaders(c, result)
+	if !result.Allowed {
+		c.Status(http.StatusNotFound)
+		return false
+	}
+	return true
+}
+
+func applyHwidHeaders(c *gin.Context, result service.HwidGateResult) {
+	if result.Active {
+		c.Header("X-Hwid-Active", "true")
+	}
+	if result.NotSupported {
+		c.Header("X-Hwid-Not-Supported", "true")
+	}
+	if result.LimitReached {
+		c.Header("X-Hwid-Limit", "true")
+	}
+	if result.MaxDevicesReached {
+		c.Header("X-Hwid-Max-Devices-Reached", "true")
 	}
 }
 
@@ -650,11 +722,15 @@ func (a *SUBController) subJsons(c *gin.Context) {
 		if !a.serveJsonBody(c, a.jsonAlwaysArray, "application/json; charset=utf-8", true) {
 			writeSubError(c, nil)
 		}
+		a.recordSubscriptionFetch(c)
 		return
 	}
 	if a.maybeServeSubPage(c) {
 		return
 	}
+	if !a.enforceHwid(c) {
+		return
+	}
 	a.serveJson(c, a.jsonAlwaysArray, "text/plain; charset=utf-8")
 }
 
@@ -662,6 +738,7 @@ func (a *SUBController) serveJson(c *gin.Context, alwaysReturnArray bool, conten
 	if !a.serveJsonBody(c, alwaysReturnArray, contentType, false) {
 		writeSubError(c, nil)
 	}
+	a.recordSubscriptionFetch(c)
 }
 
 func (a *SUBController) serveJsonBody(c *gin.Context, alwaysReturnArray bool, contentType string, rawDownload bool) bool {
@@ -675,11 +752,15 @@ func (a *SUBController) serveJsonBody(c *gin.Context, alwaysReturnArray bool, co
 	if len(jsonSub) == 0 {
 		return false
 	}
-	profileUrl := a.subProfileUrl
-	if profileUrl == "" {
-		profileUrl = fmt.Sprintf("%s://%s%s", scheme, hostWithPort, c.Request.RequestURI)
-	}
-	a.ApplyCommonHeaders(c, header, a.updateInterval, a.subTitle, a.subSupportUrl, profileUrl, a.subAnnounce, a.subEnableRouting, a.subRoutingRules, a.subHideSettings)
+	profileURL := fmt.Sprintf("%s://%s%s", scheme, hostWithPort, c.Request.RequestURI)
+	var subReq *SubService
+	metadata := a.metadataForSubRequest(func() *SubService {
+		if subReq == nil {
+			subReq = a.subService.ForRequest(host)
+		}
+		return subReq
+	}, subId, profileURL)
+	a.ApplyCommonHeaders(c, header, a.updateInterval, metadata.Title, metadata.SupportURL, metadata.ProfileURL, metadata.Announce, a.subEnableRouting, a.subRoutingRules, a.subHideSettings)
 	if rawDownload {
 		c.Writer.Header().Set("Content-Disposition", `attachment; filename="subscription.json"`)
 	}
@@ -693,14 +774,19 @@ func (a *SUBController) subClashs(c *gin.Context) {
 		if !a.serveClashBody(c, true) {
 			writeSubError(c, nil)
 		}
+		a.recordSubscriptionFetch(c)
 		return
 	}
 	if a.maybeServeSubPage(c) {
 		return
 	}
+	if !a.enforceHwid(c) {
+		return
+	}
 	if !a.serveClashBody(c, false) {
 		writeSubError(c, nil)
 	}
+	a.recordSubscriptionFetch(c)
 }
 
 func (a *SUBController) serveClashBody(c *gin.Context, rawDownload bool) bool {
@@ -714,16 +800,20 @@ func (a *SUBController) serveClashBody(c *gin.Context, rawDownload bool) bool {
 	if len(clashSub) == 0 {
 		return false
 	}
-	profileUrl := a.subProfileUrl
-	if profileUrl == "" {
-		profileUrl = fmt.Sprintf("%s://%s%s", scheme, hostWithPort, c.Request.RequestURI)
-	}
-	a.ApplyCommonHeaders(c, header, a.updateInterval, a.subTitle, a.subSupportUrl, profileUrl, a.subAnnounce, a.subEnableRouting, a.subRoutingRules, a.subHideSettings)
+	profileURL := fmt.Sprintf("%s://%s%s", scheme, hostWithPort, c.Request.RequestURI)
+	var subReq *SubService
+	metadata := a.metadataForSubRequest(func() *SubService {
+		if subReq == nil {
+			subReq = a.subService.ForRequest(host)
+		}
+		return subReq
+	}, subId, profileURL)
+	a.ApplyCommonHeaders(c, header, a.updateInterval, metadata.Title, metadata.SupportURL, metadata.ProfileURL, metadata.Announce, a.subEnableRouting, a.subRoutingRules, a.subHideSettings)
 	if rawDownload {
 		c.Writer.Header().Set("Content-Disposition", `attachment; filename="subscription.yaml"`)
-	} else if a.subTitle != "" {
+	} else if metadata.Title != "" {
 		// Clash clients commonly use Content-Disposition to choose the imported profile name.
-		c.Writer.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename*=UTF-8''%s`, url.PathEscape(a.subTitle)))
+		c.Writer.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename*=UTF-8''%s`, url.PathEscape(metadata.Title)))
 	}
 	c.Data(200, "application/yaml; charset=utf-8", []byte(clashSub))
 	return true

+ 133 - 0
internal/sub/controller_browser_test.go

@@ -0,0 +1,133 @@
+package sub
+
+import (
+	"net/http"
+	"net/http/httptest"
+	"regexp"
+	"strings"
+	"testing"
+
+	"github.com/gin-gonic/gin"
+	"github.com/nicksnyder/go-i18n/v2/i18n"
+	"golang.org/x/text/language"
+)
+
+func TestIsBrowserSubscriptionRequest(t *testing.T) {
+	gin.SetMode(gin.TestMode)
+
+	tests := []struct {
+		name   string
+		accept string
+		ua     string
+		dest   string
+		mode   string
+		query  string
+		want   bool
+	}{
+		{name: "explicit html query is not implicit navigation", query: "?html=1", want: false},
+		{name: "html accept", accept: "text/html,application/xhtml+xml", want: true},
+		{name: "browser navigation with wildcard accept", accept: "*/*", ua: "Mozilla/5.0 Safari/605.1.15", dest: "document", mode: "navigate", want: true},
+		{name: "browser ua fallback", accept: "*/*", ua: "Mozilla/5.0 Chrome/126.0.0.0", want: true},
+		{name: "vpn client wildcard", accept: "*/*", ua: "Incy/3.3.0", want: false},
+		{name: "vpn client with mozilla token", accept: "*/*", ua: "Mozilla/5.0 Incy/3.3.0", want: false},
+		{name: "plain client", accept: "*/*", ua: "Go-http-client/2.0", want: false},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			w := httptest.NewRecorder()
+			c, _ := gin.CreateTestContext(w)
+			req := httptest.NewRequest(http.MethodGet, "/sub/abc"+tt.query, nil)
+			if tt.accept != "" {
+				req.Header.Set("Accept", tt.accept)
+			}
+			if tt.ua != "" {
+				req.Header.Set("User-Agent", tt.ua)
+			}
+			if tt.dest != "" {
+				req.Header.Set("Sec-Fetch-Dest", tt.dest)
+			}
+			if tt.mode != "" {
+				req.Header.Set("Sec-Fetch-Mode", tt.mode)
+			}
+			c.Request = req
+
+			if got := (&SUBController{}).isBrowserSubscriptionRequest(c); got != tt.want {
+				t.Fatalf("isBrowserSubscriptionRequest() = %v, want %v", got, tt.want)
+			}
+		})
+	}
+}
+
+func TestBrowserClassificationHonorsConfiguredFormatMatchers(t *testing.T) {
+	cases := []struct {
+		name string
+		new  func() *SUBController
+	}{
+		{"clash", func() *SUBController {
+			return &SUBController{subClashAutoDetect: true, clashEnabled: true, clashUserAgent: regexp.MustCompile(`Custom-Client`)}
+		}},
+		{"json", func() *SUBController {
+			return &SUBController{jsonAutoDetect: true, jsonEnabled: true, jsonUserAgent: regexp.MustCompile(`Custom-Client`)}
+		}},
+	}
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			c, _ := gin.CreateTestContext(httptest.NewRecorder())
+			c.Request = httptest.NewRequest(http.MethodGet, "/sub/abc", nil)
+			c.Request.Header.Set("User-Agent", "Mozilla/5.0 Custom-Client/1.0")
+			if tc.new().isBrowserSubscriptionRequest(c) {
+				t.Fatal("configured subscription client was classified as a browser")
+			}
+		})
+	}
+}
+
+func TestSubscriptionCopyPageUsesRequestLocale(t *testing.T) {
+	bundle := i18n.NewBundle(language.English)
+	for id, text := range map[string]string{
+		"subCopyPageTitle":        "Titre localisé",
+		"subCopyPageHeading":      "En-tête localisé",
+		"subCopyPageInstructions": "Instructions localisées",
+	} {
+		bundle.AddMessages(language.French, &i18n.Message{ID: id, Other: text})
+	}
+	w := httptest.NewRecorder()
+	c, _ := gin.CreateTestContext(w)
+	c.Request = httptest.NewRequest(http.MethodGet, "/sub/abc", nil)
+	c.Request.Header.Set("Accept-Language", "fr-FR")
+	c.Set("localizer", i18n.NewLocalizer(bundle, "fr-FR"))
+
+	(&SUBController{}).serveSubscriptionCopyPage(c)
+	if body := w.Body.String(); !strings.Contains(body, `<html lang="fr-FR">`) ||
+		!strings.Contains(body, "Titre localisé") || !strings.Contains(body, "Instructions localisées") {
+		t.Fatalf("copy page was not localized from the request: %s", body)
+	}
+}
+
+func TestExplicitSubPageRequest(t *testing.T) {
+	gin.SetMode(gin.TestMode)
+
+	tests := []struct {
+		name  string
+		query string
+		want  bool
+	}{
+		{name: "html=1", query: "?html=1", want: true},
+		{name: "view=html", query: "?view=HTML", want: true},
+		{name: "no query", query: "", want: false},
+		{name: "unrelated query", query: "?format=info", want: false},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			w := httptest.NewRecorder()
+			c, _ := gin.CreateTestContext(w)
+			c.Request = httptest.NewRequest(http.MethodGet, "/sub/abc"+tt.query, nil)
+
+			if got := explicitSubPageRequest(c); got != tt.want {
+				t.Fatalf("explicitSubPageRequest() = %v, want %v", got, tt.want)
+			}
+		})
+	}
+}

+ 134 - 0
internal/sub/hwid_controller_test.go

@@ -0,0 +1,134 @@
+package sub
+
+import (
+	"net/http"
+	"net/http/httptest"
+	"os"
+	"path/filepath"
+	"testing"
+
+	"github.com/gin-gonic/gin"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+)
+
+func initHwidSubRouter(t *testing.T, limit int) (*gin.Engine, string) {
+	t.Helper()
+	tmp := t.TempDir()
+	t.Chdir(tmp)
+	if err := os.MkdirAll("internal/web/dist", 0o755); err != nil {
+		t.Fatalf("mkdir dist: %v", err)
+	}
+	if err := os.WriteFile("internal/web/dist/subpage.html", []byte("<html><head></head><body></body></html>"), 0o644); err != nil {
+		t.Fatalf("write subpage: %v", err)
+	}
+
+	t.Setenv("XUI_DB_FOLDER", tmp)
+	if err := database.InitDB(filepath.Join(tmp, "x-ui.db")); err != nil {
+		t.Fatalf("InitDB: %v", err)
+	}
+	t.Cleanup(func() { _ = database.CloseDB() })
+
+	const subID = "sub-hwid-route"
+	const email = "[email protected]"
+	const uuid = "11111111-2222-4333-8444-555555555555"
+	db := database.GetDB()
+	ib := &model.Inbound{
+		UserId:         1,
+		Tag:            "hwid-sub",
+		Enable:         true,
+		Port:           443,
+		Protocol:       model.VLESS,
+		Settings:       `{"clients":[]}`,
+		StreamSettings: `{"network":"tcp","security":"none"}`,
+	}
+	if err := db.Create(ib).Error; err != nil {
+		t.Fatalf("seed inbound: %v", err)
+	}
+	client := &model.ClientRecord{Email: email, SubID: subID, UUID: uuid, Enable: true, LimitHwid: limit}
+	if err := db.Create(client).Error; err != nil {
+		t.Fatalf("seed client: %v", err)
+	}
+	if err := db.Create(&model.ClientInbound{ClientId: client.Id, InboundId: ib.Id}).Error; err != nil {
+		t.Fatalf("seed client inbound: %v", err)
+	}
+
+	gin.SetMode(gin.TestMode)
+	router := gin.New()
+	NewSUBController(
+		router.Group("/"),
+		WithSUBPath("/sub/"),
+		WithSUBJsonPath("/json/"),
+		WithSUBClashPath("/clash/"),
+		WithSUBClashAutoDetect(true),
+		WithSUBJsonAutoDetect(true),
+		WithSUBJsonEnabled(true),
+		WithSUBClashEnabled(true),
+	)
+	return router, subID
+}
+
+func requestSub(t *testing.T, router *gin.Engine, method string, path string, hwid string, accept string) *httptest.ResponseRecorder {
+	t.Helper()
+	req := httptest.NewRequest(method, path, nil)
+	req.Host = "sub.example.com"
+	if hwid != "" {
+		req.Header.Set("X-HWID", hwid)
+	}
+	if accept != "" {
+		req.Header.Set("Accept", accept)
+	}
+	rec := httptest.NewRecorder()
+	router.ServeHTTP(rec, req)
+	return rec
+}
+
+func TestSubscriptionHwidGateAcrossBodyRoutes(t *testing.T) {
+	router, subID := initHwidSubRouter(t, 1)
+
+	for _, path := range []string{"/sub/" + subID, "/json/" + subID, "/clash/" + subID} {
+		rec := requestSub(t, router, http.MethodGet, path, "", "")
+		if rec.Code != http.StatusNotFound {
+			t.Fatalf("%s missing HWID status = %d, want 404", path, rec.Code)
+		}
+		if rec.Header().Get("X-Hwid-Active") != "true" || rec.Header().Get("X-Hwid-Not-Supported") != "true" {
+			t.Fatalf("%s missing HWID headers = %#v", path, rec.Header())
+		}
+	}
+
+	rec := requestSub(t, router, http.MethodHead, "/sub/"+subID, "", "")
+	if rec.Code != http.StatusNotFound || rec.Header().Get("X-Hwid-Not-Supported") != "true" {
+		t.Fatalf("HEAD missing HWID = %d %#v", rec.Code, rec.Header())
+	}
+
+	for _, path := range []string{"/sub/" + subID, "/json/" + subID, "/clash/" + subID} {
+		rec = requestSub(t, router, http.MethodGet, path, "device-one", "")
+		if rec.Code != http.StatusOK {
+			t.Fatalf("%s registered HWID status = %d, body=%q", path, rec.Code, rec.Body.String())
+		}
+		if rec.Header().Get("X-Hwid-Active") != "true" {
+			t.Fatalf("%s allowed response missing active HWID header", path)
+		}
+	}
+
+	rec = requestSub(t, router, http.MethodGet, "/json/"+subID, "device-two", "")
+	if rec.Code != http.StatusNotFound {
+		t.Fatalf("new HWID after limit status = %d, want 404", rec.Code)
+	}
+	if rec.Header().Get("X-Hwid-Max-Devices-Reached") != "true" || rec.Header().Get("X-Hwid-Limit") != "true" {
+		t.Fatalf("limit headers missing: %#v", rec.Header())
+	}
+}
+
+func TestSubscriptionHwidGateSkipsHtmlInfoPage(t *testing.T) {
+	router, subID := initHwidSubRouter(t, 1)
+
+	rec := requestSub(t, router, http.MethodGet, "/sub/"+subID, "", "text/html")
+	if rec.Code != http.StatusOK {
+		t.Fatalf("HTML sub page status = %d, want 200, body=%q", rec.Code, rec.Body.String())
+	}
+	if rec.Header().Get("X-Hwid-Not-Supported") != "" {
+		t.Fatalf("HTML sub page should not be HWID-gated: %#v", rec.Header())
+	}
+}

+ 27 - 4
internal/sub/info_endpoint_test.go

@@ -118,10 +118,33 @@ func TestSubInfoEndpoint_HTMLPageStillWinsWithoutFormatParam(t *testing.T) {
 	if ct := w.Header().Get("Content-Type"); !strings.Contains(ct, "text/html") {
 		t.Fatalf("Content-Type = %q, want text/html for a browser request", ct)
 	}
-	if !strings.Contains(w.Body.String(), "__SUB_PAGE_DATA__") {
-		t.Fatal("browser request must still get the SPA page with injected page data")
+	if strings.Contains(w.Body.String(), "__SUB_PAGE_DATA__") {
+		t.Fatal("copy-only browser page must not embed subscription page data")
 	}
-	if !strings.Contains(w.Body.String(), `"isOnline":false`) {
-		t.Fatalf("injected page data must carry isOnline; body=%s", w.Body.String())
+	if !strings.Contains(w.Body.String(), "This is a subscription link") {
+		t.Fatalf("browser request did not get the copy-only page; body=%s", w.Body.String())
+	}
+}
+
+func TestExplicitHTMLRequestUsesCopyOnlyPage(t *testing.T) {
+	gin.SetMode(gin.TestMode)
+	initSubDB(t)
+	seedInfoEndpointSub(t, "explicit-html", "explicit@x")
+	oldDistFS := distFS
+	distFS = testDistFS
+	t.Cleanup(func() { distFS = oldDistFS })
+
+	router := gin.New()
+	NewSUBController(router.Group("/"))
+	req := httptest.NewRequest(http.MethodGet, "/sub/explicit-html?html=1", nil)
+	req.Host = "sub.example.com"
+	w := httptest.NewRecorder()
+	router.ServeHTTP(w, req)
+
+	if w.Code != http.StatusOK {
+		t.Fatalf("status = %d, want 200", w.Code)
+	}
+	if strings.Contains(w.Body.String(), "__SUB_PAGE_DATA__") {
+		t.Fatal("explicit HTML request exposed subscription page data")
 	}
 }

+ 118 - 0
internal/sub/placeholders.go

@@ -0,0 +1,118 @@
+package sub
+
+import (
+	"errors"
+	"net/url"
+	"strings"
+
+	"gorm.io/gorm"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/logger"
+)
+
+type subPlaceholderData struct {
+	SubID   string
+	Context remarkContext
+	HasCtx  bool
+	Escape  bool
+}
+
+type renderedSubMetadata struct {
+	Title      string
+	SupportURL string
+	ProfileURL string
+	Announce   string
+}
+
+func renderSubPlaceholders(value string, data subPlaceholderData) string {
+	if value == "" || !strings.Contains(value, "{") {
+		return value
+	}
+
+	ctx := data.Context
+	if !data.HasCtx {
+		ctx = remarkContext{
+			client: model.Client{
+				SubID: data.SubID,
+			},
+		}
+	}
+	if ctx.client.SubID == "" {
+		ctx.client.SubID = data.SubID
+	}
+	return strings.TrimSpace(expandSubMetadataVars(value, ctx, data.Escape))
+}
+
+var subMetadataTokens = map[string]bool{
+	"EMAIL":       true,
+	"ID":          true,
+	"SHORT_ID":    true,
+	"TELEGRAM_ID": true,
+	"SUB_ID":      true,
+}
+
+func expandSubMetadataVars(template string, ctx remarkContext, escape bool) string {
+	return remarkVarRe.ReplaceAllStringFunc(template, func(match string) string {
+		token := match[2 : len(match)-2]
+		if !subMetadataTokens[token] {
+			return match
+		}
+		value := remarkVarValue(token, ctx)
+		if escape {
+			return url.QueryEscape(value)
+		}
+		return value
+	})
+}
+
+func subMetadataUsesPlaceholders(values ...string) bool {
+	for _, value := range values {
+		if strings.Contains(value, "{") {
+			return true
+		}
+	}
+	return false
+}
+
+func (a *SUBController) metadataForSubRequest(getSubReq func() *SubService, subID string, fallbackProfileURL string) renderedSubMetadata {
+	var context remarkContext
+	var hasContext bool
+	if subMetadataUsesPlaceholders(a.subTitle, a.subSupportUrl, a.subProfileUrl, a.subAnnounce) {
+		var err error
+		subReq := getSubReq()
+		context, hasContext, err = subReq.subscriptionTemplateContextBySubID(subID)
+		if err != nil {
+			logger.Warning("sub: load template contexts for subscription metadata:", err)
+		}
+	}
+	profileURL := a.subProfileUrl
+	if profileURL == "" {
+		profileURL = fallbackProfileURL
+	} else {
+		profileURL = renderSubPlaceholders(profileURL, subPlaceholderData{SubID: subID, Context: context, HasCtx: hasContext, Escape: true})
+	}
+	data := subPlaceholderData{SubID: subID, Context: context, HasCtx: hasContext}
+	return renderedSubMetadata{
+		Title:      renderSubPlaceholders(a.subTitle, data),
+		SupportURL: renderSubPlaceholders(a.subSupportUrl, subPlaceholderData{SubID: subID, Context: context, HasCtx: hasContext, Escape: true}),
+		ProfileURL: profileURL,
+		Announce:   renderSubPlaceholders(a.subAnnounce, data),
+	}
+}
+
+func (s *SubService) subscriptionTemplateContextBySubID(subID string) (remarkContext, bool, error) {
+	if subID == "" {
+		return remarkContext{}, false, nil
+	}
+	var rec model.ClientRecord
+	err := database.GetDB().Where("sub_id = ?", subID).Order("id ASC").First(&rec).Error
+	if errors.Is(err, gorm.ErrRecordNotFound) {
+		return remarkContext{}, false, nil
+	}
+	if err != nil {
+		return remarkContext{}, false, err
+	}
+	return remarkContext{client: *rec.ToClient()}, true, nil
+}

+ 132 - 0
internal/sub/placeholders_test.go

@@ -0,0 +1,132 @@
+package sub
+
+import (
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+)
+
+func TestRenderSubPlaceholders(t *testing.T) {
+	data := subPlaceholderData{
+		SubID: "sub-123",
+		Context: remarkContext{client: model.Client{
+			Email:  "Ilnur",
+			ID:     "abcdef12-3456-7890-abcd-ef1234567890",
+			SubID:  "sub-123",
+			TgID:   42,
+			Enable: true,
+		}},
+		HasCtx: true,
+	}
+
+	tests := []struct {
+		name string
+		tmpl string
+		data subPlaceholderData
+		want string
+	}{
+		{
+			name: "identity tokens",
+			tmpl: "{{EMAIL}}/{{ID}}/{{SHORT_ID}}/{{SUB_ID}}/{{TELEGRAM_ID}}",
+			data: data,
+			want: "Ilnur/abcdef12-3456-7890-abcd-ef1234567890/abcdef12/sub-123/42",
+		},
+		{
+			name: "no template",
+			tmpl: "isVPN",
+			data: subPlaceholderData{SubID: "sub-123"},
+			want: "isVPN",
+		},
+		{
+			name: "unsupported tokens stay literal",
+			tmpl: "{{SUB_ID}}/{{INBOUND}}/{{TRAFFIC_LEFT}}/{{PROTOCOL}}/{EMAIL}",
+			data: subPlaceholderData{SubID: "sub-123"},
+			want: "sub-123/{{INBOUND}}/{{TRAFFIC_LEFT}}/{{PROTOCOL}}/{EMAIL}",
+		},
+		{
+			name: "URL values are escaped",
+			tmpl: "https://support.example/?email={{EMAIL}}&sub={{SUB_ID}}",
+			data: subPlaceholderData{
+				SubID: "sub id",
+				Context: remarkContext{client: model.Client{
+					Email: "john [email protected]",
+					SubID: "sub id",
+				}},
+				HasCtx: true,
+				Escape: true,
+			},
+			want: "https://support.example/?email=john+doe%40example.com&sub=sub+id",
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			if got := renderSubPlaceholders(tt.tmpl, tt.data); got != tt.want {
+				t.Fatalf("renderSubPlaceholders() = %q, want %q", got, tt.want)
+			}
+		})
+	}
+}
+
+func TestMetadataForSubRequestDoesNotExpandFallbackProfileURL(t *testing.T) {
+	a := &SUBController{
+		subTitle:      "isVPN",
+		subSupportUrl: "https://support.example/",
+	}
+	fallback := "https://sub.example.com/sub/sub-123?x={{EMAIL}}"
+
+	metadata := a.metadataForSubRequest(func() *SubService {
+		t.Fatal("metadataForSubRequest loaded a subscription context without configured placeholders")
+		return nil
+	}, "sub-123", fallback)
+
+	if metadata.ProfileURL != fallback {
+		t.Fatalf("ProfileURL = %q, want untouched fallback %q", metadata.ProfileURL, fallback)
+	}
+}
+
+func TestMetadataForSubRequestUsesStableClientIdentity(t *testing.T) {
+	initSubDB(t)
+	db := database.GetDB()
+	first := model.ClientRecord{
+		Email:  "john [email protected]",
+		SubID:  "sub-123",
+		UUID:   "abcdef12-3456-7890-abcd-ef1234567890",
+		TgID:   42,
+		Enable: true,
+	}
+	second := model.ClientRecord{
+		Email:  "[email protected]",
+		SubID:  "sub-123",
+		UUID:   "fedcba98-3456-7890-abcd-ef1234567890",
+		TgID:   99,
+		Enable: true,
+	}
+	if err := db.Create(&first).Error; err != nil {
+		t.Fatalf("seed first client: %v", err)
+	}
+	if err := db.Create(&second).Error; err != nil {
+		t.Fatalf("seed second client: %v", err)
+	}
+
+	a := &SUBController{
+		subTitle:      "isVPN — {{EMAIL}}",
+		subSupportUrl: "https://support.example/?email={{EMAIL}}&tg={{TELEGRAM_ID}}",
+		subProfileUrl: "https://profile.example/account/{{ID}}",
+		subAnnounce:   "Subscription {{SUB_ID}}",
+	}
+	metadata := a.metadataForSubRequest(func() *SubService { return &SubService{} }, "sub-123", "https://fallback.example/{{EMAIL}}")
+
+	if metadata.Title != "isVPN — john [email protected]" {
+		t.Fatalf("Title = %q", metadata.Title)
+	}
+	if metadata.SupportURL != "https://support.example/?email=john+doe%40example.com&tg=42" {
+		t.Fatalf("SupportURL = %q", metadata.SupportURL)
+	}
+	if metadata.ProfileURL != "https://profile.example/account/abcdef12-3456-7890-abcd-ef1234567890" {
+		t.Fatalf("ProfileURL = %q", metadata.ProfileURL)
+	}
+	if metadata.Announce != "Subscription sub-123" {
+		t.Fatalf("Announce = %q", metadata.Announce)
+	}
+}

+ 55 - 0
internal/sub/salamander_uri_test.go

@@ -0,0 +1,55 @@
+package sub
+
+import (
+	"reflect"
+	"strconv"
+	"strings"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/logger"
+)
+
+func TestExtraSalamanderKeys(t *testing.T) {
+	if got := extraSalamanderKeys(map[string]any{"password": "pw"}); len(got) != 0 {
+		t.Fatalf("expressible settings reported extras: %v", got)
+	}
+	got := extraSalamanderKeys(map[string]any{"password": "pw", "packetSize": "512-1200"})
+	if want := []string{"packetSize"}; !reflect.DeepEqual(got, want) {
+		t.Fatalf("extraSalamanderKeys = %v, want %v", got, want)
+	}
+}
+
+func TestGenHysteriaLinkWarnsOnceForUnsupportedSalamanderSettings(t *testing.T) {
+	makeInbound := func(id int, settings string) *model.Inbound {
+		return &model.Inbound{
+			Id: id, Listen: "203.0.113.1", Port: 443, Protocol: model.Hysteria,
+			Settings:       `{"version":2,"clients":[{"auth":"secret","email":"user"}]}`,
+			StreamSettings: `{"security":"tls","finalmask":{"udp":[{"type":"salamander","settings":` + settings + `}]}}`,
+		}
+	}
+	countWarnings := func(id int) int {
+		needle := "inbound " + strconv.Itoa(id) + ": salamander settings"
+		count := 0
+		for _, line := range logger.GetLogs(100, "warning") {
+			if strings.Contains(line, needle) {
+				count++
+			}
+		}
+		return count
+	}
+
+	const standardID = 910001
+	(&SubService{}).genHysteriaLink(makeInbound(standardID, `{"password":"pw"}`), "user")
+	if got := countWarnings(standardID); got != 0 {
+		t.Fatalf("password-only warning count = %d, want 0", got)
+	}
+
+	const unsupportedID = 910002
+	in := makeInbound(unsupportedID, `{"password":"pw","packetSize":"512-1200"}`)
+	(&SubService{}).genHysteriaLink(in, "user")
+	(&SubService{}).genHysteriaLink(in, "user")
+	if got := countWarnings(unsupportedID); got != 1 {
+		t.Fatalf("unsupported-settings warning count = %d, want 1", got)
+	}
+}

+ 34 - 0
internal/sub/service.go

@@ -9,8 +9,10 @@ import (
 	"net"
 	"net/url"
 	"slices"
+	"sort"
 	"strconv"
 	"strings"
+	"sync"
 	"time"
 
 	"github.com/gin-gonic/gin"
@@ -26,6 +28,8 @@ import (
 	"github.com/mhsanaei/3x-ui/v3/internal/xray"
 )
 
+var salamanderWarningSeen sync.Map
+
 // SubService provides business logic for generating subscription links and managing subscription data.
 type SubService struct {
 	address        string
@@ -273,6 +277,16 @@ func (s *SubService) matchingClients(inbound *model.Inbound, subId string) []mod
 	return out
 }
 
+// RecordSubscriptionFetch records a successful subscription response for all clients sharing subId.
+func (s *SubService) RecordSubscriptionFetch(subId string) error {
+	if strings.TrimSpace(subId) == "" {
+		return nil
+	}
+	return database.GetDB().Model(&xray.ClientTraffic{}).
+		Where("email IN (SELECT email FROM clients WHERE sub_id = ?)", subId).
+		Update("last_sub_fetch", time.Now().UnixMilli()).Error
+}
+
 // GetSubs retrieves subscription links for a given subscription ID and host.
 func (s *SubService) GetSubs(subId string, host string) ([]string, []string, int64, xray.ClientTraffic, error) {
 	return s.ForRequest(host).getSubs(subId)
@@ -1041,6 +1055,12 @@ func (s *SubService) genHysteriaLink(inbound *model.Inbound, email string) strin
 				}
 				settings, _ := mask["settings"].(map[string]any)
 				if pw, ok := settings["password"].(string); ok && pw != "" {
+					if extra := extraSalamanderKeys(settings); len(extra) > 0 {
+						warningKey := fmt.Sprintf("%d:%v", inbound.Id, extra)
+						if _, loaded := salamanderWarningSeen.LoadOrStore(warningKey, struct{}{}); !loaded {
+							logger.Warningf("SubService - inbound %d: salamander settings %v cannot be expressed in a hysteria2 URI; standard clients will fail the handshake", inbound.Id, extra)
+						}
+					}
 					params["obfs"] = "salamander"
 					params["obfs-password"] = pw
 					break
@@ -2478,6 +2498,7 @@ type PageData struct {
 	SubClashUrl   string
 	SubTitle      string
 	SubSupportUrl string
+	SubAnnounce   string
 	Result        []string
 	Emails        []string
 }
@@ -2685,3 +2706,16 @@ func getHostFromXFH(s string) (string, error) {
 	}
 	return s, nil
 }
+
+// extraSalamanderKeys lists salamander settings the hysteria2 URI cannot carry.
+// A server using them rejects every client built from the emitted link.
+func extraSalamanderKeys(settings map[string]any) []string {
+	var extra []string
+	for k := range settings {
+		if k != "password" {
+			extra = append(extra, k)
+		}
+	}
+	sort.Strings(extra)
+	return extra
+}

+ 99 - 0
internal/sub/sub_fetch_test.go

@@ -0,0 +1,99 @@
+package sub
+
+import (
+	"net/http"
+	"net/http/httptest"
+	"testing"
+	"time"
+
+	"github.com/gin-gonic/gin"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/xray"
+)
+
+func TestRecordSubscriptionFetch(t *testing.T) {
+	initSubDB(t)
+	db := database.GetDB()
+
+	clients := []model.ClientRecord{
+		{Email: "[email protected]", SubID: "sub-alpha", Enable: true},
+		{Email: "[email protected]", SubID: "sub-bravo", Enable: true},
+	}
+	for i := range clients {
+		if err := db.Create(&clients[i]).Error; err != nil {
+			t.Fatalf("create client %s: %v", clients[i].Email, err)
+		}
+		if err := db.Create(&xray.ClientTraffic{Email: clients[i].Email}).Error; err != nil {
+			t.Fatalf("create traffic %s: %v", clients[i].Email, err)
+		}
+	}
+
+	before := time.Now().UnixMilli()
+	if err := (&SubService{}).RecordSubscriptionFetch("sub-alpha"); err != nil {
+		t.Fatalf("RecordSubscriptionFetch: %v", err)
+	}
+
+	var alpha, bravo xray.ClientTraffic
+	if err := db.Where("email = ?", "[email protected]").First(&alpha).Error; err != nil {
+		t.Fatalf("load alpha traffic: %v", err)
+	}
+	if err := db.Where("email = ?", "[email protected]").First(&bravo).Error; err != nil {
+		t.Fatalf("load bravo traffic: %v", err)
+	}
+	if alpha.LastSubFetch < before {
+		t.Fatalf("alpha lastSubFetch = %d, want >= %d", alpha.LastSubFetch, before)
+	}
+	if bravo.LastSubFetch != 0 {
+		t.Fatalf("bravo lastSubFetch = %d, want 0", bravo.LastSubFetch)
+	}
+
+	if err := (&SubService{}).RecordSubscriptionFetch("unknown"); err != nil {
+		t.Fatalf("unknown subId: %v", err)
+	}
+	if err := (&SubService{}).RecordSubscriptionFetch(""); err != nil {
+		t.Fatalf("empty subId: %v", err)
+	}
+}
+
+func TestRecordSubscriptionFetchStatusGate(t *testing.T) {
+	initSubDB(t)
+	db := database.GetDB()
+	client := &model.ClientRecord{Email: "[email protected]", SubID: "sub-alpha", Enable: true}
+	if err := db.Create(client).Error; err != nil {
+		t.Fatalf("create client: %v", err)
+	}
+	if err := db.Create(&xray.ClientTraffic{Email: client.Email}).Error; err != nil {
+		t.Fatalf("create traffic: %v", err)
+	}
+
+	controller := &SUBController{subService: &SubService{}}
+	notFoundRecorder := httptest.NewRecorder()
+	notFound, _ := gin.CreateTestContext(notFoundRecorder)
+	notFound.Request = httptest.NewRequest(http.MethodGet, "/sub/sub-alpha", nil)
+	notFound.Params = gin.Params{{Key: "subid", Value: "sub-alpha"}}
+	notFound.Status(http.StatusNotFound)
+	controller.recordSubscriptionFetch(notFound)
+
+	var traffic xray.ClientTraffic
+	if err := db.Where("email = ?", client.Email).First(&traffic).Error; err != nil {
+		t.Fatalf("load traffic after 404: %v", err)
+	}
+	if traffic.LastSubFetch != 0 {
+		t.Fatalf("404 updated lastSubFetch to %d", traffic.LastSubFetch)
+	}
+
+	okRecorder := httptest.NewRecorder()
+	ok, _ := gin.CreateTestContext(okRecorder)
+	ok.Request = httptest.NewRequest(http.MethodGet, "/sub/sub-alpha", nil)
+	ok.Params = gin.Params{{Key: "subid", Value: "sub-alpha"}}
+	ok.Status(http.StatusOK)
+	controller.recordSubscriptionFetch(ok)
+	if err := db.Where("email = ?", client.Email).First(&traffic).Error; err != nil {
+		t.Fatalf("load traffic after 200: %v", err)
+	}
+	if traffic.LastSubFetch == 0 {
+		t.Fatal("200 did not update lastSubFetch")
+	}
+}

+ 96 - 1
internal/web/controller/api.go

@@ -4,6 +4,7 @@ import (
 	"net/http"
 	"strings"
 
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
 	"github.com/mhsanaei/3x-ui/v3/internal/web/middleware"
 	"github.com/mhsanaei/3x-ui/v3/internal/web/service/panel"
 	"github.com/mhsanaei/3x-ui/v3/internal/web/service/tgbot"
@@ -42,17 +43,19 @@ func (a *APIController) checkAPIAuth(c *gin.Context) {
 			session.SetAPIAuthUser(c, u)
 		}
 		c.Set("api_authed", true)
+		c.Set("api_token_scope", model.ApiScopeNodeSync)
 		c.Next()
 		return
 	}
 	auth := c.GetHeader("Authorization")
 	if after, ok := strings.CutPrefix(auth, "Bearer "); ok {
 		tok := after
-		if a.apiTokenService.Match(tok) {
+		if row, ok := a.apiTokenService.MatchToken(tok); ok {
 			if u, err := a.userService.GetFirstUser(); err == nil {
 				session.SetAPIAuthUser(c, u)
 			}
 			c.Set("api_authed", true)
+			c.Set("api_token_scope", row.Scope)
 			c.Next()
 			return
 		}
@@ -68,11 +71,103 @@ func (a *APIController) checkAPIAuth(c *gin.Context) {
 	c.Next()
 }
 
+// monitorScopeAllow exposes only status/metrics routes without sensitive data.
+// Keys are route patterns relative to /panel/api.
+var monitorScopeAllow = map[string]struct{}{
+	"/server/status":                              {},
+	"/server/cpuHistory/:bucket":                  {},
+	"/server/history/:metric/:bucket":             {},
+	"/server/xrayMetricsState":                    {},
+	"/server/xrayMetricsHistory/:metric/:bucket":  {},
+	"/server/xrayObservatory":                     {},
+	"/server/xrayObservatoryHistory/:tag/:bucket": {},
+	"/server/getXrayVersion":                      {},
+	"/server/getPanelUpdateInfo":                  {},
+	"/nodes/history/:id/:metric/:bucket":          {},
+}
+
+// nodeSyncScopeAllow is the node-sync route/method allowlist relative to
+// /panel/api; Gin patterns prevent concrete parameters broadening authority.
+var nodeSyncScopeAllow = map[string]map[string]struct{}{
+	"/server/status":               {http.MethodGet: {}},
+	"/inbounds/list":               {http.MethodGet: {}},
+	"/inbounds/add":                {http.MethodPost: {}},
+	"/inbounds/del/:id":            {http.MethodPost: {}},
+	"/inbounds/update/:id":         {http.MethodPost: {}},
+	"/clients/add":                 {http.MethodPost: {}},
+	"/clients/del/:email":          {http.MethodPost: {}},
+	"/clients/:email/detach":       {http.MethodPost: {}},
+	"/clients/update/:email":       {http.MethodPost: {}},
+	"/server/restartXrayService":   {http.MethodPost: {}},
+	"/server/getWebCertFiles":      {http.MethodGet: {}},
+	"/server/descendants":          {http.MethodGet: {}},
+	"/clients/resetTraffic/:email": {http.MethodPost: {}},
+	"/inbounds/resetAllTraffics":   {http.MethodPost: {}},
+	"/inbounds/:id/resetTraffic":   {http.MethodPost: {}},
+	"/clients/onlinesByGuid":       {http.MethodPost: {}},
+	"/clients/onlines":             {http.MethodPost: {}},
+	"/clients/lastOnline":          {http.MethodPost: {}},
+	"/inbounds/pushClientTraffics": {http.MethodPost: {}},
+	"/server/clientIps":            {http.MethodGet: {}, http.MethodPost: {}},
+	"/clients/clientIpsByGuid":     {http.MethodPost: {}},
+	"/hosts/list":                  {http.MethodGet: {}},
+}
+
+// enforceTokenScope applies explicit allowlists to monitor and node-sync tokens.
+// Admin tokens and session-login users retain their existing behavior.
+func (a *APIController) enforceTokenScope(c *gin.Context) {
+	scopeVal, ok := c.Get("api_token_scope")
+	if !ok {
+		c.Next()
+		return
+	}
+	scope, _ := scopeVal.(string)
+	if scope == model.ApiScopeAdmin {
+		c.Next()
+		return
+	}
+	deny := func() {
+		c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
+			"success": false,
+			"msg":     "this API token is not permitted to access this endpoint",
+		})
+	}
+	rel := relAPIPath(c.FullPath())
+	switch scope {
+	case model.ApiScopeMonitor:
+		if _, allowed := monitorScopeAllow[rel]; allowed && (c.Request.Method == http.MethodGet || c.Request.Method == http.MethodHead) {
+			c.Next()
+			return
+		}
+	case model.ApiScopeNodeSync:
+		if methods, allowed := nodeSyncScopeAllow[rel]; allowed {
+			if _, allowedMethod := methods[c.Request.Method]; allowedMethod {
+				c.Next()
+				return
+			}
+		}
+	default:
+		deny()
+		return
+	}
+	deny()
+}
+
+func relAPIPath(fullPath string) string {
+	const marker = "/panel/api"
+	i := strings.Index(fullPath, marker)
+	if i < 0 {
+		return ""
+	}
+	return fullPath[i+len(marker):]
+}
+
 // initRouter sets up the API routes for inbounds, server, and other endpoints.
 func (a *APIController) initRouter(g *gin.RouterGroup) {
 	// Main API group
 	api := g.Group("/panel/api")
 	api.Use(a.checkAPIAuth)
+	api.Use(a.enforceTokenScope)
 	// Decode + verify the node config envelope (zstd + X-Config-Sha256) and
 	// advertise support, before CSRF/handlers read the body.
 	api.Use(middleware.ConfigEnvelopeMiddleware())

+ 95 - 7
internal/web/controller/api_auth_test.go

@@ -7,6 +7,7 @@ import (
 	"net/http/cookiejar"
 	"net/http/httptest"
 	"path/filepath"
+	"reflect"
 	"testing"
 
 	"github.com/gin-contrib/sessions"
@@ -57,9 +58,26 @@ func newAPIAuthTestEngine(t *testing.T) (*gin.Engine, *APIController) {
 
 	api := engine.Group("/panel/api")
 	api.Use(a.checkAPIAuth)
+	api.Use(a.enforceTokenScope)
 	api.GET("/ping", func(c *gin.Context) {
 		c.JSON(http.StatusOK, gin.H{"api_authed": c.GetBool("api_authed")})
 	})
+	api.GET("/server/status", func(c *gin.Context) {
+		scope, _ := c.Get("api_token_scope")
+		c.JSON(http.StatusOK, gin.H{"api_authed": c.GetBool("api_authed"), "scope": scope})
+	})
+	api.POST("/server/updatePanel", func(c *gin.Context) {
+		c.JSON(http.StatusOK, gin.H{"reached": true})
+	})
+	api.POST("/clients/:email/detach", func(c *gin.Context) {
+		c.JSON(http.StatusOK, gin.H{"reached": true})
+	})
+	api.POST("/inbounds/:id/resetTraffic", func(c *gin.Context) {
+		c.JSON(http.StatusOK, gin.H{"reached": true})
+	})
+	api.POST("/clients/clientIpsByGuid", func(c *gin.Context) {
+		c.JSON(http.StatusOK, gin.H{"reached": true})
+	})
 	return engine, a
 }
 
@@ -74,6 +92,7 @@ func TestCheckAPIAuth_BearerSuccess(t *testing.T) {
 		Name:    "t1",
 		Token:   crypto.HashTokenSHA256(plaintext),
 		Enabled: true,
+		Scope:   model.ApiScopeAdmin,
 	}).Error; err != nil {
 		t.Fatalf("seed token: %v", err)
 	}
@@ -91,14 +110,12 @@ func TestCheckAPIAuth_BearerSuccess(t *testing.T) {
 	}
 }
 
-// TestCheckAPIAuth_AcceptsVerifiedClientCert asserts that a completed mTLS
-// handshake (a non-empty verified client chain) authenticates the request even
-// with no bearer token and no session — the equivalent of a valid token — and
-// sets api_authed so the CSRF middleware lets mutations through.
+// TestCheckAPIAuth_AcceptsVerifiedClientCert ensures verified mTLS authenticates
+// as node-sync rather than bypassing scope checks as admin.
 func TestCheckAPIAuth_AcceptsVerifiedClientCert(t *testing.T) {
 	engine, _ := newAPIAuthTestEngine(t)
 
-	req := httptest.NewRequest(http.MethodGet, "/panel/api/ping", nil)
+	req := httptest.NewRequest(http.MethodGet, "/panel/api/server/status", nil)
 	req.TLS = &tls.ConnectionState{
 		VerifiedChains: [][]*x509.Certificate{{&x509.Certificate{}}},
 	}
@@ -108,8 +125,79 @@ func TestCheckAPIAuth_AcceptsVerifiedClientCert(t *testing.T) {
 	if w.Code != http.StatusOK {
 		t.Fatalf("status = %d, want 200; body=%s", w.Code, w.Body.String())
 	}
-	if got := w.Body.String(); got != `{"api_authed":true}` {
-		t.Fatalf("body = %s, want api_authed true", got)
+	if got := w.Body.String(); got != `{"api_authed":true,"scope":"node-sync"}` {
+		t.Fatalf("body = %s, want node-sync scope", got)
+	}
+
+	forbidden := httptest.NewRequest(http.MethodPost, "/panel/api/server/updatePanel", nil)
+	forbidden.TLS = &tls.ConnectionState{
+		VerifiedChains: [][]*x509.Certificate{{&x509.Certificate{}}},
+	}
+	w = httptest.NewRecorder()
+	engine.ServeHTTP(w, forbidden)
+	if w.Code != http.StatusForbidden {
+		t.Fatalf("updatePanel status = %d, want 403; body=%s", w.Code, w.Body.String())
+	}
+}
+
+func TestNodeSyncScopeAllowlistMatchesRemoteInventory(t *testing.T) {
+	expected := map[string]map[string]struct{}{
+		"/server/status":               {http.MethodGet: {}},
+		"/inbounds/list":               {http.MethodGet: {}},
+		"/inbounds/add":                {http.MethodPost: {}},
+		"/inbounds/del/:id":            {http.MethodPost: {}},
+		"/inbounds/update/:id":         {http.MethodPost: {}},
+		"/clients/add":                 {http.MethodPost: {}},
+		"/clients/del/:email":          {http.MethodPost: {}},
+		"/clients/:email/detach":       {http.MethodPost: {}},
+		"/clients/update/:email":       {http.MethodPost: {}},
+		"/server/restartXrayService":   {http.MethodPost: {}},
+		"/server/getWebCertFiles":      {http.MethodGet: {}},
+		"/server/descendants":          {http.MethodGet: {}},
+		"/clients/resetTraffic/:email": {http.MethodPost: {}},
+		"/inbounds/resetAllTraffics":   {http.MethodPost: {}},
+		"/inbounds/:id/resetTraffic":   {http.MethodPost: {}},
+		"/clients/onlinesByGuid":       {http.MethodPost: {}},
+		"/clients/onlines":             {http.MethodPost: {}},
+		"/clients/lastOnline":          {http.MethodPost: {}},
+		"/inbounds/pushClientTraffics": {http.MethodPost: {}},
+		"/server/clientIps":            {http.MethodGet: {}, http.MethodPost: {}},
+		"/clients/clientIpsByGuid":     {http.MethodPost: {}},
+		"/hosts/list":                  {http.MethodGet: {}},
+	}
+	if !reflect.DeepEqual(nodeSyncScopeAllow, expected) {
+		t.Fatalf("node-sync allowlist drift:\n got: %#v\nwant: %#v", nodeSyncScopeAllow, expected)
+	}
+	if _, ok := nodeSyncScopeAllow["/server/updatePanel"]; ok {
+		t.Fatal("node-sync must not include /server/updatePanel")
+	}
+}
+
+func TestNodeSyncScopeUsesFullPathPatterns(t *testing.T) {
+	engine, _ := newAPIAuthTestEngine(t)
+	cases := []struct {
+		name   string
+		method string
+		path   string
+		want   int
+	}{
+		{"detach email parameter", http.MethodPost, "/panel/api/clients/[email protected]/detach", http.StatusOK},
+		{"reset inbound id parameter", http.MethodPost, "/panel/api/inbounds/42/resetTraffic", http.StatusOK},
+		{"client IP by guid endpoint", http.MethodPost, "/panel/api/clients/clientIpsByGuid", http.StatusOK},
+		{"update panel forbidden", http.MethodPost, "/panel/api/server/updatePanel", http.StatusForbidden},
+	}
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			req := httptest.NewRequest(tc.method, tc.path, nil)
+			req.TLS = &tls.ConnectionState{
+				VerifiedChains: [][]*x509.Certificate{{&x509.Certificate{}}},
+			}
+			w := httptest.NewRecorder()
+			engine.ServeHTTP(w, req)
+			if w.Code != tc.want {
+				t.Fatalf("status = %d, want %d; body=%s", w.Code, tc.want, w.Body.String())
+			}
+		})
 	}
 }
 

+ 21 - 3
internal/web/controller/client.go

@@ -76,6 +76,8 @@ func (a *ClientController) initRouter(g *gin.RouterGroup) {
 	g.POST("/updateTraffic/:email", a.updateTrafficByEmail)
 	g.POST("/ips/:email", a.getIps)
 	g.POST("/clearIps/:email", a.clearIps)
+	g.POST("/hwids/:email", a.getHwids)
+	g.DELETE("/hwids/:email", a.clearHwids)
 	g.POST("/onlines", a.onlines)
 	g.POST("/onlinesByGuid", a.onlinesByGuid)
 	g.POST("/clientIpsByGuid", a.clientIpsByGuid)
@@ -191,13 +193,16 @@ func (a *ClientController) create(c *gin.Context) {
 
 func (a *ClientController) update(c *gin.Context) {
 	email := c.Param("email")
-	var updated model.Client
-	if err := c.ShouldBindJSON(&updated); err != nil {
+	var req struct {
+		model.Client
+		LimitHwid int `json:"limitHwid"`
+	}
+	if err := c.ShouldBindJSON(&req); err != nil {
 		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
 		return
 	}
 	inboundFilter := parseInboundIdsQuery(c.Query("inboundIds"))
-	needRestart, err := a.clientService.UpdateByEmail(&a.inboundService, email, updated, inboundFilter...)
+	needRestart, err := a.clientService.UpdateByEmail(&a.inboundService, email, req.Client, req.LimitHwid, inboundFilter...)
 	if err != nil {
 		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
 		return
@@ -540,6 +545,19 @@ func (a *ClientController) clearIps(c *gin.Context) {
 	jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.logCleanSuccess"), nil)
 }
 
+func (a *ClientController) getHwids(c *gin.Context) {
+	infos, err := a.clientService.ListClientHwids(c.Param("email"))
+	jsonObj(c, infos, err)
+}
+
+func (a *ClientController) clearHwids(c *gin.Context) {
+	if err := a.clientService.ClearClientHwids(c.Param("email")); err != nil {
+		jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.updateSuccess"), err)
+		return
+	}
+	jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.logCleanSuccess"), nil)
+}
+
 func (a *ClientController) onlines(c *gin.Context) {
 	jsonObj(c, a.inboundService.GetOnlineClients(), nil)
 }

+ 278 - 0
internal/web/controller/geodata_test.go

@@ -0,0 +1,278 @@
+package controller
+
+import (
+	"encoding/json"
+	"net/http"
+	"net/http/httptest"
+	"net/netip"
+	"net/url"
+	"os"
+	"path/filepath"
+	"strings"
+	"testing"
+
+	"github.com/gin-gonic/gin"
+	"github.com/op/go-logging"
+	xraygeodata "github.com/xtls/xray-core/common/geodata"
+	"google.golang.org/protobuf/proto"
+
+	xuilogger "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/geodata"
+)
+
+func newGeodataEngine(t *testing.T) *gin.Engine {
+	t.Helper()
+	xuilogger.InitLogger(logging.ERROR)
+	gin.SetMode(gin.TestMode)
+
+	dir := t.TempDir()
+	t.Setenv("XUI_BIN_FOLDER", dir)
+	writeGeositeDB(t, dir)
+	writeGeoipDB(t, dir)
+
+	engine := gin.New()
+	NewXraySettingController(engine.Group("/panel/api"))
+	return engine
+}
+
+func writeGeositeDB(t *testing.T, dir string) {
+	t.Helper()
+	data, err := proto.Marshal(&xraygeodata.GeoSiteList{Entry: []*xraygeodata.GeoSite{
+		{Code: "google", Domain: []*xraygeodata.Domain{
+			{Type: xraygeodata.Domain_Domain, Value: "google.com"},
+			{Type: xraygeodata.Domain_Full, Value: "ads.google.com", Attribute: []*xraygeodata.Domain_Attribute{
+				{Key: "ads", TypedValue: &xraygeodata.Domain_Attribute_BoolValue{BoolValue: true}},
+			}},
+		}},
+		{Code: "cn", Domain: []*xraygeodata.Domain{{Type: xraygeodata.Domain_Domain, Value: "baidu.com"}}},
+	}})
+	if err != nil {
+		t.Fatalf("marshal geosite: %v", err)
+	}
+	if err := os.WriteFile(filepath.Join(dir, "geosite.dat"), data, 0o644); err != nil {
+		t.Fatalf("write geosite.dat: %v", err)
+	}
+}
+
+func writeGeoipDB(t *testing.T, dir string) {
+	t.Helper()
+	prefix := netip.MustParsePrefix("10.0.0.0/8")
+	data, err := proto.Marshal(&xraygeodata.GeoIPList{Entry: []*xraygeodata.GeoIP{
+		{Code: "private", Cidr: []*xraygeodata.CIDR{{Ip: prefix.Addr().AsSlice(), Prefix: uint32(prefix.Bits())}}},
+	}})
+	if err != nil {
+		t.Fatalf("marshal geoip: %v", err)
+	}
+	if err := os.WriteFile(filepath.Join(dir, "geoip.dat"), data, 0o644); err != nil {
+		t.Fatalf("write geoip.dat: %v", err)
+	}
+}
+
+type geodataEnvelope struct {
+	Success bool            `json:"success"`
+	Msg     string          `json:"msg"`
+	Obj     json.RawMessage `json:"obj"`
+}
+
+func doGeodataGet(t *testing.T, engine *gin.Engine, path string) geodataEnvelope {
+	t.Helper()
+	return doGeodataReq(t, engine, httptest.NewRequest(http.MethodGet, path, nil))
+}
+
+func doGeodataPost(t *testing.T, engine *gin.Engine, path string, form url.Values) geodataEnvelope {
+	t.Helper()
+	req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(form.Encode()))
+	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+	return doGeodataReq(t, engine, req)
+}
+
+func doGeodataReq(t *testing.T, engine *gin.Engine, req *http.Request) geodataEnvelope {
+	t.Helper()
+	w := httptest.NewRecorder()
+	engine.ServeHTTP(w, req)
+	if w.Code != http.StatusOK {
+		t.Fatalf("%s %s: status %d, body=%s", req.Method, req.URL, w.Code, w.Body.String())
+	}
+	var env geodataEnvelope
+	if err := json.Unmarshal(w.Body.Bytes(), &env); err != nil {
+		t.Fatalf("decode envelope: %v body=%s", err, w.Body.String())
+	}
+	return env
+}
+
+func TestGeodataFiles(t *testing.T) {
+	engine := newGeodataEngine(t)
+
+	env := doGeodataGet(t, engine, "/panel/api/xray/geodata/files")
+	if !env.Success {
+		t.Fatalf("files not successful: %s", env.Msg)
+	}
+	var files []geodata.GeoFile
+	if err := json.Unmarshal(env.Obj, &files); err != nil {
+		t.Fatalf("decode files: %v", err)
+	}
+	if len(files) != 2 {
+		t.Fatalf("files = %+v, want 2 entries", files)
+	}
+	byName := make(map[string]geodata.GeoFile, len(files))
+	for _, file := range files {
+		byName[file.Name] = file
+	}
+	if got := byName["geosite.dat"]; got.Kind != geodata.KindSite || got.Categories != 2 {
+		t.Errorf("geosite.dat = %+v, want kind site with 2 categories", got)
+	}
+	if got := byName["geoip.dat"]; got.Kind != geodata.KindIP || got.Categories != 1 {
+		t.Errorf("geoip.dat = %+v, want kind ip with 1 category", got)
+	}
+}
+
+func TestGeodataCategoriesAndEntries(t *testing.T) {
+	engine := newGeodataEngine(t)
+
+	env := doGeodataGet(t, engine, "/panel/api/xray/geodata/categories?file=geosite.dat&q=goo&limit=10")
+	var categories geodata.GeoCategoryPage
+	if err := json.Unmarshal(env.Obj, &categories); err != nil {
+		t.Fatalf("decode categories: %v", err)
+	}
+	if categories.Total != 1 || categories.Items[0].Code != "google" {
+		t.Fatalf("categories = %+v, want only google", categories)
+	}
+
+	env = doGeodataGet(t, engine, "/panel/api/xray/geodata/entries?file=geosite.dat&code=google&limit=1&offset=1")
+	var entries geodata.GeoEntryPage
+	if err := json.Unmarshal(env.Obj, &entries); err != nil {
+		t.Fatalf("decode entries: %v", err)
+	}
+	if entries.Total != 2 {
+		t.Errorf("entries total = %d, want 2", entries.Total)
+	}
+	if len(entries.Items) != 1 || entries.Items[0].Value != "ads.google.com" || entries.Items[0].Kind != "full" {
+		t.Errorf("entries items = %+v, want the second entry ads.google.com", entries.Items)
+	}
+}
+
+func TestGeodataRejectsBadRequests(t *testing.T) {
+	engine := newGeodataEngine(t)
+
+	tests := []struct {
+		name string
+		path string
+	}{
+		{name: "missing code", path: "/panel/api/xray/geodata/entries?file=geosite.dat"},
+		{name: "unknown category", path: "/panel/api/xray/geodata/entries?file=geosite.dat&code=nope"},
+		{name: "path traversal", path: "/panel/api/xray/geodata/categories?file=../../etc/passwd.dat"},
+		{name: "non dat file", path: "/panel/api/xray/geodata/categories?file=x-ui.db"},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			if env := doGeodataGet(t, engine, tt.path); env.Success {
+				t.Errorf("request succeeded, want failure: %s", env.Obj)
+			}
+		})
+	}
+}
+
+func TestGeodataValidate(t *testing.T) {
+	engine := newGeodataEngine(t)
+
+	tests := []struct {
+		name       string
+		kind       string
+		tokens     string
+		wantTokens []string
+		wantReason string
+	}{
+		{name: "known categories pass", kind: "domain", tokens: "geosite:google,geosite:cn,google.com"},
+		{name: "attribute filter passes", kind: "domain", tokens: "geosite:google@ads"},
+		{
+			name:       "attribute the category does not carry",
+			kind:       "domain",
+			tokens:     "geosite:google@typo",
+			wantTokens: []string{"geosite:google@typo"},
+			wantReason: "attributeMissing",
+		},
+		{
+			name:       "empty attribute is a syntax error",
+			kind:       "domain",
+			tokens:     "geosite:google@",
+			wantTokens: []string{"geosite:google@"},
+			wantReason: "syntax",
+		},
+		{
+			name:       "missing category",
+			kind:       "domain",
+			tokens:     "geosite:google,geosite:blabla",
+			wantTokens: []string{"geosite:blabla"},
+			wantReason: "categoryMissing",
+		},
+		{
+			name:       "missing database",
+			kind:       "domain",
+			tokens:     "ext:absent.dat:corp",
+			wantTokens: []string{"ext:absent.dat:corp"},
+			wantReason: "fileMissing",
+		},
+		{
+			name:       "a geoip token in a domain field is reported",
+			kind:       "domain",
+			tokens:     "geoip:cn",
+			wantTokens: []string{"geoip:cn"},
+			wantReason: "wrongKind",
+		},
+		{name: "plain cidr passes", kind: "ip", tokens: "10.0.0.0/8,geoip:private"},
+		{
+			name:       "missing ip category",
+			kind:       "ip",
+			tokens:     "geoip:nowhere",
+			wantTokens: []string{"geoip:nowhere"},
+			wantReason: "categoryMissing",
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			env := doGeodataPost(t, engine, "/panel/api/xray/geodata/validate", url.Values{
+				"kind":   {tt.kind},
+				"tokens": {tt.tokens},
+			})
+			if !env.Success {
+				t.Fatalf("validate not successful: %s", env.Msg)
+			}
+			var issues []service.GeodataTokenIssue
+			if err := json.Unmarshal(env.Obj, &issues); err != nil {
+				t.Fatalf("decode issues: %v", err)
+			}
+			if len(issues) != len(tt.wantTokens) {
+				t.Fatalf("issues = %+v, want %d", issues, len(tt.wantTokens))
+			}
+			for i, wantToken := range tt.wantTokens {
+				if issues[i].Token != wantToken {
+					t.Errorf("issue %d token = %q, want %q", i, issues[i].Token, wantToken)
+				}
+				if issues[i].Reason != tt.wantReason {
+					t.Errorf("issue %d reason = %q, want %q", i, issues[i].Reason, tt.wantReason)
+				}
+			}
+		})
+	}
+}
+
+func TestGeodataFollowsXrayAssetLocation(t *testing.T) {
+	engine := newGeodataEngine(t)
+
+	shared := t.TempDir()
+	writeGeositeDB(t, shared)
+	t.Setenv("XRAY_LOCATION_ASSET", shared)
+
+	env := doGeodataGet(t, engine, "/panel/api/xray/geodata/files")
+	var files []geodata.GeoFile
+	if err := json.Unmarshal(env.Obj, &files); err != nil {
+		t.Fatalf("decode files: %v", err)
+	}
+	if len(files) != 1 || files[0].Name != "geosite.dat" {
+		t.Fatalf("files = %+v, want only the database from XRAY_LOCATION_ASSET", files)
+	}
+	if files[0].Categories != 2 {
+		t.Errorf("categories = %d, want 2 — the shared asset folder should be read", files[0].Categories)
+	}
+}

+ 11 - 0
internal/web/controller/node.go

@@ -44,6 +44,17 @@ func (a *NodeController) initRouter(g *gin.RouterGroup) {
 	g.GET("/history/:id/:metric/:bucket", a.history)
 	g.POST("/mtls/ca", a.mtlsCa)
 	g.POST("/mtls/trustCA", a.setMtlsTrustCA)
+	g.POST("/mtls/reloadClient", a.reloadMtlsClient)
+}
+
+// reloadMtlsClient validates the credential currently stored by the master and
+// closes cached mTLS pools so subsequent node requests present the new leaf.
+func (a *NodeController) reloadMtlsClient(c *gin.Context) {
+	if err := a.nodeService.ReloadMasterMtlsClient(); err != nil {
+		jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.reloadMtls"), err)
+		return
+	}
+	jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.reloadMtls"), nil)
 }
 
 // mtlsCa returns this panel's node-auth CA certificate (public) to paste into a

+ 17 - 5
internal/web/controller/setting.go

@@ -216,11 +216,18 @@ func (a *SettingController) getDefaultXrayConfig(c *gin.Context) {
 }
 
 type apiTokenCreateForm struct {
-	Name string `json:"name" form:"name"`
+	Name      string `json:"name" form:"name"`
+	Scope     string `json:"scope" form:"scope"`
+	ExpiresAt int64  `json:"expiresAt" form:"expiresAt"`
 }
 
 type apiTokenEnabledForm struct {
-	Enabled bool `json:"enabled" form:"enabled"`
+	Enabled       bool   `json:"enabled" form:"enabled"`
+	ExpectedScope string `json:"expectedScope" form:"expectedScope"`
+}
+
+type apiTokenScopeForm struct {
+	ExpectedScope string `json:"expectedScope" form:"expectedScope"`
 }
 
 func (a *SettingController) listApiTokens(c *gin.Context) {
@@ -238,7 +245,7 @@ func (a *SettingController) createApiToken(c *gin.Context) {
 		jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), err)
 		return
 	}
-	row, err := a.apiTokenService.Create(form.Name)
+	row, err := a.apiTokenService.Create(form.Name, form.Scope, form.ExpiresAt)
 	if err != nil {
 		jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), err)
 		return
@@ -252,7 +259,12 @@ func (a *SettingController) deleteApiToken(c *gin.Context) {
 		jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), err)
 		return
 	}
-	jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), a.apiTokenService.Delete(id))
+	form := &apiTokenScopeForm{}
+	if bindErr := c.ShouldBind(form); bindErr != nil {
+		jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), bindErr)
+		return
+	}
+	jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), a.apiTokenService.DeleteExpectedScope(id, form.ExpectedScope))
 }
 
 func (a *SettingController) setApiTokenEnabled(c *gin.Context) {
@@ -266,7 +278,7 @@ func (a *SettingController) setApiTokenEnabled(c *gin.Context) {
 		jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), bindErr)
 		return
 	}
-	jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), a.apiTokenService.SetEnabled(id, form.Enabled))
+	jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), a.apiTokenService.SetEnabledExpectedScope(id, form.ExpectedScope, form.Enabled))
 }
 
 func (a *SettingController) testSmtp(c *gin.Context) {

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

@@ -3,10 +3,16 @@ package controller
 import (
 	"net/http"
 	"net/http/httptest"
+	"path/filepath"
+	"strconv"
 	"strings"
 	"testing"
 
 	"github.com/gin-gonic/gin"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/util/crypto"
 )
 
 func TestValidateRegex(t *testing.T) {
@@ -44,3 +50,39 @@ func TestValidateRegex(t *testing.T) {
 		})
 	}
 }
+
+func TestAPITokenMutationRoutesEnforceExpectedScope(t *testing.T) {
+	t.Setenv("XUI_DB_FOLDER", t.TempDir())
+	if err := database.InitDB(filepath.Join(t.TempDir(), "x-ui.db")); err != nil {
+		t.Fatalf("InitDB: %v", err)
+	}
+	t.Cleanup(func() { _ = database.CloseDB() })
+	row := &model.ApiToken{Name: "route-scope", Token: crypto.HashTokenSHA256("token"), Enabled: true, Scope: model.ApiScopeNodeSync}
+	if err := database.GetDB().Create(row).Error; err != nil {
+		t.Fatalf("seed token: %v", err)
+	}
+
+	gin.SetMode(gin.TestMode)
+	router := gin.New()
+	NewSettingController(router.Group("/panel/api"))
+	for _, path := range []string{
+		"/panel/api/setting/apiTokens/delete/" + strconv.Itoa(row.Id),
+		"/panel/api/setting/apiTokens/setEnabled/" + strconv.Itoa(row.Id),
+	} {
+		body := `{"expectedScope":"admin","enabled":false}`
+		req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body))
+		req.Header.Set("Content-Type", "application/json")
+		resp := httptest.NewRecorder()
+		router.ServeHTTP(resp, req)
+		if !strings.Contains(resp.Body.String(), `"success":false`) {
+			t.Fatalf("%s accepted wrong expected scope: %s", path, resp.Body.String())
+		}
+	}
+	var stored model.ApiToken
+	if err := database.GetDB().First(&stored, row.Id).Error; err != nil {
+		t.Fatalf("token was deleted by wrong scope: %v", err)
+	}
+	if !stored.Enabled {
+		t.Fatal("token was disabled by wrong scope")
+	}
+}

+ 73 - 0
internal/web/controller/xray_setting.go

@@ -26,6 +26,7 @@ type XraySettingController struct {
 	WarpService                 integration.WarpService
 	NordService                 integration.NordService
 	OutboundSubscriptionService service.OutboundSubscriptionService
+	GeodataService              service.GeodataService
 }
 
 // NewXraySettingController creates a new XraySettingController and initializes its routes.
@@ -53,6 +54,11 @@ func (a *XraySettingController) initRouter(g *gin.RouterGroup) {
 	g.POST("/balancerOverride", a.balancerOverride)
 	g.POST("/routeTest", a.routeTest)
 
+	g.GET("/geodata/files", a.geodataFiles)
+	g.GET("/geodata/categories", a.geodataCategories)
+	g.GET("/geodata/entries", a.geodataEntries)
+	g.POST("/geodata/validate", a.geodataValidate)
+
 	// Outbound subscription (remote outbound lists)
 	g.GET("/outbound-subs", a.listOutboundSubs)
 	g.POST("/outbound-subs", a.createOutboundSub)
@@ -391,6 +397,73 @@ func (a *XraySettingController) routeTest(c *gin.Context) {
 	jsonObj(c, result, nil)
 }
 
+// maxGeodataTokens bounds one validation request; a routing rule listing more
+// categories than this is not something the panel needs to answer for.
+const maxGeodataTokens = 500
+
+// geodataFiles lists the geo databases Xray resolves geosite:/geoip: tokens
+// against, including ones that failed to parse.
+func (a *XraySettingController) geodataFiles(c *gin.Context) {
+	files, err := a.GeodataService.Files()
+	if err != nil {
+		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
+		return
+	}
+	jsonObj(c, files, nil)
+}
+
+// geodataCategories returns one page of a database's categories.
+func (a *XraySettingController) geodataCategories(c *gin.Context) {
+	offset, limit := geodataPaging(c)
+	page, err := a.GeodataService.Categories(c.Query("file"), c.Query("q"), offset, limit)
+	if err != nil {
+		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
+		return
+	}
+	jsonObj(c, page, nil)
+}
+
+// geodataEntries returns one page of the domains or CIDRs inside a category.
+func (a *XraySettingController) geodataEntries(c *gin.Context) {
+	code := c.Query("code")
+	if code == "" {
+		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), common.NewError("code is required"))
+		return
+	}
+	offset, limit := geodataPaging(c)
+	page, err := a.GeodataService.Entries(c.Query("file"), code, c.Query("q"), offset, limit)
+	if err != nil {
+		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
+		return
+	}
+	jsonObj(c, page, nil)
+}
+
+// geodataValidate reports which routing tokens do not resolve against the
+// databases on disk.
+func (a *XraySettingController) geodataValidate(c *gin.Context) {
+	// Split with a bound rather than splitting first: a 10 MB body of commas
+	// would otherwise allocate millions of strings before the limit is checked.
+	tokens := strings.SplitN(c.PostForm("tokens"), ",", maxGeodataTokens+1)
+	if len(tokens) > maxGeodataTokens {
+		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), common.NewErrorf("too many tokens: over %d", maxGeodataTokens))
+		return
+	}
+	jsonObj(c, a.GeodataService.Validate(c.PostForm("kind") == "ip", tokens), nil)
+}
+
+func geodataPaging(c *gin.Context) (int, int) {
+	offset, err := strconv.Atoi(c.Query("offset"))
+	if err != nil {
+		offset = 0
+	}
+	limit, err := strconv.Atoi(c.Query("limit"))
+	if err != nil {
+		limit = 0
+	}
+	return offset, limit
+}
+
 // --- Outbound Subscription handlers ---
 
 func (a *XraySettingController) listOutboundSubs(c *gin.Context) {

+ 20 - 3
internal/web/runtime/remote.go

@@ -17,6 +17,7 @@ import (
 	"sync"
 	"time"
 
+	"github.com/mhsanaei/3x-ui/v3/internal/crypto/nodetoken"
 	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
 	"github.com/mhsanaei/3x-ui/v3/internal/logger"
 	"github.com/mhsanaei/3x-ui/v3/internal/util/netsafe"
@@ -229,7 +230,11 @@ func (r *Remote) do(ctx context.Context, method, path string, body any) (*envelo
 		return nil, err
 	}
 	if r.node.ApiToken != "" {
-		req.Header.Set("Authorization", "Bearer "+r.node.ApiToken)
+		token, err := nodetoken.Decrypt(r.node.Id, r.node.ApiToken)
+		if err != nil {
+			return nil, fmt.Errorf("decrypt node token: %w", err)
+		}
+		req.Header.Set("Authorization", "Bearer "+token)
 	}
 	req.Header.Set("Accept", "application/json")
 	if contentType != "" {
@@ -693,8 +698,13 @@ type TrafficSnapshot struct {
 	// OnlineEmails so the master can attribute deeply nested clients to the real
 	// node across a chain (#4983). Empty when the node is an old build without
 	// the per-GUID endpoint — OnlineEmails is the fallback then.
-	OnlineTree    map[string][]string
-	LastOnlineMap map[string]int64
+	OnlineTree map[string][]string
+	// ActiveInboundTree is the GUID-keyed subtree of inbound tags that carried
+	// traffic within the node's online grace window. Empty when the node is an
+	// old build without the endpoint; the master then falls back to email-only
+	// online attribution for that node.
+	ActiveInboundTree map[string][]string
+	LastOnlineMap     map[string]int64
 	// HostGroups carries the node's per-inbound host overrides (TLS/SNI/
 	// fingerprint), fetched only when the snapshot holds a not-yet-adopted tag.
 	HostGroups []*entity.HostGroup
@@ -749,6 +759,13 @@ func (r *Remote) FetchTrafficSnapshot(ctx context.Context) (*TrafficSnapshot, er
 		_ = json.Unmarshal(envLastOnline.Obj, &snap.LastOnlineMap)
 	}
 
+	envActiveInbounds, err := r.do(ctx, http.MethodPost, "panel/api/clients/activeInbounds", nil)
+	if err != nil {
+		logger.Debugf("remote %s active inbounds fetch failed: %v", r.node.Name, err)
+	} else if len(envActiveInbounds.Obj) > 0 {
+		_ = json.Unmarshal(envActiveInbounds.Obj, &snap.ActiveInboundTree)
+	}
+
 	return snap, nil
 }
 

+ 131 - 0
internal/web/runtime/tls_client.go

@@ -9,6 +9,7 @@ import (
 	"net/http"
 	"strings"
 	"sync"
+	"sync/atomic"
 	"time"
 
 	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
@@ -25,6 +26,7 @@ type MasterClientCertProvider func() (tls.Certificate, error)
 var (
 	masterClientCertMu sync.RWMutex
 	masterClientCert   MasterClientCertProvider
+	masterCertEpoch    atomic.Uint64
 )
 
 // SetMasterClientCertProvider installs the provider used to obtain the master
@@ -45,6 +47,91 @@ func getMasterClientCert() (tls.Certificate, error) {
 	return p()
 }
 
+// InvalidateMasterClientConnections advances the client-credential generation.
+// Every cached mTLS transport observes the generation before its next request,
+// replaces its TLS transport, and closes the old idle pool. Requests already
+// in flight are not interrupted; no request that starts after invalidation can
+// reuse a connection authenticated with the previous leaf.
+func InvalidateMasterClientConnections() {
+	masterCertEpoch.Add(1)
+}
+
+// ReloadMasterClientConnections validates that the currently configured
+// provider can load the master credential, then invalidates every cached mTLS
+// transport. Operators that rotate the credential outside the process (for
+// example by restoring settings) can call this without restarting the panel.
+func ReloadMasterClientConnections() error {
+	if _, err := getMasterClientCert(); err != nil {
+		return err
+	}
+	InvalidateMasterClientConnections()
+	return nil
+}
+
+type idleClosingRoundTripper interface {
+	http.RoundTripper
+	CloseIdleConnections()
+}
+
+type credentialRotatingTransport struct {
+	mu         sync.Mutex
+	generation uint64
+	current    idleClosingRoundTripper
+	build      func() (idleClosingRoundTripper, error)
+}
+
+func buildStableCredentialTransport(build func() (idleClosingRoundTripper, error)) (idleClosingRoundTripper, uint64, error) {
+	for {
+		before := masterCertEpoch.Load()
+		current, err := build()
+		if err != nil {
+			return nil, 0, err
+		}
+		after := masterCertEpoch.Load()
+		if before == after {
+			return current, after, nil
+		}
+		current.CloseIdleConnections()
+	}
+}
+
+func newCredentialRotatingTransport(build func() (idleClosingRoundTripper, error)) (*credentialRotatingTransport, error) {
+	current, generation, err := buildStableCredentialTransport(build)
+	if err != nil {
+		return nil, err
+	}
+	return &credentialRotatingTransport{
+		generation: generation,
+		current:    current,
+		build:      build,
+	}, nil
+}
+
+func (t *credentialRotatingTransport) RoundTrip(req *http.Request) (*http.Response, error) {
+	t.mu.Lock()
+	if masterCertEpoch.Load() != t.generation {
+		next, generation, err := buildStableCredentialTransport(t.build)
+		if err != nil {
+			t.mu.Unlock()
+			return nil, err
+		}
+		previous := t.current
+		t.current = next
+		t.generation = generation
+		previous.CloseIdleConnections()
+	}
+	current := t.current
+	t.mu.Unlock()
+	return current.RoundTrip(req)
+}
+
+func (t *credentialRotatingTransport) CloseIdleConnections() {
+	t.mu.Lock()
+	current := t.current
+	t.mu.Unlock()
+	current.CloseIdleConnections()
+}
+
 // defaultNodeHTTPClient reaches nodes trusting the system CA store ("verify"
 // mode or plain http); shared so connections pool across nodes.
 var defaultNodeHTTPClient = &http.Client{
@@ -62,6 +149,30 @@ func HTTPClientForNode(n *model.Node, proxyURL string) (*http.Client, error) {
 		mode = "verify"
 	}
 	if proxyURL != "" {
+		if mode == "mtls" && n.Scheme != "http" {
+			timeout := remoteHTTPTimeout
+			build := func() (idleClosingRoundTripper, error) {
+				client, err := netproxy.NewHTTPClient(proxyURL, remoteHTTPTimeout)
+				if err != nil {
+					return nil, err
+				}
+				transport, ok := client.Transport.(*http.Transport)
+				if !ok {
+					return nil, common.NewError("mtls proxy client transport does not support credential rotation")
+				}
+				tlsCfg, err := tlsConfigForNode(n)
+				if err != nil {
+					return nil, err
+				}
+				transport.TLSClientConfig = tlsCfg
+				return transport, nil
+			}
+			transport, err := newCredentialRotatingTransport(build)
+			if err != nil {
+				return nil, err
+			}
+			return &http.Client{Transport: transport, Timeout: timeout}, nil
+		}
 		client, err := netproxy.NewHTTPClient(proxyURL, remoteHTTPTimeout)
 		if err != nil {
 			return nil, err
@@ -83,6 +194,26 @@ func HTTPClientForNode(n *model.Node, proxyURL string) (*http.Client, error) {
 	if mode == "verify" || n.Scheme == "http" {
 		return defaultNodeHTTPClient, nil
 	}
+	if mode == "mtls" {
+		build := func() (idleClosingRoundTripper, error) {
+			tlsCfg, err := tlsConfigForNode(n)
+			if err != nil {
+				return nil, err
+			}
+			return &http.Transport{
+				MaxIdleConns:        64,
+				MaxIdleConnsPerHost: 4,
+				IdleConnTimeout:     60 * time.Second,
+				DialContext:         netsafe.SSRFGuardedDialContext,
+				TLSClientConfig:     tlsCfg,
+			}, nil
+		}
+		transport, err := newCredentialRotatingTransport(build)
+		if err != nil {
+			return nil, err
+		}
+		return &http.Client{Transport: transport}, nil
+	}
 	tlsCfg, err := tlsConfigForNode(n)
 	if err != nil {
 		return nil, err

+ 233 - 0
internal/web/runtime/tls_client_test.go

@@ -11,12 +11,245 @@ import (
 	"net/url"
 	"strconv"
 	"strings"
+	"sync"
+	"sync/atomic"
 	"testing"
 
 	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
 	"github.com/mhsanaei/3x-ui/v3/internal/util/crypto"
 )
 
+type generationProbeTransport struct {
+	id     string
+	closed atomic.Int32
+}
+
+func (t *generationProbeTransport) RoundTrip(*http.Request) (*http.Response, error) {
+	return &http.Response{
+		StatusCode: http.StatusOK,
+		Body:       http.NoBody,
+		Header:     make(http.Header),
+		Request:    &http.Request{},
+	}, nil
+}
+
+func (t *generationProbeTransport) CloseIdleConnections() {
+	t.closed.Add(1)
+}
+
+func TestCredentialRotatingTransportDropsOldPoolBeforeNextRequest(t *testing.T) {
+	var selected atomic.Pointer[generationProbeTransport]
+	oldTransport := &generationProbeTransport{id: "old"}
+	newTransport := &generationProbeTransport{id: "new"}
+	selected.Store(oldTransport)
+
+	rotating, err := newCredentialRotatingTransport(func() (idleClosingRoundTripper, error) {
+		return selected.Load(), nil
+	})
+	if err != nil {
+		t.Fatalf("newCredentialRotatingTransport: %v", err)
+	}
+	rotating.mu.Lock()
+	initial := rotating.current
+	rotating.mu.Unlock()
+	if initial != oldTransport {
+		t.Fatalf("initial transport = %p, want old %p", initial, oldTransport)
+	}
+
+	selected.Store(newTransport)
+	InvalidateMasterClientConnections()
+
+	req := httptest.NewRequest(http.MethodGet, "https://node.example.test/panel/api/server/status", nil)
+	resp, err := rotating.RoundTrip(req)
+	if err != nil {
+		t.Fatalf("RoundTrip after credential rotation: %v", err)
+	}
+	_ = resp.Body.Close()
+
+	rotating.mu.Lock()
+	current := rotating.current
+	rotating.mu.Unlock()
+	if current != newTransport {
+		t.Fatalf("transport after invalidation = %p, want new %p", current, newTransport)
+	}
+	if got := oldTransport.closed.Load(); got != 1 {
+		t.Fatalf("old transport CloseIdleConnections calls = %d, want 1", got)
+	}
+}
+
+func TestReloadMasterClientConnectionsValidatesProviderBeforeInvalidation(t *testing.T) {
+	before := masterCertEpoch.Load()
+	SetMasterClientCertProvider(func() (tls.Certificate, error) {
+		return tls.Certificate{}, context.Canceled
+	})
+	if err := ReloadMasterClientConnections(); err == nil {
+		t.Fatal("reload with an invalid provider unexpectedly succeeded")
+	}
+	if got := masterCertEpoch.Load(); got != before {
+		t.Fatalf("failed reload changed generation from %d to %d", before, got)
+	}
+
+	SetMasterClientCertProvider(func() (tls.Certificate, error) {
+		return masterCertForTest(t), nil
+	})
+	t.Cleanup(func() { SetMasterClientCertProvider(nil) })
+	if err := ReloadMasterClientConnections(); err != nil {
+		t.Fatalf("ReloadMasterClientConnections: %v", err)
+	}
+	if got := masterCertEpoch.Load(); got != before+1 {
+		t.Fatalf("successful reload generation = %d, want %d", got, before+1)
+	}
+}
+
+func TestCredentialRotatingTransportRejectsBuildAcrossInvalidation(t *testing.T) {
+	oldTransport := &generationProbeTransport{id: "old"}
+	newTransport := &generationProbeTransport{id: "new"}
+	var selected atomic.Pointer[generationProbeTransport]
+	selected.Store(oldTransport)
+
+	firstBuildCaptured := make(chan struct{})
+	releaseFirstBuild := make(chan struct{})
+	var once sync.Once
+	build := func() (idleClosingRoundTripper, error) {
+		captured := selected.Load()
+		once.Do(func() {
+			close(firstBuildCaptured)
+			<-releaseFirstBuild
+		})
+		return captured, nil
+	}
+
+	type result struct {
+		transport *credentialRotatingTransport
+		err       error
+	}
+	resultCh := make(chan result, 1)
+	go func() {
+		transport, err := newCredentialRotatingTransport(build)
+		resultCh <- result{transport: transport, err: err}
+	}()
+
+	<-firstBuildCaptured
+	selected.Store(newTransport)
+	InvalidateMasterClientConnections()
+	close(releaseFirstBuild)
+
+	got := <-resultCh
+	if got.err != nil {
+		t.Fatalf("newCredentialRotatingTransport: %v", got.err)
+	}
+	got.transport.mu.Lock()
+	current := got.transport.current
+	got.transport.mu.Unlock()
+	if current != newTransport {
+		t.Fatalf("transport built across invalidation = %p, want new %p", current, newTransport)
+	}
+	if calls := oldTransport.closed.Load(); calls != 1 {
+		t.Fatalf("stale transport CloseIdleConnections calls = %d, want 1", calls)
+	}
+}
+
+func TestHTTPClientForNodeMTLSRebuildsTLSConfigAfterCredentialInvalidation(t *testing.T) {
+	oldCert := masterCertForTest(t)
+	newCert := masterCertForTest(t)
+	selected := oldCert
+	SetMasterClientCertProvider(func() (tls.Certificate, error) { return selected, nil })
+	t.Cleanup(func() { SetMasterClientCertProvider(nil) })
+
+	client, err := HTTPClientForNode(&model.Node{
+		Scheme:        "https",
+		Address:       "node.example.test",
+		Port:          443,
+		TlsVerifyMode: "mtls",
+	}, "")
+	if err != nil {
+		t.Fatalf("HTTPClientForNode: %v", err)
+	}
+	rotating, ok := client.Transport.(*credentialRotatingTransport)
+	if !ok {
+		t.Fatalf("transport = %T, want *credentialRotatingTransport", client.Transport)
+	}
+	leaf := func() []byte {
+		rotating.mu.Lock()
+		defer rotating.mu.Unlock()
+		transport, ok := rotating.current.(*http.Transport)
+		if !ok {
+			t.Fatalf("current transport = %T, want *http.Transport", rotating.current)
+		}
+		return transport.TLSClientConfig.Certificates[0].Certificate[0]
+	}
+	if got := leaf(); string(got) != string(oldCert.Certificate[0]) {
+		t.Fatal("initial TLS config does not contain the old credential")
+	}
+
+	selected = newCert
+	InvalidateMasterClientConnections()
+
+	ctx, cancel := context.WithCancel(context.Background())
+	cancel()
+	req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://node.example.test/", nil)
+	if err != nil {
+		t.Fatalf("NewRequestWithContext: %v", err)
+	}
+	if _, err := client.Do(req); err == nil {
+		t.Fatal("canceled request unexpectedly succeeded")
+	}
+	if got := leaf(); string(got) != string(newCert.Certificate[0]) {
+		t.Fatal("TLS config retained the old credential after invalidation")
+	}
+}
+
+func TestHTTPClientForNodeProxyMTLSRebuildKeepsProxyAndNewCredential(t *testing.T) {
+	oldCert := masterCertForTest(t)
+	newCert := masterCertForTest(t)
+	selected := oldCert
+	SetMasterClientCertProvider(func() (tls.Certificate, error) { return selected, nil })
+	t.Cleanup(func() { SetMasterClientCertProvider(nil) })
+
+	const proxyURL = "http://127.0.0.1:18080"
+	client, err := HTTPClientForNode(&model.Node{Scheme: "https", TlsVerifyMode: "mtls"}, proxyURL)
+	if err != nil {
+		t.Fatalf("HTTPClientForNode: %v", err)
+	}
+	rotating, ok := client.Transport.(*credentialRotatingTransport)
+	if !ok {
+		t.Fatalf("transport = %T, want rotating transport", client.Transport)
+	}
+	current := func() *http.Transport {
+		rotating.mu.Lock()
+		defer rotating.mu.Unlock()
+		transport, ok := rotating.current.(*http.Transport)
+		if !ok {
+			t.Fatalf("current transport = %T, want *http.Transport", rotating.current)
+		}
+		return transport
+	}
+	assertProxy := func(transport *http.Transport) {
+		t.Helper()
+		if transport.Proxy == nil {
+			t.Fatalf("proxy function is nil, want %s", proxyURL)
+		}
+		req, _ := http.NewRequest(http.MethodGet, "https://node.example.test/", nil)
+		got, err := transport.Proxy(req)
+		if err != nil || got == nil || got.String() != proxyURL {
+			t.Fatalf("proxy = %v, error = %v, want %s", got, err, proxyURL)
+		}
+	}
+	assertProxy(current())
+
+	selected = newCert
+	InvalidateMasterClientConnections()
+	ctx, cancel := context.WithCancel(context.Background())
+	cancel()
+	req, _ := http.NewRequestWithContext(ctx, http.MethodGet, "https://node.example.test/", nil)
+	_, _ = client.Do(req)
+	rebuilt := current()
+	assertProxy(rebuilt)
+	if got := rebuilt.TLSClientConfig.Certificates[0].Certificate[0]; string(got) != string(newCert.Certificate[0]) {
+		t.Fatal("proxy mTLS rebuild retained the old credential")
+	}
+}
+
 // masterCertForTest builds a real CA-signed client certificate for mtls tests.
 func masterCertForTest(t *testing.T) tls.Certificate {
 	t.Helper()

+ 162 - 0
internal/web/runtime/tls_client_wire_test.go

@@ -0,0 +1,162 @@
+package runtime
+
+import (
+	"crypto/sha256"
+	"crypto/tls"
+	"crypto/x509"
+	"encoding/hex"
+	"io"
+	"net/http"
+	"net/http/httptest"
+	"sync"
+	"testing"
+	"time"
+)
+
+type wireObservation struct {
+	pin        string
+	remoteAddr string
+}
+
+func startLeafRecordingServer(t *testing.T) (*httptest.Server, *x509.CertPool, func() []wireObservation) {
+	t.Helper()
+	var mu sync.Mutex
+	var seen []wireObservation
+	srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		observation := wireObservation{remoteAddr: r.RemoteAddr}
+		if r.TLS != nil && len(r.TLS.PeerCertificates) > 0 {
+			sum := sha256.Sum256(r.TLS.PeerCertificates[0].Raw)
+			observation.pin = hex.EncodeToString(sum[:])
+		}
+		mu.Lock()
+		seen = append(seen, observation)
+		mu.Unlock()
+		w.WriteHeader(http.StatusOK)
+		_, _ = w.Write([]byte("ok"))
+	}))
+	srv.TLS = &tls.Config{ClientAuth: tls.RequestClientCert}
+	srv.StartTLS()
+	t.Cleanup(srv.Close)
+	pool := x509.NewCertPool()
+	pool.AddCert(srv.Certificate())
+	return srv, pool, func() []wireObservation {
+		mu.Lock()
+		defer mu.Unlock()
+		result := make([]wireObservation, len(seen))
+		copy(result, seen)
+		return result
+	}
+}
+
+func pinOf(t *testing.T, cert tls.Certificate) string {
+	t.Helper()
+	sum := sha256.Sum256(cert.Certificate[0])
+	return hex.EncodeToString(sum[:])
+}
+
+func rotatingClientForTest(t *testing.T, roots *x509.CertPool) *http.Client {
+	t.Helper()
+	build := func() (idleClosingRoundTripper, error) {
+		cert, err := getMasterClientCert()
+		if err != nil {
+			return nil, err
+		}
+		return &http.Transport{
+			MaxIdleConns:        64,
+			MaxIdleConnsPerHost: 4,
+			IdleConnTimeout:     60 * time.Second,
+			TLSClientConfig: &tls.Config{
+				Certificates: []tls.Certificate{cert},
+				RootCAs:      roots,
+				MinVersion:   tls.VersionTLS12,
+			},
+		}, nil
+	}
+	transport, err := newCredentialRotatingTransport(build)
+	if err != nil {
+		t.Fatalf("newCredentialRotatingTransport: %v", err)
+	}
+	return &http.Client{Transport: transport, Timeout: 10 * time.Second}
+}
+
+func doWireRequest(t *testing.T, client *http.Client, url string) {
+	t.Helper()
+	response, err := client.Get(url)
+	if err != nil {
+		t.Fatalf("request: %v", err)
+	}
+	_, _ = io.Copy(io.Discard, response.Body)
+	_ = response.Body.Close()
+	if response.StatusCode != http.StatusOK {
+		t.Fatalf("status=%d want=%d", response.StatusCode, http.StatusOK)
+	}
+}
+
+func TestCredentialRotationPresentsNewLeafOnNextConnection(t *testing.T) {
+	server, roots, observations := startLeafRecordingServer(t)
+	oldCert := masterCertForTest(t)
+	newCert := masterCertForTest(t)
+	oldPin := pinOf(t, oldCert)
+	newPin := pinOf(t, newCert)
+	if oldPin == newPin {
+		t.Fatal("test fixture produced identical leaves")
+	}
+	var providerMu sync.Mutex
+	current := oldCert
+	SetMasterClientCertProvider(func() (tls.Certificate, error) {
+		providerMu.Lock()
+		defer providerMu.Unlock()
+		return current, nil
+	})
+	t.Cleanup(func() { SetMasterClientCertProvider(nil) })
+	client := rotatingClientForTest(t, roots)
+	doWireRequest(t, client, server.URL)
+	doWireRequest(t, client, server.URL)
+	baseline := observations()
+	if len(baseline) != 2 || baseline[0].pin != oldPin || baseline[1].pin != oldPin {
+		t.Fatalf("baseline=%v", baseline)
+	}
+	if baseline[0].remoteAddr != baseline[1].remoteAddr {
+		t.Fatalf("baseline connections differ: %v", baseline)
+	}
+	providerMu.Lock()
+	current = newCert
+	providerMu.Unlock()
+	InvalidateMasterClientConnections()
+	doWireRequest(t, client, server.URL)
+	after := observations()
+	if len(after) != 3 || after[2].pin != newPin {
+		t.Fatalf("rotation observations=%v want new leaf=%s", after, newPin)
+	}
+	if after[2].remoteAddr == baseline[1].remoteAddr {
+		t.Fatalf("rotated request reused stale connection %s", after[2].remoteAddr)
+	}
+}
+
+func TestCredentialRotationControlKeepsOldLeafWithoutInvalidation(t *testing.T) {
+	server, roots, observations := startLeafRecordingServer(t)
+	oldCert := masterCertForTest(t)
+	newCert := masterCertForTest(t)
+	oldPin := pinOf(t, oldCert)
+	var providerMu sync.Mutex
+	current := oldCert
+	SetMasterClientCertProvider(func() (tls.Certificate, error) {
+		providerMu.Lock()
+		defer providerMu.Unlock()
+		return current, nil
+	})
+	t.Cleanup(func() { SetMasterClientCertProvider(nil) })
+	client := rotatingClientForTest(t, roots)
+	doWireRequest(t, client, server.URL)
+	providerMu.Lock()
+	current = newCert
+	providerMu.Unlock()
+	doWireRequest(t, client, server.URL)
+	got := observations()
+	if len(got) != 2 || got[1].pin != oldPin {
+		t.Fatalf("control observations=%v want stale leaf=%s", got, oldPin)
+	}
+	if got[0].remoteAddr != got[1].remoteAddr {
+		t.Fatalf("control did not reuse connection: %v", got)
+	}
+}

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

@@ -113,7 +113,7 @@ func TestAllAPIsPostgresScale(t *testing.T) {
 			run("UpdateByEmail", func() error {
 				upd := clients[n/3]
 				upd.Comment = "touched"
-				_, err := svc.UpdateByEmail(inboundSvc, upd.Email, upd)
+				_, err := svc.UpdateByEmail(inboundSvc, upd.Email, upd, 0)
 				return err
 			})
 			run("AttachByEmail", func() error { _, err := svc.AttachByEmail(inboundSvc, emails[n/3], []int{ib2.Id}); return err })

+ 30 - 0
internal/web/service/client.go

@@ -68,6 +68,36 @@ var ErrClientNotInInbound = errors.New("client not found in inbound")
 type ClientCreatePayload struct {
 	Client     model.Client `json:"client"`
 	InboundIds []int        `json:"inboundIds"`
+	LimitHwid  int          `json:"-"`
 }
 
 const sqlInChunk = 400
+
+type clientPayloadWithHwid struct {
+	model.Client
+	LimitHwid int `json:"limitHwid"`
+}
+
+func (p *ClientCreatePayload) UnmarshalJSON(data []byte) error {
+	var raw struct {
+		Client     clientPayloadWithHwid `json:"client"`
+		InboundIds []int                 `json:"inboundIds"`
+	}
+	if err := json.Unmarshal(data, &raw); err != nil {
+		return err
+	}
+	p.Client = raw.Client.Client
+	p.InboundIds = raw.InboundIds
+	p.LimitHwid = raw.Client.LimitHwid
+	return nil
+}
+
+func (p ClientCreatePayload) MarshalJSON() ([]byte, error) {
+	return json.Marshal(struct {
+		Client     clientPayloadWithHwid `json:"client"`
+		InboundIds []int                 `json:"inboundIds"`
+	}{
+		Client:     clientPayloadWithHwid{Client: p.Client, LimitHwid: p.LimitHwid},
+		InboundIds: p.InboundIds,
+	})
+}

+ 13 - 3
internal/web/service/client_bulk.go

@@ -816,6 +816,7 @@ func (s *ClientService) BulkDelete(inboundSvc *InboundService, emails []string,
 	successEmails := make([]string, 0, len(recordsByEmail))
 	successIds := make([]int, 0, len(recordsByEmail))
 	failedEmails := make([]string, 0, len(recordsByEmail))
+	successSubIDs := make([]string, 0, len(recordsByEmail))
 	for email, rec := range recordsByEmail {
 		if _, skipped := skippedReasons[email]; skipped {
 			failedEmails = append(failedEmails, email)
@@ -823,6 +824,7 @@ func (s *ClientService) BulkDelete(inboundSvc *InboundService, emails []string,
 		}
 		successEmails = append(successEmails, email)
 		successIds = append(successIds, rec.Id)
+		successSubIDs = append(successSubIDs, rec.SubID)
 	}
 	withdrawClientTombstones(failedEmails...)
 
@@ -833,6 +835,9 @@ func (s *ClientService) BulkDelete(inboundSvc *InboundService, emails []string,
 			if e := adjustGroupBaselinesForRemovedTraffic(tx, successEmails); e != nil {
 				return e
 			}
+			if e := clearClientHwidsBySubIDTx(tx, successSubIDs...); e != nil {
+				return e
+			}
 			for _, batch := range chunkInts(successIds, sqlInChunk) {
 				if e := tx.Where("client_id IN ?", batch).Delete(&model.ClientInbound{}).Error; e != nil {
 					return e
@@ -1119,6 +1124,7 @@ func (s *ClientService) BulkCreate(inboundSvc *InboundService, payloads []Client
 	type prepared struct {
 		client     model.Client
 		inboundIds []int
+		limitHwid  int
 	}
 	prep := make([]prepared, 0, len(payloads))
 	emails := make([]string, 0, len(payloads))
@@ -1171,7 +1177,7 @@ func (s *ClientService) BulkCreate(inboundSvc *InboundService, payloads []Client
 		seenEmail[le] = struct{}{}
 		seenSubID[client.SubID] = le
 
-		prep = append(prep, prepared{client: client, inboundIds: payloads[i].InboundIds})
+		prep = append(prep, prepared{client: client, inboundIds: payloads[i].InboundIds, limitHwid: payloads[i].LimitHwid})
 		emails = append(emails, email)
 		subIDs = append(subIDs, client.SubID)
 	}
@@ -1303,9 +1309,13 @@ func (s *ClientService) BulkCreate(inboundSvc *InboundService, payloads []Client
 	for idx := range prep {
 		if failed[idx] {
 			skip(prep[idx].client.Email, reason[idx])
-		} else {
-			result.Created++
+			continue
+		}
+		if err := s.setClientLimitHwidByEmail(nil, prep[idx].client.Email, prep[idx].limitHwid); err != nil {
+			skip(prep[idx].client.Email, err.Error())
+			continue
 		}
+		result.Created++
 	}
 	return result, needRestart, nil
 }

+ 13 - 3
internal/web/service/client_crud.go

@@ -140,6 +140,9 @@ func (s *ClientService) Create(inboundSvc *InboundService, payload *ClientCreate
 			needRestart = true
 		}
 	}
+	if err := s.setClientLimitHwidByEmail(nil, client.Email, payload.LimitHwid); err != nil {
+		return needRestart, err
+	}
 	return needRestart, nil
 }
 
@@ -309,7 +312,7 @@ func applyShadowsocksClientMethod(clients []any, settings map[string]any) {
 	}
 }
 
-func (s *ClientService) Update(inboundSvc *InboundService, id int, updated model.Client, inboundFilter ...int) (bool, error) {
+func (s *ClientService) Update(inboundSvc *InboundService, id int, updated model.Client, limitHwid int, inboundFilter ...int) (bool, error) {
 	existing, err := s.GetByID(id)
 	if err != nil {
 		return false, err
@@ -507,6 +510,10 @@ func (s *ClientService) Update(inboundSvc *InboundService, id int, updated model
 		return needRestart, err
 	}
 
+	if err := s.setClientLimitHwidByEmail(nil, updated.Email, limitHwid); err != nil {
+		return needRestart, err
+	}
+
 	if err := database.GetDB().Model(&model.ClientRecord{}).
 		Where("id = ?", id).
 		UpdateColumn("updated_at", time.Now().UnixMilli()).Error; err != nil {
@@ -581,6 +588,9 @@ func (s *ClientService) Delete(inboundSvc *InboundService, id int, keepTraffic b
 		if err := tx.Where("client_id = ?", id).Delete(&model.ClientExternalLink{}).Error; err != nil {
 			return err
 		}
+		if err := clearClientHwidsBySubIDTx(tx, existing.SubID); err != nil {
+			return err
+		}
 		if !keepTraffic && existing.Email != "" {
 			if err := tx.Where("email = ?", existing.Email).Delete(&xray.ClientTraffic{}).Error; err != nil {
 				return err
@@ -755,7 +765,7 @@ func (s *ClientService) DeleteByEmail(inboundSvc *InboundService, email string,
 	return needRestart, nil
 }
 
-func (s *ClientService) UpdateByEmail(inboundSvc *InboundService, email string, updated model.Client, inboundFilter ...int) (bool, error) {
+func (s *ClientService) UpdateByEmail(inboundSvc *InboundService, email string, updated model.Client, limitHwid int, inboundFilter ...int) (bool, error) {
 	if email == "" {
 		return false, common.NewError("client email is required")
 	}
@@ -763,7 +773,7 @@ func (s *ClientService) UpdateByEmail(inboundSvc *InboundService, email string,
 	if err != nil {
 		return false, err
 	}
-	return s.Update(inboundSvc, rec.Id, updated, inboundFilter...)
+	return s.Update(inboundSvc, rec.Id, updated, limitHwid, inboundFilter...)
 }
 
 func (s *ClientService) Detach(inboundSvc *InboundService, id int, inboundIds []int) (bool, error) {

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

@@ -171,7 +171,7 @@ func TestClientUpdate_ClearsGroup(t *testing.T) {
 	// Edit the client and remove the group.
 	updated := *rec.ToClient()
 	updated.Group = ""
-	if _, err := svc.Update(inboundSvc, rec.Id, updated); err != nil {
+	if _, err := svc.Update(inboundSvc, rec.Id, updated, 0); err != nil {
 		t.Fatalf("Update (clear group): %v", err)
 	}
 

+ 271 - 0
internal/web/service/client_hwid.go

@@ -0,0 +1,271 @@
+package service
+
+import (
+	"crypto/sha256"
+	"encoding/hex"
+	"errors"
+	"strings"
+	"time"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+
+	"gorm.io/gorm"
+)
+
+type HwidRequest struct {
+	Hwid        string
+	UserAgent   string
+	DeviceOS    string
+	OsVersion   string
+	DeviceModel string
+}
+
+type HwidGateResult struct {
+	Allowed           bool
+	Active            bool
+	NotSupported      bool
+	MaxDevicesReached bool
+	LimitReached      bool
+	Limit             int
+	Registered        int
+}
+
+const minHwidLength = 6
+
+type ClientHwidInfo struct {
+	Id          int    `json:"id"`
+	FirstSeen   int64  `json:"firstSeen"`
+	LastSeen    int64  `json:"lastSeen"`
+	UserAgent   string `json:"userAgent"`
+	DeviceOS    string `json:"deviceOs"`
+	OsVersion   string `json:"osVersion"`
+	DeviceModel string `json:"deviceModel"`
+}
+
+func hashHwid(raw string) string {
+	sum := sha256.Sum256([]byte(raw))
+	return hex.EncodeToString(sum[:])
+}
+
+func trimHwidMeta(s string) string {
+	s = strings.TrimSpace(s)
+	r := []rune(s)
+	if len(r) > 512 {
+		return string(r[:512])
+	}
+	return s
+}
+
+func normalizeHwidRequest(req HwidRequest) HwidRequest {
+	return HwidRequest{
+		Hwid:        strings.TrimSpace(req.Hwid),
+		UserAgent:   trimHwidMeta(req.UserAgent),
+		DeviceOS:    trimHwidMeta(req.DeviceOS),
+		OsVersion:   trimHwidMeta(req.OsVersion),
+		DeviceModel: trimHwidMeta(req.DeviceModel),
+	}
+}
+
+func effectiveHwidLimitForSubID(tx *gorm.DB, subID string) (int, error) {
+	var limit int
+	err := tx.Model(&model.ClientRecord{}).
+		Where("sub_id = ? AND enable = ?", subID, true).
+		Select("COALESCE(MAX(limit_hwid), 0)").
+		Scan(&limit).Error
+	return limit, err
+}
+
+func (s *ClientService) EnforceHwidForSubID(subID string, req HwidRequest) (HwidGateResult, error) {
+	var res HwidGateResult
+	subID = strings.TrimSpace(subID)
+	if subID == "" {
+		res.Allowed = true
+		return res, nil
+	}
+
+	db := database.GetDB()
+	limit, err := effectiveHwidLimitForSubID(db, subID)
+	if err != nil {
+		return res, err
+	}
+	if limit <= 0 {
+		res.Allowed = true
+		return res, nil
+	}
+
+	req = normalizeHwidRequest(req)
+	res.Active = true
+	res.Limit = limit
+	if len(req.Hwid) < minHwidLength {
+		res.NotSupported = true
+		return res, nil
+	}
+	hwidHash := hashHwid(req.Hwid)
+
+	err = db.Transaction(func(tx *gorm.DB) error {
+		limit, err := effectiveHwidLimitForSubID(tx, subID)
+		if err != nil {
+			return err
+		}
+		if limit <= 0 {
+			res = HwidGateResult{Allowed: true}
+			return nil
+		}
+		res.Active = true
+		res.Limit = limit
+		now := time.Now().UnixMilli()
+		var existing model.ClientHwid
+		err = tx.Where("sub_id = ? AND hwid_hash = ?", subID, hwidHash).First(&existing).Error
+		if err == nil {
+			if err := tx.Model(&model.ClientHwid{}).Where("id = ?", existing.Id).Updates(map[string]any{
+				"last_seen": now, "user_agent": req.UserAgent, "device_os": req.DeviceOS, "os_version": req.OsVersion, "device_model": req.DeviceModel,
+			}).Error; err != nil {
+				return err
+			}
+			var count int64
+			if err := tx.Model(&model.ClientHwid{}).Where("sub_id = ?", subID).Count(&count).Error; err != nil {
+				return err
+			}
+			res.Allowed = true
+			res.Registered = int(count)
+			res.LimitReached = count >= int64(limit)
+			return nil
+		}
+		if !errors.Is(err, gorm.ErrRecordNotFound) {
+			return err
+		}
+		var count int64
+		if err := tx.Model(&model.ClientHwid{}).Where("sub_id = ?", subID).Count(&count).Error; err != nil {
+			return err
+		}
+		res.Registered = int(count)
+		if count >= int64(limit) {
+			res.MaxDevicesReached = true
+			res.LimitReached = true
+			return nil
+		}
+		if err := tx.Create(&model.ClientHwid{SubID: subID, HwidHash: hwidHash, FirstSeen: now, LastSeen: now, UserAgent: req.UserAgent, DeviceOS: req.DeviceOS, OsVersion: req.OsVersion, DeviceModel: req.DeviceModel}).Error; err != nil {
+			return err
+		}
+		res.Allowed = true
+		res.Registered = int(count) + 1
+		res.LimitReached = res.Registered >= limit
+		return nil
+	})
+	return res, err
+}
+
+func (s *ClientService) ListClientHwids(email string) ([]ClientHwidInfo, error) {
+	rec, err := s.GetRecordByEmail(nil, email)
+	if err != nil {
+		return nil, err
+	}
+	subID := strings.TrimSpace(rec.SubID)
+	if subID == "" {
+		return nil, nil
+	}
+	var rows []model.ClientHwid
+	if err := database.GetDB().
+		Where("sub_id = ?", subID).
+		Order("last_seen DESC").
+		Order("id DESC").
+		Find(&rows).Error; err != nil {
+		return nil, err
+	}
+	out := make([]ClientHwidInfo, 0, len(rows))
+	for _, r := range rows {
+		out = append(out, ClientHwidInfo{
+			Id:          r.Id,
+			FirstSeen:   r.FirstSeen,
+			LastSeen:    r.LastSeen,
+			UserAgent:   r.UserAgent,
+			DeviceOS:    r.DeviceOS,
+			OsVersion:   r.OsVersion,
+			DeviceModel: r.DeviceModel,
+		})
+	}
+	return out, nil
+}
+
+func (s *ClientService) ClearClientHwids(email string) error {
+	rec, err := s.GetRecordByEmail(nil, email)
+	if err != nil {
+		return err
+	}
+	subID := strings.TrimSpace(rec.SubID)
+	if subID == "" {
+		return nil
+	}
+	return database.GetDB().Where("sub_id = ?", subID).Delete(&model.ClientHwid{}).Error
+}
+
+func (s *ClientService) setClientLimitHwidByEmail(tx *gorm.DB, email string, limit int) error {
+	if tx == nil {
+		tx = database.GetDB()
+	}
+	if limit < 0 {
+		limit = 0
+	}
+	var rec model.ClientRecord
+	if err := tx.Where("email = ?", email).First(&rec).Error; err != nil {
+		return err
+	}
+	if err := tx.Model(&model.ClientRecord{}).Where("id = ?", rec.Id).UpdateColumn("limit_hwid", limit).Error; err != nil {
+		return err
+	}
+	subID := strings.TrimSpace(rec.SubID)
+	if subID == "" {
+		return nil
+	}
+	effective, err := effectiveHwidLimitForSubID(tx, subID)
+	if err != nil {
+		return err
+	}
+	return trimClientHwidsForSubID(tx, subID, effective)
+}
+
+func trimClientHwidsForSubID(tx *gorm.DB, subID string, limit int) error {
+	subID = strings.TrimSpace(subID)
+	if subID == "" || limit <= 0 {
+		return nil
+	}
+	var keep []int
+	if err := tx.Model(&model.ClientHwid{}).
+		Where("sub_id = ?", subID).
+		Order("last_seen DESC").
+		Order("id DESC").
+		Limit(limit).
+		Pluck("id", &keep).Error; err != nil {
+		return err
+	}
+	if len(keep) == 0 {
+		return tx.Where("sub_id = ?", subID).Delete(&model.ClientHwid{}).Error
+	}
+	return tx.Where("sub_id = ? AND id NOT IN ?", subID, keep).Delete(&model.ClientHwid{}).Error
+}
+
+func clearClientHwidsBySubIDTx(tx *gorm.DB, subIDs ...string) error {
+	if tx == nil {
+		tx = database.GetDB()
+	}
+	clean := make([]string, 0, len(subIDs))
+	seen := map[string]struct{}{}
+	for _, subID := range subIDs {
+		subID = strings.TrimSpace(subID)
+		if subID == "" {
+			continue
+		}
+		if _, ok := seen[subID]; ok {
+			continue
+		}
+		seen[subID] = struct{}{}
+		clean = append(clean, subID)
+	}
+	for _, batch := range chunkStrings(clean, sqlInChunk) {
+		if err := tx.Where("sub_id IN ?", batch).Delete(&model.ClientHwid{}).Error; err != nil {
+			return err
+		}
+	}
+	return nil
+}

+ 172 - 0
internal/web/service/client_hwid_test.go

@@ -0,0 +1,172 @@
+package service
+
+import (
+	"path/filepath"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+)
+
+func initClientHwidTestDB(t *testing.T) {
+	t.Helper()
+	dbDir := t.TempDir()
+	t.Setenv("XUI_DB_FOLDER", dbDir)
+	if err := database.InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil {
+		t.Fatalf("InitDB: %v", err)
+	}
+	t.Cleanup(func() { _ = database.CloseDB() })
+}
+
+func seedHwidClient(t *testing.T, limit int) *model.ClientRecord {
+	t.Helper()
+	rec := &model.ClientRecord{
+		Email:     "[email protected]",
+		SubID:     "sub-hwid",
+		UUID:      "11111111-2222-4333-8444-555555555555",
+		Enable:    true,
+		LimitHwid: limit,
+	}
+	if err := database.GetDB().Create(rec).Error; err != nil {
+		t.Fatalf("seed client: %v", err)
+	}
+	return rec
+}
+
+func TestClientHwidGate(t *testing.T) {
+	initClientHwidTestDB(t)
+	svc := &ClientService{}
+
+	seedHwidClient(t, 0)
+	res, err := svc.EnforceHwidForSubID("sub-hwid", HwidRequest{})
+	if err != nil {
+		t.Fatalf("no-limit gate: %v", err)
+	}
+	if !res.Allowed || res.Active {
+		t.Fatalf("no limit should allow missing HWID without active headers: %+v", res)
+	}
+}
+
+func TestClientHwidGateRegistersAndBlocks(t *testing.T) {
+	initClientHwidTestDB(t)
+	svc := &ClientService{}
+	rec := seedHwidClient(t, 2)
+
+	res, err := svc.EnforceHwidForSubID(rec.SubID, HwidRequest{})
+	if err != nil {
+		t.Fatalf("missing HWID gate: %v", err)
+	}
+	if res.Allowed || !res.Active || !res.NotSupported {
+		t.Fatalf("missing HWID should be denied as not supported: %+v", res)
+	}
+
+	firstRaw := "device-one"
+	for _, raw := range []string{firstRaw, "device-two"} {
+		res, err = svc.EnforceHwidForSubID(rec.SubID, HwidRequest{
+			Hwid:        raw,
+			UserAgent:   "Happ/1.0",
+			DeviceOS:    "android",
+			OsVersion:   "15",
+			DeviceModel: raw + "-model",
+		})
+		if err != nil {
+			t.Fatalf("register %s: %v", raw, err)
+		}
+		if !res.Allowed {
+			t.Fatalf("register %s denied: %+v", raw, res)
+		}
+	}
+
+	res, err = svc.EnforceHwidForSubID(rec.SubID, HwidRequest{Hwid: "device-three"})
+	if err != nil {
+		t.Fatalf("third HWID gate: %v", err)
+	}
+	if res.Allowed || !res.MaxDevicesReached || !res.LimitReached {
+		t.Fatalf("third unique HWID should be denied after limit: %+v", res)
+	}
+
+	res, err = svc.EnforceHwidForSubID(rec.SubID, HwidRequest{
+		Hwid:        firstRaw,
+		UserAgent:   "Karing/2.0",
+		DeviceOS:    "ios",
+		OsVersion:   "18",
+		DeviceModel: "updated-model",
+	})
+	if err != nil {
+		t.Fatalf("existing HWID after full limit: %v", err)
+	}
+	if !res.Allowed || !res.LimitReached {
+		t.Fatalf("existing registered HWID should pass after limit: %+v", res)
+	}
+
+	var hashes []string
+	if err := database.GetDB().Model(&model.ClientHwid{}).Pluck("hwid_hash", &hashes).Error; err != nil {
+		t.Fatalf("pluck hashes: %v", err)
+	}
+	if len(hashes) != 2 {
+		t.Fatalf("stored HWIDs = %d, want 2", len(hashes))
+	}
+	for _, h := range hashes {
+		if h == firstRaw || h == "device-two" || len(h) != 64 {
+			t.Fatalf("raw HWID leaked or invalid hash stored: %q", h)
+		}
+	}
+
+	list, err := svc.ListClientHwids(rec.Email)
+	if err != nil {
+		t.Fatalf("list HWIDs: %v", err)
+	}
+	if len(list) != 2 {
+		t.Fatalf("list count = %d, want 2", len(list))
+	}
+	foundUpdated := false
+	for _, row := range list {
+		if row.DeviceModel == "updated-model" && row.UserAgent == "Karing/2.0" && row.DeviceOS == "ios" && row.OsVersion == "18" {
+			foundUpdated = true
+		}
+	}
+	if !foundUpdated {
+		t.Fatalf("updated HWID metadata missing: %#v", list)
+	}
+
+	if err := svc.setClientLimitHwidByEmail(nil, rec.Email, 1); err != nil {
+		t.Fatalf("lower limit: %v", err)
+	}
+	var count int64
+	if err := database.GetDB().Model(&model.ClientHwid{}).Where("sub_id = ?", rec.SubID).Count(&count).Error; err != nil {
+		t.Fatalf("count after trim: %v", err)
+	}
+	if count != 1 {
+		t.Fatalf("lowered limit should trim stored HWIDs to 1, got %d", count)
+	}
+
+	if err := svc.ClearClientHwids(rec.Email); err != nil {
+		t.Fatalf("clear HWIDs: %v", err)
+	}
+	if err := database.GetDB().Model(&model.ClientHwid{}).Where("sub_id = ?", rec.SubID).Count(&count).Error; err != nil {
+		t.Fatalf("count after clear: %v", err)
+	}
+	if count != 0 {
+		t.Fatalf("clear should remove all HWIDs, got %d", count)
+	}
+}
+
+func TestClientHwidGateSharedSubIdUsesMaxLimit(t *testing.T) {
+	initClientHwidTestDB(t)
+	svc := &ClientService{}
+	db := database.GetDB()
+	subID := "shared-sub"
+	if err := db.Create(&model.ClientRecord{Email: "[email protected]", SubID: subID, UUID: "11111111-2222-4333-8444-555555555555", Enable: true, LimitHwid: 0}).Error; err != nil {
+		t.Fatalf("seed anchor: %v", err)
+	}
+	if err := db.Create(&model.ClientRecord{Email: "[email protected]", SubID: subID, UUID: "22222222-2222-4333-8444-555555555555", Enable: true, LimitHwid: 2}).Error; err != nil {
+		t.Fatalf("seed second: %v", err)
+	}
+	res, err := svc.EnforceHwidForSubID(subID, HwidRequest{})
+	if err != nil || !res.Active || res.Limit != 2 {
+		t.Fatalf("expected active gate limit 2 from max row, err=%v res=%+v", err, res)
+	}
+	if res.Allowed || !res.NotSupported {
+		t.Fatalf("missing HWID should be denied: %+v", res)
+	}
+}

+ 25 - 15
internal/web/service/client_paging.go

@@ -24,6 +24,7 @@ type ClientSlim struct {
 	TotalGB    int64               `json:"totalGB"`
 	ExpiryTime int64               `json:"expiryTime"`
 	LimitIP    int                 `json:"limitIp"`
+	LimitHwid  int                 `json:"limitHwid"`
 	Reset      int                 `json:"reset"`
 	Group      string              `json:"group,omitempty"`
 	Comment    string              `json:"comment,omitempty"`
@@ -457,21 +458,11 @@ func (q clientQuery) pageRows(params ClientPageParams, onlines []string, offset,
 		if rec == nil {
 			continue
 		}
-		items = append(items, ClientSlim{
-			Email:      rec.Email,
-			SubID:      rec.SubID,
-			Enable:     rec.Enable,
-			TotalGB:    rec.TotalGB,
-			ExpiryTime: rec.ExpiryTime,
-			LimitIP:    rec.LimitIP,
-			Reset:      rec.Reset,
-			Group:      rec.Group,
-			Comment:    rec.Comment,
-			InboundIds: attachments[rec.Id],
-			Traffic:    trafficByEmail[rec.Email],
-			CreatedAt:  rec.CreatedAt,
-			UpdatedAt:  rec.UpdatedAt,
-		})
+		items = append(items, toClientSlim(ClientWithAttachments{
+			ClientRecord: *rec,
+			InboundIds:   attachments[rec.Id],
+			Traffic:      trafficByEmail[rec.Email],
+		}))
 	}
 	return items, nil
 }
@@ -604,6 +595,25 @@ func sqlInt(v int64) string {
 	return strconv.FormatInt(v, 10)
 }
 
+func toClientSlim(c ClientWithAttachments) ClientSlim {
+	return ClientSlim{
+		Email:      c.Email,
+		SubID:      c.SubID,
+		Enable:     c.Enable,
+		TotalGB:    c.TotalGB,
+		ExpiryTime: c.ExpiryTime,
+		LimitIP:    c.LimitIP,
+		LimitHwid:  c.LimitHwid,
+		Reset:      c.Reset,
+		Group:      c.Group,
+		Comment:    c.Comment,
+		InboundIds: c.InboundIds,
+		Traffic:    c.Traffic,
+		CreatedAt:  c.CreatedAt,
+		UpdatedAt:  c.UpdatedAt,
+	}
+}
+
 // escapeLikeLiteral neutralises LIKE wildcards so searching for "a_b" keeps
 // matching literally, the way strings.Contains did.
 func escapeLikeLiteral(s string) string {

+ 9 - 1
internal/web/service/client_portable.go

@@ -54,6 +54,7 @@ func (s *ClientService) ExportAll() ([]ClientCreatePayload, error) {
 		out = append(out, ClientCreatePayload{
 			Client:     *client,
 			InboundIds: attachments[rows[i].Id],
+			LimitHwid:  rows[i].LimitHwid,
 		})
 	}
 	return out, nil
@@ -151,7 +152,9 @@ func (s *ClientService) ImportClients(inboundSvc *InboundService, items []Client
 		}
 		client.UpdatedAt = now
 
-		if err := db.Create(client.ToRecord()).Error; err != nil {
+		rec := client.ToRecord()
+		rec.LimitHwid = orphans[i].LimitHwid
+		if err := db.Create(rec).Error; err != nil {
 			skip(email, err.Error())
 			continue
 		}
@@ -178,11 +181,13 @@ func (s *ClientService) DeleteOrphans() (int, error) {
 
 	ids := make([]int, 0, len(rows))
 	emails := make([]string, 0, len(rows))
+	subIDs := make([]string, 0, len(rows))
 	for i := range rows {
 		ids = append(ids, rows[i].Id)
 		if rows[i].Email != "" {
 			emails = append(emails, rows[i].Email)
 		}
+		subIDs = append(subIDs, rows[i].SubID)
 	}
 	tombstoneClientEmails(emails)
 
@@ -190,6 +195,9 @@ func (s *ClientService) DeleteOrphans() (int, error) {
 		if e := adjustGroupBaselinesForRemovedTraffic(tx, emails); e != nil {
 			return e
 		}
+		if e := clearClientHwidsBySubIDTx(tx, subIDs...); e != nil {
+			return e
+		}
 		for _, batch := range chunkInts(ids, sqlInChunk) {
 			if e := tx.Where("client_id IN ?", batch).Delete(&model.ClientInbound{}).Error; e != nil {
 				return e

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

@@ -43,7 +43,7 @@ func TestAddClientStat_RefreshesStaleRowOnInboundDeleteThenReuse(t *testing.T) {
 	if _, err := svc.Update(inboundSvc, rec0.Id, model.Client{
 		Email: email, SubID: subID, Enable: false,
 		TotalGB: 0, ExpiryTime: 1000, Reset: 0,
-	}); err != nil {
+	}, 0); err != nil {
 		t.Fatalf("Update to disabled: %v", err)
 	}
 

+ 2 - 2
internal/web/service/client_traffic.go

@@ -30,7 +30,7 @@ func (s *ClientService) ResetTrafficByEmail(inboundSvc *InboundService, email st
 	if !rec.Enable {
 		updated := rec.ToClient()
 		updated.Enable = true
-		nr, uErr := s.Update(inboundSvc, rec.Id, *updated)
+		nr, uErr := s.Update(inboundSvc, rec.Id, *updated, rec.LimitHwid)
 		if uErr != nil {
 			logger.Warning("Failed to auto-enable client during traffic reset:", uErr)
 		}
@@ -84,7 +84,7 @@ func (s *ClientService) BulkResetTraffic(inboundSvc *InboundService, emails []st
 		if err == nil && !rec.Enable {
 			updated := rec.ToClient()
 			updated.Enable = true
-			if _, uErr := s.Update(inboundSvc, rec.Id, *updated); uErr != nil {
+			if _, uErr := s.Update(inboundSvc, rec.Id, *updated, rec.LimitHwid); uErr != nil {
 				logger.Warning("Failed to auto-enable client during bulk traffic reset:", uErr)
 			}
 		}

+ 3 - 3
internal/web/service/client_update_enable_test.go

@@ -26,7 +26,7 @@ func TestUpdate_PersistsRecordEnable_True(t *testing.T) {
 	}
 	updated := rec.ToClient()
 	updated.Enable = true
-	if _, err := svc.Update(inboundSvc, rec.Id, *updated); err != nil {
+	if _, err := svc.Update(inboundSvc, rec.Id, *updated, 0); err != nil {
 		t.Fatalf("Update: %v", err)
 	}
 
@@ -60,7 +60,7 @@ func TestUpdate_PersistsRecordEnable_False(t *testing.T) {
 	}
 	updated := rec.ToClient()
 	updated.Enable = false
-	if _, err := svc.Update(inboundSvc, rec.Id, *updated); err != nil {
+	if _, err := svc.Update(inboundSvc, rec.Id, *updated, 0); err != nil {
 		t.Fatalf("Update: %v", err)
 	}
 
@@ -88,7 +88,7 @@ func TestUpdate_PersistsRecordEnable_NoInbound(t *testing.T) {
 
 	updated := rec.ToClient()
 	updated.Enable = true
-	if _, err := svc.Update(inboundSvc, rec.Id, *updated); err != nil {
+	if _, err := svc.Update(inboundSvc, rec.Id, *updated, 0); err != nil {
 		t.Fatalf("Update: %v", err)
 	}
 

+ 2 - 2
internal/web/service/client_update_no_inbound_test.go

@@ -103,7 +103,7 @@ func TestUpdate_PersistsFields_NoInbound(t *testing.T) {
 
 			updated := rec.ToClient()
 			tc.mutate(updated)
-			if _, err := svc.Update(inboundSvc, rec.Id, *updated); err != nil {
+			if _, err := svc.Update(inboundSvc, rec.Id, *updated, rec.LimitHwid); err != nil {
 				t.Fatalf("Update: %v", err)
 			}
 
@@ -142,7 +142,7 @@ func TestUpdate_NoInbound_PreservesCredentialsWhenOmitted(t *testing.T) {
 	updated.Auth = ""
 	updated.Secret = ""
 	updated.Comment = "only comment changed"
-	if _, err := svc.Update(inboundSvc, rec.Id, *updated); err != nil {
+	if _, err := svc.Update(inboundSvc, rec.Id, *updated, rec.LimitHwid); err != nil {
 		t.Fatalf("Update: %v", err)
 	}
 

+ 4 - 4
internal/web/service/client_update_rename_test.go

@@ -60,7 +60,7 @@ func TestUpdateInboundClientCaseOnlyRenameDoesNotDuplicateRecord(t *testing.T) {
 
 	updated := source[0]
 	updated.Email = "Test"
-	if _, err := svc.Update(inboundSvc, origId, updated); err != nil {
+	if _, err := svc.Update(inboundSvc, origId, updated, 0); err != nil {
 		t.Fatalf("Update case-only email: %v", err)
 	}
 
@@ -132,7 +132,7 @@ func TestClientUpdateDuplicateSubIDDoesNotRenameEmail(t *testing.T) {
 	updated := source[0]
 	updated.Email = "kept@x"
 	updated.SubID = "sub-other"
-	if _, err := svc.Update(inboundSvc, origId, updated); err == nil {
+	if _, err := svc.Update(inboundSvc, origId, updated, 0); err == nil {
 		t.Fatalf("Update with colliding subId succeeded, want error")
 	}
 
@@ -165,7 +165,7 @@ func TestClientUpdateKeepsSharedSubIDEditable(t *testing.T) {
 
 	updated := source[0]
 	updated.TotalGB = 42
-	if _, err := svc.Update(inboundSvc, first.Id, updated); err != nil {
+	if _, err := svc.Update(inboundSvc, first.Id, updated, 0); err != nil {
 		t.Fatalf("Update of a client whose subId is already shared: %v", err)
 	}
 	if got := lookupClientRecord(t, "a@node").TotalGB; got != 42 {
@@ -175,7 +175,7 @@ func TestClientUpdateKeepsSharedSubIDEditable(t *testing.T) {
 	omitted := source[0]
 	omitted.SubID = ""
 	omitted.TotalGB = 43
-	if _, err := svc.Update(inboundSvc, first.Id, omitted); err != nil {
+	if _, err := svc.Update(inboundSvc, first.Id, omitted, 0); err != nil {
 		t.Fatalf("Update with subId omitted entirely: %v", err)
 	}
 	other := lookupClientRecord(t, "b@node")

+ 156 - 0
internal/web/service/geodata.go

@@ -0,0 +1,156 @@
+package service
+
+import (
+	"errors"
+	"os"
+	"strings"
+	"sync"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/config"
+	"github.com/mhsanaei/3x-ui/v3/internal/xray/geodata"
+)
+
+// GeodataTokenIssue reports a routing token the running core would reject,
+// or would silently match nothing against.
+type GeodataTokenIssue struct {
+	Token  string `json:"token" example:"geosite:blabla"`
+	Reason string `json:"reason" example:"categoryMissing"`
+	File   string `json:"file,omitempty" example:"geosite.dat"`
+	Code   string `json:"code,omitempty" example:"blabla"`
+}
+
+const (
+	geodataReasonSyntax           = "syntax"
+	geodataReasonFileMissing      = "fileMissing"
+	geodataReasonCategoryMissing  = "categoryMissing"
+	geodataReasonAttributeMissing = "attributeMissing"
+	geodataReasonWrongKind        = "wrongKind"
+)
+
+// geodataStores keys the cache by asset directory rather than holding a single
+// store, so a changed XUI_BIN_FOLDER is picked up instead of being pinned to
+// whatever the first call saw.
+var geodataStores sync.Map
+
+func assetStore() *geodata.Store {
+	dir := assetDir()
+	if cached, ok := geodataStores.Load(dir); ok {
+		return cached.(*geodata.Store)
+	}
+	store, _ := geodataStores.LoadOrStore(dir, geodata.NewStore(dir))
+	return store.(*geodata.Store)
+}
+
+// assetDir resolves the folder the running core reads its databases from,
+// with the same precedence the core itself uses (see ensureXrayAssetLocation
+// in internal/xray). An install that points XRAY_LOCATION_ASSET at a shared
+// asset directory would otherwise have the panel browsing an empty bin folder
+// and reporting perfectly valid geosite:/geoip: tokens as missing.
+func assetDir() string {
+	for _, key := range [...]string{"XRAY_LOCATION_ASSET", "xray.location.asset"} {
+		if dir := os.Getenv(key); dir != "" {
+			return dir
+		}
+	}
+	return config.GetBinFolderPath()
+}
+
+// GeodataService browses the geosite/geoip databases Xray resolves its
+// geosite:/geoip: routing tokens against.
+type GeodataService struct{}
+
+// Files lists the databases available in the Xray asset folder.
+func (s *GeodataService) Files() ([]geodata.GeoFile, error) {
+	return assetStore().ListFiles()
+}
+
+// Categories returns one page of a database's categories.
+func (s *GeodataService) Categories(file, query string, offset, limit int) (geodata.GeoCategoryPage, error) {
+	return assetStore().Categories(file, query, offset, limit)
+}
+
+// Entries returns one page of the rules inside a category.
+func (s *GeodataService) Entries(file, code, query string, offset, limit int) (geodata.GeoEntryPage, error) {
+	return assetStore().Entries(file, code, query, offset, limit)
+}
+
+// Validate reports which of the given routing tokens do not resolve against the
+// databases on disk. Plain domains and CIDRs are left alone — only tokens that
+// name a database are looked up.
+func (s *GeodataService) Validate(isIP bool, tokens []string) []GeodataTokenIssue {
+	kind := geodata.KindSite
+	if isIP {
+		kind = geodata.KindIP
+	}
+	issues := make([]GeodataTokenIssue, 0)
+	for _, token := range tokens {
+		token = strings.TrimSpace(token)
+		if token == "" {
+			continue
+		}
+		reference, err := geodata.ParseReference(token, kind)
+		if err != nil {
+			reason := geodataReasonSyntax
+			if errors.Is(err, geodata.ErrWrongKind) {
+				reason = geodataReasonWrongKind
+			}
+			issues = append(issues, GeodataTokenIssue{Token: token, Reason: reason})
+			continue
+		}
+		if reference.File == "" {
+			continue
+		}
+		category, err := assetStore().Lookup(reference.File, reference.Code)
+		if err != nil {
+			issues = append(issues, GeodataTokenIssue{
+				Token:  token,
+				Reason: geodataIssueReason(err),
+				File:   reference.File,
+				Code:   reference.Code,
+			})
+			continue
+		}
+		if missing := unknownAttributes(category, reference.Attributes); missing != "" {
+			issues = append(issues, GeodataTokenIssue{
+				Token:  token,
+				Reason: geodataReasonAttributeMissing,
+				File:   reference.File,
+				Code:   missing,
+			})
+		}
+	}
+	return issues
+}
+
+// unknownAttributes returns the first attribute the category does not carry.
+// The core accepts such a token, but no domain can satisfy the filter, so the
+// rule silently matches nothing — worth reporting even though Xray will start.
+// A leading "!" is accepted either way: some databases ship the negated key
+// verbatim, and the panel must not guess which convention a database follows.
+func unknownAttributes(category geodata.GeoCategory, wanted []string) string {
+	if len(wanted) == 0 {
+		return ""
+	}
+	present := make(map[string]struct{}, len(category.Attributes))
+	for _, attribute := range category.Attributes {
+		present[attribute] = struct{}{}
+		present[strings.TrimPrefix(attribute, "!")] = struct{}{}
+	}
+	for _, attribute := range wanted {
+		if _, ok := present[attribute]; ok {
+			continue
+		}
+		if _, ok := present[strings.TrimPrefix(attribute, "!")]; ok {
+			continue
+		}
+		return attribute
+	}
+	return ""
+}
+
+func geodataIssueReason(err error) string {
+	if errors.Is(err, geodata.ErrUnknownCategory) {
+		return geodataReasonCategoryMissing
+	}
+	return geodataReasonFileMissing
+}

+ 99 - 14
internal/web/service/inbound_node.go

@@ -1115,17 +1115,21 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
 			// panelGuid. Remap just that entry to the node-unique key so the
 			// clones don't merge; descendant subtrees keep their distinct GUIDs.
 			if _, ok := tree[nodeRow.Guid]; ok {
-				remapped := make(map[string][]string, len(tree))
-				for g, emails := range tree {
-					if g == nodeRow.Guid {
-						g = selfKey
-					}
-					remapped[g] = emails
-				}
-				tree = remapped
+				tree = remapGuidTreeKey(tree, nodeRow.Guid, selfKey)
 			}
 		}
 		process.SetNodeOnlineTree(nodeID, tree)
+
+		activeTree := normalizeActiveInboundTreeTags(snap.ActiveInboundTree, tagToCentral)
+		if guidShared && len(activeTree) > 0 {
+			if _, ok := activeTree[nodeRow.Guid]; ok {
+				activeTree = remapGuidTreeKey(activeTree, nodeRow.Guid, selfKey)
+			}
+		}
+		if len(activeTree) > 0 {
+			activeTree = filterGuidTreeKeys(activeTree, activeInboundGuidKeys(snap.Inbounds, tagToCentral, originGuidFor))
+		}
+		process.SetNodeActiveInboundTree(nodeID, activeTree)
 	}
 
 	return structuralChange, nil
@@ -1181,23 +1185,25 @@ func (s *InboundService) GetOnlineClientsByGuid() map[string][]string {
 }
 
 // GetActiveInboundsByGuid returns the inbound tags that carried traffic within
-// the grace window for THIS panel, under its own GUID. Remote nodes don't
-// report per-inbound activity, so a GUID missing from the map means "don't
-// gate" for that node's inbounds.
+// the grace window, keyed by the panelGuid of the node that physically hosts
+// each inbound. A GUID missing from the map means "don't gate" for that node's
+// inbounds (old-build node or no active-inbound signal).
 func (s *InboundService) GetActiveInboundsByGuid() map[string][]string {
 	process := currentXrayProcess()
 	if process == nil {
 		return map[string][]string{}
 	}
+	out := process.GetMergedActiveInboundTrees()
 	active := process.GetLocalActiveInbounds()
 	if len(active) == 0 {
-		return map[string][]string{}
+		return out
 	}
 	guid := s.panelGuid()
 	if guid == "" {
-		return map[string][]string{}
+		return out
 	}
-	return map[string][]string{guid: active}
+	out[guid] = mergeEmails(out[guid], active)
+	return out
 }
 
 func (s *InboundService) SetNodeOnlineTree(nodeID int, tree map[string][]string) {
@@ -1249,6 +1255,85 @@ func mergeEmails(a, b []string) []string {
 	return out
 }
 
+func remapGuidTreeKey(tree map[string][]string, from, to string) map[string][]string {
+	if from == "" || to == "" || from == to {
+		return tree
+	}
+	remapped := make(map[string][]string, len(tree))
+	for guid, values := range tree {
+		if guid == from {
+			guid = to
+		}
+		remapped[guid] = mergeEmails(remapped[guid], values)
+	}
+	return remapped
+}
+
+func normalizeActiveInboundTreeTags(tree map[string][]string, tagToCentral map[string]*model.Inbound) map[string][]string {
+	if len(tree) == 0 {
+		return nil
+	}
+	out := make(map[string][]string, len(tree))
+	for guid, tags := range tree {
+		if guid == "" || len(tags) == 0 {
+			continue
+		}
+		seen := make(map[string]struct{}, len(tags))
+		for _, tag := range tags {
+			if tag == "" {
+				continue
+			}
+			if central, ok := tagToCentral[tag]; ok && central != nil && central.Tag != "" {
+				tag = central.Tag
+			}
+			if _, dup := seen[tag]; dup {
+				continue
+			}
+			seen[tag] = struct{}{}
+			out[guid] = append(out[guid], tag)
+		}
+	}
+	if len(out) == 0 {
+		return nil
+	}
+	return out
+}
+
+func activeInboundGuidKeys(inbounds []*model.Inbound, tagToCentral map[string]*model.Inbound, originGuidFor func(*model.Inbound) string) map[string]struct{} {
+	allowed := make(map[string]struct{})
+	for _, ib := range inbounds {
+		if ib == nil {
+			continue
+		}
+		if _, ok := tagToCentral[ib.Tag]; !ok {
+			continue
+		}
+		if guid := originGuidFor(ib); guid != "" {
+			allowed[guid] = struct{}{}
+		}
+	}
+	return allowed
+}
+
+func filterGuidTreeKeys(tree map[string][]string, allowed map[string]struct{}) map[string][]string {
+	if len(tree) == 0 || len(allowed) == 0 {
+		return nil
+	}
+	out := make(map[string][]string, len(tree))
+	for guid, values := range tree {
+		if _, ok := allowed[guid]; !ok {
+			continue
+		}
+		if len(values) > 0 {
+			out[guid] = values
+		}
+	}
+	if len(out) == 0 {
+		return nil
+	}
+	return out
+}
+
 func (s *InboundService) GetClientsLastOnline() (map[string]int64, error) {
 	db := database.GetDB()
 	var rows []xray.ClientTraffic

+ 118 - 8
internal/web/service/node.go

@@ -17,6 +17,7 @@ import (
 	"sync"
 	"time"
 
+	"github.com/mhsanaei/3x-ui/v3/internal/crypto/nodetoken"
 	"github.com/mhsanaei/3x-ui/v3/internal/database"
 	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
 	"github.com/mhsanaei/3x-ui/v3/internal/logger"
@@ -100,6 +101,23 @@ func (s *NodeService) FetchCertFingerprint(ctx context.Context, n *model.Node) (
 	return base64.StdEncoding.EncodeToString(sum[:]), nil
 }
 
+// decryptToken exposes plaintext to callers. Failures blank only this token
+// and surface through LastError instead of dropping the node row.
+func decryptToken(n *model.Node) {
+	if n == nil || n.ApiToken == "" {
+		return
+	}
+	pt, err := nodetoken.Decrypt(n.Id, n.ApiToken)
+	if err != nil {
+		n.ApiToken = ""
+		if n.LastError == "" {
+			n.LastError = "token decrypt failed: " + err.Error()
+		}
+		return
+	}
+	n.ApiToken = pt
+}
+
 func (s *NodeService) GetAll() ([]*model.Node, error) {
 	db := database.GetDB()
 	var nodes []*model.Node
@@ -107,6 +125,9 @@ func (s *NodeService) GetAll() ([]*model.Node, error) {
 	if err != nil || len(nodes) == 0 {
 		return nodes, err
 	}
+	for _, n := range nodes {
+		decryptToken(n)
+	}
 
 	type inboundRow struct {
 		Id     int
@@ -333,6 +354,7 @@ func (s *NodeService) GetById(id int) (*model.Node, error) {
 	if err := db.Model(model.Node{}).Where("id = ?", id).First(n).Error; err != nil {
 		return nil, err
 	}
+	decryptToken(n)
 	return n, nil
 }
 
@@ -429,7 +451,29 @@ func (s *NodeService) Create(n *model.Node) error {
 		return err
 	}
 	db := database.GetDB()
-	return db.Create(n).Error
+	if !nodetoken.Enabled() {
+		return db.Create(n).Error
+	}
+	plaintext := n.ApiToken
+	return db.Transaction(func(tx *gorm.DB) error {
+		// The id-bound ciphertext can only be produced after insertion. Never put
+		// plaintext in the initial tuple: PostgreSQL WAL would retain it.
+		n.ApiToken = ""
+		defer func() { n.ApiToken = plaintext }()
+		if err := tx.Create(n).Error; err != nil {
+			return err
+		}
+		enc, err := nodetoken.Encrypt(n.Id, plaintext)
+		if err != nil {
+			return err
+		}
+		if enc == plaintext {
+			return nil // off-mode / empty token: nothing to rewrite
+		}
+		// DB column gets ciphertext; the in-memory struct keeps plaintext so the
+		// create response echoes the same usable value GetById would return.
+		return tx.Model(model.Node{}).Where("id = ?", n.Id).Update("api_token", enc).Error
+	})
 }
 
 func (s *NodeService) CreateFromRequest(req *NodeMutationRequest) (*NodeView, error) {
@@ -456,6 +500,15 @@ func (s *NodeService) Update(id int, in *model.Node) error {
 	if err := db.Where("id = ?", id).First(existing).Error; err != nil {
 		return err
 	}
+	// Blank means keep the hidden stored token; non-blank values are encrypted.
+	apiToken := existing.ApiToken
+	if in.ApiToken != "" {
+		enc, eerr := nodetoken.Encrypt(id, in.ApiToken)
+		if eerr != nil {
+			return eerr
+		}
+		apiToken = enc
+	}
 	updates := map[string]any{
 		"name":                  in.Name,
 		"remark":                in.Remark,
@@ -463,7 +516,7 @@ func (s *NodeService) Update(id int, in *model.Node) error {
 		"address":               in.Address,
 		"port":                  in.Port,
 		"base_path":             in.BasePath,
-		"api_token":             in.ApiToken,
+		"api_token":             apiToken,
 		"enable":                in.Enable,
 		"allow_private_address": in.AllowPrivateAddress,
 		"tls_verify_mode":       in.TlsVerifyMode,
@@ -508,7 +561,10 @@ func (s *NodeService) UpdateFromRequest(id int, req *NodeMutationRequest) error
 	case req.ClearApiToken:
 		apiToken = ""
 	case req.ApiToken != nil:
-		apiToken = *req.ApiToken
+		apiToken, err = nodetoken.Encrypt(id, *req.ApiToken)
+		if err != nil {
+			return err
+		}
 	}
 	if apiToken == "" && in.Enable && in.TlsVerifyMode != "mtls" {
 		return common.NewError("apiToken is required unless mtls is enabled")
@@ -529,12 +585,14 @@ func (s *NodeService) UpdateFromRequest(id int, req *NodeMutationRequest) error
 		"inbound_tags":          string(inboundTagsJSON),
 		"outbound_tag":          in.OutboundTag,
 	}
-	if err := db.Model(model.Node{}).Where("id = ?", id).Updates(updates).Error; err != nil {
+	if err := db.Transaction(func(tx *gorm.DB) error {
+		if err := tx.Model(model.Node{}).Where("id = ?", id).Updates(updates).Error; err != nil {
+			return err
+		}
+		return s.MarkNodeDirtyTx(tx, id)
+	}); err != nil {
 		return err
 	}
-	if dErr := s.MarkNodeDirty(id); dErr != nil {
-		logger.Warning("mark node dirty after update failed:", dErr)
-	}
 	if mgr := runtime.GetManager(); mgr != nil {
 		mgr.InvalidateNode(id)
 	}
@@ -590,6 +648,53 @@ func (s *NodeService) NodeFromRequestForCertificate(req *NodeMutationRequest) (*
 	return n, nil
 }
 
+// MigrateNodeTokensToActiveKey uses compare-and-swap to avoid clobbering live
+// changes. Current-key rows are skipped; changed and skipped counts are returned.
+func (s *NodeService) MigrateNodeTokensToActiveKey() (int, int, error) {
+	codec := nodetoken.Active()
+	if !codec.Enabled() {
+		return 0, 0, errors.New("node-token encryption is off; set NODE_TOKEN_ENCRYPTION=migration|required and a key first")
+	}
+	db := database.GetDB()
+	var nodes []*model.Node
+	if err := db.Model(model.Node{}).Order("id asc").Find(&nodes).Error; err != nil {
+		return 0, 0, err
+	}
+	changed, skipped := 0, 0
+	for _, n := range nodes {
+		old := n.ApiToken
+		if old == "" {
+			skipped++
+			continue
+		}
+		if codec.EncryptedWithActive(old) {
+			if _, err := codec.Decrypt(n.Id, old); err != nil {
+				return changed, skipped, fmt.Errorf("node %d validate active ciphertext: %w", n.Id, err)
+			}
+			skipped++
+			continue
+		}
+		plain, err := codec.Decrypt(n.Id, old) // plaintext passes through; old-key ciphertext is decrypted
+		if err != nil {
+			return changed, skipped, fmt.Errorf("node %d decrypt: %w", n.Id, err)
+		}
+		enc, err := codec.Encrypt(n.Id, plain)
+		if err != nil {
+			return changed, skipped, fmt.Errorf("node %d encrypt: %w", n.Id, err)
+		}
+		res := db.Model(model.Node{}).Where("id = ? AND api_token = ?", n.Id, old).Update("api_token", enc)
+		if res.Error != nil {
+			return changed, skipped, res.Error
+		}
+		if res.RowsAffected == 1 {
+			changed++
+		} else {
+			skipped++ // raced with a live update; a later run handles it
+		}
+	}
+	return changed, skipped, nil
+}
+
 func (s *NodeService) GetRemoteInboundOptions(ctx context.Context, n *model.Node) ([]runtime.RemoteInboundOption, error) {
 	if err := s.normalize(n); err != nil {
 		return nil, err
@@ -1128,7 +1233,12 @@ func (s *NodeService) probe(ctx context.Context, n *model.Node, proxyURL string)
 		return patch, err
 	}
 	if n.ApiToken != "" {
-		req.Header.Set("Authorization", "Bearer "+n.ApiToken)
+		token, derr := nodetoken.Decrypt(n.Id, n.ApiToken)
+		if derr != nil {
+			patch.LastError = derr.Error()
+			return patch, derr
+		}
+		req.Header.Set("Authorization", "Bearer "+token)
 	}
 	req.Header.Set("Accept", "application/json")
 

+ 18 - 0
internal/web/service/node_mtls.go

@@ -1,11 +1,13 @@
 package service
 
 import (
+	"crypto/tls"
 	"crypto/x509"
 	"encoding/pem"
 	"strings"
 
 	"github.com/mhsanaei/3x-ui/v3/internal/util/common"
+	"github.com/mhsanaei/3x-ui/v3/internal/web/runtime"
 )
 
 // NodeMtlsCaCert returns the PEM of this panel's node-auth CA certificate (the
@@ -24,6 +26,22 @@ func (s *NodeService) NodeMtlsCaCert() (string, error) {
 	return string(ca.CertPEM), nil
 }
 
+// ReloadMasterMtlsClient validates the master credential currently stored by
+// the panel and drops cached mTLS connection pools. This makes an intentional
+// out-of-process credential rotation take effect without restarting x-ui (and
+// therefore without stopping the xray child process in the same service).
+func (s *NodeService) ReloadMasterMtlsClient() error {
+	stored, err := (&SettingService{}).LoadMasterClientCert()
+	if err != nil {
+		return err
+	}
+	if _, err := tls.X509KeyPair(stored.CertPEM, stored.KeyPEM); err != nil {
+		return err
+	}
+	runtime.InvalidateMasterClientConnections()
+	return nil
+}
+
 // SetNodeMtlsTrustCA stores the CA certificate this panel trusts for incoming
 // node-API client certificates. An empty value clears it (mTLS off). A
 // non-empty value must be a PEM certificate (fail closed). Takes effect on the

+ 26 - 0
internal/web/service/node_mtls_test.go

@@ -1,15 +1,41 @@
 package service
 
 import (
+	"crypto/tls"
 	"crypto/x509"
 	"encoding/pem"
 	"testing"
 
 	"github.com/go-playground/validator/v10"
 
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
 	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/web/runtime"
 )
 
+func TestReloadMasterMtlsClientDoesNotMintMissingCredential(t *testing.T) {
+	_ = setupSettingMtlsDB(t)
+	runtime.SetMasterClientCertProvider(func() (tls.Certificate, error) {
+		pair, err := (&SettingService{}).EnsureMasterClientCert()
+		if err != nil {
+			return tls.Certificate{}, err
+		}
+		return tls.X509KeyPair(pair.CertPEM, pair.KeyPEM)
+	})
+	t.Cleanup(func() { runtime.SetMasterClientCertProvider(nil) })
+	if err := (&NodeService{}).ReloadMasterMtlsClient(); err == nil {
+		t.Fatal("reload on a fresh database unexpectedly succeeded")
+	}
+	var count int64
+	keys := []string{settingNodeMtlsCaCert, settingNodeMtlsCaKey, settingNodeMtlsClientCert, settingNodeMtlsClientKey}
+	if err := database.GetDB().Model(&model.Setting{}).Where("key IN ?", keys).Count(&count).Error; err != nil {
+		t.Fatalf("count mTLS settings: %v", err)
+	}
+	if count != 0 {
+		t.Fatalf("reload created %d mTLS setting rows, want 0", count)
+	}
+}
+
 func TestNormalizeKeepsMtls(t *testing.T) {
 	s := &NodeService{}
 	cases := []struct {

+ 181 - 0
internal/web/service/node_origin_guid_test.go

@@ -1,13 +1,26 @@
 package service
 
 import (
+	"slices"
 	"testing"
 
 	"github.com/mhsanaei/3x-ui/v3/internal/database"
 	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
 	"github.com/mhsanaei/3x-ui/v3/internal/web/runtime"
+	"github.com/mhsanaei/3x-ui/v3/internal/xray"
 )
 
+func assertStringSet(t *testing.T, label string, got, want []string) {
+	t.Helper()
+	g := append([]string(nil), got...)
+	w := append([]string(nil), want...)
+	slices.Sort(g)
+	slices.Sort(w)
+	if !slices.Equal(g, w) {
+		t.Fatalf("%s = %v, want %v", label, got, want)
+	}
+}
+
 // #4983: a synced inbound's OriginNodeGuid must point at the panel that
 // physically hosts it. A node's own local inbound (empty origin in its
 // snapshot) is attributed to the node's own GUID; an inbound the node forwards
@@ -131,6 +144,174 @@ func TestSetRemoteTraffic_RemapsClonedNodeOwnGuidOrigin(t *testing.T) {
 	}
 }
 
+func TestSetRemoteTraffic_RemapsActiveInboundTreeAndCentralTags(t *testing.T) {
+	setupConflictDB(t)
+	db := database.GetDB()
+
+	previousProcess, previousResult := xrayState.snapshot()
+	process := xray.NewTestProcess(nil, "")
+	xrayState.replace(process)
+	t.Cleanup(func() {
+		xrayState.mu.Lock()
+		xrayState.process = previousProcess
+		xrayState.result = previousResult
+		xrayState.mu.Unlock()
+	})
+
+	// Force the remote inbound to be adopted with an n1- prefix on the master:
+	// the active-inbound tree still arrives with the node-local tag.
+	if err := db.Create(&model.Inbound{
+		Tag: "shared-tag", Enable: true, Port: 1000, Protocol: model.VLESS, Settings: `{"clients":[]}`,
+	}).Error; err != nil {
+		t.Fatalf("create local conflicting inbound: %v", err)
+	}
+
+	// Two cloned nodes share the same panelGuid, so the node's own active tags
+	// must be keyed by node:1 instead of the duplicated GUID.
+	for _, n := range []*model.Node{
+		{Id: 1, Name: "a", Address: "10.0.0.1", Port: 2053, ApiToken: "t", Guid: "dup"},
+		{Id: 2, Name: "b", Address: "10.0.0.2", Port: 2053, ApiToken: "t", Guid: "dup"},
+	} {
+		if err := db.Create(n).Error; err != nil {
+			t.Fatalf("create node %s: %v", n.Name, err)
+		}
+	}
+
+	snap := &runtime.TrafficSnapshot{
+		Inbounds: []*model.Inbound{{
+			Tag:      "shared-tag",
+			Enable:   true,
+			Port:     8443,
+			Protocol: model.VLESS,
+			Settings: `{"clients":[]}`,
+		}},
+		ActiveInboundTree: map[string][]string{
+			"dup": {"shared-tag"},
+		},
+	}
+
+	svc := InboundService{}
+	if _, err := svc.setRemoteTrafficLocked(1, snap, false); err != nil {
+		t.Fatalf("setRemoteTrafficLocked: %v", err)
+	}
+
+	merged := process.GetMergedActiveInboundTrees()
+	assertStringSet(t, "active node:1", merged["node:1"], []string{"n1-shared-tag"})
+	if _, ok := merged["dup"]; ok {
+		t.Fatalf("cloned active-inbound subtree must not stay under shared GUID: %v", merged)
+	}
+}
+
+func TestSetRemoteTraffic_NormalizesForwardedActiveInboundSubtreeTags(t *testing.T) {
+	setupConflictDB(t)
+	db := database.GetDB()
+
+	previousProcess, previousResult := xrayState.snapshot()
+	process := xray.NewTestProcess(nil, "")
+	xrayState.replace(process)
+	t.Cleanup(func() {
+		xrayState.mu.Lock()
+		xrayState.process = previousProcess
+		xrayState.result = previousResult
+		xrayState.mu.Unlock()
+	})
+
+	for _, tag := range []string{"own-tag", "child-tag"} {
+		if err := db.Create(&model.Inbound{
+			Tag: tag, Enable: true, Port: 1000, Protocol: model.VLESS, Settings: `{"clients":[]}`,
+		}).Error; err != nil {
+			t.Fatalf("create local conflicting inbound %q: %v", tag, err)
+		}
+	}
+	if err := db.Create(&model.Node{
+		Id: 1, Name: "node2", Address: "10.0.0.2", Port: 2053, ApiToken: "t", Guid: "node2-guid",
+	}).Error; err != nil {
+		t.Fatalf("create node: %v", err)
+	}
+
+	snap := &runtime.TrafficSnapshot{
+		Inbounds: []*model.Inbound{
+			{
+				Tag:      "own-tag",
+				Enable:   true,
+				Port:     8443,
+				Protocol: model.VLESS,
+				Settings: `{"clients":[]}`,
+			},
+			{
+				Tag:            "child-tag",
+				Enable:         true,
+				Port:           9443,
+				Protocol:       model.VLESS,
+				Settings:       `{"clients":[]}`,
+				OriginNodeGuid: "child-guid",
+			},
+		},
+		ActiveInboundTree: map[string][]string{
+			"node2-guid": {"own-tag"},
+			"child-guid": {"child-tag"},
+		},
+	}
+
+	svc := InboundService{}
+	if _, err := svc.setRemoteTrafficLocked(1, snap, false); err != nil {
+		t.Fatalf("setRemoteTrafficLocked: %v", err)
+	}
+
+	merged := process.GetMergedActiveInboundTrees()
+	assertStringSet(t, "direct node active tags", merged["node2-guid"], []string{"n1-own-tag"})
+	assertStringSet(t, "forwarded child active tags", merged["child-guid"], []string{"n1-child-tag"})
+}
+
+func TestSetRemoteTraffic_DropsForeignActiveInboundGuid(t *testing.T) {
+	setupConflictDB(t)
+	db := database.GetDB()
+
+	previousProcess, previousResult := xrayState.snapshot()
+	process := xray.NewTestProcess(nil, "")
+	xrayState.replace(process)
+	t.Cleanup(func() {
+		xrayState.mu.Lock()
+		xrayState.process = previousProcess
+		xrayState.result = previousResult
+		xrayState.mu.Unlock()
+	})
+
+	for _, n := range []*model.Node{
+		{Id: 1, Name: "node-a", Address: "10.0.0.1", Port: 2053, ApiToken: "t", Guid: "node-a-guid"},
+		{Id: 2, Name: "node-b", Address: "10.0.0.2", Port: 2053, ApiToken: "t", Guid: "node-b-guid"},
+	} {
+		if err := db.Create(n).Error; err != nil {
+			t.Fatalf("create node %s: %v", n.Name, err)
+		}
+	}
+
+	snap := &runtime.TrafficSnapshot{
+		Inbounds: []*model.Inbound{{
+			Tag:      "own-tag",
+			Enable:   true,
+			Port:     8443,
+			Protocol: model.VLESS,
+			Settings: `{"clients":[]}`,
+		}},
+		ActiveInboundTree: map[string][]string{
+			"node-a-guid": {"own-tag"},
+			"node-b-guid": {"foreign-tag"},
+		},
+	}
+
+	svc := InboundService{}
+	if _, err := svc.setRemoteTrafficLocked(1, snap, false); err != nil {
+		t.Fatalf("setRemoteTrafficLocked: %v", err)
+	}
+
+	merged := process.GetMergedActiveInboundTrees()
+	assertStringSet(t, "own active tags", merged["node-a-guid"], []string{"own-tag"})
+	if _, ok := merged["node-b-guid"]; ok {
+		t.Fatalf("foreign active-inbound subtree should be ignored: %v", merged)
+	}
+}
+
 // A node mid-restart can return an empty inbound list with success=true. The
 // sync must NOT treat that as "delete all my inbounds" — otherwise a blip wipes
 // the node's central inbounds and every client on them (what happened to the

+ 180 - 0
internal/web/service/node_token_encryption_test.go

@@ -0,0 +1,180 @@
+package service
+
+import (
+	"errors"
+	"strings"
+	"testing"
+
+	"gorm.io/gorm"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/crypto/nodetoken"
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+)
+
+// enableNodeTokenEncryption installs a test keyring and restores off mode so
+// the package-global codec cannot leak between tests.
+func enableNodeTokenEncryption(t *testing.T) {
+	t.Helper()
+	var k [32]byte
+	for i := range k {
+		k[i] = byte(i + 1)
+	}
+	ring := &nodetoken.Keyring{ActiveID: "t1", Keys: map[string][32]byte{"t1": k}}
+	codec, err := nodetoken.NewCodec(nodetoken.ModeRequired, ring)
+	if err != nil {
+		t.Fatalf("new codec: %v", err)
+	}
+	nodetoken.Init(codec)
+	t.Cleanup(func() {
+		off, _ := nodetoken.NewCodec(nodetoken.ModeOff, nil)
+		nodetoken.Init(off)
+	})
+}
+
+func TestNodeToken_CreateNeverInsertsPlaintextTuple(t *testing.T) {
+	setupConflictDB(t)
+	enableNodeTokenEncryption(t)
+	db := database.GetDB()
+	const callback = "test:no-plaintext-node-insert"
+	if err := db.Callback().Create().Before("gorm:create").Register(callback, func(tx *gorm.DB) {
+		if node, ok := tx.Statement.Dest.(*model.Node); ok && node.ApiToken != "" {
+			tx.AddError(errors.New("plaintext token reached node INSERT"))
+		}
+	}); err != nil {
+		t.Fatalf("register callback: %v", err)
+	}
+	t.Cleanup(func() { _ = db.Callback().Create().Remove(callback) })
+
+	n := &model.Node{Name: "no-plain", Address: "127.0.0.1", Port: 2096, ApiToken: "secret", Enable: true}
+	if err := (&NodeService{}).Create(n); err != nil {
+		t.Fatalf("Create: %v", err)
+	}
+	if n.ApiToken != "secret" {
+		t.Fatalf("in-memory token = %q, want plaintext response value", n.ApiToken)
+	}
+}
+
+func rawStoredToken(t *testing.T, id int) string {
+	t.Helper()
+	var n model.Node
+	if err := database.GetDB().Model(model.Node{}).Where("id = ?", id).First(&n).Error; err != nil {
+		t.Fatalf("raw load: %v", err)
+	}
+	return n.ApiToken
+}
+
+// Create stores the token encrypted at rest; GetById returns it decrypted.
+func TestNodeToken_EncryptedAtRest_PlaintextInMemory(t *testing.T) {
+	setupConflictDB(t)
+	enableNodeTokenEncryption(t)
+	svc := &NodeService{}
+
+	n := &model.Node{Name: "enc1", Address: "127.0.0.1", Port: 2096, ApiToken: "super-secret", Enable: true}
+	if err := svc.Create(n); err != nil {
+		t.Fatalf("create: %v", err)
+	}
+
+	stored := rawStoredToken(t, n.Id)
+	if !nodetoken.IsEncrypted(stored) {
+		t.Fatalf("token at rest is not encrypted: %q", stored)
+	}
+	if strings.Contains(stored, "super-secret") {
+		t.Fatalf("plaintext leaked into stored column: %q", stored)
+	}
+
+	got, err := svc.GetById(n.Id)
+	if err != nil {
+		t.Fatalf("get: %v", err)
+	}
+	if got.ApiToken != "super-secret" {
+		t.Fatalf("GetById should return plaintext, got %q", got.ApiToken)
+	}
+}
+
+// A blank token on Update keeps the stored one (the UI doesn't echo secrets).
+func TestNodeToken_UpdateBlankKeepsExisting(t *testing.T) {
+	setupConflictDB(t)
+	enableNodeTokenEncryption(t)
+	svc := &NodeService{}
+
+	n := &model.Node{Name: "enc2", Address: "127.0.0.1", Port: 2096, ApiToken: "keep-me", Enable: true}
+	if err := svc.Create(n); err != nil {
+		t.Fatalf("create: %v", err)
+	}
+	before := rawStoredToken(t, n.Id)
+
+	// Update with empty token must not wipe or change the stored ciphertext.
+	upd := &model.Node{Name: "enc2-renamed", Address: "127.0.0.1", Port: 2096, ApiToken: "", Enable: true}
+	if err := svc.Update(n.Id, upd); err != nil {
+		t.Fatalf("update: %v", err)
+	}
+	if after := rawStoredToken(t, n.Id); after != before {
+		t.Fatalf("blank-token update changed stored token: %q -> %q", before, after)
+	}
+	got, _ := svc.GetById(n.Id)
+	if got.ApiToken != "keep-me" {
+		t.Fatalf("token lost after blank update, got %q", got.ApiToken)
+	}
+	if got.Name != "enc2-renamed" {
+		t.Fatalf("other fields should still update, got name %q", got.Name)
+	}
+}
+
+// The migration re-encrypts a legacy plaintext row under the active key (CAS).
+func TestNodeToken_MigratePlaintextRows(t *testing.T) {
+	setupConflictDB(t)
+	// Insert a legacy plaintext row directly (encryption off at insert time).
+	db := database.GetDB()
+	legacy := &model.Node{Name: "legacy", Address: "127.0.0.1", Port: 2096, ApiToken: "legacy-plain", Enable: true}
+	if err := db.Create(legacy).Error; err != nil {
+		t.Fatalf("create legacy: %v", err)
+	}
+	if rawStoredToken(t, legacy.Id) != "legacy-plain" {
+		t.Fatal("precondition: legacy row should be plaintext")
+	}
+
+	enableNodeTokenEncryption(t)
+	changed, _, err := (&NodeService{}).MigrateNodeTokensToActiveKey()
+	if err != nil {
+		t.Fatalf("migrate: %v", err)
+	}
+	if changed != 1 {
+		t.Fatalf("expected 1 row re-encrypted, got %d", changed)
+	}
+	if stored := rawStoredToken(t, legacy.Id); !nodetoken.IsEncrypted(stored) {
+		t.Fatalf("legacy row not encrypted after migration: %q", stored)
+	}
+	got, _ := (&NodeService{}).GetById(legacy.Id)
+	if got.ApiToken != "legacy-plain" {
+		t.Fatalf("migrated token no longer decrypts to original: %q", got.ApiToken)
+	}
+
+	// Idempotent: a second run changes nothing.
+	changed2, _, _ := (&NodeService{}).MigrateNodeTokensToActiveKey()
+	if changed2 != 0 {
+		t.Fatalf("second migration should be a no-op, changed %d", changed2)
+	}
+}
+
+func TestNodeToken_MigrationRejectsCorruptActiveCiphertext(t *testing.T) {
+	setupConflictDB(t)
+	enableNodeTokenEncryption(t)
+	n := &model.Node{Name: "corrupt", Address: "127.0.0.1", Port: 2096, ApiToken: "secret", Enable: true}
+	if err := (&NodeService{}).Create(n); err != nil {
+		t.Fatalf("Create: %v", err)
+	}
+	stored := rawStoredToken(t, n.Id)
+	body := strings.LastIndexByte(stored, ':') + 1
+	replacement := byte('A')
+	if stored[body] == replacement {
+		replacement = 'B'
+	}
+	corrupt := stored[:body] + string(replacement) + stored[body+1:]
+	if err := database.GetDB().Model(&model.Node{}).Where("id = ?", n.Id).Update("api_token", corrupt).Error; err != nil {
+		t.Fatalf("corrupt row: %v", err)
+	}
+	if _, _, err := (&NodeService{}).MigrateNodeTokensToActiveKey(); err == nil {
+		t.Fatal("migration trusted a corrupt active-key ciphertext")
+	}
+}

+ 116 - 14
internal/web/service/panel/api_token.go

@@ -4,6 +4,7 @@ import (
 	"crypto/subtle"
 	"errors"
 	"strings"
+	"time"
 
 	"gorm.io/gorm"
 
@@ -24,6 +25,8 @@ type ApiTokenView struct {
 	Token     string `json:"token,omitempty" example:"new-token-string"`
 	Enabled   bool   `json:"enabled" example:"true"`
 	CreatedAt int64  `json:"createdAt" example:"1736000000"`
+	Scope     string `json:"scope" example:"admin"`
+	ExpiresAt int64  `json:"expiresAt" example:"0"`
 }
 
 func apiTokenCreatedAtSeconds(createdAt int64) int64 {
@@ -42,6 +45,23 @@ func toView(t *model.ApiToken) *ApiTokenView {
 		Name:      t.Name,
 		Enabled:   t.Enabled,
 		CreatedAt: apiTokenCreatedAtSeconds(t.CreatedAt),
+		Scope:     t.Scope,
+		ExpiresAt: t.ExpiresAt,
+	}
+}
+
+// NormalizeScope validates a requested scope, defaulting empty to admin so
+// callers that omit it keep the legacy full-access behavior.
+func NormalizeScope(scope string) (string, error) {
+	switch strings.ToLower(strings.TrimSpace(scope)) {
+	case "", model.ApiScopeAdmin:
+		return model.ApiScopeAdmin, nil
+	case model.ApiScopeMonitor:
+		return model.ApiScopeMonitor, nil
+	case model.ApiScopeNodeSync:
+		return model.ApiScopeNodeSync, nil
+	default:
+		return "", common.NewError("scope must be 'admin', 'monitor', or 'node-sync'")
 	}
 }
 
@@ -58,7 +78,7 @@ func (s *ApiTokenService) List() ([]*ApiTokenView, error) {
 	return out, nil
 }
 
-func (s *ApiTokenService) Create(name string) (*ApiTokenView, error) {
+func (s *ApiTokenService) Create(name, scope string, expiresAt int64) (*ApiTokenView, error) {
 	name = strings.TrimSpace(name)
 	if name == "" {
 		return nil, common.NewError("token name is required")
@@ -66,6 +86,13 @@ func (s *ApiTokenService) Create(name string) (*ApiTokenView, error) {
 	if len(name) > 64 {
 		return nil, common.NewError("token name must be 64 characters or fewer")
 	}
+	normScope, err := NormalizeScope(scope)
+	if err != nil {
+		return nil, err
+	}
+	if expiresAt < 0 || (expiresAt != 0 && expiresAt <= nowMilli()) {
+		return nil, common.NewError("expiresAt must be 0 (never) or a future unix-ms timestamp")
+	}
 	db := database.GetDB()
 	var count int64
 	if err := db.Model(model.ApiToken{}).Where("name = ?", name).Count(&count).Error; err != nil {
@@ -76,9 +103,11 @@ func (s *ApiTokenService) Create(name string) (*ApiTokenView, error) {
 	}
 	plaintext := random.Seq(apiTokenLength)
 	row := &model.ApiToken{
-		Name:    name,
-		Token:   crypto.HashTokenSHA256(plaintext),
-		Enabled: true,
+		Name:      name,
+		Token:     crypto.HashTokenSHA256(plaintext),
+		Enabled:   true,
+		Scope:     normScope,
+		ExpiresAt: expiresAt,
 	}
 	if err := db.Create(row).Error; err != nil {
 		return nil, err
@@ -118,6 +147,24 @@ func (s *ApiTokenService) Delete(id int) error {
 	return db.Where("id = ?", id).Delete(model.ApiToken{}).Error
 }
 
+func (s *ApiTokenService) DeleteExpectedScope(id int, expectedScope string) error {
+	if id <= 0 {
+		return common.NewError("invalid token id")
+	}
+	scope, err := requireExpectedScope(expectedScope)
+	if err != nil {
+		return err
+	}
+	res := database.GetDB().Where("id = ? AND scope = ?", id, scope).Delete(model.ApiToken{})
+	if res.Error != nil {
+		return res.Error
+	}
+	if res.RowsAffected == 0 {
+		return errors.New("token not found with expected scope")
+	}
+	return nil
+}
+
 func (s *ApiTokenService) SetEnabled(id int, enabled bool) error {
 	if id <= 0 {
 		return common.NewError("invalid token id")
@@ -133,25 +180,80 @@ func (s *ApiTokenService) SetEnabled(id int, enabled bool) error {
 	return nil
 }
 
-// Match returns true when the presented bearer token matches any enabled
-// row in api_tokens. Tokens are stored as SHA-256 hashes, so the presented
-// value is hashed before a constant-time compare per row keeps a remote
-// attacker from timing the comparison byte-by-byte.
-func (s *ApiTokenService) Match(presented string) bool {
+func (s *ApiTokenService) SetEnabledExpectedScope(id int, expectedScope string, enabled bool) error {
+	if id <= 0 {
+		return common.NewError("invalid token id")
+	}
+	scope, err := requireExpectedScope(expectedScope)
+	if err != nil {
+		return err
+	}
+	res := database.GetDB().Model(model.ApiToken{}).Where("id = ? AND scope = ?", id, scope).Update("enabled", enabled)
+	if res.Error != nil {
+		return res.Error
+	}
+	if res.RowsAffected == 0 {
+		return errors.New("token not found with expected scope")
+	}
+	return nil
+}
+
+func nowMilli() int64 { return time.Now().UnixMilli() }
+
+// DisableExpectedScope fails closed unless the stored scope matches the caller,
+// preventing rotation from revoking a newly minted token after a wrong ID.
+func (s *ApiTokenService) DisableExpectedScope(id int, expectedScope string) error {
+	if id <= 0 {
+		return common.NewError("invalid token id")
+	}
+	return s.SetEnabledExpectedScope(id, expectedScope, false)
+}
+
+func requireExpectedScope(expectedScope string) (string, error) {
+	if strings.TrimSpace(expectedScope) == "" {
+		return "", common.NewError("expected scope is required")
+	}
+	scope, err := NormalizeScope(expectedScope)
+	if err != nil {
+		return "", err
+	}
+	return scope, nil
+}
+
+// MatchToken returns the enabled, non-expired api_token row whose stored
+// SHA-256 hash matches the presented bearer value, or (nil,false). The loop
+// scans every enabled row with constant-time compares, then applies expiry and
+// scope checks to avoid treating corrupt values as admin.
+func (s *ApiTokenService) MatchToken(presented string) (*model.ApiToken, bool) {
 	if presented == "" {
-		return false
+		return nil, false
 	}
 	db := database.GetDB()
 	var rows []*model.ApiToken
 	if err := db.Model(model.ApiToken{}).Where("enabled = ?", true).Find(&rows).Error; err != nil {
-		return false
+		return nil, false
 	}
 	presentedHash := []byte(crypto.HashTokenSHA256(presented))
-	matched := false
+	var matched *model.ApiToken
 	for _, r := range rows {
 		if subtle.ConstantTimeCompare([]byte(r.Token), presentedHash) == 1 {
-			matched = true
+			matched = r
 		}
 	}
-	return matched
+	if matched == nil {
+		return nil, false
+	}
+	if !model.IsKnownApiScope(matched.Scope) {
+		return nil, false
+	}
+	if matched.ExpiresAt != 0 && nowMilli() >= matched.ExpiresAt {
+		return nil, false
+	}
+	return matched, true
+}
+
+// Match is the legacy boolean form for callers that do not need scope.
+func (s *ApiTokenService) Match(presented string) bool {
+	_, ok := s.MatchToken(presented)
+	return ok
 }

Alguns ficheiros não foram mostrados porque muitos ficheiros mudaram neste diff