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:
+4
-2
@@ -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 |
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
// DNS as Java FILIUS runs it. tools/java-oracle's DnsRun called a client's
|
||||
// resolver on dns-lan's network in Java FILIUS and wrote every frame each
|
||||
// card saw and what the call returned (dns-java-<story>.java.json). The
|
||||
// engine makes the same call: the frames must come in the same order, with
|
||||
// the same content, at about the same distance from each other (javaRun.ts),
|
||||
// the call must return the same, after about as long.
|
||||
//
|
||||
// Java draws each message's ID with Math.random and a client's free port with
|
||||
// its random; both are compared as placeholders, and an answer must carry the
|
||||
// ID of its question.
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { DnsAddressResolution, NetworkRuntimeEngine } from '../src/index.js';
|
||||
import { GAP_TOLERANCE_MILLISECONDS, gaps, javaFrames, watchedEngine, type JavaFrame, type SeenFrame } from './javaRun.js';
|
||||
import type { Scenario } from './scenario.js';
|
||||
|
||||
const scenarioDirectory = join(dirname(fileURLToPath(import.meta.url)), 'scenarios');
|
||||
const CLIENT = '00000000-0000-0000-0000-000000000101';
|
||||
const LOST_CLIENT = '00000000-0000-0000-0000-000000000104';
|
||||
|
||||
interface JavaRun {
|
||||
frames: Record<string, JavaFrame[]>;
|
||||
events: [number, string][];
|
||||
}
|
||||
|
||||
const STORIES: Record<string, (engine: NetworkRuntimeEngine) => DnsAddressResolution> = {
|
||||
address: (engine) => engine.resolveDnsAddress(CLIENT, 'web.filius.de'),
|
||||
mail: (engine) => engine.resolveDnsMailServer(CLIENT, 'filius.de'),
|
||||
referral: (engine) => engine.resolveDnsAddress(CLIENT, 'www.schule.de'),
|
||||
unknown: (engine) => engine.resolveDnsAddress(CLIENT, 'x.unbekannt.org'),
|
||||
noServer: (engine) => engine.resolveDnsAddress(LOST_CLIENT, 'web.filius.de'),
|
||||
};
|
||||
|
||||
/** Java's words for the result: the address, null, or the exception. */
|
||||
function javaResult(resolution: DnsAddressResolution): string {
|
||||
return resolution.type === 'timedOut' ? 'failed TimeOutException' : `returned ${resolution.address ?? 'null'}`;
|
||||
}
|
||||
|
||||
/** IDs and free ports as placeholders; each ID stands for the question it first appeared in. */
|
||||
function normalized(frames: readonly SeenFrame[]): SeenFrame[] {
|
||||
const ids = new Map<string, string>();
|
||||
return frames.map((frame) => ({
|
||||
time: frame.time,
|
||||
text: frame.text
|
||||
.replace(/ID=(\d+)/, (_, id: string) => {
|
||||
if (!ids.has(id)) ids.set(id, `q${ids.size + 1}`);
|
||||
return `ID=${ids.get(id)}`;
|
||||
})
|
||||
.replace(/:(\d{5})\b/g, (whole, port: string) => (Number(port) >= 49_152 ? ':client' : whole)),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Stories the engine does not play like Java yet, with the reason; each must
|
||||
* still differ from Java (else it belongs to the others) and return the same.
|
||||
*
|
||||
* noServer: Java's transport thread sends while the resolver already waits,
|
||||
* and asks ARP twice, a round trip apart. The engine's ARP (Swift's) blocks
|
||||
* the call and asks again at once — rebuilt after Java in its own step.
|
||||
*/
|
||||
const NOT_YET_LIKE_JAVA: Record<string, string> = {
|
||||
noServer: "ARP: Java's second request comes a round trip after the first, while the resolver waits",
|
||||
};
|
||||
|
||||
describe('DNS as Java FILIUS runs it', () => {
|
||||
const scenario = JSON.parse(readFileSync(join(scenarioDirectory, 'dns-lan.scenario.json'), 'utf8')) as Scenario;
|
||||
|
||||
for (const [name, story] of Object.entries(STORIES)) {
|
||||
it(name, () => {
|
||||
const java = JSON.parse(readFileSync(join(scenarioDirectory, `dns-java-${name}.java.json`), 'utf8')) as JavaRun;
|
||||
const { engine, frames } = watchedEngine(scenario);
|
||||
engine.handle({ type: 'advance', toMilliseconds: 500 });
|
||||
const called = engine.state.currentTimeMilliseconds;
|
||||
const result = story(engine);
|
||||
const returned = engine.state.currentTimeMilliseconds;
|
||||
|
||||
const [[javaCalled], [javaReturned, javaOutcome]] = java.events as [[number, string], [number, string]];
|
||||
expect(`${javaOutcome.split(' ').slice(-2).join(' ')}`).toBe(javaResult(result).split(' ').slice(-2).join(' '));
|
||||
|
||||
const expected = gaps(normalized(javaFrames(java.frames)));
|
||||
const actual = gaps(normalized(frames()));
|
||||
if (name in NOT_YET_LIKE_JAVA) {
|
||||
expect(JSON.stringify(actual), NOT_YET_LIKE_JAVA[name]).not.toBe(JSON.stringify(expected));
|
||||
return;
|
||||
}
|
||||
expect(Math.abs(returned - called - (javaReturned - javaCalled)), 'duration of the call').toBeLessThanOrEqual(GAP_TOLERANCE_MILLISECONDS);
|
||||
for (let index = 0; index < Math.max(expected.length, actual.length); index += 1) {
|
||||
expect(actual[index]?.text, `frame #${index + 1}`).toBe(expected[index]?.text);
|
||||
expect(Math.abs(actual[index]!.gap - expected[index]!.gap), `gap before frame #${index + 1}`).toBeLessThanOrEqual(GAP_TOLERANCE_MILLISECONDS);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -8,6 +8,10 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
decodeDhcpMessage,
|
||||
decodeDnsMessage,
|
||||
encodeDnsMessage,
|
||||
findApplicableNameServerRecord,
|
||||
NotPortedError,
|
||||
DHCP_DYNAMIC_LEASE_LIFETIME_MILLISECONDS,
|
||||
deviceConfiguration,
|
||||
dhcpServerConfiguration,
|
||||
@@ -1394,3 +1398,136 @@ describe('DHCP', () => {
|
||||
expect(decodeDhcpMessage(new TextEncoder().encode('HELLO'))).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('DNS', () => {
|
||||
const lan = (records: Record<string, string[]>, recursive = false) => {
|
||||
// Hosts 10.30.0.1 (the client, DNS server .2), .2, .3 on one switch.
|
||||
const switchId = uuid(0x1900);
|
||||
const hosts = [1, 2, 3].map((index) => ({ id: uuid(0x1900 + 2 * index), portId: uuid(0x1901 + 2 * index), switchPortId: uuid(0x1910 + index) }));
|
||||
const ip = (index: number) => `10.30.0.${index + 1}`;
|
||||
const record = (line: string) => {
|
||||
const [name = '', type = 'A', target = ''] = line.split(' ');
|
||||
return { name, type: type as 'A' | 'MX' | 'NS', ttlSeconds: 3600, target };
|
||||
};
|
||||
const engine = startedEngine({
|
||||
nodes: [
|
||||
{ id: switchId, kind: 'networkSwitch', ports: hosts.map((host, index) => portSnapshot(host.switchPortId, `sw${index + 1}`)) },
|
||||
...hosts.map((host) => pc(host.id, host.portId)),
|
||||
],
|
||||
links: hosts.map((host) => link(host.id, host.portId, switchId, host.switchPortId)),
|
||||
deviceConfigurations: new Map(hosts.map((host, index) => [host.id, deviceConfiguration(ip(index), '255.255.255.0', '', index === 0 ? '10.30.0.2' : '')])),
|
||||
dnsServerConfigurationsByNodeId: new Map(
|
||||
Object.entries(records).map(([address, lines]) => [
|
||||
hosts[Number(address.split('.')[3]) - 1]!.id,
|
||||
{ recursiveResolutionEnabled: recursive, records: lines.map(record) },
|
||||
]),
|
||||
),
|
||||
});
|
||||
return { engine, client: hosts[0]!.id };
|
||||
};
|
||||
const queries = (engine: NetworkRuntimeEngine) =>
|
||||
engine.state.packetTraces.filter((trace) => trace.detail?.startsWith('DNS query') === true).map((trace) => trace.detail);
|
||||
|
||||
it("writes and reads Java's DNS messages", () => {
|
||||
const message = {
|
||||
id: 4711,
|
||||
queryResponse: 1,
|
||||
responseCode: 0,
|
||||
questions: [],
|
||||
answers: [
|
||||
{ name: 'filius.de.', type: 'MX', ttl: 3600, data: 'mail.filius.de.' },
|
||||
{ name: 'mail.filius.de.', type: 'A', ttl: 3600, data: '10.0.0.5' },
|
||||
],
|
||||
authority: [],
|
||||
additional: [],
|
||||
};
|
||||
const text = encodeDnsMessage(message);
|
||||
expect(text).toBe(
|
||||
'ID=4711 QR=1 RCODE=0 QDCOUNT=0 ANCOUNT=2 NSCOUNT=0 ARCOUNT=0 \nfilius.de. MX 3600 mail.filius.de.\nmail.filius.de. A 3600 10.0.0.5\n',
|
||||
);
|
||||
expect(decodeDnsMessage(text)).toEqual(message);
|
||||
// Java's StringTokenizer skips empty pieces; the counts say how many lines follow.
|
||||
expect(decodeDnsMessage('ID=7 QR=0 QDCOUNT=1\n\nweb.filius.de. A IN\nleft over\n')).toEqual({
|
||||
id: 7,
|
||||
queryResponse: 0,
|
||||
responseCode: 0,
|
||||
questions: [{ name: 'web.filius.de.', type: 'A', questionClass: 'IN' }],
|
||||
answers: [],
|
||||
authority: [],
|
||||
additional: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('finds the name server of the longest zone, else the root one', () => {
|
||||
const records = [
|
||||
{ name: '.', type: 'NS', ttl: 1, data: 'root.' },
|
||||
{ name: 'de.', type: 'NS', ttl: 1, data: 'ns.de.' },
|
||||
{ name: 'Schule.DE.', type: 'NS', ttl: 1, data: 'ns.schule.de.' },
|
||||
];
|
||||
expect(findApplicableNameServerRecord('www.schule.de.', records)?.data).toBe('ns.schule.de.');
|
||||
expect(findApplicableNameServerRecord('www.filius.de.', records)?.data).toBe('ns.de.');
|
||||
expect(findApplicableNameServerRecord('www.filius.org.', records)?.data).toBe('root.');
|
||||
expect(findApplicableNameServerRecord('www.filius.org.', records.slice(1))).toBeUndefined();
|
||||
});
|
||||
|
||||
it('answers localhost itself, and nothing without a DNS server', () => {
|
||||
const { engine, client } = lan({ '10.30.0.2': [] });
|
||||
expect(engine.resolveDnsAddress(client, 'LocalHost')).toEqual({ type: 'resolved', address: '127.0.0.1' });
|
||||
expect(engine.resolveDnsAddress(uuid(0x1904), 'web.filius.de')).toEqual({ type: 'resolved', address: undefined });
|
||||
expect(queries(engine)).toEqual([]);
|
||||
});
|
||||
|
||||
it('follows referrals, and asks the first server once more (Java remembers only the ones it was referred to)', () => {
|
||||
const { engine, client } = lan({
|
||||
'10.30.0.2': ['schule.de NS ns.schule.de.', 'ns.schule.de A 10.30.0.3'],
|
||||
'10.30.0.3': ['schule.de NS ns2.schule.de.', 'ns2.schule.de A 10.30.0.2'],
|
||||
});
|
||||
|
||||
expect(engine.resolveDnsAddress(client, 'www.schule.de')).toEqual({ type: 'resolved', address: undefined });
|
||||
expect(queries(engine)).toEqual(['DNS query www.schule.de. A', 'DNS query www.schule.de. A', 'DNS query www.schule.de. A']);
|
||||
expect([...engine.state.socketsById.values()].filter((socket) => socket.nodeId === client)).toEqual([]);
|
||||
});
|
||||
|
||||
it('receives a datagram when it comes, not at the end of the wait', () => {
|
||||
// A DHCP client without a server sends its DISCOVER again after a round trip (a timer, not the network).
|
||||
const [clientId, clientPortId, listenerId, listenerPortId] = [0x1981, 0x1982, 0x1983, 0x1984].map(uuid) as [string, string, string, string];
|
||||
const engine = startedEngine({
|
||||
nodes: [pc(clientId, clientPortId), pc(listenerId, listenerPortId)],
|
||||
links: [link(clientId, clientPortId, listenerId, listenerPortId)],
|
||||
deviceConfigurations: new Map([
|
||||
[clientId, device('0.0.0.0')],
|
||||
[listenerId, device('10.31.0.2')],
|
||||
]),
|
||||
dhcpClientConfigurationsByNodeId: new Map([[clientId, { isEnabled: true }]]),
|
||||
});
|
||||
const socket = engine.bindUdpSocket(listenerId, { localPort: 67 })!;
|
||||
engine.handle({ type: 'advance', toMilliseconds: 100 });
|
||||
expect(engine.receiveUdp(socket)).toBeDefined();
|
||||
|
||||
expect(engine.receiveUdp(socket, 10_000)).toBeDefined();
|
||||
expect(engine.state.currentTimeMilliseconds).toBe(2_750 + 55);
|
||||
});
|
||||
|
||||
it('returns from the answer, not at the end of the wait', () => {
|
||||
const { engine, client } = lan({ '10.30.0.2': ['web.filius.de A 10.30.0.9'] });
|
||||
const called = engine.state.currentTimeMilliseconds;
|
||||
expect(engine.resolveDnsAddress(client, 'web.filius.de')).toEqual({ type: 'resolved', address: '10.30.0.9' });
|
||||
// ARP there and back, then the query and its answer: four cables each way through the switch.
|
||||
expect(engine.state.currentTimeMilliseconds - called).toBe(4 * 110);
|
||||
});
|
||||
|
||||
it('gives up after five round trips when no server answers, and closes its socket', () => {
|
||||
const { engine, client } = lan({ '10.30.0.2': [] });
|
||||
const called = engine.state.currentTimeMilliseconds;
|
||||
// 10.30.0.3 is there, but no DNS server runs on it.
|
||||
expect(engine.resolveDns(client, 'web.filius.de', 'A', '10.30.0.3')).toEqual({ type: 'timedOut' });
|
||||
// Java's five round trips; before them the engine's send still blocks for
|
||||
// ARP and the query's way to .3 (Java waits from handing it down — the ARP step).
|
||||
expect(engine.state.currentTimeMilliseconds - called).toBe(3 * 110 + 5 * 2_750);
|
||||
expect([...engine.state.socketsById.values()].filter((socket) => socket.nodeId === client)).toEqual([]);
|
||||
});
|
||||
|
||||
it('does not answer recursively yet', () => {
|
||||
expect(() => lan({ '10.30.0.2': [] }, true)).toThrow(NotPortedError);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
type ManualRoute,
|
||||
type NodeKind,
|
||||
type TopologySnapshot,
|
||||
type DnsRecordType,
|
||||
} from '../src/index.js';
|
||||
|
||||
export interface Scenario {
|
||||
@@ -50,6 +51,8 @@ export interface Scenario {
|
||||
{ active?: boolean; lower?: string; upper?: string; gateway?: string; dns?: string; own?: boolean; staticAssignments?: [string, string][] }
|
||||
>;
|
||||
dhcpClients?: string[];
|
||||
/** Records as `name TYPE target` (Java's hinzuRecord: TTL 3600). */
|
||||
dnsServers?: Record<string, { records: string[]; recursive?: boolean }>;
|
||||
steps: {
|
||||
op: string;
|
||||
from?: string;
|
||||
@@ -164,6 +167,24 @@ export function snapshotOf(scenario: Scenario): TopologySnapshot {
|
||||
config.retention === undefined ? switchConfiguration(config.ssid) : switchConfiguration(config.ssid, config.retention),
|
||||
]),
|
||||
),
|
||||
dnsServerConfigurationsByNodeId: new Map(
|
||||
Object.entries(scenario.dnsServers ?? {}).map(([node, config]) => [
|
||||
node,
|
||||
{
|
||||
recursiveResolutionEnabled: config.recursive ?? false,
|
||||
records: config.records.map((line) => {
|
||||
const [name = '', type = 'A', target = ''] = line.split(' ');
|
||||
const nameServerOrMail = type === 'NS' || type === 'MX';
|
||||
return {
|
||||
name: name.replace(/\.$/, '').toLowerCase() || '.',
|
||||
type: type as DnsRecordType,
|
||||
ttlSeconds: 3600,
|
||||
target: nameServerOrMail && !target.endsWith('.') ? `${target}.` : target,
|
||||
};
|
||||
}),
|
||||
},
|
||||
]),
|
||||
),
|
||||
firewallConfigurationsByNodeId: firewalls,
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user