@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 +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/package.json +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
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The :443 TLS terminator — one listener, for the life of the harness.
|
|
3
|
+
*
|
|
4
|
+
* ## Why this exists
|
|
5
|
+
*
|
|
6
|
+
* Ingress certificates are minted *while tests run*: a fake that
|
|
7
|
+
* provisions a database hands the app a CA-trusted `https://<new-host>/`
|
|
8
|
+
* endpoint, and that hostname did not exist a second earlier. Bun fixes a
|
|
9
|
+
* server's TLS configuration at `Bun.serve` time — `reload({ tls })` is
|
|
10
|
+
* accepted and then ignored (re-measured on Bun 1.4.0: the added
|
|
11
|
+
* `serverName` still gets the first entry's leaf) — so for as long as
|
|
12
|
+
* :443 was a `Bun.serve`, every new certificate meant swapping the
|
|
13
|
+
* listener.
|
|
14
|
+
*
|
|
15
|
+
* **No listener swap can be made safe.** Binding the replacement first
|
|
16
|
+
* under SO_REUSEPORT and draining the original with `stop(false)` saves a
|
|
17
|
+
* request that is already *in flight*, and that is all it saves: measured
|
|
18
|
+
* on Bun 1.4.0, `stop(false)` sends FIN to an **idle kept-alive
|
|
19
|
+
* connection within 1–2 ms**. A client that writes a request into that
|
|
20
|
+
* socket in the same instant gets `SocketError: other side closed`
|
|
21
|
+
* (`UND_ERR_SOCKET` under undici), and Node's `fetch` does not retry it.
|
|
22
|
+
* That is a flake in whatever unrelated test happened to be talking
|
|
23
|
+
* through the ingress — never in the one that provisioned the service —
|
|
24
|
+
* and it is why the app under test would die far from any ingress code.
|
|
25
|
+
*
|
|
26
|
+
* So the listener must stop being swapped, which means the certificate
|
|
27
|
+
* must be chosen **per handshake** rather than baked into the socket.
|
|
28
|
+
* That is what every real server does, and under Bun it is reachable
|
|
29
|
+
* through `node:tls`'s `SNICallback` (verified working on Bun 1.4.0:
|
|
30
|
+
* adding an entry to the table is served on the next handshake, with no
|
|
31
|
+
* restart and no effect on any established connection).
|
|
32
|
+
*
|
|
33
|
+
* ## The shape, and why it is a byte pipe
|
|
34
|
+
*
|
|
35
|
+
* The terminator does TLS and *nothing else*: it accepts on :443, picks a
|
|
36
|
+
* leaf by SNI, and pipes the decrypted stream to a plaintext `Bun.serve`
|
|
37
|
+
* bound on loopback. Everything downstream — routing, fakes,
|
|
38
|
+
* interceptors, reverse-proxying, and `server.upgrade()` WebSocket
|
|
39
|
+
* bridging — is the same `Bun.serve` code that already serves :80, run
|
|
40
|
+
* unchanged. Terminating HTTP here instead (`node:https`, `(req, res)`)
|
|
41
|
+
* would have meant reimplementing the WebSocket bridge against a
|
|
42
|
+
* different API, which is exactly the kind of trade this change exists to
|
|
43
|
+
* avoid: one flake removed, another introduced somewhere subtler.
|
|
44
|
+
*
|
|
45
|
+
* Measured cost of the extra hop on this host (Bun 1.4.0, undici client,
|
|
46
|
+
* 50 MB bodies): ~11 % fewer small requests per second, download
|
|
47
|
+
* throughput unchanged, upload ~500 MB/s against ~800 MB/s direct. Ingress
|
|
48
|
+
* traffic in a test VM is nowhere near either bound.
|
|
49
|
+
*
|
|
50
|
+
* ## Recovering the client's address
|
|
51
|
+
*
|
|
52
|
+
* The plaintext server's peer is the terminator, so `server.requestIP()`
|
|
53
|
+
* reports loopback and `x-forwarded-for` would lose the caller. The
|
|
54
|
+
* upstream socket's *source port* is unique among live connections, so the
|
|
55
|
+
* terminator records `source port → real client address` for the life of
|
|
56
|
+
* each connection and {@link TlsTerminator.clientIpFor} hands it back.
|
|
57
|
+
* Verified end to end: a client connecting from 127.0.0.2 is reported as
|
|
58
|
+
* 127.0.0.2 by the plaintext server behind the pipe.
|
|
59
|
+
*/
|
|
60
|
+
import net from "node:net";
|
|
61
|
+
import tls from "node:tls";
|
|
62
|
+
/**
|
|
63
|
+
* Start the terminator. Resolves once :443 is accepting, so a caller can
|
|
64
|
+
* register the hostname in DNS immediately afterwards and know the port is
|
|
65
|
+
* live.
|
|
66
|
+
*/
|
|
67
|
+
/**
|
|
68
|
+
* Is this the ordinary end of a connection rather than a fault? A peer
|
|
69
|
+
* that goes away mid-stream, or a half-finished handshake, is routine on a
|
|
70
|
+
* listener that fronts browsers.
|
|
71
|
+
*/
|
|
72
|
+
function isRoutineDisconnect(err) {
|
|
73
|
+
const code = err?.code;
|
|
74
|
+
return (code === "ECONNRESET" ||
|
|
75
|
+
code === "EPIPE" ||
|
|
76
|
+
code === "ERR_STREAM_PREMATURE_CLOSE" ||
|
|
77
|
+
code === "ECONNABORTED");
|
|
78
|
+
}
|
|
79
|
+
export function startTlsTerminator(opts) {
|
|
80
|
+
// Parsing a PEM into a SecureContext is the expensive half of choosing a
|
|
81
|
+
// certificate, and the table is stable — cache by the certificate's own
|
|
82
|
+
// bytes so a handshake is a Map lookup. Keyed on the PEM rather than the
|
|
83
|
+
// hostname so a re-minted leaf for the same name cannot be served stale.
|
|
84
|
+
const contexts = new Map();
|
|
85
|
+
const contextFor = (leaf) => {
|
|
86
|
+
let ctx = contexts.get(leaf.cert);
|
|
87
|
+
if (!ctx) {
|
|
88
|
+
ctx = tls.createSecureContext({ cert: leaf.cert, key: leaf.key });
|
|
89
|
+
contexts.set(leaf.cert, ctx);
|
|
90
|
+
}
|
|
91
|
+
return ctx;
|
|
92
|
+
};
|
|
93
|
+
const warn = (message, err) => {
|
|
94
|
+
// A client hanging up is not a fault, and this listener carries every
|
|
95
|
+
// browser in the VM: Chromium opens speculative connections and drops
|
|
96
|
+
// them, a fetch is abandoned when its test ends, and each of those
|
|
97
|
+
// arrives here as a reset. Logging them would bury the boot log a user
|
|
98
|
+
// reads with `spectest env logs` in noise that means nothing.
|
|
99
|
+
if (isRoutineDisconnect(err))
|
|
100
|
+
return;
|
|
101
|
+
if (opts.onWarning)
|
|
102
|
+
opts.onWarning(message, err);
|
|
103
|
+
// eslint-disable-next-line no-console
|
|
104
|
+
else
|
|
105
|
+
console.warn(`[ingress] ${message}:`, err);
|
|
106
|
+
};
|
|
107
|
+
/** upstream source port → the address the TLS client connected from. */
|
|
108
|
+
const clientIpByUpstreamPort = new Map();
|
|
109
|
+
const live = new Set();
|
|
110
|
+
// A default leaf is only ever used for a client that sends no SNI at
|
|
111
|
+
// all; `SNICallback` decides every other handshake. This mirrors what
|
|
112
|
+
// Bun did with the first entry of its `tls` array.
|
|
113
|
+
const fallback = opts.certFor(undefined);
|
|
114
|
+
const server = tls.createServer({
|
|
115
|
+
...(fallback ? { cert: fallback.cert, key: fallback.key } : {}),
|
|
116
|
+
SNICallback: (serverName, cb) => {
|
|
117
|
+
const leaf = opts.certFor(serverName) ?? fallback;
|
|
118
|
+
// No certificate at all is a handshake we cannot complete. Answer
|
|
119
|
+
// with the fallback context rather than an error so the client
|
|
120
|
+
// gets a certificate-mismatch alert it can report, which is the
|
|
121
|
+
// same thing it saw when Bun served the first entry.
|
|
122
|
+
cb(null, leaf ? contextFor(leaf) : undefined);
|
|
123
|
+
},
|
|
124
|
+
}, (client) => {
|
|
125
|
+
// No idle timeout anywhere on the ingress path: it fronts app
|
|
126
|
+
// endpoints that legitimately take minutes (a deploy), and a
|
|
127
|
+
// timeout here would surface as a truncated response with no
|
|
128
|
+
// explanation.
|
|
129
|
+
client.setTimeout(0);
|
|
130
|
+
live.add(client);
|
|
131
|
+
// Nothing reads from `client` until `pipe` below, so a socket that
|
|
132
|
+
// is written to before the upstream is connected stays paused and
|
|
133
|
+
// its bytes are buffered rather than dropped — which is the normal
|
|
134
|
+
// case, since a client sends its first request the instant the
|
|
135
|
+
// handshake completes.
|
|
136
|
+
const upstream = net.connect(opts.upstreamPort, "127.0.0.1");
|
|
137
|
+
upstream.setTimeout(0);
|
|
138
|
+
upstream.setNoDelay(true);
|
|
139
|
+
client.setNoDelay(true);
|
|
140
|
+
let recordedPort;
|
|
141
|
+
upstream.on("connect", () => {
|
|
142
|
+
recordedPort = upstream.localPort;
|
|
143
|
+
if (recordedPort !== undefined && client.remoteAddress) {
|
|
144
|
+
clientIpByUpstreamPort.set(recordedPort, client.remoteAddress);
|
|
145
|
+
}
|
|
146
|
+
// `pipe` carries backpressure and the half-close in both
|
|
147
|
+
// directions, which is what a WebSocket bridge and a streamed
|
|
148
|
+
// upload each need.
|
|
149
|
+
client.pipe(upstream);
|
|
150
|
+
upstream.pipe(client);
|
|
151
|
+
});
|
|
152
|
+
const teardown = () => {
|
|
153
|
+
if (recordedPort !== undefined)
|
|
154
|
+
clientIpByUpstreamPort.delete(recordedPort);
|
|
155
|
+
live.delete(client);
|
|
156
|
+
client.destroy();
|
|
157
|
+
upstream.destroy();
|
|
158
|
+
};
|
|
159
|
+
client.on("error", (err) => {
|
|
160
|
+
warn("tls client connection failed", err);
|
|
161
|
+
teardown();
|
|
162
|
+
});
|
|
163
|
+
upstream.on("error", (err) => {
|
|
164
|
+
warn(`ingress upstream :${opts.upstreamPort} failed`, err);
|
|
165
|
+
teardown();
|
|
166
|
+
});
|
|
167
|
+
client.on("close", teardown);
|
|
168
|
+
upstream.on("close", teardown);
|
|
169
|
+
});
|
|
170
|
+
// A client that aborts mid-handshake, or offers a protocol version we
|
|
171
|
+
// do not speak, emits this. It must be handled: an unhandled 'error' on
|
|
172
|
+
// a net server is thrown, and taking the daemon down over one bad
|
|
173
|
+
// handshake would be a far worse failure than the one being fixed.
|
|
174
|
+
server.on("tlsClientError", (err) => warn("tls handshake failed", err));
|
|
175
|
+
server.on("error", (err) => warn(`tls listener :${opts.port} failed`, err));
|
|
176
|
+
return new Promise((resolve, reject) => {
|
|
177
|
+
server.once("error", reject);
|
|
178
|
+
server.listen(opts.port, opts.hostname, () => {
|
|
179
|
+
server.removeListener("error", reject);
|
|
180
|
+
resolve({
|
|
181
|
+
port: opts.port,
|
|
182
|
+
clientIpFor: (upstreamPort) => upstreamPort === undefined ? undefined : clientIpByUpstreamPort.get(upstreamPort),
|
|
183
|
+
connectionCount: () => live.size,
|
|
184
|
+
close: () => {
|
|
185
|
+
try {
|
|
186
|
+
server.close();
|
|
187
|
+
}
|
|
188
|
+
catch (err) {
|
|
189
|
+
warn("closing the tls listener failed", err);
|
|
190
|
+
}
|
|
191
|
+
for (const sock of [...live])
|
|
192
|
+
sock.destroy();
|
|
193
|
+
live.clear();
|
|
194
|
+
clientIpByUpstreamPort.clear();
|
|
195
|
+
},
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
}
|
package/package.json
CHANGED
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.
|