// Ported from filius-ipad (GPLv2/GPLv3): // State/TopologyNetworkRuntimeEngine.swift // "Runtime snapshots and transient state" (lines ~313–938) // "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; the // timing and closing after Java's TCPSocket, measured with Java // FILIUS: 3 round trips per attempt and for TIME_WAIT, the other end // closes by itself on FIN, failed data closes with FIN) // "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) // A cable carries one frame after the other, as Java's SimplexVerbindung // (Swift: all at once; the option swiftCables keeps that for the comparison). // ARP after Java's holeARPTabellenEintrag: a lookup asks twice, a round trip // each, then host unreachable (Swift: all at once, or without end when the // address exists anywhere; the option swiftArp keeps that for the comparison). // A send hands its frame to the card and returns, as in Java; the frames // travel while the clock advances (Swift: the send returns when every frame // in flight has arrived; the option swiftSends keeps that for the comparison). // Java's transport send thread: per node and protocol, one segment after the // other; while IP waits for ARP, a socket's next send blocks. // DNS after Java (filius.software.dns: DNSServer, DNSServerMitarbeiter, // Resolver, DNSQueryAgent), not Swift's typed format; recursion not yet. // Java counterparts: filius.hardware.Verbindung, filius.software.netzzugangsschicht, // filius.software.vermittlungsschicht (ARP, IP, ICMP), system.SwitchFirmware, // filius.software.transportschicht (UDP, TCP), filius.software.dhcp. // // 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 // delay is an event in the queue, ordered by deadline and then by insertion. // 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. // // Not ported yet — each place throws NotPortedError instead of guessing: // gateway NAT, RIP routing, remote links, recursive DNS resolution. 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, interfaceKey, } from './config.js'; import { maskAddress, parseIPv4, sameSubnet } from './ipv4.js'; import { forwardingClone, icmpMessage, IPV4_PROTOCOL, packetIdentityInFrame, TCP_FLAG, type ArpPacket, type EthernetFrame, type IcmpMessage, type IcmpMessageKind, type IPv4Packet, type TcpSegment, type TcpSocketState, type UdpDatagram, } from './packets.js'; import { decodeDnsMessage, DNS_PORT, DNS_QUERY, DNS_RESPONSE, encodeDnsMessage, findApplicableNameServerRecord, findDnsRecords, messageRecord, type DnsMessage, type DnsMessageRecord, type DnsQuestion, } from './dns.js'; import { JavaCompatibleRandom } from './random.js'; import { routeTableRows, type RouteRow } from './routeTable.js'; import { EMPTY_TOPOLOGY_SNAPSHOT, withSnapshot, type NodeSnapshot, type TopologySnapshot } from './snapshot.js'; import { RuntimeSpeed } from './speed.js'; import { PRODUCTION_TRACE_RETENTION, type HeaderField, type TraceDirection, type TraceEvent, type TraceLayer, type TraceOperation, type TraceRetentionPolicy, } from './trace.js'; export const ETHERNET_BROADCAST_MAC_ADDRESS = 'FF:FF:FF:FF:FF:FF'; export const UNSPECIFIED_MAC_ADDRESS = '00:00:00:00:00:00'; export const LIMITED_BROADCAST_IP_ADDRESS = '255.255.255.255'; export const UNSPECIFIED_IP_ADDRESS = '0.0.0.0'; export const LOCALHOST_IP_ADDRESS = '127.0.0.1'; /** The seed a simulation uses unless told otherwise. */ export const DEFAULT_SEED = 0xf11a5n; /** A part of the Swift engine that has not been carried over yet. */ export class NotPortedError extends Error { constructor(feature: string) { super(`Not ported yet: ${feature}`); this.name = 'NotPortedError'; } } // ── Events, inputs, outputs ──────────────────────────────────────────────── export type ScheduledEventKind = | { readonly kind: 'parityMarker'; readonly label: string } | { readonly kind: 'ripBeacon'; readonly nodeId: Id } | { readonly kind: 'dhcpClientStart'; readonly nodeId: Id } | { readonly kind: 'dhcpTimeout'; readonly nodeId: Id } /** An ARP lookup waited a round trip for its answer. */ | { readonly kind: 'arpTimeout'; readonly lookupId: number } /** The lookups waiting for an address wake up, after what else happens at that time. */ | { readonly kind: 'arpAnswered'; readonly nodeId: Id; readonly ipAddress: string; readonly macAddress: string } | { readonly kind: 'tcpTimeout'; readonly sessionId: Id } | { /** A frame that waited in the port's output buffer leaves: the cable was busy. */ readonly kind: 'ethernetLinkDeparture'; readonly sourceNodeId: Id; readonly outgoingPortId: Id; readonly targetNodeId: Id; readonly targetPortId: Id; readonly frame: EthernetFrame; } | { readonly kind: 'ethernetLinkDelivery'; readonly sourceNodeId: Id; readonly targetNodeId: Id; readonly targetPortId: Id; readonly latencyMilliseconds: number; readonly frame: EthernetFrame; } | { readonly kind: 'remoteLinkDelivery'; readonly sourceNodeId: Id; readonly partnerNodeId: Id; readonly partnerPortId: Id; readonly pairIdentifier: string; readonly latencyMilliseconds: number; readonly frame: EthernetFrame; } | { readonly kind: 'lanRemoteLinkTransmission'; readonly nodeId: Id; readonly pairIdentifier: string; readonly latencyMilliseconds: number; readonly frame: EthernetFrame; } | { readonly kind: 'natExpirySweep' }; export interface ScheduledEvent { readonly id: number; readonly deadlineMilliseconds: number; readonly sequenceNumber: number; readonly kind: ScheduledEventKind; } export type CompatibilityOperationKind = 'terminalCommand' | 'applicationOperation' | 'socketOperation'; export interface CompatibilityOperation { readonly kind: CompatibilityOperationKind; readonly nodeId: Id; readonly interfaceId?: Id; readonly detail: string; } export type RuntimeInput = | { readonly type: 'start'; readonly snapshot: TopologySnapshot; readonly seed: bigint | number; readonly initialTimeMilliseconds: number; } | { readonly type: 'stop' } | { readonly type: 'advance'; readonly toMilliseconds: number } | { readonly type: 'schedule'; readonly deadlineMilliseconds: number; readonly kind: ScheduledEventKind } | { readonly type: 'compatibilityOperation'; readonly operation: CompatibilityOperation }; export type RuntimeOutputKind = | { readonly type: 'started' } | { readonly type: 'startIgnoredAlreadyRunning' } | { readonly type: 'stopped' } | { readonly type: 'stopIgnoredAlreadyStopped' } | { readonly type: 'advanced'; readonly fromMilliseconds: number; readonly toMilliseconds: number } | { readonly type: 'advanceIgnoredWhileStopped' } | { readonly type: 'advanceRejectedPastTime' } | { readonly type: 'scheduleRejectedPastTime'; readonly deadlineMilliseconds: number } | { readonly type: 'scheduled'; readonly event: ScheduledEvent } | { readonly type: 'fired'; readonly event: ScheduledEvent } | { readonly type: 'compatibilityOperationRecorded'; readonly operation: CompatibilityOperation }; export interface RuntimeOutput { readonly timeMilliseconds: number; readonly kind: RuntimeOutputKind; } /** A named, seeded list of events to replay without any user interface. */ export interface RuntimeScenario { readonly name: string; readonly seed: bigint | number; readonly advanceToMilliseconds: number; readonly scheduledEvents: readonly { deadlineMilliseconds: number; kind: ScheduledEventKind }[]; } // ── Runtime state ────────────────────────────────────────────────────────── export interface ArpCacheEntry { readonly ipAddress: string; readonly macAddress: string; readonly updatedAtMilliseconds: number; } export interface SwitchSatEntry { readonly macAddress: string; readonly portId: Id; readonly portLabel: string; readonly updatedAtMilliseconds: number; } /** An interface of a node that has an IP configuration. */ export interface NetworkInterface { readonly nodeId: Id; readonly portId: Id; readonly index: number; readonly ipAddress: string; readonly subnetMask: string; readonly defaultGateway: string; readonly macAddress: string; } export interface DeliveredIPv4Packet { readonly nodeId: Id; readonly interfaceId: Id; readonly packet: IPv4Packet; } export interface IcmpObservation { readonly nodeId: Id; readonly interfaceId?: Id; readonly packet: IPv4Packet; readonly message: IcmpMessage; } export type IPv4DeliveryResult = | { readonly type: 'delivered'; readonly packetIdentity: number; readonly nodeId: Id } | { readonly type: 'icmpError'; readonly packetIdentity: number; readonly nodeId: Id; readonly kind: IcmpMessageKind; } | { readonly type: 'dropped'; readonly packetIdentity: number; readonly nodeId?: Id }; export interface FirewallDecision { readonly packetIdentity: number; readonly nodeId: Id; readonly accepted: boolean; readonly ruleIndex?: number; } export type RuntimePhase = 'stopped' | 'running'; export type SocketProtocol = 'udp' | 'tcp'; /** Swift's TopologyRuntimeSocketRecord. */ export interface SocketRecord { readonly id: Id; readonly nodeId: Id; readonly protocol: SocketProtocol; readonly localIPAddress: string | undefined; /** 0…65535 */ readonly localPort: number; remoteIPAddress: string | undefined; remotePort: number | undefined; /** TCP only. */ tcpState: TcpSocketState | undefined; /** A socket a TCP listener accepted: that listener. */ readonly parentListenerSocketId: Id | undefined; } /** Swift's TopologyRuntimeTCPSessionRecord; sequence numbers are unsigned 32-bit. */ export interface TcpSession { readonly id: Id; readonly socketId: Id; state: TcpSocketState; sendAttempts: number; nextSendSequenceNumber: number; lastSentSequenceNumber: number; remoteSequenceNumber: number; lastAcknowledgedSequenceNumber: number | undefined; lastSentSegment: TcpSegment | undefined; receiveAssembly: Uint8Array; receivedMessages: Uint8Array[]; } export type TcpConnectionResult = 'connected' | 'invalidSocket' | 'unreachable' | 'timedOut'; /** Java's DNSQueryAgent waits five round trips for an answer. */ export const DNS_QUERY_TIMEOUT_ROUND_TRIPS = 5; export type DnsResolution = { readonly type: 'answered'; readonly records: readonly DnsMessageRecord[] } | { readonly type: 'timedOut' }; export type DnsAddressResolution = { readonly type: 'resolved'; readonly address: string | undefined } | { readonly type: 'timedOut' }; export const DHCP_SERVER_PORT = 67; export const DHCP_CLIENT_PORT = 68; /** Java: an offer, and a blacklisted address, is kept 4 round trip times. */ export const DHCP_OFFER_LIFETIME_ROUND_TRIPS = 4; export const DHCP_DYNAMIC_LEASE_LIFETIME_MILLISECONDS = 24 * 60 * 60 * 1_000; /** 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']; /** 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; } /** 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; selectedOffer: DhcpMessage | undefined; linkLocalIPAddress: string | undefined; waiting: DhcpClientWait | 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_EPHEMERAL_PORT_LOWER_BOUND = 49_152; export const TCP_EPHEMERAL_PORT_UPPER_BOUND_EXCLUSIVE = 65_535; /** Addition modulo 2^32, as Swift's `&+` on UInt32. */ function uint32Add(value: number, addend: number): number { return (value + addend) >>> 0; } /** A datagram waiting in a UDP socket's queue. */ export interface UdpReceivedDatagram { readonly senderIPAddress: string; readonly receiverIPAddress: string; readonly datagram: UdpDatagram; readonly receivedAtMilliseconds: number; } export const UDP_EPHEMERAL_PORT_LOWER_BOUND = 49_152; export const UDP_EPHEMERAL_PORT_UPPER_BOUND_EXCLUSIVE = 65_535; export const RIP_DESTINATION_PORT = 520; export const RIP_SOURCE_PORT = 521; /** Swift's deterministicSocketID: the sequence number as the UUID's last twelve hex digits. */ export function deterministicSocketId(sequence: number): Id { return `00000000-0000-0000-0000-${sequence.toString(16).toUpperCase().padStart(12, '0')}`; } export interface RuntimeState { phase: RuntimePhase; currentTimeMilliseconds: number; simulationSpeedPercent: number; globalPacketLossEnabled: boolean; seed: bigint; topologySnapshot: TopologySnapshot; pendingEvents: ScheduledEvent[]; packetTraces: TraceEvent[]; discardedPacketTraceCount: number; arpCachesByNodeId: Map>; switchForwardingTablesByNodeId: Map>; switchForwardingUpdatedAtMillisecondsByNodeId: Map>; switchSeenFrameIdentitiesByNodeId: Map>; deliveredIPv4PacketsByNodeId: Map; icmpObservationsByNodeId: Map; firewallDecisions: FirewallDecision[]; socketsById: Map; udpReceiveQueuesBySocketId: Map; tcpSessionsById: Map; tcpAcceptedSocketIdsByListenerId: Map; dhcpOffersByIPAddress: Map; dhcpLeasesByIPAddress: Map; dhcpBlacklistByServerNodeId: Map>; dhcpLastOfferedAddressByServerNodeId: Map; dhcpServerSocketIdsByNodeId: Map; /** Each DNS server's socket on port 53. */ dnsServerSocketIdsByNodeId: Map; dhcpClientStatusesByNodeId: Map; dhcpClientContextsByNodeId: Map; nextSocketSequenceNumber: number; nextTcpInitialSequenceValue: number; nextEventSequenceNumber: number; nextFrameIdentity: number; nextPacketIdentity: number; nextTraceIdentity: number; } function initialState(seed: bigint): RuntimeState { return { phase: 'stopped', currentTimeMilliseconds: 0, simulationSpeedPercent: new RuntimeSpeed().percent, globalPacketLossEnabled: false, seed, topologySnapshot: EMPTY_TOPOLOGY_SNAPSHOT, pendingEvents: [], packetTraces: [], discardedPacketTraceCount: 0, arpCachesByNodeId: new Map(), switchForwardingTablesByNodeId: new Map(), switchForwardingUpdatedAtMillisecondsByNodeId: new Map(), switchSeenFrameIdentitiesByNodeId: new Map(), deliveredIPv4PacketsByNodeId: new Map(), icmpObservationsByNodeId: new Map(), firewallDecisions: [], socketsById: new Map(), udpReceiveQueuesBySocketId: new Map(), tcpSessionsById: new Map(), tcpAcceptedSocketIdsByListenerId: new Map(), dhcpOffersByIPAddress: new Map(), dhcpLeasesByIPAddress: new Map(), dhcpBlacklistByServerNodeId: new Map(), dhcpLastOfferedAddressByServerNodeId: new Map(), dhcpServerSocketIdsByNodeId: new Map(), dnsServerSocketIdsByNodeId: new Map(), dhcpClientStatusesByNodeId: new Map(), dhcpClientContextsByNodeId: new Map(), nextSocketSequenceNumber: 1, nextTcpInitialSequenceValue: 1, nextEventSequenceNumber: 0, nextFrameIdentity: 1, nextPacketIdentity: 1, nextTraceIdentity: 1, }; } export interface TraceInput { frameIdentity?: number | undefined; packetIdentity?: number | undefined; nodeId: Id; interfaceId?: Id | undefined; direction: TraceDirection; layer: TraceLayer; operation: TraceOperation; beforeHeaders?: readonly HeaderField[]; afterHeaders?: readonly HeaderField[]; detail?: string | undefined; } type PacketRoute = | { readonly type: 'local'; readonly interface: NetworkInterface | undefined } | { readonly type: 'broadcast' } | { readonly type: 'egress'; readonly interface: NetworkInterface; readonly nextHop: string } | { readonly type: 'unavailable' }; /** Java's IP asks ARP this many times, a round trip each (holeARPTabellenEintrag(ziel, 2, …)). */ export const ARP_ATTEMPTS = 2; interface ArpLookup { readonly id: number; readonly nodeId: Id; readonly ipAddress: string; readonly networkInterface: NetworkInterface; attemptsLeft: number; /** Swift, for the comparison: a lookup made while events run waits in a list emptied by the answer itself. */ readonly swiftQueued: boolean; readonly done: (macAddress: string | undefined) => void; } /** * Java's TransportProtokoll send thread, one per node and protocol: it takes * the segments one after the other and hands each to IP, which waits there * for ARP. */ interface TransportSender { busy: boolean; /** How many segments it has taken, and how many are out. */ started: number; finished: number; readonly queue: ((finished: () => void) => void)[]; } /** What a sender learns when its packet has left, or when it could not. */ interface IPv4SendCallbacks { /** The packet left (or failed) — at once, or when ARP has answered or given up. */ readonly onFinished?: (result: IPv4DeliveryResult) => void; /** ARP found no one for the next hop (Java: the sender sends host unreachable). */ readonly onUnresolved?: () => void; } function toSeed(seed: bigint | number): bigint { return BigInt.asUintN(64, BigInt(seed)); } /** A departure waits for the delivery of the frame before it, so draining the deliveries reaches it. */ function isNetworkDeliveryEvent(kind: ScheduledEventKind): boolean { return ( kind.kind === 'ethernetLinkDeparture' || kind.kind === 'ethernetLinkDelivery' || kind.kind === 'remoteLinkDelivery' || kind.kind === 'arpAnswered' ); } function getOrCreate(map: Map, key: K, create: () => V): V { let value = map.get(key); if (value === undefined) { value = create(); map.set(key, value); } return value; } // ── Engine ───────────────────────────────────────────────────────────────── export class NetworkRuntimeEngine { private stateValue: RuntimeState; private random: JavaCompatibleRandom; private udpPortRandom: JavaCompatibleRandom; private tcpPortRandom: JavaCompatibleRandom; private linkLocalRandom: JavaCompatibleRandom; private dnsIdRandom: JavaCompatibleRandom; private runtimeSpeed: RuntimeSpeed; private readonly packetTraceRetentionPolicy: TraceRetentionPolicy; private isProcessingScheduledEvents = false; /** ARP lookups waiting for their answer (Java: a thread in holeARPTabellenEintrag). */ private arpLookups: ArpLookup[] = []; private nextArpLookupId = 1; /** Per node and transport protocol (`nodeId/udp`, `nodeId/tcp`). */ private transportSenders = new Map(); /** Per cable end (node and port): when the last frame sent there arrives. */ private cableBusyUntilByPort = new Map(); private readonly cablesCarryOneFrameAtATime: boolean; private readonly swiftArp: boolean; private readonly swiftSends: boolean; /** * `swiftCables`: a cable carries all frames at once, as in the Swift * engine — only for the comparison with Swift. Java's cable carries one * frame after the other (SimplexVerbindung sleeps the delay with the port's * buffer locked); a frame waits in the output buffer until the one before * has arrived, and shows in the packet log when it leaves. * * `swiftArp`, `swiftSends`: Swift's ARP and Swift's send that returns * when the network is quiet, likewise only for the comparison. */ constructor( seed: bigint | number = DEFAULT_SEED, packetTraceRetentionPolicy: TraceRetentionPolicy = PRODUCTION_TRACE_RETENTION, options: { readonly swiftCables?: boolean; readonly swiftArp?: boolean; readonly swiftSends?: boolean } = {}, ) { this.cablesCarryOneFrameAtATime = options.swiftCables !== true; this.swiftArp = options.swiftArp === true; this.swiftSends = options.swiftSends === true; const normalizedSeed = toSeed(seed); this.stateValue = initialState(normalizedSeed); 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.dnsIdRandom = new JavaCompatibleRandom(normalizedSeed ^ 0x444e_5349n); this.runtimeSpeed = new RuntimeSpeed(); this.packetTraceRetentionPolicy = packetTraceRetentionPolicy; this.stateValue.simulationSpeedPercent = this.runtimeSpeed.percent; } /** The runtime state. Read it; change it only through the engine's methods. */ get state(): RuntimeState { return this.stateValue; } get simulationSpeed(): RuntimeSpeed { return this.runtimeSpeed; } setSimulationSpeed(percent: number): void { this.runtimeSpeed = new RuntimeSpeed(percent); this.stateValue.simulationSpeedPercent = this.runtimeSpeed.percent; } setGlobalPacketLossEnabled(enabled: boolean): void { this.stateValue.globalPacketLossEnabled = this.stateValue.phase === 'running' && enabled; } handle(input: RuntimeInput): RuntimeOutput[] { switch (input.type) { case 'start': { if (this.stateValue.phase !== 'stopped') return [this.output({ type: 'startIgnoredAlreadyRunning' })]; const seed = toSeed(input.seed); this.stateValue = initialState(seed); this.stateValue.phase = 'running'; this.stateValue.currentTimeMilliseconds = input.initialTimeMilliseconds; this.stateValue.simulationSpeedPercent = this.runtimeSpeed.percent; this.stateValue.topologySnapshot = input.snapshot; 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.dnsIdRandom = new JavaCompatibleRandom(seed ^ 0x444e_5349n); this.isProcessingScheduledEvents = false; this.cableBusyUntilByPort.clear(); this.initializeRipState(); this.initializeDhcpState(); this.initializeDnsState(); this.initializeNatState(); return [this.output({ type: 'started' })]; } case 'stop': { if (this.stateValue.phase !== 'running') return [this.output({ type: 'stopIgnoredAlreadyStopped' })]; const state = this.stateValue; state.phase = 'stopped'; state.globalPacketLossEnabled = false; this.isProcessingScheduledEvents = false; this.arpLookups = []; this.transportSenders.clear(); state.pendingEvents = []; state.arpCachesByNodeId.clear(); state.switchForwardingTablesByNodeId.clear(); state.switchForwardingUpdatedAtMillisecondsByNodeId.clear(); state.switchSeenFrameIdentitiesByNodeId.clear(); state.deliveredIPv4PacketsByNodeId.clear(); state.icmpObservationsByNodeId.clear(); state.socketsById.clear(); state.udpReceiveQueuesBySocketId.clear(); state.tcpSessionsById.clear(); state.tcpAcceptedSocketIdsByListenerId.clear(); this.clearDhcpState(); state.dnsServerSocketIdsByNodeId.clear(); state.firewallDecisions = []; return [this.output({ type: 'stopped' })]; } case 'advance': return this.advance(input.toMilliseconds); case 'schedule': { if (this.stateValue.phase !== 'running') return [this.output({ type: 'advanceIgnoredWhileStopped' })]; if (input.deadlineMilliseconds < this.stateValue.currentTimeMilliseconds) { return [ this.output({ type: 'scheduleRejectedPastTime', deadlineMilliseconds: input.deadlineMilliseconds }), ]; } const event = this.schedule(input.deadlineMilliseconds, input.kind); return [this.output({ type: 'scheduled', event })]; } case 'compatibilityOperation': if (this.stateValue.phase !== 'running') return [this.output({ type: 'advanceIgnoredWhileStopped' })]; this.recordCompatibilityTrace(input.operation); return [this.output({ type: 'compatibilityOperationRecorded', operation: input.operation })]; } } nextRipJitterMilliseconds(): number { return this.random.nextRipJitterMilliseconds(); } allocateFrameIdentity(): number { return this.stateValue.nextFrameIdentity++; } allocatePacketIdentity(): number { return this.stateValue.nextPacketIdentity++; } recordTrace(input: TraceInput): void { const state = this.stateValue; const trace: TraceEvent = { id: state.nextTraceIdentity, timeMilliseconds: state.currentTimeMilliseconds, ...(input.frameIdentity === undefined ? {} : { frameIdentity: input.frameIdentity }), ...(input.packetIdentity === undefined ? {} : { packetIdentity: input.packetIdentity }), nodeId: input.nodeId, ...(input.interfaceId === undefined ? {} : { interfaceId: input.interfaceId }), direction: input.direction, layer: input.layer, operation: input.operation, beforeHeaders: input.beforeHeaders ?? [], afterHeaders: input.afterHeaders ?? [], ...(input.detail === undefined ? {} : { detail: input.detail }), }; state.nextTraceIdentity += 1; if (state.packetTraces.length >= this.packetTraceRetentionPolicy.maximumEventCount) { const evictionCount = Math.min(this.packetTraceRetentionPolicy.evictionBatchSize, state.packetTraces.length); state.packetTraces.splice(0, evictionCount); state.discardedPacketTraceCount += evictionCount; } state.packetTraces.push(trace); } static run(scenario: RuntimeScenario, snapshot: TopologySnapshot = EMPTY_TOPOLOGY_SNAPSHOT): RuntimeState { const engine = new NetworkRuntimeEngine(scenario.seed); engine.handle({ type: 'start', snapshot, seed: scenario.seed, initialTimeMilliseconds: 0 }); for (const scheduled of scenario.scheduledEvents) { engine.handle({ type: 'schedule', deadlineMilliseconds: scheduled.deadlineMilliseconds, kind: scheduled.kind }); } engine.handle({ type: 'advance', toMilliseconds: scenario.advanceToMilliseconds }); return engine.state; } private schedule(deadlineMilliseconds: number, kind: ScheduledEventKind): ScheduledEvent { const state = this.stateValue; const event: ScheduledEvent = { id: state.nextEventSequenceNumber, deadlineMilliseconds, sequenceNumber: state.nextEventSequenceNumber, kind, }; state.nextEventSequenceNumber += 1; state.pendingEvents.push(event); return event; } private advance(targetTimeMilliseconds: number): RuntimeOutput[] { const state = this.stateValue; if (state.phase !== 'running') return [this.output({ type: 'advanceIgnoredWhileStopped' })]; if (targetTimeMilliseconds < state.currentTimeMilliseconds) { return [this.output({ type: 'advanceRejectedPastTime' })]; } const startTime = state.currentTimeMilliseconds; const outputs: RuntimeOutput[] = []; const wasProcessingScheduledEvents = this.isProcessingScheduledEvents; this.isProcessingScheduledEvents = true; try { for (;;) { const nextEventIndex = this.nextDueEventIndex(targetTimeMilliseconds); if (nextEventIndex === undefined) break; const [event] = this.stateValue.pendingEvents.splice(nextEventIndex, 1) as [ScheduledEvent]; this.stateValue.currentTimeMilliseconds = event.deadlineMilliseconds; this.ageAllSwitchSatEntries(); this.expireRipRoutes(); this.processScheduledRuntimeEvent(event.kind); outputs.push(this.output({ type: 'fired', event })); } this.stateValue.currentTimeMilliseconds = targetTimeMilliseconds; this.ageAllSwitchSatEntries(); this.expireRipRoutes(); outputs.push( this.output({ type: 'advanced', fromMilliseconds: startTime, toMilliseconds: targetTimeMilliseconds }), ); return outputs; } finally { this.isProcessingScheduledEvents = wasProcessingScheduledEvents; } } private nextDueEventIndex(targetTimeMilliseconds: number): number | undefined { let best: number | undefined; const events = this.stateValue.pendingEvents; for (let index = 0; index < events.length; index += 1) { const candidate = events[index]!; if (candidate.deadlineMilliseconds > targetTimeMilliseconds) continue; if (best === undefined) { best = index; continue; } const current = events[best]!; if ( candidate.deadlineMilliseconds < current.deadlineMilliseconds || (candidate.deadlineMilliseconds === current.deadlineMilliseconds && candidate.sequenceNumber < current.sequenceNumber) ) { best = index; } } return best; } private recordCompatibilityTrace(operation: CompatibilityOperation): void { this.recordTrace({ nodeId: operation.nodeId, interfaceId: operation.interfaceId, direction: 'local', layer: 'application', operation: 'compatibilityAdapter', afterHeaders: [{ name: 'kind', value: operation.kind }], detail: operation.detail, }); } private output(kind: RuntimeOutputKind): RuntimeOutput { return { timeMilliseconds: this.stateValue.currentTimeMilliseconds, kind }; } private processScheduledRuntimeEvent(kind: ScheduledEventKind): void { switch (kind.kind) { case 'ethernetLinkDeparture': this.departEthernetFrame(kind.sourceNodeId, kind.outgoingPortId, { nodeId: kind.targetNodeId, portId: kind.targetPortId }, kind.frame); return; case 'ethernetLinkDelivery': if (this.dropForGlobalPacketLoss(kind.frame, kind.targetNodeId, kind.targetPortId, 'inbound', 'physical')) { return; } this.receiveEthernetFrame(kind.frame, kind.targetNodeId, kind.targetPortId); return; case 'parityMarker': return; case 'ripBeacon': throw new NotPortedError('RIP beacons'); case 'dhcpClientStart': this.runDhcpClient(kind.nodeId); return; case 'dhcpTimeout': this.handleDhcpClientTimeout(kind.nodeId); return; case 'arpTimeout': this.handleArpTimeout(kind.lookupId); return; case 'arpAnswered': this.completeArpLookups(kind.nodeId, kind.ipAddress, kind.macAddress); return; case 'tcpTimeout': this.handleTcpTimeout(kind.sessionId); return; case 'remoteLinkDelivery': case 'lanRemoteLinkTransmission': throw new NotPortedError('remote links'); case 'natExpirySweep': throw new NotPortedError('gateway NAT'); } } private dropForGlobalPacketLoss( frame: EthernetFrame, nodeId: Id, interfaceId: Id | undefined, direction: TraceDirection, layer: TraceLayer, ): boolean { if (!this.stateValue.globalPacketLossEnabled) return false; this.recordTrace({ frameIdentity: frame.identity, packetIdentity: packetIdentityInFrame(frame), nodeId, interfaceId, direction, layer, operation: 'dropped', afterHeaders: ethernetHeaders(frame), detail: 'global packet-loss simulation', }); return true; } // ── UDP sockets ────────────────────────────────────────────────────────── /** * A UDP socket on `nodeId`: on `localPort`, else on a free port drawn at * random from the ephemeral range; undefined while stopped, when the port * is taken or the node has no interface with `localIPAddress`. */ bindUdpSocket( nodeId: Id, options: { localPort?: number; localIPAddress?: string; remoteIPAddress?: string; remotePort?: number } = {}, ): Id | undefined { const state = this.stateValue; if (state.phase !== 'running') return undefined; let reservedPort: number; if (options.localPort !== undefined) { if (this.isUdpPortReserved(nodeId, options.localPort)) return undefined; reservedPort = options.localPort; } else { const port = this.reserveFreeUdpPort(nodeId); if (port === undefined) return undefined; reservedPort = port; } if ( options.localIPAddress !== undefined && !this.networkInterfaces(nodeId).some((networkInterface) => networkInterface.ipAddress === options.localIPAddress) ) { return undefined; } const id = this.allocateSocketId(); state.socketsById.set(id, { id, nodeId, protocol: 'udp', localIPAddress: options.localIPAddress, localPort: reservedPort, remoteIPAddress: options.remoteIPAddress, remotePort: options.remotePort, tcpState: undefined, parentListenerSocketId: undefined, }); state.udpReceiveQueuesBySocketId.set(id, []); return id; } private isUdpPortReserved(nodeId: Id, port: number): boolean { // Swift also counts a gateway's NAT mappings; gateways do not run here yet. for (const socket of this.stateValue.socketsById.values()) { if (socket.nodeId === nodeId && socket.protocol === 'udp' && socket.localPort === port) return true; } return false; } private reserveFreeUdpPort(nodeId: Id): number | undefined { const span = UDP_EPHEMERAL_PORT_UPPER_BOUND_EXCLUSIVE - UDP_EPHEMERAL_PORT_LOWER_BOUND; for (let attempt = 0; attempt < span; attempt++) { const port = UDP_EPHEMERAL_PORT_LOWER_BOUND + this.udpPortRandom.nextInt(span); if (!this.isUdpPortReserved(nodeId, port)) return port; } return undefined; } closeSocket(socketId: Id): void { if (this.stateValue.socketsById.get(socketId)?.protocol === 'tcp') { this.closeTcpSocket(socketId); return; } this.stateValue.socketsById.delete(socketId); this.stateValue.udpReceiveQueuesBySocketId.delete(socketId); } /** Forgets a socket without closing it, with the sockets a TCP listener accepted. */ discardSocket(socketId: Id): void { const state = this.stateValue; const children = state.tcpAcceptedSocketIdsByListenerId.get(socketId) ?? []; state.tcpAcceptedSocketIdsByListenerId.delete(socketId); for (const child of children) { state.socketsById.delete(child); state.tcpSessionsById.delete(child); } state.socketsById.delete(socketId); state.tcpSessionsById.delete(socketId); state.udpReceiveQueuesBySocketId.delete(socketId); for (const [listener, accepted] of state.tcpAcceptedSocketIdsByListenerId) { state.tcpAcceptedSocketIdsByListenerId.set( listener, accepted.filter((id) => id !== socketId), ); } } private allocateSocketId(): Id { const id = deterministicSocketId(this.stateValue.nextSocketSequenceNumber); this.stateValue.nextSocketSequenceNumber += 1; return id; } /** * The oldest datagram in the socket's queue; with a timeout, the clock * advances up to it when the queue is empty, as a blocking receive in Java. */ receiveUdp(socketId: Id, timeoutMilliseconds = 0): UdpReceivedDatagram | undefined { const next = () => this.stateValue.udpReceiveQueuesBySocketId.get(socketId)?.shift(); const datagram = next(); if (datagram !== undefined) return datagram; if (timeoutMilliseconds <= 0 || this.stateValue.phase !== 'running') return undefined; const deadline = this.stateValue.currentTimeMilliseconds + timeoutMilliseconds; if (!Number.isSafeInteger(deadline)) return undefined; // Java's empfangen returns as soon as a datagram is there, else at the deadline. this.advanceUntil(() => (this.stateValue.udpReceiveQueuesBySocketId.get(socketId)?.length ?? 0) > 0, deadline); return next(); } /** Advances event by event until the condition holds, at most to the deadline. */ private advanceUntil(condition: () => boolean, deadlineMilliseconds: number): void { while (this.stateValue.phase === 'running' && !condition()) { const next = this.earliestDeadline(() => true); if (next === undefined || next > deadlineMilliseconds) { this.advance(deadlineMilliseconds); return; } this.advance(next); } } /** Sends from a UDP socket to its remote end, or to the given one. */ sendUdp( socketId: Id, payload: Uint8Array, destination: { ipAddress?: string; port?: number } = {}, ): IPv4DeliveryResult | undefined { const socket = this.stateValue.socketsById.get(socketId); if (socket === undefined || socket.protocol !== 'udp') return undefined; const destinationIPAddress = destination.ipAddress ?? socket.remoteIPAddress; const destinationPort = destination.port ?? socket.remotePort; if (destinationIPAddress === undefined || destinationPort === undefined) return undefined; const interfaces = this.networkInterfaces(socket.nodeId); if (interfaces.length === 0) return undefined; const boundInterface = socket.localIPAddress === undefined ? undefined : interfaces.find((networkInterface) => networkInterface.ipAddress === socket.localIPAddress); const packetIdentity = this.allocatePacketIdentity(); const datagram: UdpDatagram = { sourcePort: socket.localPort, destinationPort, payload }; const traceSent = (networkInterface: NetworkInterface, identity: number) => this.recordTrace({ packetIdentity: identity, nodeId: socket.nodeId, interfaceId: networkInterface.portId, direction: 'outbound', layer: 'transport', operation: 'sent', afterHeaders: udpHeaders(datagram), }); const handedOn: IPv4DeliveryResult = { type: 'delivered', packetIdentity, nodeId: socket.nodeId }; if (destinationIPAddress === LIMITED_BROADCAST_IP_ADDRESS) { const broadcastInterfaces = boundInterface === undefined ? interfaces : [boundInterface]; for (const networkInterface of broadcastInterfaces) traceSent(networkInterface, packetIdentity); return this.handToTransportSender(socket.nodeId, 'udp', handedOn, (finished) => { for (const networkInterface of broadcastInterfaces) { const packet: IPv4Packet = { identity: packetIdentity, senderIPAddress: networkInterface.ipAddress, receiverIPAddress: destinationIPAddress, timeToLive: 1, protocol: 'udp', payload: { kind: 'udp', datagram }, }; this.sendLimitedBroadcastPacket(socket.nodeId, networkInterface, packet); } finished(); return handedOn; }); } let networkInterface: NetworkInterface; if (boundInterface !== undefined) { networkInterface = boundInterface; } else { const route = this.routeForPacket(socket.nodeId, destinationIPAddress); networkInterface = route.type === 'egress' ? route.interface : interfaces[0]!; } const packet: IPv4Packet = { identity: packetIdentity, senderIPAddress: networkInterface.ipAddress, receiverIPAddress: destinationIPAddress, timeToLive: 64, protocol: 'udp', payload: { kind: 'udp', datagram }, }; traceSent(networkInterface, packet.identity); const portId = networkInterface.portId; return this.handToTransportSender(socket.nodeId, 'udp', handedOn, (finished) => this.sendIPv4PacketWithOptions(socket.nodeId, packet, portId, true, 'sent', { onFinished: finished }), ); } /** * Hands a segment to the node's send thread for the protocol (Java's * TransportProtokoll.senden). The thread holds its list while IP waits for * ARP: a socket's send then blocks until the segment in hand is out, as the * caller does here; from within an event, the segment queues behind it. * The senders that wait take their turns in the order they came, before * the thread takes the next — as with a fair lock; Java's is not fair and * leaves the order to chance. The result is the send's own when the thread * takes it at once, else that it was handed on. */ private handToTransportSender( nodeId: Id, protocol: 'udp' | 'tcp', handedOn: IPv4DeliveryResult, send: (finished: () => void) => IPv4DeliveryResult, ): IPv4DeliveryResult { const sender = getOrCreate(this.transportSenders, `${nodeId}/${protocol}`, () => ({ busy: false, started: 0, finished: 0, queue: [] })); if (sender.busy && !this.isProcessingScheduledEvents) { // The segment in hand is out once ARP has answered or given up. const inHand = sender.started; const deadline = this.stateValue.currentTimeMilliseconds + (ARP_ATTEMPTS + 1) * this.runtimeSpeed.roundTripTimeMilliseconds; this.advanceUntil(() => sender.finished >= inHand, deadline); if (this.stateValue.phase !== 'running' || this.transportSenders.get(`${nodeId}/${protocol}`) !== sender) return handedOn; } let result = handedOn; sender.queue.push((finished) => { result = send(finished); }); if (!sender.busy) this.startNextTransportSend(sender); return result; } private startNextTransportSend(sender: TransportSender): void { const next = sender.queue.shift(); if (next === undefined) return; sender.busy = true; sender.started += 1; let out = false; next(() => { if (out) return; out = true; sender.finished += 1; sender.busy = false; this.startNextTransportSend(sender); }); } private sendLimitedBroadcastPacket(fromNodeId: Id, networkInterface: NetworkInterface, packet: IPv4Packet): void { const broadcastPacket: IPv4Packet = { ...packet, senderIPAddress: networkInterface.ipAddress, receiverIPAddress: LIMITED_BROADCAST_IP_ADDRESS, timeToLive: 1, }; const frame: EthernetFrame = { identity: this.allocateFrameIdentity(), sourceMacAddress: networkInterface.macAddress, destinationMacAddress: ETHERNET_BROADCAST_MAC_ADDRESS, payload: { kind: 'ipv4', packet: broadcastPacket }, }; this.recordIPv4Send(broadcastPacket, frame, fromNodeId, networkInterface.portId, 'sent'); this.sendEthernetFrame(fromNodeId, networkInterface.portId, frame); } /** Into the queue of every UDP socket on the destination port, which then answers to the sender. */ private deliverUdp(packet: IPv4Packet, datagram: UdpDatagram, nodeId: Id, networkInterface: NetworkInterface): void { if ( datagram.destinationPort === RIP_DESTINATION_PORT && datagram.sourcePort === RIP_SOURCE_PORT && this.isRipEnabledRouter(nodeId) ) { 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) .sort((lhs, rhs) => compareIds(lhs.id, rhs.id)); for (const socket of matching) { getOrCreate(state.udpReceiveQueuesBySocketId, socket.id, () => []).push({ senderIPAddress: packet.senderIPAddress, receiverIPAddress: packet.receiverIPAddress, datagram, receivedAtMilliseconds: state.currentTimeMilliseconds, }); state.socketsById.set(socket.id, { ...socket, remoteIPAddress: packet.senderIPAddress, remotePort: datagram.sourcePort }); } this.recordTrace({ packetIdentity: packet.identity, nodeId, interfaceId: networkInterface.portId, direction: 'local', layer: 'transport', operation: matching.length === 0 ? 'dropped' : 'accepted', beforeHeaders: udpHeaders(datagram), detail: matching.length === 0 ? 'UDP destination port unbound' : 'UDP queued', }); if (datagram.destinationPort === DHCP_CLIENT_PORT && this.readDhcpResponse(nodeId)) this.advanceDhcpClient(nodeId); if (datagram.destinationPort === DNS_PORT) this.serveDnsQueries(nodeId); } // ── DNS (after Java's DNSServer and Resolver) ──────────────────────────── /** Every DNS server switched on listens on port 53. */ private initializeDnsState(): void { const state = this.stateValue; state.dnsServerSocketIdsByNodeId.clear(); for (const [nodeId, configuration] of state.topologySnapshot.dnsServerConfigurationsByNodeId) { if (configuration.recursiveResolutionEnabled) throw new NotPortedError('recursive DNS resolution'); const socketId = this.bindUdpSocket(nodeId, { localPort: DNS_PORT }); if (socketId !== undefined) state.dnsServerSocketIdsByNodeId.set(nodeId, socketId); } } /** DNSServerMitarbeiter: each query answered to where it came from, with the query's ID. */ private serveDnsQueries(nodeId: Id): void { const state = this.stateValue; const socketId = state.dnsServerSocketIdsByNodeId.get(nodeId); const configuration = state.topologySnapshot.dnsServerConfigurationsByNodeId.get(nodeId); if (socketId === undefined || configuration === undefined) return; for (let received = this.receiveUdp(socketId); received !== undefined; received = this.receiveUdp(socketId)) { const query = decodeDnsMessage(new TextDecoder().decode(received.datagram.payload)); const records = configuration.records.map(messageRecord); const answers = query.questions.flatMap((question) => answerDnsQuestion(question, records)); const response: DnsMessage = { id: query.id, queryResponse: DNS_RESPONSE, responseCode: 0, questions: [], answers, authority: [], additional: [], }; this.recordTrace({ nodeId, direction: 'outbound', layer: 'application', operation: 'sent', detail: 'DNS response' }); this.sendUdp(socketId, new TextEncoder().encode(encodeDnsMessage(response)), { ipAddress: received.senderIPAddress, port: received.datagram.sourcePort, }); } } /** * Java's Resolver.resolve, blocking on the virtual clock: asks the node's * DNS server (or `serverIPAddress`), and follows the name servers it is * referred to, each asked once, until one answers for the name and type. * The records for the name and type it collected; timedOut when a server * does not answer within five round trips (Java: TimeOutException). */ resolveDns(nodeId: Id, name: string, type: string, serverIPAddress?: string): DnsResolution { const domainName = name.endsWith('.') ? name : `${name}.`; const collected: DnsMessageRecord[] = []; if (/^localhost\.?$/i.test(domainName)) { return { type: 'answered', records: [{ name: 'localhost.', type: 'A', ttl: 3600, data: LOCALHOST_IP_ADDRESS }] }; } const queried = new Set(); let server: string | undefined = serverIPAddress ?? this.stateValue.topologySnapshot.deviceConfigurations.get(nodeId)?.dnsServer; while (server !== undefined && server !== '') { const response = this.queryDnsServer(nodeId, domainName, type, server); if (response === undefined) return { type: 'timedOut' }; const relevant = (records: readonly DnsMessageRecord[], wanted: string, wantedType: string) => findDnsRecords(wanted, wantedType, records); collected.push(...relevant(response.answers, domainName, type), ...relevant(response.authority, domainName, type)); const data = [...response.answers, ...response.authority, ...response.additional].filter( (record) => record.name.toLowerCase() === domainName.toLowerCase() && record.type === type, ); if (data.length > 0) { if (type === 'MX' || type === 'NS') { collected.push(...relevant(response.answers, data[0]!.data, 'A'), ...relevant(response.authority, data[0]!.data, 'A')); } break; } const all = [...response.answers, ...response.authority, ...response.additional]; const nameServer = findApplicableNameServerRecord(domainName, all); const next = nameServer === undefined ? undefined : findDnsRecords(nameServer.data, 'A', all)[0]?.data; // Java remembers only the servers it was referred to, not the first one. server = next !== undefined && !queried.has(next) ? next : undefined; if (next !== undefined) queried.add(next); } return { type: 'answered', records: collected }; } /** Resolver.holeIPAdresse: the A record's address, undefined when there is none. */ resolveDnsAddress(nodeId: Id, name: string): DnsAddressResolution { const domainName = name.endsWith('.') ? name : `${name}.`; const resolution = this.resolveDns(nodeId, domainName, 'A'); if (resolution.type === 'timedOut') return resolution; return { type: 'resolved', address: findDnsRecords(domainName, 'A', resolution.records)[0]?.data }; } /** Resolver.holeIPAdresseMailServer: the address of the domain's mail exchange. */ resolveDnsMailServer(nodeId: Id, domain: string): DnsAddressResolution { const domainName = domain.endsWith('.') ? domain : `${domain}.`; const resolution = this.resolveDns(nodeId, domainName, 'MX'); if (resolution.type === 'timedOut') return resolution; const exchange = findDnsRecords(domainName, 'MX', resolution.records)[0]; return { type: 'resolved', address: exchange === undefined ? undefined : findDnsRecords(exchange.data, 'A', resolution.records)[0]?.data }; } /** DNSQueryAgent.query: one question from a fresh socket, the first datagram back within five round trips. */ private queryDnsServer(nodeId: Id, domainName: string, type: string, serverIPAddress: string): DnsMessage | undefined { const socketId = this.bindUdpSocket(nodeId, { remoteIPAddress: serverIPAddress, remotePort: DNS_PORT }); if (socketId === undefined) return undefined; const query: DnsMessage = { // Java: (int) (Math.random() * 65536). id: Math.floor(this.dnsIdRandom.nextDouble() * 65_536), queryResponse: DNS_QUERY, responseCode: 0, questions: [{ name: domainName, type, questionClass: 'IN' }], answers: [], authority: [], additional: [], }; this.recordTrace({ nodeId, direction: 'outbound', layer: 'application', operation: 'sent', detail: `DNS query ${domainName} ${type}` }); this.sendUdp(socketId, new TextEncoder().encode(encodeDnsMessage(query))); const received = this.receiveUdp(socketId, DNS_QUERY_TIMEOUT_ROUND_TRIPS * this.runtimeSpeed.roundTripTimeMilliseconds); this.closeSocket(socketId); return received === undefined ? undefined : decodeDnsMessage(new TextDecoder().decode(received.datagram.payload)); } // ── TCP sockets ────────────────────────────────────────────────────────── /** A listening TCP socket; undefined while stopped, when the port is taken or the address is not the node's. */ openTcpServerSocket(nodeId: Id, localPort: number, localIPAddress?: string): Id | undefined { const state = this.stateValue; if ( state.phase !== 'running' || this.isTcpPortReserved(nodeId, localPort) || (localIPAddress !== undefined && !this.networkInterfaces(nodeId).some((each) => each.ipAddress === localIPAddress)) ) { return undefined; } const id = this.allocateSocketId(); state.socketsById.set(id, { id, nodeId, protocol: 'tcp', localIPAddress, localPort, remoteIPAddress: undefined, remotePort: undefined, tcpState: 'LISTEN', parentListenerSocketId: undefined, }); state.tcpSessionsById.set(id, this.makeTcpSession(id, 'LISTEN')); state.tcpAcceptedSocketIdsByListenerId.set(id, []); this.recordTcpStateTransition(id, 'CLOSED', 'LISTEN'); return id; } /** A closed TCP client socket towards the remote end, on the given or a free port. */ openTcpClientSocket( nodeId: Id, remoteIPAddress: string, remotePort: number, options: { localIPAddress?: string; localPort?: number } = {}, ): Id | undefined { const state = this.stateValue; if (state.phase !== 'running') return undefined; let localIPAddress: string | undefined; if (options.localIPAddress !== undefined) { if (!this.networkInterfaces(nodeId).some((each) => each.ipAddress === options.localIPAddress)) return undefined; localIPAddress = options.localIPAddress; } else { const route = this.routeForPacket(nodeId, remoteIPAddress); localIPAddress = route.type === 'egress' ? route.interface.ipAddress : this.networkInterfaces(nodeId)[0]?.ipAddress; } if (localIPAddress === undefined) return undefined; let localPort: number; if (options.localPort !== undefined) { if (this.isTcpPortReserved(nodeId, options.localPort)) return undefined; localPort = options.localPort; } else { const port = this.reserveFreeTcpPort(nodeId); if (port === undefined) return undefined; localPort = port; } const id = this.allocateSocketId(); state.socketsById.set(id, { id, nodeId, protocol: 'tcp', localIPAddress, localPort, remoteIPAddress, remotePort, tcpState: 'CLOSED', parentListenerSocketId: undefined, }); state.tcpSessionsById.set(id, this.makeTcpSession(id, 'CLOSED')); return id; } /** A port held by an open (not closed) TCP socket that is no listener's child. */ isTcpPortReserved(nodeId: Id, port: number): boolean { for (const socket of this.stateValue.socketsById.values()) { if ( socket.nodeId === nodeId && socket.protocol === 'tcp' && socket.localPort === port && socket.parentListenerSocketId === undefined && socket.tcpState !== 'CLOSED' ) { return true; } } return false; } connectTcp(socketId: Id): boolean { return this.connectTcpWithResult(socketId) === 'connected'; } /** The three-way handshake from a closed client socket, waiting on the virtual clock as Java blocks. */ connectTcpWithResult(socketId: Id): TcpConnectionResult { const state = this.stateValue; const socket = state.socketsById.get(socketId); if ( socket === undefined || socket.protocol !== 'tcp' || socket.parentListenerSocketId !== undefined || socket.remoteIPAddress === undefined || socket.remotePort === undefined || socket.tcpState !== 'CLOSED' || [...state.socketsById.values()].some( (other) => other.id !== socketId && other.nodeId === socket.nodeId && other.protocol === 'tcp' && other.localPort === socket.localPort && other.parentListenerSocketId === undefined && other.tcpState !== 'CLOSED', ) ) { return 'invalidSocket'; } this.updateTcpState(socketId, 'SYN_SENT'); this.resetTcpSendAttempts(socketId); const initialDelivery = this.transmitTcpSegment(socketId, TCP_FLAG.synchronize, new Uint8Array(), 0, 'TCP SYN'); const waitedForTimeout = this.advanceTcpUntilSettled(socketId, { waitingStates: ['SYN_SENT'] }); if (state.socketsById.get(socketId)?.tcpState === 'ESTABLISHED') return 'connected'; if (initialDelivery === undefined || initialDelivery.type !== 'delivered') return 'unreachable'; return waitedForTimeout ? 'timedOut' : 'unreachable'; } /** Sends in segments of at most TCP_MAXIMUM_SEGMENT_SIZE bytes, each acknowledged before the next. */ sendTcp(socketId: Id, payload: Uint8Array): boolean { const state = this.stateValue; if (state.socketsById.get(socketId)?.tcpState !== 'ESTABLISHED') return false; const chunks: Uint8Array[] = []; if (payload.length === 0) { chunks.push(new Uint8Array()); } else { for (let offset = 0; offset < payload.length; offset += TCP_MAXIMUM_SEGMENT_SIZE) { chunks.push(payload.subarray(offset, Math.min(offset + TCP_MAXIMUM_SEGMENT_SIZE, payload.length))); } } for (const [index, chunk] of chunks.entries()) { if (state.socketsById.get(socketId)?.tcpState !== 'ESTABLISHED') return false; this.resetTcpSendAttempts(socketId); const flags = index === chunks.length - 1 ? TCP_FLAG.push : 0; this.transmitTcpSegment(socketId, flags, chunk, 0, `TCP data segment ${index + 1}/${chunks.length}`); this.advanceTcpUntilSettled(socketId, { waitingForAcknowledgement: true }); const session = state.tcpSessionsById.get(socketId); if (session === undefined || session.state !== 'ESTABLISHED' || session.lastSentSegment !== undefined) return false; } return true; } /** The oldest whole message (up to a PSH) received on the socket. */ receiveTcp(socketId: Id): Uint8Array | undefined { const session = this.stateValue.tcpSessionsById.get(socketId); if (session === undefined || session.receivedMessages.length === 0) return undefined; return session.receivedMessages.shift(); } closeTcpSocket(socketId: Id): void { const socket = this.stateValue.socketsById.get(socketId); if (socket === undefined || socket.protocol !== 'tcp' || socket.tcpState === undefined) return; switch (socket.tcpState) { case 'LISTEN': case 'SYN_SENT': this.updateTcpState(socketId, 'CLOSED'); this.clearTcpPendingTransmission(socketId); return; case 'ESTABLISHED': case 'SYN_RCVD': this.updateTcpState(socketId, 'FIN_WAIT_1'); this.resetTcpSendAttempts(socketId); this.transmitTcpSegment(socketId, TCP_FLAG.finish, new Uint8Array(), 0, 'TCP FIN'); return; case 'CLOSE_WAIT': this.updateTcpState(socketId, 'LAST_ACK'); this.resetTcpSendAttempts(socketId); this.transmitTcpSegment(socketId, TCP_FLAG.finish, new Uint8Array(), 0, 'TCP FIN after CLOSE_WAIT'); return; default: return; } } /** The socket at the other end of a connection, the first by id. */ peerTcpSocketId(socketId: Id): Id | undefined { const socket = this.stateValue.socketsById.get(socketId); if ( socket === undefined || socket.protocol !== 'tcp' || socket.localIPAddress === undefined || socket.remoteIPAddress === undefined || socket.remotePort === undefined ) { return undefined; } return [...this.stateValue.socketsById.values()] .filter( (candidate) => candidate.id !== socketId && candidate.protocol === 'tcp' && candidate.tcpState !== 'LISTEN' && candidate.localIPAddress === socket.remoteIPAddress && candidate.localPort === socket.remotePort && candidate.remoteIPAddress === socket.localIPAddress && candidate.remotePort === socket.localPort, ) .sort((lhs, rhs) => compareIds(lhs.id, rhs.id))[0]?.id; } /** Removes a closed TCP socket; false when it is not one. */ cleanClosedTcpSocket(socketId: Id): boolean { const state = this.stateValue; const socket = state.socketsById.get(socketId); if (socket?.protocol !== 'tcp' || socket.tcpState !== 'CLOSED') return false; this.cancelTcpTimeouts(socketId); state.socketsById.delete(socketId); state.tcpSessionsById.delete(socketId); state.tcpAcceptedSocketIdsByListenerId.delete(socketId); for (const [listener, accepted] of state.tcpAcceptedSocketIdsByListenerId) { state.tcpAcceptedSocketIdsByListenerId.set( listener, accepted.filter((id) => id !== socketId), ); } return true; } private deliverTcp(packet: IPv4Packet, segment: TcpSegment, nodeId: Id, networkInterface: NetworkInterface): void { const state = this.stateValue; this.recordTrace({ packetIdentity: packet.identity, nodeId, interfaceId: networkInterface.portId, direction: 'local', layer: 'transport', operation: 'accepted', beforeHeaders: tcpHeaders(segment), detail: 'TCP segment received', }); const byId = (lhs: SocketRecord, rhs: SocketRecord) => compareIds(lhs.id, rhs.id); const sockets = [...state.socketsById.values()]; const exact = sockets .filter( (socket) => socket.nodeId === nodeId && socket.protocol === 'tcp' && socket.tcpState !== 'LISTEN' && socket.tcpState !== 'CLOSED' && socket.localPort === segment.destinationPort && (socket.localIPAddress === undefined || socket.localIPAddress === packet.receiverIPAddress) && socket.remoteIPAddress === packet.senderIPAddress && socket.remotePort === segment.sourcePort, ) .sort(byId)[0]; if (exact !== undefined) { this.processIncomingTcpSegment(exact.id, segment); return; } const listener = sockets .filter( (socket) => socket.nodeId === nodeId && socket.protocol === 'tcp' && socket.tcpState === 'LISTEN' && socket.localPort === segment.destinationPort && (socket.localIPAddress === undefined || socket.localIPAddress === packet.receiverIPAddress), ) .sort(byId)[0]; if (listener === undefined || (segment.flags & TCP_FLAG.synchronize) === 0 || (segment.flags & TCP_FLAG.acknowledgement) !== 0) { this.recordTrace({ packetIdentity: packet.identity, nodeId, interfaceId: networkInterface.portId, direction: 'local', layer: 'transport', operation: 'dropped', beforeHeaders: tcpHeaders(segment), detail: 'TCP destination port closed', }); return; } const acceptedId = this.allocateSocketId(); state.socketsById.set(acceptedId, { id: acceptedId, nodeId, protocol: 'tcp', localIPAddress: packet.receiverIPAddress, localPort: segment.destinationPort, remoteIPAddress: packet.senderIPAddress, remotePort: segment.sourcePort, tcpState: 'SYN_RCVD', parentListenerSocketId: listener.id, }); // Java accepts on the socket that listened: it has the listener's initial sequence number. const session = this.makeTcpSession(acceptedId, 'SYN_RCVD', state.tcpSessionsById.get(listener.id)?.nextSendSequenceNumber); session.remoteSequenceNumber = nextTcpSequenceNumber(segment); state.tcpSessionsById.set(acceptedId, session); getOrCreate(state.tcpAcceptedSocketIdsByListenerId, listener.id, () => []).push(acceptedId); this.recordTcpStateTransition(acceptedId, 'LISTEN', 'SYN_RCVD'); this.resetTcpSendAttempts(acceptedId); this.transmitTcpSegment( acceptedId, TCP_FLAG.synchronize | TCP_FLAG.acknowledgement, new Uint8Array(), session.remoteSequenceNumber, 'TCP SYN+ACK', ); } private processIncomingTcpSegment(socketId: Id, segment: TcpSegment): void { const socket = this.stateValue.socketsById.get(socketId); if (socket === undefined || socket.tcpState === undefined) return; const has = (flag: number) => (segment.flags & flag) !== 0; if (has(TCP_FLAG.reset)) { this.clearTcpPendingTransmission(socketId); this.updateTcpState(socketId, 'CLOSED'); return; } switch (socket.tcpState) { case 'SYN_SENT': { if ( !has(TCP_FLAG.synchronize) || !has(TCP_FLAG.acknowledgement) || !this.acknowledgeTcpIfExpected(socketId, segment.acknowledgementNumber) ) { this.updateTcpState(socketId, 'CLOSED'); return; } const session = this.stateValue.tcpSessionsById.get(socketId); if (session !== undefined) session.remoteSequenceNumber = nextTcpSequenceNumber(segment); this.updateTcpState(socketId, 'ESTABLISHED'); this.sendTcpAcknowledgement(socketId, segment); return; } case 'SYN_RCVD': { if (!has(TCP_FLAG.acknowledgement) || !this.acknowledgeTcpIfExpected(socketId, segment.acknowledgementNumber)) return; this.updateTcpState(socketId, 'ESTABLISHED'); // Java: the socket that listened is the connection now; the server // listens on with a new socket, and a new initial sequence number. const listenerId = socket.parentListenerSocketId; if (listenerId !== undefined && this.stateValue.tcpSessionsById.has(listenerId)) { this.stateValue.tcpSessionsById.set(listenerId, this.makeTcpSession(listenerId, 'LISTEN')); } return; } case 'TIME_WAIT': this.clearTcpPendingTransmission(socketId); this.updateTcpState(socketId, 'CLOSED'); return; case 'ESTABLISHED': if (has(TCP_FLAG.finish)) { this.sendTcpAcknowledgement(socketId, segment); this.updateTcpState(socketId, 'CLOSE_WAIT'); // Java's socket thread leaves listen once the state is no longer // ESTABLISHED and closes by itself, without waiting for the application. this.closeTcpSocket(socketId); } else if (segment.payload.length > 0 || has(TCP_FLAG.push)) { this.receiveTcpPayload(socketId, segment); } else if (has(TCP_FLAG.acknowledgement)) { this.acknowledgeTcpIfExpected(socketId, segment.acknowledgementNumber); } return; case 'FIN_WAIT_1': { const hasAck = has(TCP_FLAG.acknowledgement); const hasFin = has(TCP_FLAG.finish); if (hasAck) this.acknowledgeTcpIfExpected(socketId, segment.acknowledgementNumber); if (hasAck && hasFin) { this.sendTcpAcknowledgement(socketId, segment); this.enterTcpTimeWait(socketId); } else if (hasAck) { this.updateTcpState(socketId, 'FIN_WAIT_2'); } else if (hasFin) { this.sendTcpAcknowledgement(socketId, segment); this.updateTcpState(socketId, 'CLOSING'); } return; } case 'FIN_WAIT_2': if (has(TCP_FLAG.finish)) { this.sendTcpAcknowledgement(socketId, segment); this.enterTcpTimeWait(socketId); } return; case 'CLOSING': if (has(TCP_FLAG.acknowledgement) && this.acknowledgeTcpIfExpected(socketId, segment.acknowledgementNumber)) { this.enterTcpTimeWait(socketId); } return; case 'LAST_ACK': if (has(TCP_FLAG.acknowledgement) && this.acknowledgeTcpIfExpected(socketId, segment.acknowledgementNumber)) { this.updateTcpState(socketId, 'CLOSED'); } return; default: return; } } private receiveTcpPayload(socketId: Id, segment: TcpSegment): void { const session = this.stateValue.tcpSessionsById.get(socketId); if (session === undefined) return; const nextRemoteSequence = nextTcpSequenceNumber(segment); if (nextRemoteSequence > session.remoteSequenceNumber) { session.remoteSequenceNumber = nextRemoteSequence; session.receiveAssembly = concatBytes(session.receiveAssembly, segment.payload); if ((segment.flags & TCP_FLAG.push) !== 0) { session.receivedMessages.push(session.receiveAssembly); session.receiveAssembly = new Uint8Array(); } } this.sendTcpAcknowledgement(socketId, segment); } private sendTcpAcknowledgement(socketId: Id, segment: TcpSegment): void { this.transmitTcpSegment(socketId, TCP_FLAG.acknowledgement, new Uint8Array(), nextTcpSequenceNumber(segment), 'TCP ACK'); } private transmitTcpSegment( socketId: Id, flags: number, payload: Uint8Array, acknowledgementNumber: number, detail: string, retransmission = false, ): IPv4DeliveryResult | undefined { const state = this.stateValue; const socket = state.socketsById.get(socketId); const session = state.tcpSessionsById.get(socketId); if (socket === undefined || socket.remoteIPAddress === undefined || socket.remotePort === undefined || session === undefined) { return undefined; } let segment: TcpSegment; if (retransmission && session.lastSentSegment !== undefined) { segment = session.lastSentSegment; session.sendAttempts += 1; } else { segment = { sourcePort: socket.localPort, destinationPort: socket.remotePort, sequenceNumber: session.nextSendSequenceNumber, acknowledgementNumber, flags, payload, }; session.lastSentSequenceNumber = segment.sequenceNumber; session.nextSendSequenceNumber = nextTcpSequenceNumber(segment); if (tcpSegmentRequiresAcknowledgement(segment)) { session.lastSentSegment = segment; session.sendAttempts = 1; } } const interfaces = this.networkInterfaces(socket.nodeId); let networkInterface = socket.localIPAddress === undefined ? undefined : interfaces.find((each) => each.ipAddress === socket.localIPAddress); if (networkInterface === undefined) { const route = this.routeForPacket(socket.nodeId, socket.remoteIPAddress); networkInterface = route.type === 'egress' ? route.interface : interfaces[0]; } if (networkInterface === undefined) return undefined; const packet: IPv4Packet = { identity: this.allocatePacketIdentity(), senderIPAddress: networkInterface.ipAddress, receiverIPAddress: socket.remoteIPAddress, timeToLive: 64, protocol: 'tcp', payload: { kind: 'tcp', segment }, }; this.recordTrace({ packetIdentity: packet.identity, nodeId: socket.nodeId, interfaceId: networkInterface.portId, direction: 'outbound', layer: 'transport', operation: retransmission ? 'retransmitted' : 'sent', afterHeaders: tcpHeaders(segment), detail: retransmission ? `${detail} retransmission attempt ${session.sendAttempts}` : detail, }); const portId = networkInterface.portId; const handedOn: IPv4DeliveryResult = { type: 'delivered', packetIdentity: packet.identity, nodeId: socket.nodeId }; return this.handToTransportSender(socket.nodeId, 'tcp', handedOn, (finished) => { // Java's socket starts waiting when it has handed the segment down, // before ARP has found the next hop; the timeout counts from here. const handedDownAt = state.currentTimeMilliseconds; const delivery = this.sendIPv4PacketWithOptions(socket.nodeId, packet, portId, true, 'sent', { onFinished: finished }); if (state.tcpSessionsById.get(socketId)?.lastSentSegment !== undefined) this.scheduleTcpTimeoutIfNeeded(socketId, handedDownAt); return delivery; }); } private scheduleTcpTimeoutIfNeeded(socketId: Id, fromMilliseconds: number): void { const pending = this.stateValue.pendingEvents.some((event) => event.kind.kind === 'tcpTimeout' && event.kind.sessionId === socketId); if (pending) return; this.schedule(fromMilliseconds + this.tcpTimeoutMilliseconds(), { kind: 'tcpTimeout', sessionId: socketId }); } /** Java's defaultTimeout: MAX_SENDEVERSUCHE round trips, for each attempt and for TIME_WAIT. */ private tcpTimeoutMilliseconds(): number { return TCP_MAXIMUM_SEND_ATTEMPTS * this.runtimeSpeed.roundTripTimeMilliseconds; } private handleTcpTimeout(socketId: Id): void { const socket = this.stateValue.socketsById.get(socketId); if (socket === undefined || socket.tcpState === undefined) return; if (socket.tcpState === 'TIME_WAIT') { this.updateTcpState(socketId, 'CLOSED'); this.clearTcpPendingTransmission(socketId); return; } const session = this.stateValue.tcpSessionsById.get(socketId); const last = session?.lastSentSegment; if (session === undefined || last === undefined) return; if (session.sendAttempts >= TCP_MAXIMUM_SEND_ATTEMPTS) { this.clearTcpPendingTransmission(socketId); // Java: data that no one acknowledges (sent only while ESTABLISHED) // ends senden with schliessen, an orderly close with FIN. A handshake // or a FIN that fails closes at once. if (socket.tcpState === 'ESTABLISHED') { this.closeTcpSocket(socketId); } else { this.updateTcpState(socketId, 'CLOSED'); } return; } const synchronize = (last.flags & TCP_FLAG.synchronize) !== 0; const baseDetail = synchronize ? (last.flags & TCP_FLAG.acknowledgement) === 0 ? 'TCP SYN' : 'TCP SYN+ACK' : (last.flags & TCP_FLAG.finish) !== 0 ? 'TCP FIN' : 'TCP data'; this.transmitTcpSegment(socketId, last.flags, last.payload, last.acknowledgementNumber, baseDetail, true); } private acknowledgeTcpIfExpected(socketId: Id, acknowledgementNumber: number): boolean { const session = this.stateValue.tcpSessionsById.get(socketId); if (session === undefined || session.lastSentSegment === undefined || acknowledgementNumber !== session.nextSendSequenceNumber) { return false; } session.lastAcknowledgedSequenceNumber = acknowledgementNumber; session.lastSentSegment = undefined; this.cancelTcpTimeouts(socketId); return true; } private clearTcpPendingTransmission(socketId: Id): void { const session = this.stateValue.tcpSessionsById.get(socketId); if (session !== undefined) session.lastSentSegment = undefined; this.cancelTcpTimeouts(socketId); } private cancelTcpTimeouts(socketId: Id): void { this.stateValue.pendingEvents = this.stateValue.pendingEvents.filter( (event) => !(event.kind.kind === 'tcpTimeout' && event.kind.sessionId === socketId), ); } /** Java's close loop: TIME_WAIT ends with the next segment, else after defaultTimeout. */ private enterTcpTimeWait(socketId: Id): void { this.clearTcpPendingTransmission(socketId); this.updateTcpState(socketId, 'TIME_WAIT'); this.schedule(this.stateValue.currentTimeMilliseconds + this.tcpTimeoutMilliseconds(), { kind: 'tcpTimeout', sessionId: socketId }); } /** * Advances the clock to the socket's timeouts while it waits in one of * the states or for an acknowledgement; whether it advanced at all. */ private advanceTcpUntilSettled( socketId: Id, options: { waitingStates?: readonly TcpSocketState[]; waitingForAcknowledgement?: boolean }, ): boolean { const waiting = () => { const socket = this.stateValue.socketsById.get(socketId); if (socket === undefined || socket.tcpState === undefined) return false; const waitingOnState = options.waitingStates?.includes(socket.tcpState) === true; // Data waits for its acknowledgement only while connected: once the // socket closes (Java: senden gives up and calls schliessen), it returns. const waitingOnAck = options.waitingForAcknowledgement === true && socket.tcpState === 'ESTABLISHED' && this.stateValue.tcpSessionsById.get(socketId)?.lastSentSegment !== undefined; return waitingOnState || waitingOnAck; }; let advancedToTimeout = false; while (this.stateValue.phase === 'running' && waiting()) { const deadlines = this.stateValue.pendingEvents .filter((event) => event.kind.kind === 'tcpTimeout' && event.kind.sessionId === socketId) .map((event) => event.deadlineMilliseconds); if (deadlines.length === 0) return advancedToTimeout; advancedToTimeout = true; // Until the answer comes, or the attempt's time is up. this.advanceUntil(() => !waiting(), Math.min(...deadlines)); } return advancedToTimeout; } private updateTcpState(socketId: Id, newState: TcpSocketState): void { const socket = this.stateValue.socketsById.get(socketId); const oldState = socket?.tcpState; if (socket === undefined || oldState === undefined || oldState === newState) return; socket.tcpState = newState; const session = this.stateValue.tcpSessionsById.get(socketId); if (session !== undefined) session.state = newState; this.recordTcpStateTransition(socketId, oldState, newState); } private recordTcpStateTransition(socketId: Id, oldState: TcpSocketState, newState: TcpSocketState): void { const socket = this.stateValue.socketsById.get(socketId); if (socket === undefined) return; this.recordTrace({ nodeId: socket.nodeId, direction: 'local', layer: 'transport', operation: 'accepted', beforeHeaders: [{ name: 'state', value: oldState }], afterHeaders: [{ name: 'state', value: newState }], detail: `TCP ${oldState} -> ${newState}`, }); } private resetTcpSendAttempts(socketId: Id): void { const session = this.stateValue.tcpSessionsById.get(socketId); if (session !== undefined) session.sendAttempts = 0; } private reserveFreeTcpPort(nodeId: Id): number | undefined { const span = TCP_EPHEMERAL_PORT_UPPER_BOUND_EXCLUSIVE - TCP_EPHEMERAL_PORT_LOWER_BOUND; for (let attempt = 0; attempt < span; attempt++) { const port = TCP_EPHEMERAL_PORT_LOWER_BOUND + this.tcpPortRandom.nextInt(span); if (!this.isTcpPortReserved(nodeId, port)) return port; } return undefined; } /** Swift: (value mod 2^32) · 1,000,000, truncated to 32 bits. */ private allocateTcpInitialSequenceNumber(): number { const value = BigInt(this.stateValue.nextTcpInitialSequenceValue); this.stateValue.nextTcpInitialSequenceValue += 1; return Number(BigInt.asUintN(32, (value % 4_294_967_296n) * 1_000_000n)); } private makeTcpSession( socketId: Id, socketState: TcpSocketState, initialSequence: number = this.allocateTcpInitialSequenceNumber(), ): TcpSession { return { id: socketId, socketId, state: socketState, sendAttempts: 0, nextSendSequenceNumber: initialSequence, lastSentSequenceNumber: initialSequence, remoteSequenceNumber: 0, lastAcknowledgedSequenceNumber: undefined, lastSentSegment: undefined, receiveAssembly: new Uint8Array(), receivedMessages: [], }; } // ── Later slices hook in here ──────────────────────────────────────────── private initializeRipState(): void { // RIP (F2): builds the per-router tables and schedules the first beacons. for (const node of this.stateValue.topologySnapshot.nodes) { if (this.isRipEnabledRouter(node.id)) throw new NotPortedError('RIP'); } } 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 { 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 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; 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)) 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, 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) return; if (status.state === 'FINISH' || context.waiting !== undefined) return; switch (status.state) { case 'INIT': status.state = 'DISCOVER'; break; case 'DISCOVER': this.sendDhcpMessage(context.socketId, { type: 'DHCPDISCOVER', yourIPAddress: UNSPECIFIED_IP_ADDRESS, clientMacAddress: context.clientMacAddress, }); this.waitForDhcpResponse(nodeId, ['DHCPOFFER'], undefined); break; case 'VALIDATE': { const offer = context.selectedOffer; if (offer === undefined) { status.state = 'DISCOVER'; break; } this.checkDhcpClientAddress(nodeId, offer.yourIPAddress); break; } case 'DECLINE': { const offer = context.selectedOffer; if (offer !== undefined) { this.sendDhcpMessage(context.socketId, { type: 'DHCPDECLINE', yourIPAddress: UNSPECIFIED_IP_ADDRESS, clientMacAddress: context.clientMacAddress, serverIdentifier: offer.serverIdentifier, requestedIPAddress: offer.yourIPAddress, }); } status.state = 'DISCOVER'; break; } case 'REQUEST': { const offer = context.selectedOffer; if (offer === undefined) { status.state = 'DISCOVER'; break; } this.sendDhcpMessage(context.socketId, { type: 'DHCPREQUEST', yourIPAddress: UNSPECIFIED_IP_ADDRESS, clientMacAddress: context.clientMacAddress, serverIdentifier: offer.serverIdentifier, requestedIPAddress: offer.yourIPAddress, }); this.waitForDhcpResponse(nodeId, ['DHCPACK', 'DHCPNAK'], offer.serverIdentifier); break; } case 'ASSIGN_IP': { const offer = context.selectedOffer; if (offer === undefined) { status.state = 'DISCOVER'; break; } this.applyDhcpConfiguration(nodeId, context.interfaceId, offer); status.succeeded = true; status.state = 'FINISH'; 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); } /** * 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); 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 { this.stateValue.pendingEvents = this.stateValue.pendingEvents.filter( (event) => !(event.kind.kind === 'dhcpTimeout' && event.kind.nodeId === nodeId), ); } /** * 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); const wait = context?.waiting; if (context === undefined || status === undefined || wait === undefined) return; context.waiting = undefined; if (wait.kind === 'arp') { this.finishDhcpClientAddressCheck(nodeId, this.knownMacAddress(nodeId, wait.ipAddress) !== undefined); } else { status.errorCount += 1; if (status.errorCount === DHCP_MAXIMUM_CLIENT_ERROR_COUNT) { context.selectedOffer = undefined; status.selectedServerIPAddress = undefined; status.offeredIPAddress = undefined; status.state = 'LINK_LOCAL_INIT'; } } this.advanceDhcpClient(nodeId); } /** 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); 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_ROUND_TRIPS * this.runtimeSpeed.roundTripTimeMilliseconds, ); } 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_ROUND_TRIPS * this.runtimeSpeed.roundTripTimeMilliseconds, }); 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); } /** 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; 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, interfaceConfigurations: ReadonlyMap, ): void { this.stateValue.topologySnapshot = withSnapshot(this.stateValue.topologySnapshot, { deviceConfigurations, interfaceConfigurations }); } private initializeNatState(): void { // Gateway NAT (F2): schedules the expiry sweep when a gateway exists. if (this.stateValue.topologySnapshot.nodes.some((node) => node.kind === 'gateway')) { throw new NotPortedError('gateway NAT'); } } private expireRipRoutes(): void { // RIP (F2): marks timed-out learned routes with metric infinity. } private isRipEnabledRouter(nodeId: Id): boolean { return ( this.nodeSnapshot(nodeId)?.kind === 'router' && this.stateValue.topologySnapshot.ripEnabledByNodeId.get(nodeId) === true ); } // ── Interfaces and addresses ───────────────────────────────────────────── networkInterfaces(nodeId: Id): NetworkInterface[] { const snapshot = this.stateValue.topologySnapshot; const node = snapshot.nodes.find((candidate) => candidate.id === nodeId); if (node === undefined) return []; const deviceConfiguration = snapshot.deviceConfigurations.get(nodeId); const result: NetworkInterface[] = []; node.ports.forEach((port, index) => { let configuration: InterfaceConfiguration | undefined; switch (node.kind) { case 'pc': case 'notebook': if (index !== 0 || deviceConfiguration === undefined) return; configuration = { ipAddress: deviceConfiguration.ipAddress, subnetMask: deviceConfiguration.subnetMask }; break; case 'router': case 'gateway': configuration = snapshot.interfaceConfigurations.get(interfaceKey(nodeId, port.id)); break; case 'networkSwitch': case 'remoteLink': case 'unsupported': configuration = undefined; break; } if (configuration === undefined) return; result.push({ nodeId, portId: port.id, index, ipAddress: configuration.ipAddress, subnetMask: configuration.subnetMask, defaultGateway: deviceConfiguration?.defaultGateway ?? '', macAddress: port.macAddress ?? stableMacAddress(port.id), }); }); return result; } clearArpCache(nodeId?: Id): void { if (nodeId === undefined) { this.stateValue.arpCachesByNodeId.clear(); } else { this.stateValue.arpCachesByNodeId.set(nodeId, new Map()); } } /** * The MAC address for `targetIPAddress`, asking by ARP broadcast when it is * not cached. Blocks like Java: each request waits a round trip for its * answer (virtual time passes) before the next, or before the call gives up. */ resolveMacAddress( nodeId: Id, targetIPAddress: string, options: { preferredInterfaceId?: Id | undefined; maxRetries?: number; selectInterfaceByAddress?: boolean } = {}, ): string | undefined { const maxRetries = options.maxRetries ?? ARP_ATTEMPTS; const selectInterfaceByAddress = options.selectInterfaceByAddress ?? true; const interfaces = this.networkInterfaces(nodeId); 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) { selectedInterface = interfaces.find((candidate) => candidate.portId === options.preferredInterfaceId); } else if (selectInterfaceByAddress) { selectedInterface = bestInterface(targetIPAddress, interfaces); } else { selectedInterface = interfaces[0]; } if (selectedInterface === undefined) return undefined; let finished = false; let resolved: string | undefined; this.lookUpMacAddress(nodeId, targetIPAddress, selectedInterface, maxRetries, (macAddress) => { finished = true; resolved = macAddress; }); if (!finished && !this.isProcessingScheduledEvents) { this.advanceUntil(() => finished, this.stateValue.currentTimeMilliseconds + (maxRetries + 1) * this.runtimeSpeed.roundTripTimeMilliseconds); } return resolved; } /** * Java's holeARPTabellenEintrag as a lookup of its own: a known address at * once; otherwise an ARP request and a round trip's wait for the answer, * `attempts` times, then undefined. Java wakes on any change of the ARP * table and gives up when it finds nothing; the lookup waits for its * address (as the DHCP client's check does). */ private lookUpMacAddress( nodeId: Id, ipAddress: string, networkInterface: NetworkInterface, attempts: number, done: (macAddress: string | undefined) => void, ): void { const known = this.knownMacAddress(nodeId, ipAddress); if (known !== undefined || attempts <= 0) { done(known); return; } const swiftQueued = this.swiftArp && this.isProcessingScheduledEvents; const lookup: ArpLookup = { id: this.nextArpLookupId++, nodeId, ipAddress, networkInterface, attemptsLeft: attempts - 1, swiftQueued, done }; this.arpLookups.push(lookup); if (swiftQueued) { // Swift, only for the comparison: every request at once, then the packet waits without end. for (let attempt = 0; attempt < attempts; attempt += 1) this.sendArpRequest(nodeId, networkInterface, ipAddress); lookup.attemptsLeft = 0; return; } this.schedule(this.stateValue.currentTimeMilliseconds + this.runtimeSpeed.roundTripTimeMilliseconds, { kind: 'arpTimeout', lookupId: lookup.id }); this.sendArpRequest(nodeId, networkInterface, ipAddress); } /** The node learned `ipAddress`: every lookup waiting for it has its answer. */ private completeArpLookups(nodeId: Id, ipAddress: string, macAddress: string, onlySwiftQueued = false): void { const answered = this.arpLookups.filter( (lookup) => lookup.nodeId === nodeId && lookup.ipAddress === ipAddress && (!onlySwiftQueued || lookup.swiftQueued), ); if (answered.length === 0) return; const ids = new Set(answered.map((lookup) => lookup.id)); this.arpLookups = this.arpLookups.filter((lookup) => !ids.has(lookup.id)); this.stateValue.pendingEvents = this.stateValue.pendingEvents.filter( (event) => !(event.kind.kind === 'arpTimeout' && ids.has(event.kind.lookupId)), ); for (const lookup of answered) lookup.done(macAddress); } /** No answer within the round trip: ask again, or give up. */ private handleArpTimeout(lookupId: number): void { const lookup = this.arpLookups.find((each) => each.id === lookupId); if (lookup === undefined) return; if (lookup.attemptsLeft > 0) { lookup.attemptsLeft -= 1; this.schedule(this.stateValue.currentTimeMilliseconds + this.runtimeSpeed.roundTripTimeMilliseconds, { kind: 'arpTimeout', lookupId }); this.sendArpRequest(lookup.nodeId, lookup.networkInterface, lookup.ipAddress); return; } this.arpLookups = this.arpLookups.filter((each) => each.id !== lookupId); lookup.done(undefined); } /** The MAC address for `ipAddress` without asking: localhost, one of the node's own, or cached. */ private knownMacAddress(nodeId: Id, ipAddress: string): string | undefined { const interfaces = this.networkInterfaces(nodeId); const primaryInterface = interfaces[0]; if (primaryInterface === undefined) return undefined; if (ipAddress === LOCALHOST_IP_ADDRESS) return primaryInterface.macAddress; const localInterface = interfaces.find((candidate) => candidate.ipAddress === ipAddress); if (localInterface !== undefined) return localInterface.macAddress; return this.stateValue.arpCachesByNodeId.get(nodeId)?.get(ipAddress)?.macAddress; } /** One ARP request for `targetIPAddress`, broadcast from the interface. */ private sendArpRequest(nodeId: Id, networkInterface: NetworkInterface, targetIPAddress: string): void { const request: ArpPacket = { operation: 'request', senderMacAddress: networkInterface.macAddress, senderIPAddress: networkInterface.ipAddress, targetMacAddress: UNSPECIFIED_MAC_ADDRESS, targetIPAddress, }; const frame: EthernetFrame = { identity: this.allocateFrameIdentity(), sourceMacAddress: networkInterface.macAddress, destinationMacAddress: ETHERNET_BROADCAST_MAC_ADDRESS, payload: { kind: 'arp', packet: request }, }; this.recordTrace({ frameIdentity: frame.identity, nodeId, interfaceId: networkInterface.portId, direction: 'outbound', layer: 'dataLink', operation: 'created', afterHeaders: ethernetHeaders(frame), detail: `ARP request for ${targetIPAddress}`, }); this.sendEthernetFrame(nodeId, networkInterface.portId, frame); } private earliestDeadline(filter: (kind: ScheduledEventKind) => boolean): number | undefined { let earliest: number | undefined; for (const event of this.stateValue.pendingEvents) { if (!filter(event.kind)) continue; if (earliest === undefined || event.deadlineMilliseconds < earliest) earliest = event.deadlineMilliseconds; } return earliest; } // ── Ethernet ───────────────────────────────────────────────────────────── sendEthernetFrame(fromNodeId: Id, outgoingPortId: Id, frame: EthernetFrame): void { const endpoint = this.attachedEndpoint(fromNodeId, outgoingPortId); const now = this.stateValue.currentTimeMilliseconds; const busyUntil = this.cablesCarryOneFrameAtATime ? (this.cableBusyUntilByPort.get(portKey(fromNodeId, outgoingPortId)) ?? now) : now; if (endpoint !== undefined && busyUntil > now) { // The cable carries a frame: this one waits in the output buffer. this.cableBusyUntilByPort.set(portKey(fromNodeId, outgoingPortId), busyUntil + this.runtimeSpeed.linkTransmissionDelayMilliseconds); this.schedule(busyUntil, { kind: 'ethernetLinkDeparture', sourceNodeId: fromNodeId, outgoingPortId, targetNodeId: endpoint.nodeId, targetPortId: endpoint.portId, frame, }); this.drainPendingNetworkDeliveryEventsIfNeeded(); return; } this.departEthernetFrame(fromNodeId, outgoingPortId, endpoint, frame); } /** The frame leaves on the cable (and shows in the packet log); it arrives one cable's delay later. */ private departEthernetFrame(fromNodeId: Id, outgoingPortId: Id, endpoint: { nodeId: Id; portId: Id } | undefined, frame: EthernetFrame): void { this.recordTrace({ frameIdentity: frame.identity, packetIdentity: packetIdentityInFrame(frame), nodeId: fromNodeId, interfaceId: outgoingPortId, direction: 'outbound', layer: 'physical', operation: 'sent', afterHeaders: ethernetHeaders(frame), }); if (endpoint === undefined) return; const latencyMilliseconds = this.runtimeSpeed.linkTransmissionDelayMilliseconds; const deadlineMilliseconds = this.stateValue.currentTimeMilliseconds + latencyMilliseconds; if (!Number.isSafeInteger(deadlineMilliseconds)) { this.recordTrace({ frameIdentity: frame.identity, packetIdentity: packetIdentityInFrame(frame), nodeId: fromNodeId, interfaceId: outgoingPortId, direction: 'outbound', layer: 'physical', operation: 'dropped', afterHeaders: ethernetHeaders(frame), detail: 'link transmission deadline overflow', }); return; } if (this.cablesCarryOneFrameAtATime) this.cableBusyUntilByPort.set(portKey(fromNodeId, outgoingPortId), deadlineMilliseconds); this.schedule(deadlineMilliseconds, { kind: 'ethernetLinkDelivery', sourceNodeId: fromNodeId, targetNodeId: endpoint.nodeId, targetPortId: endpoint.portId, latencyMilliseconds, frame, }); this.drainPendingNetworkDeliveryEventsIfNeeded(); } /** * Swift's send, for the comparison: a frame sent from outside the events * runs the network until every frame in flight has arrived. Java's send * hands the frame to the card and returns; the frames travel while the * clock advances. */ private drainPendingNetworkDeliveryEventsIfNeeded(): void { if (this.isProcessingScheduledEvents || !this.swiftSends) return; const maximumEvents = 16_384; let processedEventCount = 0; while (processedEventCount < maximumEvents) { const nextDeadline = this.earliestDeadline(isNetworkDeliveryEvent); if (nextDeadline === undefined) return; this.advance(nextDeadline); processedEventCount += 1; } } private attachedEndpoint(nodeId: Id, portId: Id): { nodeId: Id; portId: Id } | undefined { for (const link of this.stateValue.topologySnapshot.links) { if (link.sourceNodeId === nodeId && link.sourcePortId === portId) { return { nodeId: link.targetNodeId, portId: link.targetPortId }; } if (link.targetNodeId === nodeId && link.targetPortId === portId) { return { nodeId: link.sourceNodeId, portId: link.sourcePortId }; } } return undefined; } private nodeSnapshot(nodeId: Id): NodeSnapshot | undefined { return this.stateValue.topologySnapshot.nodes.find((node) => node.id === nodeId); } private receiveEthernetFrame(frame: EthernetFrame, nodeId: Id, incomingPortId: Id): void { this.recordTrace({ frameIdentity: frame.identity, packetIdentity: packetIdentityInFrame(frame), nodeId, interfaceId: incomingPortId, direction: 'inbound', layer: 'physical', operation: 'received', beforeHeaders: ethernetHeaders(frame), }); const node = this.nodeSnapshot(nodeId); if (node === undefined) return; if (node.kind === 'networkSwitch') { this.forwardFrameThroughSwitch(frame, node, incomingPortId); return; } if (node.kind === 'remoteLink') throw new NotPortedError('remote links'); this.recordTrace({ frameIdentity: frame.identity, packetIdentity: packetIdentityInFrame(frame), nodeId, interfaceId: incomingPortId, direction: 'inbound', layer: 'dataLink', operation: 'received', beforeHeaders: ethernetHeaders(frame), }); const networkInterface = this.networkInterfaces(nodeId).find((candidate) => candidate.portId === incomingPortId); if (networkInterface === undefined) return; const destination = frame.destinationMacAddress.toUpperCase(); if (destination !== networkInterface.macAddress.toUpperCase() && destination !== ETHERNET_BROADCAST_MAC_ADDRESS) { this.recordTrace({ frameIdentity: frame.identity, packetIdentity: packetIdentityInFrame(frame), nodeId, interfaceId: incomingPortId, direction: 'inbound', layer: 'dataLink', operation: 'dropped', beforeHeaders: ethernetHeaders(frame), detail: 'destination MAC does not match interface', }); return; } switch (frame.payload.kind) { case 'arp': this.receiveArpPacket(frame.payload.packet, frame.identity, nodeId, networkInterface); return; case 'ipv4': this.receiveIPv4Packet(frame.payload.packet, frame.identity, nodeId, networkInterface); return; } } // ── Switch ─────────────────────────────────────────────────────────────── private forwardFrameThroughSwitch(frame: EthernetFrame, switchNode: NodeSnapshot, incomingPortId: Id): void { const state = this.stateValue; const seen = state.switchSeenFrameIdentitiesByNodeId.get(switchNode.id); if (seen?.has(frame.identity) === true) { this.recordTrace({ frameIdentity: frame.identity, packetIdentity: packetIdentityInFrame(frame), nodeId: switchNode.id, interfaceId: incomingPortId, direction: 'inbound', layer: 'dataLink', operation: 'dropped', beforeHeaders: ethernetHeaders(frame), detail: 'switch cycle suppression', }); return; } getOrCreate(state.switchSeenFrameIdentitiesByNodeId, switchNode.id, () => new Set()).add(frame.identity); this.ageSwitchSatEntries(switchNode.id); const source = frame.sourceMacAddress.toUpperCase(); getOrCreate(state.switchForwardingTablesByNodeId, switchNode.id, () => new Map()).set(source, incomingPortId); getOrCreate(state.switchForwardingUpdatedAtMillisecondsByNodeId, switchNode.id, () => new Map()).set( source, state.currentTimeMilliseconds, ); this.recordTrace({ frameIdentity: frame.identity, packetIdentity: packetIdentityInFrame(frame), nodeId: switchNode.id, interfaceId: incomingPortId, direction: 'inbound', layer: 'dataLink', operation: 'accepted', beforeHeaders: ethernetHeaders(frame), detail: 'switch learned source MAC', }); const destination = frame.destinationMacAddress.toUpperCase(); const connectedPorts = switchNode.ports .filter((port) => port.id !== incomingPortId && this.attachedEndpoint(switchNode.id, port.id) !== undefined) .map((port) => port.id); let outgoingPorts: Id[]; let forwardingDetail: string; const learnedPort = state.switchForwardingTablesByNodeId.get(switchNode.id)?.get(destination); if (destination === ETHERNET_BROADCAST_MAC_ADDRESS) { outgoingPorts = connectedPorts; forwardingDetail = 'switch broadcast flooding'; } else if (learnedPort !== undefined) { if (learnedPort === incomingPortId) { this.recordTrace({ frameIdentity: frame.identity, packetIdentity: packetIdentityInFrame(frame), nodeId: switchNode.id, interfaceId: incomingPortId, direction: 'inbound', layer: 'dataLink', operation: 'dropped', beforeHeaders: ethernetHeaders(frame), detail: 'switch filtered destination on incoming port', }); return; } if (this.attachedEndpoint(switchNode.id, learnedPort) !== undefined) { outgoingPorts = [learnedPort]; forwardingDetail = 'switch learned unicast forwarding'; } else { state.switchForwardingTablesByNodeId.get(switchNode.id)?.delete(destination); state.switchForwardingUpdatedAtMillisecondsByNodeId.get(switchNode.id)?.delete(destination); outgoingPorts = connectedPorts; forwardingDetail = 'switch unknown unicast flooding'; } } else { outgoingPorts = connectedPorts; forwardingDetail = 'switch unknown unicast flooding'; } for (const portId of outgoingPorts) { this.recordTrace({ frameIdentity: frame.identity, packetIdentity: packetIdentityInFrame(frame), nodeId: switchNode.id, interfaceId: portId, direction: 'outbound', layer: 'dataLink', operation: 'forwarded', afterHeaders: ethernetHeaders(frame), detail: forwardingDetail, }); this.sendEthernetFrame(switchNode.id, portId, frame); } } clearSwitchSat(nodeId: Id): void { this.stateValue.switchForwardingTablesByNodeId.delete(nodeId); this.stateValue.switchForwardingUpdatedAtMillisecondsByNodeId.delete(nodeId); } /** The switch's source address table (Java "SAT"), sorted by MAC address. */ switchSatEntries(nodeId: Id): SwitchSatEntry[] { const node = this.nodeSnapshot(nodeId); if (node === undefined || node.kind !== 'networkSwitch') return []; const labelsByPortId = new Map(node.ports.map((port) => [port.id, port.label])); const updatedAtByMac = this.stateValue.switchForwardingUpdatedAtMillisecondsByNodeId.get(nodeId) ?? new Map(); const table = this.stateValue.switchForwardingTablesByNodeId.get(nodeId) ?? new Map(); return [...table] .map(([macAddress, portId]) => ({ macAddress, portId, portLabel: labelsByPortId.get(portId) ?? '?', updatedAtMilliseconds: updatedAtByMac.get(macAddress) ?? 0, })) .sort((lhs, rhs) => lhs.macAddress === rhs.macAddress ? compareIds(lhs.portId, rhs.portId) : lhs.macAddress < rhs.macAddress ? -1 : 1, ); } private ageAllSwitchSatEntries(): void { for (const nodeId of [...this.stateValue.switchForwardingTablesByNodeId.keys()]) { this.ageSwitchSatEntries(nodeId); } } private ageSwitchSatEntries(nodeId: Id): void { const state = this.stateValue; const retention = state.topologySnapshot.switchConfigurationsByNodeId.get(nodeId)?.retentionTimeMilliseconds ?? DEFAULT_SWITCH_RETENTION_MILLISECONDS; const updatedAtByMac = state.switchForwardingUpdatedAtMillisecondsByNodeId.get(nodeId); if (updatedAtByMac === undefined) return; const expired: string[] = []; for (const [macAddress, updatedAt] of updatedAtByMac) { if (state.currentTimeMilliseconds >= updatedAt && state.currentTimeMilliseconds - updatedAt >= retention) { expired.push(macAddress); } } if (expired.length === 0) return; for (const macAddress of expired) { state.switchForwardingTablesByNodeId.get(nodeId)?.delete(macAddress); state.switchForwardingUpdatedAtMillisecondsByNodeId.get(nodeId)?.delete(macAddress); } if (state.switchForwardingTablesByNodeId.get(nodeId)?.size === 0) { state.switchForwardingTablesByNodeId.delete(nodeId); } if (state.switchForwardingUpdatedAtMillisecondsByNodeId.get(nodeId)?.size === 0) { state.switchForwardingUpdatedAtMillisecondsByNodeId.delete(nodeId); } } // ── ARP ────────────────────────────────────────────────────────────────── private receiveArpPacket( packet: ArpPacket, frameIdentity: number, nodeId: Id, networkInterface: NetworkInterface, ): void { if (packet.senderIPAddress !== UNSPECIFIED_IP_ADDRESS) { getOrCreate(this.stateValue.arpCachesByNodeId, nodeId, () => new Map()).set(packet.senderIPAddress, { ipAddress: packet.senderIPAddress, macAddress: packet.senderMacAddress, updatedAtMilliseconds: this.stateValue.currentTimeMilliseconds, }); // Java's waiting thread wakes when the ARP thread is done (Swift, for the comparison: at once). this.completeArpLookups(nodeId, packet.senderIPAddress, packet.senderMacAddress, true); if (this.arpLookups.some((lookup) => lookup.nodeId === nodeId && lookup.ipAddress === packet.senderIPAddress)) { this.schedule(this.stateValue.currentTimeMilliseconds, { kind: 'arpAnswered', nodeId, ipAddress: packet.senderIPAddress, macAddress: packet.senderMacAddress, }); } this.dhcpClientLearnedAddress(nodeId, packet.senderIPAddress); } this.recordTrace({ frameIdentity, nodeId, interfaceId: networkInterface.portId, direction: 'inbound', layer: 'network', operation: 'accepted', beforeHeaders: arpHeaders(packet), }); if (packet.operation !== 'request' || packet.targetIPAddress !== networkInterface.ipAddress) return; const unspecifiedSender = packet.senderIPAddress === UNSPECIFIED_IP_ADDRESS; const replyTargetIPAddress = unspecifiedSender ? LIMITED_BROADCAST_IP_ADDRESS : packet.senderIPAddress; const replyTargetMacAddress = unspecifiedSender ? ETHERNET_BROADCAST_MAC_ADDRESS : packet.senderMacAddress; const reply: ArpPacket = { operation: 'reply', senderMacAddress: networkInterface.macAddress, senderIPAddress: networkInterface.ipAddress, targetMacAddress: replyTargetMacAddress, targetIPAddress: replyTargetIPAddress, }; const replyFrame: EthernetFrame = { identity: this.allocateFrameIdentity(), sourceMacAddress: networkInterface.macAddress, destinationMacAddress: replyTargetMacAddress, payload: { kind: 'arp', packet: reply }, }; this.recordTrace({ frameIdentity: replyFrame.identity, nodeId, interfaceId: networkInterface.portId, direction: 'outbound', layer: 'dataLink', operation: 'created', afterHeaders: ethernetHeaders(replyFrame), detail: `ARP reply for ${networkInterface.ipAddress}`, }); this.sendEthernetFrame(nodeId, networkInterface.portId, replyFrame); } // ── IPv4 ───────────────────────────────────────────────────────────────── sendIPv4Packet(fromNodeId: Id, packet: IPv4Packet, preferredInterfaceId?: Id): IPv4DeliveryResult { return this.sendIPv4PacketWithOptions(fromNodeId, packet, preferredInterfaceId, true, 'sent'); } updateDeviceConfiguration(nodeId: Id, configuration: DeviceConfiguration): void { const state = this.stateValue; if (state.phase !== 'running' || !state.topologySnapshot.nodes.some((node) => node.id === nodeId)) return; const deviceConfigurations = new Map(state.topologySnapshot.deviceConfigurations); deviceConfigurations.set(nodeId, configuration); state.topologySnapshot = withSnapshot(state.topologySnapshot, { deviceConfigurations }); } /** Send one ICMP echo request and wait (virtually) for its answer. */ sendIcmpEcho( fromNodeId: Id, targetIPAddress: string, options: { timeToLive?: number; identifier?: number; sequenceNumber?: number; data?: Uint8Array } = {}, ): IPv4DeliveryResult { const timeToLive = options.timeToLive ?? 64; const identifier = options.identifier ?? 1; const sequenceNumber = options.sequenceNumber ?? 1; const interfaces = this.networkInterfaces(fromNodeId); const firstInterface = interfaces[0]; if (firstInterface === undefined) return { type: 'dropped', packetIdentity: 0, nodeId: fromNodeId }; const route = this.routeForPacket(fromNodeId, targetIPAddress); let sourceInterface: NetworkInterface; switch (route.type) { case 'local': sourceInterface = route.interface ?? firstInterface; break; case 'egress': sourceInterface = route.interface; break; case 'broadcast': case 'unavailable': sourceInterface = firstInterface; break; } const packet: IPv4Packet = { identity: this.allocatePacketIdentity(), senderIPAddress: sourceInterface.ipAddress, receiverIPAddress: targetIPAddress, timeToLive, protocol: 'icmp', payload: { kind: 'icmp', message: icmpMessage('echoRequest', { identifier, sequenceNumber, data: options.data ?? new Uint8Array() }), }, }; this.recordTrace({ packetIdentity: packet.identity, nodeId: fromNodeId, interfaceId: sourceInterface.portId, direction: 'outbound', layer: 'network', operation: 'created', afterHeaders: ipv4Headers(packet), detail: 'ICMP echo request', }); const observationCount = this.stateValue.icmpObservationsByNodeId.get(fromNodeId)?.length ?? 0; const answered = () => (this.stateValue.icmpObservationsByNodeId.get(fromNodeId) ?? []) .slice(observationCount) .some((observation) => observation.message.identifier === identifier && observation.message.sequenceNumber === sequenceNumber); let sent: IPv4DeliveryResult | undefined; let sentAt = this.stateValue.currentTimeMilliseconds; const handedOn = this.sendIPv4PacketWithOptions(fromNodeId, packet, sourceInterface.portId, true, 'sent', { onFinished: (outcome) => { sent = outcome; sentAt = this.stateValue.currentTimeMilliseconds; }, }); if (!this.isProcessingScheduledEvents) { // Java's sendProbe: the caller waits for ARP (up to two round trips), // then a round trip for the answer (ICMPThread.waitForIcmpResponse), // counted from when the packet left — the engine may have run past it. const rtt = this.runtimeSpeed.roundTripTimeMilliseconds; this.advanceUntil(() => sent !== undefined, this.stateValue.currentTimeMilliseconds + (ARP_ATTEMPTS + 1) * rtt); if (sentAt + rtt > this.stateValue.currentTimeMilliseconds) this.advanceUntil(answered, sentAt + rtt); } const result = sent ?? handedOn; const newObservations = (this.stateValue.icmpObservationsByNodeId.get(fromNodeId) ?? []).slice(observationCount); const observation = newObservations.findLast( (candidate) => candidate.message.identifier === identifier && candidate.message.sequenceNumber === sequenceNumber, ); if (observation !== undefined) { switch (observation.message.kind) { case 'echoReply': return { type: 'delivered', packetIdentity: packet.identity, nodeId: observation.nodeId }; case 'destinationNetworkUnreachable': case 'destinationHostUnreachable': case 'timeExceeded': return { type: 'icmpError', packetIdentity: packet.identity, nodeId: observation.nodeId, kind: observation.message.kind, }; case 'echoRequest': break; } } return result; } /** `traceroute`: echoes with TTL 1, 2, … until one is answered. */ traceIcmpEcho( fromNodeId: Id, targetIPAddress: string, options: { maximumTimeToLive?: number; identifier?: number } = {}, ): IcmpObservation[] { const maximumTimeToLive = options.maximumTimeToLive ?? 64; const identifier = options.identifier ?? 1; const observations: IcmpObservation[] = []; for (let ttl = 1; ttl <= maximumTimeToLive; ttl += 1) { const previousCount = this.stateValue.icmpObservationsByNodeId.get(fromNodeId)?.length ?? 0; this.sendIcmpEcho(fromNodeId, targetIPAddress, { timeToLive: ttl, identifier, sequenceNumber: ttl }); const newItems = (this.stateValue.icmpObservationsByNodeId.get(fromNodeId) ?? []).slice(previousCount); const observation = newItems.at(-1); if (observation === undefined) continue; observations.push(observation); if (observation.message.kind === 'echoReply') break; } return observations; } /** The nodes a packet passed, in the order it first reached them. */ tracedNodePath(packetIdentity: number): Id[] { const result: Id[] = []; for (const trace of this.stateValue.packetTraces) { if (trace.packetIdentity !== packetIdentity || result.includes(trace.nodeId)) continue; result.push(trace.nodeId); } return result; } private sendIPv4PacketWithOptions( fromNodeId: Id, packet: IPv4Packet, preferredInterfaceId: Id | undefined, allowIcmpErrorGeneration: boolean, operation: TraceOperation, callbacks: IPv4SendCallbacks = {}, ): IPv4DeliveryResult { const route = this.routeForPacket(fromNodeId, packet.receiverIPAddress, preferredInterfaceId); if (route.type === 'egress') return this.sendIPv4ToNextHop(fromNodeId, packet, route, allowIcmpErrorGeneration, operation, callbacks); const result = this.sendIPv4Directly(fromNodeId, packet, route, preferredInterfaceId, allowIcmpErrorGeneration, operation); callbacks.onFinished?.(result); return result; } /** * To the next hop, once ARP knows it; until then the packet waits for its * lookup and the result says it was handed on. When ARP finds no one: host * unreachable — observed at the sender itself, or sent back by the router * (onUnresolved). */ private sendIPv4ToNextHop( fromNodeId: Id, packet: IPv4Packet, route: { readonly interface: NetworkInterface; readonly nextHop: string }, allowIcmpErrorGeneration: boolean, operation: TraceOperation, callbacks: IPv4SendCallbacks, ): IPv4DeliveryResult { let finished: IPv4DeliveryResult | undefined; this.lookUpMacAddress(fromNodeId, route.nextHop, route.interface, ARP_ATTEMPTS, (macAddress) => { if (macAddress !== undefined) { this.transmitIPv4Packet(fromNodeId, packet, route.interface, macAddress, operation); finished = { type: 'delivered', packetIdentity: packet.identity, nodeId: fromNodeId }; } else { this.recordTrace({ packetIdentity: packet.identity, nodeId: fromNodeId, interfaceId: route.interface.portId, direction: 'outbound', layer: 'network', operation: 'dropped', beforeHeaders: ipv4Headers(packet), detail: `no ARP answer for ${route.nextHop}`, }); if (allowIcmpErrorGeneration) { this.observeLocalIcmpError(fromNodeId, route.interface.portId, 'destinationHostUnreachable', packet); finished = { type: 'icmpError', packetIdentity: packet.identity, nodeId: fromNodeId, kind: 'destinationHostUnreachable' }; } else { callbacks.onUnresolved?.(); finished = { type: 'dropped', packetIdentity: packet.identity, nodeId: fromNodeId }; } } callbacks.onFinished?.(finished); }); if (finished === undefined && this.swiftArp && this.isProcessingScheduledEvents) { this.recordTrace({ packetIdentity: packet.identity, nodeId: fromNodeId, interfaceId: route.interface.portId, direction: 'outbound', layer: 'network', operation: 'created', beforeHeaders: ipv4Headers(packet), detail: `waiting for ARP resolution of ${route.nextHop}`, }); } return finished ?? { type: 'delivered', packetIdentity: packet.identity, nodeId: fromNodeId }; } private sendIPv4Directly( fromNodeId: Id, packet: IPv4Packet, route: PacketRoute, preferredInterfaceId: Id | undefined, allowIcmpErrorGeneration: boolean, operation: TraceOperation, ): IPv4DeliveryResult { switch (route.type) { case 'local': { const networkInterface = route.interface ?? this.networkInterfaces(fromNodeId)[0]; if (networkInterface === undefined) return { type: 'dropped', packetIdentity: packet.identity, nodeId: fromNodeId }; this.receiveIPv4Packet(packet, undefined, fromNodeId, networkInterface); return { type: 'delivered', packetIdentity: packet.identity, nodeId: fromNodeId }; } case 'broadcast': { const interfaces = this.networkInterfaces(fromNodeId); if (interfaces.length === 0) return { type: 'dropped', packetIdentity: packet.identity, nodeId: fromNodeId }; const broadcastPacket: IPv4Packet = { ...packet, timeToLive: 1 }; for (const networkInterface of interfaces) { const frame: EthernetFrame = { identity: this.allocateFrameIdentity(), sourceMacAddress: networkInterface.macAddress, destinationMacAddress: ETHERNET_BROADCAST_MAC_ADDRESS, payload: { kind: 'ipv4', packet: broadcastPacket }, }; this.recordIPv4Send(broadcastPacket, frame, fromNodeId, networkInterface.portId, operation); this.sendEthernetFrame(fromNodeId, networkInterface.portId, frame); } return { type: 'delivered', packetIdentity: packet.identity, nodeId: fromNodeId }; } case 'egress': return this.sendIPv4ToNextHop(fromNodeId, packet, route, allowIcmpErrorGeneration, operation, {}); case 'unavailable': if (allowIcmpErrorGeneration) { this.observeLocalIcmpError(fromNodeId, preferredInterfaceId, 'destinationNetworkUnreachable', packet); return { type: 'icmpError', packetIdentity: packet.identity, nodeId: fromNodeId, kind: 'destinationNetworkUnreachable', }; } return { type: 'dropped', packetIdentity: packet.identity, nodeId: fromNodeId }; } } private transmitIPv4Packet( fromNodeId: Id, packet: IPv4Packet, networkInterface: NetworkInterface, destinationMacAddress: string, operation: TraceOperation, ): void { const frame: EthernetFrame = { identity: this.allocateFrameIdentity(), sourceMacAddress: networkInterface.macAddress, destinationMacAddress, payload: { kind: 'ipv4', packet }, }; this.recordIPv4Send(packet, frame, fromNodeId, networkInterface.portId, operation); this.sendEthernetFrame(fromNodeId, networkInterface.portId, frame); } private receiveIPv4Packet( packet: IPv4Packet, frameIdentity: number | undefined, nodeId: Id, networkInterface: NetworkInterface, ): void { this.recordTrace({ frameIdentity, packetIdentity: packet.identity, nodeId, interfaceId: networkInterface.portId, direction: 'inbound', layer: 'network', operation: 'received', beforeHeaders: ipv4Headers(packet), }); const node = this.nodeSnapshot(nodeId); if (node === undefined) return; // Swift: the gateway branch (WAN/LAN, firewall, NAT rewrite) comes here. if (node.kind === 'gateway') throw new NotPortedError('gateway NAT'); if (node.kind === 'router') { const decision = this.evaluateFirewall(packet, nodeId, networkInterface.portId, frameIdentity); if (!decision.accepted) return; } const packetForProcessing = packet; const localAddresses = new Set(this.networkInterfaces(nodeId).map((candidate) => candidate.ipAddress)); if ( localAddresses.has(packetForProcessing.receiverIPAddress) || packetForProcessing.receiverIPAddress === LIMITED_BROADCAST_IP_ADDRESS ) { if ( isPcClassEndpoint(node.kind) && this.stateValue.topologySnapshot.firewallConfigurationsByNodeId.has(nodeId) ) { const decision = this.evaluateFirewall(packetForProcessing, nodeId, networkInterface.portId, frameIdentity); if (!decision.accepted) return; } this.deliverIPv4Locally(packetForProcessing, nodeId, networkInterface); return; } if (node.kind !== 'router') { this.recordTrace({ frameIdentity, packetIdentity: packetForProcessing.identity, nodeId, interfaceId: networkInterface.portId, direction: 'inbound', layer: 'network', operation: 'dropped', beforeHeaders: ipv4Headers(packetForProcessing), detail: 'IPv4 forwarding disabled', }); return; } if (packetForProcessing.timeToLive <= 1) { this.emitIcmpError(nodeId, networkInterface, 'timeExceeded', packet); return; } const forwarded = forwardingClone(packetForProcessing); this.recordTrace({ frameIdentity, packetIdentity: forwarded.identity, nodeId, interfaceId: networkInterface.portId, direction: 'outbound', layer: 'network', operation: 'forwarded', beforeHeaders: ipv4Headers(packetForProcessing), afterHeaders: ipv4Headers(forwarded), detail: 'TTL decremented', }); // Java: a router whose ARP finds no one answers host unreachable; for // ICMP only an echo request (ICMP.sendUnicastToNextHop). const echoOrNotIcmp = packet.payload.kind !== 'icmp' || packet.payload.message.kind === 'echoRequest'; const result = this.sendIPv4PacketWithOptions(nodeId, forwarded, undefined, false, 'forwarded', { onUnresolved: () => { if (echoOrNotIcmp) this.emitIcmpError(nodeId, networkInterface, 'destinationHostUnreachable', packet); }, }); if (result.type === 'dropped' && this.routeForPacket(nodeId, packetForProcessing.receiverIPAddress).type === 'unavailable') { this.emitIcmpError(nodeId, networkInterface, 'destinationNetworkUnreachable', packet); } } private deliverIPv4Locally(packet: IPv4Packet, nodeId: Id, networkInterface: NetworkInterface): void { const payload = packet.payload; switch (payload.kind) { case 'icmp': { const message = payload.message; switch (message.kind) { case 'echoRequest': { if (packet.receiverIPAddress === LIMITED_BROADCAST_IP_ADDRESS && packet.timeToLive !== 1) return; const reply: IPv4Packet = { identity: this.allocatePacketIdentity(), senderIPAddress: networkInterface.ipAddress, receiverIPAddress: packet.senderIPAddress, timeToLive: 64, protocol: 'icmp', payload: { kind: 'icmp', message: icmpMessage('echoReply', { identifier: message.identifier, sequenceNumber: message.sequenceNumber, data: message.data, }), }, }; this.recordTrace({ packetIdentity: reply.identity, nodeId, interfaceId: networkInterface.portId, direction: 'outbound', layer: 'network', operation: 'created', afterHeaders: ipv4Headers(reply), detail: 'ICMP echo reply', }); this.sendIPv4PacketWithOptions(nodeId, reply, undefined, false, 'sent'); return; } case 'echoReply': case 'destinationNetworkUnreachable': case 'destinationHostUnreachable': case 'timeExceeded': getOrCreate(this.stateValue.icmpObservationsByNodeId, nodeId, () => []).push({ nodeId, interfaceId: networkInterface.portId, packet, message, }); this.recordTrace({ packetIdentity: packet.identity, nodeId, interfaceId: networkInterface.portId, direction: 'local', layer: 'transport', operation: 'accepted', beforeHeaders: icmpHeaders(message), detail: 'ICMP observation queued', }); return; } return; } case 'udp': getOrCreate(this.stateValue.deliveredIPv4PacketsByNodeId, nodeId, () => []).push({ nodeId, interfaceId: networkInterface.portId, packet, }); this.deliverUdp(packet, payload.datagram, nodeId, networkInterface); return; case 'tcp': getOrCreate(this.stateValue.deliveredIPv4PacketsByNodeId, nodeId, () => []).push({ nodeId, interfaceId: networkInterface.portId, packet, }); this.deliverTcp(packet, payload.segment, nodeId, networkInterface); return; } } private emitIcmpError( fromNodeId: Id, incomingInterface: NetworkInterface, kind: IcmpMessageKind, originalPacket: IPv4Packet, ): void { if ( originalPacket.senderIPAddress === UNSPECIFIED_IP_ADDRESS || originalPacket.senderIPAddress === LIMITED_BROADCAST_IP_ADDRESS ) { return; } const errorPacket: IPv4Packet = { identity: this.allocatePacketIdentity(), senderIPAddress: incomingInterface.ipAddress, receiverIPAddress: originalPacket.senderIPAddress, timeToLive: 64, protocol: 'icmp', payload: { kind: 'icmp', message: icmpMessage(kind, { identifier: icmpIdentifier(originalPacket), sequenceNumber: icmpSequenceNumber(originalPacket), embeddedOriginalPacket: embeddedOriginal(originalPacket), }), }, }; this.recordTrace({ packetIdentity: errorPacket.identity, nodeId: fromNodeId, interfaceId: incomingInterface.portId, direction: 'outbound', layer: 'network', operation: 'created', afterHeaders: ipv4Headers(errorPacket), detail: `ICMP ${kind}`, }); this.sendIPv4PacketWithOptions(fromNodeId, errorPacket, undefined, false, 'sent'); } private observeLocalIcmpError( nodeId: Id, interfaceId: Id | undefined, kind: IcmpMessageKind, originalPacket: IPv4Packet, ): void { const message = icmpMessage(kind, { identifier: icmpIdentifier(originalPacket), sequenceNumber: icmpSequenceNumber(originalPacket), embeddedOriginalPacket: embeddedOriginal(originalPacket), }); const packet: IPv4Packet = { identity: this.allocatePacketIdentity(), senderIPAddress: this.networkInterfaces(nodeId)[0]?.ipAddress ?? UNSPECIFIED_IP_ADDRESS, receiverIPAddress: originalPacket.senderIPAddress, timeToLive: 64, protocol: 'icmp', payload: { kind: 'icmp', message }, }; getOrCreate(this.stateValue.icmpObservationsByNodeId, nodeId, () => []).push({ nodeId, ...(interfaceId === undefined ? {} : { interfaceId }), packet, message, }); this.recordTrace({ packetIdentity: packet.identity, nodeId, interfaceId, direction: 'local', layer: 'network', operation: 'created', afterHeaders: ipv4Headers(packet), detail: `ICMP ${kind}`, }); } // ── Routing ────────────────────────────────────────────────────────────── private routeForPacket(nodeId: Id, targetIPAddress: string, preferredInterfaceId?: Id): PacketRoute { const interfaces = this.networkInterfaces(nodeId); if (targetIPAddress === LIMITED_BROADCAST_IP_ADDRESS) return { type: 'broadcast' }; if (targetIPAddress === LOCALHOST_IP_ADDRESS) return { type: 'local', interface: interfaces[0] }; const local = interfaces.find((candidate) => candidate.ipAddress === targetIPAddress); if (local !== undefined) return { type: 'local', interface: local }; if (this.isRipEnabledRouter(nodeId)) throw new NotPortedError('RIP routing'); if (preferredInterfaceId !== undefined) { const preferred = interfaces.find((candidate) => candidate.portId === preferredInterfaceId); if (preferred !== undefined && sameSubnet(preferred.ipAddress, targetIPAddress, preferred.subnetMask)) { return { type: 'egress', interface: preferred, nextHop: targetIPAddress }; } } const snapshot = this.stateValue.topologySnapshot; const rows = routeTableRows( interfaces.map((candidate) => ({ ipAddress: candidate.ipAddress, subnetMask: candidate.subnetMask })), snapshot.manualRoutesByNodeId.get(nodeId) ?? [], snapshot.deviceConfigurations.get(nodeId)?.defaultGateway, ); const target = parseIPv4(targetIPAddress); if (target === undefined) return { type: 'unavailable' }; let selected: RouteRow | undefined; let selectedMask: number | undefined; for (const row of rows) { const destination = parseIPv4(row.destinationNetwork); const mask = parseIPv4(row.subnetMask); if (destination === undefined || mask === undefined || destination !== maskAddress(target, mask)) continue; if (selectedMask !== undefined && mask <= selectedMask) continue; selected = row; selectedMask = mask; } if (selected === undefined) return { type: 'unavailable' }; if (selected.interfaceIPAddress === LOCALHOST_IP_ADDRESS) { return { type: 'local', interface: interfaces.find((candidate) => candidate.ipAddress === targetIPAddress) ?? interfaces[0], }; } const egress = interfaces.find((candidate) => candidate.ipAddress === selected.interfaceIPAddress); if (egress === undefined) return { type: 'unavailable' }; const nextHop = sameSubnet(egress.ipAddress, targetIPAddress, egress.subnetMask) ? targetIPAddress : selected.nextHop; return { type: 'egress', interface: egress, nextHop }; } private recordIPv4Send( packet: IPv4Packet, frame: EthernetFrame, nodeId: Id, interfaceId: Id, operation: TraceOperation, ): void { this.recordTrace({ frameIdentity: frame.identity, packetIdentity: packet.identity, nodeId, interfaceId, direction: 'outbound', layer: 'network', operation, afterHeaders: ipv4Headers(packet), }); this.recordTrace({ frameIdentity: frame.identity, packetIdentity: packet.identity, nodeId, interfaceId, direction: 'outbound', layer: 'dataLink', operation: 'created', afterHeaders: ethernetHeaders(frame), }); } // ── Firewall ───────────────────────────────────────────────────────────── setFirewallConfiguration(nodeId: Id, configuration: FirewallConfiguration): void { const configurations = new Map(this.stateValue.topologySnapshot.firewallConfigurationsByNodeId); configurations.set(nodeId, configuration); this.stateValue.topologySnapshot = withSnapshot(this.stateValue.topologySnapshot, { firewallConfigurationsByNodeId: configurations, }); } /** * Java Firewall: inactive accepts; ICMP obeys only the general setting; * TCP (optionally only opening SYNs) and UDP go through the ordered rules, * first match wins; otherwise the default policy. Every decision is * recorded, including "firewall inactive". */ evaluateFirewall(packet: IPv4Packet, nodeId: Id, incomingInterfaceId?: Id, frameIdentity?: number): FirewallDecision { const configuration = this.stateValue.topologySnapshot.firewallConfigurationsByNodeId.get(nodeId) ?? firewallConfiguration(); const decide = (accepted: boolean, ruleIndex: number | undefined, detail: string): FirewallDecision => this.recordFirewallDecision(packet, frameIdentity, nodeId, incomingInterfaceId, accepted, ruleIndex, detail); if (!configuration.isActive) return decide(true, undefined, 'firewall inactive'); if (packet.protocol === 'icmp') { return decide( !configuration.dropIcmp, undefined, configuration.dropIcmp ? 'ICMP blocked by general setting' : 'ICMP bypasses ordered rules', ); } const payload = packet.payload; switch (payload.kind) { case 'tcp': { const flags = payload.segment.flags; const isOpeningSyn = (flags & TCP_FLAG.synchronize) !== 0 && (flags & TCP_FLAG.acknowledgement) === 0; if (configuration.filterSynSegmentsOnly && !isOpeningSyn) { return decide(true, undefined, 'established TCP traffic bypasses SYN-only filtering'); } for (const [index, rule] of configuration.rules.entries()) { if (!firewallProtocolMatches(packet.protocol, rule)) continue; if ( !this.firewallEndpointsMatch( packet.senderIPAddress, packet.receiverIPAddress, payload.segment.destinationPort, rule, nodeId, ) ) { continue; } return decide(rule.action === 'accept', index, `TCP matched rule #${index + 1}`); } break; } case 'udp': { if (!configuration.filterUdp) return decide(true, undefined, 'UDP filtering disabled'); const datagram = payload.datagram; for (const [index, rule] of configuration.rules.entries()) { if (!firewallProtocolMatches(packet.protocol, rule)) continue; const requestMatches = this.firewallEndpointsMatch( packet.senderIPAddress, packet.receiverIPAddress, datagram.destinationPort, rule, nodeId, ); const responseMatches = rule.port !== ALL_PORTS && this.firewallEndpointsMatch( packet.receiverIPAddress, packet.senderIPAddress, datagram.sourcePort, rule, nodeId, ); if (!requestMatches && !responseMatches) continue; return decide(rule.action === 'accept', index, `UDP matched rule #${index + 1}`); } break; } case 'icmp': break; } return decide(configuration.defaultPolicy === 'accept', undefined, 'firewall default policy'); } private recordFirewallDecision( packet: IPv4Packet, frameIdentity: number | undefined, nodeId: Id, interfaceId: Id | undefined, accepted: boolean, ruleIndex: number | undefined, detail: string, ): FirewallDecision { const decision: FirewallDecision = { packetIdentity: packet.identity, nodeId, accepted, ...(ruleIndex === undefined ? {} : { ruleIndex }), }; this.stateValue.firewallDecisions.push(decision); this.recordTrace({ frameIdentity, packetIdentity: packet.identity, nodeId, interfaceId, direction: 'inbound', layer: 'network', operation: accepted ? 'accepted' : 'dropped', beforeHeaders: ipv4Headers(packet), detail, }); return decision; } private firewallEndpointsMatch( sourceIPAddress: string, destinationIPAddress: string, port: number, rule: FirewallRule, nodeId: Id, ): boolean { return ( this.firewallSourceMatches(sourceIPAddress, rule, nodeId) && firewallAddressMatches(destinationIPAddress, rule.destinationIPAddress, rule.destinationSubnetMask) && (rule.port === ALL_PORTS || rule.port === port) ); } private firewallSourceMatches(sourceIPAddress: string, rule: FirewallRule, nodeId: Id): boolean { if (rule.sourceIPAddress.length === 0) return true; if (rule.sourceIPAddress === DIRECTLY_CONNECTED_SOURCE_MARKER) { return this.networkInterfaces(nodeId).some((candidate) => sameSubnet(sourceIPAddress, candidate.ipAddress, candidate.subnetMask), ); } return firewallAddressMatches(sourceIPAddress, rule.sourceIPAddress, rule.sourceSubnetMask); } /** Replace the manual routes of a router (or gateway) while the simulation runs. */ replaceManualRoutes(nodeId: Id, routes: readonly ManualRoute[]): boolean { const state = this.stateValue; const node = state.topologySnapshot.nodes.find((candidate) => candidate.id === nodeId); if (state.phase !== 'running' || node === undefined || (node.kind !== 'router' && node.kind !== 'gateway')) { return false; } const routesByNodeId = new Map(state.topologySnapshot.manualRoutesByNodeId); if (routes.length === 0) { routesByNodeId.delete(nodeId); } else { routesByNodeId.set(nodeId, [...routes]); } state.topologySnapshot = withSnapshot(state.topologySnapshot, { manualRoutesByNodeId: routesByNodeId }); return true; } } // ── Helpers ──────────────────────────────────────────────────────────────── export function stableMacAddress(portId: Id): string { return stableGeneratedMacAddress(portId); } function firewallProtocolMatches(protocol: IPv4Packet['protocol'], rule: FirewallRule): boolean { return rule.protocol === 'all' || FIREWALL_PROTOCOL[rule.protocol] === IPV4_PROTOCOL[protocol]; } function firewallAddressMatches(packetIPAddress: string, configuredIPAddress: string, subnetMask: string): boolean { if (configuredIPAddress.length === 0) return true; if (subnetMask.length === 0) return false; return sameSubnet(packetIPAddress, configuredIPAddress, subnetMask); } /** * DNSServer.answer without recursion: the records for the name and type * (with the A record of an MX or NS target), else the name server of the * zone the name lies in, with its A record — in the answer section, as Java has it. */ function answerDnsQuestion(question: DnsQuestion, records: readonly DnsMessageRecord[]): DnsMessageRecord[] { const answers: DnsMessageRecord[] = []; for (const record of findDnsRecords(question.name, question.type, records)) { answers.push(record); if (record.type === 'MX' || record.type === 'NS') { const address = findDnsRecords(record.data, 'A', records)[0]; if (address !== undefined) answers.push(address); } } if (answers.length > 0) return answers; const nameServer = findApplicableNameServerRecord(question.name, records); if (nameServer === undefined) return []; const address = findDnsRecords(nameServer.data, 'A', records)[0]; return address === undefined ? [nameServer] : [nameServer, address]; } function portKey(nodeId: Id, portId: Id): string { return `${nodeId}|${portId}`; } function bestInterface(targetIPAddress: string, interfaces: readonly NetworkInterface[]): NetworkInterface | undefined { let selected: NetworkInterface | undefined; let selectedMask: number | undefined; for (const candidate of interfaces) { const mask = parseIPv4(candidate.subnetMask); if (!sameSubnet(candidate.ipAddress, targetIPAddress, candidate.subnetMask) || mask === undefined) continue; if (selectedMask !== undefined && mask <= selectedMask) continue; selected = candidate; selectedMask = mask; } return selected; } /** An ICMP error about an ICMP error carries the packet the first one was about. */ function embeddedOriginal(packet: IPv4Packet): IPv4Packet { if (packet.payload.kind === 'icmp' && packet.payload.message.embeddedOriginalPacket !== undefined) { return packet.payload.message.embeddedOriginalPacket; } return packet; } function icmpIdentifier(packet: IPv4Packet): number { return packet.payload.kind === 'icmp' ? packet.payload.message.identifier : 0; } function icmpSequenceNumber(packet: IPv4Packet): number { return packet.payload.kind === 'icmp' ? packet.payload.message.sequenceNumber : 0; } export function ethernetHeaders(frame: EthernetFrame): HeaderField[] { return [ { name: 'sourceMAC', value: frame.sourceMacAddress }, { name: 'destinationMAC', value: frame.destinationMacAddress }, ]; } export function arpHeaders(packet: ArpPacket): HeaderField[] { return [ { name: 'operation', value: packet.operation }, { name: 'senderMAC', value: packet.senderMacAddress }, { name: 'senderIP', value: packet.senderIPAddress }, { name: 'targetMAC', value: packet.targetMacAddress }, { name: 'targetIP', value: packet.targetIPAddress }, ]; } export function ipv4Headers(packet: IPv4Packet): HeaderField[] { return [ { name: 'senderIP', value: packet.senderIPAddress }, { name: 'receiverIP', value: packet.receiverIPAddress }, { name: 'ttl', value: String(packet.timeToLive) }, { name: 'protocol', value: String(IPV4_PROTOCOL[packet.protocol]) }, ]; } function dhcpClientStatus(): DhcpClientStatus { return { state: 'INIT', errorCount: 0, selectedServerIPAddress: undefined, offeredIPAddress: undefined, succeeded: false, linkLocal: 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 ); } /** The sequence number after the segment: SYN and FIN count one, and each payload byte. */ export function nextTcpSequenceNumber(segment: TcpSegment): number { let next = segment.sequenceNumber; if ((segment.flags & (TCP_FLAG.synchronize | TCP_FLAG.finish)) !== 0) next = uint32Add(next, 1); return uint32Add(next, segment.payload.length); } function concatBytes(first: Uint8Array, second: Uint8Array): Uint8Array { const result = new Uint8Array(first.length + second.length); result.set(first); result.set(second, first.length); return result; } export function tcpFlagDescription(flags: number): string { const names: string[] = []; if ((flags & TCP_FLAG.finish) !== 0) names.push('FIN'); if ((flags & TCP_FLAG.synchronize) !== 0) names.push('SYN'); if ((flags & TCP_FLAG.reset) !== 0) names.push('RST'); if ((flags & TCP_FLAG.push) !== 0) names.push('PSH'); if ((flags & TCP_FLAG.acknowledgement) !== 0) names.push('ACK'); return names.join('+'); } export function tcpHeaders(segment: TcpSegment): HeaderField[] { return [ { name: 'sourcePort', value: String(segment.sourcePort) }, { name: 'destinationPort', value: String(segment.destinationPort) }, { name: 'sequenceNumber', value: String(segment.sequenceNumber) }, { name: 'acknowledgementNumber', value: String(segment.acknowledgementNumber) }, { name: 'flags', value: tcpFlagDescription(segment.flags) }, { name: 'payloadLength', value: String(segment.payload.length) }, ]; } export function udpHeaders(datagram: UdpDatagram): HeaderField[] { return [ { name: 'sourcePort', value: String(datagram.sourcePort) }, { name: 'destinationPort', value: String(datagram.destinationPort) }, { name: 'payloadLength', value: String(datagram.payload.length) }, ]; } export function icmpHeaders(message: IcmpMessage): HeaderField[] { return [ { name: 'kind', value: message.kind }, { name: 'identifier', value: String(message.identifier) }, { name: 'sequence', value: String(message.sequenceNumber) }, ]; }