@johnhenry/wsh 0.25.1 → 0.27.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 +115 -23
- package/package.json +1 -1
- package/src/client.mjs +33 -2
- package/src/index.d.ts +11 -2
- package/src/messages.gen.mjs +6 -13
- package/src/server/connection.mjs +17 -6
- package/src/server/index.d.ts +49 -3
- package/src/server/index.mjs +22 -1
- package/src/server/relay.mjs +21 -5
- package/src/server/reverse-host.mjs +10 -3
- package/src/server/self-signed.mjs +4 -2
- package/src/server/webtransport.mjs +161 -41
package/README.md
CHANGED
|
@@ -220,11 +220,66 @@ await client.connect(url, {
|
|
|
220
220
|
client writes no primer.
|
|
221
221
|
- **`selfSigned: true`** generates (no dependency) an ECDSA P-256 certificate valid for 13 days -- a browser refuses a
|
|
222
222
|
pinned certificate valid for more than 14 -- and exposes the SHA-256 for `serverCertificateHashes`
|
|
223
|
-
(`{ selfSigned: { hosts, validityDays } }` to change the SANs or the lifetime).
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
[Pinning a Self-Signed Certificate](#pinning-a-self-signed-certificate)).
|
|
223
|
+
(`{ selfSigned: { hosts, validityDays } }` to change the SANs or the lifetime). A certificate you pass as
|
|
224
|
+
`cert`/`privKey` is used as is (`certificateHash` is then `null`, and nothing rotates).
|
|
225
|
+
`generateSelfSignedCertificate()` is exported for the same certificate without a server. The web platform's other
|
|
226
|
+
pinning rules apply (`https:` URL, HTTP/3 only; see
|
|
227
|
+
[Pinning a Self-Signed Certificate](#pinning-a-self-signed-certificate)). **It is rotated, see below.**
|
|
228
|
+
- Node has no `WebTransport` global: a Node *client* needs `globalThis.WebTransport = (await import(
|
|
229
|
+
'@fails-components/webtransport')).WebTransport` first. UDP, so a firewall must allow `port`, and there
|
|
230
|
+
is no `wss://`-style TLS terminator in front: the certificate is the one this process serves.
|
|
231
|
+
- Tests (`test/server-webtransport.test.mjs`) run the stock client against it over a real HTTP/3
|
|
232
|
+
connection and are **skipped, with the reason, where the native binary cannot load**.
|
|
233
|
+
|
|
234
|
+
#### Certificate rotation
|
|
235
|
+
|
|
236
|
+
A pinned certificate cannot just be renewed in place: clients hold its hash, and the platform
|
|
237
|
+
takes none valid for more than 14 days. So a `selfSigned` certificate is rotated **ahead of its expiry, with an
|
|
238
|
+
overlap in which both hashes are published**:
|
|
239
|
+
|
|
240
|
+
```js
|
|
241
|
+
server.certificateHashes(); // [{ algorithm: 'sha-256', value }, ...]: what a client should pin right now
|
|
242
|
+
await server.rotateCertificate(); // make the next certificate now and publish its hash too -> [current, next]
|
|
243
|
+
await server.rotateCertificate({ activate: true }); // ... and switch the listener to it immediately
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
- **Automatically**: `prepareBeforeMs` before `notAfter` (default 3 days, at most a third of the validity) the next
|
|
247
|
+
certificate is generated and `certificateHashes()` becomes `[current, next]`; `activateBeforeMs` before `notAfter`
|
|
248
|
+
(default 1 hour, at most a sixth) the listener switches to it and the list drops the old hash. Then the next rotation
|
|
249
|
+
is scheduled from the new certificate. `selfSigned: { rotate: false }` turns this off (restart before `notAfter`,
|
|
250
|
+
as before); `{ prepareBeforeMs, activateBeforeMs, validityDays }` tune it (tests use a seconds-long validity).
|
|
251
|
+
The same list is on `server.webTransport().certificateHashes()`, with validity and which one is active in
|
|
252
|
+
`.certificates()`; `certificateHash`/`notAfter` there always describe the certificate being presented.
|
|
253
|
+
- **The switch restarts the HTTP/3 listener on the same port.** The native transport has no way to swap a certificate
|
|
254
|
+
in place and presents one certificate at a time, so live WebTransport sessions end at the switch (the WebSocket
|
|
255
|
+
listener is untouched) and clients reconnect. A client that pinned the overlap list `[current, next]` before the
|
|
256
|
+
switch connects after it; one that only has the old hash does not, so it must **re-pin**.
|
|
257
|
+
- **Client re-pin flow.** `serverCertificateHashes` takes several values, and `connect()` / `WebTransportTransport` pass the
|
|
258
|
+
whole array. Publish `server.certificateHashes()` over something the client already trusts (your page's
|
|
259
|
+
HTTPS, an API call, a config endpoint) and have the client fetch it (a) before connecting and (b) again whenever
|
|
260
|
+
the WebTransport handshake fails, then retry once:
|
|
261
|
+
|
|
262
|
+
```js
|
|
263
|
+
// server: GET /wsh-pins -> hex strings (parseCertificateHash() on the client accepts hex)
|
|
264
|
+
app.get('/wsh-pins', (req, res) => res.json(server.certificateHashes().map((h) => Buffer.from(h.value).toString('hex'))));
|
|
265
|
+
|
|
266
|
+
// client
|
|
267
|
+
async function connectPinned(url, opts) {
|
|
268
|
+
for (let attempt = 0; ; attempt++) {
|
|
269
|
+
const pins = (await (await fetch('/wsh-pins')).json()).map((hex) => ({ algorithm: 'sha-256', value: hexToBytes(hex) }));
|
|
270
|
+
try { return await client.connect(url, { ...opts, transport: 'wt', webTransport: { serverCertificateHashes: pins } }); }
|
|
271
|
+
catch (err) { if (attempt === 1) throw err; } // stale pins: fetch the list again and retry once
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
Because the next hash is published `prepareBeforeMs` (3 days) before the switch, a client that refreshes at least that
|
|
277
|
+
often (or on any failure) never sees a certificate it has not pinned. A client that stays up for longer than a certificate
|
|
278
|
+
lives and never refetches needs this retry; `transport: 'wt'` makes the failure explicit instead of falling back to
|
|
279
|
+
`wss://`.
|
|
280
|
+
- Not covered: a certificate you supply (`cert`/`privKey`) is yours to renew; restart the server (or run it behind a
|
|
281
|
+
CA-issued certificate, which needs no pinning).
|
|
282
|
+
|
|
228
283
|
- Node has no `WebTransport` global: a Node *client* needs `globalThis.WebTransport = (await import(
|
|
229
284
|
'@fails-components/webtransport')).WebTransport` first. UDP, so a firewall must allow `port`, and there
|
|
230
285
|
is no `wss://`-style TLS terminator in front: the certificate is the one this process serves.
|
|
@@ -287,27 +342,56 @@ const s = await client.openSession({ type: 'exec', command: 'echo hello' });
|
|
|
287
342
|
checked against the allowlist (a non-forwardable or undecodable inner is
|
|
288
343
|
dropped, logged), and re-wrapped with the real sender; traffic from a connection
|
|
289
344
|
that is not bridged is never forwarded.
|
|
290
|
-
- **One operator per peer at a time
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
345
|
+
- **One operator per peer at a time**, by design: a `RelayForward` names its
|
|
346
|
+
sender but not its recipient, and nothing on the wire tells a peer that one
|
|
347
|
+
of several operators left, so replies could not be addressed and a departed
|
|
348
|
+
operator's work could not be cleaned up. A second `ReverseConnect` to a peer
|
|
349
|
+
that already has an operator (or a request awaiting its answer) is rejected
|
|
350
|
+
with `ReverseReject.reason` `busy: this peer already has an operator ...`
|
|
351
|
+
(exported as `BUSY_PEER` from `src/server/relay.mjs`), and an operator that
|
|
352
|
+
already has a bridge or a request in flight is rejected with `busy: you
|
|
353
|
+
already have a bridge ...` (`BUSY_OPERATOR`); a request the peer does not
|
|
354
|
+
answer within `connectTimeoutMs` (default 8 s) is `peer did not respond`.
|
|
355
|
+
A bridge lasts as long as both connections: when either ends, the relay
|
|
356
|
+
closes the other, so nothing an operator started outlives it (the reverse
|
|
357
|
+
host kills its processes). `createReverseHost` redials with backoff by
|
|
358
|
+
default (`reconnect: false` to turn it off), so it is registered again for
|
|
359
|
+
the next operator. Lifting the limit needs a spec addition (a recipient on
|
|
360
|
+
`RelayForward`, a bridge-ended notice) shared with the Rust side; it is
|
|
361
|
+
tracked rather than built.
|
|
297
362
|
- **On the peer**, the bridged operator is served by the same connection code as
|
|
298
363
|
a direct client, over an in-memory message pipe instead of a socket. Nothing a
|
|
299
364
|
client opens can cross the relay but control messages, so exec sessions are
|
|
300
365
|
`data_mode: 'virtual'` there (stdin and output ride `SessionData`; there is no
|
|
301
366
|
stdin EOF).
|
|
302
|
-
- **
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
367
|
+
- **End-to-end encryption through the bridge.** `KeyExchange` and `EncryptedFrame`
|
|
368
|
+
are on the spec's `forwardable` list, so an operator and a peer can run
|
|
369
|
+
`initiateE2E()` / `enableE2E()` with each other through the relay: it carries
|
|
370
|
+
the CBOR of each as opaque bytes and holds no key, so it sees only public
|
|
371
|
+
values and ciphertext (`test/server-relay-bridge.test.mjs` checks the relay
|
|
372
|
+
never holds the plaintext). **`KeyExchange` is unauthenticated**: this
|
|
373
|
+
protects against a relay that only observes, not against one that substitutes
|
|
374
|
+
the exchanged keys, so if the relay operator is not trusted, authenticate the
|
|
375
|
+
derived key yourself (compare it out of band, or sign it with the keys the
|
|
376
|
+
bridge already names). Only a stock-client peer (a browser tab, `connectReverse`)
|
|
377
|
+
speaks E2E: **`createReverseHost` does not answer `KeyExchange` or open
|
|
378
|
+
`EncryptedFrame`s** (the Node host has no E2E layer at all), so against it traffic
|
|
379
|
+
is in the clear to the relay -- serve the relay over `wss://` and treat its
|
|
380
|
+
operator as trusted. Key exchange from a connection that is not bridged is
|
|
381
|
+
never carried.
|
|
382
|
+
- **Feature gates follow the host behind the bridge.** The host's own features
|
|
383
|
+
(`file-write`, `file-rename`, `mcp-call-id`) are stated in the optional
|
|
384
|
+
`ReverseAccept.features`; the relay forwards the list (bounded: at most 64
|
|
385
|
+
strings of 128 characters, otherwise it is dropped) and, once
|
|
386
|
+
`reverseConnect()` resolves, `client.hasFeature()` / `client.features` answer
|
|
387
|
+
from it instead of the relay's `ServerHello`. `client.bridgedFeatures` is that
|
|
388
|
+
list (`null` before a bridge, or when the peer did not say, in which case the
|
|
389
|
+
gates fall back to `client.serverFeatures`, the relay's, as before).
|
|
390
|
+
`createReverseHost` always states its features (`reportFeatures: false` leaves
|
|
391
|
+
them out). A **Rust `wsh-server` / `wsh` older than `rust-v0.3.0` rejects a
|
|
392
|
+
`ReverseAccept` that carries `features`** (the message is `deny_unknown_fields`
|
|
393
|
+
there): against such a relay or operator, set `reportFeatures: false` on the
|
|
394
|
+
host (and use `ReverseAccept` without the field in your own peers).
|
|
311
395
|
|
|
312
396
|
### Sessions: attach / resume / detach
|
|
313
397
|
|
|
@@ -727,7 +811,10 @@ The constraints are the platform's, not wsh's:
|
|
|
727
811
|
|
|
728
812
|
- The URL must be `https:`; pinning is HTTP/3 only, with no HTTP/2 fallback.
|
|
729
813
|
- The certificate must use an **ECDSA P-256** key and be valid for **at most
|
|
730
|
-
14 days**, so it has to be reissued on a schedule.
|
|
814
|
+
14 days**, so it has to be reissued on a schedule. `serverCertificateHashes`
|
|
815
|
+
takes several digests: pin the current and the next one across a rotation
|
|
816
|
+
(the Node host's `certificateHashes()` publishes both; see
|
|
817
|
+
[Certificate rotation](#certificate-rotation)).
|
|
731
818
|
- Connection pooling is disabled for a pinned connection.
|
|
732
819
|
- Only `sha-256` is accepted as the algorithm.
|
|
733
820
|
|
|
@@ -828,7 +915,7 @@ than left to be found.
|
|
|
828
915
|
| Typed RPC channels (`openRpc`, `rpc` sessions; `wsh-host`, `wsh-fs`, `mcp`) | `WshClient.openRpc()`, `RpcChannel` | none yet (a Rust `wsh-server` does not advertise `rpc`; a JS client falls back to `FileOp` for files) | **JS server only, no wire change**: `Open { kind: 'rpc', command: <protocol> }` plus `rpc*` `ServerHello` features, negotiated so it fails fast elsewhere |
|
|
829
916
|
| 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 |
|
|
830
917
|
| 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` does the same (`rust-v0.2.0`+; fixed 256 KiB ring) |
|
|
831
|
-
| 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 |
|
|
918
|
+
| 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 (`busy:` reasons say so); E2E and bridged feature gates need `rust-v0.3.0`+ on the Rust side |
|
|
832
919
|
| 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 |
|
|
833
920
|
| 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 |
|
|
834
921
|
| 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 |
|
|
@@ -900,6 +987,11 @@ out explicitly below rather than left to be discovered.
|
|
|
900
987
|
`SHA-256("wsh-v1\0" || lp(username) || lp(session_id) || nonce || channel_binding)`,
|
|
901
988
|
so a signature can't be replayed against a different session or relabeled
|
|
902
989
|
to a different username.
|
|
990
|
+
- **E2E through a relay is opt-in and unauthenticated key agreement.** The relay
|
|
991
|
+
carries `KeyExchange` / `EncryptedFrame` between a bridged operator and peer
|
|
992
|
+
without being able to read the sealed frames, but `KeyExchange` carries no
|
|
993
|
+
signature: a relay that substitutes keys is not detected (see the [relay
|
|
994
|
+
section](#relay--reverse-mode)).
|
|
903
995
|
- **Signed peer records.** Reverse-mode registration is self-signed by the
|
|
904
996
|
peer's identity key (the libp2p RFC 0002/0003 pattern), in a signing
|
|
905
997
|
domain separate from the auth challenge. `listPeers()` verifies every
|
package/package.json
CHANGED
package/src/client.mjs
CHANGED
|
@@ -313,6 +313,11 @@ export class WshClient {
|
|
|
313
313
|
|
|
314
314
|
/** @type {string[]} Server-advertised features from SERVER_HELLO. */
|
|
315
315
|
#serverFeatures = [];
|
|
316
|
+
/**
|
|
317
|
+
* @type {string[]|null} Features of the host behind an accepted relay bridge, from
|
|
318
|
+
* `ReverseAccept.features`; `null` when no bridge is up or the peer stated none.
|
|
319
|
+
*/
|
|
320
|
+
#bridgedFeatures = null;
|
|
316
321
|
|
|
317
322
|
/** @type {number|null} Ping interval handle. */
|
|
318
323
|
#pingTimer = null;
|
|
@@ -437,11 +442,29 @@ export class WshClient {
|
|
|
437
442
|
return new Map(this.#sessions);
|
|
438
443
|
}
|
|
439
444
|
|
|
440
|
-
/**
|
|
445
|
+
/**
|
|
446
|
+
* The features this connection's operations can rely on: those of the host
|
|
447
|
+
* behind an accepted relay bridge when it stated them (`ReverseAccept.features`,
|
|
448
|
+
* see `reverseConnect()`), otherwise the server's `SERVER_HELLO` features.
|
|
449
|
+
*/
|
|
441
450
|
get features() {
|
|
451
|
+
return [...(this.#bridgedFeatures ?? this.#serverFeatures)];
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/** The `SERVER_HELLO` features of the server this connection is to (the relay, over a bridge). */
|
|
455
|
+
get serverFeatures() {
|
|
442
456
|
return [...this.#serverFeatures];
|
|
443
457
|
}
|
|
444
458
|
|
|
459
|
+
/**
|
|
460
|
+
* Features of the host behind an accepted relay bridge, as it stated them in
|
|
461
|
+
* `ReverseAccept.features`; `null` when there is no bridge, or the peer did
|
|
462
|
+
* not say (then `hasFeature()` falls back to the server's own).
|
|
463
|
+
*/
|
|
464
|
+
get bridgedFeatures() {
|
|
465
|
+
return this.#bridgedFeatures ? [...this.#bridgedFeatures] : null;
|
|
466
|
+
}
|
|
467
|
+
|
|
445
468
|
/**
|
|
446
469
|
* Low-level transport reference.
|
|
447
470
|
* Exposed for relay message replies (IncomingSession._sendReply).
|
|
@@ -458,7 +481,7 @@ export class WshClient {
|
|
|
458
481
|
* @returns {boolean}
|
|
459
482
|
*/
|
|
460
483
|
hasFeature(name) {
|
|
461
|
-
return this.#serverFeatures.includes(name);
|
|
484
|
+
return (this.#bridgedFeatures ?? this.#serverFeatures).includes(name);
|
|
462
485
|
}
|
|
463
486
|
|
|
464
487
|
/**
|
|
@@ -980,6 +1003,7 @@ export class WshClient {
|
|
|
980
1003
|
|
|
981
1004
|
this.#stopPing();
|
|
982
1005
|
this.#state = STATE_CLOSED;
|
|
1006
|
+
this.#bridgedFeatures = null;
|
|
983
1007
|
|
|
984
1008
|
// Close all sessions concurrently.
|
|
985
1009
|
const closePromises = [];
|
|
@@ -1217,6 +1241,12 @@ export class WshClient {
|
|
|
1217
1241
|
|
|
1218
1242
|
if (response.type === MSG.REVERSE_ACCEPT) {
|
|
1219
1243
|
this.trustRelayPeer(response.target_fingerprint);
|
|
1244
|
+
// The relay's SERVER_HELLO describes the relay, not the host behind the
|
|
1245
|
+
// bridge: when that host says what it supports, feature gates
|
|
1246
|
+
// (file-write, file-rename, mcp-call-id) follow it instead.
|
|
1247
|
+
this.#bridgedFeatures = Array.isArray(response.features) && response.features.every((f) => typeof f === 'string')
|
|
1248
|
+
? [...response.features]
|
|
1249
|
+
: null;
|
|
1220
1250
|
}
|
|
1221
1251
|
|
|
1222
1252
|
return response;
|
|
@@ -2747,6 +2777,7 @@ export class WshClient {
|
|
|
2747
2777
|
if (this.#state === STATE_CLOSED) return;
|
|
2748
2778
|
|
|
2749
2779
|
this.#state = STATE_CLOSED;
|
|
2780
|
+
this.#bridgedFeatures = null;
|
|
2750
2781
|
this.#stopPing();
|
|
2751
2782
|
this.#rejectAllWaiters(new Error('Transport closed'));
|
|
2752
2783
|
|
package/src/index.d.ts
CHANGED
|
@@ -603,6 +603,8 @@ export function reverseAccept(opts?: {
|
|
|
603
603
|
supportsReplay?: boolean;
|
|
604
604
|
supportsEcho?: boolean;
|
|
605
605
|
supportsTermSync?: boolean;
|
|
606
|
+
/** Features of this (bridged) host, in the vocabulary of `ServerHello.features`; omit rather than send `[]`. */
|
|
607
|
+
features?: string[];
|
|
606
608
|
}): WshMessage;
|
|
607
609
|
|
|
608
610
|
export function reverseReject(opts?: {
|
|
@@ -1970,8 +1972,15 @@ export class WshClient {
|
|
|
1970
1972
|
/** Read-only view of active sessions. */
|
|
1971
1973
|
readonly sessions: Map<number, WshSession>;
|
|
1972
1974
|
|
|
1973
|
-
/**
|
|
1975
|
+
/**
|
|
1976
|
+
* The features this connection can rely on: those of the host behind an accepted relay bridge when it
|
|
1977
|
+
* stated them (`ReverseAccept.features`), otherwise the server's `SERVER_HELLO` features.
|
|
1978
|
+
*/
|
|
1974
1979
|
readonly features: string[];
|
|
1980
|
+
/** The `SERVER_HELLO` features of the server connected to (the relay, over a bridge). */
|
|
1981
|
+
readonly serverFeatures: string[];
|
|
1982
|
+
/** Features of the host behind an accepted relay bridge as it stated them; `null` when there is no bridge or it did not say. */
|
|
1983
|
+
readonly bridgedFeatures: string[] | null;
|
|
1975
1984
|
|
|
1976
1985
|
/**
|
|
1977
1986
|
* Low-level transport reference.
|
|
@@ -2004,7 +2013,7 @@ export class WshClient {
|
|
|
2004
2013
|
onGatewayMessage: ((msg: WshMessage) => void) | null;
|
|
2005
2014
|
|
|
2006
2015
|
/**
|
|
2007
|
-
* Check if the server
|
|
2016
|
+
* Check if a feature is available: advertised by the server, or by the bridged host (see `features`).
|
|
2008
2017
|
*/
|
|
2009
2018
|
hasFeature(name: string): boolean;
|
|
2010
2019
|
|
package/src/messages.gen.mjs
CHANGED
|
@@ -495,19 +495,10 @@ export function reverseConnect({ targetFingerprint, username, fromFingerprint }
|
|
|
495
495
|
};
|
|
496
496
|
}
|
|
497
497
|
|
|
498
|
-
export function reverseAccept({ targetFingerprint, username, capabilities = [], peerType = "host", shellBackend = "pty", supportsAttach = false, supportsReplay = false, supportsEcho = false, supportsTermSync = false } = {}) {
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
username,
|
|
503
|
-
capabilities,
|
|
504
|
-
peer_type: peerType,
|
|
505
|
-
shell_backend: shellBackend,
|
|
506
|
-
supports_attach: supportsAttach,
|
|
507
|
-
supports_replay: supportsReplay,
|
|
508
|
-
supports_echo: supportsEcho,
|
|
509
|
-
supports_term_sync: supportsTermSync,
|
|
510
|
-
};
|
|
498
|
+
export function reverseAccept({ targetFingerprint, username, capabilities = [], peerType = "host", shellBackend = "pty", supportsAttach = false, supportsReplay = false, supportsEcho = false, supportsTermSync = false, features } = {}) {
|
|
499
|
+
const msg = { type: MSG.REVERSE_ACCEPT, target_fingerprint: targetFingerprint, username, capabilities, peer_type: peerType, shell_backend: shellBackend, supports_attach: supportsAttach, supports_replay: supportsReplay, supports_echo: supportsEcho, supports_term_sync: supportsTermSync };
|
|
500
|
+
if (features !== undefined) msg.features = features;
|
|
501
|
+
return msg;
|
|
511
502
|
}
|
|
512
503
|
|
|
513
504
|
export function reverseReject({ targetFingerprint, username, reason } = {}) {
|
|
@@ -962,6 +953,8 @@ export const RELAY_FORWARDABLE = new Set([
|
|
|
962
953
|
MSG.ECHO_STATE,
|
|
963
954
|
MSG.TERM_SYNC,
|
|
964
955
|
MSG.TERM_DIFF,
|
|
956
|
+
MSG.KEY_EXCHANGE,
|
|
957
|
+
MSG.ENCRYPTED_FRAME,
|
|
965
958
|
]);
|
|
966
959
|
|
|
967
960
|
/**
|
|
@@ -31,6 +31,22 @@ import { RpcChannel, RPC_FEATURE, RPC_MAX_MESSAGE_PREFIX, rpcProtocolFeature } f
|
|
|
31
31
|
import { HOST_KEY_PREFIX, HOST_KEY_SIG_PREFIX, findClientNonce, hostKeyProofMessage, toHex } from '../host-key.mjs';
|
|
32
32
|
|
|
33
33
|
export const STREAM_ANNOUNCE = 'stream-announce';
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The features a host built from `cfg` advertises (`ServerHello.features`, minus the host-key proof).
|
|
37
|
+
* Over a relay bridge (`dataStreams: false`) there are no client-opened streams, so the
|
|
38
|
+
* stream-based ones (`stream-announce`, `rpc`) are not offered; a reverse host reports this
|
|
39
|
+
* list to the operator in `ReverseAccept.features`.
|
|
40
|
+
*/
|
|
41
|
+
export function hostFeatures(cfg, { dataStreams = true } = {}) {
|
|
42
|
+
const features = dataStreams ? [STREAM_ANNOUNCE] : [];
|
|
43
|
+
if (cfg.mcp) features.push(MCP_CALL_ID_FEATURE);
|
|
44
|
+
if (cfg.files) features.push('file-transfer', 'file-write', 'file-rename');
|
|
45
|
+
if (cfg.rpc && dataStreams && cfg.rpc.protocols.size) {
|
|
46
|
+
features.push(RPC_FEATURE, ...[...cfg.rpc.protocols.keys()].map(rpcProtocolFeature), RPC_MAX_MESSAGE_PREFIX + cfg.rpc.maxMessageBytes);
|
|
47
|
+
}
|
|
48
|
+
return features;
|
|
49
|
+
}
|
|
34
50
|
const MAX_PENDING_WRITES = 8;
|
|
35
51
|
const MAX_RENAME_PATH_BYTES = 4096;
|
|
36
52
|
const WRITE_IDLE_MS = 30_000;
|
|
@@ -201,12 +217,7 @@ export function createConnectionFactory(cfg) {
|
|
|
201
217
|
}
|
|
202
218
|
state.username = user;
|
|
203
219
|
state.method = method;
|
|
204
|
-
const features =
|
|
205
|
-
if (cfg.mcp) features.push(MCP_CALL_ID_FEATURE);
|
|
206
|
-
if (cfg.files) features.push('file-transfer', 'file-write', 'file-rename');
|
|
207
|
-
if (cfg.rpc && dataStreams && cfg.rpc.protocols.size) {
|
|
208
|
-
features.push(RPC_FEATURE, ...[...cfg.rpc.protocols.keys()].map(rpcProtocolFeature), RPC_MAX_MESSAGE_PREFIX + cfg.rpc.maxMessageBytes);
|
|
209
|
-
}
|
|
220
|
+
const features = hostFeatures(cfg, { dataStreams });
|
|
210
221
|
let hostFingerprint;
|
|
211
222
|
if (cfg.hostKey) {
|
|
212
223
|
hostFingerprint = cfg.hostKey.fingerprint;
|
package/src/server/index.d.ts
CHANGED
|
@@ -215,10 +215,22 @@ export interface WshServerWebTransportOptions {
|
|
|
215
215
|
privKey?: string;
|
|
216
216
|
/**
|
|
217
217
|
* Generate a short-lived ECDSA P-256 certificate (13 days; browsers refuse a pinned one valid for more than 14) and
|
|
218
|
-
* expose its SHA-256 as `webTransport().certificateHash`, for the client's `serverCertificateHashes`.
|
|
219
|
-
*
|
|
218
|
+
* expose its SHA-256 as `webTransport().certificateHash`, for the client's `serverCertificateHashes`.
|
|
219
|
+
*
|
|
220
|
+
* It is rotated before it expires: `prepareBeforeMs` ahead of `notAfter` (default: 3 days, at most a third of the
|
|
221
|
+
* validity) the next certificate is generated and its hash published next to the current one
|
|
222
|
+
* (`server.certificateHashes()` returns both: a client that re-reads the list pins both), and `activateBeforeMs` ahead
|
|
223
|
+
* (default: 1 hour, at most a sixth of the validity) the listener switches to it. The switch restarts the HTTP/3
|
|
224
|
+
* listener on the same port, so live WebTransport sessions end and clients reconnect. `rotate: false` turns
|
|
225
|
+
* all of this off (restart before `notAfter`). An object takes `{ hosts, validityDays }` for the certificates too.
|
|
220
226
|
*/
|
|
221
|
-
selfSigned?: boolean | {
|
|
227
|
+
selfSigned?: boolean | {
|
|
228
|
+
hosts?: string[];
|
|
229
|
+
validityDays?: number;
|
|
230
|
+
rotate?: boolean;
|
|
231
|
+
prepareBeforeMs?: number;
|
|
232
|
+
activateBeforeMs?: number;
|
|
233
|
+
};
|
|
222
234
|
/** QUIC stateless-reset secret (default: random). */
|
|
223
235
|
secret?: string;
|
|
224
236
|
}
|
|
@@ -234,6 +246,27 @@ export interface WshServerWebTransport {
|
|
|
234
246
|
certificateHashHex: string | null;
|
|
235
247
|
/** When the `selfSigned` certificate expires; `null` otherwise. */
|
|
236
248
|
notAfter: Date | null;
|
|
249
|
+
/** The `serverCertificateHashes` to pin now: the certificate being presented and, during a rotation's overlap, the next. */
|
|
250
|
+
certificateHashes(): WshPinnedCertificateHash[];
|
|
251
|
+
/** The same certificates with their validity and which one is being presented. */
|
|
252
|
+
certificates(): WshServerCertificateInfo[];
|
|
253
|
+
/** See `WshServer.rotateCertificate()`. */
|
|
254
|
+
rotateCertificate(options?: { activate?: boolean }): Promise<{ current: WshServerCertificateInfo; next: WshServerCertificateInfo | null }>;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** A `serverCertificateHashes` entry, ready to pass to `new WebTransport(url, { serverCertificateHashes })`. */
|
|
258
|
+
export interface WshPinnedCertificateHash {
|
|
259
|
+
algorithm: 'sha-256';
|
|
260
|
+
value: Uint8Array;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export interface WshServerCertificateInfo {
|
|
264
|
+
hash: Uint8Array;
|
|
265
|
+
hashHex: string;
|
|
266
|
+
notBefore: Date;
|
|
267
|
+
notAfter: Date;
|
|
268
|
+
/** Is it the one the listener presents right now? */
|
|
269
|
+
active: boolean;
|
|
237
270
|
}
|
|
238
271
|
|
|
239
272
|
export interface WshSelfSignedCertificate {
|
|
@@ -344,6 +377,19 @@ export interface WshServer {
|
|
|
344
377
|
address(): WshServerAddress | null;
|
|
345
378
|
/** The WebTransport listener; `null` when not configured or before `listen()` resolves. */
|
|
346
379
|
webTransport(): WshServerWebTransport | null;
|
|
380
|
+
/**
|
|
381
|
+
* The `serverCertificateHashes` a WebTransport client should pin right now: the `selfSigned` certificate being
|
|
382
|
+
* presented and, during a rotation's overlap window, the next one. `[]` without a WebTransport listener or with a
|
|
383
|
+
* certificate you supplied. Serve it to your clients out of band (see the README's re-pin flow).
|
|
384
|
+
*/
|
|
385
|
+
certificateHashes(): WshPinnedCertificateHash[];
|
|
386
|
+
/**
|
|
387
|
+
* Start rotating the `selfSigned` certificate: generate the next one and publish it in `certificateHashes()` now; the
|
|
388
|
+
* listener switches to it shortly before the current one expires (automatically, unless `selfSigned.rotate` is
|
|
389
|
+
* `false`). `{ activate: true }` switches immediately: live WebTransport sessions end. Rejects for a certificate
|
|
390
|
+
* you supplied, or before `listen()`.
|
|
391
|
+
*/
|
|
392
|
+
rotateCertificate(options?: { activate?: boolean }): Promise<{ current: WshServerCertificateInfo; next: WshServerCertificateInfo | null }>;
|
|
347
393
|
/** Fingerprints of the peers currently registered with this relay (`[]` when it is not one). */
|
|
348
394
|
peerFingerprints(): string[];
|
|
349
395
|
/** The advertised host identity; `null` before `listen()` resolves or without a `hostKey`. */
|
package/src/server/index.mjs
CHANGED
|
@@ -36,7 +36,8 @@ export { generateSelfSignedCertificate } from './self-signed.mjs';
|
|
|
36
36
|
* @param {{ port?: number, host?: string, path?: string, cert?: string, privKey?: string, selfSigned?: boolean | object, secret?: string }} [options.webTransport]
|
|
37
37
|
* Also listen for WebTransport (HTTP/3 over UDP) clients, with the same auth and features as the WebSocket
|
|
38
38
|
* listener. `cert` / `privKey` are PEM; `selfSigned: true` makes a 13-day ECDSA P-256 certificate and exposes its
|
|
39
|
-
* SHA-256 through `webTransport()` for the client's `serverCertificateHashes
|
|
39
|
+
* SHA-256 through `webTransport()` / `certificateHashes()` for the client's `serverCertificateHashes`, and rotates it
|
|
40
|
+
* before it expires (`rotateCertificate()`). Needs the optional peers
|
|
40
41
|
* `@fails-components/webtransport` and `@fails-components/webtransport-transport-http3-quiche`. Off unless given.
|
|
41
42
|
* @param {{ canRegister?: Function, canConnect?: Function, maxPeers?: number, connectTimeoutMs?: number }} [options.relay]
|
|
42
43
|
* Act as a relay: peers register (`ReverseRegister`, a signed record), operators list and connect to them
|
|
@@ -168,9 +169,29 @@ export function createWshServer({
|
|
|
168
169
|
return {
|
|
169
170
|
port: wt.port, host: wt.host, path: wt.path, url: `https://${wt.host.includes(':') ? `[${wt.host}]` : wt.host}:${wt.port}${wt.path}`,
|
|
170
171
|
certificateHash: wt.certificateHash, certificateHashHex: wt.certificateHashHex, notAfter: wt.notAfter,
|
|
172
|
+
certificateHashes: () => wt.certificateHashes(), certificates: () => wt.certificates(), rotateCertificate: (o) => wt.rotateCertificate(o),
|
|
171
173
|
};
|
|
172
174
|
},
|
|
173
175
|
|
|
176
|
+
/**
|
|
177
|
+
* The `serverCertificateHashes` a WebTransport client should pin right now: the `selfSigned` certificate being
|
|
178
|
+
* presented and, during a rotation's overlap window, the next one too. `[]` with no WebTransport listener or
|
|
179
|
+
* with a certificate you supplied.
|
|
180
|
+
*/
|
|
181
|
+
certificateHashes() {
|
|
182
|
+
return wt ? wt.certificateHashes() : [];
|
|
183
|
+
},
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Start rotating the `selfSigned` WebTransport certificate: generate the next one and publish its hash in
|
|
187
|
+
* `certificateHashes()` now; it is switched to shortly before the current one expires (this happens by itself
|
|
188
|
+
* unless `selfSigned: { rotate: false }`). `{ activate: true }` switches immediately, ending live sessions.
|
|
189
|
+
*/
|
|
190
|
+
rotateCertificate(options) {
|
|
191
|
+
if (!wt) return Promise.reject(new Error('rotateCertificate: there is no WebTransport listener (configure `webTransport` and `listen()`)'));
|
|
192
|
+
return wt.rotateCertificate(options);
|
|
193
|
+
},
|
|
194
|
+
|
|
174
195
|
/** Fingerprints of the peers currently registered with this relay (`[]` when it is not one). */
|
|
175
196
|
peerFingerprints() {
|
|
176
197
|
return hub ? hub.peerFingerprints() : [];
|
package/src/server/relay.mjs
CHANGED
|
@@ -27,8 +27,15 @@ export const DEFAULT_CONNECT_TIMEOUT_MS = 8000;
|
|
|
27
27
|
const MAX_FORWARD_BYTES = 8 * 1024 * 1024;
|
|
28
28
|
const MAX_REMEMBERED_SEQS = 4096;
|
|
29
29
|
const MAX_CAPABILITIES = 32;
|
|
30
|
+
const MAX_FEATURES = 64;
|
|
31
|
+
const MAX_FEATURE_LENGTH = 128;
|
|
30
32
|
const SHORT_PREFIX_MIN = 8;
|
|
31
33
|
|
|
34
|
+
/** `ReverseReject.reason` for a peer that already has an operator (or a request awaiting its answer). */
|
|
35
|
+
export const BUSY_PEER = 'busy: this peer already has an operator (the relay bridges one operator per peer at a time; retry when it leaves)';
|
|
36
|
+
/** `ReverseReject.reason` for an operator that already has a bridge, or a request in flight. */
|
|
37
|
+
export const BUSY_OPERATOR = 'busy: you already have a bridge or a pending connect on this relay (one at a time)';
|
|
38
|
+
|
|
32
39
|
const isStr = (v, max) => typeof v === 'string' && v.length <= max;
|
|
33
40
|
|
|
34
41
|
/**
|
|
@@ -186,9 +193,13 @@ export class RelayHub {
|
|
|
186
193
|
fingerprint: target.meta.fingerprint, username: target.meta.username, capabilities: target.meta.capabilities,
|
|
187
194
|
}))) return reject('no such peer');
|
|
188
195
|
if (this.#peers.get(target.meta.fingerprint) !== target) return reject('no such peer');
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
196
|
+
// One bridge per peer, and one per operator: a RelayForward names its sender but not its recipient, so a
|
|
197
|
+
// peer's reply could not be addressed to one of several operators (see the README's relay section).
|
|
198
|
+
if (this.#pairs.has(target.handle) || this.#pending.has(target.handle)) {
|
|
199
|
+
return reject(BUSY_PEER);
|
|
200
|
+
}
|
|
201
|
+
if (this.#pairs.has(operator) || [...this.#pending.values()].some((p) => p.operator === operator)) {
|
|
202
|
+
return reject(BUSY_OPERATOR);
|
|
192
203
|
}
|
|
193
204
|
const timer = setTimeout(() => {
|
|
194
205
|
if (this.#pending.get(target.handle)?.operator !== operator) return;
|
|
@@ -213,8 +224,13 @@ export class RelayHub {
|
|
|
213
224
|
if (!p) return;
|
|
214
225
|
clearTimeout(p.timer);
|
|
215
226
|
this.#pending.delete(peer);
|
|
216
|
-
// The answer names the answering peer, not whatever it wrote.
|
|
217
|
-
|
|
227
|
+
// The answer names the answering peer, not whatever it wrote; its features claim is bounded.
|
|
228
|
+
const answer = { ...msg, target_fingerprint: peer.fingerprint };
|
|
229
|
+
if (msg.type === MSG.REVERSE_ACCEPT) {
|
|
230
|
+
if (Array.isArray(msg.features) && msg.features.length <= MAX_FEATURES && msg.features.every((f) => isStr(f, MAX_FEATURE_LENGTH))) answer.features = [...msg.features];
|
|
231
|
+
else delete answer.features;
|
|
232
|
+
}
|
|
233
|
+
p.operator.send(answer);
|
|
218
234
|
if (msg.type === MSG.REVERSE_ACCEPT) {
|
|
219
235
|
this.#pairs.set(p.operator, peer);
|
|
220
236
|
this.#pairs.set(peer, p.operator);
|
|
@@ -13,7 +13,7 @@ import { WshClient } from '../client.mjs';
|
|
|
13
13
|
import { exportPublicKeyRaw, fingerprint } from '../auth.mjs';
|
|
14
14
|
import { reverseAccept, reverseReject } from '../messages.gen.mjs';
|
|
15
15
|
import { buildBackends } from './backends.mjs';
|
|
16
|
-
import { createConnectionFactory } from './connection.mjs';
|
|
16
|
+
import { createConnectionFactory, hostFeatures } from './connection.mjs';
|
|
17
17
|
|
|
18
18
|
const BACKOFF_START_MS = 500;
|
|
19
19
|
const BACKOFF_MAX_MS = 30_000;
|
|
@@ -32,10 +32,13 @@ const BACKOFF_MAX_MS = 30_000;
|
|
|
32
32
|
* @param {boolean} [options.reconnect=true] - Dial again (with backoff) after the relay connection ends. A relay ends it when a bridge does.
|
|
33
33
|
* @param {object} [options.connect] - Extra options for the relay connection (`expectHostKey`, `knownHosts`, ...).
|
|
34
34
|
* @param {string} [options.peerType='host']
|
|
35
|
+
* @param {boolean} [options.reportFeatures=true] - State this host's features in `ReverseAccept.features`, so the operator's
|
|
36
|
+
* feature gates follow this host rather than the relay. Turn off only for a relay (a Rust `wsh-server` older than the field)
|
|
37
|
+
* that rejects a `ReverseAccept` carrying it.
|
|
35
38
|
* @param {(line: string) => void} [options.onLog]
|
|
36
39
|
*/
|
|
37
40
|
export function createReverseHost({
|
|
38
|
-
url, username, keyPair, accept, exec, pty, fs, mcp, reconnect = true, connect = {}, peerType = 'host', onLog = () => {},
|
|
41
|
+
url, username, keyPair, accept, exec, pty, fs, mcp, reconnect = true, connect = {}, peerType = 'host', reportFeatures = true, onLog = () => {},
|
|
39
42
|
} = {}) {
|
|
40
43
|
if (!url) throw new TypeError('createReverseHost: url is required');
|
|
41
44
|
if (!username) throw new TypeError('createReverseHost: username is required');
|
|
@@ -46,6 +49,9 @@ export function createReverseHost({
|
|
|
46
49
|
const expose = { shell: !!backends.pty, exec: !!backends.execRunner, fs: !!backends.files, tools: !!backends.mcp };
|
|
47
50
|
const capabilities = Object.entries(expose).filter(([, on]) => on).map(([k]) => k);
|
|
48
51
|
const shellBackend = backends.pty ? 'pty' : 'exec-only';
|
|
52
|
+
// What this host can do over a bridge, for the operator's feature gates (`ReverseAccept.features`).
|
|
53
|
+
// `reportFeatures: false` leaves them out (a Rust peer or relay older than the field rejects a ReverseAccept that has it).
|
|
54
|
+
const bridgeFeatures = hostFeatures({ mcp: backends.mcp, files: backends.files }, { dataStreams: false });
|
|
49
55
|
const factory = createConnectionFactory({
|
|
50
56
|
authorize: async () => false, methods: { pubkey: false, password: null }, hostKey: null, rateLimit: null,
|
|
51
57
|
execRunner: backends.execRunner, execOptions: backends.execOptions, pty: backends.pty, files: backends.files, mcp: backends.mcp,
|
|
@@ -74,7 +80,7 @@ export function createReverseHost({
|
|
|
74
80
|
const from = req.from_fingerprint;
|
|
75
81
|
const answer = (m) => c.sendRelayControl(m).catch(() => {});
|
|
76
82
|
const refuse = (reason) => answer(reverseReject({ targetFingerprint: myFingerprint, username, reason }));
|
|
77
|
-
if (bridge || typeof from !== 'string' || !from) { await refuse('busy'); return; }
|
|
83
|
+
if (bridge || typeof from !== 'string' || !from) { await refuse('busy: this host is already serving an operator'); return; }
|
|
78
84
|
let ok = false;
|
|
79
85
|
try { ok = (await accept?.({ fingerprint: from, username: String(req.username ?? '') })) === true; } catch (e) { onLog(`[reverse] accept threw: ${e.message}`); }
|
|
80
86
|
if (!ok || bridge) { await refuse('not accepted'); return; }
|
|
@@ -89,6 +95,7 @@ export function createReverseHost({
|
|
|
89
95
|
await answer(reverseAccept({
|
|
90
96
|
targetFingerprint: myFingerprint, username, capabilities, peerType, shellBackend,
|
|
91
97
|
supportsAttach: false, supportsReplay: false, supportsEcho: false, supportsTermSync: false,
|
|
98
|
+
features: reportFeatures ? bridgeFeatures : undefined,
|
|
92
99
|
}));
|
|
93
100
|
};
|
|
94
101
|
c.onClose = () => {
|
|
@@ -82,9 +82,11 @@ export function generateSelfSignedCertificate({ hosts = ['localhost', '127.0.0.1
|
|
|
82
82
|
}
|
|
83
83
|
const { publicKey, privateKey } = generateKeyPairSync('ec', { namedCurve: 'P-256' });
|
|
84
84
|
// An hour of back-dating tolerates clock skew; the whole window stays under 14 days for validityDays <= 13.
|
|
85
|
-
|
|
85
|
+
// (A very short validity, as rotation tests use, gets a proportionally smaller back-dating.)
|
|
86
|
+
const validityMs = validityDays * DAY_MS;
|
|
87
|
+
const skewMs = Math.min(60 * 60 * 1000, validityMs / 2, (MAX_PINNED_CERT_DAYS - validityDays) * DAY_MS);
|
|
86
88
|
const notBefore = new Date(Math.floor((now.getTime() - skewMs) / 1000) * 1000);
|
|
87
|
-
const notAfter = new Date(Math.
|
|
89
|
+
const notAfter = new Date(Math.ceil((now.getTime() + validityMs - skewMs) / 1000) * 1000);
|
|
88
90
|
|
|
89
91
|
const name = seq(set(seq(oid(OID.commonName), utf8('wsh self-signed'))));
|
|
90
92
|
const san = seq(...hosts.map((h) => (net.isIP(h) ? der(0x87, ipBytes(h)) : der(0x82, Buffer.from(h, 'ascii')))));
|
|
@@ -13,6 +13,10 @@
|
|
|
13
13
|
* host's `stream-announce` feature holds here as it does over QMux: the
|
|
14
14
|
* client writes no primer.
|
|
15
15
|
*
|
|
16
|
+
* A `selfSigned` certificate is rotated (see `startWebTransport`): the next one
|
|
17
|
+
* is generated and published (`certificateHashes()`) ahead of time, and the
|
|
18
|
+
* listener is switched to it shortly before the current one expires.
|
|
19
|
+
*
|
|
16
20
|
* `@fails-components/webtransport` (and its native HTTP/3 transport package)
|
|
17
21
|
* are optional peers, imported only when `webTransport` is given.
|
|
18
22
|
*/
|
|
@@ -22,6 +26,10 @@ import { FrameDecoder, frameEncode } from '../cbor.mjs';
|
|
|
22
26
|
import { generateSelfSignedCertificate } from './self-signed.mjs';
|
|
23
27
|
|
|
24
28
|
const DEFAULT_PATH = '/wsh';
|
|
29
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
30
|
+
const HOUR_MS = 60 * 60 * 1000;
|
|
31
|
+
/** setTimeout cannot wait longer than this (a 32-bit signed millisecond count). */
|
|
32
|
+
const MAX_TIMER_MS = 2 ** 31 - 1;
|
|
25
33
|
|
|
26
34
|
/** What a QMux stream looks like to the connection code, over a WebTransport bidirectional stream. */
|
|
27
35
|
function adaptStream({ readable, writable }, onGone) {
|
|
@@ -59,19 +67,27 @@ function adaptStream({ readable, writable }, onGone) {
|
|
|
59
67
|
* @param {string} opts.host
|
|
60
68
|
* @param {(opts: object) => { receiveMessage: Function, bindStream: Function, close: Function }} opts.attach
|
|
61
69
|
* @param {(line: string) => void} opts.log
|
|
62
|
-
* @returns {Promise<{ port: number, host: string, path: string, certificateHash: Uint8Array | null, certificateHashHex: string | null, notAfter: Date | null,
|
|
70
|
+
* @returns {Promise<{ port: number, host: string, path: string, certificateHash: Uint8Array | null, certificateHashHex: string | null, notAfter: Date | null,
|
|
71
|
+
* certificateHashes(): object[], certificates(): object[], rotateCertificate(opts?: { activate?: boolean }): Promise<object>, close(): Promise<void> }>}
|
|
63
72
|
*/
|
|
64
73
|
export async function startWebTransport({ options, host, attach, log }) {
|
|
65
74
|
const { port = 0, path = DEFAULT_PATH, selfSigned = false } = options;
|
|
66
75
|
let { cert, privKey } = options;
|
|
67
|
-
|
|
76
|
+
const certOpts = typeof selfSigned === 'object' ? selfSigned : {};
|
|
77
|
+
const { rotate = true, prepareBeforeMs: prepareOpt, activateBeforeMs: activateOpt, ...generateOpts } = certOpts;
|
|
78
|
+
/** The certificate being presented, and the next one once it has been generated (both only for `selfSigned`). */
|
|
79
|
+
let current = null;
|
|
80
|
+
let next = null;
|
|
68
81
|
if (selfSigned) {
|
|
69
82
|
if (cert || privKey) throw new TypeError('createWshServer: webTransport takes either selfSigned or cert/privKey, not both');
|
|
70
|
-
|
|
71
|
-
({ cert, privKey } =
|
|
83
|
+
current = generateSelfSignedCertificate(generateOpts);
|
|
84
|
+
({ cert, privKey } = current);
|
|
72
85
|
}
|
|
73
86
|
if (!cert || !privKey) throw new TypeError('createWshServer: webTransport needs cert and privKey (PEM), or selfSigned: true');
|
|
74
87
|
if (typeof path !== 'string' || !path.startsWith('/')) throw new TypeError('createWshServer: webTransport.path must start with "/"');
|
|
88
|
+
for (const [name, v] of [['prepareBeforeMs', prepareOpt], ['activateBeforeMs', activateOpt]]) {
|
|
89
|
+
if (v !== undefined && !(Number.isFinite(v) && v >= 0)) throw new TypeError(`createWshServer: webTransport.selfSigned.${name} must be a non-negative number`);
|
|
90
|
+
}
|
|
75
91
|
|
|
76
92
|
let wtLib;
|
|
77
93
|
try {
|
|
@@ -85,19 +101,7 @@ export async function startWebTransport({ options, host, attach, log }) {
|
|
|
85
101
|
);
|
|
86
102
|
}
|
|
87
103
|
|
|
88
|
-
const
|
|
89
|
-
port, host: options.host ?? host, secret: options.secret ?? randomBytes(16).toString('hex'), cert, privKey,
|
|
90
|
-
});
|
|
91
|
-
const sessions = server.sessionStream(path);
|
|
92
|
-
server.startServer();
|
|
93
|
-
try {
|
|
94
|
-
await server.ready;
|
|
95
|
-
} catch (err) {
|
|
96
|
-
try { server.stopServer(); } catch { /* never started */ }
|
|
97
|
-
throw err;
|
|
98
|
-
}
|
|
99
|
-
const addr = server.address();
|
|
100
|
-
|
|
104
|
+
const secret = options.secret ?? randomBytes(16).toString('hex');
|
|
101
105
|
const live = new Set();
|
|
102
106
|
let closing = false;
|
|
103
107
|
|
|
@@ -151,38 +155,154 @@ export async function startWebTransport({ options, host, attach, log }) {
|
|
|
151
155
|
end();
|
|
152
156
|
}
|
|
153
157
|
|
|
154
|
-
|
|
155
|
-
|
|
158
|
+
/** Start an HTTP/3 listener presenting `pem` on `onPort` and feed its sessions to `serve`. */
|
|
159
|
+
async function launch(pem, onPort) {
|
|
160
|
+
const server = new wtLib.Http3Server({
|
|
161
|
+
port: onPort, host: options.host ?? host, secret, cert: pem.cert, privKey: pem.privKey,
|
|
162
|
+
});
|
|
163
|
+
const sessions = server.sessionStream(path);
|
|
164
|
+
server.startServer();
|
|
156
165
|
try {
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
166
|
+
await server.ready;
|
|
167
|
+
} catch (err) {
|
|
168
|
+
try { server.stopServer(); } catch { /* never started */ }
|
|
169
|
+
throw err;
|
|
170
|
+
}
|
|
171
|
+
(async () => {
|
|
172
|
+
const reader = sessions.getReader();
|
|
173
|
+
try {
|
|
174
|
+
for (;;) {
|
|
175
|
+
const { done, value: session } = await reader.read();
|
|
176
|
+
if (done || closing) break;
|
|
177
|
+
serve(session).catch((e) => log(`webtransport session error: ${e.message}`));
|
|
178
|
+
}
|
|
179
|
+
} catch { /* server stopped */ }
|
|
180
|
+
})();
|
|
181
|
+
return { server, addr: server.address() };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** End every live session, giving the close a moment to reach the peers. */
|
|
185
|
+
async function closeLive() {
|
|
186
|
+
const open = [...live];
|
|
187
|
+
for (const s of open) { try { s.close(); } catch { /* gone */ } }
|
|
188
|
+
// Bounded: a peer that never acks cannot hold shutdown.
|
|
189
|
+
await Promise.race([
|
|
190
|
+
Promise.allSettled(open.map((s) => s.closed)),
|
|
191
|
+
new Promise((r) => { const t = setTimeout(r, 500); t.unref?.(); }),
|
|
192
|
+
]);
|
|
193
|
+
live.clear();
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
let listener = await launch({ cert, privKey }, port);
|
|
197
|
+
const boundPort = listener.addr.port;
|
|
198
|
+
|
|
199
|
+
// ── Rotation (selfSigned only) ─────────────────────────────────────
|
|
200
|
+
//
|
|
201
|
+
// A pinned certificate cannot simply be renewed: clients pin its hash, and a
|
|
202
|
+
// browser takes no certificate valid for more than 14 days, so a long-running
|
|
203
|
+
// server would stop being reachable at `notAfter`. Instead the next
|
|
204
|
+
// certificate is generated AHEAD of time and its hash is published next to the
|
|
205
|
+
// current one (`certificateHashes()`), so a client that re-reads the list
|
|
206
|
+
// during the overlap window pins both and survives the switch. The listener
|
|
207
|
+
// presents one certificate at a time (the native transport cannot swap one in
|
|
208
|
+
// place, so the switch restarts it on the same port: sessions end, clients
|
|
209
|
+
// reconnect).
|
|
210
|
+
let prepareTimer = null;
|
|
211
|
+
let activateTimer = null;
|
|
212
|
+
let chain = Promise.resolve();
|
|
213
|
+
const serial = (fn) => { const run = chain.then(fn); chain = run.catch(() => {}); return run; };
|
|
214
|
+
|
|
215
|
+
const validityOf = (c) => c.notAfter.getTime() - c.notBefore.getTime();
|
|
216
|
+
const prepareBeforeMs = () => prepareOpt ?? Math.min(3 * DAY_MS, validityOf(current) / 3);
|
|
217
|
+
const activateBeforeMs = () => activateOpt ?? Math.min(HOUR_MS, validityOf(current) / 6);
|
|
218
|
+
const clearTimers = () => { clearTimeout(prepareTimer); clearTimeout(activateTimer); prepareTimer = activateTimer = null; };
|
|
219
|
+
const later = (fn, at) => {
|
|
220
|
+
// Long waits are re-armed: a timer cannot exceed ~24.8 days, and a sleeping machine fires late, so re-check on wake.
|
|
221
|
+
const wait = Math.max(0, at - Date.now());
|
|
222
|
+
const t = setTimeout(() => (wait > MAX_TIMER_MS ? later(fn, at) : fn()), Math.min(wait, MAX_TIMER_MS));
|
|
223
|
+
t.unref?.();
|
|
224
|
+
return t;
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
function schedule() {
|
|
228
|
+
clearTimers();
|
|
229
|
+
if (!current || !rotate || closing) return;
|
|
230
|
+
if (!next) {
|
|
231
|
+
prepareTimer = later(() => { rotateCertificate().catch((e) => log(`webtransport: preparing the next certificate failed: ${e.message}`)); }, current.notAfter.getTime() - prepareBeforeMs());
|
|
232
|
+
} else {
|
|
233
|
+
activateTimer = later(() => { rotateCertificate({ activate: true }).catch((e) => log(`webtransport: certificate rotation failed: ${e.message}`)); }, current.notAfter.getTime() - activateBeforeMs());
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function prepare() {
|
|
238
|
+
if (next) return;
|
|
239
|
+
next = generateSelfSignedCertificate(generateOpts);
|
|
240
|
+
log(`webtransport: next certificate ready, sha-256 ${next.hashHex} (current ${current.hashHex} until ${current.notAfter.toISOString()})`);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
async function activate() {
|
|
244
|
+
prepare();
|
|
245
|
+
const incoming = next;
|
|
246
|
+
await closeLive();
|
|
247
|
+
try { listener.server.stopServer(); } catch { /* already stopped */ }
|
|
248
|
+
// The UDP port may take a moment to come free.
|
|
249
|
+
let lastErr;
|
|
250
|
+
for (let attempt = 0; attempt < 20; attempt++) {
|
|
251
|
+
try {
|
|
252
|
+
listener = await launch(incoming, boundPort);
|
|
253
|
+
lastErr = null;
|
|
254
|
+
break;
|
|
255
|
+
} catch (err) {
|
|
256
|
+
lastErr = err;
|
|
257
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
161
258
|
}
|
|
162
|
-
}
|
|
163
|
-
|
|
259
|
+
}
|
|
260
|
+
if (lastErr) throw new Error(`could not restart the WebTransport listener on port ${boundPort} with the new certificate: ${lastErr.message}`, { cause: lastErr });
|
|
261
|
+
const old = current;
|
|
262
|
+
current = incoming;
|
|
263
|
+
next = null;
|
|
264
|
+
log(`webtransport: now presenting certificate sha-256 ${current.hashHex} until ${current.notAfter.toISOString()} (replaced ${old.hashHex})`);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Start a rotation: generate the next certificate if there is none yet and publish its hash
|
|
269
|
+
* (`certificateHashes()` = [current, next]). With `activate`, also switch to it now (live sessions
|
|
270
|
+
* end); otherwise it is switched to shortly before the current one expires.
|
|
271
|
+
*/
|
|
272
|
+
function rotateCertificate({ activate: now = false } = {}) {
|
|
273
|
+
if (!current) return Promise.reject(new Error('rotateCertificate: only a selfSigned certificate is rotated (supply a new cert by restarting the server)'));
|
|
274
|
+
return serial(async () => {
|
|
275
|
+
if (closing) throw new Error('rotateCertificate: the listener is closed');
|
|
276
|
+
if (now) await activate(); else prepare();
|
|
277
|
+
schedule();
|
|
278
|
+
return { current: describe(current, true), next: next ? describe(next, false) : null };
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const describe = (c, active) => ({ hash: c.hash, hashHex: c.hashHex, notBefore: c.notBefore, notAfter: c.notAfter, active });
|
|
283
|
+
schedule();
|
|
164
284
|
|
|
165
285
|
const bound = {
|
|
166
|
-
port:
|
|
167
|
-
host: addr.host,
|
|
286
|
+
port: boundPort,
|
|
287
|
+
host: listener.addr.host,
|
|
168
288
|
path,
|
|
169
|
-
certificateHash
|
|
170
|
-
certificateHashHex
|
|
171
|
-
notAfter
|
|
289
|
+
get certificateHash() { return current?.hash ?? null; },
|
|
290
|
+
get certificateHashHex() { return current?.hashHex ?? null; },
|
|
291
|
+
get notAfter() { return current?.notAfter ?? null; },
|
|
292
|
+
/** `serverCertificateHashes` for a client: the current certificate and, during an overlap, the next. */
|
|
293
|
+
certificateHashes() { return [current, next].filter(Boolean).map((c) => ({ algorithm: 'sha-256', value: c.hash })); },
|
|
294
|
+
/** The same, with validity and which one is being presented. */
|
|
295
|
+
certificates() { return [current && describe(current, true), next && describe(next, false)].filter(Boolean); },
|
|
296
|
+
rotateCertificate,
|
|
172
297
|
async close() {
|
|
173
298
|
closing = true;
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
Promise.allSettled(open.map((s) => s.closed)),
|
|
179
|
-
new Promise((r) => { const t = setTimeout(r, 500); t.unref?.(); }),
|
|
180
|
-
]);
|
|
181
|
-
live.clear();
|
|
182
|
-
try { server.stopServer(); } catch { /* already stopped */ }
|
|
299
|
+
clearTimers();
|
|
300
|
+
await chain.catch(() => {});
|
|
301
|
+
await closeLive();
|
|
302
|
+
try { listener.server.stopServer(); } catch { /* already stopped */ }
|
|
183
303
|
},
|
|
184
304
|
};
|
|
185
|
-
log(`webtransport listening on https://${addr.host}:${
|
|
186
|
-
+ (
|
|
305
|
+
log(`webtransport listening on https://${listener.addr.host}:${boundPort}${path}`
|
|
306
|
+
+ (current ? ` (self-signed, sha-256 ${current.hashHex}, until ${current.notAfter.toISOString()}${rotate ? '' : ', not rotated'})` : ''));
|
|
187
307
|
return bound;
|
|
188
308
|
}
|