@specific.dev/spectest 0.131.19 → 0.131.20

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/src/resolver.ts CHANGED
@@ -1,35 +1,13 @@
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.
1
+ // Platform-owned DNS server. SDK registry v1 queries are memory-only; Docker
2
+ // discovery below serves only older pinned SDKs until the first full registry.
3
+ // Bind specific loopback/bridge IPs so replies have the expected source address.
27
4
 
28
5
  import { createSocket, type RemoteInfo } from "node:dgram";
29
- import { isIPv4 } from "node:net";
6
+ import { isIPv4, createServer as createTcpServer, createConnection } from "node:net";
30
7
  import { request as httpRequest } from "node:http";
31
8
  import { readFile, stat } from "node:fs/promises";
32
9
  import { lookup } from "./harness/names-registry.js";
10
+ import { serveDnsRegistry, compileDnsRegistry } from "./harness/dns-registry.js";
33
11
  import * as dnsPacket from "dns-packet";
34
12
 
35
13
  const NETWORK_NAME = process.env.SPECTEST_NETWORK ?? "spectest-net";
@@ -45,6 +23,8 @@ const TTL_SECONDS = Number(process.env.SPECTEST_RESOLVER_TTL ?? "5");
45
23
  const FAKES_REGISTRY_PATH =
46
24
  process.env.SPECTEST_FAKES_REGISTRY ?? "/run/spectest-fakes.json";
47
25
 
26
+ let registryLookup: ReturnType<typeof compileDnsRegistry> | undefined;
27
+
48
28
  function dockerGet(path: string): Promise<unknown | null> {
49
29
  return new Promise((resolve) => {
50
30
  const req = httpRequest(
@@ -67,6 +47,7 @@ function dockerGet(path: string): Promise<unknown | null> {
67
47
  res.on("error", () => resolve(null));
68
48
  },
69
49
  );
50
+ req.setTimeout(3_000, () => req.destroy());
70
51
  req.on("error", () => resolve(null));
71
52
  req.end();
72
53
  });
@@ -84,35 +65,49 @@ async function dockerLookupByName(name: string): Promise<string | null> {
84
65
  return ip && ip.length > 0 ? ip : null;
85
66
  }
86
67
 
87
- /**
88
- * Multi-label lookup: scan containers on spectest-net for one whose
89
- * Aliases include `name`. Aliases are set with `--network-alias` at
90
- * `docker run` time (see runContainer in daemon.ts).
91
- *
92
- * `/containers/json` returns `Aliases: null` in many Docker versions
93
- * even when --network-alias was set, so we use it only to enumerate
94
- * container IDs and then inspect each one — inspect reliably surfaces
95
- * the alias list. The fan-out is bounded by service count (~handful),
96
- * so the extra roundtrips are cheap.
97
- */
98
- async function dockerLookupByAlias(name: string): Promise<string | null> {
99
- const filters = encodeURIComponent(JSON.stringify({ network: [NETWORK_NAME] }));
100
- const list = (await dockerGet(`/containers/json?filters=${filters}`)) as
101
- | Array<{ Id?: string }>
102
- | null;
103
- if (!Array.isArray(list)) return null;
104
- for (const c of list) {
105
- if (!c.Id) continue;
106
- const info = (await dockerGet(`/containers/${c.Id}/json`)) as
107
- | { NetworkSettings?: { Networks?: Record<string, { Aliases?: string[]; IPAddress?: string }> } }
108
- | null;
109
- const net = info?.NetworkSettings?.Networks?.[NETWORK_NAME];
110
- if (!net) continue;
111
- if ((net.Aliases ?? []).includes(name) && net.IPAddress) {
112
- return net.IPAddress;
68
+ // Alias discovery must not use /containers/json: Docker's containerd image
69
+ // store refreshes image metadata for that endpoint, even for a names-only list.
70
+ // Network inspection enumerates the same endpoints without walking images.
71
+ // Share discovery across names and A/AAAA queries. Keep its lifetime shorter
72
+ // than our DNS TTL so runtime container changes become visible promptly.
73
+ let aliases: { expires: number; hosts: Map<string, string> } | undefined;
74
+ let refreshingAliases: Promise<Map<string, string> | null> | undefined;
75
+
76
+ async function readAliases(): Promise<{ hosts: Map<string, string>; complete: boolean } | null> {
77
+ const network = await dockerGet(`/networks/${encodeURIComponent(NETWORK_NAME)}`) as
78
+ { Containers?: Record<string, unknown> } | null;
79
+ if (!network?.Containers) return null;
80
+ const ids = Object.keys(network.Containers);
81
+ const hosts = new Map<string, string>();
82
+ let failed = false;
83
+ // Bound concurrent inspections even for large projects.
84
+ let next = 0;
85
+ await Promise.all(Array.from({ length: Math.min(8, ids.length) }, async () => {
86
+ while (next < ids.length) {
87
+ const id = ids[next++];
88
+ const info = await dockerGet(`/containers/${encodeURIComponent(id)}/json`) as
89
+ | { State?: { Running?: boolean }; NetworkSettings?: { Networks?: Record<string, { Aliases?: string[]; IPAddress?: string }> } }
90
+ | null;
91
+ if (!info) { failed = true; continue; }
92
+ if (!info.State?.Running) continue;
93
+ const net = info.NetworkSettings?.Networks?.[NETWORK_NAME];
94
+ if (!net?.IPAddress) continue;
95
+ for (const alias of net.Aliases ?? []) hosts.set(alias.toLowerCase(), net.IPAddress);
113
96
  }
97
+ }));
98
+ // A transient Docker failure must not become a cached negative answer.
99
+ return { hosts, complete: !failed };
100
+ }
101
+
102
+ async function dockerLookupByAlias(name: string): Promise<string | null> {
103
+ if (aliases && performance.now() < aliases.expires) return aliases.hosts.get(name) ?? null;
104
+ if (!refreshingAliases) {
105
+ refreshingAliases = readAliases().then(result => {
106
+ if (result?.complete) aliases = { hosts: result.hosts, expires: performance.now() + Math.min(1_000, TTL_SECONDS * 1_000) };
107
+ return result?.hosts ?? null;
108
+ }).finally(() => { refreshingAliases = undefined; });
114
109
  }
115
- return null;
110
+ return (await refreshingAliases)?.get(name) ?? null;
116
111
  }
117
112
 
118
113
  // Cache the names registry file by mtime so we re-read only when the
@@ -168,26 +163,92 @@ async function lookupWildcard(name: string): Promise<string | null> {
168
163
  return lookup({ hosts: {}, wildcards: registryCache.wildcards }, name);
169
164
  }
170
165
 
171
- async function forwardUpstream(query: Buffer): Promise<Buffer | null> {
172
- return new Promise((resolve) => {
173
- const sock = createSocket("udp4");
166
+ // One connected UDP socket validates the upstream source and multiplexes bounded
167
+ // requests. Remap IDs so simultaneous clients cannot steal each other's replies.
168
+ const upstreamSocket = createSocket("udp4");
169
+ const upstreamPending = new Map<number, { query: dnsPacket.Packet; finish: (msg: Buffer | null) => void }>();
170
+ let nextUpstreamId = 0;
171
+ const upstreamReady = new Promise<void>(resolve => upstreamSocket.connect(UPSTREAM_PORT, UPSTREAM_DNS, resolve));
172
+ upstreamSocket.on("error", () => {
173
+ for (const pending of upstreamPending.values()) pending.finish(null);
174
+ });
175
+ upstreamSocket.on("message", msg => {
176
+ if (msg.length < 12) return;
177
+ const pending = upstreamPending.get(msg.readUInt16BE(0));
178
+ if (!pending) return;
179
+ try {
180
+ const response = dnsPacket.decode(msg);
181
+ if (response.type !== "response" || JSON.stringify(response.questions) !== JSON.stringify(pending.query.questions)) return;
182
+ pending.finish(msg);
183
+ } catch { /* Discard malformed or unrelated responses. */ }
184
+ });
185
+
186
+ const upstreamFlights = new Map<string, Promise<Buffer | null>>();
187
+ async function forwardUpstream(query: Buffer, tcp: boolean): Promise<Buffer | null> {
188
+ if (tcp) return forwardTcp(query);
189
+ const normalized = Buffer.from(query);
190
+ normalized.writeUInt16BE(0, 0);
191
+ const key = normalized.toString("base64");
192
+ let flight = upstreamFlights.get(key);
193
+ if (!flight) {
194
+ flight = exchangeUdp(normalized).finally(() => { upstreamFlights.delete(key); });
195
+ upstreamFlights.set(key, flight);
196
+ }
197
+ const response = await flight;
198
+ if (!response) return null;
199
+ const result = Buffer.from(response);
200
+ result.writeUInt16BE(query.readUInt16BE(0), 0);
201
+ return result;
202
+ }
203
+
204
+ async function exchangeUdp(query: Buffer): Promise<Buffer | null> {
205
+ if (upstreamPending.size >= 2048) return null;
206
+ let id: number;
207
+ do { id = nextUpstreamId++ & 0xffff; } while (upstreamPending.has(id));
208
+ return new Promise(resolve => {
209
+ const timer = setTimeout(() => finish(null), 3_000);
210
+ const finish = (msg: Buffer | null) => {
211
+ if (!upstreamPending.delete(id)) return;
212
+ clearTimeout(timer);
213
+ if (msg) { msg = Buffer.from(msg); msg.writeUInt16BE(query.readUInt16BE(0), 0); }
214
+ resolve(msg);
215
+ };
216
+ upstreamPending.set(id, { query: dnsPacket.decode(query), finish });
217
+ const forwarded = Buffer.from(query);
218
+ forwarded.writeUInt16BE(id, 0);
219
+ void upstreamReady.then(() => {
220
+ if (upstreamPending.has(id)) upstreamSocket.send(forwarded, error => { if (error) finish(null); });
221
+ });
222
+ });
223
+ }
224
+
225
+ function forwardTcp(query: Buffer): Promise<Buffer | null> {
226
+ return new Promise(resolve => {
227
+ const socket = createConnection({ host: UPSTREAM_DNS, port: UPSTREAM_PORT });
228
+ let data = Buffer.alloc(0);
174
229
  let done = false;
175
- const finish = (b: Buffer | null) => {
230
+ const finish = (msg: Buffer | null) => {
176
231
  if (done) return;
177
232
  done = true;
178
- try {
179
- sock.close();
180
- } catch {
181
- // ignore
182
- }
183
- resolve(b);
233
+ clearTimeout(timer);
234
+ socket.destroy();
235
+ resolve(msg);
184
236
  };
185
- sock.on("message", (msg) => finish(msg));
186
- sock.on("error", () => finish(null));
187
- sock.send(query, UPSTREAM_PORT, UPSTREAM_DNS, (err) => {
188
- if (err) finish(null);
237
+ const timer = setTimeout(() => finish(null), 3_000);
238
+ socket.on("connect", () => { const size = Buffer.alloc(2); size.writeUInt16BE(query.length); socket.write(Buffer.concat([size, query])); });
239
+ socket.on("data", chunk => {
240
+ data = Buffer.concat([data, Buffer.from(chunk)]);
241
+ if (data.length >= 2 && data.length >= 2 + data.readUInt16BE(0)) {
242
+ const msg = data.subarray(2, 2 + data.readUInt16BE(0));
243
+ try {
244
+ const response = dnsPacket.decode(msg);
245
+ const request = dnsPacket.decode(query);
246
+ finish(response.type === "response" && response.id === request.id && JSON.stringify(response.questions) === JSON.stringify(request.questions) ? msg : null);
247
+ } catch { finish(null); }
248
+ }
189
249
  });
190
- setTimeout(() => finish(null), 3_000);
250
+ socket.on("error", () => finish(null));
251
+ socket.on("close", () => finish(null));
191
252
  });
192
253
  }
193
254
 
@@ -195,7 +256,7 @@ function emptyAnswer(query: dnsPacket.Packet): Buffer {
195
256
  return dnsPacket.encode({
196
257
  type: "response",
197
258
  id: query.id,
198
- flags: dnsPacket.AUTHORITATIVE_ANSWER | dnsPacket.RECURSION_DESIRED,
259
+ flags: dnsPacket.AUTHORITATIVE_ANSWER | dnsPacket.RECURSION_AVAILABLE | ((query.flags ?? 0) & dnsPacket.RECURSION_DESIRED),
199
260
  questions: query.questions,
200
261
  answers: [],
201
262
  });
@@ -205,7 +266,7 @@ function aAnswer(query: dnsPacket.Packet, name: string, ip: string): Buffer {
205
266
  return dnsPacket.encode({
206
267
  type: "response",
207
268
  id: query.id,
208
- flags: dnsPacket.AUTHORITATIVE_ANSWER | dnsPacket.RECURSION_DESIRED,
269
+ flags: dnsPacket.AUTHORITATIVE_ANSWER | dnsPacket.RECURSION_AVAILABLE | ((query.flags ?? 0) & dnsPacket.RECURSION_DESIRED),
209
270
  questions: query.questions,
210
271
  answers: [{ type: "A", name, ttl: TTL_SECONDS, data: ip }],
211
272
  });
@@ -216,18 +277,25 @@ function aAnswer(query: dnsPacket.Packet, name: string, ip: string): Buffer {
216
277
  // the client sent to — which is why each listener is bound to a specific
217
278
  // IP rather than 0.0.0.0 (glibc drops answers whose source differs from
218
279
  // the queried server).
219
- async function handleQuery(
220
- sock: ReturnType<typeof createSocket>,
221
- msg: Buffer,
222
- rinfo: RemoteInfo,
223
- ): Promise<void> {
280
+ async function handleQuery(msg: Buffer, tcp = false): Promise<Buffer | null> {
224
281
  let query: dnsPacket.Packet;
225
282
  try {
226
283
  query = dnsPacket.decode(msg);
227
284
  } catch {
228
- return;
285
+ return null;
229
286
  }
287
+ if (query.type !== "query") return null;
230
288
  const q = query.questions?.[0];
289
+ if (query.questions?.length !== 1) return dnsPacket.encode({ type: "response", id: query.id, flags: 1, questions: query.questions });
290
+ if (registryLookup && q) {
291
+ const name = q.name.toLowerCase().replace(/\.$/, "");
292
+ const ip = registryLookup(name);
293
+ if (ip !== undefined || !name.includes(".") || name.endsWith(".internal")) {
294
+ return ip && q.type === "A" ? aAnswer(query, q.name, ip) : emptyAnswer(query);
295
+ }
296
+ const answer = await forwardUpstream(msg, tcp);
297
+ return answer ?? dnsPacket.encode({ type: "response", id: query.id, flags: dnsPacket.RECURSION_AVAILABLE | ((query.flags ?? 0) & dnsPacket.RECURSION_DESIRED) | 2, questions: query.questions });
298
+ }
231
299
  if (q && (q.type === "A" || q.type === "AAAA")) {
232
300
  const name = q.name.toLowerCase();
233
301
  const isSingleLabel = !name.includes(".");
@@ -246,16 +314,14 @@ async function handleQuery(
246
314
  // we own this name so libc should fall back to A instead of chasing
247
315
  // a stray upstream AAAA for the real public hostname.
248
316
  const resp = q.type === "A" ? aAnswer(query, q.name, ip) : emptyAnswer(query);
249
- sock.send(resp, rinfo.port, rinfo.address);
250
- return;
317
+ return resp;
251
318
  }
252
319
 
253
320
  if (isSingleLabel) {
254
321
  // Bare hostname we don't own — reply NOERROR with no answers so the
255
322
  // resolver moves on quickly. Forwarding bare hostnames upstream just
256
323
  // causes timeouts.
257
- sock.send(emptyAnswer(query), rinfo.port, rinfo.address);
258
- return;
324
+ return emptyAnswer(query);
259
325
  }
260
326
  // Multi-label exact miss: try wildcard suffixes (e.g. *.example.com
261
327
  // → the k3s cluster) before forwarding upstream. Exact lookups above
@@ -263,16 +329,14 @@ async function handleQuery(
263
329
  const wildIp = await lookupWildcard(name);
264
330
  if (wildIp) {
265
331
  const resp = q.type === "A" ? aAnswer(query, q.name, wildIp) : emptyAnswer(query);
266
- sock.send(resp, rinfo.port, rinfo.address);
267
- return;
332
+ return resp;
268
333
  }
269
334
  // Still nothing — fall through and forward to upstream below.
270
335
  }
271
336
  // Anything we don't own — forward.
272
- const upstream = await forwardUpstream(msg);
337
+ const upstream = await forwardUpstream(msg, tcp);
273
338
  if (upstream) {
274
- sock.send(upstream, rinfo.port, rinfo.address);
275
- return;
339
+ return upstream;
276
340
  }
277
341
  // Upstream unreachable; return SERVFAIL (rcode 2 in low 4 bits of flags).
278
342
  const resp = dnsPacket.encode({
@@ -281,7 +345,7 @@ async function handleQuery(
281
345
  flags: dnsPacket.RECURSION_DESIRED | 0x2,
282
346
  questions: query.questions ?? [],
283
347
  });
284
- sock.send(resp, rinfo.port, rinfo.address);
348
+ return resp;
285
349
  }
286
350
 
287
351
  /** Bind one UDP listener on `addr`, wired to the shared handler. A bind
@@ -291,7 +355,7 @@ function bindListener(addr: string, fatalOnError: boolean): Promise<boolean> {
291
355
  return new Promise((resolve) => {
292
356
  const sock = createSocket("udp4");
293
357
  sock.on("message", (msg: Buffer, rinfo: RemoteInfo) => {
294
- void handleQuery(sock, msg, rinfo);
358
+ void handleQuery(msg).then(answer => { if (answer) sock.send(answer, rinfo.port, rinfo.address); }).catch(error => console.error("[spectest-resolver] query failed", error));
295
359
  });
296
360
  sock.on("error", (err) => {
297
361
  // eslint-disable-next-line no-console
@@ -314,6 +378,38 @@ function bindListener(addr: string, fatalOnError: boolean): Promise<boolean> {
314
378
  });
315
379
  }
316
380
 
381
+ /** DNS over TCP uses the same registry and supports external truncated replies. */
382
+ function bindTcpListener(addr: string, fatalOnError: boolean): void {
383
+ const server = createTcpServer(socket => {
384
+ let buffered = Buffer.alloc(0);
385
+ let inFlight = 0;
386
+ socket.setTimeout(10_000, () => socket.destroy());
387
+ socket.on("error", () => socket.destroy());
388
+ socket.on("data", chunk => {
389
+ buffered = Buffer.concat([buffered, Buffer.from(chunk)]);
390
+ while (buffered.length >= 2) {
391
+ const size = buffered.readUInt16BE(0);
392
+ if (buffered.length < size + 2) break;
393
+ const msg = Buffer.from(buffered.subarray(2, size + 2));
394
+ buffered = buffered.subarray(size + 2);
395
+ if (++inFlight > 128) { socket.destroy(); return; }
396
+ void handleQuery(msg, true).then(answer => {
397
+ if (answer && !socket.destroyed) {
398
+ const prefix = Buffer.alloc(2); prefix.writeUInt16BE(answer.length);
399
+ socket.write(Buffer.concat([prefix, answer]));
400
+ }
401
+ }).catch(() => socket.destroy()).finally(() => { inFlight--; });
402
+ }
403
+ });
404
+ });
405
+ server.on("error", error => {
406
+ console.error(`[spectest-resolver] TCP bind ${addr}:`, error);
407
+ if (fatalOnError) process.exit(1);
408
+ else setTimeout(() => bindTcpListener(addr, false), 2_000).unref();
409
+ });
410
+ server.listen(LISTEN_PORT, addr);
411
+ }
412
+
317
413
  /** Read the spectest-net bridge gateway IP from Docker — the address an
318
414
  * in-cluster k3s pod (or any off-bridge client routed through the node)
319
415
  * uses to reach the VM host. Null until the network exists. */
@@ -325,8 +421,10 @@ async function bridgeGatewayIp(): Promise<string | null> {
325
421
  return gw && gw.length > 0 ? gw : null;
326
422
  }
327
423
 
328
- // Primary listener: the loopback nameserver. Fatal if it can't bind.
329
- void bindListener(LISTEN_ADDR, true);
424
+ // Restore persisted state and open the control socket before advertising DNS.
425
+ await serveDnsRegistry(doc => { registryLookup = compileDnsRegistry(doc); aliases = undefined; });
426
+ await bindListener(LISTEN_ADDR, true);
427
+ bindTcpListener(LISTEN_ADDR, true);
330
428
 
331
429
  // Secondary listener: the spectest-net bridge gateway, for k3s pods.
332
430
  // The network is created by the daemon at /load — after this process
@@ -343,6 +441,7 @@ if (process.env.SPECTEST_RESOLVER_NO_BRIDGE !== "1") {
343
441
  if (!gw) return;
344
442
  bound = true;
345
443
  if (!(await bindListener(gw, false))) bound = false; // retry on failure
444
+ else { bindTcpListener(gw, false); clearInterval(poll); }
346
445
  }, 2_000);
347
446
  // Don't keep the event loop alive solely for the poll timer.
348
447
  poll.unref?.();