Add support for SS over WSS into shadowbox

This commit is contained in:
lunarthegrey 2025-07-26 20:31:13 -05:00
parent 26803710c9
commit 25678f7122
7 changed files with 581 additions and 16 deletions

169
.github/workflows/build-shadowbox.yml vendored Normal file
View file

@ -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

View file

@ -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 {

View file

@ -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

View file

@ -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',

View file

@ -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)}`);

View file

@ -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<void> {
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<string, ShadowsocksAccessKeyWithWebSocket[]>();
// 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) {

View file

@ -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);
}