mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-10-19 12:25:00 +03:00
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 <333915961+libmur-dev@users.noreply.github.com>
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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}',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user