mirror of
https://github.com/OutlineFoundation/outline-server.git
synced 2026-08-28 04:12:53 +00:00
refactor(api): make listeners mutable and add dedicated dynamic config endpoint
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
This commit is contained in:
parent
bec26bd2b3
commit
be31e617fd
9 changed files with 302 additions and 150 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
// Sets the listeners configuration (defaults for new keys, optionally updates all keys)
|
||||
async setListeners(req: RequestType, res: ResponseType, next: restify.Next): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue