Files
mdg-filius/packages/core/test/engine.test.ts
T
julian.kiedaischandClaude Opus 5.5 0abce60aef test: F2 acceptance — five of FILIUS' examples run as Java FILIUS runs them
ExampleRun opens an example as FILIUS opens it (SzenarioVerwaltung.laden
into the window's lists), starts it as the action mode does, plays a
story and writes every card's frames and tables, the calls, the
addresses and the RIP tables' changes. examplesJava.test.ts reads the
same file with this package's reader and runs it in the core
(projectSnapshot, new): routing (internetwork_komplett), DHCP
(einfaches_rechnernetz_komplett), DNS (dns_server), NAT
(nat_with_mapped_port) and firewall (Internet_Komplett_..._DE) match
card by card.

Found and fixed: a host's ipForwardingEnabled, which Java saves and
obeys, was unknown to the reader, the model, the core, the writer, the
export and FlsOracle; and Java's ARP learns only from a request for its
own address or a reply. Named Java bugs: a ping without a gateway throws
a NullPointerException; a home router drops the answer of a second
remote end. Open: Java picks between equally long RIP routes by thread
timing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 15:57:10 +00:00

2622 lines
130 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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,
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,
ripSeed,
NetworkRuntimeEngine,
captureRetentionPolicy,
portSnapshot,
stableMacAddress,
switchConfiguration,
TCP_FLAG,
tcpFlagDescription,
topologySnapshot,
traceRetentionPolicy,
UNSPECIFIED_MAC_ADDRESS,
type DeviceConfiguration,
type DhcpServerConfiguration,
type EthernetFrame,
type EthernetPayload,
type FirewallConfiguration,
type NatEntry,
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('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', () => {
// Measured on OpenJDK 21 (30.09.2026): (int) (1000 * 30 * (r.nextFloat() / 3 + 0.84)).
const random = new JavaCompatibleRandom(42);
expect(Array.from({ length: 5 }, () => random.nextRipJitterMilliseconds())).toEqual([
32_475, 25_746, 32_032, 25_679, 28_287,
]);
// new Random(7), the 530th draw: 28145 with Java's float division, 28144 in double.
const seven = new JavaCompatibleRandom(7);
for (let draw = 0; draw < 529; draw += 1) seven.nextFloat();
expect(seven.nextRipJitterMilliseconds()).toBe(28_145);
});
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, portIds: hosts.map((host) => host.portId) };
};
/** A host tells everyone its address with an ARP reply to all (as Java answers a probe from 0.0.0.0): every card learns it. */
const announce = (engine: NetworkRuntimeEngine, nodeId: string, portId: string, ipAddress: string) => {
const mac = stableMacAddress(portId);
engine.sendEthernetFrame(nodeId, portId, {
identity: engine.allocateFrameIdentity(),
sourceMacAddress: mac,
destinationMacAddress: ETHERNET_BROADCAST_MAC_ADDRESS,
payload: {
kind: 'arp',
packet: { operation: 'reply', senderMacAddress: mac, senderIPAddress: ipAddress, targetMacAddress: ETHERNET_BROADCAST_MAC_ADDRESS, targetIPAddress: '255.255.255.255' },
},
});
letFramesArrive(engine);
};
/** 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, portIds } = 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 });
announce(engine, hostIds[1]!, portIds[1]!, known);
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 reply comes (Java would stop waiting)', () => {
const { engine, hostIds, portIds } = 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');
announce(engine, hostIds[2]!, portIds[2]!, '192.168.0.30');
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);
expect(engine.state.pendingEvents.some((event) => event.kind.kind === 'dnsQueryTimeout')).toBe(false);
});
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('answers every question of a query, one after the other', () => {
const { engine, client } = lan({ '10.30.0.2': ['web.filius.de A 10.30.0.9', 'mail.filius.de A 10.30.0.10'] });
const socket = engine.bindUdpSocket(client, { remoteIPAddress: '10.30.0.2', remotePort: 53 })!;
const query = encodeDnsMessage({
id: 9,
queryResponse: 0,
responseCode: 0,
questions: [
{ name: 'web.filius.de.', type: 'A', questionClass: 'IN' },
{ name: 'mail.filius.de.', type: 'A', questionClass: 'IN' },
],
answers: [],
authority: [],
additional: [],
});
engine.sendUdp(socket, new TextEncoder().encode(query));
const response = decodeDnsMessage(new TextDecoder().decode(engine.receiveUdp(socket, 10_000)!.datagram.payload));
expect(response.answers.map((record) => record.data)).toEqual(['10.30.0.9', '10.30.0.10']);
});
it('forgets the DNS queries that wait on stop', () => {
const records = { '10.30.0.2': ['schule.de NS ns.schule.de.', 'ns.schule.de A 10.30.0.3'] };
const { engine, client } = lan(records, true);
// The client gives up; the server still waits for .3, which runs no DNS server, when the simulation stops.
expect(engine.resolveDnsAddress(client, 'www.schule.de')).toEqual({ type: 'timedOut' });
const snapshot = engine.state.topologySnapshot;
engine.handle({ type: 'stop' });
engine.handle({ type: 'start', snapshot, seed: 14, initialTimeMilliseconds: 0 });
// The sockets are numbered anew: the second takes the number of the server's query socket before.
engine.bindUdpSocket(client, { localPort: 7000 });
const receiver = engine.bindUdpSocket(client, { localPort: 7001 })!;
const sender = engine.bindUdpSocket(uuid(0x1906), { remoteIPAddress: '10.30.0.1', remotePort: 7001 })!;
engine.sendUdp(sender, new TextEncoder().encode('still mine'));
expect(new TextDecoder().decode(engine.receiveUdp(receiver, 10_000)?.datagram.payload)).toBe('still mine');
});
it('asks on for a name it does not know, and answers with what it learns', () => {
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': ['www.schule.de A 10.30.0.40'] },
true,
);
const called = engine.state.currentTimeMilliseconds;
expect(engine.resolveDnsAddress(client, 'www.schule.de')).toEqual({ type: 'resolved', address: '10.30.0.40' });
// ARP, query and answer between client and server, ARP, query and answer between server and .3,
// and the answer to the client: eight crossings of the switch, 110 ms each.
expect(queries(engine)).toEqual(['DNS query www.schule.de. A', 'DNS query www.schule.de. A']);
expect(engine.state.currentTimeMilliseconds - called).toBe(8 * 110);
});
it('asks neither itself as the name server of its own zone nor as its own DNS server: it refers (Java asks itself without end)', () => {
const { engine, client } = lan({ '10.30.0.2': ['filius.de NS ns.filius.de.', 'ns.filius.de A 10.30.0.2'] }, true);
const called = engine.state.currentTimeMilliseconds;
expect(engine.resolveDnsAddress(client, 'nix.filius.de')).toEqual({ type: 'resolved', address: undefined });
// The client follows the referral to the server once more: two exchanges, the first with ARP.
expect(queries(engine)).toEqual(['DNS query nix.filius.de. A', 'DNS query nix.filius.de. A']);
expect(engine.state.currentTimeMilliseconds - called).toBe(4 * 110 + 2 * 110);
});
it('gives up on a name server that does not answer after five round trips, when the client has given up too', () => {
const { engine, client } = lan({ '10.30.0.2': ['schule.de NS ns.schule.de.', 'ns.schule.de A 10.30.0.3'] }, true);
const called = engine.state.currentTimeMilliseconds;
// .3 runs no DNS server.
expect(engine.resolveDnsAddress(client, 'www.schule.de')).toEqual({ type: 'timedOut' });
expect(engine.state.currentTimeMilliseconds - called).toBe(5 * 2_750);
// The server's own wait ends later, then it refers the client, which no longer listens.
engine.handle({ type: 'advance', toMilliseconds: engine.state.currentTimeMilliseconds + 5 * 2_750 });
expect(engine.state.packetTraces.filter((trace) => trace.detail === 'DNS response')).toHaveLength(1);
expect([...engine.state.socketsById.values()].filter((socket) => socket.nodeId === client)).toEqual([]);
});
});
describe('IP forwarding on a host', () => {
// A on the switch with H, whose IP forwarding may be on; H's gateway is the router to 20.0.0.0/24.
function hostRouting(forwards: boolean | undefined) {
const [hostA, hostH, routerId, hostB] = [0x3001, 0x3002, 0x3003, 0x3004].map(uuid) as [string, string, string, string];
const [portA, portH, routerLan, routerWan, portB] = [0x3011, 0x3012, 0x3013, 0x3014, 0x3015].map(uuid) as [string, string, string, string, string];
const switchId = uuid(0x3005);
const switchPorts = [1, 2, 3].map((index) => portSnapshot(uuid(0x3020 + index), `sw${index}`));
const engine = startedEngine({
nodes: [
pc(hostA, portA),
pc(hostH, portH),
{ id: routerId, kind: 'router', ports: [portSnapshot(routerLan, 'rt1'), portSnapshot(routerWan, 'rt2')] },
pc(hostB, portB),
{ id: switchId, kind: 'networkSwitch', ports: switchPorts },
],
links: [
link(hostA, portA, switchId, switchPorts[0]!.id),
link(hostH, portH, switchId, switchPorts[1]!.id),
link(routerId, routerLan, switchId, switchPorts[2]!.id),
link(routerId, routerWan, hostB, portB),
],
deviceConfigurations: new Map([
[hostA, device('10.0.0.5', { gateway: '10.0.0.1' })],
[hostH, device('10.0.0.1', { gateway: '10.0.0.254' })],
[routerId, device('10.0.0.254')],
[hostB, device('20.0.0.5', { gateway: '20.0.0.254' })],
]),
interfaceConfigurations: new Map([
[interfaceKey(routerId, routerLan), { ipAddress: '10.0.0.254', subnetMask: '255.255.255.0' }],
[interfaceKey(routerId, routerWan), { ipAddress: '20.0.0.254', subnetMask: '255.255.255.0' }],
]),
...(forwards === undefined ? {} : { ipForwardingByNodeId: new Map([[hostH, forwards]]) }),
});
return { engine, hostA };
}
it('forwards for others only when its IP forwarding is on (Java ipForwardingEnabled), as a router', () => {
for (const [forwards, delivered] of [
[undefined, false],
[false, false],
[true, true],
] as const) {
const { engine, hostA } = hostRouting(forwards);
engine.sendIcmpEcho(hostA, '20.0.0.5');
const answered = (engine.state.icmpObservationsByNodeId.get(hostA) ?? []).some((each) => each.message.kind === 'echoReply');
expect(answered, String(forwards)).toBe(delivered);
}
});
});
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("learns only from a request for its own address and from a reply, and drops one from the network's address (Java ARPThread)", () => {
const [hostA, hostB, hostC] = [0x2f01, 0x2f02, 0x2f03].map(uuid) as [string, string, string];
const [portA, portB, portC] = [0x2f11, 0x2f12, 0x2f13].map(uuid) as [string, string, string];
const switchId = uuid(0x2f04);
const switchPorts = [1, 2, 3].map((index) => portSnapshot(uuid(0x2f20 + index), `sw${index}`));
const engine = startedEngine({
nodes: [pc(hostA, portA), pc(hostB, portB), pc(hostC, portC), { id: switchId, kind: 'networkSwitch', ports: switchPorts }],
links: [link(hostA, portA, switchId, switchPorts[0]!.id), link(hostB, portB, switchId, switchPorts[1]!.id), link(hostC, portC, switchId, switchPorts[2]!.id)],
deviceConfigurations: new Map([
[hostA, device('10.0.0.1')],
[hostB, device('10.0.0.2')],
[hostC, device('10.0.0.3')],
]),
});
const knows = (nodeId: string, ipAddress: string) => engine.state.arpCachesByNodeId.get(nodeId)?.has(ipAddress) === true;
expect(engine.resolveMacAddress(hostA, '10.0.0.2')).toBe(stableMacAddress(portB));
expect([knows(hostB, '10.0.0.1'), knows(hostA, '10.0.0.2'), knows(hostC, '10.0.0.1')]).toEqual([true, true, false]);
// A request from the network's own address: neither learned nor answered.
engine.sendEthernetFrame(hostA, portA, {
identity: engine.allocateFrameIdentity(),
sourceMacAddress: stableMacAddress(portA),
destinationMacAddress: ETHERNET_BROADCAST_MAC_ADDRESS,
payload: {
kind: 'arp',
packet: { operation: 'request', senderMacAddress: stableMacAddress(portA), senderIPAddress: '10.0.0.0', targetMacAddress: ETHERNET_BROADCAST_MAC_ADDRESS, targetIPAddress: '10.0.0.3' },
},
});
letFramesArrive(engine);
expect(knows(hostC, '10.0.0.0')).toBe(false);
expect(engine.capture.frames(portC).filter((each) => each.frame.sourceMacAddress === stableMacAddress(portC))).toEqual([]);
});
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);
});
});
describe('RIP', () => {
const [routerAId, routerAShared, routerALan, routerBId, routerBShared, routerBLan] = [0x1501, 0x1511, 0x1512, 0x1502, 0x1513, 0x1514].map(uuid) as [
string,
string,
string,
string,
string,
string,
];
const [lanPcId, lanPcPort] = [0x1503, 0x1515].map(uuid) as [string, string];
/**
* Router A (10.0.0.1, 192.168.0.1/24) and B (10.0.0.2, 172.16.0.1/16) on
* one cable, both with RIP; a PC on A's LAN if asked for.
*/
const ripTopology = (options: { manualRoutes?: boolean; lanPc?: boolean } = {}) =>
startedEngine(
{
nodes: [
{ id: routerAId, kind: 'router', ports: [portSnapshot(routerAShared, 'rt1'), portSnapshot(routerALan, 'rt2')] },
{ id: routerBId, kind: 'router', ports: [portSnapshot(routerBShared, 'rt1'), portSnapshot(routerBLan, 'rt2')] },
...(options.lanPc === true ? [pc(lanPcId, lanPcPort)] : []),
],
links: [
link(routerAId, routerAShared, routerBId, routerBShared),
...(options.lanPc === true ? [link(routerAId, routerALan, lanPcId, lanPcPort)] : []),
],
...(options.lanPc === true ? { deviceConfigurations: new Map([[lanPcId, device('192.168.0.2', { gateway: '192.168.0.1' })]]) } : {}),
interfaceConfigurations: new Map([
[interfaceKey(routerAId, routerAShared), { ipAddress: '10.0.0.1', subnetMask: '255.255.255.0' }],
[interfaceKey(routerAId, routerALan), { ipAddress: '192.168.0.1', subnetMask: '255.255.255.0' }],
[interfaceKey(routerBId, routerBShared), { ipAddress: '10.0.0.2', subnetMask: '255.255.255.0' }],
[interfaceKey(routerBId, routerBLan), { ipAddress: '172.16.0.1', subnetMask: '255.255.0.0' }],
]),
...(options.manualRoutes === true
? { manualRoutesByNodeId: new Map([[routerAId, [{ destinationNetwork: '203.0.113.0', subnetMask: '255.255.255.0', gateway: '10.0.0.2', interfaceIPAddress: '10.0.0.1' }]]]) }
: {}),
ripEnabledByNodeId: new Map([
[routerAId, true],
[routerBId, true],
]),
},
15,
);
const route = (engine: NetworkRuntimeEngine, nodeId: string, network: string) =>
engine.state.ripTablesByNodeId.get(nodeId)?.find((each) => each.destinationNetwork === network);
const beacons = (engine: NetworkRuntimeEngine, nodeId: string) =>
engine.state.pendingEvents.filter((event) => event.kind.kind === 'ripBeacon' && event.kind.nodeId === nodeId).map((event) => event.deadlineMilliseconds);
const advertisements = (engine: NetworkRuntimeEngine, receiverId: string) =>
engine.state.packetTraces
.filter((trace) => trace.nodeId === receiverId && trace.detail === 'UDP queued' && trace.beforeHeaders.some((header) => header.name === 'destinationPort' && header.value === '520'))
.map((trace) => trace.timeMilliseconds);
/** When the router sent its table (on its first interface). */
const sentTables = (engine: NetworkRuntimeEngine, nodeId: string) =>
engine.state.packetTraces
.filter((trace) => trace.nodeId === nodeId && trace.detail?.startsWith('RIP advertisement') === true)
.map((trace) => trace.timeMilliseconds)
.filter((time, index, times) => times.indexOf(time) === index);
/** A message as a router at 10.0.0.2 sends it, from any port, straight to A: the time it arrives. */
const fromB = (engine: NetworkRuntimeEngine, text: string, sourcePort = 521): number => {
const identity = engine.allocatePacketIdentity();
engine.sendIPv4Packet(routerBId, {
identity,
senderIPAddress: '10.0.0.2',
receiverIPAddress: '10.0.0.1',
timeToLive: 1,
protocol: 'udp',
payload: { kind: 'udp', datagram: { sourcePort, destinationPort: 520, payload: new TextEncoder().encode(text) } },
});
while (!(engine.state.deliveredIPv4PacketsByNodeId.get(routerAId) ?? []).some((delivered) => delivered.packet.identity === identity)) {
engine.handle({ type: 'advance', toMilliseconds: Math.min(...engine.state.pendingEvents.map((event) => event.deadlineMilliseconds)) });
}
return engine.state.currentTimeMilliseconds;
};
it('starts with a route of hop count 0 to each own network that never expires, the first beacon after a second', () => {
const engine = ripTopology();
expect(engine.state.ripTablesByNodeId.get(routerAId)).toEqual([
{ destinationNetwork: '10.0.0.0', subnetMask: '255.255.255.0', nextHop: '10.0.0.1', interfaceIPAddress: '10.0.0.1', origin: 'connected', isEditable: false, metric: 0 },
{ destinationNetwork: '192.168.0.0', subnetMask: '255.255.255.0', nextHop: '192.168.0.1', interfaceIPAddress: '192.168.0.1', origin: 'connected', isEditable: false, metric: 0 },
]);
expect(beacons(engine, routerAId)).toEqual([1_000]);
// The server's socket on 520, the beacon's on 521.
expect(engine.bindUdpSocket(routerAId, { localPort: 520 })).toBeUndefined();
expect(engine.bindUdpSocket(routerAId, { localPort: 521 })).toBeUndefined();
});
it("converges with split horizon, each router's pauses drawn from a random of its own", () => {
const engine = ripTopology();
engine.handle({ type: 'advance', toMilliseconds: 2_000 });
expect(route(engine, routerAId, '172.16.0.0')).toMatchObject({ nextHop: '10.0.0.2', interfaceIPAddress: '10.0.0.1', metric: 1, origin: 'rip', expiresAtMilliseconds: 1_110 + 75_000 });
expect(route(engine, routerBId, '192.168.0.0')).toMatchObject({ nextHop: '10.0.0.1', metric: 1 });
const payloads = (engine.state.deliveredIPv4PacketsByNodeId.get(routerBId) ?? [])
.filter((delivered) => delivered.packet.senderIPAddress === '10.0.0.1')
.map((delivered) => (delivered.packet.payload.kind === 'udp' ? new TextDecoder().decode(delivered.packet.payload.datagram.payload) : ''));
// A's first message, on the cable to B: its LAN, not the shared network.
expect(payloads[0]).toBe('10.0.0.1\n10.0.0.1\n16\n75000\n192.168.0.0 255.255.255.0 0');
// Both beacons at 1 s; each learns a route 55 ms later and sends at once, then pauses.
expect(advertisements(engine, routerBId)).toEqual([1_055, 1_110]);
const random = new JavaCompatibleRandom(ripSeed(15, routerAId));
random.nextRipJitterMilliseconds();
expect(beacons(engine, routerAId)).toEqual([1_055 + random.nextRipJitterMilliseconds()]);
});
it('names its first address as the public one in every message', () => {
const engine = ripTopology({ lanPc: true });
engine.handle({ type: 'advance', toMilliseconds: 2_000 });
const payloads = (engine.state.deliveredIPv4PacketsByNodeId.get(lanPcId) ?? []).map((delivered) =>
delivered.packet.payload.kind === 'udp' ? new TextDecoder().decode(delivered.packet.payload.datagram.payload) : '',
);
// On the LAN: from 192.168.0.1, public 10.0.0.1; after it learned B's network.
expect(payloads.at(-1)).toBe('192.168.0.1\n10.0.0.1\n16\n75000\n10.0.0.0 255.255.255.0 0\n172.16.0.0 255.255.0.0 1');
});
it('draws the pauses unmoved by the ports sockets draw', () => {
const plain = ripTopology();
const withSocket = ripTopology();
withSocket.bindUdpSocket(routerAId);
plain.handle({ type: 'advance', toMilliseconds: 2_000 });
withSocket.handle({ type: 'advance', toMilliseconds: 2_000 });
expect(beacons(withSocket, routerAId)).toEqual(beacons(plain, routerAId));
});
it('marks a learned route unreachable when it expires, keeps it, and tells its neighbours at once (Java: at its next beacon)', () => {
const engine = ripTopology();
engine.handle({ type: 'advance', toMilliseconds: 2_000 });
engine.setGlobalPacketLossEnabled(true);
const expiry = route(engine, routerAId, '172.16.0.0')!.expiresAtMilliseconds!;
engine.handle({ type: 'advance', toMilliseconds: expiry - 1 });
expect(route(engine, routerAId, '172.16.0.0')?.metric).toBe(1);
const [beacon] = beacons(engine, routerAId) as [number];
expect(beacon).toBeGreaterThan(expiry);
engine.handle({ type: 'advance', toMilliseconds: expiry });
expect(route(engine, routerAId, '172.16.0.0')).toMatchObject({ metric: 16, expiresAtMilliseconds: expiry });
expect(sentTables(engine, routerAId).at(-1)).toBe(expiry);
// The next beacon comes a pause after the message, not at the old time.
expect(beacons(engine, routerAId)[0]).toBeGreaterThan(beacon);
});
it('expires each learned route at its own time, also after another expired first', () => {
const engine = ripTopology();
engine.handle({ type: 'advance', toMilliseconds: 2_000 });
const first = fromB(engine, '10.0.0.2\n10.0.0.2\n16\n75000\n198.51.100.0 255.255.255.0 1');
engine.handle({ type: 'advance', toMilliseconds: first + 10_000 });
const second = fromB(engine, '10.0.0.2\n10.0.0.2\n16\n75000\n203.0.113.0 255.255.255.0 1');
engine.setGlobalPacketLossEnabled(true);
engine.handle({ type: 'advance', toMilliseconds: second + 75_000 - 1 });
expect(route(engine, routerAId, '198.51.100.0')?.metric).toBe(16);
expect(route(engine, routerAId, '203.0.113.0')?.metric).toBe(2);
engine.handle({ type: 'advance', toMilliseconds: second + 75_000 });
expect(route(engine, routerAId, '203.0.113.0')?.metric).toBe(16);
});
it('routes with its table alone: a manual route does not count', () => {
const engine = ripTopology({ manualRoutes: true });
const packet: IPv4Packet = {
identity: engine.allocatePacketIdentity(),
senderIPAddress: '10.0.0.1',
receiverIPAddress: '203.0.113.20',
timeToLive: 64,
protocol: 'udp',
payload: { kind: 'udp', datagram: { sourcePort: 6000, destinationPort: 7000, payload: new Uint8Array() } },
};
expect(engine.sendIPv4Packet(routerAId, packet)).toEqual({ type: 'icmpError', packetIdentity: packet.identity, nodeId: routerAId, kind: 'destinationNetworkUnreachable' });
});
it('forwards along the fewest hops, then the longest mask, and not along 16 hops', () => {
const engine = ripTopology();
// The /24 through a router at 10.0.0.3, which is not there.
fromB(engine, '10.0.0.3\n10.0.0.3\n16\n75000\n203.0.113.0 255.255.255.0 4');
fromB(engine, '10.0.0.2\n10.0.0.2\n16\n75000\n203.0.0.0 255.255.0.0 2\n40.0.0.0 255.0.0.0 3');
fromB(engine, '10.0.0.2\n10.0.0.2\n16\n75000\n40.0.0.0 255.0.0.0 15');
const packet: IPv4Packet = {
identity: engine.allocatePacketIdentity(),
senderIPAddress: '192.168.0.1',
receiverIPAddress: '203.0.113.20',
timeToLive: 64,
protocol: 'udp',
payload: { kind: 'udp', datagram: { sourcePort: 6000, destinationPort: 7000, payload: new Uint8Array() } },
};
engine.sendIPv4Packet(routerAId, packet);
letFramesArrive(engine);
// Through the /16 with 3 hops, not the /24 with 5.
expect(engine.state.packetTraces.some((trace) => trace.nodeId === routerBId && trace.packetIdentity === packet.identity)).toBe(true);
expect(route(engine, routerAId, '203.0.113.0')).toMatchObject({ metric: 5, nextHop: '10.0.0.3' });
// 40.0.0.0 went to 16 hops from the router it went through: no route.
expect(route(engine, routerAId, '40.0.0.0')?.metric).toBe(16);
const unreachable: IPv4Packet = { ...packet, identity: engine.allocatePacketIdentity(), receiverIPAddress: '40.0.0.1' };
expect(engine.sendIPv4Packet(routerAId, unreachable)).toEqual({
type: 'icmpError',
packetIdentity: unreachable.identity,
nodeId: routerAId,
kind: 'destinationNetworkUnreachable',
});
});
it("learns after Java's rules, from a message of any port, and ignores what is not for it", () => {
const engine = ripTopology();
let arrived = fromB(engine, '10.0.0.2\n10.0.0.2\n16\n75000\n30.0.0.0 255.0.0.0 3\n31.0.0.0 255.0.0.0 15\n32.0.0.0 255.0.0.0 16', 4000);
expect(route(engine, routerAId, '30.0.0.0')).toMatchObject({ metric: 4, nextHop: '10.0.0.2', expiresAtMilliseconds: arrived + 75_000 });
// 15 + 1 is unreachable: not taken.
expect(route(engine, routerAId, '31.0.0.0')).toBeUndefined();
expect(route(engine, routerAId, '32.0.0.0')).toBeUndefined();
// A new route sends the table at once.
expect(sentTables(engine, routerAId)).toContain(arrived);
// Own networks stay; the message's own infinity counts too.
arrived = fromB(engine, '10.0.0.2\n10.0.0.2\n4\n75000\n192.168.0.0 255.255.255.0 0\n33.0.0.0 255.0.0.0 4');
expect(route(engine, routerAId, '192.168.0.0')).toMatchObject({ metric: 0, nextHop: '192.168.0.1' });
expect(route(engine, routerAId, '33.0.0.0')).toBeUndefined();
expect(sentTables(engine, routerAId)).not.toContain(arrived);
// Shorter from the router it goes through: taken, not sent at once.
arrived = fromB(engine, '10.0.0.2\n10.0.0.2\n16\n75000\n30.0.0.0 255.0.0.0 1');
expect(route(engine, routerAId, '30.0.0.0')?.metric).toBe(2);
expect(sentTables(engine, routerAId)).not.toContain(arrived);
// Longer from the router it goes through: taken, and sent at once.
arrived = fromB(engine, '10.0.0.2\n10.0.0.2\n16\n75000\n30.0.0.0 255.0.0.0 6');
expect(route(engine, routerAId, '30.0.0.0')?.metric).toBe(7);
expect(sentTables(engine, routerAId)).toContain(arrived);
// Shorter through another router: it goes there now; not shorter: it stays.
fromB(engine, '10.0.0.3\n10.0.0.3\n16\n75000\n30.0.0.0 255.0.0.0 2');
expect(route(engine, routerAId, '30.0.0.0')).toMatchObject({ metric: 3, nextHop: '10.0.0.3', interfaceIPAddress: '10.0.0.1' });
fromB(engine, '10.0.0.2\n10.0.0.2\n16\n75000\n30.0.0.0 255.0.0.0 2');
expect(route(engine, routerAId, '30.0.0.0')).toMatchObject({ metric: 3, nextHop: '10.0.0.3' });
// Empty lines at the end do not count (Java's split).
fromB(engine, '10.0.0.2\n10.0.0.2\n16\n75000\n39.0.0.0 255.0.0.0 1\n\n');
expect(route(engine, routerAId, '39.0.0.0')?.metric).toBe(2);
// From elsewhere, or from itself, or not a message: nothing.
fromB(engine, '10.9.0.2\n10.0.0.2\n16\n75000\n34.0.0.0 255.0.0.0 1');
fromB(engine, '10.0.0.1\n10.0.0.1\n16\n75000\n35.0.0.0 255.0.0.0 1');
fromB(engine, '10.0.0.2\n10.0.0.2\n16\n0\n36.0.0.0 255.0.0.0 1');
fromB(engine, '10.0.0.2\n10.0.0.2\n16\n75000\n37.0.0.0 255.0.0.0 1\n38.0.0.0 255.0.0.0');
for (const network of ['34.0.0.0', '35.0.0.0', '36.0.0.0', '37.0.0.0']) expect(route(engine, routerAId, network), network).toBeUndefined();
});
it('forgets its tables on stop', () => {
const engine = ripTopology();
engine.handle({ type: 'advance', toMilliseconds: 2_000 });
engine.handle({ type: 'stop' });
expect(engine.state.ripTablesByNodeId.size).toBe(0);
});
});
describe('Home router NAT', () => {
const [lanPcId, lanPcPort, gatewayId, wanPort, lanPort, wanPcId, wanPcPort] = [0x1c01, 0x1c02, 0x1c03, 0x1c04, 0x1c05, 0x1c06, 0x1c07].map(uuid) as [
string,
string,
string,
string,
string,
string,
string,
];
/** PC 192.168.0.10 — home router (WAN 42.0.0.10/8, LAN 192.168.0.1/24) — PC 42.0.0.20, each on its own cable. */
const home = (options: { forwarding?: [string, string, string, string][]; firewall?: FirewallConfiguration; speed?: number } = {}) => {
const engine = new NetworkRuntimeEngine(14);
if (options.speed !== undefined) engine.setSimulationSpeed(options.speed);
engine.handle({
type: 'start',
snapshot: topologySnapshot({
nodes: [
pc(lanPcId, lanPcPort),
{ id: gatewayId, kind: 'gateway', ports: [portSnapshot(wanPort, 'wan0'), portSnapshot(lanPort, 'lan0')] },
pc(wanPcId, wanPcPort),
],
links: [link(lanPcId, lanPcPort, gatewayId, lanPort), link(gatewayId, wanPort, wanPcId, wanPcPort)],
deviceConfigurations: new Map([
[lanPcId, device('192.168.0.10', { gateway: '192.168.0.1' })],
[wanPcId, device('42.0.0.20', { mask: '255.0.0.0', gateway: '42.0.0.10' })],
]),
interfaceConfigurations: new Map([
[interfaceKey(gatewayId, wanPort), { ipAddress: '42.0.0.10', subnetMask: '255.0.0.0' }],
[interfaceKey(gatewayId, lanPort), { ipAddress: '192.168.0.1', subnetMask: '255.255.255.0' }],
]),
portForwardingRowsByNodeId: new Map([
[gatewayId, (options.forwarding ?? []).map(([protocolValue, publicPortValue, lanIPAddress, lanPortValue]) => ({ protocolValue, publicPortValue, lanIPAddress, lanPortValue }))],
]),
...(options.firewall === undefined ? {} : { firewallConfigurationsByNodeId: new Map([[gatewayId, options.firewall]]) }),
}),
seed: 14,
initialTimeMilliseconds: 0,
});
return engine;
};
const udpOut = (engine: NetworkRuntimeEngine, localPort = 5000) => {
engine.sendUdp(engine.bindUdpSocket(lanPcId, { localPort, remoteIPAddress: '42.0.0.20', remotePort: 7 })!, new TextEncoder().encode('out'));
letFramesArrive(engine);
};
it("takes the port forwarding rows Java's input check takes, a later row for the same port replacing an earlier one", () => {
const engine = home({
forwarding: [
['tcp', '8080', '192.168.0.10', '80'],
['17', '53', '192.168.0.10', '53'],
['6', '8080', '192.168.0.11', '81'],
['ICMP', '1', '192.168.0.10', '1'],
['TCP', '080', '192.168.0.10', '80'],
['TCP', '65536', '192.168.0.10', '80'],
['UDP', '9', '192.168.0.300', '9'],
],
});
expect(engine.natEntries(gatewayId).map((entry) => `${entry.protocol} ${entry.port} ${entry.lan.ipAddress}:${entry.lan.port} ${entry.type}`)).toEqual([
'tcp 8080 192.168.0.11:81 static',
'udp 53 192.168.0.10:53 static',
]);
});
it('holds the WAN port of an entry against the router’s own sockets', () => {
const engine = home();
udpOut(engine);
const [entry] = engine.natEntries(gatewayId) as [NatEntry];
expect(engine.bindUdpSocket(gatewayId, { localPort: entry.port })).toBeUndefined();
});
it('drops an entry when it is 300 s times the delay factor old, not at the next of Java\'s checks each second; answers refresh it', () => {
const engine = home({ speed: 100 });
udpOut(engine);
// At 100 %, the factor is 1: 300 s. The entry came at 15 ms (three cables of 5 ms); it is gone at 300.015 s (Java: at its check at 301 s).
const [entry] = engine.natEntries(gatewayId) as [NatEntry];
expect(entry.updatedAtMilliseconds).toBe(15);
engine.handle({ type: 'advance', toMilliseconds: 300_014 });
expect(engine.natEntries(gatewayId)).toHaveLength(1);
engine.handle({ type: 'advance', toMilliseconds: 300_015 });
expect(engine.natEntries(gatewayId)).toHaveLength(0);
// Another, now refreshed by an answer, and not by what goes out.
udpOut(engine, 5001);
const [fresh] = engine.natEntries(gatewayId) as [NatEntry];
engine.handle({ type: 'advance', toMilliseconds: 400_000 });
udpOut(engine, 5001);
expect(engine.natEntries(gatewayId)[0]?.updatedAtMilliseconds).toBe(fresh.updatedAtMilliseconds);
const answer = engine.bindUdpSocket(wanPcId, { localPort: 7, remoteIPAddress: '42.0.0.10', remotePort: fresh.port })!;
engine.sendUdp(answer, new TextEncoder().encode('back'));
letFramesArrive(engine);
expect(engine.natEntries(gatewayId)[0]!.updatedAtMilliseconds).toBeGreaterThan(400_000);
});
it('passes both sides through the router’s firewall', () => {
const engine = home({ firewall: firewallConfiguration({ isActive: true, defaultPolicy: 'drop' }) });
udpOut(engine);
expect(engine.natEntries(gatewayId)).toEqual([]);
expect(engine.state.firewallDecisions.some((decision) => !decision.accepted && decision.nodeId === gatewayId)).toBe(true);
});
it('answers a packet going out with TTL 1 with time exceeded, and drops what comes from the WAN for the LAN', () => {
const engine = home();
engine.sendIPv4Packet(lanPcId, {
identity: engine.allocatePacketIdentity(),
senderIPAddress: '192.168.0.10',
receiverIPAddress: '42.0.0.20',
timeToLive: 1,
protocol: 'udp',
payload: { kind: 'udp', datagram: { sourcePort: 5000, destinationPort: 7, payload: new Uint8Array() } },
});
letFramesArrive(engine);
expect((engine.state.icmpObservationsByNodeId.get(lanPcId) ?? []).map((observation) => observation.message.kind)).toEqual(['timeExceeded']);
expect(engine.natEntries(gatewayId)).toEqual([]);
engine.sendIPv4Packet(wanPcId, {
identity: engine.allocatePacketIdentity(),
senderIPAddress: '42.0.0.20',
receiverIPAddress: '192.168.0.10',
timeToLive: 64,
protocol: 'udp',
payload: { kind: 'udp', datagram: { sourcePort: 7, destinationPort: 5000, payload: new Uint8Array() } },
});
letFramesArrive(engine);
expect(engine.state.packetTraces.some((trace) => trace.nodeId === gatewayId && trace.detail === 'WAN packet addressed to the LAN')).toBe(true);
expect((engine.state.deliveredIPv4PacketsByNodeId.get(lanPcId) ?? []).filter((delivered) => delivered.packet.protocol === 'udp')).toEqual([]);
});
const raw = (engine: NetworkRuntimeEngine, from: string, packet: Omit<IPv4Packet, 'identity' | 'timeToLive'>) => {
const identity = engine.allocatePacketIdentity();
engine.sendIPv4Packet(from, { identity, timeToLive: 64, ...packet });
letFramesArrive(engine);
return identity;
};
const echo = (identifier: number) => ({ kind: 'icmp' as const, message: icmpMessage('echoRequest', { identifier, sequenceNumber: 1 }) });
it('keeps static entries, and dynamic ones for 300 s times the delay factor (55 minutes at 90 %)', () => {
const engine = home({ forwarding: [['UDP', '53', '192.168.0.10', '53']] });
udpOut(engine);
engine.handle({ type: 'advance', toMilliseconds: 3_300_000 });
expect(engine.natEntries(gatewayId).map((entry) => entry.type)).toEqual(['static', 'dynamic']);
engine.handle({ type: 'advance', toMilliseconds: 3_302_000 });
expect(engine.natEntries(gatewayId).map((entry) => entry.type)).toEqual(['static']);
});
it('opens an entry with a SYN, not with other TCP segments; they leave with port 0', () => {
const engine = home();
raw(engine, lanPcId, {
senderIPAddress: '192.168.0.10',
receiverIPAddress: '42.0.0.20',
protocol: 'tcp',
payload: { kind: 'tcp', segment: { sourcePort: 5000, destinationPort: 80, sequenceNumber: 1, acknowledgementNumber: 1, flags: TCP_FLAG.acknowledgement, payload: new Uint8Array() } },
});
expect(engine.natEntries(gatewayId)).toEqual([]);
const arrived = engine.state.packetTraces.find((trace) => trace.nodeId === wanPcId && trace.detail === 'TCP destination port closed')!;
expect(arrived.beforeHeaders.find((header) => header.name === 'sourcePort')?.value).toBe('0');
});
it('lets broadcasts from the LAN be', () => {
const engine = home();
engine.sendUdp(engine.bindUdpSocket(lanPcId, { localPort: 5000 })!, new TextEncoder().encode('all'), { ipAddress: '255.255.255.255', port: 7 });
letFramesArrive(engine);
expect(engine.natEntries(gatewayId)).toEqual([]);
expect(engine.state.icmpObservationsByNodeId.get(lanPcId) ?? []).toEqual([]);
});
it('replaces an entry for the same remote end, port and protocol (Java: a map), as two hosts pinging with one identifier do', () => {
const engine = home();
raw(engine, lanPcId, { senderIPAddress: '192.168.0.10', receiverIPAddress: '42.0.0.20', protocol: 'icmp', payload: echo(1) });
raw(engine, lanPcId, { senderIPAddress: '192.168.0.11', receiverIPAddress: '42.0.0.20', protocol: 'icmp', payload: echo(1) });
expect(engine.natEntries(gatewayId).map((entry) => `${entry.remoteIPAddress}:${entry.port} ${entry.lan.ipAddress}:${entry.lan.port}`)).toEqual([
'42.0.0.20:1 192.168.0.11:1',
]);
});
it('answers a ping at its WAN address itself, even when an entry would fit', () => {
const engine = home();
raw(engine, lanPcId, { senderIPAddress: '192.168.0.10', receiverIPAddress: '42.0.0.20', protocol: 'icmp', payload: echo(1) });
raw(engine, wanPcId, { senderIPAddress: '42.0.0.20', receiverIPAddress: '42.0.0.10', protocol: 'icmp', payload: echo(1) });
const replies = (engine.state.icmpObservationsByNodeId.get(wanPcId) ?? []).filter((observation) => observation.message.kind === 'echoReply');
expect(replies.map((observation) => observation.packet.senderIPAddress)).toContain('42.0.0.10');
expect(engine.state.packetTraces.filter((trace) => trace.nodeId === lanPcId && trace.detail === 'ICMP echo reply')).toHaveLength(0);
});
it('sends an ICMP error for a packet it translated to the LAN endpoint, found by the packet it carries', () => {
const engine = home();
udpOut(engine);
const [entry] = engine.natEntries(gatewayId) as [NatEntry];
raw(engine, wanPcId, {
senderIPAddress: '42.0.0.20',
receiverIPAddress: '42.0.0.10',
protocol: 'icmp',
payload: {
kind: 'icmp',
message: icmpMessage('destinationHostUnreachable', {
embeddedOriginalPacket: {
identity: 1,
senderIPAddress: '42.0.0.10',
receiverIPAddress: '42.0.0.20',
timeToLive: 63,
protocol: 'udp',
payload: { kind: 'udp', datagram: { sourcePort: entry.port, destinationPort: 7, payload: new Uint8Array() } },
},
}),
},
});
expect((engine.state.icmpObservationsByNodeId.get(lanPcId) ?? []).map((observation) => observation.message.kind)).toEqual(['destinationHostUnreachable']);
});
it('lets a forwarded endpoint reach a new remote end through its forwarding, whose answer makes the entry', () => {
const engine = home({ forwarding: [['UDP', '53', '192.168.0.10', '53']] });
udpOut(engine, 53);
const answer = engine.bindUdpSocket(wanPcId, { localPort: 7, remoteIPAddress: '42.0.0.10', remotePort: 53 })!;
engine.sendUdp(answer, new TextEncoder().encode('back'));
letFramesArrive(engine);
expect(engine.natEntries(gatewayId).map((entry) => `${entry.type} ${entry.remoteIPAddress}:${entry.port}`)).toEqual(['static :53', 'dynamicFromStatic 42.0.0.20:53']);
});
it('drops at its WAN side what its firewall drops', () => {
const engine = home({ firewall: firewallConfiguration({ isActive: true, defaultPolicy: 'drop' }) });
const identity = raw(engine, wanPcId, {
senderIPAddress: '42.0.0.20',
receiverIPAddress: '42.0.0.10',
protocol: 'udp',
payload: { kind: 'udp', datagram: { sourcePort: 7, destinationPort: 9, payload: new Uint8Array() } },
});
expect(engine.state.packetTraces.some((trace) => trace.nodeId === gatewayId && trace.packetIdentity === identity && trace.layer === 'transport')).toBe(false);
});
it('forgets its tables on stop', () => {
const engine = home();
udpOut(engine);
engine.handle({ type: 'stop' });
expect(engine.state.natTablesByNodeId.size).toBe(0);
});
});
describe("packet capture (Java's Lauscher)", () => {
const hostA = uuid(0x2e01);
const portA = uuid(0x2e11);
const hostB = uuid(0x2e02);
const portB = uuid(0x2e12);
const switchId = uuid(0x2e04);
const switchPorts = [1, 2].map((index) => portSnapshot(uuid(0x2e20 + index), `sw${index}`));
const network = {
nodes: [pc(hostA, portA), pc(hostB, portB), { id: switchId, kind: 'networkSwitch' as const, ports: switchPorts }],
links: [link(hostA, portA, switchId, switchPorts[0]!.id), link(hostB, portB, switchId, switchPorts[1]!.id)],
deviceConfigurations: new Map([
[hostA, device('10.0.0.1')],
[hostB, device('10.0.0.2')],
]),
};
const kinds = (engine: NetworkRuntimeEngine, portId: string) =>
engine.capture.frames(portId).map(({ number, frame }) => {
const what = frame.payload.kind === 'arp' ? `arp ${frame.payload.packet.operation}` : frame.payload.packet.payload.kind;
return `${number} ${frame.sourceMacAddress === stableMacAddress(portId) ? 'sent' : 'received'} ${what}`;
});
const arpRequest = (engine: NetworkRuntimeEngine): EthernetFrame => ({
identity: engine.allocateFrameIdentity(),
sourceMacAddress: stableMacAddress(portA),
destinationMacAddress: ETHERNET_BROADCAST_MAC_ADDRESS,
payload: {
kind: 'arp',
packet: { operation: 'request', senderMacAddress: stableMacAddress(portA), senderIPAddress: '10.0.0.1', targetMacAddress: UNSPECIFIED_MAC_ADDRESS, targetIPAddress: '10.0.0.9' },
},
});
it('logs every frame a card sends or receives, numbered, and nothing on a switch', () => {
const engine = startedEngine(network);
engine.sendIcmpEcho(hostA, '10.0.0.2');
letFramesArrive(engine);
expect(kinds(engine, portA)).toEqual(['1 sent arp request', '2 received arp reply', '3 sent icmp', '4 received icmp']);
expect(kinds(engine, portB)).toEqual(['1 received arp request', '2 sent arp reply', '3 received icmp', '4 sent icmp']);
expect(switchPorts.map((port) => engine.capture.frames(port.id))).toEqual([[], []]);
// When the card sent it, when the other card received it: two cables later.
const [sent] = engine.capture.frames(portA);
const [received] = engine.capture.frames(portB);
expect(received!.timeMilliseconds - sent!.timeMilliseconds).toBe(2 * engine.simulationSpeed.linkTransmissionDelayMilliseconds);
});
it('logs a frame on a card once, also a frame for another card', () => {
const engine = startedEngine(network);
const frame = { ...arpRequest(engine), destinationMacAddress: '02:00:00:00:00:FE' };
engine.sendEthernetFrame(hostA, portA, frame);
engine.sendEthernetFrame(hostA, portA, frame);
letFramesArrive(engine);
expect(kinds(engine, portA)).toEqual(['1 sent arp request']);
// The switch floods the unknown destination; B's card logs what is not addressed to it.
expect(kinds(engine, portB)).toEqual(['1 received arp request']);
});
it('keeps the frames the packet loss dropped apart; the sender logged them', () => {
const engine = startedEngine(network);
engine.setGlobalPacketLossEnabled(true);
engine.sendEthernetFrame(hostA, portA, arpRequest(engine));
letFramesArrive(engine);
expect(kinds(engine, portA)).toEqual(['1 sent arp request']);
expect(engine.capture.frames(portB)).toEqual([]);
expect(engine.capture.droppedFrames().map((each) => each.timeMilliseconds)).toEqual([engine.simulationSpeed.linkTransmissionDelayMilliseconds]);
});
it('logs a frame flooded onto two cables once for each cable that drops it', () => {
const hostC = uuid(0x2e03);
const portC = uuid(0x2e13);
const ports = [1, 2, 3].map((index) => portSnapshot(uuid(0x2e30 + index), `sw${index}`));
const engine = startedEngine({
nodes: [pc(hostA, portA), pc(hostB, portB), pc(hostC, portC), { id: switchId, kind: 'networkSwitch', ports }],
links: [link(hostA, portA, switchId, ports[0]!.id), link(hostB, portB, switchId, ports[1]!.id), link(hostC, portC, switchId, ports[2]!.id)],
});
engine.sendEthernetFrame(hostA, portA, arpRequest(engine));
// The switch has it and floods it; then the loss begins.
engine.handle({ type: 'advance', toMilliseconds: engine.simulationSpeed.linkTransmissionDelayMilliseconds });
engine.setGlobalPacketLossEnabled(true);
letFramesArrive(engine);
expect(engine.capture.droppedFrames()).toHaveLength(2);
expect([portB, portC].map((port) => engine.capture.frames(port))).toEqual([[], []]);
});
it('outlives a stop and a new start, as in Java, until it is cleared', () => {
const engine = startedEngine(network);
engine.sendEthernetFrame(hostA, portA, arpRequest(engine));
letFramesArrive(engine);
engine.handle({ type: 'stop' });
expect(kinds(engine, portA)).toEqual(['1 sent arp request']);
engine.handle({ type: 'start', snapshot: topologySnapshot(network), seed: 14, initialTimeMilliseconds: 1_000 });
engine.setGlobalPacketLossEnabled(true);
engine.sendEthernetFrame(hostA, portA, arpRequest(engine));
letFramesArrive(engine);
expect(engine.capture.frames(portA).map((each) => [each.number, each.timeMilliseconds])).toEqual([
[1, 0],
[2, 1_000],
]);
expect(engine.capture.droppedFrames()).toHaveLength(1);
engine.clearCapture();
expect(engine.capture.portIds()).toEqual([]);
expect(engine.capture.droppedFrames()).toEqual([]);
});
it('keeps at most the retention, dropping the oldest at once; a frame keeps its number', () => {
const engine = new NetworkRuntimeEngine(14, undefined, { captureRetention: captureRetentionPolicy(3, 2) });
engine.handle({ type: 'start', snapshot: topologySnapshot(network), seed: 14, initialTimeMilliseconds: 0 });
for (let count = 0; count < 4; count += 1) engine.sendEthernetFrame(hostA, portA, arpRequest(engine));
letFramesArrive(engine);
expect(engine.capture.frames(portA).map((each) => each.number)).toEqual([3, 4]);
expect(() => captureRetentionPolicy(2, 3)).toThrow(RangeError);
});
});