@johnhenry/wsh 0.22.0 → 0.23.1
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 +46 -3
- package/package.json +9 -1
- package/src/qmux-connection.mjs +6 -0
- package/src/server/index.d.ts +57 -0
- package/src/server/index.mjs +31 -1
- 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,7 +185,50 @@ 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
|
-
|
|
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**.
|
|
189
232
|
|
|
190
233
|
### Relay / reverse mode
|
|
191
234
|
|
|
@@ -500,7 +543,7 @@ the host to find it:
|
|
|
500
543
|
| Current stock client | `@johnhenry/wsh/server` (advertises `stream-announce`) | The client's transport announces the stream with an empty STREAM frame. **No primer is written.** |
|
|
501
544
|
| Current stock client | Host without `stream-announce` (e.g. the Rust `wsh-server`, older vendored hosts) | The client writes a one-byte primer (`0x00`) on the new stream so the host sees it; that host strips it. `primer: false` disables this (then such a host never binds the stream and the output is lost). |
|
|
502
545
|
| A client that primes anyway (older stock client, `primer` forced, other implementations) | `@johnhenry/wsh/server` | **The leading `0x00` is dropped:** if the first data on an exec stream is exactly one `0x00` byte it is discarded, never forwarded to the process's stdin. Only that first chunk is inspected; a later `0x00`, or a first chunk that is longer than one byte, is delivered untouched. The cost: a client whose genuine first stdin chunk is a lone NUL loses it. |
|
|
503
|
-
| A client that neither announces nor primes | `@johnhenry/wsh/server` | The host waits `bindTimeoutMs` (default 3000) for the stream, then drops the output and logs it. |
|
|
546
|
+
| A client that neither announces nor primes (a third-party QMux client; the stock client always does one or the other) | `@johnhenry/wsh/server` | The host waits `bindTimeoutMs` (default 3000) for the stream, then drops the output and logs it. This cannot be fixed host-side: nothing about the stream has been sent, so there is nothing to bind. Such a client must send `QMuxStream.announce()` (an empty STREAM frame) or any first write -- a zero-length `write()` now does the same. |
|
|
504
547
|
|
|
505
548
|
## Attach and Resume
|
|
506
549
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@johnhenry/wsh",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.23.1",
|
|
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
|
}
|
package/src/qmux-connection.mjs
CHANGED
|
@@ -116,6 +116,12 @@ class QMuxStream {
|
|
|
116
116
|
if (this.#sendState === SEND_STATE.DATA_SENT) {
|
|
117
117
|
throw new Error(`Cannot write to stream ${this.id}: already closed (FIN sent)`);
|
|
118
118
|
}
|
|
119
|
+
if (bytes.byteLength === 0) {
|
|
120
|
+
// Nothing to send, but a first write is still the caller saying "this
|
|
121
|
+
// stream exists": announce it (a no-op once anything has been sent).
|
|
122
|
+
this.announce();
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
119
125
|
this.#sendState = SEND_STATE.SEND;
|
|
120
126
|
|
|
121
127
|
let offset = 0;
|
package/src/server/index.d.ts
CHANGED
|
@@ -200,6 +200,55 @@ export interface WshServerRelayOptions {
|
|
|
200
200
|
connectTimeoutMs?: number;
|
|
201
201
|
}
|
|
202
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
|
+
|
|
203
252
|
export interface WshServerOptions {
|
|
204
253
|
/** Bind address (default `127.0.0.1`). */
|
|
205
254
|
host?: string;
|
|
@@ -233,6 +282,12 @@ export interface WshServerOptions {
|
|
|
233
282
|
* Default deny -- give both `canRegister` and `canConnect`. Off by default.
|
|
234
283
|
*/
|
|
235
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;
|
|
236
291
|
/** Serve MCP tools (`McpDiscover` / `McpCall`) and advertise `mcp-call-id`. Off by default. */
|
|
237
292
|
mcp?: WshServerMcpOptions;
|
|
238
293
|
/** How long exec output waits for a client that has not yet opened its data stream (default 3000). */
|
|
@@ -252,6 +307,8 @@ export interface WshServer {
|
|
|
252
307
|
close(): Promise<void>;
|
|
253
308
|
/** The bound address, or `null` when not listening. */
|
|
254
309
|
address(): WshServerAddress | null;
|
|
310
|
+
/** The WebTransport listener; `null` when not configured or before `listen()` resolves. */
|
|
311
|
+
webTransport(): WshServerWebTransport | null;
|
|
255
312
|
/** Fingerprints of the peers currently registered with this relay (`[]` when it is not one). */
|
|
256
313
|
peerFingerprints(): string[];
|
|
257
314
|
/** The advertised host identity; `null` before `listen()` resolves or without a `hostKey`. */
|
package/src/server/index.mjs
CHANGED
|
@@ -13,10 +13,12 @@ import { loadHostKey } from './host-key.mjs';
|
|
|
13
13
|
import { buildBackends } from './backends.mjs';
|
|
14
14
|
import { SessionRegistry } from './sessions.mjs';
|
|
15
15
|
import { RelayHub } from './relay.mjs';
|
|
16
|
+
import { startWebTransport } from './webtransport.mjs';
|
|
16
17
|
import { createConnectionFactory, STREAM_ANNOUNCE } from './connection.mjs';
|
|
17
18
|
|
|
18
19
|
export { parseAuthorizedKeys, STREAM_ANNOUNCE };
|
|
19
20
|
export { createReverseHost } from './reverse-host.mjs';
|
|
21
|
+
export { generateSelfSignedCertificate } from './self-signed.mjs';
|
|
20
22
|
|
|
21
23
|
/**
|
|
22
24
|
* @param {object} [options]
|
|
@@ -30,6 +32,11 @@ export { createReverseHost } from './reverse-host.mjs';
|
|
|
30
32
|
* @param {{ tools?: object[] | object, client?: object, authorize?: Function, maxConcurrent?: number, timeoutMs?: number }} [options.mcp]
|
|
31
33
|
* MCP tools served over `McpDiscover` / `McpCall`: `tools` (`{ name, description, inputSchema, call(args, { user, signal }) }`),
|
|
32
34
|
* and/or `client` (an `@modelcontextprotocol/sdk` Client to proxy). Off unless given.
|
|
35
|
+
* @param {{ port?: number, host?: string, path?: string, cert?: string, privKey?: string, selfSigned?: boolean | object, secret?: string }} [options.webTransport]
|
|
36
|
+
* Also listen for WebTransport (HTTP/3 over UDP) clients, with the same auth and features as the WebSocket
|
|
37
|
+
* listener. `cert` / `privKey` are PEM; `selfSigned: true` makes a 13-day ECDSA P-256 certificate and exposes its
|
|
38
|
+
* SHA-256 through `webTransport()` for the client's `serverCertificateHashes`. Needs the optional peers
|
|
39
|
+
* `@fails-components/webtransport` and `@fails-components/webtransport-transport-http3-quiche`. Off unless given.
|
|
33
40
|
* @param {{ canRegister?: Function, canConnect?: Function, maxPeers?: number, connectTimeoutMs?: number }} [options.relay]
|
|
34
41
|
* Act as a relay: peers register (`ReverseRegister`, a signed record), operators list and connect to them
|
|
35
42
|
* (`ReverseList` / `ReverseConnect`) and traffic is carried between them as `RelayForward`. Default deny:
|
|
@@ -48,7 +55,7 @@ export { createReverseHost } from './reverse-host.mjs';
|
|
|
48
55
|
* @returns {{ listen(): Promise<{address: string, port: number}>, close(): Promise<void>, address(): ({address: string, port: number} | null) }}
|
|
49
56
|
*/
|
|
50
57
|
export function createWshServer({
|
|
51
|
-
host = '127.0.0.1', port = 0, auth, exec, pty, fs, hostKey, mcp, sessions, sessionSecret, relay, bindTimeoutMs = 3000, onLog = () => {},
|
|
58
|
+
host = '127.0.0.1', port = 0, auth, exec, pty, fs, hostKey, mcp, sessions, sessionSecret, relay, webTransport, bindTimeoutMs = 3000, onLog = () => {},
|
|
52
59
|
} = {}) {
|
|
53
60
|
let wss = null;
|
|
54
61
|
let bound = null;
|
|
@@ -64,6 +71,7 @@ export function createWshServer({
|
|
|
64
71
|
let host_ = null;
|
|
65
72
|
let registry = null;
|
|
66
73
|
let hub = null;
|
|
74
|
+
let wt = null;
|
|
67
75
|
const sessionOptions = sessions ? (sessions === true ? {} : sessions) : null;
|
|
68
76
|
|
|
69
77
|
return {
|
|
@@ -113,6 +121,14 @@ export function createWshServer({
|
|
|
113
121
|
});
|
|
114
122
|
const a = wss.address();
|
|
115
123
|
bound = { address: a.address, port: a.port };
|
|
124
|
+
if (webTransport) {
|
|
125
|
+
try {
|
|
126
|
+
wt = await startWebTransport({ options: webTransport, host, attach, log: onLog });
|
|
127
|
+
} catch (err) {
|
|
128
|
+
await this.close();
|
|
129
|
+
throw err;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
116
132
|
return bound;
|
|
117
133
|
},
|
|
118
134
|
|
|
@@ -122,6 +138,8 @@ export function createWshServer({
|
|
|
122
138
|
wss = null;
|
|
123
139
|
bound = null;
|
|
124
140
|
for (const client of server.clients) client.terminate();
|
|
141
|
+
await wt?.close();
|
|
142
|
+
wt = null;
|
|
125
143
|
await new Promise((resolve) => server.close(resolve));
|
|
126
144
|
registry?.closeAll();
|
|
127
145
|
registry = null;
|
|
@@ -133,6 +151,18 @@ export function createWshServer({
|
|
|
133
151
|
return bound;
|
|
134
152
|
},
|
|
135
153
|
|
|
154
|
+
/**
|
|
155
|
+
* The WebTransport listener, or `null` (not configured, or `listen()` has not resolved). `certificateHash` is
|
|
156
|
+
* the SHA-256 of a `selfSigned` certificate, for the client's `serverCertificateHashes`; `null` for your own.
|
|
157
|
+
*/
|
|
158
|
+
webTransport() {
|
|
159
|
+
if (!wt) return null;
|
|
160
|
+
return {
|
|
161
|
+
port: wt.port, host: wt.host, path: wt.path, url: `https://${wt.host.includes(':') ? `[${wt.host}]` : wt.host}:${wt.port}${wt.path}`,
|
|
162
|
+
certificateHash: wt.certificateHash, certificateHashHex: wt.certificateHashHex, notAfter: wt.notAfter,
|
|
163
|
+
};
|
|
164
|
+
},
|
|
165
|
+
|
|
136
166
|
/** Fingerprints of the peers currently registered with this relay (`[]` when it is not one). */
|
|
137
167
|
peerFingerprints() {
|
|
138
168
|
return hub ? hub.peerFingerprints() : [];
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A short-lived self-signed certificate for WebTransport's
|
|
3
|
+
* `serverCertificateHashes` pinning, with no dependency: the X.509 structure
|
|
4
|
+
* is small enough to assemble as DER by hand, and `node:crypto` signs it.
|
|
5
|
+
*
|
|
6
|
+
* The web platform's rules for a pinned certificate (what a browser enforces):
|
|
7
|
+
* an ECDSA P-256 key, a validity period of at most 14 days, and the page pins
|
|
8
|
+
* the SHA-256 of the DER certificate. Nothing else about it is checked (no
|
|
9
|
+
* chain, and the name is not matched), but the subject alternative names are
|
|
10
|
+
* filled in anyway so the same certificate also works for a client that does.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { createHash, generateKeyPairSync, randomBytes, sign } from 'node:crypto';
|
|
14
|
+
import net from 'node:net';
|
|
15
|
+
|
|
16
|
+
/** What a browser allows for `serverCertificateHashes`. */
|
|
17
|
+
export const MAX_PINNED_CERT_DAYS = 14;
|
|
18
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
19
|
+
|
|
20
|
+
const OID = {
|
|
21
|
+
ecdsaWithSha256: '2a8648ce3d040302',
|
|
22
|
+
commonName: '550403',
|
|
23
|
+
subjectAltName: '551d11',
|
|
24
|
+
extKeyUsage: '551d25',
|
|
25
|
+
serverAuth: '2b06010505070301',
|
|
26
|
+
keyUsage: '551d0f',
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
// ── DER ──────────────────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
const concat = (...parts) => Buffer.concat(parts.map((p) => (Buffer.isBuffer(p) ? p : Buffer.from(p))));
|
|
32
|
+
function der(tag, ...content) {
|
|
33
|
+
const body = concat(...content);
|
|
34
|
+
const n = body.length;
|
|
35
|
+
const len = n < 0x80 ? Buffer.from([n]) : n < 0x100 ? Buffer.from([0x81, n]) : Buffer.from([0x82, n >> 8, n & 0xff]);
|
|
36
|
+
return concat(Buffer.from([tag]), len, body);
|
|
37
|
+
}
|
|
38
|
+
const seq = (...c) => der(0x30, ...c);
|
|
39
|
+
const set = (...c) => der(0x31, ...c);
|
|
40
|
+
const oid = (hex) => der(0x06, Buffer.from(hex, 'hex'));
|
|
41
|
+
const utf8 = (s) => der(0x0c, Buffer.from(s, 'utf8'));
|
|
42
|
+
const octets = (b) => der(0x04, b);
|
|
43
|
+
const bitString = (b) => der(0x03, Buffer.from([0]), b);
|
|
44
|
+
/** Positive INTEGER from big-endian bytes. */
|
|
45
|
+
function integer(bytes) {
|
|
46
|
+
let b = Buffer.from(bytes);
|
|
47
|
+
while (b.length > 1 && b[0] === 0) b = b.subarray(1);
|
|
48
|
+
if (b[0] & 0x80) b = concat(Buffer.from([0]), b);
|
|
49
|
+
return der(0x02, b);
|
|
50
|
+
}
|
|
51
|
+
function utcTime(date) {
|
|
52
|
+
const p = (n) => String(n).padStart(2, '0');
|
|
53
|
+
return der(0x17, Buffer.from(`${p(date.getUTCFullYear() % 100)}${p(date.getUTCMonth() + 1)}${p(date.getUTCDate())}${p(date.getUTCHours())}${p(date.getUTCMinutes())}${p(date.getUTCSeconds())}Z`, 'ascii'));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function ipBytes(addr) {
|
|
57
|
+
if (net.isIPv4(addr)) return Buffer.from(addr.split('.').map(Number));
|
|
58
|
+
// IPv6, possibly with "::" compression
|
|
59
|
+
const [head, tail = ''] = addr.split('::');
|
|
60
|
+
const h = head ? head.split(':') : [];
|
|
61
|
+
const t = tail ? tail.split(':') : [];
|
|
62
|
+
const groups = [...h, ...Array(8 - h.length - t.length).fill('0'), ...t];
|
|
63
|
+
return Buffer.from(groups.flatMap((g) => { const v = parseInt(g || '0', 16); return [v >> 8, v & 0xff]; }));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function pem(label, derBytes) {
|
|
67
|
+
const b64 = derBytes.toString('base64').match(/.{1,64}/g).join('\n');
|
|
68
|
+
return `-----BEGIN ${label}-----\n${b64}\n-----END ${label}-----\n`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* @param {object} [opts]
|
|
73
|
+
* @param {string[]} [opts.hosts=['localhost', '127.0.0.1', '::1']] - DNS names and IP addresses for the SAN.
|
|
74
|
+
* @param {number} [opts.validityDays=13] - At most 14 (a browser refuses a pinned certificate valid for longer).
|
|
75
|
+
* @param {Date} [opts.now]
|
|
76
|
+
* @returns {{ cert: string, privKey: string, hash: Uint8Array, hashHex: string, notBefore: Date, notAfter: Date }}
|
|
77
|
+
* `cert` / `privKey` are PEM; `hash` is the SHA-256 of the DER certificate, the value to pin.
|
|
78
|
+
*/
|
|
79
|
+
export function generateSelfSignedCertificate({ hosts = ['localhost', '127.0.0.1', '::1'], validityDays = 13, now = new Date() } = {}) {
|
|
80
|
+
if (!(validityDays > 0) || validityDays > MAX_PINNED_CERT_DAYS) {
|
|
81
|
+
throw new RangeError(`selfSigned: validityDays must be in (0, ${MAX_PINNED_CERT_DAYS}] -- browsers refuse a pinned certificate valid for longer`);
|
|
82
|
+
}
|
|
83
|
+
const { publicKey, privateKey } = generateKeyPairSync('ec', { namedCurve: 'P-256' });
|
|
84
|
+
// An hour of back-dating tolerates clock skew; the whole window stays under 14 days for validityDays <= 13.
|
|
85
|
+
const skewMs = Math.min(60 * 60 * 1000, (MAX_PINNED_CERT_DAYS - validityDays) * DAY_MS);
|
|
86
|
+
const notBefore = new Date(Math.floor((now.getTime() - skewMs) / 1000) * 1000);
|
|
87
|
+
const notAfter = new Date(Math.floor((now.getTime() + validityDays * DAY_MS - skewMs) / 1000) * 1000);
|
|
88
|
+
|
|
89
|
+
const name = seq(set(seq(oid(OID.commonName), utf8('wsh self-signed'))));
|
|
90
|
+
const san = seq(...hosts.map((h) => (net.isIP(h) ? der(0x87, ipBytes(h)) : der(0x82, Buffer.from(h, 'ascii')))));
|
|
91
|
+
const extensions = der(0xa3, seq(
|
|
92
|
+
seq(oid(OID.keyUsage), der(0x01, Buffer.from([0xff])), octets(bitString(Buffer.from([0x80])))), // digitalSignature
|
|
93
|
+
seq(oid(OID.extKeyUsage), octets(seq(oid(OID.serverAuth)))),
|
|
94
|
+
seq(oid(OID.subjectAltName), octets(san)),
|
|
95
|
+
));
|
|
96
|
+
const serial = randomBytes(16);
|
|
97
|
+
serial[0] &= 0x7f;
|
|
98
|
+
serial[0] |= 0x01;
|
|
99
|
+
|
|
100
|
+
const tbs = seq(
|
|
101
|
+
der(0xa0, integer([2])), // v3
|
|
102
|
+
integer(serial),
|
|
103
|
+
seq(oid(OID.ecdsaWithSha256)),
|
|
104
|
+
name,
|
|
105
|
+
seq(utcTime(notBefore), utcTime(notAfter)),
|
|
106
|
+
name,
|
|
107
|
+
publicKey.export({ type: 'spki', format: 'der' }),
|
|
108
|
+
extensions,
|
|
109
|
+
);
|
|
110
|
+
const signature = sign('sha256', tbs, privateKey); // DER-encoded ECDSA-Sig-Value
|
|
111
|
+
const certDer = seq(tbs, seq(oid(OID.ecdsaWithSha256)), bitString(signature));
|
|
112
|
+
const hash = new Uint8Array(createHash('sha256').update(certDer).digest());
|
|
113
|
+
return {
|
|
114
|
+
cert: pem('CERTIFICATE', certDer),
|
|
115
|
+
privKey: privateKey.export({ type: 'pkcs8', format: 'pem' }),
|
|
116
|
+
hash,
|
|
117
|
+
hashHex: Buffer.from(hash).toString('hex'),
|
|
118
|
+
notBefore,
|
|
119
|
+
notAfter,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebTransport listener for `@johnhenry/wsh/server` (`createWshServer({ webTransport })`).
|
|
3
|
+
*
|
|
4
|
+
* QUIC already multiplexes, so the QMux layer the WebSocket path needs is not
|
|
5
|
+
* used. A session's FIRST bidirectional stream is the control stream (the
|
|
6
|
+
* client's `WebTransportTransport` opens it first): length-prefixed CBOR
|
|
7
|
+
* frames, exactly what `frameEncode` / `FrameDecoder` speak. Every later
|
|
8
|
+
* bidirectional stream the client opens is a data stream, bound to the next
|
|
9
|
+
* exec channel in `OpenOk` order like a QMux one.
|
|
10
|
+
*
|
|
11
|
+
* Streams a client creates are visible to the host the moment they are
|
|
12
|
+
* created (HTTP/3 sends the stream's WebTransport header with it), so the
|
|
13
|
+
* host's `stream-announce` feature holds here as it does over QMux: the
|
|
14
|
+
* client writes no primer.
|
|
15
|
+
*
|
|
16
|
+
* `@fails-components/webtransport` (and its native HTTP/3 transport package)
|
|
17
|
+
* are optional peers, imported only when `webTransport` is given.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { randomBytes } from 'node:crypto';
|
|
21
|
+
import { FrameDecoder, frameEncode } from '../cbor.mjs';
|
|
22
|
+
import { generateSelfSignedCertificate } from './self-signed.mjs';
|
|
23
|
+
|
|
24
|
+
const DEFAULT_PATH = '/wsh';
|
|
25
|
+
|
|
26
|
+
/** What a QMux stream looks like to the connection code, over a WebTransport bidirectional stream. */
|
|
27
|
+
function adaptStream({ readable, writable }, onGone) {
|
|
28
|
+
const writer = writable.getWriter();
|
|
29
|
+
const reader = readable.getReader();
|
|
30
|
+
const stream = {
|
|
31
|
+
onData: null,
|
|
32
|
+
onEnd: null,
|
|
33
|
+
onReset: null,
|
|
34
|
+
write: (bytes) => writer.write(bytes),
|
|
35
|
+
close: async () => { try { await writer.close(); } catch { /* peer gone or already closed */ } },
|
|
36
|
+
/** Start delivering: after the consumer has had the chance to set its handlers. */
|
|
37
|
+
start() {
|
|
38
|
+
(async () => {
|
|
39
|
+
try {
|
|
40
|
+
for (;;) {
|
|
41
|
+
const { done, value } = await reader.read();
|
|
42
|
+
if (done) { stream.onEnd?.(); return; }
|
|
43
|
+
stream.onData?.(value);
|
|
44
|
+
}
|
|
45
|
+
} catch {
|
|
46
|
+
stream.onReset?.();
|
|
47
|
+
} finally {
|
|
48
|
+
onGone?.();
|
|
49
|
+
}
|
|
50
|
+
})();
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
return stream;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* @param {object} opts
|
|
58
|
+
* @param {object} opts.options - `createWshServer`'s `webTransport` option
|
|
59
|
+
* @param {string} opts.host
|
|
60
|
+
* @param {(opts: object) => { receiveMessage: Function, bindStream: Function, close: Function }} opts.attach
|
|
61
|
+
* @param {(line: string) => void} opts.log
|
|
62
|
+
* @returns {Promise<{ port: number, host: string, path: string, certificateHash: Uint8Array | null, certificateHashHex: string | null, notAfter: Date | null, close(): Promise<void> }>}
|
|
63
|
+
*/
|
|
64
|
+
export async function startWebTransport({ options, host, attach, log }) {
|
|
65
|
+
const { port = 0, path = DEFAULT_PATH, selfSigned = false } = options;
|
|
66
|
+
let { cert, privKey } = options;
|
|
67
|
+
let generated = null;
|
|
68
|
+
if (selfSigned) {
|
|
69
|
+
if (cert || privKey) throw new TypeError('createWshServer: webTransport takes either selfSigned or cert/privKey, not both');
|
|
70
|
+
generated = generateSelfSignedCertificate(typeof selfSigned === 'object' ? selfSigned : {});
|
|
71
|
+
({ cert, privKey } = generated);
|
|
72
|
+
}
|
|
73
|
+
if (!cert || !privKey) throw new TypeError('createWshServer: webTransport needs cert and privKey (PEM), or selfSigned: true');
|
|
74
|
+
if (typeof path !== 'string' || !path.startsWith('/')) throw new TypeError('createWshServer: webTransport.path must start with "/"');
|
|
75
|
+
|
|
76
|
+
let wtLib;
|
|
77
|
+
try {
|
|
78
|
+
wtLib = await import('@fails-components/webtransport');
|
|
79
|
+
await wtLib.quicheLoaded;
|
|
80
|
+
} catch (err) {
|
|
81
|
+
throw new Error(
|
|
82
|
+
'WebTransport needs the optional packages "@fails-components/webtransport" and '
|
|
83
|
+
+ '"@fails-components/webtransport-transport-http3-quiche": npm install @fails-components/webtransport @fails-components/webtransport-transport-http3-quiche',
|
|
84
|
+
{ cause: err },
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const server = new wtLib.Http3Server({
|
|
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
|
+
|
|
101
|
+
const live = new Set();
|
|
102
|
+
let closing = false;
|
|
103
|
+
|
|
104
|
+
async function serve(session) {
|
|
105
|
+
let conn = null;
|
|
106
|
+
let closed = false;
|
|
107
|
+
const end = () => { if (!closed) { closed = true; live.delete(session); conn?.close(); } };
|
|
108
|
+
live.add(session);
|
|
109
|
+
session.closed.then(end, end);
|
|
110
|
+
try {
|
|
111
|
+
await session.ready;
|
|
112
|
+
} catch { end(); return; }
|
|
113
|
+
|
|
114
|
+
let controlWriter = null;
|
|
115
|
+
const decoder = new FrameDecoder();
|
|
116
|
+
conn = attach({
|
|
117
|
+
sendMessage: (msg) => (controlWriter ? controlWriter.write(frameEncode(msg)) : undefined),
|
|
118
|
+
remote: { address: session.peerAddress, headers: session.header ?? {} },
|
|
119
|
+
closeTransport: () => { try { session.close(); } catch { /* already closed */ } },
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
const incoming = session.incomingBidirectionalStreams.getReader();
|
|
123
|
+
let first = true;
|
|
124
|
+
try {
|
|
125
|
+
for (;;) {
|
|
126
|
+
const { done, value: bidi } = await incoming.read();
|
|
127
|
+
if (done) break;
|
|
128
|
+
if (first) {
|
|
129
|
+
first = false;
|
|
130
|
+
controlWriter = bidi.writable.getWriter();
|
|
131
|
+
const reader = bidi.readable.getReader();
|
|
132
|
+
(async () => {
|
|
133
|
+
try {
|
|
134
|
+
for (;;) {
|
|
135
|
+
const { done: fin, value } = await reader.read();
|
|
136
|
+
if (fin) break;
|
|
137
|
+
let msgs;
|
|
138
|
+
try { msgs = decoder.feed(value); } catch (e) { log(`webtransport frame decode error: ${e.message}`); break; }
|
|
139
|
+
for (const m of msgs) conn.receiveMessage(m);
|
|
140
|
+
}
|
|
141
|
+
} catch { /* session went away */ }
|
|
142
|
+
end();
|
|
143
|
+
})();
|
|
144
|
+
} else {
|
|
145
|
+
const s = adaptStream(bidi);
|
|
146
|
+
conn.bindStream(s);
|
|
147
|
+
s.start();
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
} catch { /* session closed under us */ }
|
|
151
|
+
end();
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
(async () => {
|
|
155
|
+
const reader = sessions.getReader();
|
|
156
|
+
try {
|
|
157
|
+
for (;;) {
|
|
158
|
+
const { done, value: session } = await reader.read();
|
|
159
|
+
if (done || closing) break;
|
|
160
|
+
serve(session).catch((e) => log(`webtransport session error: ${e.message}`));
|
|
161
|
+
}
|
|
162
|
+
} catch { /* server stopped */ }
|
|
163
|
+
})();
|
|
164
|
+
|
|
165
|
+
const bound = {
|
|
166
|
+
port: addr.port,
|
|
167
|
+
host: addr.host,
|
|
168
|
+
path,
|
|
169
|
+
certificateHash: generated?.hash ?? null,
|
|
170
|
+
certificateHashHex: generated?.hashHex ?? null,
|
|
171
|
+
notAfter: generated?.notAfter ?? null,
|
|
172
|
+
async close() {
|
|
173
|
+
closing = true;
|
|
174
|
+
const open = [...live];
|
|
175
|
+
for (const s of open) { try { s.close(); } catch { /* gone */ } }
|
|
176
|
+
// Let the close reach the peers before the socket is torn down (bounded: a peer that never acks cannot hold shutdown).
|
|
177
|
+
await Promise.race([
|
|
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 */ }
|
|
183
|
+
},
|
|
184
|
+
};
|
|
185
|
+
log(`webtransport listening on https://${addr.host}:${addr.port}${path}`
|
|
186
|
+
+ (generated ? ` (self-signed, sha-256 ${generated.hashHex}, until ${generated.notAfter.toISOString()})` : ''));
|
|
187
|
+
return bound;
|
|
188
|
+
}
|
package/src/transport.mjs
CHANGED
|
@@ -493,6 +493,11 @@ export class WebTransportTransport extends WshTransport {
|
|
|
493
493
|
const wt = wtOptions ? new WebTransport(url, wtOptions) : new WebTransport(url);
|
|
494
494
|
this.#wt = wt;
|
|
495
495
|
|
|
496
|
+
// A session that never becomes ready rejects `closed` as well as `ready`. Nothing
|
|
497
|
+
// observes `closed` until #monitorClosed (below, after `ready`), so the second
|
|
498
|
+
// rejection would surface as an unhandled one -- fatal in Node.
|
|
499
|
+
wt.closed?.catch?.(() => {});
|
|
500
|
+
|
|
496
501
|
// Wait for the connection to be ready.
|
|
497
502
|
await wt.ready;
|
|
498
503
|
|