@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/daemon.js +62 -111
- package/dist/harness/dns-registry.d.ts +23 -0
- package/dist/harness/dns-registry.js +129 -0
- package/dist/harness/names-registry.d.ts +3 -18
- package/dist/harness/names-registry.js +3 -18
- package/dist/harness/service-dns.d.ts +35 -0
- package/dist/harness/service-dns.js +97 -0
- package/dist/resolver.js +219 -86
- package/package.json +1 -1
- package/src/daemon.ts +63 -111
- package/src/harness/dns-registry.ts +121 -0
- package/src/harness/names-registry.ts +3 -18
- package/src/harness/service-dns.test.ts +94 -0
- package/src/harness/service-dns.ts +95 -0
- package/src/resolver.test.ts +267 -0
- package/src/resolver.ts +189 -90
package/src/resolver.ts
CHANGED
|
@@ -1,35 +1,13 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
const
|
|
100
|
-
const
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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 = (
|
|
230
|
+
const finish = (msg: Buffer | null) => {
|
|
176
231
|
if (done) return;
|
|
177
232
|
done = true;
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
// ignore
|
|
182
|
-
}
|
|
183
|
-
resolve(b);
|
|
233
|
+
clearTimeout(timer);
|
|
234
|
+
socket.destroy();
|
|
235
|
+
resolve(msg);
|
|
184
236
|
};
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
//
|
|
329
|
-
|
|
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?.();
|