feat(core): DHCP in the simulation core, as in Swift

engine.ts ports Swift's "DHCP server and automatic client": an active
server listens on port 67 from the start and offers a static assignment
before its range, round from the address after the last one offered;
offers last 10 s, dynamic leases a day, and a server whose offer another
server beat blacklists that address for a while. A client starts as an
event: DISCOVER, OFFER, a check by ARP, DECLINE, REQUEST, ACK or NAK,
2.5 s per wait, and after ten failures its old address back. The snapshot
carries the DHCP settings of clients and servers.

Four scenarios match the Swift engine. One Swift error is named in the
oracle test: a client run directly is advanced a second time, to FINISH,
by the server's answer within its own DISCOVER, and the outer run then
writes back its stale copy of the status (Swift's value types); the
address and every trace agree, only the reported status is wrong in
Swift. Swift's three DHCP engine tests are ported, with tests for
stopping and the message format. 15 mutation probes, 14 caught; the other
concerns the home router, which runs only once NAT is ported.

Two departures from Java remain, to be fixed next: from the event queue
the client's ARP check does not wait, so an address conflict is never
found, and each answer that wakes the client makes it send its state's
message again (a second DISCOVER and REQUEST, the second answered with
NAK). Java waits a round trip and sends once per attempt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-30 10:17:36 +00:00
co-authored by Claude Opus 5.5
parent 12849b1535
commit 396dbacfc5
26 changed files with 43646 additions and 23 deletions
+3 -3
View File
@@ -13,12 +13,12 @@ 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`). Klasse statt Werttyp. Noch nicht übertragen (wirft `NotPortedError`): Gateway-NAT, DHCP, 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 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/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/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 |
| `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/trace.ts` | Engine, Paketspur-Modell | übertragen |
| `src/util/equal.ts` | — | neu (Ersatz für Swifts `Equatable`) |
@@ -27,7 +27,7 @@ Swift-Pfade relativ zu `filius-ipad/ios/FiliusPad/TopologyEditor/` (Stand
| `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: 10 Szenarien (davon 2 UDP, 3 TCP), Ergebnis mit dem Swift-Kern abgeglichen (`tools/swift-oracle`); Schritte für UDP und TCP |
| `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/routeTable.test.ts` | `TopologyJavaRouteTableTests.swift` (8 von 12 Tests) | übertragen; Geometrie- und Reducer-Tests folgen mit Editor und Dokument |
## packages/fls
+8 -3
View File
@@ -1,7 +1,12 @@
// TextEncoder exists in every target (browsers, Node, workers) but belongs to
// the DOM and Node type libraries, which this package does not load on
// purpose. Declared here to the extent the core uses it.
// TextEncoder and TextDecoder exist in every target (browsers, Node, workers)
// but belong to the DOM and Node type libraries, which this package does not
// load on purpose. Declared here to the extent the core uses them.
declare class TextEncoder {
encode(input?: string): Uint8Array;
}
declare class TextDecoder {
constructor(label?: string, options?: { fatal?: boolean });
decode(input?: Uint8Array): string;
}
+700 -9
View File
@@ -4,9 +4,14 @@
// "Deterministic runtime engine" (lines ~984–1284)
// "Ethernet, ARP, IPv4, and ICMP packet runtime" (lines ~1284–3585,
// without remote link, wireless and gateway NAT; with the firewall)
// "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)
// processScheduledRuntimeEvent, dropForGlobalPacketLoss (lines ~4753–4850)
// Java counterparts: filius.hardware.Verbindung, filius.software.netzzugangsschicht,
// filius.software.vermittlungsschicht (ARP, IP, ICMP), system.SwitchFirmware.
// filius.software.vermittlungsschicht (ARP, IP, ICMP), system.SwitchFirmware,
// filius.software.transportschicht (UDP, TCP), filius.software.dhcp.
//
// The Swift engine is a value type driven by a reducer; here it is a class
// with the same inputs and outputs. Time is virtual: nothing sleeps, every
@@ -14,26 +19,24 @@
// Calls that block in Java (resolving an address, waiting for an echo reply)
// advance the virtual clock inside the call, exactly as the Swift port does.
//
// "UDP sockets and Router RIP" (lines ~4472–5187, the
// UDP sockets; RIP not yet)
// "Java-compatible TCP sockets" (lines ~5187–5913)
//
// Not ported yet — each place throws NotPortedError instead of guessing:
// gateway NAT, RIP routing, remote links. DHCP is not in the
// snapshot yet, so Swift's DHCP handling of a datagram has nothing to do.
// gateway NAT, RIP routing, remote links.
import { compareIds, type Id } from '../ids.js';
import { isPcClassEndpoint, stableGeneratedMacAddress } from '../model/node.js';
import type {
DeviceConfiguration,
DhcpServerConfiguration,
FirewallConfiguration,
FirewallRule,
InterfaceConfiguration,
InterfaceKey,
ManualRoute,
} from './config.js';
import {
ALL_PORTS,
DEFAULT_SWITCH_RETENTION_MILLISECONDS,
dhcpServerConfiguration,
DIRECTLY_CONNECTED_SOURCE_MARKER,
FIREWALL_PROTOCOL,
firewallConfiguration,
@@ -268,6 +271,67 @@ export interface TcpSession {
export type TcpConnectionResult = 'connected' | 'invalidSocket' | 'unreachable' | 'timedOut';
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;
export const DHCP_DYNAMIC_LEASE_LIFETIME_MILLISECONDS = 24 * 60 * 60 * 1_000;
export const DHCP_MAXIMUM_CLIENT_ERROR_COUNT = 10;
export type DhcpMessageType = 'DHCPDISCOVER' | 'DHCPREQUEST' | 'DHCPACK' | 'DHCPNAK' | 'DHCPOFFER' | 'DHCPDECLINE';
const DHCP_MESSAGE_TYPES: readonly DhcpMessageType[] = ['DHCPDISCOVER', 'DHCPREQUEST', 'DHCPACK', 'DHCPNAK', 'DHCPOFFER', 'DHCPDECLINE'];
/** Swift's TopologyRuntimeDHCPMessage: lines of text in a UDP datagram. */
export interface DhcpMessage {
readonly type: DhcpMessageType;
yourIPAddress: string;
clientMacAddress?: string | undefined;
subnetMask?: string | undefined;
routerIPAddress?: string | undefined;
dnsServerIPAddress?: string | undefined;
serverIdentifier?: string | undefined;
requestedIPAddress?: string | undefined;
}
export interface DhcpOfferRecord {
readonly clientMacAddress: string;
readonly offeredIPAddress: string;
readonly serverNodeId: Id;
readonly expiresAtMilliseconds: number;
}
export interface DhcpLeaseRecord {
readonly clientMacAddress: string;
readonly ipAddress: string;
readonly serverNodeId: Id;
/** Undefined for a static assignment. */
readonly expiresAtMilliseconds: number | undefined;
}
export type DhcpClientState = 'INIT' | 'DISCOVER' | 'VALIDATE' | 'DECLINE' | 'REQUEST' | 'ASSIGN_IP' | 'FINISH';
export interface DhcpClientStatus {
state: DhcpClientState;
errorCount: number;
selectedServerIPAddress: string | undefined;
offeredIPAddress: string | undefined;
succeeded: boolean;
}
export interface DhcpClientContext {
readonly interfaceId: Id;
readonly socketId: Id;
readonly clientMacAddress: string;
readonly oldDeviceConfiguration: DeviceConfiguration | undefined;
readonly oldInterfaceConfiguration: InterfaceConfiguration | undefined;
selectedOffer: DhcpMessage | undefined;
}
/** Swift's caseInsensitiveCompare == .orderedSame, for addresses. */
function sameIgnoringCase(lhs: string | undefined, rhs: string | undefined): boolean {
return lhs !== undefined && rhs !== undefined && lhs.toLowerCase() === rhs.toLowerCase();
}
export const TCP_MAXIMUM_SEGMENT_SIZE = 1_460;
export const TCP_MAXIMUM_SEND_ATTEMPTS = 3;
export const TCP_ROUND_TRIP_TIME_MILLISECONDS = 2_500;
@@ -319,6 +383,13 @@ export interface RuntimeState {
udpReceiveQueuesBySocketId: Map<Id, UdpReceivedDatagram[]>;
tcpSessionsById: Map<Id, TcpSession>;
tcpAcceptedSocketIdsByListenerId: Map<Id, Id[]>;
dhcpOffersByIPAddress: Map<string, DhcpOfferRecord>;
dhcpLeasesByIPAddress: Map<string, DhcpLeaseRecord>;
dhcpBlacklistByServerNodeId: Map<Id, Map<string, number>>;
dhcpLastOfferedAddressByServerNodeId: Map<Id, string>;
dhcpServerSocketIdsByNodeId: Map<Id, Id>;
dhcpClientStatusesByNodeId: Map<Id, DhcpClientStatus>;
dhcpClientContextsByNodeId: Map<Id, DhcpClientContext>;
nextSocketSequenceNumber: number;
nextTcpInitialSequenceValue: number;
nextEventSequenceNumber: number;
@@ -349,6 +420,13 @@ function initialState(seed: bigint): RuntimeState {
udpReceiveQueuesBySocketId: new Map(),
tcpSessionsById: new Map(),
tcpAcceptedSocketIdsByListenerId: new Map(),
dhcpOffersByIPAddress: new Map(),
dhcpLeasesByIPAddress: new Map(),
dhcpBlacklistByServerNodeId: new Map(),
dhcpLastOfferedAddressByServerNodeId: new Map(),
dhcpServerSocketIdsByNodeId: new Map(),
dhcpClientStatusesByNodeId: new Map(),
dhcpClientContextsByNodeId: new Map(),
nextSocketSequenceNumber: 1,
nextTcpInitialSequenceValue: 1,
nextEventSequenceNumber: 0,
@@ -487,6 +565,7 @@ export class NetworkRuntimeEngine {
state.udpReceiveQueuesBySocketId.clear();
state.tcpSessionsById.clear();
state.tcpAcceptedSocketIdsByListenerId.clear();
this.clearDhcpState();
state.firewallDecisions = [];
return [this.output({ type: 'stopped' })];
}
@@ -658,8 +737,11 @@ export class NetworkRuntimeEngine {
case 'ripBeacon':
throw new NotPortedError('RIP beacons');
case 'dhcpClientStart':
this.runDhcpClient(kind.nodeId);
return;
case 'dhcpTimeout':
throw new NotPortedError('DHCP client');
this.handleDhcpClientTimeout(kind.nodeId);
return;
case 'tcpTimeout':
this.handleTcpTimeout(kind.sessionId);
return;
@@ -895,6 +977,7 @@ export class NetworkRuntimeEngine {
) {
throw new NotPortedError('RIP advertisements');
}
this.processDhcpDatagram(packet, datagram, nodeId, networkInterface);
const state = this.stateValue;
const matching = [...state.socketsById.values()]
.filter((socket) => socket.nodeId === nodeId && socket.protocol === 'udp' && socket.localPort === datagram.destinationPort)
@@ -918,6 +1001,9 @@ 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);
}
}
// ── TCP sockets ──────────────────────────────────────────────────────────
@@ -1544,8 +1630,542 @@ export class NetworkRuntimeEngine {
}
}
private clearDhcpState(): void {
const state = this.stateValue;
state.dhcpOffersByIPAddress.clear();
state.dhcpLeasesByIPAddress.clear();
state.dhcpBlacklistByServerNodeId.clear();
state.dhcpLastOfferedAddressByServerNodeId.clear();
state.dhcpServerSocketIdsByNodeId.clear();
state.dhcpClientStatusesByNodeId.clear();
state.dhcpClientContextsByNodeId.clear();
}
/** Every active server listens on port 67; every enabled client starts now. */
private initializeDhcpState(): void {
// DHCP (F2): no DHCP configuration exists in the snapshot yet.
const state = this.stateValue;
this.clearDhcpState();
const snapshot = state.topologySnapshot;
for (const node of snapshot.nodes) {
if (snapshot.dhcpServerConfigurationsByNodeId.get(node.id)?.isActive !== true) continue;
const serverInterface = this.dhcpServerInterface(node.id);
if (serverInterface === undefined) continue;
const socketId = this.bindUdpSocket(node.id, {
localPort: DHCP_SERVER_PORT,
localIPAddress: serverInterface.ipAddress,
remoteIPAddress: LIMITED_BROADCAST_IP_ADDRESS,
remotePort: DHCP_CLIENT_PORT,
});
if (socketId !== undefined) state.dhcpServerSocketIdsByNodeId.set(node.id, socketId);
}
for (const node of snapshot.nodes) {
if (snapshot.dhcpClientConfigurationsByNodeId.get(node.id)?.isEnabled !== true) continue;
if (!isPcClassEndpoint(node.kind) && node.kind !== 'gateway') continue;
state.dhcpClientStatusesByNodeId.set(node.id, dhcpClientStatus());
this.schedule(state.currentTimeMilliseconds, { kind: 'dhcpClientStart', nodeId: node.id });
}
}
// ── 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.
*/
runDhcpClient(nodeId: Id): DhcpClientStatus | undefined {
const state = this.stateValue;
const node = this.nodeSnapshot(nodeId);
if (
state.phase !== 'running' ||
state.topologySnapshot.dhcpClientConfigurationsByNodeId.get(nodeId)?.isEnabled !== true ||
node === undefined ||
(!isPcClassEndpoint(node.kind) && node.kind !== 'gateway')
) {
return undefined;
}
const clientInterface = this.dhcpClientInterface(nodeId);
if (clientInterface === undefined) return undefined;
if (state.dhcpClientContextsByNodeId.has(nodeId)) {
this.advanceDhcpClient(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);
const resetInterface = this.networkInterfaces(nodeId).find((each) => each.portId === clientInterface.portId);
const socketId =
resetInterface === undefined
? undefined
: this.bindUdpSocket(nodeId, {
localPort: DHCP_CLIENT_PORT,
localIPAddress: resetInterface.ipAddress,
remoteIPAddress: LIMITED_BROADCAST_IP_ADDRESS,
remotePort: DHCP_SERVER_PORT,
});
if (resetInterface === undefined || socketId === undefined) {
this.restoreDhcpClientOldIPAddress(nodeId, clientInterface.portId, oldDeviceConfiguration, oldInterfaceConfiguration);
return undefined;
}
state.dhcpClientContextsByNodeId.set(nodeId, {
interfaceId: clientInterface.portId,
socketId,
clientMacAddress: resetInterface.macAddress,
oldDeviceConfiguration,
oldInterfaceConfiguration,
selectedOffer: undefined,
});
state.dhcpClientStatusesByNodeId.set(nodeId, dhcpClientStatus());
this.advanceDhcpClient(nodeId);
return state.dhcpClientStatusesByNodeId.get(nodeId);
}
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';
};
switch (status.state) {
case 'INIT':
status.state = 'DISCOVER';
break;
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';
break;
}
case 'VALIDATE': {
const offer = context.selectedOffer;
if (offer === undefined) {
backToDiscover();
break;
}
const conflictMac = this.resolveMacAddress(nodeId, offer.yourIPAddress, {
preferredInterfaceId: context.interfaceId,
maxRetries: 1,
selectInterfaceByAddress: false,
});
status.state = conflictMac === undefined ? 'REQUEST' : 'DECLINE';
break;
}
case 'DECLINE': {
const offer = context.selectedOffer;
if (offer === undefined) {
backToDiscover();
break;
}
this.sendDhcpMessage(context.socketId, {
type: 'DHCPDECLINE',
yourIPAddress: UNSPECIFIED_IP_ADDRESS,
clientMacAddress: context.clientMacAddress,
serverIdentifier: offer.serverIdentifier,
requestedIPAddress: offer.yourIPAddress,
});
context.selectedOffer = undefined;
status.selectedServerIPAddress = undefined;
status.offeredIPAddress = undefined;
status.state = 'DISCOVER';
break;
}
case 'REQUEST': {
const offer = context.selectedOffer;
const serverIdentifier = offer?.serverIdentifier;
if (offer === undefined || serverIdentifier === undefined) {
backToDiscover();
break;
}
this.sendDhcpMessage(context.socketId, {
type: 'DHCPREQUEST',
yourIPAddress: UNSPECIFIED_IP_ADDRESS,
clientMacAddress: context.clientMacAddress,
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';
}
break;
}
case 'ASSIGN_IP': {
const offer = context.selectedOffer;
if (offer === undefined) {
backToDiscover();
break;
}
this.applyDhcpConfiguration(nodeId, context.interfaceId, offer);
status.succeeded = true;
status.state = 'FINISH';
this.finishDhcpClient(nodeId, false);
return;
}
}
}
}
private scheduleDhcpTimeout(nodeId: Id): void {
this.removeDhcpTimeouts(nodeId);
this.schedule(this.stateValue.currentTimeMilliseconds + DHCP_ROUND_TRIP_TIME_MILLISECONDS, { kind: 'dhcpTimeout', nodeId });
}
private removeDhcpTimeouts(nodeId: Id): void {
this.stateValue.pendingEvents = this.stateValue.pendingEvents.filter(
(event) => !(event.kind.kind === 'dhcpTimeout' && event.kind.nodeId === nodeId),
);
}
private handleDhcpClientTimeout(nodeId: Id): void {
const state = this.stateValue;
const status = state.dhcpClientStatusesByNodeId.get(nodeId);
if (!state.dhcpClientContextsByNodeId.has(nodeId) || status === undefined || status.state === 'FINISH') return;
status.errorCount += 1;
if (status.errorCount >= DHCP_MAXIMUM_CLIENT_ERROR_COUNT) {
status.state = 'FINISH';
status.succeeded = false;
this.finishDhcpClient(nodeId, true);
return;
}
this.advanceDhcpClient(nodeId);
}
private finishDhcpClient(nodeId: Id, restoreOldIPAddress: boolean): 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);
}
/** The server's part: answers a client's DISCOVER, REQUEST and DECLINE on its server interface. */
private processDhcpDatagram(packet: IPv4Packet, datagram: UdpDatagram, nodeId: Id, receivingInterface: NetworkInterface): void {
const state = this.stateValue;
if (
datagram.destinationPort !== DHCP_SERVER_PORT ||
datagram.sourcePort !== DHCP_CLIENT_PORT ||
state.topologySnapshot.dhcpServerConfigurationsByNodeId.get(nodeId)?.isActive !== true
) {
return;
}
const serverInterface = this.dhcpServerInterface(nodeId);
if (serverInterface === undefined || serverInterface.portId !== receivingInterface.portId) return;
const socketId = state.dhcpServerSocketIdsByNodeId.get(nodeId);
if (socketId === undefined) return;
const message = decodeDhcpMessage(datagram.payload);
const clientMacAddress = message?.clientMacAddress;
if (message === undefined || clientMacAddress === undefined) return;
switch (message.type) {
case 'DHCPDISCOVER': {
const offeredIPAddress = this.offerDhcpAddress(nodeId, clientMacAddress);
if (offeredIPAddress === undefined) return;
const configuration = state.topologySnapshot.dhcpServerConfigurationsByNodeId.get(nodeId) ?? dhcpServerConfiguration();
this.sendDhcpMessage(socketId, {
type: 'DHCPOFFER',
yourIPAddress: offeredIPAddress,
clientMacAddress,
subnetMask: serverInterface.subnetMask,
routerIPAddress: this.determineDhcpGateway(nodeId, serverInterface, configuration),
dnsServerIPAddress: this.determineDhcpDnsServer(nodeId, configuration),
serverIdentifier: serverInterface.ipAddress,
});
return;
}
case 'DHCPREQUEST': {
const requestedIPAddress = message.requestedIPAddress;
const selectedServer = message.serverIdentifier;
if (requestedIPAddress === undefined || selectedServer === undefined) return;
if (sameIgnoringCase(selectedServer, serverInterface.ipAddress)) {
const accepted = this.requestDhcpAddress(nodeId, clientMacAddress, requestedIPAddress);
this.sendDhcpMessage(socketId, {
type: accepted ? 'DHCPACK' : 'DHCPNAK',
yourIPAddress: requestedIPAddress,
clientMacAddress,
serverIdentifier: serverInterface.ipAddress,
});
} else {
// 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,
);
}
return;
}
case 'DHCPDECLINE':
this.recordTrace({
packetIdentity: packet.identity,
nodeId,
interfaceId: receivingInterface.portId,
direction: 'local',
layer: 'application',
operation: 'accepted',
detail: 'DHCPDECLINE ignored by Java server worker',
});
return;
default:
return;
}
}
/** A static assignment for the MAC, else the next free address of the range after the last one offered. */
private offerDhcpAddress(serverNodeId: Id, clientMacAddress: string): string | undefined {
const state = this.stateValue;
const configuration = state.topologySnapshot.dhcpServerConfigurationsByNodeId.get(serverNodeId);
if (configuration === undefined) return undefined;
const staticAddress = configuration.staticAssignments.find((entry) => sameIgnoringCase(entry.macAddress, clientMacAddress))?.ipAddress;
if (staticAddress !== undefined) return staticAddress;
const lower = parseIPv4(configuration.lowerBoundIPAddress);
const upper = parseIPv4(configuration.upperBoundIPAddress);
if (lower === undefined || upper === undefined || lower > upper) return undefined;
const last = state.dhcpLastOfferedAddressByServerNodeId.get(serverNodeId);
const lastValue = last === undefined ? undefined : parseIPv4(last);
const firstCandidate = lastValue === undefined ? lower : lastValue < upper ? lastValue + 1 : lower;
let candidate = firstCandidate;
do {
const address = ipv4String(candidate);
state.dhcpLastOfferedAddressByServerNodeId.set(serverNodeId, address);
if (this.isDhcpAddressAvailable(serverNodeId, address)) {
state.dhcpOffersByIPAddress.set(dhcpRecordKey(serverNodeId, address), {
clientMacAddress,
offeredIPAddress: address,
serverNodeId,
expiresAtMilliseconds: state.currentTimeMilliseconds + DHCP_OFFER_LIFETIME_MILLISECONDS,
});
return address;
}
candidate = candidate < upper ? candidate + 1 : lower;
} while (candidate !== firstCandidate);
return undefined;
}
private requestDhcpAddress(serverNodeId: Id, clientMacAddress: string, requestedIPAddress: string): boolean {
const state = this.stateValue;
this.cleanupDhcpAssignments(serverNodeId);
if (state.dhcpBlacklistByServerNodeId.get(serverNodeId)?.has(requestedIPAddress.toLowerCase()) === true) return false;
const configuration = state.topologySnapshot.dhcpServerConfigurationsByNodeId.get(serverNodeId) ?? dhcpServerConfiguration();
if (
configuration.staticAssignments.some(
(entry) => sameIgnoringCase(entry.macAddress, clientMacAddress) && sameIgnoringCase(entry.ipAddress, requestedIPAddress),
)
) {
this.storeDhcpLease({ clientMacAddress, ipAddress: requestedIPAddress, serverNodeId, expiresAtMilliseconds: undefined });
return true;
}
const key = dhcpRecordKey(serverNodeId, requestedIPAddress);
const offer = state.dhcpOffersByIPAddress.get(key);
let accepted: boolean;
if (offer !== undefined) {
if (!sameIgnoringCase(offer.clientMacAddress, clientMacAddress)) return false;
state.dhcpOffersByIPAddress.delete(key);
accepted = true;
} else {
accepted = this.isDhcpAddressAvailable(serverNodeId, requestedIPAddress);
}
if (!accepted) return false;
this.storeDhcpLease({
clientMacAddress,
ipAddress: requestedIPAddress,
serverNodeId,
expiresAtMilliseconds: state.currentTimeMilliseconds + DHCP_DYNAMIC_LEASE_LIFETIME_MILLISECONDS,
});
return true;
}
/** A client's new lease replaces its earlier one at the same server. */
private storeDhcpLease(lease: DhcpLeaseRecord): void {
const leases = this.stateValue.dhcpLeasesByIPAddress;
for (const [key, existing] of leases) {
if (existing.serverNodeId === lease.serverNodeId && sameIgnoringCase(existing.clientMacAddress, lease.clientMacAddress)) {
leases.delete(key);
}
}
leases.set(dhcpRecordKey(lease.serverNodeId, lease.ipAddress), lease);
}
private isDhcpAddressAvailable(serverNodeId: Id, ipAddress: string): boolean {
const state = this.stateValue;
this.cleanupDhcpAssignments(serverNodeId);
const configuration = state.topologySnapshot.dhcpServerConfigurationsByNodeId.get(serverNodeId) ?? dhcpServerConfiguration();
if (configuration.staticAssignments.some((entry) => sameIgnoringCase(entry.ipAddress, ipAddress))) return false;
if (state.dhcpBlacklistByServerNodeId.get(serverNodeId)?.has(ipAddress.toLowerCase()) === true) return false;
const key = dhcpRecordKey(serverNodeId, ipAddress);
return !state.dhcpOffersByIPAddress.has(key) && !state.dhcpLeasesByIPAddress.has(key);
}
/** Forgets the server's expired offers, dynamic leases and blacklist entries. */
private cleanupDhcpAssignments(serverNodeId: Id): void {
const state = this.stateValue;
const now = state.currentTimeMilliseconds;
for (const [key, offer] of state.dhcpOffersByIPAddress) {
if (offer.serverNodeId === serverNodeId && offer.expiresAtMilliseconds < now) state.dhcpOffersByIPAddress.delete(key);
}
for (const [key, lease] of state.dhcpLeasesByIPAddress) {
if (lease.serverNodeId === serverNodeId && lease.expiresAtMilliseconds !== undefined && lease.expiresAtMilliseconds < now) {
state.dhcpLeasesByIPAddress.delete(key);
}
}
const blacklist = state.dhcpBlacklistByServerNodeId.get(serverNodeId);
if (blacklist !== undefined) {
for (const [address, expiry] of blacklist) if (expiry < now) blacklist.delete(address);
}
}
/** The first queued answer of an accepted type for the MAC (and from the server, when given). */
private receiveDhcpResponse(
socketId: Id,
clientMacAddress: string,
serverIdentifier: string | undefined,
acceptedTypes: readonly DhcpMessageType[],
): DhcpMessage | undefined {
for (let received = this.receiveUdp(socketId); received !== undefined; received = this.receiveUdp(socketId)) {
const message = decodeDhcpMessage(received.datagram.payload);
if (message === undefined || !acceptedTypes.includes(message.type) || !sameIgnoringCase(message.clientMacAddress, clientMacAddress)) {
continue;
}
if (serverIdentifier !== undefined && !sameIgnoringCase(message.serverIdentifier, serverIdentifier)) continue;
return message;
}
return undefined;
}
private sendDhcpMessage(socketId: Id, message: DhcpMessage): IPv4DeliveryResult | undefined {
const socket = this.stateValue.socketsById.get(socketId);
if (socket === undefined) return undefined;
this.recordTrace({ nodeId: socket.nodeId, direction: 'outbound', layer: 'application', operation: 'sent', detail: message.type });
return this.sendUdp(socketId, encodeDhcpMessage(message), {
ipAddress: LIMITED_BROADCAST_IP_ADDRESS,
port: socket.localPort === DHCP_SERVER_PORT ? DHCP_CLIENT_PORT : DHCP_SERVER_PORT,
});
}
/** A PC's or notebook's interface, a home router's LAN (index 1). */
private dhcpServerInterface(nodeId: Id): NetworkInterface | undefined {
const kind = this.nodeSnapshot(nodeId)?.kind;
const interfaces = this.networkInterfaces(nodeId);
if (kind === 'pc' || kind === 'notebook') return interfaces[0];
if (kind === 'gateway') return interfaces.find((each) => each.index === 1);
return undefined;
}
/** A PC's or notebook's interface, a home router's WAN (index 0). */
private dhcpClientInterface(nodeId: Id): NetworkInterface | undefined {
const kind = this.nodeSnapshot(nodeId)?.kind;
const interfaces = this.networkInterfaces(nodeId);
if (kind === 'pc' || kind === 'notebook') return interfaces[0];
if (kind === 'gateway') return interfaces.find((each) => each.index === 0);
return undefined;
}
private determineDhcpGateway(serverNodeId: Id, serverInterface: NetworkInterface, configuration: DhcpServerConfiguration): string {
if (this.nodeSnapshot(serverNodeId)?.kind === 'gateway') return serverInterface.ipAddress;
if (configuration.useOwnSettings) return configuration.gatewayIPAddress;
const gateway = this.stateValue.topologySnapshot.deviceConfigurations.get(serverNodeId)?.defaultGateway ?? '';
return gateway === '' ? UNSPECIFIED_IP_ADDRESS : gateway;
}
private determineDhcpDnsServer(serverNodeId: Id, configuration: DhcpServerConfiguration): string {
if (this.nodeSnapshot(serverNodeId)?.kind === 'gateway' || configuration.useOwnSettings) return configuration.dnsServerIPAddress;
const dns = this.stateValue.topologySnapshot.deviceConfigurations.get(serverNodeId)?.dnsServer ?? '';
return dns === '' ? UNSPECIFIED_IP_ADDRESS : dns;
}
private applyDhcpConfiguration(nodeId: Id, interfaceId: Id, offer: DhcpMessage): void {
const node = this.nodeSnapshot(nodeId);
if (node === undefined) return;
const mask = offer.subnetMask ?? '0.0.0.0';
const gateway = offer.routerIPAddress ?? UNSPECIFIED_IP_ADDRESS;
const dns = offer.dnsServerIPAddress ?? UNSPECIFIED_IP_ADDRESS;
const snapshot = this.stateValue.topologySnapshot;
const devices = new Map(snapshot.deviceConfigurations);
const interfaces = new Map(snapshot.interfaceConfigurations);
if (isPcClassEndpoint(node.kind)) {
devices.set(nodeId, { ipAddress: offer.yourIPAddress, subnetMask: mask, defaultGateway: gateway, dnsServer: dns });
} else if (node.kind === 'gateway') {
interfaces.set(interfaceKey(nodeId, interfaceId), { ipAddress: offer.yourIPAddress, subnetMask: mask });
const current = devices.get(nodeId);
devices.set(nodeId, {
ipAddress: current?.ipAddress ?? '',
subnetMask: current?.subnetMask ?? '',
defaultGateway: gateway,
dnsServer: dns,
});
}
this.replaceTopologyConfigurations(devices, interfaces);
}
private setDhcpClientIPAddress(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);
const currentInterface = interfaces.get(interfaceKey(nodeId, interfaceId));
if (isPcClassEndpoint(node.kind) && current !== undefined) {
devices.set(nodeId, { ...current, ipAddress });
} else if (node.kind === 'gateway' && currentInterface !== undefined) {
interfaces.set(interfaceKey(nodeId, interfaceId), { ipAddress, subnetMask: currentInterface.subnetMask });
}
this.replaceTopologyConfigurations(devices, interfaces);
}
private restoreDhcpClientOldIPAddress(
nodeId: Id,
interfaceId: Id,
deviceConfiguration: DeviceConfiguration | undefined,
interfaceConfiguration: InterfaceConfiguration | undefined,
): 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);
const currentInterface = interfaces.get(interfaceKey(nodeId, interfaceId));
if (isPcClassEndpoint(node.kind) && deviceConfiguration !== undefined && current !== undefined) {
devices.set(nodeId, { ...current, ipAddress: deviceConfiguration.ipAddress });
} else if (node.kind === 'gateway' && interfaceConfiguration !== undefined && currentInterface !== undefined) {
interfaces.set(interfaceKey(nodeId, interfaceId), {
ipAddress: interfaceConfiguration.ipAddress,
subnetMask: currentInterface.subnetMask,
});
}
this.replaceTopologyConfigurations(devices, interfaces);
}
private replaceTopologyConfigurations(
deviceConfigurations: ReadonlyMap<Id, DeviceConfiguration>,
interfaceConfigurations: ReadonlyMap<InterfaceKey, InterfaceConfiguration>,
): void {
this.stateValue.topologySnapshot = withSnapshot(this.stateValue.topologySnapshot, { deviceConfigurations, interfaceConfigurations });
}
private initializeNatState(): void {
@@ -2864,6 +3484,77 @@ export function ipv4Headers(packet: IPv4Packet): HeaderField[] {
];
}
function dhcpClientStatus(): DhcpClientStatus {
return { state: 'INIT', errorCount: 0, selectedServerIPAddress: undefined, offeredIPAddress: undefined, succeeded: false };
}
function dhcpRecordKey(serverNodeId: Id, ipAddress: string): string {
return `${serverNodeId.toLowerCase()}|${ipAddress.toLowerCase()}`;
}
function ipv4String(address: number): string {
return [24, 16, 8, 0].map((shift) => String((address >>> shift) & 0xff)).join('.');
}
/** Swift's encodeDHCPMessage: the type, then `key=value` lines for the fields that are set. */
export function encodeDhcpMessage(message: DhcpMessage): Uint8Array {
const lines = [message.type, `yiaddr=${message.yourIPAddress}`];
const add = (key: string, value: string | undefined) => {
if (value !== undefined && value !== '') lines.push(`${key}=${value}`);
};
add('chaddr', message.clientMacAddress);
add('router', message.routerIPAddress);
add('subnetmask', message.subnetMask);
add('dnsserver', message.dnsServerIPAddress);
add('requested', message.requestedIPAddress);
add('serverident', message.serverIdentifier);
return new TextEncoder().encode(lines.join('\n'));
}
/** Swift's decodeDHCPMessage: undefined for text that is not UTF-8 or of no known type. */
export function decodeDhcpMessage(data: Uint8Array): DhcpMessage | undefined {
let text: string;
try {
text = new TextDecoder('utf-8', { fatal: true }).decode(data);
} catch {
return undefined;
}
const [command, ...fields] = text.split('\n');
const type = DHCP_MESSAGE_TYPES.find((each) => sameIgnoringCase(each, command));
if (type === undefined) return undefined;
const message: DhcpMessage = { type, yourIPAddress: UNSPECIFIED_IP_ADDRESS };
for (const line of fields) {
const separator = line.indexOf('=');
if (separator < 0) continue;
const value = line.slice(separator + 1);
switch (line.slice(0, separator)) {
case 'yiaddr':
message.yourIPAddress = value;
break;
case 'chaddr':
message.clientMacAddress = value;
break;
case 'router':
message.routerIPAddress = value;
break;
case 'subnetmask':
message.subnetMask = value;
break;
case 'dnsserver':
message.dnsServerIPAddress = value;
break;
case 'requested':
message.requestedIPAddress = value;
break;
case 'serverident':
message.serverIdentifier = value;
break;
}
}
return message;
}
function tcpSegmentRequiresAcknowledgement(segment: TcpSegment): boolean {
return (
(segment.flags & (TCP_FLAG.synchronize | TCP_FLAG.finish | TCP_FLAG.push)) !== 0 || segment.payload.length > 0
+9 -1
View File
@@ -2,13 +2,15 @@
// State/TopologyNetworkRuntimeEngine.swift (TopologyNetworkRuntimeTopologySnapshot)
// What the runtime starts from: the topology and every configuration, frozen
// at the moment the simulation starts. Later protocol slices add their
// configuration maps here (DHCP, NAT, remote link, wireless).
// configuration maps here (NAT, remote link, wireless).
import { compareIds, type Id } from '../ids.js';
import type { NodeKind } from '../model/node.js';
import { canonicalMacAddress } from '../model/node.js';
import type {
DeviceConfiguration,
DhcpClientConfiguration,
DhcpServerConfiguration,
FirewallConfiguration,
InterfaceConfiguration,
InterfaceKey,
@@ -51,6 +53,8 @@ export interface TopologySnapshot {
readonly interfaceConfigurations: ReadonlyMap<InterfaceKey, InterfaceConfiguration>;
readonly manualRoutesByNodeId: ReadonlyMap<Id, readonly ManualRoute[]>;
readonly ripEnabledByNodeId: ReadonlyMap<Id, boolean>;
readonly dhcpClientConfigurationsByNodeId: ReadonlyMap<Id, DhcpClientConfiguration>;
readonly dhcpServerConfigurationsByNodeId: ReadonlyMap<Id, DhcpServerConfiguration>;
readonly firewallConfigurationsByNodeId: ReadonlyMap<Id, FirewallConfiguration>;
readonly switchConfigurationsByNodeId: ReadonlyMap<Id, SwitchConfiguration>;
}
@@ -62,6 +66,8 @@ export interface TopologySnapshotInput {
interfaceConfigurations?: ReadonlyMap<InterfaceKey, InterfaceConfiguration>;
manualRoutesByNodeId?: ReadonlyMap<Id, readonly ManualRoute[]>;
ripEnabledByNodeId?: ReadonlyMap<Id, boolean>;
dhcpClientConfigurationsByNodeId?: ReadonlyMap<Id, DhcpClientConfiguration>;
dhcpServerConfigurationsByNodeId?: ReadonlyMap<Id, DhcpServerConfiguration>;
firewallConfigurationsByNodeId?: ReadonlyMap<Id, FirewallConfiguration>;
switchConfigurationsByNodeId?: ReadonlyMap<Id, SwitchConfiguration>;
}
@@ -74,6 +80,8 @@ export function topologySnapshot(input: TopologySnapshotInput): TopologySnapshot
interfaceConfigurations: input.interfaceConfigurations ?? new Map(),
manualRoutesByNodeId: input.manualRoutesByNodeId ?? new Map(),
ripEnabledByNodeId: input.ripEnabledByNodeId ?? new Map(),
dhcpClientConfigurationsByNodeId: input.dhcpClientConfigurationsByNodeId ?? new Map(),
dhcpServerConfigurationsByNodeId: input.dhcpServerConfigurationsByNodeId ?? new Map(),
firewallConfigurationsByNodeId: input.firewallConfigurationsByNodeId ?? new Map(),
switchConfigurationsByNodeId: input.switchConfigurationsByNodeId ?? new Map(),
};
+144 -2
View File
@@ -1,12 +1,17 @@
// Ported from filius-ipad ios/FiliusPadTests/TopologyNetworkRuntimeEngineTests.swift:
// the event queue, the virtual clock, Java-compatible randomness, restart,
// Ethernet/switch/ARP/IPv4/ICMP, routing errors, the firewall, packet loss
// and trace retention, UDP and TCP sockets. The remaining Swift tests (DHCP,
// DNS, RIP, NAT, remote link, packet viewer) follow with their protocol
// and trace retention, UDP and TCP sockets, DHCP. The remaining Swift tests
// (DNS, RIP, NAT, remote link, packet viewer) follow with their protocol
// slices.
import { describe, expect, it } from 'vitest';
import {
decodeDhcpMessage,
DHCP_DYNAMIC_LEASE_LIFETIME_MILLISECONDS,
deviceConfiguration,
dhcpServerConfiguration,
encodeDhcpMessage,
EMPTY_TOPOLOGY_SNAPSHOT,
ETHERNET_BROADCAST_MAC_ADDRESS,
firewallConfiguration,
@@ -26,6 +31,8 @@ import {
topologySnapshot,
traceRetentionPolicy,
UNSPECIFIED_MAC_ADDRESS,
type DeviceConfiguration,
type DhcpServerConfiguration,
type EthernetFrame,
type EthernetPayload,
type FirewallConfiguration,
@@ -212,6 +219,36 @@ describe('event queue and virtual clock', () => {
expect(engine.bindUdpSocket(nodeId, { localPort: 7 })).toBe(uuid(1));
});
it('forgets DHCP leases and client state on stop', () => {
const [serverId, serverPortId, clientId, clientPortId] = [0x31, 0x32, 0x33, 0x34].map(uuid) as [string, string, string, string];
const engine = startedEngine({
nodes: [pc(serverId, serverPortId), pc(clientId, clientPortId)],
links: [link(serverId, serverPortId, clientId, clientPortId)],
deviceConfigurations: new Map([
[serverId, device('10.4.0.1')],
[clientId, device('0.0.0.0')],
]),
dhcpClientConfigurationsByNodeId: new Map([[clientId, { isEnabled: true }]]),
dhcpServerConfigurationsByNodeId: new Map([
[serverId, dhcpServerConfiguration({ isActive: true, lowerBoundIPAddress: '10.4.0.20', upperBoundIPAddress: '10.4.0.29' })],
]),
});
engine.handle({ type: 'advance', toMilliseconds: 1_000 });
expect(engine.state.dhcpLeasesByIPAddress.size).toBe(1);
engine.handle({ type: 'stop' });
const state = engine.state;
expect([
state.dhcpOffersByIPAddress.size,
state.dhcpLeasesByIPAddress.size,
state.dhcpBlacklistByServerNodeId.size,
state.dhcpLastOfferedAddressByServerNodeId.size,
state.dhcpServerSocketIdsByNodeId.size,
state.dhcpClientStatusesByNodeId.size,
state.dhcpClientContextsByNodeId.size,
]).toEqual([0, 0, 0, 0, 0, 0, 0]);
});
it('forgets TCP sessions and accepted sockets on stop', () => {
const [clientId, clientPortId, serverId, serverPortId] = [0x21, 0x22, 0x23, 0x24].map(uuid) as [string, string, string, string];
const engine = startedEngine({
@@ -867,3 +904,108 @@ describe('TCP sockets', () => {
expect(engine.state.socketsById.get(listenerId)?.tcpState).toBe('LISTEN');
});
});
describe('DHCP', () => {
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(
{
nodes: [pc(serverId, serverPortId), pc(clientId, clientPortId)],
links: [link(serverId, serverPortId, clientId, clientPortId)],
deviceConfigurations: new Map([
[serverId, device(serverIp)],
[clientId, client],
]),
dhcpClientConfigurationsByNodeId: new Map([[clientId, { isEnabled: true }]]),
dhcpServerConfigurationsByNodeId: new Map([[serverId, dhcpServerConfiguration({ isActive: true, ...server })]]),
},
16,
);
return { engine, clientId, clientPortId };
};
it('completes DISCOVER, OFFER, REQUEST and ACK and applies the configuration', () => {
const { engine, clientId } = serverAndClient(0x1601, '192.168.50.1', device('169.254.1.2', { mask: '0.0.0.0' }), {
lowerBoundIPAddress: '192.168.50.20',
upperBoundIPAddress: '192.168.50.29',
gatewayIPAddress: '192.168.50.1',
dnsServerIPAddress: '192.168.50.53',
useOwnSettings: true,
});
engine.handle({ type: 'advance', toMilliseconds: 1_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]);
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);
});
it('matches a static assignment regardless of case, with a lease that does not expire', () => {
const clientPortId = uuid(0x1611 + 3);
const { engine, clientId } = serverAndClient(0x1611, '10.0.0.1', device('0.0.0.0'), {
lowerBoundIPAddress: '10.0.0.20',
upperBoundIPAddress: '10.0.0.20',
staticAssignments: [{ id: 'static', macAddress: stableMacAddress(clientPortId).toLowerCase(), ipAddress: '10.0.0.99' }],
});
engine.handle({ type: 'advance', toMilliseconds: 1_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', () => {
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]]),
dhcpClientConfigurationsByNodeId: new Map([[clientId, { isEnabled: true }]]),
},
16,
);
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 });
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(engine.state.dhcpClientContextsByNodeId.has(clientId)).toBe(false);
expect(engine.state.socketsById.size).toBe(0);
});
it("writes and reads a message in Swift's text format", () => {
const message = {
type: 'DHCPOFFER' as const,
yourIPAddress: '10.0.0.5',
clientMacAddress: '02:00:00:00:00:01',
subnetMask: '255.255.255.0',
routerIPAddress: '10.0.0.1',
dnsServerIPAddress: '10.0.0.53',
requestedIPAddress: '',
serverIdentifier: '10.0.0.1',
};
const text = new TextDecoder().decode(encodeDhcpMessage(message));
expect(text).toBe(
'DHCPOFFER\nyiaddr=10.0.0.5\nchaddr=02:00:00:00:00:01\nrouter=10.0.0.1\nsubnetmask=255.255.255.0\ndnsserver=10.0.0.53\nserverident=10.0.0.1',
);
const { requestedIPAddress: _empty, ...withoutEmpty } = message;
expect(decodeDhcpMessage(new TextEncoder().encode(text))).toEqual(withoutEmpty);
expect(decodeDhcpMessage(new TextEncoder().encode('dhcpack\nyiaddr=1.2.3.4\nnoise'))).toEqual({ type: 'DHCPACK', yourIPAddress: '1.2.3.4' });
expect(decodeDhcpMessage(new TextEncoder().encode('HELLO'))).toBeUndefined();
});
});
+90 -4
View File
@@ -9,6 +9,7 @@ import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
import {
deviceConfiguration,
dhcpServerConfiguration,
firewallConfiguration,
firewallRule,
interfaceKey,
@@ -16,6 +17,7 @@ import {
portSnapshot,
switchConfiguration,
topologySnapshot,
type DhcpClientStatus,
type FirewallConfiguration,
type IPv4DeliveryResult,
type IcmpObservation,
@@ -55,6 +57,11 @@ interface Scenario {
}[];
}
>;
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;
@@ -144,6 +151,25 @@ function snapshotOf(scenario: Scenario): TopologySnapshot {
),
]),
),
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,
@@ -156,6 +182,16 @@ function snapshotOf(scenario: Scenario): TopologySnapshot {
const upper = (id: string) => id.toUpperCase();
function dhcpStatusJson(status: DhcpClientStatus) {
return {
state: status.state,
errors: status.errorCount,
server: status.selectedServerIPAddress ?? null,
offered: status.offeredIPAddress ?? null,
succeeded: status.succeeded,
};
}
function traceJson(event: TraceEvent): Record<string, unknown> {
return {
id: event.id,
@@ -315,6 +351,11 @@ function run(scenario: Scenario): Record<string, unknown> {
case 'cleanTcp':
results.push(engine.cleanClosedTcpSocket(step.socket!));
break;
case 'runDhcp': {
const status = engine.runDhcpClient(step.node!);
results.push(status === undefined ? null : dhcpStatusJson(status));
break;
}
case 'closeSocket':
engine.closeSocket(step.socket!);
results.push(!engine.state.socketsById.has(step.socket!));
@@ -339,6 +380,25 @@ function run(scenario: Scenario): Record<string, unknown> {
Object.fromEntries([...table].map(([mac, port]) => [mac, upper(port)])),
),
icmpObservations: mapOf(state.icmpObservationsByNodeId, (items) => items.map(observationJson)),
devices: Object.fromEntries(
[...state.topologySnapshot.deviceConfigurations].map(([node, device]) => [
upper(node),
[device.ipAddress, device.subnetMask, device.defaultGateway, device.dnsServer],
]),
),
dhcp: {
clients: mapOf(state.dhcpClientStatusesByNodeId, dhcpStatusJson),
leases: [...state.dhcpLeasesByIPAddress]
.sort(([lhs], [rhs]) => (lhs < rhs ? -1 : 1))
.map(([key, lease]) => [key, lease.clientMacAddress, lease.ipAddress, lease.expiresAtMilliseconds ?? null]),
offers: [...state.dhcpOffersByIPAddress]
.sort(([lhs], [rhs]) => (lhs < rhs ? -1 : 1))
.map(([key, offer]) => [key, offer.clientMacAddress, offer.expiresAtMilliseconds]),
blacklist: mapOf(state.dhcpBlacklistByServerNodeId, (entries) =>
[...entries].sort(([lhs], [rhs]) => (lhs < rhs ? -1 : 1)).map(([address, expiry]) => [address, expiry]),
),
servers: mapOf(state.dhcpServerSocketIdsByNodeId, upper),
},
sockets: [...state.socketsById.values()]
.sort((lhs, rhs) => (upper(lhs.id) < upper(rhs.id) ? -1 : 1))
.map((socket) => ({
@@ -378,7 +438,35 @@ const scenarioNames = readdirSync(scenarioDirectory)
.map((name) => name.replace('.scenario.json', ''))
.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.
*/
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 } } };
},
};
describe('same result as the Swift engine', () => {
it('corrects Swift only where it errs', () => {
for (const [name, correct] of Object.entries(SWIFT_ERRORS)) {
const expected = JSON.parse(readFileSync(join(scenarioDirectory, `${name}.swift.json`), 'utf8')) as Record<string, unknown>;
expect(JSON.stringify(correct(expected)), name).not.toBe(JSON.stringify(expected));
}
});
it('has scenarios with Swift results', () => {
expect(scenarioNames.length).toBeGreaterThan(0);
});
@@ -386,10 +474,8 @@ describe('same result as the Swift engine', () => {
for (const name of scenarioNames) {
it(name, () => {
const scenario = JSON.parse(readFileSync(join(scenarioDirectory, `${name}.scenario.json`), 'utf8')) as Scenario;
const expected = 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>;
const expected = SWIFT_ERRORS[name]?.(swift) ?? swift;
const actual = run(scenario);
// Traces first and one by one: the first difference is the one that matters.
@@ -0,0 +1,116 @@
{
"seed": 43,
"nodes": [
{
"id": "00000000-0000-0000-0000-000000000101",
"kind": "pc",
"ports": [
{
"id": "00000000-0000-0000-0000-000000000111",
"label": "eth0"
}
]
},
{
"id": "00000000-0000-0000-0000-000000000102",
"kind": "pc",
"ports": [
{
"id": "00000000-0000-0000-0000-000000000112",
"label": "eth0"
}
]
},
{
"id": "00000000-0000-0000-0000-000000000103",
"kind": "pc",
"ports": [
{
"id": "00000000-0000-0000-0000-000000000113",
"label": "eth0"
}
]
},
{
"id": "00000000-0000-0000-0000-000000000104",
"kind": "networkSwitch",
"ports": [
{
"id": "00000000-0000-0000-0000-000000000121",
"label": "sw1"
},
{
"id": "00000000-0000-0000-0000-000000000122",
"label": "sw2"
},
{
"id": "00000000-0000-0000-0000-000000000123",
"label": "sw3"
},
{
"id": "00000000-0000-0000-0000-000000000124",
"label": "sw4"
}
]
}
],
"links": [
{
"id": "00000000-0000-0000-0000-00000000F001",
"sourceNode": "00000000-0000-0000-0000-000000000101",
"sourcePort": "00000000-0000-0000-0000-000000000111",
"targetNode": "00000000-0000-0000-0000-000000000104",
"targetPort": "00000000-0000-0000-0000-000000000121"
},
{
"id": "00000000-0000-0000-0000-00000000F002",
"sourceNode": "00000000-0000-0000-0000-000000000102",
"sourcePort": "00000000-0000-0000-0000-000000000112",
"targetNode": "00000000-0000-0000-0000-000000000104",
"targetPort": "00000000-0000-0000-0000-000000000122"
},
{
"id": "00000000-0000-0000-0000-00000000F003",
"sourceNode": "00000000-0000-0000-0000-000000000103",
"sourcePort": "00000000-0000-0000-0000-000000000113",
"targetNode": "00000000-0000-0000-0000-000000000104",
"targetPort": "00000000-0000-0000-0000-000000000123"
}
],
"devices": {
"00000000-0000-0000-0000-000000000101": {
"ip": "192.168.0.10",
"mask": "255.255.255.0"
},
"00000000-0000-0000-0000-000000000102": {
"ip": "169.254.1.2",
"mask": "255.255.0.0"
},
"00000000-0000-0000-0000-000000000103": {
"ip": "192.168.0.100",
"mask": "255.255.255.0"
}
},
"switches": {
"00000000-0000-0000-0000-000000000104": {
"ssid": "lab",
"retention": 5000
}
},
"steps": [
{
"op": "advance",
"to": 60000
}
],
"dhcpServers": {
"00000000-0000-0000-0000-000000000101": {
"active": true,
"lower": "192.168.0.100",
"upper": "192.168.0.100"
}
},
"dhcpClients": [
"00000000-0000-0000-0000-000000000102"
]
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,144 @@
{
"seed": 41,
"nodes": [
{
"id": "00000000-0000-0000-0000-000000000101",
"kind": "pc",
"ports": [
{
"id": "00000000-0000-0000-0000-000000000111",
"label": "eth0"
}
]
},
{
"id": "00000000-0000-0000-0000-000000000102",
"kind": "pc",
"ports": [
{
"id": "00000000-0000-0000-0000-000000000112",
"label": "eth0"
}
]
},
{
"id": "00000000-0000-0000-0000-000000000103",
"kind": "pc",
"ports": [
{
"id": "00000000-0000-0000-0000-000000000113",
"label": "eth0"
}
]
},
{
"id": "00000000-0000-0000-0000-000000000104",
"kind": "networkSwitch",
"ports": [
{
"id": "00000000-0000-0000-0000-000000000121",
"label": "sw1"
},
{
"id": "00000000-0000-0000-0000-000000000122",
"label": "sw2"
},
{
"id": "00000000-0000-0000-0000-000000000123",
"label": "sw3"
},
{
"id": "00000000-0000-0000-0000-000000000124",
"label": "sw4"
}
]
}
],
"links": [
{
"id": "00000000-0000-0000-0000-00000000F001",
"sourceNode": "00000000-0000-0000-0000-000000000101",
"sourcePort": "00000000-0000-0000-0000-000000000111",
"targetNode": "00000000-0000-0000-0000-000000000104",
"targetPort": "00000000-0000-0000-0000-000000000121"
},
{
"id": "00000000-0000-0000-0000-00000000F002",
"sourceNode": "00000000-0000-0000-0000-000000000102",
"sourcePort": "00000000-0000-0000-0000-000000000112",
"targetNode": "00000000-0000-0000-0000-000000000104",
"targetPort": "00000000-0000-0000-0000-000000000122"
},
{
"id": "00000000-0000-0000-0000-00000000F003",
"sourceNode": "00000000-0000-0000-0000-000000000103",
"sourcePort": "00000000-0000-0000-0000-000000000113",
"targetNode": "00000000-0000-0000-0000-000000000104",
"targetPort": "00000000-0000-0000-0000-000000000123"
}
],
"devices": {
"00000000-0000-0000-0000-000000000101": {
"ip": "192.168.0.10",
"mask": "255.255.255.0"
},
"00000000-0000-0000-0000-000000000102": {
"ip": "169.254.1.2",
"mask": "255.255.0.0"
},
"00000000-0000-0000-0000-000000000103": {
"ip": "169.254.1.3",
"mask": "255.255.0.0"
}
},
"switches": {
"00000000-0000-0000-0000-000000000104": {
"ssid": "lab",
"retention": 5000
}
},
"steps": [
{
"op": "advance",
"to": 20000
},
{
"op": "runDhcp",
"node": "00000000-0000-0000-0000-000000000102"
},
{
"op": "ping",
"from": "00000000-0000-0000-0000-000000000102",
"target": "192.168.0.10",
"identifier": 1,
"sequence": 1
},
{
"op": "ping",
"from": "00000000-0000-0000-0000-000000000101",
"target": "192.168.0.150",
"identifier": 2,
"sequence": 1
}
],
"dhcpServers": {
"00000000-0000-0000-0000-000000000101": {
"active": true,
"lower": "192.168.0.100",
"upper": "192.168.0.102",
"gateway": "192.168.0.1",
"dns": "192.168.0.53",
"own": true,
"staticAssignments": [
[
"02:00:00:00:01:13",
"192.168.0.150"
]
]
}
},
"dhcpClients": [
"00000000-0000-0000-0000-000000000102",
"00000000-0000-0000-0000-000000000103"
]
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,121 @@
{
"seed": 44,
"nodes": [
{
"id": "00000000-0000-0000-0000-000000000101",
"kind": "pc",
"ports": [
{
"id": "00000000-0000-0000-0000-000000000111",
"label": "eth0"
}
]
},
{
"id": "00000000-0000-0000-0000-000000000102",
"kind": "pc",
"ports": [
{
"id": "00000000-0000-0000-0000-000000000112",
"label": "eth0"
}
]
},
{
"id": "00000000-0000-0000-0000-000000000103",
"kind": "pc",
"ports": [
{
"id": "00000000-0000-0000-0000-000000000113",
"label": "eth0"
}
]
},
{
"id": "00000000-0000-0000-0000-000000000104",
"kind": "networkSwitch",
"ports": [
{
"id": "00000000-0000-0000-0000-000000000121",
"label": "sw1"
},
{
"id": "00000000-0000-0000-0000-000000000122",
"label": "sw2"
},
{
"id": "00000000-0000-0000-0000-000000000123",
"label": "sw3"
},
{
"id": "00000000-0000-0000-0000-000000000124",
"label": "sw4"
}
]
}
],
"links": [
{
"id": "00000000-0000-0000-0000-00000000F001",
"sourceNode": "00000000-0000-0000-0000-000000000101",
"sourcePort": "00000000-0000-0000-0000-000000000111",
"targetNode": "00000000-0000-0000-0000-000000000104",
"targetPort": "00000000-0000-0000-0000-000000000121"
},
{
"id": "00000000-0000-0000-0000-00000000F002",
"sourceNode": "00000000-0000-0000-0000-000000000102",
"sourcePort": "00000000-0000-0000-0000-000000000112",
"targetNode": "00000000-0000-0000-0000-000000000104",
"targetPort": "00000000-0000-0000-0000-000000000122"
},
{
"id": "00000000-0000-0000-0000-00000000F003",
"sourceNode": "00000000-0000-0000-0000-000000000103",
"sourcePort": "00000000-0000-0000-0000-000000000113",
"targetNode": "00000000-0000-0000-0000-000000000104",
"targetPort": "00000000-0000-0000-0000-000000000123"
}
],
"devices": {
"00000000-0000-0000-0000-000000000101": {
"ip": "192.168.0.10",
"mask": "255.255.255.0"
},
"00000000-0000-0000-0000-000000000102": {
"ip": "169.254.1.2",
"mask": "255.255.0.0"
},
"00000000-0000-0000-0000-000000000103": {
"ip": "192.168.0.30",
"mask": "255.255.255.0"
}
},
"switches": {
"00000000-0000-0000-0000-000000000104": {
"ssid": "lab",
"retention": 5000
}
},
"steps": [
{
"op": "advance",
"to": 10000
},
{
"op": "runDhcp",
"node": "00000000-0000-0000-0000-000000000102"
},
{
"op": "advance",
"to": 60000
},
{
"op": "runDhcp",
"node": "00000000-0000-0000-0000-000000000101"
}
],
"dhcpClients": [
"00000000-0000-0000-0000-000000000102"
]
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,125 @@
{
"seed": 42,
"nodes": [
{
"id": "00000000-0000-0000-0000-000000000101",
"kind": "pc",
"ports": [
{
"id": "00000000-0000-0000-0000-000000000111",
"label": "eth0"
}
]
},
{
"id": "00000000-0000-0000-0000-000000000102",
"kind": "pc",
"ports": [
{
"id": "00000000-0000-0000-0000-000000000112",
"label": "eth0"
}
]
},
{
"id": "00000000-0000-0000-0000-000000000103",
"kind": "pc",
"ports": [
{
"id": "00000000-0000-0000-0000-000000000113",
"label": "eth0"
}
]
},
{
"id": "00000000-0000-0000-0000-000000000104",
"kind": "networkSwitch",
"ports": [
{
"id": "00000000-0000-0000-0000-000000000121",
"label": "sw1"
},
{
"id": "00000000-0000-0000-0000-000000000122",
"label": "sw2"
},
{
"id": "00000000-0000-0000-0000-000000000123",
"label": "sw3"
},
{
"id": "00000000-0000-0000-0000-000000000124",
"label": "sw4"
}
]
}
],
"links": [
{
"id": "00000000-0000-0000-0000-00000000F001",
"sourceNode": "00000000-0000-0000-0000-000000000101",
"sourcePort": "00000000-0000-0000-0000-000000000111",
"targetNode": "00000000-0000-0000-0000-000000000104",
"targetPort": "00000000-0000-0000-0000-000000000121"
},
{
"id": "00000000-0000-0000-0000-00000000F002",
"sourceNode": "00000000-0000-0000-0000-000000000102",
"sourcePort": "00000000-0000-0000-0000-000000000112",
"targetNode": "00000000-0000-0000-0000-000000000104",
"targetPort": "00000000-0000-0000-0000-000000000122"
},
{
"id": "00000000-0000-0000-0000-00000000F003",
"sourceNode": "00000000-0000-0000-0000-000000000103",
"sourcePort": "00000000-0000-0000-0000-000000000113",
"targetNode": "00000000-0000-0000-0000-000000000104",
"targetPort": "00000000-0000-0000-0000-000000000123"
}
],
"devices": {
"00000000-0000-0000-0000-000000000101": {
"ip": "192.168.0.10",
"mask": "255.255.255.0",
"gateway": "192.168.0.254"
},
"00000000-0000-0000-0000-000000000102": {
"ip": "169.254.1.2",
"mask": "255.255.0.0"
},
"00000000-0000-0000-0000-000000000103": {
"ip": "192.168.0.30",
"mask": "255.255.255.0"
}
},
"switches": {
"00000000-0000-0000-0000-000000000104": {
"ssid": "lab",
"retention": 5000
}
},
"steps": [
{
"op": "advance",
"to": 20000
}
],
"dhcpServers": {
"00000000-0000-0000-0000-000000000101": {
"active": true,
"lower": "192.168.0.100",
"upper": "192.168.0.110"
},
"00000000-0000-0000-0000-000000000103": {
"active": true,
"lower": "192.168.0.200",
"upper": "192.168.0.210",
"own": true,
"gateway": "192.168.0.1",
"dns": "8.8.8.8"
}
},
"dhcpClients": [
"00000000-0000-0000-0000-000000000102"
]
}
File diff suppressed because it is too large Load Diff
@@ -13,6 +13,49 @@
"10.1.0.2" : "02:00:00:00:04:11"
}
},
"devices" : {
"00000000-0000-0000-0000-000000000401" : [
"10.1.0.2",
"255.255.255.0",
"10.1.0.1",
""
],
"00000000-0000-0000-0000-000000000402" : [
"10.1.0.1",
"255.255.255.0",
"",
""
],
"00000000-0000-0000-0000-000000000403" : [
"10.2.0.2",
"255.255.255.0",
"10.2.0.1",
""
],
"00000000-0000-0000-0000-000000000404" : [
"10.1.0.3",
"255.255.255.0",
"10.1.0.1",
""
]
},
"dhcp" : {
"blacklist" : {
},
"clients" : {
},
"leases" : [
],
"offers" : [
],
"servers" : {
}
},
"firewallDecisions" : [
{
"accepted" : false,
@@ -11,6 +11,43 @@
"20.0.0.1" : "02:00:00:00:02:22"
}
},
"devices" : {
"00000000-0000-0000-0000-000000000201" : [
"10.0.0.2",
"255.255.255.0",
"10.0.0.1",
""
],
"00000000-0000-0000-0000-000000000202" : [
"10.0.0.1",
"255.255.255.0",
"",
""
],
"00000000-0000-0000-0000-000000000203" : [
"20.0.0.2",
"255.255.255.0",
"20.0.0.1",
""
]
},
"dhcp" : {
"blacklist" : {
},
"clients" : {
},
"leases" : [
],
"offers" : [
],
"servers" : {
}
},
"firewallDecisions" : [
{
"accepted" : true,
@@ -15,6 +15,55 @@
"172.16.0.1" : "02:00:00:00:03:32"
}
},
"devices" : {
"00000000-0000-0000-0000-000000000301" : [
"192.168.1.10",
"255.255.255.0",
"192.168.1.1",
""
],
"00000000-0000-0000-0000-000000000302" : [
"192.168.1.1",
"255.255.255.0",
"",
""
],
"00000000-0000-0000-0000-000000000303" : [
"10.10.10.2",
"255.255.255.0",
"10.10.10.1",
""
],
"00000000-0000-0000-0000-000000000304" : [
"172.16.0.10",
"255.255.0.0",
"172.16.0.1",
""
],
"00000000-0000-0000-0000-000000000305" : [
"172.16.0.20",
"255.255.0.0",
"",
""
]
},
"dhcp" : {
"blacklist" : {
},
"clients" : {
},
"leases" : [
],
"offers" : [
],
"servers" : {
}
},
"firewallDecisions" : [
{
"accepted" : true,
@@ -7,6 +7,37 @@
"10.5.0.1" : "02:00:00:00:05:11"
}
},
"devices" : {
"00000000-0000-0000-0000-000000000501" : [
"10.5.0.1",
"255.255.255.0",
"",
""
],
"00000000-0000-0000-0000-000000000502" : [
"10.5.0.2",
"255.255.255.0",
"",
""
]
},
"dhcp" : {
"blacklist" : {
},
"clients" : {
},
"leases" : [
],
"offers" : [
],
"servers" : {
}
},
"firewallDecisions" : [
],
@@ -13,6 +13,43 @@
"192.168.0.20" : "02:00:00:00:01:12"
}
},
"devices" : {
"00000000-0000-0000-0000-000000000101" : [
"192.168.0.10",
"255.255.255.0",
"",
""
],
"00000000-0000-0000-0000-000000000102" : [
"192.168.0.20",
"255.255.255.0",
"",
""
],
"00000000-0000-0000-0000-000000000103" : [
"192.168.0.30",
"255.255.255.0",
"",
""
]
},
"dhcp" : {
"blacklist" : {
},
"clients" : {
},
"leases" : [
],
"offers" : [
],
"servers" : {
}
},
"firewallDecisions" : [
],
@@ -10,6 +10,43 @@
"192.168.0.20" : "02:00:00:00:01:12"
}
},
"devices" : {
"00000000-0000-0000-0000-000000000101" : [
"192.168.0.10",
"255.255.255.0",
"",
""
],
"00000000-0000-0000-0000-000000000102" : [
"192.168.0.20",
"255.255.255.0",
"",
""
],
"00000000-0000-0000-0000-000000000103" : [
"192.168.0.30",
"255.255.255.0",
"",
""
]
},
"dhcp" : {
"blacklist" : {
},
"clients" : {
},
"leases" : [
],
"offers" : [
],
"servers" : {
}
},
"firewallDecisions" : [
],
@@ -13,6 +13,43 @@
"192.168.0.20" : "02:00:00:00:01:12"
}
},
"devices" : {
"00000000-0000-0000-0000-000000000101" : [
"192.168.0.10",
"255.255.255.0",
"",
""
],
"00000000-0000-0000-0000-000000000102" : [
"192.168.0.20",
"255.255.255.0",
"",
""
],
"00000000-0000-0000-0000-000000000103" : [
"192.168.0.30",
"255.255.255.0",
"",
""
]
},
"dhcp" : {
"blacklist" : {
},
"clients" : {
},
"leases" : [
],
"offers" : [
],
"servers" : {
}
},
"firewallDecisions" : [
],
@@ -11,6 +11,43 @@
"20.0.0.1" : "02:00:00:00:02:22"
}
},
"devices" : {
"00000000-0000-0000-0000-000000000201" : [
"10.0.0.2",
"255.255.255.0",
"10.0.0.1",
""
],
"00000000-0000-0000-0000-000000000202" : [
"10.0.0.1",
"255.255.255.0",
"",
""
],
"00000000-0000-0000-0000-000000000203" : [
"20.0.0.2",
"255.255.255.0",
"20.0.0.1",
""
]
},
"dhcp" : {
"blacklist" : {
},
"clients" : {
},
"leases" : [
],
"offers" : [
],
"servers" : {
}
},
"firewallDecisions" : [
{
"accepted" : true,
@@ -11,6 +11,43 @@
"192.168.0.20" : "02:00:00:00:01:12"
}
},
"devices" : {
"00000000-0000-0000-0000-000000000101" : [
"192.168.0.10",
"255.255.255.0",
"",
""
],
"00000000-0000-0000-0000-000000000102" : [
"192.168.0.20",
"255.255.255.0",
"",
""
],
"00000000-0000-0000-0000-000000000103" : [
"192.168.0.30",
"255.255.255.0",
"",
""
]
},
"dhcp" : {
"blacklist" : {
},
"clients" : {
},
"leases" : [
],
"offers" : [
],
"servers" : {
}
},
"firewallDecisions" : [
],
@@ -11,6 +11,43 @@
"20.0.0.1" : "02:00:00:00:02:22"
}
},
"devices" : {
"00000000-0000-0000-0000-000000000201" : [
"10.0.0.2",
"255.255.255.0",
"10.0.0.1",
""
],
"00000000-0000-0000-0000-000000000202" : [
"10.0.0.1",
"255.255.255.0",
"",
""
],
"00000000-0000-0000-0000-000000000203" : [
"20.0.0.2",
"255.255.255.0",
"20.0.0.1",
""
]
},
"dhcp" : {
"blacklist" : {
},
"clients" : {
},
"leases" : [
],
"offers" : [
],
"servers" : {
}
},
"firewallDecisions" : [
{
"accepted" : true,
+5 -1
View File
@@ -24,7 +24,11 @@ npm test # vergleicht mit den geschriebenen *.swift.json
`closeSocket`; TCP: `openTcpServer`, `openTcpClient`, `connectTcp`
(Ergebnis `connected`, `invalidSocket`, `unreachable`, `timedOut`),
`sendTcp` (mit `repeatCount` für lange Nachrichten), `receiveTcp`,
`acceptedTcp`, `peerTcp`, `cleanTcp`; `closeSocket` schließt auch TCP.
`acceptedTcp`, `peerTcp`, `cleanTcp`; `closeSocket` schließt auch TCP;
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).
Das Ergebnis enthält die Sockets mit der Länge ihrer Warteschlange, dem
TCP-Zustand, dem annehmenden Socket und den Sitzungswerten.
@@ -42,6 +42,12 @@ struct ScenarioStep: Decodable {
// TCP: the payload repeated this many times, for messages longer than a segment.
let repeatCount: Int?; let localPort: UInt16?
}
struct ScenarioDHCPServer: Decodable {
let active: Bool?; let lower: String?; let upper: String?
let gateway: String?; let dns: String?; let own: Bool?
/// Pairs of MAC and IP address.
let staticAssignments: [[String]]?
}
struct Scenario: Decodable {
let seed: UInt64
let speedPercent: Int?
@@ -52,6 +58,8 @@ struct Scenario: Decodable {
let routes: [String: [ScenarioRoute]]?
let switches: [String: ScenarioSwitch]?
let firewalls: [String: ScenarioFirewall]?
let dhcpServers: [String: ScenarioDHCPServer]?
let dhcpClients: [String]?
let steps: [ScenarioStep]
}
@@ -138,10 +146,26 @@ func snapshot(of scenario: Scenario) -> TopologyNetworkRuntimeTopologySnapshot {
}
)
}
var dhcpServers: [UUID: TopologyDHCPServerConfiguration] = [:]
for (node, config) in scenario.dhcpServers ?? [:] {
dhcpServers[uuid(node)] = TopologyDHCPServerConfiguration(
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 {
TopologyDHCPStaticAssignment(macAddress: $0[0], ipAddress: $0[1])
}
)
}
var dhcpClients: [UUID: TopologyDHCPClientConfiguration] = [:]
for node in scenario.dhcpClients ?? [] { dhcpClients[uuid(node)] = TopologyDHCPClientConfiguration(isEnabled: true) }
return TopologyNetworkRuntimeTopologySnapshot(
nodes: nodes, links: links,
deviceConfigurations: devices, interfaceConfigurations: interfaces,
manualRoutesByNodeID: routes,
dhcpClientConfigurationsByNodeID: dhcpClients,
dhcpServerConfigurationsByNodeID: dhcpServers,
firewallConfigurationsByNodeID: firewalls,
switchConfigurationsByNodeID: switches
)
@@ -194,6 +218,14 @@ func observation(_ item: TopologyRuntimeICMPObservation) -> JSON {
]
}
func dhcpStatus(_ status: TopologyRuntimeDHCPClientStatus) -> JSON {
[
"state": status.state.rawValue, "errors": status.errorCount,
"server": status.selectedServerIPAddress ?? NSNull(), "offered": status.offeredIPAddress ?? NSNull(),
"succeeded": status.succeeded,
]
}
func run(_ scenario: Scenario) -> JSON {
var engine = TopologyNetworkRuntimeEngine(seed: scenario.seed)
if let percent = scenario.speedPercent { engine.setSimulationSpeed(percent: percent) }
@@ -269,6 +301,8 @@ func run(_ scenario: Scenario) -> JSON {
results.append(engine.peerTCPSocketID(socketID: uuid(step.socket!))?.uuidString ?? NSNull())
case "cleanTcp":
results.append(engine.cleanClosedTCPSocket(socketID: uuid(step.socket!)))
case "runDhcp":
results.append(engine.runDHCPClient(nodeID: uuid(step.node!)).map(dhcpStatus) ?? NSNull())
case "closeSocket":
engine.closeSocket(socketID: uuid(step.socket!))
results.append(engine.state.socketsByID[uuid(step.socket!)] == nil)
@@ -298,6 +332,22 @@ func run(_ scenario: Scenario) -> JSON {
"arp": arp,
"switchTables": switchTables,
"icmpObservations": observations,
"devices": Dictionary(uniqueKeysWithValues: state.topologySnapshot.deviceConfigurations.map { node, device in
(node.uuidString, [device.ipAddress, device.subnetMask, device.defaultGateway, device.dnsServer])
}),
"dhcp": [
"clients": Dictionary(uniqueKeysWithValues: state.dhcpClientStatusesByNodeID.map { ($0.key.uuidString, dhcpStatus($0.value)) }),
"leases": state.dhcpLeasesByIPAddress.sorted { $0.key < $1.key }.map { key, lease -> [Any] in
[key, lease.clientMACAddress, lease.ipAddress, lease.expiresAtMilliseconds.map { $0 as Any } ?? NSNull()]
},
"offers": state.dhcpOffersByIPAddress.sorted { $0.key < $1.key }.map { key, offer -> [Any] in
[key, offer.clientMACAddress, offer.expiresAtMilliseconds]
},
"blacklist": Dictionary(uniqueKeysWithValues: state.dhcpBlacklistByServerNodeID.map { node, entries in
(node.uuidString, entries.sorted { $0.key < $1.key }.map { [$0.key, $0.value] as [Any] })
}),
"servers": Dictionary(uniqueKeysWithValues: state.dhcpServerSocketIDsByNodeID.map { ($0.key.uuidString, $0.value.uuidString) }),
] as JSON,
"sockets": state.socketsByID.values.sorted { $0.id.uuidString < $1.id.uuidString }.map { socket -> JSON in
[
"id": socket.id.uuidString, "node": socket.nodeID.uuidString, "protocol": socket.protocolKind.rawValue,