瀏覽代碼

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).
MHSanaei 1 天之前
父節點
當前提交
83799d71b0

+ 17 - 0
.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:

+ 28 - 28
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

+ 1 - 0
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"
   },

+ 80 - 62
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,
+                      "allowPrivateAddress": false,
+                      "apiToken": "abcdef0123456789",
                       "basePath": "/",
-                      "apiToken": "abcdef...",
+                      "clientCount": 27,
+                      "configDirty": false,
+                      "configDirtyAt": 0,
+                      "cpuPct": 23.5,
+                      "createdAt": 1700000000,
+                      "depletedCount": 1,
                       "enable": true,
-                      "allowPrivateAddress": false,
-                      "status": "online",
+                      "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"
                   }

+ 9 - 1
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: {

+ 396 - 0
frontend/src/generated/examples.ts

@@ -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 - 0
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;

+ 48 - 0
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<typeof LoginStatusSchema>;
+
+export const ProcessStateSchema = z.string();
+export type ProcessState = z.infer<typeof ProcessStateSchema>;
+
 export const ProtocolSchema = z.string();
 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({
   datepicker: z.string(),
   expireDiff: z.number().int().min(0),
@@ -184,6 +196,15 @@ export const ApiTokenSchema = z.object({
 });
 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({
   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<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({
   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<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({
   id: z.number().int(),
   key: z.string(),

+ 1 - 0
frontend/src/pages/api-docs/ApiDocsPage.tsx

@@ -33,6 +33,7 @@ export default function ApiDocsPage() {
                 docExpansion="list"
                 deepLinking={false}
                 tryItOutEnabled
+                persistAuthorization
               />
             </div>
           </Layout.Content>

+ 15 - 12
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',

+ 12 - 1
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),

+ 25 - 1
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({
               <Form.Item label={t('pages.clients.password')}>
                 <Space.Compact style={{ display: 'flex' }}>
                   <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>
               </Form.Item>
             </Col>

+ 1 - 0
frontend/src/schemas/client.ts

@@ -43,6 +43,7 @@ export const InboundOptionSchema = z.object({
   protocol: z.string().optional(),
   port: z.number().optional(),
   tlsFlowCapable: z.boolean().optional(),
+  ssMethod: z.string().optional(),
 }).loose();
 
 export const InboundOptionsSchema = z.array(InboundOptionSchema);

+ 11 - 4
frontend/src/utils/index.ts

@@ -183,15 +183,22 @@ export class RandomUtil {
   }
 
   static randomShadowsocksPassword(method: string = '2022-blake3-aes-256-gcm'): string {
-    let length = 32;
-    if (method === '2022-blake3-aes-128-gcm') {
-      length = 16;
-    }
+    const length = method === '2022-blake3-aes-128-gcm' ? 16 : 32;
     const array = new Uint8Array(length);
     window.crypto.getRandomValues(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 {
     const array = new Uint8Array(length);
     window.crypto.getRandomValues(array);

+ 171 - 0
tools/openapigen/emit_examples.go

@@ -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 - 0
tools/openapigen/main.go

@@ -69,6 +69,14 @@ func run(root, outDir string) error {
 				"ClientTraffic",
 			),
 		},
+		{
+			Path: resolveRel(root, "web/service"),
+			StructAllow: setOf(
+				"InboundOption",
+				"ApiTokenView",
+				"ProbeResultUI",
+			),
+		},
 	}
 
 	schemas, aliases, err := walkPackages(requests)
@@ -94,6 +102,10 @@ func run(root, outDir string) error {
 	if err := emitTypes(typesBuf, schemas, aliases); err != nil {
 		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 {
 		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 {
 		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)
 	return nil

+ 3 - 1
tools/openapigen/schema.go

@@ -27,6 +27,7 @@ type Field struct {
 	Skip     bool
 	Validate []ValidateRule
 	Doc      string
+	Example  string
 }
 
 type TypeRef struct {
@@ -59,10 +60,11 @@ type ValidateRule struct {
 	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, "`"))
 	json = tag.Get("json")
 	validate = tag.Get("validate")
+	example = tag.Get("example")
 	if g := tag.Get("gorm"); g != "" {
 		for part := range strings.SplitSeq(g, ";") {
 			if strings.TrimSpace(part) == "-" {

+ 3 - 1
tools/openapigen/walker.go

@@ -102,7 +102,7 @@ func buildFields(fld *ast.Field, overrides []walkOverride) []Field {
 	if fld.Tag != nil {
 		tag = fld.Tag.Value
 	}
-	jsonTag, validateTag, gormDash := parseStructTag(tag)
+	jsonTag, validateTag, exampleTag, gormDash := parseStructTag(tag)
 	if gormDash && jsonTag == "" {
 		return nil
 	}
@@ -132,6 +132,7 @@ func buildFields(fld *ast.Field, overrides []walkOverride) []Field {
 			Optional: omitempty || isPointer(fld.Type),
 			Validate: validate,
 			Doc:      doc,
+			Example:  exampleTag,
 		})
 	}
 
@@ -154,6 +155,7 @@ func buildFields(fld *ast.Field, overrides []walkOverride) []Field {
 			Optional: omitempty || isPointer(fld.Type),
 			Validate: validate,
 			Doc:      doc,
+			Example:  exampleTag,
 		})
 	}
 

+ 5 - 5
web/service/api_token.go

@@ -17,11 +17,11 @@ type ApiTokenService struct{}
 const apiTokenLength = 48
 
 type ApiTokenView struct {
-	Id        int    `json:"id"`
-	Name      string `json:"name"`
-	Token     string `json:"token,omitempty"`
-	Enabled   bool   `json:"enabled"`
-	CreatedAt int64  `json:"createdAt"`
+	Id        int    `json:"id" example:"2"`
+	Name      string `json:"name" example:"central-panel-a"`
+	Token     string `json:"token,omitempty" example:"new-token-string"`
+	Enabled   bool   `json:"enabled" example:"true"`
+	CreatedAt int64  `json:"createdAt" example:"1736000000"`
 }
 
 // toView builds the metadata view returned by List. It never carries the

+ 26 - 7
web/service/inbound.go

@@ -356,12 +356,13 @@ func (s *InboundService) annotateFallbackParents(db *gorm.DB, inbounds []*model.
 }
 
 type InboundOption struct {
-	Id             int    `json:"id"`
-	Remark         string `json:"remark"`
-	Tag            string `json:"tag"`
-	Protocol       string `json:"protocol"`
-	Port           int    `json:"port"`
-	TlsFlowCapable bool   `json:"tlsFlowCapable"`
+	Id             int    `json:"id" example:"1"`
+	Remark         string `json:"remark" example:"VLESS-443"`
+	Tag            string `json:"tag" example:"in-443-tcp"`
+	Protocol       string `json:"protocol" example:"vless"`
+	Port           int    `json:"port" example:"443"`
+	TlsFlowCapable bool   `json:"tlsFlowCapable" example:"true"`
+	SsMethod       string `json:"ssMethod"`
 }
 
 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"`
 		Port           int    `gorm:"column:port"`
 		StreamSettings string `gorm:"column:stream_settings"`
+		Settings       string `gorm:"column:settings"`
 	}
 	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).
 		Order("id ASC").
 		Scan(&rows).Error
@@ -391,11 +393,28 @@ func (s *InboundService) GetInboundOptions(userId int) ([]InboundOption, error)
 			Protocol:       r.Protocol,
 			Port:           r.Port,
 			TlsFlowCapable: inboundCanEnableTlsFlow(r.Protocol, r.StreamSettings),
+			SsMethod:       inboundShadowsocksMethod(r.Protocol, r.Settings),
 		})
 	}
 	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:
 // XTLS Vision is only valid for VLESS on TCP with tls or reality.
 func inboundCanEnableTlsFlow(protocol, streamSettings string) bool {

+ 7 - 7
web/service/node.go

@@ -635,13 +635,13 @@ func (s *NodeService) Probe(ctx context.Context, n *model.Node) (HeartbeatPatch,
 }
 
 type ProbeResultUI struct {
-	Status       string  `json:"status"`
-	LatencyMs    int     `json:"latencyMs"`
-	XrayVersion  string  `json:"xrayVersion"`
-	PanelVersion string  `json:"panelVersion"`
-	CpuPct       float64 `json:"cpuPct"`
-	MemPct       float64 `json:"memPct"`
-	UptimeSecs   uint64  `json:"uptimeSecs"`
+	Status       string  `json:"status" example:"online"`
+	LatencyMs    int     `json:"latencyMs" example:"42"`
+	XrayVersion  string  `json:"xrayVersion" example:"25.10.31"`
+	PanelVersion string  `json:"panelVersion" example:"v3.x.x"`
+	CpuPct       float64 `json:"cpuPct" example:"12.5"`
+	MemPct       float64 `json:"memPct" example:"45.2"`
+	UptimeSecs   uint64  `json:"uptimeSecs" example:"86400"`
 	Error        string  `json:"error"`
 }
 

+ 12 - 12
xray/client_traffic.go

@@ -3,16 +3,16 @@ package xray
 // ClientTraffic represents traffic statistics and limits for a specific client.
 // It tracks upload/download usage, expiry times, and online status for inbound clients.
 type ClientTraffic struct {
-	Id         int    `json:"id" form:"id" gorm:"primaryKey;autoIncrement"`
-	InboundId  int    `json:"inboundId" form:"inboundId"`
-	Enable     bool   `json:"enable" form:"enable"`
-	Email      string `json:"email" form:"email" gorm:"unique"`
-	UUID       string `json:"uuid" form:"uuid" gorm:"-"`
-	SubId      string `json:"subId" form:"subId" gorm:"-"`
-	Up         int64  `json:"up" form:"up"`
-	Down       int64  `json:"down" form:"down"`
-	ExpiryTime int64  `json:"expiryTime" form:"expiryTime"`
-	Total      int64  `json:"total" form:"total"`
-	Reset      int    `json:"reset" form:"reset" gorm:"default:0"`
-	LastOnline int64  `json:"lastOnline" form:"lastOnline" gorm:"default:0"`
+	Id         int    `json:"id" form:"id" gorm:"primaryKey;autoIncrement" example:"14825"`
+	InboundId  int    `json:"inboundId" form:"inboundId" example:"1"`
+	Enable     bool   `json:"enable" form:"enable" example:"true"`
+	Email      string `json:"email" form:"email" gorm:"unique" example:"user1"`
+	UUID       string `json:"uuid" form:"uuid" gorm:"-" example:"e18c9a96-71bf-48d4-933f-8b9a46d4290c"`
+	SubId      string `json:"subId" form:"subId" gorm:"-" example:"i7tvdpeffi0hvvf1"`
+	Up         int64  `json:"up" form:"up" example:"1048576"`
+	Down       int64  `json:"down" form:"down" example:"2097152"`
+	ExpiryTime int64  `json:"expiryTime" form:"expiryTime" example:"1735689600000"`
+	Total      int64  `json:"total" form:"total" example:"10737418240"`
+	Reset      int    `json:"reset" form:"reset" gorm:"default:0" example:"0"`
+	LastOnline int64  `json:"lastOnline" form:"lastOnline" gorm:"default:0" example:"1735680000000"`
 }