// 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). 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 (javaRun.ts), and // every host must end with the same addresses. // // 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 { stableMacAddress } from '../src/index.js'; import { expectSameCaptures, GAP_TOLERANCE_MILLISECONDS, javaFrames, watchedEngine, type JavaFrame, type SeenFrame, type JavaTables } from './javaRun.js'; import { type Scenario } from './scenario.js'; const scenarioDirectory = join(dirname(fileURLToPath(import.meta.url)), 'scenarios'); interface JavaRun extends JavaTables { frames: Record; devices: Record; } const LINK_LOCAL = /169\.254\.1\.\d+/g; /** The engine's run of the scenario's network, without its steps, for `milliseconds`. */ function runEngine(scenario: Scenario, milliseconds: number) { const { engine, frames } = watchedEngine(scenario); engine.handle({ type: 'advance', toMilliseconds: milliseconds }); const devices = Object.fromEntries( [...engine.state.topologySnapshot.deviceConfigurations].map(([node, device]) => [ node, [device.ipAddress, device.subnetMask, device.defaultGateway, device.dnsServer], ]), ); return { engine, frames: frames(), devices }; } /** 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.startsWith('dhcp-') && 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.frames), 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, ); } } // Each card's capture, with one client: of two that start together, Java's // log loses the DISCOVER of the one that sends second, and which sends // first varies (javaRun.ts). if (clients.length === 1) expectSameCaptures(engine.engine, scenario, java.frames, { text: (text) => text.replace(LINK_LOCAL, '169.254.1.x'), tables: java }); 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'); }); });