@johnhenry/wsh 0.28.0 → 0.29.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/README.md +8 -0
- package/package.json +1 -1
- package/src/server/connection.mjs +7 -1
- package/src/server/index.d.ts +21 -0
- package/src/server/index.mjs +27 -6
- package/src/server/relay.mjs +11 -2
package/README.md
CHANGED
|
@@ -186,6 +186,14 @@ restricted host with no shell. `fs.root` confines every path: `..`, absolute
|
|
|
186
186
|
paths and symlinks pointing out of it are refused -- for `write` and `rename`
|
|
187
187
|
(both paths) exactly as for `read`.
|
|
188
188
|
|
|
189
|
+
### TLS (`wss://`) and host extensions
|
|
190
|
+
|
|
191
|
+
`tls: { cert, key }` (PEM, the options of `https.createServer`) serves the WebSocket listener over TLS on the same
|
|
192
|
+
port. `extensions: { [type]: (msg, { username, fingerprint, send }) => void }` handles application-defined control
|
|
193
|
+
messages whose `type` is a string, from authenticated connections only. On a relay,
|
|
194
|
+
`relay: { onUnreachable(from, targetFingerprint, request) }` is told when an operator asked for a peer that is not
|
|
195
|
+
registered (the operator still gets `no such peer`), so the host can reach that peer another way, such as a Web Push.
|
|
196
|
+
|
|
189
197
|
### WebTransport listener
|
|
190
198
|
|
|
191
199
|
The client's ladder prefers WebTransport (`https://`) and falls back to `wss://`;
|
package/package.json
CHANGED
|
@@ -83,6 +83,7 @@ const toBytes = (d) => (typeof d === 'string' ? enc.encode(d) : d);
|
|
|
83
83
|
* @param {object | null} cfg.mcp - from createMcpHost
|
|
84
84
|
* @param {{ protocols: Map<string, Function>, maxMessageBytes: number, maxInflight: number } | null} cfg.rpc - from createRpcHost
|
|
85
85
|
* @param {{ signKey: CryptoKey | null, hybrid?: boolean } | null} cfg.e2e - answer KeyExchange and seal virtual-mode traffic (bridged connections only)
|
|
86
|
+
* @param {Record<string, Function> | undefined} cfg.extensions - handlers for application-defined (string-typed) messages
|
|
86
87
|
* @param {object | null} cfg.relay - a RelayHub (see relay.mjs); null = not a relay
|
|
87
88
|
* @param {object | null} cfg.sessions - a SessionRegistry (see sessions.mjs); null = sessions die with their connection
|
|
88
89
|
* @param {{ limiter: object, key: Function, failureDelayMs: number } | null} cfg.rateLimit - password throttle
|
|
@@ -246,7 +247,12 @@ export function createConnectionFactory(cfg) {
|
|
|
246
247
|
// Not awaited: the handler chain is serial, and a slow tool must not hold up
|
|
247
248
|
// the next call (or a Close) behind it.
|
|
248
249
|
case MSG.MCP_CALL: handleMcpCall(m); return;
|
|
249
|
-
default:
|
|
250
|
+
default:
|
|
251
|
+
if (typeof m.type === 'string' && Object.hasOwn(cfg.extensions ?? {}, m.type) && typeof cfg.extensions[m.type] === 'function') {
|
|
252
|
+
try { await cfg.extensions[m.type](m, { username: state.username, fingerprint: state.fingerprint, send }); } catch (e) { log(`extension "${m.type}" threw: ${e.message}`); }
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
log(`ignored message type ${typeof m.type === 'number' ? `0x${m.type.toString(16)}` : JSON.stringify(String(m.type)).slice(0, 80)}`);
|
|
250
256
|
}
|
|
251
257
|
}
|
|
252
258
|
|
package/src/server/index.d.ts
CHANGED
|
@@ -196,6 +196,17 @@ export interface WshServerRelayOptions {
|
|
|
196
196
|
from: { username: string; fingerprint: string },
|
|
197
197
|
to: WshRelayPeer,
|
|
198
198
|
) => boolean | Promise<boolean>;
|
|
199
|
+
/**
|
|
200
|
+
* Called (not awaited; a throw is logged) when an authenticated operator asks to connect to a peer that is not
|
|
201
|
+
* registered, with the operator, the fingerprint it asked for and the `ReverseConnect`'s claimed `username`. It
|
|
202
|
+
* never changes the answer (the operator is still told `no such peer`); it lets the host reach the peer another
|
|
203
|
+
* way, e.g. a Web Push notification.
|
|
204
|
+
*/
|
|
205
|
+
onUnreachable?: (
|
|
206
|
+
from: { username: string; fingerprint: string },
|
|
207
|
+
targetFingerprint: string,
|
|
208
|
+
request: { username?: string },
|
|
209
|
+
) => unknown;
|
|
199
210
|
/** Registered peers at once (default 1024). */
|
|
200
211
|
maxPeers?: number;
|
|
201
212
|
/**
|
|
@@ -323,6 +334,16 @@ export interface WshServerOptions {
|
|
|
323
334
|
* Default deny -- give both `canRegister` and `canConnect`. Off by default.
|
|
324
335
|
*/
|
|
325
336
|
relay?: WshServerRelayOptions;
|
|
337
|
+
/**
|
|
338
|
+
* Serve the WebSocket listener over TLS (`wss://`) on the same port; these are the options of `https.createServer`.
|
|
339
|
+
* The WebTransport listener has its own certificate (`webTransport`). Off (plain `ws://`) by default.
|
|
340
|
+
*/
|
|
341
|
+
tls?: { cert: string | Uint8Array; key: string | Uint8Array; ca?: string | Uint8Array; passphrase?: string };
|
|
342
|
+
/**
|
|
343
|
+
* Handlers for application-defined control messages: one whose `type` is a string (protocol messages are numeric, so
|
|
344
|
+
* there is no collision) from an authenticated connection is handed to `extensions[type]`. A throw is logged.
|
|
345
|
+
*/
|
|
346
|
+
extensions?: Record<string, (msg: Record<string, unknown>, ctx: { username: string; fingerprint: string | null; send: (msg: object) => unknown }) => unknown>;
|
|
326
347
|
/**
|
|
327
348
|
* Also listen for WebTransport (HTTP/3 over UDP) clients -- the same auth, exec, pty, fs, mcp, sessions and relay as the
|
|
328
349
|
* WebSocket listener, with real independent streams. Needs the optional peers `@fails-components/webtransport` and
|
package/src/server/index.mjs
CHANGED
|
@@ -60,14 +60,22 @@ export { generateSelfSignedCertificate } from './self-signed.mjs';
|
|
|
60
60
|
* identity, advertised (with proof of possession) so clients can pin it. See `src/host-key.mjs`.
|
|
61
61
|
* @param {object} [options.auth.rateLimit] - Password-failure throttle: `{ maxFailures=5, windowMs=60000,
|
|
62
62
|
* lockoutMs=60000, failureDelayMs=250, key(info) }` (`key` defaults to the peer address).
|
|
63
|
+
* @param {{ cert: string | Buffer, key: string | Buffer, ca?: string | Buffer, passphrase?: string }} [options.tls] - Serve the
|
|
64
|
+
* WebSocket listener over TLS (`wss://`) on the same port: the options of `https.createServer`. Off (plain `ws://`) unless given.
|
|
65
|
+
* The WebTransport listener has its own certificate (`webTransport.cert` / `selfSigned`).
|
|
66
|
+
* @param {Record<string, (msg: object, ctx: { username: string, fingerprint: string | null, send: Function }) => unknown>} [options.extensions]
|
|
67
|
+
* Handlers for application-defined messages: a control message whose `type` is a *string* (every protocol message
|
|
68
|
+
* has a numeric type, so these cannot collide) from an authenticated connection is passed to `extensions[type]`.
|
|
69
|
+
* A handler that throws is logged and ignored. Off unless given.
|
|
63
70
|
* @param {number} [options.bindTimeoutMs=3000]
|
|
64
71
|
* @param {(line: string) => void} [options.onLog]
|
|
65
72
|
* @returns {{ listen(): Promise<{address: string, port: number}>, close(): Promise<void>, address(): ({address: string, port: number} | null) }}
|
|
66
73
|
*/
|
|
67
74
|
export function createWshServer({
|
|
68
|
-
host = '127.0.0.1', port = 0, auth, exec, pty, fs, hostKey, mcp, rpc, rpcMaxMessageBytes, rpcMaxInflight, sessions, sessionSecret, relay, webTransport, bindTimeoutMs = 3000, onLog = () => {},
|
|
75
|
+
host = '127.0.0.1', port = 0, tls, auth, exec, pty, fs, hostKey, mcp, rpc, rpcMaxMessageBytes, rpcMaxInflight, sessions, sessionSecret, relay, extensions, webTransport, bindTimeoutMs = 3000, onLog = () => {},
|
|
69
76
|
} = {}) {
|
|
70
77
|
let wss = null;
|
|
78
|
+
let httpsServer = null;
|
|
71
79
|
let bound = null;
|
|
72
80
|
|
|
73
81
|
const { execRunner, execOptions, pty: ptyConfig, files, mcp: mcpHost } = buildBackends({ exec, pty, fs, mcp });
|
|
@@ -110,12 +118,19 @@ export function createWshServer({
|
|
|
110
118
|
});
|
|
111
119
|
}
|
|
112
120
|
if (relay) hub = new RelayHub({ ...relay, log: onLog });
|
|
121
|
+
if (extensions !== undefined && (extensions === null || typeof extensions !== 'object')) throw new TypeError('createWshServer: extensions must be an object of handlers');
|
|
113
122
|
const attach = createConnectionFactory({
|
|
114
123
|
authorize, execRunner, execOptions, pty: ptyConfig, files, bindTimeoutMs, log: onLog,
|
|
115
|
-
methods, hostKey: host_, rateLimit, mcp: mcpHost, rpc: rpcHost, sessions: registry, relay: hub,
|
|
124
|
+
methods, hostKey: host_, rateLimit, extensions, mcp: mcpHost, rpc: rpcHost, sessions: registry, relay: hub,
|
|
116
125
|
});
|
|
117
126
|
|
|
118
|
-
|
|
127
|
+
if (tls) {
|
|
128
|
+
const { createServer } = await import('node:https');
|
|
129
|
+
httpsServer = createServer(tls);
|
|
130
|
+
wss = new WebSocketServer({ server: httpsServer });
|
|
131
|
+
} else {
|
|
132
|
+
wss = new WebSocketServer({ host, port });
|
|
133
|
+
}
|
|
119
134
|
wss.on('connection', (ws, req) => {
|
|
120
135
|
const conn = attach({
|
|
121
136
|
send: (b) => { if (ws.readyState === 1) ws.send(b); },
|
|
@@ -130,10 +145,15 @@ export function createWshServer({
|
|
|
130
145
|
ws.on('error', () => {});
|
|
131
146
|
});
|
|
132
147
|
await new Promise((resolve, reject) => {
|
|
133
|
-
|
|
134
|
-
|
|
148
|
+
if (httpsServer) {
|
|
149
|
+
httpsServer.once('error', reject);
|
|
150
|
+
httpsServer.listen(port, host, () => { httpsServer.off('error', reject); resolve(); });
|
|
151
|
+
} else {
|
|
152
|
+
wss.once('listening', resolve);
|
|
153
|
+
wss.once('error', reject);
|
|
154
|
+
}
|
|
135
155
|
});
|
|
136
|
-
const a = wss.address();
|
|
156
|
+
const a = (httpsServer ?? wss).address();
|
|
137
157
|
bound = { address: a.address, port: a.port };
|
|
138
158
|
if (webTransport) {
|
|
139
159
|
try {
|
|
@@ -155,6 +175,7 @@ export function createWshServer({
|
|
|
155
175
|
await wt?.close();
|
|
156
176
|
wt = null;
|
|
157
177
|
await new Promise((resolve) => server.close(resolve));
|
|
178
|
+
if (httpsServer) { const h = httpsServer; httpsServer = null; await new Promise((resolve) => h.close(resolve)); }
|
|
158
179
|
registry?.closeAll();
|
|
159
180
|
registry = null;
|
|
160
181
|
hub?.closeAll();
|
package/src/server/relay.mjs
CHANGED
|
@@ -77,8 +77,8 @@ export class RelayHub {
|
|
|
77
77
|
/** fingerprint -> the highest record seq ever accepted (survives disconnects, bounded). */
|
|
78
78
|
#lastSeq = new Map();
|
|
79
79
|
|
|
80
|
-
constructor({ canRegister, canConnect, maxPeers = DEFAULT_MAX_PEERS, maxOperatorsPerPeer = 1, connectTimeoutMs = DEFAULT_CONNECT_TIMEOUT_MS, log = () => {} } = {}) {
|
|
81
|
-
for (const [name, fn] of [['canRegister', canRegister], ['canConnect', canConnect]]) {
|
|
80
|
+
constructor({ canRegister, canConnect, maxPeers = DEFAULT_MAX_PEERS, maxOperatorsPerPeer = 1, connectTimeoutMs = DEFAULT_CONNECT_TIMEOUT_MS, onUnreachable, log = () => {} } = {}) {
|
|
81
|
+
for (const [name, fn] of [['canRegister', canRegister], ['canConnect', canConnect], ['onUnreachable', onUnreachable]]) {
|
|
82
82
|
if (fn !== undefined && typeof fn !== 'function') throw new TypeError(`createWshServer: relay.${name} must be a function`);
|
|
83
83
|
}
|
|
84
84
|
if (!Number.isSafeInteger(maxOperatorsPerPeer) || maxOperatorsPerPeer < 1) throw new TypeError('createWshServer: relay.maxOperatorsPerPeer must be a positive integer');
|
|
@@ -87,6 +87,7 @@ export class RelayHub {
|
|
|
87
87
|
this.canConnect = canConnect ?? (() => false);
|
|
88
88
|
this.maxPeers = maxPeers;
|
|
89
89
|
this.connectTimeoutMs = connectTimeoutMs;
|
|
90
|
+
this.onUnreachable = onUnreachable;
|
|
90
91
|
this.log = log;
|
|
91
92
|
}
|
|
92
93
|
|
|
@@ -209,6 +210,14 @@ export class RelayHub {
|
|
|
209
210
|
async connect(operator, msg) {
|
|
210
211
|
const reject = (reason) => operator.send(reverseReject({ targetFingerprint: String(msg.target_fingerprint ?? ''), username: msg.username ?? '', reason }));
|
|
211
212
|
const target = this.#find(msg.target_fingerprint);
|
|
213
|
+
if (!target && this.onUnreachable) {
|
|
214
|
+
// Tell the host application nobody is registered under that name (it may know another way to reach them, e.g. a
|
|
215
|
+
// push notification). Fire and forget: it never changes the answer, and a throw is logged, not propagated.
|
|
216
|
+
try {
|
|
217
|
+
Promise.resolve(this.onUnreachable({ username: operator.username, fingerprint: operator.fingerprint }, String(msg.target_fingerprint ?? ''), { username: msg.username }))
|
|
218
|
+
.catch((e) => this.log(`relay onUnreachable threw: ${e.message}`));
|
|
219
|
+
} catch (e) { this.log(`relay onUnreachable threw: ${e.message}`); }
|
|
220
|
+
}
|
|
212
221
|
// Unknown, unreachable-by-policy and self look the same to the caller.
|
|
213
222
|
if (!target || target.handle === operator
|
|
214
223
|
|| !(await this.#allowed(this.canConnect, { username: operator.username, fingerprint: operator.fingerprint }, {
|