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>
3862 lines
153 KiB
TypeScript
3862 lines
153 KiB
TypeScript
// 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) },
|
||
];
|
||
}
|