mirror of
https://github.com/OutlineFoundation/outline-server.git
synced 2026-09-21 13:14:31 +00:00
Add support for SS over WSS into shadowbox
This commit is contained in:
parent
26803710c9
commit
25678f7122
7 changed files with 581 additions and 16 deletions
169
.github/workflows/build-shadowbox.yml
vendored
Normal file
169
.github/workflows/build-shadowbox.yml
vendored
Normal 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
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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)}`);
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue