Files
mdg-filius/packages/core/src/runtime/engine.ts
T
julian.kiedaischandClaude Opus 5.5 a88fa1512c feat(core): sends return at once and go through Java's transport thread
A send hands its frame to the card and returns, as in Java; the frames
travel while the clock advances. Swift's send ran the network until every
frame had arrived; the option swiftSends keeps that for the Swift
comparison. Waiting calls (TCP connect, acknowledgement) now run event by
event until the answer instead of to the timeout.

UDP and TCP send through a thread per node and protocol (Java's
TransportProtokoll): one segment after the other; while IP waits for ARP,
the next send blocks and a segment from an event queues behind it. Senders
that wait take their turns in the order they came (a fair lock; Java's
leaves the order to chance).

All four ArpRun stories now match Java FILIUS, including when each call
returns. Tests let frames arrive explicitly with letFramesArrive.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 17:49:05 +00:00

4286 lines
175 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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<Id, Map<string, ArpCacheEntry>>;
switchForwardingTablesByNodeId: Map<Id, Map<string, Id>>;
switchForwardingUpdatedAtMillisecondsByNodeId: Map<Id, Map<string, number>>;
switchSeenFrameIdentitiesByNodeId: Map<Id, Set<number>>;
deliveredIPv4PacketsByNodeId: Map<Id, DeliveredIPv4Packet[]>;
icmpObservationsByNodeId: Map<Id, IcmpObservation[]>;
firewallDecisions: FirewallDecision[];
socketsById: Map<Id, SocketRecord>;
udpReceiveQueuesBySocketId: Map<Id, UdpReceivedDatagram[]>;
tcpSessionsById: Map<Id, TcpSession>;
tcpAcceptedSocketIdsByListenerId: Map<Id, Id[]>;
dhcpOffersByIPAddress: Map<string, DhcpOfferRecord>;
dhcpLeasesByIPAddress: Map<string, DhcpLeaseRecord>;
dhcpBlacklistByServerNodeId: Map<Id, Map<string, number>>;
dhcpLastOfferedAddressByServerNodeId: Map<Id, string>;
dhcpServerSocketIdsByNodeId: Map<Id, Id>;
/** Each DNS server's socket on port 53. */
dnsServerSocketIdsByNodeId: Map<Id, Id>;
dhcpClientStatusesByNodeId: Map<Id, DhcpClientStatus>;
dhcpClientContextsByNodeId: Map<Id, DhcpClientContext>;
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<K, V>(map: Map<K, V>, 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<string, TransportSender>();
/** Per cable end (node and port): when the last frame sent there arrives. */
private cableBusyUntilByPort = new Map<string, number>();
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<string>();
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<Id, DeviceConfiguration>,
interfaceConfigurations: ReadonlyMap<InterfaceKey, InterfaceConfiguration>,
): 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<string, Id>();
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) },
];
}