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
+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}`;
}