diff --git a/docs/architecture.md b/docs/architecture.md
index 59e1cfec8..39ecafe02 100644
--- a/docs/architecture.md
+++ b/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.
diff --git a/docs/content/docs/en/reference/api/api-tokens.mdx b/docs/content/docs/en/reference/api/api-tokens.mdx
index f47ca9f2d..963eaf3db 100644
--- a/docs/content/docs/en/reference/api/api-tokens.mdx
+++ b/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}
-
+
>
);
}
\ No newline at end of file
diff --git a/docs/content/docs/en/reference/api/authentication.mdx b/docs/content/docs/en/reference/api/authentication.mdx
index 8ba39be38..16e2d7b49 100644
--- a/docs/content/docs/en/reference/api/authentication.mdx
+++ b/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}
-
+
>
);
}
\ No newline at end of file
diff --git a/docs/content/docs/en/reference/api/backup.mdx b/docs/content/docs/en/reference/api/backup.mdx
index de5b9a272..f29de1513 100644
--- a/docs/content/docs/en/reference/api/backup.mdx
+++ b/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}
-
+
>
);
}
\ No newline at end of file
diff --git a/docs/content/docs/en/reference/api/clients.mdx b/docs/content/docs/en/reference/api/clients.mdx
index 4031fbb49..d120a8e3e 100644
--- a/docs/content/docs/en/reference/api/clients.mdx
+++ b/docs/content/docs/en/reference/api/clients.mdx
@@ -663,13 +663,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}
-
+
>
);
}
\ No newline at end of file
diff --git a/docs/content/docs/en/reference/api/hosts.mdx b/docs/content/docs/en/reference/api/hosts.mdx
index 92dc7ac0e..0ff41553f 100644
--- a/docs/content/docs/en/reference/api/hosts.mdx
+++ b/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}
-
+
>
);
}
\ No newline at end of file
diff --git a/docs/content/docs/en/reference/api/inbounds.mdx b/docs/content/docs/en/reference/api/inbounds.mdx
index a27dc3e73..3eaac929a 100644
--- a/docs/content/docs/en/reference/api/inbounds.mdx
+++ b/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}
-
+
>
);
}
\ No newline at end of file
diff --git a/docs/content/docs/en/reference/api/nodes.mdx b/docs/content/docs/en/reference/api/nodes.mdx
index 6cf408989..c81d856e9 100644
--- a/docs/content/docs/en/reference/api/nodes.mdx
+++ b/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}
-
+
>
);
}
\ No newline at end of file
diff --git a/docs/content/docs/en/reference/api/server.mdx b/docs/content/docs/en/reference/api/server.mdx
index d817ee18c..58cd3dbdc 100644
--- a/docs/content/docs/en/reference/api/server.mdx
+++ b/docs/content/docs/en/reference/api/server.mdx
@@ -114,8 +114,8 @@ _openapi:
url: '#download-and-install-the-specified-xray-version-pass-latest-for-the-newest-release'
- depth: 2
title: Self-update the panel to the latest version. The server restarts on
- success.
- url: '#self-update-the-panel-to-the-latest-version-the-server-restarts-on-success'
+ success. Bearer-token callers need admin or node-admin scope.
+ url: '#self-update-the-panel-to-the-latest-version-the-server-restarts-on-success-bearer-token-callers-need-admin-or-node-admin-scope'
- depth: 2
title: Toggle the panel update channel between stable and the rolling per-commit
dev release. Only effective on dev builds.
@@ -263,8 +263,8 @@ _openapi:
newest release.
id: download-and-install-the-specified-xray-version-pass-latest-for-the-newest-release
- content: Self-update the panel to the latest version. The server restarts on
- success.
- id: self-update-the-panel-to-the-latest-version-the-server-restarts-on-success
+ success. Bearer-token callers need admin or node-admin scope.
+ id: self-update-the-panel-to-the-latest-version-the-server-restarts-on-success-bearer-token-callers-need-admin-or-node-admin-scope
- content: Toggle the panel update channel between stable and the rolling
per-commit dev release. Only effective on dev builds.
id: toggle-the-panel-update-channel-between-stable-and-the-rolling-per-commit-dev-release-only-effective-on-dev-builds
@@ -321,13 +321,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}
-
+
>
);
}
\ No newline at end of file
diff --git a/docs/content/docs/en/reference/api/settings.mdx b/docs/content/docs/en/reference/api/settings.mdx
index 442de9f1a..df46620ec 100644
--- a/docs/content/docs/en/reference/api/settings.mdx
+++ b/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}
-
+
>
);
}
\ No newline at end of file
diff --git a/docs/content/docs/en/reference/api/subscription-balancers.mdx b/docs/content/docs/en/reference/api/subscription-balancers.mdx
index 6acdc9912..1efa91574 100644
--- a/docs/content/docs/en/reference/api/subscription-balancers.mdx
+++ b/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}
-
+
>
);
}
\ No newline at end of file
diff --git a/docs/content/docs/en/reference/api/subscription-server.mdx b/docs/content/docs/en/reference/api/subscription-server.mdx
index 7fd6bc674..9781ff90a 100644
--- a/docs/content/docs/en/reference/api/subscription-server.mdx
+++ b/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}
-
+
>
);
}
\ No newline at end of file
diff --git a/docs/content/docs/en/reference/api/websocket.mdx b/docs/content/docs/en/reference/api/websocket.mdx
index bd842d540..baec3c25d 100644
--- a/docs/content/docs/en/reference/api/websocket.mdx
+++ b/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}
-
+
>
);
}
\ No newline at end of file
diff --git a/docs/content/docs/en/reference/api/xray-settings.mdx b/docs/content/docs/en/reference/api/xray-settings.mdx
index 4576a0687..531d3f058 100644
--- a/docs/content/docs/en/reference/api/xray-settings.mdx
+++ b/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}
-
+
>
);
}
\ No newline at end of file
diff --git a/docs/public/openapi.json b/docs/public/openapi.json
index b50cb368f..d36661bd5 100644
--- a/docs/public/openapi.json
+++ b/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 `."
+ "description": "Scoped API token from Settings → Security → API Token. Send as `Authorization: Bearer `."
},
"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
}
}
diff --git a/frontend/public/openapi.json b/frontend/public/openapi.json
index b50cb368f..d36661bd5 100644
--- a/frontend/public/openapi.json
+++ b/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 `."
+ "description": "Scoped API token from Settings → Security → API Token. Send as `Authorization: Bearer `."
},
"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
}
}
diff --git a/frontend/scripts/build-openapi.mjs b/frontend/scripts/build-openapi.mjs
index 8cd3ff844..e2b4e5d2e 100644
--- a/frontend/scripts/build-openapi.mjs
+++ b/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 `.',
+ 'Scoped API token from Settings → Security → API Token. Send as `Authorization: Bearer `.',
},
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: {
diff --git a/frontend/src/pages/api-docs/endpoints.ts b/frontend/src/pages/api-docs/endpoints.ts
index a0b343a5b..5d6bddd2d 100644
--- a/frontend/src/pages/api-docs/endpoints.ts
+++ b/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}',
diff --git a/frontend/src/pages/settings/SecurityTab.css b/frontend/src/pages/settings/SecurityTab.css
index 87a6d5f49..2b2237d03 100644
--- a/frontend/src/pages/settings/SecurityTab.css
+++ b/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;
diff --git a/frontend/src/pages/settings/SecurityTab.tsx b/frontend/src/pages/settings/SecurityTab.tsx
index eb25b51dd..570cfef76 100644
--- a/frontend/src/pages/settings/SecurityTab.tsx
+++ b/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 {
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('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 = {
+ 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 }:
- {row.name}
+
+ {row.name}
+
+ {scopeMeta[row.scope].label}
+
+
{formatTokenDate(row.createdAt)}
@@ -396,6 +442,21 @@ export default function SecurityTab({ allSetting, updateSetting, saveSetting }:
onPressEnter={confirmCreateToken}
/>
+
+
diff --git a/frontend/src/test/api-token-date.test.tsx b/frontend/src/test/api-token-date.test.tsx
index 99a218583..2925f85cd 100644
--- a/frontend/src/test/api-token-date.test.tsx
+++ b/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(
+ ,
+ );
+ 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',
+ }),
+ );
});
});
diff --git a/internal/database/model/model.go b/internal/database/model/model.go
index 18c61158f..e2b5f2a43 100644
--- a/internal/database/model/model.go
+++ b/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 {
diff --git a/internal/web/controller/api.go b/internal/web/controller/api.go
index 202b58490..b77ba2a59 100644
--- a/internal/web/controller/api.go
+++ b/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()
diff --git a/internal/web/controller/api_auth_test.go b/internal/web/controller/api_auth_test.go
index e454d6597..4d224a3b3 100644
--- a/internal/web/controller/api_auth_test.go
+++ b/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) {
diff --git a/internal/web/controller/inbound.go b/internal/web/controller/inbound.go
index e990fe25f..44c96624d 100644
--- a/internal/web/controller/inbound.go
+++ b/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
}
diff --git a/internal/web/controller/inbound_node_sync_test.go b/internal/web/controller/inbound_node_sync_test.go
index 9b86da12e..c90fe92fc 100644
--- a/internal/web/controller/inbound_node_sync_test.go
+++ b/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") {
diff --git a/internal/web/service/panel/api_token.go b/internal/web/service/panel/api_token.go
index 190bf23d6..7ff8427aa 100644
--- a/internal/web/service/panel/api_token.go
+++ b/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'")
}
}
diff --git a/internal/web/service/panel/api_token_scope_test.go b/internal/web/service/panel/api_token_scope_test.go
index ff7518dd7..949f62480 100644
--- a/internal/web/service/panel/api_token_scope_test.go
+++ b/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{}
diff --git a/internal/web/service/panel/api_token_test.go b/internal/web/service/panel/api_token_test.go
index 1135cbca6..28933df20 100644
--- a/internal/web/service/panel/api_token_test.go
+++ b/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)
diff --git a/internal/web/translation/ar-EG.json b/internal/web/translation/ar-EG.json
index f66b4531c..06df99f00 100644
--- a/internal/web/translation/ar-EG.json
+++ b/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": "تم إنشاء الرمز",
diff --git a/internal/web/translation/en-US.json b/internal/web/translation/en-US.json
index 2ceb3b546..d4f46a98c 100644
--- a/internal/web/translation/en-US.json
+++ b/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",
diff --git a/internal/web/translation/es-ES.json b/internal/web/translation/es-ES.json
index 6fa03153e..879f7e9ce 100644
--- a/internal/web/translation/es-ES.json
+++ b/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",
diff --git a/internal/web/translation/fa-IR.json b/internal/web/translation/fa-IR.json
index 3b7cf60ec..4654f5c6b 100644
--- a/internal/web/translation/fa-IR.json
+++ b/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": "توکن ساخته شد",
diff --git a/internal/web/translation/id-ID.json b/internal/web/translation/id-ID.json
index 23895aaaf..df2c250db 100644
--- a/internal/web/translation/id-ID.json
+++ b/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",
diff --git a/internal/web/translation/ja-JP.json b/internal/web/translation/ja-JP.json
index 2ba08d6ba..e63496918 100644
--- a/internal/web/translation/ja-JP.json
+++ b/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": "トークンを作成しました",
diff --git a/internal/web/translation/pt-BR.json b/internal/web/translation/pt-BR.json
index a4331047e..8c75c3f1d 100644
--- a/internal/web/translation/pt-BR.json
+++ b/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",
diff --git a/internal/web/translation/ru-RU.json b/internal/web/translation/ru-RU.json
index 959047619..fe6c4899a 100644
--- a/internal/web/translation/ru-RU.json
+++ b/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": "Токен создан",
diff --git a/internal/web/translation/tr-TR.json b/internal/web/translation/tr-TR.json
index cefea4fd2..97fd34f3a 100644
--- a/internal/web/translation/tr-TR.json
+++ b/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",
diff --git a/internal/web/translation/uk-UA.json b/internal/web/translation/uk-UA.json
index 358ec5eec..b8530c432 100644
--- a/internal/web/translation/uk-UA.json
+++ b/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": "Токен створено",
diff --git a/internal/web/translation/vi-VN.json b/internal/web/translation/vi-VN.json
index 277f25272..715ad6365 100644
--- a/internal/web/translation/vi-VN.json
+++ b/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",
diff --git a/internal/web/translation/zh-CN.json b/internal/web/translation/zh-CN.json
index c26aa4ca2..ad8baed3d 100644
--- a/internal/web/translation/zh-CN.json
+++ b/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": "令牌已创建",
diff --git a/internal/web/translation/zh-TW.json b/internal/web/translation/zh-TW.json
index ebbecebf5..0f976139c 100644
--- a/internal/web/translation/zh-TW.json
+++ b/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": "權杖已建立",