@specific.dev/spectest 0.69.0 → 0.71.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.
@@ -68,6 +68,41 @@ export function certCovers(serverNames: Iterable<string>, hostname: string): boo
68
68
  return names.some((n) => isWildcard(n) && wildcardCoversHost(n, hostname));
69
69
  }
70
70
 
71
+ /**
72
+ * Pick the certificate server name that should serve `hostname`'s TLS
73
+ * handshake: the exact entry first, then the longest matching wildcard.
74
+ *
75
+ * This is the same precedence {@link matchRoute} applies to routes and the
76
+ * resolver applies to DNS — but over the *certificate* rule, so a wildcard
77
+ * covers exactly one label ({@link wildcardCoversHost}) rather than any
78
+ * depth.
79
+ *
80
+ * It exists because the SNI lookup is now ours to make. Until the :443
81
+ * listener stopped being rebuilt for every new certificate, Bun performed
82
+ * this match internally from the `tls` array it was served with; the
83
+ * terminator picks a certificate per handshake instead, so the rule has to
84
+ * be written down — and, being written down, it is testable, which Bun's
85
+ * internal version never was.
86
+ */
87
+ export function selectCertName(
88
+ serverNames: Iterable<string>,
89
+ hostname: string,
90
+ ): string | undefined {
91
+ const names = [...serverNames];
92
+ if (names.includes(hostname)) return hostname;
93
+ let best: string | undefined;
94
+ let bestLen = -1;
95
+ for (const name of names) {
96
+ if (!isWildcard(name) || !wildcardCoversHost(name, hostname)) continue;
97
+ const len = wildcardSuffix(name).length;
98
+ if (len > bestLen) {
99
+ best = name;
100
+ bestLen = len;
101
+ }
102
+ }
103
+ return best;
104
+ }
105
+
71
106
  /**
72
107
  * Pick the route for `host`: exact match first, then the longest matching
73
108
  * wildcard suffix.
@@ -1,17 +1,25 @@
1
1
  /**
2
- * The :443 rebind must not be collateral damage for traffic already on the
3
- * listener.
2
+ * What a listener swap does and does not cost.
4
3
  *
5
- * Adding an SNI certificate needs a second `Bun.serve` — `reload()` accepts
6
- * a new `tls` option and goes on serving the old certificates. The question
7
- * this file pins down is the *order*: bind the replacement first and drain
8
- * the old one, rather than stopping the old one and leaving a gap.
4
+ * **:443 no longer swaps.** Adding an SNI certificate used to need a
5
+ * second `Bun.serve` (`reload()` accepts a new `tls` option and goes on
6
+ * serving the old certificates — still true on Bun 1.4.0), and this file
7
+ * pinned down the *order*: bind the replacement first and drain the old
8
+ * one, rather than stopping first and leaving a gap. That ordering was
9
+ * necessary and insufficient — it saves a request already in flight and
10
+ * severs every idle kept-alive connection, which is the failure that kept
11
+ * reaching production. `tls-terminator.ts` chooses the certificate per
12
+ * handshake now, so the certificate path swaps nothing; `tls-terminator.
13
+ * test.ts` covers the invariant that replaced this one.
9
14
  *
10
- * These drive real sockets rather than a model of them, because the bug was
15
+ * These stay because the swap itself stays, on the remaining path: the
16
+ * `/load` teardown force-closes every listener and rebinds the next
17
+ * project's. That is what `reusePort` is still for, and the two properties
18
+ * below — a replacement can bind alongside the original, and the port is
19
+ * never refused across the swap — are what make it safe.
20
+ *
21
+ * They drive real sockets rather than a model of them, because the bug was
11
22
  * in socket behaviour: a model would have agreed with the broken code.
12
- * They use plain HTTP — the TLS part is Bun's SNI table, measured
13
- * separately; what is asserted here is that a listener swap costs no
14
- * connection, which is the part that was wrong.
15
23
  */
16
24
  import { afterEach, describe, expect, test } from "bun:test";
