A new oracle, TerminalWindowRun, types into Java's own command line window: the window writes each line after its prompt, takes a blank line, cls, reset and exit itself, keeps the lines for the arrow keys, and keeps its text, history and working directory over a stop and a start. The room now holds a command line window per computer for everyone (TerminalWindow), kept over stops; the protocol carries its lines and the one way to read them (terminalScreen); the core goes on in the working directory of the run before (continueIn). The desktop opens programs in Java's frame, and the command line works with keyboard and finger: grey on black, the prompt hidden while a command runs, history, chips for the commands, Ctrl+C. The room's failure is now tested in the browser. Fixed: the room handed the engine the wall clock with fractions of a millisecond, and a ping hung after its first timeout. The engine and the room count whole milliseconds, as Java's clock does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2647 lines
132 KiB
TypeScript
2647 lines
132 KiB
TypeScript
// Ported from filius-ipad ios/FiliusPadTests/TopologyNetworkRuntimeEngineTests.swift:
|
||
// the event queue, the virtual clock, Java-compatible randomness, restart,
|
||
// Ethernet/switch/ARP/IPv4/ICMP, routing errors, the firewall, packet loss
|
||
// and trace retention, UDP and TCP sockets, DHCP. The remaining Swift tests
|
||
// (DNS, RIP, NAT, remote link, packet viewer) follow with their protocol
|
||
// slices.
|
||
|
||
import { describe, expect, it } from 'vitest';
|
||
import {
|
||
decodeDhcpMessage,
|
||
decodeDnsMessage,
|
||
encodeDnsMessage,
|
||
findApplicableNameServerRecord,
|
||
DHCP_DYNAMIC_LEASE_LIFETIME_MILLISECONDS,
|
||
deviceConfiguration,
|
||
dhcpServerConfiguration,
|
||
encodeDhcpMessage,
|
||
EMPTY_TOPOLOGY_SNAPSHOT,
|
||
ETHERNET_BROADCAST_MAC_ADDRESS,
|
||
firewallConfiguration,
|
||
firewallRule,
|
||
DIRECTLY_CONNECTED_SOURCE_MARKER,
|
||
forwardingClone,
|
||
icmpMessage,
|
||
interfaceKey,
|
||
JavaCompatibleRandom,
|
||
ripSeed,
|
||
NetworkRuntimeEngine,
|
||
captureRetentionPolicy,
|
||
portSnapshot,
|
||
stableMacAddress,
|
||
switchConfiguration,
|
||
TCP_FLAG,
|
||
tcpFlagDescription,
|
||
topologySnapshot,
|
||
traceRetentionPolicy,
|
||
UNSPECIFIED_MAC_ADDRESS,
|
||
type DeviceConfiguration,
|
||
type DhcpServerConfiguration,
|
||
type EthernetFrame,
|
||
type EthernetPayload,
|
||
type FirewallConfiguration,
|
||
type NatEntry,
|
||
type IPv4Packet,
|
||
type NodeSnapshot,
|
||
type RuntimeOutput,
|
||
type ScheduledEventKind,
|
||
} from '../src/index.js';
|
||
import { device, letFramesArrive, link, loseFirst, pc, startedEngine, uuid } from './fixtures.js';
|
||
|
||
const marker = (label: string): ScheduledEventKind => ({ kind: 'parityMarker', label });
|
||
|
||
function firedKinds(outputs: RuntimeOutput[]): ScheduledEventKind[] {
|
||
return outputs.flatMap((output) => (output.kind.type === 'fired' ? [output.kind.event.kind] : []));
|
||
}
|
||
|
||
function routedTopology() {
|
||
const sourceNodeId = uuid(0x1451);
|
||
const sourcePortId = uuid(0x1461);
|
||
const routerNodeId = uuid(0x1452);
|
||
const routerPortA = uuid(0x1462);
|
||
const routerPortB = uuid(0x1463);
|
||
const targetNodeId = uuid(0x1453);
|
||
const targetPortId = uuid(0x1464);
|
||
const router: NodeSnapshot = {
|
||
id: routerNodeId,
|
||
kind: 'router',
|
||
ports: [portSnapshot(routerPortA, 'rt1'), portSnapshot(routerPortB, 'rt2')],
|
||
};
|
||
const snapshot = topologySnapshot({
|
||
nodes: [pc(sourceNodeId, sourcePortId), router, pc(targetNodeId, targetPortId)],
|
||
links: [
|
||
link(sourceNodeId, sourcePortId, routerNodeId, routerPortA),
|
||
link(routerNodeId, routerPortB, targetNodeId, targetPortId),
|
||
],
|
||
deviceConfigurations: new Map([
|
||
[sourceNodeId, device('10.0.0.2', { gateway: '10.0.0.1' })],
|
||
[routerNodeId, device('10.0.0.1')],
|
||
[targetNodeId, device('20.0.0.2', { gateway: '20.0.0.1' })],
|
||
]),
|
||
interfaceConfigurations: new Map([
|
||
[interfaceKey(routerNodeId, routerPortA), { ipAddress: '10.0.0.1', subnetMask: '255.255.255.0' }],
|
||
[interfaceKey(routerNodeId, routerPortB), { ipAddress: '20.0.0.1', subnetMask: '255.255.255.0' }],
|
||
]),
|
||
});
|
||
const engine = new NetworkRuntimeEngine(14);
|
||
engine.handle({ type: 'start', snapshot, seed: 14, initialTimeMilliseconds: 0 });
|
||
return { engine, sourceNodeId, routerNodeId };
|
||
}
|
||
|
||
function firewallEngine(configuration: FirewallConfiguration) {
|
||
const routerId = uuid(0x1802);
|
||
const portId = uuid(0x1812);
|
||
const engine = startedEngine(
|
||
{
|
||
nodes: [{ id: routerId, kind: 'router', ports: [portSnapshot(portId, 'rt1')] }],
|
||
links: [],
|
||
interfaceConfigurations: new Map([
|
||
[interfaceKey(routerId, portId), { ipAddress: '10.0.0.1', subnetMask: '255.255.255.0' }],
|
||
]),
|
||
firewallConfigurationsByNodeId: new Map([[routerId, configuration]]),
|
||
},
|
||
18,
|
||
);
|
||
return { engine, routerId };
|
||
}
|
||
|
||
function tcpPacket(identity: number, destinationPort: number, flags: number): IPv4Packet {
|
||
return {
|
||
identity,
|
||
senderIPAddress: '203.0.113.10',
|
||
receiverIPAddress: '10.0.0.1',
|
||
timeToLive: 64,
|
||
protocol: 'tcp',
|
||
payload: {
|
||
kind: 'tcp',
|
||
segment: {
|
||
sourcePort: 50_000,
|
||
destinationPort,
|
||
sequenceNumber: identity,
|
||
acknowledgementNumber: 0,
|
||
flags,
|
||
payload: new Uint8Array(),
|
||
},
|
||
},
|
||
};
|
||
}
|
||
|
||
describe('event queue and virtual clock', () => {
|
||
it('orders events by deadline, then by insertion sequence', () => {
|
||
const engine = new NetworkRuntimeEngine(42);
|
||
engine.handle({ type: 'start', snapshot: EMPTY_TOPOLOGY_SNAPSHOT, seed: 42, initialTimeMilliseconds: 0 });
|
||
engine.handle({ type: 'schedule', deadlineMilliseconds: 100, kind: marker('first-at-100') });
|
||
engine.handle({ type: 'schedule', deadlineMilliseconds: 50, kind: marker('at-50') });
|
||
engine.handle({ type: 'schedule', deadlineMilliseconds: 100, kind: marker('second-at-100') });
|
||
|
||
const outputs = engine.handle({ type: 'advance', toMilliseconds: 100 });
|
||
|
||
expect(firedKinds(outputs)).toEqual([marker('at-50'), marker('first-at-100'), marker('second-at-100')]);
|
||
expect(engine.state.currentTimeMilliseconds).toBe(100);
|
||
expect(engine.state.pendingEvents).toEqual([]);
|
||
});
|
||
|
||
it("counts whole milliseconds, as Java's clock does: a time's fraction waits for the next step", () => {
|
||
const engine = new NetworkRuntimeEngine(9);
|
||
engine.handle({ type: 'start', snapshot: EMPTY_TOPOLOGY_SNAPSHOT, seed: 9, initialTimeMilliseconds: 10.6 });
|
||
expect(engine.state.currentTimeMilliseconds).toBe(10);
|
||
engine.handle({ type: 'schedule', deadlineMilliseconds: 100, kind: marker('at-100') });
|
||
expect(firedKinds(engine.handle({ type: 'advance', toMilliseconds: 99.9 }))).toEqual([]);
|
||
expect(engine.state.currentTimeMilliseconds).toBe(99);
|
||
expect(firedKinds(engine.handle({ type: 'advance', toMilliseconds: 100.2 }))).toEqual([marker('at-100')]);
|
||
expect(engine.state.currentTimeMilliseconds).toBe(100);
|
||
});
|
||
|
||
it('rejects a past time without changing the clock', () => {
|
||
const engine = new NetworkRuntimeEngine(7);
|
||
engine.handle({ type: 'start', snapshot: EMPTY_TOPOLOGY_SNAPSHOT, seed: 7, initialTimeMilliseconds: 10 });
|
||
engine.handle({ type: 'advance', toMilliseconds: 25 });
|
||
|
||
const outputs = engine.handle({ type: 'advance', toMilliseconds: 24 });
|
||
|
||
expect(outputs.map((output) => output.kind)).toEqual([{ type: 'advanceRejectedPastTime' }]);
|
||
expect(engine.state.currentTimeMilliseconds).toBe(25);
|
||
});
|
||
|
||
it('rejects a past deadline without using up a sequence number', () => {
|
||
const engine = new NetworkRuntimeEngine(8);
|
||
engine.handle({ type: 'start', snapshot: EMPTY_TOPOLOGY_SNAPSHOT, seed: 8, initialTimeMilliseconds: 25 });
|
||
|
||
const outputs = engine.handle({ type: 'schedule', deadlineMilliseconds: 24, kind: marker('past') });
|
||
|
||
expect(outputs.map((output) => output.kind)).toEqual([
|
||
{ type: 'scheduleRejectedPastTime', deadlineMilliseconds: 24 },
|
||
]);
|
||
expect(engine.state.pendingEvents).toEqual([]);
|
||
expect(engine.state.nextEventSequenceNumber).toBe(0);
|
||
expect(engine.state.currentTimeMilliseconds).toBe(25);
|
||
});
|
||
|
||
it('replays a named scenario straight to a stable state', () => {
|
||
const finalState = NetworkRuntimeEngine.run({
|
||
name: 'equal-deadline-order',
|
||
seed: 1,
|
||
advanceToMilliseconds: 1_000,
|
||
scheduledEvents: [
|
||
{ deadlineMilliseconds: 500, kind: marker('a') },
|
||
{ deadlineMilliseconds: 500, kind: marker('b') },
|
||
],
|
||
});
|
||
|
||
expect(finalState.currentTimeMilliseconds).toBe(1_000);
|
||
expect(finalState.pendingEvents).toEqual([]);
|
||
expect(finalState.seed).toBe(1n);
|
||
});
|
||
|
||
it('clears transient state on restart and numbers packets from one again', () => {
|
||
const nodeId = uuid(0x12);
|
||
const engine = new NetworkRuntimeEngine(12);
|
||
engine.handle({ type: 'start', snapshot: EMPTY_TOPOLOGY_SNAPSHOT, seed: 12, initialTimeMilliseconds: 0 });
|
||
engine.handle({ type: 'schedule', deadlineMilliseconds: 40, kind: marker('stale') });
|
||
expect(engine.allocateFrameIdentity()).toBe(1);
|
||
expect(engine.allocatePacketIdentity()).toBe(1);
|
||
engine.recordTrace({ packetIdentity: 1, nodeId, direction: 'local', layer: 'application', operation: 'created' });
|
||
engine.handle({ type: 'stop' });
|
||
|
||
engine.handle({ type: 'start', snapshot: EMPTY_TOPOLOGY_SNAPSHOT, seed: 12, initialTimeMilliseconds: 0 });
|
||
|
||
const state = engine.state;
|
||
expect(state.pendingEvents).toEqual([]);
|
||
expect(state.packetTraces).toEqual([]);
|
||
expect(state.arpCachesByNodeId.size).toBe(0);
|
||
expect(state.switchForwardingTablesByNodeId.size).toBe(0);
|
||
expect(state.switchSeenFrameIdentitiesByNodeId.size).toBe(0);
|
||
expect(state.deliveredIPv4PacketsByNodeId.size).toBe(0);
|
||
expect(state.icmpObservationsByNodeId.size).toBe(0);
|
||
expect(state.firewallDecisions).toEqual([]);
|
||
expect(engine.allocateFrameIdentity()).toBe(1);
|
||
expect(engine.allocatePacketIdentity()).toBe(1);
|
||
});
|
||
|
||
it('forgets UDP sockets on stop, binds none while stopped, and numbers them from one again', () => {
|
||
const nodeId = uuid(0x13);
|
||
const portId = uuid(0x14);
|
||
const input = { nodes: [pc(nodeId, portId)], links: [], deviceConfigurations: new Map([[nodeId, device('10.2.0.1')]]) };
|
||
const engine = startedEngine(input);
|
||
expect(engine.bindUdpSocket(nodeId, { localPort: 7 })).toBe(uuid(1));
|
||
expect(engine.bindUdpSocket(nodeId)).toBe(uuid(2));
|
||
|
||
engine.handle({ type: 'stop' });
|
||
expect(engine.state.socketsById.size).toBe(0);
|
||
expect(engine.state.udpReceiveQueuesBySocketId.size).toBe(0);
|
||
expect(engine.bindUdpSocket(nodeId, { localPort: 7 })).toBeUndefined();
|
||
|
||
engine.handle({ type: 'start', snapshot: topologySnapshot(input), seed: 14, initialTimeMilliseconds: 0 });
|
||
expect(engine.bindUdpSocket(nodeId, { localPort: 7 })).toBe(uuid(1));
|
||
});
|
||
|
||
it('forgets DHCP leases and client state on stop', () => {
|
||
const [serverId, serverPortId, clientId, clientPortId] = [0x31, 0x32, 0x33, 0x34].map(uuid) as [string, string, string, string];
|
||
const engine = startedEngine({
|
||
nodes: [pc(serverId, serverPortId), pc(clientId, clientPortId)],
|
||
links: [link(serverId, serverPortId, clientId, clientPortId)],
|
||
deviceConfigurations: new Map([
|
||
[serverId, device('10.4.0.1')],
|
||
[clientId, device('0.0.0.0')],
|
||
]),
|
||
dhcpClientConfigurationsByNodeId: new Map([[clientId, { isEnabled: true }]]),
|
||
dhcpServerConfigurationsByNodeId: new Map([
|
||
[serverId, dhcpServerConfiguration({ isActive: true, lowerBoundIPAddress: '10.4.0.20', upperBoundIPAddress: '10.4.0.29' })],
|
||
]),
|
||
});
|
||
engine.handle({ type: 'advance', toMilliseconds: 5_000 });
|
||
expect(engine.state.dhcpLeasesByIPAddress.size).toBe(1);
|
||
|
||
engine.handle({ type: 'stop' });
|
||
const state = engine.state;
|
||
expect([
|
||
state.dhcpOffersByIPAddress.size,
|
||
state.dhcpLeasesByIPAddress.size,
|
||
state.dhcpBlacklistByServerNodeId.size,
|
||
state.dhcpLastOfferedAddressByServerNodeId.size,
|
||
state.dhcpServerSocketIdsByNodeId.size,
|
||
state.dhcpClientStatusesByNodeId.size,
|
||
state.dhcpClientContextsByNodeId.size,
|
||
]).toEqual([0, 0, 0, 0, 0, 0, 0]);
|
||
});
|
||
|
||
it('forgets which cables are busy on stop', () => {
|
||
const [clientId, clientPortId, serverId, serverPortId] = [0x25, 0x26, 0x27, 0x28].map(uuid) as [string, string, string, string];
|
||
const input = {
|
||
nodes: [pc(clientId, clientPortId), pc(serverId, serverPortId)],
|
||
links: [link(clientId, clientPortId, serverId, serverPortId)],
|
||
deviceConfigurations: new Map([
|
||
[clientId, device('10.5.0.1')],
|
||
[serverId, device('10.5.0.2')],
|
||
]),
|
||
};
|
||
const engine = startedEngine(input);
|
||
engine.handle({ type: 'advance', toMilliseconds: 10_000 });
|
||
engine.openTcpServerSocket(serverId, 80);
|
||
expect(engine.connectTcp(engine.openTcpClientSocket(clientId, '10.5.0.2', 80)!)).toBe(true);
|
||
|
||
engine.handle({ type: 'stop' });
|
||
engine.handle({ type: 'start', snapshot: topologySnapshot(input), seed: 14, initialTimeMilliseconds: 0 });
|
||
engine.resolveMacAddress(clientId, '10.5.0.2');
|
||
|
||
expect(engine.state.packetTraces.find((trace) => trace.layer === 'physical' && trace.operation === 'sent')?.timeMilliseconds).toBe(0);
|
||
});
|
||
|
||
it('forgets the send threads on stop', () => {
|
||
const [firstId, firstPortId, secondId, secondPortId] = [0x29, 0x2a, 0x2b, 0x2c].map(uuid) as [string, string, string, string];
|
||
const input = {
|
||
nodes: [pc(firstId, firstPortId), pc(secondId, secondPortId)],
|
||
links: [link(firstId, firstPortId, secondId, secondPortId)],
|
||
deviceConfigurations: new Map([
|
||
[firstId, device('10.6.0.1')],
|
||
[secondId, device('10.6.0.2')],
|
||
]),
|
||
};
|
||
const engine = startedEngine(input);
|
||
// The UDP thread waits for ARP on .99 when the simulation stops.
|
||
engine.sendUdp(engine.bindUdpSocket(firstId, { remoteIPAddress: '10.6.0.99', remotePort: 9 })!, new TextEncoder().encode('lost'));
|
||
|
||
engine.handle({ type: 'stop' });
|
||
engine.handle({ type: 'start', snapshot: topologySnapshot(input), seed: 14, initialTimeMilliseconds: 0 });
|
||
const receiver = engine.bindUdpSocket(secondId, { localPort: 9 })!;
|
||
engine.sendUdp(engine.bindUdpSocket(firstId, { remoteIPAddress: '10.6.0.2', remotePort: 9 })!, new TextEncoder().encode('found'));
|
||
|
||
expect(engine.state.currentTimeMilliseconds).toBe(0);
|
||
expect(new TextDecoder().decode(engine.receiveUdp(receiver, 1_000)?.datagram.payload)).toBe('found');
|
||
});
|
||
|
||
it('forgets TCP sessions and accepted sockets on stop', () => {
|
||
const [clientId, clientPortId, serverId, serverPortId] = [0x21, 0x22, 0x23, 0x24].map(uuid) as [string, string, string, string];
|
||
const engine = startedEngine({
|
||
nodes: [pc(clientId, clientPortId), pc(serverId, serverPortId)],
|
||
links: [link(clientId, clientPortId, serverId, serverPortId)],
|
||
deviceConfigurations: new Map([
|
||
[clientId, device('10.3.0.1')],
|
||
[serverId, device('10.3.0.2')],
|
||
]),
|
||
});
|
||
engine.openTcpServerSocket(serverId, 80);
|
||
expect(engine.connectTcp(engine.openTcpClientSocket(clientId, '10.3.0.2', 80)!)).toBe(true);
|
||
expect(engine.state.tcpSessionsById.size).toBe(3);
|
||
|
||
engine.handle({ type: 'stop' });
|
||
expect(engine.state.tcpSessionsById.size).toBe(0);
|
||
expect(engine.state.tcpAcceptedSocketIdsByListenerId.size).toBe(0);
|
||
});
|
||
});
|
||
|
||
describe('UDP sockets', () => {
|
||
const text = (payload: Uint8Array | undefined) => new TextDecoder().decode(payload ?? new Uint8Array());
|
||
|
||
it('binds, delivers and releases a port', () => {
|
||
const [sourceNodeId, sourcePortId, targetNodeId, targetPortId] = [0x15b1, 0x15b2, 0x15b3, 0x15b4].map(uuid) as [string, string, string, string];
|
||
const engine = startedEngine({
|
||
nodes: [pc(sourceNodeId, sourcePortId), pc(targetNodeId, targetPortId)],
|
||
links: [link(sourceNodeId, sourcePortId, targetNodeId, targetPortId)],
|
||
deviceConfigurations: new Map([
|
||
[sourceNodeId, device('192.168.5.10')],
|
||
[targetNodeId, device('192.168.5.20')],
|
||
]),
|
||
});
|
||
const automatic = engine.bindUdpSocket(targetNodeId);
|
||
const automaticPort = automatic === undefined ? undefined : engine.state.socketsById.get(automatic)?.localPort;
|
||
expect(automaticPort).toBeGreaterThanOrEqual(49_152);
|
||
expect(automaticPort).toBeLessThan(65_535);
|
||
|
||
const receiver = engine.bindUdpSocket(targetNodeId, { localPort: 9000 });
|
||
const sender = engine.bindUdpSocket(sourceNodeId, { localPort: 8000, remoteIPAddress: '192.168.5.20', remotePort: 9000 });
|
||
expect(receiver).toBeDefined();
|
||
expect(sender).toBeDefined();
|
||
expect(engine.bindUdpSocket(targetNodeId, { localPort: 9000 })).toBeUndefined();
|
||
|
||
engine.sendUdp(sender!, new TextEncoder().encode('hello'));
|
||
const received = engine.receiveUdp(receiver!, 1_000);
|
||
expect(text(received?.datagram.payload)).toBe('hello');
|
||
expect(received?.senderIPAddress).toBe('192.168.5.10');
|
||
|
||
engine.closeSocket(receiver!);
|
||
expect(engine.bindUdpSocket(targetNodeId, { localPort: 9000 })).toBeDefined();
|
||
});
|
||
|
||
it('returns from a send at once, the datagram arrives as the clock advances (swiftSends: when it has arrived)', () => {
|
||
const arrival = (swiftSends: boolean) => {
|
||
const [sourceNodeId, sourcePortId, targetNodeId, targetPortId] = [0x15c1, 0x15c2, 0x15c3, 0x15c4].map(uuid) as [string, string, string, string];
|
||
const engine = new NetworkRuntimeEngine(14, undefined, { swiftSends });
|
||
engine.handle({
|
||
type: 'start',
|
||
snapshot: topologySnapshot({
|
||
nodes: [pc(sourceNodeId, sourcePortId), pc(targetNodeId, targetPortId)],
|
||
links: [link(sourceNodeId, sourcePortId, targetNodeId, targetPortId)],
|
||
deviceConfigurations: new Map([
|
||
[sourceNodeId, device('192.168.6.10')],
|
||
[targetNodeId, device('192.168.6.20')],
|
||
]),
|
||
}),
|
||
seed: 14,
|
||
initialTimeMilliseconds: 0,
|
||
});
|
||
const receiver = engine.bindUdpSocket(targetNodeId, { localPort: 9000 })!;
|
||
const sender = engine.bindUdpSocket(sourceNodeId, { remoteIPAddress: '192.168.6.20', remotePort: 9000 })!;
|
||
engine.sendUdp(sender, new TextEncoder().encode('hello'));
|
||
const returnedAt = engine.state.currentTimeMilliseconds;
|
||
const waiting = engine.state.udpReceiveQueuesBySocketId.get(receiver)?.length ?? 0;
|
||
// ARP there and back, then the datagram: three cables of 55 ms.
|
||
return { returnedAt, waiting, receivedAt: engine.receiveUdp(receiver, 1_000)?.receivedAtMilliseconds };
|
||
};
|
||
|
||
expect(arrival(false)).toEqual({ returnedAt: 0, waiting: 0, receivedAt: 165 });
|
||
expect(arrival(true)).toEqual({ returnedAt: 165, waiting: 1, receivedAt: 165 });
|
||
});
|
||
|
||
|
||
it('broadcasts on every interface, and the receiver replies to its last peer', () => {
|
||
const [routerId, routerPortA, routerPortB, pcAId, pcAPort, pcBId, pcBPort] = [0x15e1, 0x15e2, 0x15e3, 0x15e4, 0x15e5, 0x15e6, 0x15e7].map(
|
||
uuid,
|
||
) as [string, string, string, string, string, string, string];
|
||
const engine = startedEngine(
|
||
{
|
||
nodes: [
|
||
{ id: routerId, kind: 'router', ports: [portSnapshot(routerPortA, 'rt1'), portSnapshot(routerPortB, 'rt2')] },
|
||
pc(pcAId, pcAPort),
|
||
pc(pcBId, pcBPort),
|
||
],
|
||
links: [link(routerId, routerPortA, pcAId, pcAPort), link(routerId, routerPortB, pcBId, pcBPort)],
|
||
deviceConfigurations: new Map([
|
||
[pcAId, device('10.0.0.2')],
|
||
[pcBId, device('20.0.0.2')],
|
||
]),
|
||
interfaceConfigurations: new Map([
|
||
[interfaceKey(routerId, routerPortA), { ipAddress: '10.0.0.1', subnetMask: '255.255.255.0' }],
|
||
[interfaceKey(routerId, routerPortB), { ipAddress: '20.0.0.1', subnetMask: '255.255.255.0' }],
|
||
]),
|
||
},
|
||
15,
|
||
);
|
||
const sender = engine.bindUdpSocket(routerId, { localPort: 8000 })!;
|
||
const receiverA = engine.bindUdpSocket(pcAId, { localPort: 9000 })!;
|
||
const receiverB = engine.bindUdpSocket(pcBId, { localPort: 9000 })!;
|
||
|
||
engine.sendUdp(sender, new TextEncoder().encode('broadcast'), { ipAddress: '255.255.255.255', port: 9000 });
|
||
|
||
expect(engine.receiveUdp(receiverA, 1_000)?.senderIPAddress).toBe('10.0.0.1');
|
||
expect(engine.receiveUdp(receiverB, 1_000)?.senderIPAddress).toBe('20.0.0.1');
|
||
expect(engine.state.socketsById.get(receiverA)?.remoteIPAddress).toBe('10.0.0.1');
|
||
expect(engine.state.socketsById.get(receiverA)?.remotePort).toBe(8000);
|
||
|
||
engine.sendUdp(receiverA, new TextEncoder().encode('reply'));
|
||
expect(text(engine.receiveUdp(sender, 1_000)?.datagram.payload)).toBe('reply');
|
||
});
|
||
|
||
it('advances the virtual clock for a receive timeout and returns nothing', () => {
|
||
const nodeId = uuid(0x15d1);
|
||
const engine = startedEngine({ nodes: [pc(nodeId, uuid(0x15d2))], links: [], deviceConfigurations: new Map([[nodeId, device('10.1.0.1')]]) });
|
||
const socketId = engine.bindUdpSocket(nodeId, { localPort: 9999 })!;
|
||
|
||
expect(engine.receiveUdp(socketId, 250)).toBeUndefined();
|
||
expect(engine.state.currentTimeMilliseconds).toBe(250);
|
||
});
|
||
});
|
||
|
||
describe('Java-compatible randomness', () => {
|
||
it('draws the same RIP jitter as java.util.Random', () => {
|
||
// Measured on OpenJDK 21 (30.09.2026): (int) (1000 * 30 * (r.nextFloat() / 3 + 0.84)).
|
||
const random = new JavaCompatibleRandom(42);
|
||
expect(Array.from({ length: 5 }, () => random.nextRipJitterMilliseconds())).toEqual([
|
||
32_475, 25_746, 32_032, 25_679, 28_287,
|
||
]);
|
||
// new Random(7), the 530th draw: 28145 with Java's float division, 28144 in double.
|
||
const seven = new JavaCompatibleRandom(7);
|
||
for (let draw = 0; draw < 529; draw += 1) seven.nextFloat();
|
||
expect(seven.nextRipJitterMilliseconds()).toBe(28_145);
|
||
});
|
||
|
||
it('matches java.util.Random.nextInt for bounded and power-of-two ranges', () => {
|
||
// Measured on OpenJDK 21 (29.09.2026): new java.util.Random(42),
|
||
// nextInt(100) three times, then nextInt(16) three times.
|
||
const random = new JavaCompatibleRandom(42);
|
||
|
||
expect([random.nextInt(100), random.nextInt(100), random.nextInt(100)]).toEqual([30, 63, 48]);
|
||
expect([random.nextInt(16), random.nextInt(16), random.nextInt(16)]).toEqual([0, 4, 15]);
|
||
});
|
||
|
||
it('matches java.util.Random.nextDouble', () => {
|
||
// Measured on OpenJDK 21 (30.09.2026): nextDouble twice, then
|
||
// Math.round(nextDouble() * 253f) as the DHCP client draws a link-local address.
|
||
const cases: [number, number, number, number][] = [
|
||
[42, 0.7275636800328681, 0.6832234717598454, 78],
|
||
[0, 0.730967787376657, 0.24053641567148587, 161],
|
||
[-7, 0.2691218093260761, 0.6731811902798038, 132],
|
||
];
|
||
for (const [seed, first, second, rounded] of cases) {
|
||
const random = new JavaCompatibleRandom(seed);
|
||
expect([random.nextDouble(), random.nextDouble(), Math.round(random.nextDouble() * 253)]).toEqual([first, second, rounded]);
|
||
}
|
||
});
|
||
});
|
||
|
||
describe('IPv4 packets', () => {
|
||
it('keeps the identity and lowers the TTL when forwarding', () => {
|
||
const packet: IPv4Packet = {
|
||
identity: 99,
|
||
senderIPAddress: '192.168.0.10',
|
||
receiverIPAddress: '192.168.1.20',
|
||
timeToLive: 2,
|
||
protocol: 'icmp',
|
||
payload: { kind: 'icmp', message: icmpMessage('echoRequest', { identifier: 3, sequenceNumber: 4 }) },
|
||
};
|
||
|
||
const forwarded = forwardingClone(packet);
|
||
const expired = forwardingClone(forwarded);
|
||
|
||
expect(forwarded).toMatchObject({ identity: 99, timeToLive: 1 });
|
||
expect(expired).toMatchObject({ identity: 99, timeToLive: 0 });
|
||
});
|
||
});
|
||
|
||
describe('Ethernet, ARP and ICMP', () => {
|
||
it('fills both ARP caches with one request and reply over a direct cable', () => {
|
||
const sourceNodeId = uuid(0x1401);
|
||
const sourcePortId = uuid(0x1411);
|
||
const targetNodeId = uuid(0x1402);
|
||
const targetPortId = uuid(0x1412);
|
||
const engine = startedEngine({
|
||
nodes: [pc(sourceNodeId, sourcePortId), pc(targetNodeId, targetPortId)],
|
||
links: [link(sourceNodeId, sourcePortId, targetNodeId, targetPortId)],
|
||
deviceConfigurations: new Map([
|
||
[sourceNodeId, device('192.168.0.10')],
|
||
[targetNodeId, device('192.168.0.20')],
|
||
]),
|
||
});
|
||
|
||
const resolved = engine.resolveMacAddress(sourceNodeId, '192.168.0.20');
|
||
|
||
expect(resolved).toBe(stableMacAddress(targetPortId));
|
||
expect(engine.state.arpCachesByNodeId.get(sourceNodeId)?.get('192.168.0.20')?.macAddress).toBe(resolved);
|
||
expect(engine.state.arpCachesByNodeId.get(targetNodeId)?.get('192.168.0.10')?.macAddress).toBe(
|
||
stableMacAddress(sourcePortId),
|
||
);
|
||
});
|
||
|
||
it('pings across a switch with Ethernet, ARP and ICMP packets', () => {
|
||
const sourceNodeId = uuid(0x1421);
|
||
const sourcePortId = uuid(0x1431);
|
||
const switchNodeId = uuid(0x1422);
|
||
const switchPortA = uuid(0x1432);
|
||
const switchPortB = uuid(0x1433);
|
||
const targetNodeId = uuid(0x1423);
|
||
const targetPortId = uuid(0x1434);
|
||
const switchNode: NodeSnapshot = {
|
||
id: switchNodeId,
|
||
kind: 'networkSwitch',
|
||
ports: [portSnapshot(switchPortA, 'sw1'), portSnapshot(switchPortB, 'sw2')],
|
||
};
|
||
const engine = startedEngine({
|
||
nodes: [pc(sourceNodeId, sourcePortId), switchNode, pc(targetNodeId, targetPortId)],
|
||
links: [
|
||
link(sourceNodeId, sourcePortId, switchNodeId, switchPortA),
|
||
link(switchNodeId, switchPortB, targetNodeId, targetPortId),
|
||
],
|
||
deviceConfigurations: new Map([
|
||
[sourceNodeId, device('192.168.0.10')],
|
||
[targetNodeId, device('192.168.0.20')],
|
||
]),
|
||
});
|
||
|
||
const result = engine.sendIcmpEcho(sourceNodeId, '192.168.0.20', { identifier: 14, sequenceNumber: 1 });
|
||
|
||
expect(result.type).toBe('delivered');
|
||
expect(engine.state.icmpObservationsByNodeId.get(sourceNodeId)?.at(-1)?.message.kind).toBe('echoReply');
|
||
expect(engine.state.switchForwardingTablesByNodeId.get(switchNodeId)?.size).toBe(2);
|
||
expect(engine.state.packetTraces.some((trace) => trace.detail === 'ARP request for 192.168.0.20')).toBe(true);
|
||
expect(
|
||
engine.state.packetTraces.some((trace) => trace.packetIdentity !== undefined && trace.layer === 'network'),
|
||
).toBe(true);
|
||
});
|
||
|
||
it('answers an expired TTL at the router with time exceeded and the original packet', () => {
|
||
const topology = routedTopology();
|
||
|
||
const result = topology.engine.sendIcmpEcho(topology.sourceNodeId, '20.0.0.2', {
|
||
timeToLive: 1,
|
||
identifier: 140,
|
||
sequenceNumber: 1,
|
||
});
|
||
|
||
expect(result).toMatchObject({ type: 'icmpError', kind: 'timeExceeded' });
|
||
const observation = topology.engine.state.icmpObservationsByNodeId.get(topology.sourceNodeId)?.at(-1);
|
||
expect(observation?.message.kind).toBe('timeExceeded');
|
||
expect(observation?.message.embeddedOriginalPacket?.timeToLive).toBe(1);
|
||
expect(observation?.message.embeddedOriginalPacket?.receiverIPAddress).toBe('20.0.0.2');
|
||
});
|
||
|
||
it('keeps the packet identity and lowers the TTL when a router forwards', () => {
|
||
const topology = routedTopology();
|
||
|
||
const result = topology.engine.sendIcmpEcho(topology.sourceNodeId, '20.0.0.2', {
|
||
timeToLive: 8,
|
||
identifier: 141,
|
||
sequenceNumber: 1,
|
||
});
|
||
|
||
expect(result.type).toBe('delivered');
|
||
const forwardingTrace = topology.engine.state.packetTraces.find(
|
||
(trace) =>
|
||
trace.nodeId === topology.routerNodeId && trace.operation === 'forwarded' && trace.detail === 'TTL decremented',
|
||
);
|
||
expect(forwardingTrace?.packetIdentity).toBeDefined();
|
||
expect(forwardingTrace?.beforeHeaders.find((field) => field.name === 'ttl')?.value).toBe('8');
|
||
expect(forwardingTrace?.afterHeaders.find((field) => field.name === 'ttl')?.value).toBe('7');
|
||
});
|
||
|
||
it('reports network unreachable when the router has no route', () => {
|
||
const topology = routedTopology();
|
||
const snapshot = topology.engine.state.topologySnapshot;
|
||
const sourceToRouter = snapshot.links.find(
|
||
(candidate) => candidate.sourceNodeId === topology.sourceNodeId || candidate.targetNodeId === topology.sourceNodeId,
|
||
);
|
||
const engine = new NetworkRuntimeEngine(14);
|
||
engine.handle({
|
||
type: 'start',
|
||
snapshot: topologySnapshot({
|
||
nodes: snapshot.nodes,
|
||
links: sourceToRouter === undefined ? [] : [sourceToRouter],
|
||
deviceConfigurations: snapshot.deviceConfigurations,
|
||
interfaceConfigurations: snapshot.interfaceConfigurations,
|
||
manualRoutesByNodeId: snapshot.manualRoutesByNodeId,
|
||
}),
|
||
seed: 14,
|
||
initialTimeMilliseconds: 0,
|
||
});
|
||
|
||
const result = engine.sendIcmpEcho(topology.sourceNodeId, '30.0.0.2', { identifier: 142, sequenceNumber: 1 });
|
||
|
||
expect(result).toMatchObject({ type: 'icmpError', kind: 'destinationNetworkUnreachable' });
|
||
});
|
||
|
||
it('reports host unreachable when a connected target never answers ARP', () => {
|
||
const sourceNodeId = uuid(0x1481);
|
||
const sourcePortId = uuid(0x1491);
|
||
const engine = startedEngine({
|
||
nodes: [pc(sourceNodeId, sourcePortId)],
|
||
links: [],
|
||
deviceConfigurations: new Map([[sourceNodeId, device('192.168.50.10')]]),
|
||
});
|
||
|
||
const result = engine.sendIcmpEcho(sourceNodeId, '192.168.50.99', { identifier: 143, sequenceNumber: 1 });
|
||
|
||
expect(result).toMatchObject({ type: 'icmpError', kind: 'destinationHostUnreachable' });
|
||
});
|
||
});
|
||
|
||
describe('switch', () => {
|
||
it('learns, floods, forwards, filters and ages its address table', () => {
|
||
const hostAId = uuid(0x1a01);
|
||
const hostAPortId = uuid(0x1a11);
|
||
const hostBId = uuid(0x1a02);
|
||
const hostBPortId = uuid(0x1a12);
|
||
const hostCId = uuid(0x1a03);
|
||
const hostCPortId = uuid(0x1a13);
|
||
const switchId = uuid(0x1a04);
|
||
const switchPorts = [1, 2, 3].map((index) => portSnapshot(uuid(0x1a20 + index), `sw${index}`));
|
||
const engine = startedEngine(
|
||
{
|
||
nodes: [
|
||
pc(hostAId, hostAPortId),
|
||
pc(hostBId, hostBPortId),
|
||
pc(hostCId, hostCPortId),
|
||
{ id: switchId, kind: 'networkSwitch', ports: switchPorts },
|
||
],
|
||
links: [
|
||
link(hostAId, hostAPortId, switchId, switchPorts[0]!.id),
|
||
link(hostBId, hostBPortId, switchId, switchPorts[1]!.id),
|
||
link(hostCId, hostCPortId, switchId, switchPorts[2]!.id),
|
||
],
|
||
switchConfigurationsByNodeId: new Map([[switchId, switchConfiguration('Classroom', 1_000)]]),
|
||
},
|
||
20,
|
||
);
|
||
const macA = stableMacAddress(hostAPortId);
|
||
const macB = stableMacAddress(hostBPortId);
|
||
const payload: EthernetPayload = {
|
||
kind: 'arp',
|
||
packet: {
|
||
operation: 'request',
|
||
senderMacAddress: macA,
|
||
senderIPAddress: '192.0.2.1',
|
||
targetMacAddress: UNSPECIFIED_MAC_ADDRESS,
|
||
targetIPAddress: '192.0.2.200',
|
||
},
|
||
};
|
||
const frame = (sourceMacAddress: string, destinationMacAddress: string): EthernetFrame => ({
|
||
identity: engine.allocateFrameIdentity(),
|
||
sourceMacAddress,
|
||
destinationMacAddress,
|
||
payload,
|
||
});
|
||
const switchTraces = (detail: string) =>
|
||
engine.state.packetTraces.filter((trace) => trace.nodeId === switchId && trace.detail === detail);
|
||
|
||
engine.sendEthernetFrame(hostAId, hostAPortId, frame(macA, '02:00:00:00:00:FE'));
|
||
letFramesArrive(engine);
|
||
expect(switchTraces('switch unknown unicast flooding')).toHaveLength(2);
|
||
|
||
engine.sendEthernetFrame(hostBId, hostBPortId, frame(macB, macA));
|
||
letFramesArrive(engine);
|
||
expect(switchTraces('switch learned unicast forwarding')).toHaveLength(1);
|
||
|
||
engine.sendEthernetFrame(hostAId, hostAPortId, frame(macA, macA));
|
||
letFramesArrive(engine);
|
||
expect(switchTraces('switch filtered destination on incoming port').length).toBeGreaterThan(0);
|
||
expect(engine.switchSatEntries(switchId)).toHaveLength(2);
|
||
|
||
const retention = 1_000;
|
||
const updateTimes = [...engine.state.switchForwardingUpdatedAtMillisecondsByNodeId.get(switchId)!.values()];
|
||
const expirationDeadlines = updateTimes.map((time) => time + retention);
|
||
const firstExpiration = Math.min(...expirationDeadlines);
|
||
const lastExpiration = Math.max(...expirationDeadlines);
|
||
|
||
engine.handle({ type: 'advance', toMilliseconds: firstExpiration - 1 });
|
||
expect(engine.switchSatEntries(switchId)).toHaveLength(2);
|
||
engine.handle({ type: 'advance', toMilliseconds: lastExpiration });
|
||
expect(engine.switchSatEntries(switchId)).toEqual([]);
|
||
});
|
||
});
|
||
|
||
describe('firewall', () => {
|
||
it('accepts when inactive, and an active ICMP drop bypasses the ordered rules', () => {
|
||
const routerId = uuid(0x1801);
|
||
const portId = uuid(0x1811);
|
||
const router: NodeSnapshot = { id: routerId, kind: 'router', ports: [portSnapshot(portId, 'rt1')] };
|
||
const packet: IPv4Packet = {
|
||
identity: 18,
|
||
senderIPAddress: '203.0.113.10',
|
||
receiverIPAddress: '10.0.0.1',
|
||
timeToLive: 64,
|
||
protocol: 'icmp',
|
||
payload: { kind: 'icmp', message: icmpMessage('echoRequest') },
|
||
};
|
||
const interfaces = new Map([
|
||
[interfaceKey(routerId, portId), { ipAddress: '10.0.0.1', subnetMask: '255.255.255.0' }],
|
||
]);
|
||
|
||
const inactive = startedEngine(
|
||
{
|
||
nodes: [router],
|
||
links: [],
|
||
interfaceConfigurations: interfaces,
|
||
firewallConfigurationsByNodeId: new Map([[routerId, firewallConfiguration()]]),
|
||
},
|
||
18,
|
||
);
|
||
expect(inactive.evaluateFirewall(packet, routerId).accepted).toBe(true);
|
||
|
||
const active = startedEngine(
|
||
{
|
||
nodes: [router],
|
||
links: [],
|
||
interfaceConfigurations: interfaces,
|
||
firewallConfigurationsByNodeId: new Map([
|
||
[
|
||
routerId,
|
||
firewallConfiguration({
|
||
isActive: true,
|
||
defaultPolicy: 'accept',
|
||
dropIcmp: true,
|
||
rules: [firewallRule({ protocol: 'icmp', action: 'accept' })],
|
||
}),
|
||
],
|
||
]),
|
||
},
|
||
18,
|
||
);
|
||
expect(active.evaluateFirewall(packet, routerId).accepted).toBe(false);
|
||
expect(active.state.firewallDecisions.at(-1)?.ruleIndex).toBeUndefined();
|
||
expect(active.state.packetTraces.at(-1)?.operation).toBe('dropped');
|
||
});
|
||
|
||
it('lets the first matching TCP rule win, and established traffic bypasses SYN-only rules', () => {
|
||
const { engine, routerId } = firewallEngine(
|
||
firewallConfiguration({
|
||
isActive: true,
|
||
defaultPolicy: 'drop',
|
||
filterSynSegmentsOnly: true,
|
||
rules: [
|
||
firewallRule({ port: 443, protocol: 'tcp', action: 'accept' }),
|
||
firewallRule({ port: 443, protocol: 'tcp', action: 'drop' }),
|
||
],
|
||
}),
|
||
);
|
||
|
||
const synDecision = engine.evaluateFirewall(tcpPacket(181, 443, TCP_FLAG.synchronize), routerId);
|
||
expect(synDecision.accepted).toBe(true);
|
||
expect(synDecision.ruleIndex).toBe(0);
|
||
expect(engine.evaluateFirewall(tcpPacket(182, 22, TCP_FLAG.acknowledgement), routerId).accepted).toBe(true);
|
||
});
|
||
|
||
it('matches a UDP response by its explicit source port and the direct-network marker', () => {
|
||
const { engine, routerId } = firewallEngine(
|
||
firewallConfiguration({
|
||
isActive: true,
|
||
defaultPolicy: 'drop',
|
||
rules: [
|
||
firewallRule({
|
||
sourceIPAddress: DIRECTLY_CONNECTED_SOURCE_MARKER,
|
||
destinationIPAddress: '8.8.8.8',
|
||
destinationSubnetMask: '255.255.255.255',
|
||
port: 53,
|
||
protocol: 'udp',
|
||
action: 'accept',
|
||
}),
|
||
],
|
||
}),
|
||
);
|
||
const udp = (identity: number, sender: string, receiver: string, sourcePort: number, destinationPort: number): IPv4Packet => ({
|
||
identity,
|
||
senderIPAddress: sender,
|
||
receiverIPAddress: receiver,
|
||
timeToLive: 64,
|
||
protocol: 'udp',
|
||
payload: { kind: 'udp', datagram: { sourcePort, destinationPort, payload: new Uint8Array() } },
|
||
});
|
||
|
||
expect(engine.evaluateFirewall(udp(183, '10.0.0.20', '8.8.8.8', 50_001, 53), routerId).accepted).toBe(true);
|
||
expect(engine.evaluateFirewall(udp(184, '8.8.8.8', '10.0.0.20', 53, 50_001), routerId).accepted).toBe(true);
|
||
});
|
||
});
|
||
|
||
describe('packet loss and trace retention', () => {
|
||
it('drops every physical delivery while global packet loss is on', () => {
|
||
const firstNodeId = uuid(0x101);
|
||
const firstPortId = uuid(0x102);
|
||
const secondNodeId = uuid(0x103);
|
||
const secondPortId = uuid(0x104);
|
||
const engine = startedEngine({
|
||
nodes: [pc(firstNodeId, firstPortId), pc(secondNodeId, secondPortId)],
|
||
links: [link(firstNodeId, firstPortId, secondNodeId, secondPortId)],
|
||
deviceConfigurations: new Map([
|
||
[firstNodeId, device('10.0.0.1')],
|
||
[secondNodeId, device('10.0.0.2')],
|
||
]),
|
||
});
|
||
const frame: EthernetFrame = {
|
||
identity: engine.allocateFrameIdentity(),
|
||
sourceMacAddress: stableMacAddress(firstPortId),
|
||
destinationMacAddress: ETHERNET_BROADCAST_MAC_ADDRESS,
|
||
payload: {
|
||
kind: 'arp',
|
||
packet: {
|
||
operation: 'request',
|
||
senderMacAddress: stableMacAddress(firstPortId),
|
||
senderIPAddress: '10.0.0.1',
|
||
targetMacAddress: UNSPECIFIED_MAC_ADDRESS,
|
||
targetIPAddress: '10.0.0.2',
|
||
},
|
||
},
|
||
};
|
||
|
||
engine.setGlobalPacketLossEnabled(true);
|
||
engine.sendEthernetFrame(firstNodeId, firstPortId, frame);
|
||
letFramesArrive(engine);
|
||
|
||
expect(engine.state.globalPacketLossEnabled).toBe(true);
|
||
expect(engine.state.arpCachesByNodeId.get(secondNodeId)?.size ?? 0).toBe(0);
|
||
expect(
|
||
engine.state.packetTraces.some(
|
||
(trace) =>
|
||
trace.frameIdentity === frame.identity &&
|
||
trace.nodeId === secondNodeId &&
|
||
trace.operation === 'dropped' &&
|
||
trace.detail === 'global packet-loss simulation',
|
||
),
|
||
).toBe(true);
|
||
|
||
engine.setGlobalPacketLossEnabled(false);
|
||
engine.sendEthernetFrame(firstNodeId, firstPortId, frame);
|
||
letFramesArrive(engine);
|
||
|
||
expect(engine.state.globalPacketLossEnabled).toBe(false);
|
||
expect(engine.state.arpCachesByNodeId.get(secondNodeId)?.get('10.0.0.1')?.macAddress).toBe(
|
||
stableMacAddress(firstPortId),
|
||
);
|
||
});
|
||
|
||
it('resets packet loss across stop and restart', () => {
|
||
const engine = new NetworkRuntimeEngine(42);
|
||
engine.handle({ type: 'start', snapshot: EMPTY_TOPOLOGY_SNAPSHOT, seed: 42, initialTimeMilliseconds: 0 });
|
||
engine.setGlobalPacketLossEnabled(true);
|
||
expect(engine.state.globalPacketLossEnabled).toBe(true);
|
||
|
||
engine.handle({ type: 'stop' });
|
||
expect(engine.state.globalPacketLossEnabled).toBe(false);
|
||
|
||
engine.handle({ type: 'start', snapshot: EMPTY_TOPOLOGY_SNAPSHOT, seed: 42, initialTimeMilliseconds: 0 });
|
||
expect(engine.state.globalPacketLossEnabled).toBe(false);
|
||
});
|
||
|
||
it('evicts the oldest trace events in configured batches', () => {
|
||
const nodeId = uuid(0x2001);
|
||
const interfaceId = uuid(0x2002);
|
||
const engine = new NetworkRuntimeEngine(20, traceRetentionPolicy(3, 2));
|
||
|
||
for (let identity = 1; identity <= 6; identity += 1) {
|
||
engine.recordTrace({
|
||
frameIdentity: identity,
|
||
nodeId,
|
||
interfaceId,
|
||
direction: 'outbound',
|
||
layer: 'dataLink',
|
||
operation: 'sent',
|
||
detail: `trace-${identity}`,
|
||
});
|
||
}
|
||
|
||
expect(engine.state.packetTraces.map((trace) => trace.id)).toEqual([5, 6]);
|
||
expect(engine.state.packetTraces.map((trace) => trace.detail)).toEqual(['trace-5', 'trace-6']);
|
||
expect(engine.state.discardedPacketTraceCount).toBe(4);
|
||
expect(engine.state.nextTraceIdentity).toBe(7);
|
||
});
|
||
});
|
||
|
||
describe('TCP sockets', () => {
|
||
/** Two PCs on one cable, started. */
|
||
const pair = (first: number, second: number, prefix: string) => {
|
||
const [clientId, clientPortId, serverId, serverPortId] = [first, first + 0x10, second, second + 0x10].map(uuid) as [
|
||
string,
|
||
string,
|
||
string,
|
||
string,
|
||
];
|
||
const engine = startedEngine({
|
||
nodes: [pc(clientId, clientPortId), pc(serverId, serverPortId)],
|
||
links: [link(clientId, clientPortId, serverId, serverPortId)],
|
||
deviceConfigurations: new Map([
|
||
[clientId, device(`${prefix}.1`)],
|
||
[serverId, device(`${prefix}.2`)],
|
||
]),
|
||
});
|
||
return { engine, clientId, serverId };
|
||
};
|
||
|
||
it('makes an established client and an accepted server socket with the three-way handshake', () => {
|
||
const { engine, clientId, serverId } = pair(0x1701, 0x1702, '10.17.0');
|
||
const listenerId = engine.openTcpServerSocket(serverId, 8080)!;
|
||
const clientSocketId = engine.openTcpClientSocket(clientId, '10.17.0.2', 8080)!;
|
||
|
||
expect(engine.connectTcp(clientSocketId)).toBe(true);
|
||
const acceptedSocketId = engine.state.tcpAcceptedSocketIdsByListenerId.get(listenerId)![0]!;
|
||
expect(engine.state.socketsById.get(clientSocketId)?.tcpState).toBe('ESTABLISHED');
|
||
// connect returns with the SYN+ACK; the client's ACK is still on its way.
|
||
expect(engine.state.socketsById.get(acceptedSocketId)?.tcpState).toBe('SYN_RCVD');
|
||
letFramesArrive(engine);
|
||
expect(engine.state.socketsById.get(acceptedSocketId)?.tcpState).toBe('ESTABLISHED');
|
||
expect(engine.state.packetTraces.some((trace) => trace.detail === 'TCP SYN_SENT -> ESTABLISHED')).toBe(true);
|
||
expect(engine.state.packetTraces.some((trace) => trace.detail === 'TCP SYN_RCVD -> ESTABLISHED')).toBe(true);
|
||
});
|
||
|
||
it("sends segments of Java's MSS and reassembles one message up to the push", () => {
|
||
const { engine, clientId, serverId } = pair(0x1721, 0x1722, '10.18.0');
|
||
const listenerId = engine.openTcpServerSocket(serverId, 8081)!;
|
||
const clientSocketId = engine.openTcpClientSocket(clientId, '10.18.0.2', 8081)!;
|
||
expect(engine.connectTcp(clientSocketId)).toBe(true);
|
||
const acceptedSocketId = engine.state.tcpAcceptedSocketIdsByListenerId.get(listenerId)![0]!;
|
||
const payload = Uint8Array.from({ length: 3_001 }, (_, index) => index % 251);
|
||
|
||
expect(engine.sendTcp(clientSocketId, payload)).toBe(true);
|
||
expect(engine.receiveTcp(acceptedSocketId)).toEqual(payload);
|
||
const payloadLengths = engine.state.packetTraces
|
||
.filter((trace) => trace.detail?.startsWith('TCP data') === true)
|
||
.map((trace) => Number(trace.afterHeaders.find((header) => header.name === 'payloadLength')?.value));
|
||
expect(payloadLengths).toEqual([1_460, 1_460, 81]);
|
||
});
|
||
|
||
it("sends TCP in a thread of its own: UDP waiting for ARP does not hold a connection up", () => {
|
||
const { engine, clientId, serverId } = pair(0x1731, 0x1732, '10.24.0');
|
||
engine.openTcpServerSocket(serverId, 80);
|
||
engine.sendUdp(engine.bindUdpSocket(clientId, { remoteIPAddress: '10.24.0.99', remotePort: 9 })!, new TextEncoder().encode('nobody'));
|
||
const clientSocketId = engine.openTcpClientSocket(clientId, '10.24.0.2', 80)!;
|
||
|
||
expect(engine.connectTcp(clientSocketId)).toBe(true);
|
||
// ARP for the server (behind the one for .99 on the cable), SYN and SYN+ACK: four cables of 55 ms.
|
||
expect(engine.state.currentTimeMilliseconds).toBe(55 + 4 * 55);
|
||
});
|
||
|
||
it("holds the TCP thread while ARP waits: a connection's SYN waits behind a SYN+ACK to no one", () => {
|
||
const { engine, clientId, serverId } = pair(0x17e1, 0x17e2, '10.25.0');
|
||
engine.openTcpServerSocket(serverId, 80);
|
||
engine.openTcpServerSocket(clientId, 81);
|
||
// A SYN to the client's port 81 from .99, which no card has: the client's answer waits for ARP.
|
||
engine.sendIPv4Packet(serverId, {
|
||
identity: engine.allocatePacketIdentity(),
|
||
senderIPAddress: '10.25.0.99',
|
||
receiverIPAddress: '10.25.0.1',
|
||
timeToLive: 64,
|
||
protocol: 'tcp',
|
||
payload: {
|
||
kind: 'tcp',
|
||
segment: { sourcePort: 5000, destinationPort: 81, sequenceNumber: 1, acknowledgementNumber: 0, flags: TCP_FLAG.synchronize, payload: new Uint8Array() },
|
||
},
|
||
});
|
||
engine.handle({ type: 'advance', toMilliseconds: 200 });
|
||
const clientSocketId = engine.openTcpClientSocket(clientId, '10.25.0.2', 80)!;
|
||
|
||
expect(engine.connectTcp(clientSocketId)).toBe(true);
|
||
// The SYN arrived after ARP there and back (165 ms); ARP for .99 gives up two
|
||
// round trips later, then the client's SYN and the server's SYN+ACK.
|
||
expect(engine.state.currentTimeMilliseconds).toBe(165 + 2 * 2_750 + 2 * 55);
|
||
});
|
||
|
||
it('retransmits a connection attempt at most three times and releases the port', () => {
|
||
const clientId = uuid(0x1741);
|
||
const engine = startedEngine({ nodes: [pc(clientId, uuid(0x1751))], links: [], deviceConfigurations: new Map([[clientId, device('10.19.0.1')]]) });
|
||
const socketId = engine.openTcpClientSocket(clientId, '10.19.0.99', 80)!;
|
||
const localPort = engine.state.socketsById.get(socketId)!.localPort;
|
||
|
||
expect(engine.connectTcp(socketId)).toBe(false);
|
||
expect(engine.state.tcpSessionsById.get(socketId)?.sendAttempts).toBe(3);
|
||
expect(engine.state.socketsById.get(socketId)?.tcpState).toBe('CLOSED');
|
||
expect(engine.isTcpPortReserved(clientId, localPort)).toBe(false);
|
||
expect(engine.state.packetTraces.filter((trace) => trace.detail?.includes('TCP SYN retransmission') === true)).toHaveLength(2);
|
||
});
|
||
|
||
it('answers a repeated SYN with its SYN+ACK once more: a lost SYN+ACK costs one attempt (Java would close)', () => {
|
||
const { engine, clientId, serverId } = pair(0x17f1, 0x17f2, '10.26.0');
|
||
loseFirst(engine, serverId, (segment) => segment.flags === (TCP_FLAG.synchronize | TCP_FLAG.acknowledgement));
|
||
const listenerId = engine.openTcpServerSocket(serverId, 80)!;
|
||
const clientSocketId = engine.openTcpClientSocket(clientId, '10.26.0.2', 80)!;
|
||
|
||
expect(engine.connectTcpWithResult(clientSocketId)).toBe('connected');
|
||
// The second SYN leaves a timeout after the first was handed down, its SYN+ACK comes a cable later each way.
|
||
expect(engine.state.currentTimeMilliseconds).toBe(3 * 2_750 + 2 * 55);
|
||
letFramesArrive(engine);
|
||
const acceptedSocketId = engine.state.tcpAcceptedSocketIdsByListenerId.get(listenerId)![0]!;
|
||
expect(engine.state.socketsById.get(acceptedSocketId)?.tcpState).toBe('ESTABLISHED');
|
||
expect(engine.state.packetTraces.filter((trace) => trace.detail?.startsWith('TCP SYN+ACK') === true).map((trace) => trace.detail)).toEqual([
|
||
'TCP SYN+ACK',
|
||
'TCP SYN+ACK retransmission attempt 2',
|
||
]);
|
||
});
|
||
|
||
it("gives a handshake up after one timeout and closes with a FIN, sent once, then five waits", () => {
|
||
const { engine, clientId, serverId } = pair(0x17f5, 0x17f6, '10.27.0');
|
||
const listenerId = engine.openTcpServerSocket(serverId, 80)!;
|
||
// A SYN from the client's address, but no socket there: the SYN+ACK finds no one.
|
||
engine.sendIPv4Packet(clientId, {
|
||
identity: engine.allocatePacketIdentity(),
|
||
senderIPAddress: '10.27.0.1',
|
||
receiverIPAddress: '10.27.0.2',
|
||
timeToLive: 64,
|
||
protocol: 'tcp',
|
||
payload: {
|
||
kind: 'tcp',
|
||
segment: { sourcePort: 5000, destinationPort: 80, sequenceNumber: 1, acknowledgementNumber: 0, flags: TCP_FLAG.synchronize, payload: new Uint8Array() },
|
||
},
|
||
});
|
||
engine.handle({ type: 'advance', toMilliseconds: 60_000 });
|
||
|
||
const acceptedSocketId = engine.state.tcpAcceptedSocketIdsByListenerId.get(listenerId)![0]!;
|
||
const states = engine.state.packetTraces
|
||
.filter((trace) => trace.nodeId === serverId && trace.detail?.includes(' -> ') === true && !trace.detail.includes('LISTEN -> CLOSED') && !trace.detail.includes('CLOSED -> LISTEN'))
|
||
.map((trace) => [trace.timeMilliseconds, trace.detail]);
|
||
// The SYN arrives after ARP there and back: 165 ms.
|
||
expect(states).toEqual([
|
||
[165, 'TCP LISTEN -> SYN_RCVD'],
|
||
[165 + 3 * 2_750, 'TCP SYN_RCVD -> FIN_WAIT_1'],
|
||
[165 + 3 * 2_750 + 5 * 3 * 2_750, 'TCP FIN_WAIT_1 -> CLOSED'],
|
||
]);
|
||
expect(engine.state.socketsById.get(acceptedSocketId)?.tcpState).toBe('CLOSED');
|
||
expect(engine.state.packetTraces.filter((trace) => trace.nodeId === serverId && trace.layer === 'transport' && trace.detail?.startsWith('TCP ') === true && trace.operation !== 'accepted' && trace.operation !== 'dropped').map((trace) => trace.detail)).toEqual([
|
||
'TCP SYN+ACK',
|
||
'TCP FIN',
|
||
]);
|
||
});
|
||
|
||
it('drops a SYN+ACK at a listening port with no connection of its own', () => {
|
||
const { engine, clientId, serverId } = pair(0x1781, 0x1782, '10.21.0');
|
||
const listenerId = engine.openTcpServerSocket(serverId, 7000)!;
|
||
const segment = { sourcePort: 80, destinationPort: 7000, sequenceNumber: 5, acknowledgementNumber: 9, flags: TCP_FLAG.synchronize | TCP_FLAG.acknowledgement, payload: new Uint8Array() };
|
||
engine.sendIPv4Packet(clientId, {
|
||
identity: engine.allocatePacketIdentity(),
|
||
senderIPAddress: '10.21.0.1',
|
||
receiverIPAddress: '10.21.0.2',
|
||
timeToLive: 64,
|
||
protocol: 'tcp',
|
||
payload: { kind: 'tcp', segment },
|
||
});
|
||
engine.handle({ type: 'advance', toMilliseconds: engine.state.currentTimeMilliseconds + 1_000 });
|
||
|
||
expect(engine.state.tcpAcceptedSocketIdsByListenerId.get(listenerId)).toEqual([]);
|
||
expect(engine.state.packetTraces.some((trace) => trace.detail === 'TCP destination port closed')).toBe(true);
|
||
});
|
||
|
||
it('names the flags of a segment in Swift\'s order', () => {
|
||
expect(tcpFlagDescription(TCP_FLAG.acknowledgement | TCP_FLAG.push | TCP_FLAG.reset | TCP_FLAG.synchronize | TCP_FLAG.finish)).toBe(
|
||
'FIN+SYN+RST+PSH+ACK',
|
||
);
|
||
expect(tcpFlagDescription(TCP_FLAG.synchronize | TCP_FLAG.acknowledgement)).toBe('SYN+ACK');
|
||
expect(tcpFlagDescription(0)).toBe('');
|
||
});
|
||
|
||
it("closes with Java's FIN states: the other end closes by itself, TIME_WAIT lasts three round trips", () => {
|
||
const { engine, clientId, serverId } = pair(0x1761, 0x1762, '10.20.0');
|
||
const listenerId = engine.openTcpServerSocket(serverId, 9090)!;
|
||
const clientSocketId = engine.openTcpClientSocket(clientId, '10.20.0.2', 9090)!;
|
||
expect(engine.connectTcp(clientSocketId)).toBe(true);
|
||
const serverSocketId = engine.state.tcpAcceptedSocketIdsByListenerId.get(listenerId)![0]!;
|
||
|
||
engine.closeTcpSocket(clientSocketId);
|
||
letFramesArrive(engine);
|
||
// Java's socket thread leaves listen on the FIN and closes without the application.
|
||
const serverStates = engine.state.packetTraces
|
||
.filter((trace) => trace.nodeId === serverId && trace.detail?.startsWith('TCP ') === true && trace.detail.includes(' -> '))
|
||
.map((trace) => trace.detail);
|
||
expect(serverStates.slice(-3)).toEqual(['TCP ESTABLISHED -> CLOSE_WAIT', 'TCP CLOSE_WAIT -> LAST_ACK', 'TCP LAST_ACK -> CLOSED']);
|
||
expect(engine.state.socketsById.get(clientSocketId)?.tcpState).toBe('TIME_WAIT');
|
||
engine.closeTcpSocket(serverSocketId);
|
||
expect(engine.state.socketsById.get(serverSocketId)?.tcpState).toBe('CLOSED');
|
||
|
||
// Java's defaultTimeout: three round trips of 2750 ms at 90 %.
|
||
const timeWait = engine.state.packetTraces.find((trace) => trace.nodeId === clientId && trace.detail?.endsWith('-> TIME_WAIT') === true)!
|
||
.timeMilliseconds;
|
||
engine.handle({ type: 'advance', toMilliseconds: timeWait + 3 * 2_750 - 1 });
|
||
expect(engine.state.socketsById.get(clientSocketId)?.tcpState).toBe('TIME_WAIT');
|
||
engine.handle({ type: 'advance', toMilliseconds: timeWait + 3 * 2_750 });
|
||
expect(engine.state.socketsById.get(clientSocketId)?.tcpState).toBe('CLOSED');
|
||
expect(engine.state.socketsById.get(listenerId)?.tcpState).toBe('LISTEN');
|
||
});
|
||
|
||
it('carries one frame after the other on a cable, as Java (Swift: all at once)', () => {
|
||
const departures = (swiftCables: boolean) => {
|
||
const [clientId, clientPortId, serverId, serverPortId] = [0x17a1, 0x17b1, 0x17a2, 0x17b2].map(uuid) as [string, string, string, string];
|
||
const engine = new NetworkRuntimeEngine(14, undefined, { swiftCables });
|
||
engine.handle({
|
||
type: 'start',
|
||
snapshot: topologySnapshot({
|
||
nodes: [pc(clientId, clientPortId), pc(serverId, serverPortId)],
|
||
links: [link(clientId, clientPortId, serverId, serverPortId)],
|
||
deviceConfigurations: new Map([
|
||
[clientId, device('10.23.0.1')],
|
||
[serverId, device('10.23.0.2')],
|
||
]),
|
||
}),
|
||
seed: 14,
|
||
initialTimeMilliseconds: 0,
|
||
});
|
||
engine.openTcpServerSocket(serverId, 80);
|
||
const client = engine.openTcpClientSocket(clientId, '10.23.0.2', 80)!;
|
||
expect(engine.connectTcp(client)).toBe(true);
|
||
letFramesArrive(engine);
|
||
const closedAt = engine.state.currentTimeMilliseconds;
|
||
engine.closeTcpSocket(client);
|
||
letFramesArrive(engine);
|
||
// The server answers the FIN with its ACK and, closing by itself, its FIN at once.
|
||
const physical = (nodeId: string, operation: string) =>
|
||
engine.state.packetTraces
|
||
.filter((trace) => trace.nodeId === nodeId && trace.layer === 'physical' && trace.operation === operation && trace.timeMilliseconds > closedAt)
|
||
.map((trace) => trace.timeMilliseconds - closedAt);
|
||
return { sent: physical(serverId, 'sent'), received: physical(clientId, 'received') };
|
||
};
|
||
|
||
expect(departures(false)).toEqual({ sent: [55, 110], received: [110, 165] });
|
||
expect(departures(true)).toEqual({ sent: [55, 55], received: [110, 110] });
|
||
});
|
||
|
||
it('queues a third frame behind the second on a busy cable', () => {
|
||
// Four DHCP clients start at once and broadcast their DISCOVER; the switch
|
||
// floods three of them onto each client's cable.
|
||
const switchId = uuid(0x17c0);
|
||
const hosts = [0, 1, 2, 3].map((index) => ({ id: uuid(0x17c1 + 2 * index), portId: uuid(0x17c2 + 2 * index), switchPortId: uuid(0x17d0 + index) }));
|
||
const engine = startedEngine({
|
||
nodes: [
|
||
{ id: switchId, kind: 'networkSwitch', ports: hosts.map((host, index) => portSnapshot(host.switchPortId, `sw${index + 1}`)) },
|
||
...hosts.map((host) => pc(host.id, host.portId)),
|
||
],
|
||
links: hosts.map((host) => link(host.id, host.portId, switchId, host.switchPortId)),
|
||
deviceConfigurations: new Map(hosts.map((host) => [host.id, device('0.0.0.0')])),
|
||
dhcpClientConfigurationsByNodeId: new Map(hosts.map((host) => [host.id, { isEnabled: true }])),
|
||
});
|
||
engine.handle({ type: 'advance', toMilliseconds: 1_000 });
|
||
|
||
const received = engine.state.packetTraces
|
||
.filter((trace) => trace.nodeId === hosts[0]!.id && trace.layer === 'physical' && trace.operation === 'received')
|
||
.map((trace) => trace.timeMilliseconds);
|
||
expect(received).toEqual([110, 165, 220]);
|
||
});
|
||
|
||
it('ends TIME_WAIT with the next segment that comes', () => {
|
||
const { engine, clientId, serverId } = pair(0x1771, 0x1772, '10.21.0');
|
||
engine.openTcpServerSocket(serverId, 9090);
|
||
const clientSocketId = engine.openTcpClientSocket(clientId, '10.21.0.2', 9090)!;
|
||
expect(engine.connectTcp(clientSocketId)).toBe(true);
|
||
engine.closeTcpSocket(clientSocketId);
|
||
letFramesArrive(engine);
|
||
expect(engine.state.socketsById.get(clientSocketId)?.tcpState).toBe('TIME_WAIT');
|
||
|
||
const localPort = engine.state.socketsById.get(clientSocketId)!.localPort;
|
||
engine.sendIPv4Packet(serverId, {
|
||
identity: 77,
|
||
senderIPAddress: '10.21.0.2',
|
||
receiverIPAddress: '10.21.0.1',
|
||
timeToLive: 64,
|
||
protocol: 'tcp',
|
||
payload: {
|
||
kind: 'tcp',
|
||
segment: { sourcePort: 9090, destinationPort: localPort, sequenceNumber: 1, acknowledgementNumber: 0, flags: TCP_FLAG.acknowledgement, payload: new Uint8Array() },
|
||
},
|
||
});
|
||
engine.handle({ type: 'advance', toMilliseconds: engine.state.currentTimeMilliseconds + 100 });
|
||
|
||
expect(engine.state.socketsById.get(clientSocketId)?.tcpState).toBe('CLOSED');
|
||
});
|
||
|
||
it('accepts on the socket that listened, and listens on with the next sequence number', () => {
|
||
const [firstId, firstPortId, secondId, secondPortId, serverId, serverPortId, switchId] = [
|
||
0x1781, 0x1782, 0x1783, 0x1784, 0x1785, 0x1786, 0x1787,
|
||
].map(uuid) as [string, string, string, string, string, string, string];
|
||
const switchPorts = [0x1791, 0x1792, 0x1793].map(uuid) as [string, string, string];
|
||
const engine = startedEngine({
|
||
nodes: [
|
||
pc(firstId, firstPortId),
|
||
pc(secondId, secondPortId),
|
||
pc(serverId, serverPortId),
|
||
{ id: switchId, kind: 'networkSwitch', ports: switchPorts.map((id, index) => portSnapshot(id, `sw${index + 1}`)) },
|
||
],
|
||
links: [
|
||
link(firstId, firstPortId, switchId, switchPorts[0]),
|
||
link(secondId, secondPortId, switchId, switchPorts[1]),
|
||
link(serverId, serverPortId, switchId, switchPorts[2]),
|
||
],
|
||
deviceConfigurations: new Map([
|
||
[firstId, device('10.22.0.1')],
|
||
[secondId, device('10.22.0.2')],
|
||
[serverId, device('10.22.0.3')],
|
||
]),
|
||
});
|
||
// Java counts initial sequence numbers over all TCP sockets: the listener 1, the first client 2,
|
||
// the listener's successor 3 (made when the first is accepted), the second client 4.
|
||
const listenerId = engine.openTcpServerSocket(serverId, 80)!;
|
||
expect(engine.connectTcp(engine.openTcpClientSocket(firstId, '10.22.0.3', 80)!)).toBe(true);
|
||
letFramesArrive(engine);
|
||
expect(engine.connectTcp(engine.openTcpClientSocket(secondId, '10.22.0.3', 80)!)).toBe(true);
|
||
|
||
const accepted = engine.state.tcpAcceptedSocketIdsByListenerId.get(listenerId)!;
|
||
expect(accepted.map((id) => engine.state.tcpSessionsById.get(id)?.nextSendSequenceNumber)).toEqual([1_000_001, 3_000_001]);
|
||
});
|
||
});
|
||
|
||
describe('DHCP', () => {
|
||
// Java's round trip time at the default speed of 90 %: 50 cables of 55 ms.
|
||
const RTT = 2_750;
|
||
|
||
const serverAndClient = (first: number, serverIp: string, client: DeviceConfiguration, server: Partial<DhcpServerConfiguration>) => {
|
||
const [serverId, serverPortId, clientId, clientPortId] = [first, first + 1, first + 2, first + 3].map(uuid) as [string, string, string, string];
|
||
const engine = startedEngine(
|
||
{
|
||
nodes: [pc(serverId, serverPortId), pc(clientId, clientPortId)],
|
||
links: [link(serverId, serverPortId, clientId, clientPortId)],
|
||
deviceConfigurations: new Map([
|
||
[serverId, device(serverIp)],
|
||
[clientId, client],
|
||
]),
|
||
dhcpClientConfigurationsByNodeId: new Map([[clientId, { isEnabled: true }]]),
|
||
dhcpServerConfigurationsByNodeId: new Map([[serverId, dhcpServerConfiguration({ isActive: true, ...server })]]),
|
||
},
|
||
16,
|
||
);
|
||
return { engine, serverId, clientId, clientPortId };
|
||
};
|
||
|
||
/** Hosts on one switch, each with its device configuration; the first is the DHCP client. */
|
||
const onSwitch = (first: number, devices: DeviceConfiguration[], servers: Map<string, Partial<DhcpServerConfiguration>> = new Map()) => {
|
||
const switchId = uuid(first);
|
||
const hosts = devices.map((_, index) => ({ id: uuid(first + 1 + 2 * index), portId: uuid(first + 2 + 2 * index), switchPortId: uuid(first + 0x80 + index) }));
|
||
const switchNode: NodeSnapshot = {
|
||
id: switchId,
|
||
kind: 'networkSwitch',
|
||
ports: hosts.map((host, index) => portSnapshot(host.switchPortId, `sw${index + 1}`)),
|
||
};
|
||
const hostIds = hosts.map((host) => host.id);
|
||
const engine = startedEngine(
|
||
{
|
||
nodes: [switchNode, ...hosts.map((host) => pc(host.id, host.portId))],
|
||
links: hosts.map((host) => link(host.id, host.portId, switchId, host.switchPortId)),
|
||
deviceConfigurations: new Map(devices.map((each, index) => [hostIds[index]!, each])),
|
||
dhcpClientConfigurationsByNodeId: new Map([[hostIds[0]!, { isEnabled: true }]]),
|
||
dhcpServerConfigurationsByNodeId: new Map(
|
||
[...servers].map(([ipAddress, server]) => [
|
||
hostIds[devices.findIndex((each) => each.ipAddress === ipAddress)]!,
|
||
dhcpServerConfiguration({ isActive: true, ...server }),
|
||
]),
|
||
),
|
||
},
|
||
16,
|
||
);
|
||
return { engine, hostIds, portIds: hosts.map((host) => host.portId) };
|
||
};
|
||
|
||
/** A host tells everyone its address with an ARP reply to all (as Java answers a probe from 0.0.0.0): every card learns it. */
|
||
const announce = (engine: NetworkRuntimeEngine, nodeId: string, portId: string, ipAddress: string) => {
|
||
const mac = stableMacAddress(portId);
|
||
engine.sendEthernetFrame(nodeId, portId, {
|
||
identity: engine.allocateFrameIdentity(),
|
||
sourceMacAddress: mac,
|
||
destinationMacAddress: ETHERNET_BROADCAST_MAC_ADDRESS,
|
||
payload: {
|
||
kind: 'arp',
|
||
packet: { operation: 'reply', senderMacAddress: mac, senderIPAddress: ipAddress, targetMacAddress: ETHERNET_BROADCAST_MAC_ADDRESS, targetIPAddress: '255.255.255.255' },
|
||
},
|
||
});
|
||
letFramesArrive(engine);
|
||
};
|
||
|
||
/** When the node sent a DHCP message of the type. */
|
||
const sentAt = (engine: NetworkRuntimeEngine, nodeId: string, type: string) =>
|
||
engine.state.packetTraces
|
||
.filter((trace) => trace.nodeId === nodeId && trace.layer === 'application' && trace.operation === 'sent' && trace.detail === type)
|
||
.map((trace) => trace.timeMilliseconds);
|
||
|
||
/** When the node broadcast an ARP packet with the detail. */
|
||
const arpAt = (engine: NetworkRuntimeEngine, nodeId: string, detail: string) =>
|
||
engine.state.packetTraces
|
||
.filter((trace) => trace.nodeId === nodeId && trace.layer === 'dataLink' && trace.operation === 'created' && trace.detail === detail)
|
||
.map((trace) => trace.timeMilliseconds);
|
||
|
||
/** The link-local addresses the client draws with seed 16, as Java's selectArbitraryLinkLocalAddress skips known ones. */
|
||
const linkLocalDraws = (count: number) => {
|
||
const random = new JavaCompatibleRandom(16n ^ 0x4c4c_4144n);
|
||
const draws: string[] = [];
|
||
while (draws.length < count) {
|
||
const address = `169.254.1.${Math.round(random.nextDouble() * 253) + 1}`;
|
||
if (!draws.includes(address)) draws.push(address);
|
||
}
|
||
return draws;
|
||
};
|
||
|
||
it('completes DISCOVER, OFFER, REQUEST and ACK and applies the configuration', () => {
|
||
const { engine, clientId } = serverAndClient(0x1601, '192.168.50.1', device('169.254.1.2', { mask: '0.0.0.0' }), {
|
||
lowerBoundIPAddress: '192.168.50.20',
|
||
upperBoundIPAddress: '192.168.50.29',
|
||
gatewayIPAddress: '192.168.50.1',
|
||
dnsServerIPAddress: '192.168.50.53',
|
||
useOwnSettings: true,
|
||
});
|
||
|
||
engine.handle({ type: 'advance', toMilliseconds: 1_000 });
|
||
// A client that runs goes on waiting; it does not start over.
|
||
expect(engine.runDhcpClient(clientId)?.state).toBe('VALIDATE');
|
||
engine.handle({ type: 'advance', toMilliseconds: 5_000 });
|
||
|
||
expect(engine.state.topologySnapshot.deviceConfigurations.get(clientId)).toEqual(
|
||
deviceConfiguration('192.168.50.20', '255.255.255.0', '192.168.50.1', '192.168.50.53'),
|
||
);
|
||
const status = engine.state.dhcpClientStatusesByNodeId.get(clientId);
|
||
expect([status?.state, status?.errorCount, status?.succeeded, status?.linkLocal]).toEqual(['FINISH', 0, true, false]);
|
||
const leaseExpiry = [...engine.state.dhcpLeasesByIPAddress.values()][0]?.expiresAtMilliseconds;
|
||
expect(leaseExpiry).toBeGreaterThan(engine.state.currentTimeMilliseconds);
|
||
expect(leaseExpiry! - engine.state.currentTimeMilliseconds).toBeLessThanOrEqual(DHCP_DYNAMIC_LEASE_LIFETIME_MILLISECONDS);
|
||
// One message per attempt.
|
||
expect(sentAt(engine, clientId, 'DHCPDISCOVER')).toEqual([0]);
|
||
// The OFFER comes after two cables' delay; the ARP check waits one round trip before the REQUEST.
|
||
expect(arpAt(engine, clientId, 'ARP request for 192.168.50.20')).toEqual([110]);
|
||
expect(sentAt(engine, clientId, 'DHCPREQUEST')).toEqual([110 + RTT]);
|
||
});
|
||
|
||
it('matches a static assignment regardless of case, with a lease that does not expire', () => {
|
||
const clientPortId = uuid(0x1611 + 3);
|
||
const { engine, clientId } = serverAndClient(0x1611, '10.0.0.1', device('0.0.0.0'), {
|
||
lowerBoundIPAddress: '10.0.0.20',
|
||
upperBoundIPAddress: '10.0.0.20',
|
||
staticAssignments: [{ id: 'static', macAddress: stableMacAddress(clientPortId).toLowerCase(), ipAddress: '10.0.0.99' }],
|
||
});
|
||
|
||
engine.handle({ type: 'advance', toMilliseconds: 5_000 });
|
||
|
||
expect(engine.state.topologySnapshot.deviceConfigurations.get(clientId)?.ipAddress).toBe('10.0.0.99');
|
||
expect([...engine.state.dhcpLeasesByIPAddress.values()][0]?.expiresAtMilliseconds).toBeUndefined();
|
||
});
|
||
|
||
it('takes a link-local address after the fifth timeout and does not give back the old one', () => {
|
||
const clientId = uuid(0x1621);
|
||
const engine = startedEngine(
|
||
{
|
||
nodes: [pc(clientId, uuid(0x1622))],
|
||
links: [],
|
||
deviceConfigurations: new Map([[clientId, deviceConfiguration('169.254.5.7', '255.255.0.0', '169.254.0.1', '169.254.0.53')]]),
|
||
dhcpClientConfigurationsByNodeId: new Map([[clientId, { isEnabled: true }]]),
|
||
},
|
||
16,
|
||
);
|
||
|
||
engine.handle({ type: 'advance', toMilliseconds: 0 });
|
||
expect(engine.state.topologySnapshot.deviceConfigurations.get(clientId)?.ipAddress).toBe('0.0.0.0');
|
||
expect(engine.state.dhcpClientStatusesByNodeId.get(clientId)?.state).toBe('DISCOVER');
|
||
|
||
engine.handle({ type: 'advance', toMilliseconds: 5 * RTT - 1 });
|
||
expect(engine.state.dhcpClientStatusesByNodeId.get(clientId)?.errorCount).toBe(4);
|
||
engine.handle({ type: 'advance', toMilliseconds: 20_000 });
|
||
|
||
const [address] = linkLocalDraws(1);
|
||
expect(sentAt(engine, clientId, 'DHCPDISCOVER')).toEqual([0, RTT, 2 * RTT, 3 * RTT, 4 * RTT]);
|
||
expect(arpAt(engine, clientId, `ARP request for ${address}`)).toEqual([5 * RTT]);
|
||
expect(arpAt(engine, clientId, `ARP reply for ${address}`)).toEqual([6 * RTT]);
|
||
// Java: the address, its /16 mask and no gateway; the DNS server stays.
|
||
expect(engine.state.topologySnapshot.deviceConfigurations.get(clientId)).toEqual(
|
||
deviceConfiguration(address!, '255.255.0.0', '', '169.254.0.53'),
|
||
);
|
||
const status = engine.state.dhcpClientStatusesByNodeId.get(clientId);
|
||
expect([status?.state, status?.errorCount, status?.succeeded, status?.linkLocal]).toEqual(['FINISH', 5, true, true]);
|
||
expect(engine.state.dhcpClientContextsByNodeId.has(clientId)).toBe(false);
|
||
expect(engine.state.socketsById.size).toBe(0);
|
||
});
|
||
|
||
it('sends the REQUEST again to the same server when it does not answer', () => {
|
||
const { engine, serverId, clientId } = serverAndClient(0x1631, '10.0.0.1', device('0.0.0.0'), {
|
||
lowerBoundIPAddress: '10.0.0.20',
|
||
upperBoundIPAddress: '10.0.0.29',
|
||
});
|
||
engine.handle({ type: 'advance', toMilliseconds: 150 });
|
||
expect(engine.state.dhcpClientStatusesByNodeId.get(clientId)?.state).toBe('VALIDATE');
|
||
engine.closeSocket(engine.state.dhcpServerSocketIdsByNodeId.get(serverId)!);
|
||
|
||
engine.handle({ type: 'advance', toMilliseconds: 110 + 5 * RTT });
|
||
|
||
const requests = [1, 2, 3, 4, 5].map((attempt) => 110 + attempt * RTT);
|
||
expect(sentAt(engine, clientId, 'DHCPREQUEST')).toEqual(requests);
|
||
expect(sentAt(engine, clientId, 'DHCPDISCOVER')).toEqual([0]);
|
||
const status = engine.state.dhcpClientStatusesByNodeId.get(clientId);
|
||
expect([status?.state, status?.errorCount, status?.offeredIPAddress]).toEqual(['REQUEST', 4, '10.0.0.20']);
|
||
});
|
||
|
||
it('declines at once when another card answers for the offered address', () => {
|
||
const { engine, hostIds } = onSwitch(
|
||
0x1641,
|
||
[device('0.0.0.0'), device('192.168.0.10'), device('192.168.0.100')],
|
||
new Map([['192.168.0.10', { lowerBoundIPAddress: '192.168.0.100', upperBoundIPAddress: '192.168.0.100' }]]),
|
||
);
|
||
const clientId = hostIds[0]!;
|
||
|
||
engine.handle({ type: 'advance', toMilliseconds: 1_000 });
|
||
|
||
// The OFFER takes two cables to the client, the ARP request and the
|
||
// holder's reply two each; the DECLINE follows the reply, not the round trip.
|
||
const [check] = arpAt(engine, clientId, 'ARP request for 192.168.0.100');
|
||
expect(check).toBe(220);
|
||
expect(sentAt(engine, clientId, 'DHCPDECLINE')).toEqual([check! + 220]);
|
||
expect(sentAt(engine, clientId, 'DHCPDISCOVER')).toEqual([0, check! + 220]);
|
||
expect(sentAt(engine, clientId, 'DHCPREQUEST')).toEqual([]);
|
||
});
|
||
|
||
it('starts over with DISCOVER after a NAK, which is no failure', () => {
|
||
const { engine, serverId, clientId } = serverAndClient(0x1671, '10.0.0.1', device('0.0.0.0'), {
|
||
lowerBoundIPAddress: '10.0.0.20',
|
||
upperBoundIPAddress: '10.0.0.29',
|
||
});
|
||
engine.handle({ type: 'advance', toMilliseconds: 150 });
|
||
// While the client checks 10.0.0.20, the server bars it: the REQUEST gets a NAK.
|
||
engine.state.dhcpBlacklistByServerNodeId.set(serverId, new Map([['10.0.0.20', 1_000_000]]));
|
||
|
||
engine.handle({ type: 'advance', toMilliseconds: 10_000 });
|
||
|
||
const nak = 110 + RTT + 110;
|
||
expect(sentAt(engine, clientId, 'DHCPREQUEST')).toEqual([110 + RTT, nak + 110 + RTT]);
|
||
expect(sentAt(engine, clientId, 'DHCPDISCOVER')).toEqual([0, nak]);
|
||
const status = engine.state.dhcpClientStatusesByNodeId.get(clientId);
|
||
expect([status?.state, status?.errorCount, status?.succeeded]).toEqual(['FINISH', 0, true]);
|
||
expect(engine.state.topologySnapshot.deviceConfigurations.get(clientId)?.ipAddress).toBe('10.0.0.21');
|
||
});
|
||
|
||
it('ignores an answer from another server than the one it asked', () => {
|
||
const { engine, hostIds } = onSwitch(
|
||
0x1681,
|
||
[device('0.0.0.0'), device('192.168.0.10'), device('192.168.0.66')],
|
||
new Map([['192.168.0.10', { lowerBoundIPAddress: '192.168.0.100', upperBoundIPAddress: '192.168.0.100' }]]),
|
||
);
|
||
const [clientId, , rogueId] = hostIds as [string, string, string];
|
||
const clientMac = stableMacAddress(uuid(0x1681 + 2));
|
||
const request = 220 + RTT;
|
||
engine.handle({ type: 'advance', toMilliseconds: request + 10 });
|
||
expect(engine.state.dhcpClientStatusesByNodeId.get(clientId)?.state).toBe('REQUEST');
|
||
|
||
// A NAK for the client, but from 192.168.0.66; it arrives before the server's ACK.
|
||
const rogue = engine.bindUdpSocket(rogueId, { localPort: 67 })!;
|
||
const nak = encodeDhcpMessage({ type: 'DHCPNAK', yourIPAddress: '192.168.0.100', clientMacAddress: clientMac, serverIdentifier: '192.168.0.66' });
|
||
engine.sendUdp(rogue, nak, { ipAddress: '255.255.255.255', port: 68 });
|
||
engine.handle({ type: 'advance', toMilliseconds: 10_000 });
|
||
|
||
expect(sentAt(engine, clientId, 'DHCPDISCOVER')).toEqual([0]);
|
||
expect(engine.state.topologySnapshot.deviceConfigurations.get(clientId)?.ipAddress).toBe('192.168.0.100');
|
||
});
|
||
|
||
it("takes the other server's queued OFFER at once after a DECLINE", () => {
|
||
// Server A offers the address a third card has, server B another one; B's
|
||
// OFFER comes while the client checks A's and waits in its queue.
|
||
const { engine, hostIds } = onSwitch(
|
||
0x1691,
|
||
[device('0.0.0.0'), device('192.168.0.10'), device('192.168.0.20'), device('192.168.0.100')],
|
||
new Map([
|
||
['192.168.0.10', { lowerBoundIPAddress: '192.168.0.100', upperBoundIPAddress: '192.168.0.100' }],
|
||
['192.168.0.20', { lowerBoundIPAddress: '192.168.0.200', upperBoundIPAddress: '192.168.0.200' }],
|
||
]),
|
||
);
|
||
const clientId = hostIds[0]!;
|
||
|
||
engine.handle({ type: 'advance', toMilliseconds: 10_000 });
|
||
|
||
const decline = 220 + 220;
|
||
expect(sentAt(engine, clientId, 'DHCPDECLINE')).toEqual([decline]);
|
||
expect(sentAt(engine, clientId, 'DHCPDISCOVER')).toEqual([0, decline]);
|
||
expect(arpAt(engine, clientId, 'ARP request for 192.168.0.200')).toEqual([decline]);
|
||
expect(sentAt(engine, clientId, 'DHCPREQUEST')).toEqual([decline + RTT]);
|
||
expect(engine.state.topologySnapshot.deviceConfigurations.get(clientId)?.ipAddress).toBe('192.168.0.200');
|
||
});
|
||
|
||
it('skips a link-local address the ARP cache knows', () => {
|
||
const [known, next] = linkLocalDraws(2) as [string, string];
|
||
const { engine, hostIds, portIds } = onSwitch(0x16a1, [
|
||
device('0.0.0.0'),
|
||
device(known, { mask: '255.255.0.0' }),
|
||
device('169.254.9.9', { mask: '255.255.0.0' }),
|
||
]);
|
||
const clientId = hostIds[0]!;
|
||
engine.handle({ type: 'advance', toMilliseconds: 1_000 });
|
||
announce(engine, hostIds[1]!, portIds[1]!, known);
|
||
expect(engine.state.arpCachesByNodeId.get(clientId)?.has(known)).toBe(true);
|
||
|
||
engine.handle({ type: 'advance', toMilliseconds: 20_000 });
|
||
|
||
expect(arpAt(engine, clientId, `ARP request for ${known}`)).toEqual([]);
|
||
expect(arpAt(engine, clientId, `ARP request for ${next}`)).toEqual([5 * RTT]);
|
||
expect(engine.state.dhcpClientStatusesByNodeId.get(clientId)?.errorCount).toBe(5);
|
||
expect(engine.state.topologySnapshot.deviceConfigurations.get(clientId)?.ipAddress).toBe(next);
|
||
});
|
||
|
||
// Java's selectArbitraryLinkLocalAddress loop counts occupied addresses
|
||
// but checks its limit only after a timeout, which cannot come there: it
|
||
// searches without end. The engine gives up after the fifth, as
|
||
// LINK_LOCAL_MAX_ERROR_COUNT intends, and keeps 0.0.0.0.
|
||
it('gives up after five occupied link-local addresses (Java searches on without end)', () => {
|
||
const occupied = linkLocalDraws(5);
|
||
const { engine, hostIds } = onSwitch(0x1651, [device('0.0.0.0'), ...occupied.map((address) => device(address, { mask: '255.255.0.0' }))]);
|
||
const clientId = hostIds[0]!;
|
||
|
||
engine.handle({ type: 'advance', toMilliseconds: 30_000 });
|
||
|
||
for (const address of occupied) expect(arpAt(engine, clientId, `ARP request for ${address}`)).toHaveLength(1);
|
||
const status = engine.state.dhcpClientStatusesByNodeId.get(clientId);
|
||
expect([status?.state, status?.errorCount, status?.succeeded]).toEqual(['FINISH', 10, false]);
|
||
expect(engine.state.topologySnapshot.deviceConfigurations.get(clientId)?.ipAddress).toBe('0.0.0.0');
|
||
expect(engine.state.dhcpClientContextsByNodeId.has(clientId)).toBe(false);
|
||
});
|
||
|
||
// Java's ARP lookup waits on the whole ARP table: any entry that changes
|
||
// ends the wait, and the address counts as free. The engine waits for the
|
||
// address it checks, or the round trip.
|
||
it('waits out the address check when an unrelated ARP reply comes (Java would stop waiting)', () => {
|
||
const { engine, hostIds, portIds } = onSwitch(
|
||
0x1661,
|
||
[device('0.0.0.0'), device('192.168.0.10'), device('192.168.0.30')],
|
||
new Map([['192.168.0.10', { lowerBoundIPAddress: '192.168.0.100', upperBoundIPAddress: '192.168.0.100' }]]),
|
||
);
|
||
const clientId = hostIds[0]!;
|
||
engine.handle({ type: 'advance', toMilliseconds: 300 });
|
||
expect(engine.state.dhcpClientStatusesByNodeId.get(clientId)?.state).toBe('VALIDATE');
|
||
|
||
announce(engine, hostIds[2]!, portIds[2]!, '192.168.0.30');
|
||
expect(engine.state.arpCachesByNodeId.get(clientId)?.has('192.168.0.30')).toBe(true);
|
||
engine.handle({ type: 'advance', toMilliseconds: 5_000 });
|
||
|
||
const [check] = arpAt(engine, clientId, 'ARP request for 192.168.0.100');
|
||
expect(sentAt(engine, clientId, 'DHCPREQUEST')).toEqual([check! + RTT]);
|
||
expect(engine.state.topologySnapshot.deviceConfigurations.get(clientId)?.ipAddress).toBe('192.168.0.100');
|
||
});
|
||
|
||
it("writes and reads a message in Swift's text format", () => {
|
||
const message = {
|
||
type: 'DHCPOFFER' as const,
|
||
yourIPAddress: '10.0.0.5',
|
||
clientMacAddress: '02:00:00:00:00:01',
|
||
subnetMask: '255.255.255.0',
|
||
routerIPAddress: '10.0.0.1',
|
||
dnsServerIPAddress: '10.0.0.53',
|
||
requestedIPAddress: '',
|
||
serverIdentifier: '10.0.0.1',
|
||
};
|
||
const text = new TextDecoder().decode(encodeDhcpMessage(message));
|
||
expect(text).toBe(
|
||
'DHCPOFFER\nyiaddr=10.0.0.5\nchaddr=02:00:00:00:00:01\nrouter=10.0.0.1\nsubnetmask=255.255.255.0\ndnsserver=10.0.0.53\nserverident=10.0.0.1',
|
||
);
|
||
const { requestedIPAddress: _empty, ...withoutEmpty } = message;
|
||
expect(decodeDhcpMessage(new TextEncoder().encode(text))).toEqual(withoutEmpty);
|
||
expect(decodeDhcpMessage(new TextEncoder().encode('dhcpack\nyiaddr=1.2.3.4\nnoise'))).toEqual({ type: 'DHCPACK', yourIPAddress: '1.2.3.4' });
|
||
expect(decodeDhcpMessage(new TextEncoder().encode('HELLO'))).toBeUndefined();
|
||
});
|
||
});
|
||
|
||
describe('DNS', () => {
|
||
const lan = (records: Record<string, string[]>, recursive = false) => {
|
||
// Hosts 10.30.0.1 (the client, DNS server .2), .2, .3 on one switch.
|
||
const switchId = uuid(0x1900);
|
||
const hosts = [1, 2, 3].map((index) => ({ id: uuid(0x1900 + 2 * index), portId: uuid(0x1901 + 2 * index), switchPortId: uuid(0x1910 + index) }));
|
||
const ip = (index: number) => `10.30.0.${index + 1}`;
|
||
const record = (line: string) => {
|
||
const [name = '', type = 'A', target = ''] = line.split(' ');
|
||
return { name, type: type as 'A' | 'MX' | 'NS', ttlSeconds: 3600, target };
|
||
};
|
||
const engine = startedEngine({
|
||
nodes: [
|
||
{ id: switchId, kind: 'networkSwitch', ports: hosts.map((host, index) => portSnapshot(host.switchPortId, `sw${index + 1}`)) },
|
||
...hosts.map((host) => pc(host.id, host.portId)),
|
||
],
|
||
links: hosts.map((host) => link(host.id, host.portId, switchId, host.switchPortId)),
|
||
deviceConfigurations: new Map(hosts.map((host, index) => [host.id, deviceConfiguration(ip(index), '255.255.255.0', '', index === 0 ? '10.30.0.2' : '')])),
|
||
dnsServerConfigurationsByNodeId: new Map(
|
||
Object.entries(records).map(([address, lines]) => [
|
||
hosts[Number(address.split('.')[3]) - 1]!.id,
|
||
{ recursiveResolutionEnabled: recursive, records: lines.map(record) },
|
||
]),
|
||
),
|
||
});
|
||
return { engine, client: hosts[0]!.id, server: hosts[1]!.id };
|
||
};
|
||
const queries = (engine: NetworkRuntimeEngine) =>
|
||
engine.state.packetTraces.filter((trace) => trace.detail?.startsWith('DNS query') === true).map((trace) => trace.detail);
|
||
|
||
it("writes and reads Java's DNS messages", () => {
|
||
const message = {
|
||
id: 4711,
|
||
queryResponse: 1,
|
||
responseCode: 0,
|
||
questions: [],
|
||
answers: [
|
||
{ name: 'filius.de.', type: 'MX', ttl: 3600, data: 'mail.filius.de.' },
|
||
{ name: 'mail.filius.de.', type: 'A', ttl: 3600, data: '10.0.0.5' },
|
||
],
|
||
authority: [],
|
||
additional: [],
|
||
};
|
||
const text = encodeDnsMessage(message);
|
||
expect(text).toBe(
|
||
'ID=4711 QR=1 RCODE=0 QDCOUNT=0 ANCOUNT=2 NSCOUNT=0 ARCOUNT=0 \nfilius.de. MX 3600 mail.filius.de.\nmail.filius.de. A 3600 10.0.0.5\n',
|
||
);
|
||
expect(decodeDnsMessage(text)).toEqual(message);
|
||
// Java's StringTokenizer skips empty pieces; the counts say how many lines follow.
|
||
expect(decodeDnsMessage('ID=7 QR=0 QDCOUNT=1\n\nweb.filius.de. A IN\nleft over\n')).toEqual({
|
||
id: 7,
|
||
queryResponse: 0,
|
||
responseCode: 0,
|
||
questions: [{ name: 'web.filius.de.', type: 'A', questionClass: 'IN' }],
|
||
answers: [],
|
||
authority: [],
|
||
additional: [],
|
||
});
|
||
});
|
||
|
||
it('finds the name server of the longest zone, else the root one', () => {
|
||
const records = [
|
||
{ name: '.', type: 'NS', ttl: 1, data: 'root.' },
|
||
{ name: 'de.', type: 'NS', ttl: 1, data: 'ns.de.' },
|
||
{ name: 'Schule.DE.', type: 'NS', ttl: 1, data: 'ns.schule.de.' },
|
||
];
|
||
expect(findApplicableNameServerRecord('www.schule.de.', records)?.data).toBe('ns.schule.de.');
|
||
expect(findApplicableNameServerRecord('www.filius.de.', records)?.data).toBe('ns.de.');
|
||
expect(findApplicableNameServerRecord('www.filius.org.', records)?.data).toBe('root.');
|
||
expect(findApplicableNameServerRecord('www.filius.org.', records.slice(1))).toBeUndefined();
|
||
});
|
||
|
||
it('answers localhost itself, and nothing without a DNS server', () => {
|
||
const { engine, client } = lan({ '10.30.0.2': [] });
|
||
expect(engine.resolveDnsAddress(client, 'LocalHost')).toEqual({ type: 'resolved', address: '127.0.0.1' });
|
||
expect(engine.resolveDnsAddress(uuid(0x1904), 'web.filius.de')).toEqual({ type: 'resolved', address: undefined });
|
||
expect(queries(engine)).toEqual([]);
|
||
});
|
||
|
||
it('follows referrals, and asks the first server once more (Java remembers only the ones it was referred to)', () => {
|
||
const { engine, client } = lan({
|
||
'10.30.0.2': ['schule.de NS ns.schule.de.', 'ns.schule.de A 10.30.0.3'],
|
||
'10.30.0.3': ['schule.de NS ns2.schule.de.', 'ns2.schule.de A 10.30.0.2'],
|
||
});
|
||
|
||
expect(engine.resolveDnsAddress(client, 'www.schule.de')).toEqual({ type: 'resolved', address: undefined });
|
||
expect(queries(engine)).toEqual(['DNS query www.schule.de. A', 'DNS query www.schule.de. A', 'DNS query www.schule.de. A']);
|
||
expect([...engine.state.socketsById.values()].filter((socket) => socket.nodeId === client)).toEqual([]);
|
||
});
|
||
|
||
it('receives a datagram when it comes, not at the end of the wait', () => {
|
||
// A DHCP client without a server sends its DISCOVER again after a round trip (a timer, not the network).
|
||
const [clientId, clientPortId, listenerId, listenerPortId] = [0x1981, 0x1982, 0x1983, 0x1984].map(uuid) as [string, string, string, string];
|
||
const engine = startedEngine({
|
||
nodes: [pc(clientId, clientPortId), pc(listenerId, listenerPortId)],
|
||
links: [link(clientId, clientPortId, listenerId, listenerPortId)],
|
||
deviceConfigurations: new Map([
|
||
[clientId, device('0.0.0.0')],
|
||
[listenerId, device('10.31.0.2')],
|
||
]),
|
||
dhcpClientConfigurationsByNodeId: new Map([[clientId, { isEnabled: true }]]),
|
||
});
|
||
const socket = engine.bindUdpSocket(listenerId, { localPort: 67 })!;
|
||
engine.handle({ type: 'advance', toMilliseconds: 100 });
|
||
expect(engine.receiveUdp(socket)).toBeDefined();
|
||
|
||
expect(engine.receiveUdp(socket, 10_000)).toBeDefined();
|
||
expect(engine.state.currentTimeMilliseconds).toBe(2_750 + 55);
|
||
});
|
||
|
||
it('returns from the answer, not at the end of the wait', () => {
|
||
const { engine, client } = lan({ '10.30.0.2': ['web.filius.de A 10.30.0.9'] });
|
||
const called = engine.state.currentTimeMilliseconds;
|
||
expect(engine.resolveDnsAddress(client, 'web.filius.de')).toEqual({ type: 'resolved', address: '10.30.0.9' });
|
||
// ARP there and back, then the query and its answer: four cables each way through the switch.
|
||
expect(engine.state.currentTimeMilliseconds - called).toBe(4 * 110);
|
||
expect(engine.state.pendingEvents.some((event) => event.kind.kind === 'dnsQueryTimeout')).toBe(false);
|
||
});
|
||
|
||
it("answers behind a datagram of its own whose ARP fails: Java's UDP thread sends one after the other", () => {
|
||
const { engine, client, server } = lan({ '10.30.0.2': ['web.filius.de A 10.30.0.9'] });
|
||
const socket = engine.bindUdpSocket(server, { remoteIPAddress: '10.30.0.99', remotePort: 9 })!;
|
||
const called = engine.state.currentTimeMilliseconds;
|
||
engine.sendUdp(socket, new TextEncoder().encode('nobody'));
|
||
expect(engine.resolveDnsAddress(client, 'web.filius.de')).toEqual({ type: 'resolved', address: '10.30.0.9' });
|
||
// The answer leaves when ARP has given up on .99 (two round trips), and takes two cables through the switch.
|
||
expect(engine.state.currentTimeMilliseconds - called).toBe(2 * 2_750 + 110);
|
||
});
|
||
|
||
it('lets the senders that wait take their turns in the order they came, before the thread takes the next', () => {
|
||
const { engine, client, server } = lan({ '10.30.0.2': ['web.filius.de A 10.30.0.9'] });
|
||
const receiver = engine.bindUdpSocket(client, { localPort: 9 })!;
|
||
// The server's UDP thread waits for ARP on .99 from 0 to 5500 ms.
|
||
engine.sendUdp(engine.bindUdpSocket(server, { remoteIPAddress: '10.30.0.99', remotePort: 9 })!, new TextEncoder().encode('nobody'));
|
||
// A query from .98, which no card has, reaches the server at 330 ms; its answer waits its turn.
|
||
const query = encodeDnsMessage({
|
||
id: 1,
|
||
queryResponse: 0,
|
||
responseCode: 0,
|
||
questions: [{ name: 'web.filius.de.', type: 'A', questionClass: 'IN' }],
|
||
answers: [],
|
||
authority: [],
|
||
additional: [],
|
||
});
|
||
engine.sendIPv4Packet(client, {
|
||
identity: engine.allocatePacketIdentity(),
|
||
senderIPAddress: '10.30.0.98',
|
||
receiverIPAddress: '10.30.0.2',
|
||
timeToLive: 64,
|
||
protocol: 'udp',
|
||
payload: { kind: 'udp', datagram: { sourcePort: 5000, destinationPort: 53, payload: new TextEncoder().encode(query) } },
|
||
});
|
||
engine.handle({ type: 'advance', toMilliseconds: 400 });
|
||
|
||
// As with a fair lock (Java's is not): the send returns when the datagram
|
||
// in hand is out; the answer to .98 goes before it and waits for ARP in turn.
|
||
engine.sendUdp(engine.bindUdpSocket(server, { remoteIPAddress: '10.30.0.1', remotePort: 9 })!, new TextEncoder().encode('third'));
|
||
expect(engine.state.currentTimeMilliseconds).toBe(2 * 2_750);
|
||
expect(engine.receiveUdp(receiver, 10_000)?.receivedAtMilliseconds).toBe(4 * 2_750 + 110);
|
||
});
|
||
|
||
it('gives up after five round trips when no server answers, and closes its socket', () => {
|
||
const { engine, client } = lan({ '10.30.0.2': [] });
|
||
const called = engine.state.currentTimeMilliseconds;
|
||
// 10.30.0.3 is there, but no DNS server runs on it.
|
||
expect(engine.resolveDns(client, 'web.filius.de', 'A', '10.30.0.3')).toEqual({ type: 'timedOut' });
|
||
// Java's five round trips, from handing the query down: the send returns at once.
|
||
expect(engine.state.currentTimeMilliseconds - called).toBe(5 * 2_750);
|
||
expect([...engine.state.socketsById.values()].filter((socket) => socket.nodeId === client)).toEqual([]);
|
||
});
|
||
|
||
it('answers every question of a query, one after the other', () => {
|
||
const { engine, client } = lan({ '10.30.0.2': ['web.filius.de A 10.30.0.9', 'mail.filius.de A 10.30.0.10'] });
|
||
const socket = engine.bindUdpSocket(client, { remoteIPAddress: '10.30.0.2', remotePort: 53 })!;
|
||
const query = encodeDnsMessage({
|
||
id: 9,
|
||
queryResponse: 0,
|
||
responseCode: 0,
|
||
questions: [
|
||
{ name: 'web.filius.de.', type: 'A', questionClass: 'IN' },
|
||
{ name: 'mail.filius.de.', type: 'A', questionClass: 'IN' },
|
||
],
|
||
answers: [],
|
||
authority: [],
|
||
additional: [],
|
||
});
|
||
engine.sendUdp(socket, new TextEncoder().encode(query));
|
||
const response = decodeDnsMessage(new TextDecoder().decode(engine.receiveUdp(socket, 10_000)!.datagram.payload));
|
||
|
||
expect(response.answers.map((record) => record.data)).toEqual(['10.30.0.9', '10.30.0.10']);
|
||
});
|
||
|
||
it('forgets the DNS queries that wait on stop', () => {
|
||
const records = { '10.30.0.2': ['schule.de NS ns.schule.de.', 'ns.schule.de A 10.30.0.3'] };
|
||
const { engine, client } = lan(records, true);
|
||
// The client gives up; the server still waits for .3, which runs no DNS server, when the simulation stops.
|
||
expect(engine.resolveDnsAddress(client, 'www.schule.de')).toEqual({ type: 'timedOut' });
|
||
const snapshot = engine.state.topologySnapshot;
|
||
engine.handle({ type: 'stop' });
|
||
engine.handle({ type: 'start', snapshot, seed: 14, initialTimeMilliseconds: 0 });
|
||
// The sockets are numbered anew: the second takes the number of the server's query socket before.
|
||
engine.bindUdpSocket(client, { localPort: 7000 });
|
||
const receiver = engine.bindUdpSocket(client, { localPort: 7001 })!;
|
||
const sender = engine.bindUdpSocket(uuid(0x1906), { remoteIPAddress: '10.30.0.1', remotePort: 7001 })!;
|
||
engine.sendUdp(sender, new TextEncoder().encode('still mine'));
|
||
|
||
expect(new TextDecoder().decode(engine.receiveUdp(receiver, 10_000)?.datagram.payload)).toBe('still mine');
|
||
});
|
||
|
||
it('asks on for a name it does not know, and answers with what it learns', () => {
|
||
const { engine, client } = lan(
|
||
{ '10.30.0.2': ['schule.de NS ns.schule.de.', 'ns.schule.de A 10.30.0.3'], '10.30.0.3': ['www.schule.de A 10.30.0.40'] },
|
||
true,
|
||
);
|
||
const called = engine.state.currentTimeMilliseconds;
|
||
expect(engine.resolveDnsAddress(client, 'www.schule.de')).toEqual({ type: 'resolved', address: '10.30.0.40' });
|
||
// ARP, query and answer between client and server, ARP, query and answer between server and .3,
|
||
// and the answer to the client: eight crossings of the switch, 110 ms each.
|
||
expect(queries(engine)).toEqual(['DNS query www.schule.de. A', 'DNS query www.schule.de. A']);
|
||
expect(engine.state.currentTimeMilliseconds - called).toBe(8 * 110);
|
||
});
|
||
|
||
it('asks neither itself as the name server of its own zone nor as its own DNS server: it refers (Java asks itself without end)', () => {
|
||
const { engine, client } = lan({ '10.30.0.2': ['filius.de NS ns.filius.de.', 'ns.filius.de A 10.30.0.2'] }, true);
|
||
const called = engine.state.currentTimeMilliseconds;
|
||
expect(engine.resolveDnsAddress(client, 'nix.filius.de')).toEqual({ type: 'resolved', address: undefined });
|
||
// The client follows the referral to the server once more: two exchanges, the first with ARP.
|
||
expect(queries(engine)).toEqual(['DNS query nix.filius.de. A', 'DNS query nix.filius.de. A']);
|
||
expect(engine.state.currentTimeMilliseconds - called).toBe(4 * 110 + 2 * 110);
|
||
});
|
||
|
||
it('gives up on a name server that does not answer after five round trips, when the client has given up too', () => {
|
||
const { engine, client } = lan({ '10.30.0.2': ['schule.de NS ns.schule.de.', 'ns.schule.de A 10.30.0.3'] }, true);
|
||
const called = engine.state.currentTimeMilliseconds;
|
||
// .3 runs no DNS server.
|
||
expect(engine.resolveDnsAddress(client, 'www.schule.de')).toEqual({ type: 'timedOut' });
|
||
expect(engine.state.currentTimeMilliseconds - called).toBe(5 * 2_750);
|
||
// The server's own wait ends later, then it refers the client, which no longer listens.
|
||
engine.handle({ type: 'advance', toMilliseconds: engine.state.currentTimeMilliseconds + 5 * 2_750 });
|
||
expect(engine.state.packetTraces.filter((trace) => trace.detail === 'DNS response')).toHaveLength(1);
|
||
expect([...engine.state.socketsById.values()].filter((socket) => socket.nodeId === client)).toEqual([]);
|
||
});
|
||
});
|
||
|
||
describe('IP forwarding on a host', () => {
|
||
// A on the switch with H, whose IP forwarding may be on; H's gateway is the router to 20.0.0.0/24.
|
||
function hostRouting(forwards: boolean | undefined) {
|
||
const [hostA, hostH, routerId, hostB] = [0x3001, 0x3002, 0x3003, 0x3004].map(uuid) as [string, string, string, string];
|
||
const [portA, portH, routerLan, routerWan, portB] = [0x3011, 0x3012, 0x3013, 0x3014, 0x3015].map(uuid) as [string, string, string, string, string];
|
||
const switchId = uuid(0x3005);
|
||
const switchPorts = [1, 2, 3].map((index) => portSnapshot(uuid(0x3020 + index), `sw${index}`));
|
||
const engine = startedEngine({
|
||
nodes: [
|
||
pc(hostA, portA),
|
||
pc(hostH, portH),
|
||
{ id: routerId, kind: 'router', ports: [portSnapshot(routerLan, 'rt1'), portSnapshot(routerWan, 'rt2')] },
|
||
pc(hostB, portB),
|
||
{ id: switchId, kind: 'networkSwitch', ports: switchPorts },
|
||
],
|
||
links: [
|
||
link(hostA, portA, switchId, switchPorts[0]!.id),
|
||
link(hostH, portH, switchId, switchPorts[1]!.id),
|
||
link(routerId, routerLan, switchId, switchPorts[2]!.id),
|
||
link(routerId, routerWan, hostB, portB),
|
||
],
|
||
deviceConfigurations: new Map([
|
||
[hostA, device('10.0.0.5', { gateway: '10.0.0.1' })],
|
||
[hostH, device('10.0.0.1', { gateway: '10.0.0.254' })],
|
||
[routerId, device('10.0.0.254')],
|
||
[hostB, device('20.0.0.5', { gateway: '20.0.0.254' })],
|
||
]),
|
||
interfaceConfigurations: new Map([
|
||
[interfaceKey(routerId, routerLan), { ipAddress: '10.0.0.254', subnetMask: '255.255.255.0' }],
|
||
[interfaceKey(routerId, routerWan), { ipAddress: '20.0.0.254', subnetMask: '255.255.255.0' }],
|
||
]),
|
||
...(forwards === undefined ? {} : { ipForwardingByNodeId: new Map([[hostH, forwards]]) }),
|
||
});
|
||
return { engine, hostA };
|
||
}
|
||
|
||
it('forwards for others only when its IP forwarding is on (Java ipForwardingEnabled), as a router', () => {
|
||
for (const [forwards, delivered] of [
|
||
[undefined, false],
|
||
[false, false],
|
||
[true, true],
|
||
] as const) {
|
||
const { engine, hostA } = hostRouting(forwards);
|
||
engine.sendIcmpEcho(hostA, '20.0.0.5');
|
||
const answered = (engine.state.icmpObservationsByNodeId.get(hostA) ?? []).some((each) => each.message.kind === 'echoReply');
|
||
expect(answered, String(forwards)).toBe(delivered);
|
||
}
|
||
});
|
||
});
|
||
|
||
describe('ARP', () => {
|
||
const requestsFor = (engine: NetworkRuntimeEngine, ipAddress: string) =>
|
||
engine.state.packetTraces.filter((trace) => trace.detail === `ARP request for ${ipAddress}`).map((trace) => trace.timeMilliseconds);
|
||
|
||
it('asks twice, a round trip apart, then gives up', () => {
|
||
const { engine, sourceNodeId } = routedTopology();
|
||
const called = engine.state.currentTimeMilliseconds;
|
||
|
||
expect(engine.resolveMacAddress(sourceNodeId, '10.0.0.9')).toBeUndefined();
|
||
expect(requestsFor(engine, '10.0.0.9')).toEqual([called, called + 2_750]);
|
||
expect(engine.state.currentTimeMilliseconds).toBe(called + 2 * 2_750);
|
||
});
|
||
|
||
it('lets a router answer host unreachable for an echo request, not for an echo reply', () => {
|
||
const { engine, sourceNodeId } = routedTopology();
|
||
const toNowhere = (kind: 'echoRequest' | 'echoReply', identity: number): IPv4Packet => ({
|
||
identity,
|
||
senderIPAddress: '10.0.0.2',
|
||
receiverIPAddress: '20.0.0.9',
|
||
timeToLive: 64,
|
||
protocol: 'icmp',
|
||
payload: { kind: 'icmp', message: icmpMessage(kind, { identifier: identity, sequenceNumber: 1, data: new Uint8Array() }) },
|
||
});
|
||
const observed = () => (engine.state.icmpObservationsByNodeId.get(sourceNodeId) ?? []).map((each) => each.message.kind);
|
||
|
||
engine.sendIPv4Packet(sourceNodeId, toNowhere('echoReply', 901));
|
||
engine.handle({ type: 'advance', toMilliseconds: 10_000 });
|
||
expect(observed()).toEqual([]);
|
||
|
||
engine.sendIPv4Packet(sourceNodeId, toNowhere('echoRequest', 902));
|
||
engine.handle({ type: 'advance', toMilliseconds: 20_000 });
|
||
expect(observed()).toEqual(['destinationHostUnreachable']);
|
||
});
|
||
|
||
it("learns only from a request for its own address and from a reply, and drops one from the network's address (Java ARPThread)", () => {
|
||
const [hostA, hostB, hostC] = [0x2f01, 0x2f02, 0x2f03].map(uuid) as [string, string, string];
|
||
const [portA, portB, portC] = [0x2f11, 0x2f12, 0x2f13].map(uuid) as [string, string, string];
|
||
const switchId = uuid(0x2f04);
|
||
const switchPorts = [1, 2, 3].map((index) => portSnapshot(uuid(0x2f20 + index), `sw${index}`));
|
||
const engine = startedEngine({
|
||
nodes: [pc(hostA, portA), pc(hostB, portB), pc(hostC, portC), { id: switchId, kind: 'networkSwitch', ports: switchPorts }],
|
||
links: [link(hostA, portA, switchId, switchPorts[0]!.id), link(hostB, portB, switchId, switchPorts[1]!.id), link(hostC, portC, switchId, switchPorts[2]!.id)],
|
||
deviceConfigurations: new Map([
|
||
[hostA, device('10.0.0.1')],
|
||
[hostB, device('10.0.0.2')],
|
||
[hostC, device('10.0.0.3')],
|
||
]),
|
||
});
|
||
const knows = (nodeId: string, ipAddress: string) => engine.state.arpCachesByNodeId.get(nodeId)?.has(ipAddress) === true;
|
||
|
||
expect(engine.resolveMacAddress(hostA, '10.0.0.2')).toBe(stableMacAddress(portB));
|
||
expect([knows(hostB, '10.0.0.1'), knows(hostA, '10.0.0.2'), knows(hostC, '10.0.0.1')]).toEqual([true, true, false]);
|
||
|
||
// A request from the network's own address: neither learned nor answered.
|
||
engine.sendEthernetFrame(hostA, portA, {
|
||
identity: engine.allocateFrameIdentity(),
|
||
sourceMacAddress: stableMacAddress(portA),
|
||
destinationMacAddress: ETHERNET_BROADCAST_MAC_ADDRESS,
|
||
payload: {
|
||
kind: 'arp',
|
||
packet: { operation: 'request', senderMacAddress: stableMacAddress(portA), senderIPAddress: '10.0.0.0', targetMacAddress: ETHERNET_BROADCAST_MAC_ADDRESS, targetIPAddress: '10.0.0.3' },
|
||
},
|
||
});
|
||
letFramesArrive(engine);
|
||
expect(knows(hostC, '10.0.0.0')).toBe(false);
|
||
expect(engine.capture.frames(portC).filter((each) => each.frame.sourceMacAddress === stableMacAddress(portC))).toEqual([]);
|
||
});
|
||
|
||
it('forgets the lookups that wait on stop', () => {
|
||
const [firstId, firstPortId, secondId, secondPortId] = [0x1a01, 0x1a02, 0x1a03, 0x1a04].map(uuid) as [string, string, string, string];
|
||
const input = (secondIp: string) => ({
|
||
nodes: [pc(firstId, firstPortId), pc(secondId, secondPortId)],
|
||
links: [link(firstId, firstPortId, secondId, secondPortId)],
|
||
deviceConfigurations: new Map([
|
||
[firstId, device('10.0.0.2')],
|
||
[secondId, device(secondIp)],
|
||
]),
|
||
});
|
||
const engine = startedEngine(input('10.0.0.8'));
|
||
const socket = engine.bindUdpSocket(firstId, { remoteIPAddress: '10.0.0.9', remotePort: 9 })!;
|
||
engine.sendUdp(socket, new TextEncoder().encode('lost'));
|
||
|
||
engine.handle({ type: 'stop' });
|
||
engine.handle({ type: 'start', snapshot: topologySnapshot(input('10.0.0.9')), seed: 14, initialTimeMilliseconds: 0 });
|
||
expect(engine.resolveMacAddress(firstId, '10.0.0.9')).toBeDefined();
|
||
engine.handle({ type: 'advance', toMilliseconds: 1_000 });
|
||
|
||
expect(engine.state.packetTraces.filter((trace) => trace.layer === 'network' && trace.operation === 'sent')).toEqual([]);
|
||
});
|
||
});
|
||
|
||
describe("Java's IP and ICMP threads", () => {
|
||
const twoPcs = (prefix: string, base: number, options: { secondIsGateway?: boolean; firewall?: FirewallConfiguration } = {}) => {
|
||
const [firstId, firstPortId, secondId, secondPortId] = [base, base + 1, base + 2, base + 3].map(uuid) as [string, string, string, string];
|
||
const engine = startedEngine({
|
||
nodes: [pc(firstId, firstPortId), pc(secondId, secondPortId)],
|
||
links: [link(firstId, firstPortId, secondId, secondPortId)],
|
||
deviceConfigurations: new Map([
|
||
[firstId, device(`${prefix}.1`, options.secondIsGateway === true ? { gateway: `${prefix}.2` } : {})],
|
||
[secondId, device(`${prefix}.2`)],
|
||
]),
|
||
...(options.firewall === undefined ? {} : { firewallConfigurationsByNodeId: new Map([[secondId, options.firewall]]) }),
|
||
});
|
||
return { engine, firstId, secondId };
|
||
};
|
||
const text = (payload: Uint8Array | undefined) => new TextDecoder().decode(payload ?? new Uint8Array());
|
||
|
||
it('holds the ICMP thread while an echo reply waits for ARP: the next echo request waits behind it', () => {
|
||
const { engine, firstId } = twoPcs('10.29.0', 0x1b01);
|
||
// An echo request from .99, which no card has: the answer waits for ARP.
|
||
engine.sendIPv4Packet(firstId, {
|
||
identity: engine.allocatePacketIdentity(),
|
||
senderIPAddress: '10.29.0.99',
|
||
receiverIPAddress: '10.29.0.2',
|
||
timeToLive: 64,
|
||
protocol: 'icmp',
|
||
payload: { kind: 'icmp', message: icmpMessage('echoRequest', { identifier: 7, sequenceNumber: 7, data: new Uint8Array() }) },
|
||
});
|
||
engine.handle({ type: 'advance', toMilliseconds: 200 });
|
||
const replies = () => (engine.state.icmpObservationsByNodeId.get(firstId) ?? []).filter((observation) => observation.message.kind === 'echoReply');
|
||
|
||
engine.sendIcmpEcho(firstId, '10.29.0.2');
|
||
expect(replies()).toEqual([]);
|
||
// The request came at 165 ms after ARP there and back; ARP for .99 gives up two round trips later, then the reply takes a cable.
|
||
engine.handle({ type: 'advance', toMilliseconds: 165 + 2 * 2_750 + 55 - 1 });
|
||
expect(replies()).toEqual([]);
|
||
engine.handle({ type: 'advance', toMilliseconds: 165 + 2 * 2_750 + 55 });
|
||
expect(replies().map((observation) => observation.nodeId)).toEqual([firstId]);
|
||
});
|
||
|
||
it('frees the IP thread of a PC that drops a packet it would have to forward', () => {
|
||
const { engine, firstId, secondId } = twoPcs('10.30.1', 0x1b11, { secondIsGateway: true });
|
||
const receiver = engine.bindUdpSocket(secondId, { localPort: 9 })!;
|
||
engine.sendUdp(engine.bindUdpSocket(firstId, { remoteIPAddress: '20.0.0.5', remotePort: 9 })!, new TextEncoder().encode('elsewhere'));
|
||
engine.sendUdp(engine.bindUdpSocket(firstId, { remoteIPAddress: '10.30.1.2', remotePort: 9 })!, new TextEncoder().encode('here'));
|
||
|
||
expect(text(engine.receiveUdp(receiver, 1_000)?.datagram.payload)).toBe('here');
|
||
expect(engine.state.packetTraces.some((trace) => trace.nodeId === secondId && trace.detail === 'IPv4 forwarding disabled')).toBe(true);
|
||
});
|
||
|
||
it('frees the IP thread of a PC whose firewall drops a packet', () => {
|
||
const firewall = firewallConfiguration({ isActive: true, defaultPolicy: 'drop', rules: [firewallRule({ port: 9, protocol: 'udp', action: 'accept' })] });
|
||
const { engine, firstId, secondId } = twoPcs('10.30.2', 0x1b21, { firewall });
|
||
const receiver = engine.bindUdpSocket(secondId, { localPort: 9 })!;
|
||
engine.bindUdpSocket(secondId, { localPort: 8 });
|
||
engine.sendUdp(engine.bindUdpSocket(firstId, { remoteIPAddress: '10.30.2.2', remotePort: 8 })!, new TextEncoder().encode('blocked'));
|
||
engine.sendUdp(engine.bindUdpSocket(firstId, { remoteIPAddress: '10.30.2.2', remotePort: 9 })!, new TextEncoder().encode('let in'));
|
||
|
||
expect(text(engine.receiveUdp(receiver, 1_000)?.datagram.payload)).toBe('let in');
|
||
expect(engine.state.firewallDecisions.some((decision) => !decision.accepted)).toBe(true);
|
||
});
|
||
|
||
it('delivers what a router sends to itself at once, while its IP thread waits for ARP', () => {
|
||
const { engine, sourceNodeId, routerNodeId } = routedTopology();
|
||
// The router forwards to 20.0.0.9, which no card has: its IP thread waits two round trips.
|
||
engine.sendUdp(engine.bindUdpSocket(sourceNodeId, { remoteIPAddress: '20.0.0.9', remotePort: 9 })!, new TextEncoder().encode('nobody'));
|
||
engine.handle({ type: 'advance', toMilliseconds: 500 });
|
||
const receiver = engine.bindUdpSocket(routerNodeId, { localPort: 9 })!;
|
||
engine.sendUdp(engine.bindUdpSocket(routerNodeId, { remoteIPAddress: '10.0.0.1', remotePort: 9 })!, new TextEncoder().encode('self'));
|
||
|
||
expect(text(engine.receiveUdp(receiver)?.datagram.payload)).toBe('self');
|
||
expect(engine.state.currentTimeMilliseconds).toBe(500);
|
||
});
|
||
});
|
||
|
||
describe('RIP', () => {
|
||
const [routerAId, routerAShared, routerALan, routerBId, routerBShared, routerBLan] = [0x1501, 0x1511, 0x1512, 0x1502, 0x1513, 0x1514].map(uuid) as [
|
||
string,
|
||
string,
|
||
string,
|
||
string,
|
||
string,
|
||
string,
|
||
];
|
||
const [lanPcId, lanPcPort] = [0x1503, 0x1515].map(uuid) as [string, string];
|
||
/**
|
||
* Router A (10.0.0.1, 192.168.0.1/24) and B (10.0.0.2, 172.16.0.1/16) on
|
||
* one cable, both with RIP; a PC on A's LAN if asked for.
|
||
*/
|
||
const ripTopology = (options: { manualRoutes?: boolean; lanPc?: boolean } = {}) =>
|
||
startedEngine(
|
||
{
|
||
nodes: [
|
||
{ id: routerAId, kind: 'router', ports: [portSnapshot(routerAShared, 'rt1'), portSnapshot(routerALan, 'rt2')] },
|
||
{ id: routerBId, kind: 'router', ports: [portSnapshot(routerBShared, 'rt1'), portSnapshot(routerBLan, 'rt2')] },
|
||
...(options.lanPc === true ? [pc(lanPcId, lanPcPort)] : []),
|
||
],
|
||
links: [
|
||
link(routerAId, routerAShared, routerBId, routerBShared),
|
||
...(options.lanPc === true ? [link(routerAId, routerALan, lanPcId, lanPcPort)] : []),
|
||
],
|
||
...(options.lanPc === true ? { deviceConfigurations: new Map([[lanPcId, device('192.168.0.2', { gateway: '192.168.0.1' })]]) } : {}),
|
||
interfaceConfigurations: new Map([
|
||
[interfaceKey(routerAId, routerAShared), { ipAddress: '10.0.0.1', subnetMask: '255.255.255.0' }],
|
||
[interfaceKey(routerAId, routerALan), { ipAddress: '192.168.0.1', subnetMask: '255.255.255.0' }],
|
||
[interfaceKey(routerBId, routerBShared), { ipAddress: '10.0.0.2', subnetMask: '255.255.255.0' }],
|
||
[interfaceKey(routerBId, routerBLan), { ipAddress: '172.16.0.1', subnetMask: '255.255.0.0' }],
|
||
]),
|
||
...(options.manualRoutes === true
|
||
? { manualRoutesByNodeId: new Map([[routerAId, [{ destinationNetwork: '203.0.113.0', subnetMask: '255.255.255.0', gateway: '10.0.0.2', interfaceIPAddress: '10.0.0.1' }]]]) }
|
||
: {}),
|
||
ripEnabledByNodeId: new Map([
|
||
[routerAId, true],
|
||
[routerBId, true],
|
||
]),
|
||
},
|
||
15,
|
||
);
|
||
const route = (engine: NetworkRuntimeEngine, nodeId: string, network: string) =>
|
||
engine.state.ripTablesByNodeId.get(nodeId)?.find((each) => each.destinationNetwork === network);
|
||
const beacons = (engine: NetworkRuntimeEngine, nodeId: string) =>
|
||
engine.state.pendingEvents.filter((event) => event.kind.kind === 'ripBeacon' && event.kind.nodeId === nodeId).map((event) => event.deadlineMilliseconds);
|
||
const advertisements = (engine: NetworkRuntimeEngine, receiverId: string) =>
|
||
engine.state.packetTraces
|
||
.filter((trace) => trace.nodeId === receiverId && trace.detail === 'UDP queued' && trace.beforeHeaders.some((header) => header.name === 'destinationPort' && header.value === '520'))
|
||
.map((trace) => trace.timeMilliseconds);
|
||
/** When the router sent its table (on its first interface). */
|
||
const sentTables = (engine: NetworkRuntimeEngine, nodeId: string) =>
|
||
engine.state.packetTraces
|
||
.filter((trace) => trace.nodeId === nodeId && trace.detail?.startsWith('RIP advertisement') === true)
|
||
.map((trace) => trace.timeMilliseconds)
|
||
.filter((time, index, times) => times.indexOf(time) === index);
|
||
/** A message as a router at 10.0.0.2 sends it, from any port, straight to A: the time it arrives. */
|
||
const fromB = (engine: NetworkRuntimeEngine, text: string, sourcePort = 521): number => {
|
||
const identity = engine.allocatePacketIdentity();
|
||
engine.sendIPv4Packet(routerBId, {
|
||
identity,
|
||
senderIPAddress: '10.0.0.2',
|
||
receiverIPAddress: '10.0.0.1',
|
||
timeToLive: 1,
|
||
protocol: 'udp',
|
||
payload: { kind: 'udp', datagram: { sourcePort, destinationPort: 520, payload: new TextEncoder().encode(text) } },
|
||
});
|
||
while (!(engine.state.deliveredIPv4PacketsByNodeId.get(routerAId) ?? []).some((delivered) => delivered.packet.identity === identity)) {
|
||
engine.handle({ type: 'advance', toMilliseconds: Math.min(...engine.state.pendingEvents.map((event) => event.deadlineMilliseconds)) });
|
||
}
|
||
return engine.state.currentTimeMilliseconds;
|
||
};
|
||
|
||
it('starts with a route of hop count 0 to each own network that never expires, the first beacon after a second', () => {
|
||
const engine = ripTopology();
|
||
expect(engine.state.ripTablesByNodeId.get(routerAId)).toEqual([
|
||
{ destinationNetwork: '10.0.0.0', subnetMask: '255.255.255.0', nextHop: '10.0.0.1', interfaceIPAddress: '10.0.0.1', origin: 'connected', isEditable: false, metric: 0, hopPublicIPAddress: '10.0.0.1' },
|
||
{ 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, hopPublicIPAddress: '10.0.0.1' },
|
||
]);
|
||
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, hopPublicIPAddress: '10.0.0.2' });
|
||
expect(route(engine, routerBId, '192.168.0.0')).toMatchObject({ nextHop: '10.0.0.1', metric: 1 });
|
||
const payloads = (engine.state.deliveredIPv4PacketsByNodeId.get(routerBId) ?? [])
|
||
.filter((delivered) => delivered.packet.senderIPAddress === '10.0.0.1')
|
||
.map((delivered) => (delivered.packet.payload.kind === 'udp' ? new TextDecoder().decode(delivered.packet.payload.datagram.payload) : ''));
|
||
// A's first message, on the cable to B: its LAN, not the shared network.
|
||
expect(payloads[0]).toBe('10.0.0.1\n10.0.0.1\n16\n75000\n192.168.0.0 255.255.255.0 0');
|
||
// Both beacons at 1 s; each learns a route 55 ms later and sends at once, then pauses.
|
||
expect(advertisements(engine, routerBId)).toEqual([1_055, 1_110]);
|
||
const random = new JavaCompatibleRandom(ripSeed(15, routerAId));
|
||
random.nextRipJitterMilliseconds();
|
||
expect(beacons(engine, routerAId)).toEqual([1_055 + random.nextRipJitterMilliseconds()]);
|
||
});
|
||
|
||
it('names its first address as the public one in every message', () => {
|
||
const engine = ripTopology({ lanPc: true });
|
||
engine.handle({ type: 'advance', toMilliseconds: 2_000 });
|
||
const payloads = (engine.state.deliveredIPv4PacketsByNodeId.get(lanPcId) ?? []).map((delivered) =>
|
||
delivered.packet.payload.kind === 'udp' ? new TextDecoder().decode(delivered.packet.payload.datagram.payload) : '',
|
||
);
|
||
// On the LAN: from 192.168.0.1, public 10.0.0.1; after it learned B's network.
|
||
expect(payloads.at(-1)).toBe('192.168.0.1\n10.0.0.1\n16\n75000\n10.0.0.0 255.255.255.0 0\n172.16.0.0 255.255.0.0 1');
|
||
});
|
||
|
||
it('draws the pauses unmoved by the ports sockets draw', () => {
|
||
const plain = ripTopology();
|
||
const withSocket = ripTopology();
|
||
withSocket.bindUdpSocket(routerAId);
|
||
plain.handle({ type: 'advance', toMilliseconds: 2_000 });
|
||
withSocket.handle({ type: 'advance', toMilliseconds: 2_000 });
|
||
expect(beacons(withSocket, routerAId)).toEqual(beacons(plain, routerAId));
|
||
});
|
||
|
||
it('marks a learned route unreachable when it expires, keeps it, and tells its neighbours at once (Java: at its next beacon)', () => {
|
||
const engine = ripTopology();
|
||
engine.handle({ type: 'advance', toMilliseconds: 2_000 });
|
||
engine.setGlobalPacketLossEnabled(true);
|
||
const expiry = route(engine, routerAId, '172.16.0.0')!.expiresAtMilliseconds!;
|
||
engine.handle({ type: 'advance', toMilliseconds: expiry - 1 });
|
||
expect(route(engine, routerAId, '172.16.0.0')?.metric).toBe(1);
|
||
const [beacon] = beacons(engine, routerAId) as [number];
|
||
expect(beacon).toBeGreaterThan(expiry);
|
||
engine.handle({ type: 'advance', toMilliseconds: expiry });
|
||
expect(route(engine, routerAId, '172.16.0.0')).toMatchObject({ metric: 16, expiresAtMilliseconds: expiry });
|
||
expect(sentTables(engine, routerAId).at(-1)).toBe(expiry);
|
||
// The next beacon comes a pause after the message, not at the old time.
|
||
expect(beacons(engine, routerAId)[0]).toBeGreaterThan(beacon);
|
||
});
|
||
|
||
it('expires each learned route at its own time, also after another expired first', () => {
|
||
const engine = ripTopology();
|
||
engine.handle({ type: 'advance', toMilliseconds: 2_000 });
|
||
const first = fromB(engine, '10.0.0.2\n10.0.0.2\n16\n75000\n198.51.100.0 255.255.255.0 1');
|
||
engine.handle({ type: 'advance', toMilliseconds: first + 10_000 });
|
||
const second = fromB(engine, '10.0.0.2\n10.0.0.2\n16\n75000\n203.0.113.0 255.255.255.0 1');
|
||
engine.setGlobalPacketLossEnabled(true);
|
||
engine.handle({ type: 'advance', toMilliseconds: second + 75_000 - 1 });
|
||
expect(route(engine, routerAId, '198.51.100.0')?.metric).toBe(16);
|
||
expect(route(engine, routerAId, '203.0.113.0')?.metric).toBe(2);
|
||
engine.handle({ type: 'advance', toMilliseconds: second + 75_000 });
|
||
expect(route(engine, routerAId, '203.0.113.0')?.metric).toBe(16);
|
||
});
|
||
|
||
it('routes with its table alone: a manual route does not count', () => {
|
||
const engine = ripTopology({ manualRoutes: true });
|
||
const packet: IPv4Packet = {
|
||
identity: engine.allocatePacketIdentity(),
|
||
senderIPAddress: '10.0.0.1',
|
||
receiverIPAddress: '203.0.113.20',
|
||
timeToLive: 64,
|
||
protocol: 'udp',
|
||
payload: { kind: 'udp', datagram: { sourcePort: 6000, destinationPort: 7000, payload: new Uint8Array() } },
|
||
};
|
||
expect(engine.sendIPv4Packet(routerAId, packet)).toEqual({ type: 'icmpError', packetIdentity: packet.identity, nodeId: routerAId, kind: 'destinationNetworkUnreachable' });
|
||
});
|
||
|
||
it('keeps the route it learned first when another router tells one as long', () => {
|
||
// Java keeps whichever its threads handle first (classicsJava.test.ts, equalRipTies); the engine the first it hears.
|
||
const engine = ripTopology();
|
||
fromB(engine, '10.0.0.3\n10.0.0.3\n16\n75000\n203.0.113.0 255.255.255.0 1');
|
||
const expires = route(engine, routerAId, '203.0.113.0')!.expiresAtMilliseconds;
|
||
engine.handle({ type: 'advance', toMilliseconds: engine.state.currentTimeMilliseconds + 5_000 });
|
||
fromB(engine, '10.0.0.2\n10.0.0.2\n16\n75000\n203.0.113.0 255.255.255.0 1');
|
||
// Nor does that one keep it alive.
|
||
expect(route(engine, routerAId, '203.0.113.0')).toMatchObject({ metric: 2, nextHop: '10.0.0.3', expiresAtMilliseconds: expires });
|
||
// A shorter one replaces it.
|
||
fromB(engine, '10.0.0.2\n10.0.0.2\n16\n75000\n203.0.113.0 255.255.255.0 0');
|
||
expect(route(engine, routerAId, '203.0.113.0')).toMatchObject({ metric: 1, nextHop: '10.0.0.2' });
|
||
});
|
||
|
||
it('forwards along the fewest hops, then the longest mask, and not along 16 hops', () => {
|
||
const engine = ripTopology();
|
||
// The /24 through a router at 10.0.0.3, which is not there.
|
||
fromB(engine, '10.0.0.3\n10.0.0.3\n16\n75000\n203.0.113.0 255.255.255.0 4');
|
||
fromB(engine, '10.0.0.2\n10.0.0.2\n16\n75000\n203.0.0.0 255.255.0.0 2\n40.0.0.0 255.0.0.0 3');
|
||
fromB(engine, '10.0.0.2\n10.0.0.2\n16\n75000\n40.0.0.0 255.0.0.0 15');
|
||
const packet: IPv4Packet = {
|
||
identity: engine.allocatePacketIdentity(),
|
||
senderIPAddress: '192.168.0.1',
|
||
receiverIPAddress: '203.0.113.20',
|
||
timeToLive: 64,
|
||
protocol: 'udp',
|
||
payload: { kind: 'udp', datagram: { sourcePort: 6000, destinationPort: 7000, payload: new Uint8Array() } },
|
||
};
|
||
engine.sendIPv4Packet(routerAId, packet);
|
||
letFramesArrive(engine);
|
||
// Through the /16 with 3 hops, not the /24 with 5.
|
||
expect(engine.state.packetTraces.some((trace) => trace.nodeId === routerBId && trace.packetIdentity === packet.identity)).toBe(true);
|
||
expect(route(engine, routerAId, '203.0.113.0')).toMatchObject({ metric: 5, nextHop: '10.0.0.3' });
|
||
// 40.0.0.0 went to 16 hops from the router it went through: no route.
|
||
expect(route(engine, routerAId, '40.0.0.0')?.metric).toBe(16);
|
||
const unreachable: IPv4Packet = { ...packet, identity: engine.allocatePacketIdentity(), receiverIPAddress: '40.0.0.1' };
|
||
expect(engine.sendIPv4Packet(routerAId, unreachable)).toEqual({
|
||
type: 'icmpError',
|
||
packetIdentity: unreachable.identity,
|
||
nodeId: routerAId,
|
||
kind: 'destinationNetworkUnreachable',
|
||
});
|
||
});
|
||
|
||
it("learns after Java's rules, from a message of any port, and ignores what is not for it", () => {
|
||
const engine = ripTopology();
|
||
let arrived = fromB(engine, '10.0.0.2\n10.0.0.2\n16\n75000\n30.0.0.0 255.0.0.0 3\n31.0.0.0 255.0.0.0 15\n32.0.0.0 255.0.0.0 16', 4000);
|
||
expect(route(engine, routerAId, '30.0.0.0')).toMatchObject({ metric: 4, nextHop: '10.0.0.2', expiresAtMilliseconds: arrived + 75_000 });
|
||
// 15 + 1 is unreachable: not taken.
|
||
expect(route(engine, routerAId, '31.0.0.0')).toBeUndefined();
|
||
expect(route(engine, routerAId, '32.0.0.0')).toBeUndefined();
|
||
// A new route sends the table at once.
|
||
expect(sentTables(engine, routerAId)).toContain(arrived);
|
||
// Own networks stay; the message's own infinity counts too.
|
||
arrived = fromB(engine, '10.0.0.2\n10.0.0.2\n4\n75000\n192.168.0.0 255.255.255.0 0\n33.0.0.0 255.0.0.0 4');
|
||
expect(route(engine, routerAId, '192.168.0.0')).toMatchObject({ metric: 0, nextHop: '192.168.0.1' });
|
||
expect(route(engine, routerAId, '33.0.0.0')).toBeUndefined();
|
||
expect(sentTables(engine, routerAId)).not.toContain(arrived);
|
||
// Shorter from the router it goes through: taken, not sent at once.
|
||
arrived = fromB(engine, '10.0.0.2\n10.0.0.2\n16\n75000\n30.0.0.0 255.0.0.0 1');
|
||
expect(route(engine, routerAId, '30.0.0.0')?.metric).toBe(2);
|
||
expect(sentTables(engine, routerAId)).not.toContain(arrived);
|
||
// Longer from the router it goes through: taken, and sent at once.
|
||
arrived = fromB(engine, '10.0.0.2\n10.0.0.2\n16\n75000\n30.0.0.0 255.0.0.0 6');
|
||
expect(route(engine, routerAId, '30.0.0.0')?.metric).toBe(7);
|
||
expect(sentTables(engine, routerAId)).toContain(arrived);
|
||
// Shorter through another router: it goes there now; not shorter: it stays.
|
||
fromB(engine, '10.0.0.3\n10.0.0.3\n16\n75000\n30.0.0.0 255.0.0.0 2');
|
||
expect(route(engine, routerAId, '30.0.0.0')).toMatchObject({ metric: 3, nextHop: '10.0.0.3', interfaceIPAddress: '10.0.0.1' });
|
||
fromB(engine, '10.0.0.2\n10.0.0.2\n16\n75000\n30.0.0.0 255.0.0.0 2');
|
||
expect(route(engine, routerAId, '30.0.0.0')).toMatchObject({ metric: 3, nextHop: '10.0.0.3' });
|
||
// Empty lines at the end do not count (Java's split).
|
||
fromB(engine, '10.0.0.2\n10.0.0.2\n16\n75000\n39.0.0.0 255.0.0.0 1\n\n');
|
||
expect(route(engine, routerAId, '39.0.0.0')?.metric).toBe(2);
|
||
// From elsewhere, or from itself, or not a message: nothing.
|
||
fromB(engine, '10.9.0.2\n10.0.0.2\n16\n75000\n34.0.0.0 255.0.0.0 1');
|
||
fromB(engine, '10.0.0.1\n10.0.0.1\n16\n75000\n35.0.0.0 255.0.0.0 1');
|
||
fromB(engine, '10.0.0.2\n10.0.0.2\n16\n0\n36.0.0.0 255.0.0.0 1');
|
||
fromB(engine, '10.0.0.2\n10.0.0.2\n16\n75000\n37.0.0.0 255.0.0.0 1\n38.0.0.0 255.0.0.0');
|
||
for (const network of ['34.0.0.0', '35.0.0.0', '36.0.0.0', '37.0.0.0']) expect(route(engine, routerAId, network), network).toBeUndefined();
|
||
});
|
||
|
||
it('forgets its tables on stop', () => {
|
||
const engine = ripTopology();
|
||
engine.handle({ type: 'advance', toMilliseconds: 2_000 });
|
||
engine.handle({ type: 'stop' });
|
||
expect(engine.state.ripTablesByNodeId.size).toBe(0);
|
||
});
|
||
});
|
||
|
||
describe('Home router NAT', () => {
|
||
const [lanPcId, lanPcPort, gatewayId, wanPort, lanPort, wanPcId, wanPcPort] = [0x1c01, 0x1c02, 0x1c03, 0x1c04, 0x1c05, 0x1c06, 0x1c07].map(uuid) as [
|
||
string,
|
||
string,
|
||
string,
|
||
string,
|
||
string,
|
||
string,
|
||
string,
|
||
];
|
||
/** PC 192.168.0.10 — home router (WAN 42.0.0.10/8, LAN 192.168.0.1/24) — PC 42.0.0.20, each on its own cable. */
|
||
const home = (options: { forwarding?: [string, string, string, string][]; firewall?: FirewallConfiguration; speed?: number } = {}) => {
|
||
const engine = new NetworkRuntimeEngine(14);
|
||
if (options.speed !== undefined) engine.setSimulationSpeed(options.speed);
|
||
engine.handle({
|
||
type: 'start',
|
||
snapshot: topologySnapshot({
|
||
nodes: [
|
||
pc(lanPcId, lanPcPort),
|
||
{ id: gatewayId, kind: 'gateway', ports: [portSnapshot(wanPort, 'wan0'), portSnapshot(lanPort, 'lan0')] },
|
||
pc(wanPcId, wanPcPort),
|
||
],
|
||
links: [link(lanPcId, lanPcPort, gatewayId, lanPort), link(gatewayId, wanPort, wanPcId, wanPcPort)],
|
||
deviceConfigurations: new Map([
|
||
[lanPcId, device('192.168.0.10', { gateway: '192.168.0.1' })],
|
||
[wanPcId, device('42.0.0.20', { mask: '255.0.0.0', gateway: '42.0.0.10' })],
|
||
]),
|
||
interfaceConfigurations: new Map([
|
||
[interfaceKey(gatewayId, wanPort), { ipAddress: '42.0.0.10', subnetMask: '255.0.0.0' }],
|
||
[interfaceKey(gatewayId, lanPort), { ipAddress: '192.168.0.1', subnetMask: '255.255.255.0' }],
|
||
]),
|
||
portForwardingRowsByNodeId: new Map([
|
||
[gatewayId, (options.forwarding ?? []).map(([protocolValue, publicPortValue, lanIPAddress, lanPortValue]) => ({ protocolValue, publicPortValue, lanIPAddress, lanPortValue }))],
|
||
]),
|
||
...(options.firewall === undefined ? {} : { firewallConfigurationsByNodeId: new Map([[gatewayId, options.firewall]]) }),
|
||
}),
|
||
seed: 14,
|
||
initialTimeMilliseconds: 0,
|
||
});
|
||
return engine;
|
||
};
|
||
const udpOut = (engine: NetworkRuntimeEngine, localPort = 5000) => {
|
||
engine.sendUdp(engine.bindUdpSocket(lanPcId, { localPort, remoteIPAddress: '42.0.0.20', remotePort: 7 })!, new TextEncoder().encode('out'));
|
||
letFramesArrive(engine);
|
||
};
|
||
|
||
it("takes the port forwarding rows Java's input check takes, a later row for the same port replacing an earlier one", () => {
|
||
const engine = home({
|
||
forwarding: [
|
||
['tcp', '8080', '192.168.0.10', '80'],
|
||
['17', '53', '192.168.0.10', '53'],
|
||
['6', '8080', '192.168.0.11', '81'],
|
||
['ICMP', '1', '192.168.0.10', '1'],
|
||
['TCP', '080', '192.168.0.10', '80'],
|
||
['TCP', '65536', '192.168.0.10', '80'],
|
||
['UDP', '9', '192.168.0.300', '9'],
|
||
],
|
||
});
|
||
expect(engine.natEntries(gatewayId).map((entry) => `${entry.protocol} ${entry.port} ${entry.lan.ipAddress}:${entry.lan.port} ${entry.type}`)).toEqual([
|
||
'tcp 8080 192.168.0.11:81 static',
|
||
'udp 53 192.168.0.10:53 static',
|
||
]);
|
||
});
|
||
|
||
it('holds the WAN port of an entry against the router’s own sockets', () => {
|
||
const engine = home();
|
||
udpOut(engine);
|
||
const [entry] = engine.natEntries(gatewayId) as [NatEntry];
|
||
expect(engine.bindUdpSocket(gatewayId, { localPort: entry.port })).toBeUndefined();
|
||
});
|
||
|
||
it('drops an entry when it is 300 s times the delay factor old, not at the next of Java\'s checks each second; answers refresh it', () => {
|
||
const engine = home({ speed: 100 });
|
||
udpOut(engine);
|
||
// At 100 %, the factor is 1: 300 s. The entry came at 15 ms (three cables of 5 ms); it is gone at 300.015 s (Java: at its check at 301 s).
|
||
const [entry] = engine.natEntries(gatewayId) as [NatEntry];
|
||
expect(entry.updatedAtMilliseconds).toBe(15);
|
||
engine.handle({ type: 'advance', toMilliseconds: 300_014 });
|
||
expect(engine.natEntries(gatewayId)).toHaveLength(1);
|
||
engine.handle({ type: 'advance', toMilliseconds: 300_015 });
|
||
expect(engine.natEntries(gatewayId)).toHaveLength(0);
|
||
|
||
// Another, now refreshed by an answer, and not by what goes out.
|
||
udpOut(engine, 5001);
|
||
const [fresh] = engine.natEntries(gatewayId) as [NatEntry];
|
||
engine.handle({ type: 'advance', toMilliseconds: 400_000 });
|
||
udpOut(engine, 5001);
|
||
expect(engine.natEntries(gatewayId)[0]?.updatedAtMilliseconds).toBe(fresh.updatedAtMilliseconds);
|
||
const answer = engine.bindUdpSocket(wanPcId, { localPort: 7, remoteIPAddress: '42.0.0.10', remotePort: fresh.port })!;
|
||
engine.sendUdp(answer, new TextEncoder().encode('back'));
|
||
letFramesArrive(engine);
|
||
expect(engine.natEntries(gatewayId)[0]!.updatedAtMilliseconds).toBeGreaterThan(400_000);
|
||
});
|
||
|
||
it('passes both sides through the router’s firewall', () => {
|
||
const engine = home({ firewall: firewallConfiguration({ isActive: true, defaultPolicy: 'drop' }) });
|
||
udpOut(engine);
|
||
expect(engine.natEntries(gatewayId)).toEqual([]);
|
||
expect(engine.state.firewallDecisions.some((decision) => !decision.accepted && decision.nodeId === gatewayId)).toBe(true);
|
||
});
|
||
|
||
it('answers a packet going out with TTL 1 with time exceeded, and drops what comes from the WAN for the LAN', () => {
|
||
const engine = home();
|
||
engine.sendIPv4Packet(lanPcId, {
|
||
identity: engine.allocatePacketIdentity(),
|
||
senderIPAddress: '192.168.0.10',
|
||
receiverIPAddress: '42.0.0.20',
|
||
timeToLive: 1,
|
||
protocol: 'udp',
|
||
payload: { kind: 'udp', datagram: { sourcePort: 5000, destinationPort: 7, payload: new Uint8Array() } },
|
||
});
|
||
letFramesArrive(engine);
|
||
expect((engine.state.icmpObservationsByNodeId.get(lanPcId) ?? []).map((observation) => observation.message.kind)).toEqual(['timeExceeded']);
|
||
expect(engine.natEntries(gatewayId)).toEqual([]);
|
||
|
||
engine.sendIPv4Packet(wanPcId, {
|
||
identity: engine.allocatePacketIdentity(),
|
||
senderIPAddress: '42.0.0.20',
|
||
receiverIPAddress: '192.168.0.10',
|
||
timeToLive: 64,
|
||
protocol: 'udp',
|
||
payload: { kind: 'udp', datagram: { sourcePort: 7, destinationPort: 5000, payload: new Uint8Array() } },
|
||
});
|
||
letFramesArrive(engine);
|
||
expect(engine.state.packetTraces.some((trace) => trace.nodeId === gatewayId && trace.detail === 'WAN packet addressed to the LAN')).toBe(true);
|
||
expect((engine.state.deliveredIPv4PacketsByNodeId.get(lanPcId) ?? []).filter((delivered) => delivered.packet.protocol === 'udp')).toEqual([]);
|
||
});
|
||
|
||
const raw = (engine: NetworkRuntimeEngine, from: string, packet: Omit<IPv4Packet, 'identity' | 'timeToLive'>) => {
|
||
const identity = engine.allocatePacketIdentity();
|
||
engine.sendIPv4Packet(from, { identity, timeToLive: 64, ...packet });
|
||
letFramesArrive(engine);
|
||
return identity;
|
||
};
|
||
const echo = (identifier: number) => ({ kind: 'icmp' as const, message: icmpMessage('echoRequest', { identifier, sequenceNumber: 1 }) });
|
||
|
||
it('keeps static entries, and dynamic ones for 300 s times the delay factor (55 minutes at 90 %)', () => {
|
||
const engine = home({ forwarding: [['UDP', '53', '192.168.0.10', '53']] });
|
||
udpOut(engine);
|
||
engine.handle({ type: 'advance', toMilliseconds: 3_300_000 });
|
||
expect(engine.natEntries(gatewayId).map((entry) => entry.type)).toEqual(['static', 'dynamic']);
|
||
engine.handle({ type: 'advance', toMilliseconds: 3_302_000 });
|
||
expect(engine.natEntries(gatewayId).map((entry) => entry.type)).toEqual(['static']);
|
||
});
|
||
|
||
it('opens an entry with a SYN, not with other TCP segments; they leave with port 0', () => {
|
||
const engine = home();
|
||
raw(engine, lanPcId, {
|
||
senderIPAddress: '192.168.0.10',
|
||
receiverIPAddress: '42.0.0.20',
|
||
protocol: 'tcp',
|
||
payload: { kind: 'tcp', segment: { sourcePort: 5000, destinationPort: 80, sequenceNumber: 1, acknowledgementNumber: 1, flags: TCP_FLAG.acknowledgement, payload: new Uint8Array() } },
|
||
});
|
||
expect(engine.natEntries(gatewayId)).toEqual([]);
|
||
const arrived = engine.state.packetTraces.find((trace) => trace.nodeId === wanPcId && trace.detail === 'TCP destination port closed')!;
|
||
expect(arrived.beforeHeaders.find((header) => header.name === 'sourcePort')?.value).toBe('0');
|
||
});
|
||
|
||
it('lets broadcasts from the LAN be', () => {
|
||
const engine = home();
|
||
engine.sendUdp(engine.bindUdpSocket(lanPcId, { localPort: 5000 })!, new TextEncoder().encode('all'), { ipAddress: '255.255.255.255', port: 7 });
|
||
letFramesArrive(engine);
|
||
expect(engine.natEntries(gatewayId)).toEqual([]);
|
||
expect(engine.state.icmpObservationsByNodeId.get(lanPcId) ?? []).toEqual([]);
|
||
});
|
||
|
||
it('replaces an entry for the same remote end, port and protocol (Java: a map), as two hosts pinging with one identifier do', () => {
|
||
const engine = home();
|
||
raw(engine, lanPcId, { senderIPAddress: '192.168.0.10', receiverIPAddress: '42.0.0.20', protocol: 'icmp', payload: echo(1) });
|
||
raw(engine, lanPcId, { senderIPAddress: '192.168.0.11', receiverIPAddress: '42.0.0.20', protocol: 'icmp', payload: echo(1) });
|
||
expect(engine.natEntries(gatewayId).map((entry) => `${entry.remoteIPAddress}:${entry.port} ${entry.lan.ipAddress}:${entry.lan.port}`)).toEqual([
|
||
'42.0.0.20:1 192.168.0.11:1',
|
||
]);
|
||
});
|
||
|
||
it('answers a ping at its WAN address itself, even when an entry would fit', () => {
|
||
const engine = home();
|
||
raw(engine, lanPcId, { senderIPAddress: '192.168.0.10', receiverIPAddress: '42.0.0.20', protocol: 'icmp', payload: echo(1) });
|
||
raw(engine, wanPcId, { senderIPAddress: '42.0.0.20', receiverIPAddress: '42.0.0.10', protocol: 'icmp', payload: echo(1) });
|
||
const replies = (engine.state.icmpObservationsByNodeId.get(wanPcId) ?? []).filter((observation) => observation.message.kind === 'echoReply');
|
||
expect(replies.map((observation) => observation.packet.senderIPAddress)).toContain('42.0.0.10');
|
||
expect(engine.state.packetTraces.filter((trace) => trace.nodeId === lanPcId && trace.detail === 'ICMP echo reply')).toHaveLength(0);
|
||
});
|
||
|
||
it('sends an ICMP error for a packet it translated to the LAN endpoint, found by the packet it carries', () => {
|
||
const engine = home();
|
||
udpOut(engine);
|
||
const [entry] = engine.natEntries(gatewayId) as [NatEntry];
|
||
raw(engine, wanPcId, {
|
||
senderIPAddress: '42.0.0.20',
|
||
receiverIPAddress: '42.0.0.10',
|
||
protocol: 'icmp',
|
||
payload: {
|
||
kind: 'icmp',
|
||
message: icmpMessage('destinationHostUnreachable', {
|
||
embeddedOriginalPacket: {
|
||
identity: 1,
|
||
senderIPAddress: '42.0.0.10',
|
||
receiverIPAddress: '42.0.0.20',
|
||
timeToLive: 63,
|
||
protocol: 'udp',
|
||
payload: { kind: 'udp', datagram: { sourcePort: entry.port, destinationPort: 7, payload: new Uint8Array() } },
|
||
},
|
||
}),
|
||
},
|
||
});
|
||
expect((engine.state.icmpObservationsByNodeId.get(lanPcId) ?? []).map((observation) => observation.message.kind)).toEqual(['destinationHostUnreachable']);
|
||
});
|
||
|
||
it('lets a forwarded endpoint reach a new remote end through its forwarding, whose answer makes the entry', () => {
|
||
const engine = home({ forwarding: [['UDP', '53', '192.168.0.10', '53']] });
|
||
udpOut(engine, 53);
|
||
const answer = engine.bindUdpSocket(wanPcId, { localPort: 7, remoteIPAddress: '42.0.0.10', remotePort: 53 })!;
|
||
engine.sendUdp(answer, new TextEncoder().encode('back'));
|
||
letFramesArrive(engine);
|
||
expect(engine.natEntries(gatewayId).map((entry) => `${entry.type} ${entry.remoteIPAddress}:${entry.port}`)).toEqual(['static :53', 'dynamicFromStatic 42.0.0.20:53']);
|
||
});
|
||
|
||
it('drops at its WAN side what its firewall drops', () => {
|
||
const engine = home({ firewall: firewallConfiguration({ isActive: true, defaultPolicy: 'drop' }) });
|
||
const identity = raw(engine, wanPcId, {
|
||
senderIPAddress: '42.0.0.20',
|
||
receiverIPAddress: '42.0.0.10',
|
||
protocol: 'udp',
|
||
payload: { kind: 'udp', datagram: { sourcePort: 7, destinationPort: 9, payload: new Uint8Array() } },
|
||
});
|
||
expect(engine.state.packetTraces.some((trace) => trace.nodeId === gatewayId && trace.packetIdentity === identity && trace.layer === 'transport')).toBe(false);
|
||
});
|
||
|
||
it('forgets its tables on stop', () => {
|
||
const engine = home();
|
||
udpOut(engine);
|
||
engine.handle({ type: 'stop' });
|
||
expect(engine.state.natTablesByNodeId.size).toBe(0);
|
||
});
|
||
});
|
||
|
||
describe("packet capture (Java's Lauscher)", () => {
|
||
const hostA = uuid(0x2e01);
|
||
const portA = uuid(0x2e11);
|
||
const hostB = uuid(0x2e02);
|
||
const portB = uuid(0x2e12);
|
||
const switchId = uuid(0x2e04);
|
||
const switchPorts = [1, 2].map((index) => portSnapshot(uuid(0x2e20 + index), `sw${index}`));
|
||
const network = {
|
||
nodes: [pc(hostA, portA), pc(hostB, portB), { id: switchId, kind: 'networkSwitch' as const, ports: switchPorts }],
|
||
links: [link(hostA, portA, switchId, switchPorts[0]!.id), link(hostB, portB, switchId, switchPorts[1]!.id)],
|
||
deviceConfigurations: new Map([
|
||
[hostA, device('10.0.0.1')],
|
||
[hostB, device('10.0.0.2')],
|
||
]),
|
||
};
|
||
const kinds = (engine: NetworkRuntimeEngine, portId: string) =>
|
||
engine.capture.frames(portId).map(({ number, frame }) => {
|
||
const what = frame.payload.kind === 'arp' ? `arp ${frame.payload.packet.operation}` : frame.payload.packet.payload.kind;
|
||
return `${number} ${frame.sourceMacAddress === stableMacAddress(portId) ? 'sent' : 'received'} ${what}`;
|
||
});
|
||
const arpRequest = (engine: NetworkRuntimeEngine): EthernetFrame => ({
|
||
identity: engine.allocateFrameIdentity(),
|
||
sourceMacAddress: stableMacAddress(portA),
|
||
destinationMacAddress: ETHERNET_BROADCAST_MAC_ADDRESS,
|
||
payload: {
|
||
kind: 'arp',
|
||
packet: { operation: 'request', senderMacAddress: stableMacAddress(portA), senderIPAddress: '10.0.0.1', targetMacAddress: UNSPECIFIED_MAC_ADDRESS, targetIPAddress: '10.0.0.9' },
|
||
},
|
||
});
|
||
|
||
it('logs every frame a card sends or receives, numbered, and nothing on a switch', () => {
|
||
const engine = startedEngine(network);
|
||
engine.sendIcmpEcho(hostA, '10.0.0.2');
|
||
letFramesArrive(engine);
|
||
|
||
expect(kinds(engine, portA)).toEqual(['1 sent arp request', '2 received arp reply', '3 sent icmp', '4 received icmp']);
|
||
expect(kinds(engine, portB)).toEqual(['1 received arp request', '2 sent arp reply', '3 received icmp', '4 sent icmp']);
|
||
expect(switchPorts.map((port) => engine.capture.frames(port.id))).toEqual([[], []]);
|
||
// When the card sent it, when the other card received it: two cables later.
|
||
const [sent] = engine.capture.frames(portA);
|
||
const [received] = engine.capture.frames(portB);
|
||
expect(received!.timeMilliseconds - sent!.timeMilliseconds).toBe(2 * engine.simulationSpeed.linkTransmissionDelayMilliseconds);
|
||
});
|
||
|
||
it('logs a frame on a card once, also a frame for another card', () => {
|
||
const engine = startedEngine(network);
|
||
const frame = { ...arpRequest(engine), destinationMacAddress: '02:00:00:00:00:FE' };
|
||
engine.sendEthernetFrame(hostA, portA, frame);
|
||
engine.sendEthernetFrame(hostA, portA, frame);
|
||
letFramesArrive(engine);
|
||
|
||
expect(kinds(engine, portA)).toEqual(['1 sent arp request']);
|
||
// The switch floods the unknown destination; B's card logs what is not addressed to it.
|
||
expect(kinds(engine, portB)).toEqual(['1 received arp request']);
|
||
});
|
||
|
||
it('keeps the frames the packet loss dropped apart; the sender logged them', () => {
|
||
const engine = startedEngine(network);
|
||
engine.setGlobalPacketLossEnabled(true);
|
||
engine.sendEthernetFrame(hostA, portA, arpRequest(engine));
|
||
letFramesArrive(engine);
|
||
|
||
expect(kinds(engine, portA)).toEqual(['1 sent arp request']);
|
||
expect(engine.capture.frames(portB)).toEqual([]);
|
||
expect(engine.capture.droppedFrames().map((each) => each.timeMilliseconds)).toEqual([engine.simulationSpeed.linkTransmissionDelayMilliseconds]);
|
||
});
|
||
|
||
it('logs a frame flooded onto two cables once for each cable that drops it', () => {
|
||
const hostC = uuid(0x2e03);
|
||
const portC = uuid(0x2e13);
|
||
const ports = [1, 2, 3].map((index) => portSnapshot(uuid(0x2e30 + index), `sw${index}`));
|
||
const engine = startedEngine({
|
||
nodes: [pc(hostA, portA), pc(hostB, portB), pc(hostC, portC), { id: switchId, kind: 'networkSwitch', ports }],
|
||
links: [link(hostA, portA, switchId, ports[0]!.id), link(hostB, portB, switchId, ports[1]!.id), link(hostC, portC, switchId, ports[2]!.id)],
|
||
});
|
||
engine.sendEthernetFrame(hostA, portA, arpRequest(engine));
|
||
// The switch has it and floods it; then the loss begins.
|
||
engine.handle({ type: 'advance', toMilliseconds: engine.simulationSpeed.linkTransmissionDelayMilliseconds });
|
||
engine.setGlobalPacketLossEnabled(true);
|
||
letFramesArrive(engine);
|
||
|
||
expect(engine.capture.droppedFrames()).toHaveLength(2);
|
||
expect([portB, portC].map((port) => engine.capture.frames(port))).toEqual([[], []]);
|
||
});
|
||
|
||
it('outlives a stop and a new start, as in Java, until it is cleared', () => {
|
||
const engine = startedEngine(network);
|
||
engine.sendEthernetFrame(hostA, portA, arpRequest(engine));
|
||
letFramesArrive(engine);
|
||
engine.handle({ type: 'stop' });
|
||
expect(kinds(engine, portA)).toEqual(['1 sent arp request']);
|
||
|
||
engine.handle({ type: 'start', snapshot: topologySnapshot(network), seed: 14, initialTimeMilliseconds: 1_000 });
|
||
engine.setGlobalPacketLossEnabled(true);
|
||
engine.sendEthernetFrame(hostA, portA, arpRequest(engine));
|
||
letFramesArrive(engine);
|
||
expect(engine.capture.frames(portA).map((each) => [each.number, each.timeMilliseconds])).toEqual([
|
||
[1, 0],
|
||
[2, 1_000],
|
||
]);
|
||
expect(engine.capture.droppedFrames()).toHaveLength(1);
|
||
|
||
engine.clearCapture();
|
||
expect(engine.capture.portIds()).toEqual([]);
|
||
expect(engine.capture.droppedFrames()).toEqual([]);
|
||
});
|
||
|
||
it('keeps at most the retention, dropping the oldest at once; a frame keeps its number', () => {
|
||
const engine = new NetworkRuntimeEngine(14, undefined, { captureRetention: captureRetentionPolicy(3, 2) });
|
||
engine.handle({ type: 'start', snapshot: topologySnapshot(network), seed: 14, initialTimeMilliseconds: 0 });
|
||
for (let count = 0; count < 4; count += 1) engine.sendEthernetFrame(hostA, portA, arpRequest(engine));
|
||
letFramesArrive(engine);
|
||
|
||
expect(engine.capture.frames(portA).map((each) => each.number)).toEqual([3, 4]);
|
||
expect(() => captureRetentionPolicy(2, 3)).toThrow(RangeError);
|
||
});
|
||
});
|