diff --git a/frontend/public/openapi.json b/frontend/public/openapi.json index 17c5361dd..731d6f9ee 100644 --- a/frontend/public/openapi.json +++ b/frontend/public/openapi.json @@ -2058,10 +2058,6 @@ "allowPrivateAddress": { "type": "boolean" }, - "apiToken": { - "example": "abcdef0123456789", - "type": "string" - }, "basePath": { "example": "/", "type": "string" @@ -2232,7 +2228,6 @@ "activeCount", "address", "allowPrivateAddress", - "apiToken", "basePath", "clientCount", "configDirty", @@ -2271,6 +2266,308 @@ ], "type": "object" }, + "NodeMutationRequest": { + "description": "NodeMutationRequest is the node write/probe contract. ApiToken is accepted\nonly as input. On update, nil means keep the stored token; replacement and\nclearing are explicit and mutually exclusive.", + "properties": { + "address": { + "type": "string" + }, + "allowPrivateAddress": { + "type": "boolean" + }, + "apiToken": { + "nullable": true, + "type": "string" + }, + "basePath": { + "type": "string" + }, + "clearApiToken": { + "type": "boolean" + }, + "enable": { + "type": "boolean" + }, + "id": { + "type": "integer" + }, + "inboundSyncMode": { + "enum": [ + "all", + "selected" + ], + "type": "string" + }, + "inboundTags": { + "items": { + "type": "string" + }, + "type": "array" + }, + "name": { + "type": "string" + }, + "outboundTag": { + "type": "string" + }, + "pinnedCertSha256": { + "type": "string" + }, + "port": { + "maximum": 65535, + "minimum": 1, + "type": "integer" + }, + "remark": { + "type": "string" + }, + "scheme": { + "enum": [ + "http", + "https" + ], + "type": "string" + }, + "tlsVerifyMode": { + "enum": [ + "verify", + "skip", + "pin", + "mtls" + ], + "type": "string" + } + }, + "required": [ + "address", + "allowPrivateAddress", + "basePath", + "enable", + "id", + "inboundSyncMode", + "inboundTags", + "name", + "outboundTag", + "pinnedCertSha256", + "port", + "remark", + "scheme", + "tlsVerifyMode" + ], + "type": "object" + }, + "NodeView": { + "description": "NodeView is the browser/API read contract for nodes. Credentials are\nwrite-only: responses expose only whether a node has a token configured.", + "properties": { + "activeCount": { + "example": 20, + "type": "integer" + }, + "address": { + "example": "node.example.com", + "type": "string" + }, + "allowPrivateAddress": { + "example": false, + "type": "boolean" + }, + "basePath": { + "example": "/", + "type": "string" + }, + "clientCount": { + "example": 25, + "type": "integer" + }, + "configDirty": { + "example": false, + "type": "boolean" + }, + "configDirtyAt": { + "example": 0, + "type": "integer" + }, + "cpuPct": { + "example": 12.5, + "type": "number" + }, + "createdAt": { + "example": 1700000000, + "type": "integer" + }, + "depletedCount": { + "example": 1, + "type": "integer" + }, + "disabledCount": { + "example": 2, + "type": "integer" + }, + "enable": { + "example": true, + "type": "boolean" + }, + "guid": { + "example": "node-guid", + "type": "string" + }, + "hasApiToken": { + "example": true, + "type": "boolean" + }, + "id": { + "example": 1, + "type": "integer" + }, + "inboundCount": { + "example": 3, + "type": "integer" + }, + "inboundSyncMode": { + "example": "all", + "type": "string" + }, + "inboundTags": { + "example": [ + "in-443-tcp" + ], + "items": { + "type": "string" + }, + "type": "array" + }, + "lastError": { + "type": "string" + }, + "lastHeartbeat": { + "example": 1700000000, + "type": "integer" + }, + "latencyMs": { + "example": 42, + "type": "integer" + }, + "memPct": { + "example": 45.2, + "type": "number" + }, + "name": { + "example": "edge-1", + "type": "string" + }, + "netDown": { + "example": 1048576, + "type": "integer" + }, + "netUp": { + "example": 2097152, + "type": "integer" + }, + "onlineCount": { + "example": 5, + "type": "integer" + }, + "outboundTag": { + "example": "direct", + "type": "string" + }, + "panelVersion": { + "example": "v3.x.x", + "type": "string" + }, + "parentGuid": { + "type": "string" + }, + "pinnedCertSha256": { + "type": "string" + }, + "port": { + "example": 2053, + "type": "integer" + }, + "remark": { + "example": "Primary edge", + "type": "string" + }, + "scheme": { + "example": "https", + "type": "string" + }, + "status": { + "example": "online", + "type": "string" + }, + "tlsVerifyMode": { + "example": "verify", + "type": "string" + }, + "transitive": { + "example": false, + "type": "boolean" + }, + "updatedAt": { + "example": 1700003600, + "type": "integer" + }, + "uptimeSecs": { + "example": 86400, + "type": "integer" + }, + "xrayError": { + "type": "string" + }, + "xrayState": { + "example": "running", + "type": "string" + }, + "xrayVersion": { + "example": "25.10.31", + "type": "string" + } + }, + "required": [ + "activeCount", + "address", + "allowPrivateAddress", + "basePath", + "clientCount", + "configDirty", + "configDirtyAt", + "cpuPct", + "createdAt", + "depletedCount", + "disabledCount", + "enable", + "guid", + "hasApiToken", + "id", + "inboundCount", + "inboundSyncMode", + "inboundTags", + "lastError", + "lastHeartbeat", + "latencyMs", + "memPct", + "name", + "netDown", + "netUp", + "onlineCount", + "outboundTag", + "panelVersion", + "pinnedCertSha256", + "port", + "remark", + "scheme", + "status", + "tlsVerifyMode", + "updatedAt", + "uptimeSecs", + "xrayError", + "xrayState", + "xrayVersion" + ], + "type": "object" + }, "OutboundTraffics": { "description": "OutboundTraffics tracks traffic statistics for Xray outbound connections.", "properties": { @@ -7520,7 +7817,7 @@ "obj": { "type": "array", "items": { - "$ref": "#/components/schemas/Node" + "$ref": "#/components/schemas/NodeView" } } } @@ -7529,48 +7826,48 @@ "success": true, "obj": [ { - "activeCount": 23, - "address": "node1.example.com", + "activeCount": 20, + "address": "node.example.com", "allowPrivateAddress": false, - "apiToken": "abcdef0123456789", "basePath": "/", - "clientCount": 27, + "clientCount": 25, "configDirty": false, "configDirtyAt": 0, - "cpuPct": 23.5, + "cpuPct": 12.5, "createdAt": 1700000000, "depletedCount": 1, - "disabledCount": 3, + "disabledCount": 2, "enable": true, - "guid": "", + "guid": "node-guid", + "hasApiToken": true, "id": 1, - "inboundCount": 5, + "inboundCount": 3, "inboundSyncMode": "all", "inboundTags": [ - "" + "in-443-tcp" ], "lastError": "", "lastHeartbeat": 1700000000, "latencyMs": 42, - "memPct": 45.1, - "name": "de-fra-1", - "netDown": 2097152, - "netUp": 1048576, - "onlineCount": 3, - "outboundTag": "", + "memPct": 45.2, + "name": "edge-1", + "netDown": 1048576, + "netUp": 2097152, + "onlineCount": 5, + "outboundTag": "direct", "panelVersion": "v3.x.x", "parentGuid": "", "pinnedCertSha256": "", "port": 2053, - "remark": "", + "remark": "Primary edge", "scheme": "https", "status": "online", "tlsVerifyMode": "verify", "transitive": false, - "updatedAt": 1700000000, + "updatedAt": 1700003600, "uptimeSecs": 86400, "xrayError": "", - "xrayState": "", + "xrayState": "running", "xrayVersion": "25.10.31" } ] @@ -7692,7 +7989,57 @@ "msg": { "type": "string" }, - "obj": {} + "obj": { + "$ref": "#/components/schemas/NodeView" + } + } + }, + "example": { + "success": true, + "obj": { + "activeCount": 20, + "address": "node.example.com", + "allowPrivateAddress": false, + "basePath": "/", + "clientCount": 25, + "configDirty": false, + "configDirtyAt": 0, + "cpuPct": 12.5, + "createdAt": 1700000000, + "depletedCount": 1, + "disabledCount": 2, + "enable": true, + "guid": "node-guid", + "hasApiToken": true, + "id": 1, + "inboundCount": 3, + "inboundSyncMode": "all", + "inboundTags": [ + "in-443-tcp" + ], + "lastError": "", + "lastHeartbeat": 1700000000, + "latencyMs": 42, + "memPct": 45.2, + "name": "edge-1", + "netDown": 1048576, + "netUp": 2097152, + "onlineCount": 5, + "outboundTag": "direct", + "panelVersion": "v3.x.x", + "parentGuid": "", + "pinnedCertSha256": "", + "port": 2053, + "remark": "Primary edge", + "scheme": "https", + "status": "online", + "tlsVerifyMode": "verify", + "transitive": false, + "updatedAt": 1700003600, + "uptimeSecs": 86400, + "xrayError": "", + "xrayState": "running", + "xrayVersion": "25.10.31" } } } @@ -7754,7 +8101,7 @@ "tags": [ "Nodes" ], - "summary": "Register a new remote node. Provide its URL, apiToken, and optional remark / allowPrivateAddress flag.", + "summary": "Register a new remote node. Provide its URL, write-only apiToken, and optional remark / allowPrivateAddress flag. Responses expose hasApiToken only.", "operationId": "post_panel_api_nodes_add", "requestBody": { "required": true, @@ -7771,6 +8118,7 @@ "port": 2053, "basePath": "/", "apiToken": "abcdef...", + "clearApiToken": false, "enable": true, "allowPrivateAddress": false } @@ -7791,7 +8139,57 @@ "msg": { "type": "string" }, - "obj": {} + "obj": { + "$ref": "#/components/schemas/NodeView" + } + } + }, + "example": { + "success": true, + "obj": { + "activeCount": 20, + "address": "node.example.com", + "allowPrivateAddress": false, + "basePath": "/", + "clientCount": 25, + "configDirty": false, + "configDirtyAt": 0, + "cpuPct": 12.5, + "createdAt": 1700000000, + "depletedCount": 1, + "disabledCount": 2, + "enable": true, + "guid": "node-guid", + "hasApiToken": true, + "id": 1, + "inboundCount": 3, + "inboundSyncMode": "all", + "inboundTags": [ + "in-443-tcp" + ], + "lastError": "", + "lastHeartbeat": 1700000000, + "latencyMs": 42, + "memPct": 45.2, + "name": "edge-1", + "netDown": 1048576, + "netUp": 2097152, + "onlineCount": 5, + "outboundTag": "direct", + "panelVersion": "v3.x.x", + "parentGuid": "", + "pinnedCertSha256": "", + "port": 2053, + "remark": "Primary edge", + "scheme": "https", + "status": "online", + "tlsVerifyMode": "verify", + "transitive": false, + "updatedAt": 1700003600, + "uptimeSecs": 86400, + "xrayError": "", + "xrayState": "running", + "xrayVersion": "25.10.31" } } } @@ -7805,7 +8203,7 @@ "tags": [ "Nodes" ], - "summary": "Replace a node’s connection details. Same body shape as /add.", + "summary": "Replace a node’s connection details. apiToken is write-only: omit it or send an empty string to keep the stored token; set clearApiToken=true to clear it.", "operationId": "post_panel_api_nodes_update_id", "parameters": [ { @@ -7832,7 +8230,8 @@ "address": "node1.example.com", "port": 2053, "basePath": "/", - "apiToken": "abcdef...", + "apiToken": "", + "clearApiToken": false, "enable": true, "allowPrivateAddress": false } diff --git a/frontend/src/generated/examples.ts b/frontend/src/generated/examples.ts index d7708b132..8b3649907 100644 --- a/frontend/src/generated/examples.ts +++ b/frontend/src/generated/examples.ts @@ -494,7 +494,6 @@ export const EXAMPLES: Record = { "activeCount": 23, "address": "node1.example.com", "allowPrivateAddress": false, - "apiToken": "abcdef0123456789", "basePath": "/", "clientCount": 27, "configDirty": false, @@ -535,6 +534,71 @@ export const EXAMPLES: Record = { "xrayState": "", "xrayVersion": "25.10.31" }, + "NodeMutationRequest": { + "address": "", + "allowPrivateAddress": false, + "apiToken": null, + "basePath": "", + "clearApiToken": false, + "enable": false, + "id": 0, + "inboundSyncMode": "all", + "inboundTags": [ + "" + ], + "name": "", + "outboundTag": "", + "pinnedCertSha256": "", + "port": 1, + "remark": "", + "scheme": "http", + "tlsVerifyMode": "verify" + }, + "NodeView": { + "activeCount": 20, + "address": "node.example.com", + "allowPrivateAddress": false, + "basePath": "/", + "clientCount": 25, + "configDirty": false, + "configDirtyAt": 0, + "cpuPct": 12.5, + "createdAt": 1700000000, + "depletedCount": 1, + "disabledCount": 2, + "enable": true, + "guid": "node-guid", + "hasApiToken": true, + "id": 1, + "inboundCount": 3, + "inboundSyncMode": "all", + "inboundTags": [ + "in-443-tcp" + ], + "lastError": "", + "lastHeartbeat": 1700000000, + "latencyMs": 42, + "memPct": 45.2, + "name": "edge-1", + "netDown": 1048576, + "netUp": 2097152, + "onlineCount": 5, + "outboundTag": "direct", + "panelVersion": "v3.x.x", + "parentGuid": "", + "pinnedCertSha256": "", + "port": 2053, + "remark": "Primary edge", + "scheme": "https", + "status": "online", + "tlsVerifyMode": "verify", + "transitive": false, + "updatedAt": 1700003600, + "uptimeSecs": 86400, + "xrayError": "", + "xrayState": "running", + "xrayVersion": "25.10.31" + }, "OutboundTraffics": { "down": 0, "id": 0, diff --git a/frontend/src/generated/schemas.ts b/frontend/src/generated/schemas.ts index c2627f293..e54876145 100644 --- a/frontend/src/generated/schemas.ts +++ b/frontend/src/generated/schemas.ts @@ -2032,10 +2032,6 @@ export const SCHEMAS: Record = { "allowPrivateAddress": { "type": "boolean" }, - "apiToken": { - "example": "abcdef0123456789", - "type": "string" - }, "basePath": { "example": "/", "type": "string" @@ -2206,7 +2202,6 @@ export const SCHEMAS: Record = { "activeCount", "address", "allowPrivateAddress", - "apiToken", "basePath", "clientCount", "configDirty", @@ -2245,6 +2240,308 @@ export const SCHEMAS: Record = { ], "type": "object" }, + "NodeMutationRequest": { + "description": "NodeMutationRequest is the node write/probe contract. ApiToken is accepted\nonly as input. On update, nil means keep the stored token; replacement and\nclearing are explicit and mutually exclusive.", + "properties": { + "address": { + "type": "string" + }, + "allowPrivateAddress": { + "type": "boolean" + }, + "apiToken": { + "nullable": true, + "type": "string" + }, + "basePath": { + "type": "string" + }, + "clearApiToken": { + "type": "boolean" + }, + "enable": { + "type": "boolean" + }, + "id": { + "type": "integer" + }, + "inboundSyncMode": { + "enum": [ + "all", + "selected" + ], + "type": "string" + }, + "inboundTags": { + "items": { + "type": "string" + }, + "type": "array" + }, + "name": { + "type": "string" + }, + "outboundTag": { + "type": "string" + }, + "pinnedCertSha256": { + "type": "string" + }, + "port": { + "maximum": 65535, + "minimum": 1, + "type": "integer" + }, + "remark": { + "type": "string" + }, + "scheme": { + "enum": [ + "http", + "https" + ], + "type": "string" + }, + "tlsVerifyMode": { + "enum": [ + "verify", + "skip", + "pin", + "mtls" + ], + "type": "string" + } + }, + "required": [ + "address", + "allowPrivateAddress", + "basePath", + "enable", + "id", + "inboundSyncMode", + "inboundTags", + "name", + "outboundTag", + "pinnedCertSha256", + "port", + "remark", + "scheme", + "tlsVerifyMode" + ], + "type": "object" + }, + "NodeView": { + "description": "NodeView is the browser/API read contract for nodes. Credentials are\nwrite-only: responses expose only whether a node has a token configured.", + "properties": { + "activeCount": { + "example": 20, + "type": "integer" + }, + "address": { + "example": "node.example.com", + "type": "string" + }, + "allowPrivateAddress": { + "example": false, + "type": "boolean" + }, + "basePath": { + "example": "/", + "type": "string" + }, + "clientCount": { + "example": 25, + "type": "integer" + }, + "configDirty": { + "example": false, + "type": "boolean" + }, + "configDirtyAt": { + "example": 0, + "type": "integer" + }, + "cpuPct": { + "example": 12.5, + "type": "number" + }, + "createdAt": { + "example": 1700000000, + "type": "integer" + }, + "depletedCount": { + "example": 1, + "type": "integer" + }, + "disabledCount": { + "example": 2, + "type": "integer" + }, + "enable": { + "example": true, + "type": "boolean" + }, + "guid": { + "example": "node-guid", + "type": "string" + }, + "hasApiToken": { + "example": true, + "type": "boolean" + }, + "id": { + "example": 1, + "type": "integer" + }, + "inboundCount": { + "example": 3, + "type": "integer" + }, + "inboundSyncMode": { + "example": "all", + "type": "string" + }, + "inboundTags": { + "example": [ + "in-443-tcp" + ], + "items": { + "type": "string" + }, + "type": "array" + }, + "lastError": { + "type": "string" + }, + "lastHeartbeat": { + "example": 1700000000, + "type": "integer" + }, + "latencyMs": { + "example": 42, + "type": "integer" + }, + "memPct": { + "example": 45.2, + "type": "number" + }, + "name": { + "example": "edge-1", + "type": "string" + }, + "netDown": { + "example": 1048576, + "type": "integer" + }, + "netUp": { + "example": 2097152, + "type": "integer" + }, + "onlineCount": { + "example": 5, + "type": "integer" + }, + "outboundTag": { + "example": "direct", + "type": "string" + }, + "panelVersion": { + "example": "v3.x.x", + "type": "string" + }, + "parentGuid": { + "type": "string" + }, + "pinnedCertSha256": { + "type": "string" + }, + "port": { + "example": 2053, + "type": "integer" + }, + "remark": { + "example": "Primary edge", + "type": "string" + }, + "scheme": { + "example": "https", + "type": "string" + }, + "status": { + "example": "online", + "type": "string" + }, + "tlsVerifyMode": { + "example": "verify", + "type": "string" + }, + "transitive": { + "example": false, + "type": "boolean" + }, + "updatedAt": { + "example": 1700003600, + "type": "integer" + }, + "uptimeSecs": { + "example": 86400, + "type": "integer" + }, + "xrayError": { + "type": "string" + }, + "xrayState": { + "example": "running", + "type": "string" + }, + "xrayVersion": { + "example": "25.10.31", + "type": "string" + } + }, + "required": [ + "activeCount", + "address", + "allowPrivateAddress", + "basePath", + "clientCount", + "configDirty", + "configDirtyAt", + "cpuPct", + "createdAt", + "depletedCount", + "disabledCount", + "enable", + "guid", + "hasApiToken", + "id", + "inboundCount", + "inboundSyncMode", + "inboundTags", + "lastError", + "lastHeartbeat", + "latencyMs", + "memPct", + "name", + "netDown", + "netUp", + "onlineCount", + "outboundTag", + "panelVersion", + "pinnedCertSha256", + "port", + "remark", + "scheme", + "status", + "tlsVerifyMode", + "updatedAt", + "uptimeSecs", + "xrayError", + "xrayState", + "xrayVersion" + ], + "type": "object" + }, "OutboundTraffics": { "description": "OutboundTraffics tracks traffic statistics for Xray outbound connections.", "properties": { diff --git a/frontend/src/generated/types.ts b/frontend/src/generated/types.ts index d2ed9a7c9..c93b746fc 100644 --- a/frontend/src/generated/types.ts +++ b/frontend/src/generated/types.ts @@ -477,7 +477,6 @@ export interface Node { activeCount: number; address: string; allowPrivateAddress: boolean; - apiToken: string; basePath: string; clientCount: number; configDirty: boolean; @@ -517,6 +516,69 @@ export interface Node { xrayVersion: string; } +export interface NodeMutationRequest { + address: string; + allowPrivateAddress: boolean; + apiToken?: string | null; + basePath: string; + clearApiToken?: boolean; + enable: boolean; + id: number; + inboundSyncMode: string; + inboundTags: string[]; + name: string; + outboundTag: string; + pinnedCertSha256: string; + port: number; + remark: string; + scheme: string; + tlsVerifyMode: string; +} + +export interface NodeView { + activeCount: number; + address: string; + allowPrivateAddress: boolean; + basePath: string; + clientCount: number; + configDirty: boolean; + configDirtyAt: number; + cpuPct: number; + createdAt: number; + depletedCount: number; + disabledCount: number; + enable: boolean; + guid: string; + hasApiToken: boolean; + id: number; + inboundCount: number; + inboundSyncMode: string; + inboundTags: string[]; + lastError: string; + lastHeartbeat: number; + latencyMs: number; + memPct: number; + name: string; + netDown: number; + netUp: number; + onlineCount: number; + outboundTag: string; + panelVersion: string; + parentGuid?: string; + pinnedCertSha256: string; + port: number; + remark: string; + scheme: string; + status: string; + tlsVerifyMode: string; + transitive?: boolean; + updatedAt: number; + uptimeSecs: number; + xrayError: string; + xrayState: string; + xrayVersion: string; +} + export interface OutboundTraffics { down: number; id: number; diff --git a/frontend/src/generated/zod.ts b/frontend/src/generated/zod.ts index 797252765..f54d0c5fe 100644 --- a/frontend/src/generated/zod.ts +++ b/frontend/src/generated/zod.ts @@ -507,7 +507,6 @@ export const NodeSchema = z.object({ activeCount: z.number().int(), address: z.string(), allowPrivateAddress: z.boolean(), - apiToken: z.string(), basePath: z.string(), clientCount: z.number().int(), configDirty: z.boolean(), @@ -548,6 +547,71 @@ export const NodeSchema = z.object({ }); export type Node = z.infer; +export const NodeMutationRequestSchema = z.object({ + address: z.string(), + allowPrivateAddress: z.boolean(), + apiToken: z.string().nullable().optional(), + basePath: z.string(), + clearApiToken: z.boolean().optional(), + enable: z.boolean(), + id: z.number().int(), + inboundSyncMode: z.enum(['all', 'selected']), + inboundTags: z.array(z.string()), + name: z.string(), + outboundTag: z.string(), + pinnedCertSha256: z.string(), + port: z.number().int().min(1).max(65535), + remark: z.string(), + scheme: z.enum(['http', 'https']), + tlsVerifyMode: z.enum(['verify', 'skip', 'pin', 'mtls']), +}); +export type NodeMutationRequest = z.infer; + +export const NodeViewSchema = z.object({ + activeCount: z.number().int(), + address: z.string(), + allowPrivateAddress: z.boolean(), + basePath: z.string(), + clientCount: z.number().int(), + configDirty: z.boolean(), + configDirtyAt: z.number().int(), + cpuPct: z.number(), + createdAt: z.number().int(), + depletedCount: z.number().int(), + disabledCount: z.number().int(), + enable: z.boolean(), + guid: z.string(), + hasApiToken: z.boolean(), + id: z.number().int(), + inboundCount: z.number().int(), + inboundSyncMode: z.string(), + inboundTags: z.array(z.string()), + lastError: z.string(), + lastHeartbeat: z.number().int(), + latencyMs: z.number().int(), + memPct: z.number(), + name: z.string(), + netDown: z.number().int(), + netUp: z.number().int(), + onlineCount: z.number().int(), + outboundTag: z.string(), + panelVersion: z.string(), + parentGuid: z.string().optional(), + pinnedCertSha256: z.string(), + port: z.number().int(), + remark: z.string(), + scheme: z.string(), + status: z.string(), + tlsVerifyMode: z.string(), + transitive: z.boolean().optional(), + updatedAt: z.number().int(), + uptimeSecs: z.number().int(), + xrayError: z.string(), + xrayState: z.string(), + xrayVersion: z.string(), +}); +export type NodeView = z.infer; + export const OutboundTrafficsSchema = z.object({ down: z.number().int(), id: z.number().int(), diff --git a/frontend/src/pages/api-docs/endpoints.ts b/frontend/src/pages/api-docs/endpoints.ts index 0b37a8f94..0709d11af 100644 --- a/frontend/src/pages/api-docs/endpoints.ts +++ b/frontend/src/pages/api-docs/endpoints.ts @@ -905,7 +905,7 @@ 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.', - responseSchema: 'Node', + responseSchema: 'NodeView', responseSchemaArray: true, }, { @@ -927,6 +927,7 @@ export const sections: readonly Section[] = [ params: [ { name: 'id', in: 'path', type: 'number', desc: 'Node ID.' }, ], + responseSchema: 'NodeView', }, { method: 'GET', @@ -940,18 +941,19 @@ export const sections: readonly Section[] = [ { method: 'POST', path: '/panel/api/nodes/add', - summary: 'Register a new remote node. Provide its URL, apiToken, and optional remark / allowPrivateAddress flag.', + summary: 'Register a new remote node. Provide its URL, write-only apiToken, and optional remark / allowPrivateAddress flag. Responses expose hasApiToken only.', body: - '{\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}', + '{\n "name": "de-fra-1",\n "remark": "",\n "scheme": "https",\n "address": "node1.example.com",\n "port": 2053,\n "basePath": "/",\n "apiToken": "abcdef...",\n "clearApiToken": false,\n "enable": true,\n "allowPrivateAddress": false\n}', + responseSchema: 'NodeView', }, { method: 'POST', path: '/panel/api/nodes/update/:id', - summary: 'Replace a node\u2019s connection details. Same body shape as /add.', + summary: 'Replace a node\u2019s connection details. apiToken is write-only: omit it or send an empty string to keep the stored token; set clearApiToken=true to clear it.', params: [ { name: 'id', in: 'path', type: 'number', desc: 'Node ID.' }, ], - body: '{\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}', + body: '{\n "name": "de-fra-1",\n "remark": "",\n "scheme": "https",\n "address": "node1.example.com",\n "port": 2053,\n "basePath": "/",\n "apiToken": "",\n "clearApiToken": false,\n "enable": true,\n "allowPrivateAddress": false\n}', }, { method: 'POST', diff --git a/frontend/src/pages/nodes/NodeFormModal.tsx b/frontend/src/pages/nodes/NodeFormModal.tsx index 5538f1d8c..b267deefd 100644 --- a/frontend/src/pages/nodes/NodeFormModal.tsx +++ b/frontend/src/pages/nodes/NodeFormModal.tsx @@ -45,6 +45,7 @@ function defaultValues(): NodeFormValues { port: 2053, basePath: '/', apiToken: '', + hasStoredToken: false, enable: true, allowPrivateAddress: false, tlsVerifyMode: 'verify', @@ -107,6 +108,8 @@ export default function NodeFormModal({ scheme: (node.scheme as 'http' | 'https') || base.scheme, inboundSyncMode: (node.inboundSyncMode as 'all' | 'selected') || base.inboundSyncMode, inboundTags: node.inboundTags ?? [], + apiToken: '', + hasStoredToken: node.hasApiToken ?? false, } : base; if (next.scheme === 'http') next.tlsVerifyMode = 'skip'; @@ -120,8 +123,11 @@ export default function NodeFormModal({ [mode, t], ); + const editingWithToken = mode === 'edit' && Boolean(node?.hasApiToken); + function buildPayload(values: NodeFormValues): Partial { - return { + const token = values.apiToken.trim(); + const payload: Partial = { id: values.id || 0, name: values.name.trim(), remark: values.remark?.trim() || '', @@ -129,7 +135,6 @@ export default function NodeFormModal({ address: values.address.trim(), port: values.port, basePath: values.basePath.trim() || '/', - apiToken: values.apiToken.trim(), enable: values.enable, allowPrivateAddress: values.allowPrivateAddress, tlsVerifyMode: values.tlsVerifyMode, @@ -138,10 +143,12 @@ export default function NodeFormModal({ inboundTags: values.inboundSyncMode === 'selected' ? values.inboundTags : [], outboundTag: values.outboundTag || '', }; + if (token) payload.apiToken = token; + return payload; } async function onTest() { - if (!(await methods.trigger(['address', 'port']))) return; + if (!(await methods.trigger(['name', 'address', 'port']))) return; setTesting(true); setTestResult(null); try { @@ -158,7 +165,7 @@ export default function NodeFormModal({ } async function onFetchPin() { - if (!(await methods.trigger(['address', 'port']))) return; + if (!(await methods.trigger(['name', 'address', 'port']))) return; setFetchingPin(true); try { const payload = buildPayload(methods.getValues()); @@ -369,8 +376,11 @@ export default function NodeFormModal({ name="apiToken" rules={{ validate: rhfZodValidate(NodeFormSchema.shape.apiToken) }} tooltip={t('pages.nodes.apiTokenHint')} + extra={editingWithToken ? t('pages.nodes.apiTokenKeepHint') : undefined} > - + tags ?? []), outboundTag: z.string().optional(), }).superRefine((val, ctx) => { - if (val.tlsVerifyMode !== 'mtls' && val.apiToken.length === 0) { + if (val.tlsVerifyMode !== 'mtls' && val.apiToken.length === 0 && !val.hasStoredToken) { ctx.addIssue({ code: 'custom', path: ['apiToken'], diff --git a/internal/database/model/model.go b/internal/database/model/model.go index 4ad891c32..92510e56e 100644 --- a/internal/database/model/model.go +++ b/internal/database/model/model.go @@ -706,7 +706,7 @@ type Node struct { 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_unless=TlsVerifyMode mtls" example:"abcdef0123456789"` + ApiToken string `json:"-" form:"-" gorm:"column:api_token" validate:"required_unless=TlsVerifyMode mtls" 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 mtls"` diff --git a/internal/web/controller/node.go b/internal/web/controller/node.go index a19ea6299..4b7f4af46 100644 --- a/internal/web/controller/node.go +++ b/internal/web/controller/node.go @@ -8,7 +8,6 @@ import ( "strconv" "time" - "github.com/mhsanaei/3x-ui/v3/internal/database/model" "github.com/mhsanaei/3x-ui/v3/internal/logger" "github.com/mhsanaei/3x-ui/v3/internal/web/middleware" "github.com/mhsanaei/3x-ui/v3/internal/web/service" @@ -78,7 +77,7 @@ func (a *NodeController) setMtlsTrustCA(c *gin.Context) { } func (a *NodeController) list(c *gin.Context) { - nodes, err := a.nodeService.GetNodeTree() + nodes, err := a.nodeService.GetNodeTreeView() if err != nil { jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.list"), err) return @@ -92,7 +91,7 @@ func (a *NodeController) get(c *gin.Context) { jsonMsg(c, I18nWeb(c, "get"), err) return } - n, err := a.nodeService.GetById(id) + n, err := a.nodeService.GetViewById(id) if err != nil { jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.obtain"), err) return @@ -116,27 +115,32 @@ func (a *NodeController) webCert(c *gin.Context) { jsonObj(c, files, nil) } -func (a *NodeController) ensureReachable(c *gin.Context, n *model.Node) error { +func (a *NodeController) ensureReachable(c *gin.Context, n *service.NodeMutationRequest, id int) error { + runtimeNode, err := a.nodeService.RuntimeNodeFromRequest(id, n) + if err != nil { + return err + } ctx, cancel := context.WithTimeout(c.Request.Context(), 6*time.Second) defer cancel() - if _, err := a.nodeService.Probe(ctx, n); err != nil { + if _, err := a.nodeService.Probe(ctx, runtimeNode); err != nil { return errors.New(service.FriendlyProbeError(err.Error())) } return nil } func (a *NodeController) add(c *gin.Context) { - n, ok := middleware.BindAndValidate[model.Node](c) + n, ok := middleware.BindAndValidate[service.NodeMutationRequest](c) if !ok { return } if n.OutboundTag == "" { - if err := a.ensureReachable(c, n); err != nil { + if err := a.ensureReachable(c, n, 0); err != nil { jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.add"), err) return } } - if err := a.nodeService.Create(n); err != nil { + view, err := a.nodeService.CreateFromRequest(n) + if err != nil { jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.add"), err) return } @@ -144,12 +148,12 @@ func (a *NodeController) add(c *gin.Context) { if err := a.xrayService.RestartXray(false); err != nil { logger.Warning("apply node outbound bridge failed:", err) } - if err := a.ensureReachable(c, n); err != nil { + if err := a.ensureReachable(c, n, view.Id); err != nil { jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.add"), err) return } } - jsonMsgObj(c, I18nWeb(c, "pages.nodes.toasts.add"), n, nil) + jsonMsgObj(c, I18nWeb(c, "pages.nodes.toasts.add"), view, nil) } func (a *NodeController) update(c *gin.Context) { @@ -158,7 +162,7 @@ func (a *NodeController) update(c *gin.Context) { jsonMsg(c, I18nWeb(c, "get"), err) return } - n, ok := middleware.BindAndValidate[model.Node](c) + n, ok := middleware.BindAndValidate[service.NodeMutationRequest](c) if !ok { return } @@ -167,13 +171,13 @@ func (a *NodeController) update(c *gin.Context) { jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.obtain"), err) return } - if n.OutboundTag == "" && old.OutboundTag == "" { - if err := a.ensureReachable(c, n); err != nil { + if n.OutboundTag == "" && old.OutboundTag == "" && (!n.ClearApiToken || n.Enable) { + if err := a.ensureReachable(c, n, id); err != nil { jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.update"), err) return } } - if err := a.nodeService.Update(id, n); err != nil { + if err := a.nodeService.UpdateFromRequest(id, n); err != nil { jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.update"), err) return } @@ -181,7 +185,7 @@ func (a *NodeController) update(c *gin.Context) { if err := a.xrayService.RestartXray(false); err != nil { logger.Warning("apply node outbound bridge change failed:", err) } - if err := a.ensureReachable(c, n); err != nil { + if err := a.ensureReachable(c, n, id); err != nil { jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.update"), err) return } @@ -233,58 +237,57 @@ func (a *NodeController) setEnable(c *gin.Context) { } func (a *NodeController) inbounds(c *gin.Context) { - n := &model.Node{} - if err := c.ShouldBind(n); err != nil { + n, ok := middleware.BindAndValidate[service.NodeMutationRequest](c) + if !ok { + return + } + runtimeNode, err := a.nodeService.RuntimeNodeFromRequest(n.Id, n) + if err != nil { jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.obtain"), err) return } ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second) defer cancel() - options, err := a.nodeService.GetRemoteInboundOptions(ctx, n) + options, err := a.nodeService.GetRemoteInboundOptions(ctx, runtimeNode) jsonObj(c, options, err) } func (a *NodeController) test(c *gin.Context) { - n := &model.Node{} - if err := c.ShouldBind(n); err != nil { - jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.test"), err) + n, ok := middleware.BindAndValidate[service.NodeMutationRequest](c) + if !ok { return } - if n.Scheme == "" { - n.Scheme = "https" - } - if n.BasePath == "" { - n.BasePath = "/" + runtimeNode, err := a.nodeService.RuntimeNodeFromRequest(n.Id, n) + if err != nil { + jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.test"), err) + return } ctx, cancel := context.WithTimeout(c.Request.Context(), 6*time.Second) defer cancel() var patch service.HeartbeatPatch - var err error - if n.OutboundTag != "" { - patch, err = a.nodeService.ProbeWithOutbound(ctx, n, n.OutboundTag) + if runtimeNode.OutboundTag != "" { + patch, err = a.nodeService.ProbeWithOutbound(ctx, runtimeNode, runtimeNode.OutboundTag) } else { - patch, err = a.nodeService.Probe(ctx, n) + patch, err = a.nodeService.Probe(ctx, runtimeNode) } jsonObj(c, patch.ToUI(err == nil), nil) } func (a *NodeController) certFingerprint(c *gin.Context) { - n := &model.Node{} - if err := c.ShouldBind(n); err != nil { - jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.test"), err) + n, ok := middleware.BindAndValidate[service.NodeMutationRequest](c) + if !ok { return } - if n.Scheme == "" { - n.Scheme = "https" - } - if n.BasePath == "" { - n.BasePath = "/" + runtimeNode, err := a.nodeService.NodeFromRequestForCertificate(n) + if err != nil { + jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.test"), err) + return } ctx, cancel := context.WithTimeout(c.Request.Context(), 6*time.Second) defer cancel() - fp, err := a.nodeService.FetchCertFingerprint(ctx, n) + fp, err := a.nodeService.FetchCertFingerprint(ctx, runtimeNode) if err != nil { jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.test"), err) return diff --git a/internal/web/controller/node_credentials_writeonly_test.go b/internal/web/controller/node_credentials_writeonly_test.go new file mode 100644 index 000000000..278a8e8ca --- /dev/null +++ b/internal/web/controller/node_credentials_writeonly_test.go @@ -0,0 +1,192 @@ +package controller + +import ( + "encoding/json" + "net" + "net/http" + "net/http/httptest" + "path/filepath" + "strconv" + "strings" + "testing" + + "github.com/gin-gonic/gin" + + "github.com/mhsanaei/3x-ui/v3/internal/database" + "github.com/mhsanaei/3x-ui/v3/internal/database/model" + "github.com/mhsanaei/3x-ui/v3/internal/web/locale" +) + +func newNodeCredentialTestEngine(t *testing.T) *gin.Engine { + t.Helper() + gin.SetMode(gin.TestMode) + dbDir := t.TempDir() + t.Setenv("XUI_DB_FOLDER", dbDir) + if err := database.InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil { + t.Fatalf("InitDB: %v", err) + } + t.Cleanup(func() { _ = database.CloseDB() }) + + engine := gin.New() + engine.Use(func(c *gin.Context) { + c.Set("I18n", func(_ locale.I18nType, key string, _ ...string) string { return key }) + c.Next() + }) + NewNodeController(engine.Group("/panel/api/nodes")) + return engine +} + +func TestNodeControllerResponsesDoNotLeakApiToken(t *testing.T) { + engine := newNodeCredentialTestEngine(t) + if err := database.GetDB().Create(&model.Node{ + Name: "stored-node", + Scheme: "https", + Address: "example.com", + Port: 2053, + BasePath: "/", + ApiToken: "stored-secret-token", + Enable: true, + }).Error; err != nil { + t.Fatalf("seed node: %v", err) + } + + for _, path := range []string{"/panel/api/nodes/list", "/panel/api/nodes/get/1"} { + w := httptest.NewRecorder() + engine.ServeHTTP(w, httptest.NewRequest(http.MethodGet, path, nil)) + if w.Code != http.StatusOK { + t.Fatalf("%s status = %d body=%s", path, w.Code, w.Body.String()) + } + body := w.Body.String() + if strings.Contains(body, "stored-secret-token") || strings.Contains(body, "apiToken") { + t.Fatalf("%s leaked api token: %s", path, body) + } + if !strings.Contains(body, `"hasApiToken":true`) { + t.Fatalf("%s did not expose credential presence: %s", path, body) + } + } +} + +func TestNodeControllerAddAcceptsTokenButReturnsView(t *testing.T) { + engine := newNodeCredentialTestEngine(t) + + remote := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/panel/api/server/status" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + if got := r.Header.Get("Authorization"); got != "Bearer input-secret-token" { + t.Fatalf("Authorization = %q", got) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"success":true,"obj":{"cpu":1,"mem":{"current":1,"total":2},"xray":{"version":"1","state":"running"},"panelVersion":"v3.4.1","panelGuid":"guid","uptime":7,"netIO":{"up":3,"down":4}}}`)) + })) + defer remote.Close() + host, portString, err := net.SplitHostPort(strings.TrimPrefix(remote.URL, "http://")) + if err != nil { + t.Fatalf("split remote addr: %v", err) + } + port, err := strconv.Atoi(portString) + if err != nil { + t.Fatalf("parse remote port: %v", err) + } + + payload := map[string]any{ + "name": "added-node", + "scheme": "http", + "address": host, + "port": port, + "basePath": "/", + "apiToken": "input-secret-token", + "enable": true, + "allowPrivateAddress": true, + } + raw, _ := json.Marshal(payload) + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/panel/api/nodes/add", strings.NewReader(string(raw))) + req.Header.Set("Content-Type", "application/json") + engine.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("add status = %d body=%s", w.Code, w.Body.String()) + } + body := w.Body.String() + if strings.Contains(body, "input-secret-token") || strings.Contains(body, "apiToken") { + t.Fatalf("add response leaked api token: %s", body) + } + if !strings.Contains(body, `"hasApiToken":true`) { + t.Fatalf("add response did not expose credential presence: %s", body) + } + + var stored model.Node + if err := database.GetDB().Where("name = ?", "added-node").First(&stored).Error; err != nil { + t.Fatalf("load stored node: %v", err) + } + if stored.ApiToken != "input-secret-token" { + t.Fatalf("stored token = %q, want input-secret-token", stored.ApiToken) + } +} + +func TestNodeControllerUpdateBlankApiTokenKeepsStoredToken(t *testing.T) { + engine := newNodeCredentialTestEngine(t) + + remote := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/panel/api/server/status" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + if got := r.Header.Get("Authorization"); got != "Bearer stored-secret-token" { + t.Fatalf("Authorization = %q", got) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"success":true,"obj":{"cpu":1,"mem":{"current":1,"total":2},"xray":{"version":"1","state":"running"},"panelVersion":"v3.4.1","panelGuid":"guid","uptime":7,"netIO":{"up":3,"down":4}}}`)) + })) + defer remote.Close() + host, portString, err := net.SplitHostPort(strings.TrimPrefix(remote.URL, "http://")) + if err != nil { + t.Fatalf("split remote addr: %v", err) + } + port, err := strconv.Atoi(portString) + if err != nil { + t.Fatalf("parse remote port: %v", err) + } + node := &model.Node{ + Name: "stored-node", + Scheme: "http", + Address: host, + Port: port, + BasePath: "/", + ApiToken: "stored-secret-token", + Enable: true, + AllowPrivateAddress: true, + } + if err := database.GetDB().Create(node).Error; err != nil { + t.Fatalf("seed node: %v", err) + } + + payload := map[string]any{ + "name": "stored-node-renamed", + "scheme": "http", + "address": host, + "port": port, + "basePath": "/", + "apiToken": "", + "enable": true, + "allowPrivateAddress": true, + } + raw, _ := json.Marshal(payload) + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/panel/api/nodes/update/"+strconv.Itoa(node.Id), strings.NewReader(string(raw))) + req.Header.Set("Content-Type", "application/json") + engine.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("update status = %d body=%s", w.Code, w.Body.String()) + } + + var stored model.Node + if err := database.GetDB().Where("id = ?", node.Id).First(&stored).Error; err != nil { + t.Fatalf("load stored node: %v", err) + } + if stored.ApiToken != "stored-secret-token" { + t.Fatalf("blank update changed token to %q", stored.ApiToken) + } + if stored.Name != "stored-node-renamed" { + t.Fatalf("stored name = %q, want stored-node-renamed", stored.Name) + } +} diff --git a/internal/web/service/node.go b/internal/web/service/node.go index 6fde81f50..52c93c726 100644 --- a/internal/web/service/node.go +++ b/internal/web/service/node.go @@ -336,6 +336,14 @@ func (s *NodeService) GetById(id int) (*model.Node, error) { return n, nil } +func (s *NodeService) GetViewById(id int) (*NodeView, error) { + n, err := s.GetById(id) + if err != nil { + return nil, err + } + return toNodeView(n), nil +} + // NodeExists reports whether a node with the given id exists on this panel. // Used to drop stale, cross-panel node references on inbound import. A Count // query distinguishes "no such node" (count 0, no error) from a real DB error. @@ -424,6 +432,17 @@ func (s *NodeService) Create(n *model.Node) error { return db.Create(n).Error } +func (s *NodeService) CreateFromRequest(req *NodeMutationRequest) (*NodeView, error) { + if err := req.validateCredentials(true); err != nil { + return nil, err + } + n := req.toNode() + if err := s.Create(n); err != nil { + return nil, err + } + return toNodeView(n), nil +} + func (s *NodeService) Update(id int, in *model.Node) error { if err := s.normalize(in); err != nil { return err @@ -467,6 +486,110 @@ func (s *NodeService) Update(id int, in *model.Node) error { return nil } +func (s *NodeService) UpdateFromRequest(id int, req *NodeMutationRequest) error { + if err := req.validateCredentials(false); err != nil { + return err + } + in := req.toNode() + if err := s.normalize(in); err != nil { + return err + } + inboundTagsJSON, err := json.Marshal(in.InboundTags) + if err != nil { + return err + } + db := database.GetDB() + existing := &model.Node{} + if err := db.Where("id = ?", id).First(existing).Error; err != nil { + return err + } + apiToken := existing.ApiToken + switch { + case req.ClearApiToken: + apiToken = "" + case req.ApiToken != nil: + apiToken = *req.ApiToken + } + if apiToken == "" && in.Enable && in.TlsVerifyMode != "mtls" { + return common.NewError("apiToken is required unless mtls is enabled") + } + updates := map[string]any{ + "name": in.Name, + "remark": in.Remark, + "scheme": in.Scheme, + "address": in.Address, + "port": in.Port, + "base_path": in.BasePath, + "api_token": apiToken, + "enable": in.Enable, + "allow_private_address": in.AllowPrivateAddress, + "tls_verify_mode": in.TlsVerifyMode, + "pinned_cert_sha256": in.PinnedCertSha256, + "inbound_sync_mode": in.InboundSyncMode, + "inbound_tags": string(inboundTagsJSON), + "outbound_tag": in.OutboundTag, + } + if err := db.Model(model.Node{}).Where("id = ?", id).Updates(updates).Error; err != nil { + return err + } + if dErr := s.MarkNodeDirty(id); dErr != nil { + logger.Warning("mark node dirty after update failed:", dErr) + } + if mgr := runtime.GetManager(); mgr != nil { + mgr.InvalidateNode(id) + } + return nil +} + +func (s *NodeService) RuntimeNodeFromRequest(id int, req *NodeMutationRequest) (*model.Node, error) { + if err := req.validateCredentials(id == 0); err != nil { + return nil, err + } + var n *model.Node + if id > 0 { + existing, err := s.GetById(id) + if err != nil { + return nil, err + } + n = existing + } else { + n = &model.Node{} + } + overlay := req.toNode() + overlay.Id = id + if req.ApiToken == nil { + overlay.ApiToken = n.ApiToken + } + if req.ClearApiToken { + overlay.ApiToken = "" + } + *n = *overlay + if err := s.normalize(n); err != nil { + return nil, err + } + if n.ApiToken == "" && n.Enable && n.TlsVerifyMode != "mtls" { + return nil, common.NewError("apiToken is required unless mtls is enabled") + } + return n, nil +} + +func (s *NodeService) NodeFromRequestForCertificate(req *NodeMutationRequest) (*model.Node, error) { + if req == nil { + return nil, common.NewError("node request is required") + } + n := req.toNode() + if n.Scheme == "" { + n.Scheme = "https" + } + if n.BasePath == "" { + n.BasePath = "/" + } + if err := s.normalize(n); err != nil { + return nil, err + } + return n, nil +} + func (s *NodeService) GetRemoteInboundOptions(ctx context.Context, n *model.Node) ([]runtime.RemoteInboundOption, error) { if err := s.normalize(n); err != nil { return nil, err diff --git a/internal/web/service/node_contract.go b/internal/web/service/node_contract.go new file mode 100644 index 000000000..b21e615be --- /dev/null +++ b/internal/web/service/node_contract.go @@ -0,0 +1,186 @@ +package service + +import ( + "strings" + + "github.com/mhsanaei/3x-ui/v3/internal/database/model" + "github.com/mhsanaei/3x-ui/v3/internal/util/common" +) + +// NodeView is the browser/API read contract for nodes. Credentials are +// write-only: responses expose only whether a node has a token configured. +type NodeView struct { + Id int `json:"id" example:"1"` + Name string `json:"name" example:"edge-1"` + Remark string `json:"remark" example:"Primary edge"` + Scheme string `json:"scheme" example:"https"` + Address string `json:"address" example:"node.example.com"` + Port int `json:"port" example:"2053"` + BasePath string `json:"basePath" example:"/"` + HasApiToken bool `json:"hasApiToken" example:"true"` + Enable bool `json:"enable" example:"true"` + AllowPrivateAddress bool `json:"allowPrivateAddress" example:"false"` + TlsVerifyMode string `json:"tlsVerifyMode" example:"verify"` + PinnedCertSha256 string `json:"pinnedCertSha256" example:""` + InboundSyncMode string `json:"inboundSyncMode" example:"all"` + InboundTags []string `json:"inboundTags" example:"[\"in-443-tcp\"]"` + OutboundTag string `json:"outboundTag" example:"direct"` + Guid string `json:"guid" example:"node-guid"` + Status string `json:"status" example:"online"` + LastHeartbeat int64 `json:"lastHeartbeat" example:"1700000000"` + 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"` + NetUp uint64 `json:"netUp" example:"2097152"` + NetDown uint64 `json:"netDown" example:"1048576"` + LastError string `json:"lastError" example:""` + XrayState string `json:"xrayState" example:"running"` + XrayError string `json:"xrayError" example:""` + ConfigDirty bool `json:"configDirty" example:"false"` + ConfigDirtyAt int64 `json:"configDirtyAt" example:"0"` + InboundCount int `json:"inboundCount" example:"3"` + ClientCount int `json:"clientCount" example:"25"` + OnlineCount int `json:"onlineCount" example:"5"` + ActiveCount int `json:"activeCount" example:"20"` + DisabledCount int `json:"disabledCount" example:"2"` + DepletedCount int `json:"depletedCount" example:"1"` + ParentGuid string `json:"parentGuid,omitempty" example:""` + Transitive bool `json:"transitive,omitempty" example:"false"` + CreatedAt int64 `json:"createdAt" example:"1700000000"` + UpdatedAt int64 `json:"updatedAt" example:"1700003600"` +} + +func toNodeView(n *model.Node) *NodeView { + if n == nil { + return nil + } + return &NodeView{ + Id: n.Id, + Name: n.Name, + Remark: n.Remark, + Scheme: n.Scheme, + Address: n.Address, + Port: n.Port, + BasePath: n.BasePath, + HasApiToken: n.ApiToken != "", + Enable: n.Enable, + AllowPrivateAddress: n.AllowPrivateAddress, + TlsVerifyMode: n.TlsVerifyMode, + PinnedCertSha256: n.PinnedCertSha256, + InboundSyncMode: n.InboundSyncMode, + InboundTags: n.InboundTags, + OutboundTag: n.OutboundTag, + Guid: n.Guid, + Status: n.Status, + LastHeartbeat: n.LastHeartbeat, + LatencyMs: n.LatencyMs, + XrayVersion: n.XrayVersion, + PanelVersion: n.PanelVersion, + CpuPct: n.CpuPct, + MemPct: n.MemPct, + UptimeSecs: n.UptimeSecs, + NetUp: n.NetUp, + NetDown: n.NetDown, + LastError: n.LastError, + XrayState: n.XrayState, + XrayError: n.XrayError, + ConfigDirty: n.ConfigDirty, + ConfigDirtyAt: n.ConfigDirtyAt, + InboundCount: n.InboundCount, + ClientCount: n.ClientCount, + OnlineCount: n.OnlineCount, + ActiveCount: n.ActiveCount, + DisabledCount: n.DisabledCount, + DepletedCount: n.DepletedCount, + ParentGuid: n.ParentGuid, + Transitive: n.Transitive, + CreatedAt: n.CreatedAt, + UpdatedAt: n.UpdatedAt, + } +} + +func toNodeViews(nodes []*model.Node) []*NodeView { + views := make([]*NodeView, 0, len(nodes)) + for _, node := range nodes { + views = append(views, toNodeView(node)) + } + return views +} + +// NodeMutationRequest is the node write/probe contract. ApiToken is accepted +// only as input. On update, nil means keep the stored token; replacement and +// clearing are explicit and mutually exclusive. +type NodeMutationRequest struct { + Id int `json:"id" form:"id"` + Name string `json:"name" form:"name" validate:"required"` + 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,omitempty" form:"apiToken"` + ClearApiToken bool `json:"clearApiToken,omitempty" form:"clearApiToken"` + Enable bool `json:"enable" form:"enable"` + AllowPrivateAddress bool `json:"allowPrivateAddress" form:"allowPrivateAddress"` + TlsVerifyMode string `json:"tlsVerifyMode" form:"tlsVerifyMode" validate:"omitempty,oneof=verify skip pin mtls"` + PinnedCertSha256 string `json:"pinnedCertSha256" form:"pinnedCertSha256"` + InboundSyncMode string `json:"inboundSyncMode" form:"inboundSyncMode" validate:"omitempty,oneof=all selected"` + InboundTags []string `json:"inboundTags" form:"inboundTags"` + OutboundTag string `json:"outboundTag" form:"outboundTag"` +} + +func (r *NodeMutationRequest) validateCredentials(create bool) error { + if r == nil { + return common.NewError("node request is required") + } + if r.ApiToken != nil && r.ClearApiToken { + return common.NewError("apiToken and clearApiToken are mutually exclusive") + } + if r.ApiToken != nil { + *r.ApiToken = strings.TrimSpace(*r.ApiToken) + if *r.ApiToken == "" { + if create { + return common.NewError("apiToken is required unless mtls is enabled") + } + r.ApiToken = nil + } + } + if create { + if r.ClearApiToken { + return common.NewError("credentials cannot be cleared while creating a node") + } + if r.ApiToken == nil && r.TlsVerifyMode != "mtls" { + return common.NewError("apiToken is required unless mtls is enabled") + } + } + if r.ClearApiToken && r.Enable && r.TlsVerifyMode != "mtls" { + return common.NewError("disable the node or enable mtls before clearing its apiToken") + } + return nil +} + +func (r *NodeMutationRequest) toNode() *model.Node { + n := &model.Node{ + Id: r.Id, + Name: r.Name, + Remark: r.Remark, + Scheme: r.Scheme, + Address: r.Address, + Port: r.Port, + BasePath: r.BasePath, + Enable: r.Enable, + AllowPrivateAddress: r.AllowPrivateAddress, + TlsVerifyMode: r.TlsVerifyMode, + PinnedCertSha256: r.PinnedCertSha256, + InboundSyncMode: r.InboundSyncMode, + InboundTags: r.InboundTags, + OutboundTag: r.OutboundTag, + } + if r.ApiToken != nil { + n.ApiToken = *r.ApiToken + } + return n +} diff --git a/internal/web/service/node_credentials_writeonly_test.go b/internal/web/service/node_credentials_writeonly_test.go new file mode 100644 index 000000000..faccab5c3 --- /dev/null +++ b/internal/web/service/node_credentials_writeonly_test.go @@ -0,0 +1,201 @@ +package service + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/mhsanaei/3x-ui/v3/internal/database" + "github.com/mhsanaei/3x-ui/v3/internal/database/model" +) + +func TestNodeCredentialsNeverMarshal(t *testing.T) { + raw, err := json.Marshal(&model.Node{ + Id: 7, + Name: "node", + ApiToken: "plain-secret-token", + }) + if err != nil { + t.Fatalf("marshal node: %v", err) + } + out := string(raw) + if strings.Contains(out, "plain-secret-token") || strings.Contains(out, "apiToken") { + t.Fatalf("model.Node JSON leaked api token field: %s", out) + } +} + +func TestNodeViewExposesOnlyCredentialPresence(t *testing.T) { + setupConflictDB(t) + + svc := &NodeService{} + reqToken := "write-only-secret" + view, err := svc.CreateFromRequest(&NodeMutationRequest{ + Name: "node-view", + Scheme: "https", + Address: "127.0.0.1", + Port: 2096, + ApiToken: &reqToken, + Enable: true, + }) + if err != nil { + t.Fatalf("create from request: %v", err) + } + if !view.HasApiToken { + t.Fatal("create view should report credential presence") + } + + got, err := svc.GetViewById(view.Id) + if err != nil { + t.Fatalf("get view: %v", err) + } + raw, err := json.Marshal(got) + if err != nil { + t.Fatalf("marshal view: %v", err) + } + out := string(raw) + if !strings.Contains(out, `"hasApiToken":true`) { + t.Fatalf("view does not report credential presence: %s", out) + } + if strings.Contains(out, reqToken) || strings.Contains(out, "apiToken") { + t.Fatalf("NodeView leaked plaintext or apiToken key: %s", out) + } +} + +func TestNodeCredentialMutationSemantics(t *testing.T) { + setupConflictDB(t) + svc := &NodeService{} + + initial := "initial-token" + view, err := svc.CreateFromRequest(&NodeMutationRequest{ + Name: "mut", + Scheme: "https", + Address: "127.0.0.1", + Port: 2096, + ApiToken: &initial, + Enable: true, + }) + if err != nil { + t.Fatalf("create: %v", err) + } + before := rawStoredNodeToken(t, view.Id) + if before != initial { + t.Fatalf("stored token = %q, want %q", before, initial) + } + + if err := svc.UpdateFromRequest(view.Id, &NodeMutationRequest{ + Name: "mut-renamed", + Scheme: "https", + Address: "127.0.0.1", + Port: 2096, + Enable: true, + }); err != nil { + t.Fatalf("keep-token update: %v", err) + } + if after := rawStoredNodeToken(t, view.Id); after != before { + t.Fatalf("omitted token should keep existing token: %q -> %q", before, after) + } + + blank := " " + if err := svc.UpdateFromRequest(view.Id, &NodeMutationRequest{ + Name: "mut-blank", + Scheme: "https", + Address: "127.0.0.1", + Port: 2096, + ApiToken: &blank, + Enable: true, + }); err != nil { + t.Fatalf("blank apiToken should keep existing token on update: %v", err) + } + if afterBlank := rawStoredNodeToken(t, view.Id); afterBlank != before { + t.Fatalf("blank token should keep existing token: %q -> %q", before, afterBlank) + } + + next := "next-token" + if err := svc.UpdateFromRequest(view.Id, &NodeMutationRequest{ + Name: "mut", + Scheme: "https", + Address: "127.0.0.1", + Port: 2096, + ApiToken: &next, + Enable: true, + }); err != nil { + t.Fatalf("replace token: %v", err) + } + if replaced := rawStoredNodeToken(t, view.Id); replaced != next { + t.Fatalf("replace token stored %q, want %q", replaced, next) + } + + if err := svc.UpdateFromRequest(view.Id, &NodeMutationRequest{ + Name: "mut", + Scheme: "https", + Address: "127.0.0.1", + Port: 2096, + ClearApiToken: true, + Enable: true, + }); err == nil { + t.Fatal("enabled non-mtls node must not clear apiToken") + } + if err := svc.UpdateFromRequest(view.Id, &NodeMutationRequest{ + Name: "mut", + Scheme: "https", + Address: "127.0.0.1", + Port: 2096, + ClearApiToken: true, + Enable: false, + }); err != nil { + t.Fatalf("clear disabled token: %v", err) + } + if cleared := rawStoredNodeToken(t, view.Id); cleared != "" { + t.Fatalf("clear token left stored value %q", cleared) + } + + if _, err := svc.CreateFromRequest(&NodeMutationRequest{ + Name: "mtls-only", + Scheme: "https", + Address: "127.0.0.1", + Port: 2097, + Enable: true, + TlsVerifyMode: "mtls", + }); err != nil { + t.Fatalf("mtls create without token: %v", err) + } +} + +func TestNodeUpdateRequiresTokenWhenNoStoredTokenAndMtlsDisabled(t *testing.T) { + setupConflictDB(t) + svc := &NodeService{} + + view, err := svc.CreateFromRequest(&NodeMutationRequest{ + Name: "mtls-empty", + Scheme: "https", + Address: "127.0.0.1", + Port: 2098, + Enable: true, + TlsVerifyMode: "mtls", + }) + if err != nil { + t.Fatalf("create mtls node: %v", err) + } + blank := "" + if err := svc.UpdateFromRequest(view.Id, &NodeMutationRequest{ + Name: "mtls-empty", + Scheme: "https", + Address: "127.0.0.1", + Port: 2098, + ApiToken: &blank, + Enable: true, + BasePath: "/", + OutboundTag: "", + }); err == nil { + t.Fatal("enabled non-mtls node without stored token must be rejected") + } +} + +func rawStoredNodeToken(t *testing.T, id int) string { + t.Helper() + var n model.Node + if err := database.GetDB().Select("api_token").Where("id = ?", id).First(&n).Error; err != nil { + t.Fatalf("load raw node token: %v", err) + } + return n.ApiToken +} diff --git a/internal/web/service/node_tree.go b/internal/web/service/node_tree.go index 36cc73518..ec89ba384 100644 --- a/internal/web/service/node_tree.go +++ b/internal/web/service/node_tree.go @@ -157,6 +157,14 @@ func (s *NodeService) GetNodeTree() ([]*model.Node, error) { return all, nil } +func (s *NodeService) GetNodeTreeView() ([]*NodeView, error) { + nodes, err := s.GetNodeTree() + if err != nil { + return nil, err + } + return toNodeViews(nodes), nil +} + // recountByGuid recomputes InboundCount/OnlineCount/DepletedCount for every node // in the tree, keyed by the GUID that physically hosts each inbound, so a direct // node shows only its own inbounds and each transitive node shows its own diff --git a/internal/web/translation/ar-EG.json b/internal/web/translation/ar-EG.json index cb9ef1d13..14731e50a 100644 --- a/internal/web/translation/ar-EG.json +++ b/internal/web/translation/ar-EG.json @@ -1007,6 +1007,7 @@ "apiToken": "رمز API", "apiTokenPlaceholder": "التوكن من صفحة إعدادات البانل البعيد", "apiTokenHint": "البانل البعيد بيعرض توكن API بتاعه في المصادقة → توكن API.", + "apiTokenKeepHint": "اتركه فارغًا للإبقاء على التوكن الحالي", "regenerate": "تجديد التوكن", "regenerateConfirm": "تجديد التوكن هيلغي التوكن الحالي. أي بانل مركزي بيستخدمه هيفقد الصلاحية لحد ما تحدّث التوكن. تكمّل؟", "allowPrivateAddress": "السماح بالعنوان الخاص", diff --git a/internal/web/translation/en-US.json b/internal/web/translation/en-US.json index 6cbbdf547..b7b3fcc1d 100644 --- a/internal/web/translation/en-US.json +++ b/internal/web/translation/en-US.json @@ -1124,6 +1124,7 @@ "apiToken": "API Token", "apiTokenPlaceholder": "Token from the remote panel's Settings page", "apiTokenHint": "The remote panel exposes its API token under Authentication → API Token.", + "apiTokenKeepHint": "Leave blank to keep the current token", "regenerate": "Regenerate Token", "regenerateConfirm": "Regenerating invalidates the current token. Any central panel using it will lose access until updated. Continue?", "allowPrivateAddress": "Allow private address", diff --git a/internal/web/translation/es-ES.json b/internal/web/translation/es-ES.json index d02c6fbd1..73ed5196d 100644 --- a/internal/web/translation/es-ES.json +++ b/internal/web/translation/es-ES.json @@ -1007,6 +1007,7 @@ "apiToken": "Token API", "apiTokenPlaceholder": "Token desde la página de Configuración del panel remoto", "apiTokenHint": "El panel remoto expone su token de API en Configuraciones de Seguridad → Token de API.", + "apiTokenKeepHint": "Déjalo en blanco para mantener el token actual", "regenerate": "Regenerar token", "regenerateConfirm": "Regenerar invalida el token actual. Cualquier panel central que lo use perderá el acceso hasta que se actualice. ¿Continuar?", "allowPrivateAddress": "Permitir dirección privada", diff --git a/internal/web/translation/fa-IR.json b/internal/web/translation/fa-IR.json index 4f0afade8..968773746 100644 --- a/internal/web/translation/fa-IR.json +++ b/internal/web/translation/fa-IR.json @@ -1007,6 +1007,7 @@ "apiToken": "توکن API", "apiTokenPlaceholder": "توکن از صفحه تنظیمات پنل ریموت", "apiTokenHint": "پنل ریموت توکن API خودش را در بخش احرازهویت → توکن API نمایش می‌دهد.", + "apiTokenKeepHint": "برای حفظ توکن فعلی خالی بگذارید", "regenerate": "تولید مجدد توکن", "regenerateConfirm": "تولید مجدد، توکن فعلی را باطل می‌کند. هر پنل مرکزی‌ای که از این توکن استفاده می‌کند تا زمان به‌روزرسانی، دسترسی‌اش قطع می‌شود. ادامه می‌دهید؟", "allowPrivateAddress": "اجازه آدرس خصوصی", diff --git a/internal/web/translation/id-ID.json b/internal/web/translation/id-ID.json index eb03de17b..d785794bc 100644 --- a/internal/web/translation/id-ID.json +++ b/internal/web/translation/id-ID.json @@ -1007,6 +1007,7 @@ "apiToken": "Token API", "apiTokenPlaceholder": "Token dari halaman Pengaturan panel jarak jauh", "apiTokenHint": "Panel jarak jauh menampilkan token API-nya di Otentikasi → Token API.", + "apiTokenKeepHint": "Biarkan kosong untuk mempertahankan token saat ini", "regenerate": "Buat Ulang Token", "regenerateConfirm": "Membuat ulang akan membatalkan token saat ini. Setiap panel pusat yang menggunakannya akan kehilangan akses sampai diperbarui. Lanjutkan?", "allowPrivateAddress": "Izinkan alamat pribadi", diff --git a/internal/web/translation/ja-JP.json b/internal/web/translation/ja-JP.json index 5db908bb2..374b19331 100644 --- a/internal/web/translation/ja-JP.json +++ b/internal/web/translation/ja-JP.json @@ -1007,6 +1007,7 @@ "apiToken": "API トークン", "apiTokenPlaceholder": "リモートパネルの設定ページから取得したトークン", "apiTokenHint": "リモートパネルでは、セキュリティ設定 → APIトークン でAPIトークンを確認できます。", + "apiTokenKeepHint": "現在のトークンを保持するには空欄のままにします", "regenerate": "トークンを再生成", "regenerateConfirm": "再生成すると現在のトークンは無効になります。これを使用しているすべての中央パネルは更新されるまでアクセスできなくなります。続行しますか?", "allowPrivateAddress": "プライベートアドレスを許可", diff --git a/internal/web/translation/pt-BR.json b/internal/web/translation/pt-BR.json index d37e994dc..ccdedd656 100644 --- a/internal/web/translation/pt-BR.json +++ b/internal/web/translation/pt-BR.json @@ -1007,6 +1007,7 @@ "apiToken": "Token API", "apiTokenPlaceholder": "Token da página de Configurações do painel remoto", "apiTokenHint": "O painel remoto exibe o token da API em Autenticação → Token da API.", + "apiTokenKeepHint": "Deixe em branco para manter o token atual", "regenerate": "Regenerar token", "regenerateConfirm": "Regenerar invalida o token atual. Qualquer painel central que o utilize perderá acesso até ser atualizado. Continuar?", "allowPrivateAddress": "Permitir endereço privado", diff --git a/internal/web/translation/ru-RU.json b/internal/web/translation/ru-RU.json index 0c2819e03..889e25bd8 100644 --- a/internal/web/translation/ru-RU.json +++ b/internal/web/translation/ru-RU.json @@ -1007,6 +1007,7 @@ "apiToken": "API Токен", "apiTokenPlaceholder": "Токен со страницы Настроек удалённой панели", "apiTokenHint": "Удалённая панель показывает свой токен API в разделе Учетная запись → Токен API.", + "apiTokenKeepHint": "Оставьте пустым, чтобы сохранить текущий токен", "regenerate": "Сгенерировать токен заново", "regenerateConfirm": "Повторная генерация аннулирует текущий токен. Любая центральная панель, использующая его, потеряет доступ до обновления. Продолжить?", "allowPrivateAddress": "Разрешить частный адрес", diff --git a/internal/web/translation/tr-TR.json b/internal/web/translation/tr-TR.json index a4cb27321..b519fb167 100644 --- a/internal/web/translation/tr-TR.json +++ b/internal/web/translation/tr-TR.json @@ -1007,6 +1007,7 @@ "apiToken": "API Token", "apiTokenPlaceholder": "Uzak panelin Ayarlar sayfasındaki token", "apiTokenHint": "Uzak panel API token'ını Kimlik Doğrulama → API Token altında gösterir.", + "apiTokenKeepHint": "Mevcut token'ı korumak için boş bırakın", "regenerate": "Token'ı Yeniden Oluştur", "regenerateConfirm": "Yeniden oluşturmak mevcut token'ı geçersiz kılar. Onu kullanan tüm merkezi paneller, güncellenene kadar erişimini kaybeder. Devam edilsin mi?", "allowPrivateAddress": "Özel Adrese İzin Ver", diff --git a/internal/web/translation/uk-UA.json b/internal/web/translation/uk-UA.json index 08b52ba69..ddb3f69c2 100644 --- a/internal/web/translation/uk-UA.json +++ b/internal/web/translation/uk-UA.json @@ -1007,6 +1007,7 @@ "apiToken": "API Токен", "apiTokenPlaceholder": "Токен зі сторінки Налаштувань віддаленої панелі", "apiTokenHint": "Віддалена панель показує свій токен API в Автентифікація → Токен API.", + "apiTokenKeepHint": "Залиште порожнім, щоб зберегти поточний токен", "regenerate": "Перегенерувати токен", "regenerateConfirm": "Перегенерація скасовує поточний токен. Будь-яка центральна панель, що його використовує, втратить доступ до оновлення. Продовжити?", "allowPrivateAddress": "Дозволити приватну адресу", diff --git a/internal/web/translation/vi-VN.json b/internal/web/translation/vi-VN.json index 9b48701be..7984ec201 100644 --- a/internal/web/translation/vi-VN.json +++ b/internal/web/translation/vi-VN.json @@ -1007,6 +1007,7 @@ "apiToken": "Token API", "apiTokenPlaceholder": "Token từ trang Cài đặt của panel từ xa", "apiTokenHint": "Panel từ xa hiển thị token API tại Bảo mật → Token API.", + "apiTokenKeepHint": "Để trống để giữ token hiện tại", "regenerate": "Tạo lại token", "regenerateConfirm": "Tạo lại sẽ vô hiệu hóa token hiện tại. Mọi panel trung tâm dùng nó sẽ mất quyền truy cập cho đến khi được cập nhật. Tiếp tục?", "allowPrivateAddress": "Cho phép địa chỉ riêng", diff --git a/internal/web/translation/zh-CN.json b/internal/web/translation/zh-CN.json index 3ad4673e5..9bcafa414 100644 --- a/internal/web/translation/zh-CN.json +++ b/internal/web/translation/zh-CN.json @@ -1007,6 +1007,7 @@ "apiToken": "API 令牌", "apiTokenPlaceholder": "远程面板设置页中的令牌", "apiTokenHint": "远程面板在 安全设定 → API 令牌 中显示其 API 令牌。", + "apiTokenKeepHint": "留空以保留当前令牌", "regenerate": "重新生成令牌", "regenerateConfirm": "重新生成会使当前令牌失效。任何使用该令牌的中央面板都会失去访问权限,直至更新。是否继续?", "allowPrivateAddress": "允许私有地址", diff --git a/internal/web/translation/zh-TW.json b/internal/web/translation/zh-TW.json index ebd8e1b5d..72c519445 100644 --- a/internal/web/translation/zh-TW.json +++ b/internal/web/translation/zh-TW.json @@ -1007,6 +1007,7 @@ "apiToken": "API 權杖", "apiTokenPlaceholder": "遠端面板設定頁中的權杖", "apiTokenHint": "遠端面板在 安全設定 → API 權杖 中顯示其 API 權杖。", + "apiTokenKeepHint": "留空以保留目前的權杖", "regenerate": "重新產生權杖", "regenerateConfirm": "重新產生會使目前的權杖失效。任何使用該權杖的中央面板將失去存取權,直到更新為止。是否繼續?", "allowPrivateAddress": "允許私有地址", diff --git a/tools/openapigen/main.go b/tools/openapigen/main.go index c1dd2c6cd..916e8bece 100644 --- a/tools/openapigen/main.go +++ b/tools/openapigen/main.go @@ -78,6 +78,8 @@ func run(root, outDir string) error { Path: resolveRel(root, "internal/web/service"), StructAllow: setOf( "InboundOption", + "NodeMutationRequest", + "NodeView", "ProbeResultUI", "RealityScanResult", ),