@johnhenry/wsh 0.21.0 → 0.22.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
@@ -185,8 +185,85 @@ 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
+ Not implemented (tracking issue): WebTransport.
189
+
190
+ ### Relay / reverse mode
191
+
192
+ A host that cannot accept connections (behind NAT, or a browser tab) dials *out*
193
+ and registers; an operator reaches it through a relay. `@johnhenry/wsh/server`
194
+ is both ends:
195
+
196
+ ```js
197
+ // The relay -- anyone who can reach it and authenticate; nothing is permitted by default.
198
+ createWshServer({
199
+ auth: { authorizedKeys },
200
+ relay: {
201
+ canRegister: (who, record) => who.username === 'build-box', // who may be a peer
202
+ canConnect: (from, to) => from.username === 'alice', // who may list and reach which peer
203
+ },
204
+ });
205
+
206
+ // The peer, on the machine behind the NAT: same backends as createWshServer.
207
+ const host = createReverseHost({
208
+ url: 'wss://relay.example/', username: 'build-box', keyPair,
209
+ exec: true, fs: { root: '/srv/share' }, mcp: { tools },
210
+ accept: ({ fingerprint, username }) => fingerprint === aliceFingerprint, // default: nobody
211
+ });
212
+ await host.start(); // host.fingerprint is what operators connect to
213
+
214
+ // The operator: stock client.
215
+ const peers = await client.listPeers(); // each entry's signed record verified client-side
216
+ await client.reverseConnect(peers[0].fingerprint); // ReverseAccept trusts the peer for RelayForward
217
+ const s = await client.openSession({ type: 'exec', command: 'echo hello' });
218
+ ```
219
+
220
+ - **Default deny, twice.** The relay admits no peer (`canRegister`) and lets no
221
+ one connect (`canConnect`) unless you say so, and the peer refuses every
222
+ operator unless `accept` says otherwise -- the client's `trustRelayPeer()` is
223
+ the third gate. A peer an operator may not connect to is not listed and is
224
+ indistinguishable from an absent one (`ReverseReject: no such peer`).
225
+ Relay roles need a **key** login (`from_fingerprint` is a key fingerprint);
226
+ password logins can still use the relay server as an ordinary host.
227
+ - **Peer table.** Keyed by fingerprint. `ReverseRegister` is checked against the
228
+ connection's own authenticated key (`public_key` must hash to it) and its
229
+ self-signed record (`buildPeerRecordTranscript` / `signPeerRecord`) is verified
230
+ here, not trusted; `ReversePeers` forwards the signed fields so operators verify
231
+ them again themselves. The record `seq` must increase per fingerprint -- and is
232
+ remembered across reconnects (the last 4096 fingerprints), so an old record
233
+ cannot regress a peer. Failures answer an `Error` (the stock client surfaces it
234
+ through `onError`); there is no acknowledgement of success on the wire, so poll
235
+ `listPeers()` (or `server.peerFingerprints()`) if you need to know it landed.
236
+ - **Bridge.** `ReverseConnect` is forwarded to the peer with `from_fingerprint`
237
+ (and `username`) set from the operator's authenticated login, whatever the
238
+ client wrote; the peer's `ReverseAccept`/`Reject` goes back, attributed to the
239
+ peer that sent it, and an accept pairs the two connections. From then on every
240
+ spec-`forwardable` message from either end is wrapped as `RelayForward {
241
+ from_fingerprint, inner }` with `from_fingerprint` the sender's authenticated
242
+ key. A `RelayForward` a client writes itself is unwrapped, its inner type
243
+ checked against the allowlist (a non-forwardable or undecodable inner is
244
+ dropped, logged), and re-wrapped with the real sender; traffic from a connection
245
+ that is not bridged is never forwarded.
246
+ - **One operator per peer at a time**; a second `ReverseConnect` is rejected as
247
+ `busy`, and one the peer does not answer within `connectTimeoutMs` (default
248
+ 8 s) as `peer did not respond`. A bridge lasts as long as both connections:
249
+ when either ends, the relay closes the other, so nothing an operator started
250
+ outlives it (the reverse host kills its processes). `createReverseHost`
251
+ redials with backoff by default (`reconnect: false` to turn it off), so it is
252
+ registered again for the next operator.
253
+ - **On the peer**, the bridged operator is served by the same connection code as
254
+ a direct client, over an in-memory message pipe instead of a socket. Nothing a
255
+ client opens can cross the relay but control messages, so exec sessions are
256
+ `data_mode: 'virtual'` there (stdin and output ride `SessionData`; there is no
257
+ stdin EOF).
258
+ - **Not provided:** end-to-end encryption between operator and peer -- `KeyExchange` /
259
+ `EncryptedFrame` are not on the spec's `forwardable` list, so the relay sees the
260
+ traffic in clear (serve it over `wss://`, and treat the relay operator as trusted).
261
+ Client-side feature gates (`fileWrite`/`fileRename` need `file-write`/`file-rename`,
262
+ `callTool` correlates on `mcp-call-id`) read the **relay's** `ServerHello`, which
263
+ says nothing about the peer behind it: against a relay without `fs`, `fileWrite()`
264
+ refuses locally (reads, listing, stat and exec are unaffected). Multiplexing
265
+ several operators onto one peer needs a way to address replies, which `RelayForward`
266
+ does not have.
190
267
 
