@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.
package/dist/daemon.js CHANGED
@@ -45,8 +45,9 @@ import { isTextualContentType, looksBinary, omittedBody, parseContentLength, } f
45
45
  import { encodeRegistry } from "./harness/names-registry.js";
46
46
  import { InterceptRegistry, parseTarget, runChain, } from "./harness/intercept.js";
47
47
  import { HOP_BY_HOP_HEADERS, augmentCorsResponse, corsPreflightResponse, isCorsPreflight, } from "./harness/http-proxy.js";
48
- import { certCovers as hostmatchCertCovers, hostWithoutPort, matchRoute, wildcardSuffix, } from "./harness/hostmatch.js";
49
- import { INGRESS_HTTPS_PORT, INGRESS_HTTP_PORT, bindRoute, certEntries, clearTables, emptyTables, planBind, registryTarget, routesFor, unbindRoute, } from "./harness/ingress-table.js";
48
+ import { certCovers as hostmatchCertCovers, hostWithoutPort, matchRoute, selectCertName, wildcardSuffix, } from "./harness/hostmatch.js";
49
+ import { INGRESS_HTTPS_PORT, INGRESS_HTTP_PORT, bindRoute, clearTables, emptyTables, planBind, registryTarget, routesFor, unbindRoute, } from "./harness/ingress-table.js";
50
+ import { startTlsTerminator } from "./harness/tls-terminator.js";
50
51
  import { runContainerArgs } from "./harness/container-run.js";
51
52
  import { assertAbsolute, certificateHostnames, defaultKeyMode, expandServiceToken, isNoopChown, mountFlag, needsIdTables, numericId, resolveChownIds, } from "./harness/file-mounts.js";
52
53
  import { conflict, notFound, requireString, } from "./harness/methods.js";
@@ -1356,43 +1357,29 @@ const INGRESS_HTTP_SERVERS = new Map();
1356
1357
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
1357
1358
  const INGRESS_HTTPS_SERVERS = new Map();
1358
1359
  /**
1359
- * Servers replaced by a rebind and now draining. On Bun 1.3.14 a request
1360
- * arriving on a kept-alive connection of a `stop(false)`-drained server
1361
- * dispatches into freed per-server state and can SEGFAULT the process
1362
- * (use-after-free class fixed upstream by oven-sh/bun#36790, first in Bun
1363
- * 1.4.0; observed here as `panic: Segmentation fault at address 0xA` in
1364
- * `server.zig onRequestFor` on ~2-3 % of runtime-TLS rebinds). Until the
1365
- * Bun bump lands, shrink the number of requests a drained server can ever
1366
- * see: every response it still serves carries `Connection: close` (one
1367
- * more request per surviving connection, not unlimited), and a grace timer
1368
- * force-closes whatever is left ({@link REBIND_DRAIN_GRACE_MS}).
1360
+ * The :443 TLS terminator, once per harness process.
1361
+ *
1362
+ * There is deliberately no draining machinery beside it any more. :443
1363
+ * used to be a `Bun.serve` that had to be swapped for every new
1364
+ * certificate, and no swap can be made safe: `stop(false)` FINs an idle
1365
+ * kept-alive connection within 1-2 ms, so a client that wrote a request in
1366
+ * that instant failed with `SocketError: other side closed` — in whatever
1367
+ * unrelated test happened to be talking through the ingress.
1368
+ * {@link startTlsTerminator} chooses the leaf per handshake instead, so
1369
+ * the listener is bound once and lives as long as the project does.
1369
1370
  */
