diff --git a/CHANGES.md b/CHANGES.md index e4a3343..49d54bf 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -13,12 +13,13 @@ Swift-Pfade relativ zu `filius-ipad/ios/FiliusPad/TopologyEditor/` (Stand | `src/model/link.ts`, `src/model/graph.ts` | `Model/TopologyLink.swift`, `Model/TopologyGraph.swift` | übertragen, bisher nur die Daten (Abfragen folgen mit dem Editor) | | `src/model/documentation.ts` | `Model/TopologyDocumentation.swift` | übertragen; FNV-Kennung mit BigInt, gegen die IDs aus dem Swift-Import geprüft | | `src/runtime/config.ts` | `State/TopologyEditorState.swift` (Laufzeit-Konfigurationen, WLAN eines Rechners, DHCP, Portweiterleitung, Remote Link ohne LAN-Transport, Programme und ihre Konfiguration, Firewall, DNS-Einträge aus `State/TopologyDNSResolver.swift`, E-Mail-Konten und -Nachrichten aus `State/TopologyEmail.swift`); dazu Echo-Server- und Client-Konfiguration, die Swift nicht speichert | übertragen; Java-Beschriftungen der Oberfläche weggelassen | -| `src/runtime/engine.ts` | `State/TopologyNetworkRuntimeEngine.swift` Z. ~313–3585, ~4472–5187 (UDP), ~4753–4850 | übertragen: Warteschlange, Uhr, Ethernet, Switch, ARP, IPv4, ICMP, Routing, Firewall, UDP-Sockets (binden, freie Ports nach Javas Zufall, senden samt Broadcast, zustellen, empfangen mit Wartezeit, schließen), TCP (Z. ~5187–5913: Server- und Client-Sockets, Handschlag mit Ergebnis, Segmente zu 1460 Byte bis zum PSH, höchstens drei Versuche, Abbau über alle Java-Zustände samt `TIME_WAIT`; seit F2.3c nach Javas `TCPSocket` an Java-Filius gemessen: je Versuch und für `TIME_WAIT` 3 RTT nach der Geschwindigkeit (8250 ms bei 90 %, Swift fest 7500 bzw. 2500 ms), der Zeitablauf zählt ab der Übergabe des Segments, vor ARP; ein Segment beendet `TIME_WAIT`; die Gegenseite schließt beim FIN von selbst; nicht bestätigte Daten schließen mit FIN statt sofort; der angenommene Socket hat die Startnummer des Listeners, der Listener eine neue. Abweichend von Java: drei SYN statt einem, weil Javas Wiederholungsschleife nach dem ersten Zeitablauf abbricht), DHCP (Z. ~5913–6694: Server mit festen Zuordnungen, Adressbereich, Angeboten, Leases und Sperrliste; Client seit F2.3b nach Javas `DHCPClient` neu gebaut und an Java-Filius gemessen: je Versuch eine Nachricht, eine Antwort, die nicht passt, wird gelesen und verworfen; die ARP-Prüfung wartet eine RTT, eine Antwort für die Adresse beendet sie sofort, dann DECLINE; nach einem Zeitablauf wiederholt der Zustand seine Nachricht, nach dem fünften nimmt der Client eine Link-Local-Adresse `169.254.1.x/16` (ARP-Probe, ARP-Reply an alle) und gibt keine alte Adresse zurück; Swift sendete doppelt, wartete nicht auf ARP und kehrte nach zehn Versuchen zur alten Adresse zurück. Abweichend von Java: Nach fünf belegten Link-Local-Adressen gibt der Client auf (Java sucht endlos), und ein ARP-Paket für eine andere Adresse beendet die Prüfung nicht. Seit F2.3d befördert ein Kabel einen Frame nach dem anderen wie Javas `SimplexVerbindung` (ein Frame wartet im Ausgangspuffer, bis der vorige angekommen ist, und erscheint im Protokoll, wenn er losgeht); Swift schickt alle zugleich, die Option `swiftCables` behält das für den Swift-Vergleich. Die RTT der DHCP-Zeiten (Warten, Angebote, Sperrliste) folgt wie in Java der Geschwindigkeit (50 Kabel, 2750 ms bei 90 %); Swift rechnet fest mit 2500 ms. Klasse statt Werttyp. Noch nicht übertragen (wirft `NotPortedError`): Gateway-NAT, DNS, RIP, Remote Link, WLAN | +| `src/runtime/engine.ts` | `State/TopologyNetworkRuntimeEngine.swift` Z. ~313–3585, ~4472–5187 (UDP), ~4753–4850 | übertragen: Warteschlange, Uhr, Ethernet, Switch, ARP, IPv4, ICMP, Routing, Firewall, UDP-Sockets (binden, freie Ports nach Javas Zufall, senden samt Broadcast, zustellen, empfangen mit Wartezeit, schließen), TCP (Z. ~5187–5913: Server- und Client-Sockets, Handschlag mit Ergebnis, Segmente zu 1460 Byte bis zum PSH, höchstens drei Versuche, Abbau über alle Java-Zustände samt `TIME_WAIT`; seit F2.3c nach Javas `TCPSocket` an Java-Filius gemessen: je Versuch und für `TIME_WAIT` 3 RTT nach der Geschwindigkeit (8250 ms bei 90 %, Swift fest 7500 bzw. 2500 ms), der Zeitablauf zählt ab der Übergabe des Segments, vor ARP; ein Segment beendet `TIME_WAIT`; die Gegenseite schließt beim FIN von selbst; nicht bestätigte Daten schließen mit FIN statt sofort; der angenommene Socket hat die Startnummer des Listeners, der Listener eine neue. Abweichend von Java: drei SYN statt einem, weil Javas Wiederholungsschleife nach dem ersten Zeitablauf abbricht), DHCP (Z. ~5913–6694: Server mit festen Zuordnungen, Adressbereich, Angeboten, Leases und Sperrliste; Client seit F2.3b nach Javas `DHCPClient` neu gebaut und an Java-Filius gemessen: je Versuch eine Nachricht, eine Antwort, die nicht passt, wird gelesen und verworfen; die ARP-Prüfung wartet eine RTT, eine Antwort für die Adresse beendet sie sofort, dann DECLINE; nach einem Zeitablauf wiederholt der Zustand seine Nachricht, nach dem fünften nimmt der Client eine Link-Local-Adresse `169.254.1.x/16` (ARP-Probe, ARP-Reply an alle) und gibt keine alte Adresse zurück; Swift sendete doppelt, wartete nicht auf ARP und kehrte nach zehn Versuchen zur alten Adresse zurück. Abweichend von Java: Nach fünf belegten Link-Local-Adressen gibt der Client auf (Java sucht endlos), und ein ARP-Paket für eine andere Adresse beendet die Prüfung nicht. Seit F2.3d befördert ein Kabel einen Frame nach dem anderen wie Javas `SimplexVerbindung` (ein Frame wartet im Ausgangspuffer, bis der vorige angekommen ist, und erscheint im Protokoll, wenn er losgeht); Swift schickt alle zugleich, die Option `swiftCables` behält das für den Swift-Vergleich. Die RTT der DHCP-Zeiten (Warten, Angebote, Sperrliste) folgt wie in Java der Geschwindigkeit (50 Kabel, 2750 ms bei 90 %); Swift rechnet fest mit 2500 ms. DNS seit F2.4a nach Javas `DNSServer`/`Resolver`, an Java-Filius gemessen: Server auf Port 53 mit Einträgen aus dem Modell (die Antwort wiederholt die Frage nicht, RCODE immer 0, ein Verweis mit NS und A im Antwortteil), iterativer Resolver mit 5 RTT je Anfrage, der nur die Server behält, an die er verwiesen wurde; noch ohne Rekursion. `receiveUdp` kehrt wie Javas `empfangen` zurück, sobald ein Datagramm da ist. Klasse statt Werttyp. Noch nicht übertragen (wirft `NotPortedError`): Gateway-NAT, DNS, RIP, Remote Link, WLAN | +| `src/runtime/dns.ts` | Java `filius.software.dns.DNSNachricht`, `Query`, `ResourceRecord` | neu nach Java statt nach Swift (Swift ersetzt Javas Textnachrichten durch `FILIUS-DNS-TYPED/1`): Kopfzeile `ID=… QR=… RCODE=… QDCOUNT=… …`, Frage- und Eintragszeilen, Suche nach Namen ohne Rücksicht auf Groß- und Kleinschreibung, NS der längsten Zone, sonst der Wurzel | | `src/runtime/ipv4.ts` | Engine `parseIPv4`/`sameSubnet` | übertragen | | `src/runtime/packets.ts` | Engine, Paketmodelle | übertragen; `Data` → `Uint8Array` | | `src/runtime/random.ts` | Engine, `TopologyJavaCompatibleRandom` | übertragen; BigInt für die 48-Bit-Arithmetik; dazu `nextDouble` (Javas `Math.random`, an OpenJDK 21 gemessen) | | `src/runtime/routeTable.ts` | `State/TopologyJavaRouteTable.swift` | übertragen ohne Tabellengeometrie (gehört in den Editor) | -| `src/runtime/snapshot.ts` | Engine, `TopologyNetworkRuntimeTopologySnapshot` | übertragen, bisher mit den Feldern der übertragenen Schichten, seit F2.3 mit den DHCP-Einstellungen von Clients und Servern | +| `src/runtime/snapshot.ts` | Engine, `TopologyNetworkRuntimeTopologySnapshot` | übertragen, bisher mit den Feldern der übertragenen Schichten, seit F2.3 mit den DHCP-Einstellungen von Clients und Servern, seit F2.4a mit den eingeschalteten DNS-Servern | | `src/runtime/speed.ts` | Engine, `TopologyNetworkRuntimeSpeed` | übertragen; dazu Javas RTT (`Verbindung.holeRTT`: 50 Kabel), die Swift nicht kennt | | `src/runtime/trace.ts` | Engine, Paketspur-Modell | übertragen | | `src/util/equal.ts` | — | neu (Ersatz für Swifts `Equatable`) | @@ -29,6 +30,7 @@ Swift-Pfade relativ zu `filius-ipad/ios/FiliusPad/TopologyEditor/` (Stand | `test/engine.test.ts` | `TopologyNetworkRuntimeEngineTests.swift` (21 von 72 Tests) | übertragen; `nextInt`- und `nextDouble`-Referenzwerte an OpenJDK 21 gemessen; die DHCP-Client-Tests seit F2.3b nach Javas Verhalten (neun, davon zwei benannte Java-Fehler, die der Kern nicht übernimmt) | | `test/oracle.test.ts`, `test/scenarios/` | — | neu: 14 Szenarien (davon 2 UDP, 3 TCP, 4 DHCP), Ergebnis mit dem Swift-Kern abgeglichen (`tools/swift-oracle`); Schritte für UDP, TCP und DHCP; die vier DHCP-Szenarien folgen seit F2.3b Java, die drei TCP-Szenarien seit F2.3c; sie müssen von Swift abweichen | | `test/dhcpJava.test.ts`, `test/scenarios/*.java.json`, `test/scenario.ts`, `test/javaRun.ts` | — | neu: die DHCP-Szenarien gegen Java-Filius (`tools/java-oracle`, `DhcpRun`): je Client dieselben Frames in derselben Reihenfolge, Abstände auf 200 ms, dieselben Adressen am Ende | +| `test/dnsJava.test.ts`, `test/scenarios/dns-lan.scenario.json`, `test/scenarios/dns-java-*.java.json` | — | neu: fünf DNS-Abfragen gegen Java-Filius (`DnsRun`): Frames, Ergebnis und Dauer; eine noch nicht wie Java (fehlender Server: Javas ARP) | | `test/tcpJava.test.ts`, `test/scenarios/tcp-java-*.java.json` | — | neu: drei TCP-Abläufe gegen Java-Filius (`TcpRun`): Frames mit Flags und Nummern, Socket-Zustände; ein benannter Java-Fehler (`JAVA_BUGS`: nur ein SYN) | | `test/routeTable.test.ts` | `TopologyJavaRouteTableTests.swift` (8 von 12 Tests) | übertragen; Geometrie- und Reducer-Tests folgen mit Editor und Dokument | diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 9f8671a..7b6a8b6 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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'; diff --git a/packages/core/src/runtime/dns.ts b/packages/core/src/runtime/dns.ts new file mode 100644 index 0000000..53fd58c --- /dev/null +++ b/packages/core/src/runtime/dns.ts @@ -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(); + 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 = (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(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(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'); +} + diff --git a/packages/core/src/runtime/engine.ts b/packages/core/src/runtime/engine.ts index ba307f8..0e4476f 100644 --- a/packages/core/src/runtime/engine.ts +++ b/packages/core/src/runtime/engine.ts @@ -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>; dhcpLastOfferedAddressByServerNodeId: Map; dhcpServerSocketIdsByNodeId: Map; + /** Each DNS server's socket on port 53. */ + dnsServerSocketIdsByNodeId: Map; dhcpClientStatusesByNodeId: Map; dhcpClientContextsByNodeId: Map; 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(); + 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}`; } diff --git a/packages/core/src/runtime/snapshot.ts b/packages/core/src/runtime/snapshot.ts index 03b556f..6490068 100644 --- a/packages/core/src/runtime/snapshot.ts +++ b/packages/core/src/runtime/snapshot.ts @@ -11,6 +11,7 @@ import type { DeviceConfiguration, DhcpClientConfiguration, DhcpServerConfiguration, + DnsServerConfiguration, FirewallConfiguration, InterfaceConfiguration, InterfaceKey, @@ -55,6 +56,8 @@ export interface TopologySnapshot { readonly ripEnabledByNodeId: ReadonlyMap; readonly dhcpClientConfigurationsByNodeId: ReadonlyMap; readonly dhcpServerConfigurationsByNodeId: ReadonlyMap; + /** The DNS servers switched on (Java: aktiv), by node. */ + readonly dnsServerConfigurationsByNodeId: ReadonlyMap; readonly firewallConfigurationsByNodeId: ReadonlyMap; readonly switchConfigurationsByNodeId: ReadonlyMap; } @@ -68,6 +71,7 @@ export interface TopologySnapshotInput { ripEnabledByNodeId?: ReadonlyMap; dhcpClientConfigurationsByNodeId?: ReadonlyMap; dhcpServerConfigurationsByNodeId?: ReadonlyMap; + dnsServerConfigurationsByNodeId?: ReadonlyMap; firewallConfigurationsByNodeId?: ReadonlyMap; switchConfigurationsByNodeId?: ReadonlyMap; } @@ -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(), }; diff --git a/packages/core/test/dnsJava.test.ts b/packages/core/test/dnsJava.test.ts new file mode 100644 index 0000000..c335b71 --- /dev/null +++ b/packages/core/test/dnsJava.test.ts @@ -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-.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; + events: [number, string][]; +} + +const STORIES: Record 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(); + 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 = { + 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); + } + }); + } +}); diff --git a/packages/core/test/engine.test.ts b/packages/core/test/engine.test.ts index c1ed140..40721c7 100644 --- a/packages/core/test/engine.test.ts +++ b/packages/core/test/engine.test.ts @@ -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, 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); + }); +}); diff --git a/packages/core/test/scenario.ts b/packages/core/test/scenario.ts index 5f5bd8c..14e4d1f 100644 --- a/packages/core/test/scenario.ts +++ b/packages/core/test/scenario.ts @@ -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; 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, }); }