wire-mesh 3.1.2 → 3.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -6
- package/dist/server.cjs +176 -4
- package/dist/server.mjs +176 -4
- package/dist/web-console/assets/index-9nDpohAd.js +72 -0
- package/dist/web-console/index.html +1 -1
- package/dist/web-console/sw.js +1 -1
- package/package.json +9 -3
- package/dist/web-console/assets/index-BTpGaFDS.js +0 -72
package/README.md
CHANGED
|
@@ -34,12 +34,13 @@ 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
|
-
|
|
42
|
-
-
|
|
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.
|
|
43
44
|
```
|
|
44
45
|
|
|
45
46
|
**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).
|
|
@@ -51,6 +52,8 @@ curl http://<host>:8787/health
|
|
|
51
52
|
# {"ok":true,"node":"wire-mesh","roles":["relay"]}
|
|
52
53
|
```
|
|
53
54
|
|
|
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.
|
|
56
|
+
|
|
54
57
|
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.
|
|
55
58
|
|
|
56
59
|
**TLS (`wss://`/`https://`)** is opt-in via `--tls-cert`/`--tls-key`, both required together — this package generates no certificate of its own, so bring your own (a real one from a CA, or a self-signed one for a LAN). This matters specifically for wire-mesh#182/#183: an `https://`-served PWA (`mesh.exadev.io`) can only reach a plain `ws://` node if it's literally on `localhost` of the same machine, since browsers block mixed-content WebSocket connections from a secure page to an insecure one — a self-hosted node on someone's LAN, addressed by its own IP, needs to answer `wss://` for that PWA to reach it at all. How a LAN node is meant to be reached from the HTTPS-served console, and why a certificate is not the whole answer, is worked through in [docs/lan-certificates.md](docs/lan-certificates.md).
|
package/dist/server.cjs
CHANGED
|
@@ -9,6 +9,12 @@ let wire_mesh_core_adapters_node_identity = require("wire-mesh-core/adapters/nod
|
|
|
9
9
|
let node_util = require("node:util");
|
|
10
10
|
let wire_mesh_core_domain_hub_mailbox = require("wire-mesh-core/domain/hub-mailbox");
|
|
11
11
|
let wire_mesh_core_adapters_node_fs_storage = require("wire-mesh-core/adapters/node-fs-storage");
|
|
12
|
+
let node_crypto = require("node:crypto");
|
|
13
|
+
let node_os = require("node:os");
|
|
14
|
+
let wire_mesh_core_adapters_byte_stream_connection = require("wire-mesh-core/adapters/byte-stream-connection");
|
|
15
|
+
let wire_mesh_core_domain_pinned_address = require("wire-mesh-core/domain/pinned-address");
|
|
16
|
+
require("reflect-metadata");
|
|
17
|
+
let _peculiar_x509 = require("@peculiar/x509");
|
|
12
18
|
//#region src/cli-options.ts
|
|
13
19
|
/** The address the server binds when --bind is not given. */
|
|
14
20
|
const DEFAULT_BIND_ADDRESS = "0.0.0.0:8787";
|
|
@@ -36,6 +42,11 @@ const FLAGS = {
|
|
|
36
42
|
valueName: "path",
|
|
37
43
|
description: "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."
|
|
38
44
|
},
|
|
45
|
+
webtransport: {
|
|
46
|
+
type: "string",
|
|
47
|
+
valueName: "host:port",
|
|
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."
|
|
49
|
+
},
|
|
39
50
|
help: {
|
|
40
51
|
type: "boolean",
|
|
41
52
|
short: "h",
|
|
@@ -69,11 +80,11 @@ function helpText() {
|
|
|
69
80
|
...rows.map((row) => ` ${row.label.padEnd(labelWidth)} ${row.description}`)
|
|
70
81
|
].join("\n");
|
|
71
82
|
}
|
|
72
|
-
function validatedBindAddress(address) {
|
|
83
|
+
function validatedBindAddress(flag, address) {
|
|
73
84
|
const separator = address.lastIndexOf(":");
|
|
74
85
|
const host = address.slice(0, separator);
|
|
75
86
|
const port = address.slice(separator + 1);
|
|
76
|
-
if (separator === -1 || host === "" || !/^\d+$/.test(port) || Number(port) > MAX_PORT) throw new CliUsageError(
|
|
87
|
+
if (separator === -1 || host === "" || !/^\d+$/.test(port) || Number(port) > MAX_PORT) throw new CliUsageError(`--${flag} expects host:port with a port from 0 to ${String(MAX_PORT)}, got "${address}"`);
|
|
77
88
|
return address;
|
|
78
89
|
}
|
|
79
90
|
/** Parses the process arguments (without the node and script entries) into the command they ask for. Throws CliUsageError for an unknown flag, a stray positional argument, a flag missing or given an unwanted value, a malformed --bind address, or --tls-cert without --tls-key (or the reverse). */
|
|
@@ -97,12 +108,13 @@ function parseCliArguments(argv) {
|
|
|
97
108
|
if (certPath === void 0 !== (keyPath === void 0)) throw new CliUsageError("--tls-cert and --tls-key must be given together");
|
|
98
109
|
return {
|
|
99
110
|
kind: "serve",
|
|
100
|
-
bindAddress: validatedBindAddress(values.bind),
|
|
111
|
+
bindAddress: validatedBindAddress("bind", values.bind),
|
|
101
112
|
tls: certPath === void 0 || keyPath === void 0 ? void 0 : {
|
|
102
113
|
certPath,
|
|
103
114
|
keyPath
|
|
104
115
|
},
|
|
105
|
-
mailboxDir: values["mailbox-dir"]
|
|
116
|
+
mailboxDir: values["mailbox-dir"],
|
|
117
|
+
webTransportAddress: values.webtransport === void 0 ? void 0 : validatedBindAddress("webtransport", values.webtransport)
|
|
106
118
|
};
|
|
107
119
|
}
|
|
108
120
|
//#endregion
|
|
@@ -115,6 +127,153 @@ const nodeMailboxLimits = {
|
|
|
115
127
|
readBatch: 64
|
|
116
128
|
};
|
|
117
129
|
//#endregion
|
|
130
|
+
//#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
|
+
const SERIAL_NUMBER_BYTES = 16;
|
|
135
|
+
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
|
+
const SIGNING_ALGORITHM = {
|
|
139
|
+
name: "ECDSA",
|
|
140
|
+
namedCurve: "P-256",
|
|
141
|
+
hash: "SHA-256"
|
|
142
|
+
};
|
|
143
|
+
function toHex(bytes) {
|
|
144
|
+
return Buffer.from(bytes).toString("hex");
|
|
145
|
+
}
|
|
146
|
+
function toPem(label, der) {
|
|
147
|
+
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
|
+
}
|
|
149
|
+
/**
|
|
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.
|
|
151
|
+
*/
|
|
152
|
+
async function mintPinnedCertificate(now) {
|
|
153
|
+
const keys = await crypto.subtle.generateKey(SIGNING_ALGORITHM, true, ["sign", "verify"]);
|
|
154
|
+
const notAfter = new Date(now.getTime() + PINNED_CERTIFICATE_LIFETIME_MS);
|
|
155
|
+
const certificate = await _peculiar_x509.X509CertificateGenerator.createSelfSigned({
|
|
156
|
+
serialNumber: toHex(crypto.getRandomValues(new Uint8Array(SERIAL_NUMBER_BYTES))),
|
|
157
|
+
name: "CN=wire-mesh",
|
|
158
|
+
notBefore: now,
|
|
159
|
+
notAfter,
|
|
160
|
+
signingAlgorithm: SIGNING_ALGORITHM,
|
|
161
|
+
keys,
|
|
162
|
+
extensions: [
|
|
163
|
+
new _peculiar_x509.BasicConstraintsExtension(false, void 0, true),
|
|
164
|
+
new _peculiar_x509.KeyUsagesExtension(_peculiar_x509.KeyUsageFlags.digitalSignature, true),
|
|
165
|
+
new _peculiar_x509.ExtendedKeyUsageExtension([_peculiar_x509.ExtendedKeyUsage.serverAuth], true)
|
|
166
|
+
]
|
|
167
|
+
});
|
|
168
|
+
const digest = await crypto.subtle.digest("SHA-256", certificate.rawData);
|
|
169
|
+
return {
|
|
170
|
+
certificatePem: certificate.toString("pem"),
|
|
171
|
+
privateKeyPem: toPem("PRIVATE KEY", await crypto.subtle.exportKey("pkcs8", keys.privateKey)),
|
|
172
|
+
sha256Hex: toHex(new Uint8Array(digest)),
|
|
173
|
+
notAfter
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
//#endregion
|
|
177
|
+
//#region src/adapters/webtransport-transport.ts
|
|
178
|
+
/** The path a client opens its WebTransport session on. */
|
|
179
|
+
const WEBTRANSPORT_PATH = "/wire-mesh";
|
|
180
|
+
const RENEWAL_DELAY_MS = PINNED_CERTIFICATE_LIFETIME_MS * .5;
|
|
181
|
+
const SECRET_BYTES = 32;
|
|
182
|
+
const WILDCARD_HOSTS = ["0.0.0.0", "::"];
|
|
183
|
+
/** 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
|
+
function reachableHosts(host) {
|
|
185
|
+
if (!WILDCARD_HOSTS.includes(host)) return [host];
|
|
186
|
+
return Object.values((0, node_os.networkInterfaces)()).flatMap((addresses) => addresses ?? []).filter((entry) => entry.family === "IPv4" && !entry.internal).map((entry) => entry.address);
|
|
187
|
+
}
|
|
188
|
+
/** The optional WebTransport package is not installed, or its native binary was not built for this platform. */
|
|
189
|
+
var WebTransportUnavailableError = class extends Error {
|
|
190
|
+
constructor(cause) {
|
|
191
|
+
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
|
+
this.name = "WebTransportUnavailableError";
|
|
193
|
+
}
|
|
194
|
+
};
|
|
195
|
+
async function loadHttp3Server() {
|
|
196
|
+
try {
|
|
197
|
+
await import("@fails-components/webtransport-transport-http3-quiche");
|
|
198
|
+
const module = await import("@fails-components/webtransport");
|
|
199
|
+
await module.quicheLoaded;
|
|
200
|
+
return module.Http3Server;
|
|
201
|
+
} catch (error) {
|
|
202
|
+
throw new WebTransportUnavailableError(error);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
function isIncomingSession(value) {
|
|
206
|
+
return typeof value === "object" && value !== null && "ready" in value && value.ready instanceof Promise && "incomingBidirectionalStreams" in value && value.incomingBidirectionalStreams instanceof ReadableStream;
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* 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.
|
|
210
|
+
*
|
|
211
|
+
* `listen` rejects with WebTransportUnavailableError when the optional native dependency is absent. `connect` always rejects: see its comment.
|
|
212
|
+
*/
|
|
213
|
+
function createWebTransportTransport(options = {}) {
|
|
214
|
+
const now = options.now ?? (() => /* @__PURE__ */ new Date());
|
|
215
|
+
const reportError = (error) => {
|
|
216
|
+
options.onError?.(error);
|
|
217
|
+
};
|
|
218
|
+
return {
|
|
219
|
+
async connect() {
|
|
220
|
+
return Promise.reject(/* @__PURE__ */ new Error("a Node process cannot dial WebTransport with a pinned certificate hash; dial a wss:// address instead"));
|
|
221
|
+
},
|
|
222
|
+
async listen(address, onConnection) {
|
|
223
|
+
const Http3ServerClass = await loadHttp3Server();
|
|
224
|
+
const lastColon = address.lastIndexOf(":");
|
|
225
|
+
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));
|
|
249
|
+
})().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();
|
|
259
|
+
return {
|
|
260
|
+
address: boundAddress,
|
|
261
|
+
get advertisedAddresses() {
|
|
262
|
+
return advertise();
|
|
263
|
+
},
|
|
264
|
+
close: async () => {
|
|
265
|
+
clearInterval(renewal);
|
|
266
|
+
server.stopServer();
|
|
267
|
+
await server.closed;
|
|
268
|
+
},
|
|
269
|
+
unref: () => {
|
|
270
|
+
renewal.unref();
|
|
271
|
+
}
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
//#endregion
|
|
118
277
|
//#region src/static-console.ts
|
|
119
278
|
const CONTENT_TYPES = {
|
|
120
279
|
".css": "text/css; charset=utf-8",
|
|
@@ -241,6 +400,19 @@ async function main(argv) {
|
|
|
241
400
|
hub.handleConnection(connection);
|
|
242
401
|
});
|
|
243
402
|
logOutput(`wire-mesh listening on ${tls ? "wss" : "ws"}://${listener.address}`);
|
|
403
|
+
if (command.webTransportAddress !== void 0) {
|
|
404
|
+
const webTransport = await createWebTransportTransport({
|
|
405
|
+
onError: (error) => {
|
|
406
|
+
logError(`wire-mesh: WebTransport: ${String(error)}`);
|
|
407
|
+
},
|
|
408
|
+
onCertificateRenewed: (addresses) => {
|
|
409
|
+
logOutput(`wire-mesh WebTransport addresses changed: ${addresses.join(" ")}`);
|
|
410
|
+
}
|
|
411
|
+
}).listen(command.webTransportAddress, (connection) => {
|
|
412
|
+
hub.handleConnection(connection);
|
|
413
|
+
});
|
|
414
|
+
for (const address of webTransport.advertisedAddresses) logOutput(`wire-mesh serving WebTransport at ${address}`);
|
|
415
|
+
}
|
|
244
416
|
}
|
|
245
417
|
/** Runs the CLI and turns a failure into a one-line message on stderr and a non-zero exit code, instead of an unhandled rejection's stack trace. */
|
|
246
418
|
async function run(argv) {
|
package/dist/server.mjs
CHANGED
|
@@ -8,6 +8,12 @@ import { deriveDeviceId, verifyWithPublicKey } from "wire-mesh-core/adapters/nod
|
|
|
8
8
|
import { parseArgs } from "node:util";
|
|
9
9
|
import { createMailbox } from "wire-mesh-core/domain/hub-mailbox";
|
|
10
10
|
import { createNodeFsStorage } from "wire-mesh-core/adapters/node-fs-storage";
|
|
11
|
+
import { randomBytes } from "node:crypto";
|
|
12
|
+
import { networkInterfaces } from "node:os";
|
|
13
|
+
import { connectionFromByteStream } from "wire-mesh-core/adapters/byte-stream-connection";
|
|
14
|
+
import { formatPinnedAddress } from "wire-mesh-core/domain/pinned-address";
|
|
15
|
+
import "reflect-metadata";
|
|
16
|
+
import { BasicConstraintsExtension, ExtendedKeyUsage, ExtendedKeyUsageExtension, KeyUsageFlags, KeyUsagesExtension, X509CertificateGenerator } from "@peculiar/x509";
|
|
11
17
|
//#region src/cli-options.ts
|
|
12
18
|
/** The address the server binds when --bind is not given. */
|
|
13
19
|
const DEFAULT_BIND_ADDRESS = "0.0.0.0:8787";
|
|
@@ -35,6 +41,11 @@ const FLAGS = {
|
|
|
35
41
|
valueName: "path",
|
|
36
42
|
description: "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."
|
|
37
43
|
},
|
|
44
|
+
webtransport: {
|
|
45
|
+
type: "string",
|
|
46
|
+
valueName: "host:port",
|
|
47
|
+
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
|
+
},
|
|
38
49
|
help: {
|
|
39
50
|
type: "boolean",
|
|
40
51
|
short: "h",
|
|
@@ -68,11 +79,11 @@ function helpText() {
|
|
|
68
79
|
...rows.map((row) => ` ${row.label.padEnd(labelWidth)} ${row.description}`)
|
|
69
80
|
].join("\n");
|
|
70
81
|
}
|
|
71
|
-
function validatedBindAddress(address) {
|
|
82
|
+
function validatedBindAddress(flag, address) {
|
|
72
83
|
const separator = address.lastIndexOf(":");
|
|
73
84
|
const host = address.slice(0, separator);
|
|
74
85
|
const port = address.slice(separator + 1);
|
|
75
|
-
if (separator === -1 || host === "" || !/^\d+$/.test(port) || Number(port) > MAX_PORT) throw new CliUsageError(
|
|
86
|
+
if (separator === -1 || host === "" || !/^\d+$/.test(port) || Number(port) > MAX_PORT) throw new CliUsageError(`--${flag} expects host:port with a port from 0 to ${String(MAX_PORT)}, got "${address}"`);
|
|
76
87
|
return address;
|
|
77
88
|
}
|
|
78
89
|
/** Parses the process arguments (without the node and script entries) into the command they ask for. Throws CliUsageError for an unknown flag, a stray positional argument, a flag missing or given an unwanted value, a malformed --bind address, or --tls-cert without --tls-key (or the reverse). */
|
|
@@ -96,12 +107,13 @@ function parseCliArguments(argv) {
|
|
|
96
107
|
if (certPath === void 0 !== (keyPath === void 0)) throw new CliUsageError("--tls-cert and --tls-key must be given together");
|
|
97
108
|
return {
|
|
98
109
|
kind: "serve",
|
|
99
|
-
bindAddress: validatedBindAddress(values.bind),
|
|
110
|
+
bindAddress: validatedBindAddress("bind", values.bind),
|
|
100
111
|
tls: certPath === void 0 || keyPath === void 0 ? void 0 : {
|
|
101
112
|
certPath,
|
|
102
113
|
keyPath
|
|
103
114
|
},
|
|
104
|
-
mailboxDir: values["mailbox-dir"]
|
|
115
|
+
mailboxDir: values["mailbox-dir"],
|
|
116
|
+
webTransportAddress: values.webtransport === void 0 ? void 0 : validatedBindAddress("webtransport", values.webtransport)
|
|
105
117
|
};
|
|
106
118
|
}
|
|
107
119
|
//#endregion
|
|
@@ -114,6 +126,153 @@ const nodeMailboxLimits = {
|
|
|
114
126
|
readBatch: 64
|
|
115
127
|
};
|
|
116
128
|
//#endregion
|
|
129
|
+
//#region src/adapters/pinned-certificate.ts
|
|
130
|
+
const MILLISECONDS_PER_DAY = 864e5;
|
|
131
|
+
const MAX_PINNED_CERTIFICATE_LIFETIME_DAYS = 14;
|
|
132
|
+
const CLOCK_SKEW_ALLOWANCE_DAYS = 1;
|
|
133
|
+
const SERIAL_NUMBER_BYTES = 16;
|
|
134
|
+
const PEM_LINE_LENGTH = 64;
|
|
135
|
+
/** Slack under the maximum, so clock skew between the node and a browser cannot push the certificate over the limit. */
|
|
136
|
+
const PINNED_CERTIFICATE_LIFETIME_MS = MAX_PINNED_CERTIFICATE_LIFETIME_DAYS * MILLISECONDS_PER_DAY - CLOCK_SKEW_ALLOWANCE_DAYS * MILLISECONDS_PER_DAY;
|
|
137
|
+
const SIGNING_ALGORITHM = {
|
|
138
|
+
name: "ECDSA",
|
|
139
|
+
namedCurve: "P-256",
|
|
140
|
+
hash: "SHA-256"
|
|
141
|
+
};
|
|
142
|
+
function toHex(bytes) {
|
|
143
|
+
return Buffer.from(bytes).toString("hex");
|
|
144
|
+
}
|
|
145
|
+
function toPem(label, der) {
|
|
146
|
+
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
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* 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.
|
|
150
|
+
*/
|
|
151
|
+
async function mintPinnedCertificate(now) {
|
|
152
|
+
const keys = await crypto.subtle.generateKey(SIGNING_ALGORITHM, true, ["sign", "verify"]);
|
|
153
|
+
const notAfter = new Date(now.getTime() + PINNED_CERTIFICATE_LIFETIME_MS);
|
|
154
|
+
const certificate = await X509CertificateGenerator.createSelfSigned({
|
|
155
|
+
serialNumber: toHex(crypto.getRandomValues(new Uint8Array(SERIAL_NUMBER_BYTES))),
|
|
156
|
+
name: "CN=wire-mesh",
|
|
157
|
+
notBefore: now,
|
|
158
|
+
notAfter,
|
|
159
|
+
signingAlgorithm: SIGNING_ALGORITHM,
|
|
160
|
+
keys,
|
|
161
|
+
extensions: [
|
|
162
|
+
new BasicConstraintsExtension(false, void 0, true),
|
|
163
|
+
new KeyUsagesExtension(KeyUsageFlags.digitalSignature, true),
|
|
164
|
+
new ExtendedKeyUsageExtension([ExtendedKeyUsage.serverAuth], true)
|
|
165
|
+
]
|
|
166
|
+
});
|
|
167
|
+
const digest = await crypto.subtle.digest("SHA-256", certificate.rawData);
|
|
168
|
+
return {
|
|
169
|
+
certificatePem: certificate.toString("pem"),
|
|
170
|
+
privateKeyPem: toPem("PRIVATE KEY", await crypto.subtle.exportKey("pkcs8", keys.privateKey)),
|
|
171
|
+
sha256Hex: toHex(new Uint8Array(digest)),
|
|
172
|
+
notAfter
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
//#endregion
|
|
176
|
+
//#region src/adapters/webtransport-transport.ts
|
|
177
|
+
/** The path a client opens its WebTransport session on. */
|
|
178
|
+
const WEBTRANSPORT_PATH = "/wire-mesh";
|
|
179
|
+
const RENEWAL_DELAY_MS = PINNED_CERTIFICATE_LIFETIME_MS * .5;
|
|
180
|
+
const SECRET_BYTES = 32;
|
|
181
|
+
const WILDCARD_HOSTS = ["0.0.0.0", "::"];
|
|
182
|
+
/** 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. */
|
|
183
|
+
function reachableHosts(host) {
|
|
184
|
+
if (!WILDCARD_HOSTS.includes(host)) return [host];
|
|
185
|
+
return Object.values(networkInterfaces()).flatMap((addresses) => addresses ?? []).filter((entry) => entry.family === "IPv4" && !entry.internal).map((entry) => entry.address);
|
|
186
|
+
}
|
|
187
|
+
/** The optional WebTransport package is not installed, or its native binary was not built for this platform. */
|
|
188
|
+
var WebTransportUnavailableError = class extends Error {
|
|
189
|
+
constructor(cause) {
|
|
190
|
+
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
|
+
this.name = "WebTransportUnavailableError";
|
|
192
|
+
}
|
|
193
|
+
};
|
|
194
|
+
async function loadHttp3Server() {
|
|
195
|
+
try {
|
|
196
|
+
await import("@fails-components/webtransport-transport-http3-quiche");
|
|
197
|
+
const module = await import("@fails-components/webtransport");
|
|
198
|
+
await module.quicheLoaded;
|
|
199
|
+
return module.Http3Server;
|
|
200
|
+
} catch (error) {
|
|
201
|
+
throw new WebTransportUnavailableError(error);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
function isIncomingSession(value) {
|
|
205
|
+
return typeof value === "object" && value !== null && "ready" in value && value.ready instanceof Promise && "incomingBidirectionalStreams" in value && value.incomingBidirectionalStreams instanceof ReadableStream;
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* 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.
|
|
209
|
+
*
|
|
210
|
+
* `listen` rejects with WebTransportUnavailableError when the optional native dependency is absent. `connect` always rejects: see its comment.
|
|
211
|
+
*/
|
|
212
|
+
function createWebTransportTransport(options = {}) {
|
|
213
|
+
const now = options.now ?? (() => /* @__PURE__ */ new Date());
|
|
214
|
+
const reportError = (error) => {
|
|
215
|
+
options.onError?.(error);
|
|
216
|
+
};
|
|
217
|
+
return {
|
|
218
|
+
async connect() {
|
|
219
|
+
return Promise.reject(/* @__PURE__ */ new Error("a Node process cannot dial WebTransport with a pinned certificate hash; dial a wss:// address instead"));
|
|
220
|
+
},
|
|
221
|
+
async listen(address, onConnection) {
|
|
222
|
+
const Http3ServerClass = await loadHttp3Server();
|
|
223
|
+
const lastColon = address.lastIndexOf(":");
|
|
224
|
+
const host = address.slice(0, lastColon);
|
|
225
|
+
const port = Number(address.slice(lastColon + 1));
|
|
226
|
+
let certificate = await mintPinnedCertificate(now());
|
|
227
|
+
const server = new Http3ServerClass({
|
|
228
|
+
host,
|
|
229
|
+
port,
|
|
230
|
+
secret: randomBytes(SECRET_BYTES).toString("hex"),
|
|
231
|
+
cert: certificate.certificatePem,
|
|
232
|
+
privKey: certificate.privateKeyPem
|
|
233
|
+
});
|
|
234
|
+
server.startServer();
|
|
235
|
+
await server.ready;
|
|
236
|
+
const bound = server.address();
|
|
237
|
+
if (bound === null) throw new Error("the WebTransport server bound no address");
|
|
238
|
+
const boundAddress = `${host}:${String(bound.port)}`;
|
|
239
|
+
const advertise = () => reachableHosts(host).map((reachable) => formatPinnedAddress(`${reachable}:${String(bound.port)}`, certificate.sha256Hex));
|
|
240
|
+
(async () => {
|
|
241
|
+
for await (const session of server.sessionStream(WEBTRANSPORT_PATH)) (async () => {
|
|
242
|
+
if (!isIncomingSession(session)) throw new Error("the WebTransport server yielded a malformed session");
|
|
243
|
+
await session.ready;
|
|
244
|
+
const reader = session.incomingBidirectionalStreams.getReader();
|
|
245
|
+
const first = await reader.read();
|
|
246
|
+
reader.releaseLock();
|
|
247
|
+
if (!first.done) onConnection(connectionFromByteStream(first.value));
|
|
248
|
+
})().catch(reportError);
|
|
249
|
+
})().catch(reportError);
|
|
250
|
+
const renewal = setInterval(() => {
|
|
251
|
+
mintPinnedCertificate(now()).then((renewed) => {
|
|
252
|
+
certificate = renewed;
|
|
253
|
+
server.updateCert(renewed.certificatePem, renewed.privateKeyPem, false);
|
|
254
|
+
options.onCertificateRenewed?.(advertise());
|
|
255
|
+
}).catch(reportError);
|
|
256
|
+
}, RENEWAL_DELAY_MS);
|
|
257
|
+
renewal.unref();
|
|
258
|
+
return {
|
|
259
|
+
address: boundAddress,
|
|
260
|
+
get advertisedAddresses() {
|
|
261
|
+
return advertise();
|
|
262
|
+
},
|
|
263
|
+
close: async () => {
|
|
264
|
+
clearInterval(renewal);
|
|
265
|
+
server.stopServer();
|
|
266
|
+
await server.closed;
|
|
267
|
+
},
|
|
268
|
+
unref: () => {
|
|
269
|
+
renewal.unref();
|
|
270
|
+
}
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
//#endregion
|
|
117
276
|
//#region src/static-console.ts
|
|
118
277
|
const CONTENT_TYPES = {
|
|
119
278
|
".css": "text/css; charset=utf-8",
|
|
@@ -240,6 +399,19 @@ async function main(argv) {
|
|
|
240
399
|
hub.handleConnection(connection);
|
|
241
400
|
});
|
|
242
401
|
logOutput(`wire-mesh listening on ${tls ? "wss" : "ws"}://${listener.address}`);
|
|
402
|
+
if (command.webTransportAddress !== void 0) {
|
|
403
|
+
const webTransport = await createWebTransportTransport({
|
|
404
|
+
onError: (error) => {
|
|
405
|
+
logError(`wire-mesh: WebTransport: ${String(error)}`);
|
|
406
|
+
},
|
|
407
|
+
onCertificateRenewed: (addresses) => {
|
|
408
|
+
logOutput(`wire-mesh WebTransport addresses changed: ${addresses.join(" ")}`);
|
|
409
|
+
}
|
|
410
|
+
}).listen(command.webTransportAddress, (connection) => {
|
|
411
|
+
hub.handleConnection(connection);
|
|
412
|
+
});
|
|
413
|
+
for (const address of webTransport.advertisedAddresses) logOutput(`wire-mesh serving WebTransport at ${address}`);
|
|
414
|
+
}
|
|
243
415
|
}
|
|
244
416
|
/** Runs the CLI and turns a failure into a one-line message on stderr and a non-zero exit code, instead of an unhandled rejection's stack trace. */
|
|
245
417
|
async function run(argv) {
|