1370
- const DRAINING_INGRESS = new WeakSet();
1371
- /** How long a drained listener may keep serving in-flight work before its
1372
- * remaining connections are force-closed. Long enough for a slow proxied
1373
- * response to finish, short enough to bound the 1.3.14 UAF window. */
1374
- const REBIND_DRAIN_GRACE_MS = 15_000;
1375
- /** Stamp `Connection: close` on a response served by a draining listener so
1376
- * the kept-alive connection retires instead of lingering as a UAF trigger.
1377
- * Proxied responses can carry immutable headers; rewrap when needed. */
1378
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
1379
- function withDrainClose(server, res) {
1380
- if (!DRAINING_INGRESS.has(server))
1381
- return res;
1382
- try {
1383
- res.headers.set("connection", "close");
1384
- return res;
1385
- }
1386
- catch {
1387
- const headers = new Headers(res.headers);
1388
- headers.set("connection", "close");
1389
- return new Response(res.body, {
1390
- status: res.status,
1391
- statusText: res.statusText,
1392
- headers,
1393
- });
1394
- }
1395
- }
1371
+ let HTTPS_TERMINATOR;
1372
+ /**
1373
+ * The in-flight first bind of :443, if one is running.
1374
+ *
1375
+ * {@link ensureHttpsIngress} is async where the swap it replaced was
1376
+ * synchronous, and that reintroduces an interleaving the old code could
1377
+ * not have: two `ctx.startService({ tls })` calls issued together by a
1378
+ * fake handler would both find :443 absent and both try to bind it, and
1379
+ * the loser gets EADDRINUSE — a provisioning failure in place of the
1380
+ * dropped connection this change exists to remove. One promise, shared.
1381
+ */
1382
+ let HTTPS_INGRESS_STARTING;
1396
1383
  /**
1397
1384
  * The live ingress tables — per-port routes and the :443 SNI cert table.
1398
1385
  *
@@ -1441,6 +1428,12 @@ function stopIngressServers() {
1441
1428
  }
1442
1429
  }
1443
1430
  INGRESS_HTTPS_SERVERS.clear();
1431
+ // Force-closed for the same reason the listeners are: the containers
1432
+ // behind these routes are going away, so a connection still riding them
1433
+ // has nothing left to reach.
1434
+ HTTPS_TERMINATOR?.close();
1435
+ HTTPS_TERMINATOR = undefined;
1436
+ HTTPS_INGRESS_STARTING = undefined;
1444
1437
  clearTables(INGRESS);
1445
1438
  }
1446
1439
  function buildIngress(project) {
@@ -1655,7 +1648,7 @@ async function startIngress() {
1655
1648
  }
1656
1649
  // ── HTTPS listener on INGRESS_HTTPS_PORT: SNI per certificated hostname.
1657
1650
  if (INGRESS.certByHost.size > 0)
1658
- rebindHttpsListener(Bun);
1651
+ await ensureHttpsIngress(Bun);
1659
1652
  // Seed the resolver's names registry: ingress hostnames (fakes, TLS
1660
1653
  // proxies, dnsName(→ingress)) → bridge gateway, plus ingress-targeted
1661
1654
  // wildcards. Service-targeted wildcards wait for the post-container pass
@@ -1671,81 +1664,85 @@ function requireBun() {
1671
1664
  }
1672
1665
  return Bun;
1673
1666
  }
1674
- /** Flatten the live SNI cert table into Bun's TLS-entry array. */
1675
- function tlsEntriesFromCerts() {
1676
- return certEntries(INGRESS);
1677
- }
1678
1667
  /**
1679
- * (Re)bind the :443 listener from the current cert table + route map.
1668
+ * Bring :443 up, once.
1680
1669
  *
1681
- * Bun fixes a server's TLS config at `Bun.serve` time — `reload()` accepts a
1682
- * new `tls` option and silently keeps serving the old certificates (measured
1683
- * on Bun 1.3.14: after reloading with a second SNI entry, the new hostname
1684
- * still gets the first one's leaf). So adding a certificate really does mean
1685
- * a second listener.
1670
+ * Two pieces: a plaintext `Bun.serve` on an ephemeral loopback port that
1671
+ * carries the HTTPS route table (and therefore all the routing, fake
1672
+ * dispatch, interception, reverse-proxying and `server.upgrade()`
1673
+ * WebSocket bridging that :80 already gets, unchanged), and the TLS
1674
+ * terminator in front of it on :443.
1686
1675
  *
1687
- * **Bind the new one before stopping the old one.** Doing it the other way
1688
- * round — which is what this used to do, with `stop(true)` — has two teeth:
1689
- * the force-close kills every established connection on :443, and the gap
1690
- * before the new listener binds refuses new ones. Neither is limited to the
1691
- * hostname being added; they hit all the unrelated traffic the listener is
1692
- * carrying. On a project that mints a certificate per provisioned database
1693
- * while deploys stream through the same port, that surfaced as the app under
1694
- * test dying with `SocketError: other side closed` or `ECONNREFUSED
1695
- * <gateway>:443` — a different test each run, and nothing pointing at
1696
- * ingress.
1676
+ * **This is idempotent, and that is the feature.** Adding a certificate
1677
+ * mutates {@link IngressTables.certByHost} and nothing else: the
1678
+ * terminator reads that table on every handshake, so the new hostname is
1679
+ * served by the next connection and no established connection is
1680
+ * disturbed. The listener this replaced had to be stopped and re-served
1681
+ * for each new certificate, and every such swap severed the idle
1682
+ * kept-alive connections it was carrying — see
1683
+ * `harness/tls-terminator.ts` for the measurements and the history.
1697
1684
  *
1698
- * Overlapping the two needs SO_REUSEPORT on both sockets ({@link
1699
- * bindIngressServer} sets it unconditionally for that reason), after which
1700
- * the old listener is drained with `stop(false)` so requests in flight
1701
- * finish. During the overlap the kernel may hand a new connection to either
1702
- * socket, which is safe: the only hostname the two disagree about is the one
1703
- * being added, and it does not resolve until the caller writes the names
1704
- * registry after this returns.
1685
+ * The route `Map` is the persistent module object, so the plaintext
1686
+ * server closes over the same table and later route additions need no
1687
+ * restart either.
1705
1688
  *
1706
- * The route Map is the persistent module object, so the new listener closes
1707
- * over the same table and later route additions need no rebind at all.
1689
+ * The loopback port is ephemeral rather than fixed so it can never
1690
+ * collide with a fake's declared port; it is bound on 127.0.0.1, which no
1691
+ * container can reach (containers arrive at the bridge gateway), so the
1692
+ * only way in is through the terminator.
1708
1693
  */
