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