@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.
@@ -10,14 +10,15 @@
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';
16
+ import { startWebTransport } from './webtransport.mjs';
18
17
  import { createConnectionFactory, STREAM_ANNOUNCE } from './connection.mjs';
19
18
 
20
19
  export { parseAuthorizedKeys, STREAM_ANNOUNCE };
20
+ export { createReverseHost } from './reverse-host.mjs';
21
+ export { generateSelfSignedCertificate } from './self-signed.mjs';
21
22
 
22
23
  /**
23
24
  * @param {object} [options]
@@ -31,6 +32,15 @@ export { parseAuthorizedKeys, STREAM_ANNOUNCE };
31
32
  * @param {{ tools?: object[] | object, client?: object, authorize?: Function, maxConcurrent?: number, timeoutMs?: number }} [options.mcp]
32
33
  * MCP tools served over `McpDiscover` / `McpCall`: `tools` (`{ name, description, inputSchema, call(args, { user, signal }) }`),
33
34
  * and/or `client` (an `@modelcontextprotocol/sdk` Client to proxy). Off unless given.
35
+ * @param {{ port?: number, host?: string, path?: string, cert?: string, privKey?: string, selfSigned?: boolean | object, secret?: string }} [options.webTransport]
36
+ * Also listen for WebTransport (HTTP/3 over UDP) clients, with the same auth and features as the WebSocket
37
+ * listener. `cert` / `privKey` are PEM; `selfSigned: true` makes a 13-day ECDSA P-256 certificate and exposes its
38
+ * SHA-256 through `webTransport()` for the client's `serverCertificateHashes`. Needs the optional peers
39
+ * `@fails-components/webtransport` and `@fails-components/webtransport-transport-http3-quiche`. Off unless given.
40
+ * @param {{ canRegister?: Function, canConnect?: Function, maxPeers?: number, connectTimeoutMs?: number }} [options.relay]
41
+ * Act as a relay: peers register (`ReverseRegister`, a signed record), operators list and connect to them
42
+ * (`ReverseList` / `ReverseConnect`) and traffic is carried between them as `RelayForward`. Default deny:
43
+ * `canRegister(who, record)` and `canConnect(from, to)` must both be given and return true. See `createReverseHost`.
34
44
  * @param {true | { detachTtlMs?: number, maxDetached?: number, ringBytes?: number, sessionSecret?: string | Uint8Array }} [options.sessions]
35
45
  * Keep pty/exec sessions alive across disconnects so a client can `resumeSession()` / `attachSession()`
36
46
  * them: a per-server registry, a ring buffer of the newest `ringBytes` (default 1 MiB) of output, and
@@ -45,22 +55,12 @@ export { parseAuthorizedKeys, STREAM_ANNOUNCE };
45
55
  * @returns {{ listen(): Promise<{address: string, port: number}>, close(): Promise<void>, address(): ({address: string, port: number} | null) }}
46
56
  */
47
57
  export function createWshServer({
48
- host = '127.0.0.1', port = 0, auth, exec, pty, fs, hostKey, mcp, sessions, sessionSecret, bindTimeoutMs = 3000, onLog = () => {},
58
+ host = '127.0.0.1', port = 0, auth, exec, pty, fs, hostKey, mcp, sessions, sessionSecret, relay, webTransport, bindTimeoutMs = 3000, onLog = () => {},
49
59
  } = {}) {
50
60
  let wss = null;
51
61
  let bound = null;
52
62
 
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;
63
+ const { execRunner, execOptions, pty: ptyConfig, files, mcp: mcpHost } = buildBackends({ exec, pty, fs, mcp });
64
64
  const methods = authMethods(auth);
65
65
  const rl = auth?.rateLimit ?? {};
66
66
  const rateLimit = {
@@ -70,6 +70,8 @@ export function createWshServer({
70
70
  };
71
71
  let host_ = null;
72
72
  let registry = null;
73
+ let hub = null;
74
+ let wt = null;
73
75
  const sessionOptions = sessions ? (sessions === true ? {} : sessions) : null;
74
76
 
75
77
  return {
@@ -93,9 +95,10 @@ export function createWshServer({
93
95
  log: onLog,
94
96
  });
95
97
  }
98
+ if (relay) hub = new RelayHub({ ...relay, log: onLog });
96
99
  const attach = createConnectionFactory({
97
100
  authorize, execRunner, execOptions, pty: ptyConfig, files, bindTimeoutMs, log: onLog,
98
- methods, hostKey: host_, rateLimit, mcp: mcpHost, sessions: registry,
101
+ methods, hostKey: host_, rateLimit, mcp: mcpHost, sessions: registry, relay: hub,
99
102
  });
100
103
 
101
104
  wss = new WebSocketServer({ host, port });
@@ -103,6 +106,7 @@ export function createWshServer({
103
106
  const conn = attach({
104
107
  send: (b) => { if (ws.readyState === 1) ws.send(b); },
105
108
  remote: { address: req?.socket?.remoteAddress, headers: req?.headers ?? {} },
109
+ closeTransport: () => ws.close(1000),
106
110
  });
107
111
  ws.on('message', (d) => {
108
112
  const buf = Array.isArray(d) ? Buffer.concat(d) : Buffer.isBuffer(d) ? d : Buffer.from(d);
@@ -117,6 +121,14 @@ export function createWshServer({
117
121
  });
118
122
  const a = wss.address();
119
123
  bound = { address: a.address, port: a.port };
124
+ if (webTransport) {
125
+ try {
126
+ wt = await startWebTransport({ options: webTransport, host, attach, log: onLog });
127
+ } catch (err) {
128
+ await this.close();
129
+ throw err;
130
+ }
131
+ }
120
132
  return bound;
121
133
  },
122
134
 
@@ -126,15 +138,36 @@ export function createWshServer({
126
138
  wss = null;
127
139
  bound = null;
128
140
  for (const client of server.clients) client.terminate();
141
+ await wt?.close();
142
+ wt = null;
129
143
  await new Promise((resolve) => server.close(resolve));
130
144
  registry?.closeAll();
131
145
  registry = null;
146
+ hub?.closeAll();
147
+ hub = null;
132
148
  },
133
149
 
134
150
  address() {
135
151
  return bound;
136
152
  },
137
153
 
154
+ /**
155
+ * The WebTransport listener, or `null` (not configured, or `listen()` has not resolved). `certificateHash` is
156
+ * the SHA-256 of a `selfSigned` certificate, for the client's `serverCertificateHashes`; `null` for your own.
157
+ */
158
+ webTransport() {
159
+ if (!wt) return null;
160
+ return {
161
+ port: wt.port, host: wt.host, path: wt.path, url: `https://${wt.host.includes(':') ? `[${wt.host}]` : wt.host}:${wt.port}${wt.path}`,
162
+ certificateHash: wt.certificateHash, certificateHashHex: wt.certificateHashHex, notAfter: wt.notAfter,
163
+ };
164
+ },
165
+
166
+ /** Fingerprints of the peers currently registered with this relay (`[]` when it is not one). */
167
+ peerFingerprints() {
168
+ return hub ? hub.peerFingerprints() : [];
169
+ },
170
+
138
171
  /** The advertised host identity (`null` until `listen()` resolves, or with no `hostKey`). */
139
172
  hostKey() {
140
173
  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
+ }