wire-mesh 3.2.0 → 3.3.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
CHANGED
|
@@ -39,6 +39,7 @@ Options:
|
|
|
39
39
|
--tls-key <path> PEM private key file for --tls-cert. Requires --tls-cert.
|
|
40
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
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.
|
|
42
43
|
-h, --help Print this help and exit.
|
|
43
44
|
-v, --version Print the version and exit.
|
|
44
45
|
```
|
|
@@ -52,7 +53,9 @@ curl http://<host>:8787/health
|
|
|
52
53
|
# {"ok":true,"node":"wire-mesh","roles":["relay"]}
|
|
53
54
|
```
|
|
54
55
|
|
|
55
|
-
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
|
|
56
|
+
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
|
+
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.
|
|
56
59
|
|
|
57
60
|
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.
|
|
58
61
|
|
package/dist/server.cjs
CHANGED
|
@@ -13,6 +13,7 @@ let node_crypto = require("node:crypto");
|
|
|
13
13
|
let node_os = require("node:os");
|
|
14
14
|
let wire_mesh_core_adapters_byte_stream_connection = require("wire-mesh-core/adapters/byte-stream-connection");
|
|
15
15
|
let wire_mesh_core_domain_pinned_address = require("wire-mesh-core/domain/pinned-address");
|
|
16
|
+
let wire_mesh_core_adapters_memory_storage = require("wire-mesh-core/adapters/memory-storage");
|
|
16
17
|
require("reflect-metadata");
|
|
17
18
|
let _peculiar_x509 = require("@peculiar/x509");
|
|
18
19
|
//#region src/cli-options.ts
|
|
@@ -47,6 +48,11 @@ const FLAGS = {
|
|
|
47
48
|
valueName: "host:port",
|
|
48
49
|
description: "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."
|
|
49
50
|
},
|
|
51
|
+
"state-dir": {
|
|
52
|
+
type: "string",
|
|
53
|
+
valueName: "path",
|
|
54
|
+
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
|
+
},
|
|
50
56
|
help: {
|
|
51
57
|
type: "boolean",
|
|
52
58
|
short: "h",
|
|
@@ -106,6 +112,7 @@ function parseCliArguments(argv) {
|
|
|
106
112
|
const certPath = values["tls-cert"];
|
|
107
113
|
const keyPath = values["tls-key"];
|
|
108
114
|
if (certPath === void 0 !== (keyPath === void 0)) throw new CliUsageError("--tls-cert and --tls-key must be given together");
|
|
115
|
+
if (values["state-dir"] !== void 0 && values.webtransport === void 0) throw new CliUsageError("--state-dir needs --webtransport");
|
|
109
116
|
return {
|
|
110
117
|
kind: "serve",
|
|
111
118
|
bindAddress: validatedBindAddress("bind", values.bind),
|
|
@@ -114,7 +121,8 @@ function parseCliArguments(argv) {
|
|
|
114
121
|
keyPath
|
|
115
122
|
},
|
|
116
123
|
mailboxDir: values["mailbox-dir"],
|
|
117
|
-
webTransportAddress: values.webtransport === void 0 ? void 0 : validatedBindAddress("webtransport", values.webtransport)
|
|
124
|
+
webTransportAddress: values.webtransport === void 0 ? void 0 : validatedBindAddress("webtransport", values.webtransport),
|
|
125
|
+
stateDir: values["state-dir"]
|
|
118
126
|
};
|
|
119
127
|
}
|
|
120
128
|
//#endregion
|
|
@@ -147,15 +155,15 @@ function toPem(label, der) {
|
|
|
147
155
|
return `-----BEGIN ${label}-----\n${(Buffer.from(der).toString("base64").match(new RegExp(`.{1,${String(PEM_LINE_LENGTH)}}`, "g")) ?? []).join("\n")}\n-----END ${label}-----\n`;
|
|
148
156
|
}
|
|
149
157
|
/**
|
|
150
|
-
* Mints an ECDSA P-256 certificate valid from `
|
|
158
|
+
* Mints an ECDSA P-256 certificate valid from `notBefore` for `lifetimeMs` (PINNED_CERTIFICATE_LIFETIME_MS unless a test asks for less). Clients pin it by hash rather than by name, so it carries only a common name.
|
|
151
159
|
*/
|
|
152
|
-
async function mintPinnedCertificate(
|
|
160
|
+
async function mintPinnedCertificate(notBefore, lifetimeMs = PINNED_CERTIFICATE_LIFETIME_MS) {
|
|
153
161
|
const keys = await crypto.subtle.generateKey(SIGNING_ALGORITHM, true, ["sign", "verify"]);
|
|
154
|
-
const notAfter = new Date(
|
|
162
|
+
const notAfter = new Date(notBefore.getTime() + lifetimeMs);
|
|
155
163
|
const certificate = await _peculiar_x509.X509CertificateGenerator.createSelfSigned({
|
|
156
164
|
serialNumber: toHex(crypto.getRandomValues(new Uint8Array(SERIAL_NUMBER_BYTES))),
|
|
157
165
|
name: "CN=wire-mesh",
|
|
158
|
-
notBefore
|
|
166
|
+
notBefore,
|
|
159
167
|
notAfter,
|
|
160
168
|
signingAlgorithm: SIGNING_ALGORITHM,
|
|
161
169
|
keys,
|
|
@@ -170,14 +178,74 @@ async function mintPinnedCertificate(now) {
|
|
|
170
178
|
certificatePem: certificate.toString("pem"),
|
|
171
179
|
privateKeyPem: toPem("PRIVATE KEY", await crypto.subtle.exportKey("pkcs8", keys.privateKey)),
|
|
172
180
|
sha256Hex: toHex(new Uint8Array(digest)),
|
|
181
|
+
notBefore,
|
|
173
182
|
notAfter
|
|
174
183
|
};
|
|
175
184
|
}
|
|
185
|
+
/** A certificate is served for half its lifetime, so each successor is already valid, and its hash already listed, well before it takes over. */
|
|
186
|
+
const SERVING_FRACTION = .5;
|
|
187
|
+
const STORAGE_KEY = "webtransport-certificates";
|
|
188
|
+
function isStoredCertificate(value) {
|
|
189
|
+
if (typeof value !== "object" || value === null) return false;
|
|
190
|
+
return "certificatePem" in value && typeof value.certificatePem === "string" && "privateKeyPem" in value && typeof value.privateKeyPem === "string" && "sha256Hex" in value && typeof value.sha256Hex === "string" && "notBefore" in value && typeof value.notBefore === "string" && "notAfter" in value && typeof value.notAfter === "string";
|
|
191
|
+
}
|
|
192
|
+
function fromStored(stored) {
|
|
193
|
+
return {
|
|
194
|
+
certificatePem: stored.certificatePem,
|
|
195
|
+
privateKeyPem: stored.privateKeyPem,
|
|
196
|
+
sha256Hex: stored.sha256Hex,
|
|
197
|
+
notBefore: new Date(stored.notBefore),
|
|
198
|
+
notAfter: new Date(stored.notAfter)
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
function toStored(certificate) {
|
|
202
|
+
return {
|
|
203
|
+
certificatePem: certificate.certificatePem,
|
|
204
|
+
privateKeyPem: certificate.privateKeyPem,
|
|
205
|
+
sha256Hex: certificate.sha256Hex,
|
|
206
|
+
notBefore: certificate.notBefore.toISOString(),
|
|
207
|
+
notAfter: certificate.notAfter.toISOString()
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
async function readStored(storage) {
|
|
211
|
+
const bytes = await storage.get(STORAGE_KEY);
|
|
212
|
+
if (bytes === void 0) return [];
|
|
213
|
+
const parsed = JSON.parse(new TextDecoder().decode(bytes));
|
|
214
|
+
if (!Array.isArray(parsed) || !parsed.every(isStoredCertificate)) throw new Error(`the stored ${STORAGE_KEY} are not in the expected shape`);
|
|
215
|
+
return parsed.map(fromStored);
|
|
216
|
+
}
|
|
217
|
+
async function writeStored(storage, certificates) {
|
|
218
|
+
await storage.set(STORAGE_KEY, new TextEncoder().encode(JSON.stringify(certificates.map(toStored))));
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* Keeps a rolling schedule of pinned certificates in `storage`: the one serving now and enough successors that an address lists ADVERTISED_CERTIFICATES of them. Each successor starts one serving period after its predecessor, so it is valid, and pinned, before it takes over.
|
|
222
|
+
*
|
|
223
|
+
* `advance` brings the schedule up to `now`: certificates that have finished serving are dropped, missing successors are minted, and if nothing stored is serving (a first run, or a node that was down past every stored certificate) a certificate valid from `now` starts a new schedule. What it returns is written back, so a restart resumes the same certificates and the addresses already handed out stay valid.
|
|
224
|
+
*/
|
|
225
|
+
async function advanceCertificateSchedule(storage, now, options = {}) {
|
|
226
|
+
const lifetimeMs = options.lifetimeMs ?? 11232e5;
|
|
227
|
+
const servingMs = lifetimeMs * SERVING_FRACTION;
|
|
228
|
+
const stillValid = (await readStored(storage)).filter((certificate) => certificate.notAfter.getTime() > now.getTime());
|
|
229
|
+
const startedIndex = stillValid.findLastIndex((certificate) => certificate.notBefore.getTime() <= now.getTime());
|
|
230
|
+
const certificates = startedIndex === -1 ? [await mintPinnedCertificate(now, lifetimeMs)] : stillValid.slice(startedIndex);
|
|
231
|
+
while (certificates.length < 4) {
|
|
232
|
+
const last = certificates[certificates.length - 1];
|
|
233
|
+
if (last === void 0) throw new Error("the certificate schedule is empty");
|
|
234
|
+
certificates.push(await mintPinnedCertificate(new Date(last.notBefore.getTime() + servingMs), lifetimeMs));
|
|
235
|
+
}
|
|
236
|
+
await writeStored(storage, certificates);
|
|
237
|
+
const [serving, next] = certificates;
|
|
238
|
+
if (serving === void 0 || next === void 0) throw new Error("the certificate schedule is empty");
|
|
239
|
+
return {
|
|
240
|
+
serving,
|
|
241
|
+
advertised: certificates.slice(0, 3),
|
|
242
|
+
rotatesAt: next.notBefore
|
|
243
|
+
};
|
|
244
|
+
}
|
|
176
245
|
//#endregion
|
|
177
246
|
//#region src/adapters/webtransport-transport.ts
|
|
178
247
|
/** The path a client opens its WebTransport session on. */
|
|
179
248
|
const WEBTRANSPORT_PATH = "/wire-mesh";
|
|
180
|
-
const RENEWAL_DELAY_MS = PINNED_CERTIFICATE_LIFETIME_MS * .5;
|
|
181
249
|
const SECRET_BYTES = 32;
|
|
182
250
|
const WILDCARD_HOSTS = ["0.0.0.0", "::"];
|
|
183
251
|
/** 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. */
|
|
@@ -188,12 +256,13 @@ function reachableHosts(host) {
|
|
|
188
256
|
/** The optional WebTransport package is not installed, or its native binary was not built for this platform. */
|
|
189
257
|
var WebTransportUnavailableError = class extends Error {
|
|
190
258
|
constructor(cause) {
|
|
191
|
-
super("serving WebTransport needs the optional dependency @fails-components/webtransport and its native binary, which is
|
|
259
|
+
super("serving WebTransport needs the optional dependency @fails-components/webtransport and its native binary, which is missing: either no binary is published for this platform, or the package manager skipped its install script (an ignore-scripts setting does that)", { cause });
|
|
192
260
|
this.name = "WebTransportUnavailableError";
|
|
193
261
|
}
|
|
194
262
|
};
|
|
195
263
|
async function loadHttp3Server() {
|
|
196
264
|
try {
|
|
265
|
+
await import("@fails-components/webtransport-transport-http3-quiche");
|
|
197
266
|
const module = await import("@fails-components/webtransport");
|
|
198
267
|
await module.quicheLoaded;
|
|
199
268
|
return module.Http3Server;
|
|
@@ -220,53 +289,72 @@ function createWebTransportTransport(options = {}) {
|
|
|
220
289
|
},
|
|
221
290
|
async listen(address, onConnection) {
|
|
222
291
|
const Http3ServerClass = await loadHttp3Server();
|
|
292
|
+
const storage = options.storage ?? (0, wire_mesh_core_adapters_memory_storage.createMemoryStorage)();
|
|
293
|
+
const scheduleOptions = options.certificateLifetimeMs === void 0 ? {} : { lifetimeMs: options.certificateLifetimeMs };
|
|
223
294
|
const lastColon = address.lastIndexOf(":");
|
|
224
295
|
const host = address.slice(0, lastColon);
|
|
225
|
-
|
|
226
|
-
let
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
296
|
+
let port = Number(address.slice(lastColon + 1));
|
|
297
|
+
let schedule = await advanceCertificateSchedule(storage, now(), scheduleOptions);
|
|
298
|
+
/** Starts a server presenting the schedule's serving certificate. The package cannot change the certificate of a running HTTP/3 server (`updateCert` only reaches the HTTP/2 side), so a rotation replaces the server. */
|
|
299
|
+
const startServer = async () => {
|
|
300
|
+
const started = new Http3ServerClass({
|
|
301
|
+
host,
|
|
302
|
+
port,
|
|
303
|
+
secret: (0, node_crypto.randomBytes)(SECRET_BYTES).toString("hex"),
|
|
304
|
+
cert: schedule.serving.certificatePem,
|
|
305
|
+
privKey: schedule.serving.privateKeyPem
|
|
306
|
+
});
|
|
307
|
+
started.startServer();
|
|
308
|
+
await started.ready;
|
|
309
|
+
const bound = started.address();
|
|
310
|
+
if (bound === null) throw new Error("the WebTransport server bound no address");
|
|
311
|
+
port = bound.port;
|
|
312
|
+
(async () => {
|
|
313
|
+
for await (const session of started.sessionStream(WEBTRANSPORT_PATH)) (async () => {
|
|
314
|
+
if (!isIncomingSession(session)) throw new Error("the WebTransport server yielded a malformed session");
|
|
315
|
+
await session.ready;
|
|
316
|
+
const reader = session.incomingBidirectionalStreams.getReader();
|
|
317
|
+
const first = await reader.read();
|
|
318
|
+
reader.releaseLock();
|
|
319
|
+
if (!first.done) onConnection((0, wire_mesh_core_adapters_byte_stream_connection.connectionFromByteStream)(first.value));
|
|
320
|
+
})().catch(reportError);
|
|
248
321
|
})().catch(reportError);
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
322
|
+
return started;
|
|
323
|
+
};
|
|
324
|
+
let server = await startServer();
|
|
325
|
+
const advertise = () => reachableHosts(host).map((reachable) => (0, wire_mesh_core_domain_pinned_address.formatPinnedAddress)(`${reachable}:${String(port)}`, schedule.advertised.map((certificate) => certificate.sha256Hex)));
|
|
326
|
+
let rotation;
|
|
327
|
+
let closed = false;
|
|
328
|
+
const armRotation = () => {
|
|
329
|
+
rotation = setTimeout(() => {
|
|
330
|
+
(async () => {
|
|
331
|
+
schedule = await advanceCertificateSchedule(storage, now(), scheduleOptions);
|
|
332
|
+
server.stopServer();
|
|
333
|
+
await server.closed;
|
|
334
|
+
if (closed) return;
|
|
335
|
+
server = await startServer();
|
|
336
|
+
options.onCertificateRenewed?.(advertise());
|
|
337
|
+
armRotation();
|
|
338
|
+
})().catch(reportError);
|
|
339
|
+
}, Math.max(schedule.rotatesAt.getTime() - now().getTime(), 0));
|
|
340
|
+
rotation.unref();
|
|
341
|
+
};
|
|
342
|
+
armRotation();
|
|
258
343
|
return {
|
|
259
|
-
address
|
|
344
|
+
get address() {
|
|
345
|
+
return `${host}:${String(port)}`;
|
|
346
|
+
},
|
|
260
347
|
get advertisedAddresses() {
|
|
261
348
|
return advertise();
|
|
262
349
|
},
|
|
263
350
|
close: async () => {
|
|
264
|
-
|
|
351
|
+
closed = true;
|
|
352
|
+
clearTimeout(rotation);
|
|
265
353
|
server.stopServer();
|
|
266
354
|
await server.closed;
|
|
267
355
|
},
|
|
268
356
|
unref: () => {
|
|
269
|
-
|
|
357
|
+
rotation?.unref();
|
|
270
358
|
}
|
|
271
359
|
};
|
|
272
360
|
}
|
|
@@ -325,6 +413,8 @@ function healthResponse(announcer) {
|
|
|
325
413
|
}
|
|
326
414
|
const HTTP_OK = 200;
|
|
327
415
|
const HTTP_NOT_FOUND = 404;
|
|
416
|
+
/** Owner read, write and search only: the state directory holds private keys. */
|
|
417
|
+
const STATE_DIR_MODE = 448;
|
|
328
418
|
const HEALTH_PATH = "/health";
|
|
329
419
|
const EXIT_FAILURE = 1;
|
|
330
420
|
const EXIT_USAGE = 2;
|
|
@@ -400,7 +490,12 @@ async function main(argv) {
|
|
|
400
490
|
});
|
|
401
491
|
logOutput(`wire-mesh listening on ${tls ? "wss" : "ws"}://${listener.address}`);
|
|
402
492
|
if (command.webTransportAddress !== void 0) {
|
|
493
|
+
if (command.stateDir !== void 0) (0, node_fs.mkdirSync)(command.stateDir, {
|
|
494
|
+
recursive: true,
|
|
495
|
+
mode: STATE_DIR_MODE
|
|
496
|
+
});
|
|
403
497
|
const webTransport = await createWebTransportTransport({
|
|
498
|
+
...command.stateDir !== void 0 ? { storage: (0, wire_mesh_core_adapters_node_fs_storage.createNodeFsStorage)({ dir: command.stateDir }) } : {},
|
|
404
499
|
onError: (error) => {
|
|
405
500
|
logError(`wire-mesh: WebTransport: ${String(error)}`);
|
|
406
501
|
},
|
package/dist/server.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { createNodeWebSocketTransport } from "./adapters/node-websocket-transport.mjs";
|
|
3
|
-
import { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
|
|
3
|
+
import { existsSync, mkdirSync, readFileSync, realpathSync, statSync } from "node:fs";
|
|
4
4
|
import { dirname, extname, join, normalize, sep } from "node:path";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
6
|
import { createRelayHub } from "wire-mesh-core/domain/relay-hub";
|
|
@@ -12,6 +12,7 @@ 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
14
|
import { formatPinnedAddress } from "wire-mesh-core/domain/pinned-address";
|
|
15
|
+
import { createMemoryStorage } from "wire-mesh-core/adapters/memory-storage";
|
|
15
16
|
import "reflect-metadata";
|
|
16
17
|
import { BasicConstraintsExtension, ExtendedKeyUsage, ExtendedKeyUsageExtension, KeyUsageFlags, KeyUsagesExtension, X509CertificateGenerator } from "@peculiar/x509";
|
|
17
18
|
//#region src/cli-options.ts
|
|
@@ -46,6 +47,11 @@ const FLAGS = {
|
|
|
46
47
|
valueName: "host:port",
|
|
47
48
|
description: "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."
|
|
48
49
|
},
|
|
50
|
+
"state-dir": {
|
|
51
|
+
type: "string",
|
|
52
|
+
valueName: "path",
|
|
53
|
+
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
|
+
},
|
|
49
55
|
help: {
|
|
50
56
|
type: "boolean",
|
|
51
57
|
short: "h",
|
|
@@ -105,6 +111,7 @@ function parseCliArguments(argv) {
|
|
|
105
111
|
const certPath = values["tls-cert"];
|
|
106
112
|
const keyPath = values["tls-key"];
|
|
107
113
|
if (certPath === void 0 !== (keyPath === void 0)) throw new CliUsageError("--tls-cert and --tls-key must be given together");
|
|
114
|
+
if (values["state-dir"] !== void 0 && values.webtransport === void 0) throw new CliUsageError("--state-dir needs --webtransport");
|
|
108
115
|
return {
|
|
109
116
|
kind: "serve",
|
|
110
117
|
bindAddress: validatedBindAddress("bind", values.bind),
|
|
@@ -113,7 +120,8 @@ function parseCliArguments(argv) {
|
|
|
113
120
|
keyPath
|
|
114
121
|
},
|
|
115
122
|
mailboxDir: values["mailbox-dir"],
|
|
116
|
-
webTransportAddress: values.webtransport === void 0 ? void 0 : validatedBindAddress("webtransport", values.webtransport)
|
|
123
|
+
webTransportAddress: values.webtransport === void 0 ? void 0 : validatedBindAddress("webtransport", values.webtransport),
|
|
124
|
+
stateDir: values["state-dir"]
|
|
117
125
|
};
|
|
118
126
|
}
|
|
119
127
|
//#endregion
|
|
@@ -146,15 +154,15 @@ function toPem(label, der) {
|
|
|
146
154
|
return `-----BEGIN ${label}-----\n${(Buffer.from(der).toString("base64").match(new RegExp(`.{1,${String(PEM_LINE_LENGTH)}}`, "g")) ?? []).join("\n")}\n-----END ${label}-----\n`;
|
|
147
155
|
}
|
|
148
156
|
/**
|
|
149
|
-
* Mints an ECDSA P-256 certificate valid from `
|
|
157
|
+
* Mints an ECDSA P-256 certificate valid from `notBefore` for `lifetimeMs` (PINNED_CERTIFICATE_LIFETIME_MS unless a test asks for less). Clients pin it by hash rather than by name, so it carries only a common name.
|
|
150
158
|
*/
|
|
151
|
-
async function mintPinnedCertificate(
|
|
159
|
+
async function mintPinnedCertificate(notBefore, lifetimeMs = PINNED_CERTIFICATE_LIFETIME_MS) {
|
|
152
160
|
const keys = await crypto.subtle.generateKey(SIGNING_ALGORITHM, true, ["sign", "verify"]);
|
|
153
|
-
const notAfter = new Date(
|
|
161
|
+
const notAfter = new Date(notBefore.getTime() + lifetimeMs);
|
|
154
162
|
const certificate = await X509CertificateGenerator.createSelfSigned({
|
|
155
163
|
serialNumber: toHex(crypto.getRandomValues(new Uint8Array(SERIAL_NUMBER_BYTES))),
|
|
156
164
|
name: "CN=wire-mesh",
|
|
157
|
-
notBefore
|
|
165
|
+
notBefore,
|
|
158
166
|
notAfter,
|
|
159
167
|
signingAlgorithm: SIGNING_ALGORITHM,
|
|
160
168
|
keys,
|
|
@@ -169,14 +177,74 @@ async function mintPinnedCertificate(now) {
|
|
|
169
177
|
certificatePem: certificate.toString("pem"),
|
|
170
178
|
privateKeyPem: toPem("PRIVATE KEY", await crypto.subtle.exportKey("pkcs8", keys.privateKey)),
|
|
171
179
|
sha256Hex: toHex(new Uint8Array(digest)),
|
|
180
|
+
notBefore,
|
|
172
181
|
notAfter
|
|
173
182
|
};
|
|
174
183
|
}
|
|
184
|
+
/** A certificate is served for half its lifetime, so each successor is already valid, and its hash already listed, well before it takes over. */
|
|
185
|
+
const SERVING_FRACTION = .5;
|
|
186
|
+
const STORAGE_KEY = "webtransport-certificates";
|
|
187
|
+
function isStoredCertificate(value) {
|
|
188
|
+
if (typeof value !== "object" || value === null) return false;
|
|
189
|
+
return "certificatePem" in value && typeof value.certificatePem === "string" && "privateKeyPem" in value && typeof value.privateKeyPem === "string" && "sha256Hex" in value && typeof value.sha256Hex === "string" && "notBefore" in value && typeof value.notBefore === "string" && "notAfter" in value && typeof value.notAfter === "string";
|
|
190
|
+
}
|
|
191
|
+
function fromStored(stored) {
|
|
192
|
+
return {
|
|
193
|
+
certificatePem: stored.certificatePem,
|
|
194
|
+
privateKeyPem: stored.privateKeyPem,
|
|
195
|
+
sha256Hex: stored.sha256Hex,
|
|
196
|
+
notBefore: new Date(stored.notBefore),
|
|
197
|
+
notAfter: new Date(stored.notAfter)
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
function toStored(certificate) {
|
|
201
|
+
return {
|
|
202
|
+
certificatePem: certificate.certificatePem,
|
|
203
|
+
privateKeyPem: certificate.privateKeyPem,
|
|
204
|
+
sha256Hex: certificate.sha256Hex,
|
|
205
|
+
notBefore: certificate.notBefore.toISOString(),
|
|
206
|
+
notAfter: certificate.notAfter.toISOString()
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
async function readStored(storage) {
|
|
210
|
+
const bytes = await storage.get(STORAGE_KEY);
|
|
211
|
+
if (bytes === void 0) return [];
|
|
212
|
+
const parsed = JSON.parse(new TextDecoder().decode(bytes));
|
|
213
|
+
if (!Array.isArray(parsed) || !parsed.every(isStoredCertificate)) throw new Error(`the stored ${STORAGE_KEY} are not in the expected shape`);
|
|
214
|
+
return parsed.map(fromStored);
|
|
215
|
+
}
|
|
216
|
+
async function writeStored(storage, certificates) {
|
|
217
|
+
await storage.set(STORAGE_KEY, new TextEncoder().encode(JSON.stringify(certificates.map(toStored))));
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* Keeps a rolling schedule of pinned certificates in `storage`: the one serving now and enough successors that an address lists ADVERTISED_CERTIFICATES of them. Each successor starts one serving period after its predecessor, so it is valid, and pinned, before it takes over.
|
|
221
|
+
*
|
|
222
|
+
* `advance` brings the schedule up to `now`: certificates that have finished serving are dropped, missing successors are minted, and if nothing stored is serving (a first run, or a node that was down past every stored certificate) a certificate valid from `now` starts a new schedule. What it returns is written back, so a restart resumes the same certificates and the addresses already handed out stay valid.
|
|
223
|
+
*/
|
|
224
|
+
async function advanceCertificateSchedule(storage, now, options = {}) {
|
|
225
|
+
const lifetimeMs = options.lifetimeMs ?? 11232e5;
|
|
226
|
+
const servingMs = lifetimeMs * SERVING_FRACTION;
|
|
227
|
+
const stillValid = (await readStored(storage)).filter((certificate) => certificate.notAfter.getTime() > now.getTime());
|
|
228
|
+
const startedIndex = stillValid.findLastIndex((certificate) => certificate.notBefore.getTime() <= now.getTime());
|
|
229
|
+
const certificates = startedIndex === -1 ? [await mintPinnedCertificate(now, lifetimeMs)] : stillValid.slice(startedIndex);
|
|
230
|
+
while (certificates.length < 4) {
|
|
231
|
+
const last = certificates[certificates.length - 1];
|
|
232
|
+
if (last === void 0) throw new Error("the certificate schedule is empty");
|
|
233
|
+
certificates.push(await mintPinnedCertificate(new Date(last.notBefore.getTime() + servingMs), lifetimeMs));
|
|
234
|
+
}
|
|
235
|
+
await writeStored(storage, certificates);
|
|
236
|
+
const [serving, next] = certificates;
|
|
237
|
+
if (serving === void 0 || next === void 0) throw new Error("the certificate schedule is empty");
|
|
238
|
+
return {
|
|
239
|
+
serving,
|
|
240
|
+
advertised: certificates.slice(0, 3),
|
|
241
|
+
rotatesAt: next.notBefore
|
|
242
|
+
};
|
|
243
|
+
}
|
|
175
244
|
//#endregion
|
|
176
245
|
//#region src/adapters/webtransport-transport.ts
|
|
177
246
|
/** The path a client opens its WebTransport session on. */
|
|
178
247
|
const WEBTRANSPORT_PATH = "/wire-mesh";
|
|
179
|
-
const RENEWAL_DELAY_MS = PINNED_CERTIFICATE_LIFETIME_MS * .5;
|
|
180
248
|
const SECRET_BYTES = 32;
|
|
181
249
|
const WILDCARD_HOSTS = ["0.0.0.0", "::"];
|
|
182
250
|
/** 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. */
|
|
@@ -187,12 +255,13 @@ function reachableHosts(host) {
|
|
|
187
255
|
/** The optional WebTransport package is not installed, or its native binary was not built for this platform. */
|
|
188
256
|
var WebTransportUnavailableError = class extends Error {
|
|
189
257
|
constructor(cause) {
|
|
190
|
-
super("serving WebTransport needs the optional dependency @fails-components/webtransport and its native binary, which is
|
|
258
|
+
super("serving WebTransport needs the optional dependency @fails-components/webtransport and its native binary, which is missing: either no binary is published for this platform, or the package manager skipped its install script (an ignore-scripts setting does that)", { cause });
|
|
191
259
|
this.name = "WebTransportUnavailableError";
|
|
192
260
|
}
|
|
193
261
|
};
|
|
194
262
|
async function loadHttp3Server() {
|
|
195
263
|
try {
|
|
264
|
+
await import("@fails-components/webtransport-transport-http3-quiche");
|
|
196
265
|
const module = await import("@fails-components/webtransport");
|
|
197
266
|
await module.quicheLoaded;
|
|
198
267
|
return module.Http3Server;
|
|
@@ -219,53 +288,72 @@ function createWebTransportTransport(options = {}) {
|
|
|
219
288
|
},
|
|
220
289
|
async listen(address, onConnection) {
|
|
221
290
|
const Http3ServerClass = await loadHttp3Server();
|
|
291
|
+
const storage = options.storage ?? createMemoryStorage();
|
|
292
|
+
const scheduleOptions = options.certificateLifetimeMs === void 0 ? {} : { lifetimeMs: options.certificateLifetimeMs };
|
|
222
293
|
const lastColon = address.lastIndexOf(":");
|
|
223
294
|
const host = address.slice(0, lastColon);
|
|
224
|
-
|
|
225
|
-
let
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
295
|
+
let port = Number(address.slice(lastColon + 1));
|
|
296
|
+
let schedule = await advanceCertificateSchedule(storage, now(), scheduleOptions);
|
|
297
|
+
/** Starts a server presenting the schedule's serving certificate. The package cannot change the certificate of a running HTTP/3 server (`updateCert` only reaches the HTTP/2 side), so a rotation replaces the server. */
|
|
298
|
+
const startServer = async () => {
|
|
299
|
+
const started = new Http3ServerClass({
|
|
300
|
+
host,
|
|
301
|
+
port,
|
|
302
|
+
secret: randomBytes(SECRET_BYTES).toString("hex"),
|
|
303
|
+
cert: schedule.serving.certificatePem,
|
|
304
|
+
privKey: schedule.serving.privateKeyPem
|
|
305
|
+
});
|
|
306
|
+
started.startServer();
|
|
307
|
+
await started.ready;
|
|
308
|
+
const bound = started.address();
|
|
309
|
+
if (bound === null) throw new Error("the WebTransport server bound no address");
|
|
310
|
+
port = bound.port;
|
|
311
|
+
(async () => {
|
|
312
|
+
for await (const session of started.sessionStream(WEBTRANSPORT_PATH)) (async () => {
|
|
313
|
+
if (!isIncomingSession(session)) throw new Error("the WebTransport server yielded a malformed session");
|
|
314
|
+
await session.ready;
|
|
315
|
+
const reader = session.incomingBidirectionalStreams.getReader();
|
|
316
|
+
const first = await reader.read();
|
|
317
|
+
reader.releaseLock();
|
|
318
|
+
if (!first.done) onConnection(connectionFromByteStream(first.value));
|
|
319
|
+
})().catch(reportError);
|
|
247
320
|
})().catch(reportError);
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
321
|
+
return started;
|
|
322
|
+
};
|
|
323
|
+
let server = await startServer();
|
|
324
|
+
const advertise = () => reachableHosts(host).map((reachable) => formatPinnedAddress(`${reachable}:${String(port)}`, schedule.advertised.map((certificate) => certificate.sha256Hex)));
|
|
325
|
+
let rotation;
|
|
326
|
+
let closed = false;
|
|
327
|
+
const armRotation = () => {
|
|
328
|
+
rotation = setTimeout(() => {
|
|
329
|
+
(async () => {
|
|
330
|
+
schedule = await advanceCertificateSchedule(storage, now(), scheduleOptions);
|
|
331
|
+
server.stopServer();
|
|
332
|
+
await server.closed;
|
|
333
|
+
if (closed) return;
|
|
334
|
+
server = await startServer();
|
|
335
|
+
options.onCertificateRenewed?.(advertise());
|
|
336
|
+
armRotation();
|
|
337
|
+
})().catch(reportError);
|
|
338
|
+
}, Math.max(schedule.rotatesAt.getTime() - now().getTime(), 0));
|
|
339
|
+
rotation.unref();
|
|
340
|
+
};
|
|
341
|
+
armRotation();
|
|
257
342
|
return {
|
|
258
|
-
address
|
|
343
|
+
get address() {
|
|
344
|
+
return `${host}:${String(port)}`;
|
|
345
|
+
},
|
|
259
346
|
get advertisedAddresses() {
|
|
260
347
|
return advertise();
|
|
261
348
|
},
|
|
262
349
|
close: async () => {
|
|
263
|
-
|
|
350
|
+
closed = true;
|
|
351
|
+
clearTimeout(rotation);
|
|
264
352
|
server.stopServer();
|
|
265
353
|
await server.closed;
|
|
266
354
|
},
|
|
267
355
|
unref: () => {
|
|
268
|
-
|
|
356
|
+
rotation?.unref();
|
|
269
357
|
}
|
|
270
358
|
};
|
|
271
359
|
}
|
|
@@ -324,6 +412,8 @@ function healthResponse(announcer) {
|
|
|
324
412
|
}
|
|
325
413
|
const HTTP_OK = 200;
|
|
326
414
|
const HTTP_NOT_FOUND = 404;
|
|
415
|
+
/** Owner read, write and search only: the state directory holds private keys. */
|
|
416
|
+
const STATE_DIR_MODE = 448;
|
|
327
417
|
const HEALTH_PATH = "/health";
|
|
328
418
|
const EXIT_FAILURE = 1;
|
|
329
419
|
const EXIT_USAGE = 2;
|
|
@@ -399,7 +489,12 @@ async function main(argv) {
|
|
|
399
489
|
});
|
|
400
490
|
logOutput(`wire-mesh listening on ${tls ? "wss" : "ws"}://${listener.address}`);
|
|
401
491
|
if (command.webTransportAddress !== void 0) {
|
|
492
|
+
if (command.stateDir !== void 0) mkdirSync(command.stateDir, {
|
|
493
|
+
recursive: true,
|
|
494
|
+
mode: STATE_DIR_MODE
|
|
495
|
+
});
|
|
402
496
|
const webTransport = await createWebTransportTransport({
|
|
497
|
+
...command.stateDir !== void 0 ? { storage: createNodeFsStorage({ dir: command.stateDir }) } : {},
|
|
403
498
|
onError: (error) => {
|
|
404
499
|
logError(`wire-mesh: WebTransport: ${String(error)}`);
|
|
405
500
|
},
|