From 25678f7122b89f0ae6b9ddfad30acb3ed64cdcb0 Mon Sep 17 00:00:00 2001 From: lunarthegrey Date: Sat, 26 Jul 2025 20:31:13 -0500 Subject: [PATCH] Add support for SS over WSS into shadowbox --- .github/workflows/build-shadowbox.yml | 169 +++++++++++++++ src/shadowbox/model/access_key.ts | 18 ++ src/shadowbox/server/api.yml | 70 +++++++ src/shadowbox/server/main.ts | 5 + src/shadowbox/server/manager_service.ts | 113 +++++++++- .../server/outline_shadowsocks_server.ts | 198 +++++++++++++++++- src/shadowbox/server/server_access_key.ts | 24 ++- 7 files changed, 581 insertions(+), 16 deletions(-) create mode 100644 .github/workflows/build-shadowbox.yml diff --git a/.github/workflows/build-shadowbox.yml b/.github/workflows/build-shadowbox.yml new file mode 100644 index 00000000..363214dd --- /dev/null +++ b/.github/workflows/build-shadowbox.yml @@ -0,0 +1,169 @@ +name: Build and Push Shadowbox Docker Image + +on: + push: + branches: + - '**' # Build on push to any branch + paths: + - 'src/shadowbox/**' + - '.github/workflows/build-shadowbox.yml' + pull_request: + paths: + - 'src/shadowbox/**' + workflow_dispatch: + inputs: + tag_suffix: + description: 'Tag suffix for the Docker image (e.g., "wss-test")' + required: false + default: '' + +env: + REGISTRY: ghcr.io + IMAGE_NAME: outline/shadowbox + +jobs: + build: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + strategy: + matrix: + platform: + - linux/amd64 + - linux/arm64 + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GitHub Container Registry + if: github.event_name != 'pull_request' + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ github.repository_owner }}/${{ env.IMAGE_NAME }} + tags: | + # Branch name + type=ref,event=branch + # Tag name + type=ref,event=tag + # PR number + type=ref,event=pr + # Latest tag for main/master branch + type=raw,value=latest,enable={{is_default_branch}} + # SHA short + type=sha,prefix={{branch}}- + # Custom suffix if provided + type=raw,value={{branch}}-${{ github.event.inputs.tag_suffix }},enable=${{ github.event.inputs.tag_suffix != '' }} + # WSS-specific tags + type=raw,value=wss-latest,enable=${{ startsWith(github.ref, 'refs/heads/wss-') }} + type=raw,value=wss-{{branch}},enable=${{ startsWith(github.ref, 'refs/heads/wss-') }} + + - name: Determine target architecture + id: arch + run: | + if [[ "${{ matrix.platform }}" == "linux/amd64" ]]; then + echo "node_image=node@sha256:a0b787b0d53feacfa6d606fb555e0dbfebab30573277f1fe25148b05b66fa097" >> $GITHUB_OUTPUT + echo "target_arch=x86_64" >> $GITHUB_OUTPUT + else + echo "node_image=node@sha256:b4b7a1dd149c65ee6025956ac065a843b4409a62068bd2b0cbafbb30ca2fab3b" >> $GITHUB_OUTPUT + echo "target_arch=arm64" >> $GITHUB_OUTPUT + fi + + - name: Build application + working-directory: ./src/shadowbox + run: | + # Install Node.js + curl -fsSL https://deb.nodesource.com/setup_16.x | sudo -E bash - + sudo apt-get install -y nodejs + + # Install dependencies and build + npm ci + npm run action:build -- \ + --platform ${{ matrix.platform == 'linux/amd64' && 'linux' || 'linux' }} + + - name: Prepare Docker build context + working-directory: ./src/shadowbox + run: | + # Create image root directory + IMAGE_ROOT="build/image_root" + rm -rf "${IMAGE_ROOT}" + mkdir -p "${IMAGE_ROOT}/opt/outline-server" + + # Copy built application + cp -R build/linux/${{ steps.arch.outputs.target_arch }}/* "${IMAGE_ROOT}/opt/outline-server/" + + # Copy scripts + cp -R scripts "${IMAGE_ROOT}/scripts" + cp -R docker/* "${IMAGE_ROOT}/" + + - name: Build and push Docker image + uses: docker/build-push-action@v5 + with: + context: ./src/shadowbox/build/image_root + file: ./src/shadowbox/build/image_root/Dockerfile + platforms: ${{ matrix.platform }} + push: ${{ github.event_name != 'pull_request' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + build-args: | + NODE_IMAGE=${{ steps.arch.outputs.node_image }} + VERSION=${{ github.sha }} + cache-from: type=gha + cache-to: type=gha,mode=max + + create-manifest: + needs: build + runs-on: ubuntu-latest + if: github.event_name != 'pull_request' + permissions: + contents: read + packages: write + + steps: + - name: Log in to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ github.repository_owner }}/${{ env.IMAGE_NAME }} + tags: | + type=ref,event=branch + type=ref,event=tag + type=ref,event=pr + type=raw,value=latest,enable={{is_default_branch}} + type=sha,prefix={{branch}}- + type=raw,value={{branch}}-${{ github.event.inputs.tag_suffix }},enable=${{ github.event.inputs.tag_suffix != '' }} + type=raw,value=wss-latest,enable=${{ startsWith(github.ref, 'refs/heads/wss-') }} + type=raw,value=wss-{{branch}},enable=${{ startsWith(github.ref, 'refs/heads/wss-') }} + + - name: Create and push manifest + run: | + TAGS="${{ steps.meta.outputs.tags }}" + for TAG in $TAGS; do + docker manifest create $TAG \ + $TAG-linux-amd64 \ + $TAG-linux-arm64 + docker manifest push $TAG + done \ No newline at end of file diff --git a/src/shadowbox/model/access_key.ts b/src/shadowbox/model/access_key.ts index 5ed57b18..390244b5 100644 --- a/src/shadowbox/model/access_key.ts +++ b/src/shadowbox/model/access_key.ts @@ -14,6 +14,20 @@ export type AccessKeyId = string; +// WebSocket configuration for Shadowsocks over WebSocket transport +export interface WebSocketConfig { + // Whether WebSocket transport is enabled + readonly enabled: boolean; + // Path for TCP over WebSocket + readonly tcpPath?: string; + // Path for UDP over WebSocket + readonly udpPath?: string; + // WebSocket server domain + readonly domain?: string; + // Whether to use TLS for WebSocket connections + readonly tls?: boolean; +} + // Parameters needed to access a Shadowsocks proxy. export interface ProxyParams { // Hostname of the proxy @@ -43,6 +57,8 @@ export interface AccessKey { readonly reachedDataLimit: boolean; // The key's current data limit. If it exists, it overrides the server default data limit. readonly dataLimit?: DataLimit; + // WebSocket configuration for this access key + readonly websocket?: WebSocketConfig; } export interface AccessKeyCreateParams { @@ -58,6 +74,8 @@ export interface AccessKeyCreateParams { readonly dataLimit?: DataLimit; // The port number to use for the access key. readonly portNumber?: number; + // WebSocket configuration for the access key. + readonly websocket?: WebSocketConfig; } export interface AccessKeyRepository { diff --git a/src/shadowbox/server/api.yml b/src/shadowbox/server/api.yml index 2532d51c..69a48884 100644 --- a/src/shadowbox/server/api.yml +++ b/src/shadowbox/server/api.yml @@ -278,6 +278,8 @@ paths: type: integer limit: $ref: "#/components/schemas/DataLimit" + websocket: + $ref: "#/components/schemas/WebSocketConfig" examples: 'No params specified': value: '{"method":"aes-192-gcm"}' @@ -349,6 +351,8 @@ paths: type: integer limit: $ref: "#/components/schemas/DataLimit" + websocket: + $ref: "#/components/schemas/WebSocketConfig" examples: '0': value: '{"method":"aes-192-gcm","name":"First","password":"8iu8V8EeoFVpwQvQeS9wiD","port": 12345,"limit":{"bytes":10000}}' @@ -504,6 +508,48 @@ paths: description: Access key limit deleted successfully. '404': description: Access key inexistent + /access-keys/{id}/dynamic-config: + get: + description: Returns the dynamic access key configuration YAML for WebSocket transport + tags: + - Access Key + parameters: + - name: id + in: path + required: true + description: The id of the access key + schema: + type: string + responses: + '200': + description: Dynamic access key configuration + content: + text/yaml: + schema: + type: string + examples: + '0': + value: | + transport: + $type: tcpudp + tcp: + $type: shadowsocks + endpoint: + $type: websocket + url: wss://example.com/tcp + cipher: chacha20-ietf-poly1305 + secret: XxXxXx + udp: + $type: shadowsocks + endpoint: + $type: websocket + url: wss://example.com/udp + cipher: chacha20-ietf-poly1305 + secret: XxXxXx + '404': + description: Access key not found or WebSocket not enabled + '501': + description: WebSocket support not configured for this access key /metrics/transfer: get: description: Returns the data transferred per access key @@ -617,6 +663,25 @@ components: type: integer minimum: 0 + WebSocketConfig: + properties: + enabled: + type: boolean + description: Whether WebSocket transport is enabled for this access key + tcpPath: + type: string + description: Path for TCP over WebSocket (e.g., "/tcp-path") + udpPath: + type: string + description: Path for UDP over WebSocket (e.g., "/udp-path") + domain: + type: string + description: WebSocket server domain (e.g., "example.com") + tls: + type: boolean + description: Whether to use TLS for WebSocket connections (wss:// vs ws://) + default: true + AccessKey: required: - id @@ -633,3 +698,8 @@ components: type: string accessUrl: type: string + websocket: + $ref: "#/components/schemas/WebSocketConfig" + dynamicAccessKeyUrl: + type: string + description: URL to dynamic access key configuration for WebSocket transport diff --git a/src/shadowbox/server/main.ts b/src/shadowbox/server/main.ts index 533f9f43..2ff44973 100644 --- a/src/shadowbox/server/main.ts +++ b/src/shadowbox/server/main.ts @@ -162,6 +162,11 @@ async function main() { if (fs.existsSync(MMDB_LOCATION_ASN)) { shadowsocksServer.configureAsnMetrics(MMDB_LOCATION_ASN); } + + // Configure WebSocket support if enabled + // TODO: Make this configurable via environment variable or server config + const webSocketPort = 8080; // Default internal WebSocket server port + shadowsocksServer.configureWebSocket(webSocketPort); const isReplayProtectionEnabled = createRolloutTracker(serverConfig).isRolloutEnabled( 'replay-protection', diff --git a/src/shadowbox/server/manager_service.ts b/src/shadowbox/server/manager_service.ts index 91bd25f4..ab94225b 100644 --- a/src/shadowbox/server/manager_service.ts +++ b/src/shadowbox/server/manager_service.ts @@ -20,7 +20,7 @@ import {makeConfig, SIP002_URI} from 'outline-shadowsocksconfig'; import {JsonConfig} from '../infrastructure/json_config'; import * as logging from '../infrastructure/logging'; -import {AccessKey, AccessKeyRepository, DataLimit} from '../model/access_key'; +import {AccessKey, AccessKeyRepository, DataLimit, WebSocketConfig} from '../model/access_key'; import * as errors from '../model/errors'; import * as version from './version'; @@ -40,11 +40,13 @@ interface AccessKeyJson { method: string; dataLimit: DataLimit; accessUrl: string; + websocket?: WebSocketConfig; + dynamicAccessKeyUrl?: string; } // Creates a AccessKey response. function accessKeyToApiJson(accessKey: AccessKey): AccessKeyJson { - return { + const result: AccessKeyJson = { id: accessKey.id, name: accessKey.name, password: accessKey.proxyParams.password, @@ -61,6 +63,17 @@ function accessKeyToApiJson(accessKey: AccessKey): AccessKeyJson { }) ), }; + + if (accessKey.websocket) { + result.websocket = accessKey.websocket; + // Generate dynamic access key URL if WebSocket is enabled + if (accessKey.websocket.enabled && accessKey.websocket.domain) { + // This URL would typically point to where the dynamic access key YAML is hosted + result.dynamicAccessKeyUrl = `https://${accessKey.websocket.domain}/access-keys/${accessKey.id}.yaml`; + } + } + + return result; } // Type to reflect that we receive untyped JSON request parameters. @@ -164,6 +177,10 @@ export function bindService( `${apiPrefix}/access-keys/:id/data-limit`, service.removeAccessKeyDataLimit.bind(service) ); + apiServer.get( + `${apiPrefix}/access-keys/:id/dynamic-config`, + service.getDynamicAccessKeyConfig.bind(service) + ); apiServer.get(`${apiPrefix}/metrics/transfer`, service.getDataUsage.bind(service)); apiServer.get(`${apiPrefix}/metrics/enabled`, service.getShareMetrics.bind(service)); @@ -263,6 +280,63 @@ function validateNumberParam(param: unknown, paramName: string): number | undefi return param; } +function validateWebSocketConfig(websocket: unknown): WebSocketConfig | undefined { + if (typeof websocket === 'undefined') { + return undefined; + } + + if (typeof websocket !== 'object' || websocket === null) { + throw new restifyErrors.InvalidArgumentError( + {statusCode: 400}, + 'WebSocket configuration must be an object' + ); + } + + const config = websocket as any; + + // Validate enabled field + if ('enabled' in config && typeof config.enabled !== 'boolean') { + throw new restifyErrors.InvalidArgumentError( + {statusCode: 400}, + 'websocket.enabled must be a boolean' + ); + } + + // Validate tcpPath + if ('tcpPath' in config && typeof config.tcpPath !== 'string') { + throw new restifyErrors.InvalidArgumentError( + {statusCode: 400}, + 'websocket.tcpPath must be a string' + ); + } + + // Validate udpPath + if ('udpPath' in config && typeof config.udpPath !== 'string') { + throw new restifyErrors.InvalidArgumentError( + {statusCode: 400}, + 'websocket.udpPath must be a string' + ); + } + + // Validate domain + if ('domain' in config && typeof config.domain !== 'string') { + throw new restifyErrors.InvalidArgumentError( + {statusCode: 400}, + 'websocket.domain must be a string' + ); + } + + // Validate tls + if ('tls' in config && typeof config.tls !== 'boolean') { + throw new restifyErrors.InvalidArgumentError( + {statusCode: 400}, + 'websocket.tls must be a boolean' + ); + } + + return config as WebSocketConfig; +} + // The ShadowsocksManagerService manages the access keys that can use the server // as a proxy using Shadowsocks. It runs an instance of the Shadowsocks server // for each existing access key, with the port and password assigned for that access key. @@ -390,6 +464,7 @@ export class ShadowsocksManagerService { const dataLimit = validateDataLimit(req.params.limit); const password = validateStringParam(req.params.password, 'password'); const portNumber = validateNumberParam(req.params.port, 'port'); + const websocket = validateWebSocketConfig(req.params.websocket); const accessKeyJson = accessKeyToApiJson( await this.accessKeys.createNewAccessKey({ @@ -399,6 +474,7 @@ export class ShadowsocksManagerService { dataLimit, password, portNumber, + websocket, }) ); return accessKeyJson; @@ -578,6 +654,39 @@ export class ShadowsocksManagerService { } } + // Returns the dynamic access key configuration YAML for WebSocket transport + getDynamicAccessKeyConfig(req: RequestType, res: ResponseType, next: restify.Next): void { + try { + logging.debug(`getDynamicAccessKeyConfig request ${JSON.stringify(req.params)}`); + const accessKeyId = validateAccessKeyId(req.params.id); + + // Verify the access key exists + const accessKey = this.accessKeys.getAccessKey(accessKeyId); + + // Check if WebSocket is enabled for this key + if (!accessKey.websocket?.enabled) { + return next(new restifyErrors.NotImplementedError('WebSocket not configured for this access key')); + } + + // Generate the dynamic config YAML + const yamlConfig = (this.shadowsocksServer as any).generateDynamicAccessKeyYaml?.(accessKeyId); + + if (!yamlConfig) { + return next(new restifyErrors.NotImplementedError('WebSocket configuration not available')); + } + + (res as any).contentType('text/yaml'); + res.send(HttpSuccess.OK, yamlConfig); + next(); + } catch (error) { + logging.error(error); + if (error instanceof errors.AccessKeyNotFound) { + return next(new restifyErrors.NotFoundError(error.message)); + } + return next(new restifyErrors.InternalServerError()); + } + } + async setDefaultDataLimit(req: RequestType, res: ResponseType, next: restify.Next) { try { logging.debug(`setDefaultDataLimit request ${JSON.stringify(req.params)}`); diff --git a/src/shadowbox/server/outline_shadowsocks_server.ts b/src/shadowbox/server/outline_shadowsocks_server.ts index e7c40370..6681d78c 100644 --- a/src/shadowbox/server/outline_shadowsocks_server.ts +++ b/src/shadowbox/server/outline_shadowsocks_server.ts @@ -21,6 +21,17 @@ import * as file from '../infrastructure/file'; import * as logging from '../infrastructure/logging'; import {ShadowsocksAccessKey, ShadowsocksServer} from '../model/shadowsocks_server'; +// Extended interface for access keys with WebSocket configuration +export interface ShadowsocksAccessKeyWithWebSocket extends ShadowsocksAccessKey { + websocket?: { + enabled: boolean; + tcpPath?: string; + udpPath?: string; + domain?: string; + tls?: boolean; + }; +} + // Runs outline-ss-server. export class OutlineShadowsocksServer implements ShadowsocksServer { private ssProcess: child_process.ChildProcess; @@ -28,6 +39,12 @@ export class OutlineShadowsocksServer implements ShadowsocksServer { private ipAsnFilename?: string; private isAsnMetricsEnabled = false; private isReplayProtectionEnabled = false; + private webSocketConfig?: { + enabled: boolean; + webServerPort: number; + // Store the full access keys with WebSocket config for generating dynamic keys + accessKeys?: ShadowsocksAccessKeyWithWebSocket[]; + }; /** * @param binaryFilename The location for the outline-ss-server binary. @@ -65,6 +82,18 @@ export class OutlineShadowsocksServer implements ShadowsocksServer { return this; } + /** + * Configures WebSocket support for the Shadowsocks server. + * @param webServerPort The port for the internal WebSocket server to listen on. + */ + configureWebSocket(webServerPort: number): OutlineShadowsocksServer { + this.webSocketConfig = { + enabled: true, + webServerPort, + }; + return this; + } + // Promise is resolved after the outline-ss-config config is updated and the SIGHUP sent. // Keys may not be active yet. // TODO(fortuna): Make promise resolve when keys are ready. @@ -81,22 +110,38 @@ export class OutlineShadowsocksServer implements ShadowsocksServer { private writeConfigFile(keys: ShadowsocksAccessKey[]): Promise { return new Promise((resolve, reject) => { - const keysJson = {keys: [] as ShadowsocksAccessKey[]}; - for (const key of keys) { - if (!isAeadCipher(key.cipher)) { - logging.error( - `Cipher ${key.cipher} for access key ${key.id} is not supported: use an AEAD cipher instead.` - ); - continue; + // Check if any key has WebSocket configuration + const extendedKeys = keys as ShadowsocksAccessKeyWithWebSocket[]; + const hasWebSocketKeys = extendedKeys.some(key => key.websocket?.enabled); + + let config: any; + + if (hasWebSocketKeys && this.webSocketConfig?.enabled) { + // Use new format with WebSocket support + config = this.generateWebSocketConfig(extendedKeys); + } else { + // Use legacy format for backward compatibility + const keysJson = {keys: [] as ShadowsocksAccessKey[]}; + for (const key of keys) { + if (!isAeadCipher(key.cipher)) { + logging.error( + `Cipher ${key.cipher} for access key ${key.id} is not supported: use an AEAD cipher instead.` + ); + continue; + } + keysJson.keys.push(key); } - - keysJson.keys.push(key); + config = keysJson; } mkdirp.sync(path.dirname(this.configFilename)); try { - file.atomicWriteFileSync(this.configFilename, jsyaml.safeDump(keysJson, {sortKeys: true})); + file.atomicWriteFileSync(this.configFilename, jsyaml.safeDump(config, {sortKeys: true})); + // Store the keys for dynamic access key generation if WebSocket is enabled + if (this.webSocketConfig) { + this.webSocketConfig.accessKeys = extendedKeys; + } resolve(); } catch (error) { reject(error); @@ -104,6 +149,139 @@ export class OutlineShadowsocksServer implements ShadowsocksServer { }); } + private generateWebSocketConfig(keys: ShadowsocksAccessKeyWithWebSocket[]): any { + // Group keys by their listener configuration + const serviceGroups = new Map(); + + // Process each key + for (const key of keys) { + if (!isAeadCipher(key.cipher)) { + logging.error( + `Cipher ${key.cipher} for access key ${key.id} is not supported: use an AEAD cipher instead.` + ); + continue; + } + + if (key.websocket?.enabled) { + // Group WebSocket-enabled keys by their paths + const groupKey = `ws:${key.websocket.tcpPath || '/tcp'}:${key.websocket.udpPath || '/udp'}`; + if (!serviceGroups.has(groupKey)) { + serviceGroups.set(groupKey, []); + } + serviceGroups.get(groupKey)!.push(key); + } else { + // Group traditional keys by port + const groupKey = `port:${key.port}`; + if (!serviceGroups.has(groupKey)) { + serviceGroups.set(groupKey, []); + } + serviceGroups.get(groupKey)!.push(key); + } + } + + // Build the configuration + const config: any = { + services: [] + }; + + // Add web server configuration if any WebSocket keys exist + if (Array.from(serviceGroups.keys()).some(k => k.startsWith('ws:'))) { + config.web = { + servers: [{ + id: 'outline-ws-server', + listen: [`127.0.0.1:${this.webSocketConfig!.webServerPort}`] + }] + }; + } + + // Create services + for (const [groupKey, groupKeys] of serviceGroups) { + const service: any = { + listeners: [], + keys: groupKeys.map(k => ({ + id: k.id, + cipher: k.cipher, + secret: k.secret + })) + }; + + if (groupKey.startsWith('ws:')) { + // WebSocket listeners + const [, tcpPath, udpPath] = groupKey.split(':'); + service.listeners.push({ + type: 'websocket-stream', + web_server: 'outline-ws-server', + path: tcpPath + }); + service.listeners.push({ + type: 'websocket-packet', + web_server: 'outline-ws-server', + path: udpPath + }); + } else if (groupKey.startsWith('port:')) { + // Traditional TCP/UDP listeners + const port = groupKey.split(':')[1]; + service.listeners.push({ + type: 'tcp', + address: `[::]:${port}` + }); + service.listeners.push({ + type: 'udp', + address: `[::]:${port}` + }); + } + + config.services.push(service); + } + + return config; + } + + /** + * Generates dynamic access key YAML content for a specific access key with WebSocket support. + * @param accessKeyId The ID of the access key + * @returns The YAML content as a string, or null if the key doesn't exist or doesn't have WebSocket enabled + */ + generateDynamicAccessKeyYaml(accessKeyId: string): string | null { + if (!this.webSocketConfig?.accessKeys) { + return null; + } + + const accessKey = this.webSocketConfig.accessKeys.find(key => key.id === accessKeyId); + if (!accessKey || !accessKey.websocket?.enabled || !accessKey.websocket.domain) { + return null; + } + + const ws = accessKey.websocket; + const protocol = ws.tls !== false ? 'wss' : 'ws'; + + const config = { + transport: { + $type: 'tcpudp', + tcp: { + $type: 'shadowsocks', + endpoint: { + $type: 'websocket', + url: `${protocol}://${ws.domain}${ws.tcpPath || '/tcp'}` + }, + cipher: accessKey.cipher, + secret: accessKey.secret + }, + udp: { + $type: 'shadowsocks', + endpoint: { + $type: 'websocket', + url: `${protocol}://${ws.domain}${ws.udpPath || '/udp'}` + }, + cipher: accessKey.cipher, + secret: accessKey.secret + } + } + }; + + return jsyaml.safeDump(config, {sortKeys: true}); + } + private start() { const commandArguments = ['-config', this.configFilename, '-metrics', this.metricsLocation]; if (this.ipCountryFilename) { diff --git a/src/shadowbox/server/server_access_key.ts b/src/shadowbox/server/server_access_key.ts index 2c522498..a09f1770 100644 --- a/src/shadowbox/server/server_access_key.ts +++ b/src/shadowbox/server/server_access_key.ts @@ -26,6 +26,7 @@ import { AccessKeyRepository, DataLimit, ProxyParams, + WebSocketConfig, } from '../model/access_key'; import * as errors from '../model/errors'; import {ShadowsocksServer} from '../model/shadowsocks_server'; @@ -39,6 +40,7 @@ interface AccessKeyStorageJson { port: number; encryptionMethod?: string; dataLimit?: DataLimit; + websocket?: WebSocketConfig; } // The configuration file format as json. @@ -55,7 +57,8 @@ class ServerAccessKey implements AccessKey { readonly id: AccessKeyId, public name: string, readonly proxyParams: ProxyParams, - public dataLimit?: DataLimit + public dataLimit?: DataLimit, + public websocket?: WebSocketConfig ) {} } @@ -76,7 +79,8 @@ function makeAccessKey(hostname: string, accessKeyJson: AccessKeyStorageJson): A accessKeyJson.id, accessKeyJson.name, proxyParams, - accessKeyJson.dataLimit + accessKeyJson.dataLimit, + accessKeyJson.websocket ); } @@ -88,6 +92,7 @@ function accessKeyToStorageJson(accessKey: AccessKey): AccessKeyStorageJson { port: accessKey.proxyParams.portNumber, encryptionMethod: accessKey.proxyParams.encryptionMethod, dataLimit: accessKey.dataLimit, + websocket: accessKey.websocket, }; } @@ -234,7 +239,8 @@ export class ServerAccessKeyRepository implements AccessKeyRepository { }; const name = params?.name ?? ''; const dataLimit = params?.dataLimit; - const accessKey = new ServerAccessKey(id, name, proxyParams, dataLimit); + const websocket = params?.websocket; + const accessKey = new ServerAccessKey(id, name, proxyParams, dataLimit, websocket); this.accessKeys.push(accessKey); this.saveAccessKeys(); await this.updateServer(); @@ -325,12 +331,22 @@ export class ServerAccessKeyRepository implements AccessKeyRepository { const serverAccessKeys = this.accessKeys .filter((key) => !key.reachedDataLimit) .map((key) => { - return { + const baseKey = { id: key.id, port: key.proxyParams.portNumber, cipher: key.proxyParams.encryptionMethod, secret: key.proxyParams.password, }; + + // Include WebSocket configuration if present + if (key.websocket) { + return { + ...baseKey, + websocket: key.websocket + }; + } + + return baseKey; }); return this.shadowsocksServer.update(serverAccessKeys); }