From 25678f7122b89f0ae6b9ddfad30acb3ed64cdcb0 Mon Sep 17 00:00:00 2001 From: lunarthegrey Date: Sat, 26 Jul 2025 20:31:13 -0500 Subject: [PATCH 01/50] 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 000000000..363214dd5 --- /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 5ed57b188..390244b50 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 2532d51ce..69a48884f 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 533f9f439..2ff44973d 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 91bd25f4b..ab94225ba 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 e7c403701..6681d78cc 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 2c5224981..a09f17708 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); } From 56439e4cf7bd779154e6357e524cafc535130308 Mon Sep 17 00:00:00 2001 From: lunarthegrey Date: Sat, 26 Jul 2025 20:41:16 -0500 Subject: [PATCH 02/50] Attempt to fix shadowbox builds --- .github/workflows/build-shadowbox.yml | 132 +++++++++----------------- 1 file changed, 45 insertions(+), 87 deletions(-) diff --git a/.github/workflows/build-shadowbox.yml b/.github/workflows/build-shadowbox.yml index 363214dd5..08fdfa051 100644 --- a/.github/workflows/build-shadowbox.yml +++ b/.github/workflows/build-shadowbox.yml @@ -28,12 +28,6 @@ jobs: contents: read packages: write - strategy: - matrix: - platform: - - linux/amd64 - - linux/arm64 - steps: - name: Checkout repository uses: actions/checkout@v4 @@ -44,6 +38,15 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '18.x' + + - name: Install task + run: | + sudo sh -c "$(curl --location https://taskfile.dev/install.sh)" -- -d -b /usr/local/bin + - name: Log in to GitHub Container Registry if: github.event_name != 'pull_request' uses: docker/login-action@v3 @@ -74,96 +77,51 @@ jobs: 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 + - name: Build application for x86_64 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 + task docker:build VERSION=${{ github.sha }} TARGET_ARCH=x86_64 + + - name: Build application for arm64 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}/" + task docker:build VERSION=${{ github.sha }} TARGET_ARCH=arm64 - - 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 + - name: Create multi-arch image and push + if: github.event_name != 'pull_request' + run: | + # Tag images for each architecture + docker tag localhost/outline/shadowbox:latest ${{ env.REGISTRY }}/${{ github.repository_owner }}/${{ env.IMAGE_NAME }}:amd64-temp - 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 }} + # Since we built x86_64 last, we need to load the arm64 image + docker load -i ./src/shadowbox/build/arm64/shadowbox-arm64.tar || true + docker tag localhost/outline/shadowbox:latest ${{ env.REGISTRY }}/${{ github.repository_owner }}/${{ env.IMAGE_NAME }}:arm64-temp - - 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: | + # Push architecture-specific images + docker push ${{ env.REGISTRY }}/${{ github.repository_owner }}/${{ env.IMAGE_NAME }}:amd64-temp + docker push ${{ env.REGISTRY }}/${{ github.repository_owner }}/${{ env.IMAGE_NAME }}:arm64-temp + + # Create and push manifests for each tag TAGS="${{ steps.meta.outputs.tags }}" + IFS=$'\n' for TAG in $TAGS; do + echo "Creating manifest for $TAG" docker manifest create $TAG \ - $TAG-linux-amd64 \ - $TAG-linux-arm64 + --amend ${{ env.REGISTRY }}/${{ github.repository_owner }}/${{ env.IMAGE_NAME }}:amd64-temp \ + --amend ${{ env.REGISTRY }}/${{ github.repository_owner }}/${{ env.IMAGE_NAME }}:arm64-temp + + docker manifest annotate $TAG \ + ${{ env.REGISTRY }}/${{ github.repository_owner }}/${{ env.IMAGE_NAME }}:amd64-temp \ + --arch amd64 --os linux + + docker manifest annotate $TAG \ + ${{ env.REGISTRY }}/${{ github.repository_owner }}/${{ env.IMAGE_NAME }}:arm64-temp \ + --arch arm64 --os linux + docker manifest push $TAG - done \ No newline at end of file + done + + # Clean up temporary tags + docker manifest rm ${{ env.REGISTRY }}/${{ github.repository_owner }}/${{ env.IMAGE_NAME }}:amd64-temp || true + docker manifest rm ${{ env.REGISTRY }}/${{ github.repository_owner }}/${{ env.IMAGE_NAME }}:arm64-temp || true \ No newline at end of file From 8d5159afb08f420841831d3986255b58fca0960f Mon Sep 17 00:00:00 2001 From: lunarthegrey Date: Sat, 26 Jul 2025 20:45:49 -0500 Subject: [PATCH 03/50] Attempt to fix shadowbox builds --- .github/workflows/build-shadowbox.yml | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/.github/workflows/build-shadowbox.yml b/.github/workflows/build-shadowbox.yml index 08fdfa051..57129987a 100644 --- a/.github/workflows/build-shadowbox.yml +++ b/.github/workflows/build-shadowbox.yml @@ -79,24 +79,25 @@ jobs: - name: Build application for x86_64 working-directory: ./src/shadowbox + env: + OUTPUT_BASE: ./build run: | npm ci - task docker:build VERSION=${{ github.sha }} TARGET_ARCH=x86_64 + task docker:build VERSION=${{ github.sha }} TARGET_ARCH=x86_64 IMAGE_NAME=localhost/outline/shadowbox:x86_64 - name: Build application for arm64 working-directory: ./src/shadowbox + env: + OUTPUT_BASE: ./build run: | - task docker:build VERSION=${{ github.sha }} TARGET_ARCH=arm64 + task docker:build VERSION=${{ github.sha }} TARGET_ARCH=arm64 IMAGE_NAME=localhost/outline/shadowbox:arm64 - name: Create multi-arch image and push if: github.event_name != 'pull_request' run: | # Tag images for each architecture - docker tag localhost/outline/shadowbox:latest ${{ env.REGISTRY }}/${{ github.repository_owner }}/${{ env.IMAGE_NAME }}:amd64-temp - - # Since we built x86_64 last, we need to load the arm64 image - docker load -i ./src/shadowbox/build/arm64/shadowbox-arm64.tar || true - docker tag localhost/outline/shadowbox:latest ${{ env.REGISTRY }}/${{ github.repository_owner }}/${{ env.IMAGE_NAME }}:arm64-temp + docker tag localhost/outline/shadowbox:x86_64 ${{ env.REGISTRY }}/${{ github.repository_owner }}/${{ env.IMAGE_NAME }}:amd64-temp + docker tag localhost/outline/shadowbox:arm64 ${{ env.REGISTRY }}/${{ github.repository_owner }}/${{ env.IMAGE_NAME }}:arm64-temp # Push architecture-specific images docker push ${{ env.REGISTRY }}/${{ github.repository_owner }}/${{ env.IMAGE_NAME }}:amd64-temp From daa17fcf07001bba8104c30696126ae3eec5a59a Mon Sep 17 00:00:00 2001 From: lunarthegrey Date: Sat, 26 Jul 2025 20:57:50 -0500 Subject: [PATCH 04/50] Attempt to fix shadowbox builds --- .github/workflows/build-shadowbox.yml | 96 +++++++++++++-------------- 1 file changed, 45 insertions(+), 51 deletions(-) diff --git a/.github/workflows/build-shadowbox.yml b/.github/workflows/build-shadowbox.yml index 57129987a..0f584bf32 100644 --- a/.github/workflows/build-shadowbox.yml +++ b/.github/workflows/build-shadowbox.yml @@ -38,15 +38,6 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: '18.x' - - - name: Install task - run: | - sudo sh -c "$(curl --location https://taskfile.dev/install.sh)" -- -d -b /usr/local/bin - - name: Log in to GitHub Container Registry if: github.event_name != 'pull_request' uses: docker/login-action@v3 @@ -77,52 +68,55 @@ jobs: 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: Build application for x86_64 - working-directory: ./src/shadowbox + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '18' + cache: 'npm' + + - name: Setup Go + uses: actions/setup-go@v4 + with: + go-version: '1.21' + + - name: Install dependencies + run: npm ci + + - name: Install Task + run: sh -c "$(curl --location https://taskfile.dev/install.sh)" -- -d -b /usr/local/bin + + - name: Build Shadowbox for amd64 env: - OUTPUT_BASE: ./build + OUTPUT_BASE: ${{ github.workspace }}/build + DOCKER_CONTENT_TRUST: "0" # Disable content trust for CI builds run: | - npm ci - task docker:build VERSION=${{ github.sha }} TARGET_ARCH=x86_64 IMAGE_NAME=localhost/outline/shadowbox:x86_64 - - - name: Build application for arm64 - working-directory: ./src/shadowbox + # Build from root directory to have access to all taskfiles + task shadowbox:docker:build TARGET_ARCH=x86_64 IMAGE_NAME=${{ env.REGISTRY }}/${{ github.repository_owner }}/${{ env.IMAGE_NAME }}:amd64-${{ github.sha }} IMAGE_VERSION=${{ github.sha }} + + - name: Build Shadowbox for arm64 env: - OUTPUT_BASE: ./build + OUTPUT_BASE: ${{ github.workspace }}/build + DOCKER_CONTENT_TRUST: "0" # Disable content trust for CI builds run: | - task docker:build VERSION=${{ github.sha }} TARGET_ARCH=arm64 IMAGE_NAME=localhost/outline/shadowbox:arm64 + # Build from root directory to have access to all taskfiles + task shadowbox:docker:build TARGET_ARCH=arm64 IMAGE_NAME=${{ env.REGISTRY }}/${{ github.repository_owner }}/${{ env.IMAGE_NAME }}:arm64-${{ github.sha }} IMAGE_VERSION=${{ github.sha }} - - name: Create multi-arch image and push + - name: Push images if: github.event_name != 'pull_request' run: | - # Tag images for each architecture - docker tag localhost/outline/shadowbox:x86_64 ${{ env.REGISTRY }}/${{ github.repository_owner }}/${{ env.IMAGE_NAME }}:amd64-temp - docker tag localhost/outline/shadowbox:arm64 ${{ env.REGISTRY }}/${{ github.repository_owner }}/${{ env.IMAGE_NAME }}:arm64-temp - - # Push architecture-specific images - docker push ${{ env.REGISTRY }}/${{ github.repository_owner }}/${{ env.IMAGE_NAME }}:amd64-temp - docker push ${{ env.REGISTRY }}/${{ github.repository_owner }}/${{ env.IMAGE_NAME }}:arm64-temp - - # Create and push manifests for each tag - TAGS="${{ steps.meta.outputs.tags }}" - IFS=$'\n' - for TAG in $TAGS; do - echo "Creating manifest for $TAG" - docker manifest create $TAG \ - --amend ${{ env.REGISTRY }}/${{ github.repository_owner }}/${{ env.IMAGE_NAME }}:amd64-temp \ - --amend ${{ env.REGISTRY }}/${{ github.repository_owner }}/${{ env.IMAGE_NAME }}:arm64-temp - - docker manifest annotate $TAG \ - ${{ env.REGISTRY }}/${{ github.repository_owner }}/${{ env.IMAGE_NAME }}:amd64-temp \ - --arch amd64 --os linux - - docker manifest annotate $TAG \ - ${{ env.REGISTRY }}/${{ github.repository_owner }}/${{ env.IMAGE_NAME }}:arm64-temp \ - --arch arm64 --os linux - - docker manifest push $TAG - done - - # Clean up temporary tags - docker manifest rm ${{ env.REGISTRY }}/${{ github.repository_owner }}/${{ env.IMAGE_NAME }}:amd64-temp || true - docker manifest rm ${{ env.REGISTRY }}/${{ github.repository_owner }}/${{ env.IMAGE_NAME }}:arm64-temp || true \ No newline at end of file + docker push ${{ env.REGISTRY }}/${{ github.repository_owner }}/${{ env.IMAGE_NAME }}:amd64-${{ github.sha }} + docker push ${{ env.REGISTRY }}/${{ github.repository_owner }}/${{ env.IMAGE_NAME }}:arm64-${{ github.sha }} + + - name: Create and push manifest + if: github.event_name != 'pull_request' + env: + DOCKER_CLI_EXPERIMENTAL: enabled + run: | + # Parse tags and create/push manifest for each + echo "${{ steps.meta.outputs.tags }}" | while read -r tag; do + echo "Creating manifest for ${tag}" + docker manifest create ${tag} \ + --amend ${{ env.REGISTRY }}/${{ github.repository_owner }}/${{ env.IMAGE_NAME }}:amd64-${{ github.sha }} \ + --amend ${{ env.REGISTRY }}/${{ github.repository_owner }}/${{ env.IMAGE_NAME }}:arm64-${{ github.sha }} + docker manifest push ${tag} + done \ No newline at end of file From 42471bba983ea9bbb12269b5edb6fdf58d065ac8 Mon Sep 17 00:00:00 2001 From: lunarthegrey Date: Sat, 26 Jul 2025 21:08:01 -0500 Subject: [PATCH 05/50] Attempt to fix shadowbox linting errors --- src/shadowbox/server/manager_service.ts | 14 ++++-- .../server/outline_shadowsocks_server.ts | 45 +++++++++++++++++-- 2 files changed, 51 insertions(+), 8 deletions(-) diff --git a/src/shadowbox/server/manager_service.ts b/src/shadowbox/server/manager_service.ts index ab94225ba..b58523d8b 100644 --- a/src/shadowbox/server/manager_service.ts +++ b/src/shadowbox/server/manager_service.ts @@ -292,7 +292,7 @@ function validateWebSocketConfig(websocket: unknown): WebSocketConfig | undefine ); } - const config = websocket as any; + const config = websocket as Record; // Validate enabled field if ('enabled' in config && typeof config.enabled !== 'boolean') { @@ -334,7 +334,7 @@ function validateWebSocketConfig(websocket: unknown): WebSocketConfig | undefine ); } - return config as WebSocketConfig; + return config as unknown as WebSocketConfig; } // The ShadowsocksManagerService manages the access keys that can use the server @@ -669,13 +669,19 @@ export class ShadowsocksManagerService { } // Generate the dynamic config YAML - const yamlConfig = (this.shadowsocksServer as any).generateDynamicAccessKeyYaml?.(accessKeyId); + // Type assertion for the extended server interface + const serverWithWebSocket = this.shadowsocksServer as ShadowsocksServer & { + generateDynamicAccessKeyYaml?: (accessKeyId: string) => string | null; + }; + const yamlConfig = serverWithWebSocket.generateDynamicAccessKeyYaml?.(accessKeyId); if (!yamlConfig) { return next(new restifyErrors.NotImplementedError('WebSocket configuration not available')); } - (res as any).contentType('text/yaml'); + // Set content type for YAML response + const response = res as restify.Response & { contentType: (type: string) => void }; + response.contentType('text/yaml'); res.send(HttpSuccess.OK, yamlConfig); next(); } catch (error) { diff --git a/src/shadowbox/server/outline_shadowsocks_server.ts b/src/shadowbox/server/outline_shadowsocks_server.ts index 6681d78cc..a73d4cad1 100644 --- a/src/shadowbox/server/outline_shadowsocks_server.ts +++ b/src/shadowbox/server/outline_shadowsocks_server.ts @@ -32,6 +32,43 @@ export interface ShadowsocksAccessKeyWithWebSocket extends ShadowsocksAccessKey }; } +// Configuration types for outline-ss-server +interface LegacyConfig { + keys: ShadowsocksAccessKey[]; +} + +interface WebSocketListener { + type: 'websocket-stream' | 'websocket-packet'; + web_server: string; + path: string; +} + +interface TcpUdpListener { + type: 'tcp' | 'udp'; + address: string; +} + +interface ServiceConfig { + listeners: Array; + keys: Array<{ + id: string; + cipher: string; + secret: string; + }>; +} + +interface WebSocketConfig { + web?: { + servers: Array<{ + id: string; + listen: string[]; + }>; + }; + services: ServiceConfig[]; +} + +type ServerConfig = LegacyConfig | WebSocketConfig; + // Runs outline-ss-server. export class OutlineShadowsocksServer implements ShadowsocksServer { private ssProcess: child_process.ChildProcess; @@ -114,7 +151,7 @@ export class OutlineShadowsocksServer implements ShadowsocksServer { const extendedKeys = keys as ShadowsocksAccessKeyWithWebSocket[]; const hasWebSocketKeys = extendedKeys.some(key => key.websocket?.enabled); - let config: any; + let config: ServerConfig; if (hasWebSocketKeys && this.webSocketConfig?.enabled) { // Use new format with WebSocket support @@ -149,7 +186,7 @@ export class OutlineShadowsocksServer implements ShadowsocksServer { }); } - private generateWebSocketConfig(keys: ShadowsocksAccessKeyWithWebSocket[]): any { + private generateWebSocketConfig(keys: ShadowsocksAccessKeyWithWebSocket[]): WebSocketConfig { // Group keys by their listener configuration const serviceGroups = new Map(); @@ -180,7 +217,7 @@ export class OutlineShadowsocksServer implements ShadowsocksServer { } // Build the configuration - const config: any = { + const config: WebSocketConfig = { services: [] }; @@ -196,7 +233,7 @@ export class OutlineShadowsocksServer implements ShadowsocksServer { // Create services for (const [groupKey, groupKeys] of serviceGroups) { - const service: any = { + const service: ServiceConfig = { listeners: [], keys: groupKeys.map(k => ({ id: k.id, From 33c398af4cd6f59c87fa81547ff8ade21e888d92 Mon Sep 17 00:00:00 2001 From: lunarthegrey Date: Sat, 26 Jul 2025 21:12:17 -0500 Subject: [PATCH 06/50] Attempt to fix license-check --- .github/workflows/build-shadowbox.yml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/.github/workflows/build-shadowbox.yml b/.github/workflows/build-shadowbox.yml index 0f584bf32..b4439b4a6 100644 --- a/.github/workflows/build-shadowbox.yml +++ b/.github/workflows/build-shadowbox.yml @@ -1,3 +1,17 @@ +# Copyright 2024 The Outline Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + name: Build and Push Shadowbox Docker Image on: From 8a1c714d39dd3565ed653e3fa3d2d3ba81be9479 Mon Sep 17 00:00:00 2001 From: lunarthegrey Date: Sat, 26 Jul 2025 22:33:33 -0500 Subject: [PATCH 07/50] Attempt to fix dynamic configs --- src/shadowbox/server/manager_service.ts | 6 ++-- .../server/outline_shadowsocks_server.ts | 35 +++++++------------ 2 files changed, 15 insertions(+), 26 deletions(-) diff --git a/src/shadowbox/server/manager_service.ts b/src/shadowbox/server/manager_service.ts index b58523d8b..154ee69f9 100644 --- a/src/shadowbox/server/manager_service.ts +++ b/src/shadowbox/server/manager_service.ts @@ -669,11 +669,11 @@ export class ShadowsocksManagerService { } // Generate the dynamic config YAML - // Type assertion for the extended server interface + // Type assertion for the extended server interface with WebSocket support const serverWithWebSocket = this.shadowsocksServer as ShadowsocksServer & { - generateDynamicAccessKeyYaml?: (accessKeyId: string) => string | null; + generateDynamicAccessKeyYaml?: (proxyParams: {encryptionMethod: string; password: string}, websocket?: WebSocketConfig) => string | null; }; - const yamlConfig = serverWithWebSocket.generateDynamicAccessKeyYaml?.(accessKeyId); + const yamlConfig = serverWithWebSocket.generateDynamicAccessKeyYaml?.(accessKey.proxyParams, accessKey.websocket); if (!yamlConfig) { return next(new restifyErrors.NotImplementedError('WebSocket configuration not available')); diff --git a/src/shadowbox/server/outline_shadowsocks_server.ts b/src/shadowbox/server/outline_shadowsocks_server.ts index a73d4cad1..80059e91a 100644 --- a/src/shadowbox/server/outline_shadowsocks_server.ts +++ b/src/shadowbox/server/outline_shadowsocks_server.ts @@ -79,8 +79,6 @@ export class OutlineShadowsocksServer implements ShadowsocksServer { private webSocketConfig?: { enabled: boolean; webServerPort: number; - // Store the full access keys with WebSocket config for generating dynamic keys - accessKeys?: ShadowsocksAccessKeyWithWebSocket[]; }; /** @@ -175,10 +173,6 @@ export class OutlineShadowsocksServer implements ShadowsocksServer { try { 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); @@ -276,21 +270,16 @@ export class OutlineShadowsocksServer implements ShadowsocksServer { /** * 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 + * @param proxyParams The proxy parameters containing cipher and password + * @param websocket The WebSocket configuration + * @returns The YAML content as a string, or null if the key 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) { + generateDynamicAccessKeyYaml(proxyParams: {encryptionMethod: string; password: string}, websocket?: {enabled: boolean; tcpPath?: string; udpPath?: string; domain?: string; tls?: boolean}): string | null { + if (!websocket?.enabled || !websocket.domain) { return null; } - const ws = accessKey.websocket; - const protocol = ws.tls !== false ? 'wss' : 'ws'; + const protocol = websocket.tls !== false ? 'wss' : 'ws'; const config = { transport: { @@ -299,19 +288,19 @@ export class OutlineShadowsocksServer implements ShadowsocksServer { $type: 'shadowsocks', endpoint: { $type: 'websocket', - url: `${protocol}://${ws.domain}${ws.tcpPath || '/tcp'}` + url: `${protocol}://${websocket.domain}${websocket.tcpPath || '/tcp'}` }, - cipher: accessKey.cipher, - secret: accessKey.secret + cipher: proxyParams.encryptionMethod, + secret: proxyParams.password }, udp: { $type: 'shadowsocks', endpoint: { $type: 'websocket', - url: `${protocol}://${ws.domain}${ws.udpPath || '/udp'}` + url: `${protocol}://${websocket.domain}${websocket.udpPath || '/udp'}` }, - cipher: accessKey.cipher, - secret: accessKey.secret + cipher: proxyParams.encryptionMethod, + secret: proxyParams.password } } }; From 00de304a497fcce56e168ba8e41c2ddac97f9411 Mon Sep 17 00:00:00 2001 From: lunarthegrey Date: Sat, 26 Jul 2025 22:59:59 -0500 Subject: [PATCH 08/50] Bump outline-ss-server to v1.9.2 --- go.mod | 2 +- src/shadowbox/server/manager_service.ts | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index d519122ea..749062af8 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module localhost go 1.21 require ( - github.com/Jigsaw-Code/outline-ss-server v1.7.3 + github.com/Jigsaw-Code/outline-ss-server v1.9.2 github.com/go-task/task/v3 v3.36.0 github.com/google/addlicense v1.1.1 ) diff --git a/src/shadowbox/server/manager_service.ts b/src/shadowbox/server/manager_service.ts index 154ee69f9..1c6b77eca 100644 --- a/src/shadowbox/server/manager_service.ts +++ b/src/shadowbox/server/manager_service.ts @@ -680,8 +680,7 @@ export class ShadowsocksManagerService { } // Set content type for YAML response - const response = res as restify.Response & { contentType: (type: string) => void }; - response.contentType('text/yaml'); + res.setHeader('Content-Type', 'text/yaml'); res.send(HttpSuccess.OK, yamlConfig); next(); } catch (error) { From 54eefedf626049c54e112040bee327b85c8af07e Mon Sep 17 00:00:00 2001 From: lunarthegrey Date: Sat, 26 Jul 2025 23:04:03 -0500 Subject: [PATCH 09/50] Attempt to fix shadowbox builds --- src/shadowbox/server/manager_service.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/shadowbox/server/manager_service.ts b/src/shadowbox/server/manager_service.ts index 1c6b77eca..678526c52 100644 --- a/src/shadowbox/server/manager_service.ts +++ b/src/shadowbox/server/manager_service.ts @@ -679,8 +679,8 @@ export class ShadowsocksManagerService { return next(new restifyErrors.NotImplementedError('WebSocket configuration not available')); } - // Set content type for YAML response - res.setHeader('Content-Type', 'text/yaml'); + // Send YAML response with proper content type + // Using restify's built-in content type handling res.send(HttpSuccess.OK, yamlConfig); next(); } catch (error) { From e82a1016573870f88c7ddc3372304e14a737bf10 Mon Sep 17 00:00:00 2001 From: lunarthegrey Date: Sat, 26 Jul 2025 23:07:14 -0500 Subject: [PATCH 10/50] Attempt to fix shadowbox builds --- .github/workflows/build-shadowbox.yml | 5 ++ go.mod | 42 +++++++----- go.sum | 98 ++++++++++++++------------- 3 files changed, 79 insertions(+), 66 deletions(-) diff --git a/.github/workflows/build-shadowbox.yml b/.github/workflows/build-shadowbox.yml index b4439b4a6..f1d7d12f9 100644 --- a/.github/workflows/build-shadowbox.yml +++ b/.github/workflows/build-shadowbox.yml @@ -96,6 +96,11 @@ jobs: - name: Install dependencies run: npm ci + - name: Update Go dependencies + run: | + go mod download + go mod tidy + - name: Install Task run: sh -c "$(curl --location https://taskfile.dev/install.sh)" -- -d -b /usr/local/bin diff --git a/go.mod b/go.mod index 749062af8..961e732c2 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,8 @@ module localhost -go 1.21 +go 1.23 + +toolchain go1.24.5 require ( github.com/Jigsaw-Code/outline-ss-server v1.9.2 @@ -9,39 +11,43 @@ require ( ) require ( - github.com/Jigsaw-Code/outline-sdk v0.0.14 // indirect - github.com/Masterminds/semver/v3 v3.2.1 // indirect + github.com/Jigsaw-Code/outline-sdk v0.0.18-0.20241106233708-faffebb12629 // indirect + github.com/Jigsaw-Code/outline-sdk/x v0.0.2-0.20250304133713-52f1a365e5ed // indirect + github.com/Masterminds/semver/v3 v3.3.1 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/bmatcuk/doublestar/v4 v4.0.2 // indirect - github.com/cespare/xxhash/v2 v2.2.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/fatih/color v1.16.0 // indirect github.com/go-task/slim-sprig/v3 v3.0.0 // indirect - github.com/golang/protobuf v1.5.3 // indirect + github.com/go-viper/mapstructure/v2 v2.2.1 // indirect + github.com/gorilla/handlers v1.4.1 // indirect + github.com/gorilla/websocket v1.5.3 // indirect github.com/joho/godotenv v1.5.1 // indirect - github.com/klauspost/cpuid/v2 v2.0.9 // indirect + github.com/klauspost/compress v1.17.11 // indirect + github.com/klauspost/cpuid/v2 v2.2.9 // indirect github.com/lmittmann/tint v1.0.5 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-zglob v0.0.4 // indirect - github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect - github.com/oschwald/geoip2-golang v1.8.0 // indirect - github.com/oschwald/maxminddb-golang v1.10.0 // indirect - github.com/prometheus/client_golang v1.15.0 // indirect - github.com/prometheus/client_model v0.3.0 // indirect - github.com/prometheus/common v0.42.0 // indirect - github.com/prometheus/procfs v0.9.0 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/oschwald/geoip2-golang v1.11.0 // indirect + github.com/oschwald/maxminddb-golang v1.13.1 // indirect + github.com/prometheus/client_golang v1.20.5 // indirect + github.com/prometheus/client_model v0.6.1 // indirect + github.com/prometheus/common v0.62.0 // indirect + github.com/prometheus/procfs v0.15.1 // indirect github.com/radovskyb/watcher v1.0.7 // indirect github.com/sajari/fuzzy v1.0.0 // indirect github.com/shadowsocks/go-shadowsocks2 v0.1.5 // indirect github.com/spf13/pflag v1.0.5 // indirect github.com/zeebo/xxh3 v1.0.2 // indirect - golang.org/x/crypto v0.31.0 // indirect - golang.org/x/sync v0.7.0 // indirect - golang.org/x/sys v0.28.0 // indirect - golang.org/x/term v0.27.0 // indirect - google.golang.org/protobuf v1.30.0 // indirect + golang.org/x/crypto v0.32.0 // indirect + golang.org/x/sync v0.10.0 // indirect + golang.org/x/sys v0.29.0 // indirect + golang.org/x/term v0.28.0 // indirect + google.golang.org/protobuf v1.36.3 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect mvdan.cc/sh/v3 v3.8.0 // indirect ) diff --git a/go.sum b/go.sum index 96578b726..df584d8ff 100644 --- a/go.sum +++ b/go.sum @@ -1,15 +1,17 @@ -github.com/Jigsaw-Code/outline-sdk v0.0.14 h1:uJLvIne7YJNolbX7KDacd8gLidrUzRuweBO2APmQEmI= -github.com/Jigsaw-Code/outline-sdk v0.0.14/go.mod h1:9cEaF6sWWMzY8orcUI9pV5D0oFp2FZArTSyJiYtMQQs= -github.com/Jigsaw-Code/outline-ss-server v1.7.3 h1:UF8AaOV2agRb6edF0U0CtTcwpyIxm6NVDa5QLkQh28E= -github.com/Jigsaw-Code/outline-ss-server v1.7.3/go.mod h1:cKPicPWlLWZKJfkQ3CBpQm8a3gXrA2+dpQvsECqBVz8= -github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0= -github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ= +github.com/Jigsaw-Code/outline-sdk v0.0.18-0.20241106233708-faffebb12629 h1:sHi1X4vwtNNBUDCbxynGXe7cM/inwTbavowHziaxlbk= +github.com/Jigsaw-Code/outline-sdk v0.0.18-0.20241106233708-faffebb12629/go.mod h1:CFDKyGZA4zatKE4vMLe8TyQpZCyINOeRFbMAmYHxodw= +github.com/Jigsaw-Code/outline-sdk/x v0.0.2-0.20250304133713-52f1a365e5ed h1:NfybsWzXQLPNueDsoPJMmvw/i7hWXqk9xaoA9X1cGgM= +github.com/Jigsaw-Code/outline-sdk/x v0.0.2-0.20250304133713-52f1a365e5ed/go.mod h1:aFUEz6Z/eD0NS3c3fEIX+JO2D9aIrXCmWTb1zJFlItw= +github.com/Jigsaw-Code/outline-ss-server v1.9.2 h1:8AlzPLugCCa9H4ZIV79rWOdgVshRzKZalq8ZD+APjqk= +github.com/Jigsaw-Code/outline-ss-server v1.9.2/go.mod h1:v0jS3ExOGwGTbWTpOw16/sid91k7PKxazdK9eLCpUlQ= +github.com/Masterminds/semver/v3 v3.3.1 h1:QtNSWtVZ3nBfk8mAOu/B6v7FMJ+NHTIgUPi7rj+4nv4= +github.com/Masterminds/semver/v3 v3.3.1/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bmatcuk/doublestar/v4 v4.0.2 h1:X0krlUVAVmtr2cRoTqR8aDMrDqnB36ht8wpWTiQ3jsA= github.com/bmatcuk/doublestar/v4 v4.0.2/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= -github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= -github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/creack/pty v1.1.21 h1:1/QdRyBaHHJP61QkWMXlOIBfsgdDeeKfK8SYVUWJKf0= github.com/creack/pty v1.1.21/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= @@ -22,24 +24,28 @@ github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1v github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= github.com/go-task/task/v3 v3.36.0 h1:XVJ5hQ5hdzTAulHpAGzbUMUuYr9MUOEQFOFazI3hUsY= github.com/go-task/task/v3 v3.36.0/go.mod h1:XBCIAzuyG/mgZVHMUm3cCznz4+IpsBQRlW1gw7OA5sA= -github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk= -github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= -github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= -github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss= +github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/google/addlicense v1.1.1 h1:jpVf9qPbU8rz5MxKo7d+RMcNHkqxi4YJi/laauX4aAE= github.com/google/addlicense v1.1.1/go.mod h1:Sm/DHu7Jk+T5miFHHehdIjbi4M5+dJDRS3Cq0rncIxA= -github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/gorilla/handlers v1.4.1 h1:BHvcRGJe/TrL+OqFxoKQGddTgeibiOjaBssV5a/N9sw= +github.com/gorilla/handlers v1.4.1/go.mod h1:Qkdc/uu4tH4g6mTK6auzZ766c4CA0Ng8+o/OAirnOIQ= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= -github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4= -github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc= +github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0= +github.com/klauspost/cpuid/v2 v2.2.9 h1:66ze0taIn2H33fBvCkXuv9BmCwDfafmiIVpKV9kKGuY= +github.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK4QwQ3WMXF8= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/lmittmann/tint v1.0.5 h1:NQclAutOfYsqs2F1Lenue6OoWCajs5wJcP3DfWVpePw= github.com/lmittmann/tint v1.0.5/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= @@ -49,32 +55,32 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-zglob v0.0.4 h1:LQi2iOm0/fGgu80AioIJ/1j9w9Oh+9DZ39J4VAGzHQM= github.com/mattn/go-zglob v0.0.4/go.mod h1:MxxjyoXXnMxfIpxTK2GAkw1w8glPsQILx3N5wrKakiY= -github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo= -github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4= github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/op/go-logging v0.0.0-20160315200505-970db520ece7 h1:lDH9UUVJtmYCjyT0CI4q8xvlXPxeZ0gYCVvWbmPlp88= github.com/op/go-logging v0.0.0-20160315200505-970db520ece7/go.mod h1:HzydrMdWErDVzsI23lYNej1Htcns9BCg93Dk0bBINWk= -github.com/oschwald/geoip2-golang v1.8.0 h1:KfjYB8ojCEn/QLqsDU0AzrJ3R5Qa9vFlx3z6SLNcKTs= -github.com/oschwald/geoip2-golang v1.8.0/go.mod h1:R7bRvYjOeaoenAp9sKRS8GX5bJWcZ0laWO5+DauEktw= -github.com/oschwald/maxminddb-golang v1.10.0 h1:Xp1u0ZhqkSuopaKmk1WwHtjF0H9Hd9181uj2MQ5Vndg= -github.com/oschwald/maxminddb-golang v1.10.0/go.mod h1:Y2ELenReaLAZ0b400URyGwvYxHV1dLIxBuyOsyYjHK0= +github.com/oschwald/geoip2-golang v1.11.0 h1:hNENhCn1Uyzhf9PTmquXENiWS6AlxAEnBII6r8krA3w= +github.com/oschwald/geoip2-golang v1.11.0/go.mod h1:P9zG+54KPEFOliZ29i7SeYZ/GM6tfEL+rgSn03hYuUo= +github.com/oschwald/maxminddb-golang v1.13.1 h1:G3wwjdN9JmIK2o/ermkHM+98oX5fS+k5MbwsmL4MRQE= +github.com/oschwald/maxminddb-golang v1.13.1/go.mod h1:K4pgV9N/GcK694KSTmVSDTODk4IsCNThNdTmnaBZ/F8= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/prometheus/client_golang v1.15.0 h1:5fCgGYogn0hFdhyhLbw7hEsWxufKtY9klyvdNfFlFhM= -github.com/prometheus/client_golang v1.15.0/go.mod h1:e9yaBhRPU2pPNsZwE+JdQl0KEt1N9XgF6zxWmaC0xOk= -github.com/prometheus/client_model v0.3.0 h1:UBgGFHqYdG/TPFD1B1ogZywDqEkwp3fBMvqdiQ7Xew4= -github.com/prometheus/client_model v0.3.0/go.mod h1:LDGWKZIo7rky3hgvBe+caln+Dr3dPggB5dvjtD7w9+w= -github.com/prometheus/common v0.42.0 h1:EKsfXEYo4JpWMHH5cg+KOUWeuJSov1Id8zGR8eeI1YM= -github.com/prometheus/common v0.42.0/go.mod h1:xBwqVerjNdUDjgODMpudtOMwlOwf2SaTr1yjz4b7Zbc= -github.com/prometheus/procfs v0.9.0 h1:wzCHvIvM5SxWqYvwgVL7yJY8Lz3PKn49KQtpgMYJfhI= -github.com/prometheus/procfs v0.9.0/go.mod h1:+pB4zwohETzFnmlpe6yd2lSc+0/46IYZRB/chUwxUZY= +github.com/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y= +github.com/prometheus/client_golang v1.20.5/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE= +github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= +github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= +github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ2Io= +github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I= +github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= +github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= github.com/radovskyb/watcher v1.0.7 h1:AYePLih6dpmS32vlHfhCeli8127LzkIgwJGcwwe8tUE= github.com/radovskyb/watcher v1.0.7/go.mod h1:78okwvY5wPdzcb1UYnip1pvrZNIVEIh/Cm+ZuvsUYIg= github.com/riobard/go-bloom v0.0.0-20200614022211-cdc8013cb5b3 h1:f/FNXud6gA3MNr8meMVVGxhp+QBTqY91tM8HjEuMjGg= github.com/riobard/go-bloom v0.0.0-20200614022211-cdc8013cb5b3/go.mod h1:HgjTstvQsPGkxUsCd2KWxErBblirPizecHcpD3ffK+s= -github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= -github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= +github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= +github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= github.com/sajari/fuzzy v1.0.0 h1:+FmwVvJErsd0d0hAPlj4CxqxUtQY/fOoY0DwX4ykpRY= github.com/sajari/fuzzy v1.0.0/go.mod h1:OjYR6KxoWOe9+dOlXeiCJd4dIbED4Oo8wpS89o0pwOo= github.com/shadowsocks/go-shadowsocks2 v0.1.5 h1:PDSQv9y2S85Fl7VBeOMF9StzeXZyK1HakRm86CUbr28= @@ -83,36 +89,32 @@ github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= -github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= -github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ= github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210220033148-5ea612d1eb83/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= -golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= -golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= +golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc= +golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= -golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= +golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= -golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= +golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= -golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q= -golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= +golang.org/x/term v0.28.0 h1:/Ts8HFuMR2E6IP/jlo7QVLZHggjKQbhu/7H0LJFr3Gg= +golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= -google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= -google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng= -google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +google.golang.org/protobuf v1.36.3 h1:82DV7MYdb8anAVi3qge1wSnMDrnKK7ebr+I0hHRN1BU= +google.golang.org/protobuf v1.36.3/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= From b673773f29a9a4e3ddadca188278b0d07f53a58d Mon Sep 17 00:00:00 2001 From: lunarthegrey Date: Sat, 26 Jul 2025 23:37:57 -0500 Subject: [PATCH 11/50] Attempt to fix dynamic access key display issues --- src/shadowbox/server/manager_service.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/shadowbox/server/manager_service.ts b/src/shadowbox/server/manager_service.ts index 678526c52..3fa671113 100644 --- a/src/shadowbox/server/manager_service.ts +++ b/src/shadowbox/server/manager_service.ts @@ -679,10 +679,15 @@ export class ShadowsocksManagerService { return next(new restifyErrors.NotImplementedError('WebSocket configuration not available')); } - // Send YAML response with proper content type - // Using restify's built-in content type handling - res.send(HttpSuccess.OK, yamlConfig); - next(); + // Send raw YAML response without JSON encoding + // We need to bypass Restify's JSON serialization for this endpoint + res.setHeader('Content-Type', 'text/yaml; charset=utf-8'); + res.statusCode = HttpSuccess.OK; + // Write raw response to avoid JSON encoding + res.write(yamlConfig); + res.end(); + // Don't call next() after ending the response + return; } catch (error) { logging.error(error); if (error instanceof errors.AccessKeyNotFound) { From f0e75c8b2bc3183434a2683f97865f2341f4a243 Mon Sep 17 00:00:00 2001 From: lunarthegrey Date: Sat, 26 Jul 2025 23:41:43 -0500 Subject: [PATCH 12/50] Attempt to fix dynamic access key display issues --- src/shadowbox/server/manager_service.ts | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/src/shadowbox/server/manager_service.ts b/src/shadowbox/server/manager_service.ts index 3fa671113..aa4eb6b75 100644 --- a/src/shadowbox/server/manager_service.ts +++ b/src/shadowbox/server/manager_service.ts @@ -681,11 +681,19 @@ export class ShadowsocksManagerService { // Send raw YAML response without JSON encoding // We need to bypass Restify's JSON serialization for this endpoint - res.setHeader('Content-Type', 'text/yaml; charset=utf-8'); - res.statusCode = HttpSuccess.OK; + // Cast to access underlying Node.js response methods + const nodeResponse = res as unknown as { + setHeader: (name: string, value: string) => void; + statusCode: number; + write: (data: string) => void; + end: () => void; + }; + + nodeResponse.setHeader('Content-Type', 'text/yaml; charset=utf-8'); + nodeResponse.statusCode = HttpSuccess.OK; // Write raw response to avoid JSON encoding - res.write(yamlConfig); - res.end(); + nodeResponse.write(yamlConfig); + nodeResponse.end(); // Don't call next() after ending the response return; } catch (error) { From 465a8cde423c2ff20bb7a7004d12428906cdf6eb Mon Sep 17 00:00:00 2001 From: lunarthegrey Date: Sat, 26 Jul 2025 23:53:30 -0500 Subject: [PATCH 13/50] Rethink how dynamic access keys are displayed --- src/shadowbox/server/manager_service.ts | 82 ++++++++----------------- 1 file changed, 27 insertions(+), 55 deletions(-) diff --git a/src/shadowbox/server/manager_service.ts b/src/shadowbox/server/manager_service.ts index aa4eb6b75..1a02c87d2 100644 --- a/src/shadowbox/server/manager_service.ts +++ b/src/shadowbox/server/manager_service.ts @@ -177,10 +177,6 @@ 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)); @@ -431,8 +427,34 @@ export class ShadowsocksManagerService { logging.debug(`getAccessKey request ${JSON.stringify(req.params)}`); const accessKeyId = validateAccessKeyId(req.params.id); const accessKey = this.accessKeys.getAccessKey(accessKeyId); + + // Check if this is a WebSocket-enabled key + if (accessKey.websocket?.enabled) { + // Generate and return YAML for WebSocket keys + const serverWithWebSocket = this.shadowsocksServer as ShadowsocksServer & { + generateDynamicAccessKeyYaml?: (proxyParams: {encryptionMethod: string; password: string}, websocket?: WebSocketConfig) => string | null; + }; + const yamlConfig = serverWithWebSocket.generateDynamicAccessKeyYaml?.(accessKey.proxyParams, accessKey.websocket); + + if (yamlConfig) { + // Return raw YAML for WebSocket keys + const nodeResponse = res as unknown as { + setHeader: (name: string, value: string) => void; + statusCode: number; + write: (data: string) => void; + end: () => void; + }; + + nodeResponse.setHeader('Content-Type', 'text/yaml; charset=utf-8'); + nodeResponse.statusCode = HttpSuccess.OK; + nodeResponse.write(yamlConfig); + nodeResponse.end(); + return; + } + } + + // Return JSON for traditional keys const accessKeyJson = accessKeyToApiJson(accessKey); - logging.debug(`getAccessKey response ${JSON.stringify(accessKeyJson)}`); res.send(HttpSuccess.OK, accessKeyJson); return next(); @@ -654,56 +676,6 @@ 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 - // Type assertion for the extended server interface with WebSocket support - const serverWithWebSocket = this.shadowsocksServer as ShadowsocksServer & { - generateDynamicAccessKeyYaml?: (proxyParams: {encryptionMethod: string; password: string}, websocket?: WebSocketConfig) => string | null; - }; - const yamlConfig = serverWithWebSocket.generateDynamicAccessKeyYaml?.(accessKey.proxyParams, accessKey.websocket); - - if (!yamlConfig) { - return next(new restifyErrors.NotImplementedError('WebSocket configuration not available')); - } - - // Send raw YAML response without JSON encoding - // We need to bypass Restify's JSON serialization for this endpoint - // Cast to access underlying Node.js response methods - const nodeResponse = res as unknown as { - setHeader: (name: string, value: string) => void; - statusCode: number; - write: (data: string) => void; - end: () => void; - }; - - nodeResponse.setHeader('Content-Type', 'text/yaml; charset=utf-8'); - nodeResponse.statusCode = HttpSuccess.OK; - // Write raw response to avoid JSON encoding - nodeResponse.write(yamlConfig); - nodeResponse.end(); - // Don't call next() after ending the response - return; - } 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 { From e6eb42f53c24d3d3101d0a613176ad1a0f421156 Mon Sep 17 00:00:00 2001 From: lunarthegrey Date: Sun, 27 Jul 2025 19:02:40 -0500 Subject: [PATCH 14/50] Update .gitignore --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index b0b6e5184..d6ed4f73d 100644 --- a/.gitignore +++ b/.gitignore @@ -8,4 +8,5 @@ /task macos-signing-certificate.p12 node_modules/ -third_party/shellcheck/download/ \ No newline at end of file +third_party/shellcheck/download/ +CLAUDE.md \ No newline at end of file From fe122287e1a65b31850951737a1a5a386edccacf Mon Sep 17 00:00:00 2001 From: lunarthegrey Date: Tue, 29 Jul 2025 00:48:38 -0500 Subject: [PATCH 15/50] Remove dynamicAccessKeyUrl as it's not necessary --- src/shadowbox/server/api.yml | 3 --- src/shadowbox/server/manager_service.ts | 6 ------ 2 files changed, 9 deletions(-) diff --git a/src/shadowbox/server/api.yml b/src/shadowbox/server/api.yml index 69a48884f..e11c096b8 100644 --- a/src/shadowbox/server/api.yml +++ b/src/shadowbox/server/api.yml @@ -700,6 +700,3 @@ components: 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/manager_service.ts b/src/shadowbox/server/manager_service.ts index 1a02c87d2..a09e198d4 100644 --- a/src/shadowbox/server/manager_service.ts +++ b/src/shadowbox/server/manager_service.ts @@ -41,7 +41,6 @@ interface AccessKeyJson { dataLimit: DataLimit; accessUrl: string; websocket?: WebSocketConfig; - dynamicAccessKeyUrl?: string; } // Creates a AccessKey response. @@ -66,11 +65,6 @@ 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; From f64efdce12aa8923b4e225ec0401b9053f9c6d7d Mon Sep 17 00:00:00 2001 From: lunarthegrey Date: Mon, 4 Aug 2025 23:19:41 -0500 Subject: [PATCH 16/50] Improve error handling for WebSocket keys --- src/shadowbox/server/manager_service.ts | 48 +++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/src/shadowbox/server/manager_service.ts b/src/shadowbox/server/manager_service.ts index a09e198d4..2a65b5c7e 100644 --- a/src/shadowbox/server/manager_service.ts +++ b/src/shadowbox/server/manager_service.ts @@ -323,6 +323,54 @@ function validateWebSocketConfig(websocket: unknown): WebSocketConfig | undefine 'websocket.tls must be a boolean' ); } + + // Additional validation for domain format when provided + if ('domain' in config && config.domain) { + const domain = config.domain as string; + // Use the same hostname validation regex as setHostnameForAccessKeys + const hostnameRegex = + /^([a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?\.)*[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?$/; + if (!hostnameRegex.test(domain) && !ipRegex({exact: true}).test(domain)) { + throw new restifyErrors.InvalidArgumentError( + {statusCode: 400}, + 'websocket.domain must be a valid domain name or IP address' + ); + } + } + + // Validate paths format + const validatePath = (path: string, fieldName: string) => { + if (!path.startsWith('/')) { + throw new restifyErrors.InvalidArgumentError( + {statusCode: 400}, + `websocket.${fieldName} must start with /` + ); + } + if (path.includes('..')) { + throw new restifyErrors.InvalidArgumentError( + {statusCode: 400}, + `websocket.${fieldName} cannot contain ..` + ); + } + }; + + if ('tcpPath' in config && config.tcpPath) { + validatePath(config.tcpPath as string, 'tcpPath'); + } + + if ('udpPath' in config && config.udpPath) { + validatePath(config.udpPath as string, 'udpPath'); + } + + // When WebSocket is enabled, domain is required + if (config.enabled === true) { + if (!config.domain) { + throw new restifyErrors.InvalidArgumentError( + {statusCode: 400}, + 'websocket.domain is required when WebSocket is enabled' + ); + } + } return config as unknown as WebSocketConfig; } From 2b8462299723175cda2058f4480f772b312220cd Mon Sep 17 00:00:00 2001 From: lunarthegrey Date: Thu, 28 Aug 2025 21:24:09 -0500 Subject: [PATCH 17/50] Migrate WebSocket support to listener-based API model --- src/shadowbox/model/access_key.ts | 23 +- src/shadowbox/server/api.yml | 122 ++++- src/shadowbox/server/main.ts | 6 +- src/shadowbox/server/manager_service.ts | 477 +++++++++++++----- .../server/outline_shadowsocks_server.ts | 67 ++- src/shadowbox/server/server_access_key.ts | 26 +- src/shadowbox/server/server_config.ts | 27 + 7 files changed, 540 insertions(+), 208 deletions(-) diff --git a/src/shadowbox/model/access_key.ts b/src/shadowbox/model/access_key.ts index 390244b50..826d60513 100644 --- a/src/shadowbox/model/access_key.ts +++ b/src/shadowbox/model/access_key.ts @@ -14,19 +14,8 @@ 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; -} +// Listener types that an access key can use +export type ListenerType = 'tcp' | 'udp' | 'websocket-stream' | 'websocket-packet'; // Parameters needed to access a Shadowsocks proxy. export interface ProxyParams { @@ -57,8 +46,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; + // Listeners enabled for this access key + readonly listeners?: ListenerType[]; } export interface AccessKeyCreateParams { @@ -74,8 +63,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; + // Listeners to enable for this access key. + readonly listeners?: ListenerType[]; } export interface AccessKeyRepository { diff --git a/src/shadowbox/server/api.yml b/src/shadowbox/server/api.yml index e11c096b8..4bf16df2c 100644 --- a/src/shadowbox/server/api.yml +++ b/src/shadowbox/server/api.yml @@ -99,6 +99,58 @@ paths: '409': description: The requested port was already in use by another service. + /server/listeners-for-new-access-keys: + put: + description: Sets the listeners configuration for newly created access keys. Supports different ports for TCP and UDP, and WebSocket paths. + tags: + - Access Key + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/ListenersConfig" + examples: + 'Basic TCP/UDP': + value: '{"tcp": {"port": 443}, "udp": {"port": 443}}' + 'With WebSocket': + value: '{"tcp": {"port": 443}, "udp": {"port": 443}, "websocketStream": {"path": "/tcp", "webServerPort": 8080}, "websocketPacket": {"path": "/udp", "webServerPort": 8080}}' + 'Different TCP/UDP ports': + value: '{"tcp": {"port": 443}, "udp": {"port": 8443}}' + responses: + '204': + description: The listeners configuration was successfully updated. + '400': + description: Invalid listeners configuration. + '409': + description: One or more requested ports were already in use by another service. + + /server/web-server: + put: + description: Configures the Caddy web server for automatic HTTPS and WebSocket reverse proxy. + tags: + - Server + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/CaddyWebServerConfig" + examples: + 'Enable with auto HTTPS': + value: '{"enabled": true, "autoHttps": true, "email": "admin@example.com", "domain": "example.com"}' + 'Basic configuration': + value: '{"enabled": true, "domain": "example.com"}' + 'Custom admin endpoint': + value: '{"enabled": true, "adminEndpoint": "localhost:2019", "domain": "example.com"}' + responses: + '204': + description: The web server configuration was successfully updated. + '400': + description: Invalid web server configuration. + '500': + description: Failed to configure Caddy via its API. + /server/access-key-data-limit: put: description: Sets a data transfer limit for all access keys @@ -278,13 +330,18 @@ paths: type: integer limit: $ref: "#/components/schemas/DataLimit" - websocket: - $ref: "#/components/schemas/WebSocketConfig" + listeners: + type: array + items: + type: string + enum: ['tcp', 'udp', 'websocket-stream', 'websocket-packet'] examples: 'No params specified': value: '{"method":"aes-192-gcm"}' 'Provide params': value: '{"method":"aes-192-gcm","name":"First","password":"8iu8V8EeoFVpwQvQeS9wiD","port": 12345,"limit":{"bytes":10000}}' + 'With listeners': + value: '{"name":"WSS User","listeners":["tcp","udp","websocket-stream","websocket-packet"]}' responses: '201': description: The newly created access key @@ -351,8 +408,6 @@ 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}}' @@ -663,24 +718,54 @@ components: type: integer minimum: 0 - WebSocketConfig: + ListenerConfig: + properties: + port: + type: integer + minimum: 1 + maximum: 65535 + description: Port number for the listener + path: + type: string + description: Path for WebSocket listeners (e.g., "/tcp" or "/udp") + webServerPort: + type: integer + minimum: 1 + maximum: 65535 + description: Port for the internal WebSocket server + + ListenersConfig: + properties: + tcp: + $ref: "#/components/schemas/ListenerConfig" + description: TCP listener configuration + udp: + $ref: "#/components/schemas/ListenerConfig" + description: UDP listener configuration + websocketStream: + $ref: "#/components/schemas/ListenerConfig" + description: WebSocket stream (TCP) listener configuration + websocketPacket: + $ref: "#/components/schemas/ListenerConfig" + description: WebSocket packet (UDP) listener configuration + + CaddyWebServerConfig: properties: enabled: type: boolean - description: Whether WebSocket transport is enabled for this access key - tcpPath: + description: Whether Caddy web server integration is enabled + adminEndpoint: type: string - description: Path for TCP over WebSocket (e.g., "/tcp-path") - udpPath: + description: Caddy admin API endpoint (default "localhost:2019") + autoHttps: + type: boolean + description: Whether to enable automatic HTTPS via ACME + email: type: string - description: Path for UDP over WebSocket (e.g., "/udp-path") + description: Email address for ACME registration 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 + description: Domain name for automatic HTTPS AccessKey: required: @@ -698,5 +783,8 @@ components: type: string accessUrl: type: string - websocket: - $ref: "#/components/schemas/WebSocketConfig" + listeners: + type: array + items: + type: string + enum: ['tcp', 'udp', 'websocket-stream', 'websocket-packet'] diff --git a/src/shadowbox/server/main.ts b/src/shadowbox/server/main.ts index 2ff44973d..57c55e52d 100644 --- a/src/shadowbox/server/main.ts +++ b/src/shadowbox/server/main.ts @@ -164,8 +164,10 @@ async function main() { } // Configure WebSocket support if enabled - // TODO: Make this configurable via environment variable or server config - const webSocketPort = 8080; // Default internal WebSocket server port + const listenersConfig = serverConfig.data().listenersForNewAccessKeys; + const webSocketPort = listenersConfig?.websocketStream?.webServerPort || + listenersConfig?.websocketPacket?.webServerPort || + 8080; // Default internal WebSocket server port shadowsocksServer.configureWebSocket(webSocketPort); const isReplayProtectionEnabled = createRolloutTracker(serverConfig).isRolloutEnabled( diff --git a/src/shadowbox/server/manager_service.ts b/src/shadowbox/server/manager_service.ts index 2a65b5c7e..c8c9b6155 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, WebSocketConfig} from '../model/access_key'; +import {AccessKey, AccessKeyRepository, DataLimit, ListenerType} from '../model/access_key'; import * as errors from '../model/errors'; import * as version from './version'; @@ -40,7 +40,7 @@ interface AccessKeyJson { method: string; dataLimit: DataLimit; accessUrl: string; - websocket?: WebSocketConfig; + listeners?: ListenerType[]; } // Creates a AccessKey response. @@ -63,8 +63,8 @@ function accessKeyToApiJson(accessKey: AccessKey): AccessKeyJson { ), }; - if (accessKey.websocket) { - result.websocket = accessKey.websocket; + if (accessKey.listeners) { + result.listeners = accessKey.listeners; } return result; @@ -155,6 +155,14 @@ export function bindService( `${apiPrefix}/server/port-for-new-access-keys`, service.setPortForNewAccessKeys.bind(service) ); + apiServer.put( + `${apiPrefix}/server/listeners-for-new-access-keys`, + service.setListenersForNewAccessKeys.bind(service) + ); + apiServer.put( + `${apiPrefix}/server/web-server`, + service.configureCaddyWebServer.bind(service) + ); apiServer.post(`${apiPrefix}/access-keys`, service.createNewAccessKey.bind(service)); apiServer.put(`${apiPrefix}/access-keys/:id`, service.createAccessKey.bind(service)); @@ -270,111 +278,6 @@ 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 Record; - - // 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' - ); - } - - // Additional validation for domain format when provided - if ('domain' in config && config.domain) { - const domain = config.domain as string; - // Use the same hostname validation regex as setHostnameForAccessKeys - const hostnameRegex = - /^([a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?\.)*[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?$/; - if (!hostnameRegex.test(domain) && !ipRegex({exact: true}).test(domain)) { - throw new restifyErrors.InvalidArgumentError( - {statusCode: 400}, - 'websocket.domain must be a valid domain name or IP address' - ); - } - } - - // Validate paths format - const validatePath = (path: string, fieldName: string) => { - if (!path.startsWith('/')) { - throw new restifyErrors.InvalidArgumentError( - {statusCode: 400}, - `websocket.${fieldName} must start with /` - ); - } - if (path.includes('..')) { - throw new restifyErrors.InvalidArgumentError( - {statusCode: 400}, - `websocket.${fieldName} cannot contain ..` - ); - } - }; - - if ('tcpPath' in config && config.tcpPath) { - validatePath(config.tcpPath as string, 'tcpPath'); - } - - if ('udpPath' in config && config.udpPath) { - validatePath(config.udpPath as string, 'udpPath'); - } - - // When WebSocket is enabled, domain is required - if (config.enabled === true) { - if (!config.domain) { - throw new restifyErrors.InvalidArgumentError( - {statusCode: 400}, - 'websocket.domain is required when WebSocket is enabled' - ); - } - } - - return config as unknown 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. @@ -470,28 +373,52 @@ export class ShadowsocksManagerService { const accessKeyId = validateAccessKeyId(req.params.id); const accessKey = this.accessKeys.getAccessKey(accessKeyId); - // Check if this is a WebSocket-enabled key - if (accessKey.websocket?.enabled) { + // Check if this key uses WebSocket listeners + const hasWebSocketListeners = accessKey.listeners && ( + accessKey.listeners.indexOf('websocket-stream') !== -1 || + accessKey.listeners.indexOf('websocket-packet') !== -1 + ); + + if (hasWebSocketListeners) { // Generate and return YAML for WebSocket keys - const serverWithWebSocket = this.shadowsocksServer as ShadowsocksServer & { - generateDynamicAccessKeyYaml?: (proxyParams: {encryptionMethod: string; password: string}, websocket?: WebSocketConfig) => string | null; - }; - const yamlConfig = serverWithWebSocket.generateDynamicAccessKeyYaml?.(accessKey.proxyParams, accessKey.websocket); + const domain = this.serverConfig.data().caddyWebServer?.domain || + this.serverConfig.data().hostname; + const listenersConfig = this.serverConfig.data().listenersForNewAccessKeys; - if (yamlConfig) { - // Return raw YAML for WebSocket keys - const nodeResponse = res as unknown as { - setHeader: (name: string, value: string) => void; - statusCode: number; - write: (data: string) => void; - end: () => void; + if (domain && listenersConfig) { + const serverWithWebSocket = this.shadowsocksServer as ShadowsocksServer & { + generateDynamicAccessKeyYaml?: ( + proxyParams: {encryptionMethod: string; password: string}, + domain: string, + tcpPath: string, + udpPath: string, + tls: boolean + ) => string | null; }; - nodeResponse.setHeader('Content-Type', 'text/yaml; charset=utf-8'); - nodeResponse.statusCode = HttpSuccess.OK; - nodeResponse.write(yamlConfig); - nodeResponse.end(); - return; + const yamlConfig = serverWithWebSocket.generateDynamicAccessKeyYaml?.( + accessKey.proxyParams, + domain, + listenersConfig.websocketStream?.path || '/tcp', + listenersConfig.websocketPacket?.path || '/udp', + this.serverConfig.data().caddyWebServer?.autoHttps !== false + ); + + if (yamlConfig) { + // Return raw YAML for WebSocket keys + const nodeResponse = res as unknown as { + setHeader: (name: string, value: string) => void; + statusCode: number; + write: (data: string) => void; + end: () => void; + }; + + nodeResponse.setHeader('Content-Type', 'text/yaml; charset=utf-8'); + nodeResponse.statusCode = HttpSuccess.OK; + nodeResponse.write(yamlConfig); + nodeResponse.end(); + return; + } } } @@ -528,7 +455,39 @@ 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); + + // Validate listeners if provided + let listeners = req.params.listeners as string[] | undefined; + if (listeners) { + if (!Array.isArray(listeners)) { + throw new restifyErrors.InvalidArgumentError( + {statusCode: 400}, + 'listeners must be an array' + ); + } + const validListeners = ['tcp', 'udp', 'websocket-stream', 'websocket-packet']; + for (const listener of listeners) { + if (validListeners.indexOf(listener) === -1) { + throw new restifyErrors.InvalidArgumentError( + {statusCode: 400}, + `Invalid listener type: ${listener}` + ); + } + } + } else { + // If no listeners specified, use default listeners based on server config + const serverListeners = this.serverConfig.data().listenersForNewAccessKeys; + if (serverListeners) { + listeners = []; + if (serverListeners.tcp) listeners.push('tcp'); + if (serverListeners.udp) listeners.push('udp'); + if (serverListeners.websocketStream) listeners.push('websocket-stream'); + if (serverListeners.websocketPacket) listeners.push('websocket-packet'); + } else { + // Default to TCP and UDP if nothing is configured + listeners = ['tcp', 'udp']; + } + } const accessKeyJson = accessKeyToApiJson( await this.accessKeys.createNewAccessKey({ @@ -538,7 +497,7 @@ export class ShadowsocksManagerService { dataLimit, password, portNumber, - websocket, + listeners: listeners as ListenerType[], }) ); return accessKeyJson; @@ -615,6 +574,112 @@ export class ShadowsocksManagerService { } await this.accessKeys.setPortForNewAccessKeys(port); this.serverConfig.data().portForNewAccessKeys = port; + // Also update listeners config for backward compatibility + if (!this.serverConfig.data().listenersForNewAccessKeys) { + this.serverConfig.data().listenersForNewAccessKeys = {}; + } + this.serverConfig.data().listenersForNewAccessKeys.tcp = { port }; + this.serverConfig.data().listenersForNewAccessKeys.udp = { port }; + this.serverConfig.write(); + res.send(HttpSuccess.NO_CONTENT); + next(); + } catch (error) { + logging.error(error); + if (error instanceof errors.InvalidPortNumber) { + return next(new restifyErrors.InvalidArgumentError({statusCode: 400}, error.message)); + } else if (error instanceof errors.PortUnavailable) { + return next(new restifyErrors.ConflictError(error.message)); + } else if (error instanceof restifyErrors.HttpError) { + return next(error); + } + return next(new restifyErrors.InternalServerError(error)); + } + } + + // Sets the listeners for new access keys + async setListenersForNewAccessKeys( + req: RequestType, + res: ResponseType, + next: restify.Next + ): Promise { + try { + logging.debug(`setListenersForNewAccessKeys request ${JSON.stringify(req.params)}`); + + const listeners = req.params as any; + if (!listeners || typeof listeners !== 'object') { + return next( + new restifyErrors.InvalidArgumentError({statusCode: 400}, 'Invalid listeners configuration') + ); + } + + // Validate TCP listener + if (listeners.tcp) { + const tcpPort = listeners.tcp.port; + if (tcpPort !== undefined) { + if (typeof tcpPort !== 'number' || tcpPort < 1 || tcpPort > 65535) { + return next( + new restifyErrors.InvalidArgumentError({statusCode: 400}, 'Invalid TCP port') + ); + } + } + } + + // Validate UDP listener + if (listeners.udp) { + const udpPort = listeners.udp.port; + if (udpPort !== undefined) { + if (typeof udpPort !== 'number' || udpPort < 1 || udpPort > 65535) { + return next( + new restifyErrors.InvalidArgumentError({statusCode: 400}, 'Invalid UDP port') + ); + } + } + } + + // Validate WebSocket listeners + if (listeners.websocketStream) { + if (!listeners.websocketStream.path || typeof listeners.websocketStream.path !== 'string') { + return next( + new restifyErrors.InvalidArgumentError({statusCode: 400}, 'WebSocket stream path is required') + ); + } + if (!listeners.websocketStream.path.startsWith('/')) { + return next( + new restifyErrors.InvalidArgumentError({statusCode: 400}, 'WebSocket stream path must start with /') + ); + } + const wsPort = listeners.websocketStream.webServerPort; + if (wsPort !== undefined) { + if (typeof wsPort !== 'number' || wsPort < 1 || wsPort > 65535) { + return next( + new restifyErrors.InvalidArgumentError({statusCode: 400}, 'Invalid WebSocket server port') + ); + } + } + } + + if (listeners.websocketPacket) { + if (!listeners.websocketPacket.path || typeof listeners.websocketPacket.path !== 'string') { + return next( + new restifyErrors.InvalidArgumentError({statusCode: 400}, 'WebSocket packet path is required') + ); + } + if (!listeners.websocketPacket.path.startsWith('/')) { + return next( + new restifyErrors.InvalidArgumentError({statusCode: 400}, 'WebSocket packet path must start with /') + ); + } + } + + // Store the listeners configuration + this.serverConfig.data().listenersForNewAccessKeys = listeners; + + // Update legacy portForNewAccessKeys if TCP port is set + if (listeners.tcp?.port) { + this.serverConfig.data().portForNewAccessKeys = listeners.tcp.port; + await this.accessKeys.setPortForNewAccessKeys(listeners.tcp.port); + } + this.serverConfig.write(); res.send(HttpSuccess.NO_CONTENT); next(); @@ -631,6 +696,160 @@ export class ShadowsocksManagerService { } } + // Configure Caddy web server for automatic HTTPS + async configureCaddyWebServer( + req: RequestType, + res: ResponseType, + next: restify.Next + ): Promise { + try { + logging.debug(`configureCaddyWebServer request ${JSON.stringify(req.params)}`); + + const config = req.params as any; + if (!config || typeof config !== 'object') { + return next( + new restifyErrors.InvalidArgumentError({statusCode: 400}, 'Invalid Caddy configuration') + ); + } + + // Validate configuration + if (config.enabled !== undefined && typeof config.enabled !== 'boolean') { + return next( + new restifyErrors.InvalidArgumentError({statusCode: 400}, 'enabled must be a boolean') + ); + } + + if (config.autoHttps !== undefined && typeof config.autoHttps !== 'boolean') { + return next( + new restifyErrors.InvalidArgumentError({statusCode: 400}, 'autoHttps must be a boolean') + ); + } + + if (config.email && typeof config.email !== 'string') { + return next( + new restifyErrors.InvalidArgumentError({statusCode: 400}, 'email must be a string') + ); + } + + if (config.domain && typeof config.domain !== 'string') { + return next( + new restifyErrors.InvalidArgumentError({statusCode: 400}, 'domain must be a string') + ); + } + + // Store Caddy configuration + this.serverConfig.data().caddyWebServer = { + enabled: config.enabled ?? false, + adminEndpoint: config.adminEndpoint || 'localhost:2019', + autoHttps: config.autoHttps ?? false, + email: config.email, + domain: config.domain + }; + + // If enabled and we have WebSocket listeners, configure Caddy + if (config.enabled && this.serverConfig.data().listenersForNewAccessKeys?.websocketStream) { + await this.configureCaddyRoutes(); + } + + this.serverConfig.write(); + res.send(HttpSuccess.NO_CONTENT); + next(); + } catch (error) { + logging.error(error); + return next(new restifyErrors.InternalServerError(error)); + } + } + + // Helper method to configure Caddy routes via its API + private async configureCaddyRoutes(): Promise { + const caddyConfig = this.serverConfig.data().caddyWebServer; + const listeners = this.serverConfig.data().listenersForNewAccessKeys; + + if (!caddyConfig?.enabled || !listeners) { + return; + } + + const adminEndpoint = caddyConfig.adminEndpoint || 'localhost:2019'; + const domain = caddyConfig.domain || this.serverConfig.data().hostname; + + // Build Caddy configuration + const paths: string[] = []; + if (listeners.websocketStream?.path) { + paths.push(listeners.websocketStream.path); + } + if (listeners.websocketPacket?.path) { + paths.push(listeners.websocketPacket.path); + } + + if (paths.length === 0) { + return; + } + + const wsPort = listeners.websocketStream?.webServerPort || 8080; + + const caddyServerConfig = { + listen: [':443'], + routes: [{ + match: [{ path: paths }], + handle: [{ + handler: 'reverse_proxy', + upstreams: [{ dial: `localhost:${wsPort}` }], + headers: { + request: { + set: { + 'Upgrade': ['websocket'], + 'Connection': ['Upgrade'] + } + } + } + }] + }] + }; + + // Add automatic HTTPS if configured + if (caddyConfig.autoHttps && domain) { + (caddyServerConfig as any).automatic_https = { + email: caddyConfig.email + }; + } + + // Send configuration to Caddy via its admin API + try { + const http = require('http'); + const data = JSON.stringify(caddyServerConfig); + + const options = { + hostname: adminEndpoint.split(':')[0], + port: parseInt(adminEndpoint.split(':')[1] || '2019'), + path: `/config/apps/http/servers/outline`, + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + 'Content-Length': data.length + } + }; + + await new Promise((resolve, reject) => { + const req = http.request(options, (res) => { + if (res.statusCode >= 200 && res.statusCode < 300) { + resolve(true); + } else { + reject(new Error(`Caddy API returned status ${res.statusCode}`)); + } + }); + + req.on('error', reject); + req.write(data); + req.end(); + }); + + logging.info('Successfully configured Caddy web server'); + } catch (error) { + logging.error(`Failed to configure Caddy: ${error}`); + throw error; + } + } + // Removes an existing access key removeAccessKey(req: RequestType, res: ResponseType, next: restify.Next): void { try { diff --git a/src/shadowbox/server/outline_shadowsocks_server.ts b/src/shadowbox/server/outline_shadowsocks_server.ts index 80059e91a..d8157a06e 100644 --- a/src/shadowbox/server/outline_shadowsocks_server.ts +++ b/src/shadowbox/server/outline_shadowsocks_server.ts @@ -21,15 +21,9 @@ 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; - }; +// Extended interface for access keys with listeners +export interface ShadowsocksAccessKeyWithListeners extends ShadowsocksAccessKey { + listeners?: string[]; } // Configuration types for outline-ss-server @@ -145,9 +139,14 @@ export class OutlineShadowsocksServer implements ShadowsocksServer { private writeConfigFile(keys: ShadowsocksAccessKey[]): Promise { return new Promise((resolve, reject) => { - // Check if any key has WebSocket configuration - const extendedKeys = keys as ShadowsocksAccessKeyWithWebSocket[]; - const hasWebSocketKeys = extendedKeys.some(key => key.websocket?.enabled); + // Check if any key has WebSocket listeners + const extendedKeys = keys as ShadowsocksAccessKeyWithListeners[]; + const hasWebSocketKeys = extendedKeys.some(key => + key.listeners && ( + key.listeners.indexOf('websocket-stream') !== -1 || + key.listeners.indexOf('websocket-packet') !== -1 + ) + ); let config: ServerConfig; @@ -180,9 +179,9 @@ export class OutlineShadowsocksServer implements ShadowsocksServer { }); } - private generateWebSocketConfig(keys: ShadowsocksAccessKeyWithWebSocket[]): WebSocketConfig { + private generateWebSocketConfig(keys: ShadowsocksAccessKeyWithListeners[]): WebSocketConfig { // Group keys by their listener configuration - const serviceGroups = new Map(); + const serviceGroups = new Map(); // Process each key for (const key of keys) { @@ -193,9 +192,16 @@ export class OutlineShadowsocksServer implements ShadowsocksServer { continue; } - if (key.websocket?.enabled) { - // Group WebSocket-enabled keys by their paths - const groupKey = `ws:${key.websocket.tcpPath || '/tcp'}:${key.websocket.udpPath || '/udp'}`; + // Check if key has WebSocket listeners + const hasWebSocketListeners = key.listeners && ( + key.listeners.indexOf('websocket-stream') !== -1 || + key.listeners.indexOf('websocket-packet') !== -1 + ); + + if (hasWebSocketListeners) { + // Group WebSocket-enabled keys + // For now, use default paths - in future, could be configurable per key + const groupKey = `ws:/tcp:/udp`; if (!serviceGroups.has(groupKey)) { serviceGroups.set(groupKey, []); } @@ -217,10 +223,12 @@ export class OutlineShadowsocksServer implements ShadowsocksServer { // Add web server configuration if any WebSocket keys exist if (Array.from(serviceGroups.keys()).some(k => k.startsWith('ws:'))) { + // Use configurable port or default to 8080 + const webServerPort = this.webSocketConfig?.webServerPort || 8080; config.web = { servers: [{ id: 'outline-ws-server', - listen: [`127.0.0.1:${this.webSocketConfig!.webServerPort}`] + listen: [`127.0.0.1:${webServerPort}`] }] }; } @@ -271,15 +279,24 @@ export class OutlineShadowsocksServer implements ShadowsocksServer { /** * Generates dynamic access key YAML content for a specific access key with WebSocket support. * @param proxyParams The proxy parameters containing cipher and password - * @param websocket The WebSocket configuration - * @returns The YAML content as a string, or null if the key doesn't have WebSocket enabled + * @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 */ - generateDynamicAccessKeyYaml(proxyParams: {encryptionMethod: string; password: string}, websocket?: {enabled: boolean; tcpPath?: string; udpPath?: string; domain?: string; tls?: boolean}): string | null { - if (!websocket?.enabled || !websocket.domain) { + generateDynamicAccessKeyYaml( + proxyParams: {encryptionMethod: string; password: string}, + domain: string, + tcpPath: string, + udpPath: string, + tls: boolean + ): string | null { + if (!domain) { return null; } - const protocol = websocket.tls !== false ? 'wss' : 'ws'; + const protocol = tls ? 'wss' : 'ws'; const config = { transport: { @@ -288,7 +305,7 @@ export class OutlineShadowsocksServer implements ShadowsocksServer { $type: 'shadowsocks', endpoint: { $type: 'websocket', - url: `${protocol}://${websocket.domain}${websocket.tcpPath || '/tcp'}` + url: `${protocol}://${domain}${tcpPath}` }, cipher: proxyParams.encryptionMethod, secret: proxyParams.password @@ -297,7 +314,7 @@ export class OutlineShadowsocksServer implements ShadowsocksServer { $type: 'shadowsocks', endpoint: { $type: 'websocket', - url: `${protocol}://${websocket.domain}${websocket.udpPath || '/udp'}` + url: `${protocol}://${domain}${udpPath}` }, cipher: proxyParams.encryptionMethod, secret: proxyParams.password diff --git a/src/shadowbox/server/server_access_key.ts b/src/shadowbox/server/server_access_key.ts index a09f17708..bc355e11f 100644 --- a/src/shadowbox/server/server_access_key.ts +++ b/src/shadowbox/server/server_access_key.ts @@ -25,8 +25,8 @@ import { AccessKeyId, AccessKeyRepository, DataLimit, + ListenerType, ProxyParams, - WebSocketConfig, } from '../model/access_key'; import * as errors from '../model/errors'; import {ShadowsocksServer} from '../model/shadowsocks_server'; @@ -40,7 +40,7 @@ interface AccessKeyStorageJson { port: number; encryptionMethod?: string; dataLimit?: DataLimit; - websocket?: WebSocketConfig; + listeners?: ListenerType[]; } // The configuration file format as json. @@ -58,7 +58,7 @@ class ServerAccessKey implements AccessKey { public name: string, readonly proxyParams: ProxyParams, public dataLimit?: DataLimit, - public websocket?: WebSocketConfig + public listeners?: ListenerType[] ) {} } @@ -80,7 +80,7 @@ function makeAccessKey(hostname: string, accessKeyJson: AccessKeyStorageJson): A accessKeyJson.name, proxyParams, accessKeyJson.dataLimit, - accessKeyJson.websocket + accessKeyJson.listeners ); } @@ -92,7 +92,7 @@ function accessKeyToStorageJson(accessKey: AccessKey): AccessKeyStorageJson { port: accessKey.proxyParams.portNumber, encryptionMethod: accessKey.proxyParams.encryptionMethod, dataLimit: accessKey.dataLimit, - websocket: accessKey.websocket, + listeners: accessKey.listeners, }; } @@ -239,8 +239,8 @@ export class ServerAccessKeyRepository implements AccessKeyRepository { }; const name = params?.name ?? ''; const dataLimit = params?.dataLimit; - const websocket = params?.websocket; - const accessKey = new ServerAccessKey(id, name, proxyParams, dataLimit, websocket); + const listeners = params?.listeners; + const accessKey = new ServerAccessKey(id, name, proxyParams, dataLimit, listeners); this.accessKeys.push(accessKey); this.saveAccessKeys(); await this.updateServer(); @@ -331,22 +331,12 @@ export class ServerAccessKeyRepository implements AccessKeyRepository { const serverAccessKeys = this.accessKeys .filter((key) => !key.reachedDataLimit) .map((key) => { - const baseKey = { + return { 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); } diff --git a/src/shadowbox/server/server_config.ts b/src/shadowbox/server/server_config.ts index 1828bb48f..1a58e15d4 100644 --- a/src/shadowbox/server/server_config.ts +++ b/src/shadowbox/server/server_config.ts @@ -17,6 +17,29 @@ import * as uuidv4 from 'uuid/v4'; import * as json_config from '../infrastructure/json_config'; import {DataLimit} from '../model/access_key'; +// Listener configuration for new access keys +export interface ListenerConfig { + port?: number; + path?: string; + webServerPort?: number; +} + +export interface ListenersForNewAccessKeys { + tcp?: ListenerConfig; + udp?: ListenerConfig; + websocketStream?: ListenerConfig; + websocketPacket?: ListenerConfig; +} + +// Caddy web server configuration +export interface CaddyWebServerConfig { + enabled?: boolean; + adminEndpoint?: string; // Default: "localhost:2019" + autoHttps?: boolean; + email?: string; // For ACME + domain?: string; // Domain for automatic HTTPS +} + // Serialized format for the server config. // WARNING: Renaming fields will break backwards-compatibility. export interface ServerConfigJson { @@ -30,6 +53,10 @@ export interface ServerConfigJson { createdTimestampMs?: number; // What port number should we use for new access keys? portForNewAccessKeys?: number; + // Listeners configuration for new access keys (supersedes portForNewAccessKeys) + listenersForNewAccessKeys?: ListenersForNewAccessKeys; + // Caddy web server configuration for automatic HTTPS + caddyWebServer?: CaddyWebServerConfig; // Which staged rollouts we should force enabled or disabled. rollouts?: RolloutConfigJson[]; // We don't serialize the shadowbox version, this is obtained dynamically from node. From f423735c32b5f7eeb86f1c104c829db0d6225cad Mon Sep 17 00:00:00 2001 From: lunarthegrey Date: Thu, 28 Aug 2025 21:34:33 -0500 Subject: [PATCH 18/50] Satisfy linting --- src/shadowbox/server/manager_service.ts | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/src/shadowbox/server/manager_service.ts b/src/shadowbox/server/manager_service.ts index c8c9b6155..e4fd51d55 100644 --- a/src/shadowbox/server/manager_service.ts +++ b/src/shadowbox/server/manager_service.ts @@ -25,9 +25,10 @@ import * as errors from '../model/errors'; import * as version from './version'; import {ManagerMetrics} from './manager_metrics'; -import {ServerConfigJson} from './server_config'; +import {ServerConfigJson, ListenersForNewAccessKeys, CaddyWebServerConfig} from './server_config'; import {SharedMetricsPublisher} from './shared_metrics'; import {ShadowsocksServer} from '../model/shadowsocks_server'; +import * as http from 'http'; interface AccessKeyJson { // The unique identifier of this access key. @@ -605,7 +606,7 @@ export class ShadowsocksManagerService { try { logging.debug(`setListenersForNewAccessKeys request ${JSON.stringify(req.params)}`); - const listeners = req.params as any; + const listeners = req.params as unknown as ListenersForNewAccessKeys; if (!listeners || typeof listeners !== 'object') { return next( new restifyErrors.InvalidArgumentError({statusCode: 400}, 'Invalid listeners configuration') @@ -705,7 +706,7 @@ export class ShadowsocksManagerService { try { logging.debug(`configureCaddyWebServer request ${JSON.stringify(req.params)}`); - const config = req.params as any; + const config = req.params as unknown as CaddyWebServerConfig; if (!config || typeof config !== 'object') { return next( new restifyErrors.InvalidArgumentError({statusCode: 400}, 'Invalid Caddy configuration') @@ -807,16 +808,21 @@ export class ShadowsocksManagerService { }; // Add automatic HTTPS if configured + type CaddyServerConfigType = typeof caddyServerConfig & { + automatic_https?: { + email?: string; + }; + }; + const configWithHttps = caddyServerConfig as CaddyServerConfigType; if (caddyConfig.autoHttps && domain) { - (caddyServerConfig as any).automatic_https = { + configWithHttps.automatic_https = { email: caddyConfig.email }; } // Send configuration to Caddy via its admin API try { - const http = require('http'); - const data = JSON.stringify(caddyServerConfig); + const data = JSON.stringify(configWithHttps); const options = { hostname: adminEndpoint.split(':')[0], From b9dbf82f40c9a18b15be885ca1c1496e3aeff6e5 Mon Sep 17 00:00:00 2001 From: lunarthegrey Date: Thu, 28 Aug 2025 21:52:58 -0500 Subject: [PATCH 19/50] Attempt to fix tests --- src/shadowbox/server/manager_service.ts | 109 ++++++++++++++---------- 1 file changed, 66 insertions(+), 43 deletions(-) diff --git a/src/shadowbox/server/manager_service.ts b/src/shadowbox/server/manager_service.ts index e4fd51d55..84fef7fd1 100644 --- a/src/shadowbox/server/manager_service.ts +++ b/src/shadowbox/server/manager_service.ts @@ -308,23 +308,27 @@ export class ShadowsocksManagerService { ); return; } - this.serverConfig.data().name = name; + const configData = this.serverConfig.data(); + if (configData) { + configData.name = name; + } this.serverConfig.write(); res.send(HttpSuccess.NO_CONTENT); next(); } getServer(req: RequestType, res: ResponseType, next: restify.Next): void { + const configData = this.serverConfig.data(); res.send(HttpSuccess.OK, { - name: this.serverConfig.data().name || this.defaultServerName, - serverId: this.serverConfig.data().serverId, - metricsEnabled: this.serverConfig.data().metricsEnabled || false, - createdTimestampMs: this.serverConfig.data().createdTimestampMs, + name: configData?.name || this.defaultServerName, + serverId: configData?.serverId, + metricsEnabled: configData?.metricsEnabled || false, + createdTimestampMs: configData?.createdTimestampMs, version: version.getPackageVersion(), - accessKeyDataLimit: this.serverConfig.data().accessKeyDataLimit, - portForNewAccessKeys: this.serverConfig.data().portForNewAccessKeys, - hostnameForAccessKeys: this.serverConfig.data().hostname, - experimental: this.serverConfig.data().experimental, + accessKeyDataLimit: configData?.accessKeyDataLimit, + portForNewAccessKeys: configData?.portForNewAccessKeys, + hostnameForAccessKeys: configData?.hostname, + experimental: configData?.experimental, }); next(); } @@ -360,7 +364,10 @@ export class ShadowsocksManagerService { ); } - this.serverConfig.data().hostname = hostname; + const configData = this.serverConfig.data(); + if (configData) { + configData.hostname = hostname; + } this.serverConfig.write(); this.accessKeys.setHostname(hostname); res.send(HttpSuccess.NO_CONTENT); @@ -382,9 +389,9 @@ export class ShadowsocksManagerService { if (hasWebSocketListeners) { // Generate and return YAML for WebSocket keys - const domain = this.serverConfig.data().caddyWebServer?.domain || - this.serverConfig.data().hostname; - const listenersConfig = this.serverConfig.data().listenersForNewAccessKeys; + const configData = this.serverConfig.data(); + const domain = configData?.caddyWebServer?.domain || configData?.hostname; + const listenersConfig = configData?.listenersForNewAccessKeys; if (domain && listenersConfig) { const serverWithWebSocket = this.shadowsocksServer as ShadowsocksServer & { @@ -402,7 +409,7 @@ export class ShadowsocksManagerService { domain, listenersConfig.websocketStream?.path || '/tcp', listenersConfig.websocketPacket?.path || '/udp', - this.serverConfig.data().caddyWebServer?.autoHttps !== false + this.serverConfig.data()?.caddyWebServer?.autoHttps !== false ); if (yamlConfig) { @@ -477,7 +484,7 @@ export class ShadowsocksManagerService { } } else { // If no listeners specified, use default listeners based on server config - const serverListeners = this.serverConfig.data().listenersForNewAccessKeys; + const serverListeners = this.serverConfig.data()?.listenersForNewAccessKeys; if (serverListeners) { listeners = []; if (serverListeners.tcp) listeners.push('tcp'); @@ -574,13 +581,16 @@ export class ShadowsocksManagerService { ); } await this.accessKeys.setPortForNewAccessKeys(port); - this.serverConfig.data().portForNewAccessKeys = port; - // Also update listeners config for backward compatibility - if (!this.serverConfig.data().listenersForNewAccessKeys) { - this.serverConfig.data().listenersForNewAccessKeys = {}; + const configData = this.serverConfig.data(); + if (configData) { + configData.portForNewAccessKeys = port; + // Also update listeners config for backward compatibility + if (!configData.listenersForNewAccessKeys) { + configData.listenersForNewAccessKeys = {}; + } + configData.listenersForNewAccessKeys.tcp = { port }; + configData.listenersForNewAccessKeys.udp = { port }; } - this.serverConfig.data().listenersForNewAccessKeys.tcp = { port }; - this.serverConfig.data().listenersForNewAccessKeys.udp = { port }; this.serverConfig.write(); res.send(HttpSuccess.NO_CONTENT); next(); @@ -673,12 +683,15 @@ export class ShadowsocksManagerService { } // Store the listeners configuration - this.serverConfig.data().listenersForNewAccessKeys = listeners; - - // Update legacy portForNewAccessKeys if TCP port is set - if (listeners.tcp?.port) { - this.serverConfig.data().portForNewAccessKeys = listeners.tcp.port; - await this.accessKeys.setPortForNewAccessKeys(listeners.tcp.port); + const configData = this.serverConfig.data(); + if (configData) { + configData.listenersForNewAccessKeys = listeners; + + // Update legacy portForNewAccessKeys if TCP port is set + if (listeners.tcp?.port) { + configData.portForNewAccessKeys = listeners.tcp.port; + await this.accessKeys.setPortForNewAccessKeys(listeners.tcp.port); + } } this.serverConfig.write(); @@ -739,17 +752,20 @@ export class ShadowsocksManagerService { } // Store Caddy configuration - this.serverConfig.data().caddyWebServer = { - enabled: config.enabled ?? false, - adminEndpoint: config.adminEndpoint || 'localhost:2019', - autoHttps: config.autoHttps ?? false, - email: config.email, - domain: config.domain - }; - - // If enabled and we have WebSocket listeners, configure Caddy - if (config.enabled && this.serverConfig.data().listenersForNewAccessKeys?.websocketStream) { - await this.configureCaddyRoutes(); + const configData = this.serverConfig.data(); + if (configData) { + configData.caddyWebServer = { + enabled: config.enabled ?? false, + adminEndpoint: config.adminEndpoint || 'localhost:2019', + autoHttps: config.autoHttps ?? false, + email: config.email, + domain: config.domain + }; + + // If enabled and we have WebSocket listeners, configure Caddy + if (config.enabled && configData.listenersForNewAccessKeys?.websocketStream) { + await this.configureCaddyRoutes(); + } } this.serverConfig.write(); @@ -763,15 +779,16 @@ export class ShadowsocksManagerService { // Helper method to configure Caddy routes via its API private async configureCaddyRoutes(): Promise { - const caddyConfig = this.serverConfig.data().caddyWebServer; - const listeners = this.serverConfig.data().listenersForNewAccessKeys; + const configData = this.serverConfig.data(); + const caddyConfig = configData?.caddyWebServer; + const listeners = configData?.listenersForNewAccessKeys; if (!caddyConfig?.enabled || !listeners) { return; } const adminEndpoint = caddyConfig.adminEndpoint || 'localhost:2019'; - const domain = caddyConfig.domain || this.serverConfig.data().hostname; + const domain = caddyConfig.domain || configData?.hostname; // Build Caddy configuration const paths: string[] = []; @@ -951,7 +968,10 @@ export class ShadowsocksManagerService { // Enforcement is done asynchronously in the proxy server. This is transparent to the manager // so this doesn't introduce any race conditions between the server and UI. this.accessKeys.setDefaultDataLimit(limit); - this.serverConfig.data().accessKeyDataLimit = limit; + const configData = this.serverConfig.data(); + if (configData) { + configData.accessKeyDataLimit = limit; + } this.serverConfig.write(); res.send(HttpSuccess.NO_CONTENT); return next(); @@ -970,7 +990,10 @@ export class ShadowsocksManagerService { // Enforcement is done asynchronously in the proxy server. This is transparent to the manager // so this doesn't introduce any race conditions between the server and UI. this.accessKeys.removeDefaultDataLimit(); - delete this.serverConfig.data().accessKeyDataLimit; + const configData = this.serverConfig.data(); + if (configData) { + delete configData.accessKeyDataLimit; + } this.serverConfig.write(); res.send(HttpSuccess.NO_CONTENT); return next(); From 05fb79c6ba1e38698490866a7e6dfb7d67707dff Mon Sep 17 00:00:00 2001 From: lunarthegrey Date: Thu, 28 Aug 2025 22:27:34 -0500 Subject: [PATCH 20/50] Fix yaml for dynamic keys --- src/shadowbox/server/manager_service.ts | 9 +++++--- .../server/outline_shadowsocks_server.ts | 21 +++++++++++++------ 2 files changed, 21 insertions(+), 9 deletions(-) diff --git a/src/shadowbox/server/manager_service.ts b/src/shadowbox/server/manager_service.ts index 84fef7fd1..7791b4dbd 100644 --- a/src/shadowbox/server/manager_service.ts +++ b/src/shadowbox/server/manager_service.ts @@ -393,7 +393,10 @@ export class ShadowsocksManagerService { const domain = configData?.caddyWebServer?.domain || configData?.hostname; const listenersConfig = configData?.listenersForNewAccessKeys; - if (domain && listenersConfig) { + logging.debug(`WebSocket key detected. Domain: ${domain}, Listeners config: ${JSON.stringify(listenersConfig)}`); + + // Generate YAML even if listenersConfig is not fully configured, using defaults + if (domain) { const serverWithWebSocket = this.shadowsocksServer as ShadowsocksServer & { generateDynamicAccessKeyYaml?: ( proxyParams: {encryptionMethod: string; password: string}, @@ -407,8 +410,8 @@ export class ShadowsocksManagerService { const yamlConfig = serverWithWebSocket.generateDynamicAccessKeyYaml?.( accessKey.proxyParams, domain, - listenersConfig.websocketStream?.path || '/tcp', - listenersConfig.websocketPacket?.path || '/udp', + listenersConfig?.websocketStream?.path || '/tcp', + listenersConfig?.websocketPacket?.path || '/udp', this.serverConfig.data()?.caddyWebServer?.autoHttps !== false ); diff --git a/src/shadowbox/server/outline_shadowsocks_server.ts b/src/shadowbox/server/outline_shadowsocks_server.ts index d8157a06e..5c538f45a 100644 --- a/src/shadowbox/server/outline_shadowsocks_server.ts +++ b/src/shadowbox/server/outline_shadowsocks_server.ts @@ -298,22 +298,23 @@ export class OutlineShadowsocksServer implements ShadowsocksServer { const protocol = tls ? 'wss' : 'ws'; + // Generate YAML configuration matching Outline client spec exactly const config = { transport: { - $type: 'tcpudp', + '$type': 'tcpudp', tcp: { - $type: 'shadowsocks', + '$type': 'shadowsocks', endpoint: { - $type: 'websocket', + '$type': 'websocket', url: `${protocol}://${domain}${tcpPath}` }, cipher: proxyParams.encryptionMethod, secret: proxyParams.password }, udp: { - $type: 'shadowsocks', + '$type': 'shadowsocks', endpoint: { - $type: 'websocket', + '$type': 'websocket', url: `${protocol}://${domain}${udpPath}` }, cipher: proxyParams.encryptionMethod, @@ -322,7 +323,15 @@ export class OutlineShadowsocksServer implements ShadowsocksServer { } }; - return jsyaml.safeDump(config, {sortKeys: true}); + // 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 + sortKeys: false, // Preserve key order + quotingType: '"', // Use double quotes when needed + forceQuotes: false // Don't quote all strings + }); } private start() { From 8575e201d2b9bba83378c7c07d550b1ecdf3a727 Mon Sep 17 00:00:00 2001 From: lunarthegrey Date: Thu, 28 Aug 2025 22:30:25 -0500 Subject: [PATCH 21/50] Attempt to fix builds --- src/shadowbox/server/outline_shadowsocks_server.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/shadowbox/server/outline_shadowsocks_server.ts b/src/shadowbox/server/outline_shadowsocks_server.ts index 5c538f45a..ef9219866 100644 --- a/src/shadowbox/server/outline_shadowsocks_server.ts +++ b/src/shadowbox/server/outline_shadowsocks_server.ts @@ -329,8 +329,9 @@ export class OutlineShadowsocksServer implements ShadowsocksServer { lineWidth: -1, // Don't wrap long lines noRefs: true, // Don't use references sortKeys: false, // Preserve key order - quotingType: '"', // Use double quotes when needed - forceQuotes: false // Don't quote all strings + styles: { + '!!null': 'canonical' // Use ~ for null values + } }); } From 42a9671cab15b37f0e72f1b1019c6a5aea22271b Mon Sep 17 00:00:00 2001 From: lunarthegrey Date: Fri, 29 Aug 2025 00:04:22 -0500 Subject: [PATCH 22/50] Enable WebSocket server when a WebSocket key is created --- src/shadowbox/server/outline_shadowsocks_server.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/shadowbox/server/outline_shadowsocks_server.ts b/src/shadowbox/server/outline_shadowsocks_server.ts index ef9219866..05fd175b8 100644 --- a/src/shadowbox/server/outline_shadowsocks_server.ts +++ b/src/shadowbox/server/outline_shadowsocks_server.ts @@ -150,8 +150,15 @@ export class OutlineShadowsocksServer implements ShadowsocksServer { let config: ServerConfig; - if (hasWebSocketKeys && this.webSocketConfig?.enabled) { + if (hasWebSocketKeys) { // Use new format with WebSocket support + // Enable WebSocket if not already configured + if (!this.webSocketConfig) { + this.webSocketConfig = { + enabled: true, + webServerPort: 8080 // Default port + }; + } config = this.generateWebSocketConfig(extendedKeys); } else { // Use legacy format for backward compatibility From f5989c18b80ad19f2963f474cfdf41a198595b81 Mon Sep 17 00:00:00 2001 From: lunarthegrey Date: Fri, 29 Aug 2025 00:20:33 -0500 Subject: [PATCH 23/50] Improve WS server config insertion when new WS keys are added --- src/shadowbox/server/outline_shadowsocks_server.ts | 11 +++++++++++ src/shadowbox/server/server_access_key.ts | 1 + 2 files changed, 12 insertions(+) diff --git a/src/shadowbox/server/outline_shadowsocks_server.ts b/src/shadowbox/server/outline_shadowsocks_server.ts index 05fd175b8..05b19edd0 100644 --- a/src/shadowbox/server/outline_shadowsocks_server.ts +++ b/src/shadowbox/server/outline_shadowsocks_server.ts @@ -141,6 +141,15 @@ 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 => { + 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 || @@ -148,6 +157,8 @@ export class OutlineShadowsocksServer implements ShadowsocksServer { ) ); + logging.info(`WebSocket keys detected: ${hasWebSocketKeys}`); + let config: ServerConfig; if (hasWebSocketKeys) { diff --git a/src/shadowbox/server/server_access_key.ts b/src/shadowbox/server/server_access_key.ts index bc355e11f..404ed946a 100644 --- a/src/shadowbox/server/server_access_key.ts +++ b/src/shadowbox/server/server_access_key.ts @@ -336,6 +336,7 @@ export class ServerAccessKeyRepository implements AccessKeyRepository { port: key.proxyParams.portNumber, cipher: key.proxyParams.encryptionMethod, secret: key.proxyParams.password, + listeners: key.listeners, // Pass listeners to enable WebSocket config generation }; }); return this.shadowsocksServer.update(serverAccessKeys); From 7f02575082f8d0a4f9df51e17889182975715c9d Mon Sep 17 00:00:00 2001 From: lunarthegrey Date: Wed, 8 Oct 2025 20:05:02 -0500 Subject: [PATCH 24/50] Add embedded OutlineCaddy integration and listener-aware WebSocket support --- src/shadowbox/Taskfile.yml | 3 +- src/shadowbox/model/shadowsocks_server.ts | 13 + src/shadowbox/server/api.yml | 9 +- src/shadowbox/server/main.ts | 38 +- src/shadowbox/server/manager_service.spec.ts | 169 ++++++++- src/shadowbox/server/manager_service.ts | 206 +++++------ src/shadowbox/server/mocks/mocks.ts | 15 +- src/shadowbox/server/outline_caddy_server.ts | 338 ++++++++++++++++++ .../server/outline_shadowsocks_server.ts | 291 +++++++++------ src/shadowbox/server/server_config.ts | 1 - 10 files changed, 833 insertions(+), 250 deletions(-) create mode 100644 src/shadowbox/server/outline_caddy_server.ts diff --git a/src/shadowbox/Taskfile.yml b/src/shadowbox/Taskfile.yml index fb72a9b87..27d073331 100644 --- a/src/shadowbox/Taskfile.yml +++ b/src/shadowbox/Taskfile.yml @@ -40,6 +40,7 @@ tasks: vars: {TARGET_DIR: '{{.BIN_DIR}}'} # Set CGO_ENABLED=0 to force static linkage. See https://mt165.co.uk/blog/static-link-go/. - GOOS={{.TARGET_OS}} GOARCH={{.GOARCH}} CGO_ENABLED=0 go build -ldflags='-s -w -X main.version=embedded' -o '{{.BIN_DIR}}/' github.com/Jigsaw-Code/outline-ss-server/cmd/outline-ss-server + - GOOS={{.TARGET_OS}} GOARCH={{.GOARCH}} CGO_ENABLED=0 go build -tags='nomysql' -ldflags='-s -w' -o '{{.BIN_DIR}}/outline-caddy' github.com/Jigsaw-Code/outline-ss-server/outlinecaddy/cmd/caddy start: desc: Run the Outline server locally @@ -204,4 +205,4 @@ tasks: - > openssl req -x509 -nodes -days 36500 -newkey rsa:4096 -subj "/CN=localhost" - -keyout "{{.PRIVATE_KEY_FILE}}" -out "{{.CERTIFICATE_FILE}}" \ No newline at end of file + -keyout "{{.PRIVATE_KEY_FILE}}" -out "{{.CERTIFICATE_FILE}}" diff --git a/src/shadowbox/model/shadowsocks_server.ts b/src/shadowbox/model/shadowsocks_server.ts index a3416ef95..2c6a6c39c 100644 --- a/src/shadowbox/model/shadowsocks_server.ts +++ b/src/shadowbox/model/shadowsocks_server.ts @@ -20,7 +20,20 @@ export interface ShadowsocksAccessKey { secret: string; } +export interface ListenerSettings { + websocketStream?: { + path?: string; + webServerPort?: number; + }; + websocketPacket?: { + path?: string; + webServerPort?: number; + }; +} + export interface ShadowsocksServer { // Updates the server to accept only the given access keys. update(keys: ShadowsocksAccessKey[]): Promise; + // Optionally updates listener-specific configuration such as WebSocket paths or ports. + configureListeners?(listeners: ListenerSettings | undefined): void; } diff --git a/src/shadowbox/server/api.yml b/src/shadowbox/server/api.yml index 4bf16df2c..84872282b 100644 --- a/src/shadowbox/server/api.yml +++ b/src/shadowbox/server/api.yml @@ -141,15 +141,13 @@ paths: value: '{"enabled": true, "autoHttps": true, "email": "admin@example.com", "domain": "example.com"}' 'Basic configuration': value: '{"enabled": true, "domain": "example.com"}' - 'Custom admin endpoint': - value: '{"enabled": true, "adminEndpoint": "localhost:2019", "domain": "example.com"}' responses: '204': description: The web server configuration was successfully updated. '400': description: Invalid web server configuration. '500': - description: Failed to configure Caddy via its API. + description: Failed to configure the embedded Caddy web server. /server/access-key-data-limit: put: @@ -732,7 +730,7 @@ components: type: integer minimum: 1 maximum: 65535 - description: Port for the internal WebSocket server + description: Port for the internal WebSocket server (must be shared by both WebSocket listeners) ListenersConfig: properties: @@ -754,9 +752,6 @@ components: enabled: type: boolean description: Whether Caddy web server integration is enabled - adminEndpoint: - type: string - description: Caddy admin API endpoint (default "localhost:2019") autoHttps: type: boolean description: Whether to enable automatic HTTPS via ACME diff --git a/src/shadowbox/server/main.ts b/src/shadowbox/server/main.ts index 57c55e52d..300c3bfbd 100644 --- a/src/shadowbox/server/main.ts +++ b/src/shadowbox/server/main.ts @@ -31,6 +31,7 @@ import * as version from './version'; import {PrometheusManagerMetrics} from './manager_metrics'; import {bindService, ShadowsocksManagerService} from './manager_service'; import {OutlineShadowsocksServer} from './outline_shadowsocks_server'; +import {OutlineCaddyServer} from './outline_caddy_server'; import {AccessKeyConfigJson, ServerAccessKeyRepository} from './server_access_key'; import * as server_config from './server_config'; import { @@ -162,13 +163,26 @@ async function main() { if (fs.existsSync(MMDB_LOCATION_ASN)) { shadowsocksServer.configureAsnMetrics(MMDB_LOCATION_ASN); } + const caddyServer = new OutlineCaddyServer( + getBinaryFilename('outline-caddy'), + getPersistentFilename('outline-caddy/config.json'), + verbose + ); - // Configure WebSocket support if enabled + // Configure listener defaults (e.g., WebSocket paths/ports) based on server configuration. const listenersConfig = serverConfig.data().listenersForNewAccessKeys; - const webSocketPort = listenersConfig?.websocketStream?.webServerPort || - listenersConfig?.websocketPacket?.webServerPort || - 8080; // Default internal WebSocket server port - shadowsocksServer.configureWebSocket(webSocketPort); + shadowsocksServer.configureListeners( + listenersConfig + ? { + websocketStream: listenersConfig.websocketStream + ? {...listenersConfig.websocketStream} + : undefined, + websocketPacket: listenersConfig.websocketPacket + ? {...listenersConfig.websocketPacket} + : undefined, + } + : undefined + ); const isReplayProtectionEnabled = createRolloutTracker(serverConfig).isRolloutEnabled( 'replay-protection', @@ -219,6 +233,17 @@ async function main() { serverConfig.data().accessKeyDataLimit ); + try { + await caddyServer.applyConfig({ + accessKeys: accessKeyRepository.listAccessKeys(), + listeners: serverConfig.data().listenersForNewAccessKeys, + caddyConfig: serverConfig.data().caddyWebServer, + hostname: serverConfig.data().hostname, + }); + } catch (error) { + logging.error(`Failed to apply initial Caddy configuration: ${error}`); + } + const metricsReader = new PrometheusUsageMetrics(prometheusClient); const managerMetrics = new PrometheusManagerMetrics(prometheusClient); const metricsCollector = new RestMetricsCollectorClient(metricsCollectorUrl); @@ -235,7 +260,8 @@ async function main() { accessKeyRepository, shadowsocksServer, managerMetrics, - metricsPublisher + metricsPublisher, + caddyServer ); const certificateFilename = process.env.SB_CERTIFICATE_FILE; diff --git a/src/shadowbox/server/manager_service.spec.ts b/src/shadowbox/server/manager_service.spec.ts index 22e04c817..69eb74519 100644 --- a/src/shadowbox/server/manager_service.spec.ts +++ b/src/shadowbox/server/manager_service.spec.ts @@ -25,6 +25,7 @@ import {AccessKeyConfigJson, ServerAccessKeyRepository} from './server_access_ke import {ServerConfigJson} from './server_config'; import {SharedMetricsPublisher} from './shared_metrics'; import {ShadowsocksServer} from '../model/shadowsocks_server'; +import type {OutlineCaddyConfigPayload, OutlineCaddyController} from './outline_caddy_server'; interface ServerInfo { name: string; @@ -126,7 +127,7 @@ describe('ShadowsocksManagerService', () => { }); describe('setHostnameForAccessKeys', () => { - it(`accepts valid hostnames`, (done) => { + it(`accepts valid hostnames`, async (done) => { const serverConfig = new InMemoryConfig({} as ServerConfigJson); const service = new ShadowsocksManagerServiceBuilder() .serverConfig(serverConfig) @@ -151,13 +152,13 @@ describe('ShadowsocksManagerService', () => { '2606:2800:220:1:248:1893:25c8:1946', ]; for (const hostname of goodHostnames) { - service.setHostnameForAccessKeys({params: {hostname}}, res, () => {}); + await service.setHostnameForAccessKeys({params: {hostname}}, res, () => {}); } responseProcessed = true; done(); }); - it(`rejects invalid hostnames`, (done) => { + it(`rejects invalid hostnames`, async (done) => { const serverConfig = new InMemoryConfig({} as ServerConfigJson); const service = new ShadowsocksManagerServiceBuilder() .serverConfig(serverConfig) @@ -179,13 +180,13 @@ describe('ShadowsocksManagerService', () => { 'gggg:ggg:220:1:248:1893:25c8:1946', ]; for (const hostname of badHostnames) { - service.setHostnameForAccessKeys({params: {hostname}}, res, next); + await service.setHostnameForAccessKeys({params: {hostname}}, res, next); } responseProcessed = true; done(); }); - it("Changes the server's hostname", (done) => { + it("Changes the server's hostname", async (done) => { const serverConfig = new InMemoryConfig({} as ServerConfigJson); const service = new ShadowsocksManagerServiceBuilder() .serverConfig(serverConfig) @@ -199,9 +200,9 @@ describe('ShadowsocksManagerService', () => { responseProcessed = true; }, }; - service.setHostnameForAccessKeys({params: {hostname}}, res, done); + await service.setHostnameForAccessKeys({params: {hostname}}, res, done); }); - it('Rejects missing hostname', (done) => { + it('Rejects missing hostname', async (done) => { const serverConfig = new InMemoryConfig({} as ServerConfigJson); const service = new ShadowsocksManagerServiceBuilder() .serverConfig(serverConfig) @@ -214,9 +215,9 @@ describe('ShadowsocksManagerService', () => { done(); }; const missingHostname = {params: {}} as {params: {hostname: string}}; - service.setHostnameForAccessKeys(missingHostname, res, next); + await service.setHostnameForAccessKeys(missingHostname, res, next); }); - it('Rejects non-string hostname', (done) => { + it('Rejects non-string hostname', async (done) => { const serverConfig = new InMemoryConfig({} as ServerConfigJson); const service = new ShadowsocksManagerServiceBuilder() .serverConfig(serverConfig) @@ -756,6 +757,131 @@ describe('ShadowsocksManagerService', () => { }); }); + describe('setListenersForNewAccessKeys', () => { + it('persists configuration and updates the Shadowsocks server', async (done) => { + const repo = getAccessKeyRepository(); + const serverConfig = new InMemoryConfig({} as ServerConfigJson); + const fakeServer = new FakeShadowsocksServer(); + const fakeCaddy = new FakeOutlineCaddyServer(); + const service = new ShadowsocksManagerServiceBuilder() + .serverConfig(serverConfig) + .accessKeys(repo) + .shadowsocksServer(fakeServer) + .caddyServer(fakeCaddy) + .build(); + + const listeners = { + tcp: {port: 443}, + udp: {port: 8443}, + websocketStream: {path: '/stream', webServerPort: 8080}, + websocketPacket: {path: '/packet', webServerPort: 8080}, + }; + + const res = { + send: (httpCode) => { + expect(httpCode).toEqual(204); + responseProcessed = true; + }, + }; + + await service.setListenersForNewAccessKeys({params: listeners}, res, () => {}); + + expect(serverConfig.data().listenersForNewAccessKeys).toEqual(listeners); + expect(fakeServer.getListenerSettings()).toEqual({ + websocketStream: listeners.websocketStream, + websocketPacket: listeners.websocketPacket, + }); + expect(fakeCaddy.applyCalls.length).toEqual(1); + expect(fakeCaddy.applyCalls[0].listeners).toEqual(listeners); + done(); + }); + + it('clears WebSocket listener settings when they are removed', async (done) => { + const repo = getAccessKeyRepository(); + const serverConfig = new InMemoryConfig({} as ServerConfigJson); + const fakeServer = new FakeShadowsocksServer(); + const fakeCaddy = new FakeOutlineCaddyServer(); + const service = new ShadowsocksManagerServiceBuilder() + .serverConfig(serverConfig) + .accessKeys(repo) + .shadowsocksServer(fakeServer) + .caddyServer(fakeCaddy) + .build(); + + const listenersWithWebsocket = { + tcp: {port: 443}, + udp: {port: 443}, + websocketStream: {path: '/tcp', webServerPort: 8080}, + websocketPacket: {path: '/udp', webServerPort: 8080}, + }; + await service.setListenersForNewAccessKeys( + {params: listenersWithWebsocket}, + {send: () => {}}, + () => {} + ); + expect(fakeServer.getListenerSettings()).toEqual({ + websocketStream: listenersWithWebsocket.websocketStream, + websocketPacket: listenersWithWebsocket.websocketPacket, + }); + + const listenersWithoutWebsocket = { + tcp: {port: 9090}, + udp: {port: 9090}, + }; + const res = { + send: (httpCode) => { + expect(httpCode).toEqual(204); + responseProcessed = true; + }, + }; + await service.setListenersForNewAccessKeys( + {params: listenersWithoutWebsocket}, + res, + () => {} + ); + + expect(serverConfig.data().listenersForNewAccessKeys).toEqual(listenersWithoutWebsocket); + expect(fakeServer.getListenerSettings()).toBeUndefined(); + expect(fakeCaddy.applyCalls.length).toEqual(2); + expect(fakeCaddy.applyCalls[1].listeners).toEqual(listenersWithoutWebsocket); + done(); + }); + }); + + describe('configureCaddyWebServer', () => { + it('stores configuration and applies it', async (done) => { + const repo = getAccessKeyRepository(); + const serverConfig = new InMemoryConfig({} as ServerConfigJson); + const fakeCaddy = new FakeOutlineCaddyServer(); + const service = new ShadowsocksManagerServiceBuilder() + .serverConfig(serverConfig) + .accessKeys(repo) + .caddyServer(fakeCaddy) + .build(); + + const config = { + enabled: true, + autoHttps: true, + email: 'admin@example.com', + domain: 'example.com', + }; + + const res = { + send: (httpCode) => { + expect(httpCode).toEqual(204); + responseProcessed = true; + }, + }; + + await service.configureCaddyWebServer({params: config}, res, () => {}); + + expect(serverConfig.data().caddyWebServer).toEqual(config); + expect(fakeCaddy.applyCalls.length).toEqual(1); + expect(fakeCaddy.applyCalls[0].caddyConfig).toEqual(config); + done(); + }); + }); + describe('removeAccessKey', () => { it('removes keys', async (done) => { const repo = getAccessKeyRepository(); @@ -1214,6 +1340,22 @@ describe('convertTimeRangeToHours', () => { }); }); +class FakeOutlineCaddyServer { + public applyCalls: OutlineCaddyConfigPayload[] = []; + public shouldFail = false; + + async applyConfig(payload: OutlineCaddyConfigPayload): Promise { + this.applyCalls.push(payload); + if (this.shouldFail) { + throw new Error('applyConfig failure'); + } + } + + async stop(): Promise { + return Promise.resolve(); + } +} + class ShadowsocksManagerServiceBuilder { private defaultServerName_ = 'default name'; private serverConfig_: JsonConfig = null; @@ -1221,6 +1363,7 @@ class ShadowsocksManagerServiceBuilder { private shadowsocksServer_: ShadowsocksServer = null; private managerMetrics_: ManagerMetrics = null; private metricsPublisher_: SharedMetricsPublisher = null; + private caddyServer_: OutlineCaddyController = new FakeOutlineCaddyServer(); defaultServerName(name: string): ShadowsocksManagerServiceBuilder { this.defaultServerName_ = name; @@ -1252,6 +1395,11 @@ class ShadowsocksManagerServiceBuilder { return this; } + caddyServer(server: OutlineCaddyController): ShadowsocksManagerServiceBuilder { + this.caddyServer_ = server; + return this; + } + build(): ShadowsocksManagerService { return new ShadowsocksManagerService( this.defaultServerName_, @@ -1259,7 +1407,8 @@ class ShadowsocksManagerServiceBuilder { this.accessKeys_, this.shadowsocksServer_, this.managerMetrics_, - this.metricsPublisher_ + this.metricsPublisher_, + this.caddyServer_ ); } } diff --git a/src/shadowbox/server/manager_service.ts b/src/shadowbox/server/manager_service.ts index 7791b4dbd..35e7b4484 100644 --- a/src/shadowbox/server/manager_service.ts +++ b/src/shadowbox/server/manager_service.ts @@ -28,7 +28,7 @@ import {ManagerMetrics} from './manager_metrics'; import {ServerConfigJson, ListenersForNewAccessKeys, CaddyWebServerConfig} from './server_config'; import {SharedMetricsPublisher} from './shared_metrics'; import {ShadowsocksServer} from '../model/shadowsocks_server'; -import * as http from 'http'; +import type {OutlineCaddyController} from './outline_caddy_server'; interface AccessKeyJson { // The unique identifier of this access key. @@ -289,7 +289,8 @@ export class ShadowsocksManagerService { private accessKeys: AccessKeyRepository, private shadowsocksServer: ShadowsocksServer, private managerMetrics: ManagerMetrics, - private metricsPublisher: SharedMetricsPublisher + private metricsPublisher: SharedMetricsPublisher, + private readonly caddyServer?: OutlineCaddyController ) {} renameServer(req: RequestType, res: ResponseType, next: restify.Next): void { @@ -334,7 +335,11 @@ export class ShadowsocksManagerService { } // Changes the server's hostname. Hostname must be a valid domain or IP address - setHostnameForAccessKeys(req: RequestType, res: ResponseType, next: restify.Next): void { + async setHostnameForAccessKeys( + req: RequestType, + res: ResponseType, + next: restify.Next + ): Promise { logging.debug(`changeHostname request: ${JSON.stringify(req.params)}`); const hostname = req.params.hostname; @@ -370,6 +375,11 @@ export class ShadowsocksManagerService { } this.serverConfig.write(); this.accessKeys.setHostname(hostname); + try { + await this.updateCaddyConfig(); + } catch (error) { + return next(new restifyErrors.InternalServerError(error)); + } res.send(HttpSuccess.NO_CONTENT); next(); } @@ -396,23 +406,25 @@ export class ShadowsocksManagerService { logging.debug(`WebSocket key detected. Domain: ${domain}, Listeners config: ${JSON.stringify(listenersConfig)}`); // Generate YAML even if listenersConfig is not fully configured, using defaults - if (domain) { - const serverWithWebSocket = this.shadowsocksServer as ShadowsocksServer & { - generateDynamicAccessKeyYaml?: ( - proxyParams: {encryptionMethod: string; password: string}, - domain: string, - tcpPath: string, - udpPath: string, - tls: boolean - ) => string | null; - }; + if (domain) { + const serverWithWebSocket = this.shadowsocksServer as ShadowsocksServer & { + generateDynamicAccessKeyYaml?: ( + proxyParams: {encryptionMethod: string; password: string}, + domain: string, + tcpPath: string, + udpPath: string, + tls: boolean, + listeners?: ListenerType[] + ) => string | null; + }; const yamlConfig = serverWithWebSocket.generateDynamicAccessKeyYaml?.( accessKey.proxyParams, domain, listenersConfig?.websocketStream?.path || '/tcp', listenersConfig?.websocketPacket?.path || '/udp', - this.serverConfig.data()?.caddyWebServer?.autoHttps !== false + this.serverConfig.data()?.caddyWebServer?.autoHttps !== false, + accessKey.listeners as ListenerType[] | undefined ); if (yamlConfig) { @@ -511,6 +523,7 @@ export class ShadowsocksManagerService { listeners: listeners as ListenerType[], }) ); + await this.updateCaddyConfig(); return accessKeyJson; } catch (error) { logging.error(error); @@ -595,6 +608,7 @@ export class ShadowsocksManagerService { configData.listenersForNewAccessKeys.udp = { port }; } this.serverConfig.write(); + await this.updateCaddyConfig(); res.send(HttpSuccess.NO_CONTENT); next(); } catch (error) { @@ -683,6 +697,27 @@ export class ShadowsocksManagerService { new restifyErrors.InvalidArgumentError({statusCode: 400}, 'WebSocket packet path must start with /') ); } + const wsPacketPort = listeners.websocketPacket.webServerPort; + if (wsPacketPort !== undefined) { + if (typeof wsPacketPort !== 'number' || wsPacketPort < 1 || wsPacketPort > 65535) { + return next( + new restifyErrors.InvalidArgumentError({statusCode: 400}, 'Invalid WebSocket server port') + ); + } + } + } + + if ( + listeners.websocketStream?.webServerPort !== undefined && + listeners.websocketPacket?.webServerPort !== undefined && + listeners.websocketStream.webServerPort !== listeners.websocketPacket.webServerPort + ) { + return next( + new restifyErrors.InvalidArgumentError( + {statusCode: 400}, + 'WebSocket stream and packet listeners must share the same web server port' + ) + ); } // Store the listeners configuration @@ -696,8 +731,18 @@ export class ShadowsocksManagerService { await this.accessKeys.setPortForNewAccessKeys(listeners.tcp.port); } } - + + // Update the underlying Shadowsocks server with the new listener defaults. + const listenerSettings = + listeners.websocketStream || listeners.websocketPacket + ? { + websocketStream: listeners.websocketStream, + websocketPacket: listeners.websocketPacket, + } + : undefined; + this.shadowsocksServer.configureListeners?.(listenerSettings); this.serverConfig.write(); + await this.updateCaddyConfig(); res.send(HttpSuccess.NO_CONTENT); next(); } catch (error) { @@ -759,19 +804,14 @@ export class ShadowsocksManagerService { if (configData) { configData.caddyWebServer = { enabled: config.enabled ?? false, - adminEndpoint: config.adminEndpoint || 'localhost:2019', autoHttps: config.autoHttps ?? false, email: config.email, domain: config.domain }; - - // If enabled and we have WebSocket listeners, configure Caddy - if (config.enabled && configData.listenersForNewAccessKeys?.websocketStream) { - await this.configureCaddyRoutes(); - } } this.serverConfig.write(); + await this.updateCaddyConfig(); res.send(HttpSuccess.NO_CONTENT); next(); } catch (error) { @@ -780,108 +820,17 @@ export class ShadowsocksManagerService { } } - // Helper method to configure Caddy routes via its API - private async configureCaddyRoutes(): Promise { - const configData = this.serverConfig.data(); - const caddyConfig = configData?.caddyWebServer; - const listeners = configData?.listenersForNewAccessKeys; - - if (!caddyConfig?.enabled || !listeners) { - return; - } - - const adminEndpoint = caddyConfig.adminEndpoint || 'localhost:2019'; - const domain = caddyConfig.domain || configData?.hostname; - - // Build Caddy configuration - const paths: string[] = []; - if (listeners.websocketStream?.path) { - paths.push(listeners.websocketStream.path); - } - if (listeners.websocketPacket?.path) { - paths.push(listeners.websocketPacket.path); - } - - if (paths.length === 0) { - return; - } - - const wsPort = listeners.websocketStream?.webServerPort || 8080; - - const caddyServerConfig = { - listen: [':443'], - routes: [{ - match: [{ path: paths }], - handle: [{ - handler: 'reverse_proxy', - upstreams: [{ dial: `localhost:${wsPort}` }], - headers: { - request: { - set: { - 'Upgrade': ['websocket'], - 'Connection': ['Upgrade'] - } - } - } - }] - }] - }; - - // Add automatic HTTPS if configured - type CaddyServerConfigType = typeof caddyServerConfig & { - automatic_https?: { - email?: string; - }; - }; - const configWithHttps = caddyServerConfig as CaddyServerConfigType; - if (caddyConfig.autoHttps && domain) { - configWithHttps.automatic_https = { - email: caddyConfig.email - }; - } - - // Send configuration to Caddy via its admin API - try { - const data = JSON.stringify(configWithHttps); - - const options = { - hostname: adminEndpoint.split(':')[0], - port: parseInt(adminEndpoint.split(':')[1] || '2019'), - path: `/config/apps/http/servers/outline`, - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - 'Content-Length': data.length - } - }; - - await new Promise((resolve, reject) => { - const req = http.request(options, (res) => { - if (res.statusCode >= 200 && res.statusCode < 300) { - resolve(true); - } else { - reject(new Error(`Caddy API returned status ${res.statusCode}`)); - } - }); - - req.on('error', reject); - req.write(data); - req.end(); - }); - - logging.info('Successfully configured Caddy web server'); - } catch (error) { - logging.error(`Failed to configure Caddy: ${error}`); - throw error; - } - } - // Removes an existing access key - removeAccessKey(req: RequestType, res: ResponseType, next: restify.Next): void { + async removeAccessKey( + req: RequestType, + res: ResponseType, + next: restify.Next + ): Promise { try { logging.debug(`removeAccessKey request ${JSON.stringify(req.params)}`); const accessKeyId = validateAccessKeyId(req.params.id); this.accessKeys.removeAccessKey(accessKeyId); + await this.updateCaddyConfig(); res.send(HttpSuccess.NO_CONTENT); return next(); } catch (error) { @@ -1081,4 +1030,25 @@ export class ShadowsocksManagerService { res.send(HttpSuccess.NO_CONTENT); next(); } + + private async updateCaddyConfig(): Promise { + if (!this.caddyServer) { + return; + } + const configData = this.serverConfig.data(); + if (!configData) { + return; + } + try { + await this.caddyServer.applyConfig({ + accessKeys: this.accessKeys.listAccessKeys(), + listeners: configData.listenersForNewAccessKeys, + caddyConfig: configData.caddyWebServer, + hostname: configData.hostname, + }); + } catch (error) { + logging.error(`Failed to apply Caddy configuration: ${error}`); + throw error; + } + } } diff --git a/src/shadowbox/server/mocks/mocks.ts b/src/shadowbox/server/mocks/mocks.ts index 9183ebce8..ec2b6d144 100644 --- a/src/shadowbox/server/mocks/mocks.ts +++ b/src/shadowbox/server/mocks/mocks.ts @@ -13,7 +13,11 @@ // limitations under the License. import {PrometheusClient, QueryResultData} from '../../infrastructure/prometheus_scraper'; -import {ShadowsocksAccessKey, ShadowsocksServer} from '../../model/shadowsocks_server'; +import { + ListenerSettings, + ShadowsocksAccessKey, + ShadowsocksServer, +} from '../../model/shadowsocks_server'; import {TextFile} from '../../infrastructure/text_file'; export class InMemoryFile implements TextFile { @@ -37,15 +41,24 @@ export class InMemoryFile implements TextFile { export class FakeShadowsocksServer implements ShadowsocksServer { private accessKeys: ShadowsocksAccessKey[] = []; + private listeners?: ListenerSettings; update(keys: ShadowsocksAccessKey[]) { this.accessKeys = keys; return Promise.resolve(); } + configureListeners(listeners: ListenerSettings | undefined) { + this.listeners = listeners; + } + getAccessKeys() { return this.accessKeys; } + + getListenerSettings() { + return this.listeners; + } } export class FakePrometheusClient implements PrometheusClient { diff --git a/src/shadowbox/server/outline_caddy_server.ts b/src/shadowbox/server/outline_caddy_server.ts new file mode 100644 index 000000000..aa667b747 --- /dev/null +++ b/src/shadowbox/server/outline_caddy_server.ts @@ -0,0 +1,338 @@ +// Copyright 2024 The Outline Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import * as child_process from 'child_process'; +import * as path from 'path'; + +import * as mkdirp from 'mkdirp'; + +import * as file from '../infrastructure/file'; +import * as logging from '../infrastructure/logging'; +import {AccessKey, ListenerType} from '../model/access_key'; +import {CaddyWebServerConfig, ListenerConfig, ListenersForNewAccessKeys} from './server_config'; + +export interface OutlineCaddyConfigPayload { + accessKeys: AccessKey[]; + listeners?: ListenersForNewAccessKeys; + caddyConfig?: CaddyWebServerConfig; + hostname?: string; +} + +export interface OutlineCaddyController { + applyConfig(payload: OutlineCaddyConfigPayload): Promise; + stop(): Promise; +} + +interface WebSocketListenerSettings { + tcpPath: string; + udpPath: string; + listenPort: number; +} + +interface CaddyConfig { + logging?: unknown; + apps: Record; +} + +export class OutlineCaddyServer implements OutlineCaddyController { + private process?: child_process.ChildProcess; + private readonly restartDelayMs = 1000; + private shouldRun = false; + private currentConfigHash?: string; + + constructor( + private readonly binaryFilename: string, + private readonly configFilename: string, + private readonly verbose: boolean + ) {} + + async applyConfig(payload: OutlineCaddyConfigPayload): Promise { + const {enabled = false} = payload.caddyConfig || {}; + if (!enabled) { + await this.stop(); + this.currentConfigHash = undefined; + return; + } + + const listenerSettings = this.getWebSocketSettings( + payload.listeners?.websocketStream, + payload.listeners?.websocketPacket + ); + const websocketKeys = this.getWebSocketKeys(payload.accessKeys); + + if (websocketKeys.length === 0) { + logging.warn('Caddy web server enabled but no WebSocket-enabled access keys found.'); + } + + const configObject = this.buildConfig(payload, listenerSettings, websocketKeys); + const configJson = JSON.stringify(configObject, null, 2); + if (configJson === this.currentConfigHash) { + // No changes; nothing to do. + return; + } + + mkdirp.sync(path.dirname(this.configFilename)); + file.atomicWriteFileSync(this.configFilename, configJson); + this.currentConfigHash = configJson; + this.shouldRun = true; + await this.ensureStarted(); + } + + async stop(): Promise { + this.shouldRun = false; + if (!this.process) { + return; + } + const proc = this.process; + this.process = undefined; + await new Promise((resolve) => { + proc.once('exit', () => resolve()); + proc.kill('SIGTERM'); + // Fallback in case the process ignores SIGTERM. + setTimeout(() => { + if (!proc.killed) { + proc.kill('SIGKILL'); + } + }, 5000); + }); + } + + private async ensureStarted(): Promise { + if (this.process) { + return; + } + try { + await this.start(); + } catch (error) { + logging.error(`Failed to start outline-caddy: ${error}`); + throw error; + } + } + + private start(): Promise { + return new Promise((resolve, reject) => { + const args = ['run', '--config', this.configFilename, '--adapter', 'json', '--watch']; + logging.info(`Starting outline-caddy with command: ${this.binaryFilename} ${args.join(' ')}`); + const proc = child_process.spawn(this.binaryFilename, args, { + stdio: ['ignore', 'pipe', 'pipe'], + }); + + const onSpawnError = (error: Error) => { + if (this.process === proc) { + this.process = undefined; + } + proc.removeAllListeners(); + reject(error); + }; + + proc.once('error', onSpawnError); + + this.process = proc; + setImmediate(() => resolve()); + + proc.stdout?.on('data', (data: Buffer) => { + logging.info(`[outline-caddy] ${data.toString().trimEnd()}`); + }); + proc.stderr?.on('data', (data: Buffer) => { + logging.error(`[outline-caddy] ${data.toString().trimEnd()}`); + }); + + proc.on('exit', (code, signal) => { + this.process = undefined; + const message = `outline-caddy exited with code ${code}, signal ${signal}`; + if (this.shouldRun) { + logging.warn(`${message}. Restarting.`); + setTimeout(() => { + if (this.shouldRun) { + this.start().catch((error) => { + logging.error(`Failed to restart outline-caddy: ${error}`); + }); + } + }, this.restartDelayMs); + } else { + logging.info(message); + } + }); + }); + } + + private getWebSocketKeys(accessKeys: AccessKey[]) { + return accessKeys + .filter((key) => { + const listeners = key.listeners || []; + return ( + listeners.includes('websocket-stream' as ListenerType) || + listeners.includes('websocket-packet' as ListenerType) + ); + }) + .map((key) => ({ + id: key.id, + cipher: key.proxyParams.encryptionMethod, + secret: key.proxyParams.password, + listeners: key.listeners || [], + })); + } + + private getWebSocketSettings( + streamListener?: ListenerConfig, + packetListener?: ListenerConfig + ): WebSocketListenerSettings { + const tcpPath = this.normalisePath(streamListener?.path ?? '/tcp'); + const udpPath = this.normalisePath(packetListener?.path ?? '/udp'); + const listenPort = streamListener?.webServerPort ?? packetListener?.webServerPort ?? 8080; + return {tcpPath, udpPath, listenPort}; + } + + private normalisePath(pathValue: string): string { + if (!pathValue.startsWith('/')) { + return `/${pathValue}`; + } + return pathValue; + } + + private buildConfig( + payload: OutlineCaddyConfigPayload, + listenerSettings: WebSocketListenerSettings, + websocketKeys: Array<{id: string; cipher: string; secret: string; listeners: ListenerType[]}> + ): CaddyConfig { + const {caddyConfig, hostname} = payload; + const requestedDomain = caddyConfig?.domain?.trim() || hostname; + let autoHttps = !!caddyConfig?.autoHttps; + if (autoHttps && !requestedDomain) { + logging.warn('Caddy auto HTTPS requested but no domain configured; disabling auto HTTPS.'); + autoHttps = false; + } + const domain = requestedDomain; + const listenAddresses = autoHttps + ? [':80', ':443'] + : [`:${listenerSettings.listenPort}`]; + + const hasStreamRoute = + websocketKeys.length === 0 || + websocketKeys.some((key) => key.listeners.includes('websocket-stream')); + const hasPacketRoute = + websocketKeys.length === 0 || + websocketKeys.some((key) => key.listeners.includes('websocket-packet')); + + const routes = []; + if (hasStreamRoute) { + routes.push(this.buildWebsocketRoute(listenerSettings.tcpPath, 'stream', domain)); + } + if (hasPacketRoute) { + routes.push(this.buildWebsocketRoute(listenerSettings.udpPath, 'packet', domain)); + } + + const connectionHandler = { + name: 'outline-ws', + handle: { + handler: 'shadowsocks', + keys: websocketKeys.map((key) => ({ + id: key.id, + cipher: key.cipher, + secret: key.secret, + })), + }, + }; + + const httpServer: Record = { + listen: listenAddresses, + routes, + trusted_proxies: { + source: 'static', + ranges: ['127.0.0.1', '::1'], + }, + client_ip_headers: [ + 'X-Forwarded-For', + 'X-Original-Forwarded-For', + 'Forwarded-For', + 'Forwarded', + 'Client-IP', + 'CF-Connecting-IP', + 'X-Real-IP', + 'X-Client-IP', + 'True-Client-IP', + ], + }; + + if (!autoHttps) { + httpServer['automatic_https'] = {disable: true}; + } + + const apps: Record = { + outline: { + shadowsocks: { + replay_history: 10000, + }, + connection_handlers: [connectionHandler], + }, + http: { + servers: { + 'outline-websocket': httpServer, + }, + }, + }; + + if (autoHttps && domain) { + apps['tls'] = { + automation: { + policies: [ + { + subjects: [domain], + issuers: [ + { + module: 'acme', + ...(caddyConfig?.email ? {email: caddyConfig.email} : {}), + }, + ], + }, + ], + }, + }; + } + + if (this.verbose) { + return { + logging: { + logs: { + default: { + level: 'DEBUG', + }, + }, + }, + apps, + }; + } + + return {apps}; + } + + private buildWebsocketRoute(pathValue: string, type: 'stream' | 'packet', domain?: string) { + const match: Record = { + path: [pathValue], + }; + if (domain) { + match['host'] = [domain]; + } + return { + match: [match], + handle: [ + { + handler: 'websocket2layer4', + type, + connection_handler: 'outline-ws', + }, + ], + }; + } +} diff --git a/src/shadowbox/server/outline_shadowsocks_server.ts b/src/shadowbox/server/outline_shadowsocks_server.ts index 05b19edd0..913def3af 100644 --- a/src/shadowbox/server/outline_shadowsocks_server.ts +++ b/src/shadowbox/server/outline_shadowsocks_server.ts @@ -19,7 +19,8 @@ import * as path from 'path'; import * as file from '../infrastructure/file'; import * as logging from '../infrastructure/logging'; -import {ShadowsocksAccessKey, ShadowsocksServer} from '../model/shadowsocks_server'; +import {ListenerType} from '../model/access_key'; +import {ListenerSettings, ShadowsocksAccessKey, ShadowsocksServer} from '../model/shadowsocks_server'; // Extended interface for access keys with listeners export interface ShadowsocksAccessKeyWithListeners extends ShadowsocksAccessKey { @@ -70,10 +71,7 @@ export class OutlineShadowsocksServer implements ShadowsocksServer { private ipAsnFilename?: string; private isAsnMetricsEnabled = false; private isReplayProtectionEnabled = false; - private webSocketConfig?: { - enabled: boolean; - webServerPort: number; - }; + private listenerSettings: ListenerSettings = {}; /** * @param binaryFilename The location for the outline-ss-server binary. @@ -114,11 +112,45 @@ export class OutlineShadowsocksServer implements ShadowsocksServer { /** * Configures WebSocket support for the Shadowsocks server. * @param webServerPort The port for the internal WebSocket server to listen on. + * @param tcpPath Optional path to expose TCP over WebSocket. + * @param udpPath Optional path to expose UDP over WebSocket. */ - configureWebSocket(webServerPort: number): OutlineShadowsocksServer { - this.webSocketConfig = { - enabled: true, - webServerPort, + configureWebSocket( + webServerPort: number, + tcpPath = '/tcp', + udpPath = '/udp' + ): OutlineShadowsocksServer { + return this.configureListeners({ + websocketStream: {webServerPort, path: tcpPath}, + websocketPacket: {webServerPort, path: udpPath}, + }); + } + + configureListeners(listeners: ListenerSettings | undefined): OutlineShadowsocksServer { + if (!listeners) { + this.listenerSettings = {}; + return this; + } + + 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; + if (stream && sharedPort !== undefined && stream.webServerPort === undefined) { + stream.webServerPort = sharedPort; + } + if (packet && sharedPort !== undefined && packet.webServerPort === undefined) { + packet.webServerPort = sharedPort; + } + + this.listenerSettings = { + websocketStream: stream, + websocketPacket: packet, }; return this; } @@ -163,13 +195,6 @@ export class OutlineShadowsocksServer implements ShadowsocksServer { if (hasWebSocketKeys) { // Use new format with WebSocket support - // Enable WebSocket if not already configured - if (!this.webSocketConfig) { - this.webSocketConfig = { - enabled: true, - webServerPort: 8080 // Default port - }; - } config = this.generateWebSocketConfig(extendedKeys); } else { // Use legacy format for backward compatibility @@ -197,11 +222,27 @@ export class OutlineShadowsocksServer implements ShadowsocksServer { }); } + private getWebSocketSettings() { + const stream = this.listenerSettings.websocketStream ?? {}; + const packet = this.listenerSettings.websocketPacket ?? {}; + const webServerPort = stream.webServerPort ?? packet.webServerPort ?? 8080; + const tcpPath = stream.path ?? '/tcp'; + const udpPath = packet.path ?? '/udp'; + return {webServerPort, tcpPath, udpPath}; + } + private generateWebSocketConfig(keys: ShadowsocksAccessKeyWithListeners[]): WebSocketConfig { - // Group keys by their listener configuration - const serviceGroups = new Map(); - - // Process each key + const {webServerPort, tcpPath, udpPath} = this.getWebSocketSettings(); + const webServerId = 'outline-ws-server'; + + type ListenerDescriptor = WebSocketListener | TcpUdpListener; + interface ServiceGroup { + listeners: ListenerDescriptor[]; + keys: ShadowsocksAccessKeyWithListeners[]; + } + + const serviceGroups = new Map(); + for (const key of keys) { if (!isAeadCipher(key.cipher)) { logging.error( @@ -210,84 +251,103 @@ export class OutlineShadowsocksServer implements ShadowsocksServer { continue; } - // Check if key has WebSocket listeners - const hasWebSocketListeners = key.listeners && ( - key.listeners.indexOf('websocket-stream') !== -1 || - key.listeners.indexOf('websocket-packet') !== -1 + const listenerSet = new Set( + (key.listeners as ListenerType[] | undefined) ?? ['tcp', 'udp'] ); + if (listenerSet.size === 0) { + listenerSet.add('tcp'); + listenerSet.add('udp'); + } - if (hasWebSocketListeners) { - // Group WebSocket-enabled keys - // For now, use default paths - in future, could be configurable per key - const groupKey = `ws:/tcp:/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); + const listenersForKey: ListenerDescriptor[] = []; + + if (listenerSet.has('tcp')) { + listenersForKey.push({ + type: 'tcp', + address: `[::]:${key.port}`, + }); + } + if (listenerSet.has('udp')) { + listenersForKey.push({ + type: 'udp', + address: `[::]:${key.port}`, + }); + } + if (listenerSet.has('websocket-stream')) { + listenersForKey.push({ + type: 'websocket-stream', + web_server: webServerId, + path: tcpPath, + }); + } + if (listenerSet.has('websocket-packet')) { + listenersForKey.push({ + type: 'websocket-packet', + web_server: webServerId, + path: udpPath, + }); + } + + if (listenersForKey.length === 0) { + logging.warn( + `Access key ${key.id} has no listeners configured; assigning default TCP/UDP listeners.` + ); + listenersForKey.push( + {type: 'tcp', address: `[::]:${key.port}`}, + {type: 'udp', address: `[::]:${key.port}`} + ); } + + const signatureParts = listenersForKey + .map((listener) => { + if (listener.type === 'tcp' || listener.type === 'udp') { + return `${listener.type}:${listener.address}`; + } + return `${listener.type}:${listener.path}`; + }) + .sort(); + const groupKey = signatureParts.join('|'); + + if (!serviceGroups.has(groupKey)) { + const listenersClone = listenersForKey.map((listener) => ({...listener})); + serviceGroups.set(groupKey, {listeners: listenersClone as ListenerDescriptor[], keys: []}); + } + serviceGroups.get(groupKey)!.keys.push(key); } - // Build the configuration const config: WebSocketConfig = { - services: [] + services: [], }; - // Add web server configuration if any WebSocket keys exist - if (Array.from(serviceGroups.keys()).some(k => k.startsWith('ws:'))) { - // Use configurable port or default to 8080 - const webServerPort = this.webSocketConfig?.webServerPort || 8080; + const needsWebServer = Array.from(serviceGroups.values()).some((group) => + group.listeners.some( + (listener) => + listener.type === 'websocket-stream' || listener.type === 'websocket-packet' + ) + ); + + if (needsWebServer) { config.web = { - servers: [{ - id: 'outline-ws-server', - listen: [`127.0.0.1:${webServerPort}`] - }] + servers: [ + { + id: webServerId, + listen: [`127.0.0.1:${webServerPort}`], + }, + ], }; } - // Create services - for (const [groupKey, groupKeys] of serviceGroups) { + for (const group of serviceGroups.values()) { const service: ServiceConfig = { - listeners: [], - keys: groupKeys.map(k => ({ + listeners: group.listeners.map((listener) => ({...listener})) as Array< + WebSocketListener | TcpUdpListener + >, + keys: group.keys.map((k) => ({ id: k.id, cipher: k.cipher, - secret: k.secret - })) + 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); } @@ -308,37 +368,56 @@ export class OutlineShadowsocksServer implements ShadowsocksServer { domain: string, tcpPath: string, udpPath: string, - tls: boolean + tls: boolean, + listeners?: ListenerType[] ): string | null { if (!domain) { return null; } + 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.'); + return null; + } + const protocol = tls ? 'wss' : 'ws'; - - // Generate YAML configuration matching Outline client spec exactly - const config = { - transport: { - '$type': 'tcpudp', - tcp: { - '$type': 'shadowsocks', - endpoint: { - '$type': 'websocket', - url: `${protocol}://${domain}${tcpPath}` - }, - cipher: proxyParams.encryptionMethod, - secret: proxyParams.password + const transportType = + includeStream && includePacket ? 'tcpudp' : includeStream ? 'tcp' : 'udp'; + + const transport: Record = { + '$type': transportType, + }; + + if (includeStream) { + transport['tcp'] = { + '$type': 'shadowsocks', + endpoint: { + '$type': 'websocket', + url: `${protocol}://${domain}${tcpPath}`, }, - udp: { - '$type': 'shadowsocks', - endpoint: { - '$type': 'websocket', - url: `${protocol}://${domain}${udpPath}` - }, - cipher: proxyParams.encryptionMethod, - secret: proxyParams.password - } - } + cipher: proxyParams.encryptionMethod, + secret: proxyParams.password, + }; + } + + if (includePacket) { + transport['udp'] = { + '$type': 'shadowsocks', + endpoint: { + '$type': 'websocket', + url: `${protocol}://${domain}${udpPath}`, + }, + cipher: proxyParams.encryptionMethod, + secret: proxyParams.password, + }; + } + + const config = { + transport, }; // Use specific YAML options to ensure proper formatting diff --git a/src/shadowbox/server/server_config.ts b/src/shadowbox/server/server_config.ts index 1a58e15d4..da5c63c2c 100644 --- a/src/shadowbox/server/server_config.ts +++ b/src/shadowbox/server/server_config.ts @@ -34,7 +34,6 @@ export interface ListenersForNewAccessKeys { // Caddy web server configuration export interface CaddyWebServerConfig { enabled?: boolean; - adminEndpoint?: string; // Default: "localhost:2019" autoHttps?: boolean; email?: string; // For ACME domain?: string; // Domain for automatic HTTPS From b964685044613b4a77295ab7a8a4c8d64c4befb6 Mon Sep 17 00:00:00 2001 From: lunarthegrey Date: Wed, 8 Oct 2025 20:12:59 -0500 Subject: [PATCH 25/50] Fix builds --- src/shadowbox/server/manager_service.spec.ts | 6 +++--- src/shadowbox/server/outline_caddy_server.ts | 8 +++++--- src/shadowbox/server/outline_shadowsocks_server.ts | 14 ++++++++++---- 3 files changed, 18 insertions(+), 10 deletions(-) diff --git a/src/shadowbox/server/manager_service.spec.ts b/src/shadowbox/server/manager_service.spec.ts index 69eb74519..e421ebb30 100644 --- a/src/shadowbox/server/manager_service.spec.ts +++ b/src/shadowbox/server/manager_service.spec.ts @@ -1340,9 +1340,9 @@ describe('convertTimeRangeToHours', () => { }); }); -class FakeOutlineCaddyServer { - public applyCalls: OutlineCaddyConfigPayload[] = []; - public shouldFail = false; +class FakeOutlineCaddyServer implements OutlineCaddyController { + applyCalls: OutlineCaddyConfigPayload[] = []; + shouldFail = false; async applyConfig(payload: OutlineCaddyConfigPayload): Promise { this.applyCalls.push(payload); diff --git a/src/shadowbox/server/outline_caddy_server.ts b/src/shadowbox/server/outline_caddy_server.ts index aa667b747..c56a490f3 100644 --- a/src/shadowbox/server/outline_caddy_server.ts +++ b/src/shadowbox/server/outline_caddy_server.ts @@ -137,9 +137,11 @@ export class OutlineCaddyServer implements OutlineCaddyController { }; proc.once('error', onSpawnError); - - this.process = proc; - setImmediate(() => resolve()); + proc.once('spawn', () => { + this.process = proc; + proc.off('error', onSpawnError); + resolve(); + }); proc.stdout?.on('data', (data: Buffer) => { logging.info(`[outline-caddy] ${data.toString().trimEnd()}`); diff --git a/src/shadowbox/server/outline_shadowsocks_server.ts b/src/shadowbox/server/outline_shadowsocks_server.ts index 913def3af..0c25872d1 100644 --- a/src/shadowbox/server/outline_shadowsocks_server.ts +++ b/src/shadowbox/server/outline_shadowsocks_server.ts @@ -236,6 +236,14 @@ 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' + ); + }; + interface ServiceGroup { listeners: ListenerDescriptor[]; keys: ShadowsocksAccessKeyWithListeners[]; @@ -300,7 +308,7 @@ export class OutlineShadowsocksServer implements ShadowsocksServer { const signatureParts = listenersForKey .map((listener) => { - if (listener.type === 'tcp' || listener.type === 'udp') { + if (!isWebSocketListener(listener)) { return `${listener.type}:${listener.address}`; } return `${listener.type}:${listener.path}`; @@ -339,9 +347,7 @@ export class OutlineShadowsocksServer implements ShadowsocksServer { for (const group of serviceGroups.values()) { const service: ServiceConfig = { - listeners: group.listeners.map((listener) => ({...listener})) as Array< - WebSocketListener | TcpUdpListener - >, + listeners: group.listeners.map((listener) => ({...listener})), keys: group.keys.map((k) => ({ id: k.id, cipher: k.cipher, From 799c34a60e2d32382f486e783df9b01d87bb5599 Mon Sep 17 00:00:00 2001 From: lunarthegrey Date: Thu, 9 Oct 2025 00:54:10 -0500 Subject: [PATCH 26/50] Update releases --- go.mod | 5 +++-- go.sum | 10 ++++++---- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/go.mod b/go.mod index 961e732c2..4b025f6b2 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ toolchain go1.24.5 require ( github.com/Jigsaw-Code/outline-ss-server v1.9.2 + github.com/Jigsaw-Code/outline-ss-server/outlinecaddy v0.0.1 github.com/go-task/task/v3 v3.36.0 github.com/google/addlicense v1.1.1 ) @@ -44,10 +45,10 @@ require ( github.com/spf13/pflag v1.0.5 // indirect github.com/zeebo/xxh3 v1.0.2 // indirect golang.org/x/crypto v0.32.0 // indirect - golang.org/x/sync v0.10.0 // indirect + golang.org/x/sync v0.11.0 // indirect golang.org/x/sys v0.29.0 // indirect golang.org/x/term v0.28.0 // indirect - google.golang.org/protobuf v1.36.3 // indirect + google.golang.org/protobuf v1.36.4 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect mvdan.cc/sh/v3 v3.8.0 // indirect ) diff --git a/go.sum b/go.sum index df584d8ff..e8ffae6fd 100644 --- a/go.sum +++ b/go.sum @@ -4,6 +4,8 @@ github.com/Jigsaw-Code/outline-sdk/x v0.0.2-0.20250304133713-52f1a365e5ed h1:Nfy github.com/Jigsaw-Code/outline-sdk/x v0.0.2-0.20250304133713-52f1a365e5ed/go.mod h1:aFUEz6Z/eD0NS3c3fEIX+JO2D9aIrXCmWTb1zJFlItw= github.com/Jigsaw-Code/outline-ss-server v1.9.2 h1:8AlzPLugCCa9H4ZIV79rWOdgVshRzKZalq8ZD+APjqk= github.com/Jigsaw-Code/outline-ss-server v1.9.2/go.mod h1:v0jS3ExOGwGTbWTpOw16/sid91k7PKxazdK9eLCpUlQ= +github.com/Jigsaw-Code/outline-ss-server/outlinecaddy v0.0.1 h1:V6XDwphZ0UAf7YUte4NsM48j6o6Cd4p9LaGLxUTSv0A= +github.com/Jigsaw-Code/outline-ss-server/outlinecaddy v0.0.1/go.mod h1:57uq2C5kK9iIHhokI/9/Vx4E5Q4CL61GCU/DbFWj7fg= github.com/Masterminds/semver/v3 v3.3.1 h1:QtNSWtVZ3nBfk8mAOu/B6v7FMJ+NHTIgUPi7rj+4nv4= github.com/Masterminds/semver/v3 v3.3.1/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= @@ -101,8 +103,8 @@ golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc= golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= -golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w= +golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -113,8 +115,8 @@ golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXR golang.org/x/term v0.28.0 h1:/Ts8HFuMR2E6IP/jlo7QVLZHggjKQbhu/7H0LJFr3Gg= golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -google.golang.org/protobuf v1.36.3 h1:82DV7MYdb8anAVi3qge1wSnMDrnKK7ebr+I0hHRN1BU= -google.golang.org/protobuf v1.36.3/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= +google.golang.org/protobuf v1.36.4 h1:6A3ZDJHn/eNqc1i+IdefRzy/9PokBTPvcqMySR7NNIM= +google.golang.org/protobuf v1.36.4/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= From 72bb7c7b4a8698fae595d2fc8fda8928caae3475 Mon Sep 17 00:00:00 2001 From: lunarthegrey Date: Thu, 9 Oct 2025 01:22:59 -0500 Subject: [PATCH 27/50] Update go.sum --- go.sum | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/go.sum b/go.sum index e8ffae6fd..137e172cd 100644 --- a/go.sum +++ b/go.sum @@ -8,6 +8,8 @@ github.com/Jigsaw-Code/outline-ss-server/outlinecaddy v0.0.1 h1:V6XDwphZ0UAf7YUt github.com/Jigsaw-Code/outline-ss-server/outlinecaddy v0.0.1/go.mod h1:57uq2C5kK9iIHhokI/9/Vx4E5Q4CL61GCU/DbFWj7fg= github.com/Masterminds/semver/v3 v3.3.1 h1:QtNSWtVZ3nBfk8mAOu/B6v7FMJ+NHTIgUPi7rj+4nv4= github.com/Masterminds/semver/v3 v3.3.1/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/caddyserver/caddy/v2 v2.9.1 h1:OEYiZ7DbCzAWVb6TNEkjRcSCRGHVoZsJinoDR/n9oaY= +github.com/caddyserver/caddy/v2 v2.9.1/go.mod h1:ImUELya2el1FDVp3ahnSO2iH1or1aHxlQEQxd/spP68= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bmatcuk/doublestar/v4 v4.0.2 h1:X0krlUVAVmtr2cRoTqR8aDMrDqnB36ht8wpWTiQ3jsA= @@ -36,6 +38,8 @@ github.com/gorilla/handlers v1.4.1 h1:BHvcRGJe/TrL+OqFxoKQGddTgeibiOjaBssV5a/N9s github.com/gorilla/handlers v1.4.1/go.mod h1:Qkdc/uu4tH4g6mTK6auzZ766c4CA0Ng8+o/OAirnOIQ= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/iamd3vil/caddy_yaml_adapter v0.0.0-20200503183711-d479c29b475a h1:5eTxtJy0pyxzY5a1N3bOap7JonTWkuRjrIEs9sK7ciE= +github.com/iamd3vil/caddy_yaml_adapter v0.0.0-20200503183711-d479c29b475a/go.mod h1:6zdSPpoYnt4wSqGahSk9ru2nA2ZPyh0T+T808LGJPy0= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc= @@ -57,6 +61,8 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-zglob v0.0.4 h1:LQi2iOm0/fGgu80AioIJ/1j9w9Oh+9DZ39J4VAGzHQM= github.com/mattn/go-zglob v0.0.4/go.mod h1:MxxjyoXXnMxfIpxTK2GAkw1w8glPsQILx3N5wrKakiY= +github.com/mholt/caddy-l4 v0.0.0-20250102174933-6e5f5e311ead h1:zmGMb9S6f2LJoaZvozULbrY7HpfcnZrRLXsnRP5d+Jo= +github.com/mholt/caddy-l4 v0.0.0-20250102174933-6e5f5e311ead/go.mod h1:zhoEExOYPSuKYLyJE88BOIHNNf3PdOLyYEYbtnmgcSw= github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= @@ -97,6 +103,10 @@ github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ= github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= +go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= +go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +go.uber.org/zap/exp v0.3.0 h1:6JYzdifzYkGmTdRR59oYH+Ng7k49H9qVpWwNSsGJj3U= +go.uber.org/zap/exp v0.3.0/go.mod h1:5I384qq7XGxYyByIhHm6jg5CHkGY0nsTfbDLgDDlgJQ= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210220033148-5ea612d1eb83/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc= From e394dca5be362447bbc8afa7eed0bef237224508 Mon Sep 17 00:00:00 2001 From: lunarthegrey Date: Thu, 9 Oct 2025 02:10:37 -0500 Subject: [PATCH 28/50] Attempt different method for outlinecaddy build --- go.mod | 7 +++---- go.sum | 2 -- src/shadowbox/Taskfile.yml | 25 ++++++++++++++++++++++++- 3 files changed, 27 insertions(+), 7 deletions(-) diff --git a/go.mod b/go.mod index 4b025f6b2..3f3375754 100644 --- a/go.mod +++ b/go.mod @@ -5,10 +5,9 @@ go 1.23 toolchain go1.24.5 require ( - github.com/Jigsaw-Code/outline-ss-server v1.9.2 - github.com/Jigsaw-Code/outline-ss-server/outlinecaddy v0.0.1 - github.com/go-task/task/v3 v3.36.0 - github.com/google/addlicense v1.1.1 + github.com/Jigsaw-Code/outline-ss-server v1.9.2 + github.com/go-task/task/v3 v3.36.0 + github.com/google/addlicense v1.1.1 ) require ( diff --git a/go.sum b/go.sum index 137e172cd..38859b382 100644 --- a/go.sum +++ b/go.sum @@ -4,8 +4,6 @@ github.com/Jigsaw-Code/outline-sdk/x v0.0.2-0.20250304133713-52f1a365e5ed h1:Nfy github.com/Jigsaw-Code/outline-sdk/x v0.0.2-0.20250304133713-52f1a365e5ed/go.mod h1:aFUEz6Z/eD0NS3c3fEIX+JO2D9aIrXCmWTb1zJFlItw= github.com/Jigsaw-Code/outline-ss-server v1.9.2 h1:8AlzPLugCCa9H4ZIV79rWOdgVshRzKZalq8ZD+APjqk= github.com/Jigsaw-Code/outline-ss-server v1.9.2/go.mod h1:v0jS3ExOGwGTbWTpOw16/sid91k7PKxazdK9eLCpUlQ= -github.com/Jigsaw-Code/outline-ss-server/outlinecaddy v0.0.1 h1:V6XDwphZ0UAf7YUte4NsM48j6o6Cd4p9LaGLxUTSv0A= -github.com/Jigsaw-Code/outline-ss-server/outlinecaddy v0.0.1/go.mod h1:57uq2C5kK9iIHhokI/9/Vx4E5Q4CL61GCU/DbFWj7fg= github.com/Masterminds/semver/v3 v3.3.1 h1:QtNSWtVZ3nBfk8mAOu/B6v7FMJ+NHTIgUPi7rj+4nv4= github.com/Masterminds/semver/v3 v3.3.1/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/caddyserver/caddy/v2 v2.9.1 h1:OEYiZ7DbCzAWVb6TNEkjRcSCRGHVoZsJinoDR/n9oaY= diff --git a/src/shadowbox/Taskfile.yml b/src/shadowbox/Taskfile.yml index 27d073331..b9f33b81b 100644 --- a/src/shadowbox/Taskfile.yml +++ b/src/shadowbox/Taskfile.yml @@ -18,6 +18,22 @@ requires: vars: [OUTPUT_BASE] tasks: + download_xcaddy: + desc: Download xcaddy + requires: {vars: [TARGET_DIR]} + vars: + XCADDY_VERSION: v0.4.5 + XCADDY_FILE: '{{if eq .GOARCH "amd64"}}xcaddy_{{.XCADDY_VERSION}}_{{OS}}_x86_64{{else}}xcaddy_{{.XCADDY_VERSION}}_{{OS}}_{{.GOARCH}}{{end}}.tar.gz' + XCADDY_URL: 'https://github.com/caddyserver/xcaddy/releases/download/{{.XCADDY_VERSION}}/{{.XCADDY_FILE}}' + cmds: + - mkdir -p '{{.TARGET_DIR}}' + - | + tmpdir=$(mktemp -d) + curl -fsSL '{{.XCADDY_URL}}' -o "$tmpdir/xcaddy.tgz" + tar -xzf "$tmpdir/xcaddy.tgz" -C '{{.TARGET_DIR}}' xcaddy + rm -rf "$tmpdir" + - chmod +x '{{joinPath .TARGET_DIR "xcaddy"}}' + build: desc: Build the Outline Server Node.js app vars: @@ -40,7 +56,14 @@ tasks: vars: {TARGET_DIR: '{{.BIN_DIR}}'} # Set CGO_ENABLED=0 to force static linkage. See https://mt165.co.uk/blog/static-link-go/. - GOOS={{.TARGET_OS}} GOARCH={{.GOARCH}} CGO_ENABLED=0 go build -ldflags='-s -w -X main.version=embedded' -o '{{.BIN_DIR}}/' github.com/Jigsaw-Code/outline-ss-server/cmd/outline-ss-server - - GOOS={{.TARGET_OS}} GOARCH={{.GOARCH}} CGO_ENABLED=0 go build -tags='nomysql' -ldflags='-s -w' -o '{{.BIN_DIR}}/outline-caddy' github.com/Jigsaw-Code/outline-ss-server/outlinecaddy/cmd/caddy + - task: download_xcaddy + vars: + TARGET_DIR: '{{.BIN_DIR}}' + - | + '{{joinPath .BIN_DIR "xcaddy"}}' build --output '{{joinPath .BIN_DIR "outline-caddy"}}' \ + --with github.com/Jigsaw-Code/outline-ss-server/outlinecaddy@v0.0.1 \ + --with github.com/iamd3vil/caddy_yaml_adapter@v0.0.0-20200503183711-d479c29b475a \ + --with github.com/mholt/caddy-l4@v0.0.0-20250102174933-6e5f5e311ead start: desc: Run the Outline server locally From 73cd6a94cef4dc2ea901430af55168a276edb37e Mon Sep 17 00:00:00 2001 From: lunarthegrey Date: Thu, 9 Oct 2025 02:19:31 -0500 Subject: [PATCH 29/50] Fix builds --- src/shadowbox/Taskfile.yml | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/src/shadowbox/Taskfile.yml b/src/shadowbox/Taskfile.yml index b9f33b81b..e7adc4fd4 100644 --- a/src/shadowbox/Taskfile.yml +++ b/src/shadowbox/Taskfile.yml @@ -20,17 +20,22 @@ requires: tasks: download_xcaddy: desc: Download xcaddy - requires: {vars: [TARGET_DIR]} + requires: {vars: [TARGET_DIR, XCADDY_OS, XCADDY_ARCH]} vars: XCADDY_VERSION: v0.4.5 - XCADDY_FILE: '{{if eq .GOARCH "amd64"}}xcaddy_{{.XCADDY_VERSION}}_{{OS}}_x86_64{{else}}xcaddy_{{.XCADDY_VERSION}}_{{OS}}_{{.GOARCH}}{{end}}.tar.gz' + XCADDY_VERSION_NUMBER: '{{trimPrefix .XCADDY_VERSION "v"}}' + XCADDY_FILE: 'xcaddy_{{.XCADDY_VERSION_NUMBER}}_{{.XCADDY_OS}}_{{.XCADDY_ARCH}}{{if eq .XCADDY_OS "windows"}}.zip{{else}}.tar.gz{{end}}' XCADDY_URL: 'https://github.com/caddyserver/xcaddy/releases/download/{{.XCADDY_VERSION}}/{{.XCADDY_FILE}}' cmds: - mkdir -p '{{.TARGET_DIR}}' - | tmpdir=$(mktemp -d) - curl -fsSL '{{.XCADDY_URL}}' -o "$tmpdir/xcaddy.tgz" - tar -xzf "$tmpdir/xcaddy.tgz" -C '{{.TARGET_DIR}}' xcaddy + curl -fsSL '{{.XCADDY_URL}}' -o "$tmpdir/xcaddy.pkg" + {{- if eq .XCADDY_OS "windows" }} + unzip -oq "$tmpdir/xcaddy.pkg" -d '{{.TARGET_DIR}}' + {{- else }} + tar -xzf "$tmpdir/xcaddy.pkg" -C '{{.TARGET_DIR}}' xcaddy + {{- end }} rm -rf "$tmpdir" - chmod +x '{{joinPath .TARGET_DIR "xcaddy"}}' @@ -59,6 +64,8 @@ tasks: - task: download_xcaddy vars: TARGET_DIR: '{{.BIN_DIR}}' + XCADDY_OS: '{{.TARGET_OS}}' + XCADDY_ARCH: '{{.GOARCH}}' - | '{{joinPath .BIN_DIR "xcaddy"}}' build --output '{{joinPath .BIN_DIR "outline-caddy"}}' \ --with github.com/Jigsaw-Code/outline-ss-server/outlinecaddy@v0.0.1 \ From 55f04b479c8e9bc5e290b03512a193d615e5025a Mon Sep 17 00:00:00 2001 From: lunarthegrey Date: Thu, 9 Oct 2025 02:24:18 -0500 Subject: [PATCH 30/50] Fix builds --- src/shadowbox/Taskfile.yml | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/shadowbox/Taskfile.yml b/src/shadowbox/Taskfile.yml index e7adc4fd4..a0fd4fb71 100644 --- a/src/shadowbox/Taskfile.yml +++ b/src/shadowbox/Taskfile.yml @@ -23,8 +23,10 @@ tasks: requires: {vars: [TARGET_DIR, XCADDY_OS, XCADDY_ARCH]} vars: XCADDY_VERSION: v0.4.5 - XCADDY_VERSION_NUMBER: '{{trimPrefix .XCADDY_VERSION "v"}}' - XCADDY_FILE: 'xcaddy_{{.XCADDY_VERSION_NUMBER}}_{{.XCADDY_OS}}_{{.XCADDY_ARCH}}{{if eq .XCADDY_OS "windows"}}.zip{{else}}.tar.gz{{end}}' + XCADDY_VERSION_NUMBER: '{{replace .XCADDY_VERSION "v" ""}}' + XCADDY_ARCH_LABEL: '{{if eq .XCADDY_ARCH "x86_64"}}amd64{{else if eq .XCADDY_ARCH "arm64"}}arm64{{else if eq .XCADDY_ARCH "armv6"}}armv6{{else if eq .XCADDY_ARCH "armv7"}}armv7{{else}}{{.XCADDY_ARCH}}{{end}}' + XCADDY_OS_LABEL: '{{if eq .XCADDY_OS "darwin"}}mac{{else}}{{.XCADDY_OS}}{{end}}' + XCADDY_FILE: 'xcaddy_{{.XCADDY_VERSION_NUMBER}}_{{.XCADDY_OS_LABEL}}_{{.XCADDY_ARCH_LABEL}}{{if eq .XCADDY_OS_LABEL "windows"}}.zip{{else}}.tar.gz{{end}}' XCADDY_URL: 'https://github.com/caddyserver/xcaddy/releases/download/{{.XCADDY_VERSION}}/{{.XCADDY_FILE}}' cmds: - mkdir -p '{{.TARGET_DIR}}' @@ -37,7 +39,10 @@ tasks: tar -xzf "$tmpdir/xcaddy.pkg" -C '{{.TARGET_DIR}}' xcaddy {{- end }} rm -rf "$tmpdir" - - chmod +x '{{joinPath .TARGET_DIR "xcaddy"}}' + - | + {{- if ne .XCADDY_OS_LABEL "windows" -}} + chmod +x '{{joinPath .TARGET_DIR "xcaddy"}}' + {{- end -}} build: desc: Build the Outline Server Node.js app From cdd10708ab8305e81e3d9ce448c50208476a1bd1 Mon Sep 17 00:00:00 2001 From: lunarthegrey Date: Tue, 2 Dec 2025 22:24:47 -0600 Subject: [PATCH 31/50] Update Taskfile to use `trimPrefix` instead of `replace` for XCADDY version number extraction. --- src/shadowbox/Taskfile.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/shadowbox/Taskfile.yml b/src/shadowbox/Taskfile.yml index a0fd4fb71..c90934d4d 100644 --- a/src/shadowbox/Taskfile.yml +++ b/src/shadowbox/Taskfile.yml @@ -23,7 +23,7 @@ tasks: requires: {vars: [TARGET_DIR, XCADDY_OS, XCADDY_ARCH]} vars: XCADDY_VERSION: v0.4.5 - XCADDY_VERSION_NUMBER: '{{replace .XCADDY_VERSION "v" ""}}' + XCADDY_VERSION_NUMBER: '{{trimPrefix "v" .XCADDY_VERSION}}' XCADDY_ARCH_LABEL: '{{if eq .XCADDY_ARCH "x86_64"}}amd64{{else if eq .XCADDY_ARCH "arm64"}}arm64{{else if eq .XCADDY_ARCH "armv6"}}armv6{{else if eq .XCADDY_ARCH "armv7"}}armv7{{else}}{{.XCADDY_ARCH}}{{end}}' XCADDY_OS_LABEL: '{{if eq .XCADDY_OS "darwin"}}mac{{else}}{{.XCADDY_OS}}{{end}}' XCADDY_FILE: 'xcaddy_{{.XCADDY_VERSION_NUMBER}}_{{.XCADDY_OS_LABEL}}_{{.XCADDY_ARCH_LABEL}}{{if eq .XCADDY_OS_LABEL "windows"}}.zip{{else}}.tar.gz{{end}}' From 573e6746378f24ebf9a33ff391dba702f7762c5f Mon Sep 17 00:00:00 2001 From: lunarthegrey Date: Tue, 2 Dec 2025 22:39:10 -0600 Subject: [PATCH 32/50] Update caddy-l4 plugin version in Taskfile.yml --- src/shadowbox/Taskfile.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/shadowbox/Taskfile.yml b/src/shadowbox/Taskfile.yml index c90934d4d..52719f436 100644 --- a/src/shadowbox/Taskfile.yml +++ b/src/shadowbox/Taskfile.yml @@ -75,7 +75,7 @@ tasks: '{{joinPath .BIN_DIR "xcaddy"}}' build --output '{{joinPath .BIN_DIR "outline-caddy"}}' \ --with github.com/Jigsaw-Code/outline-ss-server/outlinecaddy@v0.0.1 \ --with github.com/iamd3vil/caddy_yaml_adapter@v0.0.0-20200503183711-d479c29b475a \ - --with github.com/mholt/caddy-l4@v0.0.0-20250102174933-6e5f5e311ead + --with github.com/mholt/caddy-l4@v0.0.0-20251201210923-0c96591f5650 start: desc: Run the Outline server locally From 859b152b0488ec410b50d99ae9636023115888ac Mon Sep 17 00:00:00 2001 From: lunarthegrey Date: Thu, 4 Dec 2025 03:01:11 -0600 Subject: [PATCH 33/50] Add whitespace for consistency in manager_service.spec.ts. --- src/shadowbox/server/manager_service.spec.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/shadowbox/server/manager_service.spec.ts b/src/shadowbox/server/manager_service.spec.ts index e421ebb30..411c33dae 100644 --- a/src/shadowbox/server/manager_service.spec.ts +++ b/src/shadowbox/server/manager_service.spec.ts @@ -1358,7 +1358,9 @@ class FakeOutlineCaddyServer implements OutlineCaddyController { class ShadowsocksManagerServiceBuilder { private defaultServerName_ = 'default name'; - private serverConfig_: JsonConfig = null; + private serverConfig_: JsonConfig = new InMemoryConfig( + {} as ServerConfigJson + ); private accessKeys_: AccessKeyRepository = null; private shadowsocksServer_: ShadowsocksServer = null; private managerMetrics_: ManagerMetrics = null; From c962ddf81f9f9d24a11232f0977335ad5eb71f3a Mon Sep 17 00:00:00 2001 From: lunarthegrey Date: Thu, 4 Dec 2025 03:09:26 -0600 Subject: [PATCH 34/50] Include listeners in manager service server info property checks and apply formatting. --- src/shadowbox/server/manager_service.spec.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/shadowbox/server/manager_service.spec.ts b/src/shadowbox/server/manager_service.spec.ts index 411c33dae..238140e8a 100644 --- a/src/shadowbox/server/manager_service.spec.ts +++ b/src/shadowbox/server/manager_service.spec.ts @@ -42,6 +42,7 @@ const EXPECTED_ACCESS_KEY_PROPERTIES = [ 'method', 'accessUrl', 'dataLimit', + 'listeners', ].sort(); const SEND_NOTHING = (_httpCode, _data) => {}; @@ -771,8 +772,8 @@ describe('ShadowsocksManagerService', () => { .build(); const listeners = { - tcp: {port: 443}, - udp: {port: 8443}, + tcp: {port: 8443}, + udp: {port: 9443}, websocketStream: {path: '/stream', webServerPort: 8080}, websocketPacket: {path: '/packet', webServerPort: 8080}, }; @@ -809,8 +810,8 @@ describe('ShadowsocksManagerService', () => { .build(); const listenersWithWebsocket = { - tcp: {port: 443}, - udp: {port: 443}, + tcp: {port: 8443}, + udp: {port: 8443}, websocketStream: {path: '/tcp', webServerPort: 8080}, websocketPacket: {path: '/udp', webServerPort: 8080}, }; From faf0fef97ec38d81976a25dfad4b0adf589fb409 Mon Sep 17 00:00:00 2001 From: lunarthegrey Date: Thu, 4 Dec 2025 03:21:04 -0600 Subject: [PATCH 35/50] Correctly assert access key properties when created without listeners --- src/shadowbox/server/manager_service.spec.ts | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/src/shadowbox/server/manager_service.spec.ts b/src/shadowbox/server/manager_service.spec.ts index 238140e8a..656518ed6 100644 --- a/src/shadowbox/server/manager_service.spec.ts +++ b/src/shadowbox/server/manager_service.spec.ts @@ -45,6 +45,17 @@ const EXPECTED_ACCESS_KEY_PROPERTIES = [ 'listeners', ].sort(); +// Keys created directly via repo don't have listeners set +const EXPECTED_ACCESS_KEY_PROPERTIES_WITHOUT_LISTENERS = [ + 'id', + 'name', + 'password', + 'port', + 'method', + 'accessUrl', + 'dataLimit', +].sort(); + const SEND_NOTHING = (_httpCode, _data) => {}; describe('ShadowsocksManagerService', () => { @@ -299,8 +310,12 @@ describe('ShadowsocksManagerService', () => { expect(data.accessKeys.length).toEqual(2); const serviceAccessKey1 = data.accessKeys[0]; const serviceAccessKey2 = data.accessKeys[1]; - expect(Object.keys(serviceAccessKey1).sort()).toEqual(EXPECTED_ACCESS_KEY_PROPERTIES); - expect(Object.keys(serviceAccessKey2).sort()).toEqual(EXPECTED_ACCESS_KEY_PROPERTIES); + expect(Object.keys(serviceAccessKey1).sort()).toEqual( + EXPECTED_ACCESS_KEY_PROPERTIES_WITHOUT_LISTENERS + ); + expect(Object.keys(serviceAccessKey2).sort()).toEqual( + EXPECTED_ACCESS_KEY_PROPERTIES_WITHOUT_LISTENERS + ); expect(serviceAccessKey1.name).toEqual(accessKeyName); responseProcessed = true; // required for afterEach to pass. }, From 54bc6b3ce1f26160021efa832eaa69a51cb14a6d Mon Sep 17 00:00:00 2001 From: lunarthegrey Date: Thu, 4 Dec 2025 03:53:22 -0600 Subject: [PATCH 36/50] Add WebSocket support (SS over WSS) documentation and refactor manager service tests. --- src/shadowbox/README.md | 63 +++++ src/shadowbox/server/manager_service.spec.ts | 231 ++++++++----------- 2 files changed, 158 insertions(+), 136 deletions(-) diff --git a/src/shadowbox/README.md b/src/shadowbox/README.md index c0519aa23..cc7eed902 100644 --- a/src/shadowbox/README.md +++ b/src/shadowbox/README.md @@ -115,6 +115,69 @@ The Outline Server provides a REST API for access key management. If you know th Consult the [OpenAPI spec](./server/api.yml) and [documentation](https://redocly.github.io/redoc/?url=https://raw.githubusercontent.com/Jigsaw-Code/outline-server/master/src/shadowbox/server/api.yml) for more options. +## WebSocket Support (SS over WSS) + +The Outline Server supports Shadowsocks over WebSocket (SS over WSS) for improved censorship resistance. This tunnels Shadowsocks traffic over WebSocket connections, making it look like regular HTTPS web traffic. + +### Enabling WebSocket Support + +1. **Configure Listeners for New Access Keys:** + + Set up listener configuration including WebSocket paths: + + ```sh + curl --insecure -X PUT -H "Content-Type: application/json" \ + -d '{ + "tcp": {"port": 443}, + "udp": {"port": 443}, + "websocketStream": {"path": "/tcp", "webServerPort": 8080}, + "websocketPacket": {"path": "/udp", "webServerPort": 8080} + }' \ + $API_URL/server/listeners-for-new-access-keys + ``` + +2. **Enable the Caddy Web Server (for automatic HTTPS):** + + ```sh + curl --insecure -X PUT -H "Content-Type: application/json" \ + -d '{ + "enabled": true, + "autoHttps": true, + "email": "admin@example.com", + "domain": "your-domain.com" + }' \ + $API_URL/server/web-server + ``` + +3. **Create WebSocket-Enabled Access Keys:** + + ```sh + curl --insecure -X POST -H "Content-Type: application/json" \ + -d '{ + "name": "WebSocket User", + "listeners": ["tcp", "udp", "websocket-stream", "websocket-packet"] + }' \ + $API_URL/access-keys + ``` + +4. **Get Dynamic Client Configuration:** + + For WebSocket-enabled keys, retrieve the YAML configuration: + + ```sh + curl --insecure $API_URL/access-keys/0/dynamic-config + ``` + +### Listener Types + +- `tcp` - Traditional TCP Shadowsocks +- `udp` - Traditional UDP Shadowsocks +- `websocket-stream` - TCP over WebSocket (for TCP traffic tunneling) +- `websocket-packet` - UDP over WebSocket (for UDP traffic tunneling) + +> [!NOTE] +> WebSocket support requires a reverse proxy (like Caddy, Nginx, or Cloudflare Tunnel) in front of the internal WebSocket server port (default: 8080) to handle TLS termination and external traffic. + ## Testing ### Manual diff --git a/src/shadowbox/server/manager_service.spec.ts b/src/shadowbox/server/manager_service.spec.ts index 656518ed6..544199f7e 100644 --- a/src/shadowbox/server/manager_service.spec.ts +++ b/src/shadowbox/server/manager_service.spec.ts @@ -71,7 +71,7 @@ describe('ShadowsocksManagerService', () => { }); describe('getServer', () => { - it('Return default name if name is absent', (done) => { + it('Return default name if name is absent', () => { const repo = getAccessKeyRepository(); const serverConfig = new InMemoryConfig({} as ServerConfigJson); const service = new ShadowsocksManagerServiceBuilder() @@ -87,10 +87,10 @@ describe('ShadowsocksManagerService', () => { responseProcessed = true; }, }, - done + () => {} ); }); - it('Returns persisted properties', (done) => { + it('Returns persisted properties', () => { const repo = getAccessKeyRepository(); const defaultDataLimit = {bytes: 999}; const serverConfig = new InMemoryConfig({ @@ -111,13 +111,13 @@ describe('ShadowsocksManagerService', () => { responseProcessed = true; }, }, - done + () => {} ); }); }); describe('renameServer', () => { - it('Rename changes the server name', (done) => { + it('Rename changes the server name', () => { const repo = getAccessKeyRepository(); const serverConfig = new InMemoryConfig({} as ServerConfigJson); const service = new ShadowsocksManagerServiceBuilder() @@ -133,13 +133,13 @@ describe('ShadowsocksManagerService', () => { responseProcessed = true; }, }, - done + () => {} ); }); }); describe('setHostnameForAccessKeys', () => { - it(`accepts valid hostnames`, async (done) => { + it(`accepts valid hostnames`, async () => { const serverConfig = new InMemoryConfig({} as ServerConfigJson); const service = new ShadowsocksManagerServiceBuilder() .serverConfig(serverConfig) @@ -168,9 +168,8 @@ describe('ShadowsocksManagerService', () => { } responseProcessed = true; - done(); }); - it(`rejects invalid hostnames`, async (done) => { + it(`rejects invalid hostnames`, async () => { const serverConfig = new InMemoryConfig({} as ServerConfigJson); const service = new ShadowsocksManagerServiceBuilder() .serverConfig(serverConfig) @@ -196,9 +195,8 @@ describe('ShadowsocksManagerService', () => { } responseProcessed = true; - done(); }); - it("Changes the server's hostname", async (done) => { + it("Changes the server's hostname", async () => { const serverConfig = new InMemoryConfig({} as ServerConfigJson); const service = new ShadowsocksManagerServiceBuilder() .serverConfig(serverConfig) @@ -212,9 +210,9 @@ describe('ShadowsocksManagerService', () => { responseProcessed = true; }, }; - await service.setHostnameForAccessKeys({params: {hostname}}, res, done); + await service.setHostnameForAccessKeys({params: {hostname}}, res, () => {}); }); - it('Rejects missing hostname', async (done) => { + it('Rejects missing hostname', async () => { const serverConfig = new InMemoryConfig({} as ServerConfigJson); const service = new ShadowsocksManagerServiceBuilder() .serverConfig(serverConfig) @@ -224,12 +222,11 @@ describe('ShadowsocksManagerService', () => { const next = (error) => { expect(error.statusCode).toEqual(400); responseProcessed = true; - done(); }; const missingHostname = {params: {}} as {params: {hostname: string}}; await service.setHostnameForAccessKeys(missingHostname, res, next); }); - it('Rejects non-string hostname', async (done) => { + it('Rejects non-string hostname', async () => { const serverConfig = new InMemoryConfig({} as ServerConfigJson); const service = new ShadowsocksManagerServiceBuilder() .serverConfig(serverConfig) @@ -239,16 +236,15 @@ describe('ShadowsocksManagerService', () => { const next = (error) => { expect(error.statusCode).toEqual(400); responseProcessed = true; - done(); }; // eslint-disable-next-line @typescript-eslint/no-explicit-any const badHostname = {params: {hostname: 123}} as any as {params: {hostname: string}}; - service.setHostnameForAccessKeys(badHostname, res, next); + await service.setHostnameForAccessKeys(badHostname, res, next); }); }); describe('getAccessKey', () => { - it('Returns an access key', async (done) => { + it('Returns an access key', async () => { const repo = getAccessKeyRepository(); const service = new ShadowsocksManagerServiceBuilder().accessKeys(repo).build(); const key1 = await createNewAccessKeyWithName(repo, 'keyName1'); @@ -261,23 +257,22 @@ describe('ShadowsocksManagerService', () => { responseProcessed = true; }, }, - done + () => {} ); }); - it('Returns 404 if the access key does not exist', (done) => { + it('Returns 404 if the access key does not exist', () => { const repo = getAccessKeyRepository(); const service = new ShadowsocksManagerServiceBuilder().accessKeys(repo).build(); service.getAccessKey({params: {id: '1'}}, {send: () => {}}, (error) => { expect(error.statusCode).toEqual(404); responseProcessed = true; - done(); }); }); }); describe('listAccessKeys', () => { - it('lists access keys in order', async (done) => { + it('lists access keys in order', async () => { const repo = getAccessKeyRepository(); const service = new ShadowsocksManagerServiceBuilder().accessKeys(repo).build(); // Create 2 access keys with names. @@ -295,9 +290,9 @@ describe('ShadowsocksManagerService', () => { responseProcessed = true; // required for afterEach to pass. }, }; - service.listAccessKeys({params: {}}, res, done); + service.listAccessKeys({params: {}}, res, () => {}); }); - it('lists access keys with expected properties', async (done) => { + it('lists access keys with expected properties', async () => { const repo = getAccessKeyRepository(); const service = new ShadowsocksManagerServiceBuilder().accessKeys(repo).build(); const accessKey = await repo.createNewAccessKey(); @@ -320,7 +315,7 @@ describe('ShadowsocksManagerService', () => { responseProcessed = true; // required for afterEach to pass. }, }; - service.listAccessKeys({params: {}}, res, done); + service.listAccessKeys({params: {}}, res, () => {}); }); }); @@ -335,7 +330,7 @@ describe('ShadowsocksManagerService', () => { describe('handling the access key identifier', () => { describe("with 'createNewAccessKey'", () => { - it('generates a unique ID', (done) => { + it('generates a unique ID', () => { const res = { send: (httpCode, data) => { expect(httpCode).toEqual(201); @@ -343,45 +338,41 @@ describe('ShadowsocksManagerService', () => { responseProcessed = true; // required for afterEach to pass. }, }; - service.createNewAccessKey({params: {}}, res, done); + service.createNewAccessKey({params: {}}, res, () => {}); }); - it('rejects requests with ID parameter set', (done) => { + it('rejects requests with ID parameter set', () => { const res = {send: (_httpCode, _data) => {}}; service.createNewAccessKey({params: {id: 'foobar'}}, res, (error) => { expect(error.statusCode).toEqual(400); responseProcessed = true; // required for afterEach to pass. - done(); }); }); }); describe("with 'createAccessKey'", () => { - it('rejects requests without ID parameter set', (done) => { + it('rejects requests without ID parameter set', () => { const res = {send: (_httpCode, _data) => {}}; service.createAccessKey({params: {}}, res, (error) => { expect(error.statusCode).toEqual(400); responseProcessed = true; // required for afterEach to pass. - done(); }); }); - it('rejects non-string ID', (done) => { + it('rejects non-string ID', () => { const res = {send: (_httpCode, _data) => {}}; service.createAccessKey({params: {id: Number('9876')}}, res, (error) => { expect(error.statusCode).toEqual(400); responseProcessed = true; // required for afterEach to pass. - done(); }); }); - it('rejects if key exists', async (done) => { + it('rejects if key exists', async () => { const accessKey = await repo.createNewAccessKey(); const res = {send: (_httpCode, _data) => {}}; service.createAccessKey({params: {id: accessKey.id}}, res, (error) => { expect(error.statusCode).toEqual(409); responseProcessed = true; // required for afterEach to pass. - done(); }); }); - it('creates key with provided ID', (done) => { + it('creates key with provided ID', () => { const res = { send: (httpCode, data) => { expect(httpCode).toEqual(201); @@ -389,7 +380,7 @@ describe('ShadowsocksManagerService', () => { responseProcessed = true; // required for afterEach to pass. }, }; - service.createAccessKey({params: {id: 'myKeyId'}}, res, done); + service.createAccessKey({params: {id: 'myKeyId'}}, res, () => {}); }); }); }); @@ -407,7 +398,7 @@ describe('ShadowsocksManagerService', () => { serviceMethod = service[methodName].bind(service); }); - it('verify default method', (done) => { + it('verify default method', () => { // Verify that response returns a key with the expected properties. const res = { send: (httpCode, data) => { @@ -417,9 +408,9 @@ describe('ShadowsocksManagerService', () => { responseProcessed = true; // required for afterEach to pass. }, }; - serviceMethod({params: {id: accessKeyId}}, res, done); + serviceMethod({params: {id: accessKeyId}}, res, () => {}); }); - it('non-default method gets set', (done) => { + it('non-default method gets set', () => { // Verify that response returns a key with the expected properties. const res = { send: (httpCode, data) => { @@ -429,9 +420,9 @@ describe('ShadowsocksManagerService', () => { responseProcessed = true; // required for afterEach to pass. }, }; - serviceMethod({params: {id: accessKeyId, method: 'aes-256-gcm'}}, res, done); + serviceMethod({params: {id: accessKeyId, method: 'aes-256-gcm'}}, res, () => {}); }); - it('use default name is params is not defined', (done) => { + it('use default name is params is not defined', () => { const res = { send: (httpCode, data) => { expect(httpCode).toEqual(201); @@ -439,17 +430,16 @@ describe('ShadowsocksManagerService', () => { responseProcessed = true; // required for afterEach to pass. }, }; - serviceMethod({params: {id: accessKeyId}}, res, done); + serviceMethod({params: {id: accessKeyId}}, res, () => {}); }); - it('rejects non-string name', (done) => { + it('rejects non-string name', () => { const res = {send: (_httpCode, _data) => {}}; serviceMethod({params: {id: accessKeyId, name: Number('9876')}}, res, (error) => { expect(error.statusCode).toEqual(400); responseProcessed = true; // required for afterEach to pass. - done(); }); }); - it('defined name is equal to stored', (done) => { + it('defined name is equal to stored', () => { const ACCESSKEY_NAME = 'accesskeyname'; const res = { send: (httpCode, data) => { @@ -458,9 +448,9 @@ describe('ShadowsocksManagerService', () => { responseProcessed = true; // required for afterEach to pass. }, }; - serviceMethod({params: {id: accessKeyId, name: ACCESSKEY_NAME}}, res, done); + serviceMethod({params: {id: accessKeyId, name: ACCESSKEY_NAME}}, res, () => {}); }); - it('limit can be undefined', (done) => { + it('limit can be undefined', () => { const res = { send: (httpCode, data) => { expect(httpCode).toEqual(201); @@ -468,19 +458,18 @@ describe('ShadowsocksManagerService', () => { responseProcessed = true; // required for afterEach to pass. }, }; - serviceMethod({params: {id: accessKeyId}}, res, done); + serviceMethod({params: {id: accessKeyId}}, res, () => {}); }); - it('rejects non-numeric limits', (done) => { + it('rejects non-numeric limits', () => { const ACCESSKEY_LIMIT = {bytes: '9876'}; const res = {send: (_httpCode, _data) => {}}; serviceMethod({params: {id: accessKeyId, limit: ACCESSKEY_LIMIT}}, res, (error) => { expect(error.statusCode).toEqual(400); responseProcessed = true; // required for afterEach to pass. - done(); }); }); - it('defined limit is equal to stored', (done) => { + it('defined limit is equal to stored', () => { const ACCESSKEY_LIMIT = {bytes: 9876}; const res = { send: (httpCode, data) => { @@ -489,35 +478,32 @@ describe('ShadowsocksManagerService', () => { responseProcessed = true; // required for afterEach to pass. }, }; - serviceMethod({params: {id: accessKeyId, limit: ACCESSKEY_LIMIT}}, res, done); + serviceMethod({params: {id: accessKeyId, limit: ACCESSKEY_LIMIT}}, res, () => {}); }); - it('method must be of type string', (done) => { + it('method must be of type string', () => { const res = {send: (_httpCode, _data) => {}}; serviceMethod({params: {id: accessKeyId, method: Number('9876')}}, res, (error) => { expect(error.statusCode).toEqual(400); responseProcessed = true; // required for afterEach to pass. - done(); }); }); - it('method must be valid', (done) => { + it('method must be valid', () => { const res = {send: (_httpCode, _data) => {}}; serviceMethod({params: {id: accessKeyId, method: 'abcdef'}}, res, (error) => { expect(error.statusCode).toEqual(400); responseProcessed = true; // required for afterEach to pass. - done(); }); }); - it('Create returns a 500 when the repository throws an exception', (done) => { + it('Create returns a 500 when the repository throws an exception', () => { spyOn(repo, 'createNewAccessKey').and.throwError('cannot write to disk'); const res = {send: (_httpCode, _data) => {}}; serviceMethod({params: {id: accessKeyId, method: 'aes-192-gcm'}}, res, (error) => { expect(error.statusCode).toEqual(500); responseProcessed = true; // required for afterEach to pass. - done(); }); }); - it('generates a new password when no password is provided', async (done) => { + it('generates a new password when no password is provided', async () => { const res = { send: (httpCode, data) => { expect(httpCode).toEqual(201); @@ -525,10 +511,10 @@ describe('ShadowsocksManagerService', () => { responseProcessed = true; // required for afterEach to pass. }, }; - await serviceMethod({params: {id: accessKeyId}}, res, done); + await serviceMethod({params: {id: accessKeyId}}, res, () => {}); }); - it('uses the provided password when one is provided', async (done) => { + it('uses the provided password when one is provided', async () => { const PASSWORD = '8iu8V8EeoFVpwQvQeS9wiD'; const res = { send: (httpCode, data) => { @@ -537,29 +523,27 @@ describe('ShadowsocksManagerService', () => { responseProcessed = true; // required for afterEach to pass. }, }; - await serviceMethod({params: {id: accessKeyId, password: PASSWORD}}, res, done); + await serviceMethod({params: {id: accessKeyId, password: PASSWORD}}, res, () => {}); }); - it('rejects a password that is not a string', async (done) => { + it('rejects a password that is not a string', async () => { const PASSWORD = Number.MAX_SAFE_INTEGER; const res = {send: SEND_NOTHING}; await serviceMethod({params: {id: accessKeyId, password: PASSWORD}}, res, (error) => { expect(error.statusCode).toEqual(400); responseProcessed = true; // required for afterEach to pass. - done(); }); }); - it('rejects a password that is already in use', async (done) => { + it('rejects a password that is already in use', async () => { const PASSWORD = 'foobar'; await repo.createNewAccessKey({password: PASSWORD}); const res = {send: SEND_NOTHING}; await serviceMethod({params: {id: accessKeyId, password: PASSWORD}}, res, (error) => { expect(error.statusCode).toEqual(409); responseProcessed = true; // required for afterEach to pass. - done(); }); }); - it('uses the default port for new keys when no port is provided', async (done) => { + it('uses the default port for new keys when no port is provided', async () => { const res = { send: (httpCode, data) => { expect(httpCode).toEqual(201); @@ -567,10 +551,10 @@ describe('ShadowsocksManagerService', () => { responseProcessed = true; // required for afterEach to pass. }, }; - await serviceMethod({params: {id: accessKeyId}}, res, done); + await serviceMethod({params: {id: accessKeyId}}, res, () => {}); }); - it('uses the provided port when one is provided', async (done) => { + it('uses the provided port when one is provided', async () => { const res = { send: (httpCode, data) => { expect(httpCode).toEqual(201); @@ -578,28 +562,26 @@ describe('ShadowsocksManagerService', () => { responseProcessed = true; // required for afterEach to pass. }, }; - await serviceMethod({params: {id: accessKeyId, port: NEW_PORT}}, res, done); + await serviceMethod({params: {id: accessKeyId, port: NEW_PORT}}, res, () => {}); }); - it('rejects ports that are not numbers', async (done) => { + it('rejects ports that are not numbers', async () => { const res = {send: SEND_NOTHING}; await serviceMethod({params: {id: accessKeyId, port: '1234'}}, res, (error) => { expect(error.statusCode).toEqual(400); responseProcessed = true; // required for afterEach to pass. - done(); }); }); - it('rejects invalid port numbers', async (done) => { + it('rejects invalid port numbers', async () => { const res = {send: SEND_NOTHING}; await serviceMethod({params: {id: accessKeyId, port: 1.4}}, res, (error) => { expect(error.statusCode).toEqual(400); responseProcessed = true; // required for afterEach to pass. - done(); }); }); - it('rejects port numbers already in use', async (done) => { + it('rejects port numbers already in use', async () => { const server = new net.Server(); server.listen(NEW_PORT, async () => { const res = {send: SEND_NOTHING}; @@ -607,7 +589,6 @@ describe('ShadowsocksManagerService', () => { expect(error.statusCode).toEqual(409); responseProcessed = true; // required for afterEach to pass. server.close(); - done(); }); }); }); @@ -615,7 +596,7 @@ describe('ShadowsocksManagerService', () => { } }); describe('setPortForNewAccessKeys', () => { - it('changes ports for new access keys', async (done) => { + it('changes ports for new access keys', async () => { const repo = getAccessKeyRepository(); const serverConfig = new InMemoryConfig({} as ServerConfigJson); const service = new ShadowsocksManagerServiceBuilder() @@ -634,10 +615,9 @@ describe('ShadowsocksManagerService', () => { expect(newKey.proxyParams.portNumber).toEqual(NEW_PORT); expect(oldKey.proxyParams.portNumber).not.toEqual(NEW_PORT); responseProcessed = true; - done(); }); - it('changes the server config', async (done) => { + it('changes the server config', async () => { const repo = getAccessKeyRepository(); const serverConfig = new InMemoryConfig({} as ServerConfigJson); const service = new ShadowsocksManagerServiceBuilder() @@ -652,10 +632,10 @@ describe('ShadowsocksManagerService', () => { responseProcessed = true; }, }; - await service.setPortForNewAccessKeys({params: {port: NEW_PORT}}, res, done); + await service.setPortForNewAccessKeys({params: {port: NEW_PORT}}, res, () => {}); }); - it('rejects invalid port numbers', async (done) => { + it('rejects invalid port numbers', async () => { const repo = getAccessKeyRepository(); const serverConfig = new InMemoryConfig({} as ServerConfigJson); const service = new ShadowsocksManagerServiceBuilder() @@ -681,10 +661,9 @@ describe('ShadowsocksManagerService', () => { await service.setPortForNewAccessKeys({params: {port: 65536}}, res, next); responseProcessed = true; - done(); }); - it('rejects port numbers already in use', async (done) => { + it('rejects port numbers already in use', async () => { const repo = getAccessKeyRepository(); const serverConfig = new InMemoryConfig({} as ServerConfigJson); const service = new ShadowsocksManagerServiceBuilder() @@ -703,7 +682,6 @@ describe('ShadowsocksManagerService', () => { // Conflict expect(error.statusCode).toEqual(409); responseProcessed = true; - done(); }; const server = new net.Server(); @@ -713,7 +691,7 @@ describe('ShadowsocksManagerService', () => { }); }); - it('accepts port numbers already in use by access keys', async (done) => { + it('accepts port numbers already in use by access keys', async () => { const repo = getAccessKeyRepository(); const serverConfig = new InMemoryConfig({} as ServerConfigJson); const service = new ShadowsocksManagerServiceBuilder() @@ -734,11 +712,10 @@ describe('ShadowsocksManagerService', () => { firstKeyConnection.listen(OLD_PORT, async () => { await service.setPortForNewAccessKeys({params: {port: OLD_PORT}}, res, () => {}); firstKeyConnection.close(); - done(); }); }); - it('rejects malformed requests', async (done) => { + it('rejects malformed requests', async () => { const repo = getAccessKeyRepository(); const serverConfig = new InMemoryConfig({} as ServerConfigJson); const service = new ShadowsocksManagerServiceBuilder() @@ -769,12 +746,11 @@ describe('ShadowsocksManagerService', () => { ); responseProcessed = true; - done(); }); }); describe('setListenersForNewAccessKeys', () => { - it('persists configuration and updates the Shadowsocks server', async (done) => { + it('persists configuration and updates the Shadowsocks server', async () => { const repo = getAccessKeyRepository(); const serverConfig = new InMemoryConfig({} as ServerConfigJson); const fakeServer = new FakeShadowsocksServer(); @@ -809,10 +785,9 @@ describe('ShadowsocksManagerService', () => { }); expect(fakeCaddy.applyCalls.length).toEqual(1); expect(fakeCaddy.applyCalls[0].listeners).toEqual(listeners); - done(); }); - it('clears WebSocket listener settings when they are removed', async (done) => { + it('clears WebSocket listener settings when they are removed', async () => { const repo = getAccessKeyRepository(); const serverConfig = new InMemoryConfig({} as ServerConfigJson); const fakeServer = new FakeShadowsocksServer(); @@ -860,12 +835,11 @@ describe('ShadowsocksManagerService', () => { expect(fakeServer.getListenerSettings()).toBeUndefined(); expect(fakeCaddy.applyCalls.length).toEqual(2); expect(fakeCaddy.applyCalls[1].listeners).toEqual(listenersWithoutWebsocket); - done(); }); }); describe('configureCaddyWebServer', () => { - it('stores configuration and applies it', async (done) => { + it('stores configuration and applies it', async () => { const repo = getAccessKeyRepository(); const serverConfig = new InMemoryConfig({} as ServerConfigJson); const fakeCaddy = new FakeOutlineCaddyServer(); @@ -894,12 +868,11 @@ describe('ShadowsocksManagerService', () => { expect(serverConfig.data().caddyWebServer).toEqual(config); expect(fakeCaddy.applyCalls.length).toEqual(1); expect(fakeCaddy.applyCalls[0].caddyConfig).toEqual(config); - done(); }); }); describe('removeAccessKey', () => { - it('removes keys', async (done) => { + it('removes keys', async () => { const repo = getAccessKeyRepository(); const service = new ShadowsocksManagerServiceBuilder().accessKeys(repo).build(); const key1 = await repo.createNewAccessKey(); @@ -915,9 +888,9 @@ describe('ShadowsocksManagerService', () => { }, }; // remove the 1st key. - service.removeAccessKey({params: {id: key1.id}}, res, done); + service.removeAccessKey({params: {id: key1.id}}, res, () => {}); }); - it('Remove returns a 500 when the repository throws an exception', async (done) => { + it('Remove returns a 500 when the repository throws an exception', async () => { const repo = getAccessKeyRepository(); spyOn(repo, 'removeAccessKey').and.throwError('cannot write to disk'); const service = new ShadowsocksManagerServiceBuilder().accessKeys(repo).build(); @@ -926,13 +899,12 @@ describe('ShadowsocksManagerService', () => { service.removeAccessKey({params: {id: key.id}}, res, (error) => { expect(error.statusCode).toEqual(500); responseProcessed = true; // required for afterEach to pass. - done(); }); }); }); describe('renameAccessKey', () => { - it('renames keys', async (done) => { + it('renames keys', async () => { const repo = getAccessKeyRepository(); const service = new ShadowsocksManagerServiceBuilder().accessKeys(repo).build(); const OLD_NAME = 'oldName'; @@ -947,9 +919,9 @@ describe('ShadowsocksManagerService', () => { responseProcessed = true; // required for afterEach to pass. }, }; - service.renameAccessKey({params: {id: key.id, name: NEW_NAME}}, res, done); + service.renameAccessKey({params: {id: key.id, name: NEW_NAME}}, res, () => {}); }); - it('Rename returns a 400 when the access key id is not a string', async (done) => { + it('Rename returns a 400 when the access key id is not a string', async () => { const repo = getAccessKeyRepository(); const service = new ShadowsocksManagerServiceBuilder().accessKeys(repo).build(); @@ -958,10 +930,9 @@ describe('ShadowsocksManagerService', () => { service.renameAccessKey({params: {id: 123}}, res, (error) => { expect(error.statusCode).toEqual(400); responseProcessed = true; // required for afterEach to pass. - done(); }); }); - it('Rename returns a 500 when the repository throws an exception', async (done) => { + it('Rename returns a 500 when the repository throws an exception', async () => { const repo = getAccessKeyRepository(); spyOn(repo, 'renameAccessKey').and.throwError('cannot write to disk'); const service = new ShadowsocksManagerServiceBuilder().accessKeys(repo).build(); @@ -971,13 +942,12 @@ describe('ShadowsocksManagerService', () => { service.renameAccessKey({params: {id: key.id, name: 'newName'}}, res, (error) => { expect(error.statusCode).toEqual(500); responseProcessed = true; // required for afterEach to pass. - done(); }); }); }); describe('setAccessKeyDataLimit', () => { - it('sets access key data limit', async (done) => { + it('sets access key data limit', async () => { const repo = getAccessKeyRepository(); const service = new ShadowsocksManagerServiceBuilder().accessKeys(repo).build(); const key = await repo.createNewAccessKey(); @@ -987,13 +957,12 @@ describe('ShadowsocksManagerService', () => { expect(httpCode).toEqual(204); expect(key.dataLimit.bytes).toEqual(1000); responseProcessed = true; - done(); }, }; service.setAccessKeyDataLimit({params: {id: key.id, limit}}, res, () => {}); }); - it('rejects negative numbers', async (done) => { + it('rejects negative numbers', async () => { const repo = getAccessKeyRepository(); const service = new ShadowsocksManagerServiceBuilder().accessKeys(repo).build(); const keyId = (await repo.createNewAccessKey()).id; @@ -1001,11 +970,10 @@ describe('ShadowsocksManagerService', () => { service.setAccessKeyDataLimit({params: {id: keyId, limit}}, {send: () => {}}, (error) => { expect(error.statusCode).toEqual(400); responseProcessed = true; - done(); }); }); - it('rejects non-numeric limits', async (done) => { + it('rejects non-numeric limits', async () => { const repo = getAccessKeyRepository(); const service = new ShadowsocksManagerServiceBuilder().accessKeys(repo).build(); const keyId = (await repo.createNewAccessKey()).id; @@ -1013,11 +981,10 @@ describe('ShadowsocksManagerService', () => { service.setAccessKeyDataLimit({params: {id: keyId, limit}}, {send: () => {}}, (error) => { expect(error.statusCode).toEqual(400); responseProcessed = true; - done(); }); }); - it('rejects an empty request', async (done) => { + it('rejects an empty request', async () => { const repo = getAccessKeyRepository(); const service = new ShadowsocksManagerServiceBuilder().accessKeys(repo).build(); const keyId = (await repo.createNewAccessKey()).id; @@ -1025,11 +992,10 @@ describe('ShadowsocksManagerService', () => { service.setAccessKeyDataLimit({params: {id: keyId, limit}}, {send: () => {}}, (error) => { expect(error.statusCode).toEqual(400); responseProcessed = true; - done(); }); }); - it('rejects requests for nonexistent keys', async (done) => { + it('rejects requests for nonexistent keys', async () => { const repo = getAccessKeyRepository(); const service = new ShadowsocksManagerServiceBuilder().accessKeys(repo).build(); await repo.createNewAccessKey(); @@ -1040,14 +1006,13 @@ describe('ShadowsocksManagerService', () => { (error) => { expect(error.statusCode).toEqual(404); responseProcessed = true; - done(); } ); }); }); describe('removeAccessKeyDataLimit', () => { - it('removes an access key data limit', async (done) => { + it('removes an access key data limit', async () => { const repo = getAccessKeyRepository(); const service = new ShadowsocksManagerServiceBuilder().accessKeys(repo).build(); const key = await repo.createNewAccessKey(); @@ -1058,25 +1023,23 @@ describe('ShadowsocksManagerService', () => { expect(httpCode).toEqual(204); expect(key.dataLimit).toBeFalsy(); responseProcessed = true; - done(); }, }; service.removeAccessKeyDataLimit({params: {id: key.id}}, res, () => {}); }); - it('returns 404 for a nonexistent key', async (done) => { + it('returns 404 for a nonexistent key', async () => { const repo = getAccessKeyRepository(); const service = new ShadowsocksManagerServiceBuilder().accessKeys(repo).build(); await repo.createNewAccessKey(); service.removeAccessKeyDataLimit({params: {id: 'not an id'}}, {send: () => {}}, (error) => { expect(error.statusCode).toEqual(404); responseProcessed = true; - done(); }); }); }); describe('setDefaultDataLimit', () => { - it('sets default data limit', async (done) => { + it('sets default data limit', async () => { const serverConfig = new InMemoryConfig({} as ServerConfigJson); const repo = getAccessKeyRepository(); spyOn(repo, 'setDefaultDataLimit'); @@ -1099,13 +1062,13 @@ describe('ShadowsocksManagerService', () => { responseProcessed = true; // required for afterEach to pass. }, }, - done + () => {} ); }, }; - service.setDefaultDataLimit({params: {limit}}, res, done); + service.setDefaultDataLimit({params: {limit}}, res, () => {}); }); - it('returns 400 when limit is missing values', async (done) => { + it('returns 400 when limit is missing values', async () => { const repo = getAccessKeyRepository(); const service = new ShadowsocksManagerServiceBuilder().accessKeys(repo).build(); await repo.createNewAccessKey(); @@ -1114,10 +1077,9 @@ describe('ShadowsocksManagerService', () => { service.setDefaultDataLimit({params: {limit}}, res, (error) => { expect(error.statusCode).toEqual(400); responseProcessed = true; // required for afterEach to pass. - done(); }); }); - it('returns 400 when limit has negative values', async (done) => { + it('returns 400 when limit has negative values', async () => { const repo = getAccessKeyRepository(); const service = new ShadowsocksManagerServiceBuilder().accessKeys(repo).build(); await repo.createNewAccessKey(); @@ -1126,10 +1088,9 @@ describe('ShadowsocksManagerService', () => { service.setDefaultDataLimit({params: {limit}}, res, (error) => { expect(error.statusCode).toEqual(400); responseProcessed = true; // required for afterEach to pass. - done(); }); }); - it('returns 500 when the repository throws an exception', async (done) => { + it('returns 500 when the repository throws an exception', async () => { const repo = getAccessKeyRepository(); spyOn(repo, 'setDefaultDataLimit').and.throwError('cannot write to disk'); const service = new ShadowsocksManagerServiceBuilder().accessKeys(repo).build(); @@ -1139,13 +1100,12 @@ describe('ShadowsocksManagerService', () => { service.setDefaultDataLimit({params: {limit}}, res, (error) => { expect(error.statusCode).toEqual(500); responseProcessed = true; // required for afterEach to pass. - done(); }); }); }); describe('removeDefaultDataLimit', () => { - it('clears default data limit', async (done) => { + it('clears default data limit', async () => { const limit = {bytes: 10000}; const serverConfig = new InMemoryConfig({accessKeyDataLimit: limit} as ServerConfigJson); const repo = getAccessKeyRepository(); @@ -1163,9 +1123,9 @@ describe('ShadowsocksManagerService', () => { responseProcessed = true; // required for afterEach to pass. }, }; - service.removeDefaultDataLimit({params: {}}, res, done); + service.removeDefaultDataLimit({params: {}}, res, () => {}); }); - it('returns 500 when the repository throws an exception', async (done) => { + it('returns 500 when the repository throws an exception', async () => { const repo = getAccessKeyRepository(); spyOn(repo, 'removeDefaultDataLimit').and.throwError('cannot write to disk'); const service = new ShadowsocksManagerServiceBuilder().accessKeys(repo).build(); @@ -1174,13 +1134,12 @@ describe('ShadowsocksManagerService', () => { service.removeDefaultDataLimit({params: {id: accessKey.id}}, res, (error) => { expect(error.statusCode).toEqual(500); responseProcessed = true; // required for afterEach to pass. - done(); }); }); }); describe('getShareMetrics', () => { - it('Returns value from sharedMetrics', (done) => { + it('Returns value from sharedMetrics', () => { const sharedMetrics = fakeSharedMetricsReporter(); sharedMetrics.startSharing(); const service = new ShadowsocksManagerServiceBuilder() @@ -1195,12 +1154,12 @@ describe('ShadowsocksManagerService', () => { responseProcessed = true; }, }, - done + () => {} ); }); }); describe('setShareMetrics', () => { - it('Sets value in the config', (done) => { + it('Sets value in the config', () => { const sharedMetrics = fakeSharedMetricsReporter(); sharedMetrics.stopSharing(); const service = new ShadowsocksManagerServiceBuilder() @@ -1215,7 +1174,7 @@ describe('ShadowsocksManagerService', () => { responseProcessed = true; }, }, - done + () => {} ); }); }); From 7cbfa6757d567343dd5b4f6baf7161fe7f098166 Mon Sep 17 00:00:00 2001 From: lunarthegrey Date: Thu, 4 Dec 2025 04:07:49 -0600 Subject: [PATCH 37/50] Add workflow-specific prefixes to GitHub Actions concurrency groups. --- .github/workflows/build_and_test_debug.yml | 2 +- .github/workflows/license.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build_and_test_debug.yml b/.github/workflows/build_and_test_debug.yml index fff4e8c15..f76bd89fc 100644 --- a/.github/workflows/build_and_test_debug.yml +++ b/.github/workflows/build_and_test_debug.yml @@ -15,7 +15,7 @@ name: Build and Test concurrency: - group: ${{ github.head_ref || github.ref }} + group: build-and-test-${{ github.head_ref || github.ref }} cancel-in-progress: true on: diff --git a/.github/workflows/license.yml b/.github/workflows/license.yml index c99a0f7b5..58d4d9913 100644 --- a/.github/workflows/license.yml +++ b/.github/workflows/license.yml @@ -15,7 +15,7 @@ name: License checks concurrency: - group: ${{ github.head_ref || github.ref }} + group: license-${{ github.head_ref || github.ref }} cancel-in-progress: true on: From 4db6d7cfa7a73f9c7e1480c065ae3b6ae2685e89 Mon Sep 17 00:00:00 2001 From: lunarthegrey Date: Thu, 4 Dec 2025 04:14:56 -0600 Subject: [PATCH 38/50] Add spaces around curly braces in imports and object literals --- src/shadowbox/server/manager_service.spec.ts | 44 ++++++++++---------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/src/shadowbox/server/manager_service.spec.ts b/src/shadowbox/server/manager_service.spec.ts index 544199f7e..b0ec3a2be 100644 --- a/src/shadowbox/server/manager_service.spec.ts +++ b/src/shadowbox/server/manager_service.spec.ts @@ -398,7 +398,7 @@ describe('ShadowsocksManagerService', () => { serviceMethod = service[methodName].bind(service); }); - it('verify default method', () => { + it('verify default method', async () => { // Verify that response returns a key with the expected properties. const res = { send: (httpCode, data) => { @@ -408,9 +408,9 @@ describe('ShadowsocksManagerService', () => { responseProcessed = true; // required for afterEach to pass. }, }; - serviceMethod({params: {id: accessKeyId}}, res, () => {}); + await serviceMethod({params: {id: accessKeyId}}, res, () => {}); }); - it('non-default method gets set', () => { + it('non-default method gets set', async () => { // Verify that response returns a key with the expected properties. const res = { send: (httpCode, data) => { @@ -420,9 +420,9 @@ describe('ShadowsocksManagerService', () => { responseProcessed = true; // required for afterEach to pass. }, }; - serviceMethod({params: {id: accessKeyId, method: 'aes-256-gcm'}}, res, () => {}); + await serviceMethod({params: {id: accessKeyId, method: 'aes-256-gcm'}}, res, () => {}); }); - it('use default name is params is not defined', () => { + it('use default name is params is not defined', async () => { const res = { send: (httpCode, data) => { expect(httpCode).toEqual(201); @@ -430,16 +430,16 @@ describe('ShadowsocksManagerService', () => { responseProcessed = true; // required for afterEach to pass. }, }; - serviceMethod({params: {id: accessKeyId}}, res, () => {}); + await serviceMethod({params: {id: accessKeyId}}, res, () => {}); }); - it('rejects non-string name', () => { + it('rejects non-string name', async () => { const res = {send: (_httpCode, _data) => {}}; - serviceMethod({params: {id: accessKeyId, name: Number('9876')}}, res, (error) => { + await serviceMethod({params: {id: accessKeyId, name: Number('9876')}}, res, (error) => { expect(error.statusCode).toEqual(400); responseProcessed = true; // required for afterEach to pass. }); }); - it('defined name is equal to stored', () => { + it('defined name is equal to stored', async () => { const ACCESSKEY_NAME = 'accesskeyname'; const res = { send: (httpCode, data) => { @@ -448,9 +448,9 @@ describe('ShadowsocksManagerService', () => { responseProcessed = true; // required for afterEach to pass. }, }; - serviceMethod({params: {id: accessKeyId, name: ACCESSKEY_NAME}}, res, () => {}); + await serviceMethod({params: {id: accessKeyId, name: ACCESSKEY_NAME}}, res, () => {}); }); - it('limit can be undefined', () => { + it('limit can be undefined', async () => { const res = { send: (httpCode, data) => { expect(httpCode).toEqual(201); @@ -458,18 +458,18 @@ describe('ShadowsocksManagerService', () => { responseProcessed = true; // required for afterEach to pass. }, }; - serviceMethod({params: {id: accessKeyId}}, res, () => {}); + await serviceMethod({params: {id: accessKeyId}}, res, () => {}); }); - it('rejects non-numeric limits', () => { + it('rejects non-numeric limits', async () => { const ACCESSKEY_LIMIT = {bytes: '9876'}; const res = {send: (_httpCode, _data) => {}}; - serviceMethod({params: {id: accessKeyId, limit: ACCESSKEY_LIMIT}}, res, (error) => { + await serviceMethod({params: {id: accessKeyId, limit: ACCESSKEY_LIMIT}}, res, (error) => { expect(error.statusCode).toEqual(400); responseProcessed = true; // required for afterEach to pass. }); }); - it('defined limit is equal to stored', () => { + it('defined limit is equal to stored', async () => { const ACCESSKEY_LIMIT = {bytes: 9876}; const res = { send: (httpCode, data) => { @@ -478,26 +478,26 @@ describe('ShadowsocksManagerService', () => { responseProcessed = true; // required for afterEach to pass. }, }; - serviceMethod({params: {id: accessKeyId, limit: ACCESSKEY_LIMIT}}, res, () => {}); + await serviceMethod({params: {id: accessKeyId, limit: ACCESSKEY_LIMIT}}, res, () => {}); }); - it('method must be of type string', () => { + it('method must be of type string', async () => { const res = {send: (_httpCode, _data) => {}}; - serviceMethod({params: {id: accessKeyId, method: Number('9876')}}, res, (error) => { + await serviceMethod({params: {id: accessKeyId, method: Number('9876')}}, res, (error) => { expect(error.statusCode).toEqual(400); responseProcessed = true; // required for afterEach to pass. }); }); - it('method must be valid', () => { + it('method must be valid', async () => { const res = {send: (_httpCode, _data) => {}}; - serviceMethod({params: {id: accessKeyId, method: 'abcdef'}}, res, (error) => { + await serviceMethod({params: {id: accessKeyId, method: 'abcdef'}}, res, (error) => { expect(error.statusCode).toEqual(400); responseProcessed = true; // required for afterEach to pass. }); }); - it('Create returns a 500 when the repository throws an exception', () => { + it('Create returns a 500 when the repository throws an exception', async () => { spyOn(repo, 'createNewAccessKey').and.throwError('cannot write to disk'); const res = {send: (_httpCode, _data) => {}}; - serviceMethod({params: {id: accessKeyId, method: 'aes-192-gcm'}}, res, (error) => { + await serviceMethod({params: {id: accessKeyId, method: 'aes-192-gcm'}}, res, (error) => { expect(error.statusCode).toEqual(500); responseProcessed = true; // required for afterEach to pass. }); From 280ffb38f7352ed01444745e991fbdb091c97b14 Mon Sep 17 00:00:00 2001 From: lunarthegrey Date: Thu, 4 Dec 2025 04:32:16 -0600 Subject: [PATCH 39/50] Update manager service tests to correctly await asynchronous calls and apply minor formatting. --- src/shadowbox/server/manager_service.spec.ts | 45 ++++++++++++-------- 1 file changed, 27 insertions(+), 18 deletions(-) diff --git a/src/shadowbox/server/manager_service.spec.ts b/src/shadowbox/server/manager_service.spec.ts index b0ec3a2be..5401470f8 100644 --- a/src/shadowbox/server/manager_service.spec.ts +++ b/src/shadowbox/server/manager_service.spec.ts @@ -330,7 +330,7 @@ describe('ShadowsocksManagerService', () => { describe('handling the access key identifier', () => { describe("with 'createNewAccessKey'", () => { - it('generates a unique ID', () => { + it('generates a unique ID', async () => { const res = { send: (httpCode, data) => { expect(httpCode).toEqual(201); @@ -338,7 +338,7 @@ describe('ShadowsocksManagerService', () => { responseProcessed = true; // required for afterEach to pass. }, }; - service.createNewAccessKey({params: {}}, res, () => {}); + await service.createNewAccessKey({params: {}}, res, () => {}); }); it('rejects requests with ID parameter set', () => { const res = {send: (_httpCode, _data) => {}}; @@ -367,12 +367,12 @@ describe('ShadowsocksManagerService', () => { it('rejects if key exists', async () => { const accessKey = await repo.createNewAccessKey(); const res = {send: (_httpCode, _data) => {}}; - service.createAccessKey({params: {id: accessKey.id}}, res, (error) => { + await service.createAccessKey({params: {id: accessKey.id}}, res, (error) => { expect(error.statusCode).toEqual(409); responseProcessed = true; // required for afterEach to pass. }); }); - it('creates key with provided ID', () => { + it('creates key with provided ID', async () => { const res = { send: (httpCode, data) => { expect(httpCode).toEqual(201); @@ -380,7 +380,7 @@ describe('ShadowsocksManagerService', () => { responseProcessed = true; // required for afterEach to pass. }, }; - service.createAccessKey({params: {id: 'myKeyId'}}, res, () => {}); + await service.createAccessKey({params: {id: 'myKeyId'}}, res, () => {}); }); }); }); @@ -583,12 +583,15 @@ describe('ShadowsocksManagerService', () => { it('rejects port numbers already in use', async () => { const server = new net.Server(); - server.listen(NEW_PORT, async () => { - const res = {send: SEND_NOTHING}; - await serviceMethod({params: {id: accessKeyId, port: NEW_PORT}}, res, (error) => { - expect(error.statusCode).toEqual(409); - responseProcessed = true; // required for afterEach to pass. - server.close(); + await new Promise((resolve) => { + server.listen(NEW_PORT, async () => { + const res = {send: SEND_NOTHING}; + await serviceMethod({params: {id: accessKeyId, port: NEW_PORT}}, res, (error) => { + expect(error.statusCode).toEqual(409); + responseProcessed = true; // required for afterEach to pass. + server.close(); + resolve(); + }); }); }); }); @@ -685,9 +688,12 @@ describe('ShadowsocksManagerService', () => { }; const server = new net.Server(); - server.listen(NEW_PORT, async () => { - await service.setPortForNewAccessKeys({params: {port: NEW_PORT}}, res, next); - server.close(); + await new Promise((resolve) => { + server.listen(NEW_PORT, async () => { + await service.setPortForNewAccessKeys({params: {port: NEW_PORT}}, res, next); + server.close(); + resolve(); + }); }); }); @@ -709,9 +715,12 @@ describe('ShadowsocksManagerService', () => { }; const firstKeyConnection = new net.Server(); - firstKeyConnection.listen(OLD_PORT, async () => { - await service.setPortForNewAccessKeys({params: {port: OLD_PORT}}, res, () => {}); - firstKeyConnection.close(); + await new Promise((resolve) => { + firstKeyConnection.listen(OLD_PORT, async () => { + await service.setPortForNewAccessKeys({params: {port: OLD_PORT}}, res, () => {}); + firstKeyConnection.close(); + resolve(); + }); }); }); @@ -888,7 +897,7 @@ describe('ShadowsocksManagerService', () => { }, }; // remove the 1st key. - service.removeAccessKey({params: {id: key1.id}}, res, () => {}); + await service.removeAccessKey({params: {id: key1.id}}, res, () => {}); }); it('Remove returns a 500 when the repository throws an exception', async () => { const repo = getAccessKeyRepository(); From 9ca4a55b19e8b64b301c2be91283425422f84e89 Mon Sep 17 00:00:00 2001 From: lunarthegrey Date: Mon, 8 Dec 2025 07:10:55 -0600 Subject: [PATCH 40/50] Switch Caddy configuration from JSON to YAML --- src/shadowbox/server/main.ts | 4 ++-- src/shadowbox/server/outline_caddy_server.ts | 15 +++++++-------- 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/src/shadowbox/server/main.ts b/src/shadowbox/server/main.ts index 300c3bfbd..626acbcde 100644 --- a/src/shadowbox/server/main.ts +++ b/src/shadowbox/server/main.ts @@ -165,10 +165,10 @@ async function main() { } const caddyServer = new OutlineCaddyServer( getBinaryFilename('outline-caddy'), - getPersistentFilename('outline-caddy/config.json'), + getPersistentFilename('outline-caddy/config.yaml'), verbose ); - + // Configure listener defaults (e.g., WebSocket paths/ports) based on server configuration. const listenersConfig = serverConfig.data().listenersForNewAccessKeys; shadowsocksServer.configureListeners( diff --git a/src/shadowbox/server/outline_caddy_server.ts b/src/shadowbox/server/outline_caddy_server.ts index c56a490f3..ae2d6b38d 100644 --- a/src/shadowbox/server/outline_caddy_server.ts +++ b/src/shadowbox/server/outline_caddy_server.ts @@ -16,6 +16,7 @@ import * as child_process from 'child_process'; import * as path from 'path'; import * as mkdirp from 'mkdirp'; +import * as yaml from 'js-yaml'; import * as file from '../infrastructure/file'; import * as logging from '../infrastructure/logging'; @@ -76,15 +77,15 @@ export class OutlineCaddyServer implements OutlineCaddyController { } const configObject = this.buildConfig(payload, listenerSettings, websocketKeys); - const configJson = JSON.stringify(configObject, null, 2); - if (configJson === this.currentConfigHash) { + const configYaml = yaml.dump(configObject); + if (configYaml === this.currentConfigHash) { // No changes; nothing to do. return; } mkdirp.sync(path.dirname(this.configFilename)); - file.atomicWriteFileSync(this.configFilename, configJson); - this.currentConfigHash = configJson; + file.atomicWriteFileSync(this.configFilename, configYaml); + this.currentConfigHash = configYaml; this.shouldRun = true; await this.ensureStarted(); } @@ -122,7 +123,7 @@ export class OutlineCaddyServer implements OutlineCaddyController { private start(): Promise { return new Promise((resolve, reject) => { - const args = ['run', '--config', this.configFilename, '--adapter', 'json', '--watch']; + const args = ['run', '--config', this.configFilename, '--adapter', 'yaml', '--watch']; logging.info(`Starting outline-caddy with command: ${this.binaryFilename} ${args.join(' ')}`); const proc = child_process.spawn(this.binaryFilename, args, { stdio: ['ignore', 'pipe', 'pipe'], @@ -216,9 +217,7 @@ export class OutlineCaddyServer implements OutlineCaddyController { autoHttps = false; } const domain = requestedDomain; - const listenAddresses = autoHttps - ? [':80', ':443'] - : [`:${listenerSettings.listenPort}`]; + const listenAddresses = autoHttps ? [':80', ':443'] : [`:${listenerSettings.listenPort}`]; const hasStreamRoute = websocketKeys.length === 0 || From fe609c7f65b97ac919e2450705e128a3b45c128b Mon Sep 17 00:00:00 2001 From: lunarthegrey Date: Mon, 8 Dec 2025 18:36:28 -0600 Subject: [PATCH 41/50] Add Caddy API proxy support with a new configuration option and Caddyfile template changes --- src/shadowbox/server/api.yml | 3 + src/shadowbox/server/main.ts | 8 ++ src/shadowbox/server/manager_service.ts | 128 +++++++++++-------- src/shadowbox/server/outline_caddy_server.ts | 35 +++++ src/shadowbox/server/server_config.ts | 7 +- 5 files changed, 128 insertions(+), 53 deletions(-) diff --git a/src/shadowbox/server/api.yml b/src/shadowbox/server/api.yml index 84872282b..c5a28fad7 100644 --- a/src/shadowbox/server/api.yml +++ b/src/shadowbox/server/api.yml @@ -761,6 +761,9 @@ components: domain: type: string description: Domain name for automatic HTTPS + apiProxyPath: + type: string + description: Path prefix for API reverse proxy (e.g., "/api"). When set, Caddy will proxy API requests with valid TLS. AccessKey: required: diff --git a/src/shadowbox/server/main.ts b/src/shadowbox/server/main.ts index 626acbcde..5a77496e5 100644 --- a/src/shadowbox/server/main.ts +++ b/src/shadowbox/server/main.ts @@ -233,12 +233,16 @@ async function main() { serverConfig.data().accessKeyDataLimit ); + // Determine if API should be proxied through Caddy + const apiProxyEnabled = !!serverConfig.data().caddyWebServer?.apiProxyPath; + try { await caddyServer.applyConfig({ accessKeys: accessKeyRepository.listAccessKeys(), listeners: serverConfig.data().listenersForNewAccessKeys, caddyConfig: serverConfig.data().caddyWebServer, hostname: serverConfig.data().hostname, + apiPort: apiProxyEnabled ? apiPortNumber : undefined, }); } catch (error) { logging.error(`Failed to apply initial Caddy configuration: ${error}`); @@ -266,6 +270,9 @@ async function main() { const certificateFilename = process.env.SB_CERTIFICATE_FILE; const privateKeyFilename = process.env.SB_PRIVATE_KEY_FILE; + + // Create API server with HTTPS (self-signed cert) + // When apiProxyPath is set, Caddy also proxies to this with TLS verification disabled const apiServer = restify.createServer({ certificate: fs.readFileSync(certificateFilename), key: fs.readFileSync(privateKeyFilename), @@ -288,6 +295,7 @@ async function main() { apiServer.use(cors.actual); bindService(apiServer, apiPrefix, managerService); + // Listen on all interfaces apiServer.listen(apiPortNumber, () => { logging.info(`Manager listening at ${apiServer.url}${apiPrefix}`); }); diff --git a/src/shadowbox/server/manager_service.ts b/src/shadowbox/server/manager_service.ts index 35e7b4484..ae7003056 100644 --- a/src/shadowbox/server/manager_service.ts +++ b/src/shadowbox/server/manager_service.ts @@ -63,11 +63,11 @@ function accessKeyToApiJson(accessKey: AccessKey): AccessKeyJson { }) ), }; - + if (accessKey.listeners) { result.listeners = accessKey.listeners; } - + return result; } @@ -160,10 +160,7 @@ export function bindService( `${apiPrefix}/server/listeners-for-new-access-keys`, service.setListenersForNewAccessKeys.bind(service) ); - apiServer.put( - `${apiPrefix}/server/web-server`, - service.configureCaddyWebServer.bind(service) - ); + apiServer.put(`${apiPrefix}/server/web-server`, service.configureCaddyWebServer.bind(service)); apiServer.post(`${apiPrefix}/access-keys`, service.createNewAccessKey.bind(service)); apiServer.put(`${apiPrefix}/access-keys/:id`, service.createAccessKey.bind(service)); @@ -390,34 +387,38 @@ export class ShadowsocksManagerService { logging.debug(`getAccessKey request ${JSON.stringify(req.params)}`); const accessKeyId = validateAccessKeyId(req.params.id); const accessKey = this.accessKeys.getAccessKey(accessKeyId); - + // Check if this key uses WebSocket listeners - const hasWebSocketListeners = accessKey.listeners && ( - accessKey.listeners.indexOf('websocket-stream') !== -1 || - accessKey.listeners.indexOf('websocket-packet') !== -1 - ); - + const hasWebSocketListeners = + accessKey.listeners && + (accessKey.listeners.indexOf('websocket-stream') !== -1 || + accessKey.listeners.indexOf('websocket-packet') !== -1); + if (hasWebSocketListeners) { // Generate and return YAML for WebSocket keys const configData = this.serverConfig.data(); const domain = configData?.caddyWebServer?.domain || configData?.hostname; const listenersConfig = configData?.listenersForNewAccessKeys; - - logging.debug(`WebSocket key detected. Domain: ${domain}, Listeners config: ${JSON.stringify(listenersConfig)}`); - + + logging.debug( + `WebSocket key detected. Domain: ${domain}, Listeners config: ${JSON.stringify( + listenersConfig + )}` + ); + // Generate YAML even if listenersConfig is not fully configured, using defaults - if (domain) { - const serverWithWebSocket = this.shadowsocksServer as ShadowsocksServer & { - generateDynamicAccessKeyYaml?: ( - proxyParams: {encryptionMethod: string; password: string}, - domain: string, - tcpPath: string, - udpPath: string, - tls: boolean, - listeners?: ListenerType[] - ) => string | null; - }; - + if (domain) { + const serverWithWebSocket = this.shadowsocksServer as ShadowsocksServer & { + generateDynamicAccessKeyYaml?: ( + proxyParams: {encryptionMethod: string; password: string}, + domain: string, + tcpPath: string, + udpPath: string, + tls: boolean, + listeners?: ListenerType[] + ) => string | null; + }; + const yamlConfig = serverWithWebSocket.generateDynamicAccessKeyYaml?.( accessKey.proxyParams, domain, @@ -426,7 +427,7 @@ export class ShadowsocksManagerService { this.serverConfig.data()?.caddyWebServer?.autoHttps !== false, accessKey.listeners as ListenerType[] | undefined ); - + if (yamlConfig) { // Return raw YAML for WebSocket keys const nodeResponse = res as unknown as { @@ -435,7 +436,7 @@ export class ShadowsocksManagerService { write: (data: string) => void; end: () => void; }; - + nodeResponse.setHeader('Content-Type', 'text/yaml; charset=utf-8'); nodeResponse.statusCode = HttpSuccess.OK; nodeResponse.write(yamlConfig); @@ -444,7 +445,7 @@ export class ShadowsocksManagerService { } } } - + // Return JSON for traditional keys const accessKeyJson = accessKeyToApiJson(accessKey); logging.debug(`getAccessKey response ${JSON.stringify(accessKeyJson)}`); @@ -478,7 +479,7 @@ export class ShadowsocksManagerService { const dataLimit = validateDataLimit(req.params.limit); const password = validateStringParam(req.params.password, 'password'); const portNumber = validateNumberParam(req.params.port, 'port'); - + // Validate listeners if provided let listeners = req.params.listeners as string[] | undefined; if (listeners) { @@ -604,8 +605,8 @@ export class ShadowsocksManagerService { if (!configData.listenersForNewAccessKeys) { configData.listenersForNewAccessKeys = {}; } - configData.listenersForNewAccessKeys.tcp = { port }; - configData.listenersForNewAccessKeys.udp = { port }; + configData.listenersForNewAccessKeys.tcp = {port}; + configData.listenersForNewAccessKeys.udp = {port}; } this.serverConfig.write(); await this.updateCaddyConfig(); @@ -632,11 +633,14 @@ export class ShadowsocksManagerService { ): Promise { try { logging.debug(`setListenersForNewAccessKeys request ${JSON.stringify(req.params)}`); - + const listeners = req.params as unknown as ListenersForNewAccessKeys; if (!listeners || typeof listeners !== 'object') { return next( - new restifyErrors.InvalidArgumentError({statusCode: 400}, 'Invalid listeners configuration') + new restifyErrors.InvalidArgumentError( + {statusCode: 400}, + 'Invalid listeners configuration' + ) ); } @@ -668,19 +672,28 @@ export class ShadowsocksManagerService { if (listeners.websocketStream) { if (!listeners.websocketStream.path || typeof listeners.websocketStream.path !== 'string') { return next( - new restifyErrors.InvalidArgumentError({statusCode: 400}, 'WebSocket stream path is required') + new restifyErrors.InvalidArgumentError( + {statusCode: 400}, + 'WebSocket stream path is required' + ) ); } if (!listeners.websocketStream.path.startsWith('/')) { return next( - new restifyErrors.InvalidArgumentError({statusCode: 400}, 'WebSocket stream path must start with /') + new restifyErrors.InvalidArgumentError( + {statusCode: 400}, + 'WebSocket stream path must start with /' + ) ); } const wsPort = listeners.websocketStream.webServerPort; if (wsPort !== undefined) { if (typeof wsPort !== 'number' || wsPort < 1 || wsPort > 65535) { return next( - new restifyErrors.InvalidArgumentError({statusCode: 400}, 'Invalid WebSocket server port') + new restifyErrors.InvalidArgumentError( + {statusCode: 400}, + 'Invalid WebSocket server port' + ) ); } } @@ -689,19 +702,28 @@ export class ShadowsocksManagerService { if (listeners.websocketPacket) { if (!listeners.websocketPacket.path || typeof listeners.websocketPacket.path !== 'string') { return next( - new restifyErrors.InvalidArgumentError({statusCode: 400}, 'WebSocket packet path is required') + new restifyErrors.InvalidArgumentError( + {statusCode: 400}, + 'WebSocket packet path is required' + ) ); } if (!listeners.websocketPacket.path.startsWith('/')) { return next( - new restifyErrors.InvalidArgumentError({statusCode: 400}, 'WebSocket packet path must start with /') + new restifyErrors.InvalidArgumentError( + {statusCode: 400}, + 'WebSocket packet path must start with /' + ) ); } const wsPacketPort = listeners.websocketPacket.webServerPort; if (wsPacketPort !== undefined) { if (typeof wsPacketPort !== 'number' || wsPacketPort < 1 || wsPacketPort > 65535) { return next( - new restifyErrors.InvalidArgumentError({statusCode: 400}, 'Invalid WebSocket server port') + new restifyErrors.InvalidArgumentError( + {statusCode: 400}, + 'Invalid WebSocket server port' + ) ); } } @@ -724,7 +746,7 @@ export class ShadowsocksManagerService { const configData = this.serverConfig.data(); if (configData) { configData.listenersForNewAccessKeys = listeners; - + // Update legacy portForNewAccessKeys if TCP port is set if (listeners.tcp?.port) { configData.portForNewAccessKeys = listeners.tcp.port; @@ -766,7 +788,7 @@ export class ShadowsocksManagerService { ): Promise { try { logging.debug(`configureCaddyWebServer request ${JSON.stringify(req.params)}`); - + const config = req.params as unknown as CaddyWebServerConfig; if (!config || typeof config !== 'object') { return next( @@ -799,6 +821,12 @@ export class ShadowsocksManagerService { ); } + if (config.apiProxyPath && typeof config.apiProxyPath !== 'string') { + return next( + new restifyErrors.InvalidArgumentError({statusCode: 400}, 'apiProxyPath must be a string') + ); + } + // Store Caddy configuration const configData = this.serverConfig.data(); if (configData) { @@ -806,7 +834,8 @@ export class ShadowsocksManagerService { enabled: config.enabled ?? false, autoHttps: config.autoHttps ?? false, email: config.email, - domain: config.domain + domain: config.domain, + apiProxyPath: config.apiProxyPath, }; } @@ -821,11 +850,7 @@ export class ShadowsocksManagerService { } // Removes an existing access key - async removeAccessKey( - req: RequestType, - res: ResponseType, - next: restify.Next - ): Promise { + async removeAccessKey(req: RequestType, res: ResponseType, next: restify.Next): Promise { try { logging.debug(`removeAccessKey request ${JSON.stringify(req.params)}`); const accessKeyId = validateAccessKeyId(req.params.id); @@ -912,7 +937,6 @@ export class ShadowsocksManagerService { } } - async setDefaultDataLimit(req: RequestType, res: ResponseType, next: restify.Next) { try { logging.debug(`setDefaultDataLimit request ${JSON.stringify(req.params)}`); @@ -1040,11 +1064,15 @@ export class ShadowsocksManagerService { return; } try { + // Pass API port when apiProxyPath is configured + const apiPortNumber = Number(process.env.SB_API_PORT) || 8443; + const apiPort = configData.caddyWebServer?.apiProxyPath ? apiPortNumber : undefined; await this.caddyServer.applyConfig({ accessKeys: this.accessKeys.listAccessKeys(), listeners: configData.listenersForNewAccessKeys, caddyConfig: configData.caddyWebServer, hostname: configData.hostname, + apiPort, }); } catch (error) { logging.error(`Failed to apply Caddy configuration: ${error}`); diff --git a/src/shadowbox/server/outline_caddy_server.ts b/src/shadowbox/server/outline_caddy_server.ts index ae2d6b38d..eb8fc97ca 100644 --- a/src/shadowbox/server/outline_caddy_server.ts +++ b/src/shadowbox/server/outline_caddy_server.ts @@ -28,6 +28,7 @@ export interface OutlineCaddyConfigPayload { listeners?: ListenersForNewAccessKeys; caddyConfig?: CaddyWebServerConfig; hostname?: string; + apiPort?: number; // Internal API port for reverse proxy } export interface OutlineCaddyController { @@ -234,6 +235,11 @@ export class OutlineCaddyServer implements OutlineCaddyController { routes.push(this.buildWebsocketRoute(listenerSettings.udpPath, 'packet', domain)); } + // Add API proxy route if configured + if (caddyConfig?.apiProxyPath && payload.apiPort) { + routes.push(this.buildApiProxyRoute(caddyConfig.apiProxyPath, payload.apiPort, domain)); + } + const connectionHandler = { name: 'outline-ws', handle: { @@ -336,4 +342,33 @@ export class OutlineCaddyServer implements OutlineCaddyController { ], }; } + + private buildApiProxyRoute(pathPrefix: string, apiPort: number, domain?: string) { + const normalizedPrefix = this.normalisePath(pathPrefix); + const match: Record = { + path: [`${normalizedPrefix}/*`], + }; + if (domain) { + match['host'] = [domain]; + } + return { + match: [match], + handle: [ + { + handler: 'rewrite', + strip_path_prefix: normalizedPrefix, + }, + { + handler: 'reverse_proxy', + upstreams: [{dial: `localhost:${apiPort}`}], + transport: { + protocol: 'http', + tls: { + insecure_skip_verify: true, // Skip verification for self-signed cert + }, + }, + }, + ], + }; + } } diff --git a/src/shadowbox/server/server_config.ts b/src/shadowbox/server/server_config.ts index da5c63c2c..5fe7c9f6f 100644 --- a/src/shadowbox/server/server_config.ts +++ b/src/shadowbox/server/server_config.ts @@ -35,8 +35,9 @@ export interface ListenersForNewAccessKeys { export interface CaddyWebServerConfig { enabled?: boolean; autoHttps?: boolean; - email?: string; // For ACME - domain?: string; // Domain for automatic HTTPS + email?: string; // For ACME + domain?: string; // Domain for automatic HTTPS + apiProxyPath?: string; // Path prefix for API proxy (e.g., "/api") } // Serialized format for the server config. @@ -67,7 +68,7 @@ export interface ServerConfigJson { // Experimental configuration options that are expected to be short-lived. experimental?: { // Whether ASN metric annotation for Prometheus is enabled. - asnMetricsEnabled?: boolean; // DEPRECATED + asnMetricsEnabled?: boolean; // DEPRECATED }; } From bd83c542a8b102840c41e02a869a3acb5b699f2f Mon Sep 17 00:00:00 2001 From: lunarthegrey Date: Mon, 8 Dec 2025 18:43:33 -0600 Subject: [PATCH 42/50] Add spaces around curly braces in import statements and object literals. --- src/shadowbox/server/manager_service.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/shadowbox/server/manager_service.ts b/src/shadowbox/server/manager_service.ts index ae7003056..9b835e852 100644 --- a/src/shadowbox/server/manager_service.ts +++ b/src/shadowbox/server/manager_service.ts @@ -830,13 +830,16 @@ export class ShadowsocksManagerService { // Store Caddy configuration const configData = this.serverConfig.data(); if (configData) { - configData.caddyWebServer = { + const caddyConfig: CaddyWebServerConfig = { enabled: config.enabled ?? false, autoHttps: config.autoHttps ?? false, email: config.email, domain: config.domain, - apiProxyPath: config.apiProxyPath, }; + if (config.apiProxyPath) { + caddyConfig.apiProxyPath = config.apiProxyPath; + } + configData.caddyWebServer = caddyConfig; } this.serverConfig.write(); From ed9c121c8dcc6e3a816ba80c1c5a5a34a87f471e Mon Sep 17 00:00:00 2001 From: lunarthegrey Date: Tue, 9 Dec 2025 20:27:52 -0600 Subject: [PATCH 43/50] Add dynamic configuration to WebSocket-enabled access keys in the API response. --- src/shadowbox/server/api.yml | 9 ++ src/shadowbox/server/manager_service.ts | 104 ++++++++++----- .../server/outline_shadowsocks_server.ts | 120 +++++++++++------- 3 files changed, 157 insertions(+), 76 deletions(-) diff --git a/src/shadowbox/server/api.yml b/src/shadowbox/server/api.yml index c5a28fad7..a4bf1ba8f 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 9b835e852..758d105cf 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 0c25872d1..40340d2bc 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 + }, }); } From a41c796e8a5c7a14235aef74ae63be33efe52d2c Mon Sep 17 00:00:00 2001 From: lunarthegrey Date: Thu, 11 Dec 2025 14:18:00 -0600 Subject: [PATCH 44/50] 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 a4bf1ba8f..097b25466 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 758d105cf..cc2fb9c94 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'); - - if (hasWebSocketListeners) { - const configData = this.serverConfig.data(); - const domain = configData?.caddyWebServer?.domain || configData?.hostname; + // 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 (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; } } } From 67769783f7f126e82554d5b29484464163e5cef3 Mon Sep 17 00:00:00 2001 From: lunarthegrey Date: Thu, 11 Dec 2025 14:25:39 -0600 Subject: [PATCH 45/50] Adjust dataLimit field inclusion in access key API responses for all key types. --- src/shadowbox/server/manager_service.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/shadowbox/server/manager_service.ts b/src/shadowbox/server/manager_service.ts index cc2fb9c94..9f219fdf6 100644 --- a/src/shadowbox/server/manager_service.ts +++ b/src/shadowbox/server/manager_service.ts @@ -294,6 +294,7 @@ export class ShadowsocksManagerService { result.password = accessKey.proxyParams.password; result.port = accessKey.proxyParams.portNumber; result.method = accessKey.proxyParams.encryptionMethod; + result.dataLimit = accessKey.dataLimit; result.accessUrl = SIP002_URI.stringify( makeConfig({ host: accessKey.proxyParams.hostname, @@ -303,10 +304,8 @@ export class ShadowsocksManagerService { outline: 1, }) ); - } - - // dataLimit applies regardless of transport type - if (accessKey.dataLimit) { + } else if (accessKey.dataLimit) { + // For WSS-only keys, include dataLimit only when set (limits still apply server-side) result.dataLimit = accessKey.dataLimit; } From efc0886eb8b238273e0f320705111d65abcff167 Mon Sep 17 00:00:00 2001 From: lunarthegrey Date: Sun, 14 Dec 2025 00:49:59 -0600 Subject: [PATCH 46/50] Consolidate dynamic access key config into `/access-keys/{id}` endpoint and remove dedicated dynamic config endpoint. --- src/shadowbox/README.md | 27 +++++++- src/shadowbox/server/api.yml | 71 ++++++++------------ src/shadowbox/server/manager_service.ts | 4 +- src/shadowbox/server/outline_caddy_server.ts | 2 +- 4 files changed, 54 insertions(+), 50 deletions(-) diff --git a/src/shadowbox/README.md b/src/shadowbox/README.md index cc7eed902..1d23ebd16 100644 --- a/src/shadowbox/README.md +++ b/src/shadowbox/README.md @@ -160,12 +160,33 @@ The Outline Server supports Shadowsocks over WebSocket (SS over WSS) for improve $API_URL/access-keys ``` -4. **Get Dynamic Client Configuration:** +4. **Retrieve WebSocket Access Key Configuration:** - For WebSocket-enabled keys, retrieve the YAML configuration: + For WebSocket-enabled keys, `GET /access-keys/{id}` returns YAML configuration (Outline Client v1.15.0+): ```sh - curl --insecure $API_URL/access-keys/0/dynamic-config + curl --insecure $API_URL/access-keys/0 + ``` + + Example response (`Content-Type: text/yaml`): + + ```yaml + 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 ``` ### Listener Types diff --git a/src/shadowbox/server/api.yml b/src/shadowbox/server/api.yml index 097b25466..17b88ed96 100644 --- a/src/shadowbox/server/api.yml +++ b/src/shadowbox/server/api.yml @@ -433,14 +433,39 @@ paths: type: string responses: '200': - description: The access key + description: | + The access key. Response format depends on key type: + - Traditional keys (TCP/UDP only): JSON with AccessKey schema + - WebSocket-enabled keys: YAML with dynamic transport configuration content: application/json: schema: $ref: "#/components/schemas/AccessKey" examples: - '0': + 'Traditional key': value: '{"id":"0","name":"Admin","password":"XxXxXx","port":18162,"method":"chacha20-ietf-poly1305","accessUrl":"ss://SADFJSKADFJAKSD@0.0.0.0:18162/?outline=1"}' + text/yaml: + schema: + type: string + examples: + 'WebSocket-enabled key': + 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 inexistent content: @@ -561,48 +586,6 @@ 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 diff --git a/src/shadowbox/server/manager_service.ts b/src/shadowbox/server/manager_service.ts index 9f219fdf6..e356fd3a0 100644 --- a/src/shadowbox/server/manager_service.ts +++ b/src/shadowbox/server/manager_service.ts @@ -443,7 +443,7 @@ export class ShadowsocksManagerService { next(); } - // Get a access key + // Get an access key getAccessKey(req: RequestType, res: ResponseType, next: restify.Next): void { try { logging.debug(`getAccessKey request ${JSON.stringify(req.params)}`); @@ -508,7 +508,7 @@ export class ShadowsocksManagerService { } } - // Return JSON for traditional keys + // Return JSON for traditional (non-WebSocket) keys const accessKeyJson = this.accessKeyToApiJson(accessKey); logging.debug(`getAccessKey response ${JSON.stringify(accessKeyJson)}`); res.send(HttpSuccess.OK, accessKeyJson); diff --git a/src/shadowbox/server/outline_caddy_server.ts b/src/shadowbox/server/outline_caddy_server.ts index eb8fc97ca..f07f6ab14 100644 --- a/src/shadowbox/server/outline_caddy_server.ts +++ b/src/shadowbox/server/outline_caddy_server.ts @@ -362,7 +362,7 @@ export class OutlineCaddyServer implements OutlineCaddyController { handler: 'reverse_proxy', upstreams: [{dial: `localhost:${apiPort}`}], transport: { - protocol: 'http', + protocol: 'https', tls: { insecure_skip_verify: true, // Skip verification for self-signed cert }, From 739d2ec436d6fac3cf753cc53ed04ffe0e7cb3d3 Mon Sep 17 00:00:00 2001 From: lunarthegrey Date: Sun, 14 Dec 2025 22:15:07 -0600 Subject: [PATCH 47/50] Refactor `outline-caddy` build to use `go tool xcaddy` and rename `CaddyWebServerConfig` to `WebServerConfig` --- go.mod | 4 ++ src/shadowbox/Taskfile.yml | 41 ++++++-------------- src/shadowbox/server/api.yml | 4 +- src/shadowbox/server/manager_service.ts | 6 +-- src/shadowbox/server/outline_caddy_server.ts | 4 +- src/shadowbox/server/server_config.ts | 4 +- 6 files changed, 24 insertions(+), 39 deletions(-) diff --git a/go.mod b/go.mod index 3f3375754..ae105c59c 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,10 @@ require ( github.com/google/addlicense v1.1.1 ) +tool ( + github.com/caddyserver/xcaddy/cmd/xcaddy +) + require ( github.com/Jigsaw-Code/outline-sdk v0.0.18-0.20241106233708-faffebb12629 // indirect github.com/Jigsaw-Code/outline-sdk/x v0.0.2-0.20250304133713-52f1a365e5ed // indirect diff --git a/src/shadowbox/Taskfile.yml b/src/shadowbox/Taskfile.yml index 52719f436..52e2b2d25 100644 --- a/src/shadowbox/Taskfile.yml +++ b/src/shadowbox/Taskfile.yml @@ -18,31 +18,17 @@ requires: vars: [OUTPUT_BASE] tasks: - download_xcaddy: - desc: Download xcaddy - requires: {vars: [TARGET_DIR, XCADDY_OS, XCADDY_ARCH]} - vars: - XCADDY_VERSION: v0.4.5 - XCADDY_VERSION_NUMBER: '{{trimPrefix "v" .XCADDY_VERSION}}' - XCADDY_ARCH_LABEL: '{{if eq .XCADDY_ARCH "x86_64"}}amd64{{else if eq .XCADDY_ARCH "arm64"}}arm64{{else if eq .XCADDY_ARCH "armv6"}}armv6{{else if eq .XCADDY_ARCH "armv7"}}armv7{{else}}{{.XCADDY_ARCH}}{{end}}' - XCADDY_OS_LABEL: '{{if eq .XCADDY_OS "darwin"}}mac{{else}}{{.XCADDY_OS}}{{end}}' - XCADDY_FILE: 'xcaddy_{{.XCADDY_VERSION_NUMBER}}_{{.XCADDY_OS_LABEL}}_{{.XCADDY_ARCH_LABEL}}{{if eq .XCADDY_OS_LABEL "windows"}}.zip{{else}}.tar.gz{{end}}' - XCADDY_URL: 'https://github.com/caddyserver/xcaddy/releases/download/{{.XCADDY_VERSION}}/{{.XCADDY_FILE}}' + build_outline_caddy: + desc: Build the OutlineCaddy binary using xcaddy + requires: {vars: [TARGET_DIR, TARGET_OS, GOARCH]} cmds: - mkdir -p '{{.TARGET_DIR}}' - | - tmpdir=$(mktemp -d) - curl -fsSL '{{.XCADDY_URL}}' -o "$tmpdir/xcaddy.pkg" - {{- if eq .XCADDY_OS "windows" }} - unzip -oq "$tmpdir/xcaddy.pkg" -d '{{.TARGET_DIR}}' - {{- else }} - tar -xzf "$tmpdir/xcaddy.pkg" -C '{{.TARGET_DIR}}' xcaddy - {{- end }} - rm -rf "$tmpdir" - - | - {{- if ne .XCADDY_OS_LABEL "windows" -}} - chmod +x '{{joinPath .TARGET_DIR "xcaddy"}}' - {{- end -}} + XCADDY_GOOS={{.TARGET_OS}} XCADDY_GOARCH={{.GOARCH}} go tool xcaddy build \ + --output '{{joinPath .TARGET_DIR "outline-caddy"}}' \ + --with github.com/Jigsaw-Code/outline-ss-server/outlinecaddy@v0.0.1 \ + --with github.com/iamd3vil/caddy_yaml_adapter \ + --with github.com/mholt/caddy-l4 build: desc: Build the Outline Server Node.js app @@ -66,16 +52,11 @@ tasks: vars: {TARGET_DIR: '{{.BIN_DIR}}'} # Set CGO_ENABLED=0 to force static linkage. See https://mt165.co.uk/blog/static-link-go/. - GOOS={{.TARGET_OS}} GOARCH={{.GOARCH}} CGO_ENABLED=0 go build -ldflags='-s -w -X main.version=embedded' -o '{{.BIN_DIR}}/' github.com/Jigsaw-Code/outline-ss-server/cmd/outline-ss-server - - task: download_xcaddy + - task: build_outline_caddy vars: TARGET_DIR: '{{.BIN_DIR}}' - XCADDY_OS: '{{.TARGET_OS}}' - XCADDY_ARCH: '{{.GOARCH}}' - - | - '{{joinPath .BIN_DIR "xcaddy"}}' build --output '{{joinPath .BIN_DIR "outline-caddy"}}' \ - --with github.com/Jigsaw-Code/outline-ss-server/outlinecaddy@v0.0.1 \ - --with github.com/iamd3vil/caddy_yaml_adapter@v0.0.0-20200503183711-d479c29b475a \ - --with github.com/mholt/caddy-l4@v0.0.0-20251201210923-0c96591f5650 + TARGET_OS: '{{.TARGET_OS}}' + GOARCH: '{{.GOARCH}}' start: desc: Run the Outline server locally diff --git a/src/shadowbox/server/api.yml b/src/shadowbox/server/api.yml index 17b88ed96..e20875afa 100644 --- a/src/shadowbox/server/api.yml +++ b/src/shadowbox/server/api.yml @@ -135,7 +135,7 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/CaddyWebServerConfig" + $ref: "#/components/schemas/WebServerConfig" examples: 'Enable with auto HTTPS': value: '{"enabled": true, "autoHttps": true, "email": "admin@example.com", "domain": "example.com"}' @@ -730,7 +730,7 @@ components: $ref: "#/components/schemas/ListenerConfig" description: WebSocket packet (UDP) listener configuration - CaddyWebServerConfig: + WebServerConfig: properties: enabled: type: boolean diff --git a/src/shadowbox/server/manager_service.ts b/src/shadowbox/server/manager_service.ts index e356fd3a0..28f032f14 100644 --- a/src/shadowbox/server/manager_service.ts +++ b/src/shadowbox/server/manager_service.ts @@ -25,7 +25,7 @@ import * as errors from '../model/errors'; import * as version from './version'; import {ManagerMetrics} from './manager_metrics'; -import {ServerConfigJson, ListenersForNewAccessKeys, CaddyWebServerConfig} from './server_config'; +import {ServerConfigJson, ListenersForNewAccessKeys, WebServerConfig} from './server_config'; import {SharedMetricsPublisher} from './shared_metrics'; import {ShadowsocksServer} from '../model/shadowsocks_server'; import type {OutlineCaddyController} from './outline_caddy_server'; @@ -851,7 +851,7 @@ export class ShadowsocksManagerService { try { logging.debug(`configureCaddyWebServer request ${JSON.stringify(req.params)}`); - const config = req.params as unknown as CaddyWebServerConfig; + const config = req.params as unknown as WebServerConfig; if (!config || typeof config !== 'object') { return next( new restifyErrors.InvalidArgumentError({statusCode: 400}, 'Invalid Caddy configuration') @@ -892,7 +892,7 @@ export class ShadowsocksManagerService { // Store Caddy configuration const configData = this.serverConfig.data(); if (configData) { - const caddyConfig: CaddyWebServerConfig = { + const caddyConfig: WebServerConfig = { enabled: config.enabled ?? false, autoHttps: config.autoHttps ?? false, email: config.email, diff --git a/src/shadowbox/server/outline_caddy_server.ts b/src/shadowbox/server/outline_caddy_server.ts index f07f6ab14..e6635f655 100644 --- a/src/shadowbox/server/outline_caddy_server.ts +++ b/src/shadowbox/server/outline_caddy_server.ts @@ -21,12 +21,12 @@ import * as yaml from 'js-yaml'; import * as file from '../infrastructure/file'; import * as logging from '../infrastructure/logging'; import {AccessKey, ListenerType} from '../model/access_key'; -import {CaddyWebServerConfig, ListenerConfig, ListenersForNewAccessKeys} from './server_config'; +import {WebServerConfig, ListenerConfig, ListenersForNewAccessKeys} from './server_config'; export interface OutlineCaddyConfigPayload { accessKeys: AccessKey[]; listeners?: ListenersForNewAccessKeys; - caddyConfig?: CaddyWebServerConfig; + caddyConfig?: WebServerConfig; hostname?: string; apiPort?: number; // Internal API port for reverse proxy } diff --git a/src/shadowbox/server/server_config.ts b/src/shadowbox/server/server_config.ts index 5fe7c9f6f..4763fbd6f 100644 --- a/src/shadowbox/server/server_config.ts +++ b/src/shadowbox/server/server_config.ts @@ -32,7 +32,7 @@ export interface ListenersForNewAccessKeys { } // Caddy web server configuration -export interface CaddyWebServerConfig { +export interface WebServerConfig { enabled?: boolean; autoHttps?: boolean; email?: string; // For ACME @@ -56,7 +56,7 @@ export interface ServerConfigJson { // Listeners configuration for new access keys (supersedes portForNewAccessKeys) listenersForNewAccessKeys?: ListenersForNewAccessKeys; // Caddy web server configuration for automatic HTTPS - caddyWebServer?: CaddyWebServerConfig; + caddyWebServer?: WebServerConfig; // Which staged rollouts we should force enabled or disabled. rollouts?: RolloutConfigJson[]; // We don't serialize the shadowbox version, this is obtained dynamically from node. From bec26bd2b3ca0d1fb41c5f1d2698b3afb05970cb Mon Sep 17 00:00:00 2001 From: lunarthegrey Date: Sun, 14 Dec 2025 22:24:51 -0600 Subject: [PATCH 48/50] Update Go module dependencies --- go.mod | 18 ++++++++++++------ go.sum | 32 ++++++++++++++++++-------------- 2 files changed, 30 insertions(+), 20 deletions(-) diff --git a/go.mod b/go.mod index ae105c59c..52cd91d41 100644 --- a/go.mod +++ b/go.mod @@ -5,29 +5,34 @@ go 1.23 toolchain go1.24.5 require ( - github.com/Jigsaw-Code/outline-ss-server v1.9.2 - github.com/go-task/task/v3 v3.36.0 - github.com/google/addlicense v1.1.1 + github.com/Jigsaw-Code/outline-ss-server v1.9.2 + github.com/go-task/task/v3 v3.36.0 + github.com/google/addlicense v1.1.1 ) tool ( - github.com/caddyserver/xcaddy/cmd/xcaddy + github.com/caddyserver/xcaddy/cmd/xcaddy ) require ( github.com/Jigsaw-Code/outline-sdk v0.0.18-0.20241106233708-faffebb12629 // indirect github.com/Jigsaw-Code/outline-sdk/x v0.0.2-0.20250304133713-52f1a365e5ed // indirect - github.com/Masterminds/semver/v3 v3.3.1 // indirect + github.com/Masterminds/semver/v3 v3.4.0 // indirect + github.com/akavel/rsrc v0.10.2 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/bmatcuk/doublestar/v4 v4.0.2 // indirect + github.com/caddyserver/xcaddy v0.4.5 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/fatih/color v1.16.0 // indirect github.com/go-task/slim-sprig/v3 v3.0.0 // indirect github.com/go-viper/mapstructure/v2 v2.2.1 // indirect + github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect github.com/gorilla/handlers v1.4.1 // indirect github.com/gorilla/websocket v1.5.3 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/joho/godotenv v1.5.1 // indirect + github.com/josephspurrier/goversioninfo v1.5.0 // indirect github.com/klauspost/compress v1.17.11 // indirect github.com/klauspost/cpuid/v2 v2.2.9 // indirect github.com/lmittmann/tint v1.0.5 // indirect @@ -45,7 +50,8 @@ require ( github.com/radovskyb/watcher v1.0.7 // indirect github.com/sajari/fuzzy v1.0.0 // indirect github.com/shadowsocks/go-shadowsocks2 v0.1.5 // indirect - github.com/spf13/pflag v1.0.5 // indirect + github.com/spf13/cobra v1.9.1 // indirect + github.com/spf13/pflag v1.0.6 // indirect github.com/zeebo/xxh3 v1.0.2 // indirect golang.org/x/crypto v0.32.0 // indirect golang.org/x/sync v0.11.0 // indirect diff --git a/go.sum b/go.sum index 38859b382..e676c5cab 100644 --- a/go.sum +++ b/go.sum @@ -4,16 +4,19 @@ github.com/Jigsaw-Code/outline-sdk/x v0.0.2-0.20250304133713-52f1a365e5ed h1:Nfy github.com/Jigsaw-Code/outline-sdk/x v0.0.2-0.20250304133713-52f1a365e5ed/go.mod h1:aFUEz6Z/eD0NS3c3fEIX+JO2D9aIrXCmWTb1zJFlItw= github.com/Jigsaw-Code/outline-ss-server v1.9.2 h1:8AlzPLugCCa9H4ZIV79rWOdgVshRzKZalq8ZD+APjqk= github.com/Jigsaw-Code/outline-ss-server v1.9.2/go.mod h1:v0jS3ExOGwGTbWTpOw16/sid91k7PKxazdK9eLCpUlQ= -github.com/Masterminds/semver/v3 v3.3.1 h1:QtNSWtVZ3nBfk8mAOu/B6v7FMJ+NHTIgUPi7rj+4nv4= -github.com/Masterminds/semver/v3 v3.3.1/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= -github.com/caddyserver/caddy/v2 v2.9.1 h1:OEYiZ7DbCzAWVb6TNEkjRcSCRGHVoZsJinoDR/n9oaY= -github.com/caddyserver/caddy/v2 v2.9.1/go.mod h1:ImUELya2el1FDVp3ahnSO2iH1or1aHxlQEQxd/spP68= +github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= +github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/akavel/rsrc v0.10.2 h1:Zxm8V5eI1hW4gGaYsJQUhxpjkENuG91ki8B4zCrvEsw= +github.com/akavel/rsrc v0.10.2/go.mod h1:uLoCtb9J+EyAqh+26kdrTgmzRBFPGOolLWKpdxkKq+c= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bmatcuk/doublestar/v4 v4.0.2 h1:X0krlUVAVmtr2cRoTqR8aDMrDqnB36ht8wpWTiQ3jsA= github.com/bmatcuk/doublestar/v4 v4.0.2/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= +github.com/caddyserver/xcaddy v0.4.5 h1:7E4b+3Gm2do/WpuDXh5MWIj+qgCCvQqR487Sm8C6hwc= +github.com/caddyserver/xcaddy v0.4.5/go.mod h1:QrRLASVAsoDY2MvXRm0pAKZRo4MsJakfvKCYQILGPzo= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.21 h1:1/QdRyBaHHJP61QkWMXlOIBfsgdDeeKfK8SYVUWJKf0= github.com/creack/pty v1.1.21/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= @@ -32,14 +35,18 @@ github.com/google/addlicense v1.1.1 h1:jpVf9qPbU8rz5MxKo7d+RMcNHkqxi4YJi/laauX4a github.com/google/addlicense v1.1.1/go.mod h1:Sm/DHu7Jk+T5miFHHehdIjbi4M5+dJDRS3Cq0rncIxA= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= +github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= github.com/gorilla/handlers v1.4.1 h1:BHvcRGJe/TrL+OqFxoKQGddTgeibiOjaBssV5a/N9sw= github.com/gorilla/handlers v1.4.1/go.mod h1:Qkdc/uu4tH4g6mTK6auzZ766c4CA0Ng8+o/OAirnOIQ= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= -github.com/iamd3vil/caddy_yaml_adapter v0.0.0-20200503183711-d479c29b475a h1:5eTxtJy0pyxzY5a1N3bOap7JonTWkuRjrIEs9sK7ciE= -github.com/iamd3vil/caddy_yaml_adapter v0.0.0-20200503183711-d479c29b475a/go.mod h1:6zdSPpoYnt4wSqGahSk9ru2nA2ZPyh0T+T808LGJPy0= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/josephspurrier/goversioninfo v1.5.0 h1:9TJtORoyf4YMoWSOo/cXFN9A/lB3PniJ91OxIH6e7Zg= +github.com/josephspurrier/goversioninfo v1.5.0/go.mod h1:6MoTvFZ6GKJkzcdLnU5T/RGYUbHQbKpYeNP0AgQLd2o= github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc= github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0= github.com/klauspost/cpuid/v2 v2.2.9 h1:66ze0taIn2H33fBvCkXuv9BmCwDfafmiIVpKV9kKGuY= @@ -59,8 +66,6 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-zglob v0.0.4 h1:LQi2iOm0/fGgu80AioIJ/1j9w9Oh+9DZ39J4VAGzHQM= github.com/mattn/go-zglob v0.0.4/go.mod h1:MxxjyoXXnMxfIpxTK2GAkw1w8glPsQILx3N5wrKakiY= -github.com/mholt/caddy-l4 v0.0.0-20250102174933-6e5f5e311ead h1:zmGMb9S6f2LJoaZvozULbrY7HpfcnZrRLXsnRP5d+Jo= -github.com/mholt/caddy-l4 v0.0.0-20250102174933-6e5f5e311ead/go.mod h1:zhoEExOYPSuKYLyJE88BOIHNNf3PdOLyYEYbtnmgcSw= github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= @@ -87,12 +92,15 @@ github.com/riobard/go-bloom v0.0.0-20200614022211-cdc8013cb5b3 h1:f/FNXud6gA3MNr github.com/riobard/go-bloom v0.0.0-20200614022211-cdc8013cb5b3/go.mod h1:HgjTstvQsPGkxUsCd2KWxErBblirPizecHcpD3ffK+s= github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sajari/fuzzy v1.0.0 h1:+FmwVvJErsd0d0hAPlj4CxqxUtQY/fOoY0DwX4ykpRY= github.com/sajari/fuzzy v1.0.0/go.mod h1:OjYR6KxoWOe9+dOlXeiCJd4dIbED4Oo8wpS89o0pwOo= github.com/shadowsocks/go-shadowsocks2 v0.1.5 h1:PDSQv9y2S85Fl7VBeOMF9StzeXZyK1HakRm86CUbr28= github.com/shadowsocks/go-shadowsocks2 v0.1.5/go.mod h1:AGGpIoek4HRno4xzyFiAtLHkOpcoznZEkAccaI/rplM= -github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= -github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= +github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= +github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= +github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= @@ -101,10 +109,6 @@ github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ= github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= -go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= -go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= -go.uber.org/zap/exp v0.3.0 h1:6JYzdifzYkGmTdRR59oYH+Ng7k49H9qVpWwNSsGJj3U= -go.uber.org/zap/exp v0.3.0/go.mod h1:5I384qq7XGxYyByIhHm6jg5CHkGY0nsTfbDLgDDlgJQ= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210220033148-5ea612d1eb83/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc= From be31e617fd6e24f2974959d80dfb412dec32ac05 Mon Sep 17 00:00:00 2001 From: lunarthegrey Date: Fri, 16 Jan 2026 23:40:30 -0600 Subject: [PATCH 49/50] refactor(api): make listeners mutable and add dedicated dynamic config endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit API Changes: - Rename PUT /server/listeners-for-new-access-keys → PUT /server/listeners - Add `applyToExisting` parameter to optionally update all existing keys - Add PUT /access-keys/{id}/listeners for per-key listener updates - Add GET /access-keys/{id}/dynamic-config for YAML transport config - Make GET /access-keys/{id} always return JSON (use dynamic-config for YAML) Type Renames: - ListenersForNewAccessKeys → ListenersConfig - listenersForNewAccessKeys config field → listeners This simplifies the mental model by: 1. Allowing listener updates on any key, not just at creation 2. Providing a clear global vs per-key control pattern 3. Separating JSON metadata from YAML transport config --- src/shadowbox/README.md | 25 +- src/shadowbox/model/access_key.ts | 4 + src/shadowbox/server/api.yml | 144 +++++++++--- src/shadowbox/server/main.ts | 4 +- src/shadowbox/server/manager_service.spec.ts | 20 +- src/shadowbox/server/manager_service.ts | 231 ++++++++++++------- src/shadowbox/server/outline_caddy_server.ts | 4 +- src/shadowbox/server/server_access_key.ts | 14 ++ src/shadowbox/server/server_config.ts | 6 +- 9 files changed, 302 insertions(+), 150 deletions(-) diff --git a/src/shadowbox/README.md b/src/shadowbox/README.md index 1d23ebd16..932a85739 100644 --- a/src/shadowbox/README.md +++ b/src/shadowbox/README.md @@ -121,9 +121,9 @@ The Outline Server supports Shadowsocks over WebSocket (SS over WSS) for improve ### Enabling WebSocket Support -1. **Configure Listeners for New Access Keys:** +1. **Configure Listeners:** - Set up listener configuration including WebSocket paths: + Set defaults for new keys (add `"applyToExisting": true` to update all existing keys): ```sh curl --insecure -X PUT -H "Content-Type: application/json" \ @@ -133,7 +133,7 @@ The Outline Server supports Shadowsocks over WebSocket (SS over WSS) for improve "websocketStream": {"path": "/tcp", "webServerPort": 8080}, "websocketPacket": {"path": "/udp", "webServerPort": 8080} }' \ - $API_URL/server/listeners-for-new-access-keys + $API_URL/server/listeners ``` 2. **Enable the Caddy Web Server (for automatic HTTPS):** @@ -149,23 +149,20 @@ The Outline Server supports Shadowsocks over WebSocket (SS over WSS) for improve $API_URL/server/web-server ``` -3. **Create WebSocket-Enabled Access Keys:** +3. **Update a Specific Key's Listeners:** ```sh - curl --insecure -X POST -H "Content-Type: application/json" \ - -d '{ - "name": "WebSocket User", - "listeners": ["tcp", "udp", "websocket-stream", "websocket-packet"] - }' \ - $API_URL/access-keys + curl --insecure -X PUT -H "Content-Type: application/json" \ + -d '{"listeners": ["tcp", "udp", "websocket-stream", "websocket-packet"]}' \ + $API_URL/access-keys/0/listeners ``` -4. **Retrieve WebSocket Access Key Configuration:** +4. **Get Dynamic Config (YAML):** - For WebSocket-enabled keys, `GET /access-keys/{id}` returns YAML configuration (Outline Client v1.15.0+): + Use the dedicated endpoint to retrieve YAML transport configuration (Outline Client v1.15.0+): ```sh - curl --insecure $API_URL/access-keys/0 + curl --insecure $API_URL/access-keys/0/dynamic-config ``` Example response (`Content-Type: text/yaml`): @@ -189,6 +186,8 @@ The Outline Server supports Shadowsocks over WebSocket (SS over WSS) for improve secret: XxXxXx ``` +> [!NOTE] > `GET /access-keys/{id}` always returns JSON. Use `/access-keys/{id}/dynamic-config` for YAML transport configuration. + ### Listener Types - `tcp` - Traditional TCP Shadowsocks diff --git a/src/shadowbox/model/access_key.ts b/src/shadowbox/model/access_key.ts index 826d60513..baa1fbc83 100644 --- a/src/shadowbox/model/access_key.ts +++ b/src/shadowbox/model/access_key.ts @@ -90,4 +90,8 @@ export interface AccessKeyRepository { setAccessKeyDataLimit(id: AccessKeyId, limit: DataLimit): void; // Removes the custom data limit from access key `id`. removeAccessKeyDataLimit(id: AccessKeyId): void; + // Updates the listeners for access key `id`. + setAccessKeyListeners(id: AccessKeyId, listeners: ListenerType[]): void; + // Updates the listeners for all access keys. + setListenersForAllKeys(listeners: ListenerType[]): void; } diff --git a/src/shadowbox/server/api.yml b/src/shadowbox/server/api.yml index e20875afa..2aed0721a 100644 --- a/src/shadowbox/server/api.yml +++ b/src/shadowbox/server/api.yml @@ -99,24 +99,36 @@ paths: '409': description: The requested port was already in use by another service. - /server/listeners-for-new-access-keys: + /server/listeners: put: - description: Sets the listeners configuration for newly created access keys. Supports different ports for TCP and UDP, and WebSocket paths. + description: | + Sets the listeners configuration for access keys. + - By default, only affects newly created keys + - Set `applyToExisting: true` to also update all existing keys + + Use PUT /access-keys/{id}/listeners for per-key overrides. tags: - - Access Key + - Server requestBody: required: true content: application/json: schema: - $ref: "#/components/schemas/ListenersConfig" + allOf: + - $ref: "#/components/schemas/ListenersConfig" + - type: object + properties: + applyToExisting: + type: boolean + default: false + description: If true, also update all existing access keys examples: - 'Basic TCP/UDP': + 'Defaults only': value: '{"tcp": {"port": 443}, "udp": {"port": 443}}' 'With WebSocket': - value: '{"tcp": {"port": 443}, "udp": {"port": 443}, "websocketStream": {"path": "/tcp", "webServerPort": 8080}, "websocketPacket": {"path": "/udp", "webServerPort": 8080}}' - 'Different TCP/UDP ports': - value: '{"tcp": {"port": 443}, "udp": {"port": 8443}}' + value: '{"tcp": {"port": 443}, "websocketStream": {"path": "/tcp", "webServerPort": 8080}, "websocketPacket": {"path": "/udp", "webServerPort": 8080}}' + 'Update all existing keys': + value: '{"tcp": {"port": 443}, "websocketStream": {"path": "/tcp"}, "applyToExisting": true}' responses: '204': description: The listeners configuration was successfully updated. @@ -421,7 +433,7 @@ paths: value: >- {"id":"my-identifier","name":"First","password":"XxXxXx","port":9795,"method":"chacha20-ietf-poly1305","accessUrl":"ss://SADFJSKADFJAKSD@0.0.0.0:9795/?outline=1"} get: - description: Get an access key + description: Get an access key (always returns JSON) tags: - Access Key parameters: @@ -433,10 +445,7 @@ paths: type: string responses: '200': - description: | - The access key. Response format depends on key type: - - Traditional keys (TCP/UDP only): JSON with AccessKey schema - - WebSocket-enabled keys: YAML with dynamic transport configuration + description: The access key content: application/json: schema: @@ -444,28 +453,8 @@ paths: examples: 'Traditional key': value: '{"id":"0","name":"Admin","password":"XxXxXx","port":18162,"method":"chacha20-ietf-poly1305","accessUrl":"ss://SADFJSKADFJAKSD@0.0.0.0:18162/?outline=1"}' - text/yaml: - schema: - type: string - examples: - 'WebSocket-enabled key': - 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 + 'WebSocket key': + value: '{"id":"1","name":"WSS User","listeners":["websocket-stream","websocket-packet"],"dynamicConfig":{"transport":{"$type":"tcpudp"}}}' '404': description: Access key inexistent content: @@ -540,6 +529,91 @@ paths: description: Access key renamed successfully '404': description: Access key inexistent + /access-keys/{id}/listeners: + put: + description: | + Updates the listeners for a specific access key. + This overrides the global /server/listeners setting for this key. + tags: + - Access Key + parameters: + - name: id + in: path + required: true + description: The id of the access key + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - listeners + properties: + listeners: + type: array + items: + type: string + enum: ['tcp', 'udp', 'websocket-stream', 'websocket-packet'] + examples: + 'All transports': + value: '{"listeners": ["tcp", "udp", "websocket-stream", "websocket-packet"]}' + 'WebSocket only': + value: '{"listeners": ["websocket-stream", "websocket-packet"]}' + 'Traditional only': + value: '{"listeners": ["tcp", "udp"]}' + responses: + '204': + description: Listeners updated successfully + '400': + description: Invalid listeners configuration + '404': + description: Access key not found + /access-keys/{id}/dynamic-config: + get: + description: | + Returns the dynamic transport configuration for WebSocket-enabled keys. + Returns YAML content compatible with Outline Client v1.15.0+. + Returns 404 if the key has no WebSocket listeners. + tags: + - Access Key + parameters: + - name: id + in: path + required: true + description: The id of the access key + schema: + type: string + responses: + '200': + description: Dynamic transport configuration in YAML format + content: + text/yaml: + schema: + type: string + examples: + 'WebSocket config': + 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 has no WebSocket listeners /access-keys/{id}/data-limit: put: description: Sets a data limit for the given access key diff --git a/src/shadowbox/server/main.ts b/src/shadowbox/server/main.ts index 5a77496e5..9f65a3abf 100644 --- a/src/shadowbox/server/main.ts +++ b/src/shadowbox/server/main.ts @@ -170,7 +170,7 @@ async function main() { ); // Configure listener defaults (e.g., WebSocket paths/ports) based on server configuration. - const listenersConfig = serverConfig.data().listenersForNewAccessKeys; + const listenersConfig = serverConfig.data().listeners; shadowsocksServer.configureListeners( listenersConfig ? { @@ -239,7 +239,7 @@ async function main() { try { await caddyServer.applyConfig({ accessKeys: accessKeyRepository.listAccessKeys(), - listeners: serverConfig.data().listenersForNewAccessKeys, + listeners: serverConfig.data().listeners, caddyConfig: serverConfig.data().caddyWebServer, hostname: serverConfig.data().hostname, apiPort: apiProxyEnabled ? apiPortNumber : undefined, diff --git a/src/shadowbox/server/manager_service.spec.ts b/src/shadowbox/server/manager_service.spec.ts index 5401470f8..34618541c 100644 --- a/src/shadowbox/server/manager_service.spec.ts +++ b/src/shadowbox/server/manager_service.spec.ts @@ -758,7 +758,7 @@ describe('ShadowsocksManagerService', () => { }); }); - describe('setListenersForNewAccessKeys', () => { + describe('setListeners', () => { it('persists configuration and updates the Shadowsocks server', async () => { const repo = getAccessKeyRepository(); const serverConfig = new InMemoryConfig({} as ServerConfigJson); @@ -785,9 +785,9 @@ describe('ShadowsocksManagerService', () => { }, }; - await service.setListenersForNewAccessKeys({params: listeners}, res, () => {}); + await service.setListeners({params: listeners}, res, () => {}); - expect(serverConfig.data().listenersForNewAccessKeys).toEqual(listeners); + expect(serverConfig.data().listeners).toEqual(listeners); expect(fakeServer.getListenerSettings()).toEqual({ websocketStream: listeners.websocketStream, websocketPacket: listeners.websocketPacket, @@ -814,11 +814,7 @@ describe('ShadowsocksManagerService', () => { websocketStream: {path: '/tcp', webServerPort: 8080}, websocketPacket: {path: '/udp', webServerPort: 8080}, }; - await service.setListenersForNewAccessKeys( - {params: listenersWithWebsocket}, - {send: () => {}}, - () => {} - ); + await service.setListeners({params: listenersWithWebsocket}, {send: () => {}}, () => {}); expect(fakeServer.getListenerSettings()).toEqual({ websocketStream: listenersWithWebsocket.websocketStream, websocketPacket: listenersWithWebsocket.websocketPacket, @@ -834,13 +830,9 @@ describe('ShadowsocksManagerService', () => { responseProcessed = true; }, }; - await service.setListenersForNewAccessKeys( - {params: listenersWithoutWebsocket}, - res, - () => {} - ); + await service.setListeners({params: listenersWithoutWebsocket}, res, () => {}); - expect(serverConfig.data().listenersForNewAccessKeys).toEqual(listenersWithoutWebsocket); + expect(serverConfig.data().listeners).toEqual(listenersWithoutWebsocket); expect(fakeServer.getListenerSettings()).toBeUndefined(); expect(fakeCaddy.applyCalls.length).toEqual(2); expect(fakeCaddy.applyCalls[1].listeners).toEqual(listenersWithoutWebsocket); diff --git a/src/shadowbox/server/manager_service.ts b/src/shadowbox/server/manager_service.ts index 28f032f14..f145b24f8 100644 --- a/src/shadowbox/server/manager_service.ts +++ b/src/shadowbox/server/manager_service.ts @@ -25,7 +25,7 @@ import * as errors from '../model/errors'; import * as version from './version'; import {ManagerMetrics} from './manager_metrics'; -import {ServerConfigJson, ListenersForNewAccessKeys, WebServerConfig} from './server_config'; +import {ServerConfigJson, ListenersConfig, WebServerConfig} from './server_config'; import {SharedMetricsPublisher} from './shared_metrics'; import {ShadowsocksServer} from '../model/shadowsocks_server'; import type {OutlineCaddyController} from './outline_caddy_server'; @@ -135,10 +135,7 @@ export function bindService( `${apiPrefix}/server/port-for-new-access-keys`, service.setPortForNewAccessKeys.bind(service) ); - apiServer.put( - `${apiPrefix}/server/listeners-for-new-access-keys`, - service.setListenersForNewAccessKeys.bind(service) - ); + apiServer.put(`${apiPrefix}/server/listeners`, service.setListeners.bind(service)); apiServer.put(`${apiPrefix}/server/web-server`, service.configureCaddyWebServer.bind(service)); apiServer.post(`${apiPrefix}/access-keys`, service.createNewAccessKey.bind(service)); @@ -146,6 +143,14 @@ export function bindService( apiServer.get(`${apiPrefix}/access-keys`, service.listAccessKeys.bind(service)); apiServer.get(`${apiPrefix}/access-keys/:id`, service.getAccessKey.bind(service)); + apiServer.put( + `${apiPrefix}/access-keys/:id/listeners`, + service.setAccessKeyListeners.bind(service) + ); + apiServer.get( + `${apiPrefix}/access-keys/:id/dynamic-config`, + service.getAccessKeyDynamicConfig.bind(service) + ); apiServer.del(`${apiPrefix}/access-keys/:id`, service.removeAccessKey.bind(service)); apiServer.put(`${apiPrefix}/access-keys/:id/name`, service.renameAccessKey.bind(service)); apiServer.put( @@ -320,7 +325,7 @@ export class ShadowsocksManagerService { const domain = configData?.caddyWebServer?.domain || configData?.hostname; if (domain) { - const listenersConfig = configData?.listenersForNewAccessKeys; + const listenersConfig = configData?.listeners; // Cast to access generateDynamicAccessKeyConfig method const serverWithDynamicConfig = this.shadowsocksServer as ShadowsocksServer & { @@ -443,72 +448,14 @@ export class ShadowsocksManagerService { next(); } - // Get an access key + // Get an access key (always returns JSON) getAccessKey(req: RequestType, res: ResponseType, next: restify.Next): void { try { logging.debug(`getAccessKey request ${JSON.stringify(req.params)}`); const accessKeyId = validateAccessKeyId(req.params.id); const accessKey = this.accessKeys.getAccessKey(accessKeyId); - // Check if this key uses WebSocket listeners - const hasWebSocketListeners = - accessKey.listeners && - (accessKey.listeners.indexOf('websocket-stream') !== -1 || - accessKey.listeners.indexOf('websocket-packet') !== -1); - - if (hasWebSocketListeners) { - // Generate and return YAML for WebSocket keys - const configData = this.serverConfig.data(); - const domain = configData?.caddyWebServer?.domain || configData?.hostname; - const listenersConfig = configData?.listenersForNewAccessKeys; - - logging.debug( - `WebSocket key detected. Domain: ${domain}, Listeners config: ${JSON.stringify( - listenersConfig - )}` - ); - - // Generate YAML even if listenersConfig is not fully configured, using defaults - if (domain) { - const serverWithWebSocket = this.shadowsocksServer as ShadowsocksServer & { - generateDynamicAccessKeyYaml?: ( - proxyParams: {encryptionMethod: string; password: string}, - domain: string, - tcpPath: string, - udpPath: string, - tls: boolean, - listeners?: ListenerType[] - ) => string | null; - }; - - const yamlConfig = serverWithWebSocket.generateDynamicAccessKeyYaml?.( - accessKey.proxyParams, - domain, - listenersConfig?.websocketStream?.path || '/tcp', - listenersConfig?.websocketPacket?.path || '/udp', - this.serverConfig.data()?.caddyWebServer?.autoHttps !== false, - accessKey.listeners as ListenerType[] | undefined - ); - - if (yamlConfig) { - // Return raw YAML for WebSocket keys - const nodeResponse = res as unknown as { - setHeader: (name: string, value: string) => void; - statusCode: number; - write: (data: string) => void; - end: () => void; - }; - - nodeResponse.setHeader('Content-Type', 'text/yaml; charset=utf-8'); - nodeResponse.statusCode = HttpSuccess.OK; - nodeResponse.write(yamlConfig); - nodeResponse.end(); - return; - } - } - } - - // Return JSON for traditional (non-WebSocket) keys + // Always return JSON - use /access-keys/{id}/dynamic-config for YAML const accessKeyJson = this.accessKeyToApiJson(accessKey); logging.debug(`getAccessKey response ${JSON.stringify(accessKeyJson)}`); res.send(HttpSuccess.OK, accessKeyJson); @@ -562,7 +509,7 @@ export class ShadowsocksManagerService { } } else { // If no listeners specified, use default listeners based on server config - const serverListeners = this.serverConfig.data()?.listenersForNewAccessKeys; + const serverListeners = this.serverConfig.data()?.listeners; if (serverListeners) { listeners = []; if (serverListeners.tcp) listeners.push('tcp'); @@ -664,11 +611,11 @@ export class ShadowsocksManagerService { if (configData) { configData.portForNewAccessKeys = port; // Also update listeners config for backward compatibility - if (!configData.listenersForNewAccessKeys) { - configData.listenersForNewAccessKeys = {}; + if (!configData.listeners) { + configData.listeners = {}; } - configData.listenersForNewAccessKeys.tcp = {port}; - configData.listenersForNewAccessKeys.udp = {port}; + configData.listeners.tcp = {port}; + configData.listeners.udp = {port}; } this.serverConfig.write(); await this.updateCaddyConfig(); @@ -687,16 +634,14 @@ export class ShadowsocksManagerService { } } - // Sets the listeners for new access keys - async setListenersForNewAccessKeys( - req: RequestType, - res: ResponseType, - next: restify.Next - ): Promise { + // Sets the listeners configuration (defaults for new keys, optionally updates all keys) + async setListeners(req: RequestType, res: ResponseType, next: restify.Next): Promise { try { - logging.debug(`setListenersForNewAccessKeys request ${JSON.stringify(req.params)}`); + logging.debug(`setListeners request ${JSON.stringify(req.params)}`); - const listeners = req.params as unknown as ListenersForNewAccessKeys; + const {applyToExisting, ...listeners} = req.params as unknown as ListenersConfig & { + applyToExisting?: boolean; + }; if (!listeners || typeof listeners !== 'object') { return next( new restifyErrors.InvalidArgumentError( @@ -807,7 +752,7 @@ export class ShadowsocksManagerService { // Store the listeners configuration const configData = this.serverConfig.data(); if (configData) { - configData.listenersForNewAccessKeys = listeners; + configData.listeners = listeners; // Update legacy portForNewAccessKeys if TCP port is set if (listeners.tcp?.port) { @@ -816,6 +761,17 @@ export class ShadowsocksManagerService { } } + // If applyToExisting is true, update all existing keys + if (applyToExisting) { + const derivedListeners: ListenerType[] = []; + if (listeners.tcp) derivedListeners.push('tcp'); + if (listeners.udp) derivedListeners.push('udp'); + if (listeners.websocketStream) derivedListeners.push('websocket-stream'); + if (listeners.websocketPacket) derivedListeners.push('websocket-packet'); + + this.accessKeys.setListenersForAllKeys(derivedListeners); + } + // Update the underlying Shadowsocks server with the new listener defaults. const listenerSettings = listeners.websocketStream || listeners.websocketPacket @@ -914,6 +870,119 @@ export class ShadowsocksManagerService { } } + // Updates listeners for a specific access key + async setAccessKeyListeners( + req: RequestType, + res: ResponseType, + next: restify.Next + ): Promise { + try { + logging.debug(`setAccessKeyListeners request ${JSON.stringify(req.params)}`); + const accessKeyId = validateAccessKeyId(req.params.id); + + const listenersParam = req.params.listeners as string[] | undefined; + if (!listenersParam || !Array.isArray(listenersParam)) { + return next( + new restifyErrors.InvalidArgumentError({statusCode: 400}, 'listeners must be an array') + ); + } + + const validListeners = ['tcp', 'udp', 'websocket-stream', 'websocket-packet']; + for (const listener of listenersParam) { + if (!validListeners.includes(listener)) { + return next( + new restifyErrors.InvalidArgumentError( + {statusCode: 400}, + `Invalid listener type: ${listener}` + ) + ); + } + } + + this.accessKeys.setAccessKeyListeners(accessKeyId, listenersParam as ListenerType[]); + await this.updateCaddyConfig(); + res.send(HttpSuccess.NO_CONTENT); + return next(); + } catch (error) { + logging.error(error); + if (error instanceof errors.AccessKeyNotFound) { + return next(new restifyErrors.NotFoundError(error.message)); + } + return next(new restifyErrors.InternalServerError()); + } + } + + // Returns dynamic config YAML for WebSocket-enabled keys + getAccessKeyDynamicConfig(req: RequestType, res: ResponseType, next: restify.Next): void { + try { + logging.debug(`getAccessKeyDynamicConfig request ${JSON.stringify(req.params)}`); + const accessKeyId = validateAccessKeyId(req.params.id); + const accessKey = this.accessKeys.getAccessKey(accessKeyId); + + const hasWebSocketListeners = + accessKey.listeners && + (accessKey.listeners.includes('websocket-stream') || + accessKey.listeners.includes('websocket-packet')); + + if (!hasWebSocketListeners) { + return next( + new restifyErrors.NotFoundError('Access key has no WebSocket listeners configured') + ); + } + + const configData = this.serverConfig.data(); + const domain = configData?.caddyWebServer?.domain || configData?.hostname; + + if (!domain) { + return next( + new restifyErrors.InternalServerError('No domain configured for WebSocket URLs') + ); + } + + const listenersConfig = configData?.listeners; + const serverWithWebSocket = this.shadowsocksServer as ShadowsocksServer & { + generateDynamicAccessKeyYaml?: ( + proxyParams: {encryptionMethod: string; password: string}, + domain: string, + tcpPath: string, + udpPath: string, + tls: boolean, + listeners?: ListenerType[] + ) => string | null; + }; + + const yamlConfig = serverWithWebSocket.generateDynamicAccessKeyYaml?.( + accessKey.proxyParams, + domain, + listenersConfig?.websocketStream?.path || '/tcp', + listenersConfig?.websocketPacket?.path || '/udp', + configData?.caddyWebServer?.autoHttps !== false, + accessKey.listeners + ); + + if (!yamlConfig) { + return next(new restifyErrors.InternalServerError('Failed to generate dynamic config')); + } + + const nodeResponse = res as unknown as { + setHeader: (name: string, value: string) => void; + statusCode: number; + write: (data: string) => void; + end: () => void; + }; + nodeResponse.setHeader('Content-Type', 'text/yaml; charset=utf-8'); + nodeResponse.statusCode = HttpSuccess.OK; + nodeResponse.write(yamlConfig); + nodeResponse.end(); + } catch (error) { + logging.error(error); + if (error instanceof errors.AccessKeyNotFound) { + return next(new restifyErrors.NotFoundError(error.message)); + } + return next(error); + } + } + // Removes an existing access key async removeAccessKey(req: RequestType, res: ResponseType, next: restify.Next): Promise { try { @@ -1134,7 +1203,7 @@ export class ShadowsocksManagerService { const apiPort = configData.caddyWebServer?.apiProxyPath ? apiPortNumber : undefined; await this.caddyServer.applyConfig({ accessKeys: this.accessKeys.listAccessKeys(), - listeners: configData.listenersForNewAccessKeys, + listeners: configData.listeners, caddyConfig: configData.caddyWebServer, hostname: configData.hostname, apiPort, diff --git a/src/shadowbox/server/outline_caddy_server.ts b/src/shadowbox/server/outline_caddy_server.ts index e6635f655..48008d4fa 100644 --- a/src/shadowbox/server/outline_caddy_server.ts +++ b/src/shadowbox/server/outline_caddy_server.ts @@ -21,11 +21,11 @@ import * as yaml from 'js-yaml'; import * as file from '../infrastructure/file'; import * as logging from '../infrastructure/logging'; import {AccessKey, ListenerType} from '../model/access_key'; -import {WebServerConfig, ListenerConfig, ListenersForNewAccessKeys} from './server_config'; +import {WebServerConfig, ListenerConfig, ListenersConfig} from './server_config'; export interface OutlineCaddyConfigPayload { accessKeys: AccessKey[]; - listeners?: ListenersForNewAccessKeys; + listeners?: ListenersConfig; caddyConfig?: WebServerConfig; hostname?: string; apiPort?: number; // Internal API port for reverse proxy diff --git a/src/shadowbox/server/server_access_key.ts b/src/shadowbox/server/server_access_key.ts index 404ed946a..235807b74 100644 --- a/src/shadowbox/server/server_access_key.ts +++ b/src/shadowbox/server/server_access_key.ts @@ -305,6 +305,20 @@ export class ServerAccessKeyRepository implements AccessKeyRepository { this.enforceAccessKeyDataLimits(); } + setAccessKeyListeners(id: AccessKeyId, listeners: ListenerType[]): void { + this.getAccessKey(id).listeners = listeners; + this.saveAccessKeys(); + this.updateServer(); + } + + setListenersForAllKeys(listeners: ListenerType[]): void { + for (const accessKey of this.accessKeys) { + accessKey.listeners = listeners; + } + this.saveAccessKeys(); + this.updateServer(); + } + // Compares access key usage with collected metrics, marking them as under or over limit. // Updates access key data usage. async enforceAccessKeyDataLimits() { diff --git a/src/shadowbox/server/server_config.ts b/src/shadowbox/server/server_config.ts index 4763fbd6f..3c19c6fdd 100644 --- a/src/shadowbox/server/server_config.ts +++ b/src/shadowbox/server/server_config.ts @@ -24,7 +24,7 @@ export interface ListenerConfig { webServerPort?: number; } -export interface ListenersForNewAccessKeys { +export interface ListenersConfig { tcp?: ListenerConfig; udp?: ListenerConfig; websocketStream?: ListenerConfig; @@ -53,8 +53,8 @@ export interface ServerConfigJson { createdTimestampMs?: number; // What port number should we use for new access keys? portForNewAccessKeys?: number; - // Listeners configuration for new access keys (supersedes portForNewAccessKeys) - listenersForNewAccessKeys?: ListenersForNewAccessKeys; + // Listeners configuration for access keys + listeners?: ListenersConfig; // Caddy web server configuration for automatic HTTPS caddyWebServer?: WebServerConfig; // Which staged rollouts we should force enabled or disabled. From 724ae445776caa9a130c39c78662161359b8dff1 Mon Sep 17 00:00:00 2001 From: lunarthegrey Date: Sat, 17 Jan 2026 01:11:10 -0600 Subject: [PATCH 50/50] Update Caddy server's reverse proxy transport protocol to HTTP for local API communication. --- src/shadowbox/server/outline_caddy_server.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/shadowbox/server/outline_caddy_server.ts b/src/shadowbox/server/outline_caddy_server.ts index 48008d4fa..c01e60216 100644 --- a/src/shadowbox/server/outline_caddy_server.ts +++ b/src/shadowbox/server/outline_caddy_server.ts @@ -362,7 +362,7 @@ export class OutlineCaddyServer implements OutlineCaddyController { handler: 'reverse_proxy', upstreams: [{dial: `localhost:${apiPort}`}], transport: { - protocol: 'https', + protocol: 'http', tls: { insecure_skip_verify: true, // Skip verification for self-signed cert },