wire-mesh 3.4.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 CHANGED
@@ -56,7 +56,7 @@ curl http://<host>:8787/health
56
56
 
57
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.
58
58
 
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. 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.
60
60
 
61
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.
62
62
 
package/dist/server.cjs CHANGED
@@ -261,6 +261,8 @@ async function advanceCertificateSchedule(storage, now, options = {}) {
261
261
  /** The path a client opens its WebTransport session on. */
262
262
  const WEBTRANSPORT_PATH = "/wire-mesh";
263
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;
264
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. */
265
267
  const SESSION_CLOSE_CODE = 0;
266
268
  const ROTATION_CLOSE_REASON = "certificate rotated";
@@ -288,7 +290,7 @@ async function loadHttp3Server() {
288
290
  }
289
291
  }
290
292
  function isIncomingSession(value) {
291
- 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" && "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;
292
294
  }
293
295
  /**
294
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.
@@ -346,6 +348,9 @@ function createWebTransportTransport(options = {}) {
346
348
  session.closed.catch(() => void 0).finally(() => {
347
349
  liveSessions.delete(session);
348
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();
349
354
  const reader = session.incomingBidirectionalStreams.getReader();
350
355
  const first = await reader.read();
351
356
  reader.releaseLock();
@@ -364,14 +369,19 @@ function createWebTransportTransport(options = {}) {
364
369
  };
365
370
  let running = await startServer();
366
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. */
367
373
  const retire = async () => {
368
- await running.drain();
369
- running.server.stopServer();
370
- await running.server.closed;
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;
371
380
  };
372
381
  let rotation;
373
382
  let closed = false;
374
- const armRotation = () => {
383
+ const untilRotation = () => Math.max(schedule.rotatesAt.getTime() - now().getTime(), 0);
384
+ const armRotation = (delayMs) => {
375
385
  rotation = setTimeout(() => {
376
386
  (async () => {
377
387
  schedule = await advanceCertificateSchedule(storage, now(), scheduleOptions);
@@ -379,12 +389,16 @@ function createWebTransportTransport(options = {}) {
379
389
  if (closed) return;
380
390
  running = await startServer();
381
391
  options.onCertificateRenewed?.(advertise());
382
- armRotation();
383
- })().catch(reportError);
384
- }, Math.max(schedule.rotatesAt.getTime() - now().getTime(), 0));
392
+ })().then(() => {
393
+ if (!closed) armRotation(untilRotation());
394
+ }, (error) => {
395
+ reportError(error);
396
+ if (!closed) armRotation(ROTATION_RETRY_MS);
397
+ });
398
+ }, delayMs);
385
399
  rotation.unref();
386
400
  };
387
- armRotation();
401
+ armRotation(untilRotation());
388
402
  return {
389
403
  get address() {
390
404
  return `${host}:${String(port)}`;
package/dist/server.mjs CHANGED
@@ -11,7 +11,7 @@ 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";
@@ -260,6 +260,8 @@ async function advanceCertificateSchedule(storage, now, options = {}) {
260
260
  /** The path a client opens its WebTransport session on. */
261
261
  const WEBTRANSPORT_PATH = "/wire-mesh";
262
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;
263
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. */
264
266
  const SESSION_CLOSE_CODE = 0;
265
267
  const ROTATION_CLOSE_REASON = "certificate rotated";
@@ -287,7 +289,7 @@ async function loadHttp3Server() {
287
289
  }
288
290
  }
289
291
  function isIncomingSession(value) {
290
- 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" && "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;
291
293
  }
292
294
  /**
293
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.
@@ -345,6 +347,9 @@ function createWebTransportTransport(options = {}) {
345
347
  session.closed.catch(() => void 0).finally(() => {
346
348
  liveSessions.delete(session);
347
349
  });
350
+ const announcer = (await session.createUnidirectionalStream()).getWriter();
351
+ await announcer.write(encodeCertificateHashes(schedule.advertised.map((certificate) => certificate.sha256Hex)));
352
+ await announcer.close();
348
353
  const reader = session.incomingBidirectionalStreams.getReader();
349
354
  const first = await reader.read();
350
355
  reader.releaseLock();
@@ -363,14 +368,19 @@ function createWebTransportTransport(options = {}) {
363
368
  };
364
369
  let running = await startServer();
365
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. */
366
372
  const retire = async () => {
367
- await running.drain();
368
- running.server.stopServer();
369
- await running.server.closed;
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;
370
379
  };
371
380
  let rotation;
372
381
  let closed = false;
373
- const armRotation = () => {
382
+ const untilRotation = () => Math.max(schedule.rotatesAt.getTime() - now().getTime(), 0);
383
+ const armRotation = (delayMs) => {
374
384
  rotation = setTimeout(() => {
375
385
  (async () => {
376
386
  schedule = await advanceCertificateSchedule(storage, now(), scheduleOptions);
@@ -378,12 +388,16 @@ function createWebTransportTransport(options = {}) {
378
388
  if (closed) return;
379
389
  running = await startServer();
380
390
  options.onCertificateRenewed?.(advertise());
381
- armRotation();
382
- })().catch(reportError);
383
- }, Math.max(schedule.rotatesAt.getTime() - now().getTime(), 0));
391
+ })().then(() => {
392
+ if (!closed) armRotation(untilRotation());
393
+ }, (error) => {
394
+ reportError(error);
395
+ if (!closed) armRotation(ROTATION_RETRY_MS);
396
+ });
397
+ }, delayMs);
384
398
  rotation.unref();
385
399
  };
386
- armRotation();
400
+ armRotation(untilRotation());
387
401
  return {
388
402
  get address() {
389
403
  return `${host}:${String(port)}`;