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:
2026-09-30 16:53:32 +00:00
co-authored by Claude Opus 5.5
parent 24ebcea67f
commit 8869c865b1
8 changed files with 588 additions and 4 deletions
+1
View File
@@ -6,6 +6,7 @@ export * from './model/link.js';
export * from './model/node.js';
export * from './model/virtualFileSystem.js';
export * from './runtime/config.js';
export * from './runtime/dns.js';
export * from './runtime/engine.js';
export * from './runtime/ipv4.js';
export * from './runtime/packets.js';
+142
View File
@@ -0,0 +1,142 @@
// Java FILIUS' DNS messages: filius.software.dns.DNSNachricht, Query and
// ResourceRecord. Swift replaced them with a typed format of its own
// (FILIUS-DNS-TYPED/1); students read Java's in the packet log, so the core
// speaks Java's, measured with tools/java-oracle's DnsRun.
//
// A message is text: a header line of `ID=… QR=… RCODE=… QDCOUNT=… ANCOUNT=…
// NSCOUNT=… ARCOUNT=… ` (with its final space), then a line per question
// (`name. TYPE IN`) and per record (`name. TYPE ttl data`), each ending in a
// line feed.
import type { DnsResourceRecord } from './config.js';
export const DNS_PORT = 53;
export const DNS_QUERY = 0;
export const DNS_RESPONSE = 1;
export interface DnsQuestion {
readonly name: string;
readonly type: string;
readonly questionClass: string;
}
/** A record as a message carries it: Java's ResourceRecord getters. */
export interface DnsMessageRecord {
readonly name: string;
readonly type: string;
readonly ttl: number;
readonly data: string;
}
export interface DnsMessage {
readonly id: number;
readonly queryResponse: number;
readonly responseCode: number;
readonly questions: readonly DnsQuestion[];
readonly answers: readonly DnsMessageRecord[];
readonly authority: readonly DnsMessageRecord[];
readonly additional: readonly DnsMessageRecord[];
}
/** DNSNachricht.toString. */
export function encodeDnsMessage(message: DnsMessage): string {
const lines = [
`ID=${message.id} QR=${message.queryResponse} RCODE=${message.responseCode} QDCOUNT=${message.questions.length} ` +
`ANCOUNT=${message.answers.length} NSCOUNT=${message.authority.length} ARCOUNT=${message.additional.length} `,
...message.questions.map((question) => `${question.name} ${question.type} ${question.questionClass}`),
...[...message.answers, ...message.authority, ...message.additional].map(recordText),
];
return lines.map((line) => `${line}\n`).join('');
}
function recordText(record: DnsMessageRecord): string {
return `${record.name} ${record.type} ${record.ttl} ${record.data}`;
}
/** Java's StringTokenizer: the pieces between the delimiters, empty ones left out. */
function tokens(text: string, delimiter: string): string[] {
return text.split(delimiter).filter((token) => token !== '');
}
/** Integer.parseInt as far as the engine's own messages need it; anything else is 0. */
function parseNumber(text: string): number {
return /^[+-]?\d+$/.test(text) ? Number.parseInt(text, 10) : 0;
}
/**
* DNSNachricht(String): the header's fields by their names, then as many
* question and record lines as the counts say.
*/
export function decodeDnsMessage(text: string): DnsMessage {
const lines = tokens(text, '\n');
const header = new Map<string, number>();
for (const token of tokens(lines[0] ?? '', ' ').map((each) => each.trim())) {
for (const name of ['ID', 'QR', 'RCODE', 'QDCOUNT', 'ANCOUNT', 'NSCOUNT', 'ARCOUNT']) {
if (token.startsWith(name)) header.set(name, parseNumber(token.slice(name.length + 1)));
}
}
let next = 1;
const take = <T>(count: number, parse: (line: string) => T): T[] => {
const items: T[] = [];
for (let index = 0; index < count && next < lines.length; index += 1) items.push(parse(lines[next++]!));
return items;
};
return {
id: header.get('ID') ?? 0,
queryResponse: header.get('QR') ?? DNS_QUERY,
responseCode: header.get('RCODE') ?? 0,
questions: take(header.get('QDCOUNT') ?? 0, decodeQuestion),
answers: take(header.get('ANCOUNT') ?? 0, decodeRecord),
authority: take(header.get('NSCOUNT') ?? 0, decodeRecord),
additional: take(header.get('ARCOUNT') ?? 0, decodeRecord),
};
}
function decodeQuestion(line: string): DnsQuestion {
const [name = '', type = '', questionClass = 'IN'] = tokens(line, ' ').map((each) => each.trim());
return { name, type, questionClass };
}
function decodeRecord(line: string): DnsMessageRecord {
const [name = '', type = 'A', ttl, data = ''] = tokens(line, ' ').map((each) => each.trim());
return { name, type, ttl: ttl === undefined ? 3600 : parseNumber(ttl), data };
}
/**
* A record of the model as Java's server sends it: the name with its final
* dot (the root "."), the target as it is (MX and NS carry their dot).
* The model keeps names in lower case; Java writes them as /dns/hosts has
* them, which is the same for a file written from the model.
*/
export function messageRecord(record: DnsResourceRecord): DnsMessageRecord {
return { name: record.name === '.' ? '.' : `${record.name}.`, type: record.type, ttl: record.ttlSeconds, data: record.target };
}
function sameName(lhs: string, rhs: string): boolean {
return lhs.toLowerCase() === rhs.toLowerCase();
}
/** ResourceRecord.findRecords: by name, ignoring case, and type. */
export function findDnsRecords<T extends DnsMessageRecord>(name: string, type: string, records: readonly T[]): T[] {
return records.filter((record) => sameName(record.name, name) && record.type === type);
}
/**
* ResourceRecord.findApplicableNSRecord: the NS record of the longest zone
* that `name` lies in, looked for label by label, else one of the root.
*/
export function findApplicableNameServerRecord<T extends DnsMessageRecord>(name: string, records: readonly T[]): T | undefined {
// Java's split drops the empty pieces at the end.
const parts = name.split('.');
while (parts.length > 0 && parts[parts.length - 1] === '') parts.pop();
for (let start = 0; start < parts.length; start += 1) {
const zone = parts
.slice(start)
.map((part) => `${part}.`)
.join('');
const found = records.find((record) => sameName(record.name, zone) && record.type === 'NS');
if (found !== undefined) return found;
}
return records.find((record) => record.name === '.' && record.type === 'NS');
}
+182 -2
View File
@@ -17,6 +17,8 @@
// processScheduledRuntimeEvent, dropForGlobalPacketLoss (lines ~4753–4850)
// A cable carries one frame after the other, as Java's SimplexVerbindung
// (Swift: all at once; the option swiftCables keeps that for the comparison).
// DNS after Java (filius.software.dns: DNSServer, DNSServerMitarbeiter,
// Resolver, DNSQueryAgent), not Swift's typed format; recursion not yet.
// Java counterparts: filius.hardware.Verbindung, filius.software.netzzugangsschicht,
// filius.software.vermittlungsschicht (ARP, IP, ICMP), system.SwitchFirmware,
// filius.software.transportschicht (UDP, TCP), filius.software.dhcp.
@@ -28,7 +30,7 @@
// advance the virtual clock inside the call, exactly as the Swift port does.
//
// Not ported yet — each place throws NotPortedError instead of guessing:
// gateway NAT, RIP routing, remote links.
// gateway NAT, RIP routing, remote links, recursive DNS resolution.
import { compareIds, type Id } from '../ids.js';
import { isPcClassEndpoint, stableGeneratedMacAddress } from '../model/node.js';
@@ -67,6 +69,19 @@ import {
type TcpSocketState,
type UdpDatagram,
} from './packets.js';
import {
decodeDnsMessage,
DNS_PORT,
DNS_QUERY,
DNS_RESPONSE,
encodeDnsMessage,
findApplicableNameServerRecord,
findDnsRecords,
messageRecord,
type DnsMessage,
type DnsMessageRecord,
type DnsQuestion,
} from './dns.js';
import { JavaCompatibleRandom } from './random.js';
import { routeTableRows, type RouteRow } from './routeTable.js';
import { EMPTY_TOPOLOGY_SNAPSHOT, withSnapshot, type NodeSnapshot, type TopologySnapshot } from './snapshot.js';
@@ -288,6 +303,11 @@ export interface TcpSession {
export type TcpConnectionResult = 'connected' | 'invalidSocket' | 'unreachable' | 'timedOut';
/** Java's DNSQueryAgent waits five round trips for an answer. */
export const DNS_QUERY_TIMEOUT_ROUND_TRIPS = 5;
export type DnsResolution = { readonly type: 'answered'; readonly records: readonly DnsMessageRecord[] } | { readonly type: 'timedOut' };
export type DnsAddressResolution = { readonly type: 'resolved'; readonly address: string | undefined } | { readonly type: 'timedOut' };
export const DHCP_SERVER_PORT = 67;
export const DHCP_CLIENT_PORT = 68;
/** Java: an offer, and a blacklisted address, is kept 4 round trip times. */
@@ -432,6 +452,8 @@ export interface RuntimeState {
dhcpBlacklistByServerNodeId: Map<Id, Map<string, number>>;
dhcpLastOfferedAddressByServerNodeId: Map<Id, string>;
dhcpServerSocketIdsByNodeId: Map<Id, Id>;
/** Each DNS server's socket on port 53. */
dnsServerSocketIdsByNodeId: Map<Id, Id>;
dhcpClientStatusesByNodeId: Map<Id, DhcpClientStatus>;
dhcpClientContextsByNodeId: Map<Id, DhcpClientContext>;
nextSocketSequenceNumber: number;
@@ -469,6 +491,7 @@ function initialState(seed: bigint): RuntimeState {
dhcpBlacklistByServerNodeId: new Map(),
dhcpLastOfferedAddressByServerNodeId: new Map(),
dhcpServerSocketIdsByNodeId: new Map(),
dnsServerSocketIdsByNodeId: new Map(),
dhcpClientStatusesByNodeId: new Map(),
dhcpClientContextsByNodeId: new Map(),
nextSocketSequenceNumber: 1,
@@ -535,6 +558,7 @@ export class NetworkRuntimeEngine {
private udpPortRandom: JavaCompatibleRandom;
private tcpPortRandom: JavaCompatibleRandom;
private linkLocalRandom: JavaCompatibleRandom;
private dnsIdRandom: JavaCompatibleRandom;
private runtimeSpeed: RuntimeSpeed;
private readonly packetTraceRetentionPolicy: TraceRetentionPolicy;
private isProcessingScheduledEvents = false;
@@ -562,6 +586,7 @@ export class NetworkRuntimeEngine {
this.udpPortRandom = new JavaCompatibleRandom(normalizedSeed ^ 0x5544_5050n);
this.tcpPortRandom = new JavaCompatibleRandom(normalizedSeed ^ 0x5443_5050n);
this.linkLocalRandom = new JavaCompatibleRandom(normalizedSeed ^ 0x4c4c_4144n);
this.dnsIdRandom = new JavaCompatibleRandom(normalizedSeed ^ 0x444e_5349n);
this.runtimeSpeed = new RuntimeSpeed();
this.packetTraceRetentionPolicy = packetTraceRetentionPolicy;
this.stateValue.simulationSpeedPercent = this.runtimeSpeed.percent;
@@ -599,11 +624,13 @@ export class NetworkRuntimeEngine {
this.udpPortRandom = new JavaCompatibleRandom(seed ^ 0x5544_5050n);
this.tcpPortRandom = new JavaCompatibleRandom(seed ^ 0x5443_5050n);
this.linkLocalRandom = new JavaCompatibleRandom(seed ^ 0x4c4c_4144n);
this.dnsIdRandom = new JavaCompatibleRandom(seed ^ 0x444e_5349n);
this.isProcessingScheduledEvents = false;
this.pendingIPv4TransmissionsByArpKey.clear();
this.cableBusyUntilByPort.clear();
this.initializeRipState();
this.initializeDhcpState();
this.initializeDnsState();
this.initializeNatState();
return [this.output({ type: 'started' })];
}
@@ -627,6 +654,7 @@ export class NetworkRuntimeEngine {
state.tcpSessionsById.clear();
state.tcpAcceptedSocketIdsByListenerId.clear();
this.clearDhcpState();
state.dnsServerSocketIdsByNodeId.clear();
state.firewallDecisions = [];
return [this.output({ type: 'stopped' })];
}
@@ -946,10 +974,23 @@ export class NetworkRuntimeEngine {
if (timeoutMilliseconds <= 0 || this.stateValue.phase !== 'running') return undefined;
const deadline = this.stateValue.currentTimeMilliseconds + timeoutMilliseconds;
if (!Number.isSafeInteger(deadline)) return undefined;
this.advance(deadline);
// Java's empfangen returns as soon as a datagram is there, else at the deadline.
this.advanceUntil(() => (this.stateValue.udpReceiveQueuesBySocketId.get(socketId)?.length ?? 0) > 0, deadline);
return next();
}
/** Advances event by event until the condition holds, at most to the deadline. */
private advanceUntil(condition: () => boolean, deadlineMilliseconds: number): void {
while (this.stateValue.phase === 'running' && !condition()) {
const next = this.earliestDeadline(() => true);
if (next === undefined || next > deadlineMilliseconds) {
this.advance(deadlineMilliseconds);
return;
}
this.advance(next);
}
}
/** Sends from a UDP socket to its remote end, or to the given one. */
sendUdp(
socketId: Id,
@@ -1066,6 +1107,124 @@ export class NetworkRuntimeEngine {
detail: matching.length === 0 ? 'UDP destination port unbound' : 'UDP queued',
});
if (datagram.destinationPort === DHCP_CLIENT_PORT && this.readDhcpResponse(nodeId)) this.advanceDhcpClient(nodeId);
if (datagram.destinationPort === DNS_PORT) this.serveDnsQueries(nodeId);
}
// ── DNS (after Java's DNSServer and Resolver) ────────────────────────────
/** Every DNS server switched on listens on port 53. */
private initializeDnsState(): void {
const state = this.stateValue;
state.dnsServerSocketIdsByNodeId.clear();
for (const [nodeId, configuration] of state.topologySnapshot.dnsServerConfigurationsByNodeId) {
if (configuration.recursiveResolutionEnabled) throw new NotPortedError('recursive DNS resolution');
const socketId = this.bindUdpSocket(nodeId, { localPort: DNS_PORT });
if (socketId !== undefined) state.dnsServerSocketIdsByNodeId.set(nodeId, socketId);
}
}
/** DNSServerMitarbeiter: each query answered to where it came from, with the query's ID. */
private serveDnsQueries(nodeId: Id): void {
const state = this.stateValue;
const socketId = state.dnsServerSocketIdsByNodeId.get(nodeId);
const configuration = state.topologySnapshot.dnsServerConfigurationsByNodeId.get(nodeId);
if (socketId === undefined || configuration === undefined) return;
for (let received = this.receiveUdp(socketId); received !== undefined; received = this.receiveUdp(socketId)) {
const query = decodeDnsMessage(new TextDecoder().decode(received.datagram.payload));
const records = configuration.records.map(messageRecord);
const answers = query.questions.flatMap((question) => answerDnsQuestion(question, records));
const response: DnsMessage = {
id: query.id,
queryResponse: DNS_RESPONSE,
responseCode: 0,
questions: [],
answers,
authority: [],
additional: [],
};
this.recordTrace({ nodeId, direction: 'outbound', layer: 'application', operation: 'sent', detail: 'DNS response' });
this.sendUdp(socketId, new TextEncoder().encode(encodeDnsMessage(response)), {
ipAddress: received.senderIPAddress,
port: received.datagram.sourcePort,
});
}
}
/**
* Java's Resolver.resolve, blocking on the virtual clock: asks the node's
* DNS server (or `serverIPAddress`), and follows the name servers it is
* referred to, each asked once, until one answers for the name and type.
* The records for the name and type it collected; timedOut when a server
* does not answer within five round trips (Java: TimeOutException).
*/
resolveDns(nodeId: Id, name: string, type: string, serverIPAddress?: string): DnsResolution {
const domainName = name.endsWith('.') ? name : `${name}.`;
const collected: DnsMessageRecord[] = [];
if (/^localhost\.?$/i.test(domainName)) {
return { type: 'answered', records: [{ name: 'localhost.', type: 'A', ttl: 3600, data: LOCALHOST_IP_ADDRESS }] };
}
const queried = new Set<string>();
let server: string | undefined = serverIPAddress ?? this.stateValue.topologySnapshot.deviceConfigurations.get(nodeId)?.dnsServer;
while (server !== undefined && server !== '') {
const response = this.queryDnsServer(nodeId, domainName, type, server);
if (response === undefined) return { type: 'timedOut' };
const relevant = (records: readonly DnsMessageRecord[], wanted: string, wantedType: string) => findDnsRecords(wanted, wantedType, records);
collected.push(...relevant(response.answers, domainName, type), ...relevant(response.authority, domainName, type));
const data = [...response.answers, ...response.authority, ...response.additional].filter(
(record) => record.name.toLowerCase() === domainName.toLowerCase() && record.type === type,
);
if (data.length > 0) {
if (type === 'MX' || type === 'NS') {
collected.push(...relevant(response.answers, data[0]!.data, 'A'), ...relevant(response.authority, data[0]!.data, 'A'));
}
break;
}
const all = [...response.answers, ...response.authority, ...response.additional];
const nameServer = findApplicableNameServerRecord(domainName, all);
const next = nameServer === undefined ? undefined : findDnsRecords(nameServer.data, 'A', all)[0]?.data;
// Java remembers only the servers it was referred to, not the first one.
server = next !== undefined && !queried.has(next) ? next : undefined;
if (next !== undefined) queried.add(next);
}
return { type: 'answered', records: collected };
}
/** Resolver.holeIPAdresse: the A record's address, undefined when there is none. */
resolveDnsAddress(nodeId: Id, name: string): DnsAddressResolution {
const domainName = name.endsWith('.') ? name : `${name}.`;
const resolution = this.resolveDns(nodeId, domainName, 'A');
if (resolution.type === 'timedOut') return resolution;
return { type: 'resolved', address: findDnsRecords(domainName, 'A', resolution.records)[0]?.data };
}
/** Resolver.holeIPAdresseMailServer: the address of the domain's mail exchange. */
resolveDnsMailServer(nodeId: Id, domain: string): DnsAddressResolution {
const domainName = domain.endsWith('.') ? domain : `${domain}.`;
const resolution = this.resolveDns(nodeId, domainName, 'MX');
if (resolution.type === 'timedOut') return resolution;
const exchange = findDnsRecords(domainName, 'MX', resolution.records)[0];
return { type: 'resolved', address: exchange === undefined ? undefined : findDnsRecords(exchange.data, 'A', resolution.records)[0]?.data };
}
/** DNSQueryAgent.query: one question from a fresh socket, the first datagram back within five round trips. */
private queryDnsServer(nodeId: Id, domainName: string, type: string, serverIPAddress: string): DnsMessage | undefined {
const socketId = this.bindUdpSocket(nodeId, { remoteIPAddress: serverIPAddress, remotePort: DNS_PORT });
if (socketId === undefined) return undefined;
const query: DnsMessage = {
// Java: (int) (Math.random() * 65536).
id: Math.floor(this.dnsIdRandom.nextDouble() * 65_536),
queryResponse: DNS_QUERY,
responseCode: 0,
questions: [{ name: domainName, type, questionClass: 'IN' }],
answers: [],
authority: [],
additional: [],
};
this.recordTrace({ nodeId, direction: 'outbound', layer: 'application', operation: 'sent', detail: `DNS query ${domainName} ${type}` });
this.sendUdp(socketId, new TextEncoder().encode(encodeDnsMessage(query)));
const received = this.receiveUdp(socketId, DNS_QUERY_TIMEOUT_ROUND_TRIPS * this.runtimeSpeed.roundTripTimeMilliseconds);
this.closeSocket(socketId);
return received === undefined ? undefined : decodeDnsMessage(new TextDecoder().decode(received.datagram.payload));
}
// ── TCP sockets ──────────────────────────────────────────────────────────
@@ -3716,6 +3875,27 @@ function firewallAddressMatches(packetIPAddress: string, configuredIPAddress: st
return sameSubnet(packetIPAddress, configuredIPAddress, subnetMask);
}
/**
* DNSServer.answer without recursion: the records for the name and type
* (with the A record of an MX or NS target), else the name server of the
* zone the name lies in, with its A record — in the answer section, as Java has it.
*/
function answerDnsQuestion(question: DnsQuestion, records: readonly DnsMessageRecord[]): DnsMessageRecord[] {
const answers: DnsMessageRecord[] = [];
for (const record of findDnsRecords(question.name, question.type, records)) {
answers.push(record);
if (record.type === 'MX' || record.type === 'NS') {
const address = findDnsRecords(record.data, 'A', records)[0];
if (address !== undefined) answers.push(address);
}
}
if (answers.length > 0) return answers;
const nameServer = findApplicableNameServerRecord(question.name, records);
if (nameServer === undefined) return [];
const address = findDnsRecords(nameServer.data, 'A', records)[0];
return address === undefined ? [nameServer] : [nameServer, address];
}
function portKey(nodeId: Id, portId: Id): string {
return `${nodeId}|${portId}`;
}
+5
View File
@@ -11,6 +11,7 @@ import type {
DeviceConfiguration,
DhcpClientConfiguration,
DhcpServerConfiguration,
DnsServerConfiguration,
FirewallConfiguration,
InterfaceConfiguration,
InterfaceKey,
@@ -55,6 +56,8 @@ export interface TopologySnapshot {
readonly ripEnabledByNodeId: ReadonlyMap<Id, boolean>;
readonly dhcpClientConfigurationsByNodeId: ReadonlyMap<Id, DhcpClientConfiguration>;
readonly dhcpServerConfigurationsByNodeId: ReadonlyMap<Id, DhcpServerConfiguration>;
/** The DNS servers switched on (Java: aktiv), by node. */
readonly dnsServerConfigurationsByNodeId: ReadonlyMap<Id, DnsServerConfiguration>;
readonly firewallConfigurationsByNodeId: ReadonlyMap<Id, FirewallConfiguration>;
readonly switchConfigurationsByNodeId: ReadonlyMap<Id, SwitchConfiguration>;
}
@@ -68,6 +71,7 @@ export interface TopologySnapshotInput {
ripEnabledByNodeId?: ReadonlyMap<Id, boolean>;
dhcpClientConfigurationsByNodeId?: ReadonlyMap<Id, DhcpClientConfiguration>;
dhcpServerConfigurationsByNodeId?: ReadonlyMap<Id, DhcpServerConfiguration>;
dnsServerConfigurationsByNodeId?: ReadonlyMap<Id, DnsServerConfiguration>;
firewallConfigurationsByNodeId?: ReadonlyMap<Id, FirewallConfiguration>;
switchConfigurationsByNodeId?: ReadonlyMap<Id, SwitchConfiguration>;
}
@@ -82,6 +86,7 @@ export function topologySnapshot(input: TopologySnapshotInput): TopologySnapshot
ripEnabledByNodeId: input.ripEnabledByNodeId ?? new Map(),
dhcpClientConfigurationsByNodeId: input.dhcpClientConfigurationsByNodeId ?? new Map(),
dhcpServerConfigurationsByNodeId: input.dhcpServerConfigurationsByNodeId ?? new Map(),
dnsServerConfigurationsByNodeId: input.dnsServerConfigurationsByNodeId ?? new Map(),
firewallConfigurationsByNodeId: input.firewallConfigurationsByNodeId ?? new Map(),
switchConfigurationsByNodeId: input.switchConfigurationsByNodeId ?? new Map(),
};
+96
View File
@@ -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);
}
});
}
});
+137
View File
@@ -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);
});
});
+21
View File
@@ -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,
});
}