@johnhenry/wsh 0.21.0 → 0.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +124 -4
- package/package.json +9 -1
- package/src/server/backends.mjs +32 -0
- package/src/server/connection.mjs +89 -11
- package/src/server/index.d.ts +133 -0
- package/src/server/index.mjs +50 -17
- package/src/server/relay.mjs +281 -0
- package/src/server/reverse-host.mjs +147 -0
- package/src/server/self-signed.mjs +121 -0
- package/src/server/webtransport.mjs +188 -0
- package/src/transport.mjs +5 -0
package/README.md
CHANGED
|
@@ -155,7 +155,7 @@ hosts do). Pass `primer: false` to never send it.
|
|
|
155
155
|
## Node Server
|
|
156
156
|
|
|
157
157
|
`@johnhenry/wsh/server` is a Node host for the same protocol, over WebSocket
|
|
158
|
-
(QMux). It is a separate subpath -- the package root stays browser-safe and
|
|
158
|
+
(QMux) and, optionally, [WebTransport](#webtransport-listener). It is a separate subpath -- the package root stays browser-safe and
|
|
159
159
|
never imports it -- and needs the optional peer dependency `ws`
|
|
160
160
|
(`npm install ws`).
|
|
161
161
|
|
|
@@ -185,8 +185,128 @@ restricted host with no shell. `fs.root` confines every path: `..`, absolute
|
|
|
185
185
|
paths and symlinks pointing out of it are refused -- for `write` and `rename`
|
|
186
186
|
(both paths) exactly as for `read`.
|
|
187
187
|
|
|
188
|
-
|
|
189
|
-
|
|
188
|
+
### WebTransport listener
|
|
189
|
+
|
|
190
|
+
The client's ladder prefers WebTransport (`https://`) and falls back to `wss://`;
|
|
191
|
+
`webTransport` gives the Node host the first rung, with real independent QUIC
|
|
192
|
+
streams (no head-of-line blocking across channels):
|
|
193
|
+
|
|
194
|
+
```js
|
|
195
|
+
const server = createWshServer({
|
|
196
|
+
auth, exec: true, fs: { root: '/srv/share' },
|
|
197
|
+
webTransport: { port: 4433, selfSigned: true }, // or { cert, privKey } PEM
|
|
198
|
+
});
|
|
199
|
+
await server.listen(); // the WebSocket listener still runs
|
|
200
|
+
const { url, certificateHash } = server.webTransport(); // https://127.0.0.1:4433/wsh, sha-256 of the cert
|
|
201
|
+
|
|
202
|
+
// client (a browser, or Node with a WebTransport implementation)
|
|
203
|
+
await client.connect(url, {
|
|
204
|
+
username, keyPair, transport: 'wt',
|
|
205
|
+
webTransport: { serverCertificateHashes: [{ algorithm: 'sha-256', value: certificateHash }] },
|
|
206
|
+
});
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
- **Optional peers**, imported only when `webTransport` is set:
|
|
210
|
+
`npm install @fails-components/webtransport @fails-components/webtransport-transport-http3-quiche`
|
|
211
|
+
(a native HTTP/3 build, prebuilt for Linux/macOS/Windows on x64 and arm).
|
|
212
|
+
Without them `listen()` rejects with that instruction, and a server with no `webTransport`
|
|
213
|
+
never loads them.
|
|
214
|
+
- **Same host, other wire.** Auth (keys, password, host key), `exec`, `pty`, `fs`, `mcp`,
|
|
215
|
+
`sessions` and `relay` behave identically; only the transport differs. The control stream is the
|
|
216
|
+
session's first bidirectional stream (length-prefixed CBOR frames); every bidirectional stream the client
|
|
217
|
+
opens afterwards is a data stream, bound to the next exec channel in `OpenOk` order. A client-created
|
|
218
|
+
stream is visible to the host as soon as it exists, so the host advertises `stream-announce` and the
|
|
219
|
+
client writes no primer.
|
|
220
|
+
- **`selfSigned: true`** generates (no dependency) an ECDSA P-256 certificate valid for 13 days -- a browser refuses a
|
|
221
|
+
pinned certificate valid for more than 14 -- and exposes the SHA-256 for `serverCertificateHashes`
|
|
222
|
+
(`{ selfSigned: { hosts, validityDays } }` to change the SANs or the lifetime). **It is not renewed:**
|
|
223
|
+
restart before `notAfter`, and re-pin. A certificate you pass as `cert`/`privKey` is used as is
|
|
224
|
+
(`certificateHash` is then `null`). `generateSelfSignedCertificate()` is exported for the same
|
|
225
|
+
certificate without a server. The web platform's other pinning rules apply (`https:` URL, HTTP/3 only; see
|
|
226
|
+
[Pinning a Self-Signed Certificate](#pinning-a-self-signed-certificate)).
|
|
227
|
+
- Node has no `WebTransport` global: a Node *client* needs `globalThis.WebTransport = (await import(
|
|
228
|
+
'@fails-components/webtransport')).WebTransport` first. UDP, so a firewall must allow `port`, and there
|
|
229
|
+
is no `wss://`-style TLS terminator in front: the certificate is the one this process serves.
|
|
230
|
+
- Tests (`test/server-webtransport.test.mjs`) run the stock client against it over a real HTTP/3
|
|
231
|
+
connection and are **skipped, with the reason, where the native binary cannot load**.
|
|
232
|
+
|
|
233
|
+
### Relay / reverse mode
|
|
234
|
+
|
|
235
|
+
A host that cannot accept connections (behind NAT, or a browser tab) dials *out*
|
|
236
|
+
and registers; an operator reaches it through a relay. `@johnhenry/wsh/server`
|
|
237
|
+
is both ends:
|
|
238
|
+
|
|
239
|
+
```js
|
|
240
|
+
// The relay -- anyone who can reach it and authenticate; nothing is permitted by default.
|
|
241
|
+
createWshServer({
|
|
242
|
+
auth: { authorizedKeys },
|
|
243
|
+
relay: {
|
|
244
|
+
canRegister: (who, record) => who.username === 'build-box', // who may be a peer
|
|
245
|
+
canConnect: (from, to) => from.username === 'alice', // who may list and reach which peer
|
|
246
|
+
},
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
// The peer, on the machine behind the NAT: same backends as createWshServer.
|
|
250
|
+
const host = createReverseHost({
|
|
251
|
+
url: 'wss://relay.example/', username: 'build-box', keyPair,
|
|
252
|
+
exec: true, fs: { root: '/srv/share' }, mcp: { tools },
|
|
253
|
+
accept: ({ fingerprint, username }) => fingerprint === aliceFingerprint, // default: nobody
|
|
254
|
+
});
|
|
255
|
+
await host.start(); // host.fingerprint is what operators connect to
|
|
256
|
+
|
|
257
|
+
// The operator: stock client.
|
|
258
|
+
const peers = await client.listPeers(); // each entry's signed record verified client-side
|
|
259
|
+
await client.reverseConnect(peers[0].fingerprint); // ReverseAccept trusts the peer for RelayForward
|
|
260
|
+
const s = await client.openSession({ type: 'exec', command: 'echo hello' });
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
- **Default deny, twice.** The relay admits no peer (`canRegister`) and lets no
|
|
264
|
+
one connect (`canConnect`) unless you say so, and the peer refuses every
|
|
265
|
+
operator unless `accept` says otherwise -- the client's `trustRelayPeer()` is
|
|
266
|
+
the third gate. A peer an operator may not connect to is not listed and is
|
|
267
|
+
indistinguishable from an absent one (`ReverseReject: no such peer`).
|
|
268
|
+
Relay roles need a **key** login (`from_fingerprint` is a key fingerprint);
|
|
269
|
+
password logins can still use the relay server as an ordinary host.
|
|
270
|
+
- **Peer table.** Keyed by fingerprint. `ReverseRegister` is checked against the
|
|
271
|
+
connection's own authenticated key (`public_key` must hash to it) and its
|
|
272
|
+
self-signed record (`buildPeerRecordTranscript` / `signPeerRecord`) is verified
|
|
273
|
+
here, not trusted; `ReversePeers` forwards the signed fields so operators verify
|
|
274
|
+
them again themselves. The record `seq` must increase per fingerprint -- and is
|
|
275
|
+
remembered across reconnects (the last 4096 fingerprints), so an old record
|
|
276
|
+
cannot regress a peer. Failures answer an `Error` (the stock client surfaces it
|
|
277
|
+
through `onError`); there is no acknowledgement of success on the wire, so poll
|
|
278
|
+
`listPeers()` (or `server.peerFingerprints()`) if you need to know it landed.
|
|
279
|
+
- **Bridge.** `ReverseConnect` is forwarded to the peer with `from_fingerprint`
|
|
280
|
+
(and `username`) set from the operator's authenticated login, whatever the
|
|
281
|
+
client wrote; the peer's `ReverseAccept`/`Reject` goes back, attributed to the
|
|
282
|
+
peer that sent it, and an accept pairs the two connections. From then on every
|
|
283
|
+
spec-`forwardable` message from either end is wrapped as `RelayForward {
|
|
284
|
+
from_fingerprint, inner }` with `from_fingerprint` the sender's authenticated
|
|
285
|
+
key. A `RelayForward` a client writes itself is unwrapped, its inner type
|
|
286
|
+
checked against the allowlist (a non-forwardable or undecodable inner is
|
|
287
|
+
dropped, logged), and re-wrapped with the real sender; traffic from a connection
|
|
288
|
+
that is not bridged is never forwarded.
|
|
289
|
+
- **One operator per peer at a time**; a second `ReverseConnect` is rejected as
|
|
290
|
+
`busy`, and one the peer does not answer within `connectTimeoutMs` (default
|
|
291
|
+
8 s) as `peer did not respond`. A bridge lasts as long as both connections:
|
|
292
|
+
when either ends, the relay closes the other, so nothing an operator started
|
|
293
|
+
outlives it (the reverse host kills its processes). `createReverseHost`
|
|
294
|
+
redials with backoff by default (`reconnect: false` to turn it off), so it is
|
|
295
|
+
registered again for the next operator.
|
|
296
|
+
- **On the peer**, the bridged operator is served by the same connection code as
|
|
297
|
+
a direct client, over an in-memory message pipe instead of a socket. Nothing a
|
|
298
|
+
client opens can cross the relay but control messages, so exec sessions are
|
|
299
|
+
`data_mode: 'virtual'` there (stdin and output ride `SessionData`; there is no
|
|
300
|
+
stdin EOF).
|
|
301
|
+
- **Not provided:** end-to-end encryption between operator and peer -- `KeyExchange` /
|
|
302
|
+
`EncryptedFrame` are not on the spec's `forwardable` list, so the relay sees the
|
|
303
|
+
traffic in clear (serve it over `wss://`, and treat the relay operator as trusted).
|
|
304
|
+
Client-side feature gates (`fileWrite`/`fileRename` need `file-write`/`file-rename`,
|
|
305
|
+
`callTool` correlates on `mcp-call-id`) read the **relay's** `ServerHello`, which
|
|
306
|
+
says nothing about the peer behind it: against a relay without `fs`, `fileWrite()`
|
|
307
|
+
refuses locally (reads, listing, stat and exec are unaffected). Multiplexing
|
|
308
|
+
several operators onto one peer needs a way to address replies, which `RelayForward`
|
|
309
|
+
does not have.
|
|
190
310
|
|
|
191
311
|
### Sessions: attach / resume / detach
|
|
192
312
|
|
|
@@ -595,7 +715,7 @@ than left to be found.
|
|
|
595
715
|
| Host identity / TOFU | `WshKnownHosts` (localStorage-backed) | `KnownHosts`/`HostStatus` (`~/.wsh/known_hosts`-backed) | **Record unified, policy is not** (wsh #59): both pin `ServerHello.host_fingerprint`, which `@johnhenry/wsh/server` populates (with a proof of possession, [Host key](#host-key-fingerprint--tofu)) but no Rust `wsh-server` release does yet (see [Security](#security-model)). *When* to trust, prompt, or persist is deliberately left per-implementation -- a browser and a CLI have different UX for "first time seeing this host" |
|
|
596
716
|
| Interactive shell UI | none -- this SDK is a protocol client, not a terminal emulator; pair with xterm.js/ghostty-web | `wsh connect`, `wsh sftp` (line-oriented REPL) | **Deliberately not unified** -- a browser embeds a terminal widget the host page owns; a CLI process owns its own TTY |
|
|
597
717
|
| Attach / resume | `attachSession()`, `resumeSession()`, `WshSession.seq` | `attach_session()`, `resume_session()` | **Wire-unified** (`Attach`/`Resume`/`Presence`); `seq` = cumulative output bytes. `@johnhenry/wsh/server` honours `last_seq` (bounded ring, gap errors) and answers Presence with `channel_id`/`seq`; the Rust `wsh-server` replays its whole ring and leaves them unset |
|
|
598
|
-
| Reverse-connect / relay peer | `connectReverse()`, `trustRelayPeer()` | `wsh reverse`, `wsh agent` (persistent, with startup-unit install) | **Wire-unified** (registration, discovery, signed peer records); **daemonization is CLI-only** -- a browser tab cannot be a background OS service |
|
|
718
|
+
| Reverse-connect / relay peer | `connectReverse()`, `trustRelayPeer()`; `@johnhenry/wsh/server`: `relay` option and `createReverseHost()` | `wsh reverse`, `wsh agent` (persistent, with startup-unit install) | **Wire-unified** (registration, discovery, signed peer records); **daemonization is CLI-only** -- a browser tab cannot be a background OS service. The Node relay is default-deny and bridges one operator per peer |
|
|
599
719
|
| Post-quantum E2E (experimental) | `initiateE2E()` (WebCrypto ML-KEM-768 or `@noble/post-quantum` fallback) | `E2eKeyExchange` (`ml-kem` crate) | **Wire-unified** algorithm and transcript; key material backends differ by platform necessity |
|
|
600
720
|
| Session recording/replay | `SessionRecorder`/`SessionPlayer` (asciicast v2) | none | **JS-only** -- no current Rust consumer needs playback; the format itself (asciicast v2) is not proprietary if one is added later |
|
|
601
721
|
| Self-signed cert pinning | `serverCertificateHashes` (WebTransport option) | N/A -- Rust dials a cert its own TLS stack already trusts, or `--generate-cert`'s self-signed cert out of band | **Deliberately not unified** -- this is a browser-specific WebTransport API shape, not a wire-protocol concept |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@johnhenry/wsh",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.23.0",
|
|
4
4
|
"description": "Web Shell \u2014 browser-native remote command execution over WebTransport and WebSocket with Ed25519 authentication",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.mjs",
|
|
@@ -24,9 +24,17 @@
|
|
|
24
24
|
"@noble/post-quantum": "^0.7.0"
|
|
25
25
|
},
|
|
26
26
|
"peerDependencies": {
|
|
27
|
+
"@fails-components/webtransport": "^1.6.4",
|
|
28
|
+
"@fails-components/webtransport-transport-http3-quiche": "^1.6.4",
|
|
27
29
|
"ws": "^8.0.0"
|
|
28
30
|
},
|
|
29
31
|
"peerDependenciesMeta": {
|
|
32
|
+
"@fails-components/webtransport": {
|
|
33
|
+
"optional": true
|
|
34
|
+
},
|
|
35
|
+
"@fails-components/webtransport-transport-http3-quiche": {
|
|
36
|
+
"optional": true
|
|
37
|
+
},
|
|
30
38
|
"ws": {
|
|
31
39
|
"optional": true
|
|
32
40
|
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a host can do for the client on the other end: shared by
|
|
3
|
+
* `createWshServer` (clients that connect to it) and `createReverseHost`
|
|
4
|
+
* (operators bridged to it by a relay), so the two cannot drift.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { spawnRunner } from './exec.mjs';
|
|
8
|
+
import { normalizePty } from './pty.mjs';
|
|
9
|
+
import { createFileAccess } from './fs.mjs';
|
|
10
|
+
import { createMcpHost } from './mcp.mjs';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @param {{ exec?: true | object | Function, pty?: object, fs?: object, mcp?: object }} options
|
|
14
|
+
* @returns {{ execRunner: Function | null, execOptions: object, pty: object | null, files: object | null, mcp: object | null }}
|
|
15
|
+
*/
|
|
16
|
+
export function buildBackends({ exec, pty, fs, mcp } = {}) {
|
|
17
|
+
let execRunner = null;
|
|
18
|
+
let execOptions = {};
|
|
19
|
+
if (typeof exec === 'function') {
|
|
20
|
+
execRunner = exec;
|
|
21
|
+
} else if (exec) {
|
|
22
|
+
execOptions = exec === true ? {} : exec;
|
|
23
|
+
execRunner = typeof execOptions.run === 'function' ? execOptions.run : spawnRunner(execOptions);
|
|
24
|
+
}
|
|
25
|
+
return {
|
|
26
|
+
execRunner,
|
|
27
|
+
execOptions,
|
|
28
|
+
pty: pty ? normalizePty(pty) : null,
|
|
29
|
+
files: fs ? createFileAccess(fs) : null,
|
|
30
|
+
mcp: mcp ? createMcpHost(mcp) : null,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
@@ -19,7 +19,7 @@ import { FrameDecoder, frameEncode } from '../cbor.mjs';
|
|
|
19
19
|
import {
|
|
20
20
|
MSG, serverHello, challenge, authOk, authFail, openOk, openFail, sessionData,
|
|
21
21
|
exit as exitMsg, close as closeMsg, pong, fileResult, fileChunk, mcpTools, mcpResult,
|
|
22
|
-
presence as presenceMsg, error as errorMsg, sessionList, detachOk, detachFail,
|
|
22
|
+
presence as presenceMsg, error as errorMsg, sessionList, detachOk, detachFail, reversePeers, reverseReject,
|
|
23
23
|
} from '../messages.gen.mjs';
|
|
24
24
|
import {
|
|
25
25
|
generateNonce, verifyChallenge, fingerprint, importPublicKeyRaw,
|
|
@@ -52,6 +52,7 @@ const toBytes = (d) => (typeof d === 'string' ? enc.encode(d) : d);
|
|
|
52
52
|
* @param {{ pubkey: boolean, password: Function | null }} cfg.methods - which auth methods are on
|
|
53
53
|
* @param {object | null} cfg.hostKey - from loadHostKey
|
|
54
54
|
* @param {object | null} cfg.mcp - from createMcpHost
|
|
55
|
+
* @param {object | null} cfg.relay - a RelayHub (see relay.mjs); null = not a relay
|
|
55
56
|
* @param {object | null} cfg.sessions - a SessionRegistry (see sessions.mjs); null = sessions die with their connection
|
|
56
57
|
* @param {{ limiter: object, key: Function, failureDelayMs: number } | null} cfg.rateLimit - password throttle
|
|
57
58
|
* @param {number} cfg.bindTimeoutMs - how long exec output waits for the data stream
|
|
@@ -60,12 +61,20 @@ const toBytes = (d) => (typeof d === 'string' ? enc.encode(d) : d);
|
|
|
60
61
|
export function createConnectionFactory(cfg) {
|
|
61
62
|
let counter = 0;
|
|
62
63
|
|
|
63
|
-
|
|
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,86 @@ export interface WshServerSessionsOptions {
|
|
|
169
169
|
sessionSecret?: string | Uint8Array;
|
|
170
170
|
}
|
|
171
171
|
|
|
172
|
+
/** A peer as the relay's policy callbacks see it. */
|
|
173
|
+
export interface WshRelayPeer {
|
|
174
|
+
/** Hex SHA-256 of the peer's key. */
|
|
175
|
+
fingerprint: string;
|
|
176
|
+
username: string;
|
|
177
|
+
capabilities: string[];
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export interface WshServerRelayOptions {
|
|
181
|
+
/**
|
|
182
|
+
* May this authenticated connection register as a peer? `who` is the connection; `record` what it asks to
|
|
183
|
+
* advertise. Default: no (a relay with no policy admits nobody).
|
|
184
|
+
*/
|
|
185
|
+
canRegister?: (
|
|
186
|
+
who: { username: string; fingerprint: string },
|
|
187
|
+
record: { username: string; capabilities: string[]; peerType: string; shellBackend: string },
|
|
188
|
+
) => boolean | Promise<boolean>;
|
|
189
|
+
/**
|
|
190
|
+
* May `from` list and connect to `to`? A peer `from` may not connect to is neither listed nor distinguishable
|
|
191
|
+
* from an absent one. Default: no.
|
|
192
|
+
*/
|
|
193
|
+
canConnect?: (
|
|
194
|
+
from: { username: string; fingerprint: string },
|
|
195
|
+
to: WshRelayPeer,
|
|
196
|
+
) => boolean | Promise<boolean>;
|
|
197
|
+
/** Registered peers at once (default 1024). */
|
|
198
|
+
maxPeers?: number;
|
|
199
|
+
/** How long a `ReverseConnect` waits for its peer to answer before the operator is rejected (default 8000). */
|
|
200
|
+
connectTimeoutMs?: number;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export interface WshServerWebTransportOptions {
|
|
204
|
+
/** UDP port for HTTP/3 (default 0 = pick a free one; read it back from `webTransport()`). */
|
|
205
|
+
port?: number;
|
|
206
|
+
/** Bind address (default: the server's `host`). */
|
|
207
|
+
host?: string;
|
|
208
|
+
/** URL path sessions are accepted on (default `/wsh`). */
|
|
209
|
+
path?: string;
|
|
210
|
+
/** PEM certificate chain. With `privKey`. Not together with `selfSigned`. */
|
|
211
|
+
cert?: string;
|
|
212
|
+
/** PEM private key for `cert`. */
|
|
213
|
+
privKey?: string;
|
|
214
|
+
/**
|
|
215
|
+
* Generate a short-lived ECDSA P-256 certificate (13 days; browsers refuse a pinned one valid for more than 14) and
|
|
216
|
+
* expose its SHA-256 as `webTransport().certificateHash`, for the client's `serverCertificateHashes`. It is not
|
|
217
|
+
* renewed: restart the server before `notAfter`. An object takes `{ hosts, validityDays }`.
|
|
218
|
+
*/
|
|
219
|
+
selfSigned?: boolean | { hosts?: string[]; validityDays?: number };
|
|
220
|
+
/** QUIC stateless-reset secret (default: random). */
|
|
221
|
+
secret?: string;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export interface WshServerWebTransport {
|
|
225
|
+
port: number;
|
|
226
|
+
host: string;
|
|
227
|
+
path: string;
|
|
228
|
+
/** `https://host:port/path`, what a client connects to. */
|
|
229
|
+
url: string;
|
|
230
|
+
/** SHA-256 of the DER certificate, when `selfSigned` made it; `null` for a certificate you supplied. */
|
|
231
|
+
certificateHash: Uint8Array | null;
|
|
232
|
+
certificateHashHex: string | null;
|
|
233
|
+
/** When the `selfSigned` certificate expires; `null` otherwise. */
|
|
234
|
+
notAfter: Date | null;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export interface WshSelfSignedCertificate {
|
|
238
|
+
/** PEM certificate. */
|
|
239
|
+
cert: string;
|
|
240
|
+
/** PEM (PKCS#8) private key. */
|
|
241
|
+
privKey: string;
|
|
242
|
+
/** SHA-256 of the DER certificate: the value to pin. */
|
|
243
|
+
hash: Uint8Array;
|
|
244
|
+
hashHex: string;
|
|
245
|
+
notBefore: Date;
|
|
246
|
+
notAfter: Date;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** A dependency-free self-signed ECDSA P-256 certificate fit for WebTransport's `serverCertificateHashes`. */
|
|
250
|
+
export function generateSelfSignedCertificate(opts?: { hosts?: string[]; validityDays?: number; now?: Date }): WshSelfSignedCertificate;
|
|
251
|
+
|
|
172
252
|
export interface WshServerOptions {
|
|
173
253
|
/** Bind address (default `127.0.0.1`). */
|
|
174
254
|
host?: string;
|
|
@@ -196,6 +276,18 @@ export interface WshServerOptions {
|
|
|
196
276
|
sessions?: true | WshServerSessionsOptions;
|
|
197
277
|
/** Alias for `sessions.sessionSecret`. */
|
|
198
278
|
sessionSecret?: string | Uint8Array;
|
|
279
|
+
/**
|
|
280
|
+
* Act as a relay: peers (`createReverseHost`, or any client's `connectReverse()`) register a signed record,
|
|
281
|
+
* operators list and `reverseConnect()` to them, and traffic is carried between the two as `RelayForward`.
|
|
282
|
+
* Default deny -- give both `canRegister` and `canConnect`. Off by default.
|
|
283
|
+
*/
|
|
284
|
+
relay?: WshServerRelayOptions;
|
|
285
|
+
/**
|
|
286
|
+
* Also listen for WebTransport (HTTP/3 over UDP) clients -- the same auth, exec, pty, fs, mcp, sessions and relay as the
|
|
287
|
+
* WebSocket listener, with real independent streams. Needs the optional peers `@fails-components/webtransport` and
|
|
288
|
+
* `@fails-components/webtransport-transport-http3-quiche`, imported only when this is set. Off by default.
|
|
289
|
+
*/
|
|
290
|
+
webTransport?: WshServerWebTransportOptions;
|
|
199
291
|
/** Serve MCP tools (`McpDiscover` / `McpCall`) and advertise `mcp-call-id`. Off by default. */
|
|
200
292
|
mcp?: WshServerMcpOptions;
|
|
201
293
|
/** How long exec output waits for a client that has not yet opened its data stream (default 3000). */
|
|
@@ -215,6 +307,10 @@ export interface WshServer {
|
|
|
215
307
|
close(): Promise<void>;
|
|
216
308
|
/** The bound address, or `null` when not listening. */
|
|
217
309
|
address(): WshServerAddress | null;
|
|
310
|
+
/** The WebTransport listener; `null` when not configured or before `listen()` resolves. */
|
|
311
|
+
webTransport(): WshServerWebTransport | null;
|
|
312
|
+
/** Fingerprints of the peers currently registered with this relay (`[]` when it is not one). */
|
|
313
|
+
peerFingerprints(): string[];
|
|
218
314
|
/** The advertised host identity; `null` before `listen()` resolves or without a `hostKey`. */
|
|
219
315
|
hostKey(): WshServerHostKey | null;
|
|
220
316
|
}
|
|
@@ -226,3 +322,40 @@ export function parseAuthorizedKeys(text: string): Uint8Array[];
|
|
|
226
322
|
|
|
227
323
|
/** ServerHello feature: the host discovers client-opened exec data streams itself (no primer byte). */
|
|
228
324
|
export const STREAM_ANNOUNCE: 'stream-announce';
|
|
325
|
+
|
|
326
|
+
export interface WshReverseHostOptions {
|
|
327
|
+
/** The relay to register with (`ws://` / `wss://`). */
|
|
328
|
+
url: string;
|
|
329
|
+
username: string;
|
|
330
|
+
/** This host's identity; its fingerprint is what operators connect to. */
|
|
331
|
+
keyPair: CryptoKeyPair;
|
|
332
|
+
/** Who may be bridged to this host. Default: nobody. */
|
|
333
|
+
accept?: (operator: { fingerprint: string; username: string }) => boolean | Promise<boolean>;
|
|
334
|
+
exec?: true | WshServerExecOptions | WshExecRunner;
|
|
335
|
+
pty?: WshServerPtyOptions;
|
|
336
|
+
fs?: WshServerFsOptions;
|
|
337
|
+
mcp?: WshServerMcpOptions;
|
|
338
|
+
/** Dial again, with backoff, after the relay connection ends -- which a relay does when a bridge does (default true). */
|
|
339
|
+
reconnect?: boolean;
|
|
340
|
+
/** Extra options for the relay connection (`expectHostKey`, `knownHosts`, `trustOnFirstUse`, ...). */
|
|
341
|
+
connect?: Record<string, unknown>;
|
|
342
|
+
peerType?: string;
|
|
343
|
+
onLog?: (line: string) => void;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
export interface WshReverseHost {
|
|
347
|
+
/** Dial the relay and register (rejects if the first attempt fails). Registration is not acknowledged on the wire. */
|
|
348
|
+
start(): Promise<{ fingerprint: string }>;
|
|
349
|
+
/** Leave the relay and end any bridged session. */
|
|
350
|
+
close(): Promise<void>;
|
|
351
|
+
/** What operators connect to; `null` before `start()`. */
|
|
352
|
+
readonly fingerprint: string | null;
|
|
353
|
+
/** Is the relay connection up right now? */
|
|
354
|
+
readonly connected: boolean;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* A host that dials OUT to a relay and serves the operator it bridges to it, with the same exec / pty / fs / mcp
|
|
359
|
+
* backends `createWshServer` serves direct clients with. For a machine that cannot accept connections.
|
|
360
|
+
*/
|
|
361
|
+
export function createReverseHost(options: WshReverseHostOptions): WshReverseHost;
|