mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-09-06 18:27:14 +00:00
docs(api): align OpenAPI with runtime contracts (#6409)
Document the cookie-authenticated WebSocket upgrade and its emitted envelopes without exporting pseudo-paths. Align REST response schemas, paged-client filters, and subscription HEAD operations with their runtime implementations, then regenerate frontend and docs artifacts.
This commit is contained in:
@@ -92,13 +92,11 @@ _openapi:
|
||||
title: Generate a new X25519 keypair for Reality.
|
||||
url: '#generate-a-new-x25519-keypair-for-reality'
|
||||
- depth: 2
|
||||
title: Generate a new ML-DSA-65 keypair (post-quantum signature). Returns
|
||||
{privateKey, publicKey, seed}.
|
||||
url: '#generate-a-new-ml-dsa-65-keypair-post-quantum-signature-returns-privatekey-publickey-seed'
|
||||
title: Generate a new ML-DSA-65 keypair. Returns {seed, verify}.
|
||||
url: '#generate-a-new-ml-dsa-65-keypair-returns-seed-verify'
|
||||
- depth: 2
|
||||
title: Generate a new ML-KEM-768 keypair (post-quantum KEM). Returns {clientKey,
|
||||
serverKey}.
|
||||
url: '#generate-a-new-ml-kem-768-keypair-post-quantum-kem-returns-clientkey-serverkey'
|
||||
title: Generate a new ML-KEM-768 keypair. Returns {seed, client}.
|
||||
url: '#generate-a-new-ml-kem-768-keypair-returns-seed-client'
|
||||
- depth: 2
|
||||
title: Generate VLESS encryption auth options. Returns an auths array each with
|
||||
id, label, encryption, and decryption fields.
|
||||
@@ -248,12 +246,10 @@ _openapi:
|
||||
id: read-only-summaries-guid-parentguid-name-address-status-versions-of-the-nodes-this-panel-manages-a-parent-panel-calls-it-on-a-node-via-the-node-api-token-to-surface-transitive-sub-nodes-in-a-chained-topology-counts-are-computed-by-the-parent-not-returned-here
|
||||
- content: Generate a new X25519 keypair for Reality.
|
||||
id: generate-a-new-x25519-keypair-for-reality
|
||||
- content: Generate a new ML-DSA-65 keypair (post-quantum signature). Returns
|
||||
{privateKey, publicKey, seed}.
|
||||
id: generate-a-new-ml-dsa-65-keypair-post-quantum-signature-returns-privatekey-publickey-seed
|
||||
- content: Generate a new ML-KEM-768 keypair (post-quantum KEM). Returns
|
||||
{clientKey, serverKey}.
|
||||
id: generate-a-new-ml-kem-768-keypair-post-quantum-kem-returns-clientkey-serverkey
|
||||
- content: Generate a new ML-DSA-65 keypair. Returns {seed, verify}.
|
||||
id: generate-a-new-ml-dsa-65-keypair-returns-seed-verify
|
||||
- content: Generate a new ML-KEM-768 keypair. Returns {seed, client}.
|
||||
id: generate-a-new-ml-kem-768-keypair-returns-seed-client
|
||||
- content: Generate VLESS encryption auth options. Returns an auths array each
|
||||
with id, label, encryption, and decryption fields.
|
||||
id: generate-vless-encryption-auth-options-returns-an-auths-array-each-with-id-label-encryption-and-decryption-fields
|
||||
|
||||
@@ -19,16 +19,28 @@ _openapi:
|
||||
online status; no links) for live polling. The path prefix is configured
|
||||
by subPath.'
|
||||
url: '#return-base64-encoded-subscription-links-for-all-enabled-clients-matching-the-subscription-id-when-the-request-has-an-accept-texthtml-header-or-html1-renders-a-styled-info-page-instead-with-formatinfo-returns-the-page-view-model-as-json-traffic-expiry-online-status-no-links-for-live-polling-the-path-prefix-is-configured-by-subpath'
|
||||
- depth: 2
|
||||
title: Return the same status and subscription metadata headers as GET without a
|
||||
response body.
|
||||
url: '#return-the-same-status-and-subscription-metadata-headers-as-get-without-a-response-body'
|
||||
- depth: 2
|
||||
title: Return subscription as a JSON array of proxy configs (one per enabled
|
||||
client). Only when JSON subscription is enabled in settings. The path
|
||||
prefix is configured by subJsonPath.
|
||||
url: '#return-subscription-as-a-json-array-of-proxy-configs-one-per-enabled-client-only-when-json-subscription-is-enabled-in-settings-the-path-prefix-is-configured-by-subjsonpath'
|
||||
- depth: 2
|
||||
title: Return the JSON subscription status and metadata headers without a body.
|
||||
Registered only when JSON subscriptions are enabled.
|
||||
url: '#return-the-json-subscription-status-and-metadata-headers-without-a-body-registered-only-when-json-subscriptions-are-enabled'
|
||||
- depth: 2
|
||||
title: Return subscription as a Clash/Mihomo-compatible YAML config, including
|
||||
configured global Clash routing rules. Only when Clash subscription is
|
||||
enabled in settings. The path prefix is configured by subClashPath.
|
||||
url: '#return-subscription-as-a-clashmihomo-compatible-yaml-config-including-configured-global-clash-routing-rules-only-when-clash-subscription-is-enabled-in-settings-the-path-prefix-is-configured-by-subclashpath'
|
||||
- depth: 2
|
||||
title: Return the Clash subscription status and metadata headers without a body.
|
||||
Registered only when Clash subscriptions are enabled.
|
||||
url: '#return-the-clash-subscription-status-and-metadata-headers-without-a-body-registered-only-when-clash-subscriptions-are-enabled'
|
||||
structuredData:
|
||||
headings:
|
||||
- content: 'Return base64-encoded subscription links for all enabled clients
|
||||
@@ -38,14 +50,23 @@ _openapi:
|
||||
online status; no links) for live polling. The path prefix is
|
||||
configured by subPath.'
|
||||
id: return-base64-encoded-subscription-links-for-all-enabled-clients-matching-the-subscription-id-when-the-request-has-an-accept-texthtml-header-or-html1-renders-a-styled-info-page-instead-with-formatinfo-returns-the-page-view-model-as-json-traffic-expiry-online-status-no-links-for-live-polling-the-path-prefix-is-configured-by-subpath
|
||||
- content: Return the same status and subscription metadata headers as GET without
|
||||
a response body.
|
||||
id: return-the-same-status-and-subscription-metadata-headers-as-get-without-a-response-body
|
||||
- content: Return subscription as a JSON array of proxy configs (one per enabled
|
||||
client). Only when JSON subscription is enabled in settings. The path
|
||||
prefix is configured by subJsonPath.
|
||||
id: return-subscription-as-a-json-array-of-proxy-configs-one-per-enabled-client-only-when-json-subscription-is-enabled-in-settings-the-path-prefix-is-configured-by-subjsonpath
|
||||
- content: Return the JSON subscription status and metadata headers without a
|
||||
body. Registered only when JSON subscriptions are enabled.
|
||||
id: return-the-json-subscription-status-and-metadata-headers-without-a-body-registered-only-when-json-subscriptions-are-enabled
|
||||
- content: Return subscription as a Clash/Mihomo-compatible YAML config, including
|
||||
configured global Clash routing rules. Only when Clash subscription is
|
||||
enabled in settings. The path prefix is configured by subClashPath.
|
||||
id: return-subscription-as-a-clashmihomo-compatible-yaml-config-including-configured-global-clash-routing-rules-only-when-clash-subscription-is-enabled-in-settings-the-path-prefix-is-configured-by-subclashpath
|
||||
- content: Return the Clash subscription status and metadata headers without a
|
||||
body. Registered only when Clash subscriptions are enabled.
|
||||
id: return-the-clash-subscription-status-and-metadata-headers-without-a-body-registered-only-when-clash-subscriptions-are-enabled
|
||||
contents: []
|
||||
---
|
||||
|
||||
@@ -58,7 +79,7 @@ export default function Layout(props) {
|
||||
return (
|
||||
<>
|
||||
{props.children}
|
||||
<Comp document="./public/openapi.json" webhooks={[]} operations={[{"path":"/{subPath}{subid}","method":"get"},{"path":"/{jsonPath}{subid}","method":"get"},{"path":"/{clashPath}{subid}","method":"get"}]} showTitle />
|
||||
<Comp document="./public/openapi.json" webhooks={[]} operations={[{"path":"/{subPath}{subid}","method":"get"},{"path":"/{subPath}{subid}","method":"head"},{"path":"/{jsonPath}{subid}","method":"get"},{"path":"/{jsonPath}{subid}","method":"head"},{"path":"/{clashPath}{subid}","method":"get"},{"path":"/{clashPath}{subid}","method":"head"}]} showTitle />
|
||||
</>
|
||||
);
|
||||
}
|
||||
+1521
-198
File diff suppressed because it is too large
Load Diff
+1521
-198
File diff suppressed because it is too large
Load Diff
@@ -4,9 +4,15 @@ import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
|
||||
import { sections } from '../src/pages/api-docs/endpoints.ts';
|
||||
import {
|
||||
buildWebSocketEvents,
|
||||
websocketEnvelopeSchema,
|
||||
} from '../src/pages/api-docs/websocket-events.ts';
|
||||
import { EXAMPLES } from '../src/generated/examples.ts';
|
||||
import { SCHEMAS } from '../src/generated/schemas.ts';
|
||||
|
||||
const websocketEvents = buildWebSocketEvents(EXAMPLES);
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const outPath = join(__dirname, '..', 'public', 'openapi.json');
|
||||
|
||||
@@ -65,6 +71,7 @@ function schemaFromParam(p) {
|
||||
if (p.defaultValue !== undefined) schema.default = p.defaultValue;
|
||||
if (p.minLength !== undefined) schema.minLength = p.minLength;
|
||||
if (p.pattern !== undefined) schema.pattern = p.pattern;
|
||||
if (p.enum !== undefined) schema.enum = [...p.enum];
|
||||
return schema;
|
||||
}
|
||||
|
||||
@@ -175,8 +182,7 @@ function buildOperation(ep, tag) {
|
||||
const branchProperties = { ...properties };
|
||||
for (const other of ep.bodyRequiredOneOf) {
|
||||
if (other === name || !branchProperties[other]) continue;
|
||||
const { pattern: _pattern, minLength: _minLength, ...rest } =
|
||||
branchProperties[other];
|
||||
const { pattern: _pattern, minLength: _minLength, ...rest } = branchProperties[other];
|
||||
branchProperties[other] = rest;
|
||||
}
|
||||
return {
|
||||
@@ -222,6 +228,10 @@ function buildOperation(ep, tag) {
|
||||
const responses = {};
|
||||
let successExample = tryParseJson(ep.response);
|
||||
let objSchema = {};
|
||||
if (ep.responseObjectSchema && ep.responseSchema) {
|
||||
throw new Error(`${ep.method} ${ep.path}: responseObjectSchema cannot use responseSchema`);
|
||||
}
|
||||
if (ep.responseObjectSchema) objSchema = ep.responseObjectSchema;
|
||||
if (ep.responseSchema) {
|
||||
const obj = EXAMPLES[ep.responseSchema];
|
||||
if (obj === undefined) {
|
||||
@@ -240,22 +250,26 @@ function buildOperation(ep, tag) {
|
||||
successExample = { success: true, obj: ep.responseSchemaArray ? [obj] : obj };
|
||||
}
|
||||
}
|
||||
responses['200'] = {
|
||||
description: 'Successful response',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
success: { type: 'boolean' },
|
||||
msg: { type: 'string' },
|
||||
obj: objSchema,
|
||||
if (ep.responses) {
|
||||
Object.assign(responses, ep.responses);
|
||||
} else {
|
||||
responses['200'] = {
|
||||
description: 'Successful response',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
success: { type: 'boolean' },
|
||||
msg: { type: 'string' },
|
||||
obj: objSchema,
|
||||
},
|
||||
},
|
||||
...(successExample !== undefined ? { example: successExample } : {}),
|
||||
},
|
||||
...(successExample !== undefined ? { example: successExample } : {}),
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
const errExample = tryParseJson(ep.errorResponse);
|
||||
if (errExample !== undefined || ep.errorStatus) {
|
||||
@@ -278,6 +292,7 @@ function buildOperation(ep, tag) {
|
||||
}
|
||||
|
||||
op.responses = responses;
|
||||
if (ep.security !== undefined) op.security = ep.security;
|
||||
return op;
|
||||
}
|
||||
|
||||
@@ -291,6 +306,7 @@ export function buildSpec() {
|
||||
paths[openApiPath][ep.method.toLowerCase()] = buildOperation(ep, tag);
|
||||
}
|
||||
}
|
||||
paths['/ws'].get['x-websocket-events'] = websocketEvents;
|
||||
|
||||
const tags = sections.map((s) => ({
|
||||
name: s.title,
|
||||
@@ -308,7 +324,7 @@ export function buildSpec() {
|
||||
servers: [{ url: '/', description: 'Current panel (basePath aware)' }],
|
||||
components: {
|
||||
securitySchemes: SECURITY_SCHEMES,
|
||||
schemas: SCHEMAS,
|
||||
schemas: { ...SCHEMAS, WebSocketEnvelope: websocketEnvelopeSchema },
|
||||
},
|
||||
security: [{ bearerAuth: [] }, { cookieAuth: [] }],
|
||||
tags,
|
||||
|
||||
@@ -300,6 +300,55 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
"flowOverride": "",
|
||||
"inboundId": 0
|
||||
},
|
||||
"ClientPageResponse": {
|
||||
"filtered": 47,
|
||||
"groups": [
|
||||
"staff",
|
||||
"trial"
|
||||
],
|
||||
"items": [
|
||||
{
|
||||
"comment": "Primary device",
|
||||
"createdAt": 1735000000000,
|
||||
"email": "alice@example.com",
|
||||
"enable": true,
|
||||
"expiryTime": 1735689600000,
|
||||
"group": "staff",
|
||||
"inboundIds": [
|
||||
3,
|
||||
5
|
||||
],
|
||||
"limitHwid": 0,
|
||||
"limitIp": 0,
|
||||
"reset": 0,
|
||||
"resetDay": 0,
|
||||
"resetMax": 0,
|
||||
"subId": "abcd1234",
|
||||
"totalGB": 53687091200,
|
||||
"traffic": null,
|
||||
"updatedAt": 1735100000000
|
||||
}
|
||||
],
|
||||
"page": 1,
|
||||
"pageSize": 25,
|
||||
"summary": {
|
||||
"active": 1850,
|
||||
"deactive": [
|
||||
"bob@example.com"
|
||||
],
|
||||
"deactiveCount": 150,
|
||||
"depleted": [],
|
||||
"depletedCount": 0,
|
||||
"expiring": [],
|
||||
"expiringCount": 0,
|
||||
"online": [
|
||||
"alice@example.com"
|
||||
],
|
||||
"onlineCount": 1,
|
||||
"total": 2000
|
||||
},
|
||||
"total": 2000
|
||||
},
|
||||
"ClientRecord": {
|
||||
"adTag": "",
|
||||
"allowedIPs": "",
|
||||
@@ -337,6 +386,27 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
"ClientReverse": {
|
||||
"tag": ""
|
||||
},
|
||||
"ClientSlim": {
|
||||
"comment": "Primary device",
|
||||
"createdAt": 1735000000000,
|
||||
"email": "alice@example.com",
|
||||
"enable": true,
|
||||
"expiryTime": 1735689600000,
|
||||
"group": "staff",
|
||||
"inboundIds": [
|
||||
3,
|
||||
5
|
||||
],
|
||||
"limitHwid": 0,
|
||||
"limitIp": 0,
|
||||
"reset": 0,
|
||||
"resetDay": 0,
|
||||
"resetMax": 0,
|
||||
"subId": "abcd1234",
|
||||
"totalGB": 53687091200,
|
||||
"traffic": null,
|
||||
"updatedAt": 1735100000000
|
||||
},
|
||||
"ClientTraffic": {
|
||||
"down": 2097152,
|
||||
"email": "user1",
|
||||
@@ -355,6 +425,22 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
"up": 1048576,
|
||||
"uuid": "e18c9a96-71bf-48d4-933f-8b9a46d4290c"
|
||||
},
|
||||
"ClientsSummary": {
|
||||
"active": 1850,
|
||||
"deactive": [
|
||||
"bob@example.com"
|
||||
],
|
||||
"deactiveCount": 150,
|
||||
"depleted": [],
|
||||
"depletedCount": 0,
|
||||
"expiring": [],
|
||||
"expiringCount": 0,
|
||||
"online": [
|
||||
"alice@example.com"
|
||||
],
|
||||
"onlineCount": 1,
|
||||
"total": 2000
|
||||
},
|
||||
"FallbackParentInfo": {
|
||||
"masterId": 0,
|
||||
"path": ""
|
||||
@@ -586,11 +672,38 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
"wgMtu": 0,
|
||||
"wgPublicKey": ""
|
||||
},
|
||||
"InboundTrafficSummary": {
|
||||
"down": 2097152,
|
||||
"enable": true,
|
||||
"id": 1,
|
||||
"total": 10737418240,
|
||||
"up": 1048576
|
||||
},
|
||||
"LogEntry": {
|
||||
"DateTime": "2025-01-01T12:00:00Z",
|
||||
"Email": "alice@example.com",
|
||||
"Event": 0,
|
||||
"FromAddress": "192.0.2.10:54321",
|
||||
"Inbound": "inbound-443",
|
||||
"Outbound": "direct",
|
||||
"ToAddress": "example.com:443"
|
||||
},
|
||||
"MLDSA65Response": {
|
||||
"seed": "mldsa65-seed",
|
||||
"verify": "mldsa65-verify"
|
||||
},
|
||||
"MLKEM768Response": {
|
||||
"client": "mlkem768-client",
|
||||
"seed": "mlkem768-seed"
|
||||
},
|
||||
"Msg": {
|
||||
"msg": "",
|
||||
"obj": null,
|
||||
"success": false
|
||||
},
|
||||
"NewUUIDResponse": {
|
||||
"uuid": "550e8400-e29b-41d4-a716-446655440000"
|
||||
},
|
||||
"Node": {
|
||||
"activeCount": 23,
|
||||
"address": "node1.example.com",
|
||||
@@ -819,6 +932,13 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
"strategy": "random",
|
||||
"updatedAt": 1710000000000
|
||||
},
|
||||
"Traffic": {
|
||||
"Down": 2097152,
|
||||
"IsInbound": true,
|
||||
"IsOutbound": false,
|
||||
"Tag": "inbound-443",
|
||||
"Up": 1048576
|
||||
},
|
||||
"User": {
|
||||
"id": 0,
|
||||
"password": "",
|
||||
|
||||
@@ -1245,6 +1245,56 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"ClientPageResponse": {
|
||||
"description": "ClientPageResponse is the shape returned by ListPaged. `Total` is the\nrow count in the DB; `Filtered` is the count after Search/Filter/Protocol\nwere applied, before pagination. The page contains at most PageSize items.\nSummary is computed across the full DB row set so dashboard counters\non the clients page stay stable as the user paginates/filters.",
|
||||
"properties": {
|
||||
"filtered": {
|
||||
"example": 47,
|
||||
"type": "integer"
|
||||
},
|
||||
"groups": {
|
||||
"example": [
|
||||
"staff",
|
||||
"trial"
|
||||
],
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"items": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/ClientSlim"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"page": {
|
||||
"example": 1,
|
||||
"type": "integer"
|
||||
},
|
||||
"pageSize": {
|
||||
"example": 25,
|
||||
"type": "integer"
|
||||
},
|
||||
"summary": {
|
||||
"$ref": "#/components/schemas/ClientsSummary"
|
||||
},
|
||||
"total": {
|
||||
"example": 2000,
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"filtered",
|
||||
"groups",
|
||||
"items",
|
||||
"page",
|
||||
"pageSize",
|
||||
"summary",
|
||||
"total"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"ClientRecord": {
|
||||
"properties": {
|
||||
"adTag": {
|
||||
@@ -1394,6 +1444,105 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"ClientSlim": {
|
||||
"description": "ClientSlim is the row-shape used by the clients page. It drops fields the\ntable never reads (UUID, password, auth, flow, security, reverse, tgId)\nso the list payload stays compact even when the panel manages thousands\nof clients. Modals that need the full record still call /get/:email.",
|
||||
"properties": {
|
||||
"comment": {
|
||||
"example": "Primary device",
|
||||
"type": "string"
|
||||
},
|
||||
"createdAt": {
|
||||
"example": 1735000000000,
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
},
|
||||
"email": {
|
||||
"example": "alice@example.com",
|
||||
"type": "string"
|
||||
},
|
||||
"enable": {
|
||||
"example": true,
|
||||
"type": "boolean"
|
||||
},
|
||||
"expiryTime": {
|
||||
"example": 1735689600000,
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
},
|
||||
"group": {
|
||||
"example": "staff",
|
||||
"type": "string"
|
||||
},
|
||||
"inboundIds": {
|
||||
"example": [
|
||||
3,
|
||||
5
|
||||
],
|
||||
"items": {
|
||||
"type": "integer"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"limitHwid": {
|
||||
"example": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"limitIp": {
|
||||
"example": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"reset": {
|
||||
"example": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"resetDay": {
|
||||
"example": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"resetMax": {
|
||||
"example": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"subId": {
|
||||
"example": "abcd1234",
|
||||
"type": "string"
|
||||
},
|
||||
"totalGB": {
|
||||
"example": 53687091200,
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
},
|
||||
"traffic": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/ClientTraffic"
|
||||
}
|
||||
],
|
||||
"nullable": true
|
||||
},
|
||||
"updatedAt": {
|
||||
"example": 1735100000000,
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"createdAt",
|
||||
"email",
|
||||
"enable",
|
||||
"expiryTime",
|
||||
"inboundIds",
|
||||
"limitHwid",
|
||||
"limitIp",
|
||||
"reset",
|
||||
"resetDay",
|
||||
"resetMax",
|
||||
"subId",
|
||||
"totalGB",
|
||||
"updatedAt"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"ClientTraffic": {
|
||||
"description": "ClientTraffic represents traffic statistics and limits for a specific client.\nIt tracks upload/download usage, expiry times, and online status for inbound clients.",
|
||||
"properties": {
|
||||
@@ -1491,6 +1640,80 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"ClientsSummary": {
|
||||
"description": "ClientsSummary collects per-bucket counts plus the matching email lists so\nthe clients page can render the dashboard stat cards and their hover\npopovers without shipping the full client array. The counters are exact;\nthe lists stop at clientSummaryEmailCap entries and only back the popovers.",
|
||||
"properties": {
|
||||
"active": {
|
||||
"example": 1850,
|
||||
"type": "integer"
|
||||
},
|
||||
"deactive": {
|
||||
"example": [
|
||||
"bob@example.com"
|
||||
],
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"deactiveCount": {
|
||||
"example": 150,
|
||||
"type": "integer"
|
||||
},
|
||||
"depleted": {
|
||||
"example": [],
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"depletedCount": {
|
||||
"example": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"expiring": {
|
||||
"example": [],
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"expiringCount": {
|
||||
"example": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"online": {
|
||||
"example": [
|
||||
"alice@example.com"
|
||||
],
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"onlineCount": {
|
||||
"example": 1,
|
||||
"type": "integer"
|
||||
},
|
||||
"total": {
|
||||
"example": 2000,
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"active",
|
||||
"deactive",
|
||||
"deactiveCount",
|
||||
"depleted",
|
||||
"depletedCount",
|
||||
"expiring",
|
||||
"expiringCount",
|
||||
"online",
|
||||
"onlineCount",
|
||||
"total"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"FallbackParentInfo": {
|
||||
"description": "FallbackParentInfo carries everything the frontend needs to rewrite a\nchild inbound's client link: where to connect (the master's address\nand port) and which path matched on the master's fallbacks array.\nThe frontend already has the master inbound in its dbInbounds list,\nso we only ship identifiers + the match path here.",
|
||||
"properties": {
|
||||
@@ -2337,6 +2560,118 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"InboundTrafficSummary": {
|
||||
"properties": {
|
||||
"down": {
|
||||
"example": 2097152,
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
},
|
||||
"enable": {
|
||||
"example": true,
|
||||
"type": "boolean"
|
||||
},
|
||||
"id": {
|
||||
"example": 1,
|
||||
"type": "integer"
|
||||
},
|
||||
"total": {
|
||||
"example": 10737418240,
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
},
|
||||
"up": {
|
||||
"example": 1048576,
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"down",
|
||||
"enable",
|
||||
"id",
|
||||
"total",
|
||||
"up"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"LogEntry": {
|
||||
"properties": {
|
||||
"DateTime": {
|
||||
"example": "2025-01-01T12:00:00Z",
|
||||
"format": "date-time",
|
||||
"type": "string"
|
||||
},
|
||||
"Email": {
|
||||
"example": "alice@example.com",
|
||||
"type": "string"
|
||||
},
|
||||
"Event": {
|
||||
"example": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"FromAddress": {
|
||||
"example": "192.0.2.10:54321",
|
||||
"type": "string"
|
||||
},
|
||||
"Inbound": {
|
||||
"example": "inbound-443",
|
||||
"type": "string"
|
||||
},
|
||||
"Outbound": {
|
||||
"example": "direct",
|
||||
"type": "string"
|
||||
},
|
||||
"ToAddress": {
|
||||
"example": "example.com:443",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"DateTime",
|
||||
"Email",
|
||||
"Event",
|
||||
"FromAddress",
|
||||
"Inbound",
|
||||
"Outbound",
|
||||
"ToAddress"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"MLDSA65Response": {
|
||||
"properties": {
|
||||
"seed": {
|
||||
"example": "mldsa65-seed",
|
||||
"type": "string"
|
||||
},
|
||||
"verify": {
|
||||
"example": "mldsa65-verify",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"seed",
|
||||
"verify"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"MLKEM768Response": {
|
||||
"properties": {
|
||||
"client": {
|
||||
"example": "mlkem768-client",
|
||||
"type": "string"
|
||||
},
|
||||
"seed": {
|
||||
"example": "mlkem768-seed",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"client",
|
||||
"seed"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"Msg": {
|
||||
"properties": {
|
||||
"msg": {
|
||||
@@ -2354,6 +2689,18 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"NewUUIDResponse": {
|
||||
"properties": {
|
||||
"uuid": {
|
||||
"example": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"uuid"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"Node": {
|
||||
"description": "Node represents a remote 3x-ui panel registered with the central panel.\nThe central panel polls each node's existing /panel/api/server/status\nendpoint over HTTP using the per-node ApiToken to populate the runtime\nstatus fields below.",
|
||||
"properties": {
|
||||
@@ -3409,6 +3756,41 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"Traffic": {
|
||||
"description": "Traffic represents network traffic statistics for Xray connections.\nIt tracks upload and download bytes for inbound or outbound traffic.",
|
||||
"properties": {
|
||||
"Down": {
|
||||
"example": 2097152,
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
},
|
||||
"IsInbound": {
|
||||
"example": true,
|
||||
"type": "boolean"
|
||||
},
|
||||
"IsOutbound": {
|
||||
"example": false,
|
||||
"type": "boolean"
|
||||
},
|
||||
"Tag": {
|
||||
"example": "inbound-443",
|
||||
"type": "string"
|
||||
},
|
||||
"Up": {
|
||||
"example": 1048576,
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"Down",
|
||||
"IsInbound",
|
||||
"IsOutbound",
|
||||
"Tag",
|
||||
"Up"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"User": {
|
||||
"description": "User represents a user account in the 3x-ui panel.",
|
||||
"properties": {
|
||||
|
||||
@@ -298,6 +298,16 @@ export interface ClientInbound {
|
||||
inboundId: number;
|
||||
}
|
||||
|
||||
export interface ClientPageResponse {
|
||||
filtered: number;
|
||||
groups: string[];
|
||||
items: ClientSlim[];
|
||||
page: number;
|
||||
pageSize: number;
|
||||
summary: ClientsSummary;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface ClientRecord {
|
||||
adTag: string;
|
||||
allowedIPs: string;
|
||||
@@ -337,6 +347,25 @@ export interface ClientReverse {
|
||||
tag: string;
|
||||
}
|
||||
|
||||
export interface ClientSlim {
|
||||
comment?: string;
|
||||
createdAt: number;
|
||||
email: string;
|
||||
enable: boolean;
|
||||
expiryTime: number;
|
||||
group?: string;
|
||||
inboundIds: number[];
|
||||
limitHwid: number;
|
||||
limitIp: number;
|
||||
reset: number;
|
||||
resetDay: number;
|
||||
resetMax: number;
|
||||
subId: string;
|
||||
totalGB: number;
|
||||
traffic?: ClientTraffic | null;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
export interface ClientTraffic {
|
||||
down: number;
|
||||
email: string;
|
||||
@@ -356,6 +385,19 @@ export interface ClientTraffic {
|
||||
uuid: string;
|
||||
}
|
||||
|
||||
export interface ClientsSummary {
|
||||
active: number;
|
||||
deactive: string[];
|
||||
deactiveCount: number;
|
||||
depleted: string[];
|
||||
depletedCount: number;
|
||||
expiring: string[];
|
||||
expiringCount: number;
|
||||
online: string[];
|
||||
onlineCount: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface FallbackParentInfo {
|
||||
masterId: number;
|
||||
path?: string;
|
||||
@@ -541,12 +583,44 @@ export interface InboundOption {
|
||||
wgPublicKey?: string;
|
||||
}
|
||||
|
||||
export interface InboundTrafficSummary {
|
||||
down: number;
|
||||
enable: boolean;
|
||||
id: number;
|
||||
total: number;
|
||||
up: number;
|
||||
}
|
||||
|
||||
export interface LogEntry {
|
||||
DateTime: string;
|
||||
Email: string;
|
||||
Event: number;
|
||||
FromAddress: string;
|
||||
Inbound: string;
|
||||
Outbound: string;
|
||||
ToAddress: string;
|
||||
}
|
||||
|
||||
export interface MLDSA65Response {
|
||||
seed: string;
|
||||
verify: string;
|
||||
}
|
||||
|
||||
export interface MLKEM768Response {
|
||||
client: string;
|
||||
seed: string;
|
||||
}
|
||||
|
||||
export interface Msg {
|
||||
msg: string;
|
||||
obj: unknown;
|
||||
success: boolean;
|
||||
}
|
||||
|
||||
export interface NewUUIDResponse {
|
||||
uuid: string;
|
||||
}
|
||||
|
||||
export interface Node {
|
||||
activeCount: number;
|
||||
address: string;
|
||||
@@ -775,6 +849,14 @@ export interface SubBalancer {
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
export interface Traffic {
|
||||
Down: number;
|
||||
IsInbound: boolean;
|
||||
IsOutbound: boolean;
|
||||
Tag: string;
|
||||
Up: number;
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: number;
|
||||
password: string;
|
||||
|
||||
@@ -321,6 +321,17 @@ export const ClientInboundSchema = z.object({
|
||||
});
|
||||
export type ClientInbound = z.infer<typeof ClientInboundSchema>;
|
||||
|
||||
export const ClientPageResponseSchema = z.object({
|
||||
filtered: z.number().int(),
|
||||
groups: z.array(z.string()),
|
||||
items: z.array(z.lazy(() => ClientSlimSchema)),
|
||||
page: z.number().int(),
|
||||
pageSize: z.number().int(),
|
||||
summary: z.lazy(() => ClientsSummarySchema),
|
||||
total: z.number().int(),
|
||||
});
|
||||
export type ClientPageResponse = z.infer<typeof ClientPageResponseSchema>;
|
||||
|
||||
export const ClientRecordSchema = z.object({
|
||||
adTag: z.string(),
|
||||
allowedIPs: z.string(),
|
||||
@@ -362,6 +373,26 @@ export const ClientReverseSchema = z.object({
|
||||
});
|
||||
export type ClientReverse = z.infer<typeof ClientReverseSchema>;
|
||||
|
||||
export const ClientSlimSchema = z.object({
|
||||
comment: z.string().optional(),
|
||||
createdAt: z.number().int(),
|
||||
email: z.string(),
|
||||
enable: z.boolean(),
|
||||
expiryTime: z.number().int(),
|
||||
group: z.string().optional(),
|
||||
inboundIds: z.array(z.number().int()),
|
||||
limitHwid: z.number().int(),
|
||||
limitIp: z.number().int(),
|
||||
reset: z.number().int(),
|
||||
resetDay: z.number().int(),
|
||||
resetMax: z.number().int(),
|
||||
subId: z.string(),
|
||||
totalGB: z.number().int(),
|
||||
traffic: z.lazy(() => ClientTrafficSchema).nullable().optional(),
|
||||
updatedAt: z.number().int(),
|
||||
});
|
||||
export type ClientSlim = z.infer<typeof ClientSlimSchema>;
|
||||
|
||||
export const ClientTrafficSchema = z.object({
|
||||
down: z.number().int(),
|
||||
email: z.string(),
|
||||
@@ -382,6 +413,20 @@ export const ClientTrafficSchema = z.object({
|
||||
});
|
||||
export type ClientTraffic = z.infer<typeof ClientTrafficSchema>;
|
||||
|
||||
export const ClientsSummarySchema = z.object({
|
||||
active: z.number().int(),
|
||||
deactive: z.array(z.string()),
|
||||
deactiveCount: z.number().int(),
|
||||
depleted: z.array(z.string()),
|
||||
depletedCount: z.number().int(),
|
||||
expiring: z.array(z.string()),
|
||||
expiringCount: z.number().int(),
|
||||
online: z.array(z.string()),
|
||||
onlineCount: z.number().int(),
|
||||
total: z.number().int(),
|
||||
});
|
||||
export type ClientsSummary = z.infer<typeof ClientsSummarySchema>;
|
||||
|
||||
export const FallbackParentInfoSchema = z.object({
|
||||
masterId: z.number().int(),
|
||||
path: z.string().optional(),
|
||||
@@ -581,6 +626,38 @@ export const InboundOptionSchema = z.object({
|
||||
});
|
||||
export type InboundOption = z.infer<typeof InboundOptionSchema>;
|
||||
|
||||
export const InboundTrafficSummarySchema = z.object({
|
||||
down: z.number().int(),
|
||||
enable: z.boolean(),
|
||||
id: z.number().int(),
|
||||
total: z.number().int(),
|
||||
up: z.number().int(),
|
||||
});
|
||||
export type InboundTrafficSummary = z.infer<typeof InboundTrafficSummarySchema>;
|
||||
|
||||
export const LogEntrySchema = z.object({
|
||||
DateTime: z.string(),
|
||||
Email: z.string(),
|
||||
Event: z.number().int(),
|
||||
FromAddress: z.string(),
|
||||
Inbound: z.string(),
|
||||
Outbound: z.string(),
|
||||
ToAddress: z.string(),
|
||||
});
|
||||
export type LogEntry = z.infer<typeof LogEntrySchema>;
|
||||
|
||||
export const MLDSA65ResponseSchema = z.object({
|
||||
seed: z.string(),
|
||||
verify: z.string(),
|
||||
});
|
||||
export type MLDSA65Response = z.infer<typeof MLDSA65ResponseSchema>;
|
||||
|
||||
export const MLKEM768ResponseSchema = z.object({
|
||||
client: z.string(),
|
||||
seed: z.string(),
|
||||
});
|
||||
export type MLKEM768Response = z.infer<typeof MLKEM768ResponseSchema>;
|
||||
|
||||
export const MsgSchema = z.object({
|
||||
msg: z.string(),
|
||||
obj: z.unknown(),
|
||||
@@ -588,6 +665,11 @@ export const MsgSchema = z.object({
|
||||
});
|
||||
export type Msg = z.infer<typeof MsgSchema>;
|
||||
|
||||
export const NewUUIDResponseSchema = z.object({
|
||||
uuid: z.string(),
|
||||
});
|
||||
export type NewUUIDResponse = z.infer<typeof NewUUIDResponseSchema>;
|
||||
|
||||
export const NodeSchema = z.object({
|
||||
activeCount: z.number().int(),
|
||||
address: z.string(),
|
||||
@@ -827,6 +909,15 @@ export const SubBalancerSchema = z.object({
|
||||
});
|
||||
export type SubBalancer = z.infer<typeof SubBalancerSchema>;
|
||||
|
||||
export const TrafficSchema = z.object({
|
||||
Down: z.number().int(),
|
||||
IsInbound: z.boolean(),
|
||||
IsOutbound: z.boolean(),
|
||||
Tag: z.string(),
|
||||
Up: z.number().int(),
|
||||
});
|
||||
export type Traffic = z.infer<typeof TrafficSchema>;
|
||||
|
||||
export const UserSchema = z.object({
|
||||
id: z.number().int(),
|
||||
password: z.string(),
|
||||
|
||||
@@ -44,6 +44,29 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.api-docs-page .websocket-events {
|
||||
margin-bottom: 16px;
|
||||
padding: 20px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--ant-color-border-secondary);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.api-docs-page .websocket-events h2 {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.api-docs-page .websocket-events pre {
|
||||
margin: 0;
|
||||
padding: 12px;
|
||||
max-height: 360px;
|
||||
overflow: auto;
|
||||
color: var(--sw-text, inherit);
|
||||
background: var(--sw-bg-code, var(--ant-color-fill-quaternary));
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.api-docs-page .swagger-ui {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
}
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ConfigProvider, Layout } from 'antd';
|
||||
import { Card, Col, ConfigProvider, Layout, Row, Typography } from 'antd';
|
||||
import SwaggerUI from 'swagger-ui-react';
|
||||
import 'swagger-ui-react/swagger-ui.css';
|
||||
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
import AppSidebar from '@/layouts/AppSidebar';
|
||||
import { EXAMPLES } from '@/generated/examples';
|
||||
import { buildWebSocketEvents } from './websocket-events';
|
||||
import './ApiDocsPage.css';
|
||||
|
||||
const basePath = window.X_UI_BASE_PATH || '';
|
||||
const openApiUrl = `${basePath}panel/api/openapi.json`;
|
||||
const websocketEvents = buildWebSocketEvents(EXAMPLES);
|
||||
|
||||
export default function ApiDocsPage() {
|
||||
const { isDark, isUltra, antdThemeConfig } = useTheme();
|
||||
@@ -29,6 +32,27 @@ export default function ApiDocsPage() {
|
||||
|
||||
<Layout className="content-shell">
|
||||
<Layout.Content className="content-area">
|
||||
<section className="websocket-events" aria-labelledby="websocket-events-title">
|
||||
<Typography.Title id="websocket-events-title" level={2}>
|
||||
WebSocket events
|
||||
</Typography.Title>
|
||||
<Typography.Paragraph>
|
||||
After the cookie-authenticated <Typography.Text code>GET /ws</Typography.Text>{' '}
|
||||
upgrade, every server message uses{' '}
|
||||
<Typography.Text code>{'{ type, payload, time }'}</Typography.Text>. The time value
|
||||
is Unix milliseconds.
|
||||
</Typography.Paragraph>
|
||||
<Row gutter={[12, 12]}>
|
||||
{websocketEvents.map((event) => (
|
||||
<Col key={event.type} xs={24} sm={12} xl={8}>
|
||||
<Card size="small" title={<Typography.Text code>{event.type}</Typography.Text>}>
|
||||
<Typography.Paragraph>{event.summary}</Typography.Paragraph>
|
||||
<pre>{JSON.stringify(event.example, null, 2)}</pre>
|
||||
</Card>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
</section>
|
||||
<div className="docs-wrapper" role="region" aria-label={t('menu.apiDocs')}>
|
||||
<SwaggerUI
|
||||
url={openApiUrl}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'WS';
|
||||
export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'HEAD';
|
||||
export type ParamLocation =
|
||||
| 'path'
|
||||
| 'query'
|
||||
@@ -28,6 +28,7 @@ export interface EndpointParam {
|
||||
defaultValue?: string | number | boolean;
|
||||
minLength?: number;
|
||||
pattern?: string;
|
||||
enum?: readonly (string | number | boolean)[];
|
||||
}
|
||||
|
||||
export interface Endpoint {
|
||||
@@ -45,6 +46,9 @@ export interface Endpoint {
|
||||
bodyRequiredOneOf?: string[];
|
||||
responseSchema?: string;
|
||||
responseSchemaArray?: boolean;
|
||||
responseObjectSchema?: Record<string, unknown>;
|
||||
responses?: Record<string, Record<string, unknown>>;
|
||||
security?: readonly Record<string, readonly string[]>[];
|
||||
}
|
||||
|
||||
export interface SubscriptionHeader {
|
||||
@@ -172,6 +176,12 @@ const subBalancerBodyParams: EndpointParam[] = [
|
||||
},
|
||||
];
|
||||
|
||||
const subscriptionHeadResponses = {
|
||||
'200': { description: 'Subscription is available. Headers match GET; no response body.' },
|
||||
'404': { description: 'No enabled client matches the subscription ID.' },
|
||||
'500': { description: 'Subscription generation failed.' },
|
||||
};
|
||||
|
||||
export const sections: readonly Section[] = [
|
||||
{
|
||||
id: 'authentication',
|
||||
@@ -556,7 +566,7 @@ export const sections: readonly Section[] = [
|
||||
method: 'GET',
|
||||
path: '/panel/api/server/getNewUUID',
|
||||
summary: 'Generate a fresh UUID v4. Convenience helper for client IDs.',
|
||||
response: '{\n "success": true,\n "obj": "550e8400-e29b-41d4-a716-446655440000"\n}',
|
||||
responseSchema: 'NewUUIDResponse',
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
@@ -584,18 +594,14 @@ export const sections: readonly Section[] = [
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/panel/api/server/getNewmldsa65',
|
||||
summary:
|
||||
'Generate a new ML-DSA-65 keypair (post-quantum signature). Returns {privateKey, publicKey, seed}.',
|
||||
response:
|
||||
'{\n "success": true,\n "obj": {\n "privateKey": "mdsa65priv...",\n "publicKey": "mdsa65pub...",\n "seed": "random-seed..."\n }\n}',
|
||||
summary: 'Generate a new ML-DSA-65 keypair. Returns {seed, verify}.',
|
||||
responseSchema: 'MLDSA65Response',
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/panel/api/server/getNewmlkem768',
|
||||
summary:
|
||||
'Generate a new ML-KEM-768 keypair (post-quantum KEM). Returns {clientKey, serverKey}.',
|
||||
response:
|
||||
'{\n "success": true,\n "obj": {\n "clientKey": "mlkem768-client...",\n "serverKey": "mlkem768-server..."\n }\n}',
|
||||
summary: 'Generate a new ML-KEM-768 keypair. Returns {seed, client}.',
|
||||
responseSchema: 'MLKEM768Response',
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
@@ -703,8 +709,9 @@ export const sections: readonly Section[] = [
|
||||
},
|
||||
],
|
||||
body: 'level=info&syslog=false',
|
||||
responseObjectSchema: { type: 'array', items: { type: 'string' } },
|
||||
response:
|
||||
'{\n "success": true,\n "obj": "2025/01/01 12:00:00 [INFO] Server started\\n2025/01/01 12:00:01 [INFO] Xray is running"\n}',
|
||||
'{\n "success": true,\n "obj": [\n "2025/01/01 12:00:00 [INFO] Server started",\n "2025/01/01 12:00:01 [INFO] Xray is running"\n ]\n}',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
@@ -742,8 +749,8 @@ export const sections: readonly Section[] = [
|
||||
},
|
||||
],
|
||||
body: 'filter=error&showDirect=false&showBlocked=true&showProxy=true',
|
||||
response:
|
||||
'{\n "success": true,\n "obj": "2025/01/01 12:00:00 rejected vless proxy example.com reason: no valid user\\n2025/01/01 12:00:01 direct freedom ok"\n}',
|
||||
responseSchema: 'LogEntry',
|
||||
responseSchemaArray: true,
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
@@ -966,41 +973,132 @@ export const sections: readonly Section[] = [
|
||||
in: 'query',
|
||||
type: 'number',
|
||||
desc: '1-indexed page number. Defaults to 1.',
|
||||
optional: true,
|
||||
defaultValue: 1,
|
||||
},
|
||||
{
|
||||
name: 'pageSize',
|
||||
in: 'query',
|
||||
type: 'number',
|
||||
desc: 'Rows per page. Defaults to 25, capped at 200.',
|
||||
optional: true,
|
||||
defaultValue: 25,
|
||||
},
|
||||
{
|
||||
name: 'search',
|
||||
in: 'query',
|
||||
type: 'string',
|
||||
desc: 'Case-insensitive substring match on email / subId / comment.',
|
||||
desc: 'Case-insensitive substring match on email, subId, comment, UUID, password, auth or Telegram ID.',
|
||||
optional: true,
|
||||
},
|
||||
{
|
||||
name: 'filter',
|
||||
in: 'query',
|
||||
type: 'string',
|
||||
desc: 'Status bucket: online | active | deactive | depleted | expiring.',
|
||||
desc: 'CSV status buckets: online, active, deactive, depleted or expiring. Values are ORed.',
|
||||
optional: true,
|
||||
},
|
||||
{
|
||||
name: 'protocol',
|
||||
in: 'query',
|
||||
type: 'string',
|
||||
desc: 'Match clients attached to at least one inbound of this protocol (vless, vmess, trojan, shadowsocks, ...).',
|
||||
desc: 'CSV inbound protocols: vmess, vless, trojan, shadowsocks, wireguard, hysteria, http, mixed, tunnel, tun, mtproto or amneziawg. Values are ORed.',
|
||||
optional: true,
|
||||
},
|
||||
{
|
||||
name: 'inbound',
|
||||
in: 'query',
|
||||
type: 'string',
|
||||
desc: 'CSV positive inbound IDs. Values are ORed; invalid or non-positive IDs are ignored.',
|
||||
optional: true,
|
||||
},
|
||||
{
|
||||
name: 'sort',
|
||||
in: 'query',
|
||||
type: 'string',
|
||||
desc: 'Sort key: enable | email | inboundIds | traffic | remaining | expiryTime.',
|
||||
desc: 'Sort key. An omitted or unknown value falls back to client ID ascending.',
|
||||
optional: true,
|
||||
enum: [
|
||||
'enable',
|
||||
'email',
|
||||
'inboundIds',
|
||||
'traffic',
|
||||
'remaining',
|
||||
'expiryTime',
|
||||
'createdAt',
|
||||
'updatedAt',
|
||||
'lastOnline',
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'order',
|
||||
in: 'query',
|
||||
type: 'string',
|
||||
desc: 'Sort direction. Only descend selects descending order; otherwise ascending.',
|
||||
optional: true,
|
||||
enum: ['ascend', 'descend'],
|
||||
},
|
||||
{
|
||||
name: 'expiryFrom',
|
||||
in: 'query',
|
||||
type: 'number',
|
||||
desc: 'Inclusive minimum expiry time in Unix milliseconds. Zero or negative means unset.',
|
||||
optional: true,
|
||||
},
|
||||
{
|
||||
name: 'expiryTo',
|
||||
in: 'query',
|
||||
type: 'number',
|
||||
desc: 'Inclusive maximum expiry time in Unix milliseconds. Zero or negative means unbounded.',
|
||||
optional: true,
|
||||
},
|
||||
{
|
||||
name: 'usageFrom',
|
||||
in: 'query',
|
||||
type: 'number',
|
||||
desc: 'Inclusive minimum combined upload and download usage in bytes. Zero means unset.',
|
||||
optional: true,
|
||||
},
|
||||
{
|
||||
name: 'usageTo',
|
||||
in: 'query',
|
||||
type: 'number',
|
||||
desc: 'Inclusive maximum combined upload and download usage in bytes. Zero means unbounded.',
|
||||
optional: true,
|
||||
},
|
||||
{
|
||||
name: 'autoRenew',
|
||||
in: 'query',
|
||||
type: 'string',
|
||||
desc: 'on selects clients with an interval or calendar-day reset; off selects clients without either.',
|
||||
optional: true,
|
||||
enum: ['on', 'off'],
|
||||
},
|
||||
{
|
||||
name: 'hasTgId',
|
||||
in: 'query',
|
||||
type: 'string',
|
||||
desc: 'yes selects clients with a non-zero Telegram ID; no selects clients without one.',
|
||||
optional: true,
|
||||
enum: ['yes', 'no'],
|
||||
},
|
||||
{
|
||||
name: 'hasComment',
|
||||
in: 'query',
|
||||
type: 'string',
|
||||
desc: 'yes selects clients with a non-blank comment; no selects clients without one.',
|
||||
optional: true,
|
||||
enum: ['yes', 'no'],
|
||||
},
|
||||
{
|
||||
name: 'group',
|
||||
in: 'query',
|
||||
type: 'string',
|
||||
desc: 'CSV group names, matched case-insensitively after trimming. Values are ORed.',
|
||||
optional: true,
|
||||
},
|
||||
{ name: 'order', in: 'query', type: 'string', desc: 'ascend or descend.' },
|
||||
],
|
||||
response:
|
||||
'{\n "success": true,\n "obj": {\n "items": [\n {\n "email": "alice@example.com",\n "subId": "abcd1234",\n "enable": true,\n "totalGB": 53687091200,\n "expiryTime": 1735689600000,\n "limitIp": 0,\n "limitHwid": 0,\n "reset": 0,\n "inboundIds": [3, 5],\n "traffic": { "up": 1024, "down": 4096, "enable": true },\n "createdAt": 1735000000000,\n "updatedAt": 1735100000000\n }\n ],\n "total": 2000,\n "filtered": 47,\n "page": 1,\n "pageSize": 25,\n "summary": {\n "total": 2000,\n "active": 1850,\n "onlineCount": 1,\n "depletedCount": 0,\n "expiringCount": 0,\n "deactiveCount": 150,\n "online": ["alice@example.com"],\n "depleted": [],\n "expiring": [],\n "deactive": ["bob@example.com"]\n }\n }\n}',
|
||||
responseSchema: 'ClientPageResponse',
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
@@ -2503,6 +2601,14 @@ export const sections: readonly Section[] = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'HEAD',
|
||||
path: '/{subPath}:subid',
|
||||
summary:
|
||||
'Return the same status and subscription metadata headers as GET without a response body.',
|
||||
params: [{ name: 'subid', in: 'path', type: 'string', desc: 'Client subscription ID.' }],
|
||||
responses: subscriptionHeadResponses,
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/{jsonPath}:subid',
|
||||
@@ -2510,6 +2616,14 @@ export const sections: readonly Section[] = [
|
||||
'Return subscription as a JSON array of proxy configs (one per enabled client). Only when JSON subscription is enabled in settings. The path prefix is configured by subJsonPath.',
|
||||
params: [{ name: 'subid', in: 'path', type: 'string', desc: 'Client subscription ID.' }],
|
||||
},
|
||||
{
|
||||
method: 'HEAD',
|
||||
path: '/{jsonPath}:subid',
|
||||
summary:
|
||||
'Return the JSON subscription status and metadata headers without a body. Registered only when JSON subscriptions are enabled.',
|
||||
params: [{ name: 'subid', in: 'path', type: 'string', desc: 'Client subscription ID.' }],
|
||||
responses: subscriptionHeadResponses,
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/{clashPath}:subid',
|
||||
@@ -2517,6 +2631,14 @@ export const sections: readonly Section[] = [
|
||||
'Return subscription as a Clash/Mihomo-compatible YAML config, including configured global Clash routing rules. Only when Clash subscription is enabled in settings. The path prefix is configured by subClashPath.',
|
||||
params: [{ name: 'subid', in: 'path', type: 'string', desc: 'Client subscription ID.' }],
|
||||
},
|
||||
{
|
||||
method: 'HEAD',
|
||||
path: '/{clashPath}:subid',
|
||||
summary:
|
||||
'Return the Clash subscription status and metadata headers without a body. Registered only when Clash subscriptions are enabled.',
|
||||
params: [{ name: 'subid', in: 'path', type: 'string', desc: 'Client subscription ID.' }],
|
||||
responses: subscriptionHeadResponses,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -2531,36 +2653,11 @@ export const sections: readonly Section[] = [
|
||||
path: '/ws',
|
||||
summary:
|
||||
'Upgrade an HTTP connection to a WebSocket. Requires an authenticated session cookie (Bearer token auth is not supported here). Returns 101 Switching Protocols on success. The server then pushes JSON messages described below.',
|
||||
},
|
||||
{
|
||||
method: 'WS',
|
||||
path: '→ type: status',
|
||||
summary:
|
||||
'Server health snapshot pushed every 2 seconds. Contains CPU, memory, swap, disk, network IO, load, and Xray state — same shape as <code>GET /panel/api/server/status</code>.',
|
||||
response:
|
||||
'{\n "type": "status",\n "data": { "cpu": 12.5, "mem": { "current": 2147483648, "total": 8589934592 }, "xray": { "state": "running" } }\n}',
|
||||
},
|
||||
{
|
||||
method: 'WS',
|
||||
path: '→ type: xrayState',
|
||||
summary:
|
||||
'Xray process state change. Fired when Xray starts, stops, or encounters an error.',
|
||||
response: '{\n "type": "xrayState",\n "data": "running"\n}',
|
||||
},
|
||||
{
|
||||
method: 'WS',
|
||||
path: '→ type: notification',
|
||||
summary:
|
||||
'In-panel toast notification. Fired on Xray stop/restart, DB import, panel restart, etc.',
|
||||
response:
|
||||
'{\n "type": "notification",\n "title": "Xray service restarted",\n "body": "Xray has been restarted successfully",\n "severity": "success"\n}',
|
||||
},
|
||||
{
|
||||
method: 'WS',
|
||||
path: '→ type: invalidate',
|
||||
summary:
|
||||
'Instructs the UI to re-fetch a resource. Fired when another admin session modifies data (e.g. toggling inbound enable).',
|
||||
response: '{\n "type": "invalidate",\n "resource": "inbounds"\n}',
|
||||
responses: {
|
||||
'101': { description: 'Switching Protocols. WebSocket messages use WebSocketEnvelope.' },
|
||||
'401': { description: 'No authenticated panel session cookie.' },
|
||||
},
|
||||
security: [{ cookieAuth: [] }],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -0,0 +1,325 @@
|
||||
export interface WebSocketEventDoc {
|
||||
type: string;
|
||||
summary: string;
|
||||
payloadSchema: Record<string, unknown>;
|
||||
example: {
|
||||
type: string;
|
||||
payload: unknown;
|
||||
time: number;
|
||||
};
|
||||
}
|
||||
|
||||
const eventTypes = [
|
||||
'status',
|
||||
'traffic',
|
||||
'client_stats',
|
||||
'inbounds',
|
||||
'outbounds',
|
||||
'nodes',
|
||||
'notification',
|
||||
'xray_state',
|
||||
'invalidate',
|
||||
] as const;
|
||||
|
||||
const timestamp = 1735689600000;
|
||||
const int64 = { type: 'integer', format: 'int64' };
|
||||
const stringArray = { type: 'array', items: { type: 'string' } };
|
||||
const stringArrayMap = { type: 'object', additionalProperties: stringArray };
|
||||
const timestampMap = { type: 'object', additionalProperties: int64 };
|
||||
const currentTotal = {
|
||||
type: 'object',
|
||||
required: ['current', 'total'],
|
||||
properties: { current: int64, total: int64 },
|
||||
};
|
||||
|
||||
const statusPayloadSchema = {
|
||||
type: 'object',
|
||||
required: [
|
||||
'cpu',
|
||||
'cpuCores',
|
||||
'logicalPro',
|
||||
'cpuSpeedMhz',
|
||||
'mem',
|
||||
'swap',
|
||||
'disk',
|
||||
'diskIO',
|
||||
'diskTraffic',
|
||||
'xray',
|
||||
'amneziawg',
|
||||
'panelVersion',
|
||||
'panelGuid',
|
||||
'uptime',
|
||||
'loads',
|
||||
'tcpCount',
|
||||
'udpCount',
|
||||
'netIO',
|
||||
'netTraffic',
|
||||
'publicIP',
|
||||
'appStats',
|
||||
],
|
||||
properties: {
|
||||
cpu: { type: 'number' },
|
||||
cpuCores: { type: 'integer' },
|
||||
logicalPro: { type: 'integer' },
|
||||
cpuSpeedMhz: { type: 'number' },
|
||||
mem: currentTotal,
|
||||
swap: currentTotal,
|
||||
disk: currentTotal,
|
||||
diskIO: {
|
||||
type: 'object',
|
||||
required: ['read', 'write'],
|
||||
properties: { read: int64, write: int64 },
|
||||
},
|
||||
diskTraffic: {
|
||||
type: 'object',
|
||||
required: ['read', 'write'],
|
||||
properties: { read: int64, write: int64 },
|
||||
},
|
||||
xray: {
|
||||
type: 'object',
|
||||
required: ['state', 'errorMsg', 'version'],
|
||||
properties: {
|
||||
state: { type: 'string', enum: ['running', 'stop', 'error'] },
|
||||
errorMsg: { type: 'string' },
|
||||
version: { type: 'string' },
|
||||
},
|
||||
},
|
||||
amneziawg: {
|
||||
type: 'object',
|
||||
required: ['configured', 'running'],
|
||||
properties: { configured: { type: 'boolean' }, running: { type: 'boolean' } },
|
||||
},
|
||||
panelVersion: { type: 'string' },
|
||||
panelGuid: { type: 'string' },
|
||||
uptime: int64,
|
||||
loads: { type: 'array', nullable: true, items: { type: 'number' } },
|
||||
tcpCount: { type: 'integer' },
|
||||
udpCount: { type: 'integer' },
|
||||
netIO: {
|
||||
type: 'object',
|
||||
required: ['up', 'down', 'pktUp', 'pktDown'],
|
||||
properties: { up: int64, down: int64, pktUp: int64, pktDown: int64 },
|
||||
},
|
||||
netTraffic: {
|
||||
type: 'object',
|
||||
required: ['sent', 'recv', 'pktSent', 'pktRecv'],
|
||||
properties: { sent: int64, recv: int64, pktSent: int64, pktRecv: int64 },
|
||||
},
|
||||
publicIP: {
|
||||
type: 'object',
|
||||
required: ['ipv4', 'ipv6'],
|
||||
properties: { ipv4: { type: 'string' }, ipv6: { type: 'string' } },
|
||||
},
|
||||
appStats: {
|
||||
type: 'object',
|
||||
required: ['threads', 'mem', 'uptime'],
|
||||
properties: { threads: { type: 'integer' }, mem: int64, uptime: int64 },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const trafficPayloadSchema = {
|
||||
type: 'object',
|
||||
required: ['onlineClients', 'onlineByGuid', 'activeInbounds', 'lastOnlineMap'],
|
||||
properties: {
|
||||
traffics: { type: 'array', items: { $ref: '#/components/schemas/Traffic' } },
|
||||
clientTraffics: { type: 'array', items: { $ref: '#/components/schemas/ClientTraffic' } },
|
||||
nodeTraffics: {
|
||||
type: 'array',
|
||||
nullable: true,
|
||||
items: { $ref: '#/components/schemas/Traffic' },
|
||||
},
|
||||
onlineClients: stringArray,
|
||||
onlineByGuid: stringArrayMap,
|
||||
activeInbounds: stringArrayMap,
|
||||
lastOnlineMap: timestampMap,
|
||||
},
|
||||
oneOf: [{ required: ['traffics', 'clientTraffics'] }, { required: ['nodeTraffics'] }],
|
||||
};
|
||||
|
||||
const clientStatsPayloadSchema = {
|
||||
type: 'object',
|
||||
required: ['snapshot'],
|
||||
properties: {
|
||||
snapshot: { type: 'boolean' },
|
||||
clients: { type: 'array', items: { $ref: '#/components/schemas/ClientTraffic' } },
|
||||
inbounds: {
|
||||
type: 'array',
|
||||
items: { $ref: '#/components/schemas/InboundTrafficSummary' },
|
||||
},
|
||||
},
|
||||
anyOf: [{ required: ['clients'] }, { required: ['inbounds'] }],
|
||||
};
|
||||
|
||||
export const websocketEnvelopeSchema = {
|
||||
type: 'object',
|
||||
required: ['type', 'payload', 'time'],
|
||||
properties: {
|
||||
type: { type: 'string', enum: eventTypes },
|
||||
payload: { description: 'Shape is selected by type; see x-websocket-events on GET /ws.' },
|
||||
time: {
|
||||
type: 'integer',
|
||||
format: 'int64',
|
||||
description: 'Server emission time in Unix milliseconds.',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export function buildWebSocketEvents(
|
||||
examples: Record<string, unknown>,
|
||||
): readonly WebSocketEventDoc[] {
|
||||
return [
|
||||
{
|
||||
type: 'status',
|
||||
summary:
|
||||
'Server health snapshot pushed every two seconds; same payload as server/status obj.',
|
||||
payloadSchema: statusPayloadSchema,
|
||||
example: {
|
||||
type: 'status',
|
||||
payload: {
|
||||
cpu: 12.5,
|
||||
cpuCores: 4,
|
||||
logicalPro: 8,
|
||||
cpuSpeedMhz: 3200,
|
||||
mem: { current: 2147483648, total: 8589934592 },
|
||||
swap: { current: 0, total: 2147483648 },
|
||||
disk: { current: 53687091200, total: 107374182400 },
|
||||
diskIO: { read: 1048576, write: 2097152 },
|
||||
diskTraffic: { read: 4096, write: 8192 },
|
||||
xray: { state: 'running', errorMsg: '', version: '25.10.31' },
|
||||
amneziawg: { configured: false, running: false },
|
||||
panelVersion: 'v3.x.x',
|
||||
panelGuid: 'panel-guid',
|
||||
uptime: 86400,
|
||||
loads: [0.1, 0.2, 0.3],
|
||||
tcpCount: 24,
|
||||
udpCount: 8,
|
||||
netIO: { up: 1048576, down: 2097152, pktUp: 100, pktDown: 200 },
|
||||
netTraffic: { sent: 4096, recv: 8192, pktSent: 10, pktRecv: 20 },
|
||||
publicIP: { ipv4: '192.0.2.1', ipv6: '2001:db8::1' },
|
||||
appStats: { threads: 16, mem: 67108864, uptime: 3600 },
|
||||
},
|
||||
time: timestamp,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'traffic',
|
||||
summary:
|
||||
'Live traffic deltas plus online, per-node and last-online maps. Local polls send traffics/clientTraffics; node polls send nodeTraffics.',
|
||||
payloadSchema: trafficPayloadSchema,
|
||||
example: {
|
||||
type: 'traffic',
|
||||
payload: {
|
||||
traffics: [examples.Traffic],
|
||||
clientTraffics: [examples.ClientTraffic],
|
||||
onlineClients: ['alice@example.com'],
|
||||
onlineByGuid: { 'panel-guid': ['alice@example.com'] },
|
||||
activeInbounds: { 'panel-guid': ['inbound-443'] },
|
||||
lastOnlineMap: { 'alice@example.com': timestamp },
|
||||
},
|
||||
time: timestamp,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'client_stats',
|
||||
summary:
|
||||
'Absolute client counters and/or inbound summaries; snapshot says whether clients is complete or only recently active rows.',
|
||||
payloadSchema: clientStatsPayloadSchema,
|
||||
example: {
|
||||
type: 'client_stats',
|
||||
payload: {
|
||||
snapshot: true,
|
||||
clients: [examples.ClientTraffic],
|
||||
inbounds: [examples.InboundTrafficSummary],
|
||||
},
|
||||
time: timestamp,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'inbounds',
|
||||
summary: 'Full inbound list after an inbound mutation, unless invalidate is used at scale.',
|
||||
payloadSchema: { type: 'array', items: { $ref: '#/components/schemas/Inbound' } },
|
||||
example: { type: 'inbounds', payload: [examples.Inbound], time: timestamp },
|
||||
},
|
||||
{
|
||||
type: 'outbounds',
|
||||
summary: 'Current outbound traffic rows after the periodic traffic collection.',
|
||||
payloadSchema: {
|
||||
type: 'array',
|
||||
items: { $ref: '#/components/schemas/OutboundTraffics' },
|
||||
},
|
||||
example: { type: 'outbounds', payload: [examples.OutboundTraffics], time: timestamp },
|
||||
},
|
||||
{
|
||||
type: 'nodes',
|
||||
summary: 'Current node tree after the heartbeat probe cycle.',
|
||||
payloadSchema: { type: 'array', items: { $ref: '#/components/schemas/NodeView' } },
|
||||
example: { type: 'nodes', payload: [examples.NodeView], time: timestamp },
|
||||
},
|
||||
{
|
||||
type: 'notification',
|
||||
summary: 'An in-panel notification emitted by server actions.',
|
||||
payloadSchema: {
|
||||
type: 'object',
|
||||
required: ['title', 'message', 'level'],
|
||||
properties: {
|
||||
title: { type: 'string' },
|
||||
message: { type: 'string' },
|
||||
level: { type: 'string', enum: ['success', 'warning'] },
|
||||
},
|
||||
},
|
||||
example: {
|
||||
type: 'notification',
|
||||
payload: {
|
||||
title: 'Xray service restarted',
|
||||
message: 'Xray service has been restarted successfully',
|
||||
level: 'success',
|
||||
},
|
||||
time: timestamp,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'xray_state',
|
||||
summary: 'Xray process state change after a stop, restart or error.',
|
||||
payloadSchema: {
|
||||
type: 'object',
|
||||
required: ['state', 'errorMsg'],
|
||||
properties: {
|
||||
state: { type: 'string', enum: ['running', 'stop', 'error'] },
|
||||
errorMsg: { type: 'string' },
|
||||
},
|
||||
},
|
||||
example: {
|
||||
type: 'xray_state',
|
||||
payload: { state: 'running', errorMsg: '' },
|
||||
time: timestamp,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'invalidate',
|
||||
summary:
|
||||
'Requests a REST re-fetch. clients is an invalidate payload type, not a top-level event.',
|
||||
payloadSchema: {
|
||||
type: 'object',
|
||||
required: ['type'],
|
||||
properties: {
|
||||
type: {
|
||||
type: 'string',
|
||||
enum: [
|
||||
'status',
|
||||
'traffic',
|
||||
'client_stats',
|
||||
'inbounds',
|
||||
'outbounds',
|
||||
'nodes',
|
||||
'notification',
|
||||
'xray_state',
|
||||
'clients',
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
example: { type: 'invalidate', payload: { type: 'inbounds' }, time: timestamp },
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { buildSpec } from '../../scripts/build-openapi.mjs';
|
||||
|
||||
interface OpenApiSchema {
|
||||
$ref?: string;
|
||||
type?: string;
|
||||
format?: string;
|
||||
enum?: readonly string[];
|
||||
required?: string[];
|
||||
properties?: Record<string, OpenApiSchema>;
|
||||
items?: OpenApiSchema;
|
||||
}
|
||||
|
||||
interface OpenApiParameter {
|
||||
name: string;
|
||||
required: boolean;
|
||||
description: string;
|
||||
schema: OpenApiSchema;
|
||||
}
|
||||
|
||||
interface WebSocketEventDoc {
|
||||
type: string;
|
||||
summary: string;
|
||||
payloadSchema: OpenApiSchema;
|
||||
example: { type: string; payload: unknown; time: number };
|
||||
}
|
||||
|
||||
interface OpenApiOperation {
|
||||
parameters?: OpenApiParameter[];
|
||||
responses: Record<
|
||||
string,
|
||||
{
|
||||
content?: Record<string, { schema: OpenApiSchema }>;
|
||||
}
|
||||
>;
|
||||
security?: Record<string, never[]>[];
|
||||
'x-websocket-events'?: WebSocketEventDoc[];
|
||||
}
|
||||
|
||||
interface OpenApiSpec {
|
||||
paths: Record<string, Record<string, OpenApiOperation>>;
|
||||
components: { schemas: Record<string, OpenApiSchema> };
|
||||
}
|
||||
|
||||
const spec = buildSpec() as unknown as OpenApiSpec;
|
||||
|
||||
function operation(path: string, method: string): OpenApiOperation {
|
||||
const op = spec.paths[path]?.[method];
|
||||
if (!op) throw new Error(`${method.toUpperCase()} ${path} is missing`);
|
||||
return op;
|
||||
}
|
||||
|
||||
function responseObjectSchema(path: string, method = 'get'): OpenApiSchema {
|
||||
const schema = operation(path, method).responses['200']?.content?.['application/json']?.schema;
|
||||
const obj = schema?.properties?.obj;
|
||||
if (!obj) throw new Error(`${method.toUpperCase()} ${path} has no response obj schema`);
|
||||
return obj;
|
||||
}
|
||||
|
||||
describe('generated OpenAPI runtime contracts', () => {
|
||||
it('exports only valid OpenAPI paths and HTTP methods', () => {
|
||||
const validMethods = new Set([
|
||||
'get',
|
||||
'put',
|
||||
'post',
|
||||
'delete',
|
||||
'options',
|
||||
'head',
|
||||
'patch',
|
||||
'trace',
|
||||
]);
|
||||
|
||||
for (const [path, pathItem] of Object.entries(spec.paths)) {
|
||||
expect(path.startsWith('/'), path).toBe(true);
|
||||
for (const method of Object.keys(pathItem)) {
|
||||
expect(validMethods.has(method), `${method.toUpperCase()} ${path}`).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('documents the WebSocket handshake and every emitted event', () => {
|
||||
const ws = operation('/ws', 'get');
|
||||
expect(Object.keys(ws.responses)).toEqual(['101', '401']);
|
||||
expect(ws.security).toEqual([{ cookieAuth: [] }]);
|
||||
|
||||
const envelope = spec.components.schemas.WebSocketEnvelope;
|
||||
expect(envelope.required).toEqual(['type', 'payload', 'time']);
|
||||
expect(envelope.properties?.time).toMatchObject({ type: 'integer', format: 'int64' });
|
||||
|
||||
const events = ws['x-websocket-events'] ?? [];
|
||||
expect(events.map((event) => event.type)).toEqual([
|
||||
'status',
|
||||
'traffic',
|
||||
'client_stats',
|
||||
'inbounds',
|
||||
'outbounds',
|
||||
'nodes',
|
||||
'notification',
|
||||
'xray_state',
|
||||
'invalidate',
|
||||
]);
|
||||
expect(events.map((event) => event.type)).not.toContain('clients');
|
||||
|
||||
for (const event of events) {
|
||||
expect(Object.keys(event.example)).toEqual(['type', 'payload', 'time']);
|
||||
expect(event.example.type).toBe(event.type);
|
||||
expect(event.example.time).toEqual(expect.any(Number));
|
||||
}
|
||||
|
||||
const byType = Object.fromEntries(events.map((event) => [event.type, event]));
|
||||
expect(Object.keys(byType.notification.payloadSchema.properties ?? {})).toEqual([
|
||||
'title',
|
||||
'message',
|
||||
'level',
|
||||
]);
|
||||
expect(Object.keys(byType.xray_state.payloadSchema.properties ?? {})).toEqual([
|
||||
'state',
|
||||
'errorMsg',
|
||||
]);
|
||||
expect(byType.invalidate.payloadSchema.properties?.type?.enum).toContain('clients');
|
||||
expect(byType.xray_state.example.payload).toEqual({ state: 'running', errorMsg: '' });
|
||||
expect(byType.invalidate.example.payload).toEqual({ type: 'inbounds' });
|
||||
expect(byType.status.payloadSchema.properties?.loads).toMatchObject({
|
||||
type: 'array',
|
||||
nullable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('uses the runtime REST response schemas', () => {
|
||||
expect(responseObjectSchema('/panel/api/server/logs/{count}', 'post')).toEqual({
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
});
|
||||
expect(responseObjectSchema('/panel/api/server/xraylogs/{count}', 'post')).toEqual({
|
||||
type: 'array',
|
||||
items: { $ref: '#/components/schemas/LogEntry' },
|
||||
});
|
||||
expect(responseObjectSchema('/panel/api/server/getNewUUID')).toEqual({
|
||||
$ref: '#/components/schemas/NewUUIDResponse',
|
||||
});
|
||||
expect(responseObjectSchema('/panel/api/server/getNewmldsa65')).toEqual({
|
||||
$ref: '#/components/schemas/MLDSA65Response',
|
||||
});
|
||||
expect(responseObjectSchema('/panel/api/server/getNewmlkem768')).toEqual({
|
||||
$ref: '#/components/schemas/MLKEM768Response',
|
||||
});
|
||||
|
||||
const logEntryFields = Object.keys(spec.components.schemas.LogEntry.properties ?? {});
|
||||
expect(logEntryFields).toHaveLength(7);
|
||||
expect(logEntryFields).toEqual(
|
||||
expect.arrayContaining([
|
||||
'DateTime',
|
||||
'FromAddress',
|
||||
'ToAddress',
|
||||
'Inbound',
|
||||
'Outbound',
|
||||
'Email',
|
||||
'Event',
|
||||
]),
|
||||
);
|
||||
expect(spec.components.schemas.LogEntry.properties?.DateTime).toMatchObject({
|
||||
type: 'string',
|
||||
format: 'date-time',
|
||||
});
|
||||
expect(Object.keys(spec.components.schemas.NewUUIDResponse.properties ?? {})).toEqual(['uuid']);
|
||||
expect(Object.keys(spec.components.schemas.MLDSA65Response.properties ?? {})).toEqual([
|
||||
'seed',
|
||||
'verify',
|
||||
]);
|
||||
const mlkemFields = Object.keys(spec.components.schemas.MLKEM768Response.properties ?? {});
|
||||
expect(mlkemFields).toHaveLength(2);
|
||||
expect(mlkemFields).toEqual(expect.arrayContaining(['seed', 'client']));
|
||||
});
|
||||
|
||||
it('documents every paged-client query and the groups response', () => {
|
||||
const paged = operation('/panel/api/clients/list/paged', 'get');
|
||||
expect(paged.parameters?.map((param) => param.name)).toEqual([
|
||||
'page',
|
||||
'pageSize',
|
||||
'search',
|
||||
'filter',
|
||||
'protocol',
|
||||
'inbound',
|
||||
'sort',
|
||||
'order',
|
||||
'expiryFrom',
|
||||
'expiryTo',
|
||||
'usageFrom',
|
||||
'usageTo',
|
||||
'autoRenew',
|
||||
'hasTgId',
|
||||
'hasComment',
|
||||
'group',
|
||||
]);
|
||||
expect(paged.parameters?.every((param) => param.required === false)).toBe(true);
|
||||
|
||||
const params = Object.fromEntries((paged.parameters ?? []).map((param) => [param.name, param]));
|
||||
for (const name of ['filter', 'protocol', 'inbound', 'group']) {
|
||||
expect(params[name].description).toContain('CSV');
|
||||
}
|
||||
expect(params.sort.schema.enum).toEqual([
|
||||
'enable',
|
||||
'email',
|
||||
'inboundIds',
|
||||
'traffic',
|
||||
'remaining',
|
||||
'expiryTime',
|
||||
'createdAt',
|
||||
'updatedAt',
|
||||
'lastOnline',
|
||||
]);
|
||||
|
||||
expect(responseObjectSchema('/panel/api/clients/list/paged')).toEqual({
|
||||
$ref: '#/components/schemas/ClientPageResponse',
|
||||
});
|
||||
expect(spec.components.schemas.ClientPageResponse.properties?.groups).toMatchObject({
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
});
|
||||
});
|
||||
|
||||
it('includes HEAD operations for every subscription variant', () => {
|
||||
expect(operation('/{subPath}{subid}', 'head')).toBeDefined();
|
||||
expect(operation('/{jsonPath}{subid}', 'head')).toBeDefined();
|
||||
expect(operation('/{clashPath}{subid}', 'head')).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -78,7 +78,7 @@ func documentedContractRoutes(t *testing.T) map[string]bool {
|
||||
t.Fatalf("read endpoints.ts: %v", err)
|
||||
}
|
||||
text := string(source)
|
||||
methodRe := regexp.MustCompile(`method:\s*'(GET|POST|PUT|DELETE|PATCH|WS)'`)
|
||||
methodRe := regexp.MustCompile(`method:\s*'(GET|POST|PUT|DELETE|PATCH|HEAD|WS)'`)
|
||||
pathRe := regexp.MustCompile(`path:\s*'([^']+)'`)
|
||||
methods := methodRe.FindAllStringSubmatchIndex(text, -1)
|
||||
if declared := strings.Count(text, "method: '"); len(methods) != declared {
|
||||
|
||||
@@ -18,22 +18,22 @@ import (
|
||||
// so the list payload stays compact even when the panel manages thousands
|
||||
// of clients. Modals that need the full record still call /get/:email.
|
||||
type ClientSlim struct {
|
||||
Email string `json:"email"`
|
||||
SubID string `json:"subId"`
|
||||
Enable bool `json:"enable"`
|
||||
TotalGB int64 `json:"totalGB"`
|
||||
ExpiryTime int64 `json:"expiryTime"`
|
||||
LimitIP int `json:"limitIp"`
|
||||
LimitHwid int `json:"limitHwid"`
|
||||
Reset int `json:"reset"`
|
||||
ResetDay int `json:"resetDay"`
|
||||
ResetMax int `json:"resetMax"`
|
||||
Group string `json:"group,omitempty"`
|
||||
Comment string `json:"comment,omitempty"`
|
||||
InboundIds []int `json:"inboundIds"`
|
||||
Email string `json:"email" example:"alice@example.com"`
|
||||
SubID string `json:"subId" example:"abcd1234"`
|
||||
Enable bool `json:"enable" example:"true"`
|
||||
TotalGB int64 `json:"totalGB" example:"53687091200"`
|
||||
ExpiryTime int64 `json:"expiryTime" example:"1735689600000"`
|
||||
LimitIP int `json:"limitIp" example:"0"`
|
||||
LimitHwid int `json:"limitHwid" example:"0"`
|
||||
Reset int `json:"reset" example:"0"`
|
||||
ResetDay int `json:"resetDay" example:"0"`
|
||||
ResetMax int `json:"resetMax" example:"0"`
|
||||
Group string `json:"group,omitempty" example:"staff"`
|
||||
Comment string `json:"comment,omitempty" example:"Primary device"`
|
||||
InboundIds []int `json:"inboundIds" example:"[3,5]"`
|
||||
Traffic *xray.ClientTraffic `json:"traffic,omitempty"`
|
||||
CreatedAt int64 `json:"createdAt"`
|
||||
UpdatedAt int64 `json:"updatedAt"`
|
||||
CreatedAt int64 `json:"createdAt" example:"1735000000000"`
|
||||
UpdatedAt int64 `json:"updatedAt" example:"1735100000000"`
|
||||
}
|
||||
|
||||
// ClientPageParams are the query params accepted by /panel/api/clients/list/paged.
|
||||
@@ -70,12 +70,12 @@ type ClientPageParams struct {
|
||||
// on the clients page stay stable as the user paginates/filters.
|
||||
type ClientPageResponse struct {
|
||||
Items []ClientSlim `json:"items"`
|
||||
Total int `json:"total"`
|
||||
Filtered int `json:"filtered"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"pageSize"`
|
||||
Total int `json:"total" example:"2000"`
|
||||
Filtered int `json:"filtered" example:"47"`
|
||||
Page int `json:"page" example:"1"`
|
||||
PageSize int `json:"pageSize" example:"25"`
|
||||
Summary ClientsSummary `json:"summary"`
|
||||
Groups []string `json:"groups"`
|
||||
Groups []string `json:"groups" example:"[\"staff\",\"trial\"]"`
|
||||
}
|
||||
|
||||
// ClientsSummary collects per-bucket counts plus the matching email lists so
|
||||
@@ -83,16 +83,16 @@ type ClientPageResponse struct {
|
||||
// popovers without shipping the full client array. The counters are exact;
|
||||
// the lists stop at clientSummaryEmailCap entries and only back the popovers.
|
||||
type ClientsSummary struct {
|
||||
Total int `json:"total"`
|
||||
Active int `json:"active"`
|
||||
OnlineCount int `json:"onlineCount"`
|
||||
DepletedCount int `json:"depletedCount"`
|
||||
ExpiringCount int `json:"expiringCount"`
|
||||
DeactiveCount int `json:"deactiveCount"`
|
||||
Online []string `json:"online"`
|
||||
Depleted []string `json:"depleted"`
|
||||
Expiring []string `json:"expiring"`
|
||||
Deactive []string `json:"deactive"`
|
||||
Total int `json:"total" example:"2000"`
|
||||
Active int `json:"active" example:"1850"`
|
||||
OnlineCount int `json:"onlineCount" example:"1"`
|
||||
DepletedCount int `json:"depletedCount" example:"0"`
|
||||
ExpiringCount int `json:"expiringCount" example:"0"`
|
||||
DeactiveCount int `json:"deactiveCount" example:"150"`
|
||||
Online []string `json:"online" example:"[\"alice@example.com\"]"`
|
||||
Depleted []string `json:"depleted" example:"[]"`
|
||||
Expiring []string `json:"expiring" example:"[]"`
|
||||
Deactive []string `json:"deactive" example:"[\"bob@example.com\"]"`
|
||||
}
|
||||
|
||||
const (
|
||||
|
||||
@@ -1148,11 +1148,11 @@ func (s *InboundService) CountClientTraffics() (int64, error) {
|
||||
}
|
||||
|
||||
type InboundTrafficSummary struct {
|
||||
Id int `json:"id"`
|
||||
Up int64 `json:"up"`
|
||||
Down int64 `json:"down"`
|
||||
Total int64 `json:"total"`
|
||||
Enable bool `json:"enable"`
|
||||
Id int `json:"id" example:"1"`
|
||||
Up int64 `json:"up" example:"1048576"`
|
||||
Down int64 `json:"down" example:"2097152"`
|
||||
Total int64 `json:"total" example:"10737418240"`
|
||||
Enable bool `json:"enable" example:"true"`
|
||||
}
|
||||
|
||||
func (s *InboundService) GetInboundsTrafficSummary() ([]InboundTrafficSummary, error) {
|
||||
|
||||
@@ -351,13 +351,27 @@ func (s *ServerService) AggregateSystemMetric(metric string, bucketSeconds int,
|
||||
}
|
||||
|
||||
type LogEntry struct {
|
||||
DateTime time.Time
|
||||
FromAddress string
|
||||
ToAddress string
|
||||
Inbound string
|
||||
Outbound string
|
||||
Email string
|
||||
Event int
|
||||
DateTime time.Time `json:"DateTime" example:"2025-01-01T12:00:00Z"`
|
||||
FromAddress string `json:"FromAddress" example:"192.0.2.10:54321"`
|
||||
ToAddress string `json:"ToAddress" example:"example.com:443"`
|
||||
Inbound string `json:"Inbound" example:"inbound-443"`
|
||||
Outbound string `json:"Outbound" example:"direct"`
|
||||
Email string `json:"Email" example:"alice@example.com"`
|
||||
Event int `json:"Event" example:"0"`
|
||||
}
|
||||
|
||||
type NewUUIDResponse struct {
|
||||
UUID string `json:"uuid" example:"550e8400-e29b-41d4-a716-446655440000"`
|
||||
}
|
||||
|
||||
type MLDSA65Response struct {
|
||||
Seed string `json:"seed" example:"mldsa65-seed"`
|
||||
Verify string `json:"verify" example:"mldsa65-verify"`
|
||||
}
|
||||
|
||||
type MLKEM768Response struct {
|
||||
Seed string `json:"seed" example:"mlkem768-seed"`
|
||||
Client string `json:"client" example:"mlkem768-client"`
|
||||
}
|
||||
|
||||
func getPublicIP(url string) string {
|
||||
@@ -2328,7 +2342,7 @@ func (s *ServerService) GetNewX25519Cert() (any, error) {
|
||||
return keyPair, nil
|
||||
}
|
||||
|
||||
func (s *ServerService) GetNewmldsa65() (any, error) {
|
||||
func (s *ServerService) GetNewmldsa65() (*MLDSA65Response, error) {
|
||||
// Run the command
|
||||
cmd := exec.CommandContext(context.Background(), xray.GetBinaryPath(), "mldsa65")
|
||||
var out bytes.Buffer
|
||||
@@ -2343,9 +2357,9 @@ func (s *ServerService) GetNewmldsa65() (any, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
keyPair := map[string]any{
|
||||
"seed": seed,
|
||||
"verify": verify,
|
||||
keyPair := &MLDSA65Response{
|
||||
Seed: seed,
|
||||
Verify: verify,
|
||||
}
|
||||
|
||||
return keyPair, nil
|
||||
@@ -2636,18 +2650,18 @@ func vlessEncAuthID(label string) string {
|
||||
}
|
||||
}
|
||||
|
||||
func (s *ServerService) GetNewUUID() (map[string]string, error) {
|
||||
func (s *ServerService) GetNewUUID() (*NewUUIDResponse, error) {
|
||||
newUUID, err := uuid.NewRandom()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate UUID: %w", err)
|
||||
}
|
||||
|
||||
return map[string]string{
|
||||
"uuid": newUUID.String(),
|
||||
return &NewUUIDResponse{
|
||||
UUID: newUUID.String(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *ServerService) GetNewmlkem768() (any, error) {
|
||||
func (s *ServerService) GetNewmlkem768() (*MLKEM768Response, error) {
|
||||
// Run the command
|
||||
cmd := exec.CommandContext(context.Background(), xray.GetBinaryPath(), "mlkem768")
|
||||
var out bytes.Buffer
|
||||
@@ -2662,9 +2676,9 @@ func (s *ServerService) GetNewmlkem768() (any, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
keyPair := map[string]any{
|
||||
"seed": seed,
|
||||
"client": client,
|
||||
keyPair := &MLKEM768Response{
|
||||
Seed: seed,
|
||||
Client: client,
|
||||
}
|
||||
|
||||
return keyPair, nil
|
||||
|
||||
@@ -3,9 +3,9 @@ package xray
|
||||
// Traffic represents network traffic statistics for Xray connections.
|
||||
// It tracks upload and download bytes for inbound or outbound traffic.
|
||||
type Traffic struct {
|
||||
IsInbound bool
|
||||
IsOutbound bool
|
||||
Tag string
|
||||
Up int64
|
||||
Down int64
|
||||
IsInbound bool `json:"IsInbound" example:"true"`
|
||||
IsOutbound bool `json:"IsOutbound" example:"false"`
|
||||
Tag string `json:"Tag" example:"inbound-443"`
|
||||
Up int64 `json:"Up" example:"1048576"`
|
||||
Down int64 `json:"Down" example:"2097152"`
|
||||
}
|
||||
|
||||
@@ -73,6 +73,7 @@ func run(root, outDir string) error {
|
||||
Path: resolveRel(root, "internal/xray"),
|
||||
StructAllow: setOf(
|
||||
"ClientTraffic",
|
||||
"Traffic",
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -90,6 +91,14 @@ func run(root, outDir string) error {
|
||||
Path: resolveRel(root, "internal/web/service"),
|
||||
StructAllow: setOf(
|
||||
"InboundOption",
|
||||
"ClientSlim",
|
||||
"ClientPageResponse",
|
||||
"ClientsSummary",
|
||||
"InboundTrafficSummary",
|
||||
"LogEntry",
|
||||
"NewUUIDResponse",
|
||||
"MLDSA65Response",
|
||||
"MLKEM768Response",
|
||||
"NodeMutationRequest",
|
||||
"NodeView",
|
||||
"ProbeResultUI",
|
||||
|
||||
Reference in New Issue
Block a user