wire-mesh 3.2.1 → 3.4.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
@@ -34,13 +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> 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
- -h, --help Print this help and exit.
43
- -v, --version Print the version and exit.
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.
44
46
  ```
45
47
 
46
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).
@@ -52,7 +54,9 @@ curl http://<host>:8787/health
52
54
  # {"ok":true,"node":"wire-mesh","roles":["relay"]}
53
55
  ```
54
56
 
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 a self-signed ECDSA certificate valid for under two weeks, renews it on a schedule, and prints an address of the form `https://<host>:<port>#sha256=<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 by that hash with no certificate authority and no internet. 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. The certificate is renewed before it expires, and the new address is printed when its hash changes.
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
+
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.
56
60
 
57
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.
58
62
 
package/dist/server.cjs CHANGED
@@ -13,8 +13,15 @@ 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");
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
18
25
  //#region src/cli-options.ts
19
26
  /** The address the server binds when --bind is not given. */
20
27
  const DEFAULT_BIND_ADDRESS = "0.0.0.0:8787";
@@ -47,6 +54,16 @@ const FLAGS = {
47
54
  valueName: "host:port",
48
55
  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
56
  },
57
+ "state-dir": {
58
+ type: "string",
59
+ valueName: "path",
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."
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
+ },
50
67
  help: {
51
68
  type: "boolean",
52
69
  short: "h",
@@ -80,6 +97,12 @@ function helpText() {
80
97
  ...rows.map((row) => ` ${row.label.padEnd(labelWidth)} ${row.description}`)
81
98
  ].join("\n");
82
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
+ }
83
106
  function validatedBindAddress(flag, address) {
84
107
  const separator = address.lastIndexOf(":");
85
108
  const host = address.slice(0, separator);
@@ -106,6 +129,8 @@ function parseCliArguments(argv) {
106
129
  const certPath = values["tls-cert"];
107
130
  const keyPath = values["tls-key"];
108
131
  if (certPath === void 0 !== (keyPath === void 0)) throw new CliUsageError("--tls-cert and --tls-key must be given together");
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");
109
134
  return {
110
135
  kind: "serve",
111
136
  bindAddress: validatedBindAddress("bind", values.bind),
@@ -114,7 +139,9 @@ function parseCliArguments(argv) {
114
139
  keyPath
115
140
  },
116
141
  mailboxDir: values["mailbox-dir"],
117
- webTransportAddress: values.webtransport === void 0 ? void 0 : validatedBindAddress("webtransport", values.webtransport)
142
+ webTransportAddress: values.webtransport === void 0 ? void 0 : validatedBindAddress("webtransport", values.webtransport),
143
+ stateDir: values["state-dir"],
144
+ certificateLifetimeMs: validatedCertificateLifetimeMs(values["certificate-lifetime"])
118
145
  };
119
146
  }
120
147
  //#endregion
@@ -128,13 +155,8 @@ const nodeMailboxLimits = {
128
155
  };
129
156
  //#endregion
130
157
  //#region src/adapters/pinned-certificate.ts
131
- const MILLISECONDS_PER_DAY = 864e5;
132
- const MAX_PINNED_CERTIFICATE_LIFETIME_DAYS = 14;
133
- const CLOCK_SKEW_ALLOWANCE_DAYS = 1;
134
158
  const SERIAL_NUMBER_BYTES = 16;
135
159
  const PEM_LINE_LENGTH = 64;
136
- /** Slack under the maximum, so clock skew between the node and a browser cannot push the certificate over the limit. */
137
- const PINNED_CERTIFICATE_LIFETIME_MS = MAX_PINNED_CERTIFICATE_LIFETIME_DAYS * MILLISECONDS_PER_DAY - CLOCK_SKEW_ALLOWANCE_DAYS * MILLISECONDS_PER_DAY;
138
160
  const SIGNING_ALGORITHM = {
139
161
  name: "ECDSA",
140
162
  namedCurve: "P-256",
@@ -147,15 +169,15 @@ function toPem(label, der) {
147
169
  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
170
  }
149
171
  /**
150
- * Mints an ECDSA P-256 certificate valid from `now` for PINNED_CERTIFICATE_LIFETIME_MS. Clients pin it by hash rather than by name, so it carries only a common name.
172
+ * 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
173
  */
152
- async function mintPinnedCertificate(now) {
174
+ async function mintPinnedCertificate(notBefore, lifetimeMs = PINNED_CERTIFICATE_LIFETIME_MS) {
153
175
  const keys = await crypto.subtle.generateKey(SIGNING_ALGORITHM, true, ["sign", "verify"]);
154
- const notAfter = new Date(now.getTime() + PINNED_CERTIFICATE_LIFETIME_MS);
176
+ const notAfter = new Date(notBefore.getTime() + lifetimeMs);
155
177
  const certificate = await _peculiar_x509.X509CertificateGenerator.createSelfSigned({
156
178
  serialNumber: toHex(crypto.getRandomValues(new Uint8Array(SERIAL_NUMBER_BYTES))),
157
179
  name: "CN=wire-mesh",
158
- notBefore: now,
180
+ notBefore,
159
181
  notAfter,
160
182
  signingAlgorithm: SIGNING_ALGORITHM,
161
183
  keys,
@@ -170,15 +192,78 @@ async function mintPinnedCertificate(now) {
170
192
  certificatePem: certificate.toString("pem"),
171
193
  privateKeyPem: toPem("PRIVATE KEY", await crypto.subtle.exportKey("pkcs8", keys.privateKey)),
172
194
  sha256Hex: toHex(new Uint8Array(digest)),
195
+ notBefore,
173
196
  notAfter
174
197
  };
175
198
  }
199
+ /** A certificate is served for half its lifetime, so each successor is already valid, and its hash already listed, well before it takes over. */
200
+ const SERVING_FRACTION = .5;
201
+ const STORAGE_KEY = "webtransport-certificates";
202
+ function isStoredCertificate(value) {
203
+ if (typeof value !== "object" || value === null) return false;
204
+ 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";
205
+ }
206
+ function fromStored(stored) {
207
+ return {
208
+ certificatePem: stored.certificatePem,
209
+ privateKeyPem: stored.privateKeyPem,
210
+ sha256Hex: stored.sha256Hex,
211
+ notBefore: new Date(stored.notBefore),
212
+ notAfter: new Date(stored.notAfter)
213
+ };
214
+ }
215
+ function toStored(certificate) {
216
+ return {
217
+ certificatePem: certificate.certificatePem,
218
+ privateKeyPem: certificate.privateKeyPem,
219
+ sha256Hex: certificate.sha256Hex,
220
+ notBefore: certificate.notBefore.toISOString(),
221
+ notAfter: certificate.notAfter.toISOString()
222
+ };
223
+ }
224
+ async function readStored(storage) {
225
+ const bytes = await storage.get(STORAGE_KEY);
226
+ if (bytes === void 0) return [];
227
+ const parsed = JSON.parse(new TextDecoder().decode(bytes));
228
+ if (!Array.isArray(parsed) || !parsed.every(isStoredCertificate)) throw new Error(`the stored ${STORAGE_KEY} are not in the expected shape`);
229
+ return parsed.map(fromStored);
230
+ }
231
+ async function writeStored(storage, certificates) {
232
+ await storage.set(STORAGE_KEY, new TextEncoder().encode(JSON.stringify(certificates.map(toStored))));
233
+ }
234
+ /**
235
+ * 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.
236
+ *
237
+ * `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.
238
+ */
239
+ async function advanceCertificateSchedule(storage, now, options = {}) {
240
+ const lifetimeMs = options.lifetimeMs ?? 11232e5;
241
+ const servingMs = lifetimeMs * SERVING_FRACTION;
242
+ const stillValid = (await readStored(storage)).filter((certificate) => certificate.notAfter.getTime() > now.getTime());
243
+ const startedIndex = stillValid.findLastIndex((certificate) => certificate.notBefore.getTime() <= now.getTime());
244
+ const certificates = startedIndex === -1 ? [await mintPinnedCertificate(now, lifetimeMs)] : stillValid.slice(startedIndex);
245
+ while (certificates.length < 4) {
246
+ const last = certificates[certificates.length - 1];
247
+ if (last === void 0) throw new Error("the certificate schedule is empty");
248
+ certificates.push(await mintPinnedCertificate(new Date(last.notBefore.getTime() + servingMs), lifetimeMs));
249
+ }
250
+ await writeStored(storage, certificates);
251
+ const [serving, next] = certificates;
252
+ if (serving === void 0 || next === void 0) throw new Error("the certificate schedule is empty");
253
+ return {
254
+ serving,
255
+ advertised: certificates.slice(0, 3),
256
+ rotatesAt: next.notBefore
257
+ };
258
+ }
176
259
  //#endregion
