@specific.dev/spectest 0.69.0 → 0.71.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/components/k3s.d.ts +3 -0
- package/dist/components/k3s.js +156 -60
- package/dist/daemon.js +366 -185
- 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/harness/volume-paths.d.ts +13 -17
- package/dist/harness/volume-paths.js +22 -24
- package/dist/index.d.ts +0 -9
- package/package.json +1 -1
- package/src/components/k3s.ts +160 -66
- package/src/daemon.ts +360 -191
- 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/harness/volume-paths.test.ts +0 -33
- package/src/harness/volume-paths.ts +22 -26
- package/src/index.ts +0 -9
package/src/harness/hostmatch.ts
CHANGED
|
@@ -68,6 +68,41 @@ export function certCovers(serverNames: Iterable<string>, hostname: string): boo
|
|
|
68
68
|
return names.some((n) => isWildcard(n) && wildcardCoversHost(n, hostname));
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
+
/**
|
|
72
|
+
* Pick the certificate server name that should serve `hostname`'s TLS
|
|
73
|
+
* handshake: the exact entry first, then the longest matching wildcard.
|
|
74
|
+
*
|
|
75
|
+
* This is the same precedence {@link matchRoute} applies to routes and the
|
|
76
|
+
* resolver applies to DNS — but over the *certificate* rule, so a wildcard
|
|
77
|
+
* covers exactly one label ({@link wildcardCoversHost}) rather than any
|
|
78
|
+
* depth.
|
|
79
|
+
*
|
|
80
|
+
* It exists because the SNI lookup is now ours to make. Until the :443
|
|
81
|
+
* listener stopped being rebuilt for every new certificate, Bun performed
|
|
82
|
+
* this match internally from the `tls` array it was served with; the
|
|
83
|
+
* terminator picks a certificate per handshake instead, so the rule has to
|
|
84
|
+
* be written down — and, being written down, it is testable, which Bun's
|
|
85
|
+
* internal version never was.
|
|
86
|
+
*/
|
|
87
|
+
export function selectCertName(
|
|
88
|
+
serverNames: Iterable<string>,
|
|
89
|
+
hostname: string,
|
|
90
|
+
): string | undefined {
|
|
91
|
+
const names = [...serverNames];
|
|
92
|
+
if (names.includes(hostname)) return hostname;
|
|
93
|
+
let best: string | undefined;
|
|
94
|
+
let bestLen = -1;
|
|
95
|
+
for (const name of names) {
|
|
96
|
+
if (!isWildcard(name) || !wildcardCoversHost(name, hostname)) continue;
|
|
97
|
+
const len = wildcardSuffix(name).length;
|
|
98
|
+
if (len > bestLen) {
|
|
99
|
+
best = name;
|
|
100
|
+
bestLen = len;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return best;
|
|
104
|
+
}
|
|
105
|
+
|
|
71
106
|
/**
|
|
72
107
|
* Pick the route for `host`: exact match first, then the longest matching
|
|
73
108
|
* wildcard suffix.
|
|
@@ -1,17 +1,25 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
* listener.
|
|
2
|
+
* What a listener swap does and does not cost.
|
|
4
3
|
*
|
|
5
|
-
* Adding an SNI certificate
|
|
6
|
-
* a new `tls` option and goes on
|
|
7
|
-
*
|
|
8
|
-
*
|
|
4
|
+
* **:443 no longer swaps.** Adding an SNI certificate used to need a
|
|
5
|
+
* second `Bun.serve` (`reload()` accepts a new `tls` option and goes on
|
|
6
|
+
* serving the old certificates — still true on Bun 1.4.0), and this file
|
|
7
|
+
* pinned down the *order*: bind the replacement first and drain the old
|
|
8
|
+
* one, rather than stopping first and leaving a gap. That ordering was
|
|
9
|
+
* necessary and insufficient — it saves a request already in flight and
|
|
10
|
+
* severs every idle kept-alive connection, which is the failure that kept
|
|
11
|
+
* reaching production. `tls-terminator.ts` chooses the certificate per
|
|
12
|
+
* handshake now, so the certificate path swaps nothing; `tls-terminator.
|
|
13
|
+
* test.ts` covers the invariant that replaced this one.
|
|
9
14
|
*
|
|
10
|
-
* These
|
|
15
|
+
* These stay because the swap itself stays, on the remaining path: the
|
|
16
|
+
* `/load` teardown force-closes every listener and rebinds the next
|
|
17
|
+
* project's. That is what `reusePort` is still for, and the two properties
|
|
18
|
+
* below — a replacement can bind alongside the original, and the port is
|
|
19
|
+
* never refused across the swap — are what make it safe.
|
|
20
|
+
*
|
|
21
|
+
* They drive real sockets rather than a model of them, because the bug was
|
|
11
22
|
* in socket behaviour: a model would have agreed with the broken code.
|
|
12
|
-
* They use plain HTTP — the TLS part is Bun's SNI table, measured
|
|
13
|
-
* separately; what is asserted here is that a listener swap costs no
|
|
14
|
-
* connection, which is the part that was wrong.
|
|
15
23
|
*/
|
|
16
24
|
import { afterEach, describe, expect, test } from "bun:test";
|
|
17
25
|
|
|
@@ -78,21 +78,36 @@ describe("route tables", () => {
|
|
|
78
78
|
});
|
|
79
79
|
|
|
80
80
|
describe("planBind", () => {
|
|
81
|
-
test("a brand-new hostname needs a cert
|
|
81
|
+
test("a brand-new hostname needs a cert", () => {
|
|
82
82
|
const t = emptyTables();
|
|
83
83
|
expect(planBind(t, "new.test", { httpsListening: true })).toEqual({
|
|
84
84
|
needsCert: true,
|
|
85
|
-
|
|
85
|
+
needsHttpsListener: false,
|
|
86
86
|
});
|
|
87
87
|
});
|
|
88
88
|
|
|
89
|
-
|
|
89
|
+
/**
|
|
90
|
+
* The rule the terminator bought, and the regression this file is the
|
|
91
|
+
* cheap half of: minting a certificate must NOT imply touching the
|
|
92
|
+
* listener. It used to (`needsCert || !httpsListening`), and every such
|
|
93
|
+
* restart severed the idle kept-alive connections :443 was carrying —
|
|
94
|
+
* failing an unrelated test with `SocketError: other side closed`.
|
|
95
|
+
*/
|
|
96
|
+
test("a new certificate never asks for the listener to be restarted", () => {
|
|
97
|
+
const t = emptyTables();
|
|
98
|
+
t.certByHost.set("app.test", leaf("app"));
|
|
99
|
+
expect(planBind(t, "brand-new.test", { httpsListening: true })).toEqual({
|
|
100
|
+
needsCert: true,
|
|
101
|
+
needsHttpsListener: false,
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("the listener is asked for when nothing is serving :443 yet", () => {
|
|
90
106
|
const t = emptyTables();
|
|
91
107
|
t.certByHost.set("app.test", leaf("app"));
|
|
92
|
-
// Nothing is listening yet, so :443 has to be served regardless.
|
|
93
108
|
expect(planBind(t, "app.test", { httpsListening: false })).toEqual({
|
|
94
109
|
needsCert: false,
|
|
95
|
-
|
|
110
|
+
needsHttpsListener: true,
|
|
96
111
|
});
|
|
97
112
|
});
|
|
98
113
|
|
|
@@ -101,19 +116,19 @@ describe("planBind", () => {
|
|
|
101
116
|
t.certByHost.set("app.test", leaf("app"));
|
|
102
117
|
expect(planBind(t, "app.test", { httpsListening: true })).toEqual({
|
|
103
118
|
needsCert: false,
|
|
104
|
-
|
|
119
|
+
needsHttpsListener: false,
|
|
105
120
|
});
|
|
106
121
|
});
|
|
107
122
|
|
|
108
123
|
/** The case that makes runtime provisioning cheap: a component claims
|
|
109
124
|
* `*.db.test` up front, then a fake mints endpoints under it in a loop.
|
|
110
|
-
*
|
|
125
|
+
* Each one is then a route entry and not even a keygen. */
|
|
111
126
|
test("a hostname under an existing wildcard cert needs neither", () => {
|
|
112
127
|
const t = emptyTables();
|
|
113
128
|
t.certByHost.set("*.db.test", leaf("wild"));
|
|
114
129
|
expect(planBind(t, "tenant-1.db.test", { httpsListening: true })).toEqual({
|
|
115
130
|
needsCert: false,
|
|
116
|
-
|
|
131
|
+
needsHttpsListener: false,
|
|
117
132
|
});
|
|
118
133
|
});
|
|
119
134
|
|
|
@@ -32,13 +32,14 @@
|
|
|
32
32
|
* routes added afterwards go nowhere. {@link routesFor} is the only way to
|
|
33
33
|
* reach a table so that this can't be done by accident.
|
|
34
34
|
*
|
|
35
|
-
* **
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
35
|
+
* **Adding a certificate no longer touches the listener.** :443 is
|
|
36
|
+
* terminated by `harness/tls-terminator.ts`, which chooses a leaf per TLS
|
|
37
|
+
* handshake, so a new certificate is a `Map` entry and nothing more. It
|
|
38
|
+
* used to mean stopping and re-serving :443, and that swap severed every
|
|
39
|
+
* idle kept-alive connection the listener was carrying — an unrelated
|
|
40
|
+
* test's request, failing with `SocketError: other side closed`. The only
|
|
41
|
+
* thing {@link planBind} still has to say is whether the listener exists
|
|
42
|
+
* yet.
|
|
42
43
|
*/
|
|
43
44
|
|
|
44
45
|
import { certCovers, isWildcard, wildcardSuffix } from "./hostmatch";
|
|
@@ -102,19 +103,22 @@ export function routesFor<F>(tables: IngressTables<F>, port: number): Map<string
|
|
|
102
103
|
export interface BindPlan {
|
|
103
104
|
/** No existing cert covers the hostname, so a leaf must be minted. */
|
|
104
105
|
needsCert: boolean;
|
|
105
|
-
/** The :443 listener
|
|
106
|
-
|
|
106
|
+
/** The :443 listener does not exist yet and must be started. */
|
|
107
|
+
needsHttpsListener: boolean;
|
|
107
108
|
}
|
|
108
109
|
|
|
109
110
|
/**
|
|
110
111
|
* Decide what binding `hostname` for TLS costs.
|
|
111
112
|
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
*
|
|
113
|
+
* Minting is still earned — a hostname an existing exact or wildcard cert
|
|
114
|
+
* already covers needs nothing but a route, which is why a component can
|
|
115
|
+
* claim a whole domain up front and have services appear under it for
|
|
116
|
+
* free.
|
|
117
|
+
*
|
|
118
|
+
* Starting the listener, on the other hand, is now a once-per-process
|
|
119
|
+
* event: it is needed when :443 is not up yet, and never again. A new
|
|
120
|
+
* certificate does **not** imply it. That is the whole point of the
|
|
121
|
+
* terminator — see the module header.
|
|
118
122
|
*/
|
|
119
123
|
export function planBind<F>(
|
|
120
124
|
tables: IngressTables<F>,
|
|
@@ -122,7 +126,7 @@ export function planBind<F>(
|
|
|
122
126
|
opts: { httpsListening: boolean },
|
|
123
127
|
): BindPlan {
|
|
124
128
|
const needsCert = !certCovers(tables.certByHost.keys(), hostname);
|
|
125
|
-
return { needsCert,
|
|
129
|
+
return { needsCert, needsHttpsListener: !opts.httpsListening };
|
|
126
130
|
}
|
|
127
131
|
|
|
128
132
|
/**
|
|
@@ -141,15 +145,22 @@ export function bindRoute<F>(tables: IngressTables<F>, hostname: string, route:
|
|
|
141
145
|
* Drop `hostname`'s routes, so it 404s.
|
|
142
146
|
*
|
|
143
147
|
* The certificate is deliberately left in the SNI table: it is harmless
|
|
144
|
-
* without a route, and
|
|
145
|
-
*
|
|
148
|
+
* without a route, and a leaf nobody asks for costs one unused `Map`
|
|
149
|
+
* entry. (Before the terminator, removing it would also have forced a
|
|
150
|
+
* :443 rebind at exactly the moment a service was going away.)
|
|
146
151
|
*/
|
|
147
152
|
export function unbindRoute<F>(tables: IngressTables<F>, hostname: string): void {
|
|
148
153
|
tables.routesByPort.get(INGRESS_HTTP_PORT)?.delete(hostname);
|
|
149
154
|
tables.routesByPort.get(INGRESS_HTTPS_PORT)?.delete(hostname);
|
|
150
155
|
}
|
|
151
156
|
|
|
152
|
-
/**
|
|
157
|
+
/**
|
|
158
|
+
* The SNI table flattened to entries.
|
|
159
|
+
*
|
|
160
|
+
* This was `Bun.serve`'s `tls` array. The terminator selects per handshake
|
|
161
|
+
* instead ({@link import("./hostmatch").selectCertName}), so this survives
|
|
162
|
+
* as the readable projection of the table — for diagnostics and tests.
|
|
163
|
+
*/
|
|
153
164
|
export function certEntries<F>(
|
|
154
165
|
tables: IngressTables<F>,
|
|
155
166
|
): Array<{ cert: string; key: string; serverName: string }> {
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Adding a certificate must cost no connection.
|
|
3
|
+
*
|
|
4
|
+
* This is the file the previous shape of :443 could not have: when the
|
|
5
|
+
* listener was a `Bun.serve` whose `tls` array was fixed at serve time,
|
|
6
|
+
* every new certificate meant a swap, and the best a swap could do was
|
|
7
|
+
* save requests that were already in flight (`ingress-rebind.test.ts`).
|
|
8
|
+
* The failure that kept reaching production was the other half — an
|
|
9
|
+
* **idle kept-alive connection**, severed by `stop(false)` within 1-2 ms,
|
|
10
|
+
* surfacing in an unrelated test as `SocketError: other side closed`.
|
|
11
|
+
*
|
|
12
|
+
* So these drive real TLS sockets rather than a model of them: the whole
|
|
13
|
+
* bug was in socket behaviour, and a model would have agreed with the
|
|
14
|
+
* broken code. The client is Node's own `node:tls`/HTTP-over-socket so
|
|
15
|
+
* that keep-alive is under the test's control rather than a pool's.
|
|
16
|
+
*/
|
|
17
|
+
import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test";
|
|
18
|
+
import { spawnSync } from "node:child_process";
|
|
19
|
+
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
20
|
+
import net from "node:net";
|
|
21
|
+
import { tmpdir } from "node:os";
|
|
22
|
+
import { join } from "node:path";
|
|
23
|
+
import tls from "node:tls";
|
|
24
|
+
|
|
25
|
+
import type { Leaf } from "./ingress-table";
|
|
26
|
+
import { selectCertName } from "./hostmatch";
|
|
27
|
+
import { startTlsTerminator, type TlsTerminator } from "./tls-terminator";
|
|
28
|
+
|
|
29
|
+
// ── a throwaway CA and leaves, so the client can verify for real ──────────
|
|
30
|
+
let dir: string;
|
|
31
|
+
const leaves = new Map<string, Leaf>();
|
|
32
|
+
let caPem: string;
|
|
33
|
+
|
|
34
|
+
function openssl(args: string[], input?: string): void {
|
|
35
|
+
const r = spawnSync("openssl", args, { cwd: dir, input, encoding: "utf8" });
|
|
36
|
+
if (r.status !== 0) throw new Error(`openssl ${args.join(" ")}: ${r.stderr}`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function mintLeaf(name: string, san: string): Leaf {
|
|
40
|
+
const file = name.replace("*", "wild");
|
|
41
|
+
openssl(["req", "-newkey", "rsa:2048", "-keyout", `${file}.key`, "-out", `${file}.csr`,
|
|
42
|
+
"-nodes", "-subj", `/CN=${name}`]);
|
|
43
|
+
openssl(["x509", "-req", "-in", `${file}.csr`, "-CA", "ca.crt", "-CAkey", "ca.key",
|
|
44
|
+
"-CAcreateserial", "-out", `${file}.crt`, "-days", "2", "-extfile", "ext.cnf"]);
|
|
45
|
+
// -extfile is rewritten per call; write it first.
|
|
46
|
+
return {
|
|
47
|
+
cert: readFileSync(join(dir, `${file}.crt`), "utf8"),
|
|
48
|
+
key: readFileSync(join(dir, `${file}.key`), "utf8"),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
beforeAll(() => {
|
|
53
|
+
dir = mkdtempSync(join(tmpdir(), "spectest-tls-"));
|
|
54
|
+
openssl(["req", "-x509", "-newkey", "rsa:2048", "-keyout", "ca.key", "-out", "ca.crt",
|
|
55
|
+
"-days", "2", "-nodes", "-subj", "/CN=spectest-test-ca"]);
|
|
56
|
+
caPem = readFileSync(join(dir, "ca.crt"), "utf8");
|
|
57
|
+
for (const [name, san] of [
|
|
58
|
+
["app.test", "DNS:app.test"],
|
|
59
|
+
["late.test", "DNS:late.test"],
|
|
60
|
+
["*.db.test", "DNS:*.db.test"],
|
|
61
|
+
] as const) {
|
|
62
|
+
require("node:fs").writeFileSync(join(dir, "ext.cnf"), `subjectAltName=${san}\n`);
|
|
63
|
+
leaves.set(name, mintLeaf(name, san));
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
afterAll(() => rmSync(dir, { recursive: true, force: true }));
|
|
67
|
+
|
|
68
|
+
// ── the ingress shape: a plaintext Bun.serve behind the terminator ────────
|
|
69
|
+
const PORT = 19701;
|
|
70
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
71
|
+
let plain: any;
|
|
72
|
+
let term: TlsTerminator;
|
|
73
|
+
/** The live SNI table, exactly as the daemon mutates `INGRESS.certByHost`. */
|
|
74
|
+
let certByHost: Map<string, Leaf>;
|
|
75
|
+
/** What the plaintext server reported as the client's address, per request. */
|
|
76
|
+
let seenClientIp: string | undefined;
|
|
77
|
+
|
|
78
|
+
beforeAll(async () => {
|
|
79
|
+
certByHost = new Map([["app.test", leaves.get("app.test")!]]);
|
|
80
|
+
plain = Bun.serve({
|
|
81
|
+
port: 0,
|
|
82
|
+
hostname: "127.0.0.1",
|
|
83
|
+
idleTimeout: 0,
|
|
84
|
+
fetch(req, server) {
|
|
85
|
+
const peer = server.requestIP(req);
|
|
86
|
+
seenClientIp = term.clientIpFor(peer?.port) ?? peer?.address;
|
|
87
|
+
return new Response(`served ${new URL(req.url).hostname}`);
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
term = await startTlsTerminator({
|
|
91
|
+
port: PORT,
|
|
92
|
+
hostname: "127.0.0.1",
|
|
93
|
+
upstreamPort: plain.port,
|
|
94
|
+
certFor: (serverName) => {
|
|
95
|
+
if (serverName === undefined) return certByHost.values().next().value;
|
|
96
|
+
const name = selectCertName(certByHost.keys(), serverName.toLowerCase());
|
|
97
|
+
return name === undefined ? undefined : certByHost.get(name);
|
|
98
|
+
},
|
|
99
|
+
onWarning: () => {
|
|
100
|
+
/* a client hanging up mid-handshake is a case under test */
|
|
101
|
+
},
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
afterAll(() => {
|
|
105
|
+
term?.close();
|
|
106
|
+
plain?.stop(true);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
const sockets: tls.TLSSocket[] = [];
|
|
110
|
+
afterEach(() => {
|
|
111
|
+
for (const s of sockets.splice(0)) s.destroy();
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
/** A kept-alive TLS connection, handshaken and left open. */
|
|
115
|
+
async function connect(servername: string, localAddress?: string): Promise<tls.TLSSocket> {
|
|
116
|
+
const sock = tls.connect({
|
|
117
|
+
host: "127.0.0.1", port: PORT, servername, ca: caPem,
|
|
118
|
+
...(localAddress ? { localAddress } : {}),
|
|
119
|
+
});
|
|
120
|
+
sockets.push(sock);
|
|
121
|
+
await new Promise<void>((resolve, reject) => {
|
|
122
|
+
sock.once("secureConnect", () => resolve());
|
|
123
|
+
sock.once("error", reject);
|
|
124
|
+
});
|
|
125
|
+
return sock;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** One keep-alive request on an existing connection. Rejects if the peer
|
|
129
|
+
* closed it instead of answering — which is exactly the reported bug. */
|
|
130
|
+
function request(sock: tls.TLSSocket, host: string): Promise<string> {
|
|
131
|
+
return new Promise((resolve, reject) => {
|
|
132
|
+
let buf = "";
|
|
133
|
+
const onData = (d: Buffer): void => {
|
|
134
|
+
buf += d.toString();
|
|
135
|
+
if (buf.includes("\r\n\r\n")) {
|
|
136
|
+
sock.off("data", onData);
|
|
137
|
+
resolve(buf);
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
sock.on("data", onData);
|
|
141
|
+
sock.once("close", () => reject(new Error("other side closed")));
|
|
142
|
+
sock.once("error", (err) => reject(err));
|
|
143
|
+
sock.write(`GET / HTTP/1.1\r\nHost: ${host}\r\nConnection: keep-alive\r\n\r\n`);
|
|
144
|
+
setTimeout(() => reject(new Error("timed out")), 4000);
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
describe("adding a certificate", () => {
|
|
149
|
+
/**
|
|
150
|
+
* THE regression. An idle pooled connection is what a client actually
|
|
151
|
+
* holds between polls, and a listener swap severed it: the next request
|
|
152
|
+
* written into that socket came back `SocketError: other side closed`,
|
|
153
|
+
* unretried, failing whichever test happened to be deploying. Adding a
|
|
154
|
+
* leaf to the live table must be invisible to it.
|
|
155
|
+
*/
|
|
156
|
+
test("an idle kept-alive connection survives it", async () => {
|
|
157
|
+
const sock = await connect("app.test");
|
|
158
|
+
expect(await request(sock, "app.test")).toContain("served app.test");
|
|
159
|
+
|
|
160
|
+
// …the connection now sits idle in the client's pool, as a CLI polling
|
|
161
|
+
// a deploy would leave it, while an unrelated service is provisioned.
|
|
162
|
+
certByHost.set("late.test", leaves.get("late.test")!);
|
|
163
|
+
await Bun.sleep(50);
|
|
164
|
+
|
|
165
|
+
expect(await request(sock, "app.test")).toContain("served app.test");
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("many certificates later, the same connection is still good", async () => {
|
|
169
|
+
const sock = await connect("app.test");
|
|
170
|
+
await request(sock, "app.test");
|
|
171
|
+
for (let i = 0; i < 10; i++) {
|
|
172
|
+
certByHost.set(`svc-${i}.db.test`, leaves.get("*.db.test")!);
|
|
173
|
+
await Bun.sleep(5);
|
|
174
|
+
}
|
|
175
|
+
expect(await request(sock, "app.test")).toContain("served app.test");
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
/** The other side of the same coin: the point of adding a leaf is that
|
|
179
|
+
* it serves the next handshake, with no restart to wait for. */
|
|
180
|
+
test("the new hostname is served on the next handshake", async () => {
|
|
181
|
+
certByHost.set("late.test", leaves.get("late.test")!);
|
|
182
|
+
const sock = await connect("late.test");
|
|
183
|
+
expect((sock.getPeerCertificate().subject as { CN: string }).CN).toBe("late.test");
|
|
184
|
+
expect(await request(sock, "late.test")).toContain("served late.test");
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
/** A wildcard leaf serves one label under it — the same RFC 6125 rule
|
|
188
|
+
* `certCovers` plans against, now enforced at the handshake. */
|
|
189
|
+
test("a wildcard leaf serves a name one label under it", async () => {
|
|
190
|
+
certByHost.set("*.db.test", leaves.get("*.db.test")!);
|
|
191
|
+
const sock = await connect("tenant-7.db.test");
|
|
192
|
+
expect((sock.getPeerCertificate().subject as { CN: string }).CN).toBe("*.db.test");
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
describe("the pipe", () => {
|
|
197
|
+
/** `x-forwarded-for` is built from this. The plaintext server's peer is
|
|
198
|
+
* the terminator, so without the port map every proxied request would
|
|
199
|
+
* tell the app under test its caller was 127.0.0.1. */
|
|
200
|
+
test("reports the real client address, not the loopback hop", async () => {
|
|
201
|
+
seenClientIp = undefined;
|
|
202
|
+
const sock = await connect("app.test", "127.0.0.2");
|
|
203
|
+
await request(sock, "app.test");
|
|
204
|
+
expect(seenClientIp).toBe("127.0.0.2");
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
/** A handshake the terminator cannot complete must cost the process
|
|
208
|
+
* nothing: an unhandled 'error' on a node server is thrown, and taking
|
|
209
|
+
* the daemon down over one bad client would be worse than the bug. */
|
|
210
|
+
test("a client that speaks no TLS does not disturb the listener", async () => {
|
|
211
|
+
const raw = net.connect(PORT, "127.0.0.1");
|
|
212
|
+
await new Promise<void>((r) => raw.on("connect", () => r()));
|
|
213
|
+
raw.write("this is not a ClientHello\r\n\r\n");
|
|
214
|
+
await Bun.sleep(100);
|
|
215
|
+
raw.destroy();
|
|
216
|
+
|
|
217
|
+
const sock = await connect("app.test");
|
|
218
|
+
expect(await request(sock, "app.test")).toContain("served app.test");
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
/** A test VM is short-lived, but a fake that provisions in a loop opens
|
|
222
|
+
* a connection per probe — the terminator must not accumulate them. */
|
|
223
|
+
test("connections are released when they close", async () => {
|
|
224
|
+
// Earlier tests' sockets are destroyed in afterEach; let those close
|
|
225
|
+
// events land so this measures only what it opens itself.
|
|
226
|
+
const settle = async (): Promise<void> => {
|
|
227
|
+
for (let i = 0; i < 100 && term.connectionCount() > 0; i++) await Bun.sleep(20);
|
|
228
|
+
};
|
|
229
|
+
await settle();
|
|
230
|
+
expect(term.connectionCount()).toBe(0);
|
|
231
|
+
|
|
232
|
+
const sock = await connect("app.test");
|
|
233
|
+
await request(sock, "app.test");
|
|
234
|
+
expect(term.connectionCount()).toBe(1);
|
|
235
|
+
|
|
236
|
+
sock.destroy();
|
|
237
|
+
await settle();
|
|
238
|
+
expect(term.connectionCount()).toBe(0);
|
|
239
|
+
});
|
|
240
|
+
});
|