// DHCP as Java FILIUS runs it. tools/java-oracle's DhcpRun built each // scenario's network in Java FILIUS, ran it and wrote what every network card // saw (.java.json, FILIUS' own packet log). The engine runs the same // network. For each DHCP client, the frames that concern it must come in the // same order, with the same content, at about the same distance from each // other, and every host must end with the same addresses. // // A frame counts when it first shows on any card (sent, or received), in // Java and in the engine alike. Not only on the client's own card: there, // FILIUS' log misses the DISCOVER of the client that sends second when two // start together (measured in four of four runs of dhcp-lan; the server // receives it) — a fault of the log, not of the network. Java logs no frame // identity; the same text within DUPLICATE_WINDOW_MILLISECONDS is one frame // (measured: a frame reaches the last card within some 130 ms; the closest // two distinct frames of the same text are the DISCOVERs around a DECLINE, // some 520 ms apart). // // Java runs on threads in real time, at the default speed of 90 % (55 ms a // cable, a round trip time of 2750 ms). Its gaps match the engine's within a // few milliseconds, except where a card sends two frames at once: Java's // cable carries one frame at a time (SimplexVerbindung sleeps the delay with // the port's buffer locked, and the sender waits), the engine's cable all of // them together — up to three cables' delay, measured. A gap may differ by // GAP_TOLERANCE_MILLISECONDS; a wrong round trip time (250 ms) may not. Java draws the link-local // address with Math.random; it is compared as 169.254.1.x. Where several // clients start together, which one is first varies in Java (the first // thread pays for loading the classes); a client is therefore compared // without the other clients' frames and the answers to them. import { readdirSync, readFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; import { NetworkRuntimeEngine, stableMacAddress, type EthernetFrame } from '../src/index.js'; import { snapshotOf, type Scenario } from './scenario.js'; const scenarioDirectory = join(dirname(fileURLToPath(import.meta.url)), 'scenarios'); const GAP_TOLERANCE_MILLISECONDS = 200; const DUPLICATE_WINDOW_MILLISECONDS = 300; interface JavaFrame { time: number; source: string; destination: string; arp?: number; senderIp?: string; targetIp?: string; sourceIp?: string; destinationIp?: string; protocol?: number; sourcePort?: number; destinationPort?: number; payload?: string; icmp?: number; } interface JavaRun { frames: Record; devices: Record; } interface SeenFrame { readonly time: number; readonly text: string; } const LINK_LOCAL = /169\.254\.1\.\d+/g; function javaText(frame: JavaFrame): string { const macs = `${frame.source} > ${frame.destination}`.toUpperCase(); if (frame.arp !== undefined) return `${macs} ARP ${frame.arp === 1 ? 'request' : 'reply'} ${frame.senderIp} > ${frame.targetIp}`; if (frame.icmp !== undefined) return `${macs} ICMP ${frame.icmp}`; return `${macs} UDP ${frame.sourceIp}:${frame.sourcePort} > ${frame.destinationIp}:${frame.destinationPort} ${frame.payload}`; } function engineText(frame: EthernetFrame): string { const macs = `${frame.sourceMacAddress} > ${frame.destinationMacAddress}`.toUpperCase(); if (frame.payload.kind === 'arp') { const arp = frame.payload.packet; return `${macs} ARP ${arp.operation} ${arp.senderIPAddress} > ${arp.targetIPAddress}`; } const packet = frame.payload.packet; if (packet.payload.kind !== 'udp') return `${macs} ${packet.payload.kind.toUpperCase()}`; const datagram = packet.payload.datagram; return `${macs} UDP ${packet.senderIPAddress}:${datagram.sourcePort} > ${packet.receiverIPAddress}:${datagram.destinationPort} ${new TextDecoder().decode(datagram.payload)}`; } /** The engine's run of the scenario's network, without its steps, for `milliseconds`. */ function runEngine(scenario: Scenario, milliseconds: number) { const engine = new NetworkRuntimeEngine(scenario.seed); const framesByIdentity = new Map(); const send = engine.sendEthernetFrame.bind(engine); engine.sendEthernetFrame = (fromNodeId, outgoingPortId, frame) => { framesByIdentity.set(frame.identity, frame); send(fromNodeId, outgoingPortId, frame); }; engine.handle({ type: 'start', snapshot: snapshotOf(scenario), seed: scenario.seed, initialTimeMilliseconds: 0 }); engine.handle({ type: 'advance', toMilliseconds: milliseconds }); // Every frame when it first shows on a host's card (as FILIUS logs it: sent, or received). const hosts = new Set(scenario.nodes.filter((node) => node.kind !== 'networkSwitch').map((node) => node.id)); const firstSeen = new Map(); for (const event of engine.state.packetTraces) { if (event.layer !== 'physical' || !hosts.has(event.nodeId) || event.frameIdentity === undefined) continue; if (!((event.direction === 'outbound' && event.operation === 'sent') || (event.direction === 'inbound' && event.operation === 'received'))) continue; if (firstSeen.has(event.frameIdentity)) continue; firstSeen.set(event.frameIdentity, { time: event.timeMilliseconds, text: engineText(framesByIdentity.get(event.frameIdentity)!) }); } const devices = Object.fromEntries( [...engine.state.topologySnapshot.deviceConfigurations].map(([node, device]) => [ node, [device.ipAddress, device.subnetMask, device.defaultGateway, device.dnsServer], ]), ); return { frames: [...firstSeen.values()], devices }; } /** Java's frames of all cards, each once: the same text within the window is the same frame. */ function javaFrames(java: JavaRun): SeenFrame[] { const all = Object.values(java.frames) .flat() .map((frame) => ({ time: frame.time, text: javaText(frame) })) .sort((lhs, rhs) => lhs.time - rhs.time); const frames: SeenFrame[] = []; for (const frame of all) { if (frames.some((each) => each.text === frame.text && frame.time - each.time <= DUPLICATE_WINDOW_MILLISECONDS)) continue; frames.push(frame); } return frames; } /** Consecutive frames without the other clients' own: the text, and the time since the one before. */ function steps(frames: readonly SeenFrame[], otherClientMacs: readonly string[]) { const theirs = (frame: SeenFrame) => otherClientMacs.some((mac) => frame.text.startsWith(`${mac} >`) || frame.text.includes(`chaddr=${mac}`)); const own = frames.filter((frame) => !theirs(frame)); return own.map((frame, index) => ({ text: frame.text.replace(LINK_LOCAL, '169.254.1.x'), gap: index === 0 ? 0 : frame.time - own[index - 1]!.time, })); } function clientMac(scenario: Scenario, nodeId: string): string { const port = scenario.nodes.find((node) => node.id === nodeId)!.ports[0]!; return (port.mac ?? stableMacAddress(port.id)).toUpperCase(); } const runs = readdirSync(scenarioDirectory) .filter((name) => name.endsWith('.java.json')) .map((name) => name.replace('.java.json', '')) .sort(); describe('DHCP as Java FILIUS runs it', () => { it('has Java runs', () => { expect(runs.length).toBeGreaterThan(0); }); for (const name of runs) { it(name, () => { const scenario = JSON.parse(readFileSync(join(scenarioDirectory, `${name}.scenario.json`), 'utf8')) as Scenario; const java = JSON.parse(readFileSync(join(scenarioDirectory, `${name}.java.json`), 'utf8')) as JavaRun; const lastJavaFrame = Math.max(...Object.values(java.frames).flatMap((frames) => frames.map((frame) => frame.time))); const engine = runEngine(scenario, lastJavaFrame + 10_000); const clients = scenario.dhcpClients ?? []; for (const client of clients) { const others = clients.filter((each) => each !== client).map((each) => clientMac(scenario, each)); const expected = steps(javaFrames(java), others); const actual = steps(engine.frames, others); for (let index = 0; index < Math.max(expected.length, actual.length); index += 1) { expect(actual[index]?.text, `${client} frame #${index + 1}`).toBe(expected[index]?.text); expect(Math.abs(actual[index]!.gap - expected[index]!.gap), `${client} gap before frame #${index + 1}`).toBeLessThanOrEqual( GAP_TOLERANCE_MILLISECONDS, ); } } const linkLocal = (devices: Record) => Object.fromEntries(Object.entries(devices).map(([node, device]) => [node, device.map((value) => value.replace(LINK_LOCAL, '169.254.1.x'))])); expect(linkLocal(engine.devices)).toEqual(linkLocal(java.devices)); }); } it('builds the MACs as DhcpRun does', () => { expect(stableMacAddress('00000000-0000-0000-0000-000000000112')).toBe('02:00:00:00:01:12'); }); });