17
25
 
@@ -78,21 +78,36 @@ describe("route tables", () => {
78
78
  });
79
79
 
80
80
  describe("planBind", () => {
81
- test("a brand-new hostname needs a cert and a rebind", () => {
81
+ test("a brand-new hostname needs a cert", () => {
82
82
  const t = emptyTables();
83
83
  expect(planBind(t, "new.test", { httpsListening: true })).toEqual({
84
84
  needsCert: true,
85
- needsHttpsRebind: true,
85
+ needsHttpsListener: false,
86
86
  });
87
87
  });
88
88
 
89
- test("the first bind rebinds even when a cert already covers the name", () => {
89
+ /**
90
+ * The rule the terminator bought, and the regression this file is the
91
+ * cheap half of: minting a certificate must NOT imply touching the
92
+ * listener. It used to (`needsCert || !httpsListening`), and every such
93
+ * restart severed the idle kept-alive connections :443 was carrying —
94
+ * failing an unrelated test with `SocketError: other side closed`.
95
+ */
96
+ test("a new certificate never asks for the listener to be restarted", () => {
97
+ const t = emptyTables();
98
+ t.certByHost.set("app.test", leaf("app"));
99
+ expect(planBind(t, "brand-new.test", { httpsListening: true })).toEqual({
100
+ needsCert: true,
101
+ needsHttpsListener: false,
102
+ });
103
+ });
104
+
105
+ test("the listener is asked for when nothing is serving :443 yet", () => {
90
106
  const t = emptyTables();
91
107
  t.certByHost.set("app.test", leaf("app"));
92
- // Nothing is listening yet, so :443 has to be served regardless.
93
108
  expect(planBind(t, "app.test", { httpsListening: false })).toEqual({
94
109
  needsCert: false,
95
- needsHttpsRebind: true,
110
+ needsHttpsListener: true,
96
111
  });
97
112
  });
98
113
 
@@ -101,19 +116,19 @@ describe("planBind", () => {
101
116
  t.certByHost.set("app.test", leaf("app"));
102
117
  expect(planBind(t, "app.test", { httpsListening: true })).toEqual({
103
118
  needsCert: false,
104
- needsHttpsRebind: false,
119
+ needsHttpsListener: false,
105
120
  });
106
121
  });
107
122
 
108
123
  /** The case that makes runtime provisioning cheap: a component claims
109
124
  * `*.db.test` up front, then a fake mints endpoints under it in a loop.
110
- * Rebinding per iteration would restart the listener each time. */
125
+ * Each one is then a route entry and not even a keygen. */
111
126
  test("a hostname under an existing wildcard cert needs neither", () => {
112
127
  const t = emptyTables();
113
128
  t.certByHost.set("*.db.test", leaf("wild"));
114
129
  expect(planBind(t, "tenant-1.db.test", { httpsListening: true })).toEqual({
115
130
  needsCert: false,
116
- needsHttpsRebind: false,
131
+ needsHttpsListener: false,
117
132
  });
118
133
  });
119
134
 
@@ -32,13 +32,14 @@
32
32
  * routes added afterwards go nowhere. {@link routesFor} is the only way to
33
33
  * reach a table so that this can't be done by accident.
34
34
  *
35
- * **A rebind is not free, so it must be earned.** Bun fixes a server's TLS
36
- * config at `Bun.serve` time — `reload` will not add an SNI entry — so a
37
- * genuinely new certificate means stopping and re-serving :443. It's cheap
38
- * (~1 ms) but it is a real interruption of live traffic, and a hostname
39
- * already covered by an existing exact or wildcard cert needs nothing but a
40
- * route entry. {@link planBind} is that judgement, separated from the
41
- * mutation so it can be tested without a network stack.
35
+ * **Adding a certificate no longer touches the listener.** :443 is
36
+ * terminated by `harness/tls-terminator.ts`, which chooses a leaf per TLS
37
+ * handshake, so a new certificate is a `Map` entry and nothing more. It
38
+ * used to mean stopping and re-serving :443, and that swap severed every
39
+ * idle kept-alive connection the listener was carrying — an unrelated
40
+ * test's request, failing with `SocketError: other side closed`. The only
41
+ * thing {@link planBind} still has to say is whether the listener exists
42
+ * yet.
42
43
  */
43
44
 
44
45
  import { certCovers, isWildcard, wildcardSuffix } from "./hostmatch";
@@ -102,19 +103,22 @@ export function routesFor<F>(tables: IngressTables<F>, port: number): Map<string
102
103
  export interface BindPlan {
103
104
  /** No existing cert covers the hostname, so a leaf must be minted. */
104
105
  needsCert: boolean;
105
- /** The :443 listener must be stopped and re-served. */
106
- needsHttpsRebind: boolean;
106
+ /** The :443 listener does not exist yet and must be started. */
107
+ needsHttpsListener: boolean;
107
108
  }
108
109
 
109
110
  /**
110
111
  * Decide what binding `hostname` for TLS costs.
111
112
  *
112
- * A rebind is needed when a new certificate is going into the SNI table,
113
- * or when :443 isn't listening yet. It is deliberately *not* needed for a
114
- * hostname an existing wildcard already covers — the common case when a
115
- * component claims a whole domain up front and services appear under it
116
- * later, and the reason a fake can mint endpoints in a loop without
117
- * restarting the listener once per iteration.
113
+ * Minting is still earned — a hostname an existing exact or wildcard cert
114
+ * already covers needs nothing but a route, which is why a component can
115
+ * claim a whole domain up front and have services appear under it for
116
+ * free.
117
+ *
118
+ * Starting the listener, on the other hand, is now a once-per-process
119
+ * event: it is needed when :443 is not up yet, and never again. A new
120
+ * certificate does **not** imply it. That is the whole point of the
121
+ * terminator — see the module header.
118
122
  */
119
123
  export function planBind<F>(
120
124
  tables: IngressTables<F>,
@@ -122,7 +126,7 @@ export function planBind<F>(
122
126
  opts: { httpsListening: boolean },
123
127
  ): BindPlan {
124
128
  const needsCert = !certCovers(tables.certByHost.keys(), hostname);
125
- return { needsCert, needsHttpsRebind: needsCert || !opts.httpsListening };
129
+ return { needsCert, needsHttpsListener: !opts.httpsListening };
126
130
  }
127
131
 
128
132
  /**
@@ -141,15 +145,22 @@ export function bindRoute<F>(tables: IngressTables<F>, hostname: string, route:
141
145
  * Drop `hostname`'s routes, so it 404s.
142
146
  *
143
147
  * The certificate is deliberately left in the SNI table: it is harmless
144
- * without a route, and removing it would force an otherwise unnecessary
145
- * :443 rebind at exactly the moment a service is going away.
148
+ * without a route, and a leaf nobody asks for costs one unused `Map`
149
+ * entry. (Before the terminator, removing it would also have forced a
150
+ * :443 rebind at exactly the moment a service was going away.)
146
151
  */
147
152
  export function unbindRoute<F>(tables: IngressTables<F>, hostname: string): void {
148
153
  tables.routesByPort.get(INGRESS_HTTP_PORT)?.delete(hostname);
149
154
  tables.routesByPort.get(INGRESS_HTTPS_PORT)?.delete(hostname);
150
155
  }
151
156
 
152
- /** The SNI entries for `Bun.serve`'s TLS config. */
157
+ /**
158
+ * The SNI table flattened to entries.
159
+ *
160
+ * This was `Bun.serve`'s `tls` array. The terminator selects per handshake
161
+ * instead ({@link import("./hostmatch").selectCertName}), so this survives
162
+ * as the readable projection of the table — for diagnostics and tests.
163
+ */
153
164
  export function certEntries<F>(
154
165
  tables: IngressTables<F>,
155
166
  ): Array<{ cert: string; key: string; serverName: string }> {
@@ -0,0 +1,240 @@
1
+ /**
2
+ * Adding a certificate must cost no connection.
3
+ *
4
+ * This is the file the previous shape of :443 could not have: when the
5
+ * listener was a `Bun.serve` whose `tls` array was fixed at serve time,
6
+ * every new certificate meant a swap, and the best a swap could do was
7
+ * save requests that were already in flight (`ingress-rebind.test.ts`).
8
+ * The failure that kept reaching production was the other half — an
9
+ * **idle kept-alive connection**, severed by `stop(false)` within 1-2 ms,
10
+ * surfacing in an unrelated test as `SocketError: other side closed`.
11
+ *
12
+ * So these drive real TLS sockets rather than a model of them: the whole
13
+ * bug was in socket behaviour, and a model would have agreed with the
14
+ * broken code. The client is Node's own `node:tls`/HTTP-over-socket so
15
+ * that keep-alive is under the test's control rather than a pool's.
16
+ */
17
+ import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test";
18
+ import { spawnSync } from "node:child_process";
19
+ import { mkdtempSync, readFileSync, rmSync } from "node:fs";
20
+ import net from "node:net";
21
+ import { tmpdir } from "node:os";
22
+ import { join } from "node:path";
23
+ import tls from "node:tls";
24
+
25
+ import type { Leaf } from "./ingress-table";
26
+ import { selectCertName } from "./hostmatch";
27
+ import { startTlsTerminator, type TlsTerminator } from "./tls-terminator";
28
+
29
+ // ── a throwaway CA and leaves, so the client can verify for real ──────────
30
+ let dir: string;
31
+ const leaves = new Map<string, Leaf>();
32
+ let caPem: string;
33
+
34
+ function openssl(args: string[], input?: string): void {
35
+ const r = spawnSync("openssl", args, { cwd: dir, input, encoding: "utf8" });
36
+ if (r.status !== 0) throw new Error(`openssl ${args.join(" ")}: ${r.stderr}`);
37
+ }
38
+
39
+ function mintLeaf(name: string, san: string): Leaf {
40
+ const file = name.replace("*", "wild");
41
+ openssl(["req", "-newkey", "rsa:2048", "-keyout", `${file}.key`, "-out", `${file}.csr`,
42
+ "-nodes", "-subj", `/CN=${name}`]);
43
+ openssl(["x509", "-req", "-in", `${file}.csr`, "-CA", "ca.crt", "-CAkey", "ca.key",
44
+ "-CAcreateserial", "-out", `${file}.crt`, "-days", "2", "-extfile", "ext.cnf"]);
45
+ // -extfile is rewritten per call; write it first.
46
+ return {
47
+ cert: readFileSync(join(dir, `${file}.crt`), "utf8"),
48
+ key: readFileSync(join(dir, `${file}.key`), "utf8"),
49
+ };
50
+ }
51
+
52
+ beforeAll(() => {
53
+ dir = mkdtempSync(join(tmpdir(), "spectest-tls-"));
54
+ openssl(["req", "-x509", "-newkey", "rsa:2048", "-keyout", "ca.key", "-out", "ca.crt",
55
+ "-days", "2", "-nodes", "-subj", "/CN=spectest-test-ca"]);
56
+ caPem = readFileSync(join(dir, "ca.crt"), "utf8");
57
+ for (const [name, san] of [
58
+ ["app.test", "DNS:app.test"],
59
+ ["late.test", "DNS:late.test"],
60
+ ["*.db.test", "DNS:*.db.test"],
61
+ ] as const) {
62
+ require("node:fs").writeFileSync(join(dir, "ext.cnf"), `subjectAltName=${san}\n`);
63
+ leaves.set(name, mintLeaf(name, san));
64
+ }
65
+ });
66
+ afterAll(() => rmSync(dir, { recursive: true, force: true }));
67
+
68
+ // ── the ingress shape: a plaintext Bun.serve behind the terminator ────────
69
+ const PORT = 19701;
70
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
71
+ let plain: any;
72
+ let term: TlsTerminator;
73
+ /** The live SNI table, exactly as the daemon mutates `INGRESS.certByHost`. */
74
+ let certByHost: Map<string, Leaf>;
75
+ /** What the plaintext server reported as the client's address, per request. */
76
+ let seenClientIp: string | undefined;
77
+
78
+ beforeAll(async () => {
79
+ certByHost = new Map([["app.test", leaves.get("app.test")!]]);
80
+ plain = Bun.serve({
81
+ port: 0,
82
+ hostname: "127.0.0.1",
83
+ idleTimeout: 0,
84
+ fetch(req, server) {
85
+ const peer = server.requestIP(req);
86
+ seenClientIp = term.clientIpFor(peer?.port) ?? peer?.address;
87
+ return new Response(`served ${new URL(req.url).hostname}`);
88
+ },
89
+ });
90
+ term = await startTlsTerminator({
91
+ port: PORT,
92
+ hostname: "127.0.0.1",
93
+ upstreamPort: plain.port,
94
+ certFor: (serverName) => {
95
+ if (serverName === undefined) return certByHost.values().next().value;
96
+ const name = selectCertName(certByHost.keys(), serverName.toLowerCase());
97
+ return name === undefined ? undefined : certByHost.get(name);
98
+ },
99
+ onWarning: () => {
100
+ /* a client hanging up mid-handshake is a case under test */
101
+ },
102
+ });
103
+ });
104
+ afterAll(() => {
105
+ term?.close();
106
+ plain?.stop(true);
107
+ });
108
+
109
+ const sockets: tls.TLSSocket[] = [];
110
+ afterEach(() => {
111
+ for (const s of sockets.splice(0)) s.destroy();
112
+ });
113
+
114
+ /** A kept-alive TLS connection, handshaken and left open. */
115
+ async function connect(servername: string, localAddress?: string): Promise<tls.TLSSocket> {
116
+ const sock = tls.connect({
117
+ host: "127.0.0.1", port: PORT, servername, ca: caPem,
118
+ ...(localAddress ? { localAddress } : {}),
119
+ });
120
+ sockets.push(sock);
121
+ await new Promise<void>((resolve, reject) => {
122
+ sock.once("secureConnect", () => resolve());
123
+ sock.once("error", reject);
124
+ });
125
+ return sock;
126
+ }
127
+
128
+ /** One keep-alive request on an existing connection. Rejects if the peer
129
+ * closed it instead of answering — which is exactly the reported bug. */
130
+ function request(sock: tls.TLSSocket, host: string): Promise<string> {
131
+ return new Promise((resolve, reject) => {
132
+ let buf = "";
133
+ const onData = (d: Buffer): void => {
134
+ buf += d.toString();
135
+ if (buf.includes("\r\n\r\n")) {
136
+ sock.off("data", onData);
137
+ resolve(buf);
138
+ }
139
+ };
140
+ sock.on("data", onData);
141
+ sock.once("close", () => reject(new Error("other side closed")));
142
+ sock.once("error", (err) => reject(err));
143
+ sock.write(`GET / HTTP/1.1\r\nHost: ${host}\r\nConnection: keep-alive\r\n\r\n`);
144
+ setTimeout(() => reject(new Error("timed out")), 4000);
145
+ });
146
+ }
147
+
148
+ describe("adding a certificate", () => {
149
+ /**
150
+ * THE regression. An idle pooled connection is what a client actually
151
+ * holds between polls, and a listener swap severed it: the next request
152
+ * written into that socket came back `SocketError: other side closed`,
153
+ * unretried, failing whichever test happened to be deploying. Adding a
154
+ * leaf to the live table must be invisible to it.
155
+ */
156
+ test("an idle kept-alive connection survives it", async () => {
157
+ const sock = await connect("app.test");
158
+ expect(await request(sock, "app.test")).toContain("served app.test");
159
+
160
+ // …the connection now sits idle in the client's pool, as a CLI polling
161
+ // a deploy would leave it, while an unrelated service is provisioned.
162
+ certByHost.set("late.test", leaves.get("late.test")!);
163
+ await Bun.sleep(50);
164
+
165
+ expect(await request(sock, "app.test")).toContain("served app.test");
166
+ });
167
+
168
+ test("many certificates later, the same connection is still good", async () => {
169
+ const sock = await connect("app.test");
170
+ await request(sock, "app.test");
171
+ for (let i = 0; i < 10; i++) {
172
+ certByHost.set(`svc-${i}.db.test`, leaves.get("*.db.test")!);
173
+ await Bun.sleep(5);
174
+ }
175
+ expect(await request(sock, "app.test")).toContain("served app.test");
176
+ });
177
+
178
+ /** The other side of the same coin: the point of adding a leaf is that
179
+ * it serves the next handshake, with no restart to wait for. */
180
+ test("the new hostname is served on the next handshake", async () => {
181
+ certByHost.set("late.test", leaves.get("late.test")!);
182
+ const sock = await connect("late.test");
183
+ expect((sock.getPeerCertificate().subject as { CN: string }).CN).toBe("late.test");
184
+ expect(await request(sock, "late.test")).toContain("served late.test");
185
+ });
186
+
187
+ /** A wildcard leaf serves one label under it — the same RFC 6125 rule
188
+ * `certCovers` plans against, now enforced at the handshake. */
189
+ test("a wildcard leaf serves a name one label under it", async () => {
190
+ certByHost.set("*.db.test", leaves.get("*.db.test")!);
191
+ const sock = await connect("tenant-7.db.test");
192
+ expect((sock.getPeerCertificate().subject as { CN: string }).CN).toBe("*.db.test");
193
+ });
194
+ });
195
+
196
+ describe("the pipe", () => {
197
+ /** `x-forwarded-for` is built from this. The plaintext server's peer is
198
+ * the terminator, so without the port map every proxied request would
199
+ * tell the app under test its caller was 127.0.0.1. */
200
+ test("reports the real client address, not the loopback hop", async () => {
201
+ seenClientIp = undefined;
202
+ const sock = await connect("app.test", "127.0.0.2");
203
+ await request(sock, "app.test");
204
+ expect(seenClientIp).toBe("127.0.0.2");
205
+ });
206
+
207
+ /** A handshake the terminator cannot complete must cost the process
208
+ * nothing: an unhandled 'error' on a node server is thrown, and taking
209
+ * the daemon down over one bad client would be worse than the bug. */
210
+ test("a client that speaks no TLS does not disturb the listener", async () => {
211
+ const raw = net.connect(PORT, "127.0.0.1");
212
+ await new Promise<void>((r) => raw.on("connect", () => r()));
213
+ raw.write("this is not a ClientHello\r\n\r\n");
214
+ await Bun.sleep(100);
215
+ raw.destroy();
216
+
217
+ const sock = await connect("app.test");
218
+ expect(await request(sock, "app.test")).toContain("served app.test");
219
+ });
220
+
221
+ /** A test VM is short-lived, but a fake that provisions in a loop opens
222
+ * a connection per probe — the terminator must not accumulate them. */
223
+ test("connections are released when they close", async () => {
224
+ // Earlier tests' sockets are destroyed in afterEach; let those close
225
+ // events land so this measures only what it opens itself.
226
+ const settle = async (): Promise<void> => {
227
+ for (let i = 0; i < 100 && term.connectionCount() > 0; i++) await Bun.sleep(20);
228
+ };
229
+ await settle();
230
+ expect(term.connectionCount()).toBe(0);
231
+
232
+ const sock = await connect("app.test");
233
+ await request(sock, "app.test");
234
+ expect(term.connectionCount()).toBe(1);
235
+
236
+ sock.destroy();
237
+ await settle();
238
+ expect(term.connectionCount()).toBe(0);
239
+ });
240
+ });