@specific.dev/spectest 0.4.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.
@@ -0,0 +1,351 @@
1
+ // spectest-resolver: a tiny DNS server that mirrors Docker's embedded
2
+ // DNS, but from the VM host instead of from inside a container.
3
+ //
4
+ // Single-label lookups (no dots, e.g. `api`) hit the Docker socket by
5
+ // container name. Multi-label lookups (e.g. `api.stripe.com`) scan
6
+ // containers on `spectest-net` for one whose network aliases include the
7
+ // queried name — set via service `hostnames` in env.ts. Anything we
8
+ // can't resolve from Docker is forwarded to an upstream DNS server
9
+ // (default 1.1.1.1). The daemon and any test code running on the VM
10
+ // host can do `fetch("http://api:8000")` or `fetch("http://api.stripe.com")`
11
+ // exactly like a container on the same bridge would.
12
+ //
13
+ // Designed to be tiny: one process, no caching beyond DNS TTL (5s), no
14
+ // docker event subscription — every query asks Docker fresh. The latency
15
+ // is ~1ms per query and the failure mode (Docker socket down) is rare
16
+ // enough that simplicity wins.
17
+ //
18
+ // Listens on two *specific* addresses (never 0.0.0.0 — see the bind logic
19
+ // at the bottom): 127.0.0.53, the nameserver the VM's /etc/resolv.conf
20
+ // points at (daemon, host-side test code, and dockerd's embedded DNS as
21
+ // its ExtServer all use it), and the spectest-net bridge gateway IP,
22
+ // which is how an in-cluster k3s pod reaches us (k3s CoreDNS is pointed
23
+ // there; see components/k3s.ts). Binding the specific gateway IP rather
24
+ // than 0.0.0.0 matters: a 0.0.0.0 socket replies to a 127.0.0.53 query
25
+ // from a kernel-chosen source (127.0.0.1), and glibc's resolver drops the
26
+ // answer because its source doesn't match the queried server.
27
+
28
+ import { createSocket, type RemoteInfo } from "node:dgram";
29
+ import { request as httpRequest } from "node:http";
30
+ import { readFile, stat } from "node:fs/promises";
31
+ import * as dnsPacket from "dns-packet";
32
+
33
+ const NETWORK_NAME = process.env.SPECTEST_NETWORK ?? "spectest-net";
34
+ const DOCKER_SOCKET = process.env.DOCKER_SOCKET ?? "/var/run/docker.sock";
35
+ const UPSTREAM_DNS = process.env.SPECTEST_UPSTREAM_DNS ?? "1.1.1.1";
36
+ const UPSTREAM_PORT = Number(process.env.SPECTEST_UPSTREAM_PORT ?? "53");
37
+ // Primary listen address: the loopback nameserver in the VM's
38
+ // /etc/resolv.conf. Always bound at startup.
39
+ const LISTEN_ADDR = process.env.SPECTEST_RESOLVER_ADDR ?? "127.0.0.53";
40
+ const LISTEN_PORT = Number(process.env.SPECTEST_RESOLVER_PORT ?? "53");
41
+ const TTL_SECONDS = Number(process.env.SPECTEST_RESOLVER_TTL ?? "5");
42
+ /** Path the spectest-daemon writes when it brings fakes up. */
43
+ const FAKES_REGISTRY_PATH =
44
+ process.env.SPECTEST_FAKES_REGISTRY ?? "/run/spectest-fakes.json";
45
+
46
+ function dockerGet(path: string): Promise<unknown | null> {
47
+ return new Promise((resolve) => {
48
+ const req = httpRequest(
49
+ { socketPath: DOCKER_SOCKET, path, method: "GET" },
50
+ (res) => {
51
+ if (res.statusCode !== 200) {
52
+ res.resume();
53
+ resolve(null);
54
+ return;
55
+ }
56
+ const chunks: Buffer[] = [];
57
+ res.on("data", (c) => chunks.push(c));
58
+ res.on("end", () => {
59
+ try {
60
+ resolve(JSON.parse(Buffer.concat(chunks).toString("utf8")));
61
+ } catch {
62
+ resolve(null);
63
+ }
64
+ });
65
+ res.on("error", () => resolve(null));
66
+ },
67
+ );
68
+ req.on("error", () => resolve(null));
69
+ req.end();
70
+ });
71
+ }
72
+
73
+ /**
74
+ * Single-label lookup: ask Docker for the container with this exact name
75
+ * and read its IP on spectest-net. Cheap — one container fetch.
76
+ */
77
+ async function dockerLookupByName(name: string): Promise<string | null> {
78
+ const body = (await dockerGet(`/containers/${encodeURIComponent(name)}/json`)) as
79
+ | { NetworkSettings?: { Networks?: Record<string, { IPAddress?: string }> } }
80
+ | null;
81
+ const ip = body?.NetworkSettings?.Networks?.[NETWORK_NAME]?.IPAddress;
82
+ return ip && ip.length > 0 ? ip : null;
83
+ }
84
+
85
+ /**
86
+ * Multi-label lookup: scan containers on spectest-net for one whose
87
+ * Aliases include `name`. Aliases are set with `--network-alias` at
88
+ * `docker run` time (see runContainer in daemon.ts).
89
+ *
90
+ * `/containers/json` returns `Aliases: null` in many Docker versions
91
+ * even when --network-alias was set, so we use it only to enumerate
92
+ * container IDs and then inspect each one — inspect reliably surfaces
93
+ * the alias list. The fan-out is bounded by service count (~handful),
94
+ * so the extra roundtrips are cheap.
95
+ */
96
+ async function dockerLookupByAlias(name: string): Promise<string | null> {
97
+ const filters = encodeURIComponent(JSON.stringify({ network: [NETWORK_NAME] }));
98
+ const list = (await dockerGet(`/containers/json?filters=${filters}`)) as
99
+ | Array<{ Id?: string }>
100
+ | null;
101
+ if (!Array.isArray(list)) return null;
102
+ for (const c of list) {
103
+ if (!c.Id) continue;
104
+ const info = (await dockerGet(`/containers/${c.Id}/json`)) as
105
+ | { NetworkSettings?: { Networks?: Record<string, { Aliases?: string[]; IPAddress?: string }> } }
106
+ | null;
107
+ const net = info?.NetworkSettings?.Networks?.[NETWORK_NAME];
108
+ if (!net) continue;
109
+ if ((net.Aliases ?? []).includes(name) && net.IPAddress) {
110
+ return net.IPAddress;
111
+ }
112
+ }
113
+ return null;
114
+ }
115
+
116
+ // Cache the names registry file by mtime so we re-read only when the
117
+ // daemon has rewritten it (at /bootstrap, and live whenever a test calls
118
+ // ctx.dnsName). A miss returns empty tables — the registry is optional,
119
+ // and the daemon may not have written the file yet on a fresh boot.
120
+ //
121
+ // `hosts` are exact hostname → IP (fakes, TLS proxies, dnsName(→ingress),
122
+ // dynamic exact registrations). `wildcards` are suffix → IP, e.g.
123
+ // `*.example.com` stored as `{ suffix: ".example.com", ip }`; matched only
124
+ // after exact lookups (registry + docker) miss, so an exact name always
125
+ // wins over a wildcard.
126
+ interface RegistryCache {
127
+ mtimeMs: number;
128
+ hosts: Record<string, string>;
129
+ wildcards: Array<{ suffix: string; ip: string }>;
130
+ }
131
+ let registryCache: RegistryCache = { mtimeMs: 0, hosts: {}, wildcards: [] };
132
+
133
+ async function refreshRegistry(): Promise<void> {
134
+ try {
135
+ const st = await stat(FAKES_REGISTRY_PATH);
136
+ if (st.mtimeMs !== registryCache.mtimeMs) {
137
+ const raw = await readFile(FAKES_REGISTRY_PATH, "utf8");
138
+ const parsed = JSON.parse(raw) as {
139
+ hosts?: Record<string, string>;
140
+ wildcards?: Array<{ suffix: string; ip: string }>;
141
+ };
142
+ registryCache = {
143
+ mtimeMs: st.mtimeMs,
144
+ hosts: parsed.hosts ?? {},
145
+ wildcards: parsed.wildcards ?? [],
146
+ };
147
+ }
148
+ } catch {
149
+ // File missing or unreadable — treat as empty.
150
+ registryCache = { mtimeMs: 0, hosts: {}, wildcards: [] };
151
+ }
152
+ }
153
+
154
+ /** Exact names-registry lookup. */
155
+ async function lookupFake(name: string): Promise<string | null> {
156
+ await refreshRegistry();
157
+ return registryCache.hosts[name] ?? null;
158
+ }
159
+
160
+ /** Wildcard suffix lookup — consulted only after an exact miss. The
161
+ * longest (most specific) matching suffix wins. */
162
+ async function lookupWildcard(name: string): Promise<string | null> {
163
+ await refreshRegistry();
164
+ let best: { suffix: string; ip: string } | null = null;
165
+ for (const w of registryCache.wildcards) {
166
+ if (name.endsWith(w.suffix) && (!best || w.suffix.length > best.suffix.length)) {
167
+ best = w;
168
+ }
169
+ }
170
+ return best?.ip ?? null;
171
+ }
172
+
173
+ async function forwardUpstream(query: Buffer): Promise<Buffer | null> {
174
+ return new Promise((resolve) => {
175
+ const sock = createSocket("udp4");
176
+ let done = false;
177
+ const finish = (b: Buffer | null) => {
178
+ if (done) return;
179
+ done = true;
180
+ try {
181
+ sock.close();
182
+ } catch {
183
+ // ignore
184
+ }
185
+ resolve(b);
186
+ };
187
+ sock.on("message", (msg) => finish(msg));
188
+ sock.on("error", () => finish(null));
189
+ sock.send(query, UPSTREAM_PORT, UPSTREAM_DNS, (err) => {
190
+ if (err) finish(null);
191
+ });
192
+ setTimeout(() => finish(null), 3_000);
193
+ });
194
+ }
195
+
196
+ function emptyAnswer(query: dnsPacket.Packet): Buffer {
197
+ return dnsPacket.encode({
198
+ type: "response",
199
+ id: query.id,
200
+ flags: dnsPacket.AUTHORITATIVE_ANSWER | dnsPacket.RECURSION_DESIRED,
201
+ questions: query.questions,
202
+ answers: [],
203
+ });
204
+ }
205
+
206
+ function aAnswer(query: dnsPacket.Packet, name: string, ip: string): Buffer {
207
+ return dnsPacket.encode({
208
+ type: "response",
209
+ id: query.id,
210
+ flags: dnsPacket.AUTHORITATIVE_ANSWER | dnsPacket.RECURSION_DESIRED,
211
+ questions: query.questions,
212
+ answers: [{ type: "A", name, ttl: TTL_SECONDS, data: ip }],
213
+ });
214
+ }
215
+
216
+ // Shared query handler. Replies are sent back through the *same* socket
217
+ // the query arrived on so the reply's source address matches the address
218
+ // the client sent to — which is why each listener is bound to a specific
219
+ // IP rather than 0.0.0.0 (glibc drops answers whose source differs from
220
+ // the queried server).
221
+ async function handleQuery(
222
+ sock: ReturnType<typeof createSocket>,
223
+ msg: Buffer,
224
+ rinfo: RemoteInfo,
225
+ ): Promise<void> {
226
+ let query: dnsPacket.Packet;
227
+ try {
228
+ query = dnsPacket.decode(msg);
229
+ } catch {
230
+ return;
231
+ }
232
+ const q = query.questions?.[0];
233
+ if (q && (q.type === "A" || q.type === "AAAA")) {
234
+ const name = q.name.toLowerCase();
235
+ const isSingleLabel = !name.includes(".");
236
+
237
+ // Fakes win over docker — they're explicitly registered by the
238
+ // daemon and a fake's hostname (e.g. api.stripe.com) might collide
239
+ // with a real upstream we don't want to call.
240
+ const fakeIp = isSingleLabel ? null : await lookupFake(name);
241
+ const ip = fakeIp
242
+ ?? (isSingleLabel
243
+ ? await dockerLookupByName(name)
244
+ : await dockerLookupByAlias(name));
245
+
246
+ if (ip) {
247
+ // For AAAA we still answer empty — Docker bridges are IPv4 only, but
248
+ // we own this name so libc should fall back to A instead of chasing
249
+ // a stray upstream AAAA for the real public hostname.
250
+ const resp = q.type === "A" ? aAnswer(query, q.name, ip) : emptyAnswer(query);
251
+ sock.send(resp, rinfo.port, rinfo.address);
252
+ return;
253
+ }
254
+
255
+ if (isSingleLabel) {
256
+ // Bare hostname we don't own — reply NOERROR with no answers so the
257
+ // resolver moves on quickly. Forwarding bare hostnames upstream just
258
+ // causes timeouts.
259
+ sock.send(emptyAnswer(query), rinfo.port, rinfo.address);
260
+ return;
261
+ }
262
+ // Multi-label exact miss: try wildcard suffixes (e.g. *.example.com
263
+ // → the k3s cluster) before forwarding upstream. Exact lookups above
264
+ // always win, so a specific alias beats a covering wildcard.
265
+ const wildIp = await lookupWildcard(name);
266
+ if (wildIp) {
267
+ const resp = q.type === "A" ? aAnswer(query, q.name, wildIp) : emptyAnswer(query);
268
+ sock.send(resp, rinfo.port, rinfo.address);
269
+ return;
270
+ }
271
+ // Still nothing — fall through and forward to upstream below.
272
+ }
273
+ // Anything we don't own — forward.
274
+ const upstream = await forwardUpstream(msg);
275
+ if (upstream) {
276
+ sock.send(upstream, rinfo.port, rinfo.address);
277
+ return;
278
+ }
279
+ // Upstream unreachable; return SERVFAIL (rcode 2 in low 4 bits of flags).
280
+ const resp = dnsPacket.encode({
281
+ type: "response",
282
+ id: query.id,
283
+ flags: dnsPacket.RECURSION_DESIRED | 0x2,
284
+ questions: query.questions ?? [],
285
+ });
286
+ sock.send(resp, rinfo.port, rinfo.address);
287
+ }
288
+
289
+ /** Bind one UDP listener on `addr`, wired to the shared handler. A bind
290
+ * error on the primary loopback address is fatal (DNS is fully down);
291
+ * on the secondary bridge address it's logged and retried by the caller. */
292
+ function bindListener(addr: string, fatalOnError: boolean): Promise<boolean> {
293
+ return new Promise((resolve) => {
294
+ const sock = createSocket("udp4");
295
+ sock.on("message", (msg: Buffer, rinfo: RemoteInfo) => {
296
+ void handleQuery(sock, msg, rinfo);
297
+ });
298
+ sock.on("error", (err) => {
299
+ // eslint-disable-next-line no-console
300
+ console.error(`[spectest-resolver] socket error on ${addr}:`, err);
301
+ if (fatalOnError) process.exit(1);
302
+ try {
303
+ sock.close();
304
+ } catch {
305
+ // ignore
306
+ }
307
+ resolve(false);
308
+ });
309
+ sock.bind(LISTEN_PORT, addr, () => {
310
+ // eslint-disable-next-line no-console
311
+ console.log(
312
+ `[spectest-resolver] listening on ${addr}:${LISTEN_PORT} (network=${NETWORK_NAME}, upstream=${UPSTREAM_DNS}:${UPSTREAM_PORT})`,
313
+ );
314
+ resolve(true);
315
+ });
316
+ });
317
+ }
318
+
319
+ /** Read the spectest-net bridge gateway IP from Docker — the address an
320
+ * in-cluster k3s pod (or any off-bridge client routed through the node)
321
+ * uses to reach the VM host. Null until the network exists. */
322
+ async function bridgeGatewayIp(): Promise<string | null> {
323
+ const net = (await dockerGet(`/networks/${encodeURIComponent(NETWORK_NAME)}`)) as
324
+ | { IPAM?: { Config?: Array<{ Gateway?: string }> } }
325
+ | null;
326
+ const gw = net?.IPAM?.Config?.find((c) => c.Gateway)?.Gateway;
327
+ return gw && gw.length > 0 ? gw : null;
328
+ }
329
+
330
+ // Primary listener: the loopback nameserver. Fatal if it can't bind.
331
+ void bindListener(LISTEN_ADDR, true);
332
+
333
+ // Secondary listener: the spectest-net bridge gateway, for k3s pods.
334
+ // The network is created by the daemon at /load — after this process
335
+ // starts and after it's captured into the base snapshot — so poll until
336
+ // the gateway appears, bind once, then stop. The gateway is stable for a
337
+ // VM's life (the daemon only creates the network when it's missing), so a
338
+ // single successful bind survives snapshot/restore/fork. Opt out with
339
+ // SPECTEST_RESOLVER_NO_BRIDGE=1.
340
+ if (process.env.SPECTEST_RESOLVER_NO_BRIDGE !== "1") {
341
+ let bound = false;
342
+ const poll = setInterval(async () => {
343
+ if (bound) return;
344
+ const gw = await bridgeGatewayIp().catch(() => null);
345
+ if (!gw) return;
346
+ bound = true;
347
+ if (!(await bindListener(gw, false))) bound = false; // retry on failure
348
+ }, 2_000);
349
+ // Don't keep the event loop alive solely for the poll timer.
350
+ poll.unref?.();
351
+ }