Files
mdg-filius/packages/core/src/runtime/engine.ts
T
julian.kiedaischandClaude Opus 5.5 cec0a92f95 feat(core): TCP timing and closing after Java, measured with Java FILIUS
TCP follows Java's TCPSocket where it differs from Swift: each attempt and
TIME_WAIT wait three round trips that follow the speed (8250 ms at 90 %),
counted from when the segment is handed down, before ARP; the next segment
ends TIME_WAIT; the other end closes by itself on FIN; data no one
acknowledges closes with FIN; the accepted socket has the listener's
initial sequence number, and the listener takes the next.

Not taken from Java: three SYNs, not one (Java's retry loop ends after
the first timeout), named in tcpJava.test.ts as JAVA_BUGS.

tcpJava.test.ts compares TcpRun's three stories frame by frame and state
by state; the Swift comparison only checks that the TCP scenarios differ.

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

3862 lines
153 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)
// 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.
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 { deepEqual } from '../util/equal.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 { 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 }
| { readonly kind: 'tcpTimeout'; readonly sessionId: Id }
| {
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';
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>;
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(),
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' };
interface PendingIPv4Transmission {
readonly packet: IPv4Packet;
readonly interface: NetworkInterface;
readonly operation: TraceOperation;
}
function toSeed(seed: bigint | number): bigint {
return BigInt.asUintN(64, BigInt(seed));
}
function isNetworkDeliveryEvent(kind: ScheduledEventKind): boolean {
return kind.kind === 'ethernetLinkDelivery' || kind.kind === 'remoteLinkDelivery';
}
function isRemoteLinkWaitEvent(kind: ScheduledEventKind): boolean {
return isNetworkDeliveryEvent(kind) || kind.kind === 'lanRemoteLinkTransmission';
}
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 runtimeSpeed: RuntimeSpeed;
private readonly packetTraceRetentionPolicy: TraceRetentionPolicy;
private isProcessingScheduledEvents = false;
private pendingIPv4TransmissionsByArpKey = new Map<string, PendingIPv4Transmission[]>();
constructor(
seed: bigint | number = DEFAULT_SEED,
packetTraceRetentionPolicy: TraceRetentionPolicy = PRODUCTION_TRACE_RETENTION,
) {
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.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.isProcessingScheduledEvents = false;
this.pendingIPv4TransmissionsByArpKey.clear();
this.initializeRipState();
this.initializeDhcpState();
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.pendingIPv4TransmissionsByArpKey.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.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 '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 '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;
this.advance(deadline);
return 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),
});
if (destinationIPAddress === LIMITED_BROADCAST_IP_ADDRESS) {
for (const networkInterface of boundInterface === undefined ? interfaces : [boundInterface]) {
const packet: IPv4Packet = {
identity: packetIdentity,
senderIPAddress: networkInterface.ipAddress,
receiverIPAddress: destinationIPAddress,
timeToLive: 1,
protocol: 'udp',
payload: { kind: 'udp', datagram },
};
traceSent(networkInterface, packet.identity);
this.sendLimitedBroadcastPacket(socket.nodeId, networkInterface, packet);
}
return { type: 'delivered', packetIdentity, nodeId: socket.nodeId };
}
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);
return this.sendIPv4Packet(socket.nodeId, packet, networkInterface.portId);
}
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);
}
// ── 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;
}
// Java's socket starts waiting when it hands the segment down, before
// ARP has found the next hop; the timeout counts from here.
const handedDownAt = state.currentTimeMilliseconds;
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 delivery = this.sendIPv4Packet(socket.nodeId, packet, networkInterface.portId);
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 {
let advancedToTimeout = false;
while (this.stateValue.phase === 'running') {
const socket = this.stateValue.socketsById.get(socketId);
if (socket === undefined || socket.tcpState === undefined) return advancedToTimeout;
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;
if (!waitingOnState && !waitingOnAck) return advancedToTimeout;
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;
this.advance(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: the frames travel (virtual time passes)
* before the call returns.
*/
resolveMacAddress(
nodeId: Id,
targetIPAddress: string,
options: { preferredInterfaceId?: Id | undefined; maxRetries?: number; selectInterfaceByAddress?: boolean } = {},
): string | undefined {
const maxRetries = options.maxRetries ?? 2;
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;
for (let attempt = 0; attempt < Math.max(0, maxRetries); attempt += 1) {
this.sendArpRequest(nodeId, selectedInterface, targetIPAddress);
this.advanceRemoteLinkEventsUntilArpCacheResolves(nodeId, targetIPAddress);
const resolved = this.stateValue.arpCachesByNodeId.get(nodeId)?.get(targetIPAddress);
if (resolved !== undefined) return resolved.macAddress;
}
return undefined;
}
/** The MAC address for `ipAddress` without asking: localhost, one of the node's own, or cached. */
private knownMacAddress(nodeId: Id, ipAddress: string): string | undefined {
const interfaces = this.networkInterfaces(nodeId);
const primaryInterface = interfaces[0];
if (primaryInterface === undefined) return undefined;
if (ipAddress === LOCALHOST_IP_ADDRESS) return primaryInterface.macAddress;
const localInterface = interfaces.find((candidate) => candidate.ipAddress === ipAddress);
if (localInterface !== undefined) return localInterface.macAddress;
return this.stateValue.arpCachesByNodeId.get(nodeId)?.get(ipAddress)?.macAddress;
}
/** One ARP request for `targetIPAddress`, broadcast from the interface. */
private sendArpRequest(nodeId: Id, networkInterface: NetworkInterface, targetIPAddress: string): void {
const request: ArpPacket = {
operation: 'request',
senderMacAddress: networkInterface.macAddress,
senderIPAddress: networkInterface.ipAddress,
targetMacAddress: UNSPECIFIED_MAC_ADDRESS,
targetIPAddress,
};
const frame: EthernetFrame = {
identity: this.allocateFrameIdentity(),
sourceMacAddress: networkInterface.macAddress,
destinationMacAddress: ETHERNET_BROADCAST_MAC_ADDRESS,
payload: { kind: 'arp', packet: request },
};
this.recordTrace({
frameIdentity: frame.identity,
nodeId,
interfaceId: networkInterface.portId,
direction: 'outbound',
layer: 'dataLink',
operation: 'created',
afterHeaders: ethernetHeaders(frame),
detail: `ARP request for ${targetIPAddress}`,
});
this.sendEthernetFrame(nodeId, networkInterface.portId, frame);
}
private advanceRemoteLinkEventsUntilArpCacheResolves(nodeId: Id, targetIPAddress: string): void {
if (this.isProcessingScheduledEvents) return;
this.advancePendingRemoteLinkEvents(
() => this.stateValue.arpCachesByNodeId.get(nodeId)?.get(targetIPAddress) !== undefined,
);
}
private advanceRemoteLinkEventsUntilIcmpObservation(
nodeId: Id,
observationCount: number,
identifier: number,
sequenceNumber: number,
): void {
if (this.isProcessingScheduledEvents) return;
this.advancePendingRemoteLinkEvents(() => {
const newObservations = (this.stateValue.icmpObservationsByNodeId.get(nodeId) ?? []).slice(observationCount);
return newObservations.some(
(observation) =>
observation.message.identifier === identifier && observation.message.sequenceNumber === sequenceNumber,
);
});
}
private advancePendingRemoteLinkEvents(condition: () => boolean): void {
const maximumEvents = 1_024;
let processedEventCount = 0;
while (!condition() && processedEventCount < maximumEvents) {
const nextDeadline = this.earliestDeadline(isRemoteLinkWaitEvent);
if (nextDeadline === undefined) return;
this.advance(nextDeadline);
processedEventCount += 1;
}
}
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 {
this.recordTrace({
frameIdentity: frame.identity,
packetIdentity: packetIdentityInFrame(frame),
nodeId: fromNodeId,
interfaceId: outgoingPortId,
direction: 'outbound',
layer: 'physical',
operation: 'sent',
afterHeaders: ethernetHeaders(frame),
});
const endpoint = this.attachedEndpoint(fromNodeId, outgoingPortId);
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;
}
this.schedule(deadlineMilliseconds, {
kind: 'ethernetLinkDelivery',
sourceNodeId: fromNodeId,
targetNodeId: endpoint.nodeId,
targetPortId: endpoint.portId,
latencyMilliseconds,
frame,
});
this.drainPendingNetworkDeliveryEventsIfNeeded();
}
private drainPendingNetworkDeliveryEventsIfNeeded(): void {
if (this.isProcessingScheduledEvents) 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,
});
this.flushIPv4TransmissionsAwaitingArp(nodeId, packet.senderIPAddress, 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 result = this.sendIPv4Packet(fromNodeId, packet, sourceInterface.portId);
this.advanceRemoteLinkEventsUntilIcmpObservation(fromNodeId, observationCount, identifier, sequenceNumber);
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,
): IPv4DeliveryResult {
const route = this.routeForPacket(fromNodeId, packet.receiverIPAddress, preferredInterfaceId);
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': {
const destinationMacAddress = this.resolveMacAddress(fromNodeId, route.nextHop, {
preferredInterfaceId: route.interface.portId,
maxRetries: 2,
});
if (destinationMacAddress === undefined) {
if (this.isProcessingScheduledEvents && this.hasConfiguredInterface(route.nextHop)) {
this.enqueueIPv4TransmissionAwaitingArp(fromNodeId, route.nextHop, packet, route.interface, operation);
return { type: 'delivered', packetIdentity: packet.identity, nodeId: fromNodeId };
}
if (allowIcmpErrorGeneration) {
this.observeLocalIcmpError(fromNodeId, route.interface.portId, 'destinationHostUnreachable', packet);
return {
type: 'icmpError',
packetIdentity: packet.identity,
nodeId: fromNodeId,
kind: 'destinationHostUnreachable',
};
}
return { type: 'dropped', packetIdentity: packet.identity, nodeId: fromNodeId };
}
this.transmitIPv4Packet(fromNodeId, packet, route.interface, destinationMacAddress, operation);
return { type: 'delivered', packetIdentity: packet.identity, nodeId: fromNodeId };
}
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 hasConfiguredInterface(ipAddress: string): boolean {
return this.stateValue.topologySnapshot.nodes.some((node) =>
this.networkInterfaces(node.id).some((candidate) => candidate.ipAddress === ipAddress),
);
}
private enqueueIPv4TransmissionAwaitingArp(
fromNodeId: Id,
nextHop: string,
packet: IPv4Packet,
networkInterface: NetworkInterface,
operation: TraceOperation,
): void {
const key = arpKey(fromNodeId, nextHop);
const transmission: PendingIPv4Transmission = { packet, interface: networkInterface, operation };
const pending = this.pendingIPv4TransmissionsByArpKey.get(key);
if (pending?.some((candidate) => deepEqual(candidate, transmission)) === true) return;
getOrCreate(this.pendingIPv4TransmissionsByArpKey, key, () => []).push(transmission);
this.recordTrace({
packetIdentity: packet.identity,
nodeId: fromNodeId,
interfaceId: networkInterface.portId,
direction: 'outbound',
layer: 'network',
operation: 'created',
beforeHeaders: ipv4Headers(packet),
detail: `waiting for ARP resolution of ${nextHop}`,
});
}
private flushIPv4TransmissionsAwaitingArp(nodeId: Id, ipAddress: string, macAddress: string): void {
const key = arpKey(nodeId, ipAddress);
const transmissions = this.pendingIPv4TransmissionsByArpKey.get(key);
if (transmissions === undefined) return;
this.pendingIPv4TransmissionsByArpKey.delete(key);
for (const transmission of transmissions) {
this.transmitIPv4Packet(nodeId, transmission.packet, transmission.interface, macAddress, transmission.operation);
}
}
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',
});
const result = this.sendIPv4PacketWithOptions(nodeId, forwarded, undefined, false, 'forwarded');
if (result.type === 'dropped') {
const route = this.routeForPacket(nodeId, packetForProcessing.receiverIPAddress);
this.emitIcmpError(
nodeId,
networkInterface,
route.type === 'unavailable' ? 'destinationNetworkUnreachable' : 'destinationHostUnreachable',
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);
}
function arpKey(nodeId: Id, ipAddress: string): string {
return `${nodeId}|${ipAddress}`;
}
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) },
];
}