engine.ts ports Swift's "DHCP server and automatic client": an active server listens on port 67 from the start and offers a static assignment before its range, round from the address after the last one offered; offers last 10 s, dynamic leases a day, and a server whose offer another server beat blacklists that address for a while. A client starts as an event: DISCOVER, OFFER, a check by ARP, DECLINE, REQUEST, ACK or NAK, 2.5 s per wait, and after ten failures its old address back. The snapshot carries the DHCP settings of clients and servers. Four scenarios match the Swift engine. One Swift error is named in the oracle test: a client run directly is advanced a second time, to FINISH, by the server's answer within its own DISCOVER, and the outer run then writes back its stale copy of the status (Swift's value types); the address and every trace agree, only the reported status is wrong in Swift. Swift's three DHCP engine tests are ported, with tests for stopping and the message format. 15 mutation probes, 14 caught; the other concerns the home router, which runs only once NAT is ported. Two departures from Java remain, to be fixed next: from the event queue the client's ARP check does not wait, so an address conflict is never found, and each answer that wakes the client makes it send its state's message again (a second DISCOVER and REQUEST, the second answered with NAK). Java waits a round trip and sends once per attempt. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1012 lines
43 KiB
TypeScript
1012 lines
43 KiB
TypeScript
// Ported from filius-ipad ios/FiliusPadTests/TopologyNetworkRuntimeEngineTests.swift:
|
|
// the event queue, the virtual clock, Java-compatible randomness, restart,
|
|
// Ethernet/switch/ARP/IPv4/ICMP, routing errors, the firewall, packet loss
|
|
// and trace retention, UDP and TCP sockets, DHCP. The remaining Swift tests
|
|
// (DNS, RIP, NAT, remote link, packet viewer) follow with their protocol
|
|
// slices.
|
|
|
|
import { describe, expect, it } from 'vitest';
|
|
import {
|
|
decodeDhcpMessage,
|
|
DHCP_DYNAMIC_LEASE_LIFETIME_MILLISECONDS,
|
|
deviceConfiguration,
|
|
dhcpServerConfiguration,
|
|
encodeDhcpMessage,
|
|
EMPTY_TOPOLOGY_SNAPSHOT,
|
|
ETHERNET_BROADCAST_MAC_ADDRESS,
|
|
firewallConfiguration,
|
|
firewallRule,
|
|
DIRECTLY_CONNECTED_SOURCE_MARKER,
|
|
forwardingClone,
|
|
icmpMessage,
|
|
interfaceKey,
|
|
JavaCompatibleRandom,
|
|
NetworkRuntimeEngine,
|
|
portSnapshot,
|
|
stableMacAddress,
|
|
switchConfiguration,
|
|
TCP_FLAG,
|
|
TCP_ROUND_TRIP_TIME_MILLISECONDS,
|
|
tcpFlagDescription,
|
|
topologySnapshot,
|
|
traceRetentionPolicy,
|
|
UNSPECIFIED_MAC_ADDRESS,
|
|
type DeviceConfiguration,
|
|
type DhcpServerConfiguration,
|
|
type EthernetFrame,
|
|
type EthernetPayload,
|
|
type FirewallConfiguration,
|
|
type IPv4Packet,
|
|
type NodeSnapshot,
|
|
type RuntimeOutput,
|
|
type ScheduledEventKind,
|
|
} from '../src/index.js';
|
|
import { device, link, pc, startedEngine, uuid } from './fixtures.js';
|
|
|
|
const marker = (label: string): ScheduledEventKind => ({ kind: 'parityMarker', label });
|
|
|
|
function firedKinds(outputs: RuntimeOutput[]): ScheduledEventKind[] {
|
|
return outputs.flatMap((output) => (output.kind.type === 'fired' ? [output.kind.event.kind] : []));
|
|
}
|
|
|
|
function routedTopology() {
|
|
const sourceNodeId = uuid(0x1451);
|
|
const sourcePortId = uuid(0x1461);
|
|
const routerNodeId = uuid(0x1452);
|
|
const routerPortA = uuid(0x1462);
|
|
const routerPortB = uuid(0x1463);
|
|
const targetNodeId = uuid(0x1453);
|
|
const targetPortId = uuid(0x1464);
|
|
const router: NodeSnapshot = {
|
|
id: routerNodeId,
|
|
kind: 'router',
|
|
ports: [portSnapshot(routerPortA, 'rt1'), portSnapshot(routerPortB, 'rt2')],
|
|
};
|
|
const snapshot = topologySnapshot({
|
|
nodes: [pc(sourceNodeId, sourcePortId), router, pc(targetNodeId, targetPortId)],
|
|
links: [
|
|
link(sourceNodeId, sourcePortId, routerNodeId, routerPortA),
|
|
link(routerNodeId, routerPortB, targetNodeId, targetPortId),
|
|
],
|
|
deviceConfigurations: new Map([
|
|
[sourceNodeId, device('10.0.0.2', { gateway: '10.0.0.1' })],
|
|
[routerNodeId, device('10.0.0.1')],
|
|
[targetNodeId, device('20.0.0.2', { gateway: '20.0.0.1' })],
|
|
]),
|
|
interfaceConfigurations: new Map([
|
|
[interfaceKey(routerNodeId, routerPortA), { ipAddress: '10.0.0.1', subnetMask: '255.255.255.0' }],
|
|
[interfaceKey(routerNodeId, routerPortB), { ipAddress: '20.0.0.1', subnetMask: '255.255.255.0' }],
|
|
]),
|
|
});
|
|
const engine = new NetworkRuntimeEngine(14);
|
|
engine.handle({ type: 'start', snapshot, seed: 14, initialTimeMilliseconds: 0 });
|
|
return { engine, sourceNodeId, routerNodeId };
|
|
}
|
|
|
|
function firewallEngine(configuration: FirewallConfiguration) {
|
|
const routerId = uuid(0x1802);
|
|
const portId = uuid(0x1812);
|
|
const engine = startedEngine(
|
|
{
|
|
nodes: [{ id: routerId, kind: 'router', ports: [portSnapshot(portId, 'rt1')] }],
|
|
links: [],
|
|
interfaceConfigurations: new Map([
|
|
[interfaceKey(routerId, portId), { ipAddress: '10.0.0.1', subnetMask: '255.255.255.0' }],
|
|
]),
|
|
firewallConfigurationsByNodeId: new Map([[routerId, configuration]]),
|
|
},
|
|
18,
|
|
);
|
|
return { engine, routerId };
|
|
}
|
|
|
|
function tcpPacket(identity: number, destinationPort: number, flags: number): IPv4Packet {
|
|
return {
|
|
identity,
|
|
senderIPAddress: '203.0.113.10',
|
|
receiverIPAddress: '10.0.0.1',
|
|
timeToLive: 64,
|
|
protocol: 'tcp',
|
|
payload: {
|
|
kind: 'tcp',
|
|
segment: {
|
|
sourcePort: 50_000,
|
|
destinationPort,
|
|
sequenceNumber: identity,
|
|
acknowledgementNumber: 0,
|
|
flags,
|
|
payload: new Uint8Array(),
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
describe('event queue and virtual clock', () => {
|
|
it('orders events by deadline, then by insertion sequence', () => {
|
|
const engine = new NetworkRuntimeEngine(42);
|
|
engine.handle({ type: 'start', snapshot: EMPTY_TOPOLOGY_SNAPSHOT, seed: 42, initialTimeMilliseconds: 0 });
|
|
engine.handle({ type: 'schedule', deadlineMilliseconds: 100, kind: marker('first-at-100') });
|
|
engine.handle({ type: 'schedule', deadlineMilliseconds: 50, kind: marker('at-50') });
|
|
engine.handle({ type: 'schedule', deadlineMilliseconds: 100, kind: marker('second-at-100') });
|
|
|
|
const outputs = engine.handle({ type: 'advance', toMilliseconds: 100 });
|
|
|
|
expect(firedKinds(outputs)).toEqual([marker('at-50'), marker('first-at-100'), marker('second-at-100')]);
|
|
expect(engine.state.currentTimeMilliseconds).toBe(100);
|
|
expect(engine.state.pendingEvents).toEqual([]);
|
|
});
|
|
|
|
it('rejects a past time without changing the clock', () => {
|
|
const engine = new NetworkRuntimeEngine(7);
|
|
engine.handle({ type: 'start', snapshot: EMPTY_TOPOLOGY_SNAPSHOT, seed: 7, initialTimeMilliseconds: 10 });
|
|
engine.handle({ type: 'advance', toMilliseconds: 25 });
|
|
|
|
const outputs = engine.handle({ type: 'advance', toMilliseconds: 24 });
|
|
|
|
expect(outputs.map((output) => output.kind)).toEqual([{ type: 'advanceRejectedPastTime' }]);
|
|
expect(engine.state.currentTimeMilliseconds).toBe(25);
|
|
});
|
|
|
|
it('rejects a past deadline without using up a sequence number', () => {
|
|
const engine = new NetworkRuntimeEngine(8);
|
|
engine.handle({ type: 'start', snapshot: EMPTY_TOPOLOGY_SNAPSHOT, seed: 8, initialTimeMilliseconds: 25 });
|
|
|
|
const outputs = engine.handle({ type: 'schedule', deadlineMilliseconds: 24, kind: marker('past') });
|
|
|
|
expect(outputs.map((output) => output.kind)).toEqual([
|
|
{ type: 'scheduleRejectedPastTime', deadlineMilliseconds: 24 },
|
|
]);
|
|
expect(engine.state.pendingEvents).toEqual([]);
|
|
expect(engine.state.nextEventSequenceNumber).toBe(0);
|
|
expect(engine.state.currentTimeMilliseconds).toBe(25);
|
|
});
|
|
|
|
it('replays a named scenario straight to a stable state', () => {
|
|
const finalState = NetworkRuntimeEngine.run({
|
|
name: 'equal-deadline-order',
|
|
seed: 1,
|
|
advanceToMilliseconds: 1_000,
|
|
scheduledEvents: [
|
|
{ deadlineMilliseconds: 500, kind: marker('a') },
|
|
{ deadlineMilliseconds: 500, kind: marker('b') },
|
|
],
|
|
});
|
|
|
|
expect(finalState.currentTimeMilliseconds).toBe(1_000);
|
|
expect(finalState.pendingEvents).toEqual([]);
|
|
expect(finalState.seed).toBe(1n);
|
|
});
|
|
|
|
it('clears transient state on restart and numbers packets from one again', () => {
|
|
const nodeId = uuid(0x12);
|
|
const engine = new NetworkRuntimeEngine(12);
|
|
engine.handle({ type: 'start', snapshot: EMPTY_TOPOLOGY_SNAPSHOT, seed: 12, initialTimeMilliseconds: 0 });
|
|
engine.handle({ type: 'schedule', deadlineMilliseconds: 40, kind: marker('stale') });
|
|
expect(engine.allocateFrameIdentity()).toBe(1);
|
|
expect(engine.allocatePacketIdentity()).toBe(1);
|
|
engine.recordTrace({ packetIdentity: 1, nodeId, direction: 'local', layer: 'application', operation: 'created' });
|
|
engine.handle({ type: 'stop' });
|
|
|
|
engine.handle({ type: 'start', snapshot: EMPTY_TOPOLOGY_SNAPSHOT, seed: 12, initialTimeMilliseconds: 0 });
|
|
|
|
const state = engine.state;
|
|
expect(state.pendingEvents).toEqual([]);
|
|
expect(state.packetTraces).toEqual([]);
|
|
expect(state.arpCachesByNodeId.size).toBe(0);
|
|
expect(state.switchForwardingTablesByNodeId.size).toBe(0);
|
|
expect(state.switchSeenFrameIdentitiesByNodeId.size).toBe(0);
|
|
expect(state.deliveredIPv4PacketsByNodeId.size).toBe(0);
|
|
expect(state.icmpObservationsByNodeId.size).toBe(0);
|
|
expect(state.firewallDecisions).toEqual([]);
|
|
expect(engine.allocateFrameIdentity()).toBe(1);
|
|
expect(engine.allocatePacketIdentity()).toBe(1);
|
|
});
|
|
|
|
it('forgets UDP sockets on stop, binds none while stopped, and numbers them from one again', () => {
|
|
const nodeId = uuid(0x13);
|
|
const portId = uuid(0x14);
|
|
const input = { nodes: [pc(nodeId, portId)], links: [], deviceConfigurations: new Map([[nodeId, device('10.2.0.1')]]) };
|
|
const engine = startedEngine(input);
|
|
expect(engine.bindUdpSocket(nodeId, { localPort: 7 })).toBe(uuid(1));
|
|
expect(engine.bindUdpSocket(nodeId)).toBe(uuid(2));
|
|
|
|
engine.handle({ type: 'stop' });
|
|
expect(engine.state.socketsById.size).toBe(0);
|
|
expect(engine.state.udpReceiveQueuesBySocketId.size).toBe(0);
|
|
expect(engine.bindUdpSocket(nodeId, { localPort: 7 })).toBeUndefined();
|
|
|
|
engine.handle({ type: 'start', snapshot: topologySnapshot(input), seed: 14, initialTimeMilliseconds: 0 });
|
|
expect(engine.bindUdpSocket(nodeId, { localPort: 7 })).toBe(uuid(1));
|
|
});
|
|
|
|
it('forgets DHCP leases and client state on stop', () => {
|
|
const [serverId, serverPortId, clientId, clientPortId] = [0x31, 0x32, 0x33, 0x34].map(uuid) as [string, string, string, string];
|
|
const engine = startedEngine({
|
|
nodes: [pc(serverId, serverPortId), pc(clientId, clientPortId)],
|
|
links: [link(serverId, serverPortId, clientId, clientPortId)],
|
|
deviceConfigurations: new Map([
|
|
[serverId, device('10.4.0.1')],
|
|
[clientId, device('0.0.0.0')],
|
|
]),
|
|
dhcpClientConfigurationsByNodeId: new Map([[clientId, { isEnabled: true }]]),
|
|
dhcpServerConfigurationsByNodeId: new Map([
|
|
[serverId, dhcpServerConfiguration({ isActive: true, lowerBoundIPAddress: '10.4.0.20', upperBoundIPAddress: '10.4.0.29' })],
|
|
]),
|
|
});
|
|
engine.handle({ type: 'advance', toMilliseconds: 1_000 });
|
|
expect(engine.state.dhcpLeasesByIPAddress.size).toBe(1);
|
|
|
|
engine.handle({ type: 'stop' });
|
|
const state = engine.state;
|
|
expect([
|
|
state.dhcpOffersByIPAddress.size,
|
|
state.dhcpLeasesByIPAddress.size,
|
|
state.dhcpBlacklistByServerNodeId.size,
|
|
state.dhcpLastOfferedAddressByServerNodeId.size,
|
|
state.dhcpServerSocketIdsByNodeId.size,
|
|
state.dhcpClientStatusesByNodeId.size,
|
|
state.dhcpClientContextsByNodeId.size,
|
|
]).toEqual([0, 0, 0, 0, 0, 0, 0]);
|
|
});
|
|
|
|
it('forgets TCP sessions and accepted sockets on stop', () => {
|
|
const [clientId, clientPortId, serverId, serverPortId] = [0x21, 0x22, 0x23, 0x24].map(uuid) as [string, string, string, string];
|
|
const engine = startedEngine({
|
|
nodes: [pc(clientId, clientPortId), pc(serverId, serverPortId)],
|
|
links: [link(clientId, clientPortId, serverId, serverPortId)],
|
|
deviceConfigurations: new Map([
|
|
[clientId, device('10.3.0.1')],
|
|
[serverId, device('10.3.0.2')],
|
|
]),
|
|
});
|
|
engine.openTcpServerSocket(serverId, 80);
|
|
expect(engine.connectTcp(engine.openTcpClientSocket(clientId, '10.3.0.2', 80)!)).toBe(true);
|
|
expect(engine.state.tcpSessionsById.size).toBe(3);
|
|
|
|
engine.handle({ type: 'stop' });
|
|
expect(engine.state.tcpSessionsById.size).toBe(0);
|
|
expect(engine.state.tcpAcceptedSocketIdsByListenerId.size).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('UDP sockets', () => {
|
|
const text = (payload: Uint8Array | undefined) => new TextDecoder().decode(payload ?? new Uint8Array());
|
|
|
|
it('binds, delivers and releases a port', () => {
|
|
const [sourceNodeId, sourcePortId, targetNodeId, targetPortId] = [0x15b1, 0x15b2, 0x15b3, 0x15b4].map(uuid) as [string, string, string, string];
|
|
const engine = startedEngine({
|
|
nodes: [pc(sourceNodeId, sourcePortId), pc(targetNodeId, targetPortId)],
|
|
links: [link(sourceNodeId, sourcePortId, targetNodeId, targetPortId)],
|
|
deviceConfigurations: new Map([
|
|
[sourceNodeId, device('192.168.5.10')],
|
|
[targetNodeId, device('192.168.5.20')],
|
|
]),
|
|
});
|
|
const automatic = engine.bindUdpSocket(targetNodeId);
|
|
const automaticPort = automatic === undefined ? undefined : engine.state.socketsById.get(automatic)?.localPort;
|
|
expect(automaticPort).toBeGreaterThanOrEqual(49_152);
|
|
expect(automaticPort).toBeLessThan(65_535);
|
|
|
|
const receiver = engine.bindUdpSocket(targetNodeId, { localPort: 9000 });
|
|
const sender = engine.bindUdpSocket(sourceNodeId, { localPort: 8000, remoteIPAddress: '192.168.5.20', remotePort: 9000 });
|
|
expect(receiver).toBeDefined();
|
|
expect(sender).toBeDefined();
|
|
expect(engine.bindUdpSocket(targetNodeId, { localPort: 9000 })).toBeUndefined();
|
|
|
|
engine.sendUdp(sender!, new TextEncoder().encode('hello'));
|
|
const received = engine.receiveUdp(receiver!);
|
|
expect(text(received?.datagram.payload)).toBe('hello');
|
|
expect(received?.senderIPAddress).toBe('192.168.5.10');
|
|
|
|
engine.closeSocket(receiver!);
|
|
expect(engine.bindUdpSocket(targetNodeId, { localPort: 9000 })).toBeDefined();
|
|
});
|
|
|
|
it('draws ephemeral ports without disturbing the seeded RIP jitter', () => {
|
|
const nodeId = uuid(0x15f1);
|
|
const input = { nodes: [pc(nodeId, uuid(0x15f2))], links: [], deviceConfigurations: new Map([[nodeId, device('192.168.10.10')]]) };
|
|
const socketEngine = startedEngine(input);
|
|
const ripOnlyEngine = startedEngine(input);
|
|
|
|
expect(socketEngine.bindUdpSocket(nodeId)).toBeDefined();
|
|
expect(socketEngine.nextRipJitterMilliseconds()).toBe(ripOnlyEngine.nextRipJitterMilliseconds());
|
|
});
|
|
|
|
it('broadcasts on every interface, and the receiver replies to its last peer', () => {
|
|
const [routerId, routerPortA, routerPortB, pcAId, pcAPort, pcBId, pcBPort] = [0x15e1, 0x15e2, 0x15e3, 0x15e4, 0x15e5, 0x15e6, 0x15e7].map(
|
|
uuid,
|
|
) as [string, string, string, string, string, string, string];
|
|
const engine = startedEngine(
|
|
{
|
|
nodes: [
|
|
{ id: routerId, kind: 'router', ports: [portSnapshot(routerPortA, 'rt1'), portSnapshot(routerPortB, 'rt2')] },
|
|
pc(pcAId, pcAPort),
|
|
pc(pcBId, pcBPort),
|
|
],
|
|
links: [link(routerId, routerPortA, pcAId, pcAPort), link(routerId, routerPortB, pcBId, pcBPort)],
|
|
deviceConfigurations: new Map([
|
|
[pcAId, device('10.0.0.2')],
|
|
[pcBId, device('20.0.0.2')],
|
|
]),
|
|
interfaceConfigurations: new Map([
|
|
[interfaceKey(routerId, routerPortA), { ipAddress: '10.0.0.1', subnetMask: '255.255.255.0' }],
|
|
[interfaceKey(routerId, routerPortB), { ipAddress: '20.0.0.1', subnetMask: '255.255.255.0' }],
|
|
]),
|
|
},
|
|
15,
|
|
);
|
|
const sender = engine.bindUdpSocket(routerId, { localPort: 8000 })!;
|
|
const receiverA = engine.bindUdpSocket(pcAId, { localPort: 9000 })!;
|
|
const receiverB = engine.bindUdpSocket(pcBId, { localPort: 9000 })!;
|
|
|
|
engine.sendUdp(sender, new TextEncoder().encode('broadcast'), { ipAddress: '255.255.255.255', port: 9000 });
|
|
|
|
expect(engine.receiveUdp(receiverA)?.senderIPAddress).toBe('10.0.0.1');
|
|
expect(engine.receiveUdp(receiverB)?.senderIPAddress).toBe('20.0.0.1');
|
|
expect(engine.state.socketsById.get(receiverA)?.remoteIPAddress).toBe('10.0.0.1');
|
|
expect(engine.state.socketsById.get(receiverA)?.remotePort).toBe(8000);
|
|
|
|
engine.sendUdp(receiverA, new TextEncoder().encode('reply'));
|
|
expect(text(engine.receiveUdp(sender)?.datagram.payload)).toBe('reply');
|
|
});
|
|
|
|
it('advances the virtual clock for a receive timeout and returns nothing', () => {
|
|
const nodeId = uuid(0x15d1);
|
|
const engine = startedEngine({ nodes: [pc(nodeId, uuid(0x15d2))], links: [], deviceConfigurations: new Map([[nodeId, device('10.1.0.1')]]) });
|
|
const socketId = engine.bindUdpSocket(nodeId, { localPort: 9999 })!;
|
|
|
|
expect(engine.receiveUdp(socketId, 250)).toBeUndefined();
|
|
expect(engine.state.currentTimeMilliseconds).toBe(250);
|
|
});
|
|
});
|
|
|
|
describe('Java-compatible randomness', () => {
|
|
it('draws the same RIP jitter as java.util.Random', () => {
|
|
const random = new JavaCompatibleRandom(42);
|
|
|
|
expect(Array.from({ length: 5 }, () => random.nextRipJitterMilliseconds())).toEqual([
|
|
32_475, 25_746, 32_032, 25_679, 28_287,
|
|
]);
|
|
});
|
|
|
|
it('matches java.util.Random.nextInt for bounded and power-of-two ranges', () => {
|
|
// Measured on OpenJDK 21 (29.09.2026): new java.util.Random(42),
|
|
// nextInt(100) three times, then nextInt(16) three times.
|
|
const random = new JavaCompatibleRandom(42);
|
|
|
|
expect([random.nextInt(100), random.nextInt(100), random.nextInt(100)]).toEqual([30, 63, 48]);
|
|
expect([random.nextInt(16), random.nextInt(16), random.nextInt(16)]).toEqual([0, 4, 15]);
|
|
});
|
|
});
|
|
|
|
describe('IPv4 packets', () => {
|
|
it('keeps the identity and lowers the TTL when forwarding', () => {
|
|
const packet: IPv4Packet = {
|
|
identity: 99,
|
|
senderIPAddress: '192.168.0.10',
|
|
receiverIPAddress: '192.168.1.20',
|
|
timeToLive: 2,
|
|
protocol: 'icmp',
|
|
payload: { kind: 'icmp', message: icmpMessage('echoRequest', { identifier: 3, sequenceNumber: 4 }) },
|
|
};
|
|
|
|
const forwarded = forwardingClone(packet);
|
|
const expired = forwardingClone(forwarded);
|
|
|
|
expect(forwarded).toMatchObject({ identity: 99, timeToLive: 1 });
|
|
expect(expired).toMatchObject({ identity: 99, timeToLive: 0 });
|
|
});
|
|
});
|
|
|
|
describe('Ethernet, ARP and ICMP', () => {
|
|
it('fills both ARP caches with one request and reply over a direct cable', () => {
|
|
const sourceNodeId = uuid(0x1401);
|
|
const sourcePortId = uuid(0x1411);
|
|
const targetNodeId = uuid(0x1402);
|
|
const targetPortId = uuid(0x1412);
|
|
const engine = startedEngine({
|
|
nodes: [pc(sourceNodeId, sourcePortId), pc(targetNodeId, targetPortId)],
|
|
links: [link(sourceNodeId, sourcePortId, targetNodeId, targetPortId)],
|
|
deviceConfigurations: new Map([
|
|
[sourceNodeId, device('192.168.0.10')],
|
|
[targetNodeId, device('192.168.0.20')],
|
|
]),
|
|
});
|
|
|
|
const resolved = engine.resolveMacAddress(sourceNodeId, '192.168.0.20');
|
|
|
|
expect(resolved).toBe(stableMacAddress(targetPortId));
|
|
expect(engine.state.arpCachesByNodeId.get(sourceNodeId)?.get('192.168.0.20')?.macAddress).toBe(resolved);
|
|
expect(engine.state.arpCachesByNodeId.get(targetNodeId)?.get('192.168.0.10')?.macAddress).toBe(
|
|
stableMacAddress(sourcePortId),
|
|
);
|
|
});
|
|
|
|
it('pings across a switch with Ethernet, ARP and ICMP packets', () => {
|
|
const sourceNodeId = uuid(0x1421);
|
|
const sourcePortId = uuid(0x1431);
|
|
const switchNodeId = uuid(0x1422);
|
|
const switchPortA = uuid(0x1432);
|
|
const switchPortB = uuid(0x1433);
|
|
const targetNodeId = uuid(0x1423);
|
|
const targetPortId = uuid(0x1434);
|
|
const switchNode: NodeSnapshot = {
|
|
id: switchNodeId,
|
|
kind: 'networkSwitch',
|
|
ports: [portSnapshot(switchPortA, 'sw1'), portSnapshot(switchPortB, 'sw2')],
|
|
};
|
|
const engine = startedEngine({
|
|
nodes: [pc(sourceNodeId, sourcePortId), switchNode, pc(targetNodeId, targetPortId)],
|
|
links: [
|
|
link(sourceNodeId, sourcePortId, switchNodeId, switchPortA),
|
|
link(switchNodeId, switchPortB, targetNodeId, targetPortId),
|
|
],
|
|
deviceConfigurations: new Map([
|
|
[sourceNodeId, device('192.168.0.10')],
|
|
[targetNodeId, device('192.168.0.20')],
|
|
]),
|
|
});
|
|
|
|
const result = engine.sendIcmpEcho(sourceNodeId, '192.168.0.20', { identifier: 14, sequenceNumber: 1 });
|
|
|
|
expect(result.type).toBe('delivered');
|
|
expect(engine.state.icmpObservationsByNodeId.get(sourceNodeId)?.at(-1)?.message.kind).toBe('echoReply');
|
|
expect(engine.state.switchForwardingTablesByNodeId.get(switchNodeId)?.size).toBe(2);
|
|
expect(engine.state.packetTraces.some((trace) => trace.detail === 'ARP request for 192.168.0.20')).toBe(true);
|
|
expect(
|
|
engine.state.packetTraces.some((trace) => trace.packetIdentity !== undefined && trace.layer === 'network'),
|
|
).toBe(true);
|
|
});
|
|
|
|
it('answers an expired TTL at the router with time exceeded and the original packet', () => {
|
|
const topology = routedTopology();
|
|
|
|
const result = topology.engine.sendIcmpEcho(topology.sourceNodeId, '20.0.0.2', {
|
|
timeToLive: 1,
|
|
identifier: 140,
|
|
sequenceNumber: 1,
|
|
});
|
|
|
|
expect(result).toMatchObject({ type: 'icmpError', kind: 'timeExceeded' });
|
|
const observation = topology.engine.state.icmpObservationsByNodeId.get(topology.sourceNodeId)?.at(-1);
|
|
expect(observation?.message.kind).toBe('timeExceeded');
|
|
expect(observation?.message.embeddedOriginalPacket?.timeToLive).toBe(1);
|
|
expect(observation?.message.embeddedOriginalPacket?.receiverIPAddress).toBe('20.0.0.2');
|
|
});
|
|
|
|
it('keeps the packet identity and lowers the TTL when a router forwards', () => {
|
|
const topology = routedTopology();
|
|
|
|
const result = topology.engine.sendIcmpEcho(topology.sourceNodeId, '20.0.0.2', {
|
|
timeToLive: 8,
|
|
identifier: 141,
|
|
sequenceNumber: 1,
|
|
});
|
|
|
|
expect(result.type).toBe('delivered');
|
|
const forwardingTrace = topology.engine.state.packetTraces.find(
|
|
(trace) =>
|
|
trace.nodeId === topology.routerNodeId && trace.operation === 'forwarded' && trace.detail === 'TTL decremented',
|
|
);
|
|
expect(forwardingTrace?.packetIdentity).toBeDefined();
|
|
expect(forwardingTrace?.beforeHeaders.find((field) => field.name === 'ttl')?.value).toBe('8');
|
|
expect(forwardingTrace?.afterHeaders.find((field) => field.name === 'ttl')?.value).toBe('7');
|
|
});
|
|
|
|
it('reports network unreachable when the router has no route', () => {
|
|
const topology = routedTopology();
|
|
const snapshot = topology.engine.state.topologySnapshot;
|
|
const sourceToRouter = snapshot.links.find(
|
|
(candidate) => candidate.sourceNodeId === topology.sourceNodeId || candidate.targetNodeId === topology.sourceNodeId,
|
|
);
|
|
const engine = new NetworkRuntimeEngine(14);
|
|
engine.handle({
|
|
type: 'start',
|
|
snapshot: topologySnapshot({
|
|
nodes: snapshot.nodes,
|
|
links: sourceToRouter === undefined ? [] : [sourceToRouter],
|
|
deviceConfigurations: snapshot.deviceConfigurations,
|
|
interfaceConfigurations: snapshot.interfaceConfigurations,
|
|
manualRoutesByNodeId: snapshot.manualRoutesByNodeId,
|
|
}),
|
|
seed: 14,
|
|
initialTimeMilliseconds: 0,
|
|
});
|
|
|
|
const result = engine.sendIcmpEcho(topology.sourceNodeId, '30.0.0.2', { identifier: 142, sequenceNumber: 1 });
|
|
|
|
expect(result).toMatchObject({ type: 'icmpError', kind: 'destinationNetworkUnreachable' });
|
|
});
|
|
|
|
it('reports host unreachable when a connected target never answers ARP', () => {
|
|
const sourceNodeId = uuid(0x1481);
|
|
const sourcePortId = uuid(0x1491);
|
|
const engine = startedEngine({
|
|
nodes: [pc(sourceNodeId, sourcePortId)],
|
|
links: [],
|
|
deviceConfigurations: new Map([[sourceNodeId, device('192.168.50.10')]]),
|
|
});
|
|
|
|
const result = engine.sendIcmpEcho(sourceNodeId, '192.168.50.99', { identifier: 143, sequenceNumber: 1 });
|
|
|
|
expect(result).toMatchObject({ type: 'icmpError', kind: 'destinationHostUnreachable' });
|
|
});
|
|
});
|
|
|
|
describe('switch', () => {
|
|
it('learns, floods, forwards, filters and ages its address table', () => {
|
|
const hostAId = uuid(0x1a01);
|
|
const hostAPortId = uuid(0x1a11);
|
|
const hostBId = uuid(0x1a02);
|
|
const hostBPortId = uuid(0x1a12);
|
|
const hostCId = uuid(0x1a03);
|
|
const hostCPortId = uuid(0x1a13);
|
|
const switchId = uuid(0x1a04);
|
|
const switchPorts = [1, 2, 3].map((index) => portSnapshot(uuid(0x1a20 + index), `sw${index}`));
|
|
const engine = startedEngine(
|
|
{
|
|
nodes: [
|
|
pc(hostAId, hostAPortId),
|
|
pc(hostBId, hostBPortId),
|
|
pc(hostCId, hostCPortId),
|
|
{ id: switchId, kind: 'networkSwitch', ports: switchPorts },
|
|
],
|
|
links: [
|
|
link(hostAId, hostAPortId, switchId, switchPorts[0]!.id),
|
|
link(hostBId, hostBPortId, switchId, switchPorts[1]!.id),
|
|
link(hostCId, hostCPortId, switchId, switchPorts[2]!.id),
|
|
],
|
|
switchConfigurationsByNodeId: new Map([[switchId, switchConfiguration('Classroom', 1_000)]]),
|
|
},
|
|
20,
|
|
);
|
|
const macA = stableMacAddress(hostAPortId);
|
|
const macB = stableMacAddress(hostBPortId);
|
|
const payload: EthernetPayload = {
|
|
kind: 'arp',
|
|
packet: {
|
|
operation: 'request',
|
|
senderMacAddress: macA,
|
|
senderIPAddress: '192.0.2.1',
|
|
targetMacAddress: UNSPECIFIED_MAC_ADDRESS,
|
|
targetIPAddress: '192.0.2.200',
|
|
},
|
|
};
|
|
const frame = (sourceMacAddress: string, destinationMacAddress: string): EthernetFrame => ({
|
|
identity: engine.allocateFrameIdentity(),
|
|
sourceMacAddress,
|
|
destinationMacAddress,
|
|
payload,
|
|
});
|
|
const switchTraces = (detail: string) =>
|
|
engine.state.packetTraces.filter((trace) => trace.nodeId === switchId && trace.detail === detail);
|
|
|
|
engine.sendEthernetFrame(hostAId, hostAPortId, frame(macA, '02:00:00:00:00:FE'));
|
|
expect(switchTraces('switch unknown unicast flooding')).toHaveLength(2);
|
|
|
|
engine.sendEthernetFrame(hostBId, hostBPortId, frame(macB, macA));
|
|
expect(switchTraces('switch learned unicast forwarding')).toHaveLength(1);
|
|
|
|
engine.sendEthernetFrame(hostAId, hostAPortId, frame(macA, macA));
|
|
expect(switchTraces('switch filtered destination on incoming port').length).toBeGreaterThan(0);
|
|
expect(engine.switchSatEntries(switchId)).toHaveLength(2);
|
|
|
|
const retention = 1_000;
|
|
const updateTimes = [...engine.state.switchForwardingUpdatedAtMillisecondsByNodeId.get(switchId)!.values()];
|
|
const expirationDeadlines = updateTimes.map((time) => time + retention);
|
|
const firstExpiration = Math.min(...expirationDeadlines);
|
|
const lastExpiration = Math.max(...expirationDeadlines);
|
|
|
|
engine.handle({ type: 'advance', toMilliseconds: firstExpiration - 1 });
|
|
expect(engine.switchSatEntries(switchId)).toHaveLength(2);
|
|
engine.handle({ type: 'advance', toMilliseconds: lastExpiration });
|
|
expect(engine.switchSatEntries(switchId)).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('firewall', () => {
|
|
it('accepts when inactive, and an active ICMP drop bypasses the ordered rules', () => {
|
|
const routerId = uuid(0x1801);
|
|
const portId = uuid(0x1811);
|
|
const router: NodeSnapshot = { id: routerId, kind: 'router', ports: [portSnapshot(portId, 'rt1')] };
|
|
const packet: IPv4Packet = {
|
|
identity: 18,
|
|
senderIPAddress: '203.0.113.10',
|
|
receiverIPAddress: '10.0.0.1',
|
|
timeToLive: 64,
|
|
protocol: 'icmp',
|
|
payload: { kind: 'icmp', message: icmpMessage('echoRequest') },
|
|
};
|
|
const interfaces = new Map([
|
|
[interfaceKey(routerId, portId), { ipAddress: '10.0.0.1', subnetMask: '255.255.255.0' }],
|
|
]);
|
|
|
|
const inactive = startedEngine(
|
|
{
|
|
nodes: [router],
|
|
links: [],
|
|
interfaceConfigurations: interfaces,
|
|
firewallConfigurationsByNodeId: new Map([[routerId, firewallConfiguration()]]),
|
|
},
|
|
18,
|
|
);
|
|
expect(inactive.evaluateFirewall(packet, routerId).accepted).toBe(true);
|
|
|
|
const active = startedEngine(
|
|
{
|
|
nodes: [router],
|
|
links: [],
|
|
interfaceConfigurations: interfaces,
|
|
firewallConfigurationsByNodeId: new Map([
|
|
[
|
|
routerId,
|
|
firewallConfiguration({
|
|
isActive: true,
|
|
defaultPolicy: 'accept',
|
|
dropIcmp: true,
|
|
rules: [firewallRule({ protocol: 'icmp', action: 'accept' })],
|
|
}),
|
|
],
|
|
]),
|
|
},
|
|
18,
|
|
);
|
|
expect(active.evaluateFirewall(packet, routerId).accepted).toBe(false);
|
|
expect(active.state.firewallDecisions.at(-1)?.ruleIndex).toBeUndefined();
|
|
expect(active.state.packetTraces.at(-1)?.operation).toBe('dropped');
|
|
});
|
|
|
|
it('lets the first matching TCP rule win, and established traffic bypasses SYN-only rules', () => {
|
|
const { engine, routerId } = firewallEngine(
|
|
firewallConfiguration({
|
|
isActive: true,
|
|
defaultPolicy: 'drop',
|
|
filterSynSegmentsOnly: true,
|
|
rules: [
|
|
firewallRule({ port: 443, protocol: 'tcp', action: 'accept' }),
|
|
firewallRule({ port: 443, protocol: 'tcp', action: 'drop' }),
|
|
],
|
|
}),
|
|
);
|
|
|
|
const synDecision = engine.evaluateFirewall(tcpPacket(181, 443, TCP_FLAG.synchronize), routerId);
|
|
expect(synDecision.accepted).toBe(true);
|
|
expect(synDecision.ruleIndex).toBe(0);
|
|
expect(engine.evaluateFirewall(tcpPacket(182, 22, TCP_FLAG.acknowledgement), routerId).accepted).toBe(true);
|
|
});
|
|
|
|
it('matches a UDP response by its explicit source port and the direct-network marker', () => {
|
|
const { engine, routerId } = firewallEngine(
|
|
firewallConfiguration({
|
|
isActive: true,
|
|
defaultPolicy: 'drop',
|
|
rules: [
|
|
firewallRule({
|
|
sourceIPAddress: DIRECTLY_CONNECTED_SOURCE_MARKER,
|
|
destinationIPAddress: '8.8.8.8',
|
|
destinationSubnetMask: '255.255.255.255',
|
|
port: 53,
|
|
protocol: 'udp',
|
|
action: 'accept',
|
|
}),
|
|
],
|
|
}),
|
|
);
|
|
const udp = (identity: number, sender: string, receiver: string, sourcePort: number, destinationPort: number): IPv4Packet => ({
|
|
identity,
|
|
senderIPAddress: sender,
|
|
receiverIPAddress: receiver,
|
|
timeToLive: 64,
|
|
protocol: 'udp',
|
|
payload: { kind: 'udp', datagram: { sourcePort, destinationPort, payload: new Uint8Array() } },
|
|
});
|
|
|
|
expect(engine.evaluateFirewall(udp(183, '10.0.0.20', '8.8.8.8', 50_001, 53), routerId).accepted).toBe(true);
|
|
expect(engine.evaluateFirewall(udp(184, '8.8.8.8', '10.0.0.20', 53, 50_001), routerId).accepted).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('packet loss and trace retention', () => {
|
|
it('drops every physical delivery while global packet loss is on', () => {
|
|
const firstNodeId = uuid(0x101);
|
|
const firstPortId = uuid(0x102);
|
|
const secondNodeId = uuid(0x103);
|
|
const secondPortId = uuid(0x104);
|
|
const engine = startedEngine({
|
|
nodes: [pc(firstNodeId, firstPortId), pc(secondNodeId, secondPortId)],
|
|
links: [link(firstNodeId, firstPortId, secondNodeId, secondPortId)],
|
|
deviceConfigurations: new Map([
|
|
[firstNodeId, device('10.0.0.1')],
|
|
[secondNodeId, device('10.0.0.2')],
|
|
]),
|
|
});
|
|
const frame: EthernetFrame = {
|
|
identity: engine.allocateFrameIdentity(),
|
|
sourceMacAddress: stableMacAddress(firstPortId),
|
|
destinationMacAddress: ETHERNET_BROADCAST_MAC_ADDRESS,
|
|
payload: {
|
|
kind: 'arp',
|
|
packet: {
|
|
operation: 'request',
|
|
senderMacAddress: stableMacAddress(firstPortId),
|
|
senderIPAddress: '10.0.0.1',
|
|
targetMacAddress: UNSPECIFIED_MAC_ADDRESS,
|
|
targetIPAddress: '10.0.0.2',
|
|
},
|
|
},
|
|
};
|
|
|
|
engine.setGlobalPacketLossEnabled(true);
|
|
engine.sendEthernetFrame(firstNodeId, firstPortId, frame);
|
|
|
|
expect(engine.state.globalPacketLossEnabled).toBe(true);
|
|
expect(engine.state.arpCachesByNodeId.get(secondNodeId)?.size ?? 0).toBe(0);
|
|
expect(
|
|
engine.state.packetTraces.some(
|
|
(trace) =>
|
|
trace.frameIdentity === frame.identity &&
|
|
trace.nodeId === secondNodeId &&
|
|
trace.operation === 'dropped' &&
|
|
trace.detail === 'global packet-loss simulation',
|
|
),
|
|
).toBe(true);
|
|
|
|
engine.setGlobalPacketLossEnabled(false);
|
|
engine.sendEthernetFrame(firstNodeId, firstPortId, frame);
|
|
|
|
expect(engine.state.globalPacketLossEnabled).toBe(false);
|
|
expect(engine.state.arpCachesByNodeId.get(secondNodeId)?.get('10.0.0.1')?.macAddress).toBe(
|
|
stableMacAddress(firstPortId),
|
|
);
|
|
});
|
|
|
|
it('resets packet loss across stop and restart', () => {
|
|
const engine = new NetworkRuntimeEngine(42);
|
|
engine.handle({ type: 'start', snapshot: EMPTY_TOPOLOGY_SNAPSHOT, seed: 42, initialTimeMilliseconds: 0 });
|
|
engine.setGlobalPacketLossEnabled(true);
|
|
expect(engine.state.globalPacketLossEnabled).toBe(true);
|
|
|
|
engine.handle({ type: 'stop' });
|
|
expect(engine.state.globalPacketLossEnabled).toBe(false);
|
|
|
|
engine.handle({ type: 'start', snapshot: EMPTY_TOPOLOGY_SNAPSHOT, seed: 42, initialTimeMilliseconds: 0 });
|
|
expect(engine.state.globalPacketLossEnabled).toBe(false);
|
|
});
|
|
|
|
it('evicts the oldest trace events in configured batches', () => {
|
|
const nodeId = uuid(0x2001);
|
|
const interfaceId = uuid(0x2002);
|
|
const engine = new NetworkRuntimeEngine(20, traceRetentionPolicy(3, 2));
|
|
|
|
for (let identity = 1; identity <= 6; identity += 1) {
|
|
engine.recordTrace({
|
|
frameIdentity: identity,
|
|
nodeId,
|
|
interfaceId,
|
|
direction: 'outbound',
|
|
layer: 'dataLink',
|
|
operation: 'sent',
|
|
detail: `trace-${identity}`,
|
|
});
|
|
}
|
|
|
|
expect(engine.state.packetTraces.map((trace) => trace.id)).toEqual([5, 6]);
|
|
expect(engine.state.packetTraces.map((trace) => trace.detail)).toEqual(['trace-5', 'trace-6']);
|
|
expect(engine.state.discardedPacketTraceCount).toBe(4);
|
|
expect(engine.state.nextTraceIdentity).toBe(7);
|
|
});
|
|
});
|
|
|
|
describe('TCP sockets', () => {
|
|
/** Two PCs on one cable, started. */
|
|
const pair = (first: number, second: number, prefix: string) => {
|
|
const [clientId, clientPortId, serverId, serverPortId] = [first, first + 0x10, second, second + 0x10].map(uuid) as [
|
|
string,
|
|
string,
|
|
string,
|
|
string,
|
|
];
|
|
const engine = startedEngine({
|
|
nodes: [pc(clientId, clientPortId), pc(serverId, serverPortId)],
|
|
links: [link(clientId, clientPortId, serverId, serverPortId)],
|
|
deviceConfigurations: new Map([
|
|
[clientId, device(`${prefix}.1`)],
|
|
[serverId, device(`${prefix}.2`)],
|
|
]),
|
|
});
|
|
return { engine, clientId, serverId };
|
|
};
|
|
|
|
it('makes an established client and an accepted server socket with the three-way handshake', () => {
|
|
const { engine, clientId, serverId } = pair(0x1701, 0x1702, '10.17.0');
|
|
const listenerId = engine.openTcpServerSocket(serverId, 8080)!;
|
|
const clientSocketId = engine.openTcpClientSocket(clientId, '10.17.0.2', 8080)!;
|
|
|
|
expect(engine.connectTcp(clientSocketId)).toBe(true);
|
|
const acceptedSocketId = engine.state.tcpAcceptedSocketIdsByListenerId.get(listenerId)![0]!;
|
|
expect(engine.state.socketsById.get(clientSocketId)?.tcpState).toBe('ESTABLISHED');
|
|
expect(engine.state.socketsById.get(acceptedSocketId)?.tcpState).toBe('ESTABLISHED');
|
|
expect(engine.state.packetTraces.some((trace) => trace.detail === 'TCP SYN_SENT -> ESTABLISHED')).toBe(true);
|
|
expect(engine.state.packetTraces.some((trace) => trace.detail === 'TCP SYN_RCVD -> ESTABLISHED')).toBe(true);
|
|
});
|
|
|
|
it("sends segments of Java's MSS and reassembles one message up to the push", () => {
|
|
const { engine, clientId, serverId } = pair(0x1721, 0x1722, '10.18.0');
|
|
const listenerId = engine.openTcpServerSocket(serverId, 8081)!;
|
|
const clientSocketId = engine.openTcpClientSocket(clientId, '10.18.0.2', 8081)!;
|
|
expect(engine.connectTcp(clientSocketId)).toBe(true);
|
|
const acceptedSocketId = engine.state.tcpAcceptedSocketIdsByListenerId.get(listenerId)![0]!;
|
|
const payload = Uint8Array.from({ length: 3_001 }, (_, index) => index % 251);
|
|
|
|
expect(engine.sendTcp(clientSocketId, payload)).toBe(true);
|
|
expect(engine.receiveTcp(acceptedSocketId)).toEqual(payload);
|
|
const payloadLengths = engine.state.packetTraces
|
|
.filter((trace) => trace.detail?.startsWith('TCP data') === true)
|
|
.map((trace) => Number(trace.afterHeaders.find((header) => header.name === 'payloadLength')?.value));
|
|
expect(payloadLengths).toEqual([1_460, 1_460, 81]);
|
|
});
|
|
|
|
it('retransmits a connection attempt at most three times and releases the port', () => {
|
|
const clientId = uuid(0x1741);
|
|
const engine = startedEngine({ nodes: [pc(clientId, uuid(0x1751))], links: [], deviceConfigurations: new Map([[clientId, device('10.19.0.1')]]) });
|
|
const socketId = engine.openTcpClientSocket(clientId, '10.19.0.99', 80)!;
|
|
const localPort = engine.state.socketsById.get(socketId)!.localPort;
|
|
|
|
expect(engine.connectTcp(socketId)).toBe(false);
|
|
expect(engine.state.tcpSessionsById.get(socketId)?.sendAttempts).toBe(3);
|
|
expect(engine.state.socketsById.get(socketId)?.tcpState).toBe('CLOSED');
|
|
expect(engine.isTcpPortReserved(clientId, localPort)).toBe(false);
|
|
expect(engine.state.packetTraces.filter((trace) => trace.detail?.includes('TCP SYN retransmission') === true)).toHaveLength(2);
|
|
});
|
|
|
|
it('drops a SYN+ACK at a listening port with no connection of its own', () => {
|
|
const { engine, clientId, serverId } = pair(0x1781, 0x1782, '10.21.0');
|
|
const listenerId = engine.openTcpServerSocket(serverId, 7000)!;
|
|
const segment = { sourcePort: 80, destinationPort: 7000, sequenceNumber: 5, acknowledgementNumber: 9, flags: TCP_FLAG.synchronize | TCP_FLAG.acknowledgement, payload: new Uint8Array() };
|
|
engine.sendIPv4Packet(clientId, {
|
|
identity: engine.allocatePacketIdentity(),
|
|
senderIPAddress: '10.21.0.1',
|
|
receiverIPAddress: '10.21.0.2',
|
|
timeToLive: 64,
|
|
protocol: 'tcp',
|
|
payload: { kind: 'tcp', segment },
|
|
});
|
|
engine.handle({ type: 'advance', toMilliseconds: engine.state.currentTimeMilliseconds + 1_000 });
|
|
|
|
expect(engine.state.tcpAcceptedSocketIdsByListenerId.get(listenerId)).toEqual([]);
|
|
expect(engine.state.packetTraces.some((trace) => trace.detail === 'TCP destination port closed')).toBe(true);
|
|
});
|
|
|
|
it('names the flags of a segment in Swift\'s order', () => {
|
|
expect(tcpFlagDescription(TCP_FLAG.acknowledgement | TCP_FLAG.push | TCP_FLAG.reset | TCP_FLAG.synchronize | TCP_FLAG.finish)).toBe(
|
|
'FIN+SYN+RST+PSH+ACK',
|
|
);
|
|
expect(tcpFlagDescription(TCP_FLAG.synchronize | TCP_FLAG.acknowledgement)).toBe('SYN+ACK');
|
|
expect(tcpFlagDescription(0)).toBe('');
|
|
});
|
|
|
|
it("closes with Java's FIN states, and TIME_WAIT releases both ends", () => {
|
|
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);
|
|
expect(engine.state.socketsById.get(clientSocketId)?.tcpState).toBe('FIN_WAIT_2');
|
|
expect(engine.state.socketsById.get(serverSocketId)?.tcpState).toBe('CLOSE_WAIT');
|
|
engine.closeTcpSocket(serverSocketId);
|
|
expect(engine.state.socketsById.get(clientSocketId)?.tcpState).toBe('TIME_WAIT');
|
|
expect(engine.state.socketsById.get(serverSocketId)?.tcpState).toBe('CLOSED');
|
|
|
|
engine.handle({ type: 'advance', toMilliseconds: engine.state.currentTimeMilliseconds + TCP_ROUND_TRIP_TIME_MILLISECONDS });
|
|
expect(engine.state.socketsById.get(clientSocketId)?.tcpState).toBe('CLOSED');
|
|
expect(engine.state.socketsById.get(listenerId)?.tcpState).toBe('LISTEN');
|
|
});
|
|
});
|
|
|
|
describe('DHCP', () => {
|
|
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, clientId, clientPortId };
|
|
};
|
|
|
|
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 });
|
|
|
|
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]).toEqual(['FINISH', 0, true]);
|
|
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);
|
|
const details = engine.state.packetTraces.map((trace) => trace.detail);
|
|
for (const message of ['DHCPDISCOVER', 'DHCPOFFER', 'DHCPREQUEST', 'DHCPACK']) expect(details).toContain(message);
|
|
});
|
|
|
|
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: 1_000 });
|
|
|
|
expect(engine.state.topologySnapshot.deviceConfigurations.get(clientId)?.ipAddress).toBe('10.0.0.99');
|
|
expect([...engine.state.dhcpLeasesByIPAddress.values()][0]?.expiresAtMilliseconds).toBeUndefined();
|
|
});
|
|
|
|
it('retries ten timeouts and gives back only the old address', () => {
|
|
const clientId = uuid(0x1621);
|
|
const oldConfiguration = deviceConfiguration('169.254.5.7', '255.255.0.0', '169.254.0.1', '169.254.0.53');
|
|
const engine = startedEngine(
|
|
{
|
|
nodes: [pc(clientId, uuid(0x1622))],
|
|
links: [],
|
|
deviceConfigurations: new Map([[clientId, oldConfiguration]]),
|
|
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');
|
|
expect(engine.state.dhcpClientStatusesByNodeId.get(clientId)?.errorCount).toBe(0);
|
|
|
|
engine.handle({ type: 'advance', toMilliseconds: 25_000 });
|
|
|
|
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)).toEqual(oldConfiguration);
|
|
expect(engine.state.dhcpClientContextsByNodeId.has(clientId)).toBe(false);
|
|
expect(engine.state.socketsById.size).toBe(0);
|
|
});
|
|
|
|
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();
|
|
});
|
|
});
|