mirror of
https://github.com/OutlineFoundation/outline-server.git
synced 2026-08-28 04:12:53 +00:00
Add dynamic configuration to WebSocket-enabled access keys in the API response.
This commit is contained in:
parent
bd83c542a8
commit
ed9c121c8d
3 changed files with 157 additions and 76 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>;
|
||||
}
|
||||
|
||||
// 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<string, unknown> | 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,
|
||||
|
|
|
|||
|
|
@ -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<string, unknown> | null {
|
||||
if (!domain) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const listenerSet = new Set<ListenerType>(listeners ?? ['websocket-stream', 'websocket-packet']);
|
||||
const listenerSet = new Set<ListenerType>(
|
||||
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<string, unknown> = {
|
||||
'$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
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue