@johnhenry/wsh 0.21.0 → 0.23.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
@@ -155,7 +155,7 @@ hosts do). Pass `primer: false` to never send it.
155
155
  ## Node Server
156
156
 
157
157
  `@johnhenry/wsh/server` is a Node host for the same protocol, over WebSocket
158
- (QMux). It is a separate subpath -- the package root stays browser-safe and
158
+ (QMux) and, optionally, [WebTransport](#webtransport-listener). It is a separate subpath -- the package root stays browser-safe and
159
159
  never imports it -- and needs the optional peer dependency `ws`
160
160
  (`npm install ws`).
161
161
 
@@ -185,8 +185,128 @@ restricted host with no shell. `fs.root` confines every path: `..`, absolute
185
185
  paths and symlinks pointing out of it are refused -- for `write` and `rename`
186
186
  (both paths) exactly as for `read`.
187
187
 
188
- Not implemented (each has a tracking issue): WebTransport, relay/reverse
189
- mode.
188
+ ### WebTransport listener
189
+
190
+ The client's ladder prefers WebTransport (`https://`) and falls back to `wss://`;
191
+ `webTransport` gives the Node host the first rung, with real independent QUIC
192
+ streams (no head-of-line blocking across channels):
193
+
194
+ ```js
195
+ const server = createWshServer({
196
+ auth, exec: true, fs: { root: '/srv/share' },
197
+ webTransport: { port: 4433, selfSigned: true }, // or { cert, privKey } PEM
198
+ });
199
+ await server.listen(); // the WebSocket listener still runs
200
+ const { url, certificateHash } = server.webTransport(); // https://127.0.0.1:4433/wsh, sha-256 of the cert
201
+
202
+ // client (a browser, or Node with a WebTransport implementation)
203
+ await client.connect(url, {
204
+ username, keyPair, transport: 'wt',
205
+ webTransport: { serverCertificateHashes: [{ algorithm: 'sha-256', value: certificateHash }] },
206
+ });
207
+ ```
208
+
209
+ - **Optional peers**, imported only when `webTransport` is set:
210
+ `npm install @fails-components/webtransport @fails-components/webtransport-transport-http3-quiche`
211
+ (a native HTTP/3 build, prebuilt for Linux/macOS/Windows on x64 and arm).
212
+ Without them `listen()` rejects with that instruction, and a server with no `webTransport`
213
+ never loads them.
214
+ - **Same host, other wire.** Auth (keys, password, host key), `exec`, `pty`, `fs`, `mcp`,
215
+ `sessions` and `relay` behave identically; only the transport differs. The control stream is the
216
+ session's first bidirectional stream (length-prefixed CBOR frames); every bidirectional stream the client
217
+ opens afterwards is a data stream, bound to the next exec channel in `OpenOk` order. A client-created
218
+ stream is visible to the host as soon as it exists, so the host advertises `stream-announce` and the
219
+ client writes no primer.
220
+ - **`selfSigned: true`** generates (no dependency) an ECDSA P-256 certificate valid for 13 days -- a browser refuses a
221
+ pinned certificate valid for more than 14 -- and exposes the SHA-256 for `serverCertificateHashes`
222
+ (`{ selfSigned: { hosts, validityDays } }` to change the SANs or the lifetime). **It is not renewed:**
223
+ restart before `notAfter`, and re-pin. A certificate you pass as `cert`/`privKey` is used as is
224
+ (`certificateHash` is then `null`). `generateSelfSignedCertificate()` is exported for the same
225
+ certificate without a server. The web platform's other pinning rules apply (`https:` URL, HTTP/3 only; see
226
+ [Pinning a Self-Signed Certificate](#pinning-a-self-signed-certificate)).
227
+ - Node has no `WebTransport` global: a Node *client* needs `globalThis.WebTransport = (await import(
228
+ '@fails-components/webtransport')).WebTransport` first. UDP, so a firewall must allow `port`, and there
229
+ is no `wss://`-style TLS terminator in front: the certificate is the one this process serves.
230
+ - Tests (`test/server-webtransport.test.mjs`) run the stock client against it over a real HTTP/3
231
+ connection and are **skipped, with the reason, where the native binary cannot load**.
232
+
233
+ ### Relay / reverse mode
234
+
235
+ A host that cannot accept connections (behind NAT, or a browser tab) dials *out*
236
+ and registers; an operator reaches it through a relay. `@johnhenry/wsh/server`
237
+ is both ends:
238
+
239
+ ```js
240
+ // The relay -- anyone who can reach it and authenticate; nothing is permitted by default.
241
+ createWshServer({
242
+ auth: { authorizedKeys },
243
+ relay: {
244
+ canRegister: (who, record) => who.username === 'build-box', // who may be a peer
245
+ canConnect: (from, to) => from.username === 'alice', // who may list and reach which peer
246
+ },
247
+ });
248
+
249
+ // The peer, on the machine behind the NAT: same backends as createWshServer.
250
+ const host = createReverseHost({
251
+ url: 'wss://relay.example/', username: 'build-box', keyPair,
252
+ exec: true, fs: { root: '/srv/share' }, mcp: { tools },
253
+ accept: ({ fingerprint, username }) => fingerprint === aliceFingerprint, // default: nobody
254
+ });
255
+ await host.start(); // host.fingerprint is what operators connect to
256
+
257
+ // The operator: stock client.
258
+ const peers = await client.listPeers(); // each entry's signed record verified client-side
259
+ await client.reverseConnect(peers[0].fingerprint); // ReverseAccept trusts the peer for RelayForward
260
+ const s = await client.openSession({ type: 'exec', command: 'echo hello' });
261
+ ```
262
+
263
+ - **Default deny, twice.** The relay admits no peer (`canRegister`) and lets no
264
+ one connect (`canConnect`) unless you say so, and the peer refuses every
265
+ operator unless `accept` says otherwise -- the client's `trustRelayPeer()` is
266
+ the third gate. A peer an operator may not connect to is not listed and is
267
+ indistinguishable from an absent one (`ReverseReject: no such peer`).
268
+ Relay roles need a **key** login (`from_fingerprint` is a key fingerprint);
269
+ password logins can still use the relay server as an ordinary host.
270
+ - **Peer table.** Keyed by fingerprint. `ReverseRegister` is checked against the
271
+ connection's own authenticated key (`public_key` must hash to it) and its
272
+ self-signed record (`buildPeerRecordTranscript` / `signPeerRecord`) is verified
273
+ here, not trusted; `ReversePeers` forwards the signed fields so operators verify
274
+ them again themselves. The record `seq` must increase per fingerprint -- and is
275
+ remembered across reconnects (the last 4096 fingerprints), so an old record
276
+ cannot regress a peer. Failures answer an `Error` (the stock client surfaces it
277
+ through `onError`); there is no acknowledgement of success on the wire, so poll
278
+ `listPeers()` (or `server.peerFingerprints()`) if you need to know it landed.
279
+ - **Bridge.** `ReverseConnect` is forwarded to the peer with `from_fingerprint`
280
+ (and `username`) set from the operator's authenticated login, whatever the
281
+ client wrote; the peer's `ReverseAccept`/`Reject` goes back, attributed to the
282
+ peer that sent it, and an accept pairs the two connections. From then on every
283
+ spec-`forwardable` message from either end is wrapped as `RelayForward {
284
+ from_fingerprint, inner }` with `from_fingerprint` the sender's authenticated
285
+ key. A `RelayForward` a client writes itself is unwrapped, its inner type
286
+ checked against the allowlist (a non-forwardable or undecodable inner is
287
+ dropped, logged), and re-wrapped with the real sender; traffic from a connection
288
+ that is not bridged is never forwarded.
289
+ - **One operator per peer at a time**; a second `ReverseConnect` is rejected as
290
+ `busy`, and one the peer does not answer within `connectTimeoutMs` (default
291
+ 8 s) as `peer did not respond`. A bridge lasts as long as both connections:
292
+ when either ends, the relay closes the other, so nothing an operator started
293
+ outlives it (the reverse host kills its processes). `createReverseHost`
294
+ redials with backoff by default (`reconnect: false` to turn it off), so it is
295
+ registered again for the next operator.
296
+ - **On the peer**, the bridged operator is served by the same connection code as
297
+ a direct client, over an in-memory message pipe instead of a socket. Nothing a
298
+ client opens can cross the relay but control messages, so exec sessions are
299
+ `data_mode: 'virtual'` there (stdin and output ride `SessionData`; there is no
300
+ stdin EOF).
301
+ - **Not provided:** end-to-end encryption between operator and peer -- `KeyExchange` /
302
+ `EncryptedFrame` are not on the spec's `forwardable` list, so the relay sees the
303
+ traffic in clear (serve it over `wss://`, and treat the relay operator as trusted).
304
+ Client-side feature gates (`fileWrite`/`fileRename` need `file-write`/`file-rename`,
305
+ `callTool` correlates on `mcp-call-id`) read the **relay's** `ServerHello`, which
306
+ says nothing about the peer behind it: against a relay without `fs`, `fileWrite()`
307
+ refuses locally (reads, listing, stat and exec are unaffected). Multiplexing
308
+ several operators onto one peer needs a way to address replies, which `RelayForward`
309
+ does not have.
190
310
 
191
311
  ### Sessions: attach / resume / detach
192
312
 
@@ -595,7 +715,7 @@ than left to be found.
595
715
  | Host identity / TOFU | `WshKnownHosts` (localStorage-backed) | `KnownHosts`/`HostStatus` (`~/.wsh/known_hosts`-backed) | **Record unified, policy is not** (wsh #59): both pin `ServerHello.host_fingerprint`, which `@johnhenry/wsh/server` populates (with a proof of possession, [Host key](#host-key-fingerprint--tofu)) but no Rust `wsh-server` release does yet (see [Security](#security-model)). *When* to trust, prompt, or persist is deliberately left per-implementation -- a browser and a CLI have different UX for "first time seeing this host" |
596
716
  | Interactive shell UI | none -- this SDK is a protocol client, not a terminal emulator; pair with xterm.js/ghostty-web | `wsh connect`, `wsh sftp` (line-oriented REPL) | **Deliberately not unified** -- a browser embeds a terminal widget the host page owns; a CLI process owns its own TTY |
597
717
  | Attach / resume | `attachSession()`, `resumeSession()`, `WshSession.seq` | `attach_session()`, `resume_session()` | **Wire-unified** (`Attach`/`Resume`/`Presence`); `seq` = cumulative output bytes. `@johnhenry/wsh/server` honours `last_seq` (bounded ring, gap errors) and answers Presence with `channel_id`/`seq`; the Rust `wsh-server` replays its whole ring and leaves them unset |
598
- | Reverse-connect / relay peer | `connectReverse()`, `trustRelayPeer()` | `wsh reverse`, `wsh agent` (persistent, with startup-unit install) | **Wire-unified** (registration, discovery, signed peer records); **daemonization is CLI-only** -- a browser tab cannot be a background OS service |
718
+ | Reverse-connect / relay peer | `connectReverse()`, `trustRelayPeer()`; `@johnhenry/wsh/server`: `relay` option and `createReverseHost()` | `wsh reverse`, `wsh agent` (persistent, with startup-unit install) | **Wire-unified** (registration, discovery, signed peer records); **daemonization is CLI-only** -- a browser tab cannot be a background OS service. The Node relay is default-deny and bridges one operator per peer |
599
719
  | Post-quantum E2E (experimental) | `initiateE2E()` (WebCrypto ML-KEM-768 or `@noble/post-quantum` fallback) | `E2eKeyExchange` (`ml-kem` crate) | **Wire-unified** algorithm and transcript; key material backends differ by platform necessity |
600
720
  | Session recording/replay | `SessionRecorder`/`SessionPlayer` (asciicast v2) | none | **JS-only** -- no current Rust consumer needs playback; the format itself (asciicast v2) is not proprietary if one is added later |
601
721
  | Self-signed cert pinning | `serverCertificateHashes` (WebTransport option) | N/A -- Rust dials a cert its own TLS stack already trusts, or `--generate-cert`'s self-signed cert out of band | **Deliberately not unified** -- this is a browser-specific WebTransport API shape, not a wire-protocol concept |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@johnhenry/wsh",
3
- "version": "0.21.0",
3
+ "version": "0.23.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",
@@ -24,9 +24,17 @@
24
24
  "@noble/post-quantum": "^0.7.0"
25
25
  },
26
26
  "peerDependencies": {
27
+ "@fails-components/webtransport": "^1.6.4",
28
+ "@fails-components/webtransport-transport-http3-quiche": "^1.6.4",
27
29
  "ws": "^8.0.0"
28
30
  },
29
31
  "peerDependenciesMeta": {
32
+ "@fails-components/webtransport": {
33
+ "optional": true
34
+ },
35
+ "@fails-components/webtransport-transport-http3-quiche": {
36
+ "optional": true
37
+ },
30
38
  "ws": {
31
39
  "optional": true
32
40
  }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * What a host can do for the client on the other end: shared by
3
+ * `createWshServer` (clients that connect to it) and `createReverseHost`
4
+ * (operators bridged to it by a relay), so the two cannot drift.
5
+ */
6
+
7
+ import { spawnRunner } from './exec.mjs';
8
+ import { normalizePty } from './pty.mjs';
9
+ import { createFileAccess } from './fs.mjs';
10
+ import { createMcpHost } from './mcp.mjs';
11
+
12
+ /**
13
+ * @param {{ exec?: true | object | Function, pty?: object, fs?: object, mcp?: object }} options
14
+ * @returns {{ execRunner: Function | null, execOptions: object, pty: object | null, files: object | null, mcp: object | null }}
15
+ */
16
+ export function buildBackends({ exec, pty, fs, mcp } = {}) {
17
+ let execRunner = null;
18
+ let execOptions = {};
19
+ if (typeof exec === 'function') {
20
+ execRunner = exec;
21
+ } else if (exec) {
22
+ execOptions = exec === true ? {} : exec;
23
+ execRunner = typeof execOptions.run === 'function' ? execOptions.run : spawnRunner(execOptions);
24
+ }
25
+ return {
26
+ execRunner,
27
+ execOptions,
28
+ pty: pty ? normalizePty(pty) : null,
29
+ files: fs ? createFileAccess(fs) : null,
30
+ mcp: mcp ? createMcpHost(mcp) : null,
31
+ };
32
+ }
@@ -19,7 +19,7 @@ import { FrameDecoder, frameEncode } from '../cbor.mjs';
19
19
  import {
20
20
  MSG, serverHello, challenge, authOk, authFail, openOk, openFail, sessionData,
21
21
  exit as exitMsg, close as closeMsg, pong, fileResult, fileChunk, mcpTools, mcpResult,
22
- presence as presenceMsg, error as errorMsg, sessionList, detachOk, detachFail,
22
+ presence as presenceMsg, error as errorMsg, sessionList, detachOk, detachFail, reversePeers, reverseReject,
23
23
  } from '../messages.gen.mjs';
24
24
  import {
25
25
  generateNonce, verifyChallenge, fingerprint, importPublicKeyRaw,
@@ -52,6 +52,7 @@ const toBytes = (d) => (typeof d === 'string' ? enc.encode(d) : d);
52
52
  * @param {{ pubkey: boolean, password: Function | null }} cfg.methods - which auth methods are on
53
53
  * @param {object | null} cfg.hostKey - from loadHostKey
54
54
  * @param {object | null} cfg.mcp - from createMcpHost
55
+ * @param {object | null} cfg.relay - a RelayHub (see relay.mjs); null = not a relay
55
56
  * @param {object | null} cfg.sessions - a SessionRegistry (see sessions.mjs); null = sessions die with their connection
56
57
  * @param {{ limiter: object, key: Function, failureDelayMs: number } | null} cfg.rateLimit - password throttle
57
58
  * @param {number} cfg.bindTimeoutMs - how long exec output waits for the data stream
@@ -60,12 +61,20 @@ const toBytes = (d) => (typeof d === 'string' ? enc.encode(d) : d);
60
61
  export function createConnectionFactory(cfg) {
61
62
  let counter = 0;
62
63
 
63
- return function attach({ send: sendBytes, remote = {} }) {
64
+ /**
65
+ * Attach one connection. Two shapes:
66
+ * - bytes (the normal one): `{ send(bytes), remote, closeTransport() }` -> `{ receive(bytes), close() }`, speaking QMux.
67
+ * - messages: `{ sendMessage(msg), authenticated, dataStreams }` -> `{ receiveMessage(msg), bindStream(s), close() }`,
68
+ * for a transport that already frames messages itself (a relay bridge, WebTransport). `authenticated`
69
+ * (`{ username, fingerprint }`) skips the handshake for a peer something else has already authenticated;
70
+ * `dataStreams: false` means there are no client-opened streams to bind, so exec output rides SessionData.
71
+ */
72
+ return function attach({ send: sendBytes, sendMessage = null, remote = {}, authenticated = null, dataStreams = true, closeTransport = null }) {
64
73
  const cid = ++counter;
65
74
  const log = (m) => cfg.log(`[conn ${cid}] ${m}`);
66
75
  const state = {
67
- sessionId: randomUUID(), nonce: null, username: null, authed: false,
68
- closed: false, nextChannel: 0, fingerprint: null,
76
+ sessionId: randomUUID(), nonce: null, username: authenticated?.username ?? null, authed: !!authenticated,
77
+ closed: false, nextChannel: 0, fingerprint: authenticated?.fingerprint ?? null,
69
78
  };
70
79
  /** @type {Map<number, any>} */
71
80
  const channels = new Map();
@@ -79,11 +88,16 @@ export function createConnectionFactory(cfg) {
79
88
  const pendingSends = [];
80
89
  let control = null;
81
90
  let chain = Promise.resolve();
91
+ /** This connection as the relay hub sees it (created on first need). */
92
+ let relayHandle = null;
82
93
 
83
- const qmux = new QMuxConnection({ isClient: false, send: (b) => { if (!state.closed) sendBytes(b); } });
94
+ const qmux = sendMessage ? null : new QMuxConnection({ isClient: false, send: (b) => { if (!state.closed) sendBytes(b); } });
84
95
 
85
96
  const send = (msg) => {
86
97
  if (state.closed) return Promise.resolve();
98
+ if (sendMessage) {
99
+ try { return Promise.resolve(sendMessage(msg)).catch(() => {}); } catch { return Promise.resolve(); }
100
+ }
87
101
  const bytes = frameEncode(msg);
88
102
  if (control) return control.write(bytes).catch(() => {});
89
103
  pendingSends.push(bytes);
@@ -101,7 +115,10 @@ export function createConnectionFactory(cfg) {
101
115
  mcpCalls.clear();
102
116
  for (const w of fileWrites.values()) clearTimeout(w.timer);
103
117
  fileWrites.clear();
104
- try { qmux.destroy(new Error(why)); } catch { /* already gone */ }
118
+ try { qmux?.destroy(new Error(why)); } catch { /* already gone */ }
119
+ // The host ending the connection (a relay bridge ending, a refused login) must end the socket too.
120
+ try { closeTransport?.(); } catch { /* already gone */ }
121
+ relayHandle && cfg.relay?.drop(relayHandle);
105
122
  log(`closed (${why})`);
106
123
  }
107
124
 
@@ -110,6 +127,7 @@ export function createConnectionFactory(cfg) {
110
127
  // first, then the connection is torn down.
111
128
  const shutdownAfterQueue = (why) => { chain = chain.then(() => shutdown(why)); };
112
129
 
130
+ if (qmux) {
113
131
  qmux.onError = (e) => log(`qmux error: ${e.message}`);
114
132
  qmux.onClose = () => shutdownAfterQueue('peer sent CONNECTION_CLOSE');
115
133
  qmux.onStreamOpen = (s) => {
@@ -125,6 +143,7 @@ export function createConnectionFactory(cfg) {
125
143
  for (const b of pendingSends.splice(0)) s.write(b).catch(() => {});
126
144
  };
127
145
  qmux.sendHandshake();
146
+ }
128
147
 
129
148
  function bindDataStream(s) {
130
149
  const channelId = pendingStreams.shift();
@@ -138,6 +157,7 @@ export function createConnectionFactory(cfg) {
138
157
  async function handle(m) {
139
158
  if (state.closed) return;
140
159
  if (!state.authed) return handleAuth(m);
160
+ if (await handleRelay(m)) return;
141
161
  switch (m.type) {
142
162
  case MSG.PING: return send(pong({ id: m.id }));
143
163
  case MSG.OPEN: return handleOpen(m);
@@ -255,6 +275,56 @@ export function createConnectionFactory(cfg) {
255
275
  log(`authenticated ${state.username} (password)`);
256
276
  }
257
277
 
278
+ // ── Relay (createWshServer({ relay })) ────────────────────────────
279
+
280
+ const RELAY_TYPES = new Set([MSG.REVERSE_REGISTER, MSG.REVERSE_LIST, MSG.REVERSE_CONNECT, MSG.REVERSE_ACCEPT, MSG.REVERSE_REJECT, MSG.RELAY_FORWARD]);
281
+
282
+ function hubHandle() {
283
+ relayHandle ??= {
284
+ send, fingerprint: state.fingerprint, username: state.username,
285
+ close: () => shutdownAfterQueue('relay bridge ended'),
286
+ };
287
+ return relayHandle;
288
+ }
289
+
290
+ /** @returns {Promise<boolean>} true when the message was the relay's and has been dealt with */
291
+ async function handleRelay(m) {
292
+ const hub = cfg.relay;
293
+ if (!hub) {
294
+ // Not a relay: answer instead of leaving the client to time out.
295
+ if (m.type === MSG.REVERSE_LIST) { await send(reversePeers({ peers: [] })); return true; }
296
+ if (m.type === MSG.REVERSE_CONNECT) { await send(reverseReject({ targetFingerprint: String(m.target_fingerprint ?? ''), username: '', reason: 'relay is not enabled on this server' })); return true; }
297
+ if (m.type === MSG.REVERSE_REGISTER) { await send(errorMsg({ code: 3, message: 'relay is not enabled on this server' })); return true; }
298
+ return false;
299
+ }
300
+ if (!RELAY_TYPES.has(m.type)) {
301
+ // Anything else a bridged connection sends that may cross a bridge, does.
302
+ if (!relayHandle) return false;
303
+ hub.touch(relayHandle);
304
+ return hub.forward(relayHandle, m);
305
+ }
306
+ // Relay roles are keyed by an authenticated key: from_fingerprint means nothing for a password login.
307
+ if (!state.fingerprint) {
308
+ await send(errorMsg({ code: 2, message: 'relay operations need a key login' }));
309
+ return true;
310
+ }
311
+ const h = hubHandle();
312
+ switch (m.type) {
313
+ case MSG.REVERSE_REGISTER: {
314
+ const reason = await hub.register(h, m, { username: state.username, fingerprint: state.fingerprint });
315
+ if (reason) {
316
+ log(`ReverseRegister refused: ${reason}`);
317
+ await send(errorMsg({ code: 2, message: `registration refused: ${reason}` }));
318
+ }
319
+ return true;
320
+ }
321
+ case MSG.REVERSE_LIST: await send(await hub.list({ username: state.username, fingerprint: state.fingerprint })); return true;
322
+ case MSG.REVERSE_CONNECT: await hub.connect(h, m); return true;
323
+ case MSG.REVERSE_ACCEPT: case MSG.REVERSE_REJECT: hub.answer(h, m); return true;
324
+ default: hub.forward(h, m); return true; // a RelayForward the client wrote itself
325
+ }
326
+ }
327
+
258
328
  // ── MCP ───────────────────────────────────────────────────────────
259
329
 
260
330
  const principal = () => ({ username: state.username, fingerprint: state.fingerprint });
@@ -486,13 +556,19 @@ export function createConnectionFactory(cfg) {
486
556
  },
487
557
  };
488
558
  channels.set(channelId, ch);
489
- pendingStreams.push(channelId);
559
+ if (dataStreams) pendingStreams.push(channelId);
560
+ // No client-opened streams on this connection (a relay bridge): stdin arrives as SessionData.
561
+ else ch.input = (d) => { for (const cb of inputCbs) cb(d); };
490
562
 
491
563
  let timer = null;
492
564
  if (cfg.execOptions.timeoutMs > 0) timer = setTimeout(() => abort.abort(), cfg.execOptions.timeoutMs);
493
565
 
494
566
  // Output to the opening connection's data stream (held until the client has opened it).
495
567
  const toStream = (bytes) => {
568
+ if (!dataStreams) {
569
+ wq = wq.then(() => send(sessionData({ channelId, data: bytes })));
570
+ return wq;
571
+ }
496
572
  if (!stream) { buffered.push(bytes); return Promise.resolve(); }
497
573
  const s = stream;
498
574
  wq = wq.then(() => s.write(bytes).catch(() => {}));
@@ -502,8 +578,8 @@ export function createConnectionFactory(cfg) {
502
578
  // announce support waits for the first byte): give it a bounded chance rather than
503
579
  // dropping the output or hanging forever.
504
580
  const closeStream = async (code) => {
505
- if (!stream) await Promise.race([new Promise((r) => boundWaiters.push(r)), new Promise((r) => setTimeout(r, cfg.bindTimeoutMs))]);
506
- if (!stream) log(`exec channel ${channelId}: data stream never appeared; output dropped`);
581
+ if (dataStreams && !stream) await Promise.race([new Promise((r) => boundWaiters.push(r)), new Promise((r) => setTimeout(r, cfg.bindTimeoutMs))]);
582
+ if (dataStreams && !stream) log(`exec channel ${channelId}: data stream never appeared; output dropped`);
507
583
  await wq;
508
584
  try { await stream?.close(); } catch { /* peer gone */ }
509
585
  await finishChannel(channelId, Number.isInteger(code) ? code : 0);
@@ -552,7 +628,7 @@ export function createConnectionFactory(cfg) {
552
628
  };
553
629
 
554
630
  await send(openOk({
555
- channelId, dataMode: 'stream', capabilities: ['signal'],
631
+ channelId, dataMode: dataStreams ? 'stream' : 'virtual', capabilities: ['signal'],
556
632
  sessionId: hosted?.id ?? randomUUID(), token: hosted?.token ?? randomBytes(16),
557
633
  }));
558
634
  log(`exec channel ${channelId}: ${command}`);
@@ -740,7 +816,9 @@ export function createConnectionFactory(cfg) {
740
816
  }
741
817
 
742
818
  return {
743
- receive(bytes) { if (!state.closed) qmux.receiveBytes(bytes); },
819
+ receive(bytes) { if (!state.closed) qmux?.receiveBytes(bytes); },
820
+ receiveMessage(msg) { if (!state.closed) chain = chain.then(() => handle(msg)).catch((e) => log(`handler error: ${e.message}`)); },
821
+ bindStream: bindDataStream,
744
822
  close() { shutdownAfterQueue('transport closed'); },
745
823
  };
746
824
  };
@@ -169,6 +169,86 @@ export interface WshServerSessionsOptions {
169
169
  sessionSecret?: string | Uint8Array;
170
170
  }
171
171
 
172
+ /** A peer as the relay's policy callbacks see it. */
173
+ export interface WshRelayPeer {
174
+ /** Hex SHA-256 of the peer's key. */
175
+ fingerprint: string;
176
+ username: string;
177
+ capabilities: string[];
178
+ }
179
+
180
+ export interface WshServerRelayOptions {
181
+ /**
182
+ * May this authenticated connection register as a peer? `who` is the connection; `record` what it asks to
183
+ * advertise. Default: no (a relay with no policy admits nobody).
184
+ */
185
+ canRegister?: (
186
+ who: { username: string; fingerprint: string },
187
+ record: { username: string; capabilities: string[]; peerType: string; shellBackend: string },
188
+ ) => boolean | Promise<boolean>;
189
+ /**
190
+ * May `from` list and connect to `to`? A peer `from` may not connect to is neither listed nor distinguishable
191
+ * from an absent one. Default: no.
192
+ */
193
+ canConnect?: (
194
+ from: { username: string; fingerprint: string },
195
+ to: WshRelayPeer,
196
+ ) => boolean | Promise<boolean>;
197
+ /** Registered peers at once (default 1024). */
198
+ maxPeers?: number;
199
+ /** How long a `ReverseConnect` waits for its peer to answer before the operator is rejected (default 8000). */
200
+ connectTimeoutMs?: number;
201
+ }
202
+
203
+ export interface WshServerWebTransportOptions {
204
+ /** UDP port for HTTP/3 (default 0 = pick a free one; read it back from `webTransport()`). */
205
+ port?: number;
206
+ /** Bind address (default: the server's `host`). */
207
+ host?: string;
208
+ /** URL path sessions are accepted on (default `/wsh`). */
209
+ path?: string;
210
+ /** PEM certificate chain. With `privKey`. Not together with `selfSigned`. */
211
+ cert?: string;
212
+ /** PEM private key for `cert`. */
213
+ privKey?: string;
214
+ /**
215
+ * Generate a short-lived ECDSA P-256 certificate (13 days; browsers refuse a pinned one valid for more than 14) and
216
+ * expose its SHA-256 as `webTransport().certificateHash`, for the client's `serverCertificateHashes`. It is not
217
+ * renewed: restart the server before `notAfter`. An object takes `{ hosts, validityDays }`.
218
+ */
219
+ selfSigned?: boolean | { hosts?: string[]; validityDays?: number };
220
+ /** QUIC stateless-reset secret (default: random). */
221
+ secret?: string;
222
+ }
223
+
224
+ export interface WshServerWebTransport {
225
+ port: number;
226
+ host: string;
227
+ path: string;
228
+ /** `https://host:port/path`, what a client connects to. */
229
+ url: string;
230
+ /** SHA-256 of the DER certificate, when `selfSigned` made it; `null` for a certificate you supplied. */
231
+ certificateHash: Uint8Array | null;
232
+ certificateHashHex: string | null;
233
+ /** When the `selfSigned` certificate expires; `null` otherwise. */
234
+ notAfter: Date | null;
235
+ }
236
+
237
+ export interface WshSelfSignedCertificate {
238
+ /** PEM certificate. */
239
+ cert: string;
240
+ /** PEM (PKCS#8) private key. */
241
+ privKey: string;
242
+ /** SHA-256 of the DER certificate: the value to pin. */
243
+ hash: Uint8Array;
244
+ hashHex: string;
245
+ notBefore: Date;
246
+ notAfter: Date;
247
+ }
248
+
249
+ /** A dependency-free self-signed ECDSA P-256 certificate fit for WebTransport's `serverCertificateHashes`. */
250
+ export function generateSelfSignedCertificate(opts?: { hosts?: string[]; validityDays?: number; now?: Date }): WshSelfSignedCertificate;
251
+
172
252
  export interface WshServerOptions {
173
253
  /** Bind address (default `127.0.0.1`). */
174
254
  host?: string;
@@ -196,6 +276,18 @@ export interface WshServerOptions {
196
276
  sessions?: true | WshServerSessionsOptions;
197
277
  /** Alias for `sessions.sessionSecret`. */
198
278
  sessionSecret?: string | Uint8Array;
279
+ /**
280
+ * Act as a relay: peers (`createReverseHost`, or any client's `connectReverse()`) register a signed record,
281
+ * operators list and `reverseConnect()` to them, and traffic is carried between the two as `RelayForward`.
282
+ * Default deny -- give both `canRegister` and `canConnect`. Off by default.
283
+ */
284
+ relay?: WshServerRelayOptions;
285
+ /**
286
+ * Also listen for WebTransport (HTTP/3 over UDP) clients -- the same auth, exec, pty, fs, mcp, sessions and relay as the
287
+ * WebSocket listener, with real independent streams. Needs the optional peers `@fails-components/webtransport` and
288
+ * `@fails-components/webtransport-transport-http3-quiche`, imported only when this is set. Off by default.
289
+ */
290
+ webTransport?: WshServerWebTransportOptions;
199
291
  /** Serve MCP tools (`McpDiscover` / `McpCall`) and advertise `mcp-call-id`. Off by default. */
200
292
  mcp?: WshServerMcpOptions;
201
293
  /** How long exec output waits for a client that has not yet opened its data stream (default 3000). */
@@ -215,6 +307,10 @@ export interface WshServer {
215
307
  close(): Promise<void>;
216
308
  /** The bound address, or `null` when not listening. */
217
309
  address(): WshServerAddress | null;
310
+ /** The WebTransport listener; `null` when not configured or before `listen()` resolves. */
311
+ webTransport(): WshServerWebTransport | null;
312
+ /** Fingerprints of the peers currently registered with this relay (`[]` when it is not one). */
313
+ peerFingerprints(): string[];
218
314
  /** The advertised host identity; `null` before `listen()` resolves or without a `hostKey`. */
219
315
  hostKey(): WshServerHostKey | null;
220
316
  }
@@ -226,3 +322,40 @@ export function parseAuthorizedKeys(text: string): Uint8Array[];
226
322
 
227
323
  /** ServerHello feature: the host discovers client-opened exec data streams itself (no primer byte). */
228
324
  export const STREAM_ANNOUNCE: 'stream-announce';
325
+
326
+ export interface WshReverseHostOptions {
327
+ /** The relay to register with (`ws://` / `wss://`). */
328
+ url: string;
329
+ username: string;
330
+ /** This host's identity; its fingerprint is what operators connect to. */
331
+ keyPair: CryptoKeyPair;
332
+ /** Who may be bridged to this host. Default: nobody. */
333
+ accept?: (operator: { fingerprint: string; username: string }) => boolean | Promise<boolean>;
334
+ exec?: true | WshServerExecOptions | WshExecRunner;
335
+ pty?: WshServerPtyOptions;
336
+ fs?: WshServerFsOptions;
337
+ mcp?: WshServerMcpOptions;
338
+ /** Dial again, with backoff, after the relay connection ends -- which a relay does when a bridge does (default true). */
339
+ reconnect?: boolean;
340
+ /** Extra options for the relay connection (`expectHostKey`, `knownHosts`, `trustOnFirstUse`, ...). */
341
+ connect?: Record<string, unknown>;
342
+ peerType?: string;
343
+ onLog?: (line: string) => void;
344
+ }
345
+
346
+ export interface WshReverseHost {
347
+ /** Dial the relay and register (rejects if the first attempt fails). Registration is not acknowledged on the wire. */
348
+ start(): Promise<{ fingerprint: string }>;
349
+ /** Leave the relay and end any bridged session. */
350
+ close(): Promise<void>;
351
+ /** What operators connect to; `null` before `start()`. */
352
+ readonly fingerprint: string | null;
353
+ /** Is the relay connection up right now? */
354
+ readonly connected: boolean;
355
+ }
356
+
357
+ /**
358
+ * A host that dials OUT to a relay and serves the operator it bridges to it, with the same exec / pty / fs / mcp
359
+ * backends `createWshServer` serves direct clients with. For a machine that cannot accept connections.
360
+ */
361
+ export function createReverseHost(options: WshReverseHostOptions): WshReverseHost;