mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-10-22 13:55:01 +03:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 60ba66f87a | |||
| a8d0700bf2 | |||
| a1f2589eb5 |
+2
-2
@@ -1,3 +1,3 @@
|
||||
# LF in every checkout, Windows included: format-check, the msw worker check and
|
||||
# tests that parse repo files compare bytes, so a CRLF working copy fails them.
|
||||
# LF in every checkout, Windows included: format-check and tests that parse repo
|
||||
# files compare bytes, so a CRLF working copy fails them.
|
||||
* text=auto eol=lf
|
||||
|
||||
@@ -152,7 +152,7 @@ It is paths-filtered, so a docs-only or workflow-only change produces no run.
|
||||
| `golangci` | `golangci-lint` |
|
||||
| `fuzz-smoke` | 30s each on `FuzzParseLink` and `FuzzDecodeCertPin` |
|
||||
| `codegen` | `npm run gen` then `git diff --exit-code` on the generated files |
|
||||
| `frontend` | MSW worker drift, lint, format:check, typecheck, `npm test` (Vitest + headless-Chromium Storybook), build, build-storybook, `npm audit` |
|
||||
| `frontend` | lint, format:check, typecheck, `npm test` (Vitest + headless-Chromium Storybook), build, build-storybook, `npm audit` |
|
||||
|
||||
**What CI does NOT prove.** These test families `t.Skip` unless an environment
|
||||
variable is set, and CI sets only the PostgreSQL ones above:
|
||||
|
||||
@@ -201,9 +201,6 @@ jobs:
|
||||
- name: Install
|
||||
run: npm ci
|
||||
working-directory: frontend
|
||||
- name: Verify generated MSW worker is current
|
||||
run: git diff --exit-code -- public/mockServiceWorker.js package-lock.json
|
||||
working-directory: frontend
|
||||
- name: Lint
|
||||
run: npm run lint
|
||||
working-directory: frontend
|
||||
|
||||
@@ -45,10 +45,6 @@ format-check: ## oxfmt in check mode on frontend sources
|
||||
typecheck: ## tsc --noEmit
|
||||
cd $(FRONTEND) && npm run typecheck
|
||||
|
||||
.PHONY: msw-worker-check
|
||||
msw-worker-check: ## Verify the tracked worker matches the installed MSW runtime
|
||||
cmp $(FRONTEND)/public/mockServiceWorker.js $(FRONTEND)/node_modules/msw/lib/mockServiceWorker.js
|
||||
|
||||
.PHONY: test-go
|
||||
test-go: dist-stub ## Go tests (shuffle, no cache)
|
||||
go test -shuffle=on -count=1 $(GO_PKGS)
|
||||
@@ -91,5 +87,5 @@ build-storybook: ## Build the static Storybook (compile-checks all stories)
|
||||
# The PR gate. Matches ci.yml: codegen freshness, both linters, the formatter,
|
||||
# typecheck, both test suites, a full build, and the Storybook compile-check.
|
||||
.PHONY: verify
|
||||
verify: gen-check lint format-check typecheck msw-worker-check test build build-storybook ## Full local gate (mirrors CI)
|
||||
verify: gen-check lint format-check typecheck test build build-storybook ## Full local gate (mirrors CI)
|
||||
@echo "verify: OK"
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
<Comp document="./public/openapi.json" webhooks={[]} operations={[{"path":"/panel/api/clients/list","method":"get"},{"path":"/panel/api/clients/list/paged","method":"get"},{"path":"/panel/api/clients/get/{email}","method":"get"},{"path":"/panel/api/clients/get/tgId/{tgId}","method":"get"},{"path":"/panel/api/clients/add","method":"post"},{"path":"/panel/api/clients/renewalPreview","method":"post"},{"path":"/panel/api/clients/update/{email}","method":"post"},{"path":"/panel/api/clients/del/{email}","method":"post"},{"path":"/panel/api/clients/{email}/attach","method":"post"},{"path":"/panel/api/clients/{email}/detach","method":"post"},{"path":"/panel/api/clients/{email}/externalLinks","method":"post"},{"path":"/panel/api/clients/resetAllTraffics","method":"post"},{"path":"/panel/api/clients/delDepleted","method":"post"},{"path":"/panel/api/clients/delOrphans","method":"post"},{"path":"/panel/api/clients/export","method":"get"},{"path":"/panel/api/clients/import","method":"post"},{"path":"/panel/api/clients/bulkAdjust","method":"post"},{"path":"/panel/api/clients/bulkEnable","method":"post"},{"path":"/panel/api/clients/bulkDisable","method":"post"},{"path":"/panel/api/clients/bulkDel","method":"post"},{"path":"/panel/api/clients/bulkCreate","method":"post"},{"path":"/panel/api/clients/groups/bulkAdd","method":"post"},{"path":"/panel/api/clients/groups/bulkRemove","method":"post"},{"path":"/panel/api/clients/bulkAttach","method":"post"},{"path":"/panel/api/clients/bulkDetach","method":"post"},{"path":"/panel/api/clients/bulkResetTraffic","method":"post"},{"path":"/panel/api/clients/groups","method":"get"},{"path":"/panel/api/clients/groups/{name}/emails","method":"get"},{"path":"/panel/api/clients/groups/create","method":"post"},{"path":"/panel/api/clients/groups/rename","method":"post"},{"path":"/panel/api/clients/groups/delete","method":"post"},{"path":"/panel/api/clients/groups/resetTraffic","method":"post"},{"path":"/panel/api/clients/resetTraffic/{email}","method":"post"},{"path":"/panel/api/clients/updateTraffic/{email}","method":"post"},{"path":"/panel/api/clients/ips/{email}","method":"post"},{"path":"/panel/api/clients/clearIps/{email}","method":"post"},{"path":"/panel/api/clients/hwids/{email}","method":"post"},{"path":"/panel/api/clients/hwids/{email}","method":"delete"},{"path":"/panel/api/clients/hwids/{email}/{id}","method":"delete"},{"path":"/panel/api/clients/onlines","method":"post"},{"path":"/panel/api/clients/onlinesByGuid","method":"post"},{"path":"/panel/api/clients/clientIpsByGuid","method":"post"},{"path":"/panel/api/clients/activeInbounds","method":"post"},{"path":"/panel/api/clients/lastOnline","method":"post"},{"path":"/panel/api/clients/traffic/{email}","method":"get"},{"path":"/panel/api/clients/subLinks/{subId}","method":"get"},{"path":"/panel/api/clients/happLink/{id}","method":"post"},{"path":"/panel/api/clients/links/{email}","method":"get"}]} showTitle />
|
||||
<OpenAPIPage document="./public/openapi.json" webhooks={[]} operations={[{"path":"/panel/api/clients/list","method":"get"},{"path":"/panel/api/clients/list/paged","method":"get"},{"path":"/panel/api/clients/get/{email}","method":"get"},{"path":"/panel/api/clients/get/tgId/{tgId}","method":"get"},{"path":"/panel/api/clients/add","method":"post"},{"path":"/panel/api/clients/renewalPreview","method":"post"},{"path":"/panel/api/clients/update/{email}","method":"post"},{"path":"/panel/api/clients/del/{email}","method":"post"},{"path":"/panel/api/clients/{email}/attach","method":"post"},{"path":"/panel/api/clients/{email}/detach","method":"post"},{"path":"/panel/api/clients/{email}/externalLinks","method":"post"},{"path":"/panel/api/clients/resetAllTraffics","method":"post"},{"path":"/panel/api/clients/delDepleted","method":"post"},{"path":"/panel/api/clients/delOrphans","method":"post"},{"path":"/panel/api/clients/export","method":"get"},{"path":"/panel/api/clients/import","method":"post"},{"path":"/panel/api/clients/bulkAdjust","method":"post"},{"path":"/panel/api/clients/bulkEnable","method":"post"},{"path":"/panel/api/clients/bulkDisable","method":"post"},{"path":"/panel/api/clients/bulkDel","method":"post"},{"path":"/panel/api/clients/bulkCreate","method":"post"},{"path":"/panel/api/clients/groups/bulkAdd","method":"post"},{"path":"/panel/api/clients/groups/bulkRemove","method":"post"},{"path":"/panel/api/clients/bulkAttach","method":"post"},{"path":"/panel/api/clients/bulkDetach","method":"post"},{"path":"/panel/api/clients/bulkResetTraffic","method":"post"},{"path":"/panel/api/clients/groups","method":"get"},{"path":"/panel/api/clients/groups/{name}/emails","method":"get"},{"path":"/panel/api/clients/groups/create","method":"post"},{"path":"/panel/api/clients/groups/rename","method":"post"},{"path":"/panel/api/clients/groups/delete","method":"post"},{"path":"/panel/api/clients/groups/resetTraffic","method":"post"},{"path":"/panel/api/clients/resetTraffic/{email}","method":"post"},{"path":"/panel/api/clients/updateTraffic/{email}","method":"post"},{"path":"/panel/api/clients/ips/{email}","method":"post"},{"path":"/panel/api/clients/clearIps/{email}","method":"post"},{"path":"/panel/api/clients/hwids/{email}","method":"post"},{"path":"/panel/api/clients/hwids/{email}","method":"delete"},{"path":"/panel/api/clients/hwids/{email}/{id}","method":"delete"},{"path":"/panel/api/clients/onlines","method":"post"},{"path":"/panel/api/clients/onlinesByGuid","method":"post"},{"path":"/panel/api/clients/clientIpsByGuid","method":"post"},{"path":"/panel/api/clients/activeInbounds","method":"post"},{"path":"/panel/api/clients/lastOnline","method":"post"},{"path":"/panel/api/clients/traffic/{email}","method":"get"},{"path":"/panel/api/clients/subLinks/{subId}","method":"get"},{"path":"/panel/api/clients/happLink/{id}","method":"post"},{"path":"/panel/api/clients/links/{email}","method":"get"}]} showTitle />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
<Comp document="./public/openapi.json" webhooks={[]} operations={[{"path":"/panel/api/openapi.json","method":"get"},{"path":"/panel/api/server/status","method":"get"},{"path":"/panel/api/server/fail2banStatus","method":"get"},{"path":"/panel/api/server/cpuHistory/{bucket}","method":"get"},{"path":"/panel/api/server/history/{metric}/{bucket}","method":"get"},{"path":"/panel/api/server/xrayMetricsState","method":"get"},{"path":"/panel/api/server/xrayMetricsHistory/{metric}/{bucket}","method":"get"},{"path":"/panel/api/server/xrayObservatory","method":"get"},{"path":"/panel/api/server/xrayObservatoryHistory/{tag}/{bucket}","method":"get"},{"path":"/panel/api/server/getXrayVersion","method":"get"},{"path":"/panel/api/server/getPanelUpdateInfo","method":"get"},{"path":"/panel/api/server/getUpdateStatus","method":"get"},{"path":"/panel/api/server/getConfigJson","method":"get"},{"path":"/panel/api/server/getDb","method":"get"},{"path":"/panel/api/server/getMigration","method":"get"},{"path":"/panel/api/server/getNewUUID","method":"get"},{"path":"/panel/api/server/getWebCertFiles","method":"get"},{"path":"/panel/api/server/descendants","method":"get"},{"path":"/panel/api/server/getNewX25519Cert","method":"get"},{"path":"/panel/api/server/getNewmldsa65","method":"get"},{"path":"/panel/api/server/getNewmlkem768","method":"get"},{"path":"/panel/api/server/getNewVlessEnc","method":"get"},{"path":"/panel/api/server/stopXrayService","method":"post"},{"path":"/panel/api/server/restartXrayService","method":"post"},{"path":"/panel/api/server/installXray/{version}","method":"post"},{"path":"/panel/api/server/updatePanel","method":"post"},{"path":"/panel/api/server/setUpdateChannel","method":"post"},{"path":"/panel/api/server/updateGeofile","method":"post"},{"path":"/panel/api/server/updateGeofile/{fileName}","method":"post"},{"path":"/panel/api/server/logs/{count}","method":"post"},{"path":"/panel/api/server/xraylogs/{count}","method":"post"},{"path":"/panel/api/server/amneziawglogs/{count}","method":"post"},{"path":"/panel/api/server/importDB","method":"post"},{"path":"/panel/api/server/getNewEchCert","method":"post"},{"path":"/panel/api/server/getCertHash","method":"post"},{"path":"/panel/api/server/getRemoteCertHash","method":"post"},{"path":"/panel/api/server/scanRealityTarget","method":"post"},{"path":"/panel/api/server/scanRealityTargets","method":"post"},{"path":"/panel/api/server/clientIps","method":"get"},{"path":"/panel/api/server/clientIps","method":"post"}]} showTitle />
|
||||
<OpenAPIPage document="./public/openapi.json" webhooks={[]} operations={[{"path":"/panel/api/openapi.json","method":"get"},{"path":"/panel/api/server/status","method":"get"},{"path":"/panel/api/server/fail2banStatus","method":"get"},{"path":"/panel/api/server/cpuHistory/{bucket}","method":"get"},{"path":"/panel/api/server/history/{metric}/{bucket}","method":"get"},{"path":"/panel/api/server/xrayMetricsState","method":"get"},{"path":"/panel/api/server/xrayMetricsHistory/{metric}/{bucket}","method":"get"},{"path":"/panel/api/server/xrayObservatory","method":"get"},{"path":"/panel/api/server/xrayObservatoryHistory/{tag}/{bucket}","method":"get"},{"path":"/panel/api/server/getXrayVersion","method":"get"},{"path":"/panel/api/server/getPanelUpdateInfo","method":"get"},{"path":"/panel/api/server/getUpdateStatus","method":"get"},{"path":"/panel/api/server/getConfigJson","method":"get"},{"path":"/panel/api/server/getDb","method":"get"},{"path":"/panel/api/server/getMigration","method":"get"},{"path":"/panel/api/server/getNewUUID","method":"get"},{"path":"/panel/api/server/getWebCertFiles","method":"get"},{"path":"/panel/api/server/descendants","method":"get"},{"path":"/panel/api/server/getNewX25519Cert","method":"get"},{"path":"/panel/api/server/getNewmldsa65","method":"get"},{"path":"/panel/api/server/getNewmlkem768","method":"get"},{"path":"/panel/api/server/getNewVlessEnc","method":"get"},{"path":"/panel/api/server/stopXrayService","method":"post"},{"path":"/panel/api/server/restartXrayService","method":"post"},{"path":"/panel/api/server/installXray/{version}","method":"post"},{"path":"/panel/api/server/updatePanel","method":"post"},{"path":"/panel/api/server/setUpdateChannel","method":"post"},{"path":"/panel/api/server/updateGeofile","method":"post"},{"path":"/panel/api/server/updateGeofile/{fileName}","method":"post"},{"path":"/panel/api/server/logs/{count}","method":"post"},{"path":"/panel/api/server/xraylogs/{count}","method":"post"},{"path":"/panel/api/server/amneziawglogs/{count}","method":"post"},{"path":"/panel/api/server/importDB","method":"post"},{"path":"/panel/api/server/getNewEchCert","method":"post"},{"path":"/panel/api/server/getCertHash","method":"post"},{"path":"/panel/api/server/getRemoteCertHash","method":"post"},{"path":"/panel/api/server/scanRealityTarget","method":"post"},{"path":"/panel/api/server/scanRealityTargets","method":"post"},{"path":"/panel/api/server/clientIps","method":"get"},{"path":"/panel/api/server/clientIps","method":"post"}]} showTitle />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,10 +115,5 @@
|
||||
"tree-sitter-json": false,
|
||||
"msw": false,
|
||||
"esbuild": false
|
||||
},
|
||||
"msw": {
|
||||
"workerDirectory": [
|
||||
"public"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,361 +0,0 @@
|
||||
/* eslint-disable */
|
||||
/* tslint:disable */
|
||||
|
||||
/**
|
||||
* Mock Service Worker.
|
||||
* @see https://github.com/mswjs/msw
|
||||
* - Please do NOT modify this file.
|
||||
*/
|
||||
|
||||
const PACKAGE_VERSION = '2.15.0'
|
||||
const INTEGRITY_CHECKSUM = '03cb67ac84128e63d7cd722a6e5b7f1e'
|
||||
const IS_MOCKED_RESPONSE = Symbol('isMockedResponse')
|
||||
const activeClientIds = new Set()
|
||||
|
||||
addEventListener('install', function () {
|
||||
self.skipWaiting()
|
||||
})
|
||||
|
||||
addEventListener('activate', function (event) {
|
||||
event.waitUntil(self.clients.claim())
|
||||
})
|
||||
|
||||
addEventListener('message', async function (event) {
|
||||
const clientId = Reflect.get(event.source || {}, 'id')
|
||||
|
||||
if (!clientId || !self.clients) {
|
||||
return
|
||||
}
|
||||
|
||||
const client = await self.clients.get(clientId)
|
||||
|
||||
if (!client) {
|
||||
return
|
||||
}
|
||||
|
||||
const allClients = await self.clients.matchAll({
|
||||
type: 'window',
|
||||
})
|
||||
|
||||
switch (event.data) {
|
||||
case 'KEEPALIVE_REQUEST': {
|
||||
sendToClient(client, {
|
||||
type: 'KEEPALIVE_RESPONSE',
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'INTEGRITY_CHECK_REQUEST': {
|
||||
sendToClient(client, {
|
||||
type: 'INTEGRITY_CHECK_RESPONSE',
|
||||
payload: {
|
||||
packageVersion: PACKAGE_VERSION,
|
||||
checksum: INTEGRITY_CHECKSUM,
|
||||
},
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'MOCK_ACTIVATE': {
|
||||
activeClientIds.add(clientId)
|
||||
|
||||
sendToClient(client, {
|
||||
type: 'MOCKING_ENABLED',
|
||||
payload: {
|
||||
client: {
|
||||
id: client.id,
|
||||
frameType: client.frameType,
|
||||
},
|
||||
},
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'CLIENT_CLOSED': {
|
||||
activeClientIds.delete(clientId)
|
||||
|
||||
const remainingClients = allClients.filter((client) => {
|
||||
return client.id !== clientId
|
||||
})
|
||||
|
||||
// Unregister itself when there are no more clients
|
||||
if (remainingClients.length === 0) {
|
||||
self.registration.unregister()
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
addEventListener('fetch', function (event) {
|
||||
const requestInterceptedAt = Date.now()
|
||||
|
||||
// Bypass navigation requests.
|
||||
if (event.request.mode === 'navigate') {
|
||||
return
|
||||
}
|
||||
|
||||
// Opening the DevTools triggers the "only-if-cached" request
|
||||
// that cannot be handled by the worker. Bypass such requests.
|
||||
if (
|
||||
event.request.cache === 'only-if-cached' &&
|
||||
event.request.mode !== 'same-origin'
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
// Bypass all requests when there are no active clients.
|
||||
// Prevents the self-unregistered worked from handling requests
|
||||
// after it's been terminated (still remains active until the next reload).
|
||||
if (activeClientIds.size === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const requestId = crypto.randomUUID()
|
||||
event.respondWith(handleRequest(event, requestId, requestInterceptedAt))
|
||||
})
|
||||
|
||||
/**
|
||||
* @param {FetchEvent} event
|
||||
* @param {string} requestId
|
||||
* @param {number} requestInterceptedAt
|
||||
*/
|
||||
async function handleRequest(event, requestId, requestInterceptedAt) {
|
||||
const client = await resolveMainClient(event)
|
||||
const requestCloneForEvents = event.request.clone()
|
||||
const response = await getResponse(
|
||||
event,
|
||||
client,
|
||||
requestId,
|
||||
requestInterceptedAt,
|
||||
)
|
||||
|
||||
// Send back the response clone for the "response:*" life-cycle events.
|
||||
// Ensure MSW is active and ready to handle the message, otherwise
|
||||
// this message will pend indefinitely.
|
||||
if (client && activeClientIds.has(client.id)) {
|
||||
const serializedRequest = await serializeRequest(requestCloneForEvents)
|
||||
|
||||
// Omit the body of server-sent event stream responses.
|
||||
// Cloning such responses would prevent client-side stream cancelations
|
||||
// from reaching the original stream (a teed stream only cancels its
|
||||
// source once both of its branches cancel) and would buffer the
|
||||
// entire stream into the unconsumed clone indefinitely.
|
||||
const isEventStreamResponse = response.headers
|
||||
.get('content-type')
|
||||
?.toLowerCase()
|
||||
.startsWith('text/event-stream')
|
||||
|
||||
// Clone the response so both the client and the library could consume it.
|
||||
const responseClone = isEventStreamResponse ? null : response.clone()
|
||||
|
||||
sendToClient(
|
||||
client,
|
||||
{
|
||||
type: 'RESPONSE',
|
||||
payload: {
|
||||
isMockedResponse: IS_MOCKED_RESPONSE in response,
|
||||
request: {
|
||||
id: requestId,
|
||||
...serializedRequest,
|
||||
},
|
||||
response: {
|
||||
type: response.type,
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: Object.fromEntries(response.headers.entries()),
|
||||
body: responseClone ? responseClone.body : null,
|
||||
},
|
||||
},
|
||||
},
|
||||
responseClone && responseClone.body
|
||||
? [serializedRequest.body, responseClone.body]
|
||||
: [],
|
||||
)
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the main client for the given event.
|
||||
* Client that issues a request doesn't necessarily equal the client
|
||||
* that registered the worker. It's with the latter the worker should
|
||||
* communicate with during the response resolving phase.
|
||||
* @param {FetchEvent} event
|
||||
* @returns {Promise<Client | undefined>}
|
||||
*/
|
||||
async function resolveMainClient(event) {
|
||||
const client = await self.clients.get(event.clientId)
|
||||
|
||||
if (activeClientIds.has(event.clientId)) {
|
||||
return client
|
||||
}
|
||||
|
||||
if (client?.frameType === 'top-level') {
|
||||
return client
|
||||
}
|
||||
|
||||
const allClients = await self.clients.matchAll({
|
||||
type: 'window',
|
||||
})
|
||||
|
||||
return allClients
|
||||
.filter((client) => {
|
||||
// Get only those clients that are currently visible.
|
||||
return client.visibilityState === 'visible'
|
||||
})
|
||||
.find((client) => {
|
||||
// Find the client ID that's recorded in the
|
||||
// set of clients that have registered the worker.
|
||||
return activeClientIds.has(client.id)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {FetchEvent} event
|
||||
* @param {Client | undefined} client
|
||||
* @param {string} requestId
|
||||
* @param {number} requestInterceptedAt
|
||||
* @returns {Promise<Response>}
|
||||
*/
|
||||
async function getResponse(event, client, requestId, requestInterceptedAt) {
|
||||
// Clone the request because it might've been already used
|
||||
// (i.e. its body has been read and sent to the client).
|
||||
const requestClone = event.request.clone()
|
||||
|
||||
function passthrough() {
|
||||
// Cast the request headers to a new Headers instance
|
||||
// so the headers can be manipulated with.
|
||||
const headers = new Headers(requestClone.headers)
|
||||
|
||||
// Remove the "accept" header value that marked this request as passthrough.
|
||||
// This prevents request alteration and also keeps it compliant with the
|
||||
// user-defined CORS policies.
|
||||
const acceptHeader = headers.get('accept')
|
||||
if (acceptHeader) {
|
||||
const values = acceptHeader.split(',').map((value) => value.trim())
|
||||
const filteredValues = values.filter(
|
||||
(value) => value !== 'msw/passthrough',
|
||||
)
|
||||
|
||||
if (filteredValues.length > 0) {
|
||||
headers.set('accept', filteredValues.join(', '))
|
||||
} else {
|
||||
headers.delete('accept')
|
||||
}
|
||||
}
|
||||
|
||||
return fetch(requestClone, { headers })
|
||||
}
|
||||
|
||||
// Bypass mocking when the client is not active.
|
||||
if (!client) {
|
||||
return passthrough()
|
||||
}
|
||||
|
||||
// Bypass initial page load requests (i.e. static assets).
|
||||
// The absence of the immediate/parent client in the map of the active clients
|
||||
// means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet
|
||||
// and is not ready to handle requests.
|
||||
if (!activeClientIds.has(client.id)) {
|
||||
return passthrough()
|
||||
}
|
||||
|
||||
// Notify the client that a request has been intercepted.
|
||||
const serializedRequest = await serializeRequest(event.request)
|
||||
const clientMessage = await sendToClient(
|
||||
client,
|
||||
{
|
||||
type: 'REQUEST',
|
||||
payload: {
|
||||
id: requestId,
|
||||
interceptedAt: requestInterceptedAt,
|
||||
...serializedRequest,
|
||||
},
|
||||
},
|
||||
[serializedRequest.body],
|
||||
)
|
||||
|
||||
switch (clientMessage.type) {
|
||||
case 'MOCK_RESPONSE': {
|
||||
return respondWithMock(clientMessage.data)
|
||||
}
|
||||
|
||||
case 'PASSTHROUGH': {
|
||||
return passthrough()
|
||||
}
|
||||
}
|
||||
|
||||
return passthrough()
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Client} client
|
||||
* @param {any} message
|
||||
* @param {Array<Transferable>} transferrables
|
||||
* @returns {Promise<any>}
|
||||
*/
|
||||
function sendToClient(client, message, transferrables = []) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const channel = new MessageChannel()
|
||||
|
||||
channel.port1.onmessage = (event) => {
|
||||
if (event.data && event.data.error) {
|
||||
return reject(event.data.error)
|
||||
}
|
||||
|
||||
resolve(event.data)
|
||||
}
|
||||
|
||||
client.postMessage(message, [
|
||||
channel.port2,
|
||||
...transferrables.filter(Boolean),
|
||||
])
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Response} response
|
||||
* @returns {Response}
|
||||
*/
|
||||
function respondWithMock(response) {
|
||||
// Setting response status code to 0 is a no-op.
|
||||
// However, when responding with a "Response.error()", the produced Response
|
||||
// instance will have status code set to 0. Since it's not possible to create
|
||||
// a Response instance with status code 0, handle that use-case separately.
|
||||
if (response.status === 0) {
|
||||
return Response.error()
|
||||
}
|
||||
|
||||
const mockedResponse = new Response(response.body, response)
|
||||
|
||||
Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, {
|
||||
value: true,
|
||||
enumerable: true,
|
||||
})
|
||||
|
||||
return mockedResponse
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Request} request
|
||||
*/
|
||||
async function serializeRequest(request) {
|
||||
return {
|
||||
url: request.url,
|
||||
mode: request.mode,
|
||||
method: request.method,
|
||||
headers: Object.fromEntries(request.headers.entries()),
|
||||
cache: request.cache,
|
||||
credentials: request.credentials,
|
||||
destination: request.destination,
|
||||
integrity: request.integrity,
|
||||
redirect: request.redirect,
|
||||
referrer: request.referrer,
|
||||
referrerPolicy: request.referrerPolicy,
|
||||
body: await request.arrayBuffer(),
|
||||
keepalive: request.keepalive,
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,7 +16,7 @@ require (
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/jackc/pgx/v5 v5.11.0
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/klauspost/compress v1.20.1
|
||||
github.com/klauspost/compress v1.20.3
|
||||
github.com/mattn/go-sqlite3 v1.14.52
|
||||
github.com/mymmrac/telego v1.12.1
|
||||
github.com/nicksnyder/go-i18n/v2 v2.6.1
|
||||
|
||||
@@ -118,8 +118,8 @@ github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnr
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/juju/ratelimit v1.0.2 h1:sRxmtRiajbvrcLQT7S+JbqU0ntsb9W2yhSdNN8tWfaI=
|
||||
github.com/juju/ratelimit v1.0.2/go.mod h1:qapgC/Gy+xNh9UxzV13HGGl/6UXNN+ct+vwSgWNm/qk=
|
||||
github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ=
|
||||
github.com/klauspost/compress v1.20.1/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
|
||||
github.com/klauspost/compress v1.20.3 h1:zWNoFqq/lSuYQRim3dUXlXeA0IqwuZGicdG5ZTfY5j4=
|
||||
github.com/klauspost/compress v1.20.3/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
|
||||
github.com/klauspost/cpuid/v2 v2.4.0 h1:S6Hrbc7+ywsr0r+RLapfGBHfyefhCTwEh3A0tV913Dw=
|
||||
github.com/klauspost/cpuid/v2 v2.4.0/go.mod h1:19jmZ9mjzoF//ddRSUsv0zfBTJWh3QJh9FNxZTMrGxU=
|
||||
github.com/koron/go-ssdp v0.9.1 h1:zvxbAAuJftJIZ8Jh8mda+LI7V92hYZf/sKprmOxpxwA=
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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'")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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{}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1425,6 +1425,15 @@
|
||||
"apiTokenName": "الاسم",
|
||||
"apiTokenNamePlaceholder": "مثل central-panel-a",
|
||||
"apiTokenNameRequired": "الاسم مطلوب",
|
||||
"apiTokenScope": "نطاق الصلاحيات",
|
||||
"apiTokenScopeAdmin": "مسؤول",
|
||||
"apiTokenScopeAdminDesc": "وصول كامل إلى جميع نقاط API في اللوحة. استخدمه فقط للأتمتة الموثوقة.",
|
||||
"apiTokenScopeMonitor": "مراقبة",
|
||||
"apiTokenScopeMonitorDesc": "وصول للقراءة فقط إلى حالة النظام والإصدارات والمقاييس.",
|
||||
"apiTokenScopeNodeSync": "مزامنة العقدة",
|
||||
"apiTokenScopeNodeSyncDesc": "يزامن إعدادات العقدة والاتصالات الواردة والعملاء وحركة البيانات وحالة Xray. لا يمكنه تحديث برنامج اللوحة.",
|
||||
"apiTokenScopeNodeAdmin": "مسؤول العقدة",
|
||||
"apiTokenScopeNodeAdminDesc": "يتضمن صلاحيات مزامنة العقدة ويمكنه أيضًا بدء تحديثات برنامج اللوحة.",
|
||||
"apiTokenEmpty": "لا توجد رموز بعد — أنشئ واحدًا لمصادقة الروبوتات أو اللوحات البعيدة.",
|
||||
"apiTokenDeleteWarning": "أي عميل يستخدم هذا الرمز سيفقد المصادقة فورًا.",
|
||||
"apiTokenCreatedTitle": "تم إنشاء الرمز",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1429,6 +1429,15 @@
|
||||
"apiTokenName": "نام",
|
||||
"apiTokenNamePlaceholder": "مثلاً central-panel-a",
|
||||
"apiTokenNameRequired": "نام الزامی است",
|
||||
"apiTokenScope": "محدوده دسترسی",
|
||||
"apiTokenScopeAdmin": "مدیر",
|
||||
"apiTokenScopeAdminDesc": "دسترسی کامل به همه نقاط پایانی API پنل. فقط برای اتوماسیون قابل اعتماد استفاده شود.",
|
||||
"apiTokenScopeMonitor": "پایش",
|
||||
"apiTokenScopeMonitorDesc": "دسترسی فقط خواندنی به وضعیت سیستم، نسخهها و معیارها.",
|
||||
"apiTokenScopeNodeSync": "همگامسازی گره",
|
||||
"apiTokenScopeNodeSyncDesc": "پیکربندی گره، ورودیها، کاربران، ترافیک و وضعیت Xray را همگام میکند. نمیتواند نرمافزار پنل را بهروز کند.",
|
||||
"apiTokenScopeNodeAdmin": "مدیر گره",
|
||||
"apiTokenScopeNodeAdminDesc": "شامل مجوزهای همگامسازی گره است و میتواند بهروزرسانی نرمافزار پنل را نیز آغاز کند.",
|
||||
"apiTokenEmpty": "هنوز توکنی وجود ندارد — برای احراز هویت رباتها یا پنلهای راه دور یکی بسازید.",
|
||||
"apiTokenDeleteWarning": "هر کلاینتی که از این توکن استفاده میکند بلافاصله احراز هویتش قطع میشود.",
|
||||
"apiTokenCreatedTitle": "توکن ساخته شد",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1425,6 +1425,15 @@
|
||||
"apiTokenName": "名前",
|
||||
"apiTokenNamePlaceholder": "例: central-panel-a",
|
||||
"apiTokenNameRequired": "名前は必須です",
|
||||
"apiTokenScope": "スコープ",
|
||||
"apiTokenScopeAdmin": "管理者",
|
||||
"apiTokenScopeAdminDesc": "パネルのすべての API エンドポイントへのフルアクセス。信頼できる自動化にのみ使用してください。",
|
||||
"apiTokenScopeMonitor": "監視",
|
||||
"apiTokenScopeMonitorDesc": "システム状態、バージョン、メトリクスへの読み取り専用アクセス。",
|
||||
"apiTokenScopeNodeSync": "ノード同期",
|
||||
"apiTokenScopeNodeSyncDesc": "ノード設定、インバウンド、クライアント、トラフィック、Xray 状態を同期します。パネルソフトウェアは更新できません。",
|
||||
"apiTokenScopeNodeAdmin": "ノード管理者",
|
||||
"apiTokenScopeNodeAdminDesc": "ノード同期権限に加え、パネルソフトウェアの更新も開始できます。",
|
||||
"apiTokenEmpty": "トークンがまだありません — ボットやリモートパネルを認証するために作成してください。",
|
||||
"apiTokenDeleteWarning": "このトークンを使用しているクライアントは直ちに認証できなくなります。",
|
||||
"apiTokenCreatedTitle": "トークンを作成しました",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1425,6 +1425,15 @@
|
||||
"apiTokenName": "Имя",
|
||||
"apiTokenNamePlaceholder": "например, central-panel-a",
|
||||
"apiTokenNameRequired": "Имя обязательно",
|
||||
"apiTokenScope": "Область доступа",
|
||||
"apiTokenScopeAdmin": "Администратор",
|
||||
"apiTokenScopeAdminDesc": "Полный доступ ко всем API-методам панели. Используйте только для доверенной автоматизации.",
|
||||
"apiTokenScopeMonitor": "Мониторинг",
|
||||
"apiTokenScopeMonitorDesc": "Доступ только для чтения состояния системы, версий и метрик.",
|
||||
"apiTokenScopeNodeSync": "Синхронизация узла",
|
||||
"apiTokenScopeNodeSyncDesc": "Синхронизирует конфигурацию узла, входящие, клиентов, трафик и состояние Xray. Не может обновлять ПО панели.",
|
||||
"apiTokenScopeNodeAdmin": "Администратор узла",
|
||||
"apiTokenScopeNodeAdminDesc": "Включает права синхронизации узла и также может запускать обновление ПО панели.",
|
||||
"apiTokenEmpty": "Токенов пока нет — создайте один для аутентификации ботов или удалённых панелей.",
|
||||
"apiTokenDeleteWarning": "Любой клиент, использующий этот токен, немедленно потеряет аутентификацию.",
|
||||
"apiTokenCreatedTitle": "Токен создан",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1425,6 +1425,15 @@
|
||||
"apiTokenName": "Назва",
|
||||
"apiTokenNamePlaceholder": "наприклад, central-panel-a",
|
||||
"apiTokenNameRequired": "Назва обов'язкова",
|
||||
"apiTokenScope": "Область доступу",
|
||||
"apiTokenScopeAdmin": "Адміністратор",
|
||||
"apiTokenScopeAdminDesc": "Повний доступ до всіх API-методів панелі. Використовуйте лише для довіреної автоматизації.",
|
||||
"apiTokenScopeMonitor": "Моніторинг",
|
||||
"apiTokenScopeMonitorDesc": "Доступ лише для читання стану системи, версій і метрик.",
|
||||
"apiTokenScopeNodeSync": "Синхронізація вузла",
|
||||
"apiTokenScopeNodeSyncDesc": "Синхронізує конфігурацію вузла, вхідні, клієнтів, трафік і стан Xray. Не може оновлювати ПЗ панелі.",
|
||||
"apiTokenScopeNodeAdmin": "Адміністратор вузла",
|
||||
"apiTokenScopeNodeAdminDesc": "Включає права синхронізації вузла й також може запускати оновлення ПЗ панелі.",
|
||||
"apiTokenEmpty": "Поки немає токенів — створіть один для автентифікації ботів або віддалених панелей.",
|
||||
"apiTokenDeleteWarning": "Будь-який клієнт, що використовує цей токен, негайно втратить автентифікацію.",
|
||||
"apiTokenCreatedTitle": "Токен створено",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1425,6 +1425,15 @@
|
||||
"apiTokenName": "名称",
|
||||
"apiTokenNamePlaceholder": "例如 central-panel-a",
|
||||
"apiTokenNameRequired": "名称必填",
|
||||
"apiTokenScope": "权限范围",
|
||||
"apiTokenScopeAdmin": "管理员",
|
||||
"apiTokenScopeAdminDesc": "可访问面板的所有 API 端点。仅用于可信的自动化。",
|
||||
"apiTokenScopeMonitor": "监控",
|
||||
"apiTokenScopeMonitorDesc": "只读访问系统状态、版本和指标。",
|
||||
"apiTokenScopeNodeSync": "节点同步",
|
||||
"apiTokenScopeNodeSyncDesc": "同步节点配置、入站、客户端、流量和 Xray 状态。不能更新面板软件。",
|
||||
"apiTokenScopeNodeAdmin": "节点管理员",
|
||||
"apiTokenScopeNodeAdminDesc": "包含节点同步权限,还可以启动面板软件更新。",
|
||||
"apiTokenEmpty": "暂无令牌 — 创建一个用于认证机器人或远程面板。",
|
||||
"apiTokenDeleteWarning": "使用此令牌的任何调用方将立即无法认证。",
|
||||
"apiTokenCreatedTitle": "令牌已创建",
|
||||
|
||||
@@ -1425,6 +1425,15 @@
|
||||
"apiTokenName": "名稱",
|
||||
"apiTokenNamePlaceholder": "例如 central-panel-a",
|
||||
"apiTokenNameRequired": "名稱必填",
|
||||
"apiTokenScope": "權限範圍",
|
||||
"apiTokenScopeAdmin": "管理員",
|
||||
"apiTokenScopeAdminDesc": "可存取面板的所有 API 端點。僅用於可信的自動化。",
|
||||
"apiTokenScopeMonitor": "監控",
|
||||
"apiTokenScopeMonitorDesc": "唯讀存取系統狀態、版本和指標。",
|
||||
"apiTokenScopeNodeSync": "節點同步",
|
||||
"apiTokenScopeNodeSyncDesc": "同步節點設定、入站、用戶端、流量和 Xray 狀態。無法更新面板軟體。",
|
||||
"apiTokenScopeNodeAdmin": "節點管理員",
|
||||
"apiTokenScopeNodeAdminDesc": "包含節點同步權限,也可以啟動面板軟體更新。",
|
||||
"apiTokenEmpty": "尚無令牌 — 建立一個以認證機器人或遠端面板。",
|
||||
"apiTokenDeleteWarning": "使用此令牌的任何呼叫方將立即無法認證。",
|
||||
"apiTokenCreatedTitle": "權杖已建立",
|
||||
|
||||
Reference in New Issue
Block a user