diff --git a/src/shadowbox/server/api.yml b/src/shadowbox/server/api.yml index c5a28fad..a4bf1ba8 100644 --- a/src/shadowbox/server/api.yml +++ b/src/shadowbox/server/api.yml @@ -786,3 +786,12 @@ components: items: type: string enum: ['tcp', 'udp', 'websocket-stream', 'websocket-packet'] + 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. + additionalProperties: true diff --git a/src/shadowbox/server/manager_service.ts b/src/shadowbox/server/manager_service.ts index 9b835e85..758d105c 100644 --- a/src/shadowbox/server/manager_service.ts +++ b/src/shadowbox/server/manager_service.ts @@ -42,33 +42,10 @@ interface AccessKeyJson { dataLimit: DataLimit; accessUrl: string; listeners?: ListenerType[]; -} - -// Creates a AccessKey response. -function accessKeyToApiJson(accessKey: AccessKey): AccessKeyJson { - 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( - makeConfig({ - host: accessKey.proxyParams.hostname, - port: accessKey.proxyParams.portNumber, - method: accessKey.proxyParams.encryptionMethod, - password: accessKey.proxyParams.password, - outline: 1, - }) - ), - }; - - if (accessKey.listeners) { - result.listeners = accessKey.listeners; - } - - return result; + // 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. + // Compatible with Outline Client v1.15.0+. + dynamicConfig?: Record; } // Type to reflect that we receive untyped JSON request parameters. @@ -290,6 +267,73 @@ export class ShadowsocksManagerService { private readonly caddyServer?: OutlineCaddyController ) {} + // Creates an AccessKey API response JSON object. + // For WebSocket-enabled keys, includes the dynamicConfig object. + private accessKeyToApiJson(accessKey: AccessKey): AccessKeyJson { + 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( + makeConfig({ + host: accessKey.proxyParams.hostname, + port: accessKey.proxyParams.portNumber, + method: accessKey.proxyParams.encryptionMethod, + password: accessKey.proxyParams.password, + outline: 1, + }) + ), + }; + + 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'); + + if (hasWebSocketListeners) { + const configData = this.serverConfig.data(); + const domain = configData?.caddyWebServer?.domain || configData?.hostname; + + 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; + }; + + 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; + } + } + } + } + + return result; + } + renameServer(req: RequestType, res: ResponseType, next: restify.Next): void { logging.debug(`renameServer request ${JSON.stringify(req.params)}`); const name = req.params.name; @@ -447,7 +491,7 @@ export class ShadowsocksManagerService { } // Return JSON for traditional keys - const accessKeyJson = accessKeyToApiJson(accessKey); + const accessKeyJson = this.accessKeyToApiJson(accessKey); logging.debug(`getAccessKey response ${JSON.stringify(accessKeyJson)}`); res.send(HttpSuccess.OK, accessKeyJson); return next(); @@ -465,7 +509,7 @@ export class ShadowsocksManagerService { logging.debug(`listAccessKeys request ${JSON.stringify(req.params)}`); const response = {accessKeys: []}; for (const accessKey of this.accessKeys.listAccessKeys()) { - response.accessKeys.push(accessKeyToApiJson(accessKey)); + response.accessKeys.push(this.accessKeyToApiJson(accessKey)); } logging.debug(`listAccessKeys response ${JSON.stringify(response)}`); res.send(HttpSuccess.OK, response); @@ -513,7 +557,7 @@ export class ShadowsocksManagerService { } } - const accessKeyJson = accessKeyToApiJson( + const accessKeyJson = this.accessKeyToApiJson( await this.accessKeys.createNewAccessKey({ encryptionMethod, id, diff --git a/src/shadowbox/server/outline_shadowsocks_server.ts b/src/shadowbox/server/outline_shadowsocks_server.ts index 0c25872d..40340d2b 100644 --- a/src/shadowbox/server/outline_shadowsocks_server.ts +++ b/src/shadowbox/server/outline_shadowsocks_server.ts @@ -20,7 +20,11 @@ import * as path from 'path'; import * as file from '../infrastructure/file'; import * as logging from '../infrastructure/logging'; import {ListenerType} from '../model/access_key'; -import {ListenerSettings, ShadowsocksAccessKey, ShadowsocksServer} from '../model/shadowsocks_server'; +import { + ListenerSettings, + ShadowsocksAccessKey, + ShadowsocksServer, +} from '../model/shadowsocks_server'; // Extended interface for access keys with listeners export interface ShadowsocksAccessKeyWithListeners extends ShadowsocksAccessKey { @@ -132,12 +136,8 @@ export class OutlineShadowsocksServer implements ShadowsocksServer { return this; } - const stream = listeners.websocketStream - ? {...listeners.websocketStream} - : undefined; - const packet = listeners.websocketPacket - ? {...listeners.websocketPacket} - : undefined; + const stream = listeners.websocketStream ? {...listeners.websocketStream} : undefined; + const packet = listeners.websocketPacket ? {...listeners.websocketPacket} : undefined; // If only one listener specifies the web server port, share it across both listeners. const sharedPort = stream?.webServerPort ?? packet?.webServerPort; @@ -173,26 +173,26 @@ export class OutlineShadowsocksServer implements ShadowsocksServer { return new Promise((resolve, reject) => { // Check if any key has WebSocket listeners const extendedKeys = keys as ShadowsocksAccessKeyWithListeners[]; - + // Debug logging logging.info(`Writing config for ${keys.length} keys`); - extendedKeys.forEach(key => { + extendedKeys.forEach((key) => { if (key.listeners) { logging.info(`Key ${key.id} has listeners: ${JSON.stringify(key.listeners)}`); } }); - - const hasWebSocketKeys = extendedKeys.some(key => - key.listeners && ( - key.listeners.indexOf('websocket-stream') !== -1 || - key.listeners.indexOf('websocket-packet') !== -1 - ) + + const hasWebSocketKeys = extendedKeys.some( + (key) => + key.listeners && + (key.listeners.indexOf('websocket-stream') !== -1 || + key.listeners.indexOf('websocket-packet') !== -1) ); - + logging.info(`WebSocket keys detected: ${hasWebSocketKeys}`); - + let config: ServerConfig; - + if (hasWebSocketKeys) { // Use new format with WebSocket support config = this.generateWebSocketConfig(extendedKeys); @@ -236,12 +236,8 @@ export class OutlineShadowsocksServer implements ShadowsocksServer { const webServerId = 'outline-ws-server'; type ListenerDescriptor = WebSocketListener | TcpUdpListener; - const isWebSocketListener = ( - listener: ListenerDescriptor - ): listener is WebSocketListener => { - return ( - listener.type === 'websocket-stream' || listener.type === 'websocket-packet' - ); + const isWebSocketListener = (listener: ListenerDescriptor): listener is WebSocketListener => { + return listener.type === 'websocket-stream' || listener.type === 'websocket-packet'; }; interface ServiceGroup { @@ -329,8 +325,7 @@ export class OutlineShadowsocksServer implements ShadowsocksServer { const needsWebServer = Array.from(serviceGroups.values()).some((group) => group.listeners.some( - (listener) => - listener.type === 'websocket-stream' || listener.type === 'websocket-packet' + (listener) => listener.type === 'websocket-stream' || listener.type === 'websocket-packet' ) ); @@ -361,48 +356,51 @@ export class OutlineShadowsocksServer implements ShadowsocksServer { } /** - * Generates dynamic access key YAML content for a specific access key with WebSocket support. + * Generates dynamic access key configuration as a JSON object for WebSocket-enabled keys. + * This object can be serialized to YAML for use with Outline Client v1.15.0+. * @param proxyParams The proxy parameters containing cipher and password * @param domain The WebSocket server domain * @param tcpPath The path for TCP over WebSocket * @param udpPath The path for UDP over WebSocket * @param tls Whether to use TLS (wss) or not (ws) - * @returns The YAML content as a string + * @param listeners Optional list of listener types to include + * @returns The configuration object, or null if no WebSocket listeners */ - generateDynamicAccessKeyYaml( + generateDynamicAccessKeyConfig( proxyParams: {encryptionMethod: string; password: string}, domain: string, tcpPath: string, udpPath: string, tls: boolean, listeners?: ListenerType[] - ): string | null { + ): Record | null { if (!domain) { return null; } - const listenerSet = new Set(listeners ?? ['websocket-stream', 'websocket-packet']); + const listenerSet = new Set( + listeners ?? ['websocket-stream', 'websocket-packet'] + ); const includeStream = listenerSet.has('websocket-stream'); const includePacket = listenerSet.has('websocket-packet'); if (!includeStream && !includePacket) { - logging.warn('Dynamic access key requested without WebSocket listeners; skipping YAML output.'); + logging.warn('Dynamic access key config requested without WebSocket listeners; skipping.'); return null; } const protocol = tls ? 'wss' : 'ws'; - const transportType = - includeStream && includePacket ? 'tcpudp' : includeStream ? 'tcp' : 'udp'; + const transportType = includeStream && includePacket ? 'tcpudp' : includeStream ? 'tcp' : 'udp'; const transport: Record = { - '$type': transportType, + $type: transportType, }; if (includeStream) { transport['tcp'] = { - '$type': 'shadowsocks', + $type: 'shadowsocks', endpoint: { - '$type': 'websocket', + $type: 'websocket', url: `${protocol}://${domain}${tcpPath}`, }, cipher: proxyParams.encryptionMethod, @@ -412,9 +410,9 @@ export class OutlineShadowsocksServer implements ShadowsocksServer { if (includePacket) { transport['udp'] = { - '$type': 'shadowsocks', + $type: 'shadowsocks', endpoint: { - '$type': 'websocket', + $type: 'websocket', url: `${protocol}://${domain}${udpPath}`, }, cipher: proxyParams.encryptionMethod, @@ -422,19 +420,49 @@ export class OutlineShadowsocksServer implements ShadowsocksServer { }; } - const config = { - transport, - }; + return {transport}; + } + + /** + * Generates dynamic access key YAML content for a specific access key with WebSocket support. + * @param proxyParams The proxy parameters containing cipher and password + * @param domain The WebSocket server domain + * @param tcpPath The path for TCP over WebSocket + * @param udpPath The path for UDP over WebSocket + * @param tls Whether to use TLS (wss) or not (ws) + * @param listeners Optional list of listener types to include + * @returns The YAML content as a string, or null if no WebSocket listeners + */ + generateDynamicAccessKeyYaml( + proxyParams: {encryptionMethod: string; password: string}, + domain: string, + tcpPath: string, + udpPath: string, + tls: boolean, + listeners?: ListenerType[] + ): string | null { + const config = this.generateDynamicAccessKeyConfig( + proxyParams, + domain, + tcpPath, + udpPath, + tls, + listeners + ); + + if (!config) { + return null; + } // Use specific YAML options to ensure proper formatting return jsyaml.dump(config, { indent: 2, - lineWidth: -1, // Don't wrap long lines - noRefs: true, // Don't use references + lineWidth: -1, // Don't wrap long lines + noRefs: true, // Don't use references sortKeys: false, // Preserve key order styles: { - '!!null': 'canonical' // Use ~ for null values - } + '!!null': 'canonical', // Use ~ for null values + }, }); }