From 83799d71b0ffee7d843d0a8c9cf60268bba1ced1 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sat, 6 Jun 2026 14:58:15 +0200 Subject: [PATCH] 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-- 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). --- .github/workflows/ci.yml | 17 + database/model/model.go | 56 +-- frontend/package.json | 1 + frontend/public/openapi.json | 144 ++++--- frontend/scripts/build-openapi.mjs | 10 +- frontend/src/generated/examples.ts | 396 ++++++++++++++++++ frontend/src/generated/types.ts | 37 ++ frontend/src/generated/zod.ts | 48 +++ frontend/src/pages/api-docs/ApiDocsPage.tsx | 1 + frontend/src/pages/api-docs/endpoints.ts | 27 +- .../src/pages/clients/ClientBulkAddModal.tsx | 13 +- .../src/pages/clients/ClientFormModal.tsx | 26 +- frontend/src/schemas/client.ts | 1 + frontend/src/utils/index.ts | 15 +- tools/openapigen/emit_examples.go | 171 ++++++++ tools/openapigen/main.go | 15 + tools/openapigen/schema.go | 4 +- tools/openapigen/walker.go | 4 +- web/service/api_token.go | 10 +- web/service/inbound.go | 33 +- web/service/node.go | 14 +- xray/client_traffic.go | 24 +- 22 files changed, 924 insertions(+), 143 deletions(-) create mode 100644 frontend/src/generated/examples.ts create mode 100644 tools/openapigen/emit_examples.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 25984c4c..153f4a3e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,6 +37,23 @@ jobs: go list ./... | grep -v '/frontend/node_modules/' > /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: runs-on: ubuntu-latest steps: diff --git a/database/model/model.go b/database/model/model.go index 2fd54421..ddb516b1 100644 --- a/database/model/model.go +++ b/database/model/model.go @@ -41,13 +41,13 @@ type User struct { // Inbound represents an Xray inbound configuration with traffic statistics and settings. 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 Up int64 `json:"up" form:"up"` // Upload traffic in bytes Down int64 `json:"down" form:"down"` // Download traffic in bytes Total int64 `json:"total" form:"total"` // Total traffic limit in bytes - Remark string `json:"remark" form:"remark"` // Human-readable remark - Enable bool `json:"enable" form:"enable" gorm:"index:idx_enable_traffic_reset,priority:1"` // Whether the inbound is enabled + 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" example:"true"` // Whether the inbound is enabled 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 LastTrafficResetTime int64 `json:"lastTrafficResetTime" form:"lastTrafficResetTime" gorm:"default:0"` // Last traffic reset timestamp @@ -55,11 +55,11 @@ type Inbound struct { // Xray configuration fields Listen string `json:"listen" form:"listen"` - Port int `json:"port" form:"port" validate:"gte=0,lte=65535"` - Protocol Protocol `json:"protocol" form:"protocol" validate:"required,oneof=vmess vless trojan shadowsocks wireguard hysteria http mixed tunnel tun"` + 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" example:"vless"` Settings string `json:"settings" form:"settings"` 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"` 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 // status fields below. type Node struct { - Id int `json:"id" form:"id" gorm:"primaryKey;autoIncrement"` - Name string `json:"name" form:"name" gorm:"uniqueIndex" validate:"required"` + Id int `json:"id" form:"id" gorm:"primaryKey;autoIncrement" example:"1"` + Name string `json:"name" form:"name" gorm:"uniqueIndex" validate:"required" example:"de-fra-1"` Remark string `json:"remark" form:"remark"` - Scheme string `json:"scheme" form:"scheme" validate:"omitempty,oneof=http https"` - Address string `json:"address" form:"address" validate:"required"` - Port int `json:"port" form:"port" validate:"gte=1,lte=65535"` - BasePath string `json:"basePath" form:"basePath"` - ApiToken string `json:"apiToken" form:"apiToken" validate:"required"` - Enable bool `json:"enable" form:"enable" gorm:"default:true"` + Scheme string `json:"scheme" form:"scheme" validate:"omitempty,oneof=http https" example:"https"` + Address string `json:"address" form:"address" validate:"required" example:"node1.example.com"` + Port int `json:"port" form:"port" validate:"gte=1,lte=65535" example:"2053"` + BasePath string `json:"basePath" form:"basePath" example:"/"` + ApiToken string `json:"apiToken" form:"apiToken" validate:"required" example:"abcdef0123456789"` + Enable bool `json:"enable" form:"enable" gorm:"default:true" example:"true"` 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"` 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 // the row is otherwise unchanged so the UI's "last seen" tooltip is // truthful without us having to read LastHeartbeat separately. - Status string `json:"status" gorm:"default:unknown"` // online|offline|unknown - LastHeartbeat int64 `json:"lastHeartbeat"` // unix seconds, 0 = never - LatencyMs int `json:"latencyMs"` - XrayVersion string `json:"xrayVersion"` - PanelVersion string `json:"panelVersion" gorm:"column:panel_version"` - CpuPct float64 `json:"cpuPct"` - MemPct float64 `json:"memPct"` - UptimeSecs uint64 `json:"uptimeSecs"` + Status string `json:"status" gorm:"default:unknown" example:"online"` // online|offline|unknown + LastHeartbeat int64 `json:"lastHeartbeat" example:"1700000000"` // unix seconds, 0 = never + LatencyMs int `json:"latencyMs" example:"42"` + XrayVersion string `json:"xrayVersion" example:"25.10.31"` + PanelVersion string `json:"panelVersion" gorm:"column:panel_version" example:"v3.x.x"` + CpuPct float64 `json:"cpuPct" example:"23.5"` + MemPct float64 `json:"memPct" example:"45.1"` + UptimeSecs uint64 `json:"uptimeSecs" example:"86400"` LastError string `json:"lastError"` ConfigDirty bool `json:"configDirty" gorm:"default:false"` ConfigDirtyAt int64 `json:"configDirtyAt"` - InboundCount int `json:"inboundCount" gorm:"-"` - ClientCount int `json:"clientCount" gorm:"-"` - OnlineCount int `json:"onlineCount" gorm:"-"` - DepletedCount int `json:"depletedCount" gorm:"-"` + InboundCount int `json:"inboundCount" gorm:"-" example:"5"` + ClientCount int `json:"clientCount" gorm:"-" example:"27"` + OnlineCount int `json:"onlineCount" gorm:"-" example:"3"` + DepletedCount int `json:"depletedCount" gorm:"-" example:"1"` // 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 @@ -426,8 +426,8 @@ type Node struct { ParentGuid string `json:"parentGuid,omitempty" gorm:"-"` Transitive bool `json:"transitive,omitempty" gorm:"-"` - CreatedAt int64 `json:"createdAt" gorm:"autoCreateTime:milli"` - UpdatedAt int64 `json:"updatedAt" gorm:"autoUpdateTime:milli"` + CreatedAt int64 `json:"createdAt" gorm:"autoCreateTime:milli" example:"1700000000"` + UpdatedAt int64 `json:"updatedAt" gorm:"autoUpdateTime:milli" example:"1700000000"` } // NodeSummary is the read-only identity of a node as published one hop up: the diff --git a/frontend/package.json b/frontend/package.json index 1589ea28..ee9c15de 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -16,6 +16,7 @@ "typecheck": "tsc --noEmit", "test": "vitest run", "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:zod": "cd .. && go run ./tools/openapigen" }, diff --git a/frontend/public/openapi.json b/frontend/public/openapi.json index b023a861..057ee436 100644 --- a/frontend/public/openapi.json +++ b/frontend/public/openapi.json @@ -304,38 +304,41 @@ "success": true, "obj": [ { - "id": 1, - "userId": 1, - "up": 0, + "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, - "total": 0, - "remark": "VLESS-443", "enable": true, "expiryTime": 0, + "fallbackParent": null, + "id": 1, + "lastTrafficResetTime": 0, "listen": "", + "nodeId": null, + "originNodeGuid": "", "port": 443, "protocol": "vless", - "settings": { - "clients": [], - "decryption": "none" - }, - "streamSettings": { - "network": "tcp", - "security": "reality", - "realitySettings": { - "show": false, - "dest": "..." - } - }, - "tag": "inbound-443", - "sniffing": { - "enabled": true, - "destOverride": [ - "http", - "tls" - ] - }, - "clientStats": [] + "remark": "VLESS-443", + "settings": null, + "sniffing": null, + "streamSettings": null, + "tag": "in-443-tcp", + "total": 0, + "trafficReset": "never", + "up": 0 } ] } @@ -374,7 +377,6 @@ "obj": [ { "id": 1, - "userId": 1, "remark": "VLESS-443", "settings": { "clients": [ @@ -400,7 +402,7 @@ "tags": [ "Inbounds" ], - "summary": "Lightweight picker projection of the authenticated user’s 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 user’s 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", "responses": { "200": { @@ -424,9 +426,11 @@ "obj": [ { "id": 1, - "remark": "VLESS-443", - "protocol": "vless", "port": 443, + "protocol": "vless", + "remark": "VLESS-443", + "ssMethod": "", + "tag": "in-443-tcp", "tlsFlowCapable": true } ] @@ -3828,8 +3832,8 @@ "success": true, "obj": { "a1b2-...": [ - "inbound-443", - "inbound-8443" + "in-443-tcp", + "in-8443-tcp" ] } } @@ -3914,11 +3918,18 @@ "example": { "success": true, "obj": { - "email": "user1", - "up": 1048576, "down": 2097152, + "email": "user1", + "enable": true, + "expiryTime": 1735689600000, + "id": 14825, + "inboundId": 1, + "lastOnline": 1735680000000, + "reset": 0, + "subId": "i7tvdpeffi0hvvf1", "total": 10737418240, - "expiryTime": 1735689600000 + "up": 1048576, + "uuid": "e18c9a96-71bf-48d4-933f-8b9a46d4290c" } } } @@ -4050,31 +4061,38 @@ "success": true, "obj": [ { - "id": 1, - "name": "de-fra-1", - "remark": "", - "scheme": "https", "address": "node1.example.com", - "port": 2053, - "basePath": "/", - "apiToken": "abcdef...", - "enable": true, "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, "latencyMs": 42, - "xrayVersion": "25.x.x", - "panelVersion": "v3.x.x", - "cpuPct": 23.5, "memPct": 45.1, - "uptimeSecs": 86400, - "lastError": "", - "inboundCount": 5, - "clientCount": 27, + "name": "de-fra-1", "onlineCount": 3, - "depletedCount": 1, - "createdAt": 1700000000, - "updatedAt": 1700000000 + "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" } ] } @@ -4425,14 +4443,14 @@ "example": { "success": true, "obj": { - "status": "online", - "latencyMs": 42, - "xrayVersion": "25.x.x", - "panelVersion": "v3.x.x", "cpuPct": 12.5, + "error": "", + "latencyMs": 42, "memPct": 45.2, + "panelVersion": "v3.x.x", + "status": "online", "uptimeSecs": 86400, - "error": "" + "xrayVersion": "25.10.31" } } } @@ -5262,11 +5280,11 @@ "example": { "success": true, "obj": { + "createdAt": 1736000000, + "enabled": true, "id": 2, "name": "central-panel-a", - "token": "new-token-string", - "enabled": true, - "createdAt": 1736000000 + "token": "new-token-string" } } } @@ -5435,7 +5453,7 @@ "success": true, "obj": { "xraySetting": "{...raw xray config...}", - "inboundTags": "[\"inbound-443\"]", + "inboundTags": "[\"in-443-tcp\"]", "clientReverseTags": "[]", "outboundTestUrl": "https://www.google.com/generate_204" } diff --git a/frontend/scripts/build-openapi.mjs b/frontend/scripts/build-openapi.mjs index f89e1d66..06d69e98 100644 --- a/frontend/scripts/build-openapi.mjs +++ b/frontend/scripts/build-openapi.mjs @@ -4,6 +4,7 @@ import { join, dirname } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { sections } from '../src/pages/api-docs/endpoints.ts'; +import { EXAMPLES } from '../src/generated/examples.ts'; const __dirname = dirname(fileURLToPath(import.meta.url)); const outPath = join(__dirname, '..', 'public', 'openapi.json'); @@ -128,7 +129,14 @@ function buildOperation(ep, tag) { } 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'] = { description: 'Successful response', content: { diff --git a/frontend/src/generated/examples.ts b/frontend/src/generated/examples.ts new file mode 100644 index 00000000..b0b791cc --- /dev/null +++ b/frontend/src/generated/examples.ts @@ -0,0 +1,396 @@ +// Code generated by tools/openapigen. DO NOT EDIT. +export const EXAMPLES: Record = { + "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": "" + } +}; diff --git a/frontend/src/generated/types.ts b/frontend/src/generated/types.ts index d45ecc99..cb2d64f7 100644 --- a/frontend/src/generated/types.ts +++ b/frontend/src/generated/types.ts @@ -1,5 +1,9 @@ // Code generated by tools/openapigen. DO NOT EDIT. +export type LoginStatus = number; +export type ProcessState = string; export type Protocol = string; +export type SubLinkProvider = unknown; +export type transportBits = number; export interface AllSetting { datepicker: string; @@ -179,6 +183,14 @@ export interface ApiToken { token: string; } +export interface ApiTokenView { + createdAt: number; + enabled: boolean; + id: number; + name: string; + token?: string; +} + export interface Client { auth?: string; comment: string; @@ -280,6 +292,7 @@ export interface Inbound { lastTrafficResetTime: number; listen: string; nodeId?: number | null; + originNodeGuid?: string; port: number; protocol: Protocol; remark: string; @@ -310,6 +323,16 @@ export interface InboundFallback { xver: number; } +export interface InboundOption { + id: number; + port: number; + protocol: string; + remark: string; + ssMethod: string; + tag: string; + tlsFlowCapable: boolean; +} + export interface Msg { msg: string; obj: unknown; @@ -328,6 +351,7 @@ export interface Node { createdAt: number; depletedCount: number; enable: boolean; + guid: string; id: number; inboundCount: number; lastError: string; @@ -337,12 +361,14 @@ export interface Node { name: string; onlineCount: number; panelVersion: string; + parentGuid?: string; pinnedCertSha256: string; port: number; remark: string; scheme: string; status: string; tlsVerifyMode: string; + transitive?: boolean; updatedAt: number; uptimeSecs: number; xrayVersion: string; @@ -356,6 +382,17 @@ export interface OutboundTraffics { up: number; } +export interface ProbeResultUI { + cpuPct: number; + error: string; + latencyMs: number; + memPct: number; + panelVersion: string; + status: string; + uptimeSecs: number; + xrayVersion: string; +} + export interface Setting { id: number; key: string; diff --git a/frontend/src/generated/zod.ts b/frontend/src/generated/zod.ts index 74f76507..5b256b73 100644 --- a/frontend/src/generated/zod.ts +++ b/frontend/src/generated/zod.ts @@ -1,8 +1,20 @@ // Code generated by tools/openapigen. DO NOT EDIT. import { z } from 'zod'; +export const LoginStatusSchema = z.number().int(); +export type LoginStatus = z.infer; + +export const ProcessStateSchema = z.string(); +export type ProcessState = z.infer; + export const ProtocolSchema = z.string(); export type Protocol = z.infer; +export const SubLinkProviderSchema = z.unknown(); +export type SubLinkProvider = z.infer; + +export const transportBitsSchema = z.number().int(); +export type transportBits = z.infer; + export const AllSettingSchema = z.object({ datepicker: z.string(), expireDiff: z.number().int().min(0), @@ -184,6 +196,15 @@ export const ApiTokenSchema = z.object({ }); export type ApiToken = z.infer; +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; + export const ClientSchema = z.object({ auth: z.string().optional(), comment: z.string(), @@ -293,6 +314,7 @@ export const InboundSchema = z.object({ lastTrafficResetTime: z.number().int(), listen: z.string(), nodeId: z.number().int().nullable().optional(), + originNodeGuid: z.string().optional(), port: z.number().int().min(0).max(65535), protocol: z.enum(['vmess', 'vless', 'trojan', 'shadowsocks', 'wireguard', 'hysteria', 'http', 'mixed', 'tunnel', 'tun']), remark: z.string(), @@ -326,6 +348,17 @@ export const InboundFallbackSchema = z.object({ }); export type InboundFallback = z.infer; +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; + export const MsgSchema = z.object({ msg: z.string(), obj: z.unknown(), @@ -345,6 +378,7 @@ export const NodeSchema = z.object({ createdAt: z.number().int(), depletedCount: z.number().int(), enable: z.boolean(), + guid: z.string(), id: z.number().int(), inboundCount: z.number().int(), lastError: z.string(), @@ -354,12 +388,14 @@ export const NodeSchema = z.object({ name: z.string(), onlineCount: z.number().int(), panelVersion: z.string(), + parentGuid: z.string().optional(), pinnedCertSha256: z.string(), port: z.number().int().min(1).max(65535), remark: z.string(), scheme: z.enum(['http', 'https']), status: z.string(), tlsVerifyMode: z.enum(['verify', 'skip', 'pin']), + transitive: z.boolean().optional(), updatedAt: z.number().int(), uptimeSecs: z.number().int(), xrayVersion: z.string(), @@ -375,6 +411,18 @@ export const OutboundTrafficsSchema = z.object({ }); export type OutboundTraffics = z.infer; +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; + export const SettingSchema = z.object({ id: z.number().int(), key: z.string(), diff --git a/frontend/src/pages/api-docs/ApiDocsPage.tsx b/frontend/src/pages/api-docs/ApiDocsPage.tsx index d2e90c0e..a61dae45 100644 --- a/frontend/src/pages/api-docs/ApiDocsPage.tsx +++ b/frontend/src/pages/api-docs/ApiDocsPage.tsx @@ -33,6 +33,7 @@ export default function ApiDocsPage() { docExpansion="list" deepLinking={false} tryItOutEnabled + persistAuthorization /> diff --git a/frontend/src/pages/api-docs/endpoints.ts b/frontend/src/pages/api-docs/endpoints.ts index 1255d7c7..06191f69 100644 --- a/frontend/src/pages/api-docs/endpoints.ts +++ b/frontend/src/pages/api-docs/endpoints.ts @@ -38,6 +38,8 @@ export interface Endpoint { response?: string; errorResponse?: string; errorStatus?: number; + responseSchema?: string; + responseSchemaArray?: boolean; } export interface SubscriptionHeader { @@ -107,22 +109,22 @@ export const sections: readonly Section[] = [ method: 'GET', path: '/panel/api/inbounds/list', summary: 'List every inbound owned by the authenticated user, including each inbound’s 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: - '{\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}', + responseSchema: 'Inbound', + responseSchemaArray: true, }, { method: 'GET', 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, ...).', 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', path: '/panel/api/inbounds/options', - summary: 'Lightweight picker projection of the authenticated user’s 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.', - response: - '{\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}', + summary: 'Lightweight picker projection of the authenticated user’s 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.', + responseSchema: 'InboundOption', + responseSchemaArray: true, }, { method: 'GET', @@ -696,7 +698,7 @@ export const sections: readonly Section[] = [ method: 'POST', 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.', - 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', @@ -711,7 +713,7 @@ export const sections: readonly Section[] = [ params: [ { 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', @@ -748,7 +750,8 @@ export const sections: readonly Section[] = [ method: 'GET', path: '/panel/api/nodes/list', 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', @@ -805,7 +808,7 @@ export const sections: readonly Section[] = [ 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.', 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', @@ -978,7 +981,7 @@ export const sections: readonly Section[] = [ { name: 'name', in: 'body', type: 'string', desc: 'Human-readable label, e.g. "central-panel-a".' }, ], 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}', }, { @@ -1014,7 +1017,7 @@ export const sections: readonly Section[] = [ method: 'POST', 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.', - 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', diff --git a/frontend/src/pages/clients/ClientBulkAddModal.tsx b/frontend/src/pages/clients/ClientBulkAddModal.tsx index b5aaf082..2d562768 100644 --- a/frontend/src/pages/clients/ClientBulkAddModal.tsx +++ b/frontend/src/pages/clients/ClientBulkAddModal.tsx @@ -89,6 +89,15 @@ export default function ClientBulkAddModal({ [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(() => { if (!showFlow && form.flow) { @@ -153,7 +162,9 @@ export default function ClientBulkAddModal({ email, subId: form.subId || RandomUtil.randomLowerAndNum(16), id: RandomUtil.randomUUID(), - password: RandomUtil.randomLowerAndNum(16), + password: ss2022Method + ? RandomUtil.randomShadowsocksPassword(ss2022Method) + : RandomUtil.randomLowerAndNum(16), auth: RandomUtil.randomLowerAndNum(16), flow: showFlow ? (form.flow || '') : '', totalGB: Math.round((form.totalGB || 0) * SizeFormatter.ONE_GB), diff --git a/frontend/src/pages/clients/ClientFormModal.tsx b/frontend/src/pages/clients/ClientFormModal.tsx index 9d1cafce..631100af 100644 --- a/frontend/src/pages/clients/ClientFormModal.tsx +++ b/frontend/src/pages/clients/ClientFormModal.tsx @@ -228,6 +228,21 @@ export default function ClientFormModal({ return ids; }, [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( () => (form.inboundIds || []).some((id) => flowCapableIds.has(id)), [form.inboundIds, flowCapableIds], @@ -257,6 +272,15 @@ export default function ClientFormModal({ } }, [showReverseTag, form.reverseTag]); + useEffect(() => { + if (!ss2022Method) return; + setForm((prev) => ( + RandomUtil.isShadowsocks2022Password(prev.password, ss2022Method) + ? prev + : { ...prev, password: RandomUtil.randomShadowsocksPassword(ss2022Method) } + )); + }, [ss2022Method]); + const inboundOptions = useMemo( () => (inbounds || []) .filter((ib) => MULTI_CLIENT_PROTOCOLS.has(ib.protocol || '')) @@ -433,7 +457,7 @@ export default function ClientFormModal({ update('password', e.target.value)} /> -