From a41c796e8a5c7a14235aef74ae63be33efe52d2c Mon Sep 17 00:00:00 2001 From: lunarthegrey Date: Thu, 11 Dec 2025 14:18:00 -0600 Subject: [PATCH] Make Shadowsocks-specific access key fields optional and conditionally include them in the API response based on listener types. --- src/shadowbox/server/api.yml | 28 ++++-- src/shadowbox/server/manager_service.ts | 109 ++++++++++++++---------- 2 files changed, 87 insertions(+), 50 deletions(-) diff --git a/src/shadowbox/server/api.yml b/src/shadowbox/server/api.yml index a4bf1ba8..097b2546 100644 --- a/src/shadowbox/server/api.yml +++ b/src/shadowbox/server/api.yml @@ -771,27 +771,45 @@ components: properties: id: type: string + description: Unique identifier for this access key. name: type: string + description: User-assigned name for this access key. password: type: string + description: | + Shadowsocks password. Only present when the key has TCP or UDP listeners. + For WebSocket-only keys, the secret is in dynamicConfig. port: type: integer + description: | + Port number for direct Shadowsocks connections. Only present when the + key has TCP or UDP listeners. method: type: string + description: | + Encryption method (cipher). Only present when the key has TCP or UDP listeners. + For WebSocket-only keys, the cipher is in dynamicConfig. + dataLimit: + $ref: "#/components/schemas/DataLimit" + description: Optional data transfer limit for this key. accessUrl: type: string + description: | + SIP002-formatted ss:// URL for direct Shadowsocks connections. + Only present when the key has TCP or UDP listeners enabled. + For WebSocket-only keys, use dynamicConfig instead. listeners: type: array items: type: string enum: ['tcp', 'udp', 'websocket-stream', 'websocket-packet'] + description: List of enabled listener types for this key. dynamicConfig: type: object description: | - For WebSocket-enabled keys (requires Outline Client v1.15.0+), contains the - dynamic access configuration as a JSON object. This should be converted to YAML - and hosted on a censorship-resistant platform (e.g., S3, Google Drive) for - distribution via ssconf:// URLs. Only present when the key has websocket-stream - or websocket-packet listeners and the server has a configured domain. + Dynamic access configuration for WebSocket transport (Outline Client v1.15.0+). + Only present when the key has websocket-stream or websocket-packet listeners. + Contains the complete transport configuration including cipher and secret. additionalProperties: true + diff --git a/src/shadowbox/server/manager_service.ts b/src/shadowbox/server/manager_service.ts index 758d105c..cc2fb9c9 100644 --- a/src/shadowbox/server/manager_service.ts +++ b/src/shadowbox/server/manager_service.ts @@ -36,11 +36,13 @@ interface AccessKeyJson { // Admin-controlled, editable name for this access key. name: string; // Shadowsocks-specific details and credentials. - password: string; - port: number; - method: string; - dataLimit: DataLimit; - accessUrl: string; + // These fields are only present when the key has TCP or UDP listeners. + // For WSS-only keys, this information is in dynamicConfig instead. + password?: string; + port?: number; + method?: string; + dataLimit?: DataLimit; + accessUrl?: string; listeners?: ListenerType[]; // For WebSocket-enabled keys, the dynamic access configuration as a JSON object. // This can be converted to YAML and hosted on a censorship-resistant platform. @@ -268,16 +270,31 @@ export class ShadowsocksManagerService { ) {} // Creates an AccessKey API response JSON object. - // For WebSocket-enabled keys, includes the dynamicConfig object. + // For WSS-only keys, omits SS-specific fields (password, port, method, accessUrl) + // since they're redundant with dynamicConfig and not functional for direct connections. private accessKeyToApiJson(accessKey: AccessKey): AccessKeyJson { + // Determine what types of listeners are enabled + const hasDirectListeners = + !accessKey.listeners || + accessKey.listeners.includes('tcp') || + accessKey.listeners.includes('udp'); + + const hasWebSocketListeners = + accessKey.listeners?.includes('websocket-stream') || + accessKey.listeners?.includes('websocket-packet'); + + // Start with fields that are always present const result: AccessKeyJson = { id: accessKey.id, name: accessKey.name, - password: accessKey.proxyParams.password, - port: accessKey.proxyParams.portNumber, - method: accessKey.proxyParams.encryptionMethod, - dataLimit: accessKey.dataLimit, - accessUrl: SIP002_URI.stringify( + }; + + // Only include SS-specific fields if direct SS connections are supported + if (hasDirectListeners) { + result.password = accessKey.proxyParams.password; + result.port = accessKey.proxyParams.portNumber; + result.method = accessKey.proxyParams.encryptionMethod; + result.accessUrl = SIP002_URI.stringify( makeConfig({ host: accessKey.proxyParams.hostname, port: accessKey.proxyParams.portNumber, @@ -285,48 +302,50 @@ export class ShadowsocksManagerService { password: accessKey.proxyParams.password, outline: 1, }) - ), - }; + ); + } + // dataLimit applies regardless of transport type + if (accessKey.dataLimit) { + result.dataLimit = accessKey.dataLimit; + } + + // Include listeners if present if (accessKey.listeners) { result.listeners = accessKey.listeners; + } - // For WebSocket-enabled keys, include the dynamic config as a JSON object - const hasWebSocketListeners = - accessKey.listeners.includes('websocket-stream') || - accessKey.listeners.includes('websocket-packet'); + // For WebSocket-enabled keys, include the dynamic config as a JSON object + if (hasWebSocketListeners) { + const configData = this.serverConfig.data(); + const domain = configData?.caddyWebServer?.domain || configData?.hostname; - if (hasWebSocketListeners) { - const configData = this.serverConfig.data(); - const domain = configData?.caddyWebServer?.domain || configData?.hostname; + if (domain) { + const listenersConfig = configData?.listenersForNewAccessKeys; - if (domain) { - const listenersConfig = configData?.listenersForNewAccessKeys; + // Cast to access generateDynamicAccessKeyConfig method + const serverWithDynamicConfig = this.shadowsocksServer as ShadowsocksServer & { + generateDynamicAccessKeyConfig?: ( + proxyParams: {encryptionMethod: string; password: string}, + domain: string, + tcpPath: string, + udpPath: string, + tls: boolean, + listeners?: ListenerType[] + ) => Record | null; + }; - // Cast to access generateDynamicAccessKeyConfig method - const serverWithDynamicConfig = this.shadowsocksServer as ShadowsocksServer & { - generateDynamicAccessKeyConfig?: ( - proxyParams: {encryptionMethod: string; password: string}, - domain: string, - tcpPath: string, - udpPath: string, - tls: boolean, - listeners?: ListenerType[] - ) => Record | null; - }; + const dynamicConfig = serverWithDynamicConfig.generateDynamicAccessKeyConfig?.( + accessKey.proxyParams, + domain, + listenersConfig?.websocketStream?.path || '/tcp', + listenersConfig?.websocketPacket?.path || '/udp', + configData?.caddyWebServer?.autoHttps !== false, + accessKey.listeners + ); - const dynamicConfig = serverWithDynamicConfig.generateDynamicAccessKeyConfig?.( - accessKey.proxyParams, - domain, - listenersConfig?.websocketStream?.path || '/tcp', - listenersConfig?.websocketPacket?.path || '/udp', - configData?.caddyWebServer?.autoHttps !== false, - accessKey.listeners - ); - - if (dynamicConfig) { - result.dynamicConfig = dynamicConfig; - } + if (dynamicConfig) { + result.dynamicConfig = dynamicConfig; } } }