191
268
  ### Sessions: attach / resume / detach
192
269
 
@@ -595,7 +672,7 @@ than left to be found.
595
672
  | 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
673
  | 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
674
  | 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 |
675
+ | 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
676
  | 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
677
  | 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
678
  | 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.22.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",
@@ -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,37 @@ 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
+
172
203
  export interface WshServerOptions {
173
204
  /** Bind address (default `127.0.0.1`). */
174
205
  host?: string;
@@ -196,6 +227,12 @@ export interface WshServerOptions {
196
227
  sessions?: true | WshServerSessionsOptions;
197
228
  /** Alias for `sessions.sessionSecret`. */
198
229
  sessionSecret?: string | Uint8Array;
230
+ /**
231
+ * Act as a relay: peers (`createReverseHost`, or any client's `connectReverse()`) register a signed record,
232
+ * operators list and `reverseConnect()` to them, and traffic is carried between the two as `RelayForward`.
233
+ * Default deny -- give both `canRegister` and `canConnect`. Off by default.
234
+ */
235
+ relay?: WshServerRelayOptions;
199
236
  /** Serve MCP tools (`McpDiscover` / `McpCall`) and advertise `mcp-call-id`. Off by default. */
200
237
  mcp?: WshServerMcpOptions;
201
238
  /** How long exec output waits for a client that has not yet opened its data stream (default 3000). */
@@ -215,6 +252,8 @@ export interface WshServer {
215
252
  close(): Promise<void>;
216
253
  /** The bound address, or `null` when not listening. */
217
254
  address(): WshServerAddress | null;
255
+ /** Fingerprints of the peers currently registered with this relay (`[]` when it is not one). */
256
+ peerFingerprints(): string[];
218
257
  /** The advertised host identity; `null` before `listen()` resolves or without a `hostKey`. */
219
258
  hostKey(): WshServerHostKey | null;
220
259
  }
@@ -226,3 +265,40 @@ export function parseAuthorizedKeys(text: string): Uint8Array[];
226
265
 
227
266
  /** ServerHello feature: the host discovers client-opened exec data streams itself (no primer byte). */
228
267
  export const STREAM_ANNOUNCE: 'stream-announce';
268
+
269
+ export interface WshReverseHostOptions {
270
+ /** The relay to register with (`ws://` / `wss://`). */
271
+ url: string;
272
+ username: string;
273
+ /** This host's identity; its fingerprint is what operators connect to. */
274
+ keyPair: CryptoKeyPair;
275
+ /** Who may be bridged to this host. Default: nobody. */
276
+ accept?: (operator: { fingerprint: string; username: string }) => boolean | Promise<boolean>;
277
+ exec?: true | WshServerExecOptions | WshExecRunner;
278
+ pty?: WshServerPtyOptions;
279
+ fs?: WshServerFsOptions;
280
+ mcp?: WshServerMcpOptions;
281
+ /** Dial again, with backoff, after the relay connection ends -- which a relay does when a bridge does (default true). */
282
+ reconnect?: boolean;
283
+ /** Extra options for the relay connection (`expectHostKey`, `knownHosts`, `trustOnFirstUse`, ...). */
284
+ connect?: Record<string, unknown>;
285
+ peerType?: string;
286
+ onLog?: (line: string) => void;
287
+ }
288
+
289
+ export interface WshReverseHost {
290
+ /** Dial the relay and register (rejects if the first attempt fails). Registration is not acknowledged on the wire. */
291
+ start(): Promise<{ fingerprint: string }>;
292
+ /** Leave the relay and end any bridged session. */
293
+ close(): Promise<void>;
294
+ /** What operators connect to; `null` before `start()`. */
295
+ readonly fingerprint: string | null;
296
+ /** Is the relay connection up right now? */
297
+ readonly connected: boolean;
298
+ }
299
+
300
+ /**
301
+ * A host that dials OUT to a relay and serves the operator it bridges to it, with the same exec / pty / fs / mcp
302
+ * backends `createWshServer` serves direct clients with. For a machine that cannot accept connections.
303
+ */
304
+ export function createReverseHost(options: WshReverseHostOptions): WshReverseHost;
@@ -10,14 +10,13 @@
10
10
 
11
11
  import { buildAuthorizer, parseAuthorizedKeys, authMethods, FailureLimiter } from './auth.mjs';
12
12
  import { loadHostKey } from './host-key.mjs';
13
- import { spawnRunner } from './exec.mjs';
14
- import { normalizePty } from './pty.mjs';
15
- import { createFileAccess } from './fs.mjs';
16
- import { createMcpHost } from './mcp.mjs';
13
+ import { buildBackends } from './backends.mjs';
17
14
  import { SessionRegistry } from './sessions.mjs';
15
+ import { RelayHub } from './relay.mjs';
18
16
  import { createConnectionFactory, STREAM_ANNOUNCE } from './connection.mjs';
19
17
 
20
18
  export { parseAuthorizedKeys, STREAM_ANNOUNCE };
19
+ export { createReverseHost } from './reverse-host.mjs';
21
20
 
22
21
  /**
23
22
  * @param {object} [options]
@@ -31,6 +30,10 @@ export { parseAuthorizedKeys, STREAM_ANNOUNCE };
31
30
  * @param {{ tools?: object[] | object, client?: object, authorize?: Function, maxConcurrent?: number, timeoutMs?: number }} [options.mcp]
32
31
  * MCP tools served over `McpDiscover` / `McpCall`: `tools` (`{ name, description, inputSchema, call(args, { user, signal }) }`),
33
32
  * and/or `client` (an `@modelcontextprotocol/sdk` Client to proxy). Off unless given.
33
+ * @param {{ canRegister?: Function, canConnect?: Function, maxPeers?: number, connectTimeoutMs?: number }} [options.relay]
34
+ * Act as a relay: peers register (`ReverseRegister`, a signed record), operators list and connect to them
35
+ * (`ReverseList` / `ReverseConnect`) and traffic is carried between them as `RelayForward`. Default deny:
36
+ * `canRegister(who, record)` and `canConnect(from, to)` must both be given and return true. See `createReverseHost`.
34
37
  * @param {true | { detachTtlMs?: number, maxDetached?: number, ringBytes?: number, sessionSecret?: string | Uint8Array }} [options.sessions]
35
38
  * Keep pty/exec sessions alive across disconnects so a client can `resumeSession()` / `attachSession()`
36
39
  * them: a per-server registry, a ring buffer of the newest `ringBytes` (default 1 MiB) of output, and
@@ -45,22 +48,12 @@ export { parseAuthorizedKeys, STREAM_ANNOUNCE };
45
48
  * @returns {{ listen(): Promise<{address: string, port: number}>, close(): Promise<void>, address(): ({address: string, port: number} | null) }}
46
49
  */
47
50
  export function createWshServer({
48
- host = '127.0.0.1', port = 0, auth, exec, pty, fs, hostKey, mcp, sessions, sessionSecret, bindTimeoutMs = 3000, onLog = () => {},
51
+ host = '127.0.0.1', port = 0, auth, exec, pty, fs, hostKey, mcp, sessions, sessionSecret, relay, bindTimeoutMs = 3000, onLog = () => {},
49
52
  } = {}) {
50
53
  let wss = null;
51
54
  let bound = null;
52
55
 
53
- let execRunner = null;
54
- let execOptions = {};
55
- if (typeof exec === 'function') {
56
- execRunner = exec;
57
- } else if (exec) {
58
- execOptions = exec === true ? {} : exec;
59
- execRunner = typeof execOptions.run === 'function' ? execOptions.run : spawnRunner(execOptions);
60
- }
61
- const ptyConfig = pty ? normalizePty(pty) : null;
62
- const files = fs ? createFileAccess(fs) : null;
63
- const mcpHost = mcp ? createMcpHost(mcp) : null;
56
+ const { execRunner, execOptions, pty: ptyConfig, files, mcp: mcpHost } = buildBackends({ exec, pty, fs, mcp });
64
57
  const methods = authMethods(auth);
65
58
  const rl = auth?.rateLimit ?? {};
66
59
  const rateLimit = {
@@ -70,6 +63,7 @@ export function createWshServer({
70
63
  };
71
64
  let host_ = null;
72
65
  let registry = null;
66
+ let hub = null;
73
67
  const sessionOptions = sessions ? (sessions === true ? {} : sessions) : null;
74
68
 
75
69
  return {
@@ -93,9 +87,10 @@ export function createWshServer({
93
87
  log: onLog,
94
88
  });
95
89
  }
90
+ if (relay) hub = new RelayHub({ ...relay, log: onLog });
96
91
  const attach = createConnectionFactory({
97
92
  authorize, execRunner, execOptions, pty: ptyConfig, files, bindTimeoutMs, log: onLog,
98
- methods, hostKey: host_, rateLimit, mcp: mcpHost, sessions: registry,
93
+ methods, hostKey: host_, rateLimit, mcp: mcpHost, sessions: registry, relay: hub,
99
94
  });
100
95
 
101
96
  wss = new WebSocketServer({ host, port });
@@ -103,6 +98,7 @@ export function createWshServer({
103
98
  const conn = attach({
104
99
  send: (b) => { if (ws.readyState === 1) ws.send(b); },
105
100
  remote: { address: req?.socket?.remoteAddress, headers: req?.headers ?? {} },
101
+ closeTransport: () => ws.close(1000),
106
102
  });
107
103
  ws.on('message', (d) => {
108
104
  const buf = Array.isArray(d) ? Buffer.concat(d) : Buffer.isBuffer(d) ? d : Buffer.from(d);
@@ -129,12 +125,19 @@ export function createWshServer({
129
125
  await new Promise((resolve) => server.close(resolve));
130
126
  registry?.closeAll();
131
127
  registry = null;
128
+ hub?.closeAll();
129
+ hub = null;
132
130
  },
133
131
 
134
132
  address() {
135
133
  return bound;
136
134
  },
137
135
 
136
+ /** Fingerprints of the peers currently registered with this relay (`[]` when it is not one). */
137
+ peerFingerprints() {
138
+ return hub ? hub.peerFingerprints() : [];
139
+ },
140
+
138
141
  /** The advertised host identity (`null` until `listen()` resolves, or with no `hostKey`). */
139
142
  hostKey() {
140
143
  return host_ ? { fingerprint: host_.fingerprint, publicKey: host_.publicKey, openssh: host_.openssh } : null;
@@ -0,0 +1,281 @@
1
+ /**
2
+ * Relay (reverse-mode) hub for `@johnhenry/wsh/server`: what lets a host that
3
+ * cannot accept connections (behind NAT, or a browser tab) register with this
4
+ * server, and an operator reach it through it.
5
+ *
6
+ * peer --ReverseRegister--> hub (a signed peer record, verified here)
7
+ * operator --ReverseList-----> hub (peers it may connect to)
8
+ * operator --ReverseConnect--> hub --ReverseConnect(from_fingerprint)--> peer
9
+ * peer --ReverseAccept----> hub --ReverseAccept------------------> operator
10
+ * operator <==RelayForward{from_fingerprint, inner}==> peer (bridged)
11
+ *
12
+ * Nothing is open by default: `canRegister` and `canConnect` both default to
13
+ * "no". `from_fingerprint` is always the sender's own authenticated key, set
14
+ * here and never taken from a payload; only the spec's `forwardable` message
15
+ * types cross a bridge. One bridge per peer at a time, and it lasts as long as
16
+ * both connections: when either ends the hub closes the other, so no
17
+ * half-bridged state (and no processes belonging to a departed operator)
18
+ * outlives it.
19
+ */
20
+
21
+ import { cborEncode, cborDecode } from '../cbor.mjs';
22
+ import { fingerprint as fingerprintOf, importPublicKeyRaw, verifyPeerRecord } from '../auth.mjs';
23
+ import { MSG, isRelayForwardable, relayForward, reversePeers, reverseReject } from '../messages.gen.mjs';
24
+
25
+ export const DEFAULT_MAX_PEERS = 1024;
26
+ export const DEFAULT_CONNECT_TIMEOUT_MS = 8000;
27
+ const MAX_FORWARD_BYTES = 8 * 1024 * 1024;
28
+ const MAX_REMEMBERED_SEQS = 4096;
29
+ const MAX_CAPABILITIES = 32;
30
+ const SHORT_PREFIX_MIN = 8;
31
+
32
+ const isStr = (v, max) => typeof v === 'string' && v.length <= max;
33
+
34
+ /**
35
+ * A connection as the hub sees it.
36
+ * @typedef {object} RelayHandle
37
+ * @property {(msg: object) => unknown} send - deliver a message to that connection
38
+ * @property {() => void} close - end that connection
39
+ * @property {string} fingerprint - its authenticated key
40
+ * @property {string} username
41
+ */
42
+
43
+ export class RelayHub {
44
+ /** @type {Map<string, { handle: RelayHandle, meta: object }>} */
45
+ #peers = new Map();
46
+ /** @type {Map<RelayHandle, RelayHandle>} */
47
+ #pairs = new Map();
48
+ /** target handle -> { operator, timer } while a ReverseConnect awaits its answer */
49
+ #pending = new Map();
50
+ /** fingerprint -> the highest record seq ever accepted (survives disconnects, bounded). */
51
+ #lastSeq = new Map();
52
+
53
+ constructor({ canRegister, canConnect, maxPeers = DEFAULT_MAX_PEERS, connectTimeoutMs = DEFAULT_CONNECT_TIMEOUT_MS, log = () => {} } = {}) {
54
+ for (const [name, fn] of [['canRegister', canRegister], ['canConnect', canConnect]]) {
55
+ if (fn !== undefined && typeof fn !== 'function') throw new TypeError(`createWshServer: relay.${name} must be a function`);
56
+ }
57
+ this.canRegister = canRegister ?? (() => false);
58
+ this.canConnect = canConnect ?? (() => false);
59
+ this.maxPeers = maxPeers;
60
+ this.connectTimeoutMs = connectTimeoutMs;
61
+ this.log = log;
62
+ }
63
+
64
+ get peerCount() { return this.#peers.size; }
65
+
66
+ /** Fingerprints currently registered. */
67
+ peerFingerprints() { return [...this.#peers.keys()]; }
68
+
69
+ async #allowed(fn, ...args) {
70
+ try { return (await fn(...args)) === true; } catch (e) { this.log(`relay policy threw: ${e.message}`); return false; }
71
+ }
72
+
73
+ /**
74
+ * Verify and record a `ReverseRegister`. Resolves to `null` on success or a reason.
75
+ * @param {RelayHandle} handle
76
+ * @param {object} msg
77
+ * @param {{ username: string, fingerprint: string }} who - the authenticated connection
78
+ */
79
+ async register(handle, msg, who) {
80
+ // Cheap structural checks first, then policy, then the signature (the expensive part).
81
+ if (!(msg.public_key instanceof Uint8Array) || !(msg.record_signature instanceof Uint8Array)) return 'missing signed-record fields';
82
+ const seq = typeof msg.seq === 'bigint' ? Number(msg.seq) : msg.seq;
83
+ if (!Number.isSafeInteger(seq) || seq < 0) return 'missing or invalid record seq';
84
+ if (!isStr(msg.username, 64) || !isStr(msg.peer_type ?? 'host', 64) || !isStr(msg.shell_backend ?? 'pty', 64)) return 'malformed record';
85
+ const caps = msg.capabilities ?? [];
86
+ if (!Array.isArray(caps) || caps.length > MAX_CAPABILITIES || !caps.every((c) => isStr(c, 64))) return 'malformed capabilities';
87
+ if ((await fingerprintOf(msg.public_key)) !== who.fingerprint) return 'public_key does not match the authenticated identity';
88
+
89
+ if (!(await this.#allowed(this.canRegister, { username: who.username, fingerprint: who.fingerprint }, {
90
+ username: msg.username, capabilities: caps, peerType: msg.peer_type ?? 'host', shellBackend: msg.shell_backend ?? 'pty',
91
+ }))) return 'registration not permitted';
92
+
93
+ const previous = this.#lastSeq.get(who.fingerprint);
94
+ if (previous !== undefined && seq <= previous) return `stale record (seq ${seq} <= ${previous})`;
95
+
96
+ let key;
97
+ try { key = await importPublicKeyRaw(msg.public_key); } catch { return 'malformed public_key'; }
98
+ let ok = false;
99
+ try {
100
+ ok = await verifyPeerRecord(key, msg.record_signature, {
101
+ username: msg.username,
102
+ peerType: msg.peer_type,
103
+ shellBackend: msg.shell_backend,
104
+ capabilities: msg.capabilities,
105
+ supportsAttach: msg.supports_attach,
106
+ supportsReplay: msg.supports_replay,
107
+ supportsEcho: msg.supports_echo,
108
+ supportsTermSync: msg.supports_term_sync,
109
+ seq,
110
+ });
111
+ } catch { ok = false; }
112
+ if (!ok) return 'record signature does not verify';
113
+
114
+ // Re-check after the awaits: a concurrent registration may have moved the counter.
115
+ const latest = this.#lastSeq.get(who.fingerprint);
116
+ if (latest !== undefined && seq <= latest) return `stale record (seq ${seq} <= ${latest})`;
117
+ const existing = this.#peers.get(who.fingerprint);
118
+ if (!existing && this.#peers.size >= this.maxPeers) return 'relay is full';
119
+
120
+ this.#lastSeq.delete(who.fingerprint);
121
+ this.#lastSeq.set(who.fingerprint, seq);
122
+ while (this.#lastSeq.size > MAX_REMEMBERED_SEQS) this.#lastSeq.delete(this.#lastSeq.keys().next().value);
123
+
124
+ if (existing && existing.handle !== handle) {
125
+ // The same key came back on a new connection: the old one is dead or superseded.
126
+ this.#peers.delete(who.fingerprint);
127
+ this.drop(existing.handle);
128
+ existing.handle.close();
129
+ }
130
+ this.#peers.set(who.fingerprint, {
131
+ handle,
132
+ meta: {
133
+ fingerprint: who.fingerprint,
134
+ fingerprint_short: who.fingerprint.slice(0, 12),
135
+ username: msg.username,
136
+ capabilities: [...caps],
137
+ peer_type: msg.peer_type ?? 'host',
138
+ shell_backend: msg.shell_backend ?? 'pty',
139
+ source: 'wsh-relay',
140
+ supports_attach: !!msg.supports_attach,
141
+ supports_replay: !!msg.supports_replay,
142
+ supports_echo: !!msg.supports_echo,
143
+ supports_term_sync: !!msg.supports_term_sync,
144
+ last_seen: Date.now(),
145
+ public_key: msg.public_key,
146
+ seq,
147
+ record_signature: msg.record_signature,
148
+ },
149
+ });
150
+ this.log(`relay: peer registered ${who.fingerprint.slice(0, 12)} (${msg.username})`);
151
+ return null;
152
+ }
153
+
154
+ /** Is this connection a registered peer? Refreshes its `last_seen` when it is. */
155
+ touch(handle) {
156
+ const e = this.#peers.get(handle.fingerprint);
157
+ if (e && e.handle === handle) e.meta.last_seen = Date.now();
158
+ }
159
+
160
+ /** The peers `who` is allowed to connect to -- an operator sees only what it could reach. */
161
+ async list(who) {
162
+ const out = [];
163
+ for (const { meta, handle } of this.#peers.values()) {
164
+ if (handle.fingerprint === who.fingerprint) continue;
165
+ if (await this.#allowed(this.canConnect, who, { fingerprint: meta.fingerprint, username: meta.username, capabilities: meta.capabilities })) out.push({ ...meta });
166
+ }
167
+ return reversePeers({ peers: out });
168
+ }
169
+
170
+ #find(target) {
171
+ if (typeof target !== 'string') return undefined;
172
+ const exact = this.#peers.get(target);
173
+ if (exact) return exact;
174
+ if (target.length < SHORT_PREFIX_MIN) return undefined;
175
+ const hits = [...this.#peers.values()].filter((p) => p.meta.fingerprint.startsWith(target));
176
+ return hits.length === 1 ? hits[0] : undefined;
177
+ }
178
+
179
+ /** `ReverseConnect` from `operator`: forward it to the target, with the identity the hub knows. */
180
+ async connect(operator, msg) {
181
+ const reject = (reason) => operator.send(reverseReject({ targetFingerprint: String(msg.target_fingerprint ?? ''), username: msg.username ?? '', reason }));
182
+ const target = this.#find(msg.target_fingerprint);
183
+ // Unknown, unreachable-by-policy and self look the same to the caller.
184
+ if (!target || target.handle === operator
185
+ || !(await this.#allowed(this.canConnect, { username: operator.username, fingerprint: operator.fingerprint }, {
186
+ fingerprint: target.meta.fingerprint, username: target.meta.username, capabilities: target.meta.capabilities,
187
+ }))) return reject('no such peer');
188
+ if (this.#peers.get(target.meta.fingerprint) !== target) return reject('no such peer');
189
+ if (this.#pairs.has(operator) || this.#pairs.has(target.handle)
190
+ || [...this.#pending.values()].some((p) => p.operator === operator) || this.#pending.has(target.handle)) {
191
+ return reject('busy');
192
+ }
193
+ const timer = setTimeout(() => {
194
+ if (this.#pending.get(target.handle)?.operator !== operator) return;
195
+ this.#pending.delete(target.handle);
196
+ reject('peer did not respond');
197
+ }, this.connectTimeoutMs);
198
+ timer.unref?.();
199
+ this.#pending.set(target.handle, { operator, timer });
200
+ // The relay speaks for the operator's identity; whatever the client wrote in these fields is discarded.
201
+ target.handle.send({
202
+ type: MSG.REVERSE_CONNECT,
203
+ target_fingerprint: target.meta.fingerprint,
204
+ username: operator.username,
205
+ from_fingerprint: operator.fingerprint,
206
+ });
207
+ this.log(`relay: ${operator.fingerprint.slice(0, 12)} -> ${target.meta.fingerprint.slice(0, 12)} requested`);
208
+ }
209
+
210
+ /** `ReverseAccept` / `ReverseReject` from a peer, answering a pending `connect`. */
211
+ answer(peer, msg) {
212
+ const p = this.#pending.get(peer);
213
+ if (!p) return;
214
+ clearTimeout(p.timer);
215
+ this.#pending.delete(peer);
216
+ // The answer names the answering peer, not whatever it wrote.
217
+ p.operator.send({ ...msg, target_fingerprint: peer.fingerprint });
218
+ if (msg.type === MSG.REVERSE_ACCEPT) {
219
+ this.#pairs.set(p.operator, peer);
220
+ this.#pairs.set(peer, p.operator);
221
+ this.log(`relay: bridged ${p.operator.fingerprint.slice(0, 12)} <-> ${peer.fingerprint.slice(0, 12)}`);
222
+ }
223
+ }
224
+
225
+ /** Is `handle` one end of a bridge? */
226
+ bridged(handle) {
227
+ return this.#pairs.has(handle);
228
+ }
229
+
230
+ /**
231
+ * Carry a message across `handle`'s bridge. A plain forwardable message is
232
+ * wrapped; a `RelayForward` the client wrote itself is unwrapped, its inner
233
+ * checked, and re-wrapped -- either way `from_fingerprint` is the sender's
234
+ * authenticated key. Returns false when there is no bridge.
235
+ */
236
+ forward(handle, msg) {
237
+ const partner = this.#pairs.get(handle);
238
+ if (!partner) return false;
239
+ let inner;
240
+ if (msg.type === MSG.RELAY_FORWARD) {
241
+ if (!(msg.inner instanceof Uint8Array) || msg.inner.byteLength > MAX_FORWARD_BYTES) { this.log('relay: dropped an oversized or malformed RelayForward'); return true; }
242
+ let decoded;
243
+ try { decoded = cborDecode(msg.inner); } catch { this.log('relay: dropped a RelayForward with an undecodable inner'); return true; }
244
+ if (!decoded || typeof decoded.type !== 'number' || !isRelayForwardable(decoded.type)) {
245
+ this.log(`relay: dropped a RelayForward wrapping a non-forwardable type ${decoded?.type}`);
246
+ return true;
247
+ }
248
+ inner = msg.inner;
249
+ } else {
250
+ if (!isRelayForwardable(msg.type)) return false;
251
+ inner = cborEncode(msg);
252
+ }
253
+ partner.send(relayForward({ fromFingerprint: handle.fingerprint, inner }));
254
+ return true;
255
+ }
256
+
257
+ /** A connection ended: forget it, and end the bridge it was part of. */
258
+ drop(handle) {
259
+ const entry = this.#peers.get(handle.fingerprint);
260
+ if (entry && entry.handle === handle) this.#peers.delete(handle.fingerprint);
261
+ const p = this.#pending.get(handle);
262
+ if (p) { clearTimeout(p.timer); this.#pending.delete(handle); p.operator.send(reverseReject({ targetFingerprint: handle.fingerprint, username: '', reason: 'peer went away' })); }
263
+ for (const [target, pend] of this.#pending) {
264
+ if (pend.operator === handle) { clearTimeout(pend.timer); this.#pending.delete(target); }
265
+ }
266
+ const partner = this.#pairs.get(handle);
267
+ if (partner) {
268
+ this.#pairs.delete(handle);
269
+ this.#pairs.delete(partner);
270
+ partner.close();
271
+ }
272
+ }
273
+
274
+ /** Server shutdown. */
275
+ closeAll() {
276
+ for (const p of this.#pending.values()) clearTimeout(p.timer);
277
+ this.#pending.clear();
278
+ this.#pairs.clear();
279
+ this.#peers.clear();
280
+ }
281
+ }
@@ -0,0 +1,147 @@
1
+ /**
2
+ * A reverse host: this process dials OUT to a relay (`createWshServer({ relay })`),
3
+ * registers itself as a peer, and serves whichever operator the relay bridges to
4
+ * it -- with the same exec / pty / fs / mcp backends `createWshServer` serves
5
+ * direct clients with. Use it for a machine that cannot accept connections.
6
+ *
7
+ * The relay has already authenticated the operator (its key), so the bridged
8
+ * connection is pre-authenticated; what a relay-asserted identity is worth is
9
+ * yours to decide in `accept`, which defaults to refusing everyone.
10
+ */
11
+
12
+ import { WshClient } from '../client.mjs';
13
+ import { exportPublicKeyRaw, fingerprint } from '../auth.mjs';
14
+ import { reverseAccept, reverseReject } from '../messages.gen.mjs';
15
+ import { buildBackends } from './backends.mjs';
16
+ import { createConnectionFactory } from './connection.mjs';
17
+
18
+ const BACKOFF_START_MS = 500;
19
+ const BACKOFF_MAX_MS = 30_000;
20
+
21
+ /**
22
+ * @param {object} options
23
+ * @param {string} options.url - The relay (`ws://` or `wss://`).
24
+ * @param {string} options.username
25
+ * @param {CryptoKeyPair} options.keyPair - This host's identity; its fingerprint is what operators connect to.
26
+ * @param {(operator: { fingerprint: string, username: string }) => boolean | Promise<boolean>} [options.accept]
27
+ * Who may be bridged to this host. Default: nobody.
28
+ * @param {true | object | Function} [options.exec]
29
+ * @param {object} [options.pty]
30
+ * @param {object} [options.fs]
31
+ * @param {object} [options.mcp]
32
+ * @param {boolean} [options.reconnect=true] - Dial again (with backoff) after the relay connection ends. A relay ends it when a bridge does.
33
+ * @param {object} [options.connect] - Extra options for the relay connection (`expectHostKey`, `knownHosts`, ...).
34
+ * @param {string} [options.peerType='host']
35
+ * @param {(line: string) => void} [options.onLog]
36
+ */
37
+ export function createReverseHost({
38
+ url, username, keyPair, accept, exec, pty, fs, mcp, reconnect = true, connect = {}, peerType = 'host', onLog = () => {},
39
+ } = {}) {
40
+ if (!url) throw new TypeError('createReverseHost: url is required');
41
+ if (!username) throw new TypeError('createReverseHost: username is required');
42
+ if (!keyPair) throw new TypeError('createReverseHost: keyPair is required (the relay keys peers by fingerprint)');
43
+ if (accept !== undefined && typeof accept !== 'function') throw new TypeError('createReverseHost: accept must be a function');
44
+
45
+ const backends = buildBackends({ exec, pty, fs, mcp });
46
+ const expose = { shell: !!backends.pty, exec: !!backends.execRunner, fs: !!backends.files, tools: !!backends.mcp };
47
+ const capabilities = Object.entries(expose).filter(([, on]) => on).map(([k]) => k);
48
+ const shellBackend = backends.pty ? 'pty' : 'exec-only';
49
+ const factory = createConnectionFactory({
50
+ authorize: async () => false, methods: { pubkey: false, password: null }, hostKey: null, rateLimit: null,
51
+ execRunner: backends.execRunner, execOptions: backends.execOptions, pty: backends.pty, files: backends.files, mcp: backends.mcp,
52
+ sessions: null, relay: null, bindTimeoutMs: 3000, log: (l) => onLog(`[reverse] ${l}`),
53
+ });
54
+
55
+ let client = null;
56
+ let myFingerprint = null;
57
+ let closed = false;
58
+ let timer = null;
59
+ let backoff = BACKOFF_START_MS;
60
+
61
+ async function dial() {
62
+ const c = new WshClient();
63
+ let bridge = null;
64
+ let operator = null;
65
+
66
+ const endBridge = () => {
67
+ if (operator) c.untrustRelayPeer(operator);
68
+ bridge?.close();
69
+ bridge = null;
70
+ operator = null;
71
+ };
72
+ c.onRelayMessage = (msg) => bridge?.receiveMessage(msg);
73
+ c.onReverseConnect = async (req) => {
74
+ const from = req.from_fingerprint;
75
+ const answer = (m) => c.sendRelayControl(m).catch(() => {});
76
+ const refuse = (reason) => answer(reverseReject({ targetFingerprint: myFingerprint, username, reason }));
77
+ if (bridge || typeof from !== 'string' || !from) { await refuse('busy'); return; }
78
+ let ok = false;
79
+ try { ok = (await accept?.({ fingerprint: from, username: String(req.username ?? '') })) === true; } catch (e) { onLog(`[reverse] accept threw: ${e.message}`); }
80
+ if (!ok || bridge) { await refuse('not accepted'); return; }
81
+ c.trustRelayPeer(from);
82
+ operator = from;
83
+ bridge = factory({
84
+ sendMessage: (m) => c.sendRelayControl(m),
85
+ authenticated: { username: String(req.username ?? 'operator'), fingerprint: from },
86
+ dataStreams: false,
87
+ });
88
+ onLog(`[reverse] bridged to ${from.slice(0, 12)}`);
89
+ await answer(reverseAccept({
90
+ targetFingerprint: myFingerprint, username, capabilities, peerType, shellBackend,
91
+ supportsAttach: false, supportsReplay: false, supportsEcho: false, supportsTermSync: false,
92
+ }));
93
+ };
94
+ c.onClose = () => {
95
+ endBridge();
96
+ if (client === c) client = null;
97
+ if (closed || !reconnect) return;
98
+ onLog(`[reverse] relay connection ended; redialling in ${backoff}ms`);
99
+ timer = setTimeout(() => { redial().catch(() => {}); }, backoff);
100
+ timer.unref?.();
101
+ backoff = Math.min(backoff * 2, BACKOFF_MAX_MS);
102
+ };
103
+
104
+ await c.connectReverse(url, {
105
+ username, keyPair, expose, peerType, shellBackend,
106
+ supportsAttach: false, supportsReplay: false, supportsEcho: false, supportsTermSync: false,
107
+ ...connect,
108
+ });
109
+ client = c;
110
+ backoff = BACKOFF_START_MS;
111
+ onLog(`[reverse] registered ${myFingerprint.slice(0, 12)} on ${url}`);
112
+ }
113
+
114
+ async function redial() {
115
+ if (closed) return;
116
+ try { await dial(); } catch (e) {
117
+ onLog(`[reverse] redial failed: ${e.message}`);
118
+ if (closed || !reconnect) return;
119
+ timer = setTimeout(() => { redial().catch(() => {}); }, backoff);
120
+ timer.unref?.();
121
+ backoff = Math.min(backoff * 2, BACKOFF_MAX_MS);
122
+ }
123
+ }
124
+
125
+ return {
126
+ /** Dial the relay and register. Rejects if the first attempt fails; later drops are retried. */
127
+ async start() {
128
+ if (client) throw new Error('createReverseHost: already started');
129
+ closed = false;
130
+ myFingerprint = await fingerprint(await exportPublicKeyRaw(keyPair.publicKey));
131
+ await dial();
132
+ return { fingerprint: myFingerprint };
133
+ },
134
+ /** Leave the relay and end any bridged session. */
135
+ async close() {
136
+ closed = true;
137
+ clearTimeout(timer);
138
+ const c = client;
139
+ client = null;
140
+ await c?.disconnect().catch(() => {});
141
+ },
142
+ /** This host's fingerprint (what operators `reverseConnect()` to); `null` before `start()`. */
143
+ get fingerprint() { return myFingerprint; },
144
+ /** Is the relay connection up right now? */
145
+ get connected() { return client !== null; },
146
+ };
147
+ }