feat(api-docs): generate response examples from Go structs; fix SS2022 PSK regen (#4996)

Stop hand-writing OpenAPI response examples, which kept drifting from the real payloads (clients/traffic missing fields, inbounds/list exposing userId which is json:"-", the fictional inbound-443 tag instead of the real in-<port>-<transport> form).

tools/openapigen now emits frontend/src/generated/examples.ts: a per-struct example instance built from type defaults, validate oneof/min bounds, and example: struct tags, with nested-ref expansion and a cycle guard. build-openapi.mjs composes the {success,obj} envelope from it for any endpoint annotated with responseSchema (+ responseSchemaArray for lists); the hand-written response is dropped for those. Service DTOs InboundOption/ApiTokenView/ProbeResultUI are added to the walker.

#4996: client password regeneration now produces a valid Shadowsocks 2022 PSK (correct base64 length per cipher) when an SS2022 inbound is attached, in both the single and bulk client forms; backend surfaces ssMethod on /inbounds/options so the UI can pick the right length.

Also: Swagger UI persists the Authorization token across reloads (persistAuthorization).
This commit is contained in:
MHSanaei
2026-06-06 14:58:15 +02:00
parent 483952cfa0
commit 83799d71b0
22 changed files with 924 additions and 143 deletions
+17
View File
@@ -37,6 +37,23 @@ jobs:
go list ./... | grep -v '/frontend/node_modules/' > /tmp/go-packages.txt go list ./... | grep -v '/frontend/node_modules/' > /tmp/go-packages.txt
go test $(cat /tmp/go-packages.txt) go test $(cat /tmp/go-packages.txt)
codegen:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
cache: true
- uses: actions/setup-node@v6
with:
node-version-file: .nvmrc
- name: Regenerate schemas, examples and OpenAPI
run: npm run gen
working-directory: frontend
- name: Fail if generated files are stale (run 'npm run gen' and commit)
run: git diff --exit-code -- frontend/src/generated frontend/public/openapi.json
govulncheck: govulncheck:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
+28 -28
View File
@@ -41,13 +41,13 @@ type User struct {
// Inbound represents an Xray inbound configuration with traffic statistics and settings. // Inbound represents an Xray inbound configuration with traffic statistics and settings.
type Inbound struct { type Inbound struct {
Id int `json:"id" form:"id" gorm:"primaryKey;autoIncrement"` // Unique identifier Id int `json:"id" form:"id" gorm:"primaryKey;autoIncrement" example:"1"` // Unique identifier
UserId int `json:"-"` // Associated user ID UserId int `json:"-"` // Associated user ID
Up int64 `json:"up" form:"up"` // Upload traffic in bytes Up int64 `json:"up" form:"up"` // Upload traffic in bytes
Down int64 `json:"down" form:"down"` // Download traffic in bytes Down int64 `json:"down" form:"down"` // Download traffic in bytes
Total int64 `json:"total" form:"total"` // Total traffic limit in bytes Total int64 `json:"total" form:"total"` // Total traffic limit in bytes
Remark string `json:"remark" form:"remark"` // Human-readable remark Remark string `json:"remark" form:"remark" example:"VLESS-443"` // Human-readable remark
Enable bool `json:"enable" form:"enable" gorm:"index:idx_enable_traffic_reset,priority:1"` // Whether the inbound is enabled Enable bool `json:"enable" form:"enable" gorm:"index:idx_enable_traffic_reset,priority:1" example:"true"` // Whether the inbound is enabled
ExpiryTime int64 `json:"expiryTime" form:"expiryTime"` // Expiration timestamp ExpiryTime int64 `json:"expiryTime" form:"expiryTime"` // Expiration timestamp
TrafficReset string `json:"trafficReset" form:"trafficReset" gorm:"default:never;index:idx_enable_traffic_reset,priority:2" validate:"omitempty,oneof=never hourly daily weekly monthly"` // Traffic reset schedule TrafficReset string `json:"trafficReset" form:"trafficReset" gorm:"default:never;index:idx_enable_traffic_reset,priority:2" validate:"omitempty,oneof=never hourly daily weekly monthly"` // Traffic reset schedule
LastTrafficResetTime int64 `json:"lastTrafficResetTime" form:"lastTrafficResetTime" gorm:"default:0"` // Last traffic reset timestamp LastTrafficResetTime int64 `json:"lastTrafficResetTime" form:"lastTrafficResetTime" gorm:"default:0"` // Last traffic reset timestamp
@@ -55,11 +55,11 @@ type Inbound struct {
// Xray configuration fields // Xray configuration fields
Listen string `json:"listen" form:"listen"` Listen string `json:"listen" form:"listen"`
Port int `json:"port" form:"port" validate:"gte=0,lte=65535"` Port int `json:"port" form:"port" validate:"gte=0,lte=65535" example:"443"`
Protocol Protocol `json:"protocol" form:"protocol" validate:"required,oneof=vmess vless trojan shadowsocks wireguard hysteria http mixed tunnel tun"` Protocol Protocol `json:"protocol" form:"protocol" validate:"required,oneof=vmess vless trojan shadowsocks wireguard hysteria http mixed tunnel tun" example:"vless"`
Settings string `json:"settings" form:"settings"` Settings string `json:"settings" form:"settings"`
StreamSettings string `json:"streamSettings" form:"streamSettings"` StreamSettings string `json:"streamSettings" form:"streamSettings"`
Tag string `json:"tag" form:"tag" gorm:"unique"` Tag string `json:"tag" form:"tag" gorm:"unique" example:"in-443-tcp"`
Sniffing string `json:"sniffing" form:"sniffing"` Sniffing string `json:"sniffing" form:"sniffing"`
NodeID *int `json:"nodeId,omitempty" form:"nodeId" gorm:"index"` NodeID *int `json:"nodeId,omitempty" form:"nodeId" gorm:"index"`
@@ -378,15 +378,15 @@ type Setting struct {
// endpoint over HTTP using the per-node ApiToken to populate the runtime // endpoint over HTTP using the per-node ApiToken to populate the runtime
// status fields below. // status fields below.
type Node struct { type Node struct {
Id int `json:"id" form:"id" gorm:"primaryKey;autoIncrement"` Id int `json:"id" form:"id" gorm:"primaryKey;autoIncrement" example:"1"`
Name string `json:"name" form:"name" gorm:"uniqueIndex" validate:"required"` Name string `json:"name" form:"name" gorm:"uniqueIndex" validate:"required" example:"de-fra-1"`
Remark string `json:"remark" form:"remark"` Remark string `json:"remark" form:"remark"`
Scheme string `json:"scheme" form:"scheme" validate:"omitempty,oneof=http https"` Scheme string `json:"scheme" form:"scheme" validate:"omitempty,oneof=http https" example:"https"`
Address string `json:"address" form:"address" validate:"required"` Address string `json:"address" form:"address" validate:"required" example:"node1.example.com"`
Port int `json:"port" form:"port" validate:"gte=1,lte=65535"` Port int `json:"port" form:"port" validate:"gte=1,lte=65535" example:"2053"`
BasePath string `json:"basePath" form:"basePath"` BasePath string `json:"basePath" form:"basePath" example:"/"`
ApiToken string `json:"apiToken" form:"apiToken" validate:"required"` ApiToken string `json:"apiToken" form:"apiToken" validate:"required" example:"abcdef0123456789"`
Enable bool `json:"enable" form:"enable" gorm:"default:true"` Enable bool `json:"enable" form:"enable" gorm:"default:true" example:"true"`
AllowPrivateAddress bool `json:"allowPrivateAddress" form:"allowPrivateAddress" gorm:"default:false"` AllowPrivateAddress bool `json:"allowPrivateAddress" form:"allowPrivateAddress" gorm:"default:false"`
TlsVerifyMode string `json:"tlsVerifyMode" form:"tlsVerifyMode" gorm:"column:tls_verify_mode;default:verify" validate:"omitempty,oneof=verify skip pin"` TlsVerifyMode string `json:"tlsVerifyMode" form:"tlsVerifyMode" gorm:"column:tls_verify_mode;default:verify" validate:"omitempty,oneof=verify skip pin"`
PinnedCertSha256 string `json:"pinnedCertSha256" form:"pinnedCertSha256" gorm:"column:pinned_cert_sha256"` PinnedCertSha256 string `json:"pinnedCertSha256" form:"pinnedCertSha256" gorm:"column:pinned_cert_sha256"`
@@ -401,23 +401,23 @@ type Node struct {
// Heartbeat-updated fields. UpdatedAt advances on every probe even when // Heartbeat-updated fields. UpdatedAt advances on every probe even when
// the row is otherwise unchanged so the UI's "last seen" tooltip is // the row is otherwise unchanged so the UI's "last seen" tooltip is
// truthful without us having to read LastHeartbeat separately. // truthful without us having to read LastHeartbeat separately.
Status string `json:"status" gorm:"default:unknown"` // online|offline|unknown Status string `json:"status" gorm:"default:unknown" example:"online"` // online|offline|unknown
LastHeartbeat int64 `json:"lastHeartbeat"` // unix seconds, 0 = never LastHeartbeat int64 `json:"lastHeartbeat" example:"1700000000"` // unix seconds, 0 = never
LatencyMs int `json:"latencyMs"` LatencyMs int `json:"latencyMs" example:"42"`
XrayVersion string `json:"xrayVersion"` XrayVersion string `json:"xrayVersion" example:"25.10.31"`
PanelVersion string `json:"panelVersion" gorm:"column:panel_version"` PanelVersion string `json:"panelVersion" gorm:"column:panel_version" example:"v3.x.x"`
CpuPct float64 `json:"cpuPct"` CpuPct float64 `json:"cpuPct" example:"23.5"`
MemPct float64 `json:"memPct"` MemPct float64 `json:"memPct" example:"45.1"`
UptimeSecs uint64 `json:"uptimeSecs"` UptimeSecs uint64 `json:"uptimeSecs" example:"86400"`
LastError string `json:"lastError"` LastError string `json:"lastError"`
ConfigDirty bool `json:"configDirty" gorm:"default:false"` ConfigDirty bool `json:"configDirty" gorm:"default:false"`
ConfigDirtyAt int64 `json:"configDirtyAt"` ConfigDirtyAt int64 `json:"configDirtyAt"`
InboundCount int `json:"inboundCount" gorm:"-"` InboundCount int `json:"inboundCount" gorm:"-" example:"5"`
ClientCount int `json:"clientCount" gorm:"-"` ClientCount int `json:"clientCount" gorm:"-" example:"27"`
OnlineCount int `json:"onlineCount" gorm:"-"` OnlineCount int `json:"onlineCount" gorm:"-" example:"3"`
DepletedCount int `json:"depletedCount" gorm:"-"` DepletedCount int `json:"depletedCount" gorm:"-" example:"1"`
// ParentGuid + Transitive are set only when a node is surfaced as part of a // ParentGuid + Transitive are set only when a node is surfaced as part of a
// node tree (#4983): direct nodes carry the master panel's own GUID, a // node tree (#4983): direct nodes carry the master panel's own GUID, a
@@ -426,8 +426,8 @@ type Node struct {
ParentGuid string `json:"parentGuid,omitempty" gorm:"-"` ParentGuid string `json:"parentGuid,omitempty" gorm:"-"`
Transitive bool `json:"transitive,omitempty" gorm:"-"` Transitive bool `json:"transitive,omitempty" gorm:"-"`
CreatedAt int64 `json:"createdAt" gorm:"autoCreateTime:milli"` CreatedAt int64 `json:"createdAt" gorm:"autoCreateTime:milli" example:"1700000000"`
UpdatedAt int64 `json:"updatedAt" gorm:"autoUpdateTime:milli"` UpdatedAt int64 `json:"updatedAt" gorm:"autoUpdateTime:milli" example:"1700000000"`
} }
// NodeSummary is the read-only identity of a node as published one hop up: the // NodeSummary is the read-only identity of a node as published one hop up: the
+1
View File
@@ -16,6 +16,7 @@
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest", "test:watch": "vitest",
"gen": "npm run gen:zod && npm run gen:api",
"gen:api": "node --experimental-strip-types --disable-warning=ExperimentalWarning scripts/build-openapi.mjs", "gen:api": "node --experimental-strip-types --disable-warning=ExperimentalWarning scripts/build-openapi.mjs",
"gen:zod": "cd .. && go run ./tools/openapigen" "gen:zod": "cd .. && go run ./tools/openapigen"
}, },
+81 -63
View File
@@ -304,38 +304,41 @@
"success": true, "success": true,
"obj": [ "obj": [
{ {
"id": 1, "clientStats": [
"userId": 1, {
"up": 0, "down": 2097152,
"email": "user1",
"enable": true,
"expiryTime": 1735689600000,
"id": 14825,
"inboundId": 1,
"lastOnline": 1735680000000,
"reset": 0,
"subId": "i7tvdpeffi0hvvf1",
"total": 10737418240,
"up": 1048576,
"uuid": "e18c9a96-71bf-48d4-933f-8b9a46d4290c"
}
],
"down": 0, "down": 0,
"total": 0,
"remark": "VLESS-443",
"enable": true, "enable": true,
"expiryTime": 0, "expiryTime": 0,
"fallbackParent": null,
"id": 1,
"lastTrafficResetTime": 0,
"listen": "", "listen": "",
"nodeId": null,
"originNodeGuid": "",
"port": 443, "port": 443,
"protocol": "vless", "protocol": "vless",
"settings": { "remark": "VLESS-443",
"clients": [], "settings": null,
"decryption": "none" "sniffing": null,
}, "streamSettings": null,
"streamSettings": { "tag": "in-443-tcp",
"network": "tcp", "total": 0,
"security": "reality", "trafficReset": "never",
"realitySettings": { "up": 0
"show": false,
"dest": "..."
}
},
"tag": "inbound-443",
"sniffing": {
"enabled": true,
"destOverride": [
"http",
"tls"
]
},
"clientStats": []
} }
] ]
} }
@@ -374,7 +377,6 @@
"obj": [ "obj": [
{ {
"id": 1, "id": 1,
"userId": 1,
"remark": "VLESS-443", "remark": "VLESS-443",
"settings": { "settings": {
"clients": [ "clients": [
@@ -400,7 +402,7 @@
"tags": [ "tags": [
"Inbounds" "Inbounds"
], ],
"summary": "Lightweight picker projection of the authenticated users inbounds. Returns only id, remark, protocol, port, and a server-computed tlsFlowCapable flag (true for VLESS / port-fallback on TCP with tls or reality). Use this for dropdowns and attach pickers — it skips settings, streamSettings, and clientStats so the payload stays small even on panels with thousands of clients.", "summary": "Lightweight picker projection of the authenticated users inbounds. Returns id, remark, tag, protocol, port, a server-computed tlsFlowCapable flag (true for VLESS / port-fallback on TCP with tls or reality), and ssMethod (the Shadowsocks cipher, empty for non-Shadowsocks inbounds — used by the client UI to generate a valid Shadowsocks 2022 PSK). Use this for dropdowns and attach pickers — it skips settings, streamSettings, and clientStats so the payload stays small even on panels with thousands of clients.",
"operationId": "get_panel_api_inbounds_options", "operationId": "get_panel_api_inbounds_options",
"responses": { "responses": {
"200": { "200": {
@@ -424,9 +426,11 @@
"obj": [ "obj": [
{ {
"id": 1, "id": 1,
"remark": "VLESS-443",
"protocol": "vless",
"port": 443, "port": 443,
"protocol": "vless",
"remark": "VLESS-443",
"ssMethod": "",
"tag": "in-443-tcp",
"tlsFlowCapable": true "tlsFlowCapable": true
} }
] ]
@@ -3828,8 +3832,8 @@
"success": true, "success": true,
"obj": { "obj": {
"a1b2-...": [ "a1b2-...": [
"inbound-443", "in-443-tcp",
"inbound-8443" "in-8443-tcp"
] ]
} }
} }
@@ -3914,11 +3918,18 @@
"example": { "example": {
"success": true, "success": true,
"obj": { "obj": {
"email": "user1",
"up": 1048576,
"down": 2097152, "down": 2097152,
"email": "user1",
"enable": true,
"expiryTime": 1735689600000,
"id": 14825,
"inboundId": 1,
"lastOnline": 1735680000000,
"reset": 0,
"subId": "i7tvdpeffi0hvvf1",
"total": 10737418240, "total": 10737418240,
"expiryTime": 1735689600000 "up": 1048576,
"uuid": "e18c9a96-71bf-48d4-933f-8b9a46d4290c"
} }
} }
} }
@@ -4050,31 +4061,38 @@
"success": true, "success": true,
"obj": [ "obj": [
{ {
"id": 1,
"name": "de-fra-1",
"remark": "",
"scheme": "https",
"address": "node1.example.com", "address": "node1.example.com",
"port": 2053,
"basePath": "/",
"apiToken": "abcdef...",
"enable": true,
"allowPrivateAddress": false, "allowPrivateAddress": false,
"status": "online", "apiToken": "abcdef0123456789",
"basePath": "/",
"clientCount": 27,
"configDirty": false,
"configDirtyAt": 0,
"cpuPct": 23.5,
"createdAt": 1700000000,
"depletedCount": 1,
"enable": true,
"guid": "",
"id": 1,
"inboundCount": 5,
"lastError": "",
"lastHeartbeat": 1700000000, "lastHeartbeat": 1700000000,
"latencyMs": 42, "latencyMs": 42,
"xrayVersion": "25.x.x",
"panelVersion": "v3.x.x",
"cpuPct": 23.5,
"memPct": 45.1, "memPct": 45.1,
"uptimeSecs": 86400, "name": "de-fra-1",
"lastError": "",
"inboundCount": 5,
"clientCount": 27,
"onlineCount": 3, "onlineCount": 3,
"depletedCount": 1, "panelVersion": "v3.x.x",
"createdAt": 1700000000, "parentGuid": "",
"updatedAt": 1700000000 "pinnedCertSha256": "",
"port": 2053,
"remark": "",
"scheme": "https",
"status": "online",
"tlsVerifyMode": "verify",
"transitive": false,
"updatedAt": 1700000000,
"uptimeSecs": 86400,
"xrayVersion": "25.10.31"
} }
] ]
} }
@@ -4425,14 +4443,14 @@
"example": { "example": {
"success": true, "success": true,
"obj": { "obj": {
"status": "online",
"latencyMs": 42,
"xrayVersion": "25.x.x",
"panelVersion": "v3.x.x",
"cpuPct": 12.5, "cpuPct": 12.5,
"error": "",
"latencyMs": 42,
"memPct": 45.2, "memPct": 45.2,
"panelVersion": "v3.x.x",
"status": "online",
"uptimeSecs": 86400, "uptimeSecs": 86400,
"error": "" "xrayVersion": "25.10.31"
} }
} }
} }
@@ -5262,11 +5280,11 @@
"example": { "example": {
"success": true, "success": true,
"obj": { "obj": {
"createdAt": 1736000000,
"enabled": true,
"id": 2, "id": 2,
"name": "central-panel-a", "name": "central-panel-a",
"token": "new-token-string", "token": "new-token-string"
"enabled": true,
"createdAt": 1736000000
} }
} }
} }
@@ -5435,7 +5453,7 @@
"success": true, "success": true,
"obj": { "obj": {
"xraySetting": "{...raw xray config...}", "xraySetting": "{...raw xray config...}",
"inboundTags": "[\"inbound-443\"]", "inboundTags": "[\"in-443-tcp\"]",
"clientReverseTags": "[]", "clientReverseTags": "[]",
"outboundTestUrl": "https://www.google.com/generate_204" "outboundTestUrl": "https://www.google.com/generate_204"
} }
+9 -1
View File
@@ -4,6 +4,7 @@ import { join, dirname } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url'; import { fileURLToPath, pathToFileURL } from 'node:url';
import { sections } from '../src/pages/api-docs/endpoints.ts'; import { sections } from '../src/pages/api-docs/endpoints.ts';
import { EXAMPLES } from '../src/generated/examples.ts';
const __dirname = dirname(fileURLToPath(import.meta.url)); const __dirname = dirname(fileURLToPath(import.meta.url));
const outPath = join(__dirname, '..', 'public', 'openapi.json'); const outPath = join(__dirname, '..', 'public', 'openapi.json');
@@ -128,7 +129,14 @@ function buildOperation(ep, tag) {
} }
const responses = {}; const responses = {};
const successExample = tryParseJson(ep.response); let successExample = tryParseJson(ep.response);
if (successExample === undefined && ep.responseSchema) {
const obj = EXAMPLES[ep.responseSchema];
if (obj === undefined) {
throw new Error(`${ep.method} ${ep.path}: responseSchema "${ep.responseSchema}" has no generated example`);
}
successExample = { success: true, obj: ep.responseSchemaArray ? [obj] : obj };
}
responses['200'] = { responses['200'] = {
description: 'Successful response', description: 'Successful response',
content: { content: {
+396
View File
@@ -0,0 +1,396 @@
// Code generated by tools/openapigen. DO NOT EDIT.
export const EXAMPLES: Record<string, unknown> = {
"AllSetting": {
"datepicker": "",
"expireDiff": 0,
"externalTrafficInformEnable": false,
"externalTrafficInformURI": "",
"ldapAutoCreate": false,
"ldapAutoDelete": false,
"ldapBaseDN": "",
"ldapBindDN": "",
"ldapDefaultExpiryDays": 0,
"ldapDefaultLimitIP": 0,
"ldapDefaultTotalGB": 0,
"ldapEnable": false,
"ldapFlagField": "",
"ldapHost": "",
"ldapInboundTags": "",
"ldapInvertFlag": false,
"ldapPassword": "",
"ldapPort": 0,
"ldapSyncCron": "",
"ldapTruthyValues": "",
"ldapUseTLS": false,
"ldapUserAttr": "",
"ldapUserFilter": "",
"ldapVlessField": "",
"pageSize": 0,
"panelProxy": "",
"remarkModel": "",
"restartXrayOnClientDisable": false,
"sessionMaxAge": 1,
"subAnnounce": "",
"subCertFile": "",
"subClashEnable": false,
"subClashEnableRouting": false,
"subClashPath": "",
"subClashRules": "",
"subClashURI": "",
"subDomain": "",
"subEmailInRemark": false,
"subEnable": false,
"subEnableRouting": false,
"subEncrypt": false,
"subJsonEnable": false,
"subJsonFinalMask": "",
"subJsonMux": "",
"subJsonPath": "",
"subJsonRules": "",
"subJsonURI": "",
"subKeyFile": "",
"subListen": "",
"subPath": "",
"subPort": 1,
"subProfileUrl": "",
"subRoutingRules": "",
"subShowInfo": false,
"subSupportUrl": "",
"subTitle": "",
"subURI": "",
"subUpdates": 0,
"tgBotAPIServer": "",
"tgBotBackup": false,
"tgBotChatId": "",
"tgBotEnable": false,
"tgBotLoginNotify": false,
"tgBotProxy": "",
"tgBotToken": "",
"tgCpu": 0,
"tgLang": "",
"tgRunTime": "",
"timeLocation": "",
"trafficDiff": 0,
"trustedProxyCIDRs": "",
"twoFactorEnable": false,
"twoFactorToken": "",
"webBasePath": "",
"webCertFile": "",
"webDomain": "",
"webKeyFile": "",
"webListen": "",
"webPort": 1
},
"AllSettingView": {
"datepicker": "",
"expireDiff": 0,
"externalTrafficInformEnable": false,
"externalTrafficInformURI": "",
"hasApiToken": false,
"hasLdapPassword": false,
"hasNordSecret": false,
"hasTgBotToken": false,
"hasTwoFactorToken": false,
"hasWarpSecret": false,
"ldapAutoCreate": false,
"ldapAutoDelete": false,
"ldapBaseDN": "",
"ldapBindDN": "",
"ldapDefaultExpiryDays": 0,
"ldapDefaultLimitIP": 0,
"ldapDefaultTotalGB": 0,
"ldapEnable": false,
"ldapFlagField": "",
"ldapHost": "",
"ldapInboundTags": "",
"ldapInvertFlag": false,
"ldapPassword": "",
"ldapPort": 0,
"ldapSyncCron": "",
"ldapTruthyValues": "",
"ldapUseTLS": false,
"ldapUserAttr": "",
"ldapUserFilter": "",
"ldapVlessField": "",
"pageSize": 0,
"panelProxy": "",
"remarkModel": "",
"restartXrayOnClientDisable": false,
"sessionMaxAge": 1,
"subAnnounce": "",
"subCertFile": "",
"subClashEnable": false,
"subClashEnableRouting": false,
"subClashPath": "",
"subClashRules": "",
"subClashURI": "",
"subDomain": "",
"subEmailInRemark": false,
"subEnable": false,
"subEnableRouting": false,
"subEncrypt": false,
"subJsonEnable": false,
"subJsonFinalMask": "",
"subJsonMux": "",
"subJsonPath": "",
"subJsonRules": "",
"subJsonURI": "",
"subKeyFile": "",
"subListen": "",
"subPath": "",
"subPort": 1,
"subProfileUrl": "",
"subRoutingRules": "",
"subShowInfo": false,
"subSupportUrl": "",
"subTitle": "",
"subURI": "",
"subUpdates": 0,
"tgBotAPIServer": "",
"tgBotBackup": false,
"tgBotChatId": "",
"tgBotEnable": false,
"tgBotLoginNotify": false,
"tgBotProxy": "",
"tgBotToken": "",
"tgCpu": 0,
"tgLang": "",
"tgRunTime": "",
"timeLocation": "",
"trafficDiff": 0,
"trustedProxyCIDRs": "",
"twoFactorEnable": false,
"twoFactorToken": "",
"webBasePath": "",
"webCertFile": "",
"webDomain": "",
"webKeyFile": "",
"webListen": "",
"webPort": 1
},
"ApiToken": {
"createdAt": 0,
"enabled": false,
"id": 0,
"name": "",
"token": ""
},
"ApiTokenView": {
"createdAt": 1736000000,
"enabled": true,
"id": 2,
"name": "central-panel-a",
"token": "new-token-string"
},
"Client": {
"auth": "",
"comment": "",
"created_at": 0,
"email": "",
"enable": false,
"expiryTime": 0,
"flow": "",
"group": "",
"id": "",
"limitIp": 0,
"password": "",
"reset": 0,
"reverse": null,
"security": "",
"subId": "",
"tgId": 0,
"totalGB": 0,
"updated_at": 0
},
"ClientInbound": {
"clientId": 0,
"createdAt": 0,
"flowOverride": "",
"inboundId": 0
},
"ClientRecord": {
"auth": "",
"comment": "",
"createdAt": 0,
"email": "",
"enable": false,
"expiryTime": 0,
"flow": "",
"group": "",
"id": 0,
"limitIp": 0,
"password": "",
"reset": 0,
"reverse": null,
"security": "",
"subId": "",
"tgId": 0,
"totalGB": 0,
"updatedAt": 0,
"uuid": ""
},
"ClientReverse": {
"tag": ""
},
"ClientTraffic": {
"down": 2097152,
"email": "user1",
"enable": true,
"expiryTime": 1735689600000,
"id": 14825,
"inboundId": 1,
"lastOnline": 1735680000000,
"reset": 0,
"subId": "i7tvdpeffi0hvvf1",
"total": 10737418240,
"up": 1048576,
"uuid": "e18c9a96-71bf-48d4-933f-8b9a46d4290c"
},
"CustomGeoResource": {
"alias": "",
"createdAt": 0,
"id": 0,
"lastModified": "",
"lastUpdatedAt": 0,
"localPath": "",
"type": "",
"updatedAt": 0,
"url": ""
},
"FallbackParentInfo": {
"masterId": 0,
"path": ""
},
"HistoryOfSeeders": {
"id": 0,
"seederName": ""
},
"Inbound": {
"clientStats": [
{
"down": 2097152,
"email": "user1",
"enable": true,
"expiryTime": 1735689600000,
"id": 14825,
"inboundId": 1,
"lastOnline": 1735680000000,
"reset": 0,
"subId": "i7tvdpeffi0hvvf1",
"total": 10737418240,
"up": 1048576,
"uuid": "e18c9a96-71bf-48d4-933f-8b9a46d4290c"
}
],
"down": 0,
"enable": true,
"expiryTime": 0,
"fallbackParent": null,
"id": 1,
"lastTrafficResetTime": 0,
"listen": "",
"nodeId": null,
"originNodeGuid": "",
"port": 443,
"protocol": "vless",
"remark": "VLESS-443",
"settings": null,
"sniffing": null,
"streamSettings": null,
"tag": "in-443-tcp",
"total": 0,
"trafficReset": "never",
"up": 0
},
"InboundClientIps": {
"clientEmail": "",
"id": 0,
"ips": null
},
"InboundFallback": {
"alpn": "",
"childId": 0,
"dest": "",
"id": 0,
"masterId": 0,
"name": "",
"path": "",
"sortOrder": 0,
"xver": 0
},
"InboundOption": {
"id": 1,
"port": 443,
"protocol": "vless",
"remark": "VLESS-443",
"ssMethod": "",
"tag": "in-443-tcp",
"tlsFlowCapable": true
},
"Msg": {
"msg": "",
"obj": null,
"success": false
},
"Node": {
"address": "node1.example.com",
"allowPrivateAddress": false,
"apiToken": "abcdef0123456789",
"basePath": "/",
"clientCount": 27,
"configDirty": false,
"configDirtyAt": 0,
"cpuPct": 23.5,
"createdAt": 1700000000,
"depletedCount": 1,
"enable": true,
"guid": "",
"id": 1,
"inboundCount": 5,
"lastError": "",
"lastHeartbeat": 1700000000,
"latencyMs": 42,
"memPct": 45.1,
"name": "de-fra-1",
"onlineCount": 3,
"panelVersion": "v3.x.x",
"parentGuid": "",
"pinnedCertSha256": "",
"port": 2053,
"remark": "",
"scheme": "https",
"status": "online",
"tlsVerifyMode": "verify",
"transitive": false,
"updatedAt": 1700000000,
"uptimeSecs": 86400,
"xrayVersion": "25.10.31"
},
"OutboundTraffics": {
"down": 0,
"id": 0,
"tag": "",
"total": 0,
"up": 0
},
"ProbeResultUI": {
"cpuPct": 12.5,
"error": "",
"latencyMs": 42,
"memPct": 45.2,
"panelVersion": "v3.x.x",
"status": "online",
"uptimeSecs": 86400,
"xrayVersion": "25.10.31"
},
"Setting": {
"id": 0,
"key": "",
"value": ""
},
"User": {
"id": 0,
"password": "",
"username": ""
}
};
+37
View File
@@ -1,5 +1,9 @@
// Code generated by tools/openapigen. DO NOT EDIT. // Code generated by tools/openapigen. DO NOT EDIT.
export type LoginStatus = number;
export type ProcessState = string;
export type Protocol = string; export type Protocol = string;
export type SubLinkProvider = unknown;
export type transportBits = number;
export interface AllSetting { export interface AllSetting {
datepicker: string; datepicker: string;
@@ -179,6 +183,14 @@ export interface ApiToken {
token: string; token: string;
} }
export interface ApiTokenView {
createdAt: number;
enabled: boolean;
id: number;
name: string;
token?: string;
}
export interface Client { export interface Client {
auth?: string; auth?: string;
comment: string; comment: string;
@@ -280,6 +292,7 @@ export interface Inbound {
lastTrafficResetTime: number; lastTrafficResetTime: number;
listen: string; listen: string;
nodeId?: number | null; nodeId?: number | null;
originNodeGuid?: string;
port: number; port: number;
protocol: Protocol; protocol: Protocol;
remark: string; remark: string;
@@ -310,6 +323,16 @@ export interface InboundFallback {
xver: number; xver: number;
} }
export interface InboundOption {
id: number;
port: number;
protocol: string;
remark: string;
ssMethod: string;
tag: string;
tlsFlowCapable: boolean;
}
export interface Msg { export interface Msg {
msg: string; msg: string;
obj: unknown; obj: unknown;
@@ -328,6 +351,7 @@ export interface Node {
createdAt: number; createdAt: number;
depletedCount: number; depletedCount: number;
enable: boolean; enable: boolean;
guid: string;
id: number; id: number;
inboundCount: number; inboundCount: number;
lastError: string; lastError: string;
@@ -337,12 +361,14 @@ export interface Node {
name: string; name: string;
onlineCount: number; onlineCount: number;
panelVersion: string; panelVersion: string;
parentGuid?: string;
pinnedCertSha256: string; pinnedCertSha256: string;
port: number; port: number;
remark: string; remark: string;
scheme: string; scheme: string;
status: string; status: string;
tlsVerifyMode: string; tlsVerifyMode: string;
transitive?: boolean;
updatedAt: number; updatedAt: number;
uptimeSecs: number; uptimeSecs: number;
xrayVersion: string; xrayVersion: string;
@@ -356,6 +382,17 @@ export interface OutboundTraffics {
up: number; up: number;
} }
export interface ProbeResultUI {
cpuPct: number;
error: string;
latencyMs: number;
memPct: number;
panelVersion: string;
status: string;
uptimeSecs: number;
xrayVersion: string;
}
export interface Setting { export interface Setting {
id: number; id: number;
key: string; key: string;
+48
View File
@@ -1,8 +1,20 @@
// Code generated by tools/openapigen. DO NOT EDIT. // Code generated by tools/openapigen. DO NOT EDIT.
import { z } from 'zod'; import { z } from 'zod';
export const LoginStatusSchema = z.number().int();
export type LoginStatus = z.infer<typeof LoginStatusSchema>;
export const ProcessStateSchema = z.string();
export type ProcessState = z.infer<typeof ProcessStateSchema>;
export const ProtocolSchema = z.string(); export const ProtocolSchema = z.string();
export type Protocol = z.infer<typeof ProtocolSchema>; export type Protocol = z.infer<typeof ProtocolSchema>;
export const SubLinkProviderSchema = z.unknown();
export type SubLinkProvider = z.infer<typeof SubLinkProviderSchema>;
export const transportBitsSchema = z.number().int();
export type transportBits = z.infer<typeof transportBitsSchema>;
export const AllSettingSchema = z.object({ export const AllSettingSchema = z.object({
datepicker: z.string(), datepicker: z.string(),
expireDiff: z.number().int().min(0), expireDiff: z.number().int().min(0),
@@ -184,6 +196,15 @@ export const ApiTokenSchema = z.object({
}); });
export type ApiToken = z.infer<typeof ApiTokenSchema>; export type ApiToken = z.infer<typeof ApiTokenSchema>;
export const ApiTokenViewSchema = z.object({
createdAt: z.number().int(),
enabled: z.boolean(),
id: z.number().int(),
name: z.string(),
token: z.string().optional(),
});
export type ApiTokenView = z.infer<typeof ApiTokenViewSchema>;
export const ClientSchema = z.object({ export const ClientSchema = z.object({
auth: z.string().optional(), auth: z.string().optional(),
comment: z.string(), comment: z.string(),
@@ -293,6 +314,7 @@ export const InboundSchema = z.object({
lastTrafficResetTime: z.number().int(), lastTrafficResetTime: z.number().int(),
listen: z.string(), listen: z.string(),
nodeId: z.number().int().nullable().optional(), nodeId: z.number().int().nullable().optional(),
originNodeGuid: z.string().optional(),
port: z.number().int().min(0).max(65535), port: z.number().int().min(0).max(65535),
protocol: z.enum(['vmess', 'vless', 'trojan', 'shadowsocks', 'wireguard', 'hysteria', 'http', 'mixed', 'tunnel', 'tun']), protocol: z.enum(['vmess', 'vless', 'trojan', 'shadowsocks', 'wireguard', 'hysteria', 'http', 'mixed', 'tunnel', 'tun']),
remark: z.string(), remark: z.string(),
@@ -326,6 +348,17 @@ export const InboundFallbackSchema = z.object({
}); });
export type InboundFallback = z.infer<typeof InboundFallbackSchema>; export type InboundFallback = z.infer<typeof InboundFallbackSchema>;
export const InboundOptionSchema = z.object({
id: z.number().int(),
port: z.number().int(),
protocol: z.string(),
remark: z.string(),
ssMethod: z.string(),
tag: z.string(),
tlsFlowCapable: z.boolean(),
});
export type InboundOption = z.infer<typeof InboundOptionSchema>;
export const MsgSchema = z.object({ export const MsgSchema = z.object({
msg: z.string(), msg: z.string(),
obj: z.unknown(), obj: z.unknown(),
@@ -345,6 +378,7 @@ export const NodeSchema = z.object({
createdAt: z.number().int(), createdAt: z.number().int(),
depletedCount: z.number().int(), depletedCount: z.number().int(),
enable: z.boolean(), enable: z.boolean(),
guid: z.string(),
id: z.number().int(), id: z.number().int(),
inboundCount: z.number().int(), inboundCount: z.number().int(),
lastError: z.string(), lastError: z.string(),
@@ -354,12 +388,14 @@ export const NodeSchema = z.object({
name: z.string(), name: z.string(),
onlineCount: z.number().int(), onlineCount: z.number().int(),
panelVersion: z.string(), panelVersion: z.string(),
parentGuid: z.string().optional(),
pinnedCertSha256: z.string(), pinnedCertSha256: z.string(),
port: z.number().int().min(1).max(65535), port: z.number().int().min(1).max(65535),
remark: z.string(), remark: z.string(),
scheme: z.enum(['http', 'https']), scheme: z.enum(['http', 'https']),
status: z.string(), status: z.string(),
tlsVerifyMode: z.enum(['verify', 'skip', 'pin']), tlsVerifyMode: z.enum(['verify', 'skip', 'pin']),
transitive: z.boolean().optional(),
updatedAt: z.number().int(), updatedAt: z.number().int(),
uptimeSecs: z.number().int(), uptimeSecs: z.number().int(),
xrayVersion: z.string(), xrayVersion: z.string(),
@@ -375,6 +411,18 @@ export const OutboundTrafficsSchema = z.object({
}); });
export type OutboundTraffics = z.infer<typeof OutboundTrafficsSchema>; export type OutboundTraffics = z.infer<typeof OutboundTrafficsSchema>;
export const ProbeResultUISchema = z.object({
cpuPct: z.number(),
error: z.string(),
latencyMs: z.number().int(),
memPct: z.number(),
panelVersion: z.string(),
status: z.string(),
uptimeSecs: z.number().int(),
xrayVersion: z.string(),
});
export type ProbeResultUI = z.infer<typeof ProbeResultUISchema>;
export const SettingSchema = z.object({ export const SettingSchema = z.object({
id: z.number().int(), id: z.number().int(),
key: z.string(), key: z.string(),
@@ -33,6 +33,7 @@ export default function ApiDocsPage() {
docExpansion="list" docExpansion="list"
deepLinking={false} deepLinking={false}
tryItOutEnabled tryItOutEnabled
persistAuthorization
/> />
</div> </div>
</Layout.Content> </Layout.Content>
+15 -12
View File
@@ -38,6 +38,8 @@ export interface Endpoint {
response?: string; response?: string;
errorResponse?: string; errorResponse?: string;
errorStatus?: number; errorStatus?: number;
responseSchema?: string;
responseSchemaArray?: boolean;
} }
export interface SubscriptionHeader { export interface SubscriptionHeader {
@@ -107,22 +109,22 @@ export const sections: readonly Section[] = [
method: 'GET', method: 'GET',
path: '/panel/api/inbounds/list', path: '/panel/api/inbounds/list',
summary: 'List every inbound owned by the authenticated user, including each inbounds clientStats traffic counters. settings, streamSettings, and sniffing are returned as nested JSON objects (no escaped strings); legacy callers that send them back as JSON-encoded strings are still accepted on write.', summary: 'List every inbound owned by the authenticated user, including each inbounds clientStats traffic counters. settings, streamSettings, and sniffing are returned as nested JSON objects (no escaped strings); legacy callers that send them back as JSON-encoded strings are still accepted on write.',
response: responseSchema: 'Inbound',
'{\n "success": true,\n "obj": [\n {\n "id": 1,\n "userId": 1,\n "up": 0,\n "down": 0,\n "total": 0,\n "remark": "VLESS-443",\n "enable": true,\n "expiryTime": 0,\n "listen": "",\n "port": 443,\n "protocol": "vless",\n "settings": {\n "clients": [],\n "decryption": "none"\n },\n "streamSettings": {\n "network": "tcp",\n "security": "reality",\n "realitySettings": { "show": false, "dest": "..." }\n },\n "tag": "inbound-443",\n "sniffing": {\n "enabled": true,\n "destOverride": ["http", "tls"]\n },\n "clientStats": []\n }\n ]\n}', responseSchemaArray: true,
}, },
{ {
method: 'GET', method: 'GET',
path: '/panel/api/inbounds/list/slim', path: '/panel/api/inbounds/list/slim',
summary: 'Same shape as /list but with settings.clients[] stripped down to {email, enable, comment} and ClientStats not enriched with UUID/SubId. Use this for list pages; fetch /get/:id when you need the full per-client payload (uuid, password, flow, ...).', summary: 'Same shape as /list but with settings.clients[] stripped down to {email, enable, comment} and ClientStats not enriched with UUID/SubId. Use this for list pages; fetch /get/:id when you need the full per-client payload (uuid, password, flow, ...).',
response: response:
'{\n "success": true,\n "obj": [\n {\n "id": 1,\n "userId": 1,\n "remark": "VLESS-443",\n "settings": {\n "clients": [\n { "email": "alice", "enable": true }\n ],\n "decryption": "none"\n },\n "clientStats": []\n }\n ]\n}', '{\n "success": true,\n "obj": [\n {\n "id": 1,\n "remark": "VLESS-443",\n "settings": {\n "clients": [\n { "email": "alice", "enable": true }\n ],\n "decryption": "none"\n },\n "clientStats": []\n }\n ]\n}',
}, },
{ {
method: 'GET', method: 'GET',
path: '/panel/api/inbounds/options', path: '/panel/api/inbounds/options',
summary: 'Lightweight picker projection of the authenticated users inbounds. Returns only id, remark, protocol, port, and a server-computed tlsFlowCapable flag (true for VLESS / port-fallback on TCP with tls or reality). Use this for dropdowns and attach pickers — it skips settings, streamSettings, and clientStats so the payload stays small even on panels with thousands of clients.', summary: 'Lightweight picker projection of the authenticated users inbounds. Returns id, remark, tag, protocol, port, a server-computed tlsFlowCapable flag (true for VLESS / port-fallback on TCP with tls or reality), and ssMethod (the Shadowsocks cipher, empty for non-Shadowsocks inbounds — used by the client UI to generate a valid Shadowsocks 2022 PSK). Use this for dropdowns and attach pickers — it skips settings, streamSettings, and clientStats so the payload stays small even on panels with thousands of clients.',
response: responseSchema: 'InboundOption',
'{\n "success": true,\n "obj": [\n {\n "id": 1,\n "remark": "VLESS-443",\n "protocol": "vless",\n "port": 443,\n "tlsFlowCapable": true\n }\n ]\n}', responseSchemaArray: true,
}, },
{ {
method: 'GET', method: 'GET',
@@ -696,7 +698,7 @@ export const sections: readonly Section[] = [
method: 'POST', method: 'POST',
path: '/panel/api/clients/activeInbounds', path: '/panel/api/clients/activeInbounds',
summary: 'Inbound tags that carried traffic within the heartbeat window, grouped by the hosting node\'s panelGuid. Pairs with onlinesByGuid so the inbounds page only marks a multi-inbound client online on the inbounds it actually used. Nodes that do not report per-inbound activity are absent.', summary: 'Inbound tags that carried traffic within the heartbeat window, grouped by the hosting node\'s panelGuid. Pairs with onlinesByGuid so the inbounds page only marks a multi-inbound client online on the inbounds it actually used. Nodes that do not report per-inbound activity are absent.',
response: '{\n "success": true,\n "obj": {\n "a1b2-...": ["inbound-443", "inbound-8443"]\n }\n}', response: '{\n "success": true,\n "obj": {\n "a1b2-...": ["in-443-tcp", "in-8443-tcp"]\n }\n}',
}, },
{ {
method: 'POST', method: 'POST',
@@ -711,7 +713,7 @@ export const sections: readonly Section[] = [
params: [ params: [
{ name: 'email', in: 'path', type: 'string', desc: 'Client email (unique across the panel).' }, { name: 'email', in: 'path', type: 'string', desc: 'Client email (unique across the panel).' },
], ],
response: '{\n "success": true,\n "obj": {\n "email": "user1",\n "up": 1048576,\n "down": 2097152,\n "total": 10737418240,\n "expiryTime": 1735689600000\n }\n}', responseSchema: 'ClientTraffic',
}, },
{ {
method: 'GET', method: 'GET',
@@ -748,7 +750,8 @@ export const sections: readonly Section[] = [
method: 'GET', method: 'GET',
path: '/panel/api/nodes/list', path: '/panel/api/nodes/list',
summary: 'List every configured node with its connection details, health, and last heartbeat patch.', summary: 'List every configured node with its connection details, health, and last heartbeat patch.',
response: '{\n "success": true,\n "obj": [\n {\n "id": 1,\n "name": "de-fra-1",\n "remark": "",\n "scheme": "https",\n "address": "node1.example.com",\n "port": 2053,\n "basePath": "/",\n "apiToken": "abcdef...",\n "enable": true,\n "allowPrivateAddress": false,\n "status": "online",\n "lastHeartbeat": 1700000000,\n "latencyMs": 42,\n "xrayVersion": "25.x.x",\n "panelVersion": "v3.x.x",\n "cpuPct": 23.5,\n "memPct": 45.1,\n "uptimeSecs": 86400,\n "lastError": "",\n "inboundCount": 5,\n "clientCount": 27,\n "onlineCount": 3,\n "depletedCount": 1,\n "createdAt": 1700000000,\n "updatedAt": 1700000000\n }\n ]\n}', responseSchema: 'Node',
responseSchemaArray: true,
}, },
{ {
method: 'GET', method: 'GET',
@@ -805,7 +808,7 @@ export const sections: readonly Section[] = [
path: '/panel/api/nodes/test', path: '/panel/api/nodes/test',
summary: 'Probe a node without saving it. Uses the body as connection details and returns the same heartbeat snapshot a registered node would have.', summary: 'Probe a node without saving it. Uses the body as connection details and returns the same heartbeat snapshot a registered node would have.',
body: '{\n "scheme": "https",\n "address": "node1.example.com",\n "port": 2053,\n "basePath": "/",\n "apiToken": "abcdef..."\n}', body: '{\n "scheme": "https",\n "address": "node1.example.com",\n "port": 2053,\n "basePath": "/",\n "apiToken": "abcdef..."\n}',
response: '{\n "success": true,\n "obj": {\n "status": "online",\n "latencyMs": 42,\n "xrayVersion": "25.x.x",\n "panelVersion": "v3.x.x",\n "cpuPct": 12.5,\n "memPct": 45.2,\n "uptimeSecs": 86400,\n "error": ""\n }\n}', responseSchema: 'ProbeResultUI',
}, },
{ {
method: 'POST', method: 'POST',
@@ -978,7 +981,7 @@ export const sections: readonly Section[] = [
{ name: 'name', in: 'body', type: 'string', desc: 'Human-readable label, e.g. "central-panel-a".' }, { name: 'name', in: 'body', type: 'string', desc: 'Human-readable label, e.g. "central-panel-a".' },
], ],
body: '{\n "name": "central-panel-a"\n}', body: '{\n "name": "central-panel-a"\n}',
response: '{\n "success": true,\n "obj": {\n "id": 2,\n "name": "central-panel-a",\n "token": "new-token-string",\n "enabled": true,\n "createdAt": 1736000000\n }\n}', responseSchema: 'ApiTokenView',
errorResponse: '{\n "success": false,\n "msg": "a token with that name already exists"\n}', errorResponse: '{\n "success": false,\n "msg": "a token with that name already exists"\n}',
}, },
{ {
@@ -1014,7 +1017,7 @@ export const sections: readonly Section[] = [
method: 'POST', method: 'POST',
path: '/panel/xray/', path: '/panel/xray/',
summary: 'Return the Xray config template (JSON string), available inbound tags, client reverse tags, and the configured outbound test URL in one response.', summary: 'Return the Xray config template (JSON string), available inbound tags, client reverse tags, and the configured outbound test URL in one response.',
response: '{\n "success": true,\n "obj": {\n "xraySetting": "{...raw xray config...}",\n "inboundTags": "[\\"inbound-443\\"]",\n "clientReverseTags": "[]",\n "outboundTestUrl": "https://www.google.com/generate_204"\n }\n}', response: '{\n "success": true,\n "obj": {\n "xraySetting": "{...raw xray config...}",\n "inboundTags": "[\\"in-443-tcp\\"]",\n "clientReverseTags": "[]",\n "outboundTestUrl": "https://www.google.com/generate_204"\n }\n}',
}, },
{ {
method: 'GET', method: 'GET',
@@ -89,6 +89,15 @@ export default function ClientBulkAddModal({
[form.inboundIds, flowCapableIds], [form.inboundIds, flowCapableIds],
); );
const ss2022Method = useMemo(() => {
for (const id of form.inboundIds || []) {
const ib = (inbounds || []).find((row) => row.id === id);
const method = ib?.ssMethod;
if (method && method.substring(0, 4) === '2022') return method;
}
return '';
}, [form.inboundIds, inbounds]);
useEffect(() => { useEffect(() => {
if (!showFlow && form.flow) { if (!showFlow && form.flow) {
@@ -153,7 +162,9 @@ export default function ClientBulkAddModal({
email, email,
subId: form.subId || RandomUtil.randomLowerAndNum(16), subId: form.subId || RandomUtil.randomLowerAndNum(16),
id: RandomUtil.randomUUID(), id: RandomUtil.randomUUID(),
password: RandomUtil.randomLowerAndNum(16), password: ss2022Method
? RandomUtil.randomShadowsocksPassword(ss2022Method)
: RandomUtil.randomLowerAndNum(16),
auth: RandomUtil.randomLowerAndNum(16), auth: RandomUtil.randomLowerAndNum(16),
flow: showFlow ? (form.flow || '') : '', flow: showFlow ? (form.flow || '') : '',
totalGB: Math.round((form.totalGB || 0) * SizeFormatter.ONE_GB), totalGB: Math.round((form.totalGB || 0) * SizeFormatter.ONE_GB),
+25 -1
View File
@@ -228,6 +228,21 @@ export default function ClientFormModal({
return ids; return ids;
}, [inbounds]); }, [inbounds]);
const ss2022Method = useMemo(() => {
for (const id of form.inboundIds || []) {
const ib = (inbounds || []).find((row) => row.id === id);
const method = ib?.ssMethod;
if (method && method.substring(0, 4) === '2022') return method;
}
return '';
}, [form.inboundIds, inbounds]);
function regeneratePassword() {
update('password', ss2022Method
? RandomUtil.randomShadowsocksPassword(ss2022Method)
: RandomUtil.randomLowerAndNum(16));
}
const showFlow = useMemo( const showFlow = useMemo(
() => (form.inboundIds || []).some((id) => flowCapableIds.has(id)), () => (form.inboundIds || []).some((id) => flowCapableIds.has(id)),
[form.inboundIds, flowCapableIds], [form.inboundIds, flowCapableIds],
@@ -257,6 +272,15 @@ export default function ClientFormModal({
} }
}, [showReverseTag, form.reverseTag]); }, [showReverseTag, form.reverseTag]);
useEffect(() => {
if (!ss2022Method) return;
setForm((prev) => (
RandomUtil.isShadowsocks2022Password(prev.password, ss2022Method)
? prev
: { ...prev, password: RandomUtil.randomShadowsocksPassword(ss2022Method) }
));
}, [ss2022Method]);
const inboundOptions = useMemo( const inboundOptions = useMemo(
() => (inbounds || []) () => (inbounds || [])
.filter((ib) => MULTI_CLIENT_PROTOCOLS.has(ib.protocol || '')) .filter((ib) => MULTI_CLIENT_PROTOCOLS.has(ib.protocol || ''))
@@ -433,7 +457,7 @@ export default function ClientFormModal({
<Form.Item label={t('pages.clients.password')}> <Form.Item label={t('pages.clients.password')}>
<Space.Compact style={{ display: 'flex' }}> <Space.Compact style={{ display: 'flex' }}>
<Input value={form.password} style={{ flex: 1 }} onChange={(e) => update('password', e.target.value)} /> <Input value={form.password} style={{ flex: 1 }} onChange={(e) => update('password', e.target.value)} />
<Button icon={<ReloadOutlined />} onClick={() => update('password', RandomUtil.randomLowerAndNum(16))} /> <Button icon={<ReloadOutlined />} onClick={regeneratePassword} />
</Space.Compact> </Space.Compact>
</Form.Item> </Form.Item>
</Col> </Col>
+1
View File
@@ -43,6 +43,7 @@ export const InboundOptionSchema = z.object({
protocol: z.string().optional(), protocol: z.string().optional(),
port: z.number().optional(), port: z.number().optional(),
tlsFlowCapable: z.boolean().optional(), tlsFlowCapable: z.boolean().optional(),
ssMethod: z.string().optional(),
}).loose(); }).loose();
export const InboundOptionsSchema = z.array(InboundOptionSchema); export const InboundOptionsSchema = z.array(InboundOptionSchema);
+11 -4
View File
@@ -183,15 +183,22 @@ export class RandomUtil {
} }
static randomShadowsocksPassword(method: string = '2022-blake3-aes-256-gcm'): string { static randomShadowsocksPassword(method: string = '2022-blake3-aes-256-gcm'): string {
let length = 32; const length = method === '2022-blake3-aes-128-gcm' ? 16 : 32;
if (method === '2022-blake3-aes-128-gcm') {
length = 16;
}
const array = new Uint8Array(length); const array = new Uint8Array(length);
window.crypto.getRandomValues(array); window.crypto.getRandomValues(array);
return Base64.alternativeEncode(String.fromCharCode(...array)); return Base64.alternativeEncode(String.fromCharCode(...array));
} }
static isShadowsocks2022Password(password: string, method: string): boolean {
if (!method || method.substring(0, 4) !== '2022') return true;
const expected = method === '2022-blake3-aes-128-gcm' ? 16 : 32;
try {
return window.atob(password).length === expected;
} catch {
return false;
}
}
static randomBase64(length: number = 16): string { static randomBase64(length: number = 16): string {
const array = new Uint8Array(length); const array = new Uint8Array(length);
window.crypto.getRandomValues(array); window.crypto.getRandomValues(array);
+171
View File
@@ -0,0 +1,171 @@
package main
import (
"encoding/json"
"fmt"
"io"
"strconv"
"strings"
)
func emitExamples(w io.Writer, schemas []Schema, aliases []Alias) error {
byName := make(map[string]Schema, len(schemas))
for _, s := range schemas {
byName[s.Name] = s
}
aliasByName := make(map[string]Alias, len(aliases))
for _, a := range aliases {
aliasByName[a.Name] = a
}
gen := &exampleGen{byName: byName, aliasByName: aliasByName}
out := make(map[string]any, len(schemas))
for _, s := range schemas {
out[s.Name] = gen.forSchema(s, map[string]bool{})
}
payload, err := json.MarshalIndent(out, "", " ")
if err != nil {
return err
}
if _, err := fmt.Fprintln(w, examplesHeader); err != nil {
return err
}
if _, err := fmt.Fprintf(w, "export const EXAMPLES: Record<string, unknown> = %s;\n", payload); err != nil {
return err
}
return nil
}
type exampleGen struct {
byName map[string]Schema
aliasByName map[string]Alias
}
func (g *exampleGen) forSchema(s Schema, visited map[string]bool) map[string]any {
obj := make(map[string]any, len(s.Fields))
for _, f := range s.Fields {
obj[f.JSONName] = g.forField(f, visited)
}
return obj
}
func (g *exampleGen) forField(f Field, visited map[string]bool) any {
if f.Example != "" {
return coerceExample(f.Example, baseKind(f.Type))
}
if v, ok := firstOneOf(f.Validate); ok {
return v
}
bk := baseKind(f.Type)
if bk.Kind == KindInt || bk.Kind == KindNumber {
if v, ok := numericFloor(bk.Kind, f.Validate); ok {
return v
}
}
return g.forType(f.Type, visited)
}
func (g *exampleGen) forType(t TypeRef, visited map[string]bool) any {
switch t.Kind {
case KindString:
if t.Name == "datetime" {
return "2025-01-01T00:00:00Z"
}
return ""
case KindInt, KindNumber:
return 0
case KindBool:
return false
case KindArray:
if isVisitedRef(*t.Element, visited) {
return []any{}
}
return []any{g.forType(*t.Element, visited)}
case KindMap:
return map[string]any{}
case KindRef:
if t.Name == "nullable" {
return nil
}
if alias, ok := g.aliasByName[t.Name]; ok {
return g.forType(alias.Underlying, visited)
}
schema, ok := g.byName[t.Name]
if !ok || visited[t.Name] {
return map[string]any{}
}
next := cloneVisited(visited)
next[t.Name] = true
return g.forSchema(schema, next)
}
return nil
}
func baseKind(t TypeRef) TypeRef {
if t.Kind == KindRef && t.Name == "nullable" && t.Inner != nil {
return *t.Inner
}
return t
}
func isVisitedRef(t TypeRef, visited map[string]bool) bool {
return t.Kind == KindRef && t.Name != "nullable" && visited[t.Name]
}
func cloneVisited(in map[string]bool) map[string]bool {
out := make(map[string]bool, len(in)+1)
for k, v := range in {
out[k] = v
}
return out
}
func numericFloor(kind TypeKind, rules []ValidateRule) (any, bool) {
for _, r := range rules {
if (r.Name == "gte" || r.Name == "min") && r.Param != "" {
return coerceExample(r.Param, TypeRef{Kind: kind}), true
}
}
return nil, false
}
func firstOneOf(rules []ValidateRule) (string, bool) {
for _, r := range rules {
if r.Name == "oneof" {
fields := strings.Fields(r.Param)
if len(fields) > 0 {
return fields[0], true
}
}
}
return "", false
}
func coerceExample(ex string, t TypeRef) any {
switch t.Kind {
case KindInt:
if n, err := strconv.ParseInt(ex, 10, 64); err == nil {
return n
}
return 0
case KindNumber:
if n, err := strconv.ParseFloat(ex, 64); err == nil {
return n
}
return 0
case KindBool:
return ex == "true"
case KindString:
return ex
default:
var parsed any
if err := json.Unmarshal([]byte(ex), &parsed); err == nil {
return parsed
}
return ex
}
}
const examplesHeader = `// Code generated by tools/openapigen. DO NOT EDIT.`
+15
View File
@@ -69,6 +69,14 @@ func run(root, outDir string) error {
"ClientTraffic", "ClientTraffic",
), ),
}, },
{
Path: resolveRel(root, "web/service"),
StructAllow: setOf(
"InboundOption",
"ApiTokenView",
"ProbeResultUI",
),
},
} }
schemas, aliases, err := walkPackages(requests) schemas, aliases, err := walkPackages(requests)
@@ -94,6 +102,10 @@ func run(root, outDir string) error {
if err := emitTypes(typesBuf, schemas, aliases); err != nil { if err := emitTypes(typesBuf, schemas, aliases); err != nil {
return err return err
} }
examplesBuf := &bytes.Buffer{}
if err := emitExamples(examplesBuf, schemas, aliases); err != nil {
return err
}
if err := os.WriteFile(filepath.Join(target, "zod.ts"), zodBuf.Bytes(), 0o644); err != nil { if err := os.WriteFile(filepath.Join(target, "zod.ts"), zodBuf.Bytes(), 0o644); err != nil {
return err return err
@@ -101,6 +113,9 @@ func run(root, outDir string) error {
if err := os.WriteFile(filepath.Join(target, "types.ts"), typesBuf.Bytes(), 0o644); err != nil { if err := os.WriteFile(filepath.Join(target, "types.ts"), typesBuf.Bytes(), 0o644); err != nil {
return err return err
} }
if err := os.WriteFile(filepath.Join(target, "examples.ts"), examplesBuf.Bytes(), 0o644); err != nil {
return err
}
fmt.Printf("openapigen: wrote %d schemas to %s\n", len(schemas), target) fmt.Printf("openapigen: wrote %d schemas to %s\n", len(schemas), target)
return nil return nil
+3 -1
View File
@@ -27,6 +27,7 @@ type Field struct {
Skip bool Skip bool
Validate []ValidateRule Validate []ValidateRule
Doc string Doc string
Example string
} }
type TypeRef struct { type TypeRef struct {
@@ -59,10 +60,11 @@ type ValidateRule struct {
Param string Param string
} }
func parseStructTag(raw string) (json string, validate string, gormHasDash bool) { func parseStructTag(raw string) (json string, validate string, example string, gormHasDash bool) {
tag := reflect.StructTag(strings.Trim(raw, "`")) tag := reflect.StructTag(strings.Trim(raw, "`"))
json = tag.Get("json") json = tag.Get("json")
validate = tag.Get("validate") validate = tag.Get("validate")
example = tag.Get("example")
if g := tag.Get("gorm"); g != "" { if g := tag.Get("gorm"); g != "" {
for part := range strings.SplitSeq(g, ";") { for part := range strings.SplitSeq(g, ";") {
if strings.TrimSpace(part) == "-" { if strings.TrimSpace(part) == "-" {
+3 -1
View File
@@ -102,7 +102,7 @@ func buildFields(fld *ast.Field, overrides []walkOverride) []Field {
if fld.Tag != nil { if fld.Tag != nil {
tag = fld.Tag.Value tag = fld.Tag.Value
} }
jsonTag, validateTag, gormDash := parseStructTag(tag) jsonTag, validateTag, exampleTag, gormDash := parseStructTag(tag)
if gormDash && jsonTag == "" { if gormDash && jsonTag == "" {
return nil return nil
} }
@@ -132,6 +132,7 @@ func buildFields(fld *ast.Field, overrides []walkOverride) []Field {
Optional: omitempty || isPointer(fld.Type), Optional: omitempty || isPointer(fld.Type),
Validate: validate, Validate: validate,
Doc: doc, Doc: doc,
Example: exampleTag,
}) })
} }
@@ -154,6 +155,7 @@ func buildFields(fld *ast.Field, overrides []walkOverride) []Field {
Optional: omitempty || isPointer(fld.Type), Optional: omitempty || isPointer(fld.Type),
Validate: validate, Validate: validate,
Doc: doc, Doc: doc,
Example: exampleTag,
}) })
} }
+5 -5
View File
@@ -17,11 +17,11 @@ type ApiTokenService struct{}
const apiTokenLength = 48 const apiTokenLength = 48
type ApiTokenView struct { type ApiTokenView struct {
Id int `json:"id"` Id int `json:"id" example:"2"`
Name string `json:"name"` Name string `json:"name" example:"central-panel-a"`
Token string `json:"token,omitempty"` Token string `json:"token,omitempty" example:"new-token-string"`
Enabled bool `json:"enabled"` Enabled bool `json:"enabled" example:"true"`
CreatedAt int64 `json:"createdAt"` CreatedAt int64 `json:"createdAt" example:"1736000000"`
} }
// toView builds the metadata view returned by List. It never carries the // toView builds the metadata view returned by List. It never carries the
+26 -7
View File
@@ -356,12 +356,13 @@ func (s *InboundService) annotateFallbackParents(db *gorm.DB, inbounds []*model.
} }
type InboundOption struct { type InboundOption struct {
Id int `json:"id"` Id int `json:"id" example:"1"`
Remark string `json:"remark"` Remark string `json:"remark" example:"VLESS-443"`
Tag string `json:"tag"` Tag string `json:"tag" example:"in-443-tcp"`
Protocol string `json:"protocol"` Protocol string `json:"protocol" example:"vless"`
Port int `json:"port"` Port int `json:"port" example:"443"`
TlsFlowCapable bool `json:"tlsFlowCapable"` TlsFlowCapable bool `json:"tlsFlowCapable" example:"true"`
SsMethod string `json:"ssMethod"`
} }
func (s *InboundService) GetInboundOptions(userId int) ([]InboundOption, error) { func (s *InboundService) GetInboundOptions(userId int) ([]InboundOption, error) {
@@ -373,9 +374,10 @@ func (s *InboundService) GetInboundOptions(userId int) ([]InboundOption, error)
Protocol string `gorm:"column:protocol"` Protocol string `gorm:"column:protocol"`
Port int `gorm:"column:port"` Port int `gorm:"column:port"`
StreamSettings string `gorm:"column:stream_settings"` StreamSettings string `gorm:"column:stream_settings"`
Settings string `gorm:"column:settings"`
} }
err := db.Table("inbounds"). err := db.Table("inbounds").
Select("id, remark, tag, protocol, port, stream_settings"). Select("id, remark, tag, protocol, port, stream_settings, settings").
Where("user_id = ?", userId). Where("user_id = ?", userId).
Order("id ASC"). Order("id ASC").
Scan(&rows).Error Scan(&rows).Error
@@ -391,11 +393,28 @@ func (s *InboundService) GetInboundOptions(userId int) ([]InboundOption, error)
Protocol: r.Protocol, Protocol: r.Protocol,
Port: r.Port, Port: r.Port,
TlsFlowCapable: inboundCanEnableTlsFlow(r.Protocol, r.StreamSettings), TlsFlowCapable: inboundCanEnableTlsFlow(r.Protocol, r.StreamSettings),
SsMethod: inboundShadowsocksMethod(r.Protocol, r.Settings),
}) })
} }
return out, nil return out, nil
} }
// inboundShadowsocksMethod extracts settings.method for Shadowsocks inbounds so
// the client UI can generate a valid PSK (base64 of the method's key length)
// for Shadowsocks 2022 ciphers. Returns "" for non-Shadowsocks inbounds.
func inboundShadowsocksMethod(protocol, settings string) string {
if protocol != string(model.Shadowsocks) || settings == "" {
return ""
}
var s struct {
Method string `json:"method"`
}
if err := json.Unmarshal([]byte(settings), &s); err != nil {
return ""
}
return s.Method
}
// inboundCanEnableTlsFlow mirrors Inbound.canEnableTlsFlow() from the frontend: // inboundCanEnableTlsFlow mirrors Inbound.canEnableTlsFlow() from the frontend:
// XTLS Vision is only valid for VLESS on TCP with tls or reality. // XTLS Vision is only valid for VLESS on TCP with tls or reality.
func inboundCanEnableTlsFlow(protocol, streamSettings string) bool { func inboundCanEnableTlsFlow(protocol, streamSettings string) bool {
+7 -7
View File
@@ -635,13 +635,13 @@ func (s *NodeService) Probe(ctx context.Context, n *model.Node) (HeartbeatPatch,
} }
type ProbeResultUI struct { type ProbeResultUI struct {
Status string `json:"status"` Status string `json:"status" example:"online"`
LatencyMs int `json:"latencyMs"` LatencyMs int `json:"latencyMs" example:"42"`
XrayVersion string `json:"xrayVersion"` XrayVersion string `json:"xrayVersion" example:"25.10.31"`
PanelVersion string `json:"panelVersion"` PanelVersion string `json:"panelVersion" example:"v3.x.x"`
CpuPct float64 `json:"cpuPct"` CpuPct float64 `json:"cpuPct" example:"12.5"`
MemPct float64 `json:"memPct"` MemPct float64 `json:"memPct" example:"45.2"`
UptimeSecs uint64 `json:"uptimeSecs"` UptimeSecs uint64 `json:"uptimeSecs" example:"86400"`
Error string `json:"error"` Error string `json:"error"`
} }
+12 -12
View File
@@ -3,16 +3,16 @@ package xray
// ClientTraffic represents traffic statistics and limits for a specific client. // ClientTraffic represents traffic statistics and limits for a specific client.
// It tracks upload/download usage, expiry times, and online status for inbound clients. // It tracks upload/download usage, expiry times, and online status for inbound clients.
type ClientTraffic struct { type ClientTraffic struct {
Id int `json:"id" form:"id" gorm:"primaryKey;autoIncrement"` Id int `json:"id" form:"id" gorm:"primaryKey;autoIncrement" example:"14825"`
InboundId int `json:"inboundId" form:"inboundId"` InboundId int `json:"inboundId" form:"inboundId" example:"1"`
Enable bool `json:"enable" form:"enable"` Enable bool `json:"enable" form:"enable" example:"true"`
Email string `json:"email" form:"email" gorm:"unique"` Email string `json:"email" form:"email" gorm:"unique" example:"user1"`
UUID string `json:"uuid" form:"uuid" gorm:"-"` UUID string `json:"uuid" form:"uuid" gorm:"-" example:"e18c9a96-71bf-48d4-933f-8b9a46d4290c"`
SubId string `json:"subId" form:"subId" gorm:"-"` SubId string `json:"subId" form:"subId" gorm:"-" example:"i7tvdpeffi0hvvf1"`
Up int64 `json:"up" form:"up"` Up int64 `json:"up" form:"up" example:"1048576"`
Down int64 `json:"down" form:"down"` Down int64 `json:"down" form:"down" example:"2097152"`
ExpiryTime int64 `json:"expiryTime" form:"expiryTime"` ExpiryTime int64 `json:"expiryTime" form:"expiryTime" example:"1735689600000"`
Total int64 `json:"total" form:"total"` Total int64 `json:"total" form:"total" example:"10737418240"`
Reset int `json:"reset" form:"reset" gorm:"default:0"` Reset int `json:"reset" form:"reset" gorm:"default:0" example:"0"`
LastOnline int64 `json:"lastOnline" form:"lastOnline" gorm:"default:0"` LastOnline int64 `json:"lastOnline" form:"lastOnline" gorm:"default:0" example:"1735680000000"`
} }