feat(core): DHCP client after Java, measured with Java FILIUS

The client follows Java's DHCPClient instead of Swift: one DISCOVER or
REQUEST per attempt, an answer that does not fit is read and dropped, the
ARP check waits a round trip (an answer for the address ends it at once,
then DECLINE), a timeout repeats the state's message, and the fifth turns
to a link-local address 169.254.1.x/16 (ARP probe, ARP reply to all); no
old address is given back. DHCP round trip times follow the speed as in
Java (50 cables, 2750 ms at 90 %; Swift: fixed 2500 ms).

Not taken from Java: the client gives up after five occupied link-local
addresses (Java searches on without end), and an ARP packet for another
address does not end the check.

dhcpJava.test.ts compares the four DHCP scenarios with DhcpRun's Java
runs; the Swift comparison only checks that they differ.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-30 10:50:30 +00:00
co-authored by Claude Opus 5.5
parent fd445e8889
commit ed437f9b5c
10 changed files with 1005 additions and 321 deletions
+6 -5
View File
@@ -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/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/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/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/ipv4.ts` | Engine `parseIPv4`/`sameSubnet` | übertragen |
| `src/runtime/packets.ts` | Engine, Paketmodelle | übertragen; `Data` → `Uint8Array` | | `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/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 |
| `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/runtime/trace.ts` | Engine, Paketspur-Modell | übertragen |
| `src/util/equal.ts` | — | neu (Ersatz für Swifts `Equatable`) | | `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/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 | | `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/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/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/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; ein benannter Swift-Fehler (veralteter DHCP-Status nach verschachteltem Lauf) | | `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 | | `test/routeTable.test.ts` | `TopologyJavaRouteTableTests.swift` (8 von 12 Tests) | übertragen; Geometrie- und Reducer-Tests folgen mit Editor und Dokument |
## packages/fls ## packages/fls
+330 -121
View File
@@ -7,7 +7,10 @@
// "UDP sockets and Router RIP" (lines ~4472–5187, the // "UDP sockets and Router RIP" (lines ~4472–5187, the
// UDP sockets; RIP not yet) // UDP sockets; RIP not yet)
// "Java-compatible TCP sockets" (lines ~5187–5913) // "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) // processScheduledRuntimeEvent, dropForGlobalPacketLoss (lines ~4753–4850)
// Java counterparts: filius.hardware.Verbindung, filius.software.netzzugangsschicht, // Java counterparts: filius.hardware.Verbindung, filius.software.netzzugangsschicht,
// filius.software.vermittlungsschicht (ARP, IP, ICMP), system.SwitchFirmware, // 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_SERVER_PORT = 67;
export const DHCP_CLIENT_PORT = 68; export const DHCP_CLIENT_PORT = 68;
export const DHCP_ROUND_TRIP_TIME_MILLISECONDS = 2_500; /** Java: an offer, and a blacklisted address, is kept 4 round trip times. */
export const DHCP_OFFER_LIFETIME_MILLISECONDS = 4 * DHCP_ROUND_TRIP_TIME_MILLISECONDS; export const DHCP_OFFER_LIFETIME_ROUND_TRIPS = 4;
export const DHCP_DYNAMIC_LEASE_LIFETIME_MILLISECONDS = 24 * 60 * 60 * 1_000; 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'; export type DhcpMessageType = 'DHCPDISCOVER' | 'DHCPREQUEST' | 'DHCPACK' | 'DHCPNAK' | 'DHCPOFFER' | 'DHCPDECLINE';
const DHCP_MESSAGE_TYPES: readonly 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; 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 { export interface DhcpClientStatus {
state: DhcpClientState; state: DhcpClientState;
/** Java's failureCount: timeouts while waiting for a server, then occupied link-local addresses. */
errorCount: number; errorCount: number;
selectedServerIPAddress: string | undefined; selectedServerIPAddress: string | undefined;
offeredIPAddress: string | undefined; offeredIPAddress: string | undefined;
/** Configured, by a server or with a link-local address. */
succeeded: boolean; 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 { export interface DhcpClientContext {
readonly interfaceId: Id; readonly interfaceId: Id;
readonly socketId: Id; readonly socketId: Id;
readonly clientMacAddress: string; readonly clientMacAddress: string;
readonly oldDeviceConfiguration: DeviceConfiguration | undefined;
readonly oldInterfaceConfiguration: InterfaceConfiguration | undefined;
selectedOffer: DhcpMessage | undefined; selectedOffer: DhcpMessage | undefined;
linkLocalIPAddress: string | undefined;
waiting: DhcpClientWait | undefined;
} }
/** Swift's caseInsensitiveCompare == .orderedSame, for addresses. */ /** Swift's caseInsensitiveCompare == .orderedSame, for addresses. */
@@ -489,6 +521,7 @@ export class NetworkRuntimeEngine {
private random: JavaCompatibleRandom; private random: JavaCompatibleRandom;
private udpPortRandom: JavaCompatibleRandom; private udpPortRandom: JavaCompatibleRandom;
private tcpPortRandom: JavaCompatibleRandom; private tcpPortRandom: JavaCompatibleRandom;
private linkLocalRandom: JavaCompatibleRandom;
private runtimeSpeed: RuntimeSpeed; private runtimeSpeed: RuntimeSpeed;
private readonly packetTraceRetentionPolicy: TraceRetentionPolicy; private readonly packetTraceRetentionPolicy: TraceRetentionPolicy;
private isProcessingScheduledEvents = false; private isProcessingScheduledEvents = false;
@@ -503,6 +536,7 @@ export class NetworkRuntimeEngine {
this.random = new JavaCompatibleRandom(normalizedSeed); this.random = new JavaCompatibleRandom(normalizedSeed);
this.udpPortRandom = new JavaCompatibleRandom(normalizedSeed ^ 0x5544_5050n); this.udpPortRandom = new JavaCompatibleRandom(normalizedSeed ^ 0x5544_5050n);
this.tcpPortRandom = new JavaCompatibleRandom(normalizedSeed ^ 0x5443_5050n); this.tcpPortRandom = new JavaCompatibleRandom(normalizedSeed ^ 0x5443_5050n);
this.linkLocalRandom = new JavaCompatibleRandom(normalizedSeed ^ 0x4c4c_4144n);
this.runtimeSpeed = new RuntimeSpeed(); this.runtimeSpeed = new RuntimeSpeed();
this.packetTraceRetentionPolicy = packetTraceRetentionPolicy; this.packetTraceRetentionPolicy = packetTraceRetentionPolicy;
this.stateValue.simulationSpeedPercent = this.runtimeSpeed.percent; this.stateValue.simulationSpeedPercent = this.runtimeSpeed.percent;
@@ -539,6 +573,7 @@ export class NetworkRuntimeEngine {
this.random = new JavaCompatibleRandom(seed); this.random = new JavaCompatibleRandom(seed);
this.udpPortRandom = new JavaCompatibleRandom(seed ^ 0x5544_5050n); this.udpPortRandom = new JavaCompatibleRandom(seed ^ 0x5544_5050n);
this.tcpPortRandom = new JavaCompatibleRandom(seed ^ 0x5443_5050n); this.tcpPortRandom = new JavaCompatibleRandom(seed ^ 0x5443_5050n);
this.linkLocalRandom = new JavaCompatibleRandom(seed ^ 0x4c4c_4144n);
this.isProcessingScheduledEvents = false; this.isProcessingScheduledEvents = false;
this.pendingIPv4TransmissionsByArpKey.clear(); this.pendingIPv4TransmissionsByArpKey.clear();
this.initializeRipState(); this.initializeRipState();
@@ -1001,9 +1036,7 @@ export class NetworkRuntimeEngine {
beforeHeaders: udpHeaders(datagram), beforeHeaders: udpHeaders(datagram),
detail: matching.length === 0 ? 'UDP destination port unbound' : 'UDP queued', detail: matching.length === 0 ? 'UDP destination port unbound' : 'UDP queued',
}); });
if (datagram.destinationPort === DHCP_CLIENT_PORT && state.dhcpClientContextsByNodeId.has(nodeId)) { if (datagram.destinationPort === DHCP_CLIENT_PORT && this.readDhcpResponse(nodeId)) this.advanceDhcpClient(nodeId);
this.advanceDhcpClient(nodeId);
}
} }
// ── TCP sockets ────────────────────────────────────────────────────────── // ── TCP sockets ──────────────────────────────────────────────────────────
@@ -1669,9 +1702,9 @@ export class NetworkRuntimeEngine {
// ── DHCP ───────────────────────────────────────────────────────────────── // ── DHCP ─────────────────────────────────────────────────────────────────
/** /**
* Starts (or goes on with) the node's DHCP client: its address set to * Starts the node's DHCP client as Java's DHCPClient.configure: its address
* 0.0.0.0, a socket on port 68, then the client's states as far as the * set to 0.0.0.0, a socket on port 68, then the client's states as far as
* answers so far allow. * the answers so far allow. A client that is running goes on waiting.
*/ */
runDhcpClient(nodeId: Id): DhcpClientStatus | undefined { runDhcpClient(nodeId: Id): DhcpClientStatus | undefined {
const state = this.stateValue; const state = this.stateValue;
@@ -1686,10 +1719,7 @@ export class NetworkRuntimeEngine {
} }
const clientInterface = this.dhcpClientInterface(nodeId); const clientInterface = this.dhcpClientInterface(nodeId);
if (clientInterface === undefined) return undefined; if (clientInterface === undefined) return undefined;
if (state.dhcpClientContextsByNodeId.has(nodeId)) { if (state.dhcpClientContextsByNodeId.has(nodeId)) return state.dhcpClientStatusesByNodeId.get(nodeId);
this.advanceDhcpClient(nodeId);
return state.dhcpClientStatusesByNodeId.get(nodeId);
}
const oldDeviceConfiguration = state.topologySnapshot.deviceConfigurations.get(nodeId); const oldDeviceConfiguration = state.topologySnapshot.deviceConfigurations.get(nodeId);
const oldInterfaceConfiguration = state.topologySnapshot.interfaceConfigurations.get(interfaceKey(nodeId, clientInterface.portId)); const oldInterfaceConfiguration = state.topologySnapshot.interfaceConfigurations.get(interfaceKey(nodeId, clientInterface.portId));
this.setDhcpClientIPAddress(nodeId, clientInterface.portId, UNSPECIFIED_IP_ADDRESS); this.setDhcpClientIPAddress(nodeId, clientInterface.portId, UNSPECIFIED_IP_ADDRESS);
@@ -1711,127 +1741,253 @@ export class NetworkRuntimeEngine {
interfaceId: clientInterface.portId, interfaceId: clientInterface.portId,
socketId, socketId,
clientMacAddress: resetInterface.macAddress, clientMacAddress: resetInterface.macAddress,
oldDeviceConfiguration,
oldInterfaceConfiguration,
selectedOffer: undefined, selectedOffer: undefined,
linkLocalIPAddress: undefined,
waiting: undefined,
}); });
state.dhcpClientStatusesByNodeId.set(nodeId, dhcpClientStatus()); state.dhcpClientStatusesByNodeId.set(nodeId, dhcpClientStatus());
this.advanceDhcpClient(nodeId); this.advanceDhcpClient(nodeId);
return state.dhcpClientStatusesByNodeId.get(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 { private advanceDhcpClient(nodeId: Id): void {
const state = this.stateValue; const state = this.stateValue;
for (;;) { for (;;) {
const context = state.dhcpClientContextsByNodeId.get(nodeId); const context = state.dhcpClientContextsByNodeId.get(nodeId);
const status = state.dhcpClientStatusesByNodeId.get(nodeId); const status = state.dhcpClientStatusesByNodeId.get(nodeId);
if (state.phase !== 'running' || context === undefined || status === undefined || status.state === 'FINISH') return; if (state.phase !== 'running' || context === undefined || status === undefined) return;
const backToDiscover = () => { if (status.state === 'FINISH' || context.waiting !== undefined) return;
status.errorCount += 1;
status.state = 'DISCOVER';
};
switch (status.state) { switch (status.state) {
case 'INIT': case 'INIT':
status.state = 'DISCOVER'; status.state = 'DISCOVER';
break; break;
case 'DISCOVER': { case 'DISCOVER':
this.sendDhcpMessage(context.socketId, { this.sendDhcpMessage(context.socketId, {
type: 'DHCPDISCOVER', type: 'DHCPDISCOVER',
yourIPAddress: UNSPECIFIED_IP_ADDRESS, yourIPAddress: UNSPECIFIED_IP_ADDRESS,
clientMacAddress: context.clientMacAddress, clientMacAddress: context.clientMacAddress,
}); });
const offer = this.receiveDhcpResponse(context.socketId, context.clientMacAddress, undefined, ['DHCPOFFER']); this.waitForDhcpResponse(nodeId, ['DHCPOFFER'], undefined);
if (offer === undefined) {
this.scheduleDhcpTimeout(nodeId);
return;
}
context.selectedOffer = offer;
status.selectedServerIPAddress = offer.serverIdentifier;
status.offeredIPAddress = offer.yourIPAddress;
status.state = 'VALIDATE';
break; break;
}
case 'VALIDATE': { case 'VALIDATE': {
const offer = context.selectedOffer; const offer = context.selectedOffer;
if (offer === undefined) { if (offer === undefined) {
backToDiscover(); status.state = 'DISCOVER';
break; break;
} }
const conflictMac = this.resolveMacAddress(nodeId, offer.yourIPAddress, { this.checkDhcpClientAddress(nodeId, offer.yourIPAddress);
preferredInterfaceId: context.interfaceId,
maxRetries: 1,
selectInterfaceByAddress: false,
});
status.state = conflictMac === undefined ? 'REQUEST' : 'DECLINE';
break; break;
} }
case 'DECLINE': { case 'DECLINE': {
const offer = context.selectedOffer; const offer = context.selectedOffer;
if (offer === undefined) { if (offer !== undefined) {
backToDiscover(); this.sendDhcpMessage(context.socketId, {
break; 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'; status.state = 'DISCOVER';
break; break;
} }
case 'REQUEST': { case 'REQUEST': {
const offer = context.selectedOffer; const offer = context.selectedOffer;
const serverIdentifier = offer?.serverIdentifier; if (offer === undefined) {
if (offer === undefined || serverIdentifier === undefined) { status.state = 'DISCOVER';
backToDiscover();
break; break;
} }
this.sendDhcpMessage(context.socketId, { this.sendDhcpMessage(context.socketId, {
type: 'DHCPREQUEST', type: 'DHCPREQUEST',
yourIPAddress: UNSPECIFIED_IP_ADDRESS, yourIPAddress: UNSPECIFIED_IP_ADDRESS,
clientMacAddress: context.clientMacAddress, clientMacAddress: context.clientMacAddress,
serverIdentifier, serverIdentifier: offer.serverIdentifier,
requestedIPAddress: offer.yourIPAddress, requestedIPAddress: offer.yourIPAddress,
}); });
const response = this.receiveDhcpResponse(context.socketId, context.clientMacAddress, serverIdentifier, ['DHCPACK', 'DHCPNAK']); this.waitForDhcpResponse(nodeId, ['DHCPACK', 'DHCPNAK'], offer.serverIdentifier);
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';
}
break; break;
} }
case 'ASSIGN_IP': { case 'ASSIGN_IP': {
const offer = context.selectedOffer; const offer = context.selectedOffer;
if (offer === undefined) { if (offer === undefined) {
backToDiscover(); status.state = 'DISCOVER';
break; break;
} }
this.applyDhcpConfiguration(nodeId, context.interfaceId, offer); this.applyDhcpConfiguration(nodeId, context.interfaceId, offer);
status.succeeded = true; status.succeeded = true;
status.state = 'FINISH'; status.state = 'FINISH';
this.finishDhcpClient(nodeId, false); this.finishDhcpClient(nodeId);
return; 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.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 { 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 { private handleDhcpClientTimeout(nodeId: Id): void {
const state = this.stateValue; const state = this.stateValue;
const context = state.dhcpClientContextsByNodeId.get(nodeId);
const status = state.dhcpClientStatusesByNodeId.get(nodeId); const status = state.dhcpClientStatusesByNodeId.get(nodeId);
if (!state.dhcpClientContextsByNodeId.has(nodeId) || status === undefined || status.state === 'FINISH') return; const wait = context?.waiting;
status.errorCount += 1; if (context === undefined || status === undefined || wait === undefined) return;
if (status.errorCount >= DHCP_MAXIMUM_CLIENT_ERROR_COUNT) { context.waiting = undefined;
status.state = 'FINISH'; if (wait.kind === 'arp') {
status.succeeded = false; this.finishDhcpClientAddressCheck(nodeId, this.knownMacAddress(nodeId, wait.ipAddress) !== undefined);
this.finishDhcpClient(nodeId, true); } else {
return; 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); 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); const context = this.stateValue.dhcpClientContextsByNodeId.get(nodeId);
if (context === undefined) return; if (context === undefined) return;
this.stateValue.dhcpClientContextsByNodeId.delete(nodeId); this.stateValue.dhcpClientContextsByNodeId.delete(nodeId);
if (restoreOldIPAddress) {
this.restoreDhcpClientOldIPAddress(nodeId, context.interfaceId, context.oldDeviceConfiguration, context.oldInterfaceConfiguration);
}
this.closeSocket(context.socketId); this.closeSocket(context.socketId);
this.removeDhcpTimeouts(nodeId); this.removeDhcpTimeouts(nodeId);
} }
@@ -1914,7 +2081,7 @@ export class NetworkRuntimeEngine {
// Another server was chosen: this one keeps the address back for a while. // Another server was chosen: this one keeps the address back for a while.
getOrCreate(state.dhcpBlacklistByServerNodeId, nodeId, () => new Map()).set( getOrCreate(state.dhcpBlacklistByServerNodeId, nodeId, () => new Map()).set(
requestedIPAddress.toLowerCase(), requestedIPAddress.toLowerCase(),
state.currentTimeMilliseconds + DHCP_OFFER_LIFETIME_MILLISECONDS, state.currentTimeMilliseconds + DHCP_OFFER_LIFETIME_ROUND_TRIPS * this.runtimeSpeed.roundTripTimeMilliseconds,
); );
} }
return; return;
@@ -1957,7 +2124,7 @@ export class NetworkRuntimeEngine {
clientMacAddress, clientMacAddress,
offeredIPAddress: address, offeredIPAddress: address,
serverNodeId, serverNodeId,
expiresAtMilliseconds: state.currentTimeMilliseconds + DHCP_OFFER_LIFETIME_MILLISECONDS, expiresAtMilliseconds: state.currentTimeMilliseconds + DHCP_OFFER_LIFETIME_ROUND_TRIPS * this.runtimeSpeed.roundTripTimeMilliseconds,
}); });
return address; return address;
} }
@@ -2121,6 +2288,28 @@ export class NetworkRuntimeEngine {
this.replaceTopologyConfigurations(devices, interfaces); 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 { private setDhcpClientIPAddress(nodeId: Id, interfaceId: Id, ipAddress: string): void {
const node = this.nodeSnapshot(nodeId); const node = this.nodeSnapshot(nodeId);
if (node === undefined) return; if (node === undefined) return;
@@ -2247,13 +2436,9 @@ export class NetworkRuntimeEngine {
const maxRetries = options.maxRetries ?? 2; const maxRetries = options.maxRetries ?? 2;
const selectInterfaceByAddress = options.selectInterfaceByAddress ?? true; const selectInterfaceByAddress = options.selectInterfaceByAddress ?? true;
const interfaces = this.networkInterfaces(nodeId); const interfaces = this.networkInterfaces(nodeId);
const primaryInterface = interfaces[0]; if (interfaces.length === 0) return undefined;
if (primaryInterface === undefined) return undefined; const known = this.knownMacAddress(nodeId, targetIPAddress);
if (targetIPAddress === LOCALHOST_IP_ADDRESS) return primaryInterface.macAddress; if (known !== undefined) return known;
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;
let selectedInterface: NetworkInterface | undefined; let selectedInterface: NetworkInterface | undefined;
if (options.preferredInterfaceId !== undefined) { if (options.preferredInterfaceId !== undefined) {
@@ -2266,30 +2451,7 @@ export class NetworkRuntimeEngine {
if (selectedInterface === undefined) return undefined; if (selectedInterface === undefined) return undefined;
for (let attempt = 0; attempt < Math.max(0, maxRetries); attempt += 1) { for (let attempt = 0; attempt < Math.max(0, maxRetries); attempt += 1) {
const request: ArpPacket = { this.sendArpRequest(nodeId, selectedInterface, targetIPAddress);
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.advanceRemoteLinkEventsUntilArpCacheResolves(nodeId, targetIPAddress); this.advanceRemoteLinkEventsUntilArpCacheResolves(nodeId, targetIPAddress);
const resolved = this.stateValue.arpCachesByNodeId.get(nodeId)?.get(targetIPAddress); const resolved = this.stateValue.arpCachesByNodeId.get(nodeId)?.get(targetIPAddress);
if (resolved !== undefined) return resolved.macAddress; if (resolved !== undefined) return resolved.macAddress;
@@ -2297,6 +2459,45 @@ export class NetworkRuntimeEngine {
return undefined; 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 { private advanceRemoteLinkEventsUntilArpCacheResolves(nodeId: Id, targetIPAddress: string): void {
if (this.isProcessingScheduledEvents) return; if (this.isProcessingScheduledEvents) return;
this.advancePendingRemoteLinkEvents( this.advancePendingRemoteLinkEvents(
@@ -2638,6 +2839,7 @@ export class NetworkRuntimeEngine {
updatedAtMilliseconds: this.stateValue.currentTimeMilliseconds, updatedAtMilliseconds: this.stateValue.currentTimeMilliseconds,
}); });
this.flushIPv4TransmissionsAwaitingArp(nodeId, packet.senderIPAddress, packet.senderMacAddress); this.flushIPv4TransmissionsAwaitingArp(nodeId, packet.senderIPAddress, packet.senderMacAddress);
this.dhcpClientLearnedAddress(nodeId, packet.senderIPAddress);
} }
this.recordTrace({ this.recordTrace({
frameIdentity, frameIdentity,
@@ -3485,7 +3687,14 @@ export function ipv4Headers(packet: IPv4Packet): HeaderField[] {
} }
function dhcpClientStatus(): DhcpClientStatus { 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 { function dhcpRecordKey(serverNodeId: Id, ipAddress: string): string {
+5
View File
@@ -50,6 +50,11 @@ export class JavaCompatibleRandom {
return Math.fround(this.next(24) / (1 << 24)); 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. */ /** Java RIP: 30 s · (f/3 + 0.84), truncated — somewhere between 25.2 s and 35.2 s. */
nextRipJitterMilliseconds(): number { nextRipJitterMilliseconds(): number {
const javaFloat = this.nextFloat(); const javaFloat = this.nextFloat();
+10
View File
@@ -7,6 +7,8 @@ export const MINIMUM_SPEED_PERCENT = 1;
export const MAXIMUM_SPEED_PERCENT = 100; export const MAXIMUM_SPEED_PERCENT = 100;
export const DEFAULT_SPEED_PERCENT = 90; export const DEFAULT_SPEED_PERCENT = 90;
export const JAVA_MINIMUM_LINK_DELAY_MILLISECONDS = 5; 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 { export class RuntimeSpeed {
readonly percent: number; readonly percent: number;
@@ -25,6 +27,14 @@ export class RuntimeSpeed {
return JAVA_MINIMUM_LINK_DELAY_MILLISECONDS * this.javaDelayFactor; 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. */ /** A base delay scaled like a cable, or undefined past the safe integer range. */
scaledLinkDelay(baseMilliseconds: number): number | undefined { scaledLinkDelay(baseMilliseconds: number): number | undefined {
const delay = baseMilliseconds * this.javaDelayFactor; const delay = baseMilliseconds * this.javaDelayFactor;
+187
View File
@@ -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 (<name>.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<string, JavaFrame[]>;
devices: Record<string, [string, string, string, string]>;
}
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<number, EthernetFrame>();
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<number, SeenFrame>();
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<string, string[]>) =>
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');
});
});
+255 -13
View File
@@ -233,7 +233,7 @@ describe('event queue and virtual clock', () => {
[serverId, dhcpServerConfiguration({ isActive: true, lowerBoundIPAddress: '10.4.0.20', upperBoundIPAddress: '10.4.0.29' })], [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); expect(engine.state.dhcpLeasesByIPAddress.size).toBe(1);
engine.handle({ type: 'stop' }); 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(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]); 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', () => { describe('IPv4 packets', () => {
@@ -906,6 +920,9 @@ describe('TCP sockets', () => {
}); });
describe('DHCP', () => { 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<DhcpServerConfiguration>) => { const serverAndClient = (first: number, serverIp: string, client: DeviceConfiguration, server: Partial<DhcpServerConfiguration>) => {
const [serverId, serverPortId, clientId, clientPortId] = [first, first + 1, first + 2, first + 3].map(uuid) as [string, string, string, string]; const [serverId, serverPortId, clientId, clientPortId] = [first, first + 1, first + 2, first + 3].map(uuid) as [string, string, string, string];
const engine = startedEngine( const engine = startedEngine(
@@ -921,7 +938,58 @@ describe('DHCP', () => {
}, },
16, 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<string, Partial<DhcpServerConfiguration>> = 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', () => { it('completes DISCOVER, OFFER, REQUEST and ACK and applies the configuration', () => {
@@ -934,17 +1002,23 @@ describe('DHCP', () => {
}); });
engine.handle({ type: 'advance', toMilliseconds: 1_000 }); 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( 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'), deviceConfiguration('192.168.50.20', '255.255.255.0', '192.168.50.1', '192.168.50.53'),
); );
const status = engine.state.dhcpClientStatusesByNodeId.get(clientId); 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; const leaseExpiry = [...engine.state.dhcpLeasesByIPAddress.values()][0]?.expiresAtMilliseconds;
expect(leaseExpiry).toBeGreaterThan(engine.state.currentTimeMilliseconds); expect(leaseExpiry).toBeGreaterThan(engine.state.currentTimeMilliseconds);
expect(leaseExpiry! - engine.state.currentTimeMilliseconds).toBeLessThanOrEqual(DHCP_DYNAMIC_LEASE_LIFETIME_MILLISECONDS); expect(leaseExpiry! - engine.state.currentTimeMilliseconds).toBeLessThanOrEqual(DHCP_DYNAMIC_LEASE_LIFETIME_MILLISECONDS);
const details = engine.state.packetTraces.map((trace) => trace.detail); // One message per attempt.
for (const message of ['DHCPDISCOVER', 'DHCPOFFER', 'DHCPREQUEST', 'DHCPACK']) expect(details).toContain(message); 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', () => { 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' }], 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.topologySnapshot.deviceConfigurations.get(clientId)?.ipAddress).toBe('10.0.0.99');
expect([...engine.state.dhcpLeasesByIPAddress.values()][0]?.expiresAtMilliseconds).toBeUndefined(); 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 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( const engine = startedEngine(
{ {
nodes: [pc(clientId, uuid(0x1622))], nodes: [pc(clientId, uuid(0x1622))],
links: [], 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 }]]), dhcpClientConfigurationsByNodeId: new Map([[clientId, { isEnabled: true }]]),
}, },
16, 16,
@@ -977,17 +1050,186 @@ describe('DHCP', () => {
engine.handle({ type: 'advance', toMilliseconds: 0 }); engine.handle({ type: 'advance', toMilliseconds: 0 });
expect(engine.state.topologySnapshot.deviceConfigurations.get(clientId)?.ipAddress).toBe('0.0.0.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)?.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); const status = engine.state.dhcpClientStatusesByNodeId.get(clientId);
expect([status?.state, status?.errorCount, status?.succeeded]).toEqual(['FINISH', 10, false]); expect([status?.state, status?.errorCount, status?.succeeded, status?.linkLocal]).toEqual(['FINISH', 5, true, true]);
expect(engine.state.topologySnapshot.deviceConfigurations.get(clientId)).toEqual(oldConfiguration);
expect(engine.state.dhcpClientContextsByNodeId.has(clientId)).toBe(false); expect(engine.state.dhcpClientContextsByNodeId.has(clientId)).toBe(false);
expect(engine.state.socketsById.size).toBe(0); 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", () => { it("writes and reads a message in Swift's text format", () => {
const message = { const message = {
type: 'DHCPOFFER' as const, type: 'DHCPOFFER' as const,
+25 -181
View File
@@ -8,178 +8,16 @@ import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { import {
deviceConfiguration,
dhcpServerConfiguration,
firewallConfiguration,
firewallRule,
interfaceKey,
NetworkRuntimeEngine, NetworkRuntimeEngine,
portSnapshot,
switchConfiguration,
topologySnapshot,
type DhcpClientStatus, type DhcpClientStatus,
type FirewallConfiguration,
type IPv4DeliveryResult, type IPv4DeliveryResult,
type IcmpObservation, type IcmpObservation,
type ManualRoute,
type NodeKind,
type TopologySnapshot,
type TraceEvent, type TraceEvent,
} from '../src/index.js'; } from '../src/index.js';
import { snapshotOf, type Scenario } from './scenario.js';
const scenarioDirectory = join(dirname(fileURLToPath(import.meta.url)), 'scenarios'); 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<string, { ip: string; mask: string; gateway?: string; dns?: string }>;
interfaces?: { node: string; port: string; ip: string; mask: string }[];
routes?: Record<string, { destination: string; mask: string; gateway: string; interface: string }[]>;
switches?: Record<string, { ssid: string; retention?: number }>;
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<string, FirewallConfiguration>();
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(); const upper = (id: string) => id.toUpperCase();
function dhcpStatusJson(status: DhcpClientStatus) { function dhcpStatusJson(status: DhcpClientStatus) {
@@ -439,24 +277,22 @@ const scenarioNames = readdirSync(scenarioDirectory)
.sort(); .sort();
/** /**
* Where the Swift engine is wrong, per scenario: its result corrected. * 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
* dhcp-lan: a DHCP client run directly (not from the event queue) sends its * client reported — Swift wrote back a stale copy after a nested run.)
* 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.
*/ */
const SWIFT_ERRORS: Record<string, (expected: Record<string, unknown>) => Record<string, unknown>> = { const SWIFT_ERRORS: Record<string, (expected: Record<string, unknown>) => Record<string, unknown>> = {};
'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 }; * Scenarios where the engine follows Java instead of Swift; the Java
const results = [...(expected.results as unknown[])]; * comparison checks them (dhcpJava.test.ts, measured with Java FILIUS). Here
results[1] = finished; * they must differ from Swift, or the departure is not real.
const dhcp = expected.dhcp as { clients: Record<string, unknown> }; */
return { ...expected, results, dhcp: { ...dhcp, clients: { ...dhcp.clients, [client]: finished } } }; const JAVA_INSTEAD_OF_SWIFT: Record<string, string> = {
}, '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', () => { describe('same result as the Swift engine', () => {
@@ -471,7 +307,15 @@ describe('same result as the Swift engine', () => {
expect(scenarioNames.length).toBeGreaterThan(0); 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<string, unknown>;
expect(run(scenario)).not.toEqual(swift);
});
}
for (const name of scenarioNames.filter((each) => !(each in JAVA_INSTEAD_OF_SWIFT))) {
it(name, () => { it(name, () => {
const scenario = JSON.parse(readFileSync(join(scenarioDirectory, `${name}.scenario.json`), 'utf8')) as Scenario; 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<string, unknown>; const swift = JSON.parse(readFileSync(join(scenarioDirectory, `${name}.swift.json`), 'utf8')) as Record<string, unknown>;
+169
View File
@@ -0,0 +1,169 @@
// A core scenario (test/scenarios/<name>.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<string, { ip: string; mask: string; gateway?: string; dns?: string }>;
interfaces?: { node: string; port: string; ip: string; mask: string }[];
routes?: Record<string, { destination: string; mask: string; gateway: string; interface: string }[]>;
switches?: Record<string, { ssid: string; retention?: number }>;
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<string, FirewallConfiguration>();
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,
});
}
+13
View File
@@ -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) 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 nimmt der Client eine Link-Local-Adresse `169.254.1.x/16`: ARP-Probe, eine
RTT warten, dann ein ARP-Reply an alle. 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).
+5 -1
View File
@@ -28,7 +28,11 @@ npm test # vergleicht mit den geschriebenen *.swift.json
DHCP: `runDhcp` (Ergebnis der Client-Status). Szenarien können DHCP: `runDhcp` (Ergebnis der Client-Status). Szenarien können
`dhcpServers` (Bereich, eigene Einstellungen, feste Zuordnungen) und `dhcpServers` (Bereich, eigene Einstellungen, feste Zuordnungen) und
`dhcpClients` angeben; das Ergebnis enthält die Gerätekonfiguration 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 Das Ergebnis enthält die Sockets mit der Länge ihrer Warteschlange, dem
TCP-Zustand, dem annehmenden Socket und den Sitzungswerten. TCP-Zustand, dem annehmenden Socket und den Sitzungswerten.