DNS follows Java's DNSServer and Resolver, not Swift's typed format: Java's text messages (dns.ts), a server on port 53 answering from its records (no question in the answer, RCODE 0, a referral with NS and A in the answer section), an iterative resolver that waits five round trips per query and remembers only the servers it was referred to. Recursion throws NotPortedError until the next step. The snapshot carries the DNS servers switched on. receiveUdp returns as soon as a datagram is there, as Java's empfangen does. dnsJava.test.ts compares DnsRun's five lookups; four match frame by frame, the missing server waits for Java's ARP (named as not yet like Java). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
4096 lines
166 KiB
TypeScript
4096 lines
166 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)
|
||
// A cable carries one frame after the other, as Java's SimplexVerbindung
|
||
// (Swift: all at once; the option swiftCables keeps that for the comparison).
|
||
// DNS after Java (filius.software.dns: DNSServer, DNSServerMitarbeiter,
|
||
// Resolver, DNSQueryAgent), not Swift's typed format; recursion not yet.
|
||
// Java counterparts: filius.hardware.Verbindung, filius.software.netzzugangsschicht,
|
||
// filius.software.vermittlungsschicht (ARP, IP, ICMP), system.SwitchFirmware,
|
||
// filius.software.transportschicht (UDP, TCP), filius.software.dhcp.
|
||
//
|
||
// The Swift engine is a value type driven by a reducer; here it is a class
|
||
// with the same inputs and outputs. Time is virtual: nothing sleeps, every
|
||
// delay is an event in the queue, ordered by deadline and then by insertion.
|
||
// Calls that block in Java (resolving an address, waiting for an echo reply)
|
||
// advance the virtual clock inside the call, exactly as the Swift port does.
|
||
//
|
||
// Not ported yet — each place throws NotPortedError instead of guessing:
|
||
// gateway NAT, RIP routing, remote links, recursive DNS resolution.
|
||
|
||
import { compareIds, type Id } from '../ids.js';
|
||
import { isPcClassEndpoint, stableGeneratedMacAddress } from '../model/node.js';
|
||
import type {
|
||
DeviceConfiguration,
|
||
DhcpServerConfiguration,
|
||
FirewallConfiguration,
|
||
FirewallRule,
|
||
InterfaceConfiguration,
|
||
InterfaceKey,
|
||
ManualRoute,
|
||
} from './config.js';
|
||
import {
|
||
ALL_PORTS,
|
||
DEFAULT_SWITCH_RETENTION_MILLISECONDS,
|
||
dhcpServerConfiguration,
|
||
DIRECTLY_CONNECTED_SOURCE_MARKER,
|
||
FIREWALL_PROTOCOL,
|
||
firewallConfiguration,
|
||
interfaceKey,
|
||
} from './config.js';
|
||
import { 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 {
|
||
decodeDnsMessage,
|
||
DNS_PORT,
|
||
DNS_QUERY,
|
||
DNS_RESPONSE,
|
||
encodeDnsMessage,
|
||
findApplicableNameServerRecord,
|
||
findDnsRecords,
|
||
messageRecord,
|
||
type DnsMessage,
|
||
type DnsMessageRecord,
|
||
type DnsQuestion,
|
||
} from './dns.js';
|
||
import { JavaCompatibleRandom } from './random.js';
|
||
import { routeTableRows, type RouteRow } from './routeTable.js';
|
||
import { EMPTY_TOPOLOGY_SNAPSHOT, withSnapshot, type NodeSnapshot, type TopologySnapshot } from './snapshot.js';
|
||
import { RuntimeSpeed } from './speed.js';
|
||
import {
|
||
PRODUCTION_TRACE_RETENTION,
|
||
type HeaderField,
|
||
type TraceDirection,
|
||
type TraceEvent,
|
||
type TraceLayer,
|
||
type TraceOperation,
|
||
type TraceRetentionPolicy,
|
||
} from './trace.js';
|
||
|
||
export const ETHERNET_BROADCAST_MAC_ADDRESS = 'FF:FF:FF:FF:FF:FF';
|
||
export const UNSPECIFIED_MAC_ADDRESS = '00:00:00:00:00:00';
|
||
export const LIMITED_BROADCAST_IP_ADDRESS = '255.255.255.255';
|
||
export const UNSPECIFIED_IP_ADDRESS = '0.0.0.0';
|
||
export const LOCALHOST_IP_ADDRESS = '127.0.0.1';
|
||
|
||
/** The seed a simulation uses unless told otherwise. */
|
||
export const DEFAULT_SEED = 0xf11a5n;
|
||
|
||
/** A part of the Swift engine that has not been carried over yet. */
|
||
export class NotPortedError extends Error {
|
||
constructor(feature: string) {
|
||
super(`Not ported yet: ${feature}`);
|
||
this.name = 'NotPortedError';
|
||
}
|
||
}
|
||
|
||
// ── Events, inputs, outputs ────────────────────────────────────────────────
|
||
|
||
export type ScheduledEventKind =
|
||
| { readonly kind: 'parityMarker'; readonly label: string }
|
||
| { readonly kind: 'ripBeacon'; readonly nodeId: Id }
|
||
| { readonly kind: 'dhcpClientStart'; readonly nodeId: Id }
|
||
| { readonly kind: 'dhcpTimeout'; readonly nodeId: Id }
|
||
| { readonly kind: 'tcpTimeout'; readonly sessionId: Id }
|
||
| {
|
||
/** A frame that waited in the port's output buffer leaves: the cable was busy. */
|
||
readonly kind: 'ethernetLinkDeparture';
|
||
readonly sourceNodeId: Id;
|
||
readonly outgoingPortId: Id;
|
||
readonly targetNodeId: Id;
|
||
readonly targetPortId: Id;
|
||
readonly frame: EthernetFrame;
|
||
}
|
||
| {
|
||
readonly kind: 'ethernetLinkDelivery';
|
||
readonly sourceNodeId: Id;
|
||
readonly targetNodeId: Id;
|
||
readonly targetPortId: Id;
|
||
readonly latencyMilliseconds: number;
|
||
readonly frame: EthernetFrame;
|
||
}
|
||
| {
|
||
readonly kind: 'remoteLinkDelivery';
|
||
readonly sourceNodeId: Id;
|
||
readonly partnerNodeId: Id;
|
||
readonly partnerPortId: Id;
|
||
readonly pairIdentifier: string;
|
||
readonly latencyMilliseconds: number;
|
||
readonly frame: EthernetFrame;
|
||
}
|
||
| {
|
||
readonly kind: 'lanRemoteLinkTransmission';
|
||
readonly nodeId: Id;
|
||
readonly pairIdentifier: string;
|
||
readonly latencyMilliseconds: number;
|
||
readonly frame: EthernetFrame;
|
||
}
|
||
| { readonly kind: 'natExpirySweep' };
|
||
|
||
export interface ScheduledEvent {
|
||
readonly id: number;
|
||
readonly deadlineMilliseconds: number;
|
||
readonly sequenceNumber: number;
|
||
readonly kind: ScheduledEventKind;
|
||
}
|
||
|
||
export type CompatibilityOperationKind = 'terminalCommand' | 'applicationOperation' | 'socketOperation';
|
||
|
||
export interface CompatibilityOperation {
|
||
readonly kind: CompatibilityOperationKind;
|
||
readonly nodeId: Id;
|
||
readonly interfaceId?: Id;
|
||
readonly detail: string;
|
||
}
|
||
|
||
export type RuntimeInput =
|
||
| {
|
||
readonly type: 'start';
|
||
readonly snapshot: TopologySnapshot;
|
||
readonly seed: bigint | number;
|
||
readonly initialTimeMilliseconds: number;
|
||
}
|
||
| { readonly type: 'stop' }
|
||
| { readonly type: 'advance'; readonly toMilliseconds: number }
|
||
| { readonly type: 'schedule'; readonly deadlineMilliseconds: number; readonly kind: ScheduledEventKind }
|
||
| { readonly type: 'compatibilityOperation'; readonly operation: CompatibilityOperation };
|
||
|
||
export type RuntimeOutputKind =
|
||
| { readonly type: 'started' }
|
||
| { readonly type: 'startIgnoredAlreadyRunning' }
|
||
| { readonly type: 'stopped' }
|
||
| { readonly type: 'stopIgnoredAlreadyStopped' }
|
||
| { readonly type: 'advanced'; readonly fromMilliseconds: number; readonly toMilliseconds: number }
|
||
| { readonly type: 'advanceIgnoredWhileStopped' }
|
||
| { readonly type: 'advanceRejectedPastTime' }
|
||
| { readonly type: 'scheduleRejectedPastTime'; readonly deadlineMilliseconds: number }
|
||
| { readonly type: 'scheduled'; readonly event: ScheduledEvent }
|
||
| { readonly type: 'fired'; readonly event: ScheduledEvent }
|
||
| { readonly type: 'compatibilityOperationRecorded'; readonly operation: CompatibilityOperation };
|
||
|
||
export interface RuntimeOutput {
|
||
readonly timeMilliseconds: number;
|
||
readonly kind: RuntimeOutputKind;
|
||
}
|
||
|
||
/** A named, seeded list of events to replay without any user interface. */
|
||
export interface RuntimeScenario {
|
||
readonly name: string;
|
||
readonly seed: bigint | number;
|
||
readonly advanceToMilliseconds: number;
|
||
readonly scheduledEvents: readonly { deadlineMilliseconds: number; kind: ScheduledEventKind }[];
|
||
}
|
||
|
||
// ── Runtime state ──────────────────────────────────────────────────────────
|
||
|
||
export interface ArpCacheEntry {
|
||
readonly ipAddress: string;
|
||
readonly macAddress: string;
|
||
readonly updatedAtMilliseconds: number;
|
||
}
|
||
|
||
export interface SwitchSatEntry {
|
||
readonly macAddress: string;
|
||
readonly portId: Id;
|
||
readonly portLabel: string;
|
||
readonly updatedAtMilliseconds: number;
|
||
}
|
||
|
||
/** An interface of a node that has an IP configuration. */
|
||
export interface NetworkInterface {
|
||
readonly nodeId: Id;
|
||
readonly portId: Id;
|
||
readonly index: number;
|
||
readonly ipAddress: string;
|
||
readonly subnetMask: string;
|
||
readonly defaultGateway: string;
|
||
readonly macAddress: string;
|
||
}
|
||
|
||
export interface DeliveredIPv4Packet {
|
||
readonly nodeId: Id;
|
||
readonly interfaceId: Id;
|
||
readonly packet: IPv4Packet;
|
||
}
|
||
|
||
export interface IcmpObservation {
|
||
readonly nodeId: Id;
|
||
readonly interfaceId?: Id;
|
||
readonly packet: IPv4Packet;
|
||
readonly message: IcmpMessage;
|
||
}
|
||
|
||
export type IPv4DeliveryResult =
|
||
| { readonly type: 'delivered'; readonly packetIdentity: number; readonly nodeId: Id }
|
||
| {
|
||
readonly type: 'icmpError';
|
||
readonly packetIdentity: number;
|
||
readonly nodeId: Id;
|
||
readonly kind: IcmpMessageKind;
|
||
}
|
||
| { readonly type: 'dropped'; readonly packetIdentity: number; readonly nodeId?: Id };
|
||
|
||
export interface FirewallDecision {
|
||
readonly packetIdentity: number;
|
||
readonly nodeId: Id;
|
||
readonly accepted: boolean;
|
||
readonly ruleIndex?: number;
|
||
}
|
||
|
||
export type RuntimePhase = 'stopped' | 'running';
|
||
|
||
export type SocketProtocol = 'udp' | 'tcp';
|
||
|
||
/** Swift's TopologyRuntimeSocketRecord. */
|
||
export interface SocketRecord {
|
||
readonly id: Id;
|
||
readonly nodeId: Id;
|
||
readonly protocol: SocketProtocol;
|
||
readonly localIPAddress: string | undefined;
|
||
/** 0…65535 */
|
||
readonly localPort: number;
|
||
remoteIPAddress: string | undefined;
|
||
remotePort: number | undefined;
|
||
/** TCP only. */
|
||
tcpState: TcpSocketState | undefined;
|
||
/** A socket a TCP listener accepted: that listener. */
|
||
readonly parentListenerSocketId: Id | undefined;
|
||
}
|
||
|
||
/** Swift's TopologyRuntimeTCPSessionRecord; sequence numbers are unsigned 32-bit. */
|
||
export interface TcpSession {
|
||
readonly id: Id;
|
||
readonly socketId: Id;
|
||
state: TcpSocketState;
|
||
sendAttempts: number;
|
||
nextSendSequenceNumber: number;
|
||
lastSentSequenceNumber: number;
|
||
remoteSequenceNumber: number;
|
||
lastAcknowledgedSequenceNumber: number | undefined;
|
||
lastSentSegment: TcpSegment | undefined;
|
||
receiveAssembly: Uint8Array;
|
||
receivedMessages: Uint8Array[];
|
||
}
|
||
|
||
export type TcpConnectionResult = 'connected' | 'invalidSocket' | 'unreachable' | 'timedOut';
|
||
|
||
/** Java's DNSQueryAgent waits five round trips for an answer. */
|
||
export const DNS_QUERY_TIMEOUT_ROUND_TRIPS = 5;
|
||
export type DnsResolution = { readonly type: 'answered'; readonly records: readonly DnsMessageRecord[] } | { readonly type: 'timedOut' };
|
||
export type DnsAddressResolution = { readonly type: 'resolved'; readonly address: string | undefined } | { readonly type: 'timedOut' };
|
||
|
||
export const DHCP_SERVER_PORT = 67;
|
||
export const DHCP_CLIENT_PORT = 68;
|
||
/** Java: an offer, and a blacklisted address, is kept 4 round trip times. */
|
||
export const DHCP_OFFER_LIFETIME_ROUND_TRIPS = 4;
|
||
export const DHCP_DYNAMIC_LEASE_LIFETIME_MILLISECONDS = 24 * 60 * 60 * 1_000;
|
||
/** Java's DHCP_MAX_ERROR_COUNT: the client's failures before it takes a link-local address. */
|
||
export const DHCP_MAXIMUM_CLIENT_ERROR_COUNT = 5;
|
||
/** Java's LINK_LOCAL_MAX_ERROR_COUNT: occupied link-local addresses before the client gives up. */
|
||
export const DHCP_LINK_LOCAL_MAXIMUM_ERROR_COUNT = 5;
|
||
export const LINK_LOCAL_ADDRESS_PREFIX = '169.254.1.';
|
||
export const LINK_LOCAL_SUBNET_MASK = '255.255.0.0';
|
||
|
||
export type DhcpMessageType = 'DHCPDISCOVER' | 'DHCPREQUEST' | 'DHCPACK' | 'DHCPNAK' | 'DHCPOFFER' | 'DHCPDECLINE';
|
||
const DHCP_MESSAGE_TYPES: readonly DhcpMessageType[] = ['DHCPDISCOVER', 'DHCPREQUEST', 'DHCPACK', 'DHCPNAK', 'DHCPOFFER', 'DHCPDECLINE'];
|
||
|
||
/** Swift's TopologyRuntimeDHCPMessage: lines of text in a UDP datagram. */
|
||
export interface DhcpMessage {
|
||
readonly type: DhcpMessageType;
|
||
yourIPAddress: string;
|
||
clientMacAddress?: string | undefined;
|
||
subnetMask?: string | undefined;
|
||
routerIPAddress?: string | undefined;
|
||
dnsServerIPAddress?: string | undefined;
|
||
serverIdentifier?: string | undefined;
|
||
requestedIPAddress?: string | undefined;
|
||
}
|
||
|
||
export interface DhcpOfferRecord {
|
||
readonly clientMacAddress: string;
|
||
readonly offeredIPAddress: string;
|
||
readonly serverNodeId: Id;
|
||
readonly expiresAtMilliseconds: number;
|
||
}
|
||
|
||
export interface DhcpLeaseRecord {
|
||
readonly clientMacAddress: string;
|
||
readonly ipAddress: string;
|
||
readonly serverNodeId: Id;
|
||
/** Undefined for a static assignment. */
|
||
readonly expiresAtMilliseconds: number | undefined;
|
||
}
|
||
|
||
/** Java's DHCPClient.State; FINISH is Java's SUCCEED and its giving up. */
|
||
export type DhcpClientState =
|
||
| 'INIT'
|
||
| 'DISCOVER'
|
||
| 'VALIDATE'
|
||
| 'DECLINE'
|
||
| 'REQUEST'
|
||
| 'ASSIGN_IP'
|
||
| 'LINK_LOCAL_INIT'
|
||
| 'LINK_LOCAL_VALIDATE'
|
||
| 'LINK_LOCAL_ANNOUNCE'
|
||
| 'FINISH';
|
||
|
||
export interface DhcpClientStatus {
|
||
state: DhcpClientState;
|
||
/** Java's failureCount: timeouts while waiting for a server, then occupied link-local addresses. */
|
||
errorCount: number;
|
||
selectedServerIPAddress: string | undefined;
|
||
offeredIPAddress: string | undefined;
|
||
/** Configured, by a server or with a link-local address. */
|
||
succeeded: boolean;
|
||
linkLocal: boolean;
|
||
}
|
||
|
||
/** What the client waits for, at most until the deadline: a server's answer, or an ARP answer for an address. */
|
||
export type DhcpClientWait =
|
||
| {
|
||
readonly kind: 'response';
|
||
readonly acceptedTypes: readonly DhcpMessageType[];
|
||
readonly serverIdentifier: string | undefined;
|
||
readonly deadlineMilliseconds: number;
|
||
}
|
||
| { readonly kind: 'arp'; readonly ipAddress: string; readonly deadlineMilliseconds: number };
|
||
|
||
export interface DhcpClientContext {
|
||
readonly interfaceId: Id;
|
||
readonly socketId: Id;
|
||
readonly clientMacAddress: string;
|
||
selectedOffer: DhcpMessage | undefined;
|
||
linkLocalIPAddress: string | undefined;
|
||
waiting: DhcpClientWait | undefined;
|
||
}
|
||
|
||
/** Swift's caseInsensitiveCompare == .orderedSame, for addresses. */
|
||
function sameIgnoringCase(lhs: string | undefined, rhs: string | undefined): boolean {
|
||
return lhs !== undefined && rhs !== undefined && lhs.toLowerCase() === rhs.toLowerCase();
|
||
}
|
||
|
||
export const TCP_MAXIMUM_SEGMENT_SIZE = 1_460;
|
||
export const TCP_MAXIMUM_SEND_ATTEMPTS = 3;
|
||
export const TCP_EPHEMERAL_PORT_LOWER_BOUND = 49_152;
|
||
export const TCP_EPHEMERAL_PORT_UPPER_BOUND_EXCLUSIVE = 65_535;
|
||
|
||
/** Addition modulo 2^32, as Swift's `&+` on UInt32. */
|
||
function uint32Add(value: number, addend: number): number {
|
||
return (value + addend) >>> 0;
|
||
}
|
||
|
||
/** A datagram waiting in a UDP socket's queue. */
|
||
export interface UdpReceivedDatagram {
|
||
readonly senderIPAddress: string;
|
||
readonly receiverIPAddress: string;
|
||
readonly datagram: UdpDatagram;
|
||
readonly receivedAtMilliseconds: number;
|
||
}
|
||
|
||
export const UDP_EPHEMERAL_PORT_LOWER_BOUND = 49_152;
|
||
export const UDP_EPHEMERAL_PORT_UPPER_BOUND_EXCLUSIVE = 65_535;
|
||
export const RIP_DESTINATION_PORT = 520;
|
||
export const RIP_SOURCE_PORT = 521;
|
||
|
||
/** Swift's deterministicSocketID: the sequence number as the UUID's last twelve hex digits. */
|
||
export function deterministicSocketId(sequence: number): Id {
|
||
return `00000000-0000-0000-0000-${sequence.toString(16).toUpperCase().padStart(12, '0')}`;
|
||
}
|
||
|
||
export interface RuntimeState {
|
||
phase: RuntimePhase;
|
||
currentTimeMilliseconds: number;
|
||
simulationSpeedPercent: number;
|
||
globalPacketLossEnabled: boolean;
|
||
seed: bigint;
|
||
topologySnapshot: TopologySnapshot;
|
||
pendingEvents: ScheduledEvent[];
|
||
packetTraces: TraceEvent[];
|
||
discardedPacketTraceCount: number;
|
||
arpCachesByNodeId: Map<Id, Map<string, ArpCacheEntry>>;
|
||
switchForwardingTablesByNodeId: Map<Id, Map<string, Id>>;
|
||
switchForwardingUpdatedAtMillisecondsByNodeId: Map<Id, Map<string, number>>;
|
||
switchSeenFrameIdentitiesByNodeId: Map<Id, Set<number>>;
|
||
deliveredIPv4PacketsByNodeId: Map<Id, DeliveredIPv4Packet[]>;
|
||
icmpObservationsByNodeId: Map<Id, IcmpObservation[]>;
|
||
firewallDecisions: FirewallDecision[];
|
||
socketsById: Map<Id, SocketRecord>;
|
||
udpReceiveQueuesBySocketId: Map<Id, UdpReceivedDatagram[]>;
|
||
tcpSessionsById: Map<Id, TcpSession>;
|
||
tcpAcceptedSocketIdsByListenerId: Map<Id, Id[]>;
|
||
dhcpOffersByIPAddress: Map<string, DhcpOfferRecord>;
|
||
dhcpLeasesByIPAddress: Map<string, DhcpLeaseRecord>;
|
||
dhcpBlacklistByServerNodeId: Map<Id, Map<string, number>>;
|
||
dhcpLastOfferedAddressByServerNodeId: Map<Id, string>;
|
||
dhcpServerSocketIdsByNodeId: Map<Id, Id>;
|
||
/** Each DNS server's socket on port 53. */
|
||
dnsServerSocketIdsByNodeId: Map<Id, Id>;
|
||
dhcpClientStatusesByNodeId: Map<Id, DhcpClientStatus>;
|
||
dhcpClientContextsByNodeId: Map<Id, DhcpClientContext>;
|
||
nextSocketSequenceNumber: number;
|
||
nextTcpInitialSequenceValue: number;
|
||
nextEventSequenceNumber: number;
|
||
nextFrameIdentity: number;
|
||
nextPacketIdentity: number;
|
||
nextTraceIdentity: number;
|
||
}
|
||
|
||
function initialState(seed: bigint): RuntimeState {
|
||
return {
|
||
phase: 'stopped',
|
||
currentTimeMilliseconds: 0,
|
||
simulationSpeedPercent: new RuntimeSpeed().percent,
|
||
globalPacketLossEnabled: false,
|
||
seed,
|
||
topologySnapshot: EMPTY_TOPOLOGY_SNAPSHOT,
|
||
pendingEvents: [],
|
||
packetTraces: [],
|
||
discardedPacketTraceCount: 0,
|
||
arpCachesByNodeId: new Map(),
|
||
switchForwardingTablesByNodeId: new Map(),
|
||
switchForwardingUpdatedAtMillisecondsByNodeId: new Map(),
|
||
switchSeenFrameIdentitiesByNodeId: new Map(),
|
||
deliveredIPv4PacketsByNodeId: new Map(),
|
||
icmpObservationsByNodeId: new Map(),
|
||
firewallDecisions: [],
|
||
socketsById: new Map(),
|
||
udpReceiveQueuesBySocketId: new Map(),
|
||
tcpSessionsById: new Map(),
|
||
tcpAcceptedSocketIdsByListenerId: new Map(),
|
||
dhcpOffersByIPAddress: new Map(),
|
||
dhcpLeasesByIPAddress: new Map(),
|
||
dhcpBlacklistByServerNodeId: new Map(),
|
||
dhcpLastOfferedAddressByServerNodeId: new Map(),
|
||
dhcpServerSocketIdsByNodeId: new Map(),
|
||
dnsServerSocketIdsByNodeId: new Map(),
|
||
dhcpClientStatusesByNodeId: new Map(),
|
||
dhcpClientContextsByNodeId: new Map(),
|
||
nextSocketSequenceNumber: 1,
|
||
nextTcpInitialSequenceValue: 1,
|
||
nextEventSequenceNumber: 0,
|
||
nextFrameIdentity: 1,
|
||
nextPacketIdentity: 1,
|
||
nextTraceIdentity: 1,
|
||
};
|
||
}
|
||
|
||
export interface TraceInput {
|
||
frameIdentity?: number | undefined;
|
||
packetIdentity?: number | undefined;
|
||
nodeId: Id;
|
||
interfaceId?: Id | undefined;
|
||
direction: TraceDirection;
|
||
layer: TraceLayer;
|
||
operation: TraceOperation;
|
||
beforeHeaders?: readonly HeaderField[];
|
||
afterHeaders?: readonly HeaderField[];
|
||
detail?: string | undefined;
|
||
}
|
||
|
||
type PacketRoute =
|
||
| { readonly type: 'local'; readonly interface: NetworkInterface | undefined }
|
||
| { readonly type: 'broadcast' }
|
||
| { readonly type: 'egress'; readonly interface: NetworkInterface; readonly nextHop: string }
|
||
| { readonly type: 'unavailable' };
|
||
|
||
interface PendingIPv4Transmission {
|
||
readonly packet: IPv4Packet;
|
||
readonly interface: NetworkInterface;
|
||
readonly operation: TraceOperation;
|
||
}
|
||
|
||
function toSeed(seed: bigint | number): bigint {
|
||
return BigInt.asUintN(64, BigInt(seed));
|
||
}
|
||
|
||
/** A departure waits for the delivery of the frame before it, so draining the deliveries reaches it. */
|
||
function isNetworkDeliveryEvent(kind: ScheduledEventKind): boolean {
|
||
return kind.kind === 'ethernetLinkDeparture' || kind.kind === 'ethernetLinkDelivery' || kind.kind === 'remoteLinkDelivery';
|
||
}
|
||
|
||
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 dnsIdRandom: JavaCompatibleRandom;
|
||
private runtimeSpeed: RuntimeSpeed;
|
||
private readonly packetTraceRetentionPolicy: TraceRetentionPolicy;
|
||
private isProcessingScheduledEvents = false;
|
||
private pendingIPv4TransmissionsByArpKey = new Map<string, PendingIPv4Transmission[]>();
|
||
/** Per cable end (node and port): when the last frame sent there arrives. */
|
||
private cableBusyUntilByPort = new Map<string, number>();
|
||
private readonly cablesCarryOneFrameAtATime: boolean;
|
||
|
||
/**
|
||
* `swiftCables`: a cable carries all frames at once, as in the Swift
|
||
* engine — only for the comparison with Swift. Java's cable carries one
|
||
* frame after the other (SimplexVerbindung sleeps the delay with the port's
|
||
* buffer locked); a frame waits in the output buffer until the one before
|
||
* has arrived, and shows in the packet log when it leaves.
|
||
*/
|
||
constructor(
|
||
seed: bigint | number = DEFAULT_SEED,
|
||
packetTraceRetentionPolicy: TraceRetentionPolicy = PRODUCTION_TRACE_RETENTION,
|
||
options: { readonly swiftCables?: boolean } = {},
|
||
) {
|
||
this.cablesCarryOneFrameAtATime = options.swiftCables !== true;
|
||
const normalizedSeed = toSeed(seed);
|
||
this.stateValue = initialState(normalizedSeed);
|
||
this.random = new JavaCompatibleRandom(normalizedSeed);
|
||
this.udpPortRandom = new JavaCompatibleRandom(normalizedSeed ^ 0x5544_5050n);
|
||
this.tcpPortRandom = new JavaCompatibleRandom(normalizedSeed ^ 0x5443_5050n);
|
||
this.linkLocalRandom = new JavaCompatibleRandom(normalizedSeed ^ 0x4c4c_4144n);
|
||
this.dnsIdRandom = new JavaCompatibleRandom(normalizedSeed ^ 0x444e_5349n);
|
||
this.runtimeSpeed = new RuntimeSpeed();
|
||
this.packetTraceRetentionPolicy = packetTraceRetentionPolicy;
|
||
this.stateValue.simulationSpeedPercent = this.runtimeSpeed.percent;
|
||
}
|
||
|
||
/** The runtime state. Read it; change it only through the engine's methods. */
|
||
get state(): RuntimeState {
|
||
return this.stateValue;
|
||
}
|
||
|
||
get simulationSpeed(): RuntimeSpeed {
|
||
return this.runtimeSpeed;
|
||
}
|
||
|
||
setSimulationSpeed(percent: number): void {
|
||
this.runtimeSpeed = new RuntimeSpeed(percent);
|
||
this.stateValue.simulationSpeedPercent = this.runtimeSpeed.percent;
|
||
}
|
||
|
||
setGlobalPacketLossEnabled(enabled: boolean): void {
|
||
this.stateValue.globalPacketLossEnabled = this.stateValue.phase === 'running' && enabled;
|
||
}
|
||
|
||
handle(input: RuntimeInput): RuntimeOutput[] {
|
||
switch (input.type) {
|
||
case 'start': {
|
||
if (this.stateValue.phase !== 'stopped') return [this.output({ type: 'startIgnoredAlreadyRunning' })];
|
||
const seed = toSeed(input.seed);
|
||
this.stateValue = initialState(seed);
|
||
this.stateValue.phase = 'running';
|
||
this.stateValue.currentTimeMilliseconds = input.initialTimeMilliseconds;
|
||
this.stateValue.simulationSpeedPercent = this.runtimeSpeed.percent;
|
||
this.stateValue.topologySnapshot = input.snapshot;
|
||
this.random = new JavaCompatibleRandom(seed);
|
||
this.udpPortRandom = new JavaCompatibleRandom(seed ^ 0x5544_5050n);
|
||
this.tcpPortRandom = new JavaCompatibleRandom(seed ^ 0x5443_5050n);
|
||
this.linkLocalRandom = new JavaCompatibleRandom(seed ^ 0x4c4c_4144n);
|
||
this.dnsIdRandom = new JavaCompatibleRandom(seed ^ 0x444e_5349n);
|
||
this.isProcessingScheduledEvents = false;
|
||
this.pendingIPv4TransmissionsByArpKey.clear();
|
||
this.cableBusyUntilByPort.clear();
|
||
this.initializeRipState();
|
||
this.initializeDhcpState();
|
||
this.initializeDnsState();
|
||
this.initializeNatState();
|
||
return [this.output({ type: 'started' })];
|
||
}
|
||
|
||
case 'stop': {
|
||
if (this.stateValue.phase !== 'running') return [this.output({ type: 'stopIgnoredAlreadyStopped' })];
|
||
const state = this.stateValue;
|
||
state.phase = 'stopped';
|
||
state.globalPacketLossEnabled = false;
|
||
this.isProcessingScheduledEvents = false;
|
||
this.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.dnsServerSocketIdsByNodeId.clear();
|
||
state.firewallDecisions = [];
|
||
return [this.output({ type: 'stopped' })];
|
||
}
|
||
|
||
case 'advance':
|
||
return this.advance(input.toMilliseconds);
|
||
|
||
case 'schedule': {
|
||
if (this.stateValue.phase !== 'running') return [this.output({ type: 'advanceIgnoredWhileStopped' })];
|
||
if (input.deadlineMilliseconds < this.stateValue.currentTimeMilliseconds) {
|
||
return [
|
||
this.output({ type: 'scheduleRejectedPastTime', deadlineMilliseconds: input.deadlineMilliseconds }),
|
||
];
|
||
}
|
||
const event = this.schedule(input.deadlineMilliseconds, input.kind);
|
||
return [this.output({ type: 'scheduled', event })];
|
||
}
|
||
|
||
case 'compatibilityOperation':
|
||
if (this.stateValue.phase !== 'running') return [this.output({ type: 'advanceIgnoredWhileStopped' })];
|
||
this.recordCompatibilityTrace(input.operation);
|
||
return [this.output({ type: 'compatibilityOperationRecorded', operation: input.operation })];
|
||
}
|
||
}
|
||
|
||
nextRipJitterMilliseconds(): number {
|
||
return this.random.nextRipJitterMilliseconds();
|
||
}
|
||
|
||
allocateFrameIdentity(): number {
|
||
return this.stateValue.nextFrameIdentity++;
|
||
}
|
||
|
||
allocatePacketIdentity(): number {
|
||
return this.stateValue.nextPacketIdentity++;
|
||
}
|
||
|
||
recordTrace(input: TraceInput): void {
|
||
const state = this.stateValue;
|
||
const trace: TraceEvent = {
|
||
id: state.nextTraceIdentity,
|
||
timeMilliseconds: state.currentTimeMilliseconds,
|
||
...(input.frameIdentity === undefined ? {} : { frameIdentity: input.frameIdentity }),
|
||
...(input.packetIdentity === undefined ? {} : { packetIdentity: input.packetIdentity }),
|
||
nodeId: input.nodeId,
|
||
...(input.interfaceId === undefined ? {} : { interfaceId: input.interfaceId }),
|
||
direction: input.direction,
|
||
layer: input.layer,
|
||
operation: input.operation,
|
||
beforeHeaders: input.beforeHeaders ?? [],
|
||
afterHeaders: input.afterHeaders ?? [],
|
||
...(input.detail === undefined ? {} : { detail: input.detail }),
|
||
};
|
||
state.nextTraceIdentity += 1;
|
||
if (state.packetTraces.length >= this.packetTraceRetentionPolicy.maximumEventCount) {
|
||
const evictionCount = Math.min(this.packetTraceRetentionPolicy.evictionBatchSize, state.packetTraces.length);
|
||
state.packetTraces.splice(0, evictionCount);
|
||
state.discardedPacketTraceCount += evictionCount;
|
||
}
|
||
state.packetTraces.push(trace);
|
||
}
|
||
|
||
static run(scenario: RuntimeScenario, snapshot: TopologySnapshot = EMPTY_TOPOLOGY_SNAPSHOT): RuntimeState {
|
||
const engine = new NetworkRuntimeEngine(scenario.seed);
|
||
engine.handle({ type: 'start', snapshot, seed: scenario.seed, initialTimeMilliseconds: 0 });
|
||
for (const scheduled of scenario.scheduledEvents) {
|
||
engine.handle({ type: 'schedule', deadlineMilliseconds: scheduled.deadlineMilliseconds, kind: scheduled.kind });
|
||
}
|
||
engine.handle({ type: 'advance', toMilliseconds: scenario.advanceToMilliseconds });
|
||
return engine.state;
|
||
}
|
||
|
||
private schedule(deadlineMilliseconds: number, kind: ScheduledEventKind): ScheduledEvent {
|
||
const state = this.stateValue;
|
||
const event: ScheduledEvent = {
|
||
id: state.nextEventSequenceNumber,
|
||
deadlineMilliseconds,
|
||
sequenceNumber: state.nextEventSequenceNumber,
|
||
kind,
|
||
};
|
||
state.nextEventSequenceNumber += 1;
|
||
state.pendingEvents.push(event);
|
||
return event;
|
||
}
|
||
|
||
private advance(targetTimeMilliseconds: number): RuntimeOutput[] {
|
||
const state = this.stateValue;
|
||
if (state.phase !== 'running') return [this.output({ type: 'advanceIgnoredWhileStopped' })];
|
||
if (targetTimeMilliseconds < state.currentTimeMilliseconds) {
|
||
return [this.output({ type: 'advanceRejectedPastTime' })];
|
||
}
|
||
|
||
const startTime = state.currentTimeMilliseconds;
|
||
const outputs: RuntimeOutput[] = [];
|
||
const wasProcessingScheduledEvents = this.isProcessingScheduledEvents;
|
||
this.isProcessingScheduledEvents = true;
|
||
try {
|
||
for (;;) {
|
||
const nextEventIndex = this.nextDueEventIndex(targetTimeMilliseconds);
|
||
if (nextEventIndex === undefined) break;
|
||
const [event] = this.stateValue.pendingEvents.splice(nextEventIndex, 1) as [ScheduledEvent];
|
||
this.stateValue.currentTimeMilliseconds = event.deadlineMilliseconds;
|
||
this.ageAllSwitchSatEntries();
|
||
this.expireRipRoutes();
|
||
this.processScheduledRuntimeEvent(event.kind);
|
||
outputs.push(this.output({ type: 'fired', event }));
|
||
}
|
||
|
||
this.stateValue.currentTimeMilliseconds = targetTimeMilliseconds;
|
||
this.ageAllSwitchSatEntries();
|
||
this.expireRipRoutes();
|
||
outputs.push(
|
||
this.output({ type: 'advanced', fromMilliseconds: startTime, toMilliseconds: targetTimeMilliseconds }),
|
||
);
|
||
return outputs;
|
||
} finally {
|
||
this.isProcessingScheduledEvents = wasProcessingScheduledEvents;
|
||
}
|
||
}
|
||
|
||
private nextDueEventIndex(targetTimeMilliseconds: number): number | undefined {
|
||
let best: number | undefined;
|
||
const events = this.stateValue.pendingEvents;
|
||
for (let index = 0; index < events.length; index += 1) {
|
||
const candidate = events[index]!;
|
||
if (candidate.deadlineMilliseconds > targetTimeMilliseconds) continue;
|
||
if (best === undefined) {
|
||
best = index;
|
||
continue;
|
||
}
|
||
const current = events[best]!;
|
||
if (
|
||
candidate.deadlineMilliseconds < current.deadlineMilliseconds ||
|
||
(candidate.deadlineMilliseconds === current.deadlineMilliseconds &&
|
||
candidate.sequenceNumber < current.sequenceNumber)
|
||
) {
|
||
best = index;
|
||
}
|
||
}
|
||
return best;
|
||
}
|
||
|
||
private recordCompatibilityTrace(operation: CompatibilityOperation): void {
|
||
this.recordTrace({
|
||
nodeId: operation.nodeId,
|
||
interfaceId: operation.interfaceId,
|
||
direction: 'local',
|
||
layer: 'application',
|
||
operation: 'compatibilityAdapter',
|
||
afterHeaders: [{ name: 'kind', value: operation.kind }],
|
||
detail: operation.detail,
|
||
});
|
||
}
|
||
|
||
private output(kind: RuntimeOutputKind): RuntimeOutput {
|
||
return { timeMilliseconds: this.stateValue.currentTimeMilliseconds, kind };
|
||
}
|
||
|
||
private processScheduledRuntimeEvent(kind: ScheduledEventKind): void {
|
||
switch (kind.kind) {
|
||
case 'ethernetLinkDeparture':
|
||
this.departEthernetFrame(kind.sourceNodeId, kind.outgoingPortId, { nodeId: kind.targetNodeId, portId: kind.targetPortId }, kind.frame);
|
||
return;
|
||
case 'ethernetLinkDelivery':
|
||
if (this.dropForGlobalPacketLoss(kind.frame, kind.targetNodeId, kind.targetPortId, 'inbound', 'physical')) {
|
||
return;
|
||
}
|
||
this.receiveEthernetFrame(kind.frame, kind.targetNodeId, kind.targetPortId);
|
||
return;
|
||
case 'parityMarker':
|
||
return;
|
||
case 'ripBeacon':
|
||
throw new NotPortedError('RIP beacons');
|
||
case 'dhcpClientStart':
|
||
this.runDhcpClient(kind.nodeId);
|
||
return;
|
||
case 'dhcpTimeout':
|
||
this.handleDhcpClientTimeout(kind.nodeId);
|
||
return;
|
||
case 'tcpTimeout':
|
||
this.handleTcpTimeout(kind.sessionId);
|
||
return;
|
||
case 'remoteLinkDelivery':
|
||
case 'lanRemoteLinkTransmission':
|
||
throw new NotPortedError('remote links');
|
||
case 'natExpirySweep':
|
||
throw new NotPortedError('gateway NAT');
|
||
}
|
||
}
|
||
|
||
private dropForGlobalPacketLoss(
|
||
frame: EthernetFrame,
|
||
nodeId: Id,
|
||
interfaceId: Id | undefined,
|
||
direction: TraceDirection,
|
||
layer: TraceLayer,
|
||
): boolean {
|
||
if (!this.stateValue.globalPacketLossEnabled) return false;
|
||
this.recordTrace({
|
||
frameIdentity: frame.identity,
|
||
packetIdentity: packetIdentityInFrame(frame),
|
||
nodeId,
|
||
interfaceId,
|
||
direction,
|
||
layer,
|
||
operation: 'dropped',
|
||
afterHeaders: ethernetHeaders(frame),
|
||
detail: 'global packet-loss simulation',
|
||
});
|
||
return true;
|
||
}
|
||
|
||
// ── UDP sockets ──────────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* A UDP socket on `nodeId`: on `localPort`, else on a free port drawn at
|
||
* random from the ephemeral range; undefined while stopped, when the port
|
||
* is taken or the node has no interface with `localIPAddress`.
|
||
*/
|
||
bindUdpSocket(
|
||
nodeId: Id,
|
||
options: { localPort?: number; localIPAddress?: string; remoteIPAddress?: string; remotePort?: number } = {},
|
||
): Id | undefined {
|
||
const state = this.stateValue;
|
||
if (state.phase !== 'running') return undefined;
|
||
let reservedPort: number;
|
||
if (options.localPort !== undefined) {
|
||
if (this.isUdpPortReserved(nodeId, options.localPort)) return undefined;
|
||
reservedPort = options.localPort;
|
||
} else {
|
||
const port = this.reserveFreeUdpPort(nodeId);
|
||
if (port === undefined) return undefined;
|
||
reservedPort = port;
|
||
}
|
||
if (
|
||
options.localIPAddress !== undefined &&
|
||
!this.networkInterfaces(nodeId).some((networkInterface) => networkInterface.ipAddress === options.localIPAddress)
|
||
) {
|
||
return undefined;
|
||
}
|
||
const id = this.allocateSocketId();
|
||
state.socketsById.set(id, {
|
||
id,
|
||
nodeId,
|
||
protocol: 'udp',
|
||
localIPAddress: options.localIPAddress,
|
||
localPort: reservedPort,
|
||
remoteIPAddress: options.remoteIPAddress,
|
||
remotePort: options.remotePort,
|
||
tcpState: undefined,
|
||
parentListenerSocketId: undefined,
|
||
});
|
||
state.udpReceiveQueuesBySocketId.set(id, []);
|
||
return id;
|
||
}
|
||
|
||
private isUdpPortReserved(nodeId: Id, port: number): boolean {
|
||
// Swift also counts a gateway's NAT mappings; gateways do not run here yet.
|
||
for (const socket of this.stateValue.socketsById.values()) {
|
||
if (socket.nodeId === nodeId && socket.protocol === 'udp' && socket.localPort === port) return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
private reserveFreeUdpPort(nodeId: Id): number | undefined {
|
||
const span = UDP_EPHEMERAL_PORT_UPPER_BOUND_EXCLUSIVE - UDP_EPHEMERAL_PORT_LOWER_BOUND;
|
||
for (let attempt = 0; attempt < span; attempt++) {
|
||
const port = UDP_EPHEMERAL_PORT_LOWER_BOUND + this.udpPortRandom.nextInt(span);
|
||
if (!this.isUdpPortReserved(nodeId, port)) return port;
|
||
}
|
||
return undefined;
|
||
}
|
||
|
||
closeSocket(socketId: Id): void {
|
||
if (this.stateValue.socketsById.get(socketId)?.protocol === 'tcp') {
|
||
this.closeTcpSocket(socketId);
|
||
return;
|
||
}
|
||
this.stateValue.socketsById.delete(socketId);
|
||
this.stateValue.udpReceiveQueuesBySocketId.delete(socketId);
|
||
}
|
||
|
||
/** Forgets a socket without closing it, with the sockets a TCP listener accepted. */
|
||
discardSocket(socketId: Id): void {
|
||
const state = this.stateValue;
|
||
const children = state.tcpAcceptedSocketIdsByListenerId.get(socketId) ?? [];
|
||
state.tcpAcceptedSocketIdsByListenerId.delete(socketId);
|
||
for (const child of children) {
|
||
state.socketsById.delete(child);
|
||
state.tcpSessionsById.delete(child);
|
||
}
|
||
state.socketsById.delete(socketId);
|
||
state.tcpSessionsById.delete(socketId);
|
||
state.udpReceiveQueuesBySocketId.delete(socketId);
|
||
for (const [listener, accepted] of state.tcpAcceptedSocketIdsByListenerId) {
|
||
state.tcpAcceptedSocketIdsByListenerId.set(
|
||
listener,
|
||
accepted.filter((id) => id !== socketId),
|
||
);
|
||
}
|
||
}
|
||
|
||
private allocateSocketId(): Id {
|
||
const id = deterministicSocketId(this.stateValue.nextSocketSequenceNumber);
|
||
this.stateValue.nextSocketSequenceNumber += 1;
|
||
return id;
|
||
}
|
||
|
||
/**
|
||
* The oldest datagram in the socket's queue; with a timeout, the clock
|
||
* advances up to it when the queue is empty, as a blocking receive in Java.
|
||
*/
|
||
receiveUdp(socketId: Id, timeoutMilliseconds = 0): UdpReceivedDatagram | undefined {
|
||
const next = () => this.stateValue.udpReceiveQueuesBySocketId.get(socketId)?.shift();
|
||
const datagram = next();
|
||
if (datagram !== undefined) return datagram;
|
||
if (timeoutMilliseconds <= 0 || this.stateValue.phase !== 'running') return undefined;
|
||
const deadline = this.stateValue.currentTimeMilliseconds + timeoutMilliseconds;
|
||
if (!Number.isSafeInteger(deadline)) return undefined;
|
||
// Java's empfangen returns as soon as a datagram is there, else at the deadline.
|
||
this.advanceUntil(() => (this.stateValue.udpReceiveQueuesBySocketId.get(socketId)?.length ?? 0) > 0, deadline);
|
||
return next();
|
||
}
|
||
|
||
/** Advances event by event until the condition holds, at most to the deadline. */
|
||
private advanceUntil(condition: () => boolean, deadlineMilliseconds: number): void {
|
||
while (this.stateValue.phase === 'running' && !condition()) {
|
||
const next = this.earliestDeadline(() => true);
|
||
if (next === undefined || next > deadlineMilliseconds) {
|
||
this.advance(deadlineMilliseconds);
|
||
return;
|
||
}
|
||
this.advance(next);
|
||
}
|
||
}
|
||
|
||
/** Sends from a UDP socket to its remote end, or to the given one. */
|
||
sendUdp(
|
||
socketId: Id,
|
||
payload: Uint8Array,
|
||
destination: { ipAddress?: string; port?: number } = {},
|
||
): IPv4DeliveryResult | undefined {
|
||
const socket = this.stateValue.socketsById.get(socketId);
|
||
if (socket === undefined || socket.protocol !== 'udp') return undefined;
|
||
const destinationIPAddress = destination.ipAddress ?? socket.remoteIPAddress;
|
||
const destinationPort = destination.port ?? socket.remotePort;
|
||
if (destinationIPAddress === undefined || destinationPort === undefined) return undefined;
|
||
const interfaces = this.networkInterfaces(socket.nodeId);
|
||
if (interfaces.length === 0) return undefined;
|
||
const boundInterface =
|
||
socket.localIPAddress === undefined
|
||
? undefined
|
||
: interfaces.find((networkInterface) => networkInterface.ipAddress === socket.localIPAddress);
|
||
const packetIdentity = this.allocatePacketIdentity();
|
||
const datagram: UdpDatagram = { sourcePort: socket.localPort, destinationPort, payload };
|
||
const traceSent = (networkInterface: NetworkInterface, identity: number) =>
|
||
this.recordTrace({
|
||
packetIdentity: identity,
|
||
nodeId: socket.nodeId,
|
||
interfaceId: networkInterface.portId,
|
||
direction: 'outbound',
|
||
layer: 'transport',
|
||
operation: 'sent',
|
||
afterHeaders: udpHeaders(datagram),
|
||
});
|
||
|
||
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);
|
||
if (datagram.destinationPort === DNS_PORT) this.serveDnsQueries(nodeId);
|
||
}
|
||
|
||
// ── DNS (after Java's DNSServer and Resolver) ────────────────────────────
|
||
|
||
/** Every DNS server switched on listens on port 53. */
|
||
private initializeDnsState(): void {
|
||
const state = this.stateValue;
|
||
state.dnsServerSocketIdsByNodeId.clear();
|
||
for (const [nodeId, configuration] of state.topologySnapshot.dnsServerConfigurationsByNodeId) {
|
||
if (configuration.recursiveResolutionEnabled) throw new NotPortedError('recursive DNS resolution');
|
||
const socketId = this.bindUdpSocket(nodeId, { localPort: DNS_PORT });
|
||
if (socketId !== undefined) state.dnsServerSocketIdsByNodeId.set(nodeId, socketId);
|
||
}
|
||
}
|
||
|
||
/** DNSServerMitarbeiter: each query answered to where it came from, with the query's ID. */
|
||
private serveDnsQueries(nodeId: Id): void {
|
||
const state = this.stateValue;
|
||
const socketId = state.dnsServerSocketIdsByNodeId.get(nodeId);
|
||
const configuration = state.topologySnapshot.dnsServerConfigurationsByNodeId.get(nodeId);
|
||
if (socketId === undefined || configuration === undefined) return;
|
||
for (let received = this.receiveUdp(socketId); received !== undefined; received = this.receiveUdp(socketId)) {
|
||
const query = decodeDnsMessage(new TextDecoder().decode(received.datagram.payload));
|
||
const records = configuration.records.map(messageRecord);
|
||
const answers = query.questions.flatMap((question) => answerDnsQuestion(question, records));
|
||
const response: DnsMessage = {
|
||
id: query.id,
|
||
queryResponse: DNS_RESPONSE,
|
||
responseCode: 0,
|
||
questions: [],
|
||
answers,
|
||
authority: [],
|
||
additional: [],
|
||
};
|
||
this.recordTrace({ nodeId, direction: 'outbound', layer: 'application', operation: 'sent', detail: 'DNS response' });
|
||
this.sendUdp(socketId, new TextEncoder().encode(encodeDnsMessage(response)), {
|
||
ipAddress: received.senderIPAddress,
|
||
port: received.datagram.sourcePort,
|
||
});
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Java's Resolver.resolve, blocking on the virtual clock: asks the node's
|
||
* DNS server (or `serverIPAddress`), and follows the name servers it is
|
||
* referred to, each asked once, until one answers for the name and type.
|
||
* The records for the name and type it collected; timedOut when a server
|
||
* does not answer within five round trips (Java: TimeOutException).
|
||
*/
|
||
resolveDns(nodeId: Id, name: string, type: string, serverIPAddress?: string): DnsResolution {
|
||
const domainName = name.endsWith('.') ? name : `${name}.`;
|
||
const collected: DnsMessageRecord[] = [];
|
||
if (/^localhost\.?$/i.test(domainName)) {
|
||
return { type: 'answered', records: [{ name: 'localhost.', type: 'A', ttl: 3600, data: LOCALHOST_IP_ADDRESS }] };
|
||
}
|
||
const queried = new Set<string>();
|
||
let server: string | undefined = serverIPAddress ?? this.stateValue.topologySnapshot.deviceConfigurations.get(nodeId)?.dnsServer;
|
||
while (server !== undefined && server !== '') {
|
||
const response = this.queryDnsServer(nodeId, domainName, type, server);
|
||
if (response === undefined) return { type: 'timedOut' };
|
||
const relevant = (records: readonly DnsMessageRecord[], wanted: string, wantedType: string) => findDnsRecords(wanted, wantedType, records);
|
||
collected.push(...relevant(response.answers, domainName, type), ...relevant(response.authority, domainName, type));
|
||
const data = [...response.answers, ...response.authority, ...response.additional].filter(
|
||
(record) => record.name.toLowerCase() === domainName.toLowerCase() && record.type === type,
|
||
);
|
||
if (data.length > 0) {
|
||
if (type === 'MX' || type === 'NS') {
|
||
collected.push(...relevant(response.answers, data[0]!.data, 'A'), ...relevant(response.authority, data[0]!.data, 'A'));
|
||
}
|
||
break;
|
||
}
|
||
const all = [...response.answers, ...response.authority, ...response.additional];
|
||
const nameServer = findApplicableNameServerRecord(domainName, all);
|
||
const next = nameServer === undefined ? undefined : findDnsRecords(nameServer.data, 'A', all)[0]?.data;
|
||
// Java remembers only the servers it was referred to, not the first one.
|
||
server = next !== undefined && !queried.has(next) ? next : undefined;
|
||
if (next !== undefined) queried.add(next);
|
||
}
|
||
return { type: 'answered', records: collected };
|
||
}
|
||
|
||
/** Resolver.holeIPAdresse: the A record's address, undefined when there is none. */
|
||
resolveDnsAddress(nodeId: Id, name: string): DnsAddressResolution {
|
||
const domainName = name.endsWith('.') ? name : `${name}.`;
|
||
const resolution = this.resolveDns(nodeId, domainName, 'A');
|
||
if (resolution.type === 'timedOut') return resolution;
|
||
return { type: 'resolved', address: findDnsRecords(domainName, 'A', resolution.records)[0]?.data };
|
||
}
|
||
|
||
/** Resolver.holeIPAdresseMailServer: the address of the domain's mail exchange. */
|
||
resolveDnsMailServer(nodeId: Id, domain: string): DnsAddressResolution {
|
||
const domainName = domain.endsWith('.') ? domain : `${domain}.`;
|
||
const resolution = this.resolveDns(nodeId, domainName, 'MX');
|
||
if (resolution.type === 'timedOut') return resolution;
|
||
const exchange = findDnsRecords(domainName, 'MX', resolution.records)[0];
|
||
return { type: 'resolved', address: exchange === undefined ? undefined : findDnsRecords(exchange.data, 'A', resolution.records)[0]?.data };
|
||
}
|
||
|
||
/** DNSQueryAgent.query: one question from a fresh socket, the first datagram back within five round trips. */
|
||
private queryDnsServer(nodeId: Id, domainName: string, type: string, serverIPAddress: string): DnsMessage | undefined {
|
||
const socketId = this.bindUdpSocket(nodeId, { remoteIPAddress: serverIPAddress, remotePort: DNS_PORT });
|
||
if (socketId === undefined) return undefined;
|
||
const query: DnsMessage = {
|
||
// Java: (int) (Math.random() * 65536).
|
||
id: Math.floor(this.dnsIdRandom.nextDouble() * 65_536),
|
||
queryResponse: DNS_QUERY,
|
||
responseCode: 0,
|
||
questions: [{ name: domainName, type, questionClass: 'IN' }],
|
||
answers: [],
|
||
authority: [],
|
||
additional: [],
|
||
};
|
||
this.recordTrace({ nodeId, direction: 'outbound', layer: 'application', operation: 'sent', detail: `DNS query ${domainName} ${type}` });
|
||
this.sendUdp(socketId, new TextEncoder().encode(encodeDnsMessage(query)));
|
||
const received = this.receiveUdp(socketId, DNS_QUERY_TIMEOUT_ROUND_TRIPS * this.runtimeSpeed.roundTripTimeMilliseconds);
|
||
this.closeSocket(socketId);
|
||
return received === undefined ? undefined : decodeDnsMessage(new TextDecoder().decode(received.datagram.payload));
|
||
}
|
||
|
||
// ── TCP sockets ──────────────────────────────────────────────────────────
|
||
|
||
/** A listening TCP socket; undefined while stopped, when the port is taken or the address is not the node's. */
|
||
openTcpServerSocket(nodeId: Id, localPort: number, localIPAddress?: string): Id | undefined {
|
||
const state = this.stateValue;
|
||
if (
|
||
state.phase !== 'running' ||
|
||
this.isTcpPortReserved(nodeId, localPort) ||
|
||
(localIPAddress !== undefined && !this.networkInterfaces(nodeId).some((each) => each.ipAddress === localIPAddress))
|
||
) {
|
||
return undefined;
|
||
}
|
||
const id = this.allocateSocketId();
|
||
state.socketsById.set(id, {
|
||
id,
|
||
nodeId,
|
||
protocol: 'tcp',
|
||
localIPAddress,
|
||
localPort,
|
||
remoteIPAddress: undefined,
|
||
remotePort: undefined,
|
||
tcpState: 'LISTEN',
|
||
parentListenerSocketId: undefined,
|
||
});
|
||
state.tcpSessionsById.set(id, this.makeTcpSession(id, 'LISTEN'));
|
||
state.tcpAcceptedSocketIdsByListenerId.set(id, []);
|
||
this.recordTcpStateTransition(id, 'CLOSED', 'LISTEN');
|
||
return id;
|
||
}
|
||
|
||
/** A closed TCP client socket towards the remote end, on the given or a free port. */
|
||
openTcpClientSocket(
|
||
nodeId: Id,
|
||
remoteIPAddress: string,
|
||
remotePort: number,
|
||
options: { localIPAddress?: string; localPort?: number } = {},
|
||
): Id | undefined {
|
||
const state = this.stateValue;
|
||
if (state.phase !== 'running') return undefined;
|
||
let localIPAddress: string | undefined;
|
||
if (options.localIPAddress !== undefined) {
|
||
if (!this.networkInterfaces(nodeId).some((each) => each.ipAddress === options.localIPAddress)) return undefined;
|
||
localIPAddress = options.localIPAddress;
|
||
} else {
|
||
const route = this.routeForPacket(nodeId, remoteIPAddress);
|
||
localIPAddress = route.type === 'egress' ? route.interface.ipAddress : this.networkInterfaces(nodeId)[0]?.ipAddress;
|
||
}
|
||
if (localIPAddress === undefined) return undefined;
|
||
let localPort: number;
|
||
if (options.localPort !== undefined) {
|
||
if (this.isTcpPortReserved(nodeId, options.localPort)) return undefined;
|
||
localPort = options.localPort;
|
||
} else {
|
||
const port = this.reserveFreeTcpPort(nodeId);
|
||
if (port === undefined) return undefined;
|
||
localPort = port;
|
||
}
|
||
const id = this.allocateSocketId();
|
||
state.socketsById.set(id, {
|
||
id,
|
||
nodeId,
|
||
protocol: 'tcp',
|
||
localIPAddress,
|
||
localPort,
|
||
remoteIPAddress,
|
||
remotePort,
|
||
tcpState: 'CLOSED',
|
||
parentListenerSocketId: undefined,
|
||
});
|
||
state.tcpSessionsById.set(id, this.makeTcpSession(id, 'CLOSED'));
|
||
return id;
|
||
}
|
||
|
||
/** A port held by an open (not closed) TCP socket that is no listener's child. */
|
||
isTcpPortReserved(nodeId: Id, port: number): boolean {
|
||
for (const socket of this.stateValue.socketsById.values()) {
|
||
if (
|
||
socket.nodeId === nodeId &&
|
||
socket.protocol === 'tcp' &&
|
||
socket.localPort === port &&
|
||
socket.parentListenerSocketId === undefined &&
|
||
socket.tcpState !== 'CLOSED'
|
||
) {
|
||
return true;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
connectTcp(socketId: Id): boolean {
|
||
return this.connectTcpWithResult(socketId) === 'connected';
|
||
}
|
||
|
||
/** The three-way handshake from a closed client socket, waiting on the virtual clock as Java blocks. */
|
||
connectTcpWithResult(socketId: Id): TcpConnectionResult {
|
||
const state = this.stateValue;
|
||
const socket = state.socketsById.get(socketId);
|
||
if (
|
||
socket === undefined ||
|
||
socket.protocol !== 'tcp' ||
|
||
socket.parentListenerSocketId !== undefined ||
|
||
socket.remoteIPAddress === undefined ||
|
||
socket.remotePort === undefined ||
|
||
socket.tcpState !== 'CLOSED' ||
|
||
[...state.socketsById.values()].some(
|
||
(other) =>
|
||
other.id !== socketId &&
|
||
other.nodeId === socket.nodeId &&
|
||
other.protocol === 'tcp' &&
|
||
other.localPort === socket.localPort &&
|
||
other.parentListenerSocketId === undefined &&
|
||
other.tcpState !== 'CLOSED',
|
||
)
|
||
) {
|
||
return 'invalidSocket';
|
||
}
|
||
this.updateTcpState(socketId, 'SYN_SENT');
|
||
this.resetTcpSendAttempts(socketId);
|
||
const initialDelivery = this.transmitTcpSegment(socketId, TCP_FLAG.synchronize, new Uint8Array(), 0, 'TCP SYN');
|
||
const waitedForTimeout = this.advanceTcpUntilSettled(socketId, { waitingStates: ['SYN_SENT'] });
|
||
if (state.socketsById.get(socketId)?.tcpState === 'ESTABLISHED') return 'connected';
|
||
if (initialDelivery === undefined || initialDelivery.type !== 'delivered') return 'unreachable';
|
||
return waitedForTimeout ? 'timedOut' : 'unreachable';
|
||
}
|
||
|
||
/** Sends in segments of at most TCP_MAXIMUM_SEGMENT_SIZE bytes, each acknowledged before the next. */
|
||
sendTcp(socketId: Id, payload: Uint8Array): boolean {
|
||
const state = this.stateValue;
|
||
if (state.socketsById.get(socketId)?.tcpState !== 'ESTABLISHED') return false;
|
||
const chunks: Uint8Array[] = [];
|
||
if (payload.length === 0) {
|
||
chunks.push(new Uint8Array());
|
||
} else {
|
||
for (let offset = 0; offset < payload.length; offset += TCP_MAXIMUM_SEGMENT_SIZE) {
|
||
chunks.push(payload.subarray(offset, Math.min(offset + TCP_MAXIMUM_SEGMENT_SIZE, payload.length)));
|
||
}
|
||
}
|
||
for (const [index, chunk] of chunks.entries()) {
|
||
if (state.socketsById.get(socketId)?.tcpState !== 'ESTABLISHED') return false;
|
||
this.resetTcpSendAttempts(socketId);
|
||
const flags = index === chunks.length - 1 ? TCP_FLAG.push : 0;
|
||
this.transmitTcpSegment(socketId, flags, chunk, 0, `TCP data segment ${index + 1}/${chunks.length}`);
|
||
this.advanceTcpUntilSettled(socketId, { waitingForAcknowledgement: true });
|
||
const session = state.tcpSessionsById.get(socketId);
|
||
if (session === undefined || session.state !== 'ESTABLISHED' || session.lastSentSegment !== undefined) return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
/** The oldest whole message (up to a PSH) received on the socket. */
|
||
receiveTcp(socketId: Id): Uint8Array | undefined {
|
||
const session = this.stateValue.tcpSessionsById.get(socketId);
|
||
if (session === undefined || session.receivedMessages.length === 0) return undefined;
|
||
return session.receivedMessages.shift();
|
||
}
|
||
|
||
closeTcpSocket(socketId: Id): void {
|
||
const socket = this.stateValue.socketsById.get(socketId);
|
||
if (socket === undefined || socket.protocol !== 'tcp' || socket.tcpState === undefined) return;
|
||
switch (socket.tcpState) {
|
||
case 'LISTEN':
|
||
case 'SYN_SENT':
|
||
this.updateTcpState(socketId, 'CLOSED');
|
||
this.clearTcpPendingTransmission(socketId);
|
||
return;
|
||
case 'ESTABLISHED':
|
||
case 'SYN_RCVD':
|
||
this.updateTcpState(socketId, 'FIN_WAIT_1');
|
||
this.resetTcpSendAttempts(socketId);
|
||
this.transmitTcpSegment(socketId, TCP_FLAG.finish, new Uint8Array(), 0, 'TCP FIN');
|
||
return;
|
||
case 'CLOSE_WAIT':
|
||
this.updateTcpState(socketId, 'LAST_ACK');
|
||
this.resetTcpSendAttempts(socketId);
|
||
this.transmitTcpSegment(socketId, TCP_FLAG.finish, new Uint8Array(), 0, 'TCP FIN after CLOSE_WAIT');
|
||
return;
|
||
default:
|
||
return;
|
||
}
|
||
}
|
||
|
||
/** The socket at the other end of a connection, the first by id. */
|
||
peerTcpSocketId(socketId: Id): Id | undefined {
|
||
const socket = this.stateValue.socketsById.get(socketId);
|
||
if (
|
||
socket === undefined ||
|
||
socket.protocol !== 'tcp' ||
|
||
socket.localIPAddress === undefined ||
|
||
socket.remoteIPAddress === undefined ||
|
||
socket.remotePort === undefined
|
||
) {
|
||
return undefined;
|
||
}
|
||
return [...this.stateValue.socketsById.values()]
|
||
.filter(
|
||
(candidate) =>
|
||
candidate.id !== socketId &&
|
||
candidate.protocol === 'tcp' &&
|
||
candidate.tcpState !== 'LISTEN' &&
|
||
candidate.localIPAddress === socket.remoteIPAddress &&
|
||
candidate.localPort === socket.remotePort &&
|
||
candidate.remoteIPAddress === socket.localIPAddress &&
|
||
candidate.remotePort === socket.localPort,
|
||
)
|
||
.sort((lhs, rhs) => compareIds(lhs.id, rhs.id))[0]?.id;
|
||
}
|
||
|
||
/** Removes a closed TCP socket; false when it is not one. */
|
||
cleanClosedTcpSocket(socketId: Id): boolean {
|
||
const state = this.stateValue;
|
||
const socket = state.socketsById.get(socketId);
|
||
if (socket?.protocol !== 'tcp' || socket.tcpState !== 'CLOSED') return false;
|
||
this.cancelTcpTimeouts(socketId);
|
||
state.socketsById.delete(socketId);
|
||
state.tcpSessionsById.delete(socketId);
|
||
state.tcpAcceptedSocketIdsByListenerId.delete(socketId);
|
||
for (const [listener, accepted] of state.tcpAcceptedSocketIdsByListenerId) {
|
||
state.tcpAcceptedSocketIdsByListenerId.set(
|
||
listener,
|
||
accepted.filter((id) => id !== socketId),
|
||
);
|
||
}
|
||
return true;
|
||
}
|
||
|
||
private deliverTcp(packet: IPv4Packet, segment: TcpSegment, nodeId: Id, networkInterface: NetworkInterface): void {
|
||
const state = this.stateValue;
|
||
this.recordTrace({
|
||
packetIdentity: packet.identity,
|
||
nodeId,
|
||
interfaceId: networkInterface.portId,
|
||
direction: 'local',
|
||
layer: 'transport',
|
||
operation: 'accepted',
|
||
beforeHeaders: tcpHeaders(segment),
|
||
detail: 'TCP segment received',
|
||
});
|
||
const byId = (lhs: SocketRecord, rhs: SocketRecord) => compareIds(lhs.id, rhs.id);
|
||
const sockets = [...state.socketsById.values()];
|
||
const exact = sockets
|
||
.filter(
|
||
(socket) =>
|
||
socket.nodeId === nodeId &&
|
||
socket.protocol === 'tcp' &&
|
||
socket.tcpState !== 'LISTEN' &&
|
||
socket.tcpState !== 'CLOSED' &&
|
||
socket.localPort === segment.destinationPort &&
|
||
(socket.localIPAddress === undefined || socket.localIPAddress === packet.receiverIPAddress) &&
|
||
socket.remoteIPAddress === packet.senderIPAddress &&
|
||
socket.remotePort === segment.sourcePort,
|
||
)
|
||
.sort(byId)[0];
|
||
if (exact !== undefined) {
|
||
this.processIncomingTcpSegment(exact.id, segment);
|
||
return;
|
||
}
|
||
const listener = sockets
|
||
.filter(
|
||
(socket) =>
|
||
socket.nodeId === nodeId &&
|
||
socket.protocol === 'tcp' &&
|
||
socket.tcpState === 'LISTEN' &&
|
||
socket.localPort === segment.destinationPort &&
|
||
(socket.localIPAddress === undefined || socket.localIPAddress === packet.receiverIPAddress),
|
||
)
|
||
.sort(byId)[0];
|
||
if (listener === undefined || (segment.flags & TCP_FLAG.synchronize) === 0 || (segment.flags & TCP_FLAG.acknowledgement) !== 0) {
|
||
this.recordTrace({
|
||
packetIdentity: packet.identity,
|
||
nodeId,
|
||
interfaceId: networkInterface.portId,
|
||
direction: 'local',
|
||
layer: 'transport',
|
||
operation: 'dropped',
|
||
beforeHeaders: tcpHeaders(segment),
|
||
detail: 'TCP destination port closed',
|
||
});
|
||
return;
|
||
}
|
||
const acceptedId = this.allocateSocketId();
|
||
state.socketsById.set(acceptedId, {
|
||
id: acceptedId,
|
||
nodeId,
|
||
protocol: 'tcp',
|
||
localIPAddress: packet.receiverIPAddress,
|
||
localPort: segment.destinationPort,
|
||
remoteIPAddress: packet.senderIPAddress,
|
||
remotePort: segment.sourcePort,
|
||
tcpState: 'SYN_RCVD',
|
||
parentListenerSocketId: listener.id,
|
||
});
|
||
// Java accepts on the socket that listened: it has the listener's initial sequence number.
|
||
const session = this.makeTcpSession(acceptedId, 'SYN_RCVD', state.tcpSessionsById.get(listener.id)?.nextSendSequenceNumber);
|
||
session.remoteSequenceNumber = nextTcpSequenceNumber(segment);
|
||
state.tcpSessionsById.set(acceptedId, session);
|
||
getOrCreate(state.tcpAcceptedSocketIdsByListenerId, listener.id, () => []).push(acceptedId);
|
||
this.recordTcpStateTransition(acceptedId, 'LISTEN', 'SYN_RCVD');
|
||
this.resetTcpSendAttempts(acceptedId);
|
||
this.transmitTcpSegment(
|
||
acceptedId,
|
||
TCP_FLAG.synchronize | TCP_FLAG.acknowledgement,
|
||
new Uint8Array(),
|
||
session.remoteSequenceNumber,
|
||
'TCP SYN+ACK',
|
||
);
|
||
}
|
||
|
||
private processIncomingTcpSegment(socketId: Id, segment: TcpSegment): void {
|
||
const socket = this.stateValue.socketsById.get(socketId);
|
||
if (socket === undefined || socket.tcpState === undefined) return;
|
||
const has = (flag: number) => (segment.flags & flag) !== 0;
|
||
if (has(TCP_FLAG.reset)) {
|
||
this.clearTcpPendingTransmission(socketId);
|
||
this.updateTcpState(socketId, 'CLOSED');
|
||
return;
|
||
}
|
||
switch (socket.tcpState) {
|
||
case 'SYN_SENT': {
|
||
if (
|
||
!has(TCP_FLAG.synchronize) ||
|
||
!has(TCP_FLAG.acknowledgement) ||
|
||
!this.acknowledgeTcpIfExpected(socketId, segment.acknowledgementNumber)
|
||
) {
|
||
this.updateTcpState(socketId, 'CLOSED');
|
||
return;
|
||
}
|
||
const session = this.stateValue.tcpSessionsById.get(socketId);
|
||
if (session !== undefined) session.remoteSequenceNumber = nextTcpSequenceNumber(segment);
|
||
this.updateTcpState(socketId, 'ESTABLISHED');
|
||
this.sendTcpAcknowledgement(socketId, segment);
|
||
return;
|
||
}
|
||
case 'SYN_RCVD': {
|
||
if (!has(TCP_FLAG.acknowledgement) || !this.acknowledgeTcpIfExpected(socketId, segment.acknowledgementNumber)) return;
|
||
this.updateTcpState(socketId, 'ESTABLISHED');
|
||
// Java: the socket that listened is the connection now; the server
|
||
// listens on with a new socket, and a new initial sequence number.
|
||
const listenerId = socket.parentListenerSocketId;
|
||
if (listenerId !== undefined && this.stateValue.tcpSessionsById.has(listenerId)) {
|
||
this.stateValue.tcpSessionsById.set(listenerId, this.makeTcpSession(listenerId, 'LISTEN'));
|
||
}
|
||
return;
|
||
}
|
||
case 'TIME_WAIT':
|
||
this.clearTcpPendingTransmission(socketId);
|
||
this.updateTcpState(socketId, 'CLOSED');
|
||
return;
|
||
case 'ESTABLISHED':
|
||
if (has(TCP_FLAG.finish)) {
|
||
this.sendTcpAcknowledgement(socketId, segment);
|
||
this.updateTcpState(socketId, 'CLOSE_WAIT');
|
||
// Java's socket thread leaves listen once the state is no longer
|
||
// ESTABLISHED and closes by itself, without waiting for the application.
|
||
this.closeTcpSocket(socketId);
|
||
} else if (segment.payload.length > 0 || has(TCP_FLAG.push)) {
|
||
this.receiveTcpPayload(socketId, segment);
|
||
} else if (has(TCP_FLAG.acknowledgement)) {
|
||
this.acknowledgeTcpIfExpected(socketId, segment.acknowledgementNumber);
|
||
}
|
||
return;
|
||
case 'FIN_WAIT_1': {
|
||
const hasAck = has(TCP_FLAG.acknowledgement);
|
||
const hasFin = has(TCP_FLAG.finish);
|
||
if (hasAck) this.acknowledgeTcpIfExpected(socketId, segment.acknowledgementNumber);
|
||
if (hasAck && hasFin) {
|
||
this.sendTcpAcknowledgement(socketId, segment);
|
||
this.enterTcpTimeWait(socketId);
|
||
} else if (hasAck) {
|
||
this.updateTcpState(socketId, 'FIN_WAIT_2');
|
||
} else if (hasFin) {
|
||
this.sendTcpAcknowledgement(socketId, segment);
|
||
this.updateTcpState(socketId, 'CLOSING');
|
||
}
|
||
return;
|
||
}
|
||
case 'FIN_WAIT_2':
|
||
if (has(TCP_FLAG.finish)) {
|
||
this.sendTcpAcknowledgement(socketId, segment);
|
||
this.enterTcpTimeWait(socketId);
|
||
}
|
||
return;
|
||
case 'CLOSING':
|
||
if (has(TCP_FLAG.acknowledgement) && this.acknowledgeTcpIfExpected(socketId, segment.acknowledgementNumber)) {
|
||
this.enterTcpTimeWait(socketId);
|
||
}
|
||
return;
|
||
case 'LAST_ACK':
|
||
if (has(TCP_FLAG.acknowledgement) && this.acknowledgeTcpIfExpected(socketId, segment.acknowledgementNumber)) {
|
||
this.updateTcpState(socketId, 'CLOSED');
|
||
}
|
||
return;
|
||
default:
|
||
return;
|
||
}
|
||
}
|
||
|
||
private receiveTcpPayload(socketId: Id, segment: TcpSegment): void {
|
||
const session = this.stateValue.tcpSessionsById.get(socketId);
|
||
if (session === undefined) return;
|
||
const nextRemoteSequence = nextTcpSequenceNumber(segment);
|
||
if (nextRemoteSequence > session.remoteSequenceNumber) {
|
||
session.remoteSequenceNumber = nextRemoteSequence;
|
||
session.receiveAssembly = concatBytes(session.receiveAssembly, segment.payload);
|
||
if ((segment.flags & TCP_FLAG.push) !== 0) {
|
||
session.receivedMessages.push(session.receiveAssembly);
|
||
session.receiveAssembly = new Uint8Array();
|
||
}
|
||
}
|
||
this.sendTcpAcknowledgement(socketId, segment);
|
||
}
|
||
|
||
private sendTcpAcknowledgement(socketId: Id, segment: TcpSegment): void {
|
||
this.transmitTcpSegment(socketId, TCP_FLAG.acknowledgement, new Uint8Array(), nextTcpSequenceNumber(segment), 'TCP ACK');
|
||
}
|
||
|
||
private transmitTcpSegment(
|
||
socketId: Id,
|
||
flags: number,
|
||
payload: Uint8Array,
|
||
acknowledgementNumber: number,
|
||
detail: string,
|
||
retransmission = false,
|
||
): IPv4DeliveryResult | undefined {
|
||
const state = this.stateValue;
|
||
const socket = state.socketsById.get(socketId);
|
||
const session = state.tcpSessionsById.get(socketId);
|
||
if (socket === undefined || socket.remoteIPAddress === undefined || socket.remotePort === undefined || session === undefined) {
|
||
return undefined;
|
||
}
|
||
// 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 {
|
||
const endpoint = this.attachedEndpoint(fromNodeId, outgoingPortId);
|
||
const now = this.stateValue.currentTimeMilliseconds;
|
||
const busyUntil = this.cablesCarryOneFrameAtATime ? (this.cableBusyUntilByPort.get(portKey(fromNodeId, outgoingPortId)) ?? now) : now;
|
||
if (endpoint !== undefined && busyUntil > now) {
|
||
// The cable carries a frame: this one waits in the output buffer.
|
||
this.cableBusyUntilByPort.set(portKey(fromNodeId, outgoingPortId), busyUntil + this.runtimeSpeed.linkTransmissionDelayMilliseconds);
|
||
this.schedule(busyUntil, {
|
||
kind: 'ethernetLinkDeparture',
|
||
sourceNodeId: fromNodeId,
|
||
outgoingPortId,
|
||
targetNodeId: endpoint.nodeId,
|
||
targetPortId: endpoint.portId,
|
||
frame,
|
||
});
|
||
this.drainPendingNetworkDeliveryEventsIfNeeded();
|
||
return;
|
||
}
|
||
this.departEthernetFrame(fromNodeId, outgoingPortId, endpoint, frame);
|
||
}
|
||
|
||
/** The frame leaves on the cable (and shows in the packet log); it arrives one cable's delay later. */
|
||
private departEthernetFrame(fromNodeId: Id, outgoingPortId: Id, endpoint: { nodeId: Id; portId: Id } | undefined, frame: EthernetFrame): void {
|
||
this.recordTrace({
|
||
frameIdentity: frame.identity,
|
||
packetIdentity: packetIdentityInFrame(frame),
|
||
nodeId: fromNodeId,
|
||
interfaceId: outgoingPortId,
|
||
direction: 'outbound',
|
||
layer: 'physical',
|
||
operation: 'sent',
|
||
afterHeaders: ethernetHeaders(frame),
|
||
});
|
||
if (endpoint === undefined) return;
|
||
const latencyMilliseconds = this.runtimeSpeed.linkTransmissionDelayMilliseconds;
|
||
const deadlineMilliseconds = this.stateValue.currentTimeMilliseconds + latencyMilliseconds;
|
||
if (!Number.isSafeInteger(deadlineMilliseconds)) {
|
||
this.recordTrace({
|
||
frameIdentity: frame.identity,
|
||
packetIdentity: packetIdentityInFrame(frame),
|
||
nodeId: fromNodeId,
|
||
interfaceId: outgoingPortId,
|
||
direction: 'outbound',
|
||
layer: 'physical',
|
||
operation: 'dropped',
|
||
afterHeaders: ethernetHeaders(frame),
|
||
detail: 'link transmission deadline overflow',
|
||
});
|
||
return;
|
||
}
|
||
if (this.cablesCarryOneFrameAtATime) this.cableBusyUntilByPort.set(portKey(fromNodeId, outgoingPortId), deadlineMilliseconds);
|
||
|
||
this.schedule(deadlineMilliseconds, {
|
||
kind: 'ethernetLinkDelivery',
|
||
sourceNodeId: fromNodeId,
|
||
targetNodeId: endpoint.nodeId,
|
||
targetPortId: endpoint.portId,
|
||
latencyMilliseconds,
|
||
frame,
|
||
});
|
||
this.drainPendingNetworkDeliveryEventsIfNeeded();
|
||
}
|
||
|
||
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);
|
||
}
|
||
|
||
/**
|
||
* DNSServer.answer without recursion: the records for the name and type
|
||
* (with the A record of an MX or NS target), else the name server of the
|
||
* zone the name lies in, with its A record — in the answer section, as Java has it.
|
||
*/
|
||
function answerDnsQuestion(question: DnsQuestion, records: readonly DnsMessageRecord[]): DnsMessageRecord[] {
|
||
const answers: DnsMessageRecord[] = [];
|
||
for (const record of findDnsRecords(question.name, question.type, records)) {
|
||
answers.push(record);
|
||
if (record.type === 'MX' || record.type === 'NS') {
|
||
const address = findDnsRecords(record.data, 'A', records)[0];
|
||
if (address !== undefined) answers.push(address);
|
||
}
|
||
}
|
||
if (answers.length > 0) return answers;
|
||
const nameServer = findApplicableNameServerRecord(question.name, records);
|
||
if (nameServer === undefined) return [];
|
||
const address = findDnsRecords(nameServer.data, 'A', records)[0];
|
||
return address === undefined ? [nameServer] : [nameServer, address];
|
||
}
|
||
|
||
function portKey(nodeId: Id, portId: Id): string {
|
||
return `${nodeId}|${portId}`;
|
||
}
|
||
|
||
function 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) },
|
||
];
|
||
}
|