@yuriai/relay-server 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/DOCUMENTATION.md +348 -0
- package/README.md +460 -2
- package/bin/cli-entry.js +55 -0
- package/bin/yuri-push-relay.js +11 -0
- package/bin/yuri-relay.js +11 -0
- package/package.json +54 -4
- package/src/cli.js +135 -0
- package/src/index.d.ts +95 -0
- package/src/index.js +378 -0
- package/src/push/apns.js +138 -0
- package/src/push/cached-store.js +121 -0
- package/src/push/cli.js +73 -0
- package/src/push/combined.js +34 -0
- package/src/push/config.js +145 -0
- package/src/push/crypto.js +34 -0
- package/src/push/guard.js +146 -0
- package/src/push/handler.js +314 -0
- package/src/push/index.d.ts +113 -0
- package/src/push/index.js +3 -0
- package/src/push/schema.js +230 -0
- package/src/push/server.js +104 -0
- package/src/push/store-worker.js +15 -0
- package/src/push/store.js +60 -0
package/src/cli.js
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { startPrivateRelayServer } from './index.js';
|
|
2
|
+
import { isIP } from 'node:net';
|
|
3
|
+
|
|
4
|
+
const LIMIT_KEYS = ['maxUrlBytes', 'maxFieldBytes', 'maxHosts', 'maxSockets', 'maxConnections', 'maxClientsPerHost', 'maxClientsPerIp', 'maxPendingClients', 'pendingMs', 'maxRawSockets', 'maxRawSocketsPerIp', 'graceMs', 'timestampSkewMs', 'replayMs', 'maxReplayEntries', 'maxFrameBytes', 'maxQueuedBytesPerConnection', 'maxGlobalQueuedBytes', 'maxBufferedAmount', 'maxControlQueueEntries', 'maxControlQueuedBytes', 'pumpRetryMs', 'heartbeatMs', 'handshakeMs', 'closeDeadlineMs', 'admissionWindowMs', 'maxAdmissionsPerIp', 'maxHostControlAdmissionsPerIp', 'maxHostControlAdmissionsPerServer', 'maxAdmissionEntries', 'idAttempts'];
|
|
5
|
+
const upperSnake = (key) => key.replace(/[A-Z]/g, (letter) => `_${letter}`).toUpperCase();
|
|
6
|
+
const envName = (key) => `YURI_RELAY_SERVER_${upperSnake(key)}`;
|
|
7
|
+
const fail = (name) => { throw new Error(`Invalid ${name}`); };
|
|
8
|
+
|
|
9
|
+
export const parseRelayServerArgs = (argv = []) => {
|
|
10
|
+
const parsed = {};
|
|
11
|
+
const values = new Map([['--host', 'host'], ['--port', 'port'], ['--path', 'path'], ['--public-url', 'publicUrl']]);
|
|
12
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
13
|
+
const arg = argv[index];
|
|
14
|
+
if (values.has(arg)) { const value = argv[++index]; if (!value || value.startsWith('--')) fail(arg, value ?? ''); parsed[values.get(arg)] = value; continue; }
|
|
15
|
+
if (arg === '--trust-proxy') { parsed.trustProxy = true; continue; }
|
|
16
|
+
if (arg === '--no-trust-proxy') { parsed.trustProxy = false; continue; }
|
|
17
|
+
if (arg === '--json') { parsed.json = true; continue; }
|
|
18
|
+
if (arg === '--quiet' || arg === '-q') { parsed.quiet = true; continue; }
|
|
19
|
+
if (arg === '--help' || arg === '-h') { parsed.help = true; continue; }
|
|
20
|
+
if (arg === '--version' || arg === '-v') { parsed.version = true; continue; }
|
|
21
|
+
fail(arg, '');
|
|
22
|
+
}
|
|
23
|
+
return parsed;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const positive = (name, value) => {
|
|
27
|
+
if (!/^[1-9][0-9]*$/.test(String(value))) fail(name, value);
|
|
28
|
+
const number = Number(value); if (!Number.isSafeInteger(number)) fail(name, value);
|
|
29
|
+
return number;
|
|
30
|
+
};
|
|
31
|
+
const bool = (name, value) => {
|
|
32
|
+
if (value === undefined) return false;
|
|
33
|
+
if (value === 'true' || value === '1' || value === true) return true;
|
|
34
|
+
if (value === 'false' || value === '0' || value === false) return false;
|
|
35
|
+
fail(name, value);
|
|
36
|
+
};
|
|
37
|
+
const validPath = (name, value) => {
|
|
38
|
+
if (typeof value !== 'string' || !value.startsWith('/') || value.startsWith('//') || value.includes('?') || value.includes('#') || new URL(value, 'http://relay').pathname !== value) fail(name, value);
|
|
39
|
+
return value;
|
|
40
|
+
};
|
|
41
|
+
const validHost = (name, value) => {
|
|
42
|
+
if (typeof value !== 'string' || value.trim() !== value || value.length === 0 || value.includes('/') || value.includes('\\') || value.includes('@') || value.includes(':') && !isIP(value) || (!isIP(value) && !/^(?=.{1,253}$)(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)(?:\.(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?))*$/.test(value))) fail(name, value);
|
|
43
|
+
return value;
|
|
44
|
+
};
|
|
45
|
+
const publicUrl = (name, value, path) => {
|
|
46
|
+
if (!value) return undefined;
|
|
47
|
+
let url; try { url = new URL(value); } catch { fail(name, value); }
|
|
48
|
+
if (!['ws:', 'wss:'].includes(url.protocol) || !url.hostname || url.username || url.password || url.search || url.hash || url.pathname !== path) fail(name, value);
|
|
49
|
+
return url.toString();
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export const buildRelayConfig = (parsed = {}, env = process.env) => {
|
|
53
|
+
const envValue = (key) => {
|
|
54
|
+
const value = env[envName(key)];
|
|
55
|
+
return typeof value === 'string' && value.trim().length === 0 ? undefined : value;
|
|
56
|
+
};
|
|
57
|
+
const pick = (key, fallback) => parsed[key] ?? envValue(key) ?? fallback;
|
|
58
|
+
const portValue = pick('port', 8787); const port = positive(parsed.port !== undefined ? '--port' : envName('port'), portValue);
|
|
59
|
+
if (port > 65535) fail(parsed.port !== undefined ? '--port' : envName('port'), portValue);
|
|
60
|
+
const path = validPath(parsed.path !== undefined ? '--path' : envName('path'), pick('path', '/ws'));
|
|
61
|
+
const host = validHost(parsed.host !== undefined ? '--host' : envName('host'), pick('host', '127.0.0.1'));
|
|
62
|
+
const outputPublicUrl = publicUrl(parsed.publicUrl !== undefined ? '--public-url' : envName('publicUrl'), pick('publicUrl', undefined), path);
|
|
63
|
+
const limits = {};
|
|
64
|
+
for (const key of LIMIT_KEYS) { const value = pick(key, undefined); if (value !== undefined) limits[key] = positive(parsed[key] !== undefined ? `--${upperSnake(key).toLowerCase().replaceAll('_', '-')}` : envName(key), value); }
|
|
65
|
+
const replayMs = limits.replayMs ?? 120_000; const timestampSkewMs = limits.timestampSkewMs ?? 60_000; const maxConnections = limits.maxConnections ?? 1_000;
|
|
66
|
+
if (replayMs < timestampSkewMs * 2) fail(parsed.replayMs !== undefined ? '--replay-ms' : envName('replayMs'), replayMs);
|
|
67
|
+
if ((limits.maxPendingClients ?? 30) > maxConnections) fail(parsed.maxPendingClients !== undefined ? '--max-pending-clients' : envName('maxPendingClients'), limits.maxPendingClients ?? 30);
|
|
68
|
+
if ((limits.maxClientsPerHost ?? 100) > maxConnections) fail(parsed.maxClientsPerHost !== undefined ? '--max-clients-per-host' : envName('maxClientsPerHost'), limits.maxClientsPerHost ?? 100);
|
|
69
|
+
return { host, port, path, publicUrl: outputPublicUrl, trustProxy: parsed.trustProxy ?? bool(envName('trustProxy'), envValue('trustProxy')), limits };
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const helpText = 'Usage: yuri-relay [--host HOST] [--port PORT] [--path PATH] [--public-url WS_URL] [--trust-proxy] [--json] [--quiet]\nEnable --trust-proxy only when public ingress reaches this relay through a trusted reverse proxy.\n';
|
|
73
|
+
const writeJson = (stdout, payload) => stdout.write(`${JSON.stringify(payload)}\n`);
|
|
74
|
+
const hasPushApnsEnv = (env) => {
|
|
75
|
+
for (const [key, value] of Object.entries(env ?? {})) {
|
|
76
|
+
if (key.startsWith('YURI_PUSH_RELAY_APNS_') && typeof value === 'string' && value.trim().length > 0) return true;
|
|
77
|
+
}
|
|
78
|
+
return false;
|
|
79
|
+
};
|
|
80
|
+
const loadCombinedPushMount = async () => {
|
|
81
|
+
// Computed specifier keeps bun --compile from bundling Push/SQLite into the Layer 1 binary.
|
|
82
|
+
return (await import(['.', 'push', 'combined.js'].join('/'))).createCombinedPushMount;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
export const runRelayServerCli = async (argv, dependencies = {}) => {
|
|
86
|
+
const processLike = dependencies.process ?? process; const stdout = dependencies.stdout ?? process.stdout; const stderr = dependencies.stderr ?? process.stderr; const version = dependencies.version ?? '0.0.0';
|
|
87
|
+
let parsed;
|
|
88
|
+
try { parsed = parseRelayServerArgs(argv ?? processLike.argv?.slice(2) ?? []); } catch (error) {
|
|
89
|
+
const json = (argv ?? processLike.argv?.slice(2) ?? []).includes('--json');
|
|
90
|
+
if (json) writeJson(stdout, { status: 'error', error: error.message }); else stderr.write(`${error.message}\n`);
|
|
91
|
+
processLike.exitCode = 1; return 1;
|
|
92
|
+
}
|
|
93
|
+
const json = parsed.json;
|
|
94
|
+
const respond = (payload, error = false, essential = false) => { if (json) writeJson(stdout, payload); else if (payload.message && (essential || !parsed.quiet || error)) (error ? stderr : stdout).write(`${payload.message}\n`); };
|
|
95
|
+
if (parsed.help) { respond(json ? { status: 'ok', help: helpText.trim() } : { message: helpText.trim() }, false, true); return 0; }
|
|
96
|
+
if (parsed.version) { respond(json ? { status: 'ok', version } : { message: version }, false, true); return 0; }
|
|
97
|
+
let config;
|
|
98
|
+
try { config = buildRelayConfig(parsed, processLike.env ?? {}); } catch (error) { respond({ status: 'error', error: error.message, message: error.message }, true); processLike.exitCode = 1; return 1; }
|
|
99
|
+
try {
|
|
100
|
+
const env = processLike.env ?? {};
|
|
101
|
+
const createPushMount = dependencies.createPushMount ?? (
|
|
102
|
+
hasPushApnsEnv(env) ? await loadCombinedPushMount() : () => null
|
|
103
|
+
);
|
|
104
|
+
const mount = createPushMount(env, dependencies.pushMountDeps ?? {});
|
|
105
|
+
if (mount) config.requestHandler = mount.requestHandler;
|
|
106
|
+
const relay = await (dependencies.start ?? startPrivateRelayServer)(config);
|
|
107
|
+
if (mount) {
|
|
108
|
+
try { await mount.start(); } catch (error) {
|
|
109
|
+
await mount.stop();
|
|
110
|
+
await relay.stop();
|
|
111
|
+
throw error;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
const url = config.publicUrl ?? relay.wsUrl;
|
|
115
|
+
respond(json ? { status: 'ok', url, host: config.host, port: relay.address?.()?.port ?? config.port, path: config.path, ...(mount ? { push: true } : {}) } : { message: `Relay listening at ${url}` });
|
|
116
|
+
if (mount && !json) respond({ message: 'Push relay mounted at /v1/push/*' });
|
|
117
|
+
let stopping = false;
|
|
118
|
+
const stop = async () => {
|
|
119
|
+
if (stopping) return Promise.resolve();
|
|
120
|
+
stopping = true;
|
|
121
|
+
processLike.off?.('SIGINT', stop); processLike.off?.('SIGTERM', stop);
|
|
122
|
+
try {
|
|
123
|
+
if (mount) await mount.stop();
|
|
124
|
+
await relay.stop();
|
|
125
|
+
processLike.exit?.(0);
|
|
126
|
+
} catch {
|
|
127
|
+
processLike.exitCode = 1;
|
|
128
|
+
if (json) writeJson(stderr, { status: 'error', error: 'Relay stop failed' }); else stderr.write('Relay stop failed\n');
|
|
129
|
+
processLike.exit?.(1);
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
processLike.on?.('SIGINT', stop); processLike.on?.('SIGTERM', stop);
|
|
133
|
+
return 0;
|
|
134
|
+
} catch (error) { respond({ status: 'error', error: error.message, message: error.message }, true); processLike.exitCode = 1; return 1; }
|
|
135
|
+
};
|
package/src/index.d.ts
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
|
+
import type { AddressInfo } from 'node:net';
|
|
3
|
+
|
|
4
|
+
export type RelaySocketRole = 'client' | 'host-control' | 'host-data';
|
|
5
|
+
|
|
6
|
+
export interface Limits {
|
|
7
|
+
maxUrlBytes: number;
|
|
8
|
+
maxFieldBytes: number;
|
|
9
|
+
maxHosts: number;
|
|
10
|
+
maxSockets: number;
|
|
11
|
+
maxConnections: number;
|
|
12
|
+
maxClientsPerHost: number;
|
|
13
|
+
maxClientsPerIp: number;
|
|
14
|
+
maxPendingClients: number;
|
|
15
|
+
pendingMs: number;
|
|
16
|
+
maxRawSockets: number;
|
|
17
|
+
maxRawSocketsPerIp: number;
|
|
18
|
+
graceMs: number;
|
|
19
|
+
timestampSkewMs: number;
|
|
20
|
+
replayMs: number;
|
|
21
|
+
maxReplayEntries: number;
|
|
22
|
+
maxFrameBytes: number;
|
|
23
|
+
maxQueuedBytesPerConnection: number;
|
|
24
|
+
maxGlobalQueuedBytes: number;
|
|
25
|
+
maxBufferedAmount: number;
|
|
26
|
+
maxControlQueueEntries: number;
|
|
27
|
+
maxControlQueuedBytes: number;
|
|
28
|
+
pumpRetryMs: number;
|
|
29
|
+
heartbeatMs: number;
|
|
30
|
+
handshakeMs: number;
|
|
31
|
+
closeDeadlineMs: number;
|
|
32
|
+
admissionWindowMs: number;
|
|
33
|
+
maxAdmissionsPerIp: number;
|
|
34
|
+
maxHostControlAdmissionsPerIp: number;
|
|
35
|
+
maxHostControlAdmissionsPerServer: number;
|
|
36
|
+
maxAdmissionEntries: number;
|
|
37
|
+
idAttempts: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface SnapshotReasons {
|
|
41
|
+
authRejected: number;
|
|
42
|
+
policyRejected: number;
|
|
43
|
+
limited: number;
|
|
44
|
+
heartbeatReaped: number;
|
|
45
|
+
replayRejected: number;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface Snapshot {
|
|
49
|
+
state: 'idle' | 'starting' | 'running' | 'stopping' | 'stopped';
|
|
50
|
+
hosts: number;
|
|
51
|
+
sockets: number;
|
|
52
|
+
rawSockets: number;
|
|
53
|
+
controls: number;
|
|
54
|
+
clients: number;
|
|
55
|
+
pairs: number;
|
|
56
|
+
pending: number;
|
|
57
|
+
queuedBytes: number;
|
|
58
|
+
reasons: SnapshotReasons;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface Clock {
|
|
62
|
+
now: () => number;
|
|
63
|
+
setTimeout: typeof globalThis.setTimeout;
|
|
64
|
+
clearTimeout: typeof globalThis.clearTimeout;
|
|
65
|
+
setInterval: typeof globalThis.setInterval;
|
|
66
|
+
clearInterval: typeof globalThis.clearInterval;
|
|
67
|
+
setImmediate: typeof globalThis.setImmediate;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface Options {
|
|
71
|
+
host?: string;
|
|
72
|
+
port?: number;
|
|
73
|
+
path?: string;
|
|
74
|
+
trustProxy?: boolean;
|
|
75
|
+
requestHandler?: (request: IncomingMessage, response: ServerResponse) => boolean;
|
|
76
|
+
clock?: Partial<Clock>;
|
|
77
|
+
randomBytes?: (size: number) => Buffer;
|
|
78
|
+
logger?: Pick<Console, 'info' | 'warn' | 'error'>;
|
|
79
|
+
limits?: Partial<Limits>;
|
|
80
|
+
resolveClientIp?: (request: IncomingMessage) => string;
|
|
81
|
+
onSocketAccepted?: (connection: { socket: unknown; role: RelaySocketRole }) => void;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface ServerInstance {
|
|
85
|
+
start(): Promise<void>;
|
|
86
|
+
stop(): Promise<void>;
|
|
87
|
+
address(): string | AddressInfo | null | undefined;
|
|
88
|
+
readonly wsUrl: string | null;
|
|
89
|
+
getSnapshot(): Snapshot;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function resolveRelayClientIp(request: IncomingMessage, trustProxy?: boolean): string;
|
|
93
|
+
export function formatRelayWsUrl(host: string, port: number, relayPath: string): string;
|
|
94
|
+
export function createPrivateRelayServer(options?: Options): ServerInstance;
|
|
95
|
+
export function startPrivateRelayServer(options?: Options): Promise<ServerInstance>;
|
package/src/index.js
ADDED
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import http from 'node:http';
|
|
3
|
+
import { isIP } from 'node:net';
|
|
4
|
+
import { WebSocketServer } from 'ws';
|
|
5
|
+
|
|
6
|
+
const CLOSE = { replaced: 4001, duplicate: 4002, stuck: 4003, unavailable: 4008, auth: 4010, limit: 4029, away: 1012, malformed: 1008 };
|
|
7
|
+
const B64 = /^[A-Za-z0-9_-]+$/;
|
|
8
|
+
const SERVER_ID_BYTES = 32;
|
|
9
|
+
const DEFAULT_LIMITS = {
|
|
10
|
+
maxUrlBytes: 4096, maxFieldBytes: 512, maxHosts: 256, maxSockets: 2048, maxConnections: 1000,
|
|
11
|
+
maxClientsPerHost: 100, maxClientsPerIp: 30, maxPendingClients: 30, pendingMs: 15_000,
|
|
12
|
+
maxRawSockets: 4096, maxRawSocketsPerIp: 128,
|
|
13
|
+
graceMs: 30_000, timestampSkewMs: 60_000, replayMs: 120_000, maxReplayEntries: 10_000,
|
|
14
|
+
maxFrameBytes: 128 * 1024, maxQueuedBytesPerConnection: 2 * 1024 * 1024,
|
|
15
|
+
maxGlobalQueuedBytes: 32 * 1024 * 1024, maxBufferedAmount: 2 * 1024 * 1024,
|
|
16
|
+
maxControlQueueEntries: 256, maxControlQueuedBytes: 2 * 1024 * 1024,
|
|
17
|
+
pumpRetryMs: 25, heartbeatMs: 30_000, handshakeMs: 10_000, closeDeadlineMs: 5_000,
|
|
18
|
+
admissionWindowMs: 60_000, maxAdmissionsPerIp: 120,
|
|
19
|
+
maxHostControlAdmissionsPerIp: 30, maxHostControlAdmissionsPerServer: 12, maxAdmissionEntries: 10_000,
|
|
20
|
+
idAttempts: 4,
|
|
21
|
+
};
|
|
22
|
+
const fields = {
|
|
23
|
+
client: new Set(['v', 'role', 'serverId', 'grant']),
|
|
24
|
+
'host-control': new Set(['v', 'role', 'serverId', 'ts', 'sig', 'pk']),
|
|
25
|
+
'host-data': new Set(['v', 'role', 'serverId', 'connectionId', 'ts', 'sig', 'pk']),
|
|
26
|
+
};
|
|
27
|
+
const bytes = (value) => Buffer.byteLength(value, 'utf8');
|
|
28
|
+
const canonical = (jwk) => JSON.stringify({ crv: jwk.crv, kty: jwk.kty, x: jwk.x, y: jwk.y });
|
|
29
|
+
const b64 = (value, length) => {
|
|
30
|
+
if (typeof value !== 'string' || !B64.test(value)) return null;
|
|
31
|
+
try { const decoded = Buffer.from(value, 'base64url'); return decoded.length === length && decoded.toString('base64url') === value ? decoded : null; } catch { return null; }
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export const resolveRelayClientIp = (request, trustProxy = false) => {
|
|
35
|
+
const remoteAddress = request.socket.remoteAddress ?? 'unknown';
|
|
36
|
+
if (!trustProxy) return remoteAddress;
|
|
37
|
+
const forwarded = request.headers['x-forwarded-for'];
|
|
38
|
+
const candidate = typeof forwarded === 'string' && !forwarded.includes(',') ? forwarded.trim() : '';
|
|
39
|
+
return isIP(candidate) ? candidate : remoteAddress;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export const formatRelayWsUrl = (host, port, relayPath) => `ws://${isIP(host) === 6 ? `[${host}]` : host}:${port}${relayPath}`;
|
|
43
|
+
|
|
44
|
+
/** @param {string} encoded */
|
|
45
|
+
const decodeJwk = (encoded) => {
|
|
46
|
+
const raw = b64(encoded, Buffer.byteLength(encoded, 'base64url'));
|
|
47
|
+
if (!raw) return null;
|
|
48
|
+
try {
|
|
49
|
+
const text = raw.toString('utf8'); const jwk = JSON.parse(text);
|
|
50
|
+
if (text !== canonical(jwk) || jwk.kty !== 'EC' || jwk.crv !== 'P-256' || !b64(jwk.x, 32) || !b64(jwk.y, 32)) return null;
|
|
51
|
+
return { jwk, key: crypto.createPublicKey({ key: jwk, format: 'jwk' }) };
|
|
52
|
+
} catch { return null; }
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Creates the Layer 1 self-hosted relay. It routes opaque Layer 2/3 frames.
|
|
57
|
+
* @param {{ host?: string, port?: number, path?: string, trustProxy?: boolean, requestHandler?: (request: http.IncomingMessage, response: http.ServerResponse) => boolean, clock?: Partial<typeof globalThis>, randomBytes?: (size: number) => Buffer, logger?: Pick<Console, 'info'|'warn'|'error'>, limits?: Partial<typeof DEFAULT_LIMITS>, resolveClientIp?: (request: http.IncomingMessage) => string, onSocketAccepted?: ({ socket: import('ws').WebSocket, role: string }) => void }} options
|
|
58
|
+
*/
|
|
59
|
+
export const createPrivateRelayServer = (options = {}) => {
|
|
60
|
+
const limits = { ...DEFAULT_LIMITS, ...options.limits };
|
|
61
|
+
if (limits.replayMs < limits.timestampSkewMs * 2 || limits.maxReplayEntries < 1) throw new RangeError('invalid replay limits');
|
|
62
|
+
const clock = { now: Date.now, setTimeout, clearTimeout, setInterval, clearInterval, setImmediate, ...options.clock };
|
|
63
|
+
const randomBytes = options.randomBytes ?? crypto.randomBytes;
|
|
64
|
+
const hosts = new Map(); const replay = new Map(); const admissions = new Map(); const clientIpCounts = new Map(); const rawIpCounts = new Map();
|
|
65
|
+
const accepted = new Set(); const rawSockets = new Set();
|
|
66
|
+
const counts = { hosts: 0, sockets: 0, rawSockets: 0, controls: 0, clients: 0, pairs: 0, pending: 0, queuedBytes: 0 };
|
|
67
|
+
const reasons = { authRejected: 0, policyRejected: 0, limited: 0, heartbeatReaped: 0, replayRejected: 0 };
|
|
68
|
+
let server = null; let wss = null; let heartbeat = null; let startPromise = null; let stopPromise = null; let abortStart = null; let state = 'idle'; let generation = 0;
|
|
69
|
+
const addReason = (key) => { reasons[key] += 1; };
|
|
70
|
+
const snapshot = () => ({ state, ...counts, reasons: { ...reasons } });
|
|
71
|
+
const resolveClientIp = options.resolveClientIp ?? ((request) => resolveRelayClientIp(request, options.trustProxy));
|
|
72
|
+
const relayPath = options.path ?? '/ws';
|
|
73
|
+
const decrement = (map, key) => { const value = map.get(key) ?? 0; if (value <= 1) map.delete(key); else map.set(key, value - 1); };
|
|
74
|
+
const safeClose = (socket, code, reason = '') => {
|
|
75
|
+
if (socket.readyState === socket.CLOSED) return;
|
|
76
|
+
try {
|
|
77
|
+
if (socket.readyState === socket.OPEN || socket.readyState === socket.CONNECTING) socket.close(code, reason);
|
|
78
|
+
if (socket.readyState === socket.CLOSED) return;
|
|
79
|
+
if (socket.readyState !== socket.OPEN && socket.readyState !== socket.CONNECTING && socket.readyState !== socket.CLOSING) return;
|
|
80
|
+
if (!socket._relayCloseDeadline) socket._relayCloseDeadline = clock.setTimeout(() => {
|
|
81
|
+
socket._relayCloseDeadline = null;
|
|
82
|
+
if (socket.readyState !== socket.CLOSED) socket.terminate();
|
|
83
|
+
}, limits.closeDeadlineMs);
|
|
84
|
+
} catch { socket.terminate(); }
|
|
85
|
+
};
|
|
86
|
+
const purge = () => {
|
|
87
|
+
const now = clock.now();
|
|
88
|
+
for (const [key, expiry] of replay) if (expiry <= now) replay.delete(key);
|
|
89
|
+
for (const [key, record] of admissions) if (record.until <= now) admissions.delete(key);
|
|
90
|
+
};
|
|
91
|
+
const admit = (key, cap) => {
|
|
92
|
+
purge(); let record = admissions.get(key);
|
|
93
|
+
if (!record) {
|
|
94
|
+
if (admissions.size >= limits.maxAdmissionEntries) { addReason('limited'); return false; }
|
|
95
|
+
record = { count: 0, until: clock.now() + limits.admissionWindowMs }; admissions.set(key, record);
|
|
96
|
+
}
|
|
97
|
+
record.count += 1;
|
|
98
|
+
if (record.count > cap) { addReason('limited'); return false; }
|
|
99
|
+
return true;
|
|
100
|
+
};
|
|
101
|
+
const admitUpgrade = (ip, parsed) => {
|
|
102
|
+
if (parsed.role === 'host-control') {
|
|
103
|
+
return admit(`host-control:${ip}`, limits.maxHostControlAdmissionsPerIp)
|
|
104
|
+
&& admit(`host-control-server:${parsed.serverId}`, limits.maxHostControlAdmissionsPerServer);
|
|
105
|
+
}
|
|
106
|
+
return admit(`${parsed.role}:${ip}`, limits.maxAdmissionsPerIp);
|
|
107
|
+
};
|
|
108
|
+
const removeSocket = (socket) => {
|
|
109
|
+
if (!accepted.delete(socket)) return;
|
|
110
|
+
counts.sockets -= 1;
|
|
111
|
+
if (socket._relayHandshake) clock.clearTimeout(socket._relayHandshake);
|
|
112
|
+
if (socket._relayCloseDeadline) clock.clearTimeout(socket._relayCloseDeadline);
|
|
113
|
+
};
|
|
114
|
+
const detachControl = (host, control, { code = CLOSE.away, reason = 'control closed', grace = true } = {}) => {
|
|
115
|
+
if (!control || hosts.get(host.serverId) !== host || host.control !== control || control.epoch !== host.epoch) return;
|
|
116
|
+
host.control = null;
|
|
117
|
+
counts.controls -= 1;
|
|
118
|
+
const queued = control.queuedBytes;
|
|
119
|
+
control.queue = []; control.queuedBytes = 0; control.busy = false;
|
|
120
|
+
counts.queuedBytes = Math.max(0, counts.queuedBytes - queued);
|
|
121
|
+
safeClose(control.socket, code, reason);
|
|
122
|
+
if (grace) beginGrace(host, control.epoch);
|
|
123
|
+
};
|
|
124
|
+
const controlPump = (host, message) => {
|
|
125
|
+
if (!host.control || host.control.epoch !== host.epoch) return;
|
|
126
|
+
const control = host.control; const payload = Buffer.from(JSON.stringify(message));
|
|
127
|
+
if (control.queue.length >= limits.maxControlQueueEntries || control.queuedBytes + payload.length > limits.maxControlQueuedBytes || counts.queuedBytes + payload.length > limits.maxGlobalQueuedBytes) {
|
|
128
|
+
addReason('limited'); detachControl(host, control, { code: CLOSE.limit, reason: 'control queue limit' }); return;
|
|
129
|
+
}
|
|
130
|
+
control.queue.push(payload); control.queuedBytes += payload.length; counts.queuedBytes += payload.length;
|
|
131
|
+
const pump = () => {
|
|
132
|
+
const control = host.control;
|
|
133
|
+
if (!control || control.epoch !== host.epoch || control.busy || !control.queue.length) return;
|
|
134
|
+
if (control.socket.readyState !== control.socket.OPEN) return detachControl(host, control, { code: CLOSE.away, reason: 'control unavailable' });
|
|
135
|
+
control.busy = true; const item = control.queue[0];
|
|
136
|
+
control.socket.send(item, { binary: false }, (error) => {
|
|
137
|
+
if (!host.control || host.control !== control || control.epoch !== host.epoch) return;
|
|
138
|
+
control.busy = false;
|
|
139
|
+
if (error) { detachControl(host, control, { code: CLOSE.away, reason: 'control send failure' }); return; }
|
|
140
|
+
control.queue.shift(); control.queuedBytes -= item.length; counts.queuedBytes = Math.max(0, counts.queuedBytes - item.length); pump();
|
|
141
|
+
});
|
|
142
|
+
};
|
|
143
|
+
pump();
|
|
144
|
+
};
|
|
145
|
+
const releasePump = (entry, pump) => {
|
|
146
|
+
if (pump.retry) clock.clearTimeout(pump.retry);
|
|
147
|
+
const total = pump.bytes; pump.queue = []; pump.bytes = 0; pump.busy = false; pump.retry = null;
|
|
148
|
+
entry.queuedBytes = Math.max(0, entry.queuedBytes - total);
|
|
149
|
+
counts.queuedBytes = Math.max(0, counts.queuedBytes - total);
|
|
150
|
+
};
|
|
151
|
+
const removeClient = (host, entry, code, reason, notify = true) => {
|
|
152
|
+
if (host.clients.get(entry.id) !== entry) return;
|
|
153
|
+
host.clients.delete(entry.id); counts.clients -= 1; decrement(clientIpCounts, entry.ip); if (entry.data) counts.pairs -= 1; else counts.pending -= 1;
|
|
154
|
+
if (entry.pendingTimer) clock.clearTimeout(entry.pendingTimer);
|
|
155
|
+
releasePump(entry, entry.toData); releasePump(entry, entry.toClient);
|
|
156
|
+
setRelayPaused(entry.client, false); setRelayPaused(entry.data, false);
|
|
157
|
+
if (entry.client) safeClose(entry.client, code, reason); if (entry.data) safeClose(entry.data, code, reason);
|
|
158
|
+
if (notify) controlPump(host, { type: 'disconnected', connectionId: entry.id });
|
|
159
|
+
if (!host.control && host.clients.size === 0 && !host.graceTimer && hosts.delete(host.serverId)) counts.hosts -= 1;
|
|
160
|
+
};
|
|
161
|
+
const expireHost = (host, epoch) => {
|
|
162
|
+
if (hosts.get(host.serverId) !== host || host.epoch !== epoch || host.control) return;
|
|
163
|
+
host.graceTimer = null;
|
|
164
|
+
for (const entry of [...host.clients.values()]) removeClient(host, entry, CLOSE.away, 'host went away', false);
|
|
165
|
+
if (hosts.get(host.serverId) === host && hosts.delete(host.serverId)) counts.hosts -= 1;
|
|
166
|
+
};
|
|
167
|
+
const beginGrace = (host, epoch) => {
|
|
168
|
+
if (hosts.get(host.serverId) !== host || host.epoch !== epoch || host.graceTimer) return;
|
|
169
|
+
host.graceTimer = clock.setTimeout(() => expireHost(host, epoch), limits.graceMs);
|
|
170
|
+
};
|
|
171
|
+
const pauseHigh = Math.max(1, Math.min(limits.maxQueuedBytesPerConnection - 1, Math.floor(limits.maxQueuedBytesPerConnection / 2)));
|
|
172
|
+
const pauseLow = Math.min(pauseHigh, Math.floor(limits.maxQueuedBytesPerConnection / 4));
|
|
173
|
+
const sourceOf = (entry, direction) => (direction === 'toData' ? entry.client : entry.data);
|
|
174
|
+
// Pause the fast sender when this direction's queue is above the high watermark.
|
|
175
|
+
const setRelayPaused = (socket, paused) => {
|
|
176
|
+
if (!socket || Boolean(socket._relayPaused) === paused) return;
|
|
177
|
+
if (paused && socket.readyState !== socket.OPEN) return;
|
|
178
|
+
try {
|
|
179
|
+
if (typeof socket.pause === 'function' && typeof socket.resume === 'function') {
|
|
180
|
+
if (paused) socket.pause();
|
|
181
|
+
else socket.resume();
|
|
182
|
+
} else {
|
|
183
|
+
const stream = socket._socket;
|
|
184
|
+
if (stream && typeof stream.pause === 'function') {
|
|
185
|
+
if (paused) stream.pause();
|
|
186
|
+
else stream.resume();
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
} catch { /* pause/resume only apply while OPEN */ }
|
|
190
|
+
socket._relayPaused = paused;
|
|
191
|
+
};
|
|
192
|
+
const applyBackpressure = (entry, direction) => {
|
|
193
|
+
const source = sourceOf(entry, direction);
|
|
194
|
+
if (!source) return;
|
|
195
|
+
const queued = entry[direction].bytes;
|
|
196
|
+
if (queued >= pauseHigh) setRelayPaused(source, true);
|
|
197
|
+
else if (queued <= pauseLow) setRelayPaused(source, false);
|
|
198
|
+
};
|
|
199
|
+
const fairReady = [];
|
|
200
|
+
let fairScheduled = false;
|
|
201
|
+
const requestPump = (host, entry, direction) => {
|
|
202
|
+
const channel = entry[direction];
|
|
203
|
+
if (channel.scheduled) return;
|
|
204
|
+
channel.scheduled = true;
|
|
205
|
+
fairReady.push({ host, entry, direction });
|
|
206
|
+
if (fairScheduled) return;
|
|
207
|
+
fairScheduled = true;
|
|
208
|
+
clock.setImmediate(runFairPump);
|
|
209
|
+
};
|
|
210
|
+
// One frame per ready pair per tick so a single tunnel cannot monopolize the event loop.
|
|
211
|
+
const runFairPump = () => {
|
|
212
|
+
fairScheduled = false;
|
|
213
|
+
const pending = fairReady.length;
|
|
214
|
+
for (let index = 0; index < pending; index += 1) {
|
|
215
|
+
const job = fairReady.shift();
|
|
216
|
+
if (!job) break;
|
|
217
|
+
job.entry[job.direction].scheduled = false;
|
|
218
|
+
sendOne(job.host, job.entry, job.direction);
|
|
219
|
+
}
|
|
220
|
+
};
|
|
221
|
+
const sendOne = (host, entry, direction) => {
|
|
222
|
+
const channel = entry[direction]; const target = direction === 'toData' ? entry.data : entry.client;
|
|
223
|
+
if (host.clients.get(entry.id) !== entry || channel.busy || !channel.queue.length) return;
|
|
224
|
+
if (!target || target.readyState !== target.OPEN || target.bufferedAmount > limits.maxBufferedAmount) {
|
|
225
|
+
if (!channel.retry) channel.retry = clock.setTimeout(() => { channel.retry = null; requestPump(host, entry, direction); }, limits.pumpRetryMs);
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
channel.busy = true; const item = channel.queue[0];
|
|
229
|
+
target.send(item.data, { binary: item.binary }, (error) => {
|
|
230
|
+
if (host.clients.get(entry.id) !== entry || entry[direction] !== channel) return;
|
|
231
|
+
channel.busy = false;
|
|
232
|
+
if (error) { removeClient(host, entry, CLOSE.limit, 'send failure'); return; }
|
|
233
|
+
channel.queue.shift(); channel.bytes = Math.max(0, channel.bytes - item.data.length); entry.queuedBytes = Math.max(0, entry.queuedBytes - item.data.length); counts.queuedBytes = Math.max(0, counts.queuedBytes - item.data.length);
|
|
234
|
+
applyBackpressure(entry, direction);
|
|
235
|
+
if (channel.queue.length) requestPump(host, entry, direction);
|
|
236
|
+
});
|
|
237
|
+
};
|
|
238
|
+
const enqueue = (host, entry, direction, data, binary) => {
|
|
239
|
+
if (hosts.get(host.serverId) !== host || host.clients.get(entry.id) !== entry) return;
|
|
240
|
+
const payload = Buffer.from(data); const channel = entry[direction];
|
|
241
|
+
if (payload.length > limits.maxFrameBytes || entry.queuedBytes + payload.length > limits.maxQueuedBytesPerConnection || counts.queuedBytes + payload.length > limits.maxGlobalQueuedBytes) { addReason('limited'); removeClient(host, entry, CLOSE.limit, 'queue limit'); return; }
|
|
242
|
+
channel.queue.push({ data: payload, binary }); channel.bytes += payload.length; entry.queuedBytes += payload.length; counts.queuedBytes += payload.length;
|
|
243
|
+
applyBackpressure(entry, direction);
|
|
244
|
+
requestPump(host, entry, direction);
|
|
245
|
+
};
|
|
246
|
+
const bindClient = (host, entry, socket) => {
|
|
247
|
+
entry.client = socket;
|
|
248
|
+
socket.on('message', (data, binary) => enqueue(host, entry, 'toData', data, binary));
|
|
249
|
+
socket.on('close', () => removeClient(host, entry, 1000, '', true)); socket.on('error', () => {});
|
|
250
|
+
};
|
|
251
|
+
const bindData = (host, entry, socket) => {
|
|
252
|
+
entry.data = socket; counts.pending -= 1; counts.pairs += 1; if (entry.pendingTimer) clock.clearTimeout(entry.pendingTimer);
|
|
253
|
+
socket.on('message', (data, binary) => enqueue(host, entry, 'toClient', data, binary));
|
|
254
|
+
socket.on('close', () => { if (host.clients.get(entry.id) === entry && entry.data === socket) removeClient(host, entry, CLOSE.away, 'host data closed'); }); socket.on('error', () => {});
|
|
255
|
+
requestPump(host, entry, 'toData');
|
|
256
|
+
};
|
|
257
|
+
const parse = (request) => {
|
|
258
|
+
if (!request.url || bytes(request.url) > limits.maxUrlBytes) return null;
|
|
259
|
+
let url; try { url = new URL(request.url, 'http://relay'); } catch { return null; }
|
|
260
|
+
if (url.pathname !== relayPath) return null;
|
|
261
|
+
const role = url.searchParams.get('role'); const allowed = fields[role];
|
|
262
|
+
if (!allowed || url.searchParams.get('v') !== '1') return null;
|
|
263
|
+
for (const [key, value] of url.searchParams) if (!allowed.has(key) || url.searchParams.getAll(key).length !== 1 || bytes(value) > limits.maxFieldBytes) return null;
|
|
264
|
+
for (const key of allowed) if (key !== 'grant' && !url.searchParams.has(key)) return null;
|
|
265
|
+
const serverId = url.searchParams.get('serverId');
|
|
266
|
+
if (!b64(serverId, SERVER_ID_BYTES)) return null;
|
|
267
|
+
const connectionId = url.searchParams.get('connectionId');
|
|
268
|
+
if (connectionId && !b64(connectionId, 16)) return null;
|
|
269
|
+
return { role, serverId, connectionId, params: url.searchParams };
|
|
270
|
+
};
|
|
271
|
+
const authenticate = (parsed) => {
|
|
272
|
+
if (parsed.role === 'client') return true;
|
|
273
|
+
const ts = parsed.params.get('ts'); const sig = parsed.params.get('sig'); const pk = parsed.params.get('pk');
|
|
274
|
+
if (!/^(0|[1-9][0-9]{0,15})$/.test(ts ?? '')) return false;
|
|
275
|
+
const timestamp = Number(ts); if (!Number.isSafeInteger(timestamp) || Math.abs(clock.now() - timestamp) > limits.timestampSkewMs) return false;
|
|
276
|
+
const signature = b64(sig, 64); const publicKey = decodeJwk(pk);
|
|
277
|
+
if (!signature || !publicKey) return false;
|
|
278
|
+
const id = crypto.createHash('sha256').update(canonical(publicKey.jwk)).digest('base64url'); if (id !== parsed.serverId) return false;
|
|
279
|
+
const key = `${parsed.serverId}.${parsed.role}.${parsed.connectionId ?? ''}.${ts}`;
|
|
280
|
+
purge(); if (replay.has(key) || replay.size >= limits.maxReplayEntries) { addReason('replayRejected'); return false; }
|
|
281
|
+
const message = `${ts}.${parsed.serverId}.${parsed.role}.${parsed.connectionId ?? ''}`;
|
|
282
|
+
if (!crypto.verify('SHA256', Buffer.from(message), { key: publicKey.key, dsaEncoding: 'ieee-p1363' }, signature)) return false;
|
|
283
|
+
replay.set(key, clock.now() + limits.replayMs); return true;
|
|
284
|
+
};
|
|
285
|
+
const idFor = (host) => {
|
|
286
|
+
for (let attempt = 0; attempt < limits.idAttempts; attempt += 1) { const id = Buffer.from(randomBytes(16)).toString('base64url'); if (id.length === 22 && !host.clients.has(id)) return id; }
|
|
287
|
+
return null;
|
|
288
|
+
};
|
|
289
|
+
const attach = (socket, request, parsed) => {
|
|
290
|
+
const ip = resolveClientIp(request); socket._relayRole = parsed.role; socket._relayAlive = true;
|
|
291
|
+
socket.on('pong', () => { socket._relayAlive = true; });
|
|
292
|
+
if (parsed.role === 'host-control') {
|
|
293
|
+
let host = hosts.get(parsed.serverId);
|
|
294
|
+
if (!host && counts.hosts >= limits.maxHosts) { addReason('limited'); return safeClose(socket, CLOSE.limit, 'host limit'); }
|
|
295
|
+
if (!host) { host = { serverId: parsed.serverId, clients: new Map(), control: null, epoch: 0, graceTimer: null }; hosts.set(parsed.serverId, host); counts.hosts += 1; }
|
|
296
|
+
if (host.control) { const previous = host.control; host.epoch += 1; for (const entry of [...host.clients.values()]) removeClient(host, entry, CLOSE.replaced, 'control replaced', false); host.epoch -= 1; detachControl(host, previous, { code: CLOSE.replaced, reason: 'control replaced', grace: false }); }
|
|
297
|
+
if (host.graceTimer) { clock.clearTimeout(host.graceTimer); host.graceTimer = null; }
|
|
298
|
+
host.epoch += 1; const epoch = host.epoch; host.control = { socket, epoch, queue: [], queuedBytes: 0, busy: false }; counts.controls += 1;
|
|
299
|
+
socket.on('close', () => detachControl(host, host.control?.socket === socket ? host.control : null, { code: CLOSE.away, reason: 'control closed' })); socket.on('error', () => {});
|
|
300
|
+
controlPump(host, { type: 'sync', connectionIds: [...host.clients.keys()] }); return;
|
|
301
|
+
}
|
|
302
|
+
if (parsed.role === 'client') {
|
|
303
|
+
const host = hosts.get(parsed.serverId);
|
|
304
|
+
if (!host) return safeClose(socket, CLOSE.unavailable, 'host unavailable');
|
|
305
|
+
if (counts.clients >= limits.maxConnections || host.clients.size >= limits.maxClientsPerHost || counts.pending >= limits.maxPendingClients || (clientIpCounts.get(ip) ?? 0) >= limits.maxClientsPerIp) { addReason('limited'); return safeClose(socket, CLOSE.limit, 'client limit'); }
|
|
306
|
+
const id = idFor(host); if (!id) { addReason('limited'); return safeClose(socket, CLOSE.limit, 'id collision'); }
|
|
307
|
+
const entry = { id, ip, client: null, data: null, queuedBytes: 0, toData: { queue: [], bytes: 0, busy: false, retry: null, scheduled: false }, toClient: { queue: [], bytes: 0, busy: false, retry: null, scheduled: false }, pendingTimer: null };
|
|
308
|
+
host.clients.set(id, entry); counts.clients += 1; counts.pending += 1; clientIpCounts.set(ip, (clientIpCounts.get(ip) ?? 0) + 1); bindClient(host, entry, socket);
|
|
309
|
+
entry.pendingTimer = clock.setTimeout(() => { if (host.clients.get(id) === entry && !entry.data) removeClient(host, entry, CLOSE.limit, 'pending timeout'); }, limits.pendingMs);
|
|
310
|
+
controlPump(host, { type: 'connected', connectionId: id }); return;
|
|
311
|
+
}
|
|
312
|
+
const host = hosts.get(parsed.serverId); const entry = host?.clients.get(parsed.connectionId);
|
|
313
|
+
if (!host || !entry || entry.data) return safeClose(socket, CLOSE.duplicate, 'data attach');
|
|
314
|
+
bindData(host, entry, socket);
|
|
315
|
+
};
|
|
316
|
+
const reject = (request, socket, head, code) => wss.handleUpgrade(request, socket, head, (ws) => safeClose(ws, code, 'rejected'));
|
|
317
|
+
const start = () => {
|
|
318
|
+
if (state === 'running') return Promise.resolve(); if (state === 'stopping') return stopPromise.then(() => start()); if (startPromise) return startPromise;
|
|
319
|
+
state = 'starting'; const localGeneration = ++generation; const localServer = http.createServer((request, response) => {
|
|
320
|
+
if (options.requestHandler?.(request, response) || response.writableEnded) return;
|
|
321
|
+
const pathname = new URL(request.url ?? '/', 'http://relay').pathname;
|
|
322
|
+
const ready = pathname === '/readyz' && state === 'running';
|
|
323
|
+
const healthy = pathname === '/healthz';
|
|
324
|
+
response.setHeader('cache-control', 'no-store');
|
|
325
|
+
if ((healthy || ready) && (request.method === 'GET' || request.method === 'HEAD')) {
|
|
326
|
+
response.writeHead(200, { 'content-type': 'application/json' });
|
|
327
|
+
response.end(request.method === 'HEAD' ? undefined : '{"status":"ok"}');
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
response.writeHead(404); response.end();
|
|
331
|
+
}); const localWss = new WebSocketServer({ noServer: true, perMessageDeflate: false, maxPayload: limits.maxFrameBytes }); server = localServer; wss = localWss;
|
|
332
|
+
localServer.on('connection', (socket) => {
|
|
333
|
+
const ip = socket.remoteAddress ?? 'unknown';
|
|
334
|
+
if (counts.rawSockets >= limits.maxRawSockets || (rawIpCounts.get(ip) ?? 0) >= limits.maxRawSocketsPerIp) { addReason('limited'); socket.destroy(); return; }
|
|
335
|
+
rawSockets.add(socket); counts.rawSockets += 1; rawIpCounts.set(ip, (rawIpCounts.get(ip) ?? 0) + 1); socket._relayRawIp = ip;
|
|
336
|
+
socket._relayTcpTimer = clock.setTimeout(() => socket.destroy(), limits.handshakeMs);
|
|
337
|
+
socket.on('close', () => { if (socket._relayTcpTimer) clock.clearTimeout(socket._relayTcpTimer); if (rawSockets.delete(socket)) { counts.rawSockets -= 1; decrement(rawIpCounts, ip); } });
|
|
338
|
+
});
|
|
339
|
+
localServer.on('upgrade', (request, socket, head) => {
|
|
340
|
+
if (socket._relayTcpTimer) { clock.clearTimeout(socket._relayTcpTimer); socket._relayTcpTimer = null; }
|
|
341
|
+
const parsed = parse(request); const ip = resolveClientIp(request);
|
|
342
|
+
if (!parsed) {
|
|
343
|
+
if (!admit(`malformed:${ip}`, limits.maxAdmissionsPerIp)) return reject(request, socket, head, CLOSE.limit);
|
|
344
|
+
addReason('policyRejected'); return reject(request, socket, head, CLOSE.malformed);
|
|
345
|
+
}
|
|
346
|
+
if (!admitUpgrade(ip, parsed)) return reject(request, socket, head, CLOSE.limit);
|
|
347
|
+
if (!authenticate(parsed)) { addReason('authRejected'); return reject(request, socket, head, CLOSE.auth); }
|
|
348
|
+
if (counts.sockets >= limits.maxSockets) { addReason('limited'); return reject(request, socket, head, CLOSE.limit); }
|
|
349
|
+
localWss.handleUpgrade(request, socket, head, (ws) => { accepted.add(ws); counts.sockets += 1; ws.on('close', () => removeSocket(ws)); ws._relayHandshake = clock.setTimeout(() => { if (!ws._relayRole) safeClose(ws, CLOSE.malformed, 'handshake timeout'); }, limits.handshakeMs); options.onSocketAccepted?.({ socket: ws, role: parsed.role }); attach(ws, request, parsed); if (ws._relayHandshake) { clock.clearTimeout(ws._relayHandshake); ws._relayHandshake = null; } });
|
|
350
|
+
});
|
|
351
|
+
heartbeat = clock.setInterval(() => { purge(); for (const socket of accepted) { if (socket.readyState !== socket.OPEN) continue; if (socket._relayPaused) socket._relayAlive = true; if (!socket._relayAlive) { addReason('heartbeatReaped'); safeClose(socket, socket._relayRole === 'host-control' ? CLOSE.stuck : CLOSE.away, 'heartbeat'); continue; } socket._relayAlive = false; try { socket.ping(); } catch { safeClose(socket, CLOSE.away, 'heartbeat'); } } }, limits.heartbeatMs);
|
|
352
|
+
startPromise = new Promise((resolve, rejectStart) => {
|
|
353
|
+
const fail = (error) => { if (localGeneration !== generation) return; localServer.off('listening', ready); cleanupStart(); rejectStart(error); };
|
|
354
|
+
const ready = () => { localServer.off('error', fail); if (localGeneration !== generation || state !== 'starting') return; state = 'running'; resolve(); };
|
|
355
|
+
const cleanupStart = () => { if (heartbeat) { clock.clearInterval(heartbeat); heartbeat = null; } localWss.close(); localServer.close(); if (server === localServer) { server = null; wss = null; } state = 'stopped'; };
|
|
356
|
+
abortStart = () => { if (state === 'starting') { cleanupStart(); rejectStart(new Error('relay stopped during start')); } };
|
|
357
|
+
localServer.once('error', fail); localServer.once('listening', ready); localServer.listen(options.port ?? 0, options.host ?? '127.0.0.1');
|
|
358
|
+
}).finally(() => { startPromise = null; abortStart = null; });
|
|
359
|
+
return startPromise;
|
|
360
|
+
};
|
|
361
|
+
const stop = () => {
|
|
362
|
+
if (stopPromise) return stopPromise;
|
|
363
|
+
if (state === 'idle' || state === 'stopped') { state = 'stopped'; return Promise.resolve(); }
|
|
364
|
+
if (state === 'starting') abortStart?.();
|
|
365
|
+
state = 'stopping'; generation += 1; const localServer = server; const localWss = wss;
|
|
366
|
+
stopPromise = new Promise((resolve) => {
|
|
367
|
+
if (heartbeat) { clock.clearInterval(heartbeat); heartbeat = null; }
|
|
368
|
+
for (const host of hosts.values()) { if (host.graceTimer) clock.clearTimeout(host.graceTimer); if (host.control) detachControl(host, host.control, { code: CLOSE.away, reason: 'server stopping', grace: false }); for (const entry of [...host.clients.values()]) removeClient(host, entry, CLOSE.away, 'server stopping', false); }
|
|
369
|
+
hosts.clear(); replay.clear(); admissions.clear(); clientIpCounts.clear(); counts.hosts = 0; counts.controls = 0; counts.clients = 0; counts.pairs = 0; counts.pending = 0; counts.queuedBytes = 0;
|
|
370
|
+
for (const socket of accepted) { if (socket._relayCloseDeadline) clock.clearTimeout(socket._relayCloseDeadline); socket.terminate(); } for (const socket of rawSockets) socket.destroy(); accepted.clear(); rawSockets.clear(); rawIpCounts.clear(); counts.sockets = 0; counts.rawSockets = 0;
|
|
371
|
+
if (!localServer) return resolve(); localWss?.close(); localServer.close(() => resolve()); clock.setTimeout(resolve, 100);
|
|
372
|
+
}).then(() => { if (server === localServer && wss === localWss) { server = null; wss = null; state = 'stopped'; } stopPromise = null; });
|
|
373
|
+
return stopPromise;
|
|
374
|
+
};
|
|
375
|
+
return { start, stop, address: () => server?.address(), get wsUrl() { const address = server?.address(); return address && typeof address === 'object' ? formatRelayWsUrl(options.host ?? '127.0.0.1', address.port, relayPath) : null; }, getSnapshot: snapshot };
|
|
376
|
+
};
|
|
377
|
+
|
|
378
|
+
export const startPrivateRelayServer = async (options) => { const relay = createPrivateRelayServer(options); await relay.start(); return relay; };
|