@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/dist/browser.js
CHANGED
|
@@ -1406,7 +1406,7 @@ function buildBackend(holder, recorder, buildOpts) {
|
|
|
1406
1406
|
}, { wrap: true });
|
|
1407
1407
|
},
|
|
1408
1408
|
async waitForFunction(description, fn, arg, options = {}) {
|
|
1409
|
-
const timeoutMs = options.timeout ??
|
|
1409
|
+
const timeoutMs = options.timeout ?? DEFAULT_ACTION_TIMEOUT_MS;
|
|
1410
1410
|
const intervalMs = options.polling ?? 100;
|
|
1411
1411
|
const src = typeof fn === "string" ? fn : fn.toString();
|
|
1412
1412
|
const t = truncateUtf8(src);
|
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,
|
|
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
|
-
*
|
|
1360
|
-
*
|
|
1361
|
-
*
|
|
1362
|
-
*
|
|
1363
|
-
*
|
|
1364
|
-
*
|
|
1365
|
-
*
|
|
1366
|
-
*
|
|
1367
|
-
*
|
|
1368
|
-
*
|
|
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
|
-
|
|
1371
|
-
/**
|
|
1372
|
-
*
|
|
1373
|
-
*
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
*
|
|
1377
|
-
*
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
1668
|
+
* Bring :443 up, once.
|
|
1680
1669
|
*
|
|
1681
|
-
*
|
|
1682
|
-
*
|
|
1683
|
-
*
|
|
1684
|
-
*
|
|
1685
|
-
*
|
|
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
|
-
* **
|
|
1688
|
-
*
|
|
1689
|
-
*
|
|
1690
|
-
*
|
|
1691
|
-
*
|
|
1692
|
-
*
|
|
1693
|
-
*
|
|
1694
|
-
*
|
|
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
|
-
*
|
|
1699
|
-
*
|
|
1700
|
-
*
|
|
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
|
|
1707
|
-
*
|
|
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
|
|
1711
|
-
|
|
1712
|
-
|
|
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
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
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
|
-
|
|
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
|
|
1742
|
-
|
|
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.
|
|
1806
|
-
|
|
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,
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
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
|
|
1864
|
-
//
|
|
1865
|
-
//
|
|
1866
|
-
//
|
|
1867
|
-
//
|
|
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)
|
|
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
|
|
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
|
-
* **
|
|
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
|
* 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
|
|
93
|
-
|
|
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
|
-
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
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
|
|
121
|
-
*
|
|
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
|
-
/**
|
|
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
|
-
* **
|
|
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
|
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
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
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,
|
|
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
|
|
96
|
-
*
|
|
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
|
-
/**
|
|
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>;
|