@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@johnhenry/wsh",
3
- "version": "0.28.0",
3
+ "version": "0.29.0",
4
4
  "description": "Web Shell \u2014 browser-native remote command execution over WebTransport and WebSocket with Ed25519 authentication",
5
5
  "type": "module",
6
6
  "main": "./src/index.mjs",
@@ -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: log(`ignored message type 0x${m.type.toString(16)}`);
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
 
@@ -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
@@ -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
- wss = new WebSocketServer({ host, port });
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
- wss.once('listening', resolve);
134
- wss.once('error', reject);
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();
@@ -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 }, {