177
260
  //#region src/adapters/webtransport-transport.ts
178
261
  /** The path a client opens its WebTransport session on. */
179
262
  const WEBTRANSPORT_PATH = "/wire-mesh";
180
- const RENEWAL_DELAY_MS = PINNED_CERTIFICATE_LIFETIME_MS * .5;
181
263
  const SECRET_BYTES = 32;
264
+ /** 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
+ const SESSION_CLOSE_CODE = 0;
266
+ const ROTATION_CLOSE_REASON = "certificate rotated";
182
267
  const WILDCARD_HOSTS = ["0.0.0.0", "::"];
183
268
  /** 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. */
184
269
  function reachableHosts(host) {
@@ -203,7 +288,7 @@ async function loadHttp3Server() {
203
288
  }
204
289
  }
205
290
  function isIncomingSession(value) {
206
- return typeof value === "object" && value !== null && "ready" in value && value.ready instanceof Promise && "incomingBidirectionalStreams" in value && value.incomingBidirectionalStreams instanceof ReadableStream;
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;
207
292
  }
208
293
  /**
209
294
  * 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.
@@ -221,53 +306,99 @@ function createWebTransportTransport(options = {}) {
221
306
  },
222
307
  async listen(address, onConnection) {
223
308
  const Http3ServerClass = await loadHttp3Server();
309
+ const storage = options.storage ?? (0, wire_mesh_core_adapters_memory_storage.createMemoryStorage)();
310
+ const scheduleOptions = options.certificateLifetimeMs === void 0 ? {} : { lifetimeMs: options.certificateLifetimeMs };
224
311
  const lastColon = address.lastIndexOf(":");
225
312
  const host = address.slice(0, lastColon);
226
- const port = Number(address.slice(lastColon + 1));
227
- let certificate = await mintPinnedCertificate(now());
228
- const server = new Http3ServerClass({
229
- host,
230
- port,
231
- secret: (0, node_crypto.randomBytes)(SECRET_BYTES).toString("hex"),
232
- cert: certificate.certificatePem,
233
- privKey: certificate.privateKeyPem
234
- });
235
- server.startServer();
236
- await server.ready;
237
- const bound = server.address();
238
- if (bound === null) throw new Error("the WebTransport server bound no address");
239
- const boundAddress = `${host}:${String(bound.port)}`;
240
- const advertise = () => reachableHosts(host).map((reachable) => (0, wire_mesh_core_domain_pinned_address.formatPinnedAddress)(`${reachable}:${String(bound.port)}`, certificate.sha256Hex));
241
- (async () => {
242
- for await (const session of server.sessionStream(WEBTRANSPORT_PATH)) (async () => {
243
- if (!isIncomingSession(session)) throw new Error("the WebTransport server yielded a malformed session");
244
- await session.ready;
245
- const reader = session.incomingBidirectionalStreams.getReader();
246
- const first = await reader.read();
247
- reader.releaseLock();
248
- if (!first.done) onConnection((0, wire_mesh_core_adapters_byte_stream_connection.connectionFromByteStream)(first.value));
313
+ let port = Number(address.slice(lastColon + 1));
314
+ let schedule = await advanceCertificateSchedule(storage, now(), scheduleOptions);
315
+ /** 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. */
316
+ const startServer = async () => {
317
+ const started = new Http3ServerClass({
318
+ host,
319
+ port,
320
+ secret: (0, node_crypto.randomBytes)(SECRET_BYTES).toString("hex"),
321
+ cert: schedule.serving.certificatePem,
322
+ privKey: schedule.serving.privateKeyPem
323
+ });
324
+ started.startServer();
325
+ await started.ready;
326
+ const bound = started.address();
327
+ if (bound === null) throw new Error("the WebTransport server bound no address");
328
+ port = bound.port;
329
+ const liveSessions = /* @__PURE__ */ new Set();
330
+ const drainState = { draining: false };
331
+ const endSession = (session) => {
332
+ session.close({
333
+ closeCode: SESSION_CLOSE_CODE,
334
+ reason: ROTATION_CLOSE_REASON
335
+ });
336
+ };
337
+ (async () => {
338
+ for await (const session of started.sessionStream(WEBTRANSPORT_PATH)) (async () => {
339
+ if (!isIncomingSession(session)) throw new Error("the WebTransport server yielded a malformed session");
340
+ await session.ready;
341
+ if (drainState.draining) {
342
+ endSession(session);
343
+ return;
344
+ }
345
+ liveSessions.add(session);
346
+ session.closed.catch(() => void 0).finally(() => {
347
+ liveSessions.delete(session);
348
+ });
349
+ const reader = session.incomingBidirectionalStreams.getReader();
350
+ const first = await reader.read();
351
+ reader.releaseLock();
352
+ if (!first.done) onConnection((0, wire_mesh_core_adapters_byte_stream_connection.connectionFromByteStream)(first.value));
353
+ })().catch(reportError);
249
354
  })().catch(reportError);
250
- })().catch(reportError);
251
- const renewal = setInterval(() => {
252
- mintPinnedCertificate(now()).then((renewed) => {
253
- certificate = renewed;
254
- server.updateCert(renewed.certificatePem, renewed.privateKeyPem, false);
255
- options.onCertificateRenewed?.(advertise());
256
- }).catch(reportError);
257
- }, RENEWAL_DELAY_MS);
258
- renewal.unref();
355
+ return {
356
+ server: started,
357
+ drain: async () => {
358
+ drainState.draining = true;
359
+ const open = [...liveSessions];
360
+ for (const session of open) endSession(session);
361
+ await Promise.allSettled(open.map(async (session) => session.closed));
362
+ }
363
+ };
364
+ };
365
+ let running = await startServer();
366
+ const advertise = () => reachableHosts(host).map((reachable) => (0, wire_mesh_core_domain_pinned_address.formatPinnedAddress)(`${reachable}:${String(port)}`, schedule.advertised.map((certificate) => certificate.sha256Hex)));
367
+ const retire = async () => {
368
+ await running.drain();
369
+ running.server.stopServer();
370
+ await running.server.closed;
371
+ };
372
+ let rotation;
373
+ let closed = false;
374
+ const armRotation = () => {
375
+ rotation = setTimeout(() => {
376
+ (async () => {
377
+ schedule = await advanceCertificateSchedule(storage, now(), scheduleOptions);
378
+ await retire();
379
+ if (closed) return;
380
+ running = await startServer();
381
+ options.onCertificateRenewed?.(advertise());
382
+ armRotation();
383
+ })().catch(reportError);
384
+ }, Math.max(schedule.rotatesAt.getTime() - now().getTime(), 0));
385
+ rotation.unref();
386
+ };
387
+ armRotation();
259
388
  return {
260
- address: boundAddress,
389
+ get address() {
390
+ return `${host}:${String(port)}`;
391
+ },
261
392
  get advertisedAddresses() {
262
393
  return advertise();
263
394
  },
264
395
  close: async () => {
265
- clearInterval(renewal);
266
- server.stopServer();
267
- await server.closed;
396
+ closed = true;
397
+ clearTimeout(rotation);
398
+ await retire();
268
399
  },
269
400
  unref: () => {
270
- renewal.unref();
401
+ rotation?.unref();
271
402
  }
272
403
  };
