@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 +80 -3
- package/package.json +1 -1
- package/src/server/backends.mjs +32 -0
- package/src/server/connection.mjs +89 -11
- package/src/server/index.d.ts +76 -0
- package/src/server/index.mjs +20 -17
- package/src/server/relay.mjs +281 -0
- package/src/server/reverse-host.mjs +147 -0
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 (
|
|
189
|
-
|
|
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
|
@@ -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
|
-
|
|
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:
|
|
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
|
|
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
|
|
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
|
};
|
package/src/server/index.d.ts
CHANGED
|
@@ -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;
|
package/src/server/index.mjs
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
+
}
|