diff --git a/CHANGES.md b/CHANGES.md index 4e5a835..93b6d19 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -13,21 +13,22 @@ 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, Zeitgrenze 3 × 2,5 s mit höchstens drei Versuchen, Abbau über alle Java-Zustände samt `TIME_WAIT`), DHCP (Z. ~5913–6694: Server mit festen Zuordnungen, Adressbereich, Angeboten, Leases und Sperrliste; Client mit DISCOVER, Prüfung per ARP, DECLINE, REQUEST, ACK/NAK, 10 Versuchen und Rückkehr zur alten Adresse). 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, Zeitgrenze 3 × 2,5 s mit höchstens drei Versuchen, Abbau über alle Java-Zustände samt `TIME_WAIT`), 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. 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/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 | +| `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/speed.ts` | Engine, `TopologyNetworkRuntimeSpeed` | übertragen | +| `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`) | | `src/util/swiftString.ts` | — | neu: Swift-String-Verhalten, das der übertragene Code stillschweigend voraussetzt (Trimmen, Kleinschreibung je Skalar, kanonische Gleichheit, Sortierung nach Skalarwerten, Base64 wie Foundation); am Swift-Orakel gemessen, Leerraum und Steuerzeichen über ganz Unicode | | `src/model/virtualFileSystem.ts`, `src/model/defaultImages.ts` | `State/TopologyVirtualFileSystem.swift` | übertragen; Schlüssel in NFC statt Swifts kanonischer Wörterbuch-Gleichheit; Steuerzeichen nach Unicode Cc+Cf, ohne Foundations Tabellenfehler in Ebene 14 | | `test/virtualFileSystem.test.ts`, `test/vfs.*.json` | `TopologyProjectPersistenceTests.swift`, `TopologyEditorReducerTests.swift` (drei Dateisystem-Tests, als Fälle) | übertragen und erweitert: 11 Fälle, Ergebnis aus dem Swift-Kern | | `test/strings.test.ts`, `test/strings.*.json` | — | neu: Fälle und ganze Zeichenmengen, Ergebnis aus dem Swift-Kern | -| `test/engine.test.ts` | `TopologyNetworkRuntimeEngineTests.swift` (21 von 72 Tests) | übertragen; `nextInt`-Referenzwerte an OpenJDK 21 gemessen | -| `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; ein benannter Swift-Fehler (veralteter DHCP-Status nach verschachteltem Lauf) | +| `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 und müssen von Swift abweichen | +| `test/dhcpJava.test.ts`, `test/scenarios/*.java.json`, `test/scenario.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/routeTable.test.ts` | `TopologyJavaRouteTableTests.swift` (8 von 12 Tests) | übertragen; Geometrie- und Reducer-Tests folgen mit Editor und Dokument | ## packages/fls diff --git a/packages/core/src/runtime/engine.ts b/packages/core/src/runtime/engine.ts index 5749005..2bc00c8 100644 --- a/packages/core/src/runtime/engine.ts +++ b/packages/core/src/runtime/engine.ts @@ -7,7 +7,10 @@ // "UDP sockets and Router RIP" (lines ~4472–5187, the // UDP sockets; RIP not yet) // "Java-compatible TCP sockets" (lines ~5187–5913) -// "DHCP server and automatic client" (lines ~5913–6694) +// "DHCP server and automatic client" (lines ~5913–6694; the +// client rebuilt after Java's DHCPClient, measured with Java FILIUS: +// one message per attempt, an ARP check that waits a round trip, +// link-local after five failures; round trip times follow the speed) // processScheduledRuntimeEvent, dropForGlobalPacketLoss (lines ~4753–4850) // Java counterparts: filius.hardware.Verbindung, filius.software.netzzugangsschicht, // filius.software.vermittlungsschicht (ARP, IP, ICMP), system.SwitchFirmware, @@ -273,10 +276,15 @@ export type TcpConnectionResult = 'connected' | 'invalidSocket' | 'unreachable' export const DHCP_SERVER_PORT = 67; export const DHCP_CLIENT_PORT = 68; -export const DHCP_ROUND_TRIP_TIME_MILLISECONDS = 2_500; -export const DHCP_OFFER_LIFETIME_MILLISECONDS = 4 * DHCP_ROUND_TRIP_TIME_MILLISECONDS; +/** Java: an offer, and a blacklisted address, is kept 4 round trip times. */ +export const DHCP_OFFER_LIFETIME_ROUND_TRIPS = 4; export const DHCP_DYNAMIC_LEASE_LIFETIME_MILLISECONDS = 24 * 60 * 60 * 1_000; -export const DHCP_MAXIMUM_CLIENT_ERROR_COUNT = 10; +/** Java's DHCP_MAX_ERROR_COUNT: the client's failures before it takes a link-local address. */ +export const DHCP_MAXIMUM_CLIENT_ERROR_COUNT = 5; +/** Java's LINK_LOCAL_MAX_ERROR_COUNT: occupied link-local addresses before the client gives up. */ +export const DHCP_LINK_LOCAL_MAXIMUM_ERROR_COUNT = 5; +export const LINK_LOCAL_ADDRESS_PREFIX = '169.254.1.'; +export const LINK_LOCAL_SUBNET_MASK = '255.255.0.0'; export type DhcpMessageType = 'DHCPDISCOVER' | 'DHCPREQUEST' | 'DHCPACK' | 'DHCPNAK' | 'DHCPOFFER' | 'DHCPDECLINE'; const DHCP_MESSAGE_TYPES: readonly DhcpMessageType[] = ['DHCPDISCOVER', 'DHCPREQUEST', 'DHCPACK', 'DHCPNAK', 'DHCPOFFER', 'DHCPDECLINE']; @@ -308,23 +316,47 @@ export interface DhcpLeaseRecord { readonly expiresAtMilliseconds: number | undefined; } -export type DhcpClientState = 'INIT' | 'DISCOVER' | 'VALIDATE' | 'DECLINE' | 'REQUEST' | 'ASSIGN_IP' | 'FINISH'; +/** Java's DHCPClient.State; FINISH is Java's SUCCEED and its giving up. */ +export type DhcpClientState = + | 'INIT' + | 'DISCOVER' + | 'VALIDATE' + | 'DECLINE' + | 'REQUEST' + | 'ASSIGN_IP' + | 'LINK_LOCAL_INIT' + | 'LINK_LOCAL_VALIDATE' + | 'LINK_LOCAL_ANNOUNCE' + | 'FINISH'; export interface DhcpClientStatus { state: DhcpClientState; + /** Java's failureCount: timeouts while waiting for a server, then occupied link-local addresses. */ errorCount: number; selectedServerIPAddress: string | undefined; offeredIPAddress: string | undefined; + /** Configured, by a server or with a link-local address. */ succeeded: boolean; + linkLocal: boolean; } +/** What the client waits for, at most until the deadline: a server's answer, or an ARP answer for an address. */ +export type DhcpClientWait = + | { + readonly kind: 'response'; + readonly acceptedTypes: readonly DhcpMessageType[]; + readonly serverIdentifier: string | undefined; + readonly deadlineMilliseconds: number; + } + | { readonly kind: 'arp'; readonly ipAddress: string; readonly deadlineMilliseconds: number }; + export interface DhcpClientContext { readonly interfaceId: Id; readonly socketId: Id; readonly clientMacAddress: string; - readonly oldDeviceConfiguration: DeviceConfiguration | undefined; - readonly oldInterfaceConfiguration: InterfaceConfiguration | undefined; selectedOffer: DhcpMessage | undefined; + linkLocalIPAddress: string | undefined; + waiting: DhcpClientWait | undefined; } /** Swift's caseInsensitiveCompare == .orderedSame, for addresses. */ @@ -489,6 +521,7 @@ export class NetworkRuntimeEngine { private random: JavaCompatibleRandom; private udpPortRandom: JavaCompatibleRandom; private tcpPortRandom: JavaCompatibleRandom; + private linkLocalRandom: JavaCompatibleRandom; private runtimeSpeed: RuntimeSpeed; private readonly packetTraceRetentionPolicy: TraceRetentionPolicy; private isProcessingScheduledEvents = false; @@ -503,6 +536,7 @@ export class NetworkRuntimeEngine { this.random = new JavaCompatibleRandom(normalizedSeed); this.udpPortRandom = new JavaCompatibleRandom(normalizedSeed ^ 0x5544_5050n); this.tcpPortRandom = new JavaCompatibleRandom(normalizedSeed ^ 0x5443_5050n); + this.linkLocalRandom = new JavaCompatibleRandom(normalizedSeed ^ 0x4c4c_4144n); this.runtimeSpeed = new RuntimeSpeed(); this.packetTraceRetentionPolicy = packetTraceRetentionPolicy; this.stateValue.simulationSpeedPercent = this.runtimeSpeed.percent; @@ -539,6 +573,7 @@ export class NetworkRuntimeEngine { this.random = new JavaCompatibleRandom(seed); this.udpPortRandom = new JavaCompatibleRandom(seed ^ 0x5544_5050n); this.tcpPortRandom = new JavaCompatibleRandom(seed ^ 0x5443_5050n); + this.linkLocalRandom = new JavaCompatibleRandom(seed ^ 0x4c4c_4144n); this.isProcessingScheduledEvents = false; this.pendingIPv4TransmissionsByArpKey.clear(); this.initializeRipState(); @@ -1001,9 +1036,7 @@ export class NetworkRuntimeEngine { beforeHeaders: udpHeaders(datagram), detail: matching.length === 0 ? 'UDP destination port unbound' : 'UDP queued', }); - if (datagram.destinationPort === DHCP_CLIENT_PORT && state.dhcpClientContextsByNodeId.has(nodeId)) { - this.advanceDhcpClient(nodeId); - } + if (datagram.destinationPort === DHCP_CLIENT_PORT && this.readDhcpResponse(nodeId)) this.advanceDhcpClient(nodeId); } // ── TCP sockets ────────────────────────────────────────────────────────── @@ -1669,9 +1702,9 @@ export class NetworkRuntimeEngine { // ── DHCP ───────────────────────────────────────────────────────────────── /** - * Starts (or goes on with) the node's DHCP client: its address set to - * 0.0.0.0, a socket on port 68, then the client's states as far as the - * answers so far allow. + * Starts the node's DHCP client as Java's DHCPClient.configure: its address + * set to 0.0.0.0, a socket on port 68, then the client's states as far as + * the answers so far allow. A client that is running goes on waiting. */ runDhcpClient(nodeId: Id): DhcpClientStatus | undefined { const state = this.stateValue; @@ -1686,10 +1719,7 @@ export class NetworkRuntimeEngine { } const clientInterface = this.dhcpClientInterface(nodeId); if (clientInterface === undefined) return undefined; - if (state.dhcpClientContextsByNodeId.has(nodeId)) { - this.advanceDhcpClient(nodeId); - return state.dhcpClientStatusesByNodeId.get(nodeId); - } + if (state.dhcpClientContextsByNodeId.has(nodeId)) return state.dhcpClientStatusesByNodeId.get(nodeId); const oldDeviceConfiguration = state.topologySnapshot.deviceConfigurations.get(nodeId); const oldInterfaceConfiguration = state.topologySnapshot.interfaceConfigurations.get(interfaceKey(nodeId, clientInterface.portId)); this.setDhcpClientIPAddress(nodeId, clientInterface.portId, UNSPECIFIED_IP_ADDRESS); @@ -1711,127 +1741,253 @@ export class NetworkRuntimeEngine { interfaceId: clientInterface.portId, socketId, clientMacAddress: resetInterface.macAddress, - oldDeviceConfiguration, - oldInterfaceConfiguration, selectedOffer: undefined, + linkLocalIPAddress: undefined, + waiting: undefined, }); state.dhcpClientStatusesByNodeId.set(nodeId, dhcpClientStatus()); this.advanceDhcpClient(nodeId); return state.dhcpClientStatusesByNodeId.get(nodeId); } + /** + * Java's configure loop, one state after the other until the client waits + * (for a server's answer or an ARP answer) or is done. Each attempt sends + * its message once; an answer that does not fit is read and dropped, and + * the client waits on. + */ private advanceDhcpClient(nodeId: Id): void { const state = this.stateValue; for (;;) { const context = state.dhcpClientContextsByNodeId.get(nodeId); const status = state.dhcpClientStatusesByNodeId.get(nodeId); - if (state.phase !== 'running' || context === undefined || status === undefined || status.state === 'FINISH') return; - const backToDiscover = () => { - status.errorCount += 1; - status.state = 'DISCOVER'; - }; + if (state.phase !== 'running' || context === undefined || status === undefined) return; + if (status.state === 'FINISH' || context.waiting !== undefined) return; switch (status.state) { case 'INIT': status.state = 'DISCOVER'; break; - case 'DISCOVER': { + case 'DISCOVER': this.sendDhcpMessage(context.socketId, { type: 'DHCPDISCOVER', yourIPAddress: UNSPECIFIED_IP_ADDRESS, clientMacAddress: context.clientMacAddress, }); - const offer = this.receiveDhcpResponse(context.socketId, context.clientMacAddress, undefined, ['DHCPOFFER']); - if (offer === undefined) { - this.scheduleDhcpTimeout(nodeId); - return; - } - context.selectedOffer = offer; - status.selectedServerIPAddress = offer.serverIdentifier; - status.offeredIPAddress = offer.yourIPAddress; - status.state = 'VALIDATE'; + this.waitForDhcpResponse(nodeId, ['DHCPOFFER'], undefined); break; - } case 'VALIDATE': { const offer = context.selectedOffer; if (offer === undefined) { - backToDiscover(); + status.state = 'DISCOVER'; break; } - const conflictMac = this.resolveMacAddress(nodeId, offer.yourIPAddress, { - preferredInterfaceId: context.interfaceId, - maxRetries: 1, - selectInterfaceByAddress: false, - }); - status.state = conflictMac === undefined ? 'REQUEST' : 'DECLINE'; + this.checkDhcpClientAddress(nodeId, offer.yourIPAddress); break; } case 'DECLINE': { const offer = context.selectedOffer; - if (offer === undefined) { - backToDiscover(); - break; + if (offer !== undefined) { + this.sendDhcpMessage(context.socketId, { + type: 'DHCPDECLINE', + yourIPAddress: UNSPECIFIED_IP_ADDRESS, + clientMacAddress: context.clientMacAddress, + serverIdentifier: offer.serverIdentifier, + requestedIPAddress: offer.yourIPAddress, + }); } - this.sendDhcpMessage(context.socketId, { - type: 'DHCPDECLINE', - yourIPAddress: UNSPECIFIED_IP_ADDRESS, - clientMacAddress: context.clientMacAddress, - serverIdentifier: offer.serverIdentifier, - requestedIPAddress: offer.yourIPAddress, - }); - context.selectedOffer = undefined; - status.selectedServerIPAddress = undefined; - status.offeredIPAddress = undefined; status.state = 'DISCOVER'; break; } case 'REQUEST': { const offer = context.selectedOffer; - const serverIdentifier = offer?.serverIdentifier; - if (offer === undefined || serverIdentifier === undefined) { - backToDiscover(); + if (offer === undefined) { + status.state = 'DISCOVER'; break; } this.sendDhcpMessage(context.socketId, { type: 'DHCPREQUEST', yourIPAddress: UNSPECIFIED_IP_ADDRESS, clientMacAddress: context.clientMacAddress, - serverIdentifier, + serverIdentifier: offer.serverIdentifier, requestedIPAddress: offer.yourIPAddress, }); - const response = this.receiveDhcpResponse(context.socketId, context.clientMacAddress, serverIdentifier, ['DHCPACK', 'DHCPNAK']); - if (response === undefined) { - this.scheduleDhcpTimeout(nodeId); - return; - } - if (response.type === 'DHCPACK') { - status.state = 'ASSIGN_IP'; - } else { - context.selectedOffer = undefined; - status.selectedServerIPAddress = undefined; - status.offeredIPAddress = undefined; - status.state = 'DISCOVER'; - } + this.waitForDhcpResponse(nodeId, ['DHCPACK', 'DHCPNAK'], offer.serverIdentifier); break; } case 'ASSIGN_IP': { const offer = context.selectedOffer; if (offer === undefined) { - backToDiscover(); + status.state = 'DISCOVER'; break; } this.applyDhcpConfiguration(nodeId, context.interfaceId, offer); status.succeeded = true; status.state = 'FINISH'; - this.finishDhcpClient(nodeId, false); + this.finishDhcpClient(nodeId); return; } + case 'LINK_LOCAL_INIT': + context.linkLocalIPAddress = this.selectLinkLocalAddress(nodeId); + status.state = 'LINK_LOCAL_VALIDATE'; + break; + case 'LINK_LOCAL_VALIDATE': + this.checkDhcpClientAddress(nodeId, context.linkLocalIPAddress ?? UNSPECIFIED_IP_ADDRESS); + break; + case 'LINK_LOCAL_ANNOUNCE': + this.announceLinkLocalAddress(nodeId, context); + status.succeeded = true; + status.linkLocal = true; + status.state = 'FINISH'; + this.finishDhcpClient(nodeId); + return; } } } - private scheduleDhcpTimeout(nodeId: Id): void { + /** Java's receiveResponse: waits one RTT, reading what is queued first. */ + private waitForDhcpResponse(nodeId: Id, acceptedTypes: readonly DhcpMessageType[], serverIdentifier: string | undefined): void { + const context = this.stateValue.dhcpClientContextsByNodeId.get(nodeId); + if (context === undefined) return; + const deadlineMilliseconds = this.stateValue.currentTimeMilliseconds + this.runtimeSpeed.roundTripTimeMilliseconds; + context.waiting = { kind: 'response', acceptedTypes, serverIdentifier, deadlineMilliseconds }; + this.scheduleDhcpTimeout(nodeId, deadlineMilliseconds); + this.readDhcpResponse(nodeId); + } + + /** + * Reads the client's queue while it waits for a server: drops what does not + * fit and takes the first answer that does. False when there is none yet. + */ + private readDhcpResponse(nodeId: Id): boolean { + const state = this.stateValue; + const context = state.dhcpClientContextsByNodeId.get(nodeId); + const status = state.dhcpClientStatusesByNodeId.get(nodeId); + const wait = context?.waiting; + if (context === undefined || status === undefined || wait?.kind !== 'response') return false; + const response = this.receiveDhcpResponse(context.socketId, context.clientMacAddress, wait.serverIdentifier, wait.acceptedTypes); + if (response === undefined) return false; + context.waiting = undefined; this.removeDhcpTimeouts(nodeId); - this.schedule(this.stateValue.currentTimeMilliseconds + DHCP_ROUND_TRIP_TIME_MILLISECONDS, { kind: 'dhcpTimeout', nodeId }); + switch (response.type) { + case 'DHCPOFFER': + context.selectedOffer = response; + status.selectedServerIPAddress = response.serverIdentifier; + status.offeredIPAddress = response.yourIPAddress; + status.state = 'VALIDATE'; + break; + case 'DHCPACK': + status.state = 'ASSIGN_IP'; + break; + default: + context.selectedOffer = undefined; + status.selectedServerIPAddress = undefined; + status.offeredIPAddress = undefined; + status.state = 'DISCOVER'; + } + return true; + } + + /** + * Java's validateOfferedAddress: whether another card has the address. A + * known answer (the node's own address, the ARP cache) decides at once; + * otherwise one ARP request, and the client waits one RTT for its answer. + */ + private checkDhcpClientAddress(nodeId: Id, ipAddress: string): void { + const state = this.stateValue; + const context = state.dhcpClientContextsByNodeId.get(nodeId); + if (context === undefined) return; + if (this.knownMacAddress(nodeId, ipAddress) !== undefined) { + this.finishDhcpClientAddressCheck(nodeId, true); + return; + } + const clientInterface = this.networkInterfaces(nodeId).find((each) => each.portId === context.interfaceId); + if (clientInterface !== undefined) this.sendArpRequest(nodeId, clientInterface, ipAddress); + const deadlineMilliseconds = state.currentTimeMilliseconds + this.runtimeSpeed.roundTripTimeMilliseconds; + context.waiting = { kind: 'arp', ipAddress, deadlineMilliseconds }; + this.scheduleDhcpTimeout(nodeId, deadlineMilliseconds); + } + + /** An ARP packet taught the node `ipAddress`: a client checking that address has its answer. */ + private dhcpClientLearnedAddress(nodeId: Id, ipAddress: string): void { + const context = this.stateValue.dhcpClientContextsByNodeId.get(nodeId); + const wait = context?.waiting; + if (context === undefined || wait?.kind !== 'arp' || wait.ipAddress !== ipAddress) return; + context.waiting = undefined; + this.removeDhcpTimeouts(nodeId); + this.finishDhcpClientAddressCheck(nodeId, true); + this.advanceDhcpClient(nodeId); + } + + private finishDhcpClientAddressCheck(nodeId: Id, occupied: boolean): void { + const state = this.stateValue; + const context = state.dhcpClientContextsByNodeId.get(nodeId); + const status = state.dhcpClientStatusesByNodeId.get(nodeId); + if (context === undefined || status === undefined) return; + if (status.state === 'VALIDATE') { + status.state = occupied ? 'DECLINE' : 'REQUEST'; + return; + } + if (status.state !== 'LINK_LOCAL_VALIDATE') return; + if (!occupied) { + this.applyLinkLocalConfiguration(nodeId, context.interfaceId, context.linkLocalIPAddress ?? UNSPECIFIED_IP_ADDRESS); + status.state = 'LINK_LOCAL_ANNOUNCE'; + return; + } + status.errorCount += 1; + // Java counts on without end here (its limit is checked only after a + // timeout); the client gives up after LINK_LOCAL_MAX_ERROR_COUNT, as Java intends. + if (status.errorCount >= DHCP_MAXIMUM_CLIENT_ERROR_COUNT + DHCP_LINK_LOCAL_MAXIMUM_ERROR_COUNT) { + status.state = 'FINISH'; + this.finishDhcpClient(nodeId); + return; + } + status.state = 'LINK_LOCAL_INIT'; + } + + /** Java's selectArbitraryLinkLocalAddress: 169.254.1.1 to .254 at random, not one the ARP cache knows. */ + private selectLinkLocalAddress(nodeId: Id): string { + const cache = this.stateValue.arpCachesByNodeId.get(nodeId); + let lastByte = 0; + for (let attempt = 0; attempt < 500 && lastByte === 0; attempt += 1) { + lastByte = Math.round(this.linkLocalRandom.nextDouble() * 253) + 1; + if (cache?.has(LINK_LOCAL_ADDRESS_PREFIX + lastByte) === true) lastByte = 0; + } + return LINK_LOCAL_ADDRESS_PREFIX + lastByte; + } + + /** Java's LINK_LOCAL_ANNOUNCE: an ARP reply with the new address to every card. */ + private announceLinkLocalAddress(nodeId: Id, context: DhcpClientContext): void { + const clientInterface = this.networkInterfaces(nodeId).find((each) => each.portId === context.interfaceId); + if (clientInterface === undefined) return; + const reply: ArpPacket = { + operation: 'reply', + senderMacAddress: clientInterface.macAddress, + senderIPAddress: clientInterface.ipAddress, + targetMacAddress: ETHERNET_BROADCAST_MAC_ADDRESS, + targetIPAddress: LIMITED_BROADCAST_IP_ADDRESS, + }; + const frame: EthernetFrame = { + identity: this.allocateFrameIdentity(), + sourceMacAddress: clientInterface.macAddress, + destinationMacAddress: ETHERNET_BROADCAST_MAC_ADDRESS, + payload: { kind: 'arp', packet: reply }, + }; + this.recordTrace({ + frameIdentity: frame.identity, + nodeId, + interfaceId: clientInterface.portId, + direction: 'outbound', + layer: 'dataLink', + operation: 'created', + afterHeaders: ethernetHeaders(frame), + detail: `ARP reply for ${clientInterface.ipAddress}`, + }); + this.sendEthernetFrame(nodeId, clientInterface.portId, frame); + } + + private scheduleDhcpTimeout(nodeId: Id, deadlineMilliseconds: number): void { + this.removeDhcpTimeouts(nodeId); + this.schedule(deadlineMilliseconds, { kind: 'dhcpTimeout', nodeId }); } private removeDhcpTimeouts(nodeId: Id): void { @@ -1840,27 +1996,38 @@ export class NetworkRuntimeEngine { ); } + /** + * The wait is over. No ARP answer: the address is free. No server's answer + * (Java's TimeOutException): one failure more, and the state sends its + * message again — DISCOVER, or REQUEST to the same server; the fifth + * failure turns to a link-local address. + */ private handleDhcpClientTimeout(nodeId: Id): void { const state = this.stateValue; + const context = state.dhcpClientContextsByNodeId.get(nodeId); const status = state.dhcpClientStatusesByNodeId.get(nodeId); - if (!state.dhcpClientContextsByNodeId.has(nodeId) || status === undefined || status.state === 'FINISH') return; - status.errorCount += 1; - if (status.errorCount >= DHCP_MAXIMUM_CLIENT_ERROR_COUNT) { - status.state = 'FINISH'; - status.succeeded = false; - this.finishDhcpClient(nodeId, true); - return; + const wait = context?.waiting; + if (context === undefined || status === undefined || wait === undefined) return; + context.waiting = undefined; + if (wait.kind === 'arp') { + this.finishDhcpClientAddressCheck(nodeId, this.knownMacAddress(nodeId, wait.ipAddress) !== undefined); + } else { + status.errorCount += 1; + if (status.errorCount === DHCP_MAXIMUM_CLIENT_ERROR_COUNT) { + context.selectedOffer = undefined; + status.selectedServerIPAddress = undefined; + status.offeredIPAddress = undefined; + status.state = 'LINK_LOCAL_INIT'; + } } this.advanceDhcpClient(nodeId); } - private finishDhcpClient(nodeId: Id, restoreOldIPAddress: boolean): void { + /** Java closes the socket and keeps what the client set: an address, or 0.0.0.0. */ + private finishDhcpClient(nodeId: Id): void { const context = this.stateValue.dhcpClientContextsByNodeId.get(nodeId); if (context === undefined) return; this.stateValue.dhcpClientContextsByNodeId.delete(nodeId); - if (restoreOldIPAddress) { - this.restoreDhcpClientOldIPAddress(nodeId, context.interfaceId, context.oldDeviceConfiguration, context.oldInterfaceConfiguration); - } this.closeSocket(context.socketId); this.removeDhcpTimeouts(nodeId); } @@ -1914,7 +2081,7 @@ export class NetworkRuntimeEngine { // Another server was chosen: this one keeps the address back for a while. getOrCreate(state.dhcpBlacklistByServerNodeId, nodeId, () => new Map()).set( requestedIPAddress.toLowerCase(), - state.currentTimeMilliseconds + DHCP_OFFER_LIFETIME_MILLISECONDS, + state.currentTimeMilliseconds + DHCP_OFFER_LIFETIME_ROUND_TRIPS * this.runtimeSpeed.roundTripTimeMilliseconds, ); } return; @@ -1957,7 +2124,7 @@ export class NetworkRuntimeEngine { clientMacAddress, offeredIPAddress: address, serverNodeId, - expiresAtMilliseconds: state.currentTimeMilliseconds + DHCP_OFFER_LIFETIME_MILLISECONDS, + expiresAtMilliseconds: state.currentTimeMilliseconds + DHCP_OFFER_LIFETIME_ROUND_TRIPS * this.runtimeSpeed.roundTripTimeMilliseconds, }); return address; } @@ -2121,6 +2288,28 @@ export class NetworkRuntimeEngine { this.replaceTopologyConfigurations(devices, interfaces); } + /** Java: the link-local address with its /16 mask, and no gateway; the DNS server stays. */ + private applyLinkLocalConfiguration(nodeId: Id, interfaceId: Id, ipAddress: string): void { + const node = this.nodeSnapshot(nodeId); + if (node === undefined) return; + const snapshot = this.stateValue.topologySnapshot; + const devices = new Map(snapshot.deviceConfigurations); + const interfaces = new Map(snapshot.interfaceConfigurations); + const current = devices.get(nodeId); + if (isPcClassEndpoint(node.kind)) { + devices.set(nodeId, { + ipAddress, + subnetMask: LINK_LOCAL_SUBNET_MASK, + defaultGateway: '', + dnsServer: current?.dnsServer ?? '', + }); + } else if (node.kind === 'gateway') { + interfaces.set(interfaceKey(nodeId, interfaceId), { ipAddress, subnetMask: LINK_LOCAL_SUBNET_MASK }); + if (current !== undefined) devices.set(nodeId, { ...current, defaultGateway: '' }); + } + this.replaceTopologyConfigurations(devices, interfaces); + } + private setDhcpClientIPAddress(nodeId: Id, interfaceId: Id, ipAddress: string): void { const node = this.nodeSnapshot(nodeId); if (node === undefined) return; @@ -2247,13 +2436,9 @@ export class NetworkRuntimeEngine { const maxRetries = options.maxRetries ?? 2; const selectInterfaceByAddress = options.selectInterfaceByAddress ?? true; const interfaces = this.networkInterfaces(nodeId); - const primaryInterface = interfaces[0]; - if (primaryInterface === undefined) return undefined; - if (targetIPAddress === LOCALHOST_IP_ADDRESS) return primaryInterface.macAddress; - const localInterface = interfaces.find((candidate) => candidate.ipAddress === targetIPAddress); - if (localInterface !== undefined) return localInterface.macAddress; - const cached = this.stateValue.arpCachesByNodeId.get(nodeId)?.get(targetIPAddress); - if (cached !== undefined) return cached.macAddress; + if (interfaces.length === 0) return undefined; + const known = this.knownMacAddress(nodeId, targetIPAddress); + if (known !== undefined) return known; let selectedInterface: NetworkInterface | undefined; if (options.preferredInterfaceId !== undefined) { @@ -2266,30 +2451,7 @@ export class NetworkRuntimeEngine { if (selectedInterface === undefined) return undefined; for (let attempt = 0; attempt < Math.max(0, maxRetries); attempt += 1) { - const request: ArpPacket = { - operation: 'request', - senderMacAddress: selectedInterface.macAddress, - senderIPAddress: selectedInterface.ipAddress, - targetMacAddress: UNSPECIFIED_MAC_ADDRESS, - targetIPAddress, - }; - const frame: EthernetFrame = { - identity: this.allocateFrameIdentity(), - sourceMacAddress: selectedInterface.macAddress, - destinationMacAddress: ETHERNET_BROADCAST_MAC_ADDRESS, - payload: { kind: 'arp', packet: request }, - }; - this.recordTrace({ - frameIdentity: frame.identity, - nodeId, - interfaceId: selectedInterface.portId, - direction: 'outbound', - layer: 'dataLink', - operation: 'created', - afterHeaders: ethernetHeaders(frame), - detail: `ARP request for ${targetIPAddress}`, - }); - this.sendEthernetFrame(nodeId, selectedInterface.portId, frame); + this.sendArpRequest(nodeId, selectedInterface, targetIPAddress); this.advanceRemoteLinkEventsUntilArpCacheResolves(nodeId, targetIPAddress); const resolved = this.stateValue.arpCachesByNodeId.get(nodeId)?.get(targetIPAddress); if (resolved !== undefined) return resolved.macAddress; @@ -2297,6 +2459,45 @@ export class NetworkRuntimeEngine { return undefined; } + /** The MAC address for `ipAddress` without asking: localhost, one of the node's own, or cached. */ + private knownMacAddress(nodeId: Id, ipAddress: string): string | undefined { + const interfaces = this.networkInterfaces(nodeId); + const primaryInterface = interfaces[0]; + if (primaryInterface === undefined) return undefined; + if (ipAddress === LOCALHOST_IP_ADDRESS) return primaryInterface.macAddress; + const localInterface = interfaces.find((candidate) => candidate.ipAddress === ipAddress); + if (localInterface !== undefined) return localInterface.macAddress; + return this.stateValue.arpCachesByNodeId.get(nodeId)?.get(ipAddress)?.macAddress; + } + + /** One ARP request for `targetIPAddress`, broadcast from the interface. */ + private sendArpRequest(nodeId: Id, networkInterface: NetworkInterface, targetIPAddress: string): void { + const request: ArpPacket = { + operation: 'request', + senderMacAddress: networkInterface.macAddress, + senderIPAddress: networkInterface.ipAddress, + targetMacAddress: UNSPECIFIED_MAC_ADDRESS, + targetIPAddress, + }; + const frame: EthernetFrame = { + identity: this.allocateFrameIdentity(), + sourceMacAddress: networkInterface.macAddress, + destinationMacAddress: ETHERNET_BROADCAST_MAC_ADDRESS, + payload: { kind: 'arp', packet: request }, + }; + this.recordTrace({ + frameIdentity: frame.identity, + nodeId, + interfaceId: networkInterface.portId, + direction: 'outbound', + layer: 'dataLink', + operation: 'created', + afterHeaders: ethernetHeaders(frame), + detail: `ARP request for ${targetIPAddress}`, + }); + this.sendEthernetFrame(nodeId, networkInterface.portId, frame); + } + private advanceRemoteLinkEventsUntilArpCacheResolves(nodeId: Id, targetIPAddress: string): void { if (this.isProcessingScheduledEvents) return; this.advancePendingRemoteLinkEvents( @@ -2638,6 +2839,7 @@ export class NetworkRuntimeEngine { updatedAtMilliseconds: this.stateValue.currentTimeMilliseconds, }); this.flushIPv4TransmissionsAwaitingArp(nodeId, packet.senderIPAddress, packet.senderMacAddress); + this.dhcpClientLearnedAddress(nodeId, packet.senderIPAddress); } this.recordTrace({ frameIdentity, @@ -3485,7 +3687,14 @@ export function ipv4Headers(packet: IPv4Packet): HeaderField[] { } function dhcpClientStatus(): DhcpClientStatus { - return { state: 'INIT', errorCount: 0, selectedServerIPAddress: undefined, offeredIPAddress: undefined, succeeded: false }; + return { + state: 'INIT', + errorCount: 0, + selectedServerIPAddress: undefined, + offeredIPAddress: undefined, + succeeded: false, + linkLocal: false, + }; } function dhcpRecordKey(serverNodeId: Id, ipAddress: string): string { diff --git a/packages/core/src/runtime/random.ts b/packages/core/src/runtime/random.ts index cd69fc4..b406cca 100644 --- a/packages/core/src/runtime/random.ts +++ b/packages/core/src/runtime/random.ts @@ -50,6 +50,11 @@ export class JavaCompatibleRandom { return Math.fround(this.next(24) / (1 << 24)); } + /** Java's nextDouble (what Math.random draws): 53 random bits over 2^53. */ + nextDouble(): number { + return (this.next(26) * 2 ** 27 + this.next(27)) / 2 ** 53; + } + /** Java RIP: 30 s · (f/3 + 0.84), truncated — somewhere between 25.2 s and 35.2 s. */ nextRipJitterMilliseconds(): number { const javaFloat = this.nextFloat(); diff --git a/packages/core/src/runtime/speed.ts b/packages/core/src/runtime/speed.ts index 483359c..d442e05 100644 --- a/packages/core/src/runtime/speed.ts +++ b/packages/core/src/runtime/speed.ts @@ -7,6 +7,8 @@ export const MINIMUM_SPEED_PERCENT = 1; export const MAXIMUM_SPEED_PERCENT = 100; export const DEFAULT_SPEED_PERCENT = 90; export const JAVA_MINIMUM_LINK_DELAY_MILLISECONDS = 5; +/** Java's Verbindung.MAX_HOPS: the round trip time is this many cables. */ +export const JAVA_MAXIMUM_HOPS = 50; export class RuntimeSpeed { readonly percent: number; @@ -25,6 +27,14 @@ export class RuntimeSpeed { return JAVA_MINIMUM_LINK_DELAY_MILLISECONDS * this.javaDelayFactor; } + /** + * Java's Verbindung.holeRTT: how long an answer may take, 50 cables (2750 ms + * at the default 90 %). Java's RTT factor (a start option, 1 to 5) is 1. + */ + get roundTripTimeMilliseconds(): number { + return JAVA_MAXIMUM_HOPS * this.linkTransmissionDelayMilliseconds; + } + /** A base delay scaled like a cable, or undefined past the safe integer range. */ scaledLinkDelay(baseMilliseconds: number): number | undefined { const delay = baseMilliseconds * this.javaDelayFactor; diff --git a/packages/core/test/dhcpJava.test.ts b/packages/core/test/dhcpJava.test.ts new file mode 100644 index 0000000..d1939e0 --- /dev/null +++ b/packages/core/test/dhcpJava.test.ts @@ -0,0 +1,187 @@ +// DHCP as Java FILIUS runs it. tools/java-oracle's DhcpRun built each +// scenario's network in Java FILIUS, ran it and wrote what every network card +// saw (.java.json, FILIUS' own packet log). The engine runs the same +// network. For each DHCP client, the frames that concern it must come in the +// same order, with the same content, at about the same distance from each +// other, and every host must end with the same addresses. +// +// A frame counts when it first shows on any card (sent, or received), in +// Java and in the engine alike. Not only on the client's own card: there, +// FILIUS' log misses the DISCOVER of the client that sends second when two +// start together (measured in four of four runs of dhcp-lan; the server +// receives it) — a fault of the log, not of the network. Java logs no frame +// identity; the same text within DUPLICATE_WINDOW_MILLISECONDS is one frame +// (measured: a frame reaches the last card within some 130 ms; the closest +// two distinct frames of the same text are the DISCOVERs around a DECLINE, +// some 520 ms apart). +// +// Java runs on threads in real time, at the default speed of 90 % (55 ms a +// cable, a round trip time of 2750 ms). Its gaps match the engine's within a +// few milliseconds, except where a card sends two frames at once: Java's +// cable carries one frame at a time (SimplexVerbindung sleeps the delay with +// the port's buffer locked, and the sender waits), the engine's cable all of +// them together — up to three cables' delay, measured. A gap may differ by +// GAP_TOLERANCE_MILLISECONDS; a wrong round trip time (250 ms) may not. Java draws the link-local +// address with Math.random; it is compared as 169.254.1.x. Where several +// clients start together, which one is first varies in Java (the first +// thread pays for loading the classes); a client is therefore compared +// without the other clients' frames and the answers to them. + +import { readdirSync, readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; +import { NetworkRuntimeEngine, stableMacAddress, type EthernetFrame } from '../src/index.js'; +import { snapshotOf, type Scenario } from './scenario.js'; + +const scenarioDirectory = join(dirname(fileURLToPath(import.meta.url)), 'scenarios'); + +const GAP_TOLERANCE_MILLISECONDS = 200; +const DUPLICATE_WINDOW_MILLISECONDS = 300; + +interface JavaFrame { + time: number; + source: string; + destination: string; + arp?: number; + senderIp?: string; + targetIp?: string; + sourceIp?: string; + destinationIp?: string; + protocol?: number; + sourcePort?: number; + destinationPort?: number; + payload?: string; + icmp?: number; +} + +interface JavaRun { + frames: Record; + devices: Record; +} + +interface SeenFrame { + readonly time: number; + readonly text: string; +} + +const LINK_LOCAL = /169\.254\.1\.\d+/g; + +function javaText(frame: JavaFrame): string { + const macs = `${frame.source} > ${frame.destination}`.toUpperCase(); + if (frame.arp !== undefined) return `${macs} ARP ${frame.arp === 1 ? 'request' : 'reply'} ${frame.senderIp} > ${frame.targetIp}`; + if (frame.icmp !== undefined) return `${macs} ICMP ${frame.icmp}`; + return `${macs} UDP ${frame.sourceIp}:${frame.sourcePort} > ${frame.destinationIp}:${frame.destinationPort} ${frame.payload}`; +} + +function engineText(frame: EthernetFrame): string { + const macs = `${frame.sourceMacAddress} > ${frame.destinationMacAddress}`.toUpperCase(); + if (frame.payload.kind === 'arp') { + const arp = frame.payload.packet; + return `${macs} ARP ${arp.operation} ${arp.senderIPAddress} > ${arp.targetIPAddress}`; + } + const packet = frame.payload.packet; + if (packet.payload.kind !== 'udp') return `${macs} ${packet.payload.kind.toUpperCase()}`; + const datagram = packet.payload.datagram; + return `${macs} UDP ${packet.senderIPAddress}:${datagram.sourcePort} > ${packet.receiverIPAddress}:${datagram.destinationPort} ${new TextDecoder().decode(datagram.payload)}`; +} + +/** The engine's run of the scenario's network, without its steps, for `milliseconds`. */ +function runEngine(scenario: Scenario, milliseconds: number) { + const engine = new NetworkRuntimeEngine(scenario.seed); + const framesByIdentity = new Map(); + const send = engine.sendEthernetFrame.bind(engine); + engine.sendEthernetFrame = (fromNodeId, outgoingPortId, frame) => { + framesByIdentity.set(frame.identity, frame); + send(fromNodeId, outgoingPortId, frame); + }; + engine.handle({ type: 'start', snapshot: snapshotOf(scenario), seed: scenario.seed, initialTimeMilliseconds: 0 }); + engine.handle({ type: 'advance', toMilliseconds: milliseconds }); + + // Every frame when it first shows on a host's card (as FILIUS logs it: sent, or received). + const hosts = new Set(scenario.nodes.filter((node) => node.kind !== 'networkSwitch').map((node) => node.id)); + const firstSeen = new Map(); + for (const event of engine.state.packetTraces) { + if (event.layer !== 'physical' || !hosts.has(event.nodeId) || event.frameIdentity === undefined) continue; + if (!((event.direction === 'outbound' && event.operation === 'sent') || (event.direction === 'inbound' && event.operation === 'received'))) continue; + if (firstSeen.has(event.frameIdentity)) continue; + firstSeen.set(event.frameIdentity, { time: event.timeMilliseconds, text: engineText(framesByIdentity.get(event.frameIdentity)!) }); + } + const devices = Object.fromEntries( + [...engine.state.topologySnapshot.deviceConfigurations].map(([node, device]) => [ + node, + [device.ipAddress, device.subnetMask, device.defaultGateway, device.dnsServer], + ]), + ); + return { frames: [...firstSeen.values()], devices }; +} + +/** Java's frames of all cards, each once: the same text within the window is the same frame. */ +function javaFrames(java: JavaRun): SeenFrame[] { + const all = Object.values(java.frames) + .flat() + .map((frame) => ({ time: frame.time, text: javaText(frame) })) + .sort((lhs, rhs) => lhs.time - rhs.time); + const frames: SeenFrame[] = []; + for (const frame of all) { + if (frames.some((each) => each.text === frame.text && frame.time - each.time <= DUPLICATE_WINDOW_MILLISECONDS)) continue; + frames.push(frame); + } + return frames; +} + +/** Consecutive frames without the other clients' own: the text, and the time since the one before. */ +function steps(frames: readonly SeenFrame[], otherClientMacs: readonly string[]) { + const theirs = (frame: SeenFrame) => + otherClientMacs.some((mac) => frame.text.startsWith(`${mac} >`) || frame.text.includes(`chaddr=${mac}`)); + const own = frames.filter((frame) => !theirs(frame)); + return own.map((frame, index) => ({ + text: frame.text.replace(LINK_LOCAL, '169.254.1.x'), + gap: index === 0 ? 0 : frame.time - own[index - 1]!.time, + })); +} + +function clientMac(scenario: Scenario, nodeId: string): string { + const port = scenario.nodes.find((node) => node.id === nodeId)!.ports[0]!; + return (port.mac ?? stableMacAddress(port.id)).toUpperCase(); +} + +const runs = readdirSync(scenarioDirectory) + .filter((name) => name.endsWith('.java.json')) + .map((name) => name.replace('.java.json', '')) + .sort(); + +describe('DHCP as Java FILIUS runs it', () => { + it('has Java runs', () => { + expect(runs.length).toBeGreaterThan(0); + }); + + for (const name of runs) { + it(name, () => { + const scenario = JSON.parse(readFileSync(join(scenarioDirectory, `${name}.scenario.json`), 'utf8')) as Scenario; + const java = JSON.parse(readFileSync(join(scenarioDirectory, `${name}.java.json`), 'utf8')) as JavaRun; + const lastJavaFrame = Math.max(...Object.values(java.frames).flatMap((frames) => frames.map((frame) => frame.time))); + const engine = runEngine(scenario, lastJavaFrame + 10_000); + + const clients = scenario.dhcpClients ?? []; + for (const client of clients) { + const others = clients.filter((each) => each !== client).map((each) => clientMac(scenario, each)); + const expected = steps(javaFrames(java), others); + const actual = steps(engine.frames, others); + for (let index = 0; index < Math.max(expected.length, actual.length); index += 1) { + expect(actual[index]?.text, `${client} frame #${index + 1}`).toBe(expected[index]?.text); + expect(Math.abs(actual[index]!.gap - expected[index]!.gap), `${client} gap before frame #${index + 1}`).toBeLessThanOrEqual( + GAP_TOLERANCE_MILLISECONDS, + ); + } + } + const linkLocal = (devices: Record) => + Object.fromEntries(Object.entries(devices).map(([node, device]) => [node, device.map((value) => value.replace(LINK_LOCAL, '169.254.1.x'))])); + expect(linkLocal(engine.devices)).toEqual(linkLocal(java.devices)); + }); + } + + it('builds the MACs as DhcpRun does', () => { + expect(stableMacAddress('00000000-0000-0000-0000-000000000112')).toBe('02:00:00:00:01:12'); + }); +}); diff --git a/packages/core/test/engine.test.ts b/packages/core/test/engine.test.ts index fe15308..fb3d2a9 100644 --- a/packages/core/test/engine.test.ts +++ b/packages/core/test/engine.test.ts @@ -233,7 +233,7 @@ describe('event queue and virtual clock', () => { [serverId, dhcpServerConfiguration({ isActive: true, lowerBoundIPAddress: '10.4.0.20', upperBoundIPAddress: '10.4.0.29' })], ]), }); - engine.handle({ type: 'advance', toMilliseconds: 1_000 }); + engine.handle({ type: 'advance', toMilliseconds: 5_000 }); expect(engine.state.dhcpLeasesByIPAddress.size).toBe(1); engine.handle({ type: 'stop' }); @@ -377,6 +377,20 @@ describe('Java-compatible randomness', () => { expect([random.nextInt(100), random.nextInt(100), random.nextInt(100)]).toEqual([30, 63, 48]); expect([random.nextInt(16), random.nextInt(16), random.nextInt(16)]).toEqual([0, 4, 15]); }); + + it('matches java.util.Random.nextDouble', () => { + // Measured on OpenJDK 21 (30.09.2026): nextDouble twice, then + // Math.round(nextDouble() * 253f) as the DHCP client draws a link-local address. + const cases: [number, number, number, number][] = [ + [42, 0.7275636800328681, 0.6832234717598454, 78], + [0, 0.730967787376657, 0.24053641567148587, 161], + [-7, 0.2691218093260761, 0.6731811902798038, 132], + ]; + for (const [seed, first, second, rounded] of cases) { + const random = new JavaCompatibleRandom(seed); + expect([random.nextDouble(), random.nextDouble(), Math.round(random.nextDouble() * 253)]).toEqual([first, second, rounded]); + } + }); }); describe('IPv4 packets', () => { @@ -906,6 +920,9 @@ describe('TCP sockets', () => { }); describe('DHCP', () => { + // Java's round trip time at the default speed of 90 %: 50 cables of 55 ms. + const RTT = 2_750; + const serverAndClient = (first: number, serverIp: string, client: DeviceConfiguration, server: Partial) => { const [serverId, serverPortId, clientId, clientPortId] = [first, first + 1, first + 2, first + 3].map(uuid) as [string, string, string, string]; const engine = startedEngine( @@ -921,7 +938,58 @@ describe('DHCP', () => { }, 16, ); - return { engine, clientId, clientPortId }; + return { engine, serverId, clientId, clientPortId }; + }; + + /** Hosts on one switch, each with its device configuration; the first is the DHCP client. */ + const onSwitch = (first: number, devices: DeviceConfiguration[], servers: Map> = new Map()) => { + const switchId = uuid(first); + const hosts = devices.map((_, index) => ({ id: uuid(first + 1 + 2 * index), portId: uuid(first + 2 + 2 * index), switchPortId: uuid(first + 0x80 + index) })); + const switchNode: NodeSnapshot = { + id: switchId, + kind: 'networkSwitch', + ports: hosts.map((host, index) => portSnapshot(host.switchPortId, `sw${index + 1}`)), + }; + const hostIds = hosts.map((host) => host.id); + const engine = startedEngine( + { + nodes: [switchNode, ...hosts.map((host) => pc(host.id, host.portId))], + links: hosts.map((host) => link(host.id, host.portId, switchId, host.switchPortId)), + deviceConfigurations: new Map(devices.map((each, index) => [hostIds[index]!, each])), + dhcpClientConfigurationsByNodeId: new Map([[hostIds[0]!, { isEnabled: true }]]), + dhcpServerConfigurationsByNodeId: new Map( + [...servers].map(([ipAddress, server]) => [ + hostIds[devices.findIndex((each) => each.ipAddress === ipAddress)]!, + dhcpServerConfiguration({ isActive: true, ...server }), + ]), + ), + }, + 16, + ); + return { engine, hostIds }; + }; + + /** When the node sent a DHCP message of the type. */ + const sentAt = (engine: NetworkRuntimeEngine, nodeId: string, type: string) => + engine.state.packetTraces + .filter((trace) => trace.nodeId === nodeId && trace.layer === 'application' && trace.operation === 'sent' && trace.detail === type) + .map((trace) => trace.timeMilliseconds); + + /** When the node broadcast an ARP packet with the detail. */ + const arpAt = (engine: NetworkRuntimeEngine, nodeId: string, detail: string) => + engine.state.packetTraces + .filter((trace) => trace.nodeId === nodeId && trace.layer === 'dataLink' && trace.operation === 'created' && trace.detail === detail) + .map((trace) => trace.timeMilliseconds); + + /** The link-local addresses the client draws with seed 16, as Java's selectArbitraryLinkLocalAddress skips known ones. */ + const linkLocalDraws = (count: number) => { + const random = new JavaCompatibleRandom(16n ^ 0x4c4c_4144n); + const draws: string[] = []; + while (draws.length < count) { + const address = `169.254.1.${Math.round(random.nextDouble() * 253) + 1}`; + if (!draws.includes(address)) draws.push(address); + } + return draws; }; it('completes DISCOVER, OFFER, REQUEST and ACK and applies the configuration', () => { @@ -934,17 +1002,23 @@ describe('DHCP', () => { }); engine.handle({ type: 'advance', toMilliseconds: 1_000 }); + // A client that runs goes on waiting; it does not start over. + expect(engine.runDhcpClient(clientId)?.state).toBe('VALIDATE'); + engine.handle({ type: 'advance', toMilliseconds: 5_000 }); expect(engine.state.topologySnapshot.deviceConfigurations.get(clientId)).toEqual( deviceConfiguration('192.168.50.20', '255.255.255.0', '192.168.50.1', '192.168.50.53'), ); const status = engine.state.dhcpClientStatusesByNodeId.get(clientId); - expect([status?.state, status?.errorCount, status?.succeeded]).toEqual(['FINISH', 0, true]); + expect([status?.state, status?.errorCount, status?.succeeded, status?.linkLocal]).toEqual(['FINISH', 0, true, false]); const leaseExpiry = [...engine.state.dhcpLeasesByIPAddress.values()][0]?.expiresAtMilliseconds; expect(leaseExpiry).toBeGreaterThan(engine.state.currentTimeMilliseconds); expect(leaseExpiry! - engine.state.currentTimeMilliseconds).toBeLessThanOrEqual(DHCP_DYNAMIC_LEASE_LIFETIME_MILLISECONDS); - const details = engine.state.packetTraces.map((trace) => trace.detail); - for (const message of ['DHCPDISCOVER', 'DHCPOFFER', 'DHCPREQUEST', 'DHCPACK']) expect(details).toContain(message); + // One message per attempt. + expect(sentAt(engine, clientId, 'DHCPDISCOVER')).toEqual([0]); + // The OFFER comes after two cables' delay; the ARP check waits one round trip before the REQUEST. + expect(arpAt(engine, clientId, 'ARP request for 192.168.50.20')).toEqual([110]); + expect(sentAt(engine, clientId, 'DHCPREQUEST')).toEqual([110 + RTT]); }); it('matches a static assignment regardless of case, with a lease that does not expire', () => { @@ -955,20 +1029,19 @@ describe('DHCP', () => { staticAssignments: [{ id: 'static', macAddress: stableMacAddress(clientPortId).toLowerCase(), ipAddress: '10.0.0.99' }], }); - engine.handle({ type: 'advance', toMilliseconds: 1_000 }); + engine.handle({ type: 'advance', toMilliseconds: 5_000 }); expect(engine.state.topologySnapshot.deviceConfigurations.get(clientId)?.ipAddress).toBe('10.0.0.99'); expect([...engine.state.dhcpLeasesByIPAddress.values()][0]?.expiresAtMilliseconds).toBeUndefined(); }); - it('retries ten timeouts and gives back only the old address', () => { + it('takes a link-local address after the fifth timeout and does not give back the old one', () => { const clientId = uuid(0x1621); - const oldConfiguration = deviceConfiguration('169.254.5.7', '255.255.0.0', '169.254.0.1', '169.254.0.53'); const engine = startedEngine( { nodes: [pc(clientId, uuid(0x1622))], links: [], - deviceConfigurations: new Map([[clientId, oldConfiguration]]), + deviceConfigurations: new Map([[clientId, deviceConfiguration('169.254.5.7', '255.255.0.0', '169.254.0.1', '169.254.0.53')]]), dhcpClientConfigurationsByNodeId: new Map([[clientId, { isEnabled: true }]]), }, 16, @@ -977,17 +1050,186 @@ describe('DHCP', () => { engine.handle({ type: 'advance', toMilliseconds: 0 }); expect(engine.state.topologySnapshot.deviceConfigurations.get(clientId)?.ipAddress).toBe('0.0.0.0'); expect(engine.state.dhcpClientStatusesByNodeId.get(clientId)?.state).toBe('DISCOVER'); - expect(engine.state.dhcpClientStatusesByNodeId.get(clientId)?.errorCount).toBe(0); - engine.handle({ type: 'advance', toMilliseconds: 25_000 }); + engine.handle({ type: 'advance', toMilliseconds: 5 * RTT - 1 }); + expect(engine.state.dhcpClientStatusesByNodeId.get(clientId)?.errorCount).toBe(4); + engine.handle({ type: 'advance', toMilliseconds: 20_000 }); + const [address] = linkLocalDraws(1); + expect(sentAt(engine, clientId, 'DHCPDISCOVER')).toEqual([0, RTT, 2 * RTT, 3 * RTT, 4 * RTT]); + expect(arpAt(engine, clientId, `ARP request for ${address}`)).toEqual([5 * RTT]); + expect(arpAt(engine, clientId, `ARP reply for ${address}`)).toEqual([6 * RTT]); + // Java: the address, its /16 mask and no gateway; the DNS server stays. + expect(engine.state.topologySnapshot.deviceConfigurations.get(clientId)).toEqual( + deviceConfiguration(address!, '255.255.0.0', '', '169.254.0.53'), + ); const status = engine.state.dhcpClientStatusesByNodeId.get(clientId); - expect([status?.state, status?.errorCount, status?.succeeded]).toEqual(['FINISH', 10, false]); - expect(engine.state.topologySnapshot.deviceConfigurations.get(clientId)).toEqual(oldConfiguration); + expect([status?.state, status?.errorCount, status?.succeeded, status?.linkLocal]).toEqual(['FINISH', 5, true, true]); expect(engine.state.dhcpClientContextsByNodeId.has(clientId)).toBe(false); expect(engine.state.socketsById.size).toBe(0); }); + it('sends the REQUEST again to the same server when it does not answer', () => { + const { engine, serverId, clientId } = serverAndClient(0x1631, '10.0.0.1', device('0.0.0.0'), { + lowerBoundIPAddress: '10.0.0.20', + upperBoundIPAddress: '10.0.0.29', + }); + engine.handle({ type: 'advance', toMilliseconds: 150 }); + expect(engine.state.dhcpClientStatusesByNodeId.get(clientId)?.state).toBe('VALIDATE'); + engine.closeSocket(engine.state.dhcpServerSocketIdsByNodeId.get(serverId)!); + + engine.handle({ type: 'advance', toMilliseconds: 110 + 5 * RTT }); + + const requests = [1, 2, 3, 4, 5].map((attempt) => 110 + attempt * RTT); + expect(sentAt(engine, clientId, 'DHCPREQUEST')).toEqual(requests); + expect(sentAt(engine, clientId, 'DHCPDISCOVER')).toEqual([0]); + const status = engine.state.dhcpClientStatusesByNodeId.get(clientId); + expect([status?.state, status?.errorCount, status?.offeredIPAddress]).toEqual(['REQUEST', 4, '10.0.0.20']); + }); + + it('declines at once when another card answers for the offered address', () => { + const { engine, hostIds } = onSwitch( + 0x1641, + [device('0.0.0.0'), device('192.168.0.10'), device('192.168.0.100')], + new Map([['192.168.0.10', { lowerBoundIPAddress: '192.168.0.100', upperBoundIPAddress: '192.168.0.100' }]]), + ); + const clientId = hostIds[0]!; + + engine.handle({ type: 'advance', toMilliseconds: 1_000 }); + + // The OFFER takes two cables to the client, the ARP request and the + // holder's reply two each; the DECLINE follows the reply, not the round trip. + const [check] = arpAt(engine, clientId, 'ARP request for 192.168.0.100'); + expect(check).toBe(220); + expect(sentAt(engine, clientId, 'DHCPDECLINE')).toEqual([check! + 220]); + expect(sentAt(engine, clientId, 'DHCPDISCOVER')).toEqual([0, check! + 220]); + expect(sentAt(engine, clientId, 'DHCPREQUEST')).toEqual([]); + }); + + it('starts over with DISCOVER after a NAK, which is no failure', () => { + const { engine, serverId, clientId } = serverAndClient(0x1671, '10.0.0.1', device('0.0.0.0'), { + lowerBoundIPAddress: '10.0.0.20', + upperBoundIPAddress: '10.0.0.29', + }); + engine.handle({ type: 'advance', toMilliseconds: 150 }); + // While the client checks 10.0.0.20, the server bars it: the REQUEST gets a NAK. + engine.state.dhcpBlacklistByServerNodeId.set(serverId, new Map([['10.0.0.20', 1_000_000]])); + + engine.handle({ type: 'advance', toMilliseconds: 10_000 }); + + const nak = 110 + RTT + 110; + expect(sentAt(engine, clientId, 'DHCPREQUEST')).toEqual([110 + RTT, nak + 110 + RTT]); + expect(sentAt(engine, clientId, 'DHCPDISCOVER')).toEqual([0, nak]); + const status = engine.state.dhcpClientStatusesByNodeId.get(clientId); + expect([status?.state, status?.errorCount, status?.succeeded]).toEqual(['FINISH', 0, true]); + expect(engine.state.topologySnapshot.deviceConfigurations.get(clientId)?.ipAddress).toBe('10.0.0.21'); + }); + + it('ignores an answer from another server than the one it asked', () => { + const { engine, hostIds } = onSwitch( + 0x1681, + [device('0.0.0.0'), device('192.168.0.10'), device('192.168.0.66')], + new Map([['192.168.0.10', { lowerBoundIPAddress: '192.168.0.100', upperBoundIPAddress: '192.168.0.100' }]]), + ); + const [clientId, , rogueId] = hostIds as [string, string, string]; + const clientMac = stableMacAddress(uuid(0x1681 + 2)); + const request = 220 + RTT; + engine.handle({ type: 'advance', toMilliseconds: request + 10 }); + expect(engine.state.dhcpClientStatusesByNodeId.get(clientId)?.state).toBe('REQUEST'); + + // A NAK for the client, but from 192.168.0.66; it arrives before the server's ACK. + const rogue = engine.bindUdpSocket(rogueId, { localPort: 67 })!; + const nak = encodeDhcpMessage({ type: 'DHCPNAK', yourIPAddress: '192.168.0.100', clientMacAddress: clientMac, serverIdentifier: '192.168.0.66' }); + engine.sendUdp(rogue, nak, { ipAddress: '255.255.255.255', port: 68 }); + engine.handle({ type: 'advance', toMilliseconds: 10_000 }); + + expect(sentAt(engine, clientId, 'DHCPDISCOVER')).toEqual([0]); + expect(engine.state.topologySnapshot.deviceConfigurations.get(clientId)?.ipAddress).toBe('192.168.0.100'); + }); + + it("takes the other server's queued OFFER at once after a DECLINE", () => { + // Server A offers the address a third card has, server B another one; B's + // OFFER comes while the client checks A's and waits in its queue. + const { engine, hostIds } = onSwitch( + 0x1691, + [device('0.0.0.0'), device('192.168.0.10'), device('192.168.0.20'), device('192.168.0.100')], + new Map([ + ['192.168.0.10', { lowerBoundIPAddress: '192.168.0.100', upperBoundIPAddress: '192.168.0.100' }], + ['192.168.0.20', { lowerBoundIPAddress: '192.168.0.200', upperBoundIPAddress: '192.168.0.200' }], + ]), + ); + const clientId = hostIds[0]!; + + engine.handle({ type: 'advance', toMilliseconds: 10_000 }); + + const decline = 220 + 220; + expect(sentAt(engine, clientId, 'DHCPDECLINE')).toEqual([decline]); + expect(sentAt(engine, clientId, 'DHCPDISCOVER')).toEqual([0, decline]); + expect(arpAt(engine, clientId, 'ARP request for 192.168.0.200')).toEqual([decline]); + expect(sentAt(engine, clientId, 'DHCPREQUEST')).toEqual([decline + RTT]); + expect(engine.state.topologySnapshot.deviceConfigurations.get(clientId)?.ipAddress).toBe('192.168.0.200'); + }); + + it('skips a link-local address the ARP cache knows', () => { + const [known, next] = linkLocalDraws(2) as [string, string]; + const { engine, hostIds } = onSwitch(0x16a1, [ + device('0.0.0.0'), + device(known, { mask: '255.255.0.0' }), + device('169.254.9.9', { mask: '255.255.0.0' }), + ]); + const clientId = hostIds[0]!; + engine.handle({ type: 'advance', toMilliseconds: 1_000 }); + engine.resolveMacAddress(hostIds[1]!, '169.254.9.9', { maxRetries: 1 }); + expect(engine.state.arpCachesByNodeId.get(clientId)?.has(known)).toBe(true); + + engine.handle({ type: 'advance', toMilliseconds: 20_000 }); + + expect(arpAt(engine, clientId, `ARP request for ${known}`)).toEqual([]); + expect(arpAt(engine, clientId, `ARP request for ${next}`)).toEqual([5 * RTT]); + expect(engine.state.dhcpClientStatusesByNodeId.get(clientId)?.errorCount).toBe(5); + expect(engine.state.topologySnapshot.deviceConfigurations.get(clientId)?.ipAddress).toBe(next); + }); + + // Java's selectArbitraryLinkLocalAddress loop counts occupied addresses + // but checks its limit only after a timeout, which cannot come there: it + // searches without end. The engine gives up after the fifth, as + // LINK_LOCAL_MAX_ERROR_COUNT intends, and keeps 0.0.0.0. + it('gives up after five occupied link-local addresses (Java searches on without end)', () => { + const occupied = linkLocalDraws(5); + const { engine, hostIds } = onSwitch(0x1651, [device('0.0.0.0'), ...occupied.map((address) => device(address, { mask: '255.255.0.0' }))]); + const clientId = hostIds[0]!; + + engine.handle({ type: 'advance', toMilliseconds: 30_000 }); + + for (const address of occupied) expect(arpAt(engine, clientId, `ARP request for ${address}`)).toHaveLength(1); + const status = engine.state.dhcpClientStatusesByNodeId.get(clientId); + expect([status?.state, status?.errorCount, status?.succeeded]).toEqual(['FINISH', 10, false]); + expect(engine.state.topologySnapshot.deviceConfigurations.get(clientId)?.ipAddress).toBe('0.0.0.0'); + expect(engine.state.dhcpClientContextsByNodeId.has(clientId)).toBe(false); + }); + + // Java's ARP lookup waits on the whole ARP table: any entry that changes + // ends the wait, and the address counts as free. The engine waits for the + // address it checks, or the round trip. + it('waits out the address check when an unrelated ARP packet comes (Java would stop waiting)', () => { + const { engine, hostIds } = onSwitch( + 0x1661, + [device('0.0.0.0'), device('192.168.0.10'), device('192.168.0.30')], + new Map([['192.168.0.10', { lowerBoundIPAddress: '192.168.0.100', upperBoundIPAddress: '192.168.0.100' }]]), + ); + const clientId = hostIds[0]!; + engine.handle({ type: 'advance', toMilliseconds: 300 }); + expect(engine.state.dhcpClientStatusesByNodeId.get(clientId)?.state).toBe('VALIDATE'); + + engine.resolveMacAddress(hostIds[2]!, '192.168.0.77', { maxRetries: 1 }); + expect(engine.state.arpCachesByNodeId.get(clientId)?.has('192.168.0.30')).toBe(true); + engine.handle({ type: 'advance', toMilliseconds: 5_000 }); + + const [check] = arpAt(engine, clientId, 'ARP request for 192.168.0.100'); + expect(sentAt(engine, clientId, 'DHCPREQUEST')).toEqual([check! + RTT]); + expect(engine.state.topologySnapshot.deviceConfigurations.get(clientId)?.ipAddress).toBe('192.168.0.100'); + }); + it("writes and reads a message in Swift's text format", () => { const message = { type: 'DHCPOFFER' as const, diff --git a/packages/core/test/oracle.test.ts b/packages/core/test/oracle.test.ts index a5e2c87..1463c2d 100644 --- a/packages/core/test/oracle.test.ts +++ b/packages/core/test/oracle.test.ts @@ -8,178 +8,16 @@ import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; import { - deviceConfiguration, - dhcpServerConfiguration, - firewallConfiguration, - firewallRule, - interfaceKey, NetworkRuntimeEngine, - portSnapshot, - switchConfiguration, - topologySnapshot, type DhcpClientStatus, - type FirewallConfiguration, type IPv4DeliveryResult, type IcmpObservation, - type ManualRoute, - type NodeKind, - type TopologySnapshot, type TraceEvent, } from '../src/index.js'; +import { snapshotOf, type Scenario } from './scenario.js'; const scenarioDirectory = join(dirname(fileURLToPath(import.meta.url)), 'scenarios'); -interface Scenario { - seed: number; - speedPercent?: number; - nodes: { id: string; kind: NodeKind; ports: { id: string; label: string; mac?: string }[] }[]; - links: { id: string; sourceNode: string; sourcePort: string; targetNode: string; targetPort: string }[]; - devices?: Record; - interfaces?: { node: string; port: string; ip: string; mask: string }[]; - routes?: Record; - switches?: Record; - firewalls?: Record< - string, - { - active?: boolean; - defaultPolicy?: 'accept' | 'drop'; - dropIcmp?: boolean; - synOnly?: boolean; - filterUdp?: boolean; - rules?: { - source?: string; - sourceMask?: string; - destination?: string; - destinationMask?: string; - port?: number; - protocol?: 'all' | 'icmp' | 'tcp' | 'udp'; - action?: 'accept' | 'drop'; - }[]; - } - >; - dhcpServers?: Record< - string, - { active?: boolean; lower?: string; upper?: string; gateway?: string; dns?: string; own?: boolean; staticAssignments?: [string, string][] } - >; - dhcpClients?: string[]; - steps: { - op: string; - from?: string; - node?: string; - target?: string; - ttl?: number; - identifier?: number; - sequence?: number; - maxTtl?: number; - to?: number; - enabled?: boolean; - percent?: number; - // UDP: a socket is named by the id bindUdp returned. - port?: number; - localIp?: string; - remoteIp?: string; - remotePort?: number; - socket?: string; - payload?: string; - timeout?: number; - // TCP: the payload repeated this many times, for messages longer than a segment. - repeatCount?: number; - localPort?: number; - }[]; -} - -function snapshotOf(scenario: Scenario): TopologySnapshot { - const firewalls = new Map(); - for (const [node, config] of Object.entries(scenario.firewalls ?? {})) { - firewalls.set( - node, - firewallConfiguration({ - isActive: config.active ?? false, - defaultPolicy: config.defaultPolicy ?? 'drop', - dropIcmp: config.dropIcmp ?? false, - filterSynSegmentsOnly: config.synOnly ?? true, - filterUdp: config.filterUdp ?? true, - rules: (config.rules ?? []).map((rule) => - firewallRule({ - sourceIPAddress: rule.source ?? '', - sourceSubnetMask: rule.sourceMask ?? '', - destinationIPAddress: rule.destination ?? '', - destinationSubnetMask: rule.destinationMask ?? '', - ...(rule.port === undefined ? {} : { port: rule.port }), - protocol: rule.protocol ?? 'tcp', - action: rule.action ?? 'drop', - }), - ), - }), - ); - } - return topologySnapshot({ - nodes: scenario.nodes.map((node) => ({ - id: node.id, - kind: node.kind, - ports: node.ports.map((port) => portSnapshot(port.id, port.label, port.mac)), - })), - links: scenario.links.map((link) => ({ - id: link.id, - sourceNodeId: link.sourceNode, - sourcePortId: link.sourcePort, - targetNodeId: link.targetNode, - targetPortId: link.targetPort, - })), - deviceConfigurations: new Map( - Object.entries(scenario.devices ?? {}).map(([node, device]) => [ - node, - deviceConfiguration(device.ip, device.mask, device.gateway ?? '', device.dns ?? ''), - ]), - ), - interfaceConfigurations: new Map( - (scenario.interfaces ?? []).map((item) => [ - interfaceKey(item.node, item.port), - { ipAddress: item.ip, subnetMask: item.mask }, - ]), - ), - manualRoutesByNodeId: new Map( - Object.entries(scenario.routes ?? {}).map(([node, rows]) => [ - node, - rows.map( - (row): ManualRoute => ({ - destinationNetwork: row.destination, - subnetMask: row.mask, - gateway: row.gateway, - interfaceIPAddress: row.interface, - }), - ), - ]), - ), - dhcpServerConfigurationsByNodeId: new Map( - Object.entries(scenario.dhcpServers ?? {}).map(([node, config]) => [ - node, - dhcpServerConfiguration({ - isActive: config.active ?? false, - lowerBoundIPAddress: config.lower ?? '0.0.0.0', - upperBoundIPAddress: config.upper ?? '0.0.0.0', - gatewayIPAddress: config.gateway ?? '0.0.0.0', - dnsServerIPAddress: config.dns ?? '0.0.0.0', - useOwnSettings: config.own ?? false, - staticAssignments: (config.staticAssignments ?? []).map(([macAddress, ipAddress], index) => ({ - id: `static-${index}`, - macAddress, - ipAddress, - })), - }), - ]), - ), - dhcpClientConfigurationsByNodeId: new Map((scenario.dhcpClients ?? []).map((node) => [node, { isEnabled: true }])), - switchConfigurationsByNodeId: new Map( - Object.entries(scenario.switches ?? {}).map(([node, config]) => [ - node, - config.retention === undefined ? switchConfiguration(config.ssid) : switchConfiguration(config.ssid, config.retention), - ]), - ), - firewallConfigurationsByNodeId: firewalls, - }); -} - const upper = (id: string) => id.toUpperCase(); function dhcpStatusJson(status: DhcpClientStatus) { @@ -439,24 +277,22 @@ const scenarioNames = readdirSync(scenarioDirectory) .sort(); /** - * Where the Swift engine is wrong, per scenario: its result corrected. - * - * dhcp-lan: a DHCP client run directly (not from the event queue) sends its - * DISCOVER at once; the server's OFFER arrives within that send, and the - * UDP delivery advances the client again — to FINISH, with the address set. - * The outer run then writes back its stale copy of the status (Swift's - * value types): DISCOVER, not succeeded. The address and every trace are - * the same; only the reported status is wrong in Swift. + * Where the Swift engine is wrong, per scenario: its result corrected. None + * at the moment. (Until F2.3b: dhcp-lan, the status a directly run DHCP + * client reported — Swift wrote back a stale copy after a nested run.) */ -const SWIFT_ERRORS: Record) => Record> = { - 'dhcp-lan': (expected) => { - const client = '00000000-0000-0000-0000-000000000102'; - const finished = { errors: 0, offered: '192.168.0.102', server: '192.168.0.10', state: 'FINISH', succeeded: true }; - const results = [...(expected.results as unknown[])]; - results[1] = finished; - const dhcp = expected.dhcp as { clients: Record }; - return { ...expected, results, dhcp: { ...dhcp, clients: { ...dhcp.clients, [client]: finished } } }; - }, +const SWIFT_ERRORS: Record) => Record> = {}; + +/** + * Scenarios where the engine follows Java instead of Swift; the Java + * comparison checks them (dhcpJava.test.ts, measured with Java FILIUS). Here + * they must differ from Swift, or the departure is not real. + */ +const JAVA_INSTEAD_OF_SWIFT: Record = { + 'dhcp-lan': 'DHCP client: one message per attempt, the ARP check waits a round trip, which follows the speed', + 'dhcp-two-servers': 'DHCP client: the second OFFER is read and dropped, no REQUEST twice (Swift: answered with NAK)', + 'dhcp-conflict': 'DHCP client: the ARP check finds the address taken and declines (Swift: it does not wait, two cards share the address)', + 'dhcp-no-server': 'DHCP client: a link-local address after five timeouts (Swift: the old address after ten)', }; describe('same result as the Swift engine', () => { @@ -471,7 +307,15 @@ describe('same result as the Swift engine', () => { expect(scenarioNames.length).toBeGreaterThan(0); }); - for (const name of scenarioNames) { + for (const [name, reason] of Object.entries(JAVA_INSTEAD_OF_SWIFT)) { + it(`${name} follows Java instead: ${reason}`, () => { + const scenario = JSON.parse(readFileSync(join(scenarioDirectory, `${name}.scenario.json`), 'utf8')) as Scenario; + const swift = JSON.parse(readFileSync(join(scenarioDirectory, `${name}.swift.json`), 'utf8')) as Record; + expect(run(scenario)).not.toEqual(swift); + }); + } + + for (const name of scenarioNames.filter((each) => !(each in JAVA_INSTEAD_OF_SWIFT))) { it(name, () => { const scenario = JSON.parse(readFileSync(join(scenarioDirectory, `${name}.scenario.json`), 'utf8')) as Scenario; const swift = JSON.parse(readFileSync(join(scenarioDirectory, `${name}.swift.json`), 'utf8')) as Record; diff --git a/packages/core/test/scenario.ts b/packages/core/test/scenario.ts new file mode 100644 index 0000000..5f5bd8c --- /dev/null +++ b/packages/core/test/scenario.ts @@ -0,0 +1,169 @@ +// A core scenario (test/scenarios/.scenario.json): a network, its +// settings and the steps to run on it. Read by the Swift comparison +// (oracle.test.ts) and the Java comparison (dhcpJava.test.ts). + +import { + deviceConfiguration, + dhcpServerConfiguration, + firewallConfiguration, + firewallRule, + interfaceKey, + portSnapshot, + switchConfiguration, + topologySnapshot, + type FirewallConfiguration, + type ManualRoute, + type NodeKind, + type TopologySnapshot, +} from '../src/index.js'; + +export interface Scenario { + seed: number; + speedPercent?: number; + nodes: { id: string; kind: NodeKind; ports: { id: string; label: string; mac?: string }[] }[]; + links: { id: string; sourceNode: string; sourcePort: string; targetNode: string; targetPort: string }[]; + devices?: Record; + interfaces?: { node: string; port: string; ip: string; mask: string }[]; + routes?: Record; + switches?: Record; + firewalls?: Record< + string, + { + active?: boolean; + defaultPolicy?: 'accept' | 'drop'; + dropIcmp?: boolean; + synOnly?: boolean; + filterUdp?: boolean; + rules?: { + source?: string; + sourceMask?: string; + destination?: string; + destinationMask?: string; + port?: number; + protocol?: 'all' | 'icmp' | 'tcp' | 'udp'; + action?: 'accept' | 'drop'; + }[]; + } + >; + dhcpServers?: Record< + string, + { active?: boolean; lower?: string; upper?: string; gateway?: string; dns?: string; own?: boolean; staticAssignments?: [string, string][] } + >; + dhcpClients?: string[]; + steps: { + op: string; + from?: string; + node?: string; + target?: string; + ttl?: number; + identifier?: number; + sequence?: number; + maxTtl?: number; + to?: number; + enabled?: boolean; + percent?: number; + // UDP: a socket is named by the id bindUdp returned. + port?: number; + localIp?: string; + remoteIp?: string; + remotePort?: number; + socket?: string; + payload?: string; + timeout?: number; + // TCP: the payload repeated this many times, for messages longer than a segment. + repeatCount?: number; + localPort?: number; + }[]; +} + +export function snapshotOf(scenario: Scenario): TopologySnapshot { + const firewalls = new Map(); + for (const [node, config] of Object.entries(scenario.firewalls ?? {})) { + firewalls.set( + node, + firewallConfiguration({ + isActive: config.active ?? false, + defaultPolicy: config.defaultPolicy ?? 'drop', + dropIcmp: config.dropIcmp ?? false, + filterSynSegmentsOnly: config.synOnly ?? true, + filterUdp: config.filterUdp ?? true, + rules: (config.rules ?? []).map((rule) => + firewallRule({ + sourceIPAddress: rule.source ?? '', + sourceSubnetMask: rule.sourceMask ?? '', + destinationIPAddress: rule.destination ?? '', + destinationSubnetMask: rule.destinationMask ?? '', + ...(rule.port === undefined ? {} : { port: rule.port }), + protocol: rule.protocol ?? 'tcp', + action: rule.action ?? 'drop', + }), + ), + }), + ); + } + return topologySnapshot({ + nodes: scenario.nodes.map((node) => ({ + id: node.id, + kind: node.kind, + ports: node.ports.map((port) => portSnapshot(port.id, port.label, port.mac)), + })), + links: scenario.links.map((link) => ({ + id: link.id, + sourceNodeId: link.sourceNode, + sourcePortId: link.sourcePort, + targetNodeId: link.targetNode, + targetPortId: link.targetPort, + })), + deviceConfigurations: new Map( + Object.entries(scenario.devices ?? {}).map(([node, device]) => [ + node, + deviceConfiguration(device.ip, device.mask, device.gateway ?? '', device.dns ?? ''), + ]), + ), + interfaceConfigurations: new Map( + (scenario.interfaces ?? []).map((item) => [ + interfaceKey(item.node, item.port), + { ipAddress: item.ip, subnetMask: item.mask }, + ]), + ), + manualRoutesByNodeId: new Map( + Object.entries(scenario.routes ?? {}).map(([node, rows]) => [ + node, + rows.map( + (row): ManualRoute => ({ + destinationNetwork: row.destination, + subnetMask: row.mask, + gateway: row.gateway, + interfaceIPAddress: row.interface, + }), + ), + ]), + ), + dhcpServerConfigurationsByNodeId: new Map( + Object.entries(scenario.dhcpServers ?? {}).map(([node, config]) => [ + node, + dhcpServerConfiguration({ + isActive: config.active ?? false, + lowerBoundIPAddress: config.lower ?? '0.0.0.0', + upperBoundIPAddress: config.upper ?? '0.0.0.0', + gatewayIPAddress: config.gateway ?? '0.0.0.0', + dnsServerIPAddress: config.dns ?? '0.0.0.0', + useOwnSettings: config.own ?? false, + staticAssignments: (config.staticAssignments ?? []).map(([macAddress, ipAddress], index) => ({ + id: `static-${index}`, + macAddress, + ipAddress, + })), + }), + ]), + ), + dhcpClientConfigurationsByNodeId: new Map((scenario.dhcpClients ?? []).map((node) => [node, { isEnabled: true }])), + switchConfigurationsByNodeId: new Map( + Object.entries(scenario.switches ?? {}).map(([node, config]) => [ + node, + config.retention === undefined ? switchConfiguration(config.ssid) : switchConfiguration(config.ssid, config.retention), + ]), + ), + firewallConfigurationsByNodeId: firewalls, + }); +} diff --git a/tools/java-oracle/README.md b/tools/java-oracle/README.md index 92c55c4..57f73c9 100644 --- a/tools/java-oracle/README.md +++ b/tools/java-oracle/README.md @@ -172,3 +172,16 @@ DHCPDECLINE ab. Javas Server übergeht das DECLINE und hält die Adresse weiter als Angebot fest (4 RTT). Nach dem fünften Fehlversuch (Zeitablauf) nimmt der Client eine Link-Local-Adresse `169.254.1.x/16`: ARP-Probe, eine RTT warten, dann ein ARP-Reply an alle. + +Die RTT ist in Java nicht fest: `Verbindung.holeRTT` = 50 Kabel × +Verzögerung je Kabel × RTT-Faktor (Startoption, 1). Beim Anlegen des +Fensters setzt das Hauptmenü den Regler auf 90 %, also 55 ms je Kabel und +2750 ms RTT — so laufen die `DhcpRun`-Referenzen; gemessen sind die +Wartezeiten 2745 bis 2751 ms. Außerdem befördert ein Java-Kabel einen Frame +nach dem anderen (`SimplexVerbindung` schläft die Verzögerung mit gesperrtem +Puffer): Sendet eine Karte zwei Frames direkt nacheinander, kommt der zweite +eine Kabellänge später. + +Filius' Mitschnitt selbst ist dabei nicht ganz zuverlässig: Starten zwei +Clients zugleich, fehlt auf der Karte des zweiten dessen eigenes DISCOVER +(vier von vier Läufen von `dhcp-lan`; der Server empfängt es). diff --git a/tools/swift-oracle/README.md b/tools/swift-oracle/README.md index a19b8a1..9c47b15 100644 --- a/tools/swift-oracle/README.md +++ b/tools/swift-oracle/README.md @@ -28,7 +28,11 @@ npm test # vergleicht mit den geschriebenen *.swift.json DHCP: `runDhcp` (Ergebnis der Client-Status). Szenarien können `dhcpServers` (Bereich, eigene Einstellungen, feste Zuordnungen) und `dhcpClients` angeben; das Ergebnis enthält die Gerätekonfiguration und - den DHCP-Zustand (Clients, Leases, Angebote, Sperrliste, Server). + den DHCP-Zustand (Clients, Leases, Angebote, Sperrliste, Server). Seit + F2.3b folgt der DHCP-Client des Kerns Java statt Swift; die vier + DHCP-Szenarien vergleicht `dhcpJava.test.ts` mit Java-Läufen + (`tools/java-oracle`, `DhcpRun`), am Swift-Ergebnis prüft der Test nur + noch, dass sie abweichen. Das Ergebnis enthält die Sockets mit der Länge ihrer Warteschlange, dem TCP-Zustand, dem annehmenden Socket und den Sitzungswerten.