@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/dist/resolver.js CHANGED
@@ -1,34 +1,12 @@
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
  import { createSocket } from "node:dgram";
28
- import { isIPv4 } from "node:net";
5
+ import { isIPv4, createServer as createTcpServer, createConnection } from "node:net";
29
6
  import { request as httpRequest } from "node:http";
30
7
  import { readFile, stat } from "node:fs/promises";
31
8
  import { lookup } from "./harness/names-registry.js";
9
+ import { serveDnsRegistry, compileDnsRegistry } from "./harness/dns-registry.js";
32
10
  import * as dnsPacket from "dns-packet";
33
11
  const NETWORK_NAME = process.env.SPECTEST_NETWORK ?? "spectest-net";
34
12
  const DOCKER_SOCKET = process.env.DOCKER_SOCKET ?? "/var/run/docker.sock";
@@ -41,6 +19,7 @@ const LISTEN_PORT = Number(process.env.SPECTEST_RESOLVER_PORT ?? "53");
41
19
  const TTL_SECONDS = Number(process.env.SPECTEST_RESOLVER_TTL ?? "5");
42
20
  /** Path the harness writes when it brings fakes up. */
43
21
  const FAKES_REGISTRY_PATH = process.env.SPECTEST_FAKES_REGISTRY ?? "/run/spectest-fakes.json";
22
+ let registryLookup;
44
23
  function dockerGet(path) {
45
24
  return new Promise((resolve) => {
46
25
  const req = httpRequest({ socketPath: DOCKER_SOCKET, path, method: "GET" }, (res) => {
@@ -61,6 +40,7 @@ function dockerGet(path) {
61
40
  });
62
41
  res.on("error", () => resolve(null));
63
42
  });
43
+ req.setTimeout(3_000, () => req.destroy());
64
44
  req.on("error", () => resolve(null));
65
45
  req.end();
66
46
  });
@@ -74,34 +54,53 @@ async function dockerLookupByName(name) {
74
54
  const ip = body?.NetworkSettings?.Networks?.[NETWORK_NAME]?.IPAddress;
75
55
  return ip && ip.length > 0 ? ip : null;
76
56
  }
