wire-mesh 3.3.0 → 3.5.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 +10 -9
- package/dist/server.cjs +78 -19
- package/dist/server.mjs +79 -20
- package/dist/web-console/assets/index-C3PnJRkL.js +72 -0
- package/dist/web-console/index.html +1 -1
- package/dist/web-console/sw.js +1 -1
- package/package.json +3 -3
- package/dist/web-console/assets/index-DjhSAVKV.js +0 -72
package/README.md
CHANGED
|
@@ -34,14 +34,15 @@ Every flag the CLI accepts, exactly as `wire-mesh --help` prints it. An unknown
|
|
|
34
34
|
Usage: wire-mesh [options]
|
|
35
35
|
|
|
36
36
|
Options:
|
|
37
|
-
--bind <host:port>
|
|
38
|
-
--tls-cert <path>
|
|
39
|
-
--tls-key <path>
|
|
40
|
-
--mailbox-dir <path>
|
|
41
|
-
--webtransport <host:port>
|
|
42
|
-
--state-dir <path>
|
|
43
|
-
|
|
44
|
-
-
|
|
37
|
+
--bind <host:port> Address to listen on. Port 0 asks the OS for a free port. Use 127.0.0.1:8787 to accept local connections only. (default: 0.0.0.0:8787)
|
|
38
|
+
--tls-cert <path> PEM certificate file, to serve wss:// and https://. Requires --tls-key.
|
|
39
|
+
--tls-key <path> PEM private key file for --tls-cert. Requires --tls-cert.
|
|
40
|
+
--mailbox-dir <path> Directory to hold other devices' logs in while they are offline, which makes this node an announcer as well as a relay. Bounded by fixed limits. Off unless given.
|
|
41
|
+
--webtransport <host:port> Also serve WebTransport on this UDP address, with a self-signed certificate renewed on a schedule that a browser accepts by pinning its hash, and print the address to give a console. Needs the optional @fails-components/webtransport dependency. Off unless given.
|
|
42
|
+
--state-dir <path> Directory to keep the WebTransport certificates in, private keys included, so a restart serves the same certificates and the addresses already handed out stay valid. Created readable by this user only. Without it the certificates live in memory and a restart starts a new set. Needs --webtransport.
|
|
43
|
+
--certificate-lifetime <seconds> How long each WebTransport certificate is valid, in seconds. The node rotates to the next certificate every half lifetime, ending the sessions open on the server it replaces, so a shorter lifetime rotates more often. At most the WebTransport limit for a pinned certificate, which is also the default. Needs --webtransport.
|
|
44
|
+
-h, --help Print this help and exit.
|
|
45
|
+
-v, --version Print the version and exit.
|
|
45
46
|
```
|
|
46
47
|
|
|
47
48
|
**Default bind address is `0.0.0.0:8787`, not loopback.** Unlike a typical dev-server tool, whose loopback-only default assumes only the machine itself needs to reach it, this package's whole purpose is LAN reachability — a phone on the same network, a laptop in the next room. `--bind` overrides the address if you want to restrict it (loopback-only, a specific interface, a different port).
|
|
@@ -55,7 +56,7 @@ curl http://<host>:8787/health
|
|
|
55
56
|
|
|
56
57
|
With `--webtransport <host:port>` the node also serves WebTransport on a UDP port, for a browser that has no way to trust a certificate for this node's LAN address. The node mints self-signed ECDSA certificates, each valid for under two weeks, and prints an address of the form `https://<host>:<port>#sha256=<hash>[,<hash>...]` for each address of this machine a client can reach (when bound to a wildcard, one per non-internal IPv4 address). Give one of them to a console (the Node field takes it as typed): a browser accepts the certificate whose hash matches any in the list, with no certificate authority and no internet.
|
|
57
58
|
|
|
58
|
-
The list holds the certificate served now and the ones that will replace it. The node rotates to the next certificate every half lifetime, by replacing its WebTransport server, so open sessions on that server end and a console reconnects, and an address stays usable for about three of those periods after it was copied. Without `--state-dir` the certificates live in memory, so a restart starts a new set and every address already handed out stops working; with it the certificates, private keys included, are kept in that directory (created readable by this user only) and a restart resumes them. The server is the optional `@fails-components/webtransport` dependency with its native binary; where it is not installed the flag fails at startup rather than serving without it. The prebuilt binary needs glibc 2.38 or newer on Linux.
|
|
59
|
+
The list holds the certificate served now and the ones that will replace it. The node rotates to the next certificate every half lifetime, by replacing its WebTransport server, so open sessions on that server end and a console reconnects, and an address stays usable for about three of those periods after it was copied, and a console that reconnects is told the current list by the node, so one that keeps reconnecting never needs a new address. Without `--state-dir` the certificates live in memory, so a restart starts a new set and every address already handed out stops working; with it the certificates, private keys included, are kept in that directory (created readable by this user only) and a restart resumes them. The server is the optional `@fails-components/webtransport` dependency with its native binary; where it is not installed the flag fails at startup rather than serving without it. The prebuilt binary needs glibc 2.38 or newer on Linux.
|
|
59
60
|
|
|
60
61
|
With `--mailbox-dir` the node also serves the announcer role and `/health` says so (`"roles":["relay","announcer"]`). It then holds other devices' `core/data` logs in that directory while those devices are offline, so a peer that reconnects catches up from the node. Only the device that owns a log can write to it: the node takes a log's entries from the connection whose verified advert names that device and from no one else. Any client that has gossiped an advert can read a log. Entries are opaque bytes to the node, and the applications that write them sign and encrypt their own. The number of logs, the bytes in each and the size of an entry are bounded (`src/mailbox-limits.ts`), and a frame that would exceed a bound is refused whole; nothing is evicted, so a full log stays full until the directory is cleared.
|
|
61
62
|
|
package/dist/server.cjs
CHANGED
|
@@ -16,6 +16,12 @@ let wire_mesh_core_domain_pinned_address = require("wire-mesh-core/domain/pinned
|
|
|
16
16
|
let wire_mesh_core_adapters_memory_storage = require("wire-mesh-core/adapters/memory-storage");
|
|
17
17
|
require("reflect-metadata");
|
|
18
18
|
let _peculiar_x509 = require("@peculiar/x509");
|
|
19
|
+
//#region src/adapters/certificate-limits.ts
|
|
20
|
+
const MILLISECONDS_PER_SECOND = 1e3;
|
|
21
|
+
const MILLISECONDS_PER_DAY = 86400 * MILLISECONDS_PER_SECOND;
|
|
22
|
+
/** Slack under the maximum, so clock skew between the node and a browser cannot push the certificate over the limit. */
|
|
23
|
+
const PINNED_CERTIFICATE_LIFETIME_MS = 14 * MILLISECONDS_PER_DAY - 1 * MILLISECONDS_PER_DAY;
|
|
24
|
+
//#endregion
|
|
19
25
|
//#region src/cli-options.ts
|
|
20
26
|
/** The address the server binds when --bind is not given. */
|
|
21
27
|
const DEFAULT_BIND_ADDRESS = "0.0.0.0:8787";
|
|
@@ -53,6 +59,11 @@ const FLAGS = {
|
|
|
53
59
|
valueName: "path",
|
|
54
60
|
description: "Directory to keep the WebTransport certificates in, private keys included, so a restart serves the same certificates and the addresses already handed out stay valid. Created readable by this user only. Without it the certificates live in memory and a restart starts a new set. Needs --webtransport."
|
|
55
61
|
},
|
|
62
|
+
"certificate-lifetime": {
|
|
63
|
+
type: "string",
|
|
64
|
+
valueName: "seconds",
|
|
65
|
+
description: "How long each WebTransport certificate is valid, in seconds. The node rotates to the next certificate every half lifetime, ending the sessions open on the server it replaces, so a shorter lifetime rotates more often. At most the WebTransport limit for a pinned certificate, which is also the default. Needs --webtransport."
|
|
66
|
+
},
|
|
56
67
|
help: {
|
|
57
68
|
type: "boolean",
|
|
58
69
|
short: "h",
|
|
@@ -86,6 +97,12 @@ function helpText() {
|
|
|
86
97
|
...rows.map((row) => ` ${row.label.padEnd(labelWidth)} ${row.description}`)
|
|
87
98
|
].join("\n");
|
|
88
99
|
}
|
|
100
|
+
function validatedCertificateLifetimeMs(seconds) {
|
|
101
|
+
if (seconds === void 0) return;
|
|
102
|
+
const lifetimeMs = Number(seconds) * MILLISECONDS_PER_SECOND;
|
|
103
|
+
if (!/^\d+$/.test(seconds) || lifetimeMs === 0 || lifetimeMs > 11232e5) throw new CliUsageError(`--certificate-lifetime expects a whole number of seconds from 1 to ${String(PINNED_CERTIFICATE_LIFETIME_MS / MILLISECONDS_PER_SECOND)}, got "${seconds}"`);
|
|
104
|
+
return lifetimeMs;
|
|
105
|
+
}
|
|
89
106
|
function validatedBindAddress(flag, address) {
|
|
90
107
|
const separator = address.lastIndexOf(":");
|
|
91
108
|
const host = address.slice(0, separator);
|
|
@@ -113,6 +130,7 @@ function parseCliArguments(argv) {
|
|
|
113
130
|
const keyPath = values["tls-key"];
|
|
114
131
|
if (certPath === void 0 !== (keyPath === void 0)) throw new CliUsageError("--tls-cert and --tls-key must be given together");
|
|
115
132
|
if (values["state-dir"] !== void 0 && values.webtransport === void 0) throw new CliUsageError("--state-dir needs --webtransport");
|
|
133
|
+
if (values["certificate-lifetime"] !== void 0 && values.webtransport === void 0) throw new CliUsageError("--certificate-lifetime needs --webtransport");
|
|
116
134
|
return {
|
|
117
135
|
kind: "serve",
|
|
118
136
|
bindAddress: validatedBindAddress("bind", values.bind),
|
|
@@ -122,7 +140,8 @@ function parseCliArguments(argv) {
|
|
|
122
140
|
},
|
|
123
141
|
mailboxDir: values["mailbox-dir"],
|
|
124
142
|
webTransportAddress: values.webtransport === void 0 ? void 0 : validatedBindAddress("webtransport", values.webtransport),
|
|
125
|
-
stateDir: values["state-dir"]
|
|
143
|
+
stateDir: values["state-dir"],
|
|
144
|
+
certificateLifetimeMs: validatedCertificateLifetimeMs(values["certificate-lifetime"])
|
|
126
145
|
};
|
|
127
146
|
}
|
|
128
147
|
//#endregion
|
|
@@ -136,13 +155,8 @@ const nodeMailboxLimits = {
|
|
|
136
155
|
};
|
|
137
156
|
//#endregion
|
|
138
157
|
//#region src/adapters/pinned-certificate.ts
|
|
139
|
-
const MILLISECONDS_PER_DAY = 864e5;
|
|
140
|
-
const MAX_PINNED_CERTIFICATE_LIFETIME_DAYS = 14;
|
|
141
|
-
const CLOCK_SKEW_ALLOWANCE_DAYS = 1;
|
|
142
158
|
const SERIAL_NUMBER_BYTES = 16;
|
|
143
159
|
const PEM_LINE_LENGTH = 64;
|
|
144
|
-
/** Slack under the maximum, so clock skew between the node and a browser cannot push the certificate over the limit. */
|
|
145
|
-
const PINNED_CERTIFICATE_LIFETIME_MS = MAX_PINNED_CERTIFICATE_LIFETIME_DAYS * MILLISECONDS_PER_DAY - CLOCK_SKEW_ALLOWANCE_DAYS * MILLISECONDS_PER_DAY;
|
|
146
160
|
const SIGNING_ALGORITHM = {
|
|
147
161
|
name: "ECDSA",
|
|
148
162
|
namedCurve: "P-256",
|
|
@@ -247,6 +261,11 @@ async function advanceCertificateSchedule(storage, now, options = {}) {
|
|
|
247
261
|
/** The path a client opens its WebTransport session on. */
|
|
248
262
|
const WEBTRANSPORT_PATH = "/wire-mesh";
|
|
249
263
|
const SECRET_BYTES = 32;
|
|
264
|
+
/** How long to wait before trying a failed rotation again: the port may still be held by the server just stopped. */
|
|
265
|
+
const ROTATION_RETRY_MS = 1e3;
|
|
266
|
+
/** The application close code and reason sent when a certificate rotation ends a session; the code is arbitrary, and 0 is what a normal close carries. */
|
|
267
|
+
const SESSION_CLOSE_CODE = 0;
|
|
268
|
+
const ROTATION_CLOSE_REASON = "certificate rotated";
|
|
250
269
|
const WILDCARD_HOSTS = ["0.0.0.0", "::"];
|
|
251
270
|
/** The hosts a client can dial for a listener bound to `host`: the host itself, or for a wildcard every non-internal IPv4 address of this machine. */
|
|
252
271
|
function reachableHosts(host) {
|
|
@@ -271,7 +290,7 @@ async function loadHttp3Server() {
|
|
|
271
290
|
}
|
|
272
291
|
}
|
|
273
292
|
function isIncomingSession(value) {
|
|
274
|
-
return typeof value === "object" && value !== null && "ready" in value && value.ready instanceof Promise && "incomingBidirectionalStreams" in value && value.incomingBidirectionalStreams instanceof ReadableStream;
|
|
293
|
+
return typeof value === "object" && value !== null && "ready" in value && value.ready instanceof Promise && "closed" in value && value.closed instanceof Promise && "close" in value && typeof value.close === "function" && "createUnidirectionalStream" in value && typeof value.createUnidirectionalStream === "function" && "incomingBidirectionalStreams" in value && value.incomingBidirectionalStreams instanceof ReadableStream;
|
|
275
294
|
}
|
|
276
295
|
/**
|
|
277
296
|
* A WebTransport Transport: each session carries one connection, on the first bidirectional stream the client opens, framed as length-prefixed CBOR (core's byte-stream-connection). A server binds a UDP port and serves a self-signed ECDSA certificate it renews on a schedule, which a browser accepts by pinning its hash (`serverCertificateHashes`) with no certificate authority involved.
|
|
@@ -309,37 +328,77 @@ function createWebTransportTransport(options = {}) {
|
|
|
309
328
|
const bound = started.address();
|
|
310
329
|
if (bound === null) throw new Error("the WebTransport server bound no address");
|
|
311
330
|
port = bound.port;
|
|
331
|
+
const liveSessions = /* @__PURE__ */ new Set();
|
|
332
|
+
const drainState = { draining: false };
|
|
333
|
+
const endSession = (session) => {
|
|
334
|
+
session.close({
|
|
335
|
+
closeCode: SESSION_CLOSE_CODE,
|
|
336
|
+
reason: ROTATION_CLOSE_REASON
|
|
337
|
+
});
|
|
338
|
+
};
|
|
312
339
|
(async () => {
|
|
313
340
|
for await (const session of started.sessionStream(WEBTRANSPORT_PATH)) (async () => {
|
|
314
341
|
if (!isIncomingSession(session)) throw new Error("the WebTransport server yielded a malformed session");
|
|
315
342
|
await session.ready;
|
|
343
|
+
if (drainState.draining) {
|
|
344
|
+
endSession(session);
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
liveSessions.add(session);
|
|
348
|
+
session.closed.catch(() => void 0).finally(() => {
|
|
349
|
+
liveSessions.delete(session);
|
|
350
|
+
});
|
|
351
|
+
const announcer = (await session.createUnidirectionalStream()).getWriter();
|
|
352
|
+
await announcer.write((0, wire_mesh_core_domain_pinned_address.encodeCertificateHashes)(schedule.advertised.map((certificate) => certificate.sha256Hex)));
|
|
353
|
+
await announcer.close();
|
|
316
354
|
const reader = session.incomingBidirectionalStreams.getReader();
|
|
317
355
|
const first = await reader.read();
|
|
318
356
|
reader.releaseLock();
|
|
319
357
|
if (!first.done) onConnection((0, wire_mesh_core_adapters_byte_stream_connection.connectionFromByteStream)(first.value));
|
|
320
358
|
})().catch(reportError);
|
|
321
359
|
})().catch(reportError);
|
|
322
|
-
return
|
|
360
|
+
return {
|
|
361
|
+
server: started,
|
|
362
|
+
drain: async () => {
|
|
363
|
+
drainState.draining = true;
|
|
364
|
+
const open = [...liveSessions];
|
|
365
|
+
for (const session of open) endSession(session);
|
|
366
|
+
await Promise.allSettled(open.map(async (session) => session.closed));
|
|
367
|
+
}
|
|
368
|
+
};
|
|
323
369
|
};
|
|
324
|
-
let
|
|
370
|
+
let running = await startServer();
|
|
325
371
|
const advertise = () => reachableHosts(host).map((reachable) => (0, wire_mesh_core_domain_pinned_address.formatPinnedAddress)(`${reachable}:${String(port)}`, schedule.advertised.map((certificate) => certificate.sha256Hex)));
|
|
372
|
+
/** Drains and stops the running server, if there is one: cleared first, so a rotation that failed after this point is retried by starting a server rather than by retiring one that is already gone. */
|
|
373
|
+
const retire = async () => {
|
|
374
|
+
const current = running;
|
|
375
|
+
running = void 0;
|
|
376
|
+
if (current === void 0) return;
|
|
377
|
+
await current.drain();
|
|
378
|
+
current.server.stopServer();
|
|
379
|
+
await current.server.closed;
|
|
380
|
+
};
|
|
326
381
|
let rotation;
|
|
327
382
|
let closed = false;
|
|
328
|
-
const
|
|
383
|
+
const untilRotation = () => Math.max(schedule.rotatesAt.getTime() - now().getTime(), 0);
|
|
384
|
+
const armRotation = (delayMs) => {
|
|
329
385
|
rotation = setTimeout(() => {
|
|
330
386
|
(async () => {
|
|
331
387
|
schedule = await advanceCertificateSchedule(storage, now(), scheduleOptions);
|
|
332
|
-
|
|
333
|
-
await server.closed;
|
|
388
|
+
await retire();
|
|
334
389
|
if (closed) return;
|
|
335
|
-
|
|
390
|
+
running = await startServer();
|
|
336
391
|
options.onCertificateRenewed?.(advertise());
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
392
|
+
})().then(() => {
|
|
393
|
+
if (!closed) armRotation(untilRotation());
|
|
394
|
+
}, (error) => {
|
|
395
|
+
reportError(error);
|
|
396
|
+
if (!closed) armRotation(ROTATION_RETRY_MS);
|
|
397
|
+
});
|
|
398
|
+
}, delayMs);
|
|
340
399
|
rotation.unref();
|
|
341
400
|
};
|
|
342
|
-
armRotation();
|
|
401
|
+
armRotation(untilRotation());
|
|
343
402
|
return {
|
|
344
403
|
get address() {
|
|
345
404
|
return `${host}:${String(port)}`;
|
|
@@ -350,8 +409,7 @@ function createWebTransportTransport(options = {}) {
|
|
|
350
409
|
close: async () => {
|
|
351
410
|
closed = true;
|
|
352
411
|
clearTimeout(rotation);
|
|
353
|
-
|
|
354
|
-
await server.closed;
|
|
412
|
+
await retire();
|
|
355
413
|
},
|
|
356
414
|
unref: () => {
|
|
357
415
|
rotation?.unref();
|
|
@@ -495,6 +553,7 @@ async function main(argv) {
|
|
|
495
553
|
mode: STATE_DIR_MODE
|
|
496
554
|
});
|
|
497
555
|
const webTransport = await createWebTransportTransport({
|
|
556
|
+
...command.certificateLifetimeMs !== void 0 ? { certificateLifetimeMs: command.certificateLifetimeMs } : {},
|
|
498
557
|
...command.stateDir !== void 0 ? { storage: (0, wire_mesh_core_adapters_node_fs_storage.createNodeFsStorage)({ dir: command.stateDir }) } : {},
|
|
499
558
|
onError: (error) => {
|
|
500
559
|
logError(`wire-mesh: WebTransport: ${String(error)}`);
|
package/dist/server.mjs
CHANGED
|
@@ -11,10 +11,16 @@ import { createNodeFsStorage } from "wire-mesh-core/adapters/node-fs-storage";
|
|
|
11
11
|
import { randomBytes } from "node:crypto";
|
|
12
12
|
import { networkInterfaces } from "node:os";
|
|
13
13
|
import { connectionFromByteStream } from "wire-mesh-core/adapters/byte-stream-connection";
|
|
14
|
-
import { formatPinnedAddress } from "wire-mesh-core/domain/pinned-address";
|
|
14
|
+
import { encodeCertificateHashes, formatPinnedAddress } from "wire-mesh-core/domain/pinned-address";
|
|
15
15
|
import { createMemoryStorage } from "wire-mesh-core/adapters/memory-storage";
|
|
16
16
|
import "reflect-metadata";
|
|
17
17
|
import { BasicConstraintsExtension, ExtendedKeyUsage, ExtendedKeyUsageExtension, KeyUsageFlags, KeyUsagesExtension, X509CertificateGenerator } from "@peculiar/x509";
|
|
18
|
+
//#region src/adapters/certificate-limits.ts
|
|
19
|
+
const MILLISECONDS_PER_SECOND = 1e3;
|
|
20
|
+
const MILLISECONDS_PER_DAY = 86400 * MILLISECONDS_PER_SECOND;
|
|
21
|
+
/** Slack under the maximum, so clock skew between the node and a browser cannot push the certificate over the limit. */
|
|
22
|
+
const PINNED_CERTIFICATE_LIFETIME_MS = 14 * MILLISECONDS_PER_DAY - 1 * MILLISECONDS_PER_DAY;
|
|
23
|
+
//#endregion
|
|
18
24
|
//#region src/cli-options.ts
|
|
19
25
|
/** The address the server binds when --bind is not given. */
|
|
20
26
|
const DEFAULT_BIND_ADDRESS = "0.0.0.0:8787";
|
|
@@ -52,6 +58,11 @@ const FLAGS = {
|
|
|
52
58
|
valueName: "path",
|
|
53
59
|
description: "Directory to keep the WebTransport certificates in, private keys included, so a restart serves the same certificates and the addresses already handed out stay valid. Created readable by this user only. Without it the certificates live in memory and a restart starts a new set. Needs --webtransport."
|
|
54
60
|
},
|
|
61
|
+
"certificate-lifetime": {
|
|
62
|
+
type: "string",
|
|
63
|
+
valueName: "seconds",
|
|
64
|
+
description: "How long each WebTransport certificate is valid, in seconds. The node rotates to the next certificate every half lifetime, ending the sessions open on the server it replaces, so a shorter lifetime rotates more often. At most the WebTransport limit for a pinned certificate, which is also the default. Needs --webtransport."
|
|
65
|
+
},
|
|
55
66
|
help: {
|
|
56
67
|
type: "boolean",
|
|
57
68
|
short: "h",
|
|
@@ -85,6 +96,12 @@ function helpText() {
|
|
|
85
96
|
...rows.map((row) => ` ${row.label.padEnd(labelWidth)} ${row.description}`)
|
|
86
97
|
].join("\n");
|
|
87
98
|
}
|
|
99
|
+
function validatedCertificateLifetimeMs(seconds) {
|
|
100
|
+
if (seconds === void 0) return;
|
|
101
|
+
const lifetimeMs = Number(seconds) * MILLISECONDS_PER_SECOND;
|
|
102
|
+
if (!/^\d+$/.test(seconds) || lifetimeMs === 0 || lifetimeMs > 11232e5) throw new CliUsageError(`--certificate-lifetime expects a whole number of seconds from 1 to ${String(PINNED_CERTIFICATE_LIFETIME_MS / MILLISECONDS_PER_SECOND)}, got "${seconds}"`);
|
|
103
|
+
return lifetimeMs;
|
|
104
|
+
}
|
|
88
105
|
function validatedBindAddress(flag, address) {
|
|
89
106
|
const separator = address.lastIndexOf(":");
|
|
90
107
|
const host = address.slice(0, separator);
|
|
@@ -112,6 +129,7 @@ function parseCliArguments(argv) {
|
|
|
112
129
|
const keyPath = values["tls-key"];
|
|
113
130
|
if (certPath === void 0 !== (keyPath === void 0)) throw new CliUsageError("--tls-cert and --tls-key must be given together");
|
|
114
131
|
if (values["state-dir"] !== void 0 && values.webtransport === void 0) throw new CliUsageError("--state-dir needs --webtransport");
|
|
132
|
+
if (values["certificate-lifetime"] !== void 0 && values.webtransport === void 0) throw new CliUsageError("--certificate-lifetime needs --webtransport");
|
|
115
133
|
return {
|
|
116
134
|
kind: "serve",
|
|
117
135
|
bindAddress: validatedBindAddress("bind", values.bind),
|
|
@@ -121,7 +139,8 @@ function parseCliArguments(argv) {
|
|
|
121
139
|
},
|
|
122
140
|
mailboxDir: values["mailbox-dir"],
|
|
123
141
|
webTransportAddress: values.webtransport === void 0 ? void 0 : validatedBindAddress("webtransport", values.webtransport),
|
|
124
|
-
stateDir: values["state-dir"]
|
|
142
|
+
stateDir: values["state-dir"],
|
|
143
|
+
certificateLifetimeMs: validatedCertificateLifetimeMs(values["certificate-lifetime"])
|
|
125
144
|
};
|
|
126
145
|
}
|
|
127
146
|
//#endregion
|
|
@@ -135,13 +154,8 @@ const nodeMailboxLimits = {
|
|
|
135
154
|
};
|
|
136
155
|
//#endregion
|
|
137
156
|
//#region src/adapters/pinned-certificate.ts
|
|
138
|
-
const MILLISECONDS_PER_DAY = 864e5;
|
|
139
|
-
const MAX_PINNED_CERTIFICATE_LIFETIME_DAYS = 14;
|
|
140
|
-
const CLOCK_SKEW_ALLOWANCE_DAYS = 1;
|
|
141
157
|
const SERIAL_NUMBER_BYTES = 16;
|
|
142
158
|
const PEM_LINE_LENGTH = 64;
|
|
143
|
-
/** Slack under the maximum, so clock skew between the node and a browser cannot push the certificate over the limit. */
|
|
144
|
-
const PINNED_CERTIFICATE_LIFETIME_MS = MAX_PINNED_CERTIFICATE_LIFETIME_DAYS * MILLISECONDS_PER_DAY - CLOCK_SKEW_ALLOWANCE_DAYS * MILLISECONDS_PER_DAY;
|
|
145
159
|
const SIGNING_ALGORITHM = {
|
|
146
160
|
name: "ECDSA",
|
|
147
161
|
namedCurve: "P-256",
|
|
@@ -246,6 +260,11 @@ async function advanceCertificateSchedule(storage, now, options = {}) {
|
|
|
246
260
|
/** The path a client opens its WebTransport session on. */
|
|
247
261
|
const WEBTRANSPORT_PATH = "/wire-mesh";
|
|
248
262
|
const SECRET_BYTES = 32;
|
|
263
|
+
/** How long to wait before trying a failed rotation again: the port may still be held by the server just stopped. */
|
|
264
|
+
const ROTATION_RETRY_MS = 1e3;
|
|
265
|
+
/** The application close code and reason sent when a certificate rotation ends a session; the code is arbitrary, and 0 is what a normal close carries. */
|
|
266
|
+
const SESSION_CLOSE_CODE = 0;
|
|
267
|
+
const ROTATION_CLOSE_REASON = "certificate rotated";
|
|
249
268
|
const WILDCARD_HOSTS = ["0.0.0.0", "::"];
|
|
250
269
|
/** The hosts a client can dial for a listener bound to `host`: the host itself, or for a wildcard every non-internal IPv4 address of this machine. */
|
|
251
270
|
function reachableHosts(host) {
|
|
@@ -270,7 +289,7 @@ async function loadHttp3Server() {
|
|
|
270
289
|
}
|
|
271
290
|
}
|
|
272
291
|
function isIncomingSession(value) {
|
|
273
|
-
return typeof value === "object" && value !== null && "ready" in value && value.ready instanceof Promise && "incomingBidirectionalStreams" in value && value.incomingBidirectionalStreams instanceof ReadableStream;
|
|
292
|
+
return typeof value === "object" && value !== null && "ready" in value && value.ready instanceof Promise && "closed" in value && value.closed instanceof Promise && "close" in value && typeof value.close === "function" && "createUnidirectionalStream" in value && typeof value.createUnidirectionalStream === "function" && "incomingBidirectionalStreams" in value && value.incomingBidirectionalStreams instanceof ReadableStream;
|
|
274
293
|
}
|
|
275
294
|
/**
|
|
276
295
|
* A WebTransport Transport: each session carries one connection, on the first bidirectional stream the client opens, framed as length-prefixed CBOR (core's byte-stream-connection). A server binds a UDP port and serves a self-signed ECDSA certificate it renews on a schedule, which a browser accepts by pinning its hash (`serverCertificateHashes`) with no certificate authority involved.
|
|
@@ -308,37 +327,77 @@ function createWebTransportTransport(options = {}) {
|
|
|
308
327
|
const bound = started.address();
|
|
309
328
|
if (bound === null) throw new Error("the WebTransport server bound no address");
|
|
310
329
|
port = bound.port;
|
|
330
|
+
const liveSessions = /* @__PURE__ */ new Set();
|
|
331
|
+
const drainState = { draining: false };
|
|
332
|
+
const endSession = (session) => {
|
|
333
|
+
session.close({
|
|
334
|
+
closeCode: SESSION_CLOSE_CODE,
|
|
335
|
+
reason: ROTATION_CLOSE_REASON
|
|
336
|
+
});
|
|
337
|
+
};
|
|
311
338
|
(async () => {
|
|
312
339
|
for await (const session of started.sessionStream(WEBTRANSPORT_PATH)) (async () => {
|
|
313
340
|
if (!isIncomingSession(session)) throw new Error("the WebTransport server yielded a malformed session");
|
|
314
341
|
await session.ready;
|
|
342
|
+
if (drainState.draining) {
|
|
343
|
+
endSession(session);
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
liveSessions.add(session);
|
|
347
|
+
session.closed.catch(() => void 0).finally(() => {
|
|
348
|
+
liveSessions.delete(session);
|
|
349
|
+
});
|
|
350
|
+
const announcer = (await session.createUnidirectionalStream()).getWriter();
|
|
351
|
+
await announcer.write(encodeCertificateHashes(schedule.advertised.map((certificate) => certificate.sha256Hex)));
|
|
352
|
+
await announcer.close();
|
|
315
353
|
const reader = session.incomingBidirectionalStreams.getReader();
|
|
316
354
|
const first = await reader.read();
|
|
317
355
|
reader.releaseLock();
|
|
318
356
|
if (!first.done) onConnection(connectionFromByteStream(first.value));
|
|
319
357
|
})().catch(reportError);
|
|
320
358
|
})().catch(reportError);
|
|
321
|
-
return
|
|
359
|
+
return {
|
|
360
|
+
server: started,
|
|
361
|
+
drain: async () => {
|
|
362
|
+
drainState.draining = true;
|
|
363
|
+
const open = [...liveSessions];
|
|
364
|
+
for (const session of open) endSession(session);
|
|
365
|
+
await Promise.allSettled(open.map(async (session) => session.closed));
|
|
366
|
+
}
|
|
367
|
+
};
|
|
322
368
|
};
|
|
323
|
-
let
|
|
369
|
+
let running = await startServer();
|
|
324
370
|
const advertise = () => reachableHosts(host).map((reachable) => formatPinnedAddress(`${reachable}:${String(port)}`, schedule.advertised.map((certificate) => certificate.sha256Hex)));
|
|
371
|
+
/** Drains and stops the running server, if there is one: cleared first, so a rotation that failed after this point is retried by starting a server rather than by retiring one that is already gone. */
|
|
372
|
+
const retire = async () => {
|
|
373
|
+
const current = running;
|
|
374
|
+
running = void 0;
|
|
375
|
+
if (current === void 0) return;
|
|
376
|
+
await current.drain();
|
|
377
|
+
current.server.stopServer();
|
|
378
|
+
await current.server.closed;
|
|
379
|
+
};
|
|
325
380
|
let rotation;
|
|
326
381
|
let closed = false;
|
|
327
|
-
const
|
|
382
|
+
const untilRotation = () => Math.max(schedule.rotatesAt.getTime() - now().getTime(), 0);
|
|
383
|
+
const armRotation = (delayMs) => {
|
|
328
384
|
rotation = setTimeout(() => {
|
|
329
385
|
(async () => {
|
|
330
386
|
schedule = await advanceCertificateSchedule(storage, now(), scheduleOptions);
|
|
331
|
-
|
|
332
|
-
await server.closed;
|
|
387
|
+
await retire();
|
|
333
388
|
if (closed) return;
|
|
334
|
-
|
|
389
|
+
running = await startServer();
|
|
335
390
|
options.onCertificateRenewed?.(advertise());
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
391
|
+
})().then(() => {
|
|
392
|
+
if (!closed) armRotation(untilRotation());
|
|
393
|
+
}, (error) => {
|
|
394
|
+
reportError(error);
|
|
395
|
+
if (!closed) armRotation(ROTATION_RETRY_MS);
|
|
396
|
+
});
|
|
397
|
+
}, delayMs);
|
|
339
398
|
rotation.unref();
|
|
340
399
|
};
|
|
341
|
-
armRotation();
|
|
400
|
+
armRotation(untilRotation());
|
|
342
401
|
return {
|
|
343
402
|
get address() {
|
|
344
403
|
return `${host}:${String(port)}`;
|
|
@@ -349,8 +408,7 @@ function createWebTransportTransport(options = {}) {
|
|
|
349
408
|
close: async () => {
|
|
350
409
|
closed = true;
|
|
351
410
|
clearTimeout(rotation);
|
|
352
|
-
|
|
353
|
-
await server.closed;
|
|
411
|
+
await retire();
|
|
354
412
|
},
|
|
355
413
|
unref: () => {
|
|
356
414
|
rotation?.unref();
|
|
@@ -494,6 +552,7 @@ async function main(argv) {
|
|
|
494
552
|
mode: STATE_DIR_MODE
|
|
495
553
|
});
|
|
496
554
|
const webTransport = await createWebTransportTransport({
|
|
555
|
+
...command.certificateLifetimeMs !== void 0 ? { certificateLifetimeMs: command.certificateLifetimeMs } : {},
|
|
497
556
|
...command.stateDir !== void 0 ? { storage: createNodeFsStorage({ dir: command.stateDir }) } : {},
|
|
498
557
|
onError: (error) => {
|
|
499
558
|
logError(`wire-mesh: WebTransport: ${String(error)}`);
|