feat(core): DNS after Java, measured with Java FILIUS
DNS follows Java's DNSServer and Resolver, not Swift's typed format: Java's text messages (dns.ts), a server on port 53 answering from its records (no question in the answer, RCODE 0, a referral with NS and A in the answer section), an iterative resolver that waits five round trips per query and remembers only the servers it was referred to. Recursion throws NotPortedError until the next step. The snapshot carries the DNS servers switched on. receiveUdp returns as soon as a datagram is there, as Java's empfangen does. dnsJava.test.ts compares DnsRun's five lookups; four match frame by frame, the missing server waits for Java's ARP (named as not yet like Java). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,96 @@
|
||||
// DNS as Java FILIUS runs it. tools/java-oracle's DnsRun called a client's
|
||||
// resolver on dns-lan's network in Java FILIUS and wrote every frame each
|
||||
// card saw and what the call returned (dns-java-<story>.java.json). The
|
||||
// engine makes the same call: the frames must come in the same order, with
|
||||
// the same content, at about the same distance from each other (javaRun.ts),
|
||||
// the call must return the same, after about as long.
|
||||
//
|
||||
// Java draws each message's ID with Math.random and a client's free port with
|
||||
// its random; both are compared as placeholders, and an answer must carry the
|
||||
// ID of its question.
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { DnsAddressResolution, NetworkRuntimeEngine } from '../src/index.js';
|
||||
import { GAP_TOLERANCE_MILLISECONDS, gaps, javaFrames, watchedEngine, type JavaFrame, type SeenFrame } from './javaRun.js';
|
||||
import type { Scenario } from './scenario.js';
|
||||
|
||||
const scenarioDirectory = join(dirname(fileURLToPath(import.meta.url)), 'scenarios');
|
||||
const CLIENT = '00000000-0000-0000-0000-000000000101';
|
||||
const LOST_CLIENT = '00000000-0000-0000-0000-000000000104';
|
||||
|
||||
interface JavaRun {
|
||||
frames: Record<string, JavaFrame[]>;
|
||||
events: [number, string][];
|
||||
}
|
||||
|
||||
const STORIES: Record<string, (engine: NetworkRuntimeEngine) => DnsAddressResolution> = {
|
||||
address: (engine) => engine.resolveDnsAddress(CLIENT, 'web.filius.de'),
|
||||
mail: (engine) => engine.resolveDnsMailServer(CLIENT, 'filius.de'),
|
||||
referral: (engine) => engine.resolveDnsAddress(CLIENT, 'www.schule.de'),
|
||||
unknown: (engine) => engine.resolveDnsAddress(CLIENT, 'x.unbekannt.org'),
|
||||
noServer: (engine) => engine.resolveDnsAddress(LOST_CLIENT, 'web.filius.de'),
|
||||
};
|
||||
|
||||
/** Java's words for the result: the address, null, or the exception. */
|
||||
function javaResult(resolution: DnsAddressResolution): string {
|
||||
return resolution.type === 'timedOut' ? 'failed TimeOutException' : `returned ${resolution.address ?? 'null'}`;
|
||||
}
|
||||
|
||||
/** IDs and free ports as placeholders; each ID stands for the question it first appeared in. */
|
||||
function normalized(frames: readonly SeenFrame[]): SeenFrame[] {
|
||||
const ids = new Map<string, string>();
|
||||
return frames.map((frame) => ({
|
||||
time: frame.time,
|
||||
text: frame.text
|
||||
.replace(/ID=(\d+)/, (_, id: string) => {
|
||||
if (!ids.has(id)) ids.set(id, `q${ids.size + 1}`);
|
||||
return `ID=${ids.get(id)}`;
|
||||
})
|
||||
.replace(/:(\d{5})\b/g, (whole, port: string) => (Number(port) >= 49_152 ? ':client' : whole)),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Stories the engine does not play like Java yet, with the reason; each must
|
||||
* still differ from Java (else it belongs to the others) and return the same.
|
||||
*
|
||||
* noServer: Java's transport thread sends while the resolver already waits,
|
||||
* and asks ARP twice, a round trip apart. The engine's ARP (Swift's) blocks
|
||||
* the call and asks again at once — rebuilt after Java in its own step.
|
||||
*/
|
||||
const NOT_YET_LIKE_JAVA: Record<string, string> = {
|
||||
noServer: "ARP: Java's second request comes a round trip after the first, while the resolver waits",
|
||||
};
|
||||
|
||||
describe('DNS as Java FILIUS runs it', () => {
|
||||
const scenario = JSON.parse(readFileSync(join(scenarioDirectory, 'dns-lan.scenario.json'), 'utf8')) as Scenario;
|
||||
|
||||
for (const [name, story] of Object.entries(STORIES)) {
|
||||
it(name, () => {
|
||||
const java = JSON.parse(readFileSync(join(scenarioDirectory, `dns-java-${name}.java.json`), 'utf8')) as JavaRun;
|
||||
const { engine, frames } = watchedEngine(scenario);
|
||||
engine.handle({ type: 'advance', toMilliseconds: 500 });
|
||||
const called = engine.state.currentTimeMilliseconds;
|
||||
const result = story(engine);
|
||||
const returned = engine.state.currentTimeMilliseconds;
|
||||
|
||||
const [[javaCalled], [javaReturned, javaOutcome]] = java.events as [[number, string], [number, string]];
|
||||
expect(`${javaOutcome.split(' ').slice(-2).join(' ')}`).toBe(javaResult(result).split(' ').slice(-2).join(' '));
|
||||
|
||||
const expected = gaps(normalized(javaFrames(java.frames)));
|
||||
const actual = gaps(normalized(frames()));
|
||||
if (name in NOT_YET_LIKE_JAVA) {
|
||||
expect(JSON.stringify(actual), NOT_YET_LIKE_JAVA[name]).not.toBe(JSON.stringify(expected));
|
||||
return;
|
||||
}
|
||||
expect(Math.abs(returned - called - (javaReturned - javaCalled)), 'duration of the call').toBeLessThanOrEqual(GAP_TOLERANCE_MILLISECONDS);
|
||||
for (let index = 0; index < Math.max(expected.length, actual.length); index += 1) {
|
||||
expect(actual[index]?.text, `frame #${index + 1}`).toBe(expected[index]?.text);
|
||||
expect(Math.abs(actual[index]!.gap - expected[index]!.gap), `gap before frame #${index + 1}`).toBeLessThanOrEqual(GAP_TOLERANCE_MILLISECONDS);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -8,6 +8,10 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
decodeDhcpMessage,
|
||||
decodeDnsMessage,
|
||||
encodeDnsMessage,
|
||||
findApplicableNameServerRecord,
|
||||
NotPortedError,
|
||||
DHCP_DYNAMIC_LEASE_LIFETIME_MILLISECONDS,
|
||||
deviceConfiguration,
|
||||
dhcpServerConfiguration,
|
||||
@@ -1394,3 +1398,136 @@ describe('DHCP', () => {
|
||||
expect(decodeDhcpMessage(new TextEncoder().encode('HELLO'))).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('DNS', () => {
|
||||
const lan = (records: Record<string, string[]>, recursive = false) => {
|
||||
// Hosts 10.30.0.1 (the client, DNS server .2), .2, .3 on one switch.
|
||||
const switchId = uuid(0x1900);
|
||||
const hosts = [1, 2, 3].map((index) => ({ id: uuid(0x1900 + 2 * index), portId: uuid(0x1901 + 2 * index), switchPortId: uuid(0x1910 + index) }));
|
||||
const ip = (index: number) => `10.30.0.${index + 1}`;
|
||||
const record = (line: string) => {
|
||||
const [name = '', type = 'A', target = ''] = line.split(' ');
|
||||
return { name, type: type as 'A' | 'MX' | 'NS', ttlSeconds: 3600, target };
|
||||
};
|
||||
const engine = startedEngine({
|
||||
nodes: [
|
||||
{ id: switchId, kind: 'networkSwitch', ports: hosts.map((host, index) => portSnapshot(host.switchPortId, `sw${index + 1}`)) },
|
||||
...hosts.map((host) => pc(host.id, host.portId)),
|
||||
],
|
||||
links: hosts.map((host) => link(host.id, host.portId, switchId, host.switchPortId)),
|
||||
deviceConfigurations: new Map(hosts.map((host, index) => [host.id, deviceConfiguration(ip(index), '255.255.255.0', '', index === 0 ? '10.30.0.2' : '')])),
|
||||
dnsServerConfigurationsByNodeId: new Map(
|
||||
Object.entries(records).map(([address, lines]) => [
|
||||
hosts[Number(address.split('.')[3]) - 1]!.id,
|
||||
{ recursiveResolutionEnabled: recursive, records: lines.map(record) },
|
||||
]),
|
||||
),
|
||||
});
|
||||
return { engine, client: hosts[0]!.id };
|
||||
};
|
||||
const queries = (engine: NetworkRuntimeEngine) =>
|
||||
engine.state.packetTraces.filter((trace) => trace.detail?.startsWith('DNS query') === true).map((trace) => trace.detail);
|
||||
|
||||
it("writes and reads Java's DNS messages", () => {
|
||||
const message = {
|
||||
id: 4711,
|
||||
queryResponse: 1,
|
||||
responseCode: 0,
|
||||
questions: [],
|
||||
answers: [
|
||||
{ name: 'filius.de.', type: 'MX', ttl: 3600, data: 'mail.filius.de.' },
|
||||
{ name: 'mail.filius.de.', type: 'A', ttl: 3600, data: '10.0.0.5' },
|
||||
],
|
||||
authority: [],
|
||||
additional: [],
|
||||
};
|
||||
const text = encodeDnsMessage(message);
|
||||
expect(text).toBe(
|
||||
'ID=4711 QR=1 RCODE=0 QDCOUNT=0 ANCOUNT=2 NSCOUNT=0 ARCOUNT=0 \nfilius.de. MX 3600 mail.filius.de.\nmail.filius.de. A 3600 10.0.0.5\n',
|
||||
);
|
||||
expect(decodeDnsMessage(text)).toEqual(message);
|
||||
// Java's StringTokenizer skips empty pieces; the counts say how many lines follow.
|
||||
expect(decodeDnsMessage('ID=7 QR=0 QDCOUNT=1\n\nweb.filius.de. A IN\nleft over\n')).toEqual({
|
||||
id: 7,
|
||||
queryResponse: 0,
|
||||
responseCode: 0,
|
||||
questions: [{ name: 'web.filius.de.', type: 'A', questionClass: 'IN' }],
|
||||
answers: [],
|
||||
authority: [],
|
||||
additional: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('finds the name server of the longest zone, else the root one', () => {
|
||||
const records = [
|
||||
{ name: '.', type: 'NS', ttl: 1, data: 'root.' },
|
||||
{ name: 'de.', type: 'NS', ttl: 1, data: 'ns.de.' },
|
||||
{ name: 'Schule.DE.', type: 'NS', ttl: 1, data: 'ns.schule.de.' },
|
||||
];
|
||||
expect(findApplicableNameServerRecord('www.schule.de.', records)?.data).toBe('ns.schule.de.');
|
||||
expect(findApplicableNameServerRecord('www.filius.de.', records)?.data).toBe('ns.de.');
|
||||
expect(findApplicableNameServerRecord('www.filius.org.', records)?.data).toBe('root.');
|
||||
expect(findApplicableNameServerRecord('www.filius.org.', records.slice(1))).toBeUndefined();
|
||||
});
|
||||
|
||||
it('answers localhost itself, and nothing without a DNS server', () => {
|
||||
const { engine, client } = lan({ '10.30.0.2': [] });
|
||||
expect(engine.resolveDnsAddress(client, 'LocalHost')).toEqual({ type: 'resolved', address: '127.0.0.1' });
|
||||
expect(engine.resolveDnsAddress(uuid(0x1904), 'web.filius.de')).toEqual({ type: 'resolved', address: undefined });
|
||||
expect(queries(engine)).toEqual([]);
|
||||
});
|
||||
|
||||
it('follows referrals, and asks the first server once more (Java remembers only the ones it was referred to)', () => {
|
||||
const { engine, client } = lan({
|
||||
'10.30.0.2': ['schule.de NS ns.schule.de.', 'ns.schule.de A 10.30.0.3'],
|
||||
'10.30.0.3': ['schule.de NS ns2.schule.de.', 'ns2.schule.de A 10.30.0.2'],
|
||||
});
|
||||
|
||||
expect(engine.resolveDnsAddress(client, 'www.schule.de')).toEqual({ type: 'resolved', address: undefined });
|
||||
expect(queries(engine)).toEqual(['DNS query www.schule.de. A', 'DNS query www.schule.de. A', 'DNS query www.schule.de. A']);
|
||||
expect([...engine.state.socketsById.values()].filter((socket) => socket.nodeId === client)).toEqual([]);
|
||||
});
|
||||
|
||||
it('receives a datagram when it comes, not at the end of the wait', () => {
|
||||
// A DHCP client without a server sends its DISCOVER again after a round trip (a timer, not the network).
|
||||
const [clientId, clientPortId, listenerId, listenerPortId] = [0x1981, 0x1982, 0x1983, 0x1984].map(uuid) as [string, string, string, string];
|
||||
const engine = startedEngine({
|
||||
nodes: [pc(clientId, clientPortId), pc(listenerId, listenerPortId)],
|
||||
links: [link(clientId, clientPortId, listenerId, listenerPortId)],
|
||||
deviceConfigurations: new Map([
|
||||
[clientId, device('0.0.0.0')],
|
||||
[listenerId, device('10.31.0.2')],
|
||||
]),
|
||||
dhcpClientConfigurationsByNodeId: new Map([[clientId, { isEnabled: true }]]),
|
||||
});
|
||||
const socket = engine.bindUdpSocket(listenerId, { localPort: 67 })!;
|
||||
engine.handle({ type: 'advance', toMilliseconds: 100 });
|
||||
expect(engine.receiveUdp(socket)).toBeDefined();
|
||||
|
||||
expect(engine.receiveUdp(socket, 10_000)).toBeDefined();
|
||||
expect(engine.state.currentTimeMilliseconds).toBe(2_750 + 55);
|
||||
});
|
||||
|
||||
it('returns from the answer, not at the end of the wait', () => {
|
||||
const { engine, client } = lan({ '10.30.0.2': ['web.filius.de A 10.30.0.9'] });
|
||||
const called = engine.state.currentTimeMilliseconds;
|
||||
expect(engine.resolveDnsAddress(client, 'web.filius.de')).toEqual({ type: 'resolved', address: '10.30.0.9' });
|
||||
// ARP there and back, then the query and its answer: four cables each way through the switch.
|
||||
expect(engine.state.currentTimeMilliseconds - called).toBe(4 * 110);
|
||||
});
|
||||
|
||||
it('gives up after five round trips when no server answers, and closes its socket', () => {
|
||||
const { engine, client } = lan({ '10.30.0.2': [] });
|
||||
const called = engine.state.currentTimeMilliseconds;
|
||||
// 10.30.0.3 is there, but no DNS server runs on it.
|
||||
expect(engine.resolveDns(client, 'web.filius.de', 'A', '10.30.0.3')).toEqual({ type: 'timedOut' });
|
||||
// Java's five round trips; before them the engine's send still blocks for
|
||||
// ARP and the query's way to .3 (Java waits from handing it down — the ARP step).
|
||||
expect(engine.state.currentTimeMilliseconds - called).toBe(3 * 110 + 5 * 2_750);
|
||||
expect([...engine.state.socketsById.values()].filter((socket) => socket.nodeId === client)).toEqual([]);
|
||||
});
|
||||
|
||||
it('does not answer recursively yet', () => {
|
||||
expect(() => lan({ '10.30.0.2': [] }, true)).toThrow(NotPortedError);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
type ManualRoute,
|
||||
type NodeKind,
|
||||
type TopologySnapshot,
|
||||
type DnsRecordType,
|
||||
} from '../src/index.js';
|
||||
|
||||
export interface Scenario {
|
||||
@@ -50,6 +51,8 @@ export interface Scenario {
|
||||
{ active?: boolean; lower?: string; upper?: string; gateway?: string; dns?: string; own?: boolean; staticAssignments?: [string, string][] }
|
||||
>;
|
||||
dhcpClients?: string[];
|
||||
/** Records as `name TYPE target` (Java's hinzuRecord: TTL 3600). */
|
||||
dnsServers?: Record<string, { records: string[]; recursive?: boolean }>;
|
||||
steps: {
|
||||
op: string;
|
||||
from?: string;
|
||||
@@ -164,6 +167,24 @@ export function snapshotOf(scenario: Scenario): TopologySnapshot {
|
||||
config.retention === undefined ? switchConfiguration(config.ssid) : switchConfiguration(config.ssid, config.retention),
|
||||
]),
|
||||
),
|
||||
dnsServerConfigurationsByNodeId: new Map(
|
||||
Object.entries(scenario.dnsServers ?? {}).map(([node, config]) => [
|
||||
node,
|
||||
{
|
||||
recursiveResolutionEnabled: config.recursive ?? false,
|
||||
records: config.records.map((line) => {
|
||||
const [name = '', type = 'A', target = ''] = line.split(' ');
|
||||
const nameServerOrMail = type === 'NS' || type === 'MX';
|
||||
return {
|
||||
name: name.replace(/\.$/, '').toLowerCase() || '.',
|
||||
type: type as DnsRecordType,
|
||||
ttlSeconds: 3600,
|
||||
target: nameServerOrMail && !target.endsWith('.') ? `${target}.` : target,
|
||||
};
|
||||
}),
|
||||
},
|
||||
]),
|
||||
),
|
||||
firewallConfigurationsByNodeId: firewalls,
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user