@specific.dev/spectest 0.69.0 → 0.70.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.
@@ -0,0 +1,199 @@
1
+ /**
2
+ * The :443 TLS terminator — one listener, for the life of the harness.
3
+ *
4
+ * ## Why this exists
5
+ *
6
+ * Ingress certificates are minted *while tests run*: a fake that
7
+ * provisions a database hands the app a CA-trusted `https://<new-host>/`
8
+ * endpoint, and that hostname did not exist a second earlier. Bun fixes a
9
+ * server's TLS configuration at `Bun.serve` time — `reload({ tls })` is
10
+ * accepted and then ignored (re-measured on Bun 1.4.0: the added
11
+ * `serverName` still gets the first entry's leaf) — so for as long as
12
+ * :443 was a `Bun.serve`, every new certificate meant swapping the
13
+ * listener.
14
+ *
15
+ * **No listener swap can be made safe.** Binding the replacement first
16
+ * under SO_REUSEPORT and draining the original with `stop(false)` saves a
17
+ * request that is already *in flight*, and that is all it saves: measured
18
+ * on Bun 1.4.0, `stop(false)` sends FIN to an **idle kept-alive
19
+ * connection within 1–2 ms**. A client that writes a request into that
20
+ * socket in the same instant gets `SocketError: other side closed`
21
+ * (`UND_ERR_SOCKET` under undici), and Node's `fetch` does not retry it.
22
+ * That is a flake in whatever unrelated test happened to be talking
23
+ * through the ingress — never in the one that provisioned the service —
24
+ * and it is why the app under test would die far from any ingress code.
25
+ *
26
+ * So the listener must stop being swapped, which means the certificate
27
+ * must be chosen **per handshake** rather than baked into the socket.
28
+ * That is what every real server does, and under Bun it is reachable
29
+ * through `node:tls`'s `SNICallback` (verified working on Bun 1.4.0:
30
+ * adding an entry to the table is served on the next handshake, with no
31
+ * restart and no effect on any established connection).
32
+ *
33
+ * ## The shape, and why it is a byte pipe
34
+ *
35
+ * The terminator does TLS and *nothing else*: it accepts on :443, picks a
36
+ * leaf by SNI, and pipes the decrypted stream to a plaintext `Bun.serve`
37
+ * bound on loopback. Everything downstream — routing, fakes,
38
+ * interceptors, reverse-proxying, and `server.upgrade()` WebSocket
39
+ * bridging — is the same `Bun.serve` code that already serves :80, run
40
+ * unchanged. Terminating HTTP here instead (`node:https`, `(req, res)`)
41
+ * would have meant reimplementing the WebSocket bridge against a
42
+ * different API, which is exactly the kind of trade this change exists to
43
+ * avoid: one flake removed, another introduced somewhere subtler.
44
+ *
45
+ * Measured cost of the extra hop on this host (Bun 1.4.0, undici client,
46
+ * 50 MB bodies): ~11 % fewer small requests per second, download
47
+ * throughput unchanged, upload ~500 MB/s against ~800 MB/s direct. Ingress
48
+ * traffic in a test VM is nowhere near either bound.
49
+ *
50
+ * ## Recovering the client's address
51
+ *
52
+ * The plaintext server's peer is the terminator, so `server.requestIP()`
53
+ * reports loopback and `x-forwarded-for` would lose the caller. The
54
+ * upstream socket's *source port* is unique among live connections, so the
55
+ * terminator records `source port → real client address` for the life of
56
+ * each connection and {@link TlsTerminator.clientIpFor} hands it back.
57
+ * Verified end to end: a client connecting from 127.0.0.2 is reported as
58
+ * 127.0.0.2 by the plaintext server behind the pipe.
59
+ */
60
+ import net from "node:net";
61
+ import tls from "node:tls";
62
+ /**
63
+ * Start the terminator. Resolves once :443 is accepting, so a caller can
64
+ * register the hostname in DNS immediately afterwards and know the port is
65
+ * live.
66
+ */
67
+ /**
68
+ * Is this the ordinary end of a connection rather than a fault? A peer
69
+ * that goes away mid-stream, or a half-finished handshake, is routine on a
70
+ * listener that fronts browsers.
71
+ */
72
+ function isRoutineDisconnect(err) {
73
+ const code = err?.code;
74
+ return (code === "ECONNRESET" ||
75
+ code === "EPIPE" ||
76
+ code === "ERR_STREAM_PREMATURE_CLOSE" ||
77
+ code === "ECONNABORTED");
78
+ }
79
+ export function startTlsTerminator(opts) {
80
+ // Parsing a PEM into a SecureContext is the expensive half of choosing a
81
+ // certificate, and the table is stable — cache by the certificate's own
82
+ // bytes so a handshake is a Map lookup. Keyed on the PEM rather than the
83
+ // hostname so a re-minted leaf for the same name cannot be served stale.
84
+ const contexts = new Map();
85
+ const contextFor = (leaf) => {
86
+ let ctx = contexts.get(leaf.cert);
87
+ if (!ctx) {
88
+ ctx = tls.createSecureContext({ cert: leaf.cert, key: leaf.key });
89
+ contexts.set(leaf.cert, ctx);
90
+ }
91
+ return ctx;
92
+ };
93
+ const warn = (message, err) => {
94
+ // A client hanging up is not a fault, and this listener carries every
95
+ // browser in the VM: Chromium opens speculative connections and drops
96
+ // them, a fetch is abandoned when its test ends, and each of those
97
+ // arrives here as a reset. Logging them would bury the boot log a user
98
+ // reads with `spectest env logs` in noise that means nothing.
99
+ if (isRoutineDisconnect(err))
100
+ return;
101
+ if (opts.onWarning)
102
+ opts.onWarning(message, err);
103
+ // eslint-disable-next-line no-console
104
+ else
105
+ console.warn(`[ingress] ${message}:`, err);
106
+ };
107
+ /** upstream source port → the address the TLS client connected from. */
108
+ const clientIpByUpstreamPort = new Map();
109
+ const live = new Set();
110
+ // A default leaf is only ever used for a client that sends no SNI at
111
+ // all; `SNICallback` decides every other handshake. This mirrors what
112
+ // Bun did with the first entry of its `tls` array.
113
+ const fallback = opts.certFor(undefined);
114
+ const server = tls.createServer({
115
+ ...(fallback ? { cert: fallback.cert, key: fallback.key } : {}),
116
+ SNICallback: (serverName, cb) => {
117
+ const leaf = opts.certFor(serverName) ?? fallback;
118
+ // No certificate at all is a handshake we cannot complete. Answer
119
+ // with the fallback context rather than an error so the client
120
+ // gets a certificate-mismatch alert it can report, which is the
121
+ // same thing it saw when Bun served the first entry.
122
+ cb(null, leaf ? contextFor(leaf) : undefined);
123
+ },
124
+ }, (client) => {
125
+ // No idle timeout anywhere on the ingress path: it fronts app
126
+ // endpoints that legitimately take minutes (a deploy), and a
127
+ // timeout here would surface as a truncated response with no
128
+ // explanation.
129
+ client.setTimeout(0);
130
+ live.add(client);
131
+ // Nothing reads from `client` until `pipe` below, so a socket that
132
+ // is written to before the upstream is connected stays paused and
133
+ // its bytes are buffered rather than dropped — which is the normal
134
+ // case, since a client sends its first request the instant the
135
+ // handshake completes.
136
+ const upstream = net.connect(opts.upstreamPort, "127.0.0.1");
137
+ upstream.setTimeout(0);
138
+ upstream.setNoDelay(true);
139
+ client.setNoDelay(true);
140
+ let recordedPort;
141
+ upstream.on("connect", () => {
142
+ recordedPort = upstream.localPort;
143
+ if (recordedPort !== undefined && client.remoteAddress) {
144
+ clientIpByUpstreamPort.set(recordedPort, client.remoteAddress);
145
+ }
146
+ // `pipe` carries backpressure and the half-close in both
147
+ // directions, which is what a WebSocket bridge and a streamed
148
+ // upload each need.
149
+ client.pipe(upstream);
150
+ upstream.pipe(client);
151
+ });
152
+ const teardown = () => {
153
+ if (recordedPort !== undefined)
154
+ clientIpByUpstreamPort.delete(recordedPort);
155
+ live.delete(client);
156
+ client.destroy();
157
+ upstream.destroy();
158
+ };
159
+ client.on("error", (err) => {
160
+ warn("tls client connection failed", err);
161
+ teardown();
162
+ });
163
+ upstream.on("error", (err) => {
164
+ warn(`ingress upstream :${opts.upstreamPort} failed`, err);
165
+ teardown();
166
+ });
167
+ client.on("close", teardown);
168
+ upstream.on("close", teardown);
169
+ });
170
+ // A client that aborts mid-handshake, or offers a protocol version we
171
+ // do not speak, emits this. It must be handled: an unhandled 'error' on
172
+ // a net server is thrown, and taking the daemon down over one bad
173
+ // handshake would be a far worse failure than the one being fixed.
174
+ server.on("tlsClientError", (err) => warn("tls handshake failed", err));
175
+ server.on("error", (err) => warn(`tls listener :${opts.port} failed`, err));
176
+ return new Promise((resolve, reject) => {
177
+ server.once("error", reject);
178
+ server.listen(opts.port, opts.hostname, () => {
179
+ server.removeListener("error", reject);
180
+ resolve({
181
+ port: opts.port,
182
+ clientIpFor: (upstreamPort) => upstreamPort === undefined ? undefined : clientIpByUpstreamPort.get(upstreamPort),
183
+ connectionCount: () => live.size,
184
+ close: () => {
185
+ try {
186
+ server.close();
187
+ }
188
+ catch (err) {
189
+ warn("closing the tls listener failed", err);
190
+ }
191
+ for (const sock of [...live])
192
+ sock.destroy();
193
+ live.clear();
194
+ clientIpByUpstreamPort.clear();
195
+ },
196
+ });
197
+ });
198
+ });
199
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specific.dev/spectest",
3
- "version": "0.69.0",
3
+ "version": "0.70.0",
4
4
  "description": "Spectest SDK for defining test environments in TypeScript.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
package/src/daemon.ts CHANGED
@@ -116,6 +116,7 @@ import {
116
116
  certCovers as hostmatchCertCovers,
117
117
  hostWithoutPort,
118
118
  matchRoute,
119
+ selectCertName,
119
120
  wildcardCoversHost,
120
121
  wildcardSuffix,
121
122
  } from "./harness/hostmatch.js";
@@ -123,7 +124,6 @@ import {
123
124
  INGRESS_HTTPS_PORT,
124
125
  INGRESS_HTTP_PORT,
125
126
  bindRoute,
126
- certEntries,
127
127
  clearTables,
128
128
  emptyTables,
129
129
  planBind,
@@ -132,6 +132,7 @@ import {
132
132
  unbindRoute,
133
133
  type Route as IngressRoute,
134
134
  } from "./harness/ingress-table.js";
135
+ import { startTlsTerminator, type TlsTerminator } from "./harness/tls-terminator.js";
135
136
  import { runContainerArgs } from "./harness/container-run.js";
136
137
  import {
137
138
  assertAbsolute,
@@ -1769,42 +1770,29 @@ const INGRESS_HTTP_SERVERS = new Map<number, any>();
1769
1770
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
1770
1771
  const INGRESS_HTTPS_SERVERS = new Map<number, any>();
1771
1772
  /**
1772
- * Servers replaced by a rebind and now draining. On Bun 1.3.14 a request
1773
- * arriving on a kept-alive connection of a `stop(false)`-drained server
1774
- * dispatches into freed per-server state and can SEGFAULT the process
1775
- * (use-after-free class fixed upstream by oven-sh/bun#36790, first in Bun
1776
- * 1.4.0; observed here as `panic: Segmentation fault at address 0xA` in
1777
- * `server.zig onRequestFor` on ~2-3 % of runtime-TLS rebinds). Until the
1778
- * Bun bump lands, shrink the number of requests a drained server can ever
1779
- * see: every response it still serves carries `Connection: close` (one
1780
- * more request per surviving connection, not unlimited), and a grace timer
1781
- * force-closes whatever is left ({@link REBIND_DRAIN_GRACE_MS}).
1773
+ * The :443 TLS terminator, once per harness process.
1774
+ *
1775
+ * There is deliberately no draining machinery beside it any more. :443
1776
+ * used to be a `Bun.serve` that had to be swapped for every new
1777
+ * certificate, and no swap can be made safe: `stop(false)` FINs an idle
1778
+ * kept-alive connection within 1-2 ms, so a client that wrote a request in
1779
+ * that instant failed with `SocketError: other side closed` — in whatever
1780
+ * unrelated test happened to be talking through the ingress.
1781
+ * {@link startTlsTerminator} chooses the leaf per handshake instead, so
1782
+ * the listener is bound once and lives as long as the project does.
1782
1783
  */
1783
- const DRAINING_INGRESS = new WeakSet<object>();
1784
- /** How long a drained listener may keep serving in-flight work before its
1785
- * remaining connections are force-closed. Long enough for a slow proxied
1786
- * response to finish, short enough to bound the 1.3.14 UAF window. */
1787
- const REBIND_DRAIN_GRACE_MS = 15_000;
1788
-
1789
- /** Stamp `Connection: close` on a response served by a draining listener so
1790
- * the kept-alive connection retires instead of lingering as a UAF trigger.
1791
- * Proxied responses can carry immutable headers; rewrap when needed. */
1792
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
1793
- function withDrainClose(server: any, res: Response): Response {
1794
- if (!DRAINING_INGRESS.has(server)) return res;
1795
- try {
1796
- res.headers.set("connection", "close");
1797
- return res;
1798
- } catch {
1799
- const headers = new Headers(res.headers);
1800
- headers.set("connection", "close");
1801
- return new Response(res.body, {
1802
- status: res.status,
1803
- statusText: res.statusText,
1804
- headers,
1805
- });
1806
- }
1807
- }
1784
+ let HTTPS_TERMINATOR: TlsTerminator | undefined;
1785
+ /**
1786
+ * The in-flight first bind of :443, if one is running.
1787
+ *
1788
+ * {@link ensureHttpsIngress} is async where the swap it replaced was
1789
+ * synchronous, and that reintroduces an interleaving the old code could
1790
+ * not have: two `ctx.startService({ tls })` calls issued together by a
1791
+ * fake handler would both find :443 absent and both try to bind it, and
1792
+ * the loser gets EADDRINUSE — a provisioning failure in place of the
1793
+ * dropped connection this change exists to remove. One promise, shared.
1794
+ */
1795
+ let HTTPS_INGRESS_STARTING: Promise<void> | undefined;
1808
1796
  /**
1809
1797
  * The live ingress tables — per-port routes and the :443 SNI cert table.
1810
1798
  *
@@ -1852,6 +1840,12 @@ function stopIngressServers(): void {
1852
1840
  }
1853
1841
  }
1854
1842
  INGRESS_HTTPS_SERVERS.clear();
1843
+ // Force-closed for the same reason the listeners are: the containers
1844
+ // behind these routes are going away, so a connection still riding them
1845
+ // has nothing left to reach.
1846
+ HTTPS_TERMINATOR?.close();
1847
+ HTTPS_TERMINATOR = undefined;
1848
+ HTTPS_INGRESS_STARTING = undefined;
1855
1849
  clearTables(INGRESS);
1856
1850
  }
1857
1851
 
@@ -2089,7 +2083,7 @@ async function startIngress(): Promise<void> {
2089
2083
  console.log(`[ingress] http :${port} for ${[...byHost.keys()].join(", ")}`);
2090
2084
  }
2091
2085
  // ── HTTPS listener on INGRESS_HTTPS_PORT: SNI per certificated hostname.
2092
- if (INGRESS.certByHost.size > 0) rebindHttpsListener(Bun);
2086
+ if (INGRESS.certByHost.size > 0) await ensureHttpsIngress(Bun);
2093
2087
 
2094
2088
  // Seed the resolver's names registry: ingress hostnames (fakes, TLS
2095
2089
  // proxies, dnsName(→ingress)) → bridge gateway, plus ingress-targeted
@@ -2110,86 +2104,85 @@ function requireBun(): any {
2110
2104
  return Bun;
2111
2105
  }
2112
2106
 
2113
- /** Flatten the live SNI cert table into Bun's TLS-entry array. */
2114
- function tlsEntriesFromCerts(): Array<{ cert: string; key: string; serverName: string }> {
2115
- return certEntries(INGRESS);
2116
- }
2117
2107
 
2118
2108
  /**
2119
- * (Re)bind the :443 listener from the current cert table + route map.
2109
+ * Bring :443 up, once.
2120
2110
  *
2121
- * Bun fixes a server's TLS config at `Bun.serve` time — `reload()` accepts a
2122
- * new `tls` option and silently keeps serving the old certificates (measured
2123
- * on Bun 1.3.14: after reloading with a second SNI entry, the new hostname
2124
- * still gets the first one's leaf). So adding a certificate really does mean
2125
- * a second listener.
2111
+ * Two pieces: a plaintext `Bun.serve` on an ephemeral loopback port that
2112
+ * carries the HTTPS route table (and therefore all the routing, fake
2113
+ * dispatch, interception, reverse-proxying and `server.upgrade()`
2114
+ * WebSocket bridging that :80 already gets, unchanged), and the TLS
2115
+ * terminator in front of it on :443.
2126
2116
  *
2127
- * **Bind the new one before stopping the old one.** Doing it the other way
2128
- * round — which is what this used to do, with `stop(true)` — has two teeth:
2129
- * the force-close kills every established connection on :443, and the gap
2130
- * before the new listener binds refuses new ones. Neither is limited to the
2131
- * hostname being added; they hit all the unrelated traffic the listener is
2132
- * carrying. On a project that mints a certificate per provisioned database
2133
- * while deploys stream through the same port, that surfaced as the app under
2134
- * test dying with `SocketError: other side closed` or `ECONNREFUSED
2135
- * <gateway>:443` — a different test each run, and nothing pointing at
2136
- * ingress.
2117
+ * **This is idempotent, and that is the feature.** Adding a certificate
2118
+ * mutates {@link IngressTables.certByHost} and nothing else: the
2119
+ * terminator reads that table on every handshake, so the new hostname is
2120
+ * served by the next connection and no established connection is
2121
+ * disturbed. The listener this replaced had to be stopped and re-served
2122
+ * for each new certificate, and every such swap severed the idle
2123
+ * kept-alive connections it was carrying — see
2124
+ * `harness/tls-terminator.ts` for the measurements and the history.
2137
2125
  *
2138
- * Overlapping the two needs SO_REUSEPORT on both sockets ({@link
2139
- * bindIngressServer} sets it unconditionally for that reason), after which
2140
- * the old listener is drained with `stop(false)` so requests in flight
2141
- * finish. During the overlap the kernel may hand a new connection to either
2142
- * socket, which is safe: the only hostname the two disagree about is the one
2143
- * being added, and it does not resolve until the caller writes the names
2144
- * registry after this returns.
2126
+ * The route `Map` is the persistent module object, so the plaintext
2127
+ * server closes over the same table and later route additions need no
2128
+ * restart either.
2145
2129
  *
2146
- * The route Map is the persistent module object, so the new listener closes
2147
- * over the same table and later route additions need no rebind at all.
2130
+ * The loopback port is ephemeral rather than fixed so it can never
2131
+ * collide with a fake's declared port; it is bound on 127.0.0.1, which no
2132
+ * container can reach (containers arrive at the bridge gateway), so the
2133
+ * only way in is through the terminator.
2148
2134
  */
2149
2135
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
2150
- function rebindHttpsListener(Bun: any): void {
2151
- // The *same* table object the previous listener closed over: the new
2152
- // listener must serve the routes bound since, and any bound later.
2136
+ async function ensureHttpsIngress(Bun: any): Promise<void> {
2137
+ if (INGRESS_HTTPS_SERVERS.has(INGRESS_HTTPS_PORT)) return;
2138
+ if (!HTTPS_INGRESS_STARTING) {
2139
+ // Cleared on both outcomes: once bound, the check above short-circuits
2140
+ // every later caller, and a failed bind must be retryable rather than
2141
+ // remembered as a rejection for the life of the project.
2142
+ HTTPS_INGRESS_STARTING = startHttpsIngress(Bun).finally(() => {
2143
+ HTTPS_INGRESS_STARTING = undefined;
2144
+ });
2145
+ }
2146
+ return HTTPS_INGRESS_STARTING;
2147
+ }
2148
+
2149
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
2150
+ async function startHttpsIngress(Bun: any): Promise<void> {
2153
2151
  const routes = routesFor(INGRESS, INGRESS_HTTPS_PORT);
2154
- const old = INGRESS_HTTPS_SERVERS.get(INGRESS_HTTPS_PORT);
2155
- const server = bindIngressServer(
2156
- Bun,
2157
- INGRESS_HTTPS_PORT,
2158
- routes,
2159
- `https :${INGRESS_HTTPS_PORT}`,
2160
- tlsEntriesFromCerts(),
2161
- );
2162
- if (old) {
2152
+ // `proto` is passed rather than inferred: this listener speaks plain
2153
+ // HTTP, but everything reaching it arrived over TLS, so X-Forwarded-Proto
2154
+ // must say https.
2155
+ const plain = bindIngressServer(Bun, 0, routes, `https :${INGRESS_HTTPS_PORT}`, {
2156
+ proto: "https",
2157
+ hostname: "127.0.0.1",
2158
+ });
2159
+ INGRESS_HTTPS_SERVERS.set(INGRESS_HTTPS_PORT, plain);
2160
+ try {
2161
+ HTTPS_TERMINATOR = await startTlsTerminator({
2162
+ port: INGRESS_HTTPS_PORT,
2163
+ hostname: "0.0.0.0",
2164
+ upstreamPort: plain.port,
2165
+ certFor: (serverName) => {
2166
+ if (serverName === undefined) return INGRESS.certByHost.values().next().value;
2167
+ const name = selectCertName(INGRESS.certByHost.keys(), serverName.toLowerCase());
2168
+ return name === undefined ? undefined : INGRESS.certByHost.get(name);
2169
+ },
2170
+ });
2171
+ } catch (err) {
2172
+ // Leave nothing half-built: a registered plaintext server with no
2173
+ // terminator would make `planBind` believe :443 is up.
2174
+ INGRESS_HTTPS_SERVERS.delete(INGRESS_HTTPS_PORT);
2163
2175
  try {
2164
- // Graceful: stop accepting, let in-flight requests finish. The old
2165
- // server stays alive until they do, which is the point — a long
2166
- // upload through ingress must not be collateral damage of another
2167
- // service being provisioned.
2168
- old.stop(false);
2169
- // Bun 1.3.14 landmine: a request arriving later on one of the old
2170
- // server's kept-alive connections dispatches into freed state and can
2171
- // segfault the daemon (see {@link DRAINING_INGRESS}). Mark it so any
2172
- // response it still serves closes its connection, and force-close the
2173
- // stragglers once in-flight work has had a fair window to finish.
2174
- DRAINING_INGRESS.add(old);
2175
- const graceTimer = setTimeout(() => {
2176
- try {
2177
- old.stop(true);
2178
- } catch {
2179
- /* already fully stopped */
2180
- }
2181
- }, REBIND_DRAIN_GRACE_MS);
2182
- // Don't let the grace timer keep the process alive on shutdown.
2183
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
2184
- (graceTimer as any).unref?.();
2185
- } catch (err) {
2186
- // eslint-disable-next-line no-console
2187
- console.warn("[ingress] failed to drain the previous https listener:", err);
2176
+ plain.stop(true);
2177
+ } catch {
2178
+ /* already gone */
2188
2179
  }
2180
+ throw err;
2189
2181
  }
2190
- INGRESS_HTTPS_SERVERS.set(INGRESS_HTTPS_PORT, server);
2191
2182
  // eslint-disable-next-line no-console
2192
- console.log(`[ingress] https :${INGRESS_HTTPS_PORT} for ${[...routes.keys()].join(", ")}`);
2183
+ console.log(
2184
+ `[ingress] https :${INGRESS_HTTPS_PORT} (tls terminator -> :${plain.port}) for ${[...routes.keys()].join(", ")}`,
2185
+ );
2193
2186
  }
2194
2187
 
2195
2188
  /**
@@ -2258,9 +2251,12 @@ async function bindRuntimeTls(hostname: string, service: string, port: number):
2258
2251
  );
2259
2252
  }
2260
2253
  if (plan.needsCert) {
2254
+ // The terminator reads this table on every handshake, so the leaf is
2255
+ // live the moment it lands — no listener is touched, and no connection
2256
+ // already on :443 notices.
2261
2257
  INGRESS.certByHost.set(host, await generateHostCert(host, [host]));
2262
2258
  }
2263
- if (plan.needsHttpsRebind) rebindHttpsListener(Bun);
2259
+ if (plan.needsHttpsListener) await ensureHttpsIngress(Bun);
2264
2260
 
2265
2261
  // Resolve the hostname to the daemon gateway (where :443/:80 listen).
2266
2262
  // A wildcard can only live in the resolver's suffix table.
@@ -2312,22 +2308,27 @@ function bindIngressServer(
2312
2308
  port: number,
2313
2309
  byHost: Map<string, Route>,
2314
2310
  listenerLabel: string,
2315
- tlsEntries?: Array<{ cert: string; key: string; serverName: string }>,
2311
+ // Every ingress listener speaks plain HTTP now — the one behind :443
2312
+ // sits under the TLS terminator. So the scheme a client actually used
2313
+ // can no longer be inferred from the socket and is declared instead; it
2314
+ // stamps X-Forwarded-Proto, which upstreams that build absolute URLs or
2315
+ // redirect depend on.
2316
+ serve: { proto: "http" | "https"; hostname?: string } = { proto: "http" },
2316
2317
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
2317
2318
  ): any {
2318
- // A TLS listener terminates https; everything else is plain http. Used
2319
- // to stamp X-Forwarded-Proto so upstreams that build absolute URLs or
2320
- // redirect see the scheme the client actually used, not our http hop.
2321
- const proto = tlsEntries ? "https" : "http";
2319
+ const proto = serve.proto;
2322
2320
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
2323
2321
  const opts: Record<string, any> = {
2324
2322
  port,
2325
- hostname: "0.0.0.0",
2326
- // SO_REUSEPORT on every ingress listener, so a replacement can be bound
2327
- // while the old one is still serving. That overlap is the only way to
2328
- // add an SNI certificate without a gap — see {@link rebindHttpsListener}
2329
- // — and it only works if *both* sockets opt in: a second plain bind
2330
- // fails with "Is port 443 in use?".
2323
+ hostname: serve.hostname ?? "0.0.0.0",
2324
+ // SO_REUSEPORT on every ingress listener. It was introduced so a
2325
+ // replacement :443 could overlap the original during a certificate
2326
+ // swap; the terminator removed that swap, and this stays for the
2327
+ // remaining case — the `/load` teardown force-closes and rebinds, and
2328
+ // a socket the kernel has not finished releasing would otherwise fail
2329
+ // the next bind with "Is port 443 in use?". Note the flip side, which
2330
+ // {@link stopIngressServers} depends on: a half-alive listener shares
2331
+ // the port silently instead of colliding loudly.
2331
2332
  reusePort: true,
2332
2333
  // Bun.serve defaults to a 10s idleTimeout, which kills any proxied
2333
2334
  // request whose upstream takes >10s to produce bytes — under parallel
@@ -2340,9 +2341,7 @@ function bindIngressServer(
2340
2341
  idleTimeout: 0,
2341
2342
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
2342
2343
  fetch: (req: Request, server: any): Response | Promise<Response> =>
2343
- dispatchIngress(req, server, byHost, listenerLabel, proto).then((res) =>
2344
- withDrainClose(server, res),
2345
- ),
2344
+ dispatchIngress(req, server, byHost, listenerLabel, proto),
2346
2345
  websocket: {
2347
2346
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
2348
2347
  async open(ws: any) {
@@ -2412,7 +2411,6 @@ function bindIngressServer(
2412
2411
  },
2413
2412
  },
2414
2413
  };
2415
- if (tlsEntries) opts.tls = tlsEntries;
2416
2414
  return Bun.serve(opts);
2417
2415
  }
2418
2416
 
@@ -2489,6 +2487,27 @@ async function dispatchIngress(
2489
2487
  return augmentCorsResponse(req, res);
2490
2488
  }
2491
2489
 
2490
+ /**
2491
+ * The address to report as the client's in `X-Forwarded-For`.
2492
+ *
2493
+ * On :80 that is simply the socket's peer. On :443 the peer is the TLS
2494
+ * terminator on loopback, so the real address has to be recovered from it:
2495
+ * the terminator keys `source port -> client address` for the life of each
2496
+ * upstream connection, and the source port is what `requestIP` reports
2497
+ * here. Gated on `proto === "https"` so a genuinely loopback caller on :80
2498
+ * can never pick up an unrelated terminator connection's port.
2499
+ *
2500
+ * Falling back to the socket's own address means the worst case is the
2501
+ * pre-terminator answer for a plain HTTP hop, never a wrong tenant.
2502
+ */
2503
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
2504
+ function ingressClientIp(server: any, req: Request, proto: string): string | undefined {
2505
+ const peer = server.requestIP?.(req) as { address?: string; port?: number } | undefined;
2506
+ if (!peer?.address) return undefined;
2507
+ if (proto !== "https") return peer.address;
2508
+ return HTTPS_TERMINATOR?.clientIpFor(peer.port) ?? peer.address;
2509
+ }
2510
+
2492
2511
  /**
2493
2512
  * Reverse-proxy a request to `http://<service>:<port>` on
2494
2513
  * `spectest-net`. Handles plain HTTP/1.1 + 2 and WebSocket upgrades:
@@ -2548,7 +2567,7 @@ async function proxyToService(
2548
2567
  // Standard reverse-proxy provenance headers: the upstream sees the
2549
2568
  // public scheme/host it was reached through and the client's address,
2550
2569
  // even though we rewrite Host below to the service-net name.
2551
- const clientIp = server.requestIP?.(req)?.address as string | undefined;
2570
+ const clientIp = ingressClientIp(server, req, proto);
2552
2571
  const priorXff = req.headers.get("x-forwarded-for");
2553
2572
  const xff = clientIp ? (priorXff ? `${priorXff}, ${clientIp}` : clientIp) : priorXff;
2554
2573
  if (xff) fwdHeaders.set("x-forwarded-for", xff);
@@ -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.