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
+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(),
};