@specific.dev/spectest 0.131.19 → 0.131.21
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/browser.js +4 -2
- 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/harness/shadow-builder.js +3 -1
- package/dist/resolver.js +219 -86
- package/dist/vendor/rrweb-record.min.js +2 -17
- package/package.json +1 -1
- package/src/browser-coverage-navigation.test.ts +10 -2
- package/src/browser-image-loading.test.ts +51 -0
- package/src/browser.ts +4 -2
- 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/harness/shadow-builder.test.ts +4 -1
- package/src/harness/shadow-builder.ts +3 -1
- package/src/resolver.test.ts +267 -0
- package/src/resolver.ts +189 -90
- package/src/vendor/rrweb-record.min.js +2 -17
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { publishDnsRegistry, parseDnsRegistry } from "./dns-registry.js";
|
|
3
|
+
/** SDK-owned desired state. Updates are serialized and publish the whole view.
|
|
4
|
+
* Service bindings retain their target, so replacement IPs and stops also
|
|
5
|
+
* update ctx.dnsName aliases. State and the publication queue fork with the VM. */
|
|
6
|
+
export class ServiceDns {
|
|
7
|
+
publish;
|
|
8
|
+
services = new Map();
|
|
9
|
+
bindings = new Map();
|
|
10
|
+
pending = Promise.resolve();
|
|
11
|
+
constructor(publish = publishDnsRegistry) {
|
|
12
|
+
this.publish = publish;
|
|
13
|
+
}
|
|
14
|
+
update(change) {
|
|
15
|
+
const next = this.pending.then(async () => {
|
|
16
|
+
const services = this.services;
|
|
17
|
+
const bindings = this.bindings;
|
|
18
|
+
this.services = new Map(services);
|
|
19
|
+
this.bindings = new Map(bindings);
|
|
20
|
+
let doc;
|
|
21
|
+
try {
|
|
22
|
+
change();
|
|
23
|
+
doc = parseDnsRegistry(JSON.stringify(this.document()));
|
|
24
|
+
}
|
|
25
|
+
catch (error) {
|
|
26
|
+
this.services = services;
|
|
27
|
+
this.bindings = bindings;
|
|
28
|
+
throw error;
|
|
29
|
+
}
|
|
30
|
+
await this.publish(doc);
|
|
31
|
+
});
|
|
32
|
+
// Preserve desired state after failure; the caller gets an error and the
|
|
33
|
+
// next full publication repairs an uncertain/lost acknowledgement.
|
|
34
|
+
this.pending = next.catch(() => { });
|
|
35
|
+
return next;
|
|
36
|
+
}
|
|
37
|
+
document() {
|
|
38
|
+
const hosts = Object.create(null);
|
|
39
|
+
const suffixes = new Map();
|
|
40
|
+
const put = (name, ip) => {
|
|
41
|
+
if (name.startsWith("*."))
|
|
42
|
+
suffixes.set(name.slice(1), ip);
|
|
43
|
+
else
|
|
44
|
+
hosts[name] = ip;
|
|
45
|
+
};
|
|
46
|
+
for (const [name, svc] of this.services)
|
|
47
|
+
for (const alias of [name, `${name}.internal`, ...svc.aliases])
|
|
48
|
+
put(alias.toLowerCase(), svc.ip);
|
|
49
|
+
for (const [name, { target }] of this.bindings)
|
|
50
|
+
put(name, "service" in target ? this.services.get(target.service)?.ip ?? null : target.ip);
|
|
51
|
+
return { version: 1, generation: randomUUID(), hosts, wildcards: [...suffixes].map(([suffix, ip]) => ({ suffix, ip })) };
|
|
52
|
+
}
|
|
53
|
+
address(name) { return this.services.get(name)?.ip ?? null; }
|
|
54
|
+
reset(services, bindings) {
|
|
55
|
+
return this.update(() => {
|
|
56
|
+
this.services = new Map(services.map(svc => [svc.name, { aliases: svc.aliases, ip: null }]));
|
|
57
|
+
this.bindings = new Map(bindings.map(b => [b.name.toLowerCase(), { target: b.target, owner: "static" }]));
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
service(name, ip, aliases) {
|
|
61
|
+
return this.update(() => {
|
|
62
|
+
const previous = this.services.get(name);
|
|
63
|
+
// Retire removed aliases as owned/unavailable as well.
|
|
64
|
+
for (const old of previous?.aliases ?? []) {
|
|
65
|
+
if (aliases && !aliases.includes(old) && !this.bindings.has(old))
|
|
66
|
+
this.bindings.set(old, { target: { ip: null }, owner: `retired:${name}` });
|
|
67
|
+
}
|
|
68
|
+
for (const alias of aliases ?? []) {
|
|
69
|
+
if (this.bindings.get(alias)?.owner === `retired:${name}`)
|
|
70
|
+
this.bindings.delete(alias);
|
|
71
|
+
}
|
|
72
|
+
this.services.set(name, { ip, aliases: aliases ?? previous?.aliases ?? [] });
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
bind(name, target, owner = "explicit") {
|
|
76
|
+
return this.update(() => { this.bindings.set(name.toLowerCase(), { target, owner }); });
|
|
77
|
+
}
|
|
78
|
+
retire(name, owner) {
|
|
79
|
+
return this.update(() => {
|
|
80
|
+
const binding = this.bindings.get(name.toLowerCase());
|
|
81
|
+
if (binding?.owner === owner)
|
|
82
|
+
this.bindings.set(name.toLowerCase(), { target: { ip: null }, owner });
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
/** Serialize same-name start/stop operations, while different services can
|
|
87
|
+
* start concurrently. Prevents stale teardown deleting a replacement. */
|
|
88
|
+
export class ServiceOperations {
|
|
89
|
+
pending = new Map();
|
|
90
|
+
run(name, fn) {
|
|
91
|
+
const next = (this.pending.get(name) ?? Promise.resolve()).catch(() => { }).then(fn);
|
|
92
|
+
this.pending.set(name, next);
|
|
93
|
+
void next.finally(() => { if (this.pending.get(name) === next)
|
|
94
|
+
this.pending.delete(name); }).catch(() => { });
|
|
95
|
+
return next;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
@@ -19,8 +19,10 @@ export function shadowImage(c, service) {
|
|
|
19
19
|
const id = createHash("sha256").update(service).digest("hex").slice(0, 32);
|
|
20
20
|
return `${c.registry}/shadow/${c.experiment}/${id}:result`;
|
|
21
21
|
}
|
|
22
|
+
// Convert inherited gzip layers too; the persistent builder caches the conversion.
|
|
23
|
+
// Level 1 favors export latency over a small additional size reduction.
|
|
22
24
|
export function shadowOutput(reference) {
|
|
23
|
-
return `type=image,name=${reference},push=true,oci-mediatypes=true,compression=zstd`;
|
|
25
|
+
return `type=image,name=${reference},push=true,oci-mediatypes=true,compression=zstd,compression-level=1,force-compression=true`;
|
|
24
26
|
}
|
|
25
27
|
export function imageDigest(metadata) {
|
|
26
28
|
const digest = metadata?.["containerimage.digest"];
|
package/dist/resolver.js
CHANGED
|
@@ -1,34 +1,12 @@
|
|
|
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
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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 = (
|
|
214
|
+
const finish = (msg) => {
|
|
143
215
|
if (done)
|
|
144
216
|
return;
|
|
145
217
|
done = true;
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
catch {
|
|
150
|
-
// ignore
|
|
151
|
-
}
|
|
152
|
-
resolve(b);
|
|
218
|
+
clearTimeout(timer);
|
|
219
|
+
socket.destroy();
|
|
220
|
+
resolve(msg);
|
|
153
221
|
};
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
//
|
|
285
|
-
|
|
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?.();
|
|
@@ -1163,8 +1163,6 @@ function serializeElementNode(n, options) {
|
|
|
1163
1163
|
canvasCtx = canvasService.getContext("2d");
|
|
1164
1164
|
}
|
|
1165
1165
|
const image = n;
|
|
1166
|
-
const imageSrc = image.currentSrc || image.getAttribute("src") || "<unknown-src>";
|
|
1167
|
-
const priorCrossOrigin = image.crossOrigin;
|
|
1168
1166
|
const recordInlineImage = () => {
|
|
1169
1167
|
image.removeEventListener("load", recordInlineImage);
|
|
1170
1168
|
try {
|
|
@@ -1175,21 +1173,8 @@ function serializeElementNode(n, options) {
|
|
|
1175
1173
|
dataURLOptions.type,
|
|
1176
1174
|
dataURLOptions.quality
|
|
1177
1175
|
);
|
|
1178
|
-
} catch (
|
|
1179
|
-
|
|
1180
|
-
image.crossOrigin = "anonymous";
|
|
1181
|
-
if (image.complete && image.naturalWidth !== 0)
|
|
1182
|
-
recordInlineImage();
|
|
1183
|
-
else image.addEventListener("load", recordInlineImage);
|
|
1184
|
-
return;
|
|
1185
|
-
} else {
|
|
1186
|
-
console.warn(
|
|
1187
|
-
`Cannot inline img src=${imageSrc}! Error: ${err}`
|
|
1188
|
-
);
|
|
1189
|
-
}
|
|
1190
|
-
}
|
|
1191
|
-
if (image.crossOrigin === "anonymous") {
|
|
1192
|
-
priorCrossOrigin ? attributes.crossOrigin = priorCrossOrigin : image.removeAttribute("crossorigin");
|
|
1176
|
+
} catch (e) {
|
|
1177
|
+
return;
|
|
1193
1178
|
}
|
|
1194
1179
|
};
|
|
1195
1180
|
if (image.complete && image.naturalWidth !== 0) recordInlineImage();
|
package/package.json
CHANGED
|
@@ -14,10 +14,13 @@ test.skipIf(!existsSync(executablePath))("cross-site script ID reuse cannot attr
|
|
|
14
14
|
const map = Buffer.from(JSON.stringify({ version: 3, sources: ["src/customer.ts"], names: [], mappings: "AAAA" })).toString("base64");
|
|
15
15
|
const server = Bun.serve({ hostname: "0.0.0.0", port: 0, fetch(request) {
|
|
16
16
|
const url = new URL(request.url);
|
|
17
|
+
if (url.pathname === "/padding.js") return new Response("void 0;", { headers: { "content-type": "application/javascript" } });
|
|
17
18
|
if (url.pathname === emptyScript) return new Response("", { headers: { "content-type": "application/javascript" } });
|
|
18
19
|
if (url.pathname === "/app.js") return new Response(`globalThis.appRan = true;\n//# sourceMappingURL=data:application/json;base64,${map}`, { headers: { "content-type": "application/javascript" } });
|
|
19
20
|
return new Response(url.hostname === "127.0.0.1"
|
|
20
|
-
|
|
21
|
+
// Advance the application's script IDs past browser-injected scripts.
|
|
22
|
+
// Padding is served outside the covered service and has no source map.
|
|
23
|
+
? `${Array.from({ length: 32 }, (_, i) => `<script src="http://127.0.0.2:${url.port}/padding.js?${i}"></script>`).join("")}<script src="${emptyScript}"></script><script src="/app.js"></script><p>Application</p>`
|
|
21
24
|
: "<p>External login</p>", { headers: { "content-type": "text/html", "cache-control": "no-store" } });
|
|
22
25
|
} });
|
|
23
26
|
const browser = await chromium.launch({ executablePath, headless: true, args: ["--no-sandbox", "--site-per-process"] });
|
|
@@ -36,7 +39,12 @@ test.skipIf(!existsSync(executablePath))("cross-site script ID reuse cannot attr
|
|
|
36
39
|
// Keep collected application counts across navigation until service capture.
|
|
37
40
|
for (const host of ["127.0.0.1", "127.0.0.2", "127.0.0.1", "127.0.0.2"]) {
|
|
38
41
|
await page.goto(`http://${host}:${server.port}/`);
|
|
39
|
-
|
|
42
|
+
// V8's first evaluation ID varies across browser versions and runners.
|
|
43
|
+
// Generate distinct anonymous scripts until the old named ID is reused.
|
|
44
|
+
for (let i = 0; i < 128; i++) {
|
|
45
|
+
await cdp.send("Runtime.evaluate", { expression: `(() => { const value = ${i}; return value + 1; })()` });
|
|
46
|
+
if (host === "127.0.0.1" || anonymousReuse) break;
|
|
47
|
+
}
|
|
40
48
|
await harvestBrowserCoverage(cdp);
|
|
41
49
|
}
|
|
42
50
|
expect(anonymousReuse).toBe(true); // Prove the regression's trigger happened.
|