273
404
  }
@@ -326,6 +457,8 @@ function healthResponse(announcer) {
326
457
  }
327
458
  const HTTP_OK = 200;
328
459
  const HTTP_NOT_FOUND = 404;
460
+ /** Owner read, write and search only: the state directory holds private keys. */
461
+ const STATE_DIR_MODE = 448;
329
462
  const HEALTH_PATH = "/health";
330
463
  const EXIT_FAILURE = 1;
331
464
  const EXIT_USAGE = 2;
@@ -401,7 +534,13 @@ async function main(argv) {
401
534
  });
402
535
  logOutput(`wire-mesh listening on ${tls ? "wss" : "ws"}://${listener.address}`);
403
536
  if (command.webTransportAddress !== void 0) {
537
+ if (command.stateDir !== void 0) (0, node_fs.mkdirSync)(command.stateDir, {
538
+ recursive: true,
539
+ mode: STATE_DIR_MODE
540
+ });
404
541
  const webTransport = await createWebTransportTransport({
542
+ ...command.certificateLifetimeMs !== void 0 ? { certificateLifetimeMs: command.certificateLifetimeMs } : {},
543
+ ...command.stateDir !== void 0 ? { storage: (0, wire_mesh_core_adapters_node_fs_storage.createNodeFsStorage)({ dir: command.stateDir }) } : {},
405
544
  onError: (error) => {
406
545
  logError(`wire-mesh: WebTransport: ${String(error)}`);
407
546
  },