Browse Source

fix(auth): add node-admin token scope for panel updates (#6666)

* feat(auth): add node-admin API token scope

Keep ordinary node-sync credentials unable to self-update a remote panel while allowing explicitly elevated node administrators to inherit synchronization access and invoke updatePanel.

* style(docs): format API scope table

* fix(auth): preserve mTLS node-sync semantics

Allow only an explicit node-admin bearer to elevate a verified mTLS request. Existing admin, monitor, and node-sync bearer values continue to use the historical node-sync identity on mTLS connections.

* chore(ci): rerun flaky checks

---------

Co-authored-by: libmur-dev <[email protected]>
libmur-dev 14 hours ago
parent
commit
a8d0700bf2
42 changed files with 504 additions and 121 deletions
  1. 16 0
      docs/architecture.md
  2. 7 7
      docs/content/docs/en/reference/api/api-tokens.mdx
  3. 2 4
      docs/content/docs/en/reference/api/authentication.mdx
  4. 2 4
      docs/content/docs/en/reference/api/backup.mdx
  5. 1 3
      docs/content/docs/en/reference/api/clients.mdx
  6. 2 4
      docs/content/docs/en/reference/api/hosts.mdx
  7. 2 4
      docs/content/docs/en/reference/api/inbounds.mdx
  8. 2 4
      docs/content/docs/en/reference/api/nodes.mdx
  9. 5 7
      docs/content/docs/en/reference/api/server.mdx
  10. 2 4
      docs/content/docs/en/reference/api/settings.mdx
  11. 2 4
      docs/content/docs/en/reference/api/subscription-balancers.mdx
  12. 2 4
      docs/content/docs/en/reference/api/subscription-server.mdx
  13. 2 4
      docs/content/docs/en/reference/api/websocket.mdx
  14. 2 4
      docs/content/docs/en/reference/api/xray-settings.mdx
  15. 16 8
      docs/public/openapi.json
  16. 16 8
      frontend/public/openapi.json
  17. 2 2
      frontend/scripts/build-openapi.mjs
  18. 7 5
      frontend/src/pages/api-docs/endpoints.ts
  19. 11 0
      frontend/src/pages/settings/SecurityTab.css
  20. 67 6
      frontend/src/pages/settings/SecurityTab.tsx
  21. 46 1
      frontend/src/test/api-token-date.test.tsx
  22. 9 4
      internal/database/model/model.go
  23. 43 23
      internal/web/controller/api.go
  24. 79 0
      internal/web/controller/api_auth_test.go
  25. 3 2
      internal/web/controller/inbound.go
  26. 15 1
      internal/web/controller/inbound_node_sync_test.go
  27. 3 1
      internal/web/service/panel/api_token.go
  28. 17 0
      internal/web/service/panel/api_token_scope_test.go
  29. 4 3
      internal/web/service/panel/api_token_test.go
  30. 9 0
      internal/web/translation/ar-EG.json
  31. 9 0
      internal/web/translation/en-US.json
  32. 9 0
      internal/web/translation/es-ES.json
  33. 9 0
      internal/web/translation/fa-IR.json
  34. 9 0
      internal/web/translation/id-ID.json
  35. 9 0
      internal/web/translation/ja-JP.json
  36. 9 0
      internal/web/translation/pt-BR.json
  37. 9 0
      internal/web/translation/ru-RU.json
  38. 9 0
      internal/web/translation/tr-TR.json
  39. 9 0
      internal/web/translation/uk-UA.json
  40. 9 0
      internal/web/translation/vi-VN.json
  41. 9 0
      internal/web/translation/zh-CN.json
  42. 9 0
      internal/web/translation/zh-TW.json

+ 16 - 0
docs/architecture.md

@@ -340,6 +340,22 @@ code_ works whether the target is the local Xray or a remote node.
 - **Dispatch:** `manager.go` → `Manager.RuntimeFor(nodeID *int)`; `nil` nodeID → `Local`,
   otherwise a cached/lazy-loaded `Remote`. `InvalidateNode(id)` drops a cached remote client.
 
+Bearer tokens use the narrowest scope that matches the caller's duties:
+
+| Scope        | Access                                                                 |
+| ------------ | ---------------------------------------------------------------------- |
+| `monitor`    | Read-only status, version, and metric endpoints.                       |
+| `node-sync`  | Node configuration, inbound/client synchronization, traffic, and Xray. |
+| `node-admin` | All `node-sync` access plus `POST /panel/api/server/updatePanel`.      |
+| `admin`      | Every panel API endpoint; intended for fully trusted automation.       |
+
+`node-admin` remains a node synchronization identity when inbound rules are applied. This
+preserves the node-specific validation path while keeping software-update authority separate
+from ordinary `node-sync` credentials. On an mTLS connection, only a valid `node-admin` bearer
+elevates the request; other bearer scopes retain the historical mTLS `node-sync` behavior.
+Existing tokens retain their stored scope, and legacy tokens without one continue to migrate to
+`admin`.
+
 **Node identity & attribution (the hard part).** Inbounds carry a `NodeID` _and_ an
 `OriginNodeGuid`. Because inbounds can be pushed across hops, the panel attributes traffic and
 online clients back to the originating panel using **stable GUIDs** rather than local IDs.

+ 7 - 7
docs/content/docs/en/reference/api/api-tokens.mdx

@@ -1,8 +1,10 @@
 ---
 title: API Tokens
-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.
+description: Manage scoped Bearer tokens for programmatic auth. admin grants the
+  full panel API; monitor is read-only for status and metrics; node-sync manages
+  node configuration without panel updates; node-admin adds panel self-update to
+  node-sync. Tokens may expire and are stored as SHA-256 hashes. The plaintext
+  is returned only once at creation.
 full: true
 _openapi:
   preload:
@@ -44,13 +46,11 @@ _openapi:
 {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
 
 export default function Layout(props) {
-  const { APIPage, OpenAPIPage } = props.components ?? {};
-  // "APIPage" is the old name from v10, this allows both for backward compatibility
-  const Comp = OpenAPIPage ?? APIPage;
+  const { OpenAPIPage } = props.components ?? {};
   return (
     <>
       {props.children}
-      <Comp document="./public/openapi.json" webhooks={[]} operations={[{"path":"/panel/api/setting/apiTokens","method":"get"},{"path":"/panel/api/setting/apiTokens/create","method":"post"},{"path":"/panel/api/setting/apiTokens/delete/{id}","method":"post"},{"path":"/panel/api/setting/apiTokens/setEnabled/{id}","method":"post"}]} showTitle />
+      <OpenAPIPage document="./public/openapi.json" webhooks={[]} operations={[{"path":"/panel/api/setting/apiTokens","method":"get"},{"path":"/panel/api/setting/apiTokens/create","method":"post"},{"path":"/panel/api/setting/apiTokens/delete/{id}","method":"post"},{"path":"/panel/api/setting/apiTokens/setEnabled/{id}","method":"post"}]} showTitle />
     </>
   );
 }

+ 2 - 4
docs/content/docs/en/reference/api/authentication.mdx

@@ -59,13 +59,11 @@ _openapi:
 {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
 
 export default function Layout(props) {
-  const { APIPage, OpenAPIPage } = props.components ?? {};
-  // "APIPage" is the old name from v10, this allows both for backward compatibility
-  const Comp = OpenAPIPage ?? APIPage;
+  const { OpenAPIPage } = props.components ?? {};
   return (
     <>
       {props.children}
-      <Comp document="./public/openapi.json" webhooks={[]} operations={[{"path":"/login","method":"post"},{"path":"/logout","method":"post"},{"path":"/csrf-token","method":"get"},{"path":"/sponsors","method":"get"},{"path":"/getTwoFactorEnable","method":"post"}]} showTitle />
+      <OpenAPIPage document="./public/openapi.json" webhooks={[]} operations={[{"path":"/login","method":"post"},{"path":"/logout","method":"post"},{"path":"/csrf-token","method":"get"},{"path":"/sponsors","method":"get"},{"path":"/getTwoFactorEnable","method":"post"}]} showTitle />
     </>
   );
 }

+ 2 - 4
docs/content/docs/en/reference/api/backup.mdx

@@ -21,13 +21,11 @@ _openapi:
 {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
 
 export default function Layout(props) {
-  const { APIPage, OpenAPIPage } = props.components ?? {};
-  // "APIPage" is the old name from v10, this allows both for backward compatibility
-  const Comp = OpenAPIPage ?? APIPage;
+  const { OpenAPIPage } = props.components ?? {};
   return (
     <>
       {props.children}
-      <Comp document="./public/openapi.json" webhooks={[]} operations={[{"path":"/panel/api/backuptotgbot","method":"post"}]} showTitle />
+      <OpenAPIPage document="./public/openapi.json" webhooks={[]} operations={[{"path":"/panel/api/backuptotgbot","method":"post"}]} showTitle />
     </>
   );
 }

File diff suppressed because it is too large
+ 1 - 3
docs/content/docs/en/reference/api/clients.mdx


+ 2 - 4
docs/content/docs/en/reference/api/hosts.mdx

@@ -78,13 +78,11 @@ _openapi:
 {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
 
 export default function Layout(props) {
-  const { APIPage, OpenAPIPage } = props.components ?? {};
-  // "APIPage" is the old name from v10, this allows both for backward compatibility
-  const Comp = OpenAPIPage ?? APIPage;
+  const { OpenAPIPage } = props.components ?? {};
   return (
     <>
       {props.children}
-      <Comp document="./public/openapi.json" webhooks={[]} operations={[{"path":"/panel/api/hosts/list","method":"get"},{"path":"/panel/api/hosts/get/{groupId}","method":"get"},{"path":"/panel/api/hosts/byInbound/{inboundId}","method":"get"},{"path":"/panel/api/hosts/tags","method":"get"},{"path":"/panel/api/hosts/add","method":"post"},{"path":"/panel/api/hosts/update/{groupId}","method":"post"},{"path":"/panel/api/hosts/del/{groupId}","method":"post"},{"path":"/panel/api/hosts/setEnable/{groupId}","method":"post"},{"path":"/panel/api/hosts/reorder","method":"post"},{"path":"/panel/api/hosts/bulk/add","method":"post"},{"path":"/panel/api/hosts/bulk/setEnable","method":"post"},{"path":"/panel/api/hosts/bulk/del","method":"post"}]} showTitle />
+      <OpenAPIPage document="./public/openapi.json" webhooks={[]} operations={[{"path":"/panel/api/hosts/list","method":"get"},{"path":"/panel/api/hosts/get/{groupId}","method":"get"},{"path":"/panel/api/hosts/byInbound/{inboundId}","method":"get"},{"path":"/panel/api/hosts/tags","method":"get"},{"path":"/panel/api/hosts/add","method":"post"},{"path":"/panel/api/hosts/update/{groupId}","method":"post"},{"path":"/panel/api/hosts/del/{groupId}","method":"post"},{"path":"/panel/api/hosts/setEnable/{groupId}","method":"post"},{"path":"/panel/api/hosts/reorder","method":"post"},{"path":"/panel/api/hosts/bulk/add","method":"post"},{"path":"/panel/api/hosts/bulk/setEnable","method":"post"},{"path":"/panel/api/hosts/bulk/del","method":"post"}]} showTitle />
     </>
   );
 }

+ 2 - 4
docs/content/docs/en/reference/api/inbounds.mdx

@@ -197,13 +197,11 @@ _openapi:
 {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
 
 export default function Layout(props) {
-  const { APIPage, OpenAPIPage } = props.components ?? {};
-  // "APIPage" is the old name from v10, this allows both for backward compatibility
-  const Comp = OpenAPIPage ?? APIPage;
+  const { OpenAPIPage } = props.components ?? {};
   return (
     <>
       {props.children}
-      <Comp document="./public/openapi.json" webhooks={[]} operations={[{"path":"/panel/api/inbounds/list","method":"get"},{"path":"/panel/api/inbounds/list/slim","method":"get"},{"path":"/panel/api/inbounds/options","method":"get"},{"path":"/panel/api/inbounds/allLinks","method":"get"},{"path":"/panel/api/inbounds/get/{id}","method":"get"},{"path":"/panel/api/inbounds/add","method":"post"},{"path":"/panel/api/inbounds/del/{id}","method":"post"},{"path":"/panel/api/inbounds/bulkDel","method":"post"},{"path":"/panel/api/inbounds/update/{id}","method":"post"},{"path":"/panel/api/inbounds/setEnable/{id}","method":"post"},{"path":"/panel/api/inbounds/{id}/subSortIndex","method":"post"},{"path":"/panel/api/inbounds/{id}/resetTraffic","method":"post"},{"path":"/panel/api/inbounds/{id}/delAllClients","method":"post"},{"path":"/panel/api/inbounds/resetAllTraffics","method":"post"},{"path":"/panel/api/inbounds/import","method":"post"},{"path":"/panel/api/inbounds/pushClientTraffics","method":"post"},{"path":"/panel/api/inbounds/{id}/fallbacks","method":"get"},{"path":"/panel/api/inbounds/{id}/fallbacks","method":"post"}]} showTitle />
+      <OpenAPIPage document="./public/openapi.json" webhooks={[]} operations={[{"path":"/panel/api/inbounds/list","method":"get"},{"path":"/panel/api/inbounds/list/slim","method":"get"},{"path":"/panel/api/inbounds/options","method":"get"},{"path":"/panel/api/inbounds/allLinks","method":"get"},{"path":"/panel/api/inbounds/get/{id}","method":"get"},{"path":"/panel/api/inbounds/add","method":"post"},{"path":"/panel/api/inbounds/del/{id}","method":"post"},{"path":"/panel/api/inbounds/bulkDel","method":"post"},{"path":"/panel/api/inbounds/update/{id}","method":"post"},{"path":"/panel/api/inbounds/setEnable/{id}","method":"post"},{"path":"/panel/api/inbounds/{id}/subSortIndex","method":"post"},{"path":"/panel/api/inbounds/{id}/resetTraffic","method":"post"},{"path":"/panel/api/inbounds/{id}/delAllClients","method":"post"},{"path":"/panel/api/inbounds/resetAllTraffics","method":"post"},{"path":"/panel/api/inbounds/import","method":"post"},{"path":"/panel/api/inbounds/pushClientTraffics","method":"post"},{"path":"/panel/api/inbounds/{id}/fallbacks","method":"get"},{"path":"/panel/api/inbounds/{id}/fallbacks","method":"post"}]} showTitle />
     </>
   );
 }

+ 2 - 4
docs/content/docs/en/reference/api/nodes.mdx

@@ -145,13 +145,11 @@ _openapi:
 {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
 
 export default function Layout(props) {
-  const { APIPage, OpenAPIPage } = props.components ?? {};
-  // "APIPage" is the old name from v10, this allows both for backward compatibility
-  const Comp = OpenAPIPage ?? APIPage;
+  const { OpenAPIPage } = props.components ?? {};
   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/mtls/reloadClient","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 />
+      <OpenAPIPage 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/mtls/reloadClient","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 />
     </>
   );
 }

File diff suppressed because it is too large
+ 5 - 7
docs/content/docs/en/reference/api/server.mdx


+ 2 - 4
docs/content/docs/en/reference/api/settings.mdx

@@ -102,13 +102,11 @@ _openapi:
 {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
 
 export default function Layout(props) {
-  const { APIPage, OpenAPIPage } = props.components ?? {};
-  // "APIPage" is the old name from v10, this allows both for backward compatibility
-  const Comp = OpenAPIPage ?? APIPage;
+  const { OpenAPIPage } = props.components ?? {};
   return (
     <>
       {props.children}
-      <Comp document="./public/openapi.json" webhooks={[]} operations={[{"path":"/panel/api/setting/all","method":"post"},{"path":"/panel/api/setting/defaultSettings","method":"post"},{"path":"/panel/api/setting/factoryDefaults","method":"post"},{"path":"/panel/api/setting/update","method":"post"},{"path":"/panel/api/setting/validateRegex","method":"post"},{"path":"/panel/api/setting/updateUser","method":"post"},{"path":"/panel/api/setting/restartPanel","method":"post"},{"path":"/panel/api/setting/testSmtp","method":"post"},{"path":"/panel/api/setting/testTgBot","method":"post"},{"path":"/panel/api/setting/testDiscord","method":"post"},{"path":"/panel/api/setting/getDefaultJsonConfig","method":"get"}]} showTitle />
+      <OpenAPIPage document="./public/openapi.json" webhooks={[]} operations={[{"path":"/panel/api/setting/all","method":"post"},{"path":"/panel/api/setting/defaultSettings","method":"post"},{"path":"/panel/api/setting/factoryDefaults","method":"post"},{"path":"/panel/api/setting/update","method":"post"},{"path":"/panel/api/setting/validateRegex","method":"post"},{"path":"/panel/api/setting/updateUser","method":"post"},{"path":"/panel/api/setting/restartPanel","method":"post"},{"path":"/panel/api/setting/testSmtp","method":"post"},{"path":"/panel/api/setting/testTgBot","method":"post"},{"path":"/panel/api/setting/testDiscord","method":"post"},{"path":"/panel/api/setting/getDefaultJsonConfig","method":"get"}]} showTitle />
     </>
   );
 }

+ 2 - 4
docs/content/docs/en/reference/api/subscription-balancers.mdx

@@ -50,13 +50,11 @@ _openapi:
 {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
 
 export default function Layout(props) {
-  const { APIPage, OpenAPIPage } = props.components ?? {};
-  // "APIPage" is the old name from v10, this allows both for backward compatibility
-  const Comp = OpenAPIPage ?? APIPage;
+  const { OpenAPIPage } = props.components ?? {};
   return (
     <>
       {props.children}
-      <Comp document="./public/openapi.json" webhooks={[]} operations={[{"path":"/panel/api/sub-balancers","method":"get"},{"path":"/panel/api/sub-balancers","method":"post"},{"path":"/panel/api/sub-balancers/{id}","method":"post"},{"path":"/panel/api/sub-balancers/{id}","method":"delete"},{"path":"/panel/api/sub-balancers/{id}/del","method":"post"}]} showTitle />
+      <OpenAPIPage document="./public/openapi.json" webhooks={[]} operations={[{"path":"/panel/api/sub-balancers","method":"get"},{"path":"/panel/api/sub-balancers","method":"post"},{"path":"/panel/api/sub-balancers/{id}","method":"post"},{"path":"/panel/api/sub-balancers/{id}","method":"delete"},{"path":"/panel/api/sub-balancers/{id}/del","method":"post"}]} showTitle />
     </>
   );
 }

+ 2 - 4
docs/content/docs/en/reference/api/subscription-server.mdx

@@ -98,13 +98,11 @@ _openapi:
 {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
 
 export default function Layout(props) {
-  const { APIPage, OpenAPIPage } = props.components ?? {};
-  // "APIPage" is the old name from v10, this allows both for backward compatibility
-  const Comp = OpenAPIPage ?? APIPage;
+  const { OpenAPIPage } = props.components ?? {};
   return (
     <>
       {props.children}
-      <Comp document="./public/openapi.json" webhooks={[]} operations={[{"path":"/{subPath}{subid}","method":"get"},{"path":"/{subPath}{subid}","method":"head"},{"path":"/{subPath}{subid}/hwid-status","method":"get"},{"path":"/{subPath}{subid}/hwid-status","method":"head"},{"path":"/{jsonPath}{subid}","method":"get"},{"path":"/{jsonPath}{subid}","method":"head"},{"path":"/{clashPath}{subid}","method":"get"},{"path":"/{clashPath}{subid}","method":"head"}]} showTitle />
+      <OpenAPIPage document="./public/openapi.json" webhooks={[]} operations={[{"path":"/{subPath}{subid}","method":"get"},{"path":"/{subPath}{subid}","method":"head"},{"path":"/{subPath}{subid}/hwid-status","method":"get"},{"path":"/{subPath}{subid}/hwid-status","method":"head"},{"path":"/{jsonPath}{subid}","method":"get"},{"path":"/{jsonPath}{subid}","method":"head"},{"path":"/{clashPath}{subid}","method":"get"},{"path":"/{clashPath}{subid}","method":"head"}]} showTitle />
     </>
   );
 }

+ 2 - 4
docs/content/docs/en/reference/api/websocket.mdx

@@ -29,13 +29,11 @@ _openapi:
 {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
 
 export default function Layout(props) {
-  const { APIPage, OpenAPIPage } = props.components ?? {};
-  // "APIPage" is the old name from v10, this allows both for backward compatibility
-  const Comp = OpenAPIPage ?? APIPage;
+  const { OpenAPIPage } = props.components ?? {};
   return (
     <>
       {props.children}
-      <Comp document="./public/openapi.json" webhooks={[]} operations={[{"path":"/ws","method":"get"}]} showTitle />
+      <OpenAPIPage document="./public/openapi.json" webhooks={[]} operations={[{"path":"/ws","method":"get"}]} showTitle />
     </>
   );
 }

+ 2 - 4
docs/content/docs/en/reference/api/xray-settings.mdx

@@ -213,13 +213,11 @@ _openapi:
 {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
 
 export default function Layout(props) {
-  const { APIPage, OpenAPIPage } = props.components ?? {};
-  // "APIPage" is the old name from v10, this allows both for backward compatibility
-  const Comp = OpenAPIPage ?? APIPage;
+  const { OpenAPIPage } = props.components ?? {};
   return (
     <>
       {props.children}
-      <Comp document="./public/openapi.json" webhooks={[]} operations={[{"path":"/panel/api/xray/","method":"post"},{"path":"/panel/api/xray/getDefaultJsonConfig","method":"get"},{"path":"/panel/api/xray/getOutboundsTraffic","method":"get"},{"path":"/panel/api/xray/getXrayResult","method":"get"},{"path":"/panel/api/xray/update","method":"post"},{"path":"/panel/api/xray/warp/{action}","method":"post"},{"path":"/panel/api/xray/nord/{action}","method":"post"},{"path":"/panel/api/xray/pia/{action}","method":"post"},{"path":"/panel/api/xray/resetOutboundsTraffic","method":"post"},{"path":"/panel/api/xray/testOutbound","method":"post"},{"path":"/panel/api/xray/testOutbounds","method":"post"},{"path":"/panel/api/xray/balancerStatus","method":"post"},{"path":"/panel/api/xray/balancerOverride","method":"post"},{"path":"/panel/api/xray/routeTest","method":"post"},{"path":"/panel/api/xray/geodata/files","method":"get"},{"path":"/panel/api/xray/geodata/categories","method":"get"},{"path":"/panel/api/xray/geodata/entries","method":"get"},{"path":"/panel/api/xray/geodata/validate","method":"post"},{"path":"/panel/api/xray/outbound-subs","method":"get"},{"path":"/panel/api/xray/outbound-subs","method":"post"},{"path":"/panel/api/xray/outbound-subs/{id}","method":"post"},{"path":"/panel/api/xray/outbound-subs/{id}","method":"delete"},{"path":"/panel/api/xray/outbound-subs/{id}/del","method":"post"},{"path":"/panel/api/xray/outbound-subs/{id}/refresh","method":"post"},{"path":"/panel/api/xray/outbound-subs/{id}/move","method":"post"},{"path":"/panel/api/xray/outbound-subs/parse","method":"post"}]} showTitle />
+      <OpenAPIPage document="./public/openapi.json" webhooks={[]} operations={[{"path":"/panel/api/xray/","method":"post"},{"path":"/panel/api/xray/getDefaultJsonConfig","method":"get"},{"path":"/panel/api/xray/getOutboundsTraffic","method":"get"},{"path":"/panel/api/xray/getXrayResult","method":"get"},{"path":"/panel/api/xray/update","method":"post"},{"path":"/panel/api/xray/warp/{action}","method":"post"},{"path":"/panel/api/xray/nord/{action}","method":"post"},{"path":"/panel/api/xray/pia/{action}","method":"post"},{"path":"/panel/api/xray/resetOutboundsTraffic","method":"post"},{"path":"/panel/api/xray/testOutbound","method":"post"},{"path":"/panel/api/xray/testOutbounds","method":"post"},{"path":"/panel/api/xray/balancerStatus","method":"post"},{"path":"/panel/api/xray/balancerOverride","method":"post"},{"path":"/panel/api/xray/routeTest","method":"post"},{"path":"/panel/api/xray/geodata/files","method":"get"},{"path":"/panel/api/xray/geodata/categories","method":"get"},{"path":"/panel/api/xray/geodata/entries","method":"get"},{"path":"/panel/api/xray/geodata/validate","method":"post"},{"path":"/panel/api/xray/outbound-subs","method":"get"},{"path":"/panel/api/xray/outbound-subs","method":"post"},{"path":"/panel/api/xray/outbound-subs/{id}","method":"post"},{"path":"/panel/api/xray/outbound-subs/{id}","method":"delete"},{"path":"/panel/api/xray/outbound-subs/{id}/del","method":"post"},{"path":"/panel/api/xray/outbound-subs/{id}/refresh","method":"post"},{"path":"/panel/api/xray/outbound-subs/{id}/move","method":"post"},{"path":"/panel/api/xray/outbound-subs/parse","method":"post"}]} showTitle />
     </>
   );
 }

+ 16 - 8
docs/public/openapi.json

@@ -3,7 +3,7 @@
   "info": {
     "title": "3X-UI Panel API",
     "version": "3.x",
-    "description": "Programmatic interface to a 3X-UI panel. Authenticate either by logging in (cookie) or with an API token from Settings → Security → API Token (Bearer). All endpoints under /panel/api/* honour both modes — an API token is a full-admin credential, so treat it like the panel password."
+    "description": "Programmatic interface to a 3X-UI panel. Authenticate either by logging in (cookie) or with a scoped API token from Settings → Security → API Token (Bearer). Token access is limited by its admin, monitor, node-sync, or node-admin scope; treat admin tokens like the panel password."
   },
   "servers": [
     {
@@ -16,7 +16,7 @@
       "bearerAuth": {
         "type": "http",
         "scheme": "bearer",
-        "description": "API token from Settings → Security → API Token. Send as `Authorization: Bearer <token>`."
+        "description": "Scoped API token from Settings → Security → API Token. Send as `Authorization: Bearer <token>`."
       },
       "cookieAuth": {
         "type": "apiKey",
@@ -4855,7 +4855,7 @@
     },
     {
       "name": "API Tokens",
-      "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."
+      "description": "Manage scoped Bearer tokens for programmatic auth. admin grants the full panel API; monitor is read-only for status and metrics; node-sync manages node configuration without panel updates; node-admin adds panel self-update to node-sync. Tokens may expire and are stored as SHA-256 hashes. The plaintext is returned only once at creation."
     },
     {
       "name": "Xray Settings",
@@ -7184,7 +7184,7 @@
         "tags": [
           "Server"
         ],
-        "summary": "Self-update the panel to the latest version. The server restarts on success.",
+        "summary": "Self-update the panel to the latest version. The server restarts on success. Bearer-token callers need admin or node-admin scope.",
         "operationId": "post_panel_api_server_updatePanel",
         "requestBody": {
           "required": false,
@@ -13208,9 +13208,11 @@
                   "obj": [
                     {
                       "id": 1,
-                      "name": "default",
+                      "name": "central-panel-a",
                       "enabled": true,
-                      "createdAt": 1736000000
+                      "createdAt": 1736000000,
+                      "scope": "node-admin",
+                      "expiresAt": 0
                     }
                   ]
                 }
@@ -13240,7 +13242,13 @@
                   },
                   "scope": {
                     "type": "string",
-                    "description": "admin (default), monitor, or node-sync."
+                    "enum": [
+                      "admin",
+                      "monitor",
+                      "node-sync",
+                      "node-admin"
+                    ],
+                    "description": "admin (default), monitor, node-sync, or node-admin. node-admin inherits node-sync and may also start a panel update."
                   },
                   "expiresAt": {
                     "type": "integer",
@@ -13253,7 +13261,7 @@
               },
               "example": {
                 "name": "central-panel-a",
-                "scope": "node-sync",
+                "scope": "node-admin",
                 "expiresAt": 1798761600000
               }
             }

+ 16 - 8
frontend/public/openapi.json

@@ -3,7 +3,7 @@
   "info": {
     "title": "3X-UI Panel API",
     "version": "3.x",
-    "description": "Programmatic interface to a 3X-UI panel. Authenticate either by logging in (cookie) or with an API token from Settings → Security → API Token (Bearer). All endpoints under /panel/api/* honour both modes — an API token is a full-admin credential, so treat it like the panel password."
+    "description": "Programmatic interface to a 3X-UI panel. Authenticate either by logging in (cookie) or with a scoped API token from Settings → Security → API Token (Bearer). Token access is limited by its admin, monitor, node-sync, or node-admin scope; treat admin tokens like the panel password."
   },
   "servers": [
     {
@@ -16,7 +16,7 @@
       "bearerAuth": {
         "type": "http",
         "scheme": "bearer",
-        "description": "API token from Settings → Security → API Token. Send as `Authorization: Bearer <token>`."
+        "description": "Scoped API token from Settings → Security → API Token. Send as `Authorization: Bearer <token>`."
       },
       "cookieAuth": {
         "type": "apiKey",
@@ -4855,7 +4855,7 @@
     },
     {
       "name": "API Tokens",
-      "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."
+      "description": "Manage scoped Bearer tokens for programmatic auth. admin grants the full panel API; monitor is read-only for status and metrics; node-sync manages node configuration without panel updates; node-admin adds panel self-update to node-sync. Tokens may expire and are stored as SHA-256 hashes. The plaintext is returned only once at creation."
     },
     {
       "name": "Xray Settings",
@@ -7184,7 +7184,7 @@
         "tags": [
           "Server"
         ],
-        "summary": "Self-update the panel to the latest version. The server restarts on success.",
+        "summary": "Self-update the panel to the latest version. The server restarts on success. Bearer-token callers need admin or node-admin scope.",
         "operationId": "post_panel_api_server_updatePanel",
         "requestBody": {
           "required": false,
@@ -13208,9 +13208,11 @@
                   "obj": [
                     {
                       "id": 1,
-                      "name": "default",
+                      "name": "central-panel-a",
                       "enabled": true,
-                      "createdAt": 1736000000
+                      "createdAt": 1736000000,
+                      "scope": "node-admin",
+                      "expiresAt": 0
                     }
                   ]
                 }
@@ -13240,7 +13242,13 @@
                   },
                   "scope": {
                     "type": "string",
-                    "description": "admin (default), monitor, or node-sync."
+                    "enum": [
+                      "admin",
+                      "monitor",
+                      "node-sync",
+                      "node-admin"
+                    ],
+                    "description": "admin (default), monitor, node-sync, or node-admin. node-admin inherits node-sync and may also start a panel update."
                   },
                   "expiresAt": {
                     "type": "integer",
@@ -13253,7 +13261,7 @@
               },
               "example": {
                 "name": "central-panel-a",
-                "scope": "node-sync",
+                "scope": "node-admin",
                 "expiresAt": 1798761600000
               }
             }

+ 2 - 2
frontend/scripts/build-openapi.mjs

@@ -23,7 +23,7 @@ const SECURITY_SCHEMES = {
     type: 'http',
     scheme: 'bearer',
     description:
-      'API token from Settings → Security → API Token. Send as `Authorization: Bearer <token>`.',
+      'Scoped API token from Settings → Security → API Token. Send as `Authorization: Bearer <token>`.',
   },
   cookieAuth: {
     type: 'apiKey',
@@ -342,7 +342,7 @@ export function buildSpec() {
       title: '3X-UI Panel API',
       version: PANEL_VERSION,
       description:
-        'Programmatic interface to a 3X-UI panel. Authenticate either by logging in (cookie) or with an API token from Settings → Security → API Token (Bearer). All endpoints under /panel/api/* honour both modes — an API token is a full-admin credential, so treat it like the panel password.',
+        'Programmatic interface to a 3X-UI panel. Authenticate either by logging in (cookie) or with a scoped API token from Settings → Security → API Token (Bearer). Token access is limited by its admin, monitor, node-sync, or node-admin scope; treat admin tokens like the panel password.',
     },
     servers: [{ url: '/', description: 'Current panel (basePath aware)' }],
     components: {

+ 7 - 5
frontend/src/pages/api-docs/endpoints.ts

@@ -663,7 +663,8 @@ export const sections: readonly Section[] = [
       {
         method: 'POST',
         path: '/panel/api/server/updatePanel',
-        summary: 'Self-update the panel to the latest version. The server restarts on success.',
+        summary:
+          'Self-update the panel to the latest version. The server restarts on success. Bearer-token callers need admin or node-admin scope.',
         params: [
           {
             name: 'dev',
@@ -1996,7 +1997,7 @@ export const sections: readonly Section[] = [
     id: 'api-tokens',
     title: 'API Tokens',
     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.',
+      'Manage scoped Bearer tokens for programmatic auth. admin grants the full panel API; monitor is read-only for status and metrics; node-sync manages node configuration without panel updates; node-admin adds panel self-update to node-sync. Tokens may expire and are stored as SHA-256 hashes. The plaintext is returned only once at creation.',
     endpoints: [
       {
         method: 'GET',
@@ -2004,7 +2005,7 @@ export const sections: readonly Section[] = [
         summary:
           'List every API token, enabled or not. The token value is never returned — only metadata.',
         response:
-          '{\n  "success": true,\n  "obj": [\n    {\n      "id": 1,\n      "name": "default",\n      "enabled": true,\n      "createdAt": 1736000000\n    }\n  ]\n}',
+          '{\n  "success": true,\n  "obj": [\n    {\n      "id": 1,\n      "name": "central-panel-a",\n      "enabled": true,\n      "createdAt": 1736000000,\n      "scope": "node-admin",\n      "expiresAt": 0\n    }\n  ]\n}',
       },
       {
         method: 'POST',
@@ -2022,7 +2023,8 @@ export const sections: readonly Section[] = [
             name: 'scope',
             in: 'body',
             type: 'string',
-            desc: 'admin (default), monitor, or node-sync.',
+            desc: 'admin (default), monitor, node-sync, or node-admin. node-admin inherits node-sync and may also start a panel update.',
+            enum: ['admin', 'monitor', 'node-sync', 'node-admin'],
             optional: true,
           },
           {
@@ -2033,7 +2035,7 @@ export const sections: readonly Section[] = [
             optional: true,
           },
         ],
-        body: '{\n  "name": "central-panel-a",\n  "scope": "node-sync",\n  "expiresAt": 1798761600000\n}',
+        body: '{\n  "name": "central-panel-a",\n  "scope": "node-admin",\n  "expiresAt": 1798761600000\n}',
         responseSchema: 'ApiTokenView',
         errorResponse:
           '{\n  "success": false,\n  "msg": "a token with that name already exists"\n}',

+ 11 - 0
frontend/src/pages/settings/SecurityTab.css

@@ -49,6 +49,17 @@
   gap: 2px;
 }
 
+.api-token-name-line {
+  display: flex;
+  align-items: center;
+  gap: 6px;
+  flex-wrap: wrap;
+}
+
+.api-token-name-line .ant-tag {
+  margin-inline-end: 0;
+}
+
 .api-token-name {
   font-weight: 600;
   font-size: 13.5px;

+ 67 - 6
frontend/src/pages/settings/SecurityTab.tsx

@@ -1,6 +1,19 @@
 import { useCallback, useEffect, useState } from 'react';
 import { useTranslation } from 'react-i18next';
-import { Button, Empty, Form, Input, Modal, Space, Spin, Switch, Tabs, message } from 'antd';
+import {
+  Button,
+  Empty,
+  Form,
+  Input,
+  Modal,
+  Select,
+  Space,
+  Spin,
+  Switch,
+  Tabs,
+  Tag,
+  message,
+} from 'antd';
 import { ApiOutlined, SafetyOutlined, UserOutlined } from '@ant-design/icons';
 import { ClipboardManager, HttpUtil, IntlUtil, RandomUtil } from '@/utils';
 import type { AllSetting } from '@/models/setting';
@@ -16,12 +29,14 @@ interface ApiMsg<T = unknown> {
   obj?: T;
 }
 
+type ApiTokenScope = 'admin' | 'monitor' | 'node-sync' | 'node-admin';
+
 interface ApiTokenRow {
   id: number;
   name: string;
   enabled: boolean;
   createdAt: number;
-  scope: 'admin' | 'monitor' | 'node-sync';
+  scope: ApiTokenScope;
   expiresAt: number;
 }
 
@@ -76,6 +91,7 @@ export default function SecurityTab({ allSetting, updateSetting, saveSetting }:
   const [apiTokensLoading, setApiTokensLoading] = useState(true);
   const [createOpen, setCreateOpen] = useState(false);
   const [createName, setCreateName] = useState('');
+  const [createScope, setCreateScope] = useState<ApiTokenScope>('admin');
   const [creating, setCreating] = useState(false);
   const [createdToken, setCreatedToken] = useState<{ name: string; token: string } | null>(null);
 
@@ -157,6 +173,7 @@ export default function SecurityTab({ allSetting, updateSetting, saveSetting }:
 
   function openCreateModal() {
     setCreateName('');
+    setCreateScope('admin');
     setCreateOpen(true);
   }
 
@@ -168,9 +185,10 @@ export default function SecurityTab({ allSetting, updateSetting, saveSetting }:
     }
     setCreating(true);
     try {
-      const msg = (await HttpUtil.post('/panel/api/setting/apiTokens/create', { name })) as ApiMsg<{
-        token?: string;
-      }>;
+      const msg = (await HttpUtil.post('/panel/api/setting/apiTokens/create', {
+        name,
+        scope: createScope,
+      })) as ApiMsg<{ token?: string }>;
       if (msg?.success) {
         setCreateOpen(false);
         await loadApiTokens();
@@ -217,6 +235,29 @@ export default function SecurityTab({ allSetting, updateSetting, saveSetting }:
     return IntlUtil.formatDate(apiTokenCreatedAtMilliseconds(ts));
   }
 
+  const scopeMeta: Record<ApiTokenScope, { label: string; description: string; color: string }> = {
+    admin: {
+      label: t('pages.settings.security.apiTokenScopeAdmin'),
+      description: t('pages.settings.security.apiTokenScopeAdminDesc'),
+      color: 'red',
+    },
+    monitor: {
+      label: t('pages.settings.security.apiTokenScopeMonitor'),
+      description: t('pages.settings.security.apiTokenScopeMonitorDesc'),
+      color: 'blue',
+    },
+    'node-sync': {
+      label: t('pages.settings.security.apiTokenScopeNodeSync'),
+      description: t('pages.settings.security.apiTokenScopeNodeSyncDesc'),
+      color: 'gold',
+    },
+    'node-admin': {
+      label: t('pages.settings.security.apiTokenScopeNodeAdmin'),
+      description: t('pages.settings.security.apiTokenScopeNodeAdminDesc'),
+      color: 'purple',
+    },
+  };
+
   function toggleTwoFactor() {
     if (!allSetting.twoFactorEnable) {
       const newToken = RandomUtil.randomBase32String();
@@ -345,7 +386,12 @@ export default function SecurityTab({ allSetting, updateSetting, saveSetting }:
                     <div key={row.id} className={`api-token-row${row.enabled ? '' : ' disabled'}`}>
                       <div className="api-token-row-head">
                         <div className="api-token-name-wrap">
-                          <span className="api-token-name">{row.name}</span>
+                          <div className="api-token-name-line">
+                            <span className="api-token-name">{row.name}</span>
+                            <Tag color={scopeMeta[row.scope].color}>
+                              {scopeMeta[row.scope].label}
+                            </Tag>
+                          </div>
                           <span className="api-token-created">
                             {formatTokenDate(row.createdAt)}
                           </span>
@@ -396,6 +442,21 @@ export default function SecurityTab({ allSetting, updateSetting, saveSetting }:
               onPressEnter={confirmCreateToken}
             />
           </Form.Item>
+          <Form.Item
+            label={t('pages.settings.security.apiTokenScope')}
+            extra={scopeMeta[createScope].description}
+            required
+          >
+            <Select
+              aria-label={t('pages.settings.security.apiTokenScope')}
+              value={createScope}
+              options={(Object.keys(scopeMeta) as ApiTokenScope[]).map((scope) => ({
+                value: scope,
+                label: scopeMeta[scope].label,
+              }))}
+              onChange={setCreateScope}
+            />
+          </Form.Item>
         </Form>
       </Modal>
 

+ 46 - 1
frontend/src/test/api-token-date.test.tsx

@@ -1,4 +1,4 @@
-import { fireEvent, render, screen } from '@testing-library/react';
+import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
 import { describe, expect, it, vi } from 'vitest';
 
 import type { AllSetting } from '@/models/setting';
@@ -16,12 +16,16 @@ describe('API token creation date', () => {
           name: 'seconds-token',
           enabled: true,
           createdAt: 1782485394,
+          scope: 'node-sync',
+          expiresAt: 0,
         },
         {
           id: 3,
           name: 'legacy-milliseconds-token',
           enabled: true,
           createdAt: 1782485394270,
+          scope: 'node-admin',
+          expiresAt: 0,
         },
       ],
     });
@@ -34,5 +38,46 @@ describe('API token creation date', () => {
     expect(await screen.findByText('seconds-token')).toBeTruthy();
     expect(screen.getByText('legacy-milliseconds-token')).toBeTruthy();
     expect(screen.getAllByText(/2026/)).toHaveLength(2);
+    expect(screen.getByText('Node synchronization')).toBeTruthy();
+    expect(screen.getByText('Node administrator')).toBeTruthy();
+  });
+
+  it('creates a token with the selected scope', async () => {
+    vi.spyOn(HttpUtil, 'get').mockResolvedValue({ success: true, msg: '', obj: [] });
+    const post = vi.spyOn(HttpUtil, 'post').mockResolvedValue({
+      success: true,
+      msg: '',
+      obj: { token: 'created-token' },
+    });
+
+    render(
+      <SecurityTab allSetting={{} as AllSetting} updateSetting={vi.fn()} saveSetting={vi.fn()} />,
+    );
+    fireEvent.click(screen.getByRole('tab', { name: /API Token/ }));
+    fireEvent.click(await screen.findByRole('button', { name: /New token/ }));
+    fireEvent.change(screen.getByPlaceholderText('e.g. central-panel-a'), {
+      target: { value: 'central-node' },
+    });
+
+    const scope = screen.getByRole('combobox', { name: 'Scope' });
+    fireEvent.mouseDown(scope.closest('.ant-select') ?? scope);
+    const option = Array.from(document.querySelectorAll('.ant-select-item-option')).find(
+      (item) => item.textContent === 'Node administrator',
+    );
+    if (!option) throw new Error('Node administrator option not found');
+    fireEvent.click(option);
+    expect(
+      screen.getByText(
+        'Includes node synchronization permissions and can also start panel software updates.',
+      ),
+    ).toBeTruthy();
+
+    fireEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Confirm' }));
+    await waitFor(() =>
+      expect(post).toHaveBeenCalledWith('/panel/api/setting/apiTokens/create', {
+        name: 'central-node',
+        scope: 'node-admin',
+      }),
+    );
   });
 });

+ 9 - 4
internal/database/model/model.go

@@ -161,13 +161,18 @@ type HistoryOfSeeders struct {
 const ApiTokenUnixMillisecondsThreshold int64 = 100_000_000_000
 
 const (
-	ApiScopeAdmin    = "admin"
-	ApiScopeMonitor  = "monitor"
-	ApiScopeNodeSync = "node-sync"
+	ApiScopeAdmin     = "admin"
+	ApiScopeMonitor   = "monitor"
+	ApiScopeNodeSync  = "node-sync"
+	ApiScopeNodeAdmin = "node-admin"
 )
 
 func IsKnownApiScope(s string) bool {
-	return s == ApiScopeAdmin || s == ApiScopeMonitor || s == ApiScopeNodeSync
+	return s == ApiScopeAdmin || s == ApiScopeMonitor || s == ApiScopeNodeSync || s == ApiScopeNodeAdmin
+}
+
+func IsNodeSyncApiScope(s string) bool {
+	return s == ApiScopeNodeSync || s == ApiScopeNodeAdmin
 }
 
 type ApiToken struct {

+ 43 - 23
internal/web/controller/api.go

@@ -35,30 +35,34 @@ func NewAPIController(g *gin.RouterGroup) *APIController {
 }
 
 func (a *APIController) checkAPIAuth(c *gin.Context) {
-	// A verified client certificate (a completed mTLS handshake) authenticates
-	// the caller, equivalent to a valid bearer token. api_authed must be set so
-	// the CSRF middleware lets cert-authed mutations through.
-	if c.Request.TLS != nil && len(c.Request.TLS.VerifiedChains) > 0 {
+	setAuthenticated := func(scope string) {
 		if u, err := a.userService.GetFirstUser(); err == nil {
 			session.SetAPIAuthUser(c, u)
 		}
 		c.Set("api_authed", true)
-		c.Set("api_token_scope", model.ApiScopeNodeSync)
+		c.Set("api_token_scope", scope)
+	}
+	matchedScope := ""
+	if after, ok := strings.CutPrefix(c.GetHeader("Authorization"), "Bearer "); ok {
+		if row, matched := a.apiTokenService.MatchToken(after); matched {
+			matchedScope = row.Scope
+		}
+	}
+	// Verified mTLS keeps its historical node-sync identity. Only an explicit
+	// node-admin bearer elevates it to software-update authority.
+	if c.Request.TLS != nil && len(c.Request.TLS.VerifiedChains) > 0 {
+		scope := model.ApiScopeNodeSync
+		if matchedScope == model.ApiScopeNodeAdmin {
+			scope = model.ApiScopeNodeAdmin
+		}
+		setAuthenticated(scope)
 		c.Next()
 		return
 	}
-	auth := c.GetHeader("Authorization")
-	if after, ok := strings.CutPrefix(auth, "Bearer "); ok {
-		tok := after
-		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
-		}
+	if matchedScope != "" {
+		setAuthenticated(matchedScope)
+		c.Next()
+		return
 	}
 	if !session.IsLogin(c) {
 		// A presented Bearer token is not an anonymous scan: return 401 so
@@ -120,7 +124,20 @@ var nodeSyncScopeAllow = map[string]map[string]struct{}{
 	"/hosts/list":                  {http.MethodGet: {}},
 }
 
-// enforceTokenScope applies explicit allowlists to monitor and node-sync tokens.
+var nodeAdminScopeAllow = map[string]map[string]struct{}{
+	"/server/updatePanel": {http.MethodPost: {}},
+}
+
+func scopeAllows(allow map[string]map[string]struct{}, path, method string) bool {
+	methods, exists := allow[path]
+	if !exists {
+		return false
+	}
+	_, exists = methods[method]
+	return exists
+}
+
+// enforceTokenScope applies explicit allowlists to restricted API 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")
@@ -147,11 +164,14 @@ func (a *APIController) enforceTokenScope(c *gin.Context) {
 			return
 		}
 	case model.ApiScopeNodeSync:
-		if methods, allowed := nodeSyncScopeAllow[rel]; allowed {
-			if _, allowedMethod := methods[c.Request.Method]; allowedMethod {
-				c.Next()
-				return
-			}
+		if scopeAllows(nodeSyncScopeAllow, rel, c.Request.Method) {
+			c.Next()
+			return
+		}
+	case model.ApiScopeNodeAdmin:
+		if scopeAllows(nodeSyncScopeAllow, rel, c.Request.Method) || scopeAllows(nodeAdminScopeAllow, rel, c.Request.Method) {
+			c.Next()
+			return
 		}
 	default:
 		deny()

+ 79 - 0
internal/web/controller/api_auth_test.go

@@ -107,6 +107,43 @@ func TestCheckAPIAuth_BearerSuccess(t *testing.T) {
 	}
 }
 
+func TestPanelUpdateRequiresNodeAdminScope(t *testing.T) {
+	engine, _ := newAPIAuthTestEngine(t)
+
+	cases := []struct {
+		name     string
+		scope    string
+		wantCode int
+		wantBody string
+	}{
+		{"node sync denied", model.ApiScopeNodeSync, http.StatusForbidden, `{"msg":"this API token is not permitted to access this endpoint","success":false}`},
+		{"node admin allowed", model.ApiScopeNodeAdmin, http.StatusOK, `{"reached":true}`},
+		{"monitor denied", model.ApiScopeMonitor, http.StatusForbidden, `{"msg":"this API token is not permitted to access this endpoint","success":false}`},
+	}
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			plaintext := tc.name + "-token"
+			if err := database.GetDB().Create(&model.ApiToken{
+				Name:    tc.name,
+				Token:   crypto.HashTokenSHA256(plaintext),
+				Enabled: true,
+				Scope:   tc.scope,
+			}).Error; err != nil {
+				t.Fatalf("seed token: %v", err)
+			}
+
+			req := httptest.NewRequest(http.MethodPost, "/panel/api/server/updatePanel", nil)
+			req.Header.Set("Authorization", "Bearer "+plaintext)
+			w := httptest.NewRecorder()
+			engine.ServeHTTP(w, req)
+
+			if w.Code != tc.wantCode || w.Body.String() != tc.wantBody {
+				t.Fatalf("response = %d %s, want %d %s", w.Code, w.Body.String(), tc.wantCode, tc.wantBody)
+			}
+		})
+	}
+}
+
 // TestCheckAPIAuth_AcceptsVerifiedClientCert ensures verified mTLS authenticates
 // as node-sync rather than bypassing scope checks as admin.
 func TestCheckAPIAuth_AcceptsVerifiedClientCert(t *testing.T) {
@@ -135,6 +172,48 @@ func TestCheckAPIAuth_AcceptsVerifiedClientCert(t *testing.T) {
 	if w.Code != http.StatusForbidden {
 		t.Fatalf("updatePanel status = %d, want 403; body=%s", w.Code, w.Body.String())
 	}
+
+	for _, legacyScope := range []string{model.ApiScopeAdmin, model.ApiScopeMonitor, model.ApiScopeNodeSync} {
+		plaintext := "mtls-" + legacyScope + "-token"
+		if err := database.GetDB().Create(&model.ApiToken{
+			Name:    "mtls-" + legacyScope,
+			Token:   crypto.HashTokenSHA256(plaintext),
+			Enabled: true,
+			Scope:   legacyScope,
+		}).Error; err != nil {
+			t.Fatalf("seed %s token: %v", legacyScope, err)
+		}
+		legacy := httptest.NewRequest(http.MethodGet, "/panel/api/server/status", nil)
+		legacy.Header.Set("Authorization", "Bearer "+plaintext)
+		legacy.TLS = &tls.ConnectionState{
+			VerifiedChains: [][]*x509.Certificate{{&x509.Certificate{}}},
+		}
+		w = httptest.NewRecorder()
+		engine.ServeHTTP(w, legacy)
+		if w.Code != http.StatusOK || w.Body.String() != `{"api_authed":true,"scope":"node-sync"}` {
+			t.Fatalf("mTLS %s response = %d %s, want preserved node-sync scope", legacyScope, w.Code, w.Body.String())
+		}
+	}
+
+	const plaintext = "mtls-node-admin-token"
+	if err := database.GetDB().Create(&model.ApiToken{
+		Name:    "mtls-node-admin",
+		Token:   crypto.HashTokenSHA256(plaintext),
+		Enabled: true,
+		Scope:   model.ApiScopeNodeAdmin,
+	}).Error; err != nil {
+		t.Fatalf("seed node-admin token: %v", err)
+	}
+	allowed := httptest.NewRequest(http.MethodPost, "/panel/api/server/updatePanel", nil)
+	allowed.Header.Set("Authorization", "Bearer "+plaintext)
+	allowed.TLS = &tls.ConnectionState{
+		VerifiedChains: [][]*x509.Certificate{{&x509.Certificate{}}},
+	}
+	w = httptest.NewRecorder()
+	engine.ServeHTTP(w, allowed)
+	if w.Code != http.StatusOK || w.Body.String() != `{"reached":true}` {
+		t.Fatalf("mTLS node-admin response = %d %s, want 200 update launch", w.Code, w.Body.String())
+	}
 }
 
 func TestNodeSyncScopeUsesFullPathPatterns(t *testing.T) {

+ 3 - 2
internal/web/controller/inbound.go

@@ -64,10 +64,11 @@ func (a *InboundController) broadcastInboundsUpdate(userId int) {
 // node-sync push, so the node stores the row instead of re-judging it.
 func (a *InboundController) inboundServiceFor(c *gin.Context) *service.InboundService {
 	svc := a.inboundService
-	scope, _ := c.Get("api_token_scope")
+	scopeValue, _ := c.Get("api_token_scope")
+	scope, _ := scopeValue.(string)
 	// A master enrolled with an admin token (the -getApiToken default) has no
 	// node-sync scope, so it marks every request it sends instead.
-	svc.FromNodeSync = scope == model.ApiScopeNodeSync || c.GetHeader(wirecodec.MasterPushHeader) != ""
+	svc.FromNodeSync = model.IsNodeSyncApiScope(scope) || c.GetHeader(wirecodec.MasterPushHeader) != ""
 	return &svc
 }
 

+ 15 - 1
internal/web/controller/inbound_node_sync_test.go

@@ -29,7 +29,11 @@ func TestNodeSyncPushSkipsOperatorTLSGuard(t *testing.T) {
 	runtime.SetManager(runtime.NewManager(runtime.LocalDeps{APIPort: func() int { return 0 }, SetNeedRestart: func() {}}))
 	t.Cleanup(func() { runtime.SetManager(prev) })
 
-	for name, scope := range map[string]string{"node-sync": model.ApiScopeNodeSync, "admin": model.ApiScopeAdmin} {
+	for name, scope := range map[string]string{
+		"node-sync":  model.ApiScopeNodeSync,
+		"node-admin": model.ApiScopeNodeAdmin,
+		"admin":      model.ApiScopeAdmin,
+	} {
 		row := &model.ApiToken{Name: name, Token: crypto.HashTokenSHA256(name + "-token"), Enabled: true, Scope: scope}
 		if err := database.GetDB().Create(row).Error; err != nil {
 			t.Fatalf("seed %s token: %v", name, err)
@@ -79,6 +83,16 @@ func TestNodeSyncPushSkipsOperatorTLSGuard(t *testing.T) {
 		}
 	})
 
+	t.Run("a node admin push keeps node sync semantics", func(t *testing.T) {
+		body := add(t, "node-admin-token", 45003)
+		if !strings.Contains(body, `"success":true`) {
+			t.Fatalf("node-admin add rejected: %s", body)
+		}
+		if got := rows(t, "tls-legacy-45003"); got != 1 {
+			t.Fatalf("stored rows = %d, want 1", got)
+		}
+	})
+
 	t.Run("an operator token is still held to the guard", func(t *testing.T) {
 		body := add(t, "admin-token", 45002)
 		if !strings.Contains(body, `"success":false`) || !strings.Contains(body, "TLS") {

+ 3 - 1
internal/web/service/panel/api_token.go

@@ -60,8 +60,10 @@ func NormalizeScope(scope string) (string, error) {
 		return model.ApiScopeMonitor, nil
 	case model.ApiScopeNodeSync:
 		return model.ApiScopeNodeSync, nil
+	case model.ApiScopeNodeAdmin:
+		return model.ApiScopeNodeAdmin, nil
 	default:
-		return "", common.NewError("scope must be 'admin', 'monitor', or 'node-sync'")
+		return "", common.NewError("scope must be 'admin', 'monitor', 'node-sync', or 'node-admin'")
 	}
 }
 

+ 17 - 0
internal/web/service/panel/api_token_scope_test.go

@@ -83,6 +83,23 @@ func TestAPITokenScopeExpiryAndExpectedRevoke(t *testing.T) {
 	}
 }
 
+func TestAPITokenNodeAdminScopeRoundTrips(t *testing.T) {
+	setupAPITokenTestDB(t)
+	svc := &ApiTokenService{}
+
+	created, err := svc.Create("node-updater", model.ApiScopeNodeAdmin, 0)
+	if err != nil {
+		t.Fatalf("create node-admin token: %v", err)
+	}
+	row, ok := svc.MatchToken(created.Token)
+	if !ok {
+		t.Fatal("fresh node-admin token did not authenticate")
+	}
+	if created.Scope != model.ApiScopeNodeAdmin || row.Scope != model.ApiScopeNodeAdmin {
+		t.Fatalf("created/matched scopes = %q/%q, want node-admin/node-admin", created.Scope, row.Scope)
+	}
+}
+
 func TestAPITokenDeleteAndEnableRequireExpectedScope(t *testing.T) {
 	setupAPITokenTestDB(t)
 	svc := &ApiTokenService{}

+ 4 - 3
internal/web/service/panel/api_token_test.go

@@ -126,8 +126,9 @@ func TestRecreateByNameAppliesGivenScope(t *testing.T) {
 		wantErr   string
 	}{
 		{name: "replaces a monitor token as node-sync", seedScope: model.ApiScopeMonitor, scope: model.ApiScopeNodeSync, want: model.ApiScopeNodeSync},
+		{name: "replaces a monitor token as node-admin", seedScope: model.ApiScopeMonitor, scope: model.ApiScopeNodeAdmin, want: model.ApiScopeNodeAdmin},
 		{name: "creates a new token as monitor", scope: model.ApiScopeMonitor, want: model.ApiScopeMonitor},
-		{name: "refuses an unknown scope", seedScope: model.ApiScopeMonitor, scope: "root", want: model.ApiScopeMonitor, wantErr: "scope must be 'admin', 'monitor', or 'node-sync'"},
+		{name: "refuses an unknown scope", seedScope: model.ApiScopeMonitor, scope: "root", want: model.ApiScopeMonitor, wantErr: "scope must be 'admin', 'monitor', 'node-sync', or 'node-admin'"},
 	}
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
@@ -172,12 +173,12 @@ func TestRecreateByNameRefusesUnknownStoredScope(t *testing.T) {
 	dbtest.InitDB(t, config.GetDBPath())
 
 	db := database.GetDB()
-	stored := model.ApiToken{Name: "remote", Token: "stored-hash", Enabled: true, Scope: "node-admin"}
+	stored := model.ApiToken{Name: "remote", Token: "stored-hash", Enabled: true, Scope: "future-node-scope"}
 	if err := db.Create(&stored).Error; err != nil {
 		t.Fatalf("seed remote: %v", err)
 	}
 
-	const wantErr = `token "remote" has unknown scope "node-admin"`
+	const wantErr = `token "remote" has unknown scope "future-node-scope"`
 	_, err := (&ApiTokenService{}).RecreateByName("remote", "")
 	if err == nil || strings.TrimSpace(err.Error()) != wantErr {
 		t.Fatalf("error = %v, want %q", err, wantErr)

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

@@ -1425,6 +1425,15 @@
         "apiTokenName": "الاسم",
         "apiTokenNamePlaceholder": "مثل central-panel-a",
         "apiTokenNameRequired": "الاسم مطلوب",
+        "apiTokenScope": "نطاق الصلاحيات",
+        "apiTokenScopeAdmin": "مسؤول",
+        "apiTokenScopeAdminDesc": "وصول كامل إلى جميع نقاط API في اللوحة. استخدمه فقط للأتمتة الموثوقة.",
+        "apiTokenScopeMonitor": "مراقبة",
+        "apiTokenScopeMonitorDesc": "وصول للقراءة فقط إلى حالة النظام والإصدارات والمقاييس.",
+        "apiTokenScopeNodeSync": "مزامنة العقدة",
+        "apiTokenScopeNodeSyncDesc": "يزامن إعدادات العقدة والاتصالات الواردة والعملاء وحركة البيانات وحالة Xray. لا يمكنه تحديث برنامج اللوحة.",
+        "apiTokenScopeNodeAdmin": "مسؤول العقدة",
+        "apiTokenScopeNodeAdminDesc": "يتضمن صلاحيات مزامنة العقدة ويمكنه أيضًا بدء تحديثات برنامج اللوحة.",
         "apiTokenEmpty": "لا توجد رموز بعد — أنشئ واحدًا لمصادقة الروبوتات أو اللوحات البعيدة.",
         "apiTokenDeleteWarning": "أي عميل يستخدم هذا الرمز سيفقد المصادقة فورًا.",
         "apiTokenCreatedTitle": "تم إنشاء الرمز",

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

@@ -1547,6 +1547,15 @@
         "apiTokenName": "Name",
         "apiTokenNamePlaceholder": "e.g. central-panel-a",
         "apiTokenNameRequired": "Name is required",
+        "apiTokenScope": "Scope",
+        "apiTokenScopeAdmin": "Administrator",
+        "apiTokenScopeAdminDesc": "Full access to every panel API endpoint. Use only for trusted automation.",
+        "apiTokenScopeMonitor": "Monitoring",
+        "apiTokenScopeMonitorDesc": "Read-only access to system status, versions, and metrics.",
+        "apiTokenScopeNodeSync": "Node synchronization",
+        "apiTokenScopeNodeSyncDesc": "Synchronizes node configuration, inbounds, clients, traffic, and Xray state. Cannot update panel software.",
+        "apiTokenScopeNodeAdmin": "Node administrator",
+        "apiTokenScopeNodeAdminDesc": "Includes node synchronization permissions and can also start panel software updates.",
         "apiTokenEmpty": "No tokens yet — create one to authenticate bots or remote panels.",
         "apiTokenDeleteWarning": "Any caller using this token will stop authenticating immediately.",
         "apiTokenCreatedTitle": "Token created",

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

@@ -1425,6 +1425,15 @@
         "apiTokenName": "Nombre",
         "apiTokenNamePlaceholder": "por ejemplo central-panel-a",
         "apiTokenNameRequired": "El nombre es obligatorio",
+        "apiTokenScope": "Alcance",
+        "apiTokenScopeAdmin": "Administrador",
+        "apiTokenScopeAdminDesc": "Acceso completo a todos los endpoints de la API del panel. Úselo solo para automatizaciones de confianza.",
+        "apiTokenScopeMonitor": "Monitorización",
+        "apiTokenScopeMonitorDesc": "Acceso de solo lectura al estado del sistema, versiones y métricas.",
+        "apiTokenScopeNodeSync": "Sincronización de nodo",
+        "apiTokenScopeNodeSyncDesc": "Sincroniza la configuración del nodo, entradas, clientes, tráfico y estado de Xray. No puede actualizar el software del panel.",
+        "apiTokenScopeNodeAdmin": "Administrador de nodo",
+        "apiTokenScopeNodeAdminDesc": "Incluye los permisos de sincronización de nodo y también puede iniciar actualizaciones del software del panel.",
         "apiTokenEmpty": "Aún no hay tokens — crea uno para autenticar bots o paneles remotos.",
         "apiTokenDeleteWarning": "Cualquier cliente que use este token dejará de autenticarse inmediatamente.",
         "apiTokenCreatedTitle": "Token creado",

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

@@ -1429,6 +1429,15 @@
         "apiTokenName": "نام",
         "apiTokenNamePlaceholder": "مثلاً central-panel-a",
         "apiTokenNameRequired": "نام الزامی است",
+        "apiTokenScope": "محدوده دسترسی",
+        "apiTokenScopeAdmin": "مدیر",
+        "apiTokenScopeAdminDesc": "دسترسی کامل به همه نقاط پایانی API پنل. فقط برای اتوماسیون قابل اعتماد استفاده شود.",
+        "apiTokenScopeMonitor": "پایش",
+        "apiTokenScopeMonitorDesc": "دسترسی فقط خواندنی به وضعیت سیستم، نسخه‌ها و معیارها.",
+        "apiTokenScopeNodeSync": "همگام‌سازی گره",
+        "apiTokenScopeNodeSyncDesc": "پیکربندی گره، ورودی‌ها، کاربران، ترافیک و وضعیت Xray را همگام می‌کند. نمی‌تواند نرم‌افزار پنل را به‌روز کند.",
+        "apiTokenScopeNodeAdmin": "مدیر گره",
+        "apiTokenScopeNodeAdminDesc": "شامل مجوزهای همگام‌سازی گره است و می‌تواند به‌روزرسانی نرم‌افزار پنل را نیز آغاز کند.",
         "apiTokenEmpty": "هنوز توکنی وجود ندارد — برای احراز هویت ربات‌ها یا پنل‌های راه دور یکی بسازید.",
         "apiTokenDeleteWarning": "هر کلاینتی که از این توکن استفاده می‌کند بلافاصله احراز هویتش قطع می‌شود.",
         "apiTokenCreatedTitle": "توکن ساخته شد",

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

@@ -1425,6 +1425,15 @@
         "apiTokenName": "Nama",
         "apiTokenNamePlaceholder": "misalnya central-panel-a",
         "apiTokenNameRequired": "Nama wajib diisi",
+        "apiTokenScope": "Cakupan",
+        "apiTokenScopeAdmin": "Administrator",
+        "apiTokenScopeAdminDesc": "Akses penuh ke semua endpoint API panel. Gunakan hanya untuk otomatisasi tepercaya.",
+        "apiTokenScopeMonitor": "Pemantauan",
+        "apiTokenScopeMonitorDesc": "Akses baca-saja ke status sistem, versi, dan metrik.",
+        "apiTokenScopeNodeSync": "Sinkronisasi node",
+        "apiTokenScopeNodeSyncDesc": "Menyinkronkan konfigurasi node, inbound, klien, trafik, dan status Xray. Tidak dapat memperbarui perangkat lunak panel.",
+        "apiTokenScopeNodeAdmin": "Administrator node",
+        "apiTokenScopeNodeAdminDesc": "Mencakup izin sinkronisasi node dan juga dapat memulai pembaruan perangkat lunak panel.",
         "apiTokenEmpty": "Belum ada token — buat satu untuk mengautentikasi bot atau panel jarak jauh.",
         "apiTokenDeleteWarning": "Setiap pemanggil yang menggunakan token ini akan berhenti terautentikasi segera.",
         "apiTokenCreatedTitle": "Token dibuat",

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

@@ -1425,6 +1425,15 @@
         "apiTokenName": "名前",
         "apiTokenNamePlaceholder": "例: central-panel-a",
         "apiTokenNameRequired": "名前は必須です",
+        "apiTokenScope": "スコープ",
+        "apiTokenScopeAdmin": "管理者",
+        "apiTokenScopeAdminDesc": "パネルのすべての API エンドポイントへのフルアクセス。信頼できる自動化にのみ使用してください。",
+        "apiTokenScopeMonitor": "監視",
+        "apiTokenScopeMonitorDesc": "システム状態、バージョン、メトリクスへの読み取り専用アクセス。",
+        "apiTokenScopeNodeSync": "ノード同期",
+        "apiTokenScopeNodeSyncDesc": "ノード設定、インバウンド、クライアント、トラフィック、Xray 状態を同期します。パネルソフトウェアは更新できません。",
+        "apiTokenScopeNodeAdmin": "ノード管理者",
+        "apiTokenScopeNodeAdminDesc": "ノード同期権限に加え、パネルソフトウェアの更新も開始できます。",
         "apiTokenEmpty": "トークンがまだありません — ボットやリモートパネルを認証するために作成してください。",
         "apiTokenDeleteWarning": "このトークンを使用しているクライアントは直ちに認証できなくなります。",
         "apiTokenCreatedTitle": "トークンを作成しました",

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

@@ -1425,6 +1425,15 @@
         "apiTokenName": "Nome",
         "apiTokenNamePlaceholder": "ex.: central-panel-a",
         "apiTokenNameRequired": "O nome é obrigatório",
+        "apiTokenScope": "Escopo",
+        "apiTokenScopeAdmin": "Administrador",
+        "apiTokenScopeAdminDesc": "Acesso completo a todos os endpoints da API do painel. Use apenas para automações confiáveis.",
+        "apiTokenScopeMonitor": "Monitoramento",
+        "apiTokenScopeMonitorDesc": "Acesso somente leitura ao estado do sistema, versões e métricas.",
+        "apiTokenScopeNodeSync": "Sincronização de nó",
+        "apiTokenScopeNodeSyncDesc": "Sincroniza configuração do nó, entradas, clientes, tráfego e estado do Xray. Não pode atualizar o software do painel.",
+        "apiTokenScopeNodeAdmin": "Administrador de nó",
+        "apiTokenScopeNodeAdminDesc": "Inclui as permissões de sincronização do nó e também pode iniciar atualizações do software do painel.",
         "apiTokenEmpty": "Nenhum token ainda — crie um para autenticar bots ou painéis remotos.",
         "apiTokenDeleteWarning": "Qualquer cliente usando este token deixará de se autenticar imediatamente.",
         "apiTokenCreatedTitle": "Token criado",

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

@@ -1425,6 +1425,15 @@
         "apiTokenName": "Имя",
         "apiTokenNamePlaceholder": "например, central-panel-a",
         "apiTokenNameRequired": "Имя обязательно",
+        "apiTokenScope": "Область доступа",
+        "apiTokenScopeAdmin": "Администратор",
+        "apiTokenScopeAdminDesc": "Полный доступ ко всем API-методам панели. Используйте только для доверенной автоматизации.",
+        "apiTokenScopeMonitor": "Мониторинг",
+        "apiTokenScopeMonitorDesc": "Доступ только для чтения состояния системы, версий и метрик.",
+        "apiTokenScopeNodeSync": "Синхронизация узла",
+        "apiTokenScopeNodeSyncDesc": "Синхронизирует конфигурацию узла, входящие, клиентов, трафик и состояние Xray. Не может обновлять ПО панели.",
+        "apiTokenScopeNodeAdmin": "Администратор узла",
+        "apiTokenScopeNodeAdminDesc": "Включает права синхронизации узла и также может запускать обновление ПО панели.",
         "apiTokenEmpty": "Токенов пока нет — создайте один для аутентификации ботов или удалённых панелей.",
         "apiTokenDeleteWarning": "Любой клиент, использующий этот токен, немедленно потеряет аутентификацию.",
         "apiTokenCreatedTitle": "Токен создан",

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

@@ -1425,6 +1425,15 @@
         "apiTokenName": "Ad",
         "apiTokenNamePlaceholder": "örn. central-panel-a",
         "apiTokenNameRequired": "Ad zorunludur",
+        "apiTokenScope": "Kapsam",
+        "apiTokenScopeAdmin": "Yönetici",
+        "apiTokenScopeAdminDesc": "Paneldeki tüm API uç noktalarına tam erişim. Yalnızca güvenilir otomasyonlarda kullanın.",
+        "apiTokenScopeMonitor": "İzleme",
+        "apiTokenScopeMonitorDesc": "Sistem durumu, sürümler ve metriklere salt okunur erişim.",
+        "apiTokenScopeNodeSync": "Düğüm senkronizasyonu",
+        "apiTokenScopeNodeSyncDesc": "Düğüm yapılandırmasını, gelen bağlantıları, istemcileri, trafiği ve Xray durumunu senkronize eder. Panel yazılımını güncelleyemez.",
+        "apiTokenScopeNodeAdmin": "Düğüm yöneticisi",
+        "apiTokenScopeNodeAdminDesc": "Düğüm senkronizasyonu izinlerini içerir ve panel yazılımı güncellemelerini de başlatabilir.",
         "apiTokenEmpty": "Henüz token yok — botları veya uzak panelleri doğrulamak için bir tane oluşturun.",
         "apiTokenDeleteWarning": "Bu token'ı kullanan tüm bağlantılar anında kimlik doğrulamasını kaybeder.",
         "apiTokenCreatedTitle": "Token Oluşturuldu",

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

@@ -1425,6 +1425,15 @@
         "apiTokenName": "Назва",
         "apiTokenNamePlaceholder": "наприклад, central-panel-a",
         "apiTokenNameRequired": "Назва обов'язкова",
+        "apiTokenScope": "Область доступу",
+        "apiTokenScopeAdmin": "Адміністратор",
+        "apiTokenScopeAdminDesc": "Повний доступ до всіх API-методів панелі. Використовуйте лише для довіреної автоматизації.",
+        "apiTokenScopeMonitor": "Моніторинг",
+        "apiTokenScopeMonitorDesc": "Доступ лише для читання стану системи, версій і метрик.",
+        "apiTokenScopeNodeSync": "Синхронізація вузла",
+        "apiTokenScopeNodeSyncDesc": "Синхронізує конфігурацію вузла, вхідні, клієнтів, трафік і стан Xray. Не може оновлювати ПЗ панелі.",
+        "apiTokenScopeNodeAdmin": "Адміністратор вузла",
+        "apiTokenScopeNodeAdminDesc": "Включає права синхронізації вузла й також може запускати оновлення ПЗ панелі.",
         "apiTokenEmpty": "Поки немає токенів — створіть один для автентифікації ботів або віддалених панелей.",
         "apiTokenDeleteWarning": "Будь-який клієнт, що використовує цей токен, негайно втратить автентифікацію.",
         "apiTokenCreatedTitle": "Токен створено",

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

@@ -1425,6 +1425,15 @@
         "apiTokenName": "Tên",
         "apiTokenNamePlaceholder": "ví dụ: central-panel-a",
         "apiTokenNameRequired": "Tên là bắt buộc",
+        "apiTokenScope": "Phạm vi",
+        "apiTokenScopeAdmin": "Quản trị viên",
+        "apiTokenScopeAdminDesc": "Toàn quyền truy cập mọi endpoint API của panel. Chỉ dùng cho tự động hóa đáng tin cậy.",
+        "apiTokenScopeMonitor": "Giám sát",
+        "apiTokenScopeMonitorDesc": "Quyền chỉ đọc trạng thái hệ thống, phiên bản và số liệu.",
+        "apiTokenScopeNodeSync": "Đồng bộ node",
+        "apiTokenScopeNodeSyncDesc": "Đồng bộ cấu hình node, inbound, client, lưu lượng và trạng thái Xray. Không thể cập nhật phần mềm panel.",
+        "apiTokenScopeNodeAdmin": "Quản trị node",
+        "apiTokenScopeNodeAdminDesc": "Bao gồm quyền đồng bộ node và cũng có thể khởi chạy cập nhật phần mềm panel.",
         "apiTokenEmpty": "Chưa có token nào — tạo một token để xác thực bot hoặc panel từ xa.",
         "apiTokenDeleteWarning": "Mọi client đang dùng token này sẽ ngừng xác thực ngay lập tức.",
         "apiTokenCreatedTitle": "Đã tạo token",

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

@@ -1425,6 +1425,15 @@
         "apiTokenName": "名称",
         "apiTokenNamePlaceholder": "例如 central-panel-a",
         "apiTokenNameRequired": "名称必填",
+        "apiTokenScope": "权限范围",
+        "apiTokenScopeAdmin": "管理员",
+        "apiTokenScopeAdminDesc": "可访问面板的所有 API 端点。仅用于可信的自动化。",
+        "apiTokenScopeMonitor": "监控",
+        "apiTokenScopeMonitorDesc": "只读访问系统状态、版本和指标。",
+        "apiTokenScopeNodeSync": "节点同步",
+        "apiTokenScopeNodeSyncDesc": "同步节点配置、入站、客户端、流量和 Xray 状态。不能更新面板软件。",
+        "apiTokenScopeNodeAdmin": "节点管理员",
+        "apiTokenScopeNodeAdminDesc": "包含节点同步权限,还可以启动面板软件更新。",
         "apiTokenEmpty": "暂无令牌 — 创建一个用于认证机器人或远程面板。",
         "apiTokenDeleteWarning": "使用此令牌的任何调用方将立即无法认证。",
         "apiTokenCreatedTitle": "令牌已创建",

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

@@ -1425,6 +1425,15 @@
         "apiTokenName": "名稱",
         "apiTokenNamePlaceholder": "例如 central-panel-a",
         "apiTokenNameRequired": "名稱必填",
+        "apiTokenScope": "權限範圍",
+        "apiTokenScopeAdmin": "管理員",
+        "apiTokenScopeAdminDesc": "可存取面板的所有 API 端點。僅用於可信的自動化。",
+        "apiTokenScopeMonitor": "監控",
+        "apiTokenScopeMonitorDesc": "唯讀存取系統狀態、版本和指標。",
+        "apiTokenScopeNodeSync": "節點同步",
+        "apiTokenScopeNodeSyncDesc": "同步節點設定、入站、用戶端、流量和 Xray 狀態。無法更新面板軟體。",
+        "apiTokenScopeNodeAdmin": "節點管理員",
+        "apiTokenScopeNodeAdminDesc": "包含節點同步權限,也可以啟動面板軟體更新。",
         "apiTokenEmpty": "尚無令牌 — 建立一個以認證機器人或遠端面板。",
         "apiTokenDeleteWarning": "使用此令牌的任何呼叫方將立即無法認證。",
         "apiTokenCreatedTitle": "權杖已建立",

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