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

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

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

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

1319 lines
59 KiB
TypeScript

// Ported from filius-ipad ios/FiliusPadTests/TopologyNetworkRuntimeEngineTests.swift:
// the event queue, the virtual clock, Java-compatible randomness, restart,
// Ethernet/switch/ARP/IPv4/ICMP, routing errors, the firewall, packet loss
// and trace retention, UDP and TCP sockets, DHCP. The remaining Swift tests
// (DNS, RIP, NAT, remote link, packet viewer) follow with their protocol
// slices.
import { describe, expect, it } from 'vitest';
import {
decodeDhcpMessage,
DHCP_DYNAMIC_LEASE_LIFETIME_MILLISECONDS,
deviceConfiguration,
dhcpServerConfiguration,
encodeDhcpMessage,
EMPTY_TOPOLOGY_SNAPSHOT,
ETHERNET_BROADCAST_MAC_ADDRESS,
firewallConfiguration,
firewallRule,
DIRECTLY_CONNECTED_SOURCE_MARKER,
forwardingClone,
icmpMessage,
interfaceKey,
JavaCompatibleRandom,
NetworkRuntimeEngine,
portSnapshot,
stableMacAddress,
switchConfiguration,
TCP_FLAG,
tcpFlagDescription,
topologySnapshot,
traceRetentionPolicy,
UNSPECIFIED_MAC_ADDRESS,
type DeviceConfiguration,
type DhcpServerConfiguration,
type EthernetFrame,
type EthernetPayload,
type FirewallConfiguration,
type IPv4Packet,
type NodeSnapshot,
type RuntimeOutput,
type ScheduledEventKind,
} from '../src/index.js';
import { device, link, pc, startedEngine, uuid } from './fixtures.js';
const marker = (label: string): ScheduledEventKind => ({ kind: 'parityMarker', label });
function firedKinds(outputs: RuntimeOutput[]): ScheduledEventKind[] {
return outputs.flatMap((output) => (output.kind.type === 'fired' ? [output.kind.event.kind] : []));
}
function routedTopology() {
const sourceNodeId = uuid(0x1451);
const sourcePortId = uuid(0x1461);
const routerNodeId = uuid(0x1452);
const routerPortA = uuid(0x1462);
const routerPortB = uuid(0x1463);
const targetNodeId = uuid(0x1453);
const targetPortId = uuid(0x1464);
const router: NodeSnapshot = {
id: routerNodeId,
kind: 'router',
ports: [portSnapshot(routerPortA, 'rt1'), portSnapshot(routerPortB, 'rt2')],
};
const snapshot = topologySnapshot({
nodes: [pc(sourceNodeId, sourcePortId), router, pc(targetNodeId, targetPortId)],
links: [
link(sourceNodeId, sourcePortId, routerNodeId, routerPortA),
link(routerNodeId, routerPortB, targetNodeId, targetPortId),
],
deviceConfigurations: new Map([
[sourceNodeId, device('10.0.0.2', { gateway: '10.0.0.1' })],
[routerNodeId, device('10.0.0.1')],
[targetNodeId, device('20.0.0.2', { gateway: '20.0.0.1' })],
]),
interfaceConfigurations: new Map([
[interfaceKey(routerNodeId, routerPortA), { ipAddress: '10.0.0.1', subnetMask: '255.255.255.0' }],
[interfaceKey(routerNodeId, routerPortB), { ipAddress: '20.0.0.1', subnetMask: '255.255.255.0' }],
]),
});
const engine = new NetworkRuntimeEngine(14);
engine.handle({ type: 'start', snapshot, seed: 14, initialTimeMilliseconds: 0 });
return { engine, sourceNodeId, routerNodeId };
}
function firewallEngine(configuration: FirewallConfiguration) {
const routerId = uuid(0x1802);
const portId = uuid(0x1812);
const engine = startedEngine(
{
nodes: [{ id: routerId, kind: 'router', ports: [portSnapshot(portId, 'rt1')] }],
links: [],
interfaceConfigurations: new Map([
[interfaceKey(routerId, portId), { ipAddress: '10.0.0.1', subnetMask: '255.255.255.0' }],
]),
firewallConfigurationsByNodeId: new Map([[routerId, configuration]]),
},
18,
);
return { engine, routerId };
}
function tcpPacket(identity: number, destinationPort: number, flags: number): IPv4Packet {
return {
identity,
senderIPAddress: '203.0.113.10',
receiverIPAddress: '10.0.0.1',
timeToLive: 64,
protocol: 'tcp',
payload: {
kind: 'tcp',
segment: {
sourcePort: 50_000,
destinationPort,
sequenceNumber: identity,
acknowledgementNumber: 0,
flags,
payload: new Uint8Array(),
},
},
};
}
describe('event queue and virtual clock', () => {
it('orders events by deadline, then by insertion sequence', () => {
const engine = new NetworkRuntimeEngine(42);
engine.handle({ type: 'start', snapshot: EMPTY_TOPOLOGY_SNAPSHOT, seed: 42, initialTimeMilliseconds: 0 });
engine.handle({ type: 'schedule', deadlineMilliseconds: 100, kind: marker('first-at-100') });
engine.handle({ type: 'schedule', deadlineMilliseconds: 50, kind: marker('at-50') });
engine.handle({ type: 'schedule', deadlineMilliseconds: 100, kind: marker('second-at-100') });
const outputs = engine.handle({ type: 'advance', toMilliseconds: 100 });
expect(firedKinds(outputs)).toEqual([marker('at-50'), marker('first-at-100'), marker('second-at-100')]);
expect(engine.state.currentTimeMilliseconds).toBe(100);
expect(engine.state.pendingEvents).toEqual([]);
});
it('rejects a past time without changing the clock', () => {
const engine = new NetworkRuntimeEngine(7);
engine.handle({ type: 'start', snapshot: EMPTY_TOPOLOGY_SNAPSHOT, seed: 7, initialTimeMilliseconds: 10 });
engine.handle({ type: 'advance', toMilliseconds: 25 });
const outputs = engine.handle({ type: 'advance', toMilliseconds: 24 });
expect(outputs.map((output) => output.kind)).toEqual([{ type: 'advanceRejectedPastTime' }]);
expect(engine.state.currentTimeMilliseconds).toBe(25);
});
it('rejects a past deadline without using up a sequence number', () => {
const engine = new NetworkRuntimeEngine(8);
engine.handle({ type: 'start', snapshot: EMPTY_TOPOLOGY_SNAPSHOT, seed: 8, initialTimeMilliseconds: 25 });
const outputs = engine.handle({ type: 'schedule', deadlineMilliseconds: 24, kind: marker('past') });
expect(outputs.map((output) => output.kind)).toEqual([
{ type: 'scheduleRejectedPastTime', deadlineMilliseconds: 24 },
]);
expect(engine.state.pendingEvents).toEqual([]);
expect(engine.state.nextEventSequenceNumber).toBe(0);
expect(engine.state.currentTimeMilliseconds).toBe(25);
});
it('replays a named scenario straight to a stable state', () => {
const finalState = NetworkRuntimeEngine.run({
name: 'equal-deadline-order',
seed: 1,
advanceToMilliseconds: 1_000,
scheduledEvents: [
{ deadlineMilliseconds: 500, kind: marker('a') },
{ deadlineMilliseconds: 500, kind: marker('b') },
],
});
expect(finalState.currentTimeMilliseconds).toBe(1_000);
expect(finalState.pendingEvents).toEqual([]);
expect(finalState.seed).toBe(1n);
});
it('clears transient state on restart and numbers packets from one again', () => {
const nodeId = uuid(0x12);
const engine = new NetworkRuntimeEngine(12);
engine.handle({ type: 'start', snapshot: EMPTY_TOPOLOGY_SNAPSHOT, seed: 12, initialTimeMilliseconds: 0 });
engine.handle({ type: 'schedule', deadlineMilliseconds: 40, kind: marker('stale') });
expect(engine.allocateFrameIdentity()).toBe(1);
expect(engine.allocatePacketIdentity()).toBe(1);
engine.recordTrace({ packetIdentity: 1, nodeId, direction: 'local', layer: 'application', operation: 'created' });
engine.handle({ type: 'stop' });
engine.handle({ type: 'start', snapshot: EMPTY_TOPOLOGY_SNAPSHOT, seed: 12, initialTimeMilliseconds: 0 });
const state = engine.state;
expect(state.pendingEvents).toEqual([]);
expect(state.packetTraces).toEqual([]);
expect(state.arpCachesByNodeId.size).toBe(0);
expect(state.switchForwardingTablesByNodeId.size).toBe(0);
expect(state.switchSeenFrameIdentitiesByNodeId.size).toBe(0);
expect(state.deliveredIPv4PacketsByNodeId.size).toBe(0);
expect(state.icmpObservationsByNodeId.size).toBe(0);
expect(state.firewallDecisions).toEqual([]);
expect(engine.allocateFrameIdentity()).toBe(1);
expect(engine.allocatePacketIdentity()).toBe(1);
});
it('forgets UDP sockets on stop, binds none while stopped, and numbers them from one again', () => {
const nodeId = uuid(0x13);
const portId = uuid(0x14);
const input = { nodes: [pc(nodeId, portId)], links: [], deviceConfigurations: new Map([[nodeId, device('10.2.0.1')]]) };
const engine = startedEngine(input);
expect(engine.bindUdpSocket(nodeId, { localPort: 7 })).toBe(uuid(1));
expect(engine.bindUdpSocket(nodeId)).toBe(uuid(2));
engine.handle({ type: 'stop' });
expect(engine.state.socketsById.size).toBe(0);
expect(engine.state.udpReceiveQueuesBySocketId.size).toBe(0);
expect(engine.bindUdpSocket(nodeId, { localPort: 7 })).toBeUndefined();
engine.handle({ type: 'start', snapshot: topologySnapshot(input), seed: 14, initialTimeMilliseconds: 0 });
expect(engine.bindUdpSocket(nodeId, { localPort: 7 })).toBe(uuid(1));
});
it('forgets DHCP leases and client state on stop', () => {
const [serverId, serverPortId, clientId, clientPortId] = [0x31, 0x32, 0x33, 0x34].map(uuid) as [string, string, string, string];
const engine = startedEngine({
nodes: [pc(serverId, serverPortId), pc(clientId, clientPortId)],
links: [link(serverId, serverPortId, clientId, clientPortId)],
deviceConfigurations: new Map([
[serverId, device('10.4.0.1')],
[clientId, device('0.0.0.0')],
]),
dhcpClientConfigurationsByNodeId: new Map([[clientId, { isEnabled: true }]]),
dhcpServerConfigurationsByNodeId: new Map([
[serverId, dhcpServerConfiguration({ isActive: true, lowerBoundIPAddress: '10.4.0.20', upperBoundIPAddress: '10.4.0.29' })],
]),
});
engine.handle({ type: 'advance', toMilliseconds: 5_000 });
expect(engine.state.dhcpLeasesByIPAddress.size).toBe(1);
engine.handle({ type: 'stop' });
const state = engine.state;
expect([
state.dhcpOffersByIPAddress.size,
state.dhcpLeasesByIPAddress.size,
state.dhcpBlacklistByServerNodeId.size,
state.dhcpLastOfferedAddressByServerNodeId.size,
state.dhcpServerSocketIdsByNodeId.size,
state.dhcpClientStatusesByNodeId.size,
state.dhcpClientContextsByNodeId.size,
]).toEqual([0, 0, 0, 0, 0, 0, 0]);
});
it('forgets TCP sessions and accepted sockets on stop', () => {
const [clientId, clientPortId, serverId, serverPortId] = [0x21, 0x22, 0x23, 0x24].map(uuid) as [string, string, string, string];
const engine = startedEngine({
nodes: [pc(clientId, clientPortId), pc(serverId, serverPortId)],
links: [link(clientId, clientPortId, serverId, serverPortId)],
deviceConfigurations: new Map([
[clientId, device('10.3.0.1')],
[serverId, device('10.3.0.2')],
]),
});
engine.openTcpServerSocket(serverId, 80);
expect(engine.connectTcp(engine.openTcpClientSocket(clientId, '10.3.0.2', 80)!)).toBe(true);
expect(engine.state.tcpSessionsById.size).toBe(3);
engine.handle({ type: 'stop' });
expect(engine.state.tcpSessionsById.size).toBe(0);
expect(engine.state.tcpAcceptedSocketIdsByListenerId.size).toBe(0);
});
});
describe('UDP sockets', () => {
const text = (payload: Uint8Array | undefined) => new TextDecoder().decode(payload ?? new Uint8Array());
it('binds, delivers and releases a port', () => {
const [sourceNodeId, sourcePortId, targetNodeId, targetPortId] = [0x15b1, 0x15b2, 0x15b3, 0x15b4].map(uuid) as [string, string, string, string];
const engine = startedEngine({
nodes: [pc(sourceNodeId, sourcePortId), pc(targetNodeId, targetPortId)],
links: [link(sourceNodeId, sourcePortId, targetNodeId, targetPortId)],
deviceConfigurations: new Map([
[sourceNodeId, device('192.168.5.10')],
[targetNodeId, device('192.168.5.20')],
]),
});
const automatic = engine.bindUdpSocket(targetNodeId);
const automaticPort = automatic === undefined ? undefined : engine.state.socketsById.get(automatic)?.localPort;
expect(automaticPort).toBeGreaterThanOrEqual(49_152);
expect(automaticPort).toBeLessThan(65_535);
const receiver = engine.bindUdpSocket(targetNodeId, { localPort: 9000 });
const sender = engine.bindUdpSocket(sourceNodeId, { localPort: 8000, remoteIPAddress: '192.168.5.20', remotePort: 9000 });
expect(receiver).toBeDefined();
expect(sender).toBeDefined();
expect(engine.bindUdpSocket(targetNodeId, { localPort: 9000 })).toBeUndefined();
engine.sendUdp(sender!, new TextEncoder().encode('hello'));
const received = engine.receiveUdp(receiver!);
expect(text(received?.datagram.payload)).toBe('hello');
expect(received?.senderIPAddress).toBe('192.168.5.10');
engine.closeSocket(receiver!);
expect(engine.bindUdpSocket(targetNodeId, { localPort: 9000 })).toBeDefined();
});
it('draws ephemeral ports without disturbing the seeded RIP jitter', () => {
const nodeId = uuid(0x15f1);
const input = { nodes: [pc(nodeId, uuid(0x15f2))], links: [], deviceConfigurations: new Map([[nodeId, device('192.168.10.10')]]) };
const socketEngine = startedEngine(input);
const ripOnlyEngine = startedEngine(input);
expect(socketEngine.bindUdpSocket(nodeId)).toBeDefined();
expect(socketEngine.nextRipJitterMilliseconds()).toBe(ripOnlyEngine.nextRipJitterMilliseconds());
});
it('broadcasts on every interface, and the receiver replies to its last peer', () => {
const [routerId, routerPortA, routerPortB, pcAId, pcAPort, pcBId, pcBPort] = [0x15e1, 0x15e2, 0x15e3, 0x15e4, 0x15e5, 0x15e6, 0x15e7].map(
uuid,
) as [string, string, string, string, string, string, string];
const engine = startedEngine(
{
nodes: [
{ id: routerId, kind: 'router', ports: [portSnapshot(routerPortA, 'rt1'), portSnapshot(routerPortB, 'rt2')] },
pc(pcAId, pcAPort),
pc(pcBId, pcBPort),
],
links: [link(routerId, routerPortA, pcAId, pcAPort), link(routerId, routerPortB, pcBId, pcBPort)],
deviceConfigurations: new Map([
[pcAId, device('10.0.0.2')],
[pcBId, device('20.0.0.2')],
]),
interfaceConfigurations: new Map([
[interfaceKey(routerId, routerPortA), { ipAddress: '10.0.0.1', subnetMask: '255.255.255.0' }],
[interfaceKey(routerId, routerPortB), { ipAddress: '20.0.0.1', subnetMask: '255.255.255.0' }],
]),
},
15,
);
const sender = engine.bindUdpSocket(routerId, { localPort: 8000 })!;
const receiverA = engine.bindUdpSocket(pcAId, { localPort: 9000 })!;
const receiverB = engine.bindUdpSocket(pcBId, { localPort: 9000 })!;
engine.sendUdp(sender, new TextEncoder().encode('broadcast'), { ipAddress: '255.255.255.255', port: 9000 });
expect(engine.receiveUdp(receiverA)?.senderIPAddress).toBe('10.0.0.1');
expect(engine.receiveUdp(receiverB)?.senderIPAddress).toBe('20.0.0.1');
expect(engine.state.socketsById.get(receiverA)?.remoteIPAddress).toBe('10.0.0.1');
expect(engine.state.socketsById.get(receiverA)?.remotePort).toBe(8000);
engine.sendUdp(receiverA, new TextEncoder().encode('reply'));
expect(text(engine.receiveUdp(sender)?.datagram.payload)).toBe('reply');
});
it('advances the virtual clock for a receive timeout and returns nothing', () => {
const nodeId = uuid(0x15d1);
const engine = startedEngine({ nodes: [pc(nodeId, uuid(0x15d2))], links: [], deviceConfigurations: new Map([[nodeId, device('10.1.0.1')]]) });
const socketId = engine.bindUdpSocket(nodeId, { localPort: 9999 })!;
expect(engine.receiveUdp(socketId, 250)).toBeUndefined();
expect(engine.state.currentTimeMilliseconds).toBe(250);
});
});
describe('Java-compatible randomness', () => {
it('draws the same RIP jitter as java.util.Random', () => {
const random = new JavaCompatibleRandom(42);
expect(Array.from({ length: 5 }, () => random.nextRipJitterMilliseconds())).toEqual([
32_475, 25_746, 32_032, 25_679, 28_287,
]);
});
it('matches java.util.Random.nextInt for bounded and power-of-two ranges', () => {
// Measured on OpenJDK 21 (29.09.2026): new java.util.Random(42),
// nextInt(100) three times, then nextInt(16) three times.
const random = new JavaCompatibleRandom(42);
expect([random.nextInt(100), random.nextInt(100), random.nextInt(100)]).toEqual([30, 63, 48]);
expect([random.nextInt(16), random.nextInt(16), random.nextInt(16)]).toEqual([0, 4, 15]);
});
it('matches java.util.Random.nextDouble', () => {
// Measured on OpenJDK 21 (30.09.2026): nextDouble twice, then
// Math.round(nextDouble() * 253f) as the DHCP client draws a link-local address.
const cases: [number, number, number, number][] = [
[42, 0.7275636800328681, 0.6832234717598454, 78],
[0, 0.730967787376657, 0.24053641567148587, 161],
[-7, 0.2691218093260761, 0.6731811902798038, 132],
];
for (const [seed, first, second, rounded] of cases) {
const random = new JavaCompatibleRandom(seed);
expect([random.nextDouble(), random.nextDouble(), Math.round(random.nextDouble() * 253)]).toEqual([first, second, rounded]);
}
});
});
describe('IPv4 packets', () => {
it('keeps the identity and lowers the TTL when forwarding', () => {
const packet: IPv4Packet = {
identity: 99,
senderIPAddress: '192.168.0.10',
receiverIPAddress: '192.168.1.20',
timeToLive: 2,
protocol: 'icmp',
payload: { kind: 'icmp', message: icmpMessage('echoRequest', { identifier: 3, sequenceNumber: 4 }) },
};
const forwarded = forwardingClone(packet);
const expired = forwardingClone(forwarded);
expect(forwarded).toMatchObject({ identity: 99, timeToLive: 1 });
expect(expired).toMatchObject({ identity: 99, timeToLive: 0 });
});
});
describe('Ethernet, ARP and ICMP', () => {
it('fills both ARP caches with one request and reply over a direct cable', () => {
const sourceNodeId = uuid(0x1401);
const sourcePortId = uuid(0x1411);
const targetNodeId = uuid(0x1402);
const targetPortId = uuid(0x1412);
const engine = startedEngine({
nodes: [pc(sourceNodeId, sourcePortId), pc(targetNodeId, targetPortId)],
links: [link(sourceNodeId, sourcePortId, targetNodeId, targetPortId)],
deviceConfigurations: new Map([
[sourceNodeId, device('192.168.0.10')],
[targetNodeId, device('192.168.0.20')],
]),
});
const resolved = engine.resolveMacAddress(sourceNodeId, '192.168.0.20');
expect(resolved).toBe(stableMacAddress(targetPortId));
expect(engine.state.arpCachesByNodeId.get(sourceNodeId)?.get('192.168.0.20')?.macAddress).toBe(resolved);
expect(engine.state.arpCachesByNodeId.get(targetNodeId)?.get('192.168.0.10')?.macAddress).toBe(
stableMacAddress(sourcePortId),
);
});
it('pings across a switch with Ethernet, ARP and ICMP packets', () => {
const sourceNodeId = uuid(0x1421);
const sourcePortId = uuid(0x1431);
const switchNodeId = uuid(0x1422);
const switchPortA = uuid(0x1432);
const switchPortB = uuid(0x1433);
const targetNodeId = uuid(0x1423);
const targetPortId = uuid(0x1434);
const switchNode: NodeSnapshot = {
id: switchNodeId,
kind: 'networkSwitch',
ports: [portSnapshot(switchPortA, 'sw1'), portSnapshot(switchPortB, 'sw2')],
};
const engine = startedEngine({
nodes: [pc(sourceNodeId, sourcePortId), switchNode, pc(targetNodeId, targetPortId)],
links: [
link(sourceNodeId, sourcePortId, switchNodeId, switchPortA),
link(switchNodeId, switchPortB, targetNodeId, targetPortId),
],
deviceConfigurations: new Map([
[sourceNodeId, device('192.168.0.10')],
[targetNodeId, device('192.168.0.20')],
]),
});
const result = engine.sendIcmpEcho(sourceNodeId, '192.168.0.20', { identifier: 14, sequenceNumber: 1 });
expect(result.type).toBe('delivered');
expect(engine.state.icmpObservationsByNodeId.get(sourceNodeId)?.at(-1)?.message.kind).toBe('echoReply');
expect(engine.state.switchForwardingTablesByNodeId.get(switchNodeId)?.size).toBe(2);
expect(engine.state.packetTraces.some((trace) => trace.detail === 'ARP request for 192.168.0.20')).toBe(true);
expect(
engine.state.packetTraces.some((trace) => trace.packetIdentity !== undefined && trace.layer === 'network'),
).toBe(true);
});
it('answers an expired TTL at the router with time exceeded and the original packet', () => {
const topology = routedTopology();
const result = topology.engine.sendIcmpEcho(topology.sourceNodeId, '20.0.0.2', {
timeToLive: 1,
identifier: 140,
sequenceNumber: 1,
});
expect(result).toMatchObject({ type: 'icmpError', kind: 'timeExceeded' });
const observation = topology.engine.state.icmpObservationsByNodeId.get(topology.sourceNodeId)?.at(-1);
expect(observation?.message.kind).toBe('timeExceeded');
expect(observation?.message.embeddedOriginalPacket?.timeToLive).toBe(1);
expect(observation?.message.embeddedOriginalPacket?.receiverIPAddress).toBe('20.0.0.2');
});
it('keeps the packet identity and lowers the TTL when a router forwards', () => {
const topology = routedTopology();
const result = topology.engine.sendIcmpEcho(topology.sourceNodeId, '20.0.0.2', {
timeToLive: 8,
identifier: 141,
sequenceNumber: 1,
});
expect(result.type).toBe('delivered');
const forwardingTrace = topology.engine.state.packetTraces.find(
(trace) =>
trace.nodeId === topology.routerNodeId && trace.operation === 'forwarded' && trace.detail === 'TTL decremented',
);
expect(forwardingTrace?.packetIdentity).toBeDefined();
expect(forwardingTrace?.beforeHeaders.find((field) => field.name === 'ttl')?.value).toBe('8');
expect(forwardingTrace?.afterHeaders.find((field) => field.name === 'ttl')?.value).toBe('7');
});
it('reports network unreachable when the router has no route', () => {
const topology = routedTopology();
const snapshot = topology.engine.state.topologySnapshot;
const sourceToRouter = snapshot.links.find(
(candidate) => candidate.sourceNodeId === topology.sourceNodeId || candidate.targetNodeId === topology.sourceNodeId,
);
const engine = new NetworkRuntimeEngine(14);
engine.handle({
type: 'start',
snapshot: topologySnapshot({
nodes: snapshot.nodes,
links: sourceToRouter === undefined ? [] : [sourceToRouter],
deviceConfigurations: snapshot.deviceConfigurations,
interfaceConfigurations: snapshot.interfaceConfigurations,
manualRoutesByNodeId: snapshot.manualRoutesByNodeId,
}),
seed: 14,
initialTimeMilliseconds: 0,
});
const result = engine.sendIcmpEcho(topology.sourceNodeId, '30.0.0.2', { identifier: 142, sequenceNumber: 1 });
expect(result).toMatchObject({ type: 'icmpError', kind: 'destinationNetworkUnreachable' });
});
it('reports host unreachable when a connected target never answers ARP', () => {
const sourceNodeId = uuid(0x1481);
const sourcePortId = uuid(0x1491);
const engine = startedEngine({
nodes: [pc(sourceNodeId, sourcePortId)],
links: [],
deviceConfigurations: new Map([[sourceNodeId, device('192.168.50.10')]]),
});
const result = engine.sendIcmpEcho(sourceNodeId, '192.168.50.99', { identifier: 143, sequenceNumber: 1 });
expect(result).toMatchObject({ type: 'icmpError', kind: 'destinationHostUnreachable' });
});
});
describe('switch', () => {
it('learns, floods, forwards, filters and ages its address table', () => {
const hostAId = uuid(0x1a01);
const hostAPortId = uuid(0x1a11);
const hostBId = uuid(0x1a02);
const hostBPortId = uuid(0x1a12);
const hostCId = uuid(0x1a03);
const hostCPortId = uuid(0x1a13);
const switchId = uuid(0x1a04);
const switchPorts = [1, 2, 3].map((index) => portSnapshot(uuid(0x1a20 + index), `sw${index}`));
const engine = startedEngine(
{
nodes: [
pc(hostAId, hostAPortId),
pc(hostBId, hostBPortId),
pc(hostCId, hostCPortId),
{ id: switchId, kind: 'networkSwitch', ports: switchPorts },
],
links: [
link(hostAId, hostAPortId, switchId, switchPorts[0]!.id),
link(hostBId, hostBPortId, switchId, switchPorts[1]!.id),
link(hostCId, hostCPortId, switchId, switchPorts[2]!.id),
],
switchConfigurationsByNodeId: new Map([[switchId, switchConfiguration('Classroom', 1_000)]]),
},
20,
);
const macA = stableMacAddress(hostAPortId);
const macB = stableMacAddress(hostBPortId);
const payload: EthernetPayload = {
kind: 'arp',
packet: {
operation: 'request',
senderMacAddress: macA,
senderIPAddress: '192.0.2.1',
targetMacAddress: UNSPECIFIED_MAC_ADDRESS,
targetIPAddress: '192.0.2.200',
},
};
const frame = (sourceMacAddress: string, destinationMacAddress: string): EthernetFrame => ({
identity: engine.allocateFrameIdentity(),
sourceMacAddress,
destinationMacAddress,
payload,
});
const switchTraces = (detail: string) =>
engine.state.packetTraces.filter((trace) => trace.nodeId === switchId && trace.detail === detail);
engine.sendEthernetFrame(hostAId, hostAPortId, frame(macA, '02:00:00:00:00:FE'));
expect(switchTraces('switch unknown unicast flooding')).toHaveLength(2);
engine.sendEthernetFrame(hostBId, hostBPortId, frame(macB, macA));
expect(switchTraces('switch learned unicast forwarding')).toHaveLength(1);
engine.sendEthernetFrame(hostAId, hostAPortId, frame(macA, macA));
expect(switchTraces('switch filtered destination on incoming port').length).toBeGreaterThan(0);
expect(engine.switchSatEntries(switchId)).toHaveLength(2);
const retention = 1_000;
const updateTimes = [...engine.state.switchForwardingUpdatedAtMillisecondsByNodeId.get(switchId)!.values()];
const expirationDeadlines = updateTimes.map((time) => time + retention);
const firstExpiration = Math.min(...expirationDeadlines);
const lastExpiration = Math.max(...expirationDeadlines);
engine.handle({ type: 'advance', toMilliseconds: firstExpiration - 1 });
expect(engine.switchSatEntries(switchId)).toHaveLength(2);
engine.handle({ type: 'advance', toMilliseconds: lastExpiration });
expect(engine.switchSatEntries(switchId)).toEqual([]);
});
});
describe('firewall', () => {
it('accepts when inactive, and an active ICMP drop bypasses the ordered rules', () => {
const routerId = uuid(0x1801);
const portId = uuid(0x1811);
const router: NodeSnapshot = { id: routerId, kind: 'router', ports: [portSnapshot(portId, 'rt1')] };
const packet: IPv4Packet = {
identity: 18,
senderIPAddress: '203.0.113.10',
receiverIPAddress: '10.0.0.1',
timeToLive: 64,
protocol: 'icmp',
payload: { kind: 'icmp', message: icmpMessage('echoRequest') },
};
const interfaces = new Map([
[interfaceKey(routerId, portId), { ipAddress: '10.0.0.1', subnetMask: '255.255.255.0' }],
]);
const inactive = startedEngine(
{
nodes: [router],
links: [],
interfaceConfigurations: interfaces,
firewallConfigurationsByNodeId: new Map([[routerId, firewallConfiguration()]]),
},
18,
);
expect(inactive.evaluateFirewall(packet, routerId).accepted).toBe(true);
const active = startedEngine(
{
nodes: [router],
links: [],
interfaceConfigurations: interfaces,
firewallConfigurationsByNodeId: new Map([
[
routerId,
firewallConfiguration({
isActive: true,
defaultPolicy: 'accept',
dropIcmp: true,
rules: [firewallRule({ protocol: 'icmp', action: 'accept' })],
}),
],
]),
},
18,
);
expect(active.evaluateFirewall(packet, routerId).accepted).toBe(false);
expect(active.state.firewallDecisions.at(-1)?.ruleIndex).toBeUndefined();
expect(active.state.packetTraces.at(-1)?.operation).toBe('dropped');
});
it('lets the first matching TCP rule win, and established traffic bypasses SYN-only rules', () => {
const { engine, routerId } = firewallEngine(
firewallConfiguration({
isActive: true,
defaultPolicy: 'drop',
filterSynSegmentsOnly: true,
rules: [
firewallRule({ port: 443, protocol: 'tcp', action: 'accept' }),
firewallRule({ port: 443, protocol: 'tcp', action: 'drop' }),
],
}),
);
const synDecision = engine.evaluateFirewall(tcpPacket(181, 443, TCP_FLAG.synchronize), routerId);
expect(synDecision.accepted).toBe(true);
expect(synDecision.ruleIndex).toBe(0);
expect(engine.evaluateFirewall(tcpPacket(182, 22, TCP_FLAG.acknowledgement), routerId).accepted).toBe(true);
});
it('matches a UDP response by its explicit source port and the direct-network marker', () => {
const { engine, routerId } = firewallEngine(
firewallConfiguration({
isActive: true,
defaultPolicy: 'drop',
rules: [
firewallRule({
sourceIPAddress: DIRECTLY_CONNECTED_SOURCE_MARKER,
destinationIPAddress: '8.8.8.8',
destinationSubnetMask: '255.255.255.255',
port: 53,
protocol: 'udp',
action: 'accept',
}),
],
}),
);
const udp = (identity: number, sender: string, receiver: string, sourcePort: number, destinationPort: number): IPv4Packet => ({
identity,
senderIPAddress: sender,
receiverIPAddress: receiver,
timeToLive: 64,
protocol: 'udp',
payload: { kind: 'udp', datagram: { sourcePort, destinationPort, payload: new Uint8Array() } },
});
expect(engine.evaluateFirewall(udp(183, '10.0.0.20', '8.8.8.8', 50_001, 53), routerId).accepted).toBe(true);
expect(engine.evaluateFirewall(udp(184, '8.8.8.8', '10.0.0.20', 53, 50_001), routerId).accepted).toBe(true);
});
});
describe('packet loss and trace retention', () => {
it('drops every physical delivery while global packet loss is on', () => {
const firstNodeId = uuid(0x101);
const firstPortId = uuid(0x102);
const secondNodeId = uuid(0x103);
const secondPortId = uuid(0x104);
const engine = startedEngine({
nodes: [pc(firstNodeId, firstPortId), pc(secondNodeId, secondPortId)],
links: [link(firstNodeId, firstPortId, secondNodeId, secondPortId)],
deviceConfigurations: new Map([
[firstNodeId, device('10.0.0.1')],
[secondNodeId, device('10.0.0.2')],
]),
});
const frame: EthernetFrame = {
identity: engine.allocateFrameIdentity(),
sourceMacAddress: stableMacAddress(firstPortId),
destinationMacAddress: ETHERNET_BROADCAST_MAC_ADDRESS,
payload: {
kind: 'arp',
packet: {
operation: 'request',
senderMacAddress: stableMacAddress(firstPortId),
senderIPAddress: '10.0.0.1',
targetMacAddress: UNSPECIFIED_MAC_ADDRESS,
targetIPAddress: '10.0.0.2',
},
},
};
engine.setGlobalPacketLossEnabled(true);
engine.sendEthernetFrame(firstNodeId, firstPortId, frame);
expect(engine.state.globalPacketLossEnabled).toBe(true);
expect(engine.state.arpCachesByNodeId.get(secondNodeId)?.size ?? 0).toBe(0);
expect(
engine.state.packetTraces.some(
(trace) =>
trace.frameIdentity === frame.identity &&
trace.nodeId === secondNodeId &&
trace.operation === 'dropped' &&
trace.detail === 'global packet-loss simulation',
),
).toBe(true);
engine.setGlobalPacketLossEnabled(false);
engine.sendEthernetFrame(firstNodeId, firstPortId, frame);
expect(engine.state.globalPacketLossEnabled).toBe(false);
expect(engine.state.arpCachesByNodeId.get(secondNodeId)?.get('10.0.0.1')?.macAddress).toBe(
stableMacAddress(firstPortId),
);
});
it('resets packet loss across stop and restart', () => {
const engine = new NetworkRuntimeEngine(42);
engine.handle({ type: 'start', snapshot: EMPTY_TOPOLOGY_SNAPSHOT, seed: 42, initialTimeMilliseconds: 0 });
engine.setGlobalPacketLossEnabled(true);
expect(engine.state.globalPacketLossEnabled).toBe(true);
engine.handle({ type: 'stop' });
expect(engine.state.globalPacketLossEnabled).toBe(false);
engine.handle({ type: 'start', snapshot: EMPTY_TOPOLOGY_SNAPSHOT, seed: 42, initialTimeMilliseconds: 0 });
expect(engine.state.globalPacketLossEnabled).toBe(false);
});
it('evicts the oldest trace events in configured batches', () => {
const nodeId = uuid(0x2001);
const interfaceId = uuid(0x2002);
const engine = new NetworkRuntimeEngine(20, traceRetentionPolicy(3, 2));
for (let identity = 1; identity <= 6; identity += 1) {
engine.recordTrace({
frameIdentity: identity,
nodeId,
interfaceId,
direction: 'outbound',
layer: 'dataLink',
operation: 'sent',
detail: `trace-${identity}`,
});
}
expect(engine.state.packetTraces.map((trace) => trace.id)).toEqual([5, 6]);
expect(engine.state.packetTraces.map((trace) => trace.detail)).toEqual(['trace-5', 'trace-6']);
expect(engine.state.discardedPacketTraceCount).toBe(4);
expect(engine.state.nextTraceIdentity).toBe(7);
});
});
describe('TCP sockets', () => {
/** Two PCs on one cable, started. */
const pair = (first: number, second: number, prefix: string) => {
const [clientId, clientPortId, serverId, serverPortId] = [first, first + 0x10, second, second + 0x10].map(uuid) as [
string,
string,
string,
string,
];
const engine = startedEngine({
nodes: [pc(clientId, clientPortId), pc(serverId, serverPortId)],
links: [link(clientId, clientPortId, serverId, serverPortId)],
deviceConfigurations: new Map([
[clientId, device(`${prefix}.1`)],
[serverId, device(`${prefix}.2`)],
]),
});
return { engine, clientId, serverId };
};
it('makes an established client and an accepted server socket with the three-way handshake', () => {
const { engine, clientId, serverId } = pair(0x1701, 0x1702, '10.17.0');
const listenerId = engine.openTcpServerSocket(serverId, 8080)!;
const clientSocketId = engine.openTcpClientSocket(clientId, '10.17.0.2', 8080)!;
expect(engine.connectTcp(clientSocketId)).toBe(true);
const acceptedSocketId = engine.state.tcpAcceptedSocketIdsByListenerId.get(listenerId)![0]!;
expect(engine.state.socketsById.get(clientSocketId)?.tcpState).toBe('ESTABLISHED');
expect(engine.state.socketsById.get(acceptedSocketId)?.tcpState).toBe('ESTABLISHED');
expect(engine.state.packetTraces.some((trace) => trace.detail === 'TCP SYN_SENT -> ESTABLISHED')).toBe(true);
expect(engine.state.packetTraces.some((trace) => trace.detail === 'TCP SYN_RCVD -> ESTABLISHED')).toBe(true);
});
it("sends segments of Java's MSS and reassembles one message up to the push", () => {
const { engine, clientId, serverId } = pair(0x1721, 0x1722, '10.18.0');
const listenerId = engine.openTcpServerSocket(serverId, 8081)!;
const clientSocketId = engine.openTcpClientSocket(clientId, '10.18.0.2', 8081)!;
expect(engine.connectTcp(clientSocketId)).toBe(true);
const acceptedSocketId = engine.state.tcpAcceptedSocketIdsByListenerId.get(listenerId)![0]!;
const payload = Uint8Array.from({ length: 3_001 }, (_, index) => index % 251);
expect(engine.sendTcp(clientSocketId, payload)).toBe(true);
expect(engine.receiveTcp(acceptedSocketId)).toEqual(payload);
const payloadLengths = engine.state.packetTraces
.filter((trace) => trace.detail?.startsWith('TCP data') === true)
.map((trace) => Number(trace.afterHeaders.find((header) => header.name === 'payloadLength')?.value));
expect(payloadLengths).toEqual([1_460, 1_460, 81]);
});
it('retransmits a connection attempt at most three times and releases the port', () => {
const clientId = uuid(0x1741);
const engine = startedEngine({ nodes: [pc(clientId, uuid(0x1751))], links: [], deviceConfigurations: new Map([[clientId, device('10.19.0.1')]]) });
const socketId = engine.openTcpClientSocket(clientId, '10.19.0.99', 80)!;
const localPort = engine.state.socketsById.get(socketId)!.localPort;
expect(engine.connectTcp(socketId)).toBe(false);
expect(engine.state.tcpSessionsById.get(socketId)?.sendAttempts).toBe(3);
expect(engine.state.socketsById.get(socketId)?.tcpState).toBe('CLOSED');
expect(engine.isTcpPortReserved(clientId, localPort)).toBe(false);
expect(engine.state.packetTraces.filter((trace) => trace.detail?.includes('TCP SYN retransmission') === true)).toHaveLength(2);
});
it('drops a SYN+ACK at a listening port with no connection of its own', () => {
const { engine, clientId, serverId } = pair(0x1781, 0x1782, '10.21.0');
const listenerId = engine.openTcpServerSocket(serverId, 7000)!;
const segment = { sourcePort: 80, destinationPort: 7000, sequenceNumber: 5, acknowledgementNumber: 9, flags: TCP_FLAG.synchronize | TCP_FLAG.acknowledgement, payload: new Uint8Array() };
engine.sendIPv4Packet(clientId, {
identity: engine.allocatePacketIdentity(),
senderIPAddress: '10.21.0.1',
receiverIPAddress: '10.21.0.2',
timeToLive: 64,
protocol: 'tcp',
payload: { kind: 'tcp', segment },
});
engine.handle({ type: 'advance', toMilliseconds: engine.state.currentTimeMilliseconds + 1_000 });
expect(engine.state.tcpAcceptedSocketIdsByListenerId.get(listenerId)).toEqual([]);
expect(engine.state.packetTraces.some((trace) => trace.detail === 'TCP destination port closed')).toBe(true);
});
it('names the flags of a segment in Swift\'s order', () => {
expect(tcpFlagDescription(TCP_FLAG.acknowledgement | TCP_FLAG.push | TCP_FLAG.reset | TCP_FLAG.synchronize | TCP_FLAG.finish)).toBe(
'FIN+SYN+RST+PSH+ACK',
);
expect(tcpFlagDescription(TCP_FLAG.synchronize | TCP_FLAG.acknowledgement)).toBe('SYN+ACK');
expect(tcpFlagDescription(0)).toBe('');
});
it("closes with Java's FIN states: the other end closes by itself, TIME_WAIT lasts three round trips", () => {
const { engine, clientId, serverId } = pair(0x1761, 0x1762, '10.20.0');
const listenerId = engine.openTcpServerSocket(serverId, 9090)!;
const clientSocketId = engine.openTcpClientSocket(clientId, '10.20.0.2', 9090)!;
expect(engine.connectTcp(clientSocketId)).toBe(true);
const serverSocketId = engine.state.tcpAcceptedSocketIdsByListenerId.get(listenerId)![0]!;
engine.closeTcpSocket(clientSocketId);
// Java's socket thread leaves listen on the FIN and closes without the application.
const serverStates = engine.state.packetTraces
.filter((trace) => trace.nodeId === serverId && trace.detail?.startsWith('TCP ') === true && trace.detail.includes(' -> '))
.map((trace) => trace.detail);
expect(serverStates.slice(-3)).toEqual(['TCP ESTABLISHED -> CLOSE_WAIT', 'TCP CLOSE_WAIT -> LAST_ACK', 'TCP LAST_ACK -> CLOSED']);
expect(engine.state.socketsById.get(clientSocketId)?.tcpState).toBe('TIME_WAIT');
engine.closeTcpSocket(serverSocketId);
expect(engine.state.socketsById.get(serverSocketId)?.tcpState).toBe('CLOSED');
// Java's defaultTimeout: three round trips of 2750 ms at 90 %.
const timeWait = engine.state.packetTraces.find((trace) => trace.nodeId === clientId && trace.detail?.endsWith('-> TIME_WAIT') === true)!
.timeMilliseconds;
engine.handle({ type: 'advance', toMilliseconds: timeWait + 3 * 2_750 - 1 });
expect(engine.state.socketsById.get(clientSocketId)?.tcpState).toBe('TIME_WAIT');
engine.handle({ type: 'advance', toMilliseconds: timeWait + 3 * 2_750 });
expect(engine.state.socketsById.get(clientSocketId)?.tcpState).toBe('CLOSED');
expect(engine.state.socketsById.get(listenerId)?.tcpState).toBe('LISTEN');
});
it('ends TIME_WAIT with the next segment that comes', () => {
const { engine, clientId, serverId } = pair(0x1771, 0x1772, '10.21.0');
engine.openTcpServerSocket(serverId, 9090);
const clientSocketId = engine.openTcpClientSocket(clientId, '10.21.0.2', 9090)!;
expect(engine.connectTcp(clientSocketId)).toBe(true);
engine.closeTcpSocket(clientSocketId);
expect(engine.state.socketsById.get(clientSocketId)?.tcpState).toBe('TIME_WAIT');
const localPort = engine.state.socketsById.get(clientSocketId)!.localPort;
engine.sendIPv4Packet(serverId, {
identity: 77,
senderIPAddress: '10.21.0.2',
receiverIPAddress: '10.21.0.1',
timeToLive: 64,
protocol: 'tcp',
payload: {
kind: 'tcp',
segment: { sourcePort: 9090, destinationPort: localPort, sequenceNumber: 1, acknowledgementNumber: 0, flags: TCP_FLAG.acknowledgement, payload: new Uint8Array() },
},
});
engine.handle({ type: 'advance', toMilliseconds: engine.state.currentTimeMilliseconds + 100 });
expect(engine.state.socketsById.get(clientSocketId)?.tcpState).toBe('CLOSED');
});
it('accepts on the socket that listened, and listens on with the next sequence number', () => {
const [firstId, firstPortId, secondId, secondPortId, serverId, serverPortId, switchId] = [
0x1781, 0x1782, 0x1783, 0x1784, 0x1785, 0x1786, 0x1787,
].map(uuid) as [string, string, string, string, string, string, string];
const switchPorts = [0x1791, 0x1792, 0x1793].map(uuid) as [string, string, string];
const engine = startedEngine({
nodes: [
pc(firstId, firstPortId),
pc(secondId, secondPortId),
pc(serverId, serverPortId),
{ id: switchId, kind: 'networkSwitch', ports: switchPorts.map((id, index) => portSnapshot(id, `sw${index + 1}`)) },
],
links: [
link(firstId, firstPortId, switchId, switchPorts[0]),
link(secondId, secondPortId, switchId, switchPorts[1]),
link(serverId, serverPortId, switchId, switchPorts[2]),
],
deviceConfigurations: new Map([
[firstId, device('10.22.0.1')],
[secondId, device('10.22.0.2')],
[serverId, device('10.22.0.3')],
]),
});
// Java counts initial sequence numbers over all TCP sockets: the listener 1, the first client 2,
// the listener's successor 3 (made when the first is accepted), the second client 4.
const listenerId = engine.openTcpServerSocket(serverId, 80)!;
expect(engine.connectTcp(engine.openTcpClientSocket(firstId, '10.22.0.3', 80)!)).toBe(true);
expect(engine.connectTcp(engine.openTcpClientSocket(secondId, '10.22.0.3', 80)!)).toBe(true);
const accepted = engine.state.tcpAcceptedSocketIdsByListenerId.get(listenerId)!;
expect(accepted.map((id) => engine.state.tcpSessionsById.get(id)?.nextSendSequenceNumber)).toEqual([1_000_001, 3_000_001]);
});
});
describe('DHCP', () => {
// Java's round trip time at the default speed of 90 %: 50 cables of 55 ms.
const RTT = 2_750;
const serverAndClient = (first: number, serverIp: string, client: DeviceConfiguration, server: Partial<DhcpServerConfiguration>) => {
const [serverId, serverPortId, clientId, clientPortId] = [first, first + 1, first + 2, first + 3].map(uuid) as [string, string, string, string];
const engine = startedEngine(
{
nodes: [pc(serverId, serverPortId), pc(clientId, clientPortId)],
links: [link(serverId, serverPortId, clientId, clientPortId)],
deviceConfigurations: new Map([
[serverId, device(serverIp)],
[clientId, client],
]),
dhcpClientConfigurationsByNodeId: new Map([[clientId, { isEnabled: true }]]),
dhcpServerConfigurationsByNodeId: new Map([[serverId, dhcpServerConfiguration({ isActive: true, ...server })]]),
},
16,
);
return { engine, serverId, clientId, clientPortId };
};
/** Hosts on one switch, each with its device configuration; the first is the DHCP client. */
const onSwitch = (first: number, devices: DeviceConfiguration[], servers: Map<string, Partial<DhcpServerConfiguration>> = new Map()) => {
const switchId = uuid(first);
const hosts = devices.map((_, index) => ({ id: uuid(first + 1 + 2 * index), portId: uuid(first + 2 + 2 * index), switchPortId: uuid(first + 0x80 + index) }));
const switchNode: NodeSnapshot = {
id: switchId,
kind: 'networkSwitch',
ports: hosts.map((host, index) => portSnapshot(host.switchPortId, `sw${index + 1}`)),
};
const hostIds = hosts.map((host) => host.id);
const engine = startedEngine(
{
nodes: [switchNode, ...hosts.map((host) => pc(host.id, host.portId))],
links: hosts.map((host) => link(host.id, host.portId, switchId, host.switchPortId)),
deviceConfigurations: new Map(devices.map((each, index) => [hostIds[index]!, each])),
dhcpClientConfigurationsByNodeId: new Map([[hostIds[0]!, { isEnabled: true }]]),
dhcpServerConfigurationsByNodeId: new Map(
[...servers].map(([ipAddress, server]) => [
hostIds[devices.findIndex((each) => each.ipAddress === ipAddress)]!,
dhcpServerConfiguration({ isActive: true, ...server }),
]),
),
},
16,
);
return { engine, hostIds };
};
/** When the node sent a DHCP message of the type. */
const sentAt = (engine: NetworkRuntimeEngine, nodeId: string, type: string) =>
engine.state.packetTraces
.filter((trace) => trace.nodeId === nodeId && trace.layer === 'application' && trace.operation === 'sent' && trace.detail === type)
.map((trace) => trace.timeMilliseconds);
/** When the node broadcast an ARP packet with the detail. */
const arpAt = (engine: NetworkRuntimeEngine, nodeId: string, detail: string) =>
engine.state.packetTraces
.filter((trace) => trace.nodeId === nodeId && trace.layer === 'dataLink' && trace.operation === 'created' && trace.detail === detail)
.map((trace) => trace.timeMilliseconds);
/** The link-local addresses the client draws with seed 16, as Java's selectArbitraryLinkLocalAddress skips known ones. */
const linkLocalDraws = (count: number) => {
const random = new JavaCompatibleRandom(16n ^ 0x4c4c_4144n);
const draws: string[] = [];
while (draws.length < count) {
const address = `169.254.1.${Math.round(random.nextDouble() * 253) + 1}`;
if (!draws.includes(address)) draws.push(address);
}
return draws;
};
it('completes DISCOVER, OFFER, REQUEST and ACK and applies the configuration', () => {
const { engine, clientId } = serverAndClient(0x1601, '192.168.50.1', device('169.254.1.2', { mask: '0.0.0.0' }), {
lowerBoundIPAddress: '192.168.50.20',
upperBoundIPAddress: '192.168.50.29',
gatewayIPAddress: '192.168.50.1',
dnsServerIPAddress: '192.168.50.53',
useOwnSettings: true,
});
engine.handle({ type: 'advance', toMilliseconds: 1_000 });
// A client that runs goes on waiting; it does not start over.
expect(engine.runDhcpClient(clientId)?.state).toBe('VALIDATE');
engine.handle({ type: 'advance', toMilliseconds: 5_000 });
expect(engine.state.topologySnapshot.deviceConfigurations.get(clientId)).toEqual(
deviceConfiguration('192.168.50.20', '255.255.255.0', '192.168.50.1', '192.168.50.53'),
);
const status = engine.state.dhcpClientStatusesByNodeId.get(clientId);
expect([status?.state, status?.errorCount, status?.succeeded, status?.linkLocal]).toEqual(['FINISH', 0, true, false]);
const leaseExpiry = [...engine.state.dhcpLeasesByIPAddress.values()][0]?.expiresAtMilliseconds;
expect(leaseExpiry).toBeGreaterThan(engine.state.currentTimeMilliseconds);
expect(leaseExpiry! - engine.state.currentTimeMilliseconds).toBeLessThanOrEqual(DHCP_DYNAMIC_LEASE_LIFETIME_MILLISECONDS);
// One message per attempt.
expect(sentAt(engine, clientId, 'DHCPDISCOVER')).toEqual([0]);
// The OFFER comes after two cables' delay; the ARP check waits one round trip before the REQUEST.
expect(arpAt(engine, clientId, 'ARP request for 192.168.50.20')).toEqual([110]);
expect(sentAt(engine, clientId, 'DHCPREQUEST')).toEqual([110 + RTT]);
});
it('matches a static assignment regardless of case, with a lease that does not expire', () => {
const clientPortId = uuid(0x1611 + 3);
const { engine, clientId } = serverAndClient(0x1611, '10.0.0.1', device('0.0.0.0'), {
lowerBoundIPAddress: '10.0.0.20',
upperBoundIPAddress: '10.0.0.20',
staticAssignments: [{ id: 'static', macAddress: stableMacAddress(clientPortId).toLowerCase(), ipAddress: '10.0.0.99' }],
});
engine.handle({ type: 'advance', toMilliseconds: 5_000 });
expect(engine.state.topologySnapshot.deviceConfigurations.get(clientId)?.ipAddress).toBe('10.0.0.99');
expect([...engine.state.dhcpLeasesByIPAddress.values()][0]?.expiresAtMilliseconds).toBeUndefined();
});
it('takes a link-local address after the fifth timeout and does not give back the old one', () => {
const clientId = uuid(0x1621);
const engine = startedEngine(
{
nodes: [pc(clientId, uuid(0x1622))],
links: [],
deviceConfigurations: new Map([[clientId, deviceConfiguration('169.254.5.7', '255.255.0.0', '169.254.0.1', '169.254.0.53')]]),
dhcpClientConfigurationsByNodeId: new Map([[clientId, { isEnabled: true }]]),
},
16,
);
engine.handle({ type: 'advance', toMilliseconds: 0 });
expect(engine.state.topologySnapshot.deviceConfigurations.get(clientId)?.ipAddress).toBe('0.0.0.0');
expect(engine.state.dhcpClientStatusesByNodeId.get(clientId)?.state).toBe('DISCOVER');
engine.handle({ type: 'advance', toMilliseconds: 5 * RTT - 1 });
expect(engine.state.dhcpClientStatusesByNodeId.get(clientId)?.errorCount).toBe(4);
engine.handle({ type: 'advance', toMilliseconds: 20_000 });
const [address] = linkLocalDraws(1);
expect(sentAt(engine, clientId, 'DHCPDISCOVER')).toEqual([0, RTT, 2 * RTT, 3 * RTT, 4 * RTT]);
expect(arpAt(engine, clientId, `ARP request for ${address}`)).toEqual([5 * RTT]);
expect(arpAt(engine, clientId, `ARP reply for ${address}`)).toEqual([6 * RTT]);
// Java: the address, its /16 mask and no gateway; the DNS server stays.
expect(engine.state.topologySnapshot.deviceConfigurations.get(clientId)).toEqual(
deviceConfiguration(address!, '255.255.0.0', '', '169.254.0.53'),
);
const status = engine.state.dhcpClientStatusesByNodeId.get(clientId);
expect([status?.state, status?.errorCount, status?.succeeded, status?.linkLocal]).toEqual(['FINISH', 5, true, true]);
expect(engine.state.dhcpClientContextsByNodeId.has(clientId)).toBe(false);
expect(engine.state.socketsById.size).toBe(0);
});
it('sends the REQUEST again to the same server when it does not answer', () => {
const { engine, serverId, clientId } = serverAndClient(0x1631, '10.0.0.1', device('0.0.0.0'), {
lowerBoundIPAddress: '10.0.0.20',
upperBoundIPAddress: '10.0.0.29',
});
engine.handle({ type: 'advance', toMilliseconds: 150 });
expect(engine.state.dhcpClientStatusesByNodeId.get(clientId)?.state).toBe('VALIDATE');
engine.closeSocket(engine.state.dhcpServerSocketIdsByNodeId.get(serverId)!);
engine.handle({ type: 'advance', toMilliseconds: 110 + 5 * RTT });
const requests = [1, 2, 3, 4, 5].map((attempt) => 110 + attempt * RTT);
expect(sentAt(engine, clientId, 'DHCPREQUEST')).toEqual(requests);
expect(sentAt(engine, clientId, 'DHCPDISCOVER')).toEqual([0]);
const status = engine.state.dhcpClientStatusesByNodeId.get(clientId);
expect([status?.state, status?.errorCount, status?.offeredIPAddress]).toEqual(['REQUEST', 4, '10.0.0.20']);
});
it('declines at once when another card answers for the offered address', () => {
const { engine, hostIds } = onSwitch(
0x1641,
[device('0.0.0.0'), device('192.168.0.10'), device('192.168.0.100')],
new Map([['192.168.0.10', { lowerBoundIPAddress: '192.168.0.100', upperBoundIPAddress: '192.168.0.100' }]]),
);
const clientId = hostIds[0]!;
engine.handle({ type: 'advance', toMilliseconds: 1_000 });
// The OFFER takes two cables to the client, the ARP request and the
// holder's reply two each; the DECLINE follows the reply, not the round trip.
const [check] = arpAt(engine, clientId, 'ARP request for 192.168.0.100');
expect(check).toBe(220);
expect(sentAt(engine, clientId, 'DHCPDECLINE')).toEqual([check! + 220]);
expect(sentAt(engine, clientId, 'DHCPDISCOVER')).toEqual([0, check! + 220]);
expect(sentAt(engine, clientId, 'DHCPREQUEST')).toEqual([]);
});
it('starts over with DISCOVER after a NAK, which is no failure', () => {
const { engine, serverId, clientId } = serverAndClient(0x1671, '10.0.0.1', device('0.0.0.0'), {
lowerBoundIPAddress: '10.0.0.20',
upperBoundIPAddress: '10.0.0.29',
});
engine.handle({ type: 'advance', toMilliseconds: 150 });
// While the client checks 10.0.0.20, the server bars it: the REQUEST gets a NAK.
engine.state.dhcpBlacklistByServerNodeId.set(serverId, new Map([['10.0.0.20', 1_000_000]]));
engine.handle({ type: 'advance', toMilliseconds: 10_000 });
const nak = 110 + RTT + 110;
expect(sentAt(engine, clientId, 'DHCPREQUEST')).toEqual([110 + RTT, nak + 110 + RTT]);
expect(sentAt(engine, clientId, 'DHCPDISCOVER')).toEqual([0, nak]);
const status = engine.state.dhcpClientStatusesByNodeId.get(clientId);
expect([status?.state, status?.errorCount, status?.succeeded]).toEqual(['FINISH', 0, true]);
expect(engine.state.topologySnapshot.deviceConfigurations.get(clientId)?.ipAddress).toBe('10.0.0.21');
});
it('ignores an answer from another server than the one it asked', () => {
const { engine, hostIds } = onSwitch(
0x1681,
[device('0.0.0.0'), device('192.168.0.10'), device('192.168.0.66')],
new Map([['192.168.0.10', { lowerBoundIPAddress: '192.168.0.100', upperBoundIPAddress: '192.168.0.100' }]]),
);
const [clientId, , rogueId] = hostIds as [string, string, string];
const clientMac = stableMacAddress(uuid(0x1681 + 2));
const request = 220 + RTT;
engine.handle({ type: 'advance', toMilliseconds: request + 10 });
expect(engine.state.dhcpClientStatusesByNodeId.get(clientId)?.state).toBe('REQUEST');
// A NAK for the client, but from 192.168.0.66; it arrives before the server's ACK.
const rogue = engine.bindUdpSocket(rogueId, { localPort: 67 })!;
const nak = encodeDhcpMessage({ type: 'DHCPNAK', yourIPAddress: '192.168.0.100', clientMacAddress: clientMac, serverIdentifier: '192.168.0.66' });
engine.sendUdp(rogue, nak, { ipAddress: '255.255.255.255', port: 68 });
engine.handle({ type: 'advance', toMilliseconds: 10_000 });
expect(sentAt(engine, clientId, 'DHCPDISCOVER')).toEqual([0]);
expect(engine.state.topologySnapshot.deviceConfigurations.get(clientId)?.ipAddress).toBe('192.168.0.100');
});
it("takes the other server's queued OFFER at once after a DECLINE", () => {
// Server A offers the address a third card has, server B another one; B's
// OFFER comes while the client checks A's and waits in its queue.
const { engine, hostIds } = onSwitch(
0x1691,
[device('0.0.0.0'), device('192.168.0.10'), device('192.168.0.20'), device('192.168.0.100')],
new Map([
['192.168.0.10', { lowerBoundIPAddress: '192.168.0.100', upperBoundIPAddress: '192.168.0.100' }],
['192.168.0.20', { lowerBoundIPAddress: '192.168.0.200', upperBoundIPAddress: '192.168.0.200' }],
]),
);
const clientId = hostIds[0]!;
engine.handle({ type: 'advance', toMilliseconds: 10_000 });
const decline = 220 + 220;
expect(sentAt(engine, clientId, 'DHCPDECLINE')).toEqual([decline]);
expect(sentAt(engine, clientId, 'DHCPDISCOVER')).toEqual([0, decline]);
expect(arpAt(engine, clientId, 'ARP request for 192.168.0.200')).toEqual([decline]);
expect(sentAt(engine, clientId, 'DHCPREQUEST')).toEqual([decline + RTT]);
expect(engine.state.topologySnapshot.deviceConfigurations.get(clientId)?.ipAddress).toBe('192.168.0.200');
});
it('skips a link-local address the ARP cache knows', () => {
const [known, next] = linkLocalDraws(2) as [string, string];
const { engine, hostIds } = onSwitch(0x16a1, [
device('0.0.0.0'),
device(known, { mask: '255.255.0.0' }),
device('169.254.9.9', { mask: '255.255.0.0' }),
]);
const clientId = hostIds[0]!;
engine.handle({ type: 'advance', toMilliseconds: 1_000 });
engine.resolveMacAddress(hostIds[1]!, '169.254.9.9', { maxRetries: 1 });
expect(engine.state.arpCachesByNodeId.get(clientId)?.has(known)).toBe(true);
engine.handle({ type: 'advance', toMilliseconds: 20_000 });
expect(arpAt(engine, clientId, `ARP request for ${known}`)).toEqual([]);
expect(arpAt(engine, clientId, `ARP request for ${next}`)).toEqual([5 * RTT]);
expect(engine.state.dhcpClientStatusesByNodeId.get(clientId)?.errorCount).toBe(5);
expect(engine.state.topologySnapshot.deviceConfigurations.get(clientId)?.ipAddress).toBe(next);
});
// Java's selectArbitraryLinkLocalAddress loop counts occupied addresses
// but checks its limit only after a timeout, which cannot come there: it
// searches without end. The engine gives up after the fifth, as
// LINK_LOCAL_MAX_ERROR_COUNT intends, and keeps 0.0.0.0.
it('gives up after five occupied link-local addresses (Java searches on without end)', () => {
const occupied = linkLocalDraws(5);
const { engine, hostIds } = onSwitch(0x1651, [device('0.0.0.0'), ...occupied.map((address) => device(address, { mask: '255.255.0.0' }))]);
const clientId = hostIds[0]!;
engine.handle({ type: 'advance', toMilliseconds: 30_000 });
for (const address of occupied) expect(arpAt(engine, clientId, `ARP request for ${address}`)).toHaveLength(1);
const status = engine.state.dhcpClientStatusesByNodeId.get(clientId);
expect([status?.state, status?.errorCount, status?.succeeded]).toEqual(['FINISH', 10, false]);
expect(engine.state.topologySnapshot.deviceConfigurations.get(clientId)?.ipAddress).toBe('0.0.0.0');
expect(engine.state.dhcpClientContextsByNodeId.has(clientId)).toBe(false);
});
// Java's ARP lookup waits on the whole ARP table: any entry that changes
// ends the wait, and the address counts as free. The engine waits for the
// address it checks, or the round trip.
it('waits out the address check when an unrelated ARP packet comes (Java would stop waiting)', () => {
const { engine, hostIds } = onSwitch(
0x1661,
[device('0.0.0.0'), device('192.168.0.10'), device('192.168.0.30')],
new Map([['192.168.0.10', { lowerBoundIPAddress: '192.168.0.100', upperBoundIPAddress: '192.168.0.100' }]]),
);
const clientId = hostIds[0]!;
engine.handle({ type: 'advance', toMilliseconds: 300 });
expect(engine.state.dhcpClientStatusesByNodeId.get(clientId)?.state).toBe('VALIDATE');
engine.resolveMacAddress(hostIds[2]!, '192.168.0.77', { maxRetries: 1 });
expect(engine.state.arpCachesByNodeId.get(clientId)?.has('192.168.0.30')).toBe(true);
engine.handle({ type: 'advance', toMilliseconds: 5_000 });
const [check] = arpAt(engine, clientId, 'ARP request for 192.168.0.100');
expect(sentAt(engine, clientId, 'DHCPREQUEST')).toEqual([check! + RTT]);
expect(engine.state.topologySnapshot.deviceConfigurations.get(clientId)?.ipAddress).toBe('192.168.0.100');
});
it("writes and reads a message in Swift's text format", () => {
const message = {
type: 'DHCPOFFER' as const,
yourIPAddress: '10.0.0.5',
clientMacAddress: '02:00:00:00:00:01',
subnetMask: '255.255.255.0',
routerIPAddress: '10.0.0.1',
dnsServerIPAddress: '10.0.0.53',
requestedIPAddress: '',
serverIdentifier: '10.0.0.1',
};
const text = new TextDecoder().decode(encodeDhcpMessage(message));
expect(text).toBe(
'DHCPOFFER\nyiaddr=10.0.0.5\nchaddr=02:00:00:00:00:01\nrouter=10.0.0.1\nsubnetmask=255.255.255.0\ndnsserver=10.0.0.53\nserverident=10.0.0.1',
);
const { requestedIPAddress: _empty, ...withoutEmpty } = message;
expect(decodeDhcpMessage(new TextEncoder().encode(text))).toEqual(withoutEmpty);
expect(decodeDhcpMessage(new TextEncoder().encode('dhcpack\nyiaddr=1.2.3.4\nnoise'))).toEqual({ type: 'DHCPACK', yourIPAddress: '1.2.3.4' });
expect(decodeDhcpMessage(new TextEncoder().encode('HELLO'))).toBeUndefined();
});
});