Files
mdg-filius/packages/core/test/engine.test.ts
T
julian.kiedaischandClaude Opus 5.5 f5c61cdce7 feat(core): Java's IP and ICMP threads, measured with Java FILIUS
Every packet that comes in goes through its node's IP thread (UDP, TCP) or
ICMP thread (ICMP), as Java's IPThread and ICMPThread: deliver, answer an
echo request or forward, one after the other; while one waits for ARP, the
next wait behind it. A packet a node sends to itself is delivered at once,
as Java's IP.senden does. The transport send threads and these are one
mechanism now (handToProtocolThread).

ArpRun has three stories through the router (routedUdpBehind,
routedPingBesideUdp, routedPingBehindPing); the oracle writes ICMP frames
with type, code, addresses and sequence number. All seven ArpRun stories
match Java FILIUS.

Measured and not taken: Java's ARP wakes one waiter with notify, not the
one whose answer came; the core wakes all that wait for the address.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 18:32:48 +00:00

1860 lines
88 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,
decodeDnsMessage,
encodeDnsMessage,
findApplicableNameServerRecord,
NotPortedError,
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, letFramesArrive, link, loseFirst, 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 which cables are busy on stop', () => {
const [clientId, clientPortId, serverId, serverPortId] = [0x25, 0x26, 0x27, 0x28].map(uuid) as [string, string, string, string];
const input = {
nodes: [pc(clientId, clientPortId), pc(serverId, serverPortId)],
links: [link(clientId, clientPortId, serverId, serverPortId)],
deviceConfigurations: new Map([
[clientId, device('10.5.0.1')],
[serverId, device('10.5.0.2')],
]),
};
const engine = startedEngine(input);
engine.handle({ type: 'advance', toMilliseconds: 10_000 });
engine.openTcpServerSocket(serverId, 80);
expect(engine.connectTcp(engine.openTcpClientSocket(clientId, '10.5.0.2', 80)!)).toBe(true);
engine.handle({ type: 'stop' });
engine.handle({ type: 'start', snapshot: topologySnapshot(input), seed: 14, initialTimeMilliseconds: 0 });
engine.resolveMacAddress(clientId, '10.5.0.2');
expect(engine.state.packetTraces.find((trace) => trace.layer === 'physical' && trace.operation === 'sent')?.timeMilliseconds).toBe(0);
});
it('forgets the send threads on stop', () => {
const [firstId, firstPortId, secondId, secondPortId] = [0x29, 0x2a, 0x2b, 0x2c].map(uuid) as [string, string, string, string];
const input = {
nodes: [pc(firstId, firstPortId), pc(secondId, secondPortId)],
links: [link(firstId, firstPortId, secondId, secondPortId)],
deviceConfigurations: new Map([
[firstId, device('10.6.0.1')],
[secondId, device('10.6.0.2')],
]),
};
const engine = startedEngine(input);
// The UDP thread waits for ARP on .99 when the simulation stops.
engine.sendUdp(engine.bindUdpSocket(firstId, { remoteIPAddress: '10.6.0.99', remotePort: 9 })!, new TextEncoder().encode('lost'));
engine.handle({ type: 'stop' });
engine.handle({ type: 'start', snapshot: topologySnapshot(input), seed: 14, initialTimeMilliseconds: 0 });
const receiver = engine.bindUdpSocket(secondId, { localPort: 9 })!;
engine.sendUdp(engine.bindUdpSocket(firstId, { remoteIPAddress: '10.6.0.2', remotePort: 9 })!, new TextEncoder().encode('found'));
expect(engine.state.currentTimeMilliseconds).toBe(0);
expect(new TextDecoder().decode(engine.receiveUdp(receiver, 1_000)?.datagram.payload)).toBe('found');
});
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!, 1_000);
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('returns from a send at once, the datagram arrives as the clock advances (swiftSends: when it has arrived)', () => {
const arrival = (swiftSends: boolean) => {
const [sourceNodeId, sourcePortId, targetNodeId, targetPortId] = [0x15c1, 0x15c2, 0x15c3, 0x15c4].map(uuid) as [string, string, string, string];
const engine = new NetworkRuntimeEngine(14, undefined, { swiftSends });
engine.handle({
type: 'start',
snapshot: topologySnapshot({
nodes: [pc(sourceNodeId, sourcePortId), pc(targetNodeId, targetPortId)],
links: [link(sourceNodeId, sourcePortId, targetNodeId, targetPortId)],
deviceConfigurations: new Map([
[sourceNodeId, device('192.168.6.10')],
[targetNodeId, device('192.168.6.20')],
]),
}),
seed: 14,
initialTimeMilliseconds: 0,
});
const receiver = engine.bindUdpSocket(targetNodeId, { localPort: 9000 })!;
const sender = engine.bindUdpSocket(sourceNodeId, { remoteIPAddress: '192.168.6.20', remotePort: 9000 })!;
engine.sendUdp(sender, new TextEncoder().encode('hello'));
const returnedAt = engine.state.currentTimeMilliseconds;
const waiting = engine.state.udpReceiveQueuesBySocketId.get(receiver)?.length ?? 0;
// ARP there and back, then the datagram: three cables of 55 ms.
return { returnedAt, waiting, receivedAt: engine.receiveUdp(receiver, 1_000)?.receivedAtMilliseconds };
};
expect(arrival(false)).toEqual({ returnedAt: 0, waiting: 0, receivedAt: 165 });
expect(arrival(true)).toEqual({ returnedAt: 165, waiting: 1, receivedAt: 165 });
});
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, 1_000)?.senderIPAddress).toBe('10.0.0.1');
expect(engine.receiveUdp(receiverB, 1_000)?.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, 1_000)?.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'));
letFramesArrive(engine);
expect(switchTraces('switch unknown unicast flooding')).toHaveLength(2);
engine.sendEthernetFrame(hostBId, hostBPortId, frame(macB, macA));
letFramesArrive(engine);
expect(switchTraces('switch learned unicast forwarding')).toHaveLength(1);
engine.sendEthernetFrame(hostAId, hostAPortId, frame(macA, macA));
letFramesArrive(engine);
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);
letFramesArrive(engine);
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);
letFramesArrive(engine);
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');
// connect returns with the SYN+ACK; the client's ACK is still on its way.
expect(engine.state.socketsById.get(acceptedSocketId)?.tcpState).toBe('SYN_RCVD');
letFramesArrive(engine);
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("sends TCP in a thread of its own: UDP waiting for ARP does not hold a connection up", () => {
const { engine, clientId, serverId } = pair(0x1731, 0x1732, '10.24.0');
engine.openTcpServerSocket(serverId, 80);
engine.sendUdp(engine.bindUdpSocket(clientId, { remoteIPAddress: '10.24.0.99', remotePort: 9 })!, new TextEncoder().encode('nobody'));
const clientSocketId = engine.openTcpClientSocket(clientId, '10.24.0.2', 80)!;
expect(engine.connectTcp(clientSocketId)).toBe(true);
// ARP for the server (behind the one for .99 on the cable), SYN and SYN+ACK: four cables of 55 ms.
expect(engine.state.currentTimeMilliseconds).toBe(55 + 4 * 55);
});
it("holds the TCP thread while ARP waits: a connection's SYN waits behind a SYN+ACK to no one", () => {
const { engine, clientId, serverId } = pair(0x17e1, 0x17e2, '10.25.0');
engine.openTcpServerSocket(serverId, 80);
engine.openTcpServerSocket(clientId, 81);
// A SYN to the client's port 81 from .99, which no card has: the client's answer waits for ARP.
engine.sendIPv4Packet(serverId, {
identity: engine.allocatePacketIdentity(),
senderIPAddress: '10.25.0.99',
receiverIPAddress: '10.25.0.1',
timeToLive: 64,
protocol: 'tcp',
payload: {
kind: 'tcp',
segment: { sourcePort: 5000, destinationPort: 81, sequenceNumber: 1, acknowledgementNumber: 0, flags: TCP_FLAG.synchronize, payload: new Uint8Array() },
},
});
engine.handle({ type: 'advance', toMilliseconds: 200 });
const clientSocketId = engine.openTcpClientSocket(clientId, '10.25.0.2', 80)!;
expect(engine.connectTcp(clientSocketId)).toBe(true);
// The SYN arrived after ARP there and back (165 ms); ARP for .99 gives up two
// round trips later, then the client's SYN and the server's SYN+ACK.
expect(engine.state.currentTimeMilliseconds).toBe(165 + 2 * 2_750 + 2 * 55);
});
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('answers a repeated SYN with its SYN+ACK once more: a lost SYN+ACK costs one attempt (Java would close)', () => {
const { engine, clientId, serverId } = pair(0x17f1, 0x17f2, '10.26.0');
loseFirst(engine, serverId, (segment) => segment.flags === (TCP_FLAG.synchronize | TCP_FLAG.acknowledgement));
const listenerId = engine.openTcpServerSocket(serverId, 80)!;
const clientSocketId = engine.openTcpClientSocket(clientId, '10.26.0.2', 80)!;
expect(engine.connectTcpWithResult(clientSocketId)).toBe('connected');
// The second SYN leaves a timeout after the first was handed down, its SYN+ACK comes a cable later each way.
expect(engine.state.currentTimeMilliseconds).toBe(3 * 2_750 + 2 * 55);
letFramesArrive(engine);
const acceptedSocketId = engine.state.tcpAcceptedSocketIdsByListenerId.get(listenerId)![0]!;
expect(engine.state.socketsById.get(acceptedSocketId)?.tcpState).toBe('ESTABLISHED');
expect(engine.state.packetTraces.filter((trace) => trace.detail?.startsWith('TCP SYN+ACK') === true).map((trace) => trace.detail)).toEqual([
'TCP SYN+ACK',
'TCP SYN+ACK retransmission attempt 2',
]);
});
it("gives a handshake up after one timeout and closes with a FIN, sent once, then five waits", () => {
const { engine, clientId, serverId } = pair(0x17f5, 0x17f6, '10.27.0');
const listenerId = engine.openTcpServerSocket(serverId, 80)!;
// A SYN from the client's address, but no socket there: the SYN+ACK finds no one.
engine.sendIPv4Packet(clientId, {
identity: engine.allocatePacketIdentity(),
senderIPAddress: '10.27.0.1',
receiverIPAddress: '10.27.0.2',
timeToLive: 64,
protocol: 'tcp',
payload: {
kind: 'tcp',
segment: { sourcePort: 5000, destinationPort: 80, sequenceNumber: 1, acknowledgementNumber: 0, flags: TCP_FLAG.synchronize, payload: new Uint8Array() },
},
});
engine.handle({ type: 'advance', toMilliseconds: 60_000 });
const acceptedSocketId = engine.state.tcpAcceptedSocketIdsByListenerId.get(listenerId)![0]!;
const states = engine.state.packetTraces
.filter((trace) => trace.nodeId === serverId && trace.detail?.includes(' -> ') === true && !trace.detail.includes('LISTEN -> CLOSED') && !trace.detail.includes('CLOSED -> LISTEN'))
.map((trace) => [trace.timeMilliseconds, trace.detail]);
// The SYN arrives after ARP there and back: 165 ms.
expect(states).toEqual([
[165, 'TCP LISTEN -> SYN_RCVD'],
[165 + 3 * 2_750, 'TCP SYN_RCVD -> FIN_WAIT_1'],
[165 + 3 * 2_750 + 5 * 3 * 2_750, 'TCP FIN_WAIT_1 -> CLOSED'],
]);
expect(engine.state.socketsById.get(acceptedSocketId)?.tcpState).toBe('CLOSED');
expect(engine.state.packetTraces.filter((trace) => trace.nodeId === serverId && trace.layer === 'transport' && trace.detail?.startsWith('TCP ') === true && trace.operation !== 'accepted' && trace.operation !== 'dropped').map((trace) => trace.detail)).toEqual([
'TCP SYN+ACK',
'TCP FIN',
]);
});
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);
letFramesArrive(engine);
// 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('carries one frame after the other on a cable, as Java (Swift: all at once)', () => {
const departures = (swiftCables: boolean) => {
const [clientId, clientPortId, serverId, serverPortId] = [0x17a1, 0x17b1, 0x17a2, 0x17b2].map(uuid) as [string, string, string, string];
const engine = new NetworkRuntimeEngine(14, undefined, { swiftCables });
engine.handle({
type: 'start',
snapshot: topologySnapshot({
nodes: [pc(clientId, clientPortId), pc(serverId, serverPortId)],
links: [link(clientId, clientPortId, serverId, serverPortId)],
deviceConfigurations: new Map([
[clientId, device('10.23.0.1')],
[serverId, device('10.23.0.2')],
]),
}),
seed: 14,
initialTimeMilliseconds: 0,
});
engine.openTcpServerSocket(serverId, 80);
const client = engine.openTcpClientSocket(clientId, '10.23.0.2', 80)!;
expect(engine.connectTcp(client)).toBe(true);
letFramesArrive(engine);
const closedAt = engine.state.currentTimeMilliseconds;
engine.closeTcpSocket(client);
letFramesArrive(engine);
// The server answers the FIN with its ACK and, closing by itself, its FIN at once.
const physical = (nodeId: string, operation: string) =>
engine.state.packetTraces
.filter((trace) => trace.nodeId === nodeId && trace.layer === 'physical' && trace.operation === operation && trace.timeMilliseconds > closedAt)
.map((trace) => trace.timeMilliseconds - closedAt);
return { sent: physical(serverId, 'sent'), received: physical(clientId, 'received') };
};
expect(departures(false)).toEqual({ sent: [55, 110], received: [110, 165] });
expect(departures(true)).toEqual({ sent: [55, 55], received: [110, 110] });
});
it('queues a third frame behind the second on a busy cable', () => {
// Four DHCP clients start at once and broadcast their DISCOVER; the switch
// floods three of them onto each client's cable.
const switchId = uuid(0x17c0);
const hosts = [0, 1, 2, 3].map((index) => ({ id: uuid(0x17c1 + 2 * index), portId: uuid(0x17c2 + 2 * index), switchPortId: uuid(0x17d0 + index) }));
const engine = startedEngine({
nodes: [
{ id: switchId, kind: 'networkSwitch', ports: hosts.map((host, index) => portSnapshot(host.switchPortId, `sw${index + 1}`)) },
...hosts.map((host) => pc(host.id, host.portId)),
],
links: hosts.map((host) => link(host.id, host.portId, switchId, host.switchPortId)),
deviceConfigurations: new Map(hosts.map((host) => [host.id, device('0.0.0.0')])),
dhcpClientConfigurationsByNodeId: new Map(hosts.map((host) => [host.id, { isEnabled: true }])),
});
engine.handle({ type: 'advance', toMilliseconds: 1_000 });
const received = engine.state.packetTraces
.filter((trace) => trace.nodeId === hosts[0]!.id && trace.layer === 'physical' && trace.operation === 'received')
.map((trace) => trace.timeMilliseconds);
expect(received).toEqual([110, 165, 220]);
});
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);
letFramesArrive(engine);
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);
letFramesArrive(engine);
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();
});
});
describe('DNS', () => {
const lan = (records: Record<string, string[]>, recursive = false) => {
// Hosts 10.30.0.1 (the client, DNS server .2), .2, .3 on one switch.
const switchId = uuid(0x1900);
const hosts = [1, 2, 3].map((index) => ({ id: uuid(0x1900 + 2 * index), portId: uuid(0x1901 + 2 * index), switchPortId: uuid(0x1910 + index) }));
const ip = (index: number) => `10.30.0.${index + 1}`;
const record = (line: string) => {
const [name = '', type = 'A', target = ''] = line.split(' ');
return { name, type: type as 'A' | 'MX' | 'NS', ttlSeconds: 3600, target };
};
const engine = startedEngine({
nodes: [
{ id: switchId, kind: 'networkSwitch', ports: hosts.map((host, index) => portSnapshot(host.switchPortId, `sw${index + 1}`)) },
...hosts.map((host) => pc(host.id, host.portId)),
],
links: hosts.map((host) => link(host.id, host.portId, switchId, host.switchPortId)),
deviceConfigurations: new Map(hosts.map((host, index) => [host.id, deviceConfiguration(ip(index), '255.255.255.0', '', index === 0 ? '10.30.0.2' : '')])),
dnsServerConfigurationsByNodeId: new Map(
Object.entries(records).map(([address, lines]) => [
hosts[Number(address.split('.')[3]) - 1]!.id,
{ recursiveResolutionEnabled: recursive, records: lines.map(record) },
]),
),
});
return { engine, client: hosts[0]!.id, server: hosts[1]!.id };
};
const queries = (engine: NetworkRuntimeEngine) =>
engine.state.packetTraces.filter((trace) => trace.detail?.startsWith('DNS query') === true).map((trace) => trace.detail);
it("writes and reads Java's DNS messages", () => {
const message = {
id: 4711,
queryResponse: 1,
responseCode: 0,
questions: [],
answers: [
{ name: 'filius.de.', type: 'MX', ttl: 3600, data: 'mail.filius.de.' },
{ name: 'mail.filius.de.', type: 'A', ttl: 3600, data: '10.0.0.5' },
],
authority: [],
additional: [],
};
const text = encodeDnsMessage(message);
expect(text).toBe(
'ID=4711 QR=1 RCODE=0 QDCOUNT=0 ANCOUNT=2 NSCOUNT=0 ARCOUNT=0 \nfilius.de. MX 3600 mail.filius.de.\nmail.filius.de. A 3600 10.0.0.5\n',
);
expect(decodeDnsMessage(text)).toEqual(message);
// Java's StringTokenizer skips empty pieces; the counts say how many lines follow.
expect(decodeDnsMessage('ID=7 QR=0 QDCOUNT=1\n\nweb.filius.de. A IN\nleft over\n')).toEqual({
id: 7,
queryResponse: 0,
responseCode: 0,
questions: [{ name: 'web.filius.de.', type: 'A', questionClass: 'IN' }],
answers: [],
authority: [],
additional: [],
});
});
it('finds the name server of the longest zone, else the root one', () => {
const records = [
{ name: '.', type: 'NS', ttl: 1, data: 'root.' },
{ name: 'de.', type: 'NS', ttl: 1, data: 'ns.de.' },
{ name: 'Schule.DE.', type: 'NS', ttl: 1, data: 'ns.schule.de.' },
];
expect(findApplicableNameServerRecord('www.schule.de.', records)?.data).toBe('ns.schule.de.');
expect(findApplicableNameServerRecord('www.filius.de.', records)?.data).toBe('ns.de.');
expect(findApplicableNameServerRecord('www.filius.org.', records)?.data).toBe('root.');
expect(findApplicableNameServerRecord('www.filius.org.', records.slice(1))).toBeUndefined();
});
it('answers localhost itself, and nothing without a DNS server', () => {
const { engine, client } = lan({ '10.30.0.2': [] });
expect(engine.resolveDnsAddress(client, 'LocalHost')).toEqual({ type: 'resolved', address: '127.0.0.1' });
expect(engine.resolveDnsAddress(uuid(0x1904), 'web.filius.de')).toEqual({ type: 'resolved', address: undefined });
expect(queries(engine)).toEqual([]);
});
it('follows referrals, and asks the first server once more (Java remembers only the ones it was referred to)', () => {
const { engine, client } = lan({
'10.30.0.2': ['schule.de NS ns.schule.de.', 'ns.schule.de A 10.30.0.3'],
'10.30.0.3': ['schule.de NS ns2.schule.de.', 'ns2.schule.de A 10.30.0.2'],
});
expect(engine.resolveDnsAddress(client, 'www.schule.de')).toEqual({ type: 'resolved', address: undefined });
expect(queries(engine)).toEqual(['DNS query www.schule.de. A', 'DNS query www.schule.de. A', 'DNS query www.schule.de. A']);
expect([...engine.state.socketsById.values()].filter((socket) => socket.nodeId === client)).toEqual([]);
});
it('receives a datagram when it comes, not at the end of the wait', () => {
// A DHCP client without a server sends its DISCOVER again after a round trip (a timer, not the network).
const [clientId, clientPortId, listenerId, listenerPortId] = [0x1981, 0x1982, 0x1983, 0x1984].map(uuid) as [string, string, string, string];
const engine = startedEngine({
nodes: [pc(clientId, clientPortId), pc(listenerId, listenerPortId)],
links: [link(clientId, clientPortId, listenerId, listenerPortId)],
deviceConfigurations: new Map([
[clientId, device('0.0.0.0')],
[listenerId, device('10.31.0.2')],
]),
dhcpClientConfigurationsByNodeId: new Map([[clientId, { isEnabled: true }]]),
});
const socket = engine.bindUdpSocket(listenerId, { localPort: 67 })!;
engine.handle({ type: 'advance', toMilliseconds: 100 });
expect(engine.receiveUdp(socket)).toBeDefined();
expect(engine.receiveUdp(socket, 10_000)).toBeDefined();
expect(engine.state.currentTimeMilliseconds).toBe(2_750 + 55);
});
it('returns from the answer, not at the end of the wait', () => {
const { engine, client } = lan({ '10.30.0.2': ['web.filius.de A 10.30.0.9'] });
const called = engine.state.currentTimeMilliseconds;
expect(engine.resolveDnsAddress(client, 'web.filius.de')).toEqual({ type: 'resolved', address: '10.30.0.9' });
// ARP there and back, then the query and its answer: four cables each way through the switch.
expect(engine.state.currentTimeMilliseconds - called).toBe(4 * 110);
});
it("answers behind a datagram of its own whose ARP fails: Java's UDP thread sends one after the other", () => {
const { engine, client, server } = lan({ '10.30.0.2': ['web.filius.de A 10.30.0.9'] });
const socket = engine.bindUdpSocket(server, { remoteIPAddress: '10.30.0.99', remotePort: 9 })!;
const called = engine.state.currentTimeMilliseconds;
engine.sendUdp(socket, new TextEncoder().encode('nobody'));
expect(engine.resolveDnsAddress(client, 'web.filius.de')).toEqual({ type: 'resolved', address: '10.30.0.9' });
// The answer leaves when ARP has given up on .99 (two round trips), and takes two cables through the switch.
expect(engine.state.currentTimeMilliseconds - called).toBe(2 * 2_750 + 110);
});
it('lets the senders that wait take their turns in the order they came, before the thread takes the next', () => {
const { engine, client, server } = lan({ '10.30.0.2': ['web.filius.de A 10.30.0.9'] });
const receiver = engine.bindUdpSocket(client, { localPort: 9 })!;
// The server's UDP thread waits for ARP on .99 from 0 to 5500 ms.
engine.sendUdp(engine.bindUdpSocket(server, { remoteIPAddress: '10.30.0.99', remotePort: 9 })!, new TextEncoder().encode('nobody'));
// A query from .98, which no card has, reaches the server at 330 ms; its answer waits its turn.
const query = encodeDnsMessage({
id: 1,
queryResponse: 0,
responseCode: 0,
questions: [{ name: 'web.filius.de.', type: 'A', questionClass: 'IN' }],
answers: [],
authority: [],
additional: [],
});
engine.sendIPv4Packet(client, {
identity: engine.allocatePacketIdentity(),
senderIPAddress: '10.30.0.98',
receiverIPAddress: '10.30.0.2',
timeToLive: 64,
protocol: 'udp',
payload: { kind: 'udp', datagram: { sourcePort: 5000, destinationPort: 53, payload: new TextEncoder().encode(query) } },
});
engine.handle({ type: 'advance', toMilliseconds: 400 });
// As with a fair lock (Java's is not): the send returns when the datagram
// in hand is out; the answer to .98 goes before it and waits for ARP in turn.
engine.sendUdp(engine.bindUdpSocket(server, { remoteIPAddress: '10.30.0.1', remotePort: 9 })!, new TextEncoder().encode('third'));
expect(engine.state.currentTimeMilliseconds).toBe(2 * 2_750);
expect(engine.receiveUdp(receiver, 10_000)?.receivedAtMilliseconds).toBe(4 * 2_750 + 110);
});
it('gives up after five round trips when no server answers, and closes its socket', () => {
const { engine, client } = lan({ '10.30.0.2': [] });
const called = engine.state.currentTimeMilliseconds;
// 10.30.0.3 is there, but no DNS server runs on it.
expect(engine.resolveDns(client, 'web.filius.de', 'A', '10.30.0.3')).toEqual({ type: 'timedOut' });
// Java's five round trips, from handing the query down: the send returns at once.
expect(engine.state.currentTimeMilliseconds - called).toBe(5 * 2_750);
expect([...engine.state.socketsById.values()].filter((socket) => socket.nodeId === client)).toEqual([]);
});
it('does not answer recursively yet', () => {
expect(() => lan({ '10.30.0.2': [] }, true)).toThrow(NotPortedError);
});
});
describe('ARP', () => {
const requestsFor = (engine: NetworkRuntimeEngine, ipAddress: string) =>
engine.state.packetTraces.filter((trace) => trace.detail === `ARP request for ${ipAddress}`).map((trace) => trace.timeMilliseconds);
it('asks twice, a round trip apart, then gives up', () => {
const { engine, sourceNodeId } = routedTopology();
const called = engine.state.currentTimeMilliseconds;
expect(engine.resolveMacAddress(sourceNodeId, '10.0.0.9')).toBeUndefined();
expect(requestsFor(engine, '10.0.0.9')).toEqual([called, called + 2_750]);
expect(engine.state.currentTimeMilliseconds).toBe(called + 2 * 2_750);
});
it('lets a router answer host unreachable for an echo request, not for an echo reply', () => {
const { engine, sourceNodeId } = routedTopology();
const toNowhere = (kind: 'echoRequest' | 'echoReply', identity: number): IPv4Packet => ({
identity,
senderIPAddress: '10.0.0.2',
receiverIPAddress: '20.0.0.9',
timeToLive: 64,
protocol: 'icmp',
payload: { kind: 'icmp', message: icmpMessage(kind, { identifier: identity, sequenceNumber: 1, data: new Uint8Array() }) },
});
const observed = () => (engine.state.icmpObservationsByNodeId.get(sourceNodeId) ?? []).map((each) => each.message.kind);
engine.sendIPv4Packet(sourceNodeId, toNowhere('echoReply', 901));
engine.handle({ type: 'advance', toMilliseconds: 10_000 });
expect(observed()).toEqual([]);
engine.sendIPv4Packet(sourceNodeId, toNowhere('echoRequest', 902));
engine.handle({ type: 'advance', toMilliseconds: 20_000 });
expect(observed()).toEqual(['destinationHostUnreachable']);
});
it('forgets the lookups that wait on stop', () => {
const [firstId, firstPortId, secondId, secondPortId] = [0x1a01, 0x1a02, 0x1a03, 0x1a04].map(uuid) as [string, string, string, string];
const input = (secondIp: string) => ({
nodes: [pc(firstId, firstPortId), pc(secondId, secondPortId)],
links: [link(firstId, firstPortId, secondId, secondPortId)],
deviceConfigurations: new Map([
[firstId, device('10.0.0.2')],
[secondId, device(secondIp)],
]),
});
const engine = startedEngine(input('10.0.0.8'));
const socket = engine.bindUdpSocket(firstId, { remoteIPAddress: '10.0.0.9', remotePort: 9 })!;
engine.sendUdp(socket, new TextEncoder().encode('lost'));
engine.handle({ type: 'stop' });
engine.handle({ type: 'start', snapshot: topologySnapshot(input('10.0.0.9')), seed: 14, initialTimeMilliseconds: 0 });
expect(engine.resolveMacAddress(firstId, '10.0.0.9')).toBeDefined();
engine.handle({ type: 'advance', toMilliseconds: 1_000 });
expect(engine.state.packetTraces.filter((trace) => trace.layer === 'network' && trace.operation === 'sent')).toEqual([]);
});
});
describe("Java's IP and ICMP threads", () => {
const twoPcs = (prefix: string, base: number, options: { secondIsGateway?: boolean; firewall?: FirewallConfiguration } = {}) => {
const [firstId, firstPortId, secondId, secondPortId] = [base, base + 1, base + 2, base + 3].map(uuid) as [string, string, string, string];
const engine = startedEngine({
nodes: [pc(firstId, firstPortId), pc(secondId, secondPortId)],
links: [link(firstId, firstPortId, secondId, secondPortId)],
deviceConfigurations: new Map([
[firstId, device(`${prefix}.1`, options.secondIsGateway === true ? { gateway: `${prefix}.2` } : {})],
[secondId, device(`${prefix}.2`)],
]),
...(options.firewall === undefined ? {} : { firewallConfigurationsByNodeId: new Map([[secondId, options.firewall]]) }),
});
return { engine, firstId, secondId };
};
const text = (payload: Uint8Array | undefined) => new TextDecoder().decode(payload ?? new Uint8Array());
it('holds the ICMP thread while an echo reply waits for ARP: the next echo request waits behind it', () => {
const { engine, firstId } = twoPcs('10.29.0', 0x1b01);
// An echo request from .99, which no card has: the answer waits for ARP.
engine.sendIPv4Packet(firstId, {
identity: engine.allocatePacketIdentity(),
senderIPAddress: '10.29.0.99',
receiverIPAddress: '10.29.0.2',
timeToLive: 64,
protocol: 'icmp',
payload: { kind: 'icmp', message: icmpMessage('echoRequest', { identifier: 7, sequenceNumber: 7, data: new Uint8Array() }) },
});
engine.handle({ type: 'advance', toMilliseconds: 200 });
const replies = () => (engine.state.icmpObservationsByNodeId.get(firstId) ?? []).filter((observation) => observation.message.kind === 'echoReply');
engine.sendIcmpEcho(firstId, '10.29.0.2');
expect(replies()).toEqual([]);
// The request came at 165 ms after ARP there and back; ARP for .99 gives up two round trips later, then the reply takes a cable.
engine.handle({ type: 'advance', toMilliseconds: 165 + 2 * 2_750 + 55 - 1 });
expect(replies()).toEqual([]);
engine.handle({ type: 'advance', toMilliseconds: 165 + 2 * 2_750 + 55 });
expect(replies().map((observation) => observation.nodeId)).toEqual([firstId]);
});
it('frees the IP thread of a PC that drops a packet it would have to forward', () => {
const { engine, firstId, secondId } = twoPcs('10.30.1', 0x1b11, { secondIsGateway: true });
const receiver = engine.bindUdpSocket(secondId, { localPort: 9 })!;
engine.sendUdp(engine.bindUdpSocket(firstId, { remoteIPAddress: '20.0.0.5', remotePort: 9 })!, new TextEncoder().encode('elsewhere'));
engine.sendUdp(engine.bindUdpSocket(firstId, { remoteIPAddress: '10.30.1.2', remotePort: 9 })!, new TextEncoder().encode('here'));
expect(text(engine.receiveUdp(receiver, 1_000)?.datagram.payload)).toBe('here');
expect(engine.state.packetTraces.some((trace) => trace.nodeId === secondId && trace.detail === 'IPv4 forwarding disabled')).toBe(true);
});
it('frees the IP thread of a PC whose firewall drops a packet', () => {
const firewall = firewallConfiguration({ isActive: true, defaultPolicy: 'drop', rules: [firewallRule({ port: 9, protocol: 'udp', action: 'accept' })] });
const { engine, firstId, secondId } = twoPcs('10.30.2', 0x1b21, { firewall });
const receiver = engine.bindUdpSocket(secondId, { localPort: 9 })!;
engine.bindUdpSocket(secondId, { localPort: 8 });
engine.sendUdp(engine.bindUdpSocket(firstId, { remoteIPAddress: '10.30.2.2', remotePort: 8 })!, new TextEncoder().encode('blocked'));
engine.sendUdp(engine.bindUdpSocket(firstId, { remoteIPAddress: '10.30.2.2', remotePort: 9 })!, new TextEncoder().encode('let in'));
expect(text(engine.receiveUdp(receiver, 1_000)?.datagram.payload)).toBe('let in');
expect(engine.state.firewallDecisions.some((decision) => !decision.accepted)).toBe(true);
});
it('delivers what a router sends to itself at once, while its IP thread waits for ARP', () => {
const { engine, sourceNodeId, routerNodeId } = routedTopology();
// The router forwards to 20.0.0.9, which no card has: its IP thread waits two round trips.
engine.sendUdp(engine.bindUdpSocket(sourceNodeId, { remoteIPAddress: '20.0.0.9', remotePort: 9 })!, new TextEncoder().encode('nobody'));
engine.handle({ type: 'advance', toMilliseconds: 500 });
const receiver = engine.bindUdpSocket(routerNodeId, { localPort: 9 })!;
engine.sendUdp(engine.bindUdpSocket(routerNodeId, { remoteIPAddress: '10.0.0.1', remotePort: 9 })!, new TextEncoder().encode('self'));
expect(text(engine.receiveUdp(receiver)?.datagram.payload)).toBe('self');
expect(engine.state.currentTimeMilliseconds).toBe(500);
});
});