@specific.dev/spectest 0.68.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.
- package/dist/browser.js +1 -1
- package/dist/daemon.js +144 -118
- package/dist/harness/hostmatch.d.ts +17 -0
- package/dist/harness/hostmatch.js +33 -0
- package/dist/harness/ingress-table.d.ts +29 -18
- package/dist/harness/ingress-table.js +28 -17
- package/dist/harness/tls-terminator.d.ts +32 -0
- package/dist/harness/tls-terminator.js +199 -0
- package/dist/index.js +3 -3
- package/dist/locator-errors.js +1 -1
- package/dist/locator.d.ts +10 -5
- package/dist/locator.js +11 -6
- package/dist/terminal.js +4 -1
- package/package.json +1 -1
- package/src/browser.ts +1 -1
- package/src/daemon.ts +140 -121
- package/src/harness/hostmatch.ts +35 -0
- package/src/harness/ingress-rebind.test.ts +18 -10
- package/src/harness/ingress-table.test.ts +23 -8
- package/src/harness/ingress-table.ts +30 -19
- package/src/harness/tls-terminator.test.ts +240 -0
- package/src/harness/tls-terminator.ts +244 -0
- package/src/index.ts +3 -3
- package/src/locator-errors.ts +1 -1
- package/src/locator.ts +12 -7
- package/src/terminal.ts +4 -1
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
|
-
*
|
|
1773
|
-
*
|
|
1774
|
-
*
|
|
1775
|
-
*
|
|
1776
|
-
*
|
|
1777
|
-
*
|
|
1778
|
-
*
|
|
1779
|
-
*
|
|
1780
|
-
*
|
|
1781
|
-
*
|
|
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
|
-
|
|
1784
|
-
/**
|
|
1785
|
-
*
|
|
1786
|
-
*
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
*
|
|
1791
|
-
*
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
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)
|
|
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
|
-
*
|
|
2109
|
+
* Bring :443 up, once.
|
|
2120
2110
|
*
|
|
2121
|
-
*
|
|
2122
|
-
*
|
|
2123
|
-
*
|
|
2124
|
-
*
|
|
2125
|
-
*
|
|
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
|
-
* **
|
|
2128
|
-
*
|
|
2129
|
-
*
|
|
2130
|
-
*
|
|
2131
|
-
*
|
|
2132
|
-
*
|
|
2133
|
-
*
|
|
2134
|
-
*
|
|
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
|
-
*
|
|
2139
|
-
*
|
|
2140
|
-
*
|
|
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
|
|
2147
|
-
*
|
|
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
|
|
2151
|
-
|
|
2152
|
-
|
|
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
|
-
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
);
|
|
2162
|
-
|
|
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
|
-
|
|
2165
|
-
|
|
2166
|
-
|
|
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(
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
2327
|
-
//
|
|
2328
|
-
//
|
|
2329
|
-
//
|
|
2330
|
-
//
|
|
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)
|
|
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
|
|
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);
|
package/src/harness/hostmatch.ts
CHANGED
|
@@ -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
|
-
*
|
|
3
|
-
* listener.
|
|
2
|
+
* What a listener swap does and does not cost.
|
|
4
3
|
*
|
|
5
|
-
* Adding an SNI certificate
|
|
6
|
-
* a new `tls` option and goes on
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
|
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
|
|
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
|
-
|
|
85
|
+
needsHttpsListener: false,
|
|
86
86
|
});
|
|
87
87
|
});
|
|
88
88
|
|
|
89
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
* **
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
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
|
|
106
|
-
|
|
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
|
-
*
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
*
|
|
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,
|
|
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
|
|
145
|
-
*
|
|
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
|
-
/**
|
|
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 }> {
|