1709
1694
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
1710
- function rebindHttpsListener(Bun) {
1711
- // The *same* table object the previous listener closed over: the new
1712
- // listener must serve the routes bound since, and any bound later.
1695
+ async function ensureHttpsIngress(Bun) {
1696
+ if (INGRESS_HTTPS_SERVERS.has(INGRESS_HTTPS_PORT))
1697
+ return;
1698
+ if (!HTTPS_INGRESS_STARTING) {
1699
+ // Cleared on both outcomes: once bound, the check above short-circuits
1700
+ // every later caller, and a failed bind must be retryable rather than
1701
+ // remembered as a rejection for the life of the project.
1702
+ HTTPS_INGRESS_STARTING = startHttpsIngress(Bun).finally(() => {
1703
+ HTTPS_INGRESS_STARTING = undefined;
1704
+ });
1705
+ }
1706
+ return HTTPS_INGRESS_STARTING;
1707
+ }
1708
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1709
+ async function startHttpsIngress(Bun) {
1713
1710
  const routes = routesFor(INGRESS, INGRESS_HTTPS_PORT);
1714
- const old = INGRESS_HTTPS_SERVERS.get(INGRESS_HTTPS_PORT);
1715
- const server = bindIngressServer(Bun, INGRESS_HTTPS_PORT, routes, `https :${INGRESS_HTTPS_PORT}`, tlsEntriesFromCerts());
1716
- if (old) {
1711
+ // `proto` is passed rather than inferred: this listener speaks plain
1712
+ // HTTP, but everything reaching it arrived over TLS, so X-Forwarded-Proto
1713
+ // must say https.
1714
+ const plain = bindIngressServer(Bun, 0, routes, `https :${INGRESS_HTTPS_PORT}`, {
1715
+ proto: "https",
1716
+ hostname: "127.0.0.1",
1717
+ });
1718
+ INGRESS_HTTPS_SERVERS.set(INGRESS_HTTPS_PORT, plain);
1719
+ try {
1720
+ HTTPS_TERMINATOR = await startTlsTerminator({
1721
+ port: INGRESS_HTTPS_PORT,
1722
+ hostname: "0.0.0.0",
1723
+ upstreamPort: plain.port,
1724
+ certFor: (serverName) => {
1725
+ if (serverName === undefined)
1726
+ return INGRESS.certByHost.values().next().value;
1727
+ const name = selectCertName(INGRESS.certByHost.keys(), serverName.toLowerCase());
1728
+ return name === undefined ? undefined : INGRESS.certByHost.get(name);
1729
+ },
1730
+ });
1731
+ }
1732
+ catch (err) {
1733
+ // Leave nothing half-built: a registered plaintext server with no
1734
+ // terminator would make `planBind` believe :443 is up.
1735
+ INGRESS_HTTPS_SERVERS.delete(INGRESS_HTTPS_PORT);
1717
1736
  try {
1718
- // Graceful: stop accepting, let in-flight requests finish. The old
1719
- // server stays alive until they do, which is the point — a long
1720
- // upload through ingress must not be collateral damage of another
1721
- // service being provisioned.
1722
- old.stop(false);
1723
- // Bun 1.3.14 landmine: a request arriving later on one of the old
1724
- // server's kept-alive connections dispatches into freed state and can
1725
- // segfault the daemon (see {@link DRAINING_INGRESS}). Mark it so any
1726
- // response it still serves closes its connection, and force-close the
1727
- // stragglers once in-flight work has had a fair window to finish.
1728
- DRAINING_INGRESS.add(old);
1729
- const graceTimer = setTimeout(() => {
1730
- try {
1731
- old.stop(true);
1732
- }
1733
- catch {
1734
- /* already fully stopped */
1735
- }
1736
- }, REBIND_DRAIN_GRACE_MS);
1737
- // Don't let the grace timer keep the process alive on shutdown.
1738
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
1739
- graceTimer.unref?.();
1737
+ plain.stop(true);
1740
1738
  }
1741
- catch (err) {
1742
- // eslint-disable-next-line no-console
1743
- console.warn("[ingress] failed to drain the previous https listener:", err);
1739
+ catch {
1740
+ /* already gone */
1744
1741
  }
1742
+ throw err;
1745
1743
  }
1746
- INGRESS_HTTPS_SERVERS.set(INGRESS_HTTPS_PORT, server);
1747
1744
  // eslint-disable-next-line no-console
1748
- console.log(`[ingress] https :${INGRESS_HTTPS_PORT} for ${[...routes.keys()].join(", ")}`);
1745
+ console.log(`[ingress] https :${INGRESS_HTTPS_PORT} (tls terminator -> :${plain.port}) for ${[...routes.keys()].join(", ")}`);
1749
1746
  }
1750
1747
  /**
1751
1748
  * RFC 6125 wildcard match: `*.example.com` covers `api.example.com` but NOT
@@ -1800,10 +1797,13 @@ async function bindRuntimeTls(hostname, service, port) {
1800
1797
  INGRESS_HTTP_SERVERS.set(INGRESS_HTTP_PORT, bindIngressServer(Bun, INGRESS_HTTP_PORT, routesFor(INGRESS, INGRESS_HTTP_PORT), `port ${INGRESS_HTTP_PORT}`));
1801
1798
  }
1802
1799
  if (plan.needsCert) {
1800
+ // The terminator reads this table on every handshake, so the leaf is
1801
+ // live the moment it lands — no listener is touched, and no connection
1802
+ // already on :443 notices.
1803
1803
  INGRESS.certByHost.set(host, await generateHostCert(host, [host]));
1804
1804
  }
1805
- if (plan.needsHttpsRebind)
1806
- rebindHttpsListener(Bun);
1805
+ if (plan.needsHttpsListener)
1806
+ await ensureHttpsIngress(Bun);
1807
1807
  // Resolve the hostname to the daemon gateway (where :443/:80 listen).
1808
1808
  // A wildcard can only live in the resolver's suffix table.
1809
1809
  const gw = await bridgeGatewayIp();
@@ -1851,20 +1851,26 @@ async function unbindRuntimeTls(hostname) {
1851
1851
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
1852
1852
  function bindIngressServer(
1853
1853
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
1854
- Bun, port, byHost, listenerLabel, tlsEntries) {
1855
- // A TLS listener terminates https; everything else is plain http. Used
1856
- // to stamp X-Forwarded-Proto so upstreams that build absolute URLs or
1857
- // redirect see the scheme the client actually used, not our http hop.
1858
- const proto = tlsEntries ? "https" : "http";
1854
+ Bun, port, byHost, listenerLabel,
1855
+ // Every ingress listener speaks plain HTTP now — the one behind :443
1856
+ // sits under the TLS terminator. So the scheme a client actually used
1857
+ // can no longer be inferred from the socket and is declared instead; it
1858
+ // stamps X-Forwarded-Proto, which upstreams that build absolute URLs or
1859
+ // redirect depend on.
1860
+ serve = { proto: "http" }) {
1861
+ const proto = serve.proto;
1859
1862
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
1860
1863
  const opts = {
1861
1864
  port,
1862
- hostname: "0.0.0.0",
1863
- // SO_REUSEPORT on every ingress listener, so a replacement can be bound
1864
- // while the old one is still serving. That overlap is the only way to
1865
- // add an SNI certificate without a gap — see {@link rebindHttpsListener}
1866
- // — and it only works if *both* sockets opt in: a second plain bind
1867
- // fails with "Is port 443 in use?".
1865
+ hostname: serve.hostname ?? "0.0.0.0",
1866
+ // SO_REUSEPORT on every ingress listener. It was introduced so a
1867
+ // replacement :443 could overlap the original during a certificate
1868
+ // swap; the terminator removed that swap, and this stays for the
1869
+ // remaining case — the `/load` teardown force-closes and rebinds, and
1870
+ // a socket the kernel has not finished releasing would otherwise fail
1871
+ // the next bind with "Is port 443 in use?". Note the flip side, which
1872
+ // {@link stopIngressServers} depends on: a half-alive listener shares
1873
+ // the port silently instead of colliding loudly.
1868
1874
  reusePort: true,
1869
1875
  // Bun.serve defaults to a 10s idleTimeout, which kills any proxied
1870
1876
  // request whose upstream takes >10s to produce bytes — under parallel
@@ -1876,7 +1882,7 @@ Bun, port, byHost, listenerLabel, tlsEntries) {
1876
1882
  // short-lived, leaked-connection risk is bounded by the fork.
1877
1883
  idleTimeout: 0,
1878
1884
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
1879
- fetch: (req, server) => dispatchIngress(req, server, byHost, listenerLabel, proto).then((res) => withDrainClose(server, res)),
1885
+ fetch: (req, server) => dispatchIngress(req, server, byHost, listenerLabel, proto),
1880
1886
  websocket: {
1881
1887
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
1882
1888
  async open(ws) {
@@ -1953,8 +1959,6 @@ Bun, port, byHost, listenerLabel, tlsEntries) {
1953
1959
  },
1954
1960
  },
1955
1961
  };
1956
- if (tlsEntries)
1957
- opts.tls = tlsEntries;
1958
1962
  return Bun.serve(opts);
1959
1963
  }
1960
1964
  /**
@@ -2018,6 +2022,28 @@ server, byHost, listenerLabel, proto) {
2018
2022
  : await runChain(chain, req, upstream, recordInterceptedRequest);
2019
2023
  return augmentCorsResponse(req, res);
2020
2024
  }
2025
+ /**
2026
+ * The address to report as the client's in `X-Forwarded-For`.
2027
+ *
2028
+ * On :80 that is simply the socket's peer. On :443 the peer is the TLS
2029
+ * terminator on loopback, so the real address has to be recovered from it:
2030
+ * the terminator keys `source port -> client address` for the life of each
2031
+ * upstream connection, and the source port is what `requestIP` reports
2032
+ * here. Gated on `proto === "https"` so a genuinely loopback caller on :80
2033
+ * can never pick up an unrelated terminator connection's port.
2034
+ *
2035
+ * Falling back to the socket's own address means the worst case is the
2036
+ * pre-terminator answer for a plain HTTP hop, never a wrong tenant.
2037
+ */
2038
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
2039
+ function ingressClientIp(server, req, proto) {
2040
+ const peer = server.requestIP?.(req);
2041
+ if (!peer?.address)
2042
+ return undefined;
2043
+ if (proto !== "https")
2044
+ return peer.address;
2045
+ return HTTPS_TERMINATOR?.clientIpFor(peer.port) ?? peer.address;
2046
+ }
2021
2047
  /**
2022
2048
  * Reverse-proxy a request to `http://<service>:<port>` on
2023
2049
  * `spectest-net`. Handles plain HTTP/1.1 + 2 and WebSocket upgrades:
@@ -2067,7 +2093,7 @@ server, service, port, listenerLabel, proto) {
2067
2093
  // Standard reverse-proxy provenance headers: the upstream sees the
2068
2094
  // public scheme/host it was reached through and the client's address,
2069
2095
  // even though we rewrite Host below to the service-net name.
2070
- const clientIp = server.requestIP?.(req)?.address;
2096
+ const clientIp = ingressClientIp(server, req, proto);
2071
2097
  const priorXff = req.headers.get("x-forwarded-for");
2072
2098
  const xff = clientIp ? (priorXff ? `${priorXff}, ${clientIp}` : clientIp) : priorXff;
2073
2099
  if (xff)
@@ -48,6 +48,23 @@ export declare function wildcardCoversHost(pattern: string, hostname: string): b
48
48
  * `HTTPS_CERT_BY_HOST.keys()`.
49
49
  */
50
50
  export declare function certCovers(serverNames: Iterable<string>, hostname: string): boolean;
51
+ /**
52
+ * Pick the certificate server name that should serve `hostname`'s TLS
53
+ * handshake: the exact entry first, then the longest matching wildcard.
54
+ *
55
+ * This is the same precedence {@link matchRoute} applies to routes and the
56
+ * resolver applies to DNS — but over the *certificate* rule, so a wildcard
57
+ * covers exactly one label ({@link wildcardCoversHost}) rather than any
58
+ * depth.
59
+ *
60
+ * It exists because the SNI lookup is now ours to make. Until the :443
61
+ * listener stopped being rebuilt for every new certificate, Bun performed
62
+ * this match internally from the `tls` array it was served with; the
63
+ * terminator picks a certificate per handshake instead, so the rule has to
64
+ * be written down — and, being written down, it is testable, which Bun's
65
+ * internal version never was.
66
+ */
67
+ export declare function selectCertName(serverNames: Iterable<string>, hostname: string): string | undefined;
51
68
  /**
52
69
  * Pick the route for `host`: exact match first, then the longest matching
53
70
  * wildcard suffix.
@@ -65,6 +65,39 @@ export function certCovers(serverNames, hostname) {
65
65
  return true;
66
66
  return names.some((n) => isWildcard(n) && wildcardCoversHost(n, hostname));
67
67
  }
68
+ /**
69
+ * Pick the certificate server name that should serve `hostname`'s TLS
70
+ * handshake: the exact entry first, then the longest matching wildcard.
71
+ *
72
+ * This is the same precedence {@link matchRoute} applies to routes and the
73
+ * resolver applies to DNS — but over the *certificate* rule, so a wildcard
74
+ * covers exactly one label ({@link wildcardCoversHost}) rather than any
75
+ * depth.
76
+ *
77
+ * It exists because the SNI lookup is now ours to make. Until the :443
78
+ * listener stopped being rebuilt for every new certificate, Bun performed
79
+ * this match internally from the `tls` array it was served with; the
80
+ * terminator picks a certificate per handshake instead, so the rule has to
81
+ * be written down — and, being written down, it is testable, which Bun's
82
+ * internal version never was.
83
+ */
84
+ export function selectCertName(serverNames, hostname) {
85
+ const names = [...serverNames];
86
+ if (names.includes(hostname))
87
+ return hostname;
88
+ let best;
89
+ let bestLen = -1;
90
+ for (const name of names) {
91
+ if (!isWildcard(name) || !wildcardCoversHost(name, hostname))
92
+ continue;
93
+ const len = wildcardSuffix(name).length;
94
+ if (len > bestLen) {
95
+ best = name;
96
+ bestLen = len;
97
+ }
98
+ }
99
+ return best;
100
+ }
68
101
  /**
69
102
  * Pick the route for `host`: exact match first, then the longest matching
70
103
  * wildcard suffix.
@@ -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
  * One hostname's upstream: a fake handled in-process, or a container.
@@ -89,18 +90,21 @@ export declare function routesFor<F>(tables: IngressTables<F>, port: number): Ma
89
90
  export interface BindPlan {
90
91
  /** No existing cert covers the hostname, so a leaf must be minted. */
91
92
  needsCert: boolean;
92
- /** The :443 listener must be stopped and re-served. */
93
- needsHttpsRebind: boolean;
93
+ /** The :443 listener does not exist yet and must be started. */
94
+ needsHttpsListener: boolean;
94
95
  }
95
96
  /**
96
97
  * Decide what binding `hostname` for TLS costs.
97
98
  *
98
- * A rebind is needed when a new certificate is going into the SNI table,
99
- * or when :443 isn't listening yet. It is deliberately *not* needed for a
100
- * hostname an existing wildcard already covers — the common case when a
101
- * component claims a whole domain up front and services appear under it
102
- * later, and the reason a fake can mint endpoints in a loop without
103
- * restarting the listener once per iteration.
99
+ * Minting is still earned — a hostname an existing exact or wildcard cert
100
+ * already covers needs nothing but a route, which is why a component can
101
+ * claim a whole domain up front and have services appear under it for
102
+ * free.
103
+ *
104
+ * Starting the listener, on the other hand, is now a once-per-process
105
+ * event: it is needed when :443 is not up yet, and never again. A new
106
+ * certificate does **not** imply it. That is the whole point of the
107
+ * terminator — see the module header.
104
108
  */
105
109
  export declare function planBind<F>(tables: IngressTables<F>, hostname: string, opts: {
106
110
  httpsListening: boolean;
@@ -117,11 +121,18 @@ export declare function bindRoute<F>(tables: IngressTables<F>, hostname: string,
117
121
  * Drop `hostname`'s routes, so it 404s.
118
122
  *
119
123
  * The certificate is deliberately left in the SNI table: it is harmless
120
- * without a route, and removing it would force an otherwise unnecessary
121
- * :443 rebind at exactly the moment a service is going away.
124
+ * without a route, and a leaf nobody asks for costs one unused `Map`
125
+ * entry. (Before the terminator, removing it would also have forced a
126
+ * :443 rebind at exactly the moment a service was going away.)
122
127
  */
123
128
  export declare function unbindRoute<F>(tables: IngressTables<F>, hostname: string): void;
124
- /** The SNI entries for `Bun.serve`'s TLS config. */
129
+ /**
130
+ * The SNI table flattened to entries.
131
+ *
132
+ * This was `Bun.serve`'s `tls` array. The terminator selects per handshake
133
+ * instead ({@link import("./hostmatch").selectCertName}), so this survives
134
+ * as the readable projection of the table — for diagnostics and tests.
135
+ */
125
136
  export declare function certEntries<F>(tables: IngressTables<F>): Array<{
126
137
  cert: string;
127
138
  key: string;
@@ -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
  import { certCovers, isWildcard, wildcardSuffix } from "./hostmatch";
44
45
  /** Fixed HTTPS port shared by every TLS route (fakes + service `tls`). */
@@ -66,16 +67,19 @@ export function routesFor(tables, port) {
66
67
  /**
67
68
  * Decide what binding `hostname` for TLS costs.
68
69
  *
69
- * A rebind is needed when a new certificate is going into the SNI table,
70
- * or when :443 isn't listening yet. It is deliberately *not* needed for a
71
- * hostname an existing wildcard already covers — the common case when a
72
- * component claims a whole domain up front and services appear under it
73
- * later, and the reason a fake can mint endpoints in a loop without
74
- * restarting the listener once per iteration.
70
+ * Minting is still earned — a hostname an existing exact or wildcard cert
71
+ * already covers needs nothing but a route, which is why a component can
72
+ * claim a whole domain up front and have services appear under it for
73
+ * free.
74
+ *
75
+ * Starting the listener, on the other hand, is now a once-per-process
76
+ * event: it is needed when :443 is not up yet, and never again. A new
77
+ * certificate does **not** imply it. That is the whole point of the
78
+ * terminator — see the module header.
75
79
  */
76
80
  export function planBind(tables, hostname, opts) {
77
81
  const needsCert = !certCovers(tables.certByHost.keys(), hostname);
78
- return { needsCert, needsHttpsRebind: needsCert || !opts.httpsListening };
82
+ return { needsCert, needsHttpsListener: !opts.httpsListening };
79
83
  }
80
84
  /**
81
85
  * Point `hostname` at `route` on both :80 and :443.
@@ -92,14 +96,21 @@ export function bindRoute(tables, hostname, route) {
92
96
  * Drop `hostname`'s routes, so it 404s.
93
97
  *
94
98
  * The certificate is deliberately left in the SNI table: it is harmless
95
- * without a route, and removing it would force an otherwise unnecessary
96
- * :443 rebind at exactly the moment a service is going away.
99
+ * without a route, and a leaf nobody asks for costs one unused `Map`
100
+ * entry. (Before the terminator, removing it would also have forced a
101
+ * :443 rebind at exactly the moment a service was going away.)
97
102
  */
98
103
  export function unbindRoute(tables, hostname) {
99
104
  tables.routesByPort.get(INGRESS_HTTP_PORT)?.delete(hostname);
100
105
  tables.routesByPort.get(INGRESS_HTTPS_PORT)?.delete(hostname);
101
106
  }
102
- /** The SNI entries for `Bun.serve`'s TLS config. */
107
+ /**
108
+ * The SNI table flattened to entries.
109
+ *
110
+ * This was `Bun.serve`'s `tls` array. The terminator selects per handshake
111
+ * instead ({@link import("./hostmatch").selectCertName}), so this survives
112
+ * as the readable projection of the table — for diagnostics and tests.
113
+ */
103
114
  export function certEntries(tables) {
104
115
  return [...tables.certByHost].map(([serverName, leaf]) => ({
105
116
  cert: leaf.cert,
@@ -0,0 +1,32 @@
1
+ import type { Leaf } from "./ingress-table";
2
+ export interface TlsTerminatorOptions {
3
+ /** Public port to accept TLS on (443 in the harness). */
4
+ port: number;
5
+ /** Interface to accept on. */
6
+ hostname: string;
7
+ /** Loopback port of the plaintext ingress server to pipe into. */
8
+ upstreamPort: number;
9
+ /**
10
+ * The leaf to serve for an SNI server name, read live on every
11
+ * handshake — this is what lets a certificate be added with no restart.
12
+ * `undefined` server name means the client sent no SNI.
13
+ */
14
+ certFor(serverName: string | undefined): Leaf | undefined;
15
+ /** Non-fatal diagnostics (a handshake a client aborted, a pipe error). */
16
+ onWarning?(message: string, err: unknown): void;
17
+ }
18
+ export interface TlsTerminator {
19
+ /** The port actually bound. */
20
+ readonly port: number;
21
+ /**
22
+ * The real client address behind an upstream connection, keyed by the
23
+ * source port the plaintext server sees. `undefined` for a port this
24
+ * terminator did not open.
25
+ */
26
+ clientIpFor(upstreamPort: number | undefined): string | undefined;
27
+ /** Open TLS connections right now (diagnostics and tests). */
28
+ connectionCount(): number;
29
+ /** Stop accepting and force-close every connection. */
30
+ close(): void;
31
+ }
32
+ export declare function startTlsTerminator(opts: TlsTerminatorOptions): Promise<TlsTerminator>;