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/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
+309 -100
View File
@@ -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,66 +1741,52 @@ 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,
@@ -1778,60 +1794,200 @@ export class NetworkRuntimeEngine {
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;
}
}
}
/** 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);
}
private scheduleDhcpTimeout(nodeId: Id): void {
/**
* 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;
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) {
status.state = 'FINISH';
status.succeeded = false;
this.finishDhcpClient(nodeId, true);
return;
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,35 +2451,51 @@ export class NetworkRuntimeEngine {
if (selectedInterface === undefined) return undefined;
for (let attempt = 0; attempt < Math.max(0, maxRetries); attempt += 1) {
this.sendArpRequest(nodeId, selectedInterface, targetIPAddress);
this.advanceRemoteLinkEventsUntilArpCacheResolves(nodeId, targetIPAddress);
const resolved = this.stateValue.arpCachesByNodeId.get(nodeId)?.get(targetIPAddress);
if (resolved !== undefined) return resolved.macAddress;
}
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: selectedInterface.macAddress,
senderIPAddress: selectedInterface.ipAddress,
senderMacAddress: networkInterface.macAddress,
senderIPAddress: networkInterface.ipAddress,
targetMacAddress: UNSPECIFIED_MAC_ADDRESS,
targetIPAddress,
};
const frame: EthernetFrame = {
identity: this.allocateFrameIdentity(),
sourceMacAddress: selectedInterface.macAddress,
sourceMacAddress: networkInterface.macAddress,
destinationMacAddress: ETHERNET_BROADCAST_MAC_ADDRESS,
payload: { kind: 'arp', packet: request },
};
this.recordTrace({
frameIdentity: frame.identity,
nodeId,
interfaceId: selectedInterface.portId,
interfaceId: networkInterface.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);
const resolved = this.stateValue.arpCachesByNodeId.get(nodeId)?.get(targetIPAddress);
if (resolved !== undefined) return resolved.macAddress;
}
return undefined;
this.sendEthernetFrame(nodeId, networkInterface.portId, frame);
}
private advanceRemoteLinkEventsUntilArpCacheResolves(nodeId: Id, targetIPAddress: string): void {
@@ -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 {
+5
View File
@@ -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();
+10
View File
@@ -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;
+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' })],
]),
});
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<DhcpServerConfiguration>) => {
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<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', () => {
@@ -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,
+25 -181
View File
@@ -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<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();
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<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 };
const results = [...(expected.results as unknown[])];
results[1] = finished;
const dhcp = expected.dhcp as { clients: Record<string, unknown> };
return { ...expected, results, dhcp: { ...dhcp, clients: { ...dhcp.clients, [client]: finished } } };
},
const SWIFT_ERRORS: Record<string, (expected: Record<string, unknown>) => Record<string, unknown>> = {};
/**
* 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<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', () => {
@@ -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<string, unknown>;
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<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)
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).
+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
`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.