WlanRun pings on the new network wlan-lan: over wireless cables, over a plain cable, and to and from a notebook on the switch's SSID without a cable. The engine matches Java without a change: it runs the links only. Swift's wirelessAssociations, which join a host to its SSID's switch at run time, are not taken. ScenarioNetwork sets wireless cables, wireless cards and SSIDs; a corpus test pings over the wireless cables of multicast_server.fls. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
5118 lines
215 KiB
TypeScript
5118 lines
215 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 and gateway NAT; with the firewall. Wireless
|
||
// needs nothing here: in Java a wireless cable is a cable like any
|
||
// other at run time and an SSID connects nothing — only the editor
|
||
// lays the cable by it; Swift's wirelessAssociations, which join by
|
||
// SSID at run time, are not taken. Measured with Java FILIUS)
|
||
// "UDP sockets and Router RIP" (lines ~4472–5187; RIP
|
||
// after Java's filius.software.rip: expiry checked at the beacon, a
|
||
// random of its own per router, any source port)
|
||
// "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; SYN+ACK and
|
||
// FIN go once, then Java's close loop of five waits)
|
||
// "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)
|
||
// "Gateway NAT/PAT" (lines ~3585–3900; after Java's filius.software.nat,
|
||
// measured with Java FILIUS: one entry per LAN endpoint, the expiry of
|
||
// Java's GatewayFirmware.checkNAT)
|
||
// A cable carries one frame after the other, as Java's SimplexVerbindung
|
||
// (Swift: all at once; the option swiftCables keeps that for the comparison).
|
||
// ARP after Java's holeARPTabellenEintrag: a lookup asks twice, a round trip
|
||
// each, then host unreachable (Swift: all at once, or without end when the
|
||
// address exists anywhere; the option swiftArp keeps that for the comparison).
|
||
// A send hands its frame to the card and returns, as in Java; the frames
|
||
// travel while the clock advances (Swift: the send returns when every frame
|
||
// in flight has arrived; the option swiftSends keeps that for the comparison).
|
||
// Java's threads: per node a send thread for UDP and for TCP, the IP thread
|
||
// (delivers or forwards UDP and TCP) and the ICMP thread (answers, delivers
|
||
// or forwards ICMP); each takes one unit after the other and holds the rest
|
||
// while it waits for ARP. A socket's send blocks behind its thread.
|
||
// DNS after Java (filius.software.dns: DNSServer, DNSServerMitarbeiter,
|
||
// Resolver, DNSQueryAgent), not Swift's typed format; a recursive server
|
||
// asks on without asking itself (Java does, without end).
|
||
// 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:
|
||
// remote links.
|
||
|
||
import { compareIds, type Id } from '../ids.js';
|
||
import { isPcClassEndpoint, stableGeneratedMacAddress } from '../model/node.js';
|
||
import type {
|
||
DeviceConfiguration,
|
||
DhcpServerConfiguration,
|
||
FirewallConfiguration,
|
||
GatewayPortForwardingRow,
|
||
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 { formatIPv4, maskAddress, parseIPv4, sameSubnet } from './ipv4.js';
|
||
import { javaIpCheck, javaParseInt } from '../util/javaIp.js';
|
||
import {
|
||
forwardingClone,
|
||
icmpMessage,
|
||
IPV4_PROTOCOL,
|
||
packetIdentityInFrame,
|
||
TCP_FLAG,
|
||
type ArpPacket,
|
||
type EthernetFrame,
|
||
type IcmpMessage,
|
||
type IcmpMessageKind,
|
||
type IPv4Packet,
|
||
type TcpSegment,
|
||
type TcpSocketState,
|
||
type UdpDatagram,
|
||
} from './packets.js';
|
||
import {
|
||
decodeDnsMessage,
|
||
DNS_PORT,
|
||
DNS_QUERY,
|
||
DNS_RESPONSE,
|
||
encodeDnsMessage,
|
||
findApplicableNameServerRecord,
|
||
findDnsRecords,
|
||
messageRecord,
|
||
type DnsMessage,
|
||
type DnsMessageRecord,
|
||
type DnsQuestion,
|
||
} from './dns.js';
|
||
import { JavaCompatibleRandom } from './random.js';
|
||
import { routeTableRows, type RouteRow } from './routeTable.js';
|
||
import { EMPTY_TOPOLOGY_SNAPSHOT, withSnapshot, type NodeSnapshot, type TopologySnapshot } from './snapshot.js';
|
||
import { RuntimeSpeed } from './speed.js';
|
||
import {
|
||
PRODUCTION_TRACE_RETENTION,
|
||
type HeaderField,
|
||
type TraceDirection,
|
||
type TraceEvent,
|
||
type TraceLayer,
|
||
type TraceOperation,
|
||
type TraceRetentionPolicy,
|
||
} from './trace.js';
|
||
|
||
export const ETHERNET_BROADCAST_MAC_ADDRESS = 'FF:FF:FF:FF:FF:FF';
|
||
export const UNSPECIFIED_MAC_ADDRESS = '00:00:00:00:00:00';
|
||
export const LIMITED_BROADCAST_IP_ADDRESS = '255.255.255.255';
|
||
export const UNSPECIFIED_IP_ADDRESS = '0.0.0.0';
|
||
export const LOCALHOST_IP_ADDRESS = '127.0.0.1';
|
||
|
||
/** The seed a simulation uses unless told otherwise. */
|
||
export const DEFAULT_SEED = 0xf11a5n;
|
||
|
||
/** A part of the Swift engine that has not been carried over yet. */
|
||
export class NotPortedError extends Error {
|
||
constructor(feature: string) {
|
||
super(`Not ported yet: ${feature}`);
|
||
this.name = 'NotPortedError';
|
||
}
|
||
}
|
||
|
||
// ── Events, inputs, outputs ────────────────────────────────────────────────
|
||
|
||
export type ScheduledEventKind =
|
||
| { readonly kind: 'parityMarker'; readonly label: string }
|
||
| { readonly kind: 'ripBeacon'; readonly nodeId: Id }
|
||
| { readonly kind: 'dhcpClientStart'; readonly nodeId: Id }
|
||
| { readonly kind: 'dhcpTimeout'; readonly nodeId: Id }
|
||
/** An ARP lookup waited a round trip for its answer. */
|
||
| { readonly kind: 'arpTimeout'; readonly lookupId: number }
|
||
/** The lookups waiting for an address wake up, after what else happens at that time. */
|
||
| { readonly kind: 'arpAnswered'; readonly nodeId: Id; readonly ipAddress: string; readonly macAddress: string }
|
||
| { readonly kind: 'tcpTimeout'; readonly sessionId: Id }
|
||
/** A DNS query waited five round trips for its answer (DNSQueryAgent). */
|
||
| { readonly kind: 'dnsQueryTimeout'; readonly socketId: 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;
|
||
};
|
||
|
||
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;
|
||
/** While the socket closes (Java's close loop): the waits it has left; else undefined. */
|
||
closingWaitsLeft: number | 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' };
|
||
/** A resolution as it goes on the events: the answer and authority records apart, as Java keeps them. */
|
||
type DnsLookup =
|
||
| { readonly type: 'answered'; readonly answers: readonly DnsMessageRecord[]; readonly authority: 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;
|
||
/**
|
||
* Java's TCPSocket.close: after its FIN (sent once) a socket waits at most
|
||
* five times, each wait ended by a segment or after defaultTimeout; then it
|
||
* is gone.
|
||
*/
|
||
export const TCP_CLOSING_WAITS = 5;
|
||
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;
|
||
/** RIPTable: 16 hops is unreachable, a beacon every 30 s (drawn 25.2–35.2 s), a learned route lasts 75 s. */
|
||
export const RIP_INFINITY = 16;
|
||
export const RIP_TIMEOUT_MILLISECONDS = 75_000;
|
||
export const RIP_FIRST_BEACON_DELAY_MILLISECONDS = 1_000;
|
||
|
||
/** GatewayFirmware.retentionTime: a dynamic NAT entry lasts 300 s, times Java's delay factor. */
|
||
export const NAT_RETENTION_MILLISECONDS = 300_000;
|
||
|
||
export type NatProtocol = 'tcp' | 'udp' | 'icmp';
|
||
|
||
export interface NatEndpoint {
|
||
readonly ipAddress: string;
|
||
/** A port; for ICMP, the identifier. */
|
||
readonly port: number;
|
||
}
|
||
|
||
/**
|
||
* An entry of a home router's NAT table (Java NatEntry under its
|
||
* PortProtocolPair): the WAN port and the remote end (empty for a static
|
||
* entry of the port forwarding) lead to the LAN endpoint.
|
||
*/
|
||
export interface NatEntry {
|
||
readonly protocol: NatProtocol;
|
||
readonly remoteIPAddress: string;
|
||
readonly port: number;
|
||
readonly lan: NatEndpoint;
|
||
/** Java NatType: StaticEntry, DynamicEntry, DynamicEnryFromStatic. */
|
||
readonly type: 'static' | 'dynamic' | 'dynamicFromStatic';
|
||
readonly updatedAtMilliseconds: number;
|
||
}
|
||
|
||
/**
|
||
* A port forwarding row as Java's GatewayFirmware.validateData takes it:
|
||
* TCP or UDP in any case, or 6 or 17; ports 1…65535 without leading zeros;
|
||
* an IPv4 address (EingabenUeberpruefung).
|
||
*/
|
||
function parsePortForwardingRow(
|
||
row: GatewayPortForwardingRow,
|
||
): { protocol: 'tcp' | 'udp'; publicPort: number; lanIPAddress: string; lanPort: number } | undefined {
|
||
const protocolValue = row.protocolValue.toUpperCase();
|
||
const protocol = protocolValue === 'TCP' || protocolValue === '6' ? 'tcp' : protocolValue === 'UDP' || protocolValue === '17' ? 'udp' : undefined;
|
||
const port = (value: string) => (/^[1-9][0-9]{0,4}$/.test(value) && Number(value) <= 65_535 ? Number(value) : undefined);
|
||
const address =
|
||
/^(0*([1-9]?[0-9]?|1[0-9]{1,2}|2[0-4]?[0-9]|25[0-5]))\.((0*(1?[0-9]{1,2}|2[0-4]?[0-9]|25[0-5]))\.){2}(0*(1?[0-9]{1,2}|2[0-4]?[0-9]|25[0-5]))$/;
|
||
const publicPort = port(row.publicPortValue);
|
||
const lanPort = port(row.lanPortValue);
|
||
if (protocol === undefined || publicPort === undefined || lanPort === undefined || !address.test(row.lanIPAddress)) return undefined;
|
||
return { protocol, publicPort, lanIPAddress: row.lanIPAddress, lanPort };
|
||
}
|
||
|
||
/** The endpoint a packet from the LAN leaves from, and whether it opens an entry (a TCP SYN, any UDP datagram, any ICMP packet). */
|
||
function natOutboundEndpoint(packet: IPv4Packet): { protocol: NatProtocol; port: number; opens: boolean } {
|
||
const payload = packet.payload;
|
||
switch (payload.kind) {
|
||
case 'tcp': {
|
||
const flags = payload.segment.flags;
|
||
return { protocol: 'tcp', port: payload.segment.sourcePort, opens: (flags & TCP_FLAG.synchronize) !== 0 && (flags & TCP_FLAG.acknowledgement) === 0 };
|
||
}
|
||
case 'udp':
|
||
return { protocol: 'udp', port: payload.datagram.sourcePort, opens: true };
|
||
case 'icmp':
|
||
return { protocol: 'icmp', port: payload.message.identifier, opens: true };
|
||
}
|
||
}
|
||
|
||
/**
|
||
* NatGateway.replaceDestination's key: the remote end, the WAN port and the
|
||
* protocol; for an ICMP error, those of the packet it carries (whose
|
||
* receiver was the remote end and whose source port, or identifier, the WAN
|
||
* port); for other ICMP, the sender and the identifier.
|
||
*/
|
||
function natInboundLookup(packet: IPv4Packet): { remoteIPAddress: string; port: number; protocol: NatProtocol } | undefined {
|
||
const payload = packet.payload;
|
||
switch (payload.kind) {
|
||
case 'tcp':
|
||
return { remoteIPAddress: packet.senderIPAddress, port: payload.segment.destinationPort, protocol: 'tcp' };
|
||
case 'udp':
|
||
return { remoteIPAddress: packet.senderIPAddress, port: payload.datagram.destinationPort, protocol: 'udp' };
|
||
case 'icmp': {
|
||
const embedded = payload.message.embeddedOriginalPacket;
|
||
if (embedded === undefined) return { remoteIPAddress: packet.senderIPAddress, port: payload.message.identifier, protocol: 'icmp' };
|
||
const inner = embedded.payload;
|
||
const port = inner.kind === 'icmp' ? inner.message.identifier : inner.kind === 'tcp' ? inner.segment.sourcePort : inner.datagram.sourcePort;
|
||
return { remoteIPAddress: embedded.receiverIPAddress, port, protocol: inner.kind };
|
||
}
|
||
}
|
||
}
|
||
|
||
/** NatGateway.replaceSource: the WAN address; TCP and UDP the entry's port (ICMP keeps its identifier). */
|
||
function natWithSource(packet: IPv4Packet, ipAddress: string, port: number | undefined): IPv4Packet {
|
||
const payload = packet.payload;
|
||
if (port === undefined || payload.kind === 'icmp') return { ...packet, senderIPAddress: ipAddress };
|
||
return payload.kind === 'tcp'
|
||
? { ...packet, senderIPAddress: ipAddress, payload: { kind: 'tcp', segment: { ...payload.segment, sourcePort: port } } }
|
||
: { ...packet, senderIPAddress: ipAddress, payload: { kind: 'udp', datagram: { ...payload.datagram, sourcePort: port } } };
|
||
}
|
||
|
||
/** NatGateway.replaceDestination: the LAN endpoint's address; TCP and UDP its port. */
|
||
function natWithDestination(packet: IPv4Packet, endpoint: NatEndpoint): IPv4Packet {
|
||
const payload = packet.payload;
|
||
if (payload.kind === 'icmp') return { ...packet, receiverIPAddress: endpoint.ipAddress };
|
||
return payload.kind === 'tcp'
|
||
? { ...packet, receiverIPAddress: endpoint.ipAddress, payload: { kind: 'tcp', segment: { ...payload.segment, destinationPort: endpoint.port } } }
|
||
: { ...packet, receiverIPAddress: endpoint.ipAddress, payload: { kind: 'udp', datagram: { ...payload.datagram, destinationPort: endpoint.port } } };
|
||
}
|
||
|
||
/** VermittlungsProtokoll.isBroadcast: the limited broadcast, or the broadcast address of the sender's network. */
|
||
function isBroadcastFrom(receiverIPAddress: string, senderIPAddress: string, subnetMask: string): boolean {
|
||
if (receiverIPAddress === LIMITED_BROADCAST_IP_ADDRESS) return true;
|
||
const receiver = parseIPv4(receiverIPAddress);
|
||
const sender = parseIPv4(senderIPAddress);
|
||
const mask = parseIPv4(subnetMask);
|
||
if (receiver === undefined || sender === undefined || mask === undefined) return false;
|
||
return maskAddress(receiver, mask) === maskAddress(sender, mask) && (receiver | mask) >>> 0 === 0xffff_ffff;
|
||
}
|
||
|
||
/** RIPMessage: the sender's interface address, its first address, infinity, the timeout, its routes. */
|
||
interface RipMessage {
|
||
readonly ipAddress: string;
|
||
readonly publicIPAddress: string;
|
||
readonly infinity: number;
|
||
readonly timeoutMilliseconds: number;
|
||
readonly routes: RipMessageRoute[];
|
||
}
|
||
|
||
interface RipMessageRoute {
|
||
readonly network: string;
|
||
readonly mask: string;
|
||
readonly hops: number;
|
||
}
|
||
|
||
/**
|
||
* RIPMessage.addRoute: a network goes in once. Java keeps the fewer hops of
|
||
* the two; only a router's own networks can be in its table twice (two
|
||
* interfaces in one network), both with hop count 0.
|
||
*/
|
||
function addRipMessageRoute(message: RipMessage, route: RipMessageRoute): void {
|
||
if (!message.routes.some((each) => each.network === route.network && each.mask === route.mask)) message.routes.push(route);
|
||
}
|
||
|
||
/** RIPMessage.toString: four lines, then a line `network mask hops` per route. */
|
||
function encodeRipMessage(message: RipMessage): string {
|
||
return [
|
||
message.ipAddress,
|
||
message.publicIPAddress,
|
||
String(message.infinity),
|
||
String(message.timeoutMilliseconds),
|
||
...message.routes.map((route) => `${route.network} ${route.mask} ${route.hops}`),
|
||
].join('\n');
|
||
}
|
||
|
||
/**
|
||
* RIPMessage(String): undefined where Java throws. Java's split drops the
|
||
* empty lines at the end; a route line's first three fields count, split at
|
||
* each space.
|
||
*/
|
||
function decodeRipMessage(text: string): RipMessage | undefined {
|
||
const lines = text.split('\n');
|
||
while (lines.length > 0 && lines[lines.length - 1] === '') lines.pop();
|
||
if (lines.length < 4) return undefined;
|
||
const ipAddress = javaIpCheck(lines[0]!);
|
||
const publicIPAddress = javaIpCheck(lines[1]!);
|
||
const infinity = javaParseInt(lines[2]!);
|
||
const timeoutMilliseconds = javaParseInt(lines[3]!);
|
||
if (ipAddress === null || publicIPAddress === null || infinity === undefined || timeoutMilliseconds === undefined) return undefined;
|
||
if (timeoutMilliseconds <= 0 || infinity <= 0) return undefined;
|
||
const routes: RipMessageRoute[] = [];
|
||
for (const line of lines.slice(4)) {
|
||
const fields = line.split(' ');
|
||
while (fields.length > 0 && fields[fields.length - 1] === '') fields.pop();
|
||
if (fields.length < 3) return undefined;
|
||
const network = javaIpCheck(fields[0]!);
|
||
const mask = javaIpCheck(fields[1]!);
|
||
const hops = javaParseInt(fields[2]!);
|
||
if (network === null || mask === null || hops === undefined || hops < 0) return undefined;
|
||
routes.push({ network, mask, hops });
|
||
}
|
||
return { ipAddress, publicIPAddress, infinity, timeoutMilliseconds, routes };
|
||
}
|
||
|
||
/** The seed of a router's RIP random: the scenario's, mixed with the router's id (FNV-1a). */
|
||
export function ripSeed(seed: bigint | number, nodeId: Id): bigint {
|
||
let hash = 0x811c_9dc5;
|
||
for (let index = 0; index < nodeId.length; index += 1) {
|
||
hash = Math.imul(hash ^ nodeId.charCodeAt(index), 0x0100_0193) >>> 0;
|
||
}
|
||
return BigInt(seed) ^ 0x5249_5000n ^ BigInt(hash);
|
||
}
|
||
|
||
/** 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>;
|
||
/** Each RIP router's table (RIPTable.routes): its networks, then what it learned. */
|
||
ripTablesByNodeId: Map<Id, RouteRow[]>;
|
||
/** Each home router's NAT table (Java NetworkAddressTranslationTable): static and dynamic entries. */
|
||
natTablesByNodeId: Map<Id, NatEntry[]>;
|
||
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(),
|
||
ripTablesByNodeId: new Map(),
|
||
natTablesByNodeId: new Map(),
|
||
dhcpClientStatusesByNodeId: new Map(),
|
||
dhcpClientContextsByNodeId: new Map(),
|
||
nextSocketSequenceNumber: 1,
|
||
nextTcpInitialSequenceValue: 1,
|
||
nextEventSequenceNumber: 0,
|
||
nextFrameIdentity: 1,
|
||
nextPacketIdentity: 1,
|
||
nextTraceIdentity: 1,
|
||
};
|
||
}
|
||
|
||
export interface TraceInput {
|
||
frameIdentity?: number | undefined;
|
||
packetIdentity?: number | undefined;
|
||
nodeId: Id;
|
||
interfaceId?: Id | undefined;
|
||
direction: TraceDirection;
|
||
layer: TraceLayer;
|
||
operation: TraceOperation;
|
||
beforeHeaders?: readonly HeaderField[];
|
||
afterHeaders?: readonly HeaderField[];
|
||
detail?: string | undefined;
|
||
}
|
||
|
||
type PacketRoute =
|
||
| { readonly type: 'local'; readonly interface: NetworkInterface | undefined }
|
||
| { readonly type: 'broadcast' }
|
||
| { readonly type: 'egress'; readonly interface: NetworkInterface; readonly nextHop: string }
|
||
| { readonly type: 'unavailable' };
|
||
|
||
/** Java's IP asks ARP this many times, a round trip each (holeARPTabellenEintrag(ziel, 2, …)). */
|
||
export const ARP_ATTEMPTS = 2;
|
||
|
||
interface ArpLookup {
|
||
readonly id: number;
|
||
readonly nodeId: Id;
|
||
readonly ipAddress: string;
|
||
readonly networkInterface: NetworkInterface;
|
||
attemptsLeft: number;
|
||
/** Swift, for the comparison: a lookup made while events run waits in a list emptied by the answer itself. */
|
||
readonly swiftQueued: boolean;
|
||
readonly done: (macAddress: string | undefined) => void;
|
||
}
|
||
|
||
/**
|
||
* Java's threads that each take one unit after the other and wait for ARP
|
||
* with it: per node the send thread of each transport protocol
|
||
* (TransportProtokoll: `udp`, `tcp`), the IP thread (IPThread: delivers or
|
||
* forwards every UDP and TCP packet that comes in, `ip`) and the ICMP thread
|
||
* (ICMPThread: answers, delivers or forwards every ICMP packet, `icmp`).
|
||
*/
|
||
type ProtocolThreadKind = 'udp' | 'tcp' | 'ip' | 'icmp';
|
||
|
||
interface ProtocolThread {
|
||
busy: boolean;
|
||
/** How many units it has taken, and how many it is done with. */
|
||
started: number;
|
||
finished: number;
|
||
readonly queue: ((finished: () => void) => void)[];
|
||
}
|
||
|
||
/** What a sender learns when its packet has left, or when it could not. */
|
||
interface IPv4SendCallbacks {
|
||
/** The packet left (or failed) — at once, or when ARP has answered or given up. */
|
||
readonly onFinished?: (result: IPv4DeliveryResult) => void;
|
||
/** ARP found no one for the next hop (Java: the sender sends host unreachable). */
|
||
readonly onUnresolved?: () => void;
|
||
}
|
||
|
||
function toSeed(seed: bigint | number): bigint {
|
||
return BigInt.asUintN(64, BigInt(seed));
|
||
}
|
||
|
||
/** A departure waits for the delivery of the frame before it, so draining the deliveries reaches it. */
|
||
function isNetworkDeliveryEvent(kind: ScheduledEventKind): boolean {
|
||
return (
|
||
kind.kind === 'ethernetLinkDeparture' ||
|
||
kind.kind === 'ethernetLinkDelivery' ||
|
||
kind.kind === 'remoteLinkDelivery' ||
|
||
kind.kind === 'arpAnswered'
|
||
);
|
||
}
|
||
|
||
|
||
function getOrCreate<K, V>(map: Map<K, V>, key: K, create: () => V): V {
|
||
let value = map.get(key);
|
||
if (value === undefined) {
|
||
value = create();
|
||
map.set(key, value);
|
||
}
|
||
return value;
|
||
}
|
||
|
||
// ── Engine ─────────────────────────────────────────────────────────────────
|
||
|
||
export class NetworkRuntimeEngine {
|
||
private stateValue: RuntimeState;
|
||
private random: JavaCompatibleRandom;
|
||
private udpPortRandom: JavaCompatibleRandom;
|
||
private tcpPortRandom: JavaCompatibleRandom;
|
||
private linkLocalRandom: JavaCompatibleRandom;
|
||
private dnsIdRandom: JavaCompatibleRandom;
|
||
private runtimeSpeed: RuntimeSpeed;
|
||
private readonly packetTraceRetentionPolicy: TraceRetentionPolicy;
|
||
private isProcessingScheduledEvents = false;
|
||
/** ARP lookups waiting for their answer (Java: a thread in holeARPTabellenEintrag). */
|
||
private arpLookups: ArpLookup[] = [];
|
||
private nextArpLookupId = 1;
|
||
/** Per RIP router: its random for the beacon's pauses and its sockets (RIPBeacon, RIPServer). */
|
||
private ripRoutersByNodeId = new Map<Id, { readonly random: JavaCompatibleRandom; readonly beaconSocketId: Id; readonly serverSocketId: Id }>();
|
||
/** When the NAT tables started: Java's expiry timer ticks every second from then. */
|
||
private natStartedAtMilliseconds = 0;
|
||
/** DNS queries waiting for their answer, by the socket they were sent from. */
|
||
private dnsQueriesBySocketId = new Map<Id, (response: DnsMessage | undefined) => void>();
|
||
/** Per node and kind (`nodeId/udp`, `nodeId/icmp`, …). */
|
||
private protocolThreads = new Map<string, ProtocolThread>();
|
||
/** Per cable end (node and port): when the last frame sent there arrives. */
|
||
private cableBusyUntilByPort = new Map<string, number>();
|
||
private readonly cablesCarryOneFrameAtATime: boolean;
|
||
private readonly swiftArp: boolean;
|
||
private readonly swiftSends: boolean;
|
||
|
||
/**
|
||
* `swiftCables`: a cable carries all frames at once, as in the Swift
|
||
* engine — only for the comparison with Swift. Java's cable carries one
|
||
* frame after the other (SimplexVerbindung sleeps the delay with the port's
|
||
* buffer locked); a frame waits in the output buffer until the one before
|
||
* has arrived, and shows in the packet log when it leaves.
|
||
*
|
||
* `swiftArp`, `swiftSends`: Swift's ARP and Swift's send that returns
|
||
* when the network is quiet, likewise only for the comparison.
|
||
*/
|
||
constructor(
|
||
seed: bigint | number = DEFAULT_SEED,
|
||
packetTraceRetentionPolicy: TraceRetentionPolicy = PRODUCTION_TRACE_RETENTION,
|
||
options: { readonly swiftCables?: boolean; readonly swiftArp?: boolean; readonly swiftSends?: boolean } = {},
|
||
) {
|
||
this.cablesCarryOneFrameAtATime = options.swiftCables !== true;
|
||
this.swiftArp = options.swiftArp === true;
|
||
this.swiftSends = options.swiftSends === true;
|
||
const normalizedSeed = toSeed(seed);
|
||
this.stateValue = initialState(normalizedSeed);
|
||
this.random = new JavaCompatibleRandom(normalizedSeed);
|
||
this.udpPortRandom = new JavaCompatibleRandom(normalizedSeed ^ 0x5544_5050n);
|
||
this.tcpPortRandom = new JavaCompatibleRandom(normalizedSeed ^ 0x5443_5050n);
|
||
this.linkLocalRandom = new JavaCompatibleRandom(normalizedSeed ^ 0x4c4c_4144n);
|
||
this.dnsIdRandom = new JavaCompatibleRandom(normalizedSeed ^ 0x444e_5349n);
|
||
this.runtimeSpeed = new RuntimeSpeed();
|
||
this.packetTraceRetentionPolicy = packetTraceRetentionPolicy;
|
||
this.stateValue.simulationSpeedPercent = this.runtimeSpeed.percent;
|
||
}
|
||
|
||
/** The runtime state. Read it; change it only through the engine's methods. */
|
||
get state(): RuntimeState {
|
||
return this.stateValue;
|
||
}
|
||
|
||
get simulationSpeed(): RuntimeSpeed {
|
||
return this.runtimeSpeed;
|
||
}
|
||
|
||
setSimulationSpeed(percent: number): void {
|
||
this.runtimeSpeed = new RuntimeSpeed(percent);
|
||
this.stateValue.simulationSpeedPercent = this.runtimeSpeed.percent;
|
||
}
|
||
|
||
setGlobalPacketLossEnabled(enabled: boolean): void {
|
||
this.stateValue.globalPacketLossEnabled = this.stateValue.phase === 'running' && enabled;
|
||
}
|
||
|
||
handle(input: RuntimeInput): RuntimeOutput[] {
|
||
switch (input.type) {
|
||
case 'start': {
|
||
if (this.stateValue.phase !== 'stopped') return [this.output({ type: 'startIgnoredAlreadyRunning' })];
|
||
const seed = toSeed(input.seed);
|
||
this.stateValue = initialState(seed);
|
||
this.stateValue.phase = 'running';
|
||
this.stateValue.currentTimeMilliseconds = input.initialTimeMilliseconds;
|
||
this.stateValue.simulationSpeedPercent = this.runtimeSpeed.percent;
|
||
this.stateValue.topologySnapshot = input.snapshot;
|
||
this.random = new JavaCompatibleRandom(seed);
|
||
this.udpPortRandom = new JavaCompatibleRandom(seed ^ 0x5544_5050n);
|
||
this.tcpPortRandom = new JavaCompatibleRandom(seed ^ 0x5443_5050n);
|
||
this.linkLocalRandom = new JavaCompatibleRandom(seed ^ 0x4c4c_4144n);
|
||
this.dnsIdRandom = new JavaCompatibleRandom(seed ^ 0x444e_5349n);
|
||
this.isProcessingScheduledEvents = false;
|
||
this.cableBusyUntilByPort.clear();
|
||
this.initializeRipState(seed);
|
||
this.initializeDhcpState();
|
||
this.initializeDnsState();
|
||
this.initializeNatState();
|
||
return [this.output({ type: 'started' })];
|
||
}
|
||
|
||
case 'stop': {
|
||
if (this.stateValue.phase !== 'running') return [this.output({ type: 'stopIgnoredAlreadyStopped' })];
|
||
const state = this.stateValue;
|
||
state.phase = 'stopped';
|
||
state.globalPacketLossEnabled = false;
|
||
this.isProcessingScheduledEvents = false;
|
||
this.arpLookups = [];
|
||
this.protocolThreads.clear();
|
||
this.dnsQueriesBySocketId.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.ripTablesByNodeId.clear();
|
||
this.ripRoutersByNodeId.clear();
|
||
state.natTablesByNodeId.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 })];
|
||
}
|
||
}
|
||
|
||
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.processScheduledRuntimeEvent(event.kind);
|
||
outputs.push(this.output({ type: 'fired', event }));
|
||
}
|
||
|
||
this.stateValue.currentTimeMilliseconds = targetTimeMilliseconds;
|
||
this.ageAllSwitchSatEntries();
|
||
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':
|
||
this.sendRipBeacon(kind.nodeId);
|
||
return;
|
||
case 'dhcpClientStart':
|
||
this.runDhcpClient(kind.nodeId);
|
||
return;
|
||
case 'dhcpTimeout':
|
||
this.handleDhcpClientTimeout(kind.nodeId);
|
||
return;
|
||
case 'arpTimeout':
|
||
this.handleArpTimeout(kind.lookupId);
|
||
return;
|
||
case 'arpAnswered':
|
||
this.completeArpLookups(kind.nodeId, kind.ipAddress, kind.macAddress);
|
||
return;
|
||
case 'dnsQueryTimeout':
|
||
this.finishDnsQuery(kind.socketId, undefined);
|
||
return;
|
||
case 'tcpTimeout':
|
||
this.handleTcpTimeout(kind.sessionId);
|
||
return;
|
||
case 'remoteLinkDelivery':
|
||
case 'lanRemoteLinkTransmission':
|
||
throw new NotPortedError('remote links');
|
||
}
|
||
}
|
||
|
||
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 {
|
||
if (this.isNatPort(nodeId, 'udp', port)) return true;
|
||
for (const socket of this.stateValue.socketsById.values()) {
|
||
if (socket.nodeId === nodeId && socket.protocol === 'udp' && socket.localPort === port) return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
private reserveFreeUdpPort(nodeId: Id): number | undefined {
|
||
const span = UDP_EPHEMERAL_PORT_UPPER_BOUND_EXCLUSIVE - UDP_EPHEMERAL_PORT_LOWER_BOUND;
|
||
for (let attempt = 0; attempt < span; attempt++) {
|
||
const port = UDP_EPHEMERAL_PORT_LOWER_BOUND + this.udpPortRandom.nextInt(span);
|
||
if (!this.isUdpPortReserved(nodeId, port)) return port;
|
||
}
|
||
return undefined;
|
||
}
|
||
|
||
closeSocket(socketId: Id): void {
|
||
if (this.stateValue.socketsById.get(socketId)?.protocol === 'tcp') {
|
||
this.closeTcpSocket(socketId);
|
||
return;
|
||
}
|
||
this.stateValue.socketsById.delete(socketId);
|
||
this.stateValue.udpReceiveQueuesBySocketId.delete(socketId);
|
||
}
|
||
|
||
/** Forgets a socket without closing it, with the sockets a TCP listener accepted. */
|
||
discardSocket(socketId: Id): void {
|
||
const state = this.stateValue;
|
||
const children = state.tcpAcceptedSocketIdsByListenerId.get(socketId) ?? [];
|
||
state.tcpAcceptedSocketIdsByListenerId.delete(socketId);
|
||
for (const child of children) {
|
||
state.socketsById.delete(child);
|
||
state.tcpSessionsById.delete(child);
|
||
}
|
||
state.socketsById.delete(socketId);
|
||
state.tcpSessionsById.delete(socketId);
|
||
state.udpReceiveQueuesBySocketId.delete(socketId);
|
||
for (const [listener, accepted] of state.tcpAcceptedSocketIdsByListenerId) {
|
||
state.tcpAcceptedSocketIdsByListenerId.set(
|
||
listener,
|
||
accepted.filter((id) => id !== socketId),
|
||
);
|
||
}
|
||
}
|
||
|
||
private allocateSocketId(): Id {
|
||
const id = deterministicSocketId(this.stateValue.nextSocketSequenceNumber);
|
||
this.stateValue.nextSocketSequenceNumber += 1;
|
||
return id;
|
||
}
|
||
|
||
/**
|
||
* The oldest datagram in the socket's queue; with a timeout, the clock
|
||
* advances up to it when the queue is empty, as a blocking receive in Java.
|
||
*/
|
||
receiveUdp(socketId: Id, timeoutMilliseconds = 0): UdpReceivedDatagram | undefined {
|
||
const next = () => this.stateValue.udpReceiveQueuesBySocketId.get(socketId)?.shift();
|
||
const datagram = next();
|
||
if (datagram !== undefined) return datagram;
|
||
if (timeoutMilliseconds <= 0 || this.stateValue.phase !== 'running') return undefined;
|
||
const deadline = this.stateValue.currentTimeMilliseconds + timeoutMilliseconds;
|
||
if (!Number.isSafeInteger(deadline)) return undefined;
|
||
// Java's empfangen returns as soon as a datagram is there, else at the deadline.
|
||
this.advanceUntil(() => (this.stateValue.udpReceiveQueuesBySocketId.get(socketId)?.length ?? 0) > 0, deadline);
|
||
return next();
|
||
}
|
||
|
||
/** Advances event by event until the condition holds, at most to the deadline. */
|
||
private advanceUntil(condition: () => boolean, deadlineMilliseconds: number): void {
|
||
while (this.stateValue.phase === 'running' && !condition()) {
|
||
const next = this.earliestDeadline(() => true);
|
||
if (next === undefined || next > deadlineMilliseconds) {
|
||
this.advance(deadlineMilliseconds);
|
||
return;
|
||
}
|
||
this.advance(next);
|
||
}
|
||
}
|
||
|
||
/** Sends from a UDP socket to its remote end, or to the given one. */
|
||
sendUdp(
|
||
socketId: Id,
|
||
payload: Uint8Array,
|
||
destination: { ipAddress?: string; port?: number } = {},
|
||
): IPv4DeliveryResult | undefined {
|
||
const socket = this.stateValue.socketsById.get(socketId);
|
||
if (socket === undefined || socket.protocol !== 'udp') return undefined;
|
||
const destinationIPAddress = destination.ipAddress ?? socket.remoteIPAddress;
|
||
const destinationPort = destination.port ?? socket.remotePort;
|
||
if (destinationIPAddress === undefined || destinationPort === undefined) return undefined;
|
||
const interfaces = this.networkInterfaces(socket.nodeId);
|
||
if (interfaces.length === 0) return undefined;
|
||
const boundInterface =
|
||
socket.localIPAddress === undefined
|
||
? undefined
|
||
: interfaces.find((networkInterface) => networkInterface.ipAddress === socket.localIPAddress);
|
||
const packetIdentity = this.allocatePacketIdentity();
|
||
const datagram: UdpDatagram = { sourcePort: socket.localPort, destinationPort, payload };
|
||
const traceSent = (networkInterface: NetworkInterface, identity: number) =>
|
||
this.recordTrace({
|
||
packetIdentity: identity,
|
||
nodeId: socket.nodeId,
|
||
interfaceId: networkInterface.portId,
|
||
direction: 'outbound',
|
||
layer: 'transport',
|
||
operation: 'sent',
|
||
afterHeaders: udpHeaders(datagram),
|
||
});
|
||
|
||
const handedOn: IPv4DeliveryResult = { type: 'delivered', packetIdentity, nodeId: socket.nodeId };
|
||
if (destinationIPAddress === LIMITED_BROADCAST_IP_ADDRESS) {
|
||
const broadcastInterfaces = boundInterface === undefined ? interfaces : [boundInterface];
|
||
for (const networkInterface of broadcastInterfaces) traceSent(networkInterface, packetIdentity);
|
||
return this.handToProtocolThread(socket.nodeId, 'udp', handedOn, (finished) => {
|
||
for (const networkInterface of broadcastInterfaces) {
|
||
const packet: IPv4Packet = {
|
||
identity: packetIdentity,
|
||
senderIPAddress: networkInterface.ipAddress,
|
||
receiverIPAddress: destinationIPAddress,
|
||
timeToLive: 1,
|
||
protocol: 'udp',
|
||
payload: { kind: 'udp', datagram },
|
||
};
|
||
this.sendLimitedBroadcastPacket(socket.nodeId, networkInterface, packet);
|
||
}
|
||
finished();
|
||
return handedOn;
|
||
});
|
||
}
|
||
|
||
let networkInterface: NetworkInterface;
|
||
if (boundInterface !== undefined) {
|
||
networkInterface = boundInterface;
|
||
} else {
|
||
const route = this.routeForPacket(socket.nodeId, destinationIPAddress);
|
||
networkInterface = route.type === 'egress' ? route.interface : interfaces[0]!;
|
||
}
|
||
const packet: IPv4Packet = {
|
||
identity: packetIdentity,
|
||
senderIPAddress: networkInterface.ipAddress,
|
||
receiverIPAddress: destinationIPAddress,
|
||
timeToLive: 64,
|
||
protocol: 'udp',
|
||
payload: { kind: 'udp', datagram },
|
||
};
|
||
traceSent(networkInterface, packet.identity);
|
||
const portId = networkInterface.portId;
|
||
return this.handToProtocolThread(socket.nodeId, 'udp', handedOn, (finished) =>
|
||
this.sendIPv4PacketWithOptions(socket.nodeId, packet, portId, true, 'sent', { onFinished: finished }),
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Hands a unit to one of the node's threads (ProtocolThreadKind). The
|
||
* thread holds its list while it waits for ARP. A socket's send then blocks
|
||
* until the unit in hand is done, as the caller does here
|
||
* (TransportProtokoll.senden); from within an event, the unit queues
|
||
* behind it. Those that wait take their turns in the order they came,
|
||
* before the thread takes the next — as with a fair lock; Java's is not
|
||
* fair and leaves the order to chance. The result is the unit's own when
|
||
* the thread takes it at once, else `handedOn`.
|
||
*/
|
||
private handToProtocolThread<T>(nodeId: Id, kind: ProtocolThreadKind, handedOn: T, unit: (finished: () => void) => T): T {
|
||
const key = `${nodeId}/${kind}`;
|
||
const thread = getOrCreate(this.protocolThreads, key, () => ({ busy: false, started: 0, finished: 0, queue: [] }));
|
||
if (thread.busy && !this.isProcessingScheduledEvents) {
|
||
// The unit in hand is done once ARP has answered or given up.
|
||
const inHand = thread.started;
|
||
const deadline = this.stateValue.currentTimeMilliseconds + (ARP_ATTEMPTS + 1) * this.runtimeSpeed.roundTripTimeMilliseconds;
|
||
this.advanceUntil(() => thread.finished >= inHand, deadline);
|
||
if (this.stateValue.phase !== 'running' || this.protocolThreads.get(key) !== thread) return handedOn;
|
||
}
|
||
let result = handedOn;
|
||
thread.queue.push((finished) => {
|
||
result = unit(finished);
|
||
});
|
||
if (!thread.busy) this.startNextProtocolUnit(thread);
|
||
return result;
|
||
}
|
||
|
||
private startNextProtocolUnit(thread: ProtocolThread): void {
|
||
const next = thread.queue.shift();
|
||
if (next === undefined) return;
|
||
thread.busy = true;
|
||
thread.started += 1;
|
||
let done = false;
|
||
next(() => {
|
||
if (done) return;
|
||
done = true;
|
||
thread.finished += 1;
|
||
thread.busy = false;
|
||
this.startNextProtocolUnit(thread);
|
||
});
|
||
}
|
||
|
||
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 {
|
||
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);
|
||
if (datagram.destinationPort === RIP_DESTINATION_PORT) this.serveRipMessages(nodeId);
|
||
for (const socket of matching) {
|
||
if (!this.dnsQueriesBySocketId.has(socket.id)) continue;
|
||
const received = this.receiveUdp(socket.id);
|
||
this.finishDnsQuery(socket.id, received === undefined ? undefined : decodeDnsMessage(new TextDecoder().decode(received.datagram.payload)));
|
||
}
|
||
}
|
||
|
||
// ── 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) {
|
||
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, once every question has its records (a recursive server may
|
||
* have to ask on).
|
||
*/
|
||
private serveDnsQueries(nodeId: Id): void {
|
||
const state = this.stateValue;
|
||
const socketId = state.dnsServerSocketIdsByNodeId.get(nodeId);
|
||
if (socketId === undefined || !state.topologySnapshot.dnsServerConfigurationsByNodeId.has(nodeId)) return;
|
||
for (let received = this.receiveUdp(socketId); received !== undefined; received = this.receiveUdp(socketId)) {
|
||
const client = { ipAddress: received.senderIPAddress, port: received.datagram.sourcePort };
|
||
const query = decodeDnsMessage(new TextDecoder().decode(received.datagram.payload));
|
||
this.answerDnsQuestions(nodeId, query.questions, [], (answers) => {
|
||
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)), client);
|
||
});
|
||
}
|
||
}
|
||
|
||
/** The questions one after the other, as the worker loops over them. */
|
||
private answerDnsQuestions(
|
||
nodeId: Id,
|
||
questions: readonly DnsQuestion[],
|
||
answered: readonly DnsMessageRecord[],
|
||
done: (answers: DnsMessageRecord[]) => void,
|
||
): void {
|
||
const [question, ...rest] = questions;
|
||
if (question === undefined) {
|
||
done([...answered]);
|
||
return;
|
||
}
|
||
this.answerDnsQuestion(nodeId, question, (answers) => this.answerDnsQuestions(nodeId, rest, [...answered, ...answers], done));
|
||
}
|
||
|
||
/**
|
||
* DNSServer.answer: the server's own records for the question; else, when
|
||
* it resolves recursively, what it learns from others; else a referral
|
||
* (the NS record of the question's zone and its address).
|
||
*/
|
||
private answerDnsQuestion(nodeId: Id, question: DnsQuestion, done: (answers: DnsMessageRecord[]) => void): void {
|
||
const configuration = this.stateValue.topologySnapshot.dnsServerConfigurationsByNodeId.get(nodeId);
|
||
const records = (configuration?.records ?? []).map(messageRecord);
|
||
const local = localDnsAnswers(question, records);
|
||
if (local.length > 0 || configuration?.recursiveResolutionEnabled !== true) {
|
||
done(local.length > 0 ? local : dnsReferral(question, records));
|
||
return;
|
||
}
|
||
this.resolveDnsRemotely(nodeId, question, records, (remote) => done(remote.length > 0 ? remote : dnsReferral(question, records)));
|
||
}
|
||
|
||
/**
|
||
* DNSServer.answerWithRemoteData: resolves with the name server its records
|
||
* name for the question's zone, then, if that brings nothing, with its own
|
||
* DNS server. The records of the answer and authority sections; nothing
|
||
* when a server does not answer.
|
||
*
|
||
* Java asks its own DNS server only when that is not itself, but asks the
|
||
* zone's name server even then: a query to itself, which asks itself again,
|
||
* without end (measured: the client times out, then every level falls back
|
||
* to its own DNS server). The core asks neither when it is itself.
|
||
*/
|
||
private resolveDnsRemotely(nodeId: Id, question: DnsQuestion, records: readonly DnsMessageRecord[], done: (answers: DnsMessageRecord[]) => void): void {
|
||
const ownAddresses = new Set(this.networkInterfaces(nodeId).map((each) => each.ipAddress));
|
||
const resolveWith = (server: string | undefined, next: (answers: DnsMessageRecord[]) => void) => {
|
||
if (server === undefined || server.trim() === '' || ownAddresses.has(server)) {
|
||
next([]);
|
||
return;
|
||
}
|
||
this.beginDnsResolution(nodeId, question.name, question.type, server, (resolution) =>
|
||
next(resolution.type === 'answered' ? [...resolution.answers, ...resolution.authority] : []),
|
||
);
|
||
};
|
||
const nameServer = findApplicableNameServerRecord(question.name, records);
|
||
const nameServerAddress = nameServer === undefined ? undefined : findDnsRecords(nameServer.data, 'A', records)[0]?.data;
|
||
resolveWith(nameServerAddress, (answers) => {
|
||
if (answers.length > 0) {
|
||
done(answers);
|
||
return;
|
||
}
|
||
resolveWith(this.stateValue.topologySnapshot.deviceConfigurations.get(nodeId)?.dnsServer, done);
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 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 {
|
||
let resolution: DnsLookup | undefined;
|
||
const server = serverIPAddress ?? this.stateValue.topologySnapshot.deviceConfigurations.get(nodeId)?.dnsServer;
|
||
this.beginDnsResolution(nodeId, name, type, server, (result) => {
|
||
resolution = result;
|
||
});
|
||
// Each query ends within five round trips, by its answer or its timeout.
|
||
const window = (DNS_QUERY_TIMEOUT_ROUND_TRIPS + 1) * this.runtimeSpeed.roundTripTimeMilliseconds;
|
||
while (resolution === undefined && this.stateValue.phase === 'running' && this.stateValue.pendingEvents.length > 0) {
|
||
this.advanceUntil(() => resolution !== undefined, this.stateValue.currentTimeMilliseconds + window);
|
||
}
|
||
if (resolution === undefined || resolution.type === 'timedOut') return { type: 'timedOut' };
|
||
return { type: 'answered', records: [...resolution.answers, ...resolution.authority] };
|
||
}
|
||
|
||
/**
|
||
* Resolver.resolve as a chain of queries on the events: `done` with the
|
||
* records for the name and type from the answer and authority sections of
|
||
* the responses (and, for MX and NS, the address of the first), or
|
||
* timedOut when a server did not answer.
|
||
*/
|
||
private beginDnsResolution(nodeId: Id, name: string, type: string, server: string | undefined, done: (resolution: DnsLookup) => void): void {
|
||
const domainName = name.endsWith('.') ? name : `${name}.`;
|
||
if (/^localhost\.?$/i.test(domainName)) {
|
||
done({ type: 'answered', answers: [{ name: 'localhost.', type: 'A', ttl: 3600, data: LOCALHOST_IP_ADDRESS }], authority: [] });
|
||
return;
|
||
}
|
||
const answers: DnsMessageRecord[] = [];
|
||
const authority: DnsMessageRecord[] = [];
|
||
const queried = new Set<string>();
|
||
const ask = (serverToQuery: string | undefined): void => {
|
||
if (serverToQuery === undefined || serverToQuery === '') {
|
||
done({ type: 'answered', answers, authority });
|
||
return;
|
||
}
|
||
this.queryDnsServer(nodeId, domainName, type, serverToQuery, (response) => {
|
||
if (response === undefined) {
|
||
done({ type: 'timedOut' });
|
||
return;
|
||
}
|
||
answers.push(...findDnsRecords(domainName, type, response.answers));
|
||
authority.push(...findDnsRecords(domainName, type, response.authority));
|
||
const data = findDnsRecords(domainName, type, [...response.answers, ...response.authority, ...response.additional]);
|
||
if (data.length > 0) {
|
||
if (type === 'MX' || type === 'NS') {
|
||
answers.push(...findDnsRecords(data[0]!.data, 'A', response.answers));
|
||
authority.push(...findDnsRecords(data[0]!.data, 'A', response.authority));
|
||
}
|
||
done({ type: 'answered', answers, authority });
|
||
return;
|
||
}
|
||
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.
|
||
if (next === undefined || queried.has(next)) {
|
||
done({ type: 'answered', answers, authority });
|
||
return;
|
||
}
|
||
queried.add(next);
|
||
ask(next);
|
||
});
|
||
};
|
||
ask(server);
|
||
}
|
||
|
||
/** 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; `done` with the
|
||
* first datagram back, or undefined after five round trips.
|
||
*/
|
||
private queryDnsServer(nodeId: Id, domainName: string, type: string, serverIPAddress: string, done: (response: DnsMessage | undefined) => void): void {
|
||
const socketId = this.bindUdpSocket(nodeId, { remoteIPAddress: serverIPAddress, remotePort: DNS_PORT });
|
||
if (socketId === undefined) {
|
||
done(undefined);
|
||
return;
|
||
}
|
||
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)));
|
||
if (!this.stateValue.socketsById.has(socketId)) {
|
||
done(undefined);
|
||
return;
|
||
}
|
||
// The wait begins when the send returns (empfangen after senden).
|
||
this.dnsQueriesBySocketId.set(socketId, done);
|
||
this.schedule(this.stateValue.currentTimeMilliseconds + DNS_QUERY_TIMEOUT_ROUND_TRIPS * this.runtimeSpeed.roundTripTimeMilliseconds, {
|
||
kind: 'dnsQueryTimeout',
|
||
socketId,
|
||
});
|
||
}
|
||
|
||
/** The query's answer came (or its time ran out): its socket closes, the resolution goes on. */
|
||
private finishDnsQuery(socketId: Id, response: DnsMessage | undefined): void {
|
||
const done = this.dnsQueriesBySocketId.get(socketId);
|
||
if (done === undefined) return;
|
||
this.dnsQueriesBySocketId.delete(socketId);
|
||
this.stateValue.pendingEvents = this.stateValue.pendingEvents.filter(
|
||
(event) => !(event.kind.kind === 'dnsQueryTimeout' && event.kind.socketId === socketId),
|
||
);
|
||
this.closeSocket(socketId);
|
||
done(response);
|
||
}
|
||
|
||
// ── 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 {
|
||
if (this.isNatPort(nodeId, 'tcp', port)) return true;
|
||
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.beginTcpClosing(socketId, 'FIN_WAIT_1', 'TCP FIN');
|
||
return;
|
||
case 'CLOSE_WAIT':
|
||
this.beginTcpClosing(socketId, 'LAST_ACK', 'TCP FIN after CLOSE_WAIT');
|
||
return;
|
||
default:
|
||
return;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Java's close: the FIN goes once, never again; the first wait counts from
|
||
* handing it down (the timeout transmitTcpSegment schedules).
|
||
*/
|
||
private beginTcpClosing(socketId: Id, state: 'FIN_WAIT_1' | 'LAST_ACK', detail: string): void {
|
||
const session = this.stateValue.tcpSessionsById.get(socketId);
|
||
this.clearTcpPendingTransmission(socketId);
|
||
this.updateTcpState(socketId, state);
|
||
this.resetTcpSendAttempts(socketId);
|
||
if (session !== undefined) session.closingWaitsLeft = TCP_CLOSING_WAITS;
|
||
this.transmitTcpSegment(socketId, TCP_FLAG.finish, new Uint8Array(), 0, detail);
|
||
}
|
||
|
||
/**
|
||
* One wait of Java's close loop has ended, with a segment (already handled)
|
||
* or after defaultTimeout. TIME_WAIT ends with it; after the last wait the
|
||
* socket is gone — Java leaves its state as it was, the core shows CLOSED.
|
||
*/
|
||
private endTcpClosingWait(socketId: Id): void {
|
||
const socket = this.stateValue.socketsById.get(socketId);
|
||
const session = this.stateValue.tcpSessionsById.get(socketId);
|
||
if (socket === undefined || session?.closingWaitsLeft === undefined) return;
|
||
this.cancelTcpTimeouts(socketId);
|
||
session.closingWaitsLeft -= 1;
|
||
if (socket.tcpState === 'CLOSED') return;
|
||
if (session.closingWaitsLeft <= 0) {
|
||
session.lastSentSegment = undefined;
|
||
this.updateTcpState(socketId, 'CLOSED');
|
||
return;
|
||
}
|
||
this.schedule(this.stateValue.currentTimeMilliseconds + this.tcpTimeoutMilliseconds(), { kind: 'tcpTimeout', sessionId: socketId });
|
||
}
|
||
|
||
/** 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;
|
||
}
|
||
if (this.stateValue.tcpSessionsById.get(socketId)?.closingWaitsLeft !== undefined) {
|
||
this.processTcpSegmentWhileClosing(socketId, segment);
|
||
this.endTcpClosingWait(socketId);
|
||
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.synchronize) && !has(TCP_FLAG.acknowledgement)) {
|
||
// The client's SYN again: its SYN+ACK was lost. Java closes here
|
||
// (its client never repeats the SYN); the core answers once more
|
||
// and waits anew.
|
||
const last = this.stateValue.tcpSessionsById.get(socketId)?.lastSentSegment;
|
||
if (last === undefined) return;
|
||
this.cancelTcpTimeouts(socketId);
|
||
this.transmitTcpSegment(socketId, last.flags, last.payload, last.acknowledgementNumber, 'TCP SYN+ACK', true);
|
||
return;
|
||
}
|
||
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 '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;
|
||
default:
|
||
return;
|
||
}
|
||
}
|
||
|
||
/** A segment in Java's close loop: TIME_WAIT ends with it, the other states move on. */
|
||
private processTcpSegmentWhileClosing(socketId: Id, segment: TcpSegment): void {
|
||
const socket = this.stateValue.socketsById.get(socketId);
|
||
const has = (flag: number) => (segment.flags & flag) !== 0;
|
||
switch (socket?.tcpState) {
|
||
case 'TIME_WAIT':
|
||
this.clearTcpPendingTransmission(socketId);
|
||
this.updateTcpState(socketId, 'CLOSED');
|
||
return;
|
||
case 'FIN_WAIT_1': {
|
||
const hasAck = has(TCP_FLAG.acknowledgement);
|
||
const hasFin = has(TCP_FLAG.finish);
|
||
if (hasAck) this.acknowledgeTcpIfExpected(socketId, segment.acknowledgementNumber);
|
||
if (hasAck && hasFin) {
|
||
this.sendTcpAcknowledgement(socketId, segment);
|
||
this.enterTcpTimeWait(socketId);
|
||
} else if (hasAck) {
|
||
this.updateTcpState(socketId, 'FIN_WAIT_2');
|
||
} else if (hasFin) {
|
||
this.sendTcpAcknowledgement(socketId, segment);
|
||
this.updateTcpState(socketId, 'CLOSING');
|
||
}
|
||
return;
|
||
}
|
||
case 'FIN_WAIT_2':
|
||
if (has(TCP_FLAG.finish)) {
|
||
this.sendTcpAcknowledgement(socketId, segment);
|
||
this.enterTcpTimeWait(socketId);
|
||
}
|
||
return;
|
||
case 'CLOSING':
|
||
if (has(TCP_FLAG.acknowledgement) && this.acknowledgeTcpIfExpected(socketId, segment.acknowledgementNumber)) {
|
||
this.enterTcpTimeWait(socketId);
|
||
}
|
||
return;
|
||
case 'LAST_ACK':
|
||
if (has(TCP_FLAG.acknowledgement) && this.acknowledgeTcpIfExpected(socketId, segment.acknowledgementNumber)) {
|
||
this.updateTcpState(socketId, 'CLOSED');
|
||
}
|
||
return;
|
||
default:
|
||
return;
|
||
}
|
||
}
|
||
|
||
private receiveTcpPayload(socketId: Id, segment: TcpSegment): void {
|
||
const session = this.stateValue.tcpSessionsById.get(socketId);
|
||
if (session === undefined) return;
|
||
const nextRemoteSequence = nextTcpSequenceNumber(segment);
|
||
if (nextRemoteSequence > session.remoteSequenceNumber) {
|
||
session.remoteSequenceNumber = nextRemoteSequence;
|
||
session.receiveAssembly = concatBytes(session.receiveAssembly, segment.payload);
|
||
if ((segment.flags & TCP_FLAG.push) !== 0) {
|
||
session.receivedMessages.push(session.receiveAssembly);
|
||
session.receiveAssembly = new Uint8Array();
|
||
}
|
||
}
|
||
this.sendTcpAcknowledgement(socketId, segment);
|
||
}
|
||
|
||
private sendTcpAcknowledgement(socketId: Id, segment: TcpSegment): void {
|
||
this.transmitTcpSegment(socketId, TCP_FLAG.acknowledgement, new Uint8Array(), nextTcpSequenceNumber(segment), 'TCP ACK');
|
||
}
|
||
|
||
private transmitTcpSegment(
|
||
socketId: Id,
|
||
flags: number,
|
||
payload: Uint8Array,
|
||
acknowledgementNumber: number,
|
||
detail: string,
|
||
retransmission = false,
|
||
): IPv4DeliveryResult | undefined {
|
||
const state = this.stateValue;
|
||
const socket = state.socketsById.get(socketId);
|
||
const session = state.tcpSessionsById.get(socketId);
|
||
if (socket === undefined || socket.remoteIPAddress === undefined || socket.remotePort === undefined || session === undefined) {
|
||
return undefined;
|
||
}
|
||
let segment: TcpSegment;
|
||
if (retransmission && session.lastSentSegment !== undefined) {
|
||
segment = session.lastSentSegment;
|
||
session.sendAttempts += 1;
|
||
} else {
|
||
segment = {
|
||
sourcePort: socket.localPort,
|
||
destinationPort: socket.remotePort,
|
||
sequenceNumber: session.nextSendSequenceNumber,
|
||
acknowledgementNumber,
|
||
flags,
|
||
payload,
|
||
};
|
||
session.lastSentSequenceNumber = segment.sequenceNumber;
|
||
session.nextSendSequenceNumber = nextTcpSequenceNumber(segment);
|
||
if (tcpSegmentRequiresAcknowledgement(segment)) {
|
||
session.lastSentSegment = segment;
|
||
session.sendAttempts = 1;
|
||
}
|
||
}
|
||
const interfaces = this.networkInterfaces(socket.nodeId);
|
||
let networkInterface =
|
||
socket.localIPAddress === undefined ? undefined : interfaces.find((each) => each.ipAddress === socket.localIPAddress);
|
||
if (networkInterface === undefined) {
|
||
const route = this.routeForPacket(socket.nodeId, socket.remoteIPAddress);
|
||
networkInterface = route.type === 'egress' ? route.interface : interfaces[0];
|
||
}
|
||
if (networkInterface === undefined) return undefined;
|
||
const packet: IPv4Packet = {
|
||
identity: this.allocatePacketIdentity(),
|
||
senderIPAddress: networkInterface.ipAddress,
|
||
receiverIPAddress: socket.remoteIPAddress,
|
||
timeToLive: 64,
|
||
protocol: 'tcp',
|
||
payload: { kind: 'tcp', segment },
|
||
};
|
||
this.recordTrace({
|
||
packetIdentity: packet.identity,
|
||
nodeId: socket.nodeId,
|
||
interfaceId: networkInterface.portId,
|
||
direction: 'outbound',
|
||
layer: 'transport',
|
||
operation: retransmission ? 'retransmitted' : 'sent',
|
||
afterHeaders: tcpHeaders(segment),
|
||
detail: retransmission ? `${detail} retransmission attempt ${session.sendAttempts}` : detail,
|
||
});
|
||
const portId = networkInterface.portId;
|
||
const handedOn: IPv4DeliveryResult = { type: 'delivered', packetIdentity: packet.identity, nodeId: socket.nodeId };
|
||
return this.handToProtocolThread(socket.nodeId, 'tcp', handedOn, (finished) => {
|
||
// Java's socket starts waiting when it has handed the segment down,
|
||
// before ARP has found the next hop; the timeout counts from here.
|
||
const handedDownAt = state.currentTimeMilliseconds;
|
||
const delivery = this.sendIPv4PacketWithOptions(socket.nodeId, packet, portId, true, 'sent', { onFinished: finished });
|
||
if (state.tcpSessionsById.get(socketId)?.lastSentSegment !== undefined) this.scheduleTcpTimeoutIfNeeded(socketId, handedDownAt);
|
||
return delivery;
|
||
});
|
||
}
|
||
|
||
private scheduleTcpTimeoutIfNeeded(socketId: Id, fromMilliseconds: number): void {
|
||
const pending = this.stateValue.pendingEvents.some((event) => event.kind.kind === 'tcpTimeout' && event.kind.sessionId === socketId);
|
||
if (pending) return;
|
||
this.schedule(fromMilliseconds + this.tcpTimeoutMilliseconds(), { kind: 'tcpTimeout', sessionId: socketId });
|
||
}
|
||
|
||
/** Java's defaultTimeout: MAX_SENDEVERSUCHE round trips, for each attempt and for TIME_WAIT. */
|
||
private tcpTimeoutMilliseconds(): number {
|
||
return TCP_MAXIMUM_SEND_ATTEMPTS * this.runtimeSpeed.roundTripTimeMilliseconds;
|
||
}
|
||
|
||
private handleTcpTimeout(socketId: Id): void {
|
||
const socket = this.stateValue.socketsById.get(socketId);
|
||
if (socket === undefined || socket.tcpState === undefined) return;
|
||
const session = this.stateValue.tcpSessionsById.get(socketId);
|
||
if (session?.closingWaitsLeft !== undefined) {
|
||
if (socket.tcpState === 'TIME_WAIT') {
|
||
this.clearTcpPendingTransmission(socketId);
|
||
this.updateTcpState(socketId, 'CLOSED');
|
||
}
|
||
this.endTcpClosingWait(socketId);
|
||
return;
|
||
}
|
||
if (socket.tcpState === 'SYN_RCVD') {
|
||
// Java's server sends its SYN+ACK once and waits defaultTimeout for the
|
||
// ACK, then closes: a FIN (Java's boundary check sometimes lets it wait
|
||
// a second time; it means one).
|
||
this.closeTcpSocket(socketId);
|
||
return;
|
||
}
|
||
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 connection
|
||
// attempt that fails closes at once.
|
||
if (socket.tcpState === 'ESTABLISHED') {
|
||
this.closeTcpSocket(socketId);
|
||
} else {
|
||
this.updateTcpState(socketId, 'CLOSED');
|
||
}
|
||
return;
|
||
}
|
||
const detail = (last.flags & TCP_FLAG.synchronize) !== 0 ? 'TCP SYN' : 'TCP data';
|
||
this.transmitTcpSegment(socketId, last.flags, last.payload, last.acknowledgementNumber, detail, 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 wait, by a segment or after defaultTimeout. */
|
||
private enterTcpTimeWait(socketId: Id): void {
|
||
this.clearTcpPendingTransmission(socketId);
|
||
this.updateTcpState(socketId, 'TIME_WAIT');
|
||
}
|
||
|
||
/**
|
||
* Advances the clock to the socket's timeouts while it waits in one of
|
||
* the states or for an acknowledgement; whether it advanced at all.
|
||
*/
|
||
private advanceTcpUntilSettled(
|
||
socketId: Id,
|
||
options: { waitingStates?: readonly TcpSocketState[]; waitingForAcknowledgement?: boolean },
|
||
): boolean {
|
||
const waiting = () => {
|
||
const socket = this.stateValue.socketsById.get(socketId);
|
||
if (socket === undefined || socket.tcpState === undefined) return false;
|
||
const waitingOnState = options.waitingStates?.includes(socket.tcpState) === true;
|
||
// Data waits for its acknowledgement only while connected: once the
|
||
// socket closes (Java: senden gives up and calls schliessen), it returns.
|
||
const waitingOnAck =
|
||
options.waitingForAcknowledgement === true &&
|
||
socket.tcpState === 'ESTABLISHED' &&
|
||
this.stateValue.tcpSessionsById.get(socketId)?.lastSentSegment !== undefined;
|
||
return waitingOnState || waitingOnAck;
|
||
};
|
||
let advancedToTimeout = false;
|
||
while (this.stateValue.phase === 'running' && waiting()) {
|
||
const deadlines = this.stateValue.pendingEvents
|
||
.filter((event) => event.kind.kind === 'tcpTimeout' && event.kind.sessionId === socketId)
|
||
.map((event) => event.deadlineMilliseconds);
|
||
if (deadlines.length === 0) return advancedToTimeout;
|
||
advancedToTimeout = true;
|
||
// Until the answer comes, or the attempt's time is up.
|
||
this.advanceUntil(() => !waiting(), Math.min(...deadlines));
|
||
}
|
||
return advancedToTimeout;
|
||
}
|
||
|
||
private updateTcpState(socketId: Id, newState: TcpSocketState): void {
|
||
const socket = this.stateValue.socketsById.get(socketId);
|
||
const oldState = socket?.tcpState;
|
||
if (socket === undefined || oldState === undefined || oldState === newState) return;
|
||
socket.tcpState = newState;
|
||
const session = this.stateValue.tcpSessionsById.get(socketId);
|
||
if (session !== undefined) session.state = newState;
|
||
this.recordTcpStateTransition(socketId, oldState, newState);
|
||
}
|
||
|
||
private recordTcpStateTransition(socketId: Id, oldState: TcpSocketState, newState: TcpSocketState): void {
|
||
const socket = this.stateValue.socketsById.get(socketId);
|
||
if (socket === undefined) return;
|
||
this.recordTrace({
|
||
nodeId: socket.nodeId,
|
||
direction: 'local',
|
||
layer: 'transport',
|
||
operation: 'accepted',
|
||
beforeHeaders: [{ name: 'state', value: oldState }],
|
||
afterHeaders: [{ name: 'state', value: newState }],
|
||
detail: `TCP ${oldState} -> ${newState}`,
|
||
});
|
||
}
|
||
|
||
private resetTcpSendAttempts(socketId: Id): void {
|
||
const session = this.stateValue.tcpSessionsById.get(socketId);
|
||
if (session !== undefined) session.sendAttempts = 0;
|
||
}
|
||
|
||
private reserveFreeTcpPort(nodeId: Id): number | undefined {
|
||
const span = TCP_EPHEMERAL_PORT_UPPER_BOUND_EXCLUSIVE - TCP_EPHEMERAL_PORT_LOWER_BOUND;
|
||
for (let attempt = 0; attempt < span; attempt++) {
|
||
const port = TCP_EPHEMERAL_PORT_LOWER_BOUND + this.tcpPortRandom.nextInt(span);
|
||
if (!this.isTcpPortReserved(nodeId, port)) return port;
|
||
}
|
||
return undefined;
|
||
}
|
||
|
||
/** Swift: (value mod 2^32) · 1,000,000, truncated to 32 bits. */
|
||
private allocateTcpInitialSequenceNumber(): number {
|
||
const value = BigInt(this.stateValue.nextTcpInitialSequenceValue);
|
||
this.stateValue.nextTcpInitialSequenceValue += 1;
|
||
return Number(BigInt.asUintN(32, (value % 4_294_967_296n) * 1_000_000n));
|
||
}
|
||
|
||
private makeTcpSession(
|
||
socketId: Id,
|
||
socketState: TcpSocketState,
|
||
initialSequence: number = this.allocateTcpInitialSequenceNumber(),
|
||
): TcpSession {
|
||
return {
|
||
id: socketId,
|
||
socketId,
|
||
state: socketState,
|
||
sendAttempts: 0,
|
||
nextSendSequenceNumber: initialSequence,
|
||
lastSentSequenceNumber: initialSequence,
|
||
remoteSequenceNumber: 0,
|
||
lastAcknowledgedSequenceNumber: undefined,
|
||
lastSentSegment: undefined,
|
||
closingWaitsLeft: undefined,
|
||
receiveAssembly: new Uint8Array(),
|
||
receivedMessages: [],
|
||
};
|
||
}
|
||
|
||
// ── Later slices hook in here ────────────────────────────────────────────
|
||
|
||
/**
|
||
* Java's router with RIP switched on (VermittlungsrechnerBetriebssystem
|
||
* .starten): a route of hop count 0 to each of its networks that never
|
||
* expires, the server on port 520, the beacon on 521, its first a second
|
||
* after the start. Java draws the beacon's pauses from a new Random per
|
||
* router; the core seeds it from the scenario and the router.
|
||
*/
|
||
private initializeRipState(seed: bigint): void {
|
||
const state = this.stateValue;
|
||
state.ripTablesByNodeId.clear();
|
||
this.ripRoutersByNodeId.clear();
|
||
for (const node of state.topologySnapshot.nodes) {
|
||
if (!this.isRipEnabledRouter(node.id)) continue;
|
||
state.ripTablesByNodeId.set(
|
||
node.id,
|
||
this.networkInterfaces(node.id).flatMap((networkInterface) => {
|
||
const address = parseIPv4(networkInterface.ipAddress);
|
||
const mask = parseIPv4(networkInterface.subnetMask);
|
||
if (address === undefined || mask === undefined) return [];
|
||
return [
|
||
{
|
||
destinationNetwork: formatIPv4(maskAddress(address, mask)),
|
||
subnetMask: networkInterface.subnetMask,
|
||
nextHop: networkInterface.ipAddress,
|
||
interfaceIPAddress: networkInterface.ipAddress,
|
||
origin: 'connected' as const,
|
||
isEditable: false,
|
||
metric: 0,
|
||
},
|
||
];
|
||
}),
|
||
);
|
||
const serverSocketId = this.bindUdpSocket(node.id, { localPort: RIP_DESTINATION_PORT });
|
||
const beaconSocketId = this.bindUdpSocket(node.id, {
|
||
localPort: RIP_SOURCE_PORT,
|
||
remoteIPAddress: LIMITED_BROADCAST_IP_ADDRESS,
|
||
remotePort: RIP_DESTINATION_PORT,
|
||
});
|
||
if (serverSocketId === undefined || beaconSocketId === undefined) continue;
|
||
this.ripRoutersByNodeId.set(node.id, { random: new JavaCompatibleRandom(ripSeed(seed, node.id)), beaconSocketId, serverSocketId });
|
||
this.schedule(state.currentTimeMilliseconds + RIP_FIRST_BEACON_DELAY_MILLISECONDS, { kind: 'ripBeacon', nodeId: node.id });
|
||
}
|
||
}
|
||
|
||
/**
|
||
* RIPBeacon.announce, one round: learned routes past their time get hop
|
||
* count 16 (RIPTable.check — only here, not when they expire), the table
|
||
* goes out on every interface, the next round comes 25.2 to 35.2 s later.
|
||
*/
|
||
private sendRipBeacon(nodeId: Id): void {
|
||
const router = this.ripRoutersByNodeId.get(nodeId);
|
||
const table = this.stateValue.ripTablesByNodeId.get(nodeId);
|
||
if (router === undefined || table === undefined) return;
|
||
const now = this.stateValue.currentTimeMilliseconds;
|
||
this.stateValue.ripTablesByNodeId.set(
|
||
nodeId,
|
||
table.map((route) =>
|
||
route.expiresAtMilliseconds !== undefined && route.expiresAtMilliseconds < now ? { ...route, metric: RIP_INFINITY } : route,
|
||
),
|
||
);
|
||
this.advertiseRipRoutes(nodeId, router.beaconSocketId);
|
||
this.scheduleRipBeacon(nodeId, now + router.random.nextRipJitterMilliseconds());
|
||
}
|
||
|
||
/** One pending beacon per router, the earlier one wins (Java: setNextBeacon, then notify). */
|
||
private scheduleRipBeacon(nodeId: Id, deadlineMilliseconds: number): void {
|
||
const state = this.stateValue;
|
||
const pending = state.pendingEvents.filter((event) => event.kind.kind === 'ripBeacon' && event.kind.nodeId === nodeId);
|
||
if (pending.some((event) => event.deadlineMilliseconds <= deadlineMilliseconds)) return;
|
||
state.pendingEvents = state.pendingEvents.filter((event) => !(event.kind.kind === 'ripBeacon' && event.kind.nodeId === nodeId));
|
||
this.schedule(deadlineMilliseconds, { kind: 'ripBeacon', nodeId });
|
||
}
|
||
|
||
/**
|
||
* RIPBeacon.broadcast: per interface a message from its address (the
|
||
* socket bound to it) to 255.255.255.255:520, with every route that does
|
||
* not go out on that interface (split horizon).
|
||
*/
|
||
private advertiseRipRoutes(nodeId: Id, socketId: Id): void {
|
||
const table = this.stateValue.ripTablesByNodeId.get(nodeId) ?? [];
|
||
const interfaces = this.networkInterfaces(nodeId);
|
||
const publicIPAddress = interfaces[0]?.ipAddress;
|
||
if (publicIPAddress === undefined) return;
|
||
for (const networkInterface of interfaces) {
|
||
const message: RipMessage = {
|
||
ipAddress: networkInterface.ipAddress,
|
||
publicIPAddress,
|
||
infinity: RIP_INFINITY,
|
||
timeoutMilliseconds: RIP_TIMEOUT_MILLISECONDS,
|
||
routes: [],
|
||
};
|
||
for (const route of table) {
|
||
if (route.interfaceIPAddress === networkInterface.ipAddress) continue;
|
||
addRipMessageRoute(message, { network: route.destinationNetwork, mask: route.subnetMask, hops: route.metric ?? RIP_INFINITY });
|
||
}
|
||
const socket = this.stateValue.socketsById.get(socketId);
|
||
if (socket === undefined) return;
|
||
this.stateValue.socketsById.set(socketId, { ...socket, localIPAddress: networkInterface.ipAddress });
|
||
this.recordTrace({
|
||
nodeId,
|
||
interfaceId: networkInterface.portId,
|
||
direction: 'outbound',
|
||
layer: 'application',
|
||
operation: 'sent',
|
||
detail: `RIP advertisement routes=${message.routes.length}`,
|
||
});
|
||
this.sendUdp(socketId, new TextEncoder().encode(encodeRipMessage(message)));
|
||
}
|
||
}
|
||
|
||
/** RIPServerMitarbeiter.verarbeiteNachricht for every message on the server's socket. */
|
||
private serveRipMessages(nodeId: Id): void {
|
||
const router = this.ripRoutersByNodeId.get(nodeId);
|
||
if (router === undefined) return;
|
||
for (let received = this.receiveUdp(router.serverSocketId); received !== undefined; received = this.receiveUdp(router.serverSocketId)) {
|
||
const message = decodeRipMessage(new TextDecoder().decode(received.datagram.payload));
|
||
if (message !== undefined) this.learnRipRoutes(nodeId, message);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* A neighbour's table: each route one hop further (16 at most), taken
|
||
* when it is new, shorter, or from the router the route already goes
|
||
* through; a new route or a longer one from there sends the table at once.
|
||
* Messages from an address in none of the router's networks, or from its
|
||
* own, are ignored.
|
||
*/
|
||
private learnRipRoutes(nodeId: Id, message: RipMessage): void {
|
||
const state = this.stateValue;
|
||
const interfaceIPAddress = this.networkInterfaces(nodeId).find((each) => sameSubnet(each.ipAddress, message.ipAddress, each.subnetMask))?.ipAddress;
|
||
if (interfaceIPAddress === undefined || interfaceIPAddress === message.ipAddress) return;
|
||
const table = [...(state.ripTablesByNodeId.get(nodeId) ?? [])];
|
||
const expiresAtMilliseconds = state.currentTimeMilliseconds + message.timeoutMilliseconds;
|
||
let announce = false;
|
||
for (const entry of message.routes) {
|
||
const hops = entry.hops >= message.infinity || entry.hops + 1 >= RIP_INFINITY ? RIP_INFINITY : entry.hops + 1;
|
||
const index = table.findIndex((route) => route.destinationNetwork === entry.network && route.subnetMask === entry.mask);
|
||
const route = table[index];
|
||
if (route !== undefined) {
|
||
const routeHops = route.metric ?? RIP_INFINITY;
|
||
if (route.nextHop !== message.ipAddress && routeHops <= hops) continue;
|
||
let { nextHop, interfaceIPAddress: routeInterface } = route;
|
||
if (routeHops > hops) {
|
||
nextHop = message.ipAddress;
|
||
routeInterface = interfaceIPAddress;
|
||
} else if (routeHops < hops) {
|
||
announce = true;
|
||
}
|
||
table[index] = { ...route, nextHop, interfaceIPAddress: routeInterface, metric: hops, expiresAtMilliseconds };
|
||
} else if (hops < RIP_INFINITY) {
|
||
table.push({
|
||
destinationNetwork: entry.network,
|
||
subnetMask: entry.mask,
|
||
nextHop: message.ipAddress,
|
||
interfaceIPAddress,
|
||
origin: 'rip',
|
||
isEditable: false,
|
||
metric: hops,
|
||
expiresAtMilliseconds,
|
||
});
|
||
announce = true;
|
||
}
|
||
}
|
||
state.ripTablesByNodeId.set(nodeId, table);
|
||
if (announce) this.scheduleRipBeacon(nodeId, state.currentTimeMilliseconds);
|
||
}
|
||
|
||
/**
|
||
* Weiterleitungstabelle.determineRouteFromDynamicRoutingTable: of the routes
|
||
* with fewer than 16 hops that hold the address, the one with the fewest,
|
||
* then the longest mask; to the address itself when it lies in the
|
||
* interface's network, else to the route's next router.
|
||
*/
|
||
private ripRouteForPacket(nodeId: Id, targetIPAddress: string): PacketRoute {
|
||
const target = parseIPv4(targetIPAddress);
|
||
if (target === undefined) return { type: 'unavailable' };
|
||
let selected: RouteRow | undefined;
|
||
let selectedHops = RIP_INFINITY - 1;
|
||
let selectedMask = -1;
|
||
for (const route of this.stateValue.ripTablesByNodeId.get(nodeId) ?? []) {
|
||
const destination = parseIPv4(route.destinationNetwork);
|
||
const mask = parseIPv4(route.subnetMask);
|
||
const hops = route.metric ?? RIP_INFINITY;
|
||
if (destination === undefined || mask === undefined || destination !== maskAddress(target, mask)) continue;
|
||
if (selectedHops < hops) continue;
|
||
if (selectedHops > hops || selectedMask < mask) {
|
||
selected = route;
|
||
selectedHops = hops;
|
||
selectedMask = mask;
|
||
}
|
||
}
|
||
const egress = this.networkInterfaces(nodeId).find((candidate) => candidate.ipAddress === selected?.interfaceIPAddress);
|
||
if (selected === undefined || egress === undefined) return { type: 'unavailable' };
|
||
return { type: 'egress', interface: egress, nextHop: sameSubnet(egress.ipAddress, targetIPAddress, egress.subnetMask) ? targetIPAddress : selected.nextHop };
|
||
}
|
||
|
||
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 });
|
||
}
|
||
|
||
/**
|
||
* Java's GatewayFirmware.starten: each home router's port forwarding
|
||
* becomes its static entries (rows Java's input check rejects are left
|
||
* out). Dynamic entries expire as Java's timer finds them, checked every
|
||
* second from the start (natEntryExpired).
|
||
*/
|
||
private initializeNatState(): void {
|
||
const state = this.stateValue;
|
||
state.natTablesByNodeId.clear();
|
||
this.natStartedAtMilliseconds = state.currentTimeMilliseconds;
|
||
for (const node of state.topologySnapshot.nodes) {
|
||
if (node.kind !== 'gateway') continue;
|
||
const entries: NatEntry[] = [];
|
||
for (const row of state.topologySnapshot.portForwardingRowsByNodeId.get(node.id) ?? []) {
|
||
const forwarding = parsePortForwardingRow(row);
|
||
if (forwarding === undefined) continue;
|
||
// Java's HashMap: a second row for the same port and protocol replaces the first.
|
||
const index = entries.findIndex((entry) => entry.protocol === forwarding.protocol && entry.port === forwarding.publicPort);
|
||
const entry: NatEntry = {
|
||
protocol: forwarding.protocol,
|
||
remoteIPAddress: '',
|
||
port: forwarding.publicPort,
|
||
lan: { ipAddress: forwarding.lanIPAddress, port: forwarding.lanPort },
|
||
type: 'static',
|
||
updatedAtMilliseconds: state.currentTimeMilliseconds,
|
||
};
|
||
if (index >= 0) entries[index] = entry;
|
||
else entries.push(entry);
|
||
}
|
||
state.natTablesByNodeId.set(node.id, entries);
|
||
}
|
||
}
|
||
|
||
/** The home router's NAT table as it stands: static entries, then the dynamic ones that have not expired. */
|
||
natEntries(nodeId: Id): readonly NatEntry[] {
|
||
this.expireNatEntries(nodeId);
|
||
return this.stateValue.natTablesByNodeId.get(nodeId) ?? [];
|
||
}
|
||
|
||
/**
|
||
* GatewayFirmware.checkNAT: every second from a second after the start,
|
||
* dynamic entries whose last update is 300 s (times Java's delay factor)
|
||
* old are removed — at the first such check.
|
||
*/
|
||
private natEntryExpired(entry: NatEntry, nowMilliseconds: number): boolean {
|
||
if (entry.type === 'static') return false;
|
||
const start = this.natStartedAtMilliseconds;
|
||
const retention = NAT_RETENTION_MILLISECONDS * this.runtimeSpeed.javaDelayFactor;
|
||
const firstCheck = start + Math.max(1, Math.ceil((entry.updatedAtMilliseconds + retention - start) / 1_000)) * 1_000;
|
||
return nowMilliseconds >= firstCheck;
|
||
}
|
||
|
||
private expireNatEntries(nodeId: Id): void {
|
||
const table = this.stateValue.natTablesByNodeId.get(nodeId);
|
||
if (table === undefined) return;
|
||
const now = this.stateValue.currentTimeMilliseconds;
|
||
if (table.some((entry) => this.natEntryExpired(entry, now))) {
|
||
this.stateValue.natTablesByNodeId.set(
|
||
nodeId,
|
||
table.filter((entry) => !this.natEntryExpired(entry, now)),
|
||
);
|
||
}
|
||
}
|
||
|
||
/** A port a dynamic entry of the home router holds (Java reserves it at the transport layer). */
|
||
private isNatPort(nodeId: Id, protocol: 'tcp' | 'udp', port: number): boolean {
|
||
return this.natEntries(nodeId).some((entry) => entry.type !== 'static' && entry.protocol === protocol && entry.port === port);
|
||
}
|
||
|
||
/**
|
||
* NetworkAddressTranslationTable.find: the dynamic entry for the remote end,
|
||
* port and protocol (updated), else the static one for the port, which
|
||
* then gets a dynamic entry for the remote end.
|
||
*/
|
||
private findNatEntry(nodeId: Id, port: number, remoteIPAddress: string, protocol: NatProtocol): NatEndpoint | undefined {
|
||
const table = [...this.natEntries(nodeId)];
|
||
const now = this.stateValue.currentTimeMilliseconds;
|
||
const index = table.findIndex((entry) => entry.type !== 'static' && entry.protocol === protocol && entry.port === port && entry.remoteIPAddress === remoteIPAddress);
|
||
if (index >= 0) {
|
||
table[index] = { ...table[index]!, updatedAtMilliseconds: now };
|
||
this.stateValue.natTablesByNodeId.set(nodeId, table);
|
||
return table[index]!.lan;
|
||
}
|
||
const forwarding = table.find((entry) => entry.type === 'static' && entry.protocol === protocol && entry.port === port);
|
||
if (forwarding === undefined) return undefined;
|
||
this.putDynamicNatEntry(nodeId, { protocol, remoteIPAddress, port, lan: forwarding.lan, type: 'dynamicFromStatic', updatedAtMilliseconds: now });
|
||
return forwarding.lan;
|
||
}
|
||
|
||
/** Java's ConcurrentHashMap.put: an entry for the same remote end, port and protocol is replaced. */
|
||
private putDynamicNatEntry(nodeId: Id, entry: NatEntry): void {
|
||
const table = [...this.natEntries(nodeId)];
|
||
const index = table.findIndex(
|
||
(each) => each.type !== 'static' && each.protocol === entry.protocol && each.port === entry.port && each.remoteIPAddress === entry.remoteIPAddress,
|
||
);
|
||
if (index >= 0) table[index] = entry;
|
||
else table.push(entry);
|
||
this.stateValue.natTablesByNodeId.set(nodeId, table);
|
||
}
|
||
|
||
/**
|
||
* NetworkAddressTranslationTable.findPort: the WAN port of the LAN
|
||
* endpoint's entries, 0 without one. (Java looks at the dynamic entries
|
||
* first; an endpoint's entries all have the same port.)
|
||
*/
|
||
private natPortOf(nodeId: Id, lan: NatEndpoint, protocol: NatProtocol): number {
|
||
return (
|
||
this.natEntries(nodeId).find((entry) => entry.protocol === protocol && entry.lan.ipAddress === lan.ipAddress && entry.lan.port === lan.port)?.port ?? 0
|
||
);
|
||
}
|
||
|
||
/**
|
||
* NatGateway.insertNewConnection: a LAN endpoint without an entry gets one
|
||
* for the remote end, on a free port of the home router (ICMP: its
|
||
* identifier).
|
||
*
|
||
* Java keeps that one entry for the endpoint's later remote ends too: they
|
||
* get its WAN port, but their answers find no entry and never reach the LAN
|
||
* (measured: udpTwoRemotes). The core adds an entry for each new remote end
|
||
* on the same WAN port — unless a port forwarding serves the endpoint,
|
||
* whose answers find their way already.
|
||
*/
|
||
private insertNatConnection(nodeId: Id, protocol: NatProtocol, lan: NatEndpoint, remoteIPAddress: string): void {
|
||
const table = this.natEntries(nodeId);
|
||
const now = this.stateValue.currentTimeMilliseconds;
|
||
const ofEndpoint = table.filter((entry) => entry.protocol === protocol && entry.lan.ipAddress === lan.ipAddress && entry.lan.port === lan.port);
|
||
if (ofEndpoint.length === 0) {
|
||
const port = protocol === 'tcp' ? this.reserveFreeTcpPort(nodeId) : protocol === 'udp' ? this.reserveFreeUdpPort(nodeId) : lan.port;
|
||
if (port === undefined) return;
|
||
this.putDynamicNatEntry(nodeId, { protocol, remoteIPAddress, port, lan, type: 'dynamic', updatedAtMilliseconds: now });
|
||
return;
|
||
}
|
||
if (ofEndpoint.some((entry) => entry.type === 'static' || entry.remoteIPAddress === remoteIPAddress)) return;
|
||
this.putDynamicNatEntry(nodeId, { protocol, remoteIPAddress, port: this.natPortOf(nodeId, lan, protocol), lan, type: 'dynamic', updatedAtMilliseconds: now });
|
||
}
|
||
|
||
/**
|
||
* What Java's NatGatewayLANThread and NatGatewayWANThread do to a packet
|
||
* before the home router's IP layer sees it: from the LAN, a packet to
|
||
* elsewhere (not the LAN, not a broadcast, not the WAN address) gets the
|
||
* WAN address and the entry's port; from the WAN, a packet to the WAN
|
||
* address with an entry goes to its LAN endpoint, one to the LAN's
|
||
* addresses is dropped, and an echo request to the WAN address is answered
|
||
* by the router itself. Both sides pass the router's firewall first.
|
||
* Undefined when the packet goes no further.
|
||
*/
|
||
private translateAtGateway(packet: IPv4Packet, frameIdentity: number | undefined, nodeId: Id, networkInterface: NetworkInterface): IPv4Packet | undefined {
|
||
const interfaces = this.networkInterfaces(nodeId);
|
||
const wan = interfaces.find((each) => each.index === 0);
|
||
const lan = interfaces.find((each) => each.index === 1);
|
||
if (wan === undefined || lan === undefined) return packet;
|
||
const payload = packet.payload;
|
||
const trace = (operation: TraceOperation, detail: string, after?: IPv4Packet) =>
|
||
this.recordTrace({
|
||
frameIdentity,
|
||
packetIdentity: packet.identity,
|
||
nodeId,
|
||
interfaceId: networkInterface.portId,
|
||
direction: networkInterface.portId === wan.portId ? 'inbound' : 'outbound',
|
||
layer: 'network',
|
||
operation,
|
||
beforeHeaders: ipv4Headers(packet),
|
||
...(after === undefined ? {} : { afterHeaders: ipv4Headers(after) }),
|
||
detail,
|
||
});
|
||
|
||
if (networkInterface.portId === wan.portId) {
|
||
if (payload.kind === 'icmp' && payload.message.kind === 'echoRequest' && packet.receiverIPAddress === wan.ipAddress) return packet;
|
||
if (sameSubnet(packet.receiverIPAddress, lan.ipAddress, lan.subnetMask)) {
|
||
trace('dropped', 'WAN packet addressed to the LAN');
|
||
return undefined;
|
||
}
|
||
if (!this.evaluateFirewall(packet, nodeId, networkInterface.portId, frameIdentity).accepted) return undefined;
|
||
if (packet.receiverIPAddress !== wan.ipAddress) return packet;
|
||
const lookup = natInboundLookup(packet);
|
||
if (lookup === undefined) return packet;
|
||
const endpoint = this.findNatEntry(nodeId, lookup.port, lookup.remoteIPAddress, lookup.protocol);
|
||
if (endpoint === undefined) return packet;
|
||
const translated = natWithDestination(packet, endpoint);
|
||
trace('rewritten', 'NAT WAN to LAN', translated);
|
||
return translated;
|
||
}
|
||
|
||
if (networkInterface.portId !== lan.portId) return packet;
|
||
if (!this.evaluateFirewall(packet, nodeId, networkInterface.portId, frameIdentity).accepted) return undefined;
|
||
const outgoing =
|
||
!sameSubnet(packet.receiverIPAddress, lan.ipAddress, lan.subnetMask) &&
|
||
!isBroadcastFrom(packet.receiverIPAddress, packet.senderIPAddress, lan.subnetMask) &&
|
||
packet.receiverIPAddress !== wan.ipAddress;
|
||
if (!outgoing) return packet;
|
||
if (packet.timeToLive <= 1) {
|
||
this.emitIcmpError(nodeId, networkInterface, 'timeExceeded', packet);
|
||
return undefined;
|
||
}
|
||
const endpoint = natOutboundEndpoint(packet);
|
||
if (endpoint.opens) this.insertNatConnection(nodeId, endpoint.protocol, { ipAddress: packet.senderIPAddress, port: endpoint.port }, packet.receiverIPAddress);
|
||
const port = endpoint.protocol === 'icmp' ? undefined : this.natPortOf(nodeId, { ipAddress: packet.senderIPAddress, port: endpoint.port }, endpoint.protocol);
|
||
const translated = natWithSource(packet, wan.ipAddress, port);
|
||
trace('rewritten', 'NAT LAN to WAN', translated);
|
||
return translated;
|
||
}
|
||
|
||
private isRipEnabledRouter(nodeId: Id): boolean {
|
||
return (
|
||
this.nodeSnapshot(nodeId)?.kind === 'router' &&
|
||
this.stateValue.topologySnapshot.ripEnabledByNodeId.get(nodeId) === true
|
||
);
|
||
}
|
||
|
||
// ── Interfaces and addresses ─────────────────────────────────────────────
|
||
|
||
networkInterfaces(nodeId: Id): NetworkInterface[] {
|
||
const snapshot = this.stateValue.topologySnapshot;
|
||
const node = snapshot.nodes.find((candidate) => candidate.id === nodeId);
|
||
if (node === undefined) return [];
|
||
const deviceConfiguration = snapshot.deviceConfigurations.get(nodeId);
|
||
const result: NetworkInterface[] = [];
|
||
node.ports.forEach((port, index) => {
|
||
let configuration: InterfaceConfiguration | undefined;
|
||
switch (node.kind) {
|
||
case 'pc':
|
||
case 'notebook':
|
||
if (index !== 0 || deviceConfiguration === undefined) return;
|
||
configuration = { ipAddress: deviceConfiguration.ipAddress, subnetMask: deviceConfiguration.subnetMask };
|
||
break;
|
||
case 'router':
|
||
case 'gateway':
|
||
configuration = snapshot.interfaceConfigurations.get(interfaceKey(nodeId, port.id));
|
||
break;
|
||
case 'networkSwitch':
|
||
case 'remoteLink':
|
||
case 'unsupported':
|
||
configuration = undefined;
|
||
break;
|
||
}
|
||
if (configuration === undefined) return;
|
||
result.push({
|
||
nodeId,
|
||
portId: port.id,
|
||
index,
|
||
ipAddress: configuration.ipAddress,
|
||
subnetMask: configuration.subnetMask,
|
||
defaultGateway: deviceConfiguration?.defaultGateway ?? '',
|
||
macAddress: port.macAddress ?? stableMacAddress(port.id),
|
||
});
|
||
});
|
||
return result;
|
||
}
|
||
|
||
clearArpCache(nodeId?: Id): void {
|
||
if (nodeId === undefined) {
|
||
this.stateValue.arpCachesByNodeId.clear();
|
||
} else {
|
||
this.stateValue.arpCachesByNodeId.set(nodeId, new Map());
|
||
}
|
||
}
|
||
|
||
/**
|
||
* The MAC address for `targetIPAddress`, asking by ARP broadcast when it is
|
||
* not cached. Blocks like Java: each request waits a round trip for its
|
||
* answer (virtual time passes) before the next, or before the call gives up.
|
||
*/
|
||
resolveMacAddress(
|
||
nodeId: Id,
|
||
targetIPAddress: string,
|
||
options: { preferredInterfaceId?: Id | undefined; maxRetries?: number; selectInterfaceByAddress?: boolean } = {},
|
||
): string | undefined {
|
||
const maxRetries = options.maxRetries ?? ARP_ATTEMPTS;
|
||
const selectInterfaceByAddress = options.selectInterfaceByAddress ?? true;
|
||
const interfaces = this.networkInterfaces(nodeId);
|
||
if (interfaces.length === 0) return undefined;
|
||
const known = this.knownMacAddress(nodeId, targetIPAddress);
|
||
if (known !== undefined) return known;
|
||
|
||
let selectedInterface: NetworkInterface | undefined;
|
||
if (options.preferredInterfaceId !== undefined) {
|
||
selectedInterface = interfaces.find((candidate) => candidate.portId === options.preferredInterfaceId);
|
||
} else if (selectInterfaceByAddress) {
|
||
selectedInterface = bestInterface(targetIPAddress, interfaces);
|
||
} else {
|
||
selectedInterface = interfaces[0];
|
||
}
|
||
if (selectedInterface === undefined) return undefined;
|
||
|
||
let finished = false;
|
||
let resolved: string | undefined;
|
||
this.lookUpMacAddress(nodeId, targetIPAddress, selectedInterface, maxRetries, (macAddress) => {
|
||
finished = true;
|
||
resolved = macAddress;
|
||
});
|
||
if (!finished && !this.isProcessingScheduledEvents) {
|
||
this.advanceUntil(() => finished, this.stateValue.currentTimeMilliseconds + (maxRetries + 1) * this.runtimeSpeed.roundTripTimeMilliseconds);
|
||
}
|
||
return resolved;
|
||
}
|
||
|
||
/**
|
||
* Java's holeARPTabellenEintrag as a lookup of its own: a known address at
|
||
* once; otherwise an ARP request and a round trip's wait for the answer,
|
||
* `attempts` times, then undefined. Java wakes on any change of the ARP
|
||
* table and gives up when it finds nothing; the lookup waits for its
|
||
* address (as the DHCP client's check does).
|
||
*/
|
||
private lookUpMacAddress(
|
||
nodeId: Id,
|
||
ipAddress: string,
|
||
networkInterface: NetworkInterface,
|
||
attempts: number,
|
||
done: (macAddress: string | undefined) => void,
|
||
): void {
|
||
const known = this.knownMacAddress(nodeId, ipAddress);
|
||
if (known !== undefined || attempts <= 0) {
|
||
done(known);
|
||
return;
|
||
}
|
||
const swiftQueued = this.swiftArp && this.isProcessingScheduledEvents;
|
||
const lookup: ArpLookup = { id: this.nextArpLookupId++, nodeId, ipAddress, networkInterface, attemptsLeft: attempts - 1, swiftQueued, done };
|
||
this.arpLookups.push(lookup);
|
||
if (swiftQueued) {
|
||
// Swift, only for the comparison: every request at once, then the packet waits without end.
|
||
for (let attempt = 0; attempt < attempts; attempt += 1) this.sendArpRequest(nodeId, networkInterface, ipAddress);
|
||
lookup.attemptsLeft = 0;
|
||
return;
|
||
}
|
||
this.schedule(this.stateValue.currentTimeMilliseconds + this.runtimeSpeed.roundTripTimeMilliseconds, { kind: 'arpTimeout', lookupId: lookup.id });
|
||
this.sendArpRequest(nodeId, networkInterface, ipAddress);
|
||
}
|
||
|
||
/** The node learned `ipAddress`: every lookup waiting for it has its answer. */
|
||
private completeArpLookups(nodeId: Id, ipAddress: string, macAddress: string, onlySwiftQueued = false): void {
|
||
const answered = this.arpLookups.filter(
|
||
(lookup) => lookup.nodeId === nodeId && lookup.ipAddress === ipAddress && (!onlySwiftQueued || lookup.swiftQueued),
|
||
);
|
||
if (answered.length === 0) return;
|
||
const ids = new Set(answered.map((lookup) => lookup.id));
|
||
this.arpLookups = this.arpLookups.filter((lookup) => !ids.has(lookup.id));
|
||
this.stateValue.pendingEvents = this.stateValue.pendingEvents.filter(
|
||
(event) => !(event.kind.kind === 'arpTimeout' && ids.has(event.kind.lookupId)),
|
||
);
|
||
for (const lookup of answered) lookup.done(macAddress);
|
||
}
|
||
|
||
/** No answer within the round trip: ask again, or give up. */
|
||
private handleArpTimeout(lookupId: number): void {
|
||
const lookup = this.arpLookups.find((each) => each.id === lookupId);
|
||
if (lookup === undefined) return;
|
||
if (lookup.attemptsLeft > 0) {
|
||
lookup.attemptsLeft -= 1;
|
||
this.schedule(this.stateValue.currentTimeMilliseconds + this.runtimeSpeed.roundTripTimeMilliseconds, { kind: 'arpTimeout', lookupId });
|
||
this.sendArpRequest(lookup.nodeId, lookup.networkInterface, lookup.ipAddress);
|
||
return;
|
||
}
|
||
this.arpLookups = this.arpLookups.filter((each) => each.id !== lookupId);
|
||
lookup.done(undefined);
|
||
}
|
||
|
||
/** The MAC address for `ipAddress` without asking: localhost, one of the node's own, or cached. */
|
||
private knownMacAddress(nodeId: Id, ipAddress: string): string | undefined {
|
||
const interfaces = this.networkInterfaces(nodeId);
|
||
const primaryInterface = interfaces[0];
|
||
if (primaryInterface === undefined) return undefined;
|
||
if (ipAddress === LOCALHOST_IP_ADDRESS) return primaryInterface.macAddress;
|
||
const localInterface = interfaces.find((candidate) => candidate.ipAddress === ipAddress);
|
||
if (localInterface !== undefined) return localInterface.macAddress;
|
||
return this.stateValue.arpCachesByNodeId.get(nodeId)?.get(ipAddress)?.macAddress;
|
||
}
|
||
|
||
/** One ARP request for `targetIPAddress`, broadcast from the interface. */
|
||
private sendArpRequest(nodeId: Id, networkInterface: NetworkInterface, targetIPAddress: string): void {
|
||
const request: ArpPacket = {
|
||
operation: 'request',
|
||
senderMacAddress: networkInterface.macAddress,
|
||
senderIPAddress: networkInterface.ipAddress,
|
||
targetMacAddress: UNSPECIFIED_MAC_ADDRESS,
|
||
targetIPAddress,
|
||
};
|
||
const frame: EthernetFrame = {
|
||
identity: this.allocateFrameIdentity(),
|
||
sourceMacAddress: networkInterface.macAddress,
|
||
destinationMacAddress: ETHERNET_BROADCAST_MAC_ADDRESS,
|
||
payload: { kind: 'arp', packet: request },
|
||
};
|
||
this.recordTrace({
|
||
frameIdentity: frame.identity,
|
||
nodeId,
|
||
interfaceId: networkInterface.portId,
|
||
direction: 'outbound',
|
||
layer: 'dataLink',
|
||
operation: 'created',
|
||
afterHeaders: ethernetHeaders(frame),
|
||
detail: `ARP request for ${targetIPAddress}`,
|
||
});
|
||
this.sendEthernetFrame(nodeId, networkInterface.portId, frame);
|
||
}
|
||
|
||
|
||
private earliestDeadline(filter: (kind: ScheduledEventKind) => boolean): number | undefined {
|
||
let earliest: number | undefined;
|
||
for (const event of this.stateValue.pendingEvents) {
|
||
if (!filter(event.kind)) continue;
|
||
if (earliest === undefined || event.deadlineMilliseconds < earliest) earliest = event.deadlineMilliseconds;
|
||
}
|
||
return earliest;
|
||
}
|
||
|
||
// ── Ethernet ─────────────────────────────────────────────────────────────
|
||
|
||
sendEthernetFrame(fromNodeId: Id, outgoingPortId: Id, frame: EthernetFrame): void {
|
||
const endpoint = this.attachedEndpoint(fromNodeId, outgoingPortId);
|
||
const now = this.stateValue.currentTimeMilliseconds;
|
||
const busyUntil = this.cablesCarryOneFrameAtATime ? (this.cableBusyUntilByPort.get(portKey(fromNodeId, outgoingPortId)) ?? now) : now;
|
||
if (endpoint !== undefined && busyUntil > now) {
|
||
// The cable carries a frame: this one waits in the output buffer.
|
||
this.cableBusyUntilByPort.set(portKey(fromNodeId, outgoingPortId), busyUntil + this.runtimeSpeed.linkTransmissionDelayMilliseconds);
|
||
this.schedule(busyUntil, {
|
||
kind: 'ethernetLinkDeparture',
|
||
sourceNodeId: fromNodeId,
|
||
outgoingPortId,
|
||
targetNodeId: endpoint.nodeId,
|
||
targetPortId: endpoint.portId,
|
||
frame,
|
||
});
|
||
this.drainPendingNetworkDeliveryEventsIfNeeded();
|
||
return;
|
||
}
|
||
this.departEthernetFrame(fromNodeId, outgoingPortId, endpoint, frame);
|
||
}
|
||
|
||
/** The frame leaves on the cable (and shows in the packet log); it arrives one cable's delay later. */
|
||
private departEthernetFrame(fromNodeId: Id, outgoingPortId: Id, endpoint: { nodeId: Id; portId: Id } | undefined, frame: EthernetFrame): void {
|
||
this.recordTrace({
|
||
frameIdentity: frame.identity,
|
||
packetIdentity: packetIdentityInFrame(frame),
|
||
nodeId: fromNodeId,
|
||
interfaceId: outgoingPortId,
|
||
direction: 'outbound',
|
||
layer: 'physical',
|
||
operation: 'sent',
|
||
afterHeaders: ethernetHeaders(frame),
|
||
});
|
||
if (endpoint === undefined) return;
|
||
const latencyMilliseconds = this.runtimeSpeed.linkTransmissionDelayMilliseconds;
|
||
const deadlineMilliseconds = this.stateValue.currentTimeMilliseconds + latencyMilliseconds;
|
||
if (!Number.isSafeInteger(deadlineMilliseconds)) {
|
||
this.recordTrace({
|
||
frameIdentity: frame.identity,
|
||
packetIdentity: packetIdentityInFrame(frame),
|
||
nodeId: fromNodeId,
|
||
interfaceId: outgoingPortId,
|
||
direction: 'outbound',
|
||
layer: 'physical',
|
||
operation: 'dropped',
|
||
afterHeaders: ethernetHeaders(frame),
|
||
detail: 'link transmission deadline overflow',
|
||
});
|
||
return;
|
||
}
|
||
if (this.cablesCarryOneFrameAtATime) this.cableBusyUntilByPort.set(portKey(fromNodeId, outgoingPortId), deadlineMilliseconds);
|
||
|
||
this.schedule(deadlineMilliseconds, {
|
||
kind: 'ethernetLinkDelivery',
|
||
sourceNodeId: fromNodeId,
|
||
targetNodeId: endpoint.nodeId,
|
||
targetPortId: endpoint.portId,
|
||
latencyMilliseconds,
|
||
frame,
|
||
});
|
||
this.drainPendingNetworkDeliveryEventsIfNeeded();
|
||
}
|
||
|
||
/**
|
||
* Swift's send, for the comparison: a frame sent from outside the events
|
||
* runs the network until every frame in flight has arrived. Java's send
|
||
* hands the frame to the card and returns; the frames travel while the
|
||
* clock advances.
|
||
*/
|
||
private drainPendingNetworkDeliveryEventsIfNeeded(): void {
|
||
if (this.isProcessingScheduledEvents || !this.swiftSends) return;
|
||
const maximumEvents = 16_384;
|
||
let processedEventCount = 0;
|
||
while (processedEventCount < maximumEvents) {
|
||
const nextDeadline = this.earliestDeadline(isNetworkDeliveryEvent);
|
||
if (nextDeadline === undefined) return;
|
||
this.advance(nextDeadline);
|
||
processedEventCount += 1;
|
||
}
|
||
}
|
||
|
||
private attachedEndpoint(nodeId: Id, portId: Id): { nodeId: Id; portId: Id } | undefined {
|
||
for (const link of this.stateValue.topologySnapshot.links) {
|
||
if (link.sourceNodeId === nodeId && link.sourcePortId === portId) {
|
||
return { nodeId: link.targetNodeId, portId: link.targetPortId };
|
||
}
|
||
if (link.targetNodeId === nodeId && link.targetPortId === portId) {
|
||
return { nodeId: link.sourceNodeId, portId: link.sourcePortId };
|
||
}
|
||
}
|
||
return undefined;
|
||
}
|
||
|
||
private nodeSnapshot(nodeId: Id): NodeSnapshot | undefined {
|
||
return this.stateValue.topologySnapshot.nodes.find((node) => node.id === nodeId);
|
||
}
|
||
|
||
private receiveEthernetFrame(frame: EthernetFrame, nodeId: Id, incomingPortId: Id): void {
|
||
this.recordTrace({
|
||
frameIdentity: frame.identity,
|
||
packetIdentity: packetIdentityInFrame(frame),
|
||
nodeId,
|
||
interfaceId: incomingPortId,
|
||
direction: 'inbound',
|
||
layer: 'physical',
|
||
operation: 'received',
|
||
beforeHeaders: ethernetHeaders(frame),
|
||
});
|
||
const node = this.nodeSnapshot(nodeId);
|
||
if (node === undefined) return;
|
||
if (node.kind === 'networkSwitch') {
|
||
this.forwardFrameThroughSwitch(frame, node, incomingPortId);
|
||
return;
|
||
}
|
||
if (node.kind === 'remoteLink') throw new NotPortedError('remote links');
|
||
|
||
this.recordTrace({
|
||
frameIdentity: frame.identity,
|
||
packetIdentity: packetIdentityInFrame(frame),
|
||
nodeId,
|
||
interfaceId: incomingPortId,
|
||
direction: 'inbound',
|
||
layer: 'dataLink',
|
||
operation: 'received',
|
||
beforeHeaders: ethernetHeaders(frame),
|
||
});
|
||
const networkInterface = this.networkInterfaces(nodeId).find((candidate) => candidate.portId === incomingPortId);
|
||
if (networkInterface === undefined) return;
|
||
const destination = frame.destinationMacAddress.toUpperCase();
|
||
if (destination !== networkInterface.macAddress.toUpperCase() && destination !== ETHERNET_BROADCAST_MAC_ADDRESS) {
|
||
this.recordTrace({
|
||
frameIdentity: frame.identity,
|
||
packetIdentity: packetIdentityInFrame(frame),
|
||
nodeId,
|
||
interfaceId: incomingPortId,
|
||
direction: 'inbound',
|
||
layer: 'dataLink',
|
||
operation: 'dropped',
|
||
beforeHeaders: ethernetHeaders(frame),
|
||
detail: 'destination MAC does not match interface',
|
||
});
|
||
return;
|
||
}
|
||
switch (frame.payload.kind) {
|
||
case 'arp':
|
||
this.receiveArpPacket(frame.payload.packet, frame.identity, nodeId, networkInterface);
|
||
return;
|
||
case 'ipv4':
|
||
this.receiveIPv4Packet(frame.payload.packet, frame.identity, nodeId, networkInterface, true);
|
||
return;
|
||
}
|
||
}
|
||
|
||
// ── Switch ───────────────────────────────────────────────────────────────
|
||
|
||
private forwardFrameThroughSwitch(frame: EthernetFrame, switchNode: NodeSnapshot, incomingPortId: Id): void {
|
||
const state = this.stateValue;
|
||
const seen = state.switchSeenFrameIdentitiesByNodeId.get(switchNode.id);
|
||
if (seen?.has(frame.identity) === true) {
|
||
this.recordTrace({
|
||
frameIdentity: frame.identity,
|
||
packetIdentity: packetIdentityInFrame(frame),
|
||
nodeId: switchNode.id,
|
||
interfaceId: incomingPortId,
|
||
direction: 'inbound',
|
||
layer: 'dataLink',
|
||
operation: 'dropped',
|
||
beforeHeaders: ethernetHeaders(frame),
|
||
detail: 'switch cycle suppression',
|
||
});
|
||
return;
|
||
}
|
||
getOrCreate(state.switchSeenFrameIdentitiesByNodeId, switchNode.id, () => new Set()).add(frame.identity);
|
||
this.ageSwitchSatEntries(switchNode.id);
|
||
|
||
const source = frame.sourceMacAddress.toUpperCase();
|
||
getOrCreate(state.switchForwardingTablesByNodeId, switchNode.id, () => new Map()).set(source, incomingPortId);
|
||
getOrCreate(state.switchForwardingUpdatedAtMillisecondsByNodeId, switchNode.id, () => new Map()).set(
|
||
source,
|
||
state.currentTimeMilliseconds,
|
||
);
|
||
this.recordTrace({
|
||
frameIdentity: frame.identity,
|
||
packetIdentity: packetIdentityInFrame(frame),
|
||
nodeId: switchNode.id,
|
||
interfaceId: incomingPortId,
|
||
direction: 'inbound',
|
||
layer: 'dataLink',
|
||
operation: 'accepted',
|
||
beforeHeaders: ethernetHeaders(frame),
|
||
detail: 'switch learned source MAC',
|
||
});
|
||
|
||
const destination = frame.destinationMacAddress.toUpperCase();
|
||
const connectedPorts = switchNode.ports
|
||
.filter((port) => port.id !== incomingPortId && this.attachedEndpoint(switchNode.id, port.id) !== undefined)
|
||
.map((port) => port.id);
|
||
|
||
let outgoingPorts: Id[];
|
||
let forwardingDetail: string;
|
||
const learnedPort = state.switchForwardingTablesByNodeId.get(switchNode.id)?.get(destination);
|
||
if (destination === ETHERNET_BROADCAST_MAC_ADDRESS) {
|
||
outgoingPorts = connectedPorts;
|
||
forwardingDetail = 'switch broadcast flooding';
|
||
} else if (learnedPort !== undefined) {
|
||
if (learnedPort === incomingPortId) {
|
||
this.recordTrace({
|
||
frameIdentity: frame.identity,
|
||
packetIdentity: packetIdentityInFrame(frame),
|
||
nodeId: switchNode.id,
|
||
interfaceId: incomingPortId,
|
||
direction: 'inbound',
|
||
layer: 'dataLink',
|
||
operation: 'dropped',
|
||
beforeHeaders: ethernetHeaders(frame),
|
||
detail: 'switch filtered destination on incoming port',
|
||
});
|
||
return;
|
||
}
|
||
if (this.attachedEndpoint(switchNode.id, learnedPort) !== undefined) {
|
||
outgoingPorts = [learnedPort];
|
||
forwardingDetail = 'switch learned unicast forwarding';
|
||
} else {
|
||
state.switchForwardingTablesByNodeId.get(switchNode.id)?.delete(destination);
|
||
state.switchForwardingUpdatedAtMillisecondsByNodeId.get(switchNode.id)?.delete(destination);
|
||
outgoingPorts = connectedPorts;
|
||
forwardingDetail = 'switch unknown unicast flooding';
|
||
}
|
||
} else {
|
||
outgoingPorts = connectedPorts;
|
||
forwardingDetail = 'switch unknown unicast flooding';
|
||
}
|
||
|
||
for (const portId of outgoingPorts) {
|
||
this.recordTrace({
|
||
frameIdentity: frame.identity,
|
||
packetIdentity: packetIdentityInFrame(frame),
|
||
nodeId: switchNode.id,
|
||
interfaceId: portId,
|
||
direction: 'outbound',
|
||
layer: 'dataLink',
|
||
operation: 'forwarded',
|
||
afterHeaders: ethernetHeaders(frame),
|
||
detail: forwardingDetail,
|
||
});
|
||
this.sendEthernetFrame(switchNode.id, portId, frame);
|
||
}
|
||
}
|
||
|
||
clearSwitchSat(nodeId: Id): void {
|
||
this.stateValue.switchForwardingTablesByNodeId.delete(nodeId);
|
||
this.stateValue.switchForwardingUpdatedAtMillisecondsByNodeId.delete(nodeId);
|
||
}
|
||
|
||
/** The switch's source address table (Java "SAT"), sorted by MAC address. */
|
||
switchSatEntries(nodeId: Id): SwitchSatEntry[] {
|
||
const node = this.nodeSnapshot(nodeId);
|
||
if (node === undefined || node.kind !== 'networkSwitch') return [];
|
||
const labelsByPortId = new Map(node.ports.map((port) => [port.id, port.label]));
|
||
const updatedAtByMac = this.stateValue.switchForwardingUpdatedAtMillisecondsByNodeId.get(nodeId) ?? new Map();
|
||
const table = this.stateValue.switchForwardingTablesByNodeId.get(nodeId) ?? new Map<string, Id>();
|
||
return [...table]
|
||
.map(([macAddress, portId]) => ({
|
||
macAddress,
|
||
portId,
|
||
portLabel: labelsByPortId.get(portId) ?? '?',
|
||
updatedAtMilliseconds: updatedAtByMac.get(macAddress) ?? 0,
|
||
}))
|
||
.sort((lhs, rhs) =>
|
||
lhs.macAddress === rhs.macAddress
|
||
? compareIds(lhs.portId, rhs.portId)
|
||
: lhs.macAddress < rhs.macAddress
|
||
? -1
|
||
: 1,
|
||
);
|
||
}
|
||
|
||
private ageAllSwitchSatEntries(): void {
|
||
for (const nodeId of [...this.stateValue.switchForwardingTablesByNodeId.keys()]) {
|
||
this.ageSwitchSatEntries(nodeId);
|
||
}
|
||
}
|
||
|
||
private ageSwitchSatEntries(nodeId: Id): void {
|
||
const state = this.stateValue;
|
||
const retention =
|
||
state.topologySnapshot.switchConfigurationsByNodeId.get(nodeId)?.retentionTimeMilliseconds ??
|
||
DEFAULT_SWITCH_RETENTION_MILLISECONDS;
|
||
const updatedAtByMac = state.switchForwardingUpdatedAtMillisecondsByNodeId.get(nodeId);
|
||
if (updatedAtByMac === undefined) return;
|
||
const expired: string[] = [];
|
||
for (const [macAddress, updatedAt] of updatedAtByMac) {
|
||
if (state.currentTimeMilliseconds >= updatedAt && state.currentTimeMilliseconds - updatedAt >= retention) {
|
||
expired.push(macAddress);
|
||
}
|
||
}
|
||
if (expired.length === 0) return;
|
||
for (const macAddress of expired) {
|
||
state.switchForwardingTablesByNodeId.get(nodeId)?.delete(macAddress);
|
||
state.switchForwardingUpdatedAtMillisecondsByNodeId.get(nodeId)?.delete(macAddress);
|
||
}
|
||
if (state.switchForwardingTablesByNodeId.get(nodeId)?.size === 0) {
|
||
state.switchForwardingTablesByNodeId.delete(nodeId);
|
||
}
|
||
if (state.switchForwardingUpdatedAtMillisecondsByNodeId.get(nodeId)?.size === 0) {
|
||
state.switchForwardingUpdatedAtMillisecondsByNodeId.delete(nodeId);
|
||
}
|
||
}
|
||
|
||
// ── ARP ──────────────────────────────────────────────────────────────────
|
||
|
||
private receiveArpPacket(
|
||
packet: ArpPacket,
|
||
frameIdentity: number,
|
||
nodeId: Id,
|
||
networkInterface: NetworkInterface,
|
||
): void {
|
||
if (packet.senderIPAddress !== UNSPECIFIED_IP_ADDRESS) {
|
||
getOrCreate(this.stateValue.arpCachesByNodeId, nodeId, () => new Map()).set(packet.senderIPAddress, {
|
||
ipAddress: packet.senderIPAddress,
|
||
macAddress: packet.senderMacAddress,
|
||
updatedAtMilliseconds: this.stateValue.currentTimeMilliseconds,
|
||
});
|
||
// Java's waiting thread wakes when the ARP thread is done (Swift, for the comparison: at once).
|
||
this.completeArpLookups(nodeId, packet.senderIPAddress, packet.senderMacAddress, true);
|
||
if (this.arpLookups.some((lookup) => lookup.nodeId === nodeId && lookup.ipAddress === packet.senderIPAddress)) {
|
||
this.schedule(this.stateValue.currentTimeMilliseconds, {
|
||
kind: 'arpAnswered',
|
||
nodeId,
|
||
ipAddress: packet.senderIPAddress,
|
||
macAddress: packet.senderMacAddress,
|
||
});
|
||
}
|
||
this.dhcpClientLearnedAddress(nodeId, packet.senderIPAddress);
|
||
}
|
||
this.recordTrace({
|
||
frameIdentity,
|
||
nodeId,
|
||
interfaceId: networkInterface.portId,
|
||
direction: 'inbound',
|
||
layer: 'network',
|
||
operation: 'accepted',
|
||
beforeHeaders: arpHeaders(packet),
|
||
});
|
||
if (packet.operation !== 'request' || packet.targetIPAddress !== networkInterface.ipAddress) return;
|
||
|
||
const unspecifiedSender = packet.senderIPAddress === UNSPECIFIED_IP_ADDRESS;
|
||
const replyTargetIPAddress = unspecifiedSender ? LIMITED_BROADCAST_IP_ADDRESS : packet.senderIPAddress;
|
||
const replyTargetMacAddress = unspecifiedSender ? ETHERNET_BROADCAST_MAC_ADDRESS : packet.senderMacAddress;
|
||
const reply: ArpPacket = {
|
||
operation: 'reply',
|
||
senderMacAddress: networkInterface.macAddress,
|
||
senderIPAddress: networkInterface.ipAddress,
|
||
targetMacAddress: replyTargetMacAddress,
|
||
targetIPAddress: replyTargetIPAddress,
|
||
};
|
||
const replyFrame: EthernetFrame = {
|
||
identity: this.allocateFrameIdentity(),
|
||
sourceMacAddress: networkInterface.macAddress,
|
||
destinationMacAddress: replyTargetMacAddress,
|
||
payload: { kind: 'arp', packet: reply },
|
||
};
|
||
this.recordTrace({
|
||
frameIdentity: replyFrame.identity,
|
||
nodeId,
|
||
interfaceId: networkInterface.portId,
|
||
direction: 'outbound',
|
||
layer: 'dataLink',
|
||
operation: 'created',
|
||
afterHeaders: ethernetHeaders(replyFrame),
|
||
detail: `ARP reply for ${networkInterface.ipAddress}`,
|
||
});
|
||
this.sendEthernetFrame(nodeId, networkInterface.portId, replyFrame);
|
||
}
|
||
|
||
// ── IPv4 ─────────────────────────────────────────────────────────────────
|
||
|
||
sendIPv4Packet(fromNodeId: Id, packet: IPv4Packet, preferredInterfaceId?: Id): IPv4DeliveryResult {
|
||
return this.sendIPv4PacketWithOptions(fromNodeId, packet, preferredInterfaceId, true, 'sent');
|
||
}
|
||
|
||
updateDeviceConfiguration(nodeId: Id, configuration: DeviceConfiguration): void {
|
||
const state = this.stateValue;
|
||
if (state.phase !== 'running' || !state.topologySnapshot.nodes.some((node) => node.id === nodeId)) return;
|
||
const deviceConfigurations = new Map(state.topologySnapshot.deviceConfigurations);
|
||
deviceConfigurations.set(nodeId, configuration);
|
||
state.topologySnapshot = withSnapshot(state.topologySnapshot, { deviceConfigurations });
|
||
}
|
||
|
||
/** Send one ICMP echo request and wait (virtually) for its answer. */
|
||
sendIcmpEcho(
|
||
fromNodeId: Id,
|
||
targetIPAddress: string,
|
||
options: { timeToLive?: number; identifier?: number; sequenceNumber?: number; data?: Uint8Array } = {},
|
||
): IPv4DeliveryResult {
|
||
const timeToLive = options.timeToLive ?? 64;
|
||
const identifier = options.identifier ?? 1;
|
||
const sequenceNumber = options.sequenceNumber ?? 1;
|
||
const interfaces = this.networkInterfaces(fromNodeId);
|
||
const firstInterface = interfaces[0];
|
||
if (firstInterface === undefined) return { type: 'dropped', packetIdentity: 0, nodeId: fromNodeId };
|
||
const route = this.routeForPacket(fromNodeId, targetIPAddress);
|
||
let sourceInterface: NetworkInterface;
|
||
switch (route.type) {
|
||
case 'local':
|
||
sourceInterface = route.interface ?? firstInterface;
|
||
break;
|
||
case 'egress':
|
||
sourceInterface = route.interface;
|
||
break;
|
||
case 'broadcast':
|
||
case 'unavailable':
|
||
sourceInterface = firstInterface;
|
||
break;
|
||
}
|
||
const packet: IPv4Packet = {
|
||
identity: this.allocatePacketIdentity(),
|
||
senderIPAddress: sourceInterface.ipAddress,
|
||
receiverIPAddress: targetIPAddress,
|
||
timeToLive,
|
||
protocol: 'icmp',
|
||
payload: {
|
||
kind: 'icmp',
|
||
message: icmpMessage('echoRequest', { identifier, sequenceNumber, data: options.data ?? new Uint8Array() }),
|
||
},
|
||
};
|
||
this.recordTrace({
|
||
packetIdentity: packet.identity,
|
||
nodeId: fromNodeId,
|
||
interfaceId: sourceInterface.portId,
|
||
direction: 'outbound',
|
||
layer: 'network',
|
||
operation: 'created',
|
||
afterHeaders: ipv4Headers(packet),
|
||
detail: 'ICMP echo request',
|
||
});
|
||
const observationCount = this.stateValue.icmpObservationsByNodeId.get(fromNodeId)?.length ?? 0;
|
||
const answered = () =>
|
||
(this.stateValue.icmpObservationsByNodeId.get(fromNodeId) ?? [])
|
||
.slice(observationCount)
|
||
.some((observation) => observation.message.identifier === identifier && observation.message.sequenceNumber === sequenceNumber);
|
||
let sent: IPv4DeliveryResult | undefined;
|
||
let sentAt = this.stateValue.currentTimeMilliseconds;
|
||
const handedOn = this.sendIPv4PacketWithOptions(fromNodeId, packet, sourceInterface.portId, true, 'sent', {
|
||
onFinished: (outcome) => {
|
||
sent = outcome;
|
||
sentAt = this.stateValue.currentTimeMilliseconds;
|
||
},
|
||
});
|
||
if (!this.isProcessingScheduledEvents) {
|
||
// Java's sendProbe: the caller waits for ARP (up to two round trips),
|
||
// then a round trip for the answer (ICMPThread.waitForIcmpResponse),
|
||
// counted from when the packet left — the engine may have run past it.
|
||
const rtt = this.runtimeSpeed.roundTripTimeMilliseconds;
|
||
this.advanceUntil(() => sent !== undefined, this.stateValue.currentTimeMilliseconds + (ARP_ATTEMPTS + 1) * rtt);
|
||
if (sentAt + rtt > this.stateValue.currentTimeMilliseconds) this.advanceUntil(answered, sentAt + rtt);
|
||
}
|
||
const result = sent ?? handedOn;
|
||
const newObservations = (this.stateValue.icmpObservationsByNodeId.get(fromNodeId) ?? []).slice(observationCount);
|
||
const observation = newObservations.findLast(
|
||
(candidate) => candidate.message.identifier === identifier && candidate.message.sequenceNumber === sequenceNumber,
|
||
);
|
||
if (observation !== undefined) {
|
||
switch (observation.message.kind) {
|
||
case 'echoReply':
|
||
return { type: 'delivered', packetIdentity: packet.identity, nodeId: observation.nodeId };
|
||
case 'destinationNetworkUnreachable':
|
||
case 'destinationHostUnreachable':
|
||
case 'timeExceeded':
|
||
return {
|
||
type: 'icmpError',
|
||
packetIdentity: packet.identity,
|
||
nodeId: observation.nodeId,
|
||
kind: observation.message.kind,
|
||
};
|
||
case 'echoRequest':
|
||
break;
|
||
}
|
||
}
|
||
return result;
|
||
}
|
||
|
||
/** `traceroute`: echoes with TTL 1, 2, … until one is answered. */
|
||
traceIcmpEcho(
|
||
fromNodeId: Id,
|
||
targetIPAddress: string,
|
||
options: { maximumTimeToLive?: number; identifier?: number } = {},
|
||
): IcmpObservation[] {
|
||
const maximumTimeToLive = options.maximumTimeToLive ?? 64;
|
||
const identifier = options.identifier ?? 1;
|
||
const observations: IcmpObservation[] = [];
|
||
for (let ttl = 1; ttl <= maximumTimeToLive; ttl += 1) {
|
||
const previousCount = this.stateValue.icmpObservationsByNodeId.get(fromNodeId)?.length ?? 0;
|
||
this.sendIcmpEcho(fromNodeId, targetIPAddress, { timeToLive: ttl, identifier, sequenceNumber: ttl });
|
||
const newItems = (this.stateValue.icmpObservationsByNodeId.get(fromNodeId) ?? []).slice(previousCount);
|
||
const observation = newItems.at(-1);
|
||
if (observation === undefined) continue;
|
||
observations.push(observation);
|
||
if (observation.message.kind === 'echoReply') break;
|
||
}
|
||
return observations;
|
||
}
|
||
|
||
/** The nodes a packet passed, in the order it first reached them. */
|
||
tracedNodePath(packetIdentity: number): Id[] {
|
||
const result: Id[] = [];
|
||
for (const trace of this.stateValue.packetTraces) {
|
||
if (trace.packetIdentity !== packetIdentity || result.includes(trace.nodeId)) continue;
|
||
result.push(trace.nodeId);
|
||
}
|
||
return result;
|
||
}
|
||
|
||
private sendIPv4PacketWithOptions(
|
||
fromNodeId: Id,
|
||
packet: IPv4Packet,
|
||
preferredInterfaceId: Id | undefined,
|
||
allowIcmpErrorGeneration: boolean,
|
||
operation: TraceOperation,
|
||
callbacks: IPv4SendCallbacks = {},
|
||
): IPv4DeliveryResult {
|
||
const route = this.routeForPacket(fromNodeId, packet.receiverIPAddress, preferredInterfaceId);
|
||
if (route.type === 'egress') return this.sendIPv4ToNextHop(fromNodeId, packet, route, allowIcmpErrorGeneration, operation, callbacks);
|
||
const result = this.sendIPv4Directly(fromNodeId, packet, route, preferredInterfaceId, allowIcmpErrorGeneration, operation);
|
||
callbacks.onFinished?.(result);
|
||
return result;
|
||
}
|
||
|
||
/**
|
||
* To the next hop, once ARP knows it; until then the packet waits for its
|
||
* lookup and the result says it was handed on. When ARP finds no one: host
|
||
* unreachable — observed at the sender itself, or sent back by the router
|
||
* (onUnresolved).
|
||
*/
|
||
private sendIPv4ToNextHop(
|
||
fromNodeId: Id,
|
||
packet: IPv4Packet,
|
||
route: { readonly interface: NetworkInterface; readonly nextHop: string },
|
||
allowIcmpErrorGeneration: boolean,
|
||
operation: TraceOperation,
|
||
callbacks: IPv4SendCallbacks,
|
||
): IPv4DeliveryResult {
|
||
let finished: IPv4DeliveryResult | undefined;
|
||
this.lookUpMacAddress(fromNodeId, route.nextHop, route.interface, ARP_ATTEMPTS, (macAddress) => {
|
||
if (macAddress !== undefined) {
|
||
this.transmitIPv4Packet(fromNodeId, packet, route.interface, macAddress, operation);
|
||
finished = { type: 'delivered', packetIdentity: packet.identity, nodeId: fromNodeId };
|
||
} else {
|
||
this.recordTrace({
|
||
packetIdentity: packet.identity,
|
||
nodeId: fromNodeId,
|
||
interfaceId: route.interface.portId,
|
||
direction: 'outbound',
|
||
layer: 'network',
|
||
operation: 'dropped',
|
||
beforeHeaders: ipv4Headers(packet),
|
||
detail: `no ARP answer for ${route.nextHop}`,
|
||
});
|
||
if (allowIcmpErrorGeneration) {
|
||
this.observeLocalIcmpError(fromNodeId, route.interface.portId, 'destinationHostUnreachable', packet);
|
||
finished = { type: 'icmpError', packetIdentity: packet.identity, nodeId: fromNodeId, kind: 'destinationHostUnreachable' };
|
||
} else {
|
||
callbacks.onUnresolved?.();
|
||
finished = { type: 'dropped', packetIdentity: packet.identity, nodeId: fromNodeId };
|
||
}
|
||
}
|
||
callbacks.onFinished?.(finished);
|
||
});
|
||
if (finished === undefined && this.swiftArp && this.isProcessingScheduledEvents) {
|
||
this.recordTrace({
|
||
packetIdentity: packet.identity,
|
||
nodeId: fromNodeId,
|
||
interfaceId: route.interface.portId,
|
||
direction: 'outbound',
|
||
layer: 'network',
|
||
operation: 'created',
|
||
beforeHeaders: ipv4Headers(packet),
|
||
detail: `waiting for ARP resolution of ${route.nextHop}`,
|
||
});
|
||
}
|
||
return finished ?? { type: 'delivered', packetIdentity: packet.identity, nodeId: fromNodeId };
|
||
}
|
||
|
||
private sendIPv4Directly(
|
||
fromNodeId: Id,
|
||
packet: IPv4Packet,
|
||
route: PacketRoute,
|
||
preferredInterfaceId: Id | undefined,
|
||
allowIcmpErrorGeneration: boolean,
|
||
operation: TraceOperation,
|
||
): IPv4DeliveryResult {
|
||
switch (route.type) {
|
||
case 'local': {
|
||
const networkInterface = route.interface ?? this.networkInterfaces(fromNodeId)[0];
|
||
if (networkInterface === undefined) return { type: 'dropped', packetIdentity: packet.identity, nodeId: fromNodeId };
|
||
this.receiveIPv4Packet(packet, undefined, fromNodeId, networkInterface, false);
|
||
return { type: 'delivered', packetIdentity: packet.identity, nodeId: fromNodeId };
|
||
}
|
||
|
||
case 'broadcast': {
|
||
const interfaces = this.networkInterfaces(fromNodeId);
|
||
if (interfaces.length === 0) return { type: 'dropped', packetIdentity: packet.identity, nodeId: fromNodeId };
|
||
const broadcastPacket: IPv4Packet = { ...packet, timeToLive: 1 };
|
||
for (const networkInterface of interfaces) {
|
||
const frame: EthernetFrame = {
|
||
identity: this.allocateFrameIdentity(),
|
||
sourceMacAddress: networkInterface.macAddress,
|
||
destinationMacAddress: ETHERNET_BROADCAST_MAC_ADDRESS,
|
||
payload: { kind: 'ipv4', packet: broadcastPacket },
|
||
};
|
||
this.recordIPv4Send(broadcastPacket, frame, fromNodeId, networkInterface.portId, operation);
|
||
this.sendEthernetFrame(fromNodeId, networkInterface.portId, frame);
|
||
}
|
||
return { type: 'delivered', packetIdentity: packet.identity, nodeId: fromNodeId };
|
||
}
|
||
|
||
case 'egress':
|
||
return this.sendIPv4ToNextHop(fromNodeId, packet, route, allowIcmpErrorGeneration, operation, {});
|
||
|
||
case 'unavailable':
|
||
if (allowIcmpErrorGeneration) {
|
||
this.observeLocalIcmpError(fromNodeId, preferredInterfaceId, 'destinationNetworkUnreachable', packet);
|
||
return {
|
||
type: 'icmpError',
|
||
packetIdentity: packet.identity,
|
||
nodeId: fromNodeId,
|
||
kind: 'destinationNetworkUnreachable',
|
||
};
|
||
}
|
||
return { type: 'dropped', packetIdentity: packet.identity, nodeId: fromNodeId };
|
||
}
|
||
}
|
||
|
||
private transmitIPv4Packet(
|
||
fromNodeId: Id,
|
||
packet: IPv4Packet,
|
||
networkInterface: NetworkInterface,
|
||
destinationMacAddress: string,
|
||
operation: TraceOperation,
|
||
): void {
|
||
const frame: EthernetFrame = {
|
||
identity: this.allocateFrameIdentity(),
|
||
sourceMacAddress: networkInterface.macAddress,
|
||
destinationMacAddress,
|
||
payload: { kind: 'ipv4', packet },
|
||
};
|
||
this.recordIPv4Send(packet, frame, fromNodeId, networkInterface.portId, operation);
|
||
this.sendEthernetFrame(fromNodeId, networkInterface.portId, frame);
|
||
}
|
||
|
||
/**
|
||
* A packet that came in on a card goes to the node's IP or ICMP thread
|
||
* (`throughThread`); one the node sent to itself is delivered at once, as
|
||
* Java's IP.senden does.
|
||
*/
|
||
private receiveIPv4Packet(
|
||
packet: IPv4Packet,
|
||
frameIdentity: number | undefined,
|
||
nodeId: Id,
|
||
networkInterface: NetworkInterface,
|
||
throughThread: boolean,
|
||
): 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;
|
||
|
||
if (node.kind === 'gateway' && throughThread) {
|
||
const translated = this.translateAtGateway(packet, frameIdentity, nodeId, networkInterface);
|
||
if (translated === undefined) return;
|
||
packet = translated;
|
||
}
|
||
if (node.kind === 'router') {
|
||
const decision = this.evaluateFirewall(packet, nodeId, networkInterface.portId, frameIdentity);
|
||
if (!decision.accepted) return;
|
||
}
|
||
if (throughThread) {
|
||
this.handToProtocolThread(nodeId, packet.protocol === 'icmp' ? 'icmp' : 'ip', undefined, (finished) =>
|
||
this.processIPv4Packet(packet, frameIdentity, nodeId, networkInterface, finished),
|
||
);
|
||
} else {
|
||
this.processIPv4Packet(packet, frameIdentity, nodeId, networkInterface, () => {});
|
||
}
|
||
}
|
||
|
||
/**
|
||
* What Java's IPThread and ICMPThread do with a packet: deliver it, answer
|
||
* an echo request, or forward it. `finished` when the thread is free again:
|
||
* at once, or when the answer or the forwarded packet has left (or ARP gave
|
||
* up on it).
|
||
*/
|
||
private processIPv4Packet(
|
||
packet: IPv4Packet,
|
||
frameIdentity: number | undefined,
|
||
nodeId: Id,
|
||
networkInterface: NetworkInterface,
|
||
finished: () => void,
|
||
): void {
|
||
const node = this.nodeSnapshot(nodeId)!;
|
||
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) {
|
||
finished();
|
||
return;
|
||
}
|
||
}
|
||
if (!this.deliverIPv4Locally(packetForProcessing, nodeId, networkInterface, finished)) finished();
|
||
return;
|
||
}
|
||
// Java: routers and home routers forward (IpForwardingEnabled), nothing else.
|
||
if (node.kind !== 'router' && node.kind !== 'gateway') {
|
||
this.recordTrace({
|
||
frameIdentity,
|
||
packetIdentity: packetForProcessing.identity,
|
||
nodeId,
|
||
interfaceId: networkInterface.portId,
|
||
direction: 'inbound',
|
||
layer: 'network',
|
||
operation: 'dropped',
|
||
beforeHeaders: ipv4Headers(packetForProcessing),
|
||
detail: 'IPv4 forwarding disabled',
|
||
});
|
||
finished();
|
||
return;
|
||
}
|
||
if (packetForProcessing.timeToLive <= 1) {
|
||
this.emitIcmpError(nodeId, networkInterface, 'timeExceeded', packet);
|
||
finished();
|
||
return;
|
||
}
|
||
|
||
const forwarded = forwardingClone(packetForProcessing);
|
||
this.recordTrace({
|
||
frameIdentity,
|
||
packetIdentity: forwarded.identity,
|
||
nodeId,
|
||
interfaceId: networkInterface.portId,
|
||
direction: 'outbound',
|
||
layer: 'network',
|
||
operation: 'forwarded',
|
||
beforeHeaders: ipv4Headers(packetForProcessing),
|
||
afterHeaders: ipv4Headers(forwarded),
|
||
detail: 'TTL decremented',
|
||
});
|
||
// Java: a router whose ARP finds no one answers host unreachable; for
|
||
// ICMP only an echo request (ICMP.sendUnicastToNextHop).
|
||
const echoOrNotIcmp = packet.payload.kind !== 'icmp' || packet.payload.message.kind === 'echoRequest';
|
||
const result = this.sendIPv4PacketWithOptions(nodeId, forwarded, undefined, false, 'forwarded', {
|
||
onUnresolved: () => {
|
||
if (echoOrNotIcmp) this.emitIcmpError(nodeId, networkInterface, 'destinationHostUnreachable', packet);
|
||
},
|
||
onFinished: finished,
|
||
});
|
||
if (result.type === 'dropped' && this.routeForPacket(nodeId, packetForProcessing.receiverIPAddress).type === 'unavailable') {
|
||
this.emitIcmpError(nodeId, networkInterface, 'destinationNetworkUnreachable', packet);
|
||
}
|
||
}
|
||
|
||
/** Delivers the packet; true when an answer goes out, which calls `finished` once it has left. */
|
||
private deliverIPv4Locally(packet: IPv4Packet, nodeId: Id, networkInterface: NetworkInterface, finished: () => void): boolean {
|
||
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 false;
|
||
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', { onFinished: finished });
|
||
return true;
|
||
}
|
||
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 false;
|
||
}
|
||
return false;
|
||
}
|
||
case 'udp':
|
||
getOrCreate(this.stateValue.deliveredIPv4PacketsByNodeId, nodeId, () => []).push({
|
||
nodeId,
|
||
interfaceId: networkInterface.portId,
|
||
packet,
|
||
});
|
||
this.deliverUdp(packet, payload.datagram, nodeId, networkInterface);
|
||
return false;
|
||
case 'tcp':
|
||
getOrCreate(this.stateValue.deliveredIPv4PacketsByNodeId, nodeId, () => []).push({
|
||
nodeId,
|
||
interfaceId: networkInterface.portId,
|
||
packet,
|
||
});
|
||
this.deliverTcp(packet, payload.segment, nodeId, networkInterface);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
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)) return this.ripRouteForPacket(nodeId, targetIPAddress);
|
||
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.
|
||
*/
|
||
/** DNSServer.answerWithLocalData: the records for the question, an MX's or NS's with its address. */
|
||
function localDnsAnswers(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);
|
||
}
|
||
}
|
||
return answers;
|
||
}
|
||
|
||
/** DNSServer.defineNameserverRecords: the NS record of the question's zone (or the root's) and its address. */
|
||
function dnsReferral(question: DnsQuestion, records: readonly DnsMessageRecord[]): DnsMessageRecord[] {
|
||
const nameServer = findApplicableNameServerRecord(question.name, records);
|
||
if (nameServer === undefined) return [];
|
||
const address = findDnsRecords(nameServer.data, 'A', records)[0];
|
||
return address === undefined ? [nameServer] : [nameServer, address];
|
||
}
|
||
|
||
function portKey(nodeId: Id, portId: Id): string {
|
||
return `${nodeId}|${portId}`;
|
||
}
|
||
|
||
function bestInterface(targetIPAddress: string, interfaces: readonly NetworkInterface[]): NetworkInterface | undefined {
|
||
let selected: NetworkInterface | undefined;
|
||
let selectedMask: number | undefined;
|
||
for (const candidate of interfaces) {
|
||
const mask = parseIPv4(candidate.subnetMask);
|
||
if (!sameSubnet(candidate.ipAddress, targetIPAddress, candidate.subnetMask) || mask === undefined) continue;
|
||
if (selectedMask !== undefined && mask <= selectedMask) continue;
|
||
selected = candidate;
|
||
selectedMask = mask;
|
||
}
|
||
return selected;
|
||
}
|
||
|
||
/** An ICMP error about an ICMP error carries the packet the first one was about. */
|
||
function embeddedOriginal(packet: IPv4Packet): IPv4Packet {
|
||
if (packet.payload.kind === 'icmp' && packet.payload.message.embeddedOriginalPacket !== undefined) {
|
||
return packet.payload.message.embeddedOriginalPacket;
|
||
}
|
||
return packet;
|
||
}
|
||
|
||
function icmpIdentifier(packet: IPv4Packet): number {
|
||
return packet.payload.kind === 'icmp' ? packet.payload.message.identifier : 0;
|
||
}
|
||
|
||
function icmpSequenceNumber(packet: IPv4Packet): number {
|
||
return packet.payload.kind === 'icmp' ? packet.payload.message.sequenceNumber : 0;
|
||
}
|
||
|
||
export function ethernetHeaders(frame: EthernetFrame): HeaderField[] {
|
||
return [
|
||
{ name: 'sourceMAC', value: frame.sourceMacAddress },
|
||
{ name: 'destinationMAC', value: frame.destinationMacAddress },
|
||
];
|
||
}
|
||
|
||
export function arpHeaders(packet: ArpPacket): HeaderField[] {
|
||
return [
|
||
{ name: 'operation', value: packet.operation },
|
||
{ name: 'senderMAC', value: packet.senderMacAddress },
|
||
{ name: 'senderIP', value: packet.senderIPAddress },
|
||
{ name: 'targetMAC', value: packet.targetMacAddress },
|
||
{ name: 'targetIP', value: packet.targetIPAddress },
|
||
];
|
||
}
|
||
|
||
export function ipv4Headers(packet: IPv4Packet): HeaderField[] {
|
||
return [
|
||
{ name: 'senderIP', value: packet.senderIPAddress },
|
||
{ name: 'receiverIP', value: packet.receiverIPAddress },
|
||
{ name: 'ttl', value: String(packet.timeToLive) },
|
||
{ name: 'protocol', value: String(IPV4_PROTOCOL[packet.protocol]) },
|
||
];
|
||
}
|
||
|
||
function dhcpClientStatus(): DhcpClientStatus {
|
||
return {
|
||
state: 'INIT',
|
||
errorCount: 0,
|
||
selectedServerIPAddress: undefined,
|
||
offeredIPAddress: undefined,
|
||
succeeded: false,
|
||
linkLocal: false,
|
||
};
|
||
}
|
||
|
||
function dhcpRecordKey(serverNodeId: Id, ipAddress: string): string {
|
||
return `${serverNodeId.toLowerCase()}|${ipAddress.toLowerCase()}`;
|
||
}
|
||
|
||
function ipv4String(address: number): string {
|
||
return [24, 16, 8, 0].map((shift) => String((address >>> shift) & 0xff)).join('.');
|
||
}
|
||
|
||
/** Swift's encodeDHCPMessage: the type, then `key=value` lines for the fields that are set. */
|
||
export function encodeDhcpMessage(message: DhcpMessage): Uint8Array {
|
||
const lines = [message.type, `yiaddr=${message.yourIPAddress}`];
|
||
const add = (key: string, value: string | undefined) => {
|
||
if (value !== undefined && value !== '') lines.push(`${key}=${value}`);
|
||
};
|
||
add('chaddr', message.clientMacAddress);
|
||
add('router', message.routerIPAddress);
|
||
add('subnetmask', message.subnetMask);
|
||
add('dnsserver', message.dnsServerIPAddress);
|
||
add('requested', message.requestedIPAddress);
|
||
add('serverident', message.serverIdentifier);
|
||
return new TextEncoder().encode(lines.join('\n'));
|
||
}
|
||
|
||
/** Swift's decodeDHCPMessage: undefined for text that is not UTF-8 or of no known type. */
|
||
export function decodeDhcpMessage(data: Uint8Array): DhcpMessage | undefined {
|
||
let text: string;
|
||
try {
|
||
text = new TextDecoder('utf-8', { fatal: true }).decode(data);
|
||
} catch {
|
||
return undefined;
|
||
}
|
||
const [command, ...fields] = text.split('\n');
|
||
const type = DHCP_MESSAGE_TYPES.find((each) => sameIgnoringCase(each, command));
|
||
if (type === undefined) return undefined;
|
||
const message: DhcpMessage = { type, yourIPAddress: UNSPECIFIED_IP_ADDRESS };
|
||
for (const line of fields) {
|
||
const separator = line.indexOf('=');
|
||
if (separator < 0) continue;
|
||
const value = line.slice(separator + 1);
|
||
switch (line.slice(0, separator)) {
|
||
case 'yiaddr':
|
||
message.yourIPAddress = value;
|
||
break;
|
||
case 'chaddr':
|
||
message.clientMacAddress = value;
|
||
break;
|
||
case 'router':
|
||
message.routerIPAddress = value;
|
||
break;
|
||
case 'subnetmask':
|
||
message.subnetMask = value;
|
||
break;
|
||
case 'dnsserver':
|
||
message.dnsServerIPAddress = value;
|
||
break;
|
||
case 'requested':
|
||
message.requestedIPAddress = value;
|
||
break;
|
||
case 'serverident':
|
||
message.serverIdentifier = value;
|
||
break;
|
||
}
|
||
}
|
||
return message;
|
||
}
|
||
|
||
|
||
function tcpSegmentRequiresAcknowledgement(segment: TcpSegment): boolean {
|
||
return (
|
||
(segment.flags & (TCP_FLAG.synchronize | TCP_FLAG.finish | TCP_FLAG.push)) !== 0 || segment.payload.length > 0
|
||
);
|
||
}
|
||
|
||
/** The sequence number after the segment: SYN and FIN count one, and each payload byte. */
|
||
export function nextTcpSequenceNumber(segment: TcpSegment): number {
|
||
let next = segment.sequenceNumber;
|
||
if ((segment.flags & (TCP_FLAG.synchronize | TCP_FLAG.finish)) !== 0) next = uint32Add(next, 1);
|
||
return uint32Add(next, segment.payload.length);
|
||
}
|
||
|
||
function concatBytes(first: Uint8Array, second: Uint8Array): Uint8Array {
|
||
const result = new Uint8Array(first.length + second.length);
|
||
result.set(first);
|
||
result.set(second, first.length);
|
||
return result;
|
||
}
|
||
|
||
export function tcpFlagDescription(flags: number): string {
|
||
const names: string[] = [];
|
||
if ((flags & TCP_FLAG.finish) !== 0) names.push('FIN');
|
||
if ((flags & TCP_FLAG.synchronize) !== 0) names.push('SYN');
|
||
if ((flags & TCP_FLAG.reset) !== 0) names.push('RST');
|
||
if ((flags & TCP_FLAG.push) !== 0) names.push('PSH');
|
||
if ((flags & TCP_FLAG.acknowledgement) !== 0) names.push('ACK');
|
||
return names.join('+');
|
||
}
|
||
|
||
export function tcpHeaders(segment: TcpSegment): HeaderField[] {
|
||
return [
|
||
{ name: 'sourcePort', value: String(segment.sourcePort) },
|
||
{ name: 'destinationPort', value: String(segment.destinationPort) },
|
||
{ name: 'sequenceNumber', value: String(segment.sequenceNumber) },
|
||
{ name: 'acknowledgementNumber', value: String(segment.acknowledgementNumber) },
|
||
{ name: 'flags', value: tcpFlagDescription(segment.flags) },
|
||
{ name: 'payloadLength', value: String(segment.payload.length) },
|
||
];
|
||
}
|
||
|
||
export function udpHeaders(datagram: UdpDatagram): HeaderField[] {
|
||
return [
|
||
{ name: 'sourcePort', value: String(datagram.sourcePort) },
|
||
{ name: 'destinationPort', value: String(datagram.destinationPort) },
|
||
{ name: 'payloadLength', value: String(datagram.payload.length) },
|
||
];
|
||
}
|
||
|
||
export function icmpHeaders(message: IcmpMessage): HeaderField[] {
|
||
return [
|
||
{ name: 'kind', value: message.kind },
|
||
{ name: 'identifier', value: String(message.identifier) },
|
||
{ name: 'sequence', value: String(message.sequenceNumber) },
|
||
];
|
||
}
|