feat(core): recursive DNS server after Java, measured with Java FILIUS

The resolver runs on the events now (a query, its dnsQueryTimeout, its
answer at the socket); the blocking resolveDns waits for it, and a
recursive server uses it: its own records, else the zone's name server
from its records, else its own DNS server, else a referral
(DNSServer.answerWithRemoteData).

DnsRun plays five stories on the new dns-recursive network. Measured and
not taken: a server that is its zone's name server asks itself, which asks
itself again, without end (the client times out, then every level floods
its own DNS server); Java guards only the step through its own DNS server.
The core asks itself in neither step. That story is compared up to the
client's query (JAVA_UNTIL).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-30 18:46:32 +00:00
co-authored by Claude Opus 5.5
parent f5c61cdce7
commit 313786e738
13 changed files with 1570 additions and 91 deletions
+198 -52
View File
@@ -29,7 +29,8 @@
// or forwards ICMP); each takes one unit after the other and holds the rest
// while it waits for ARP. A socket's send blocks behind its thread.
// DNS after Java (filius.software.dns: DNSServer, DNSServerMitarbeiter,
// Resolver, DNSQueryAgent), not Swift's typed format; recursion not yet.
// Resolver, DNSQueryAgent), not Swift's typed format; a recursive server
// asks on without asking itself (Java does, without end).
// Java counterparts: filius.hardware.Verbindung, filius.software.netzzugangsschicht,
// filius.software.vermittlungsschicht (ARP, IP, ICMP), system.SwitchFirmware,
// filius.software.transportschicht (UDP, TCP), filius.software.dhcp.
@@ -41,7 +42,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, recursive DNS resolution.
// gateway NAT, RIP routing, remote links.
import { compareIds, type Id } from '../ids.js';
import { isPcClassEndpoint, stableGeneratedMacAddress } from '../model/node.js';
@@ -135,6 +136,8 @@ export type ScheduledEventKind =
/** The lookups waiting for an address wake up, after what else happens at that time. */
| { readonly kind: 'arpAnswered'; readonly nodeId: Id; readonly ipAddress: string; readonly macAddress: string }
| { readonly kind: 'tcpTimeout'; readonly sessionId: Id }
/** A DNS query waited five round trips for its answer (DNSQueryAgent). */
| { readonly kind: 'dnsQueryTimeout'; readonly socketId: Id }
| {
/** A frame that waited in the port's output buffer leaves: the cable was busy. */
readonly kind: 'ethernetLinkDeparture';
@@ -322,6 +325,10 @@ export type TcpConnectionResult = 'connected' | 'invalidSocket' | 'unreachable'
/** 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' };
/** A resolution as it goes on the events: the answer and authority records apart, as Java keeps them. */
type DnsLookup =
| { readonly type: 'answered'; readonly answers: readonly DnsMessageRecord[]; readonly authority: readonly DnsMessageRecord[] }
| { readonly type: 'timedOut' };
export type DnsAddressResolution = { readonly type: 'resolved'; readonly address: string | undefined } | { readonly type: 'timedOut' };
export const DHCP_SERVER_PORT = 67;
@@ -622,6 +629,8 @@ export class NetworkRuntimeEngine {
/** ARP lookups waiting for their answer (Java: a thread in holeARPTabellenEintrag). */
private arpLookups: ArpLookup[] = [];
private nextArpLookupId = 1;
/** DNS queries waiting for their answer, by the socket they were sent from. */
private dnsQueriesBySocketId = new Map<Id, (response: DnsMessage | undefined) => void>();
/** Per node and kind (`nodeId/udp`, `nodeId/icmp`, …). */
private protocolThreads = new Map<string, ProtocolThread>();
/** Per cable end (node and port): when the last frame sent there arrives. */
@@ -710,6 +719,7 @@ export class NetworkRuntimeEngine {
this.isProcessingScheduledEvents = false;
this.arpLookups = [];
this.protocolThreads.clear();
this.dnsQueriesBySocketId.clear();
state.pendingEvents = [];
state.arpCachesByNodeId.clear();
state.switchForwardingTablesByNodeId.clear();
@@ -908,6 +918,9 @@ export class NetworkRuntimeEngine {
case 'arpAnswered':
this.completeArpLookups(kind.nodeId, kind.ipAddress, kind.macAddress);
return;
case 'dnsQueryTimeout':
this.finishDnsQuery(kind.socketId, undefined);
return;
case 'tcpTimeout':
this.handleTcpTimeout(kind.sessionId);
return;
@@ -1233,6 +1246,11 @@ export class NetworkRuntimeEngine {
});
if (datagram.destinationPort === DHCP_CLIENT_PORT && this.readDhcpResponse(nodeId)) this.advanceDhcpClient(nodeId);
if (datagram.destinationPort === DNS_PORT) this.serveDnsQueries(nodeId);
for (const socket of matching) {
if (!this.dnsQueriesBySocketId.has(socket.id)) continue;
const received = this.receiveUdp(socket.id);
this.finishDnsQuery(socket.id, received === undefined ? undefined : decodeDnsMessage(new TextDecoder().decode(received.datagram.payload)));
}
}
// ── DNS (after Java's DNSServer and Resolver) ────────────────────────────
@@ -1242,39 +1260,103 @@ export class NetworkRuntimeEngine {
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. */
/**
* DNSServerMitarbeiter: each query answered to where it came from, with the
* query's ID, once every question has its records (a recursive server may
* have to ask on).
*/
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;
if (socketId === undefined || !state.topologySnapshot.dnsServerConfigurationsByNodeId.has(nodeId)) return;
for (let received = this.receiveUdp(socketId); received !== undefined; received = this.receiveUdp(socketId)) {
const client = { ipAddress: received.senderIPAddress, port: received.datagram.sourcePort };
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,
this.answerDnsQuestions(nodeId, query.questions, [], (answers) => {
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)), client);
});
}
}
/** The questions one after the other, as the worker loops over them. */
private answerDnsQuestions(
nodeId: Id,
questions: readonly DnsQuestion[],
answered: readonly DnsMessageRecord[],
done: (answers: DnsMessageRecord[]) => void,
): void {
const [question, ...rest] = questions;
if (question === undefined) {
done([...answered]);
return;
}
this.answerDnsQuestion(nodeId, question, (answers) => this.answerDnsQuestions(nodeId, rest, [...answered, ...answers], done));
}
/**
* DNSServer.answer: the server's own records for the question; else, when
* it resolves recursively, what it learns from others; else a referral
* (the NS record of the question's zone and its address).
*/
private answerDnsQuestion(nodeId: Id, question: DnsQuestion, done: (answers: DnsMessageRecord[]) => void): void {
const configuration = this.stateValue.topologySnapshot.dnsServerConfigurationsByNodeId.get(nodeId);
const records = (configuration?.records ?? []).map(messageRecord);
const local = localDnsAnswers(question, records);
if (local.length > 0 || configuration?.recursiveResolutionEnabled !== true) {
done(local.length > 0 ? local : dnsReferral(question, records));
return;
}
this.resolveDnsRemotely(nodeId, question, records, (remote) => done(remote.length > 0 ? remote : dnsReferral(question, records)));
}
/**
* DNSServer.answerWithRemoteData: resolves with the name server its records
* name for the question's zone, then, if that brings nothing, with its own
* DNS server. The records of the answer and authority sections; nothing
* when a server does not answer.
*
* Java asks its own DNS server only when that is not itself, but asks the
* zone's name server even then: a query to itself, which asks itself again,
* without end (measured: the client times out, then every level falls back
* to its own DNS server). The core asks neither when it is itself.
*/
private resolveDnsRemotely(nodeId: Id, question: DnsQuestion, records: readonly DnsMessageRecord[], done: (answers: DnsMessageRecord[]) => void): void {
const ownAddresses = new Set(this.networkInterfaces(nodeId).map((each) => each.ipAddress));
const resolveWith = (server: string | undefined, next: (answers: DnsMessageRecord[]) => void) => {
if (server === undefined || server.trim() === '' || ownAddresses.has(server)) {
next([]);
return;
}
this.beginDnsResolution(nodeId, question.name, question.type, server, (resolution) =>
next(resolution.type === 'answered' ? [...resolution.answers, ...resolution.authority] : []),
);
};
const nameServer = findApplicableNameServerRecord(question.name, records);
const nameServerAddress = nameServer === undefined ? undefined : findDnsRecords(nameServer.data, 'A', records)[0]?.data;
resolveWith(nameServerAddress, (answers) => {
if (answers.length > 0) {
done(answers);
return;
}
resolveWith(this.stateValue.topologySnapshot.deviceConfigurations.get(nodeId)?.dnsServer, done);
});
}
/**
* Java's Resolver.resolve, blocking on the virtual clock: asks the node's
* DNS server (or `serverIPAddress`), and follows the name servers it is
@@ -1283,35 +1365,69 @@ export class NetworkRuntimeEngine {
* does not answer within five round trips (Java: TimeOutException).
*/
resolveDns(nodeId: Id, name: string, type: string, serverIPAddress?: string): DnsResolution {
let resolution: DnsLookup | undefined;
const server = serverIPAddress ?? this.stateValue.topologySnapshot.deviceConfigurations.get(nodeId)?.dnsServer;
this.beginDnsResolution(nodeId, name, type, server, (result) => {
resolution = result;
});
// Each query ends within five round trips, by its answer or its timeout.
const window = (DNS_QUERY_TIMEOUT_ROUND_TRIPS + 1) * this.runtimeSpeed.roundTripTimeMilliseconds;
while (resolution === undefined && this.stateValue.phase === 'running' && this.stateValue.pendingEvents.length > 0) {
this.advanceUntil(() => resolution !== undefined, this.stateValue.currentTimeMilliseconds + window);
}
if (resolution === undefined || resolution.type === 'timedOut') return { type: 'timedOut' };
return { type: 'answered', records: [...resolution.answers, ...resolution.authority] };
}
/**
* Resolver.resolve as a chain of queries on the events: `done` with the
* records for the name and type from the answer and authority sections of
* the responses (and, for MX and NS, the address of the first), or
* timedOut when a server did not answer.
*/
private beginDnsResolution(nodeId: Id, name: string, type: string, server: string | undefined, done: (resolution: DnsLookup) => void): void {
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 }] };
done({ type: 'answered', answers: [{ name: 'localhost.', type: 'A', ttl: 3600, data: LOCALHOST_IP_ADDRESS }], authority: [] });
return;
}
const answers: DnsMessageRecord[] = [];
const authority: DnsMessageRecord[] = [];
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 ask = (serverToQuery: string | undefined): void => {
if (serverToQuery === undefined || serverToQuery === '') {
done({ type: 'answered', answers, authority });
return;
}
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 };
this.queryDnsServer(nodeId, domainName, type, serverToQuery, (response) => {
if (response === undefined) {
done({ type: 'timedOut' });
return;
}
answers.push(...findDnsRecords(domainName, type, response.answers));
authority.push(...findDnsRecords(domainName, type, response.authority));
const data = findDnsRecords(domainName, type, [...response.answers, ...response.authority, ...response.additional]);
if (data.length > 0) {
if (type === 'MX' || type === 'NS') {
answers.push(...findDnsRecords(data[0]!.data, 'A', response.answers));
authority.push(...findDnsRecords(data[0]!.data, 'A', response.authority));
}
done({ type: 'answered', answers, authority });
return;
}
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.
if (next === undefined || queried.has(next)) {
done({ type: 'answered', answers, authority });
return;
}
queried.add(next);
ask(next);
});
};
ask(server);
}
/** Resolver.holeIPAdresse: the A record's address, undefined when there is none. */
@@ -1331,10 +1447,16 @@ export class NetworkRuntimeEngine {
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 {
/**
* DNSQueryAgent.query: one question from a fresh socket; `done` with the
* first datagram back, or undefined after five round trips.
*/
private queryDnsServer(nodeId: Id, domainName: string, type: string, serverIPAddress: string, done: (response: DnsMessage | undefined) => void): void {
const socketId = this.bindUdpSocket(nodeId, { remoteIPAddress: serverIPAddress, remotePort: DNS_PORT });
if (socketId === undefined) return undefined;
if (socketId === undefined) {
done(undefined);
return;
}
const query: DnsMessage = {
// Java: (int) (Math.random() * 65536).
id: Math.floor(this.dnsIdRandom.nextDouble() * 65_536),
@@ -1347,9 +1469,28 @@ export class NetworkRuntimeEngine {
};
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);
if (!this.stateValue.socketsById.has(socketId)) {
done(undefined);
return;
}
// The wait begins when the send returns (empfangen after senden).
this.dnsQueriesBySocketId.set(socketId, done);
this.schedule(this.stateValue.currentTimeMilliseconds + DNS_QUERY_TIMEOUT_ROUND_TRIPS * this.runtimeSpeed.roundTripTimeMilliseconds, {
kind: 'dnsQueryTimeout',
socketId,
});
}
/** The query's answer came (or its time ran out): its socket closes, the resolution goes on. */
private finishDnsQuery(socketId: Id, response: DnsMessage | undefined): void {
const done = this.dnsQueriesBySocketId.get(socketId);
if (done === undefined) return;
this.dnsQueriesBySocketId.delete(socketId);
this.stateValue.pendingEvents = this.stateValue.pendingEvents.filter(
(event) => !(event.kind.kind === 'dnsQueryTimeout' && event.kind.socketId === socketId),
);
this.closeSocket(socketId);
return received === undefined ? undefined : decodeDnsMessage(new TextDecoder().decode(received.datagram.payload));
done(response);
}
// ── TCP sockets ──────────────────────────────────────────────────────────
@@ -4178,7 +4319,8 @@ function firewallAddressMatches(packetIPAddress: string, configuredIPAddress: st
* (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[] {
/** DNSServer.answerWithLocalData: the records for the question, an MX's or NS's with its address. */
function localDnsAnswers(question: DnsQuestion, records: readonly DnsMessageRecord[]): DnsMessageRecord[] {
const answers: DnsMessageRecord[] = [];
for (const record of findDnsRecords(question.name, question.type, records)) {
answers.push(record);
@@ -4187,7 +4329,11 @@ function answerDnsQuestion(question: DnsQuestion, records: readonly DnsMessageRe
if (address !== undefined) answers.push(address);
}
}
if (answers.length > 0) return answers;
return answers;
}
/** DNSServer.defineNameserverRecords: the NS record of the question's zone (or the root's) and its address. */
function dnsReferral(question: DnsQuestion, records: readonly DnsMessageRecord[]): DnsMessageRecord[] {
const nameServer = findApplicableNameServerRecord(question.name, records);
if (nameServer === undefined) return [];
const address = findDnsRecords(nameServer.data, 'A', records)[0];