77
- /**
78
- * Multi-label lookup: scan containers on spectest-net for one whose
79
- * Aliases include `name`. Aliases are set with `--network-alias` at
80
- * `docker run` time (see runContainer in daemon.ts).
81
- *
82
- * `/containers/json` returns `Aliases: null` in many Docker versions
83
- * even when --network-alias was set, so we use it only to enumerate
84
- * container IDs and then inspect each one — inspect reliably surfaces
85
- * the alias list. The fan-out is bounded by service count (~handful),
86
- * so the extra roundtrips are cheap.
87
- */
88
- async function dockerLookupByAlias(name) {
89
- const filters = encodeURIComponent(JSON.stringify({ network: [NETWORK_NAME] }));
90
- const list = (await dockerGet(`/containers/json?filters=${filters}`));
91
- if (!Array.isArray(list))
57
+ // Alias discovery must not use /containers/json: Docker's containerd image
58
+ // store refreshes image metadata for that endpoint, even for a names-only list.
59
+ // Network inspection enumerates the same endpoints without walking images.
60
+ // Share discovery across names and A/AAAA queries. Keep its lifetime shorter
61
+ // than our DNS TTL so runtime container changes become visible promptly.
62
+ let aliases;
63
+ let refreshingAliases;
64
+ async function readAliases() {
65
+ const network = await dockerGet(`/networks/${encodeURIComponent(NETWORK_NAME)}`);
66
+ if (!network?.Containers)
92
67
  return null;
93
- for (const c of list) {
94
- if (!c.Id)
95
- continue;
96
- const info = (await dockerGet(`/containers/${c.Id}/json`));
97
- const net = info?.NetworkSettings?.Networks?.[NETWORK_NAME];
98
- if (!net)
99
- continue;
100
- if ((net.Aliases ?? []).includes(name) && net.IPAddress) {
101
- return net.IPAddress;
68
+ const ids = Object.keys(network.Containers);
69
+ const hosts = new Map();
70
+ let failed = false;
71
+ // Bound concurrent inspections even for large projects.
72
+ let next = 0;
73
+ await Promise.all(Array.from({ length: Math.min(8, ids.length) }, async () => {
74
+ while (next < ids.length) {
75
+ const id = ids[next++];
76
+ const info = await dockerGet(`/containers/${encodeURIComponent(id)}/json`);
77
+ if (!info) {
78
+ failed = true;
79
+ continue;
80
+ }
81
+ if (!info.State?.Running)
82
+ continue;
83
+ const net = info.NetworkSettings?.Networks?.[NETWORK_NAME];
84
+ if (!net?.IPAddress)
85
+ continue;
86
+ for (const alias of net.Aliases ?? [])
87
+ hosts.set(alias.toLowerCase(), net.IPAddress);
102
88
  }
89
+ }));
90
+ // A transient Docker failure must not become a cached negative answer.
91
+ return { hosts, complete: !failed };
92
+ }
93
+ async function dockerLookupByAlias(name) {
94
+ if (aliases && performance.now() < aliases.expires)
95
+ return aliases.hosts.get(name) ?? null;
96
+ if (!refreshingAliases) {
97
+ refreshingAliases = readAliases().then(result => {
98
+ if (result?.complete)
99
+ aliases = { hosts: result.hosts, expires: performance.now() + Math.min(1_000, TTL_SECONDS * 1_000) };
100
+ return result?.hosts ?? null;
101
+ }).finally(() => { refreshingAliases = undefined; });
103
102
  }
104
- return null;
103
+ return (await refreshingAliases)?.get(name) ?? null;
105
104
  }
106
105
  let registryCache = { mtimeMs: 0, hosts: {}, wildcards: [] };
107
106
  async function refreshRegistry() {
@@ -135,36 +134,116 @@ async function lookupWildcard(name) {
135
134
  // format cannot drift apart.
136
135
  return lookup({ hosts: {}, wildcards: registryCache.wildcards }, name);
137
136
  }
138
- async function forwardUpstream(query) {
139
- return new Promise((resolve) => {
140
- const sock = createSocket("udp4");
137
+ // One connected UDP socket validates the upstream source and multiplexes bounded
138
+ // requests. Remap IDs so simultaneous clients cannot steal each other's replies.
139
+ const upstreamSocket = createSocket("udp4");
140
+ const upstreamPending = new Map();
141
+ let nextUpstreamId = 0;
142
+ const upstreamReady = new Promise(resolve => upstreamSocket.connect(UPSTREAM_PORT, UPSTREAM_DNS, resolve));
143
+ upstreamSocket.on("error", () => {
144
+ for (const pending of upstreamPending.values())
145
+ pending.finish(null);
146
+ });
147
+ upstreamSocket.on("message", msg => {
148
+ if (msg.length < 12)
149
+ return;
150
+ const pending = upstreamPending.get(msg.readUInt16BE(0));
151
+ if (!pending)
152
+ return;
153
+ try {
154
+ const response = dnsPacket.decode(msg);
155
+ if (response.type !== "response" || JSON.stringify(response.questions) !== JSON.stringify(pending.query.questions))
156
+ return;
157
+ pending.finish(msg);
158
+ }
159
+ catch { /* Discard malformed or unrelated responses. */ }
160
+ });
161
+ const upstreamFlights = new Map();
162
+ async function forwardUpstream(query, tcp) {
163
+ if (tcp)
164
+ return forwardTcp(query);
165
+ const normalized = Buffer.from(query);
166
+ normalized.writeUInt16BE(0, 0);
167
+ const key = normalized.toString("base64");
168
+ let flight = upstreamFlights.get(key);
169
+ if (!flight) {
170
+ flight = exchangeUdp(normalized).finally(() => { upstreamFlights.delete(key); });
171
+ upstreamFlights.set(key, flight);
172
+ }
173
+ const response = await flight;
174
+ if (!response)
175
+ return null;
176
+ const result = Buffer.from(response);
177
+ result.writeUInt16BE(query.readUInt16BE(0), 0);
178
+ return result;
179
+ }
180
+ async function exchangeUdp(query) {
181
+ if (upstreamPending.size >= 2048)
182
+ return null;
183
+ let id;
184
+ do {
185
+ id = nextUpstreamId++ & 0xffff;
186
+ } while (upstreamPending.has(id));
187
+ return new Promise(resolve => {
188
+ const timer = setTimeout(() => finish(null), 3_000);
189
+ const finish = (msg) => {
190
+ if (!upstreamPending.delete(id))
191
+ return;
192
+ clearTimeout(timer);
193
+ if (msg) {
194
+ msg = Buffer.from(msg);
195
+ msg.writeUInt16BE(query.readUInt16BE(0), 0);
196
+ }
197
+ resolve(msg);
198
+ };
199
+ upstreamPending.set(id, { query: dnsPacket.decode(query), finish });
200
+ const forwarded = Buffer.from(query);
201
+ forwarded.writeUInt16BE(id, 0);
202
+ void upstreamReady.then(() => {
203
+ if (upstreamPending.has(id))
204
+ upstreamSocket.send(forwarded, error => { if (error)
205
+ finish(null); });
206
+ });
207
+ });
208
+ }
209
+ function forwardTcp(query) {
210
+ return new Promise(resolve => {
211
+ const socket = createConnection({ host: UPSTREAM_DNS, port: UPSTREAM_PORT });
212
+ let data = Buffer.alloc(0);
141
213
  let done = false;
142
- const finish = (b) => {
214
+ const finish = (msg) => {
143
215
  if (done)
144
216
  return;
145
217
  done = true;
146
- try {
147
- sock.close();
148
- }
149
- catch {
150
- // ignore
151
- }
152
- resolve(b);
218
+ clearTimeout(timer);
219
+ socket.destroy();
220
+ resolve(msg);
153
221
  };
154
- sock.on("message", (msg) => finish(msg));
155
- sock.on("error", () => finish(null));
156
- sock.send(query, UPSTREAM_PORT, UPSTREAM_DNS, (err) => {
157
- if (err)
158
- finish(null);
222
+ const timer = setTimeout(() => finish(null), 3_000);
223
+ socket.on("connect", () => { const size = Buffer.alloc(2); size.writeUInt16BE(query.length); socket.write(Buffer.concat([size, query])); });
224
+ socket.on("data", chunk => {
225
+ data = Buffer.concat([data, Buffer.from(chunk)]);
226
+ if (data.length >= 2 && data.length >= 2 + data.readUInt16BE(0)) {
227
+ const msg = data.subarray(2, 2 + data.readUInt16BE(0));
228
+ try {
229
+ const response = dnsPacket.decode(msg);
230
+ const request = dnsPacket.decode(query);
231
+ finish(response.type === "response" && response.id === request.id && JSON.stringify(response.questions) === JSON.stringify(request.questions) ? msg : null);
232
+ }
233
+ catch {
234
+ finish(null);
235
+ }
236
+ }
159
237
  });
160
- setTimeout(() => finish(null), 3_000);
238
+ socket.on("error", () => finish(null));
239
+ socket.on("close", () => finish(null));
161
240
  });
162
241
  }
163
242
  function emptyAnswer(query) {
164
243
  return dnsPacket.encode({
165
244
  type: "response",
166
245
  id: query.id,
167
- flags: dnsPacket.AUTHORITATIVE_ANSWER | dnsPacket.RECURSION_DESIRED,
246
+ flags: dnsPacket.AUTHORITATIVE_ANSWER | dnsPacket.RECURSION_AVAILABLE | ((query.flags ?? 0) & dnsPacket.RECURSION_DESIRED),
168
247
  questions: query.questions,
169
248
  answers: [],
170
249
  });
@@ -173,7 +252,7 @@ function aAnswer(query, name, ip) {
173
252
  return dnsPacket.encode({
174
253
  type: "response",
175
254
  id: query.id,
176
- flags: dnsPacket.AUTHORITATIVE_ANSWER | dnsPacket.RECURSION_DESIRED,
255
+ flags: dnsPacket.AUTHORITATIVE_ANSWER | dnsPacket.RECURSION_AVAILABLE | ((query.flags ?? 0) & dnsPacket.RECURSION_DESIRED),
177
256
  questions: query.questions,
178
257
  answers: [{ type: "A", name, ttl: TTL_SECONDS, data: ip }],
179
258
  });
@@ -183,15 +262,28 @@ function aAnswer(query, name, ip) {
183
262
  // the client sent to — which is why each listener is bound to a specific
184
263
  // IP rather than 0.0.0.0 (glibc drops answers whose source differs from
185
264
  // the queried server).
186
- async function handleQuery(sock, msg, rinfo) {
265
+ async function handleQuery(msg, tcp = false) {
187
266
  let query;
188
267
  try {
189
268
  query = dnsPacket.decode(msg);
190
269
  }
191
270
  catch {
192
- return;
271
+ return null;
193
272
  }
273
+ if (query.type !== "query")
274
+ return null;
194
275
  const q = query.questions?.[0];
276
+ if (query.questions?.length !== 1)
277
+ return dnsPacket.encode({ type: "response", id: query.id, flags: 1, questions: query.questions });
278
+ if (registryLookup && q) {
279
+ const name = q.name.toLowerCase().replace(/\.$/, "");
280
+ const ip = registryLookup(name);
281
+ if (ip !== undefined || !name.includes(".") || name.endsWith(".internal")) {
282
+ return ip && q.type === "A" ? aAnswer(query, q.name, ip) : emptyAnswer(query);
283
+ }
284
+ const answer = await forwardUpstream(msg, tcp);
285
+ return answer ?? dnsPacket.encode({ type: "response", id: query.id, flags: dnsPacket.RECURSION_AVAILABLE | ((query.flags ?? 0) & dnsPacket.RECURSION_DESIRED) | 2, questions: query.questions });
286
+ }
195
287
  if (q && (q.type === "A" || q.type === "AAAA")) {
196
288
  const name = q.name.toLowerCase();
197
289
  const isSingleLabel = !name.includes(".");
@@ -208,15 +300,13 @@ async function handleQuery(sock, msg, rinfo) {
208
300
  // we own this name so libc should fall back to A instead of chasing
209
301
  // a stray upstream AAAA for the real public hostname.
210
302
  const resp = q.type === "A" ? aAnswer(query, q.name, ip) : emptyAnswer(query);
211
- sock.send(resp, rinfo.port, rinfo.address);
212
- return;
303
+ return resp;
213
304
  }
214
305
  if (isSingleLabel) {
215
306
  // Bare hostname we don't own — reply NOERROR with no answers so the
216
307
  // resolver moves on quickly. Forwarding bare hostnames upstream just
217
308
  // causes timeouts.
218
- sock.send(emptyAnswer(query), rinfo.port, rinfo.address);
219
- return;
309
+ return emptyAnswer(query);
220
310
  }
221
311
  // Multi-label exact miss: try wildcard suffixes (e.g. *.example.com
222
312
  // → the k3s cluster) before forwarding upstream. Exact lookups above
@@ -224,16 +314,14 @@ async function handleQuery(sock, msg, rinfo) {
224
314
  const wildIp = await lookupWildcard(name);
225
315
  if (wildIp) {
226
316
  const resp = q.type === "A" ? aAnswer(query, q.name, wildIp) : emptyAnswer(query);
227
- sock.send(resp, rinfo.port, rinfo.address);
228
- return;
317
+ return resp;
229
318
  }
230
319
  // Still nothing — fall through and forward to upstream below.
231
320
  }
232
321
  // Anything we don't own — forward.
233
- const upstream = await forwardUpstream(msg);
322
+ const upstream = await forwardUpstream(msg, tcp);
234
323
  if (upstream) {
235
- sock.send(upstream, rinfo.port, rinfo.address);
236
- return;
324
+ return upstream;
237
325
  }
238
326
  // Upstream unreachable; return SERVFAIL (rcode 2 in low 4 bits of flags).
239
327
  const resp = dnsPacket.encode({
@@ -242,7 +330,7 @@ async function handleQuery(sock, msg, rinfo) {
242
330
  flags: dnsPacket.RECURSION_DESIRED | 0x2,
243
331
  questions: query.questions ?? [],
244
332
  });
245
- sock.send(resp, rinfo.port, rinfo.address);
333
+ return resp;
246
334
  }
247
335
  /** Bind one UDP listener on `addr`, wired to the shared handler. A bind
248
336
  * error on the primary loopback address is fatal (DNS is fully down);
@@ -251,7 +339,8 @@ function bindListener(addr, fatalOnError) {
251
339
  return new Promise((resolve) => {
252
340
  const sock = createSocket("udp4");
253
341
  sock.on("message", (msg, rinfo) => {
254
- void handleQuery(sock, msg, rinfo);
342
+ void handleQuery(msg).then(answer => { if (answer)
343
+ sock.send(answer, rinfo.port, rinfo.address); }).catch(error => console.error("[spectest-resolver] query failed", error));
255
344
  });
256
345
  sock.on("error", (err) => {
257
346
  // eslint-disable-next-line no-console
@@ -273,6 +362,44 @@ function bindListener(addr, fatalOnError) {
273
362
  });
274
363
  });
275
364
  }
365
+ /** DNS over TCP uses the same registry and supports external truncated replies. */
366
+ function bindTcpListener(addr, fatalOnError) {
367
+ const server = createTcpServer(socket => {
368
+ let buffered = Buffer.alloc(0);
369
+ let inFlight = 0;
370
+ socket.setTimeout(10_000, () => socket.destroy());
371
+ socket.on("error", () => socket.destroy());
372
+ socket.on("data", chunk => {
373
+ buffered = Buffer.concat([buffered, Buffer.from(chunk)]);
374
+ while (buffered.length >= 2) {
375
+ const size = buffered.readUInt16BE(0);
376
+ if (buffered.length < size + 2)
377
+ break;
378
+ const msg = Buffer.from(buffered.subarray(2, size + 2));
379
+ buffered = buffered.subarray(size + 2);
380
+ if (++inFlight > 128) {
381
+ socket.destroy();
382
+ return;
383
+ }
384
+ void handleQuery(msg, true).then(answer => {
385
+ if (answer && !socket.destroyed) {
386
+ const prefix = Buffer.alloc(2);
387
+ prefix.writeUInt16BE(answer.length);
388
+ socket.write(Buffer.concat([prefix, answer]));
389
+ }
390
+ }).catch(() => socket.destroy()).finally(() => { inFlight--; });
391
+ }
392
+ });
393
+ });
394
+ server.on("error", error => {
395
+ console.error(`[spectest-resolver] TCP bind ${addr}:`, error);
396
+ if (fatalOnError)
397
+ process.exit(1);
398
+ else
399
+ setTimeout(() => bindTcpListener(addr, false), 2_000).unref();
400
+ });
401
+ server.listen(LISTEN_PORT, addr);
402
+ }
276
403
  /** Read the spectest-net bridge gateway IP from Docker — the address an
277
404
  * in-cluster k3s pod (or any off-bridge client routed through the node)
278
405
  * uses to reach the VM host. Null until the network exists. */
@@ -281,8 +408,10 @@ async function bridgeGatewayIp() {
281
408
  const gw = net?.IPAM?.Config?.find(c => c.Gateway && isIPv4(c.Gateway))?.Gateway;
282
409
  return gw && gw.length > 0 ? gw : null;
283
410
  }
284
- // Primary listener: the loopback nameserver. Fatal if it can't bind.
285
- void bindListener(LISTEN_ADDR, true);
411
+ // Restore persisted state and open the control socket before advertising DNS.
412
+ await serveDnsRegistry(doc => { registryLookup = compileDnsRegistry(doc); aliases = undefined; });
413
+ await bindListener(LISTEN_ADDR, true);
414
+ bindTcpListener(LISTEN_ADDR, true);
286
415
  // Secondary listener: the spectest-net bridge gateway, for k3s pods.
287
416
  // The network is created by the daemon at /load — after this process
288
417
  // starts and after it's captured into the base snapshot — so poll until
@@ -301,6 +430,10 @@ if (process.env.SPECTEST_RESOLVER_NO_BRIDGE !== "1") {
301
430
  bound = true;
302
431
  if (!(await bindListener(gw, false)))
303
432
  bound = false; // retry on failure
433
+ else {
434
+ bindTcpListener(gw, false);
435
+ clearInterval(poll);
436
+ }
304
437
  }, 2_000);
305
438
  // Don't keep the event loop alive solely for the poll timer.
306
439
  poll.unref?.();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specific.dev/spectest",
3
- "version": "0.131.19",
3
+ "version": "0.131.20",
4
4
  "description": "Spectest SDK for defining test environments in TypeScript.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",