// The pages of routers and home routers as Java FILIUS serves them. // tools/java-oracle's RouterWebRun let a host's browser ask a router's or // home router's web server for its index page, its routing table and its // firewall log (routerweb-java-.java.json). The engine plays the same // stories: each card's frames must come in the same order, with the same // content and rows, at about the same time; the server's lines and the // browser's pages likewise. The pages are Java's German ones. // // The router's server switches itself on when the simulation starts (Java // writes so into its window): lines from before the story's first call are // not compared. Java's router server sometimes never listens (setAktiv wakes // its thread only when it already waits), and takes no initial sequence // number then; the engine's always listens: sequence numbers are compared // below the millions. The settings page (konfig) changes the firewall at run // time: a ping passes once "drop ICMP" is saved off; a rule moved above the // one that accepts the browser shuts it out. import { readFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; import type { LogLine, NetworkRuntimeEngine } from '../src/index.js'; import { expectSameCaptures, GAP_TOLERANCE_MILLISECONDS, watchedEngine, withClientPortText, type JavaFrame, type JavaTables } from './javaRun.js'; import { browserText, isPolled, POLL_MILLISECONDS, serverText } from './programLines.js'; import { snapshotOf, type Scenario } from './scenario.js'; const scenarioDirectory = join(dirname(fileURLToPath(import.meta.url)), 'scenarios'); const id = (suffix: number) => `00000000-0000-0000-0000-${String(suffix).padStart(12, '0')}`; type Program = 'server' | 'browser'; interface JavaRun extends JavaTables { frames: Record; events: [number, string, string][]; } interface Story { readonly scenario: string; readonly browser: string; readonly router: string; readonly acts: readonly [number, (engine: NetworkRuntimeEngine, browser: string) => void][]; } const open = (typed: string) => (engine: NetworkRuntimeEngine, browser: string): void => { engine.webBrowser(browser)!.open(typed); }; const post = (typed: string, data: string) => (engine: NetworkRuntimeEngine, browser: string): void => { engine.webBrowser(browser)!.open(typed, data); }; const ping = (engine: NetworkRuntimeEngine, browser: string): void => { engine.sendIcmpEcho(browser, '20.0.0.2', { sequenceNumber: 1 }); }; /** The form as Java's browser window posts it: every field, then the button pressed. */ const FORM = { saveWithoutIcmp: 'firewallActivate=1&onlySYN=1&btnSave=speichern&defaultPolicy=accept&srcIP=&srcMask=&destIP=&destMask=&protocol=*&port=&action=drop', addTcpDrop: 'firewallActivate=1&dropICMP=1&onlySYN=1&defaultPolicy=accept&srcIP=&srcMask=&destIP=&destMask=&protocol=TCP&port=80&action=drop&addrule=hinzuf%C3%BCgen', moveSecondUp: 'firewallActivate=1&dropICMP=1&onlySYN=1&defaultPolicy=accept&radioRule=2&moveup=rauf&srcIP=&srcMask=&destIP=&destMask=&protocol=*&port=&action=drop', }; const STORIES: Record = { routerIndex: { scenario: 'router-web', browser: id(301), router: id(302), acts: [[500, open('10.0.0.1')]] }, routerRoutes: { scenario: 'router-web', browser: id(301), router: id(302), acts: [[500, open('10.0.0.1/routes')]] }, routerKonfig: { scenario: 'router-web', browser: id(301), router: id(302), acts: [[500, open('10.0.0.1/konfig')]] }, konfigIcmp: { scenario: 'router-web', browser: id(301), router: id(302), acts: [ [500, ping], [3500, post('10.0.0.1/konfig', FORM.saveWithoutIcmp)], [7000, ping], ], }, konfigBlock: { scenario: 'router-web', browser: id(301), router: id(302), acts: [ [500, post('10.0.0.1/konfig', FORM.addTcpDrop)], [4000, post('10.0.0.1/konfig', FORM.moveSecondUp)], [8000, open('10.0.0.1')], ], }, routerLog: { scenario: 'router-web', browser: id(301), router: id(302), acts: [ [500, ping], [3500, open('10.0.0.1')], [7000, open('10.0.0.1/log')], ], }, gatewayIndex: { scenario: 'nat-home', browser: id(501), router: id(502), acts: [ [500, open('192.168.0.1')], [4000, open('192.168.0.1/routes')], ], }, gatewayFromWan: { scenario: 'nat-home', browser: id(504), router: id(502), acts: [[500, open('42.0.0.10')]] }, ripRoutes: { scenario: 'rip-chain', browser: id(401), router: id(402), acts: [[40_000, open('10.0.1.1/routes')]] }, }; /** Sequence numbers below the millions: which socket drew which initial number differs (see above). */ const withoutInitialNumbers = (text: string) => withClientPortText(text) .replace(/(seq|ack)=(\d+)/g, (_, name: string, value: string) => `${name}=${Number(value) % 1_000_000}`) .replace(/(SEQ|ACK): \d+\.(\d{3}\.\d{3})/g, '$1: n.$2'); /** Java's wait for a SYN's answer (TCPSocket: three times 2.75 s, its timeout here). */ const JAVA_TIMEOUT_MILLISECONDS = 3 * 2_750; /** * Where Java errs: its run corrected, as Java means it. routerLog: the * German log page closes its title with (sw_firewallweblog_msg1); * the engine writes . konfigBlock: the browser's SYN, dropped by the * firewall, goes once in Java, not three times (webJava.test.ts, noServer); * the page "server not reachable" comes two attempts later in the engine. */ const JAVA_BUGS: Record JavaRun> = { konfigBlock: (java) => ({ ...java, events: java.events.map(([time, who, text]) => (who === 'browser' && text.startsWith('page 0') ? [time + 2 * JAVA_TIMEOUT_MILLISECONDS, who, text] : [time, who, text])), }), routerLog: (java) => { const fixed = (text: string | null) => text?.replace('', '') ?? null; const rows = (byCard: Record | undefined) => Object.fromEntries(Object.entries(byCard ?? {}).map(([card, cardRows]) => [card, cardRows.map((row) => row.map(fixed))])); return { ...java, events: java.events.map(([time, who, text]) => [time, who, fixed(text)!]), rows: rows(java.rows), layeredRows: rows(java.layeredRows) }; }, }; /** * ripRoutes: how long a learned route has left. Java's routers start one * after the other and draw their beacons' pauses in another order (rip-chain * in ripJava.test.ts: R2 sends three times at the start, not twice); a * neighbour's beacon refreshes the route some seconds apart. Compared as a * placeholder; the page's other cells exactly. */ const PAGE_PLACEHOLDERS: Record string> = { ripRoutes: (text) => text.replace(/-?\d+<\/td>([\d.]+<\/td>validity$1'), }; /** Where the frames are compared only up to a time (Java's). konfigBlock: the engine's second SYN, which Java does not send. */ const UNTIL: Record number> = { konfigBlock: (java) => java.events.find(([, who, text]) => who === 'browser' && text.startsWith('page 0'))![0] - 250, }; type Lines = Record; function javaLines(java: JavaRun): Lines { const lines: Lines = { server: [], browser: [] }; const firstCall = Math.min(...java.events.filter(([, , text]) => text.startsWith('call ')).map(([time]) => time)); for (const [time, program, text] of java.events) { if (program !== 'server' && program !== 'browser') continue; if (time < firstCall || text === 'null' || text.startsWith('call ')) continue; const page = /^page (\d+) (\S+)\n/.exec(text); lines[program].push([time, withClientPortText(page !== null && page[1] !== '200' ? `page ${page[1]} ${page[2]}` : text)]); } return lines; } function engineLines(engine: NetworkRuntimeEngine, story: Story, firstCall: number): Lines { const lines = (log: readonly LogLine[], text: (entry: E) => string | undefined): [number, string][] => log.flatMap((line) => { const each = line.timeMilliseconds < firstCall ? undefined : text(line); return each === undefined ? [] : [[line.timeMilliseconds, withClientPortText(each)] as [number, string]]; }); return { server: lines(engine.webServer(story.router)!.log.all, serverText), browser: lines(engine.webBrowser(story.browser)!.log.all, browserText), }; } describe('the pages of routers and home routers as Java FILIUS serves them', () => { it('corrects Java only where it errs, and puts in placeholders only where they change something', () => { for (const [name, correct] of Object.entries(JAVA_BUGS)) { const java = JSON.parse(readFileSync(join(scenarioDirectory, `routerweb-java-${name}.java.json`), 'utf8')) as JavaRun; expect(JSON.stringify(correct(java)), name).not.toBe(JSON.stringify(java)); } for (const [name, placeholders] of Object.entries(PAGE_PLACEHOLDERS)) { const text = readFileSync(join(scenarioDirectory, `routerweb-java-${name}.java.json`), 'utf8'); expect(placeholders(text), name).not.toBe(text); } }); for (const [name, story] of Object.entries(STORIES)) { it(name, () => { const measured = JSON.parse(readFileSync(join(scenarioDirectory, `routerweb-java-${name}.java.json`), 'utf8')) as JavaRun; const java = JAVA_BUGS[name]?.(measured) ?? measured; const placeholders = PAGE_PLACEHOLDERS[name] ?? ((text: string) => text); const scenario = JSON.parse(readFileSync(join(scenarioDirectory, `${story.scenario}.scenario.json`), 'utf8')) as Scenario; const snapshot = { ...snapshotOf(scenario), webBrowserNodeIds: new Set([story.browser]) }; const { engine } = watchedEngine(scenario, snapshot); for (const [time, act] of story.acts) { engine.handle({ type: 'advance', toMilliseconds: time }); act(engine, story.browser); } engine.handle({ type: 'advance', toMilliseconds: Math.max(...java.events.map(([time]) => time)) + 5_000 }); const firstCall = story.acts[0]![0]; // Before the story, only what the network does by itself (RIP); compared from the first call. expectSameCaptures(engine, scenario, java.frames, { text: withoutInitialNumbers, cell: (text) => placeholders(withoutInitialNumbers(text)), tables: java, from: firstCall, until: UNTIL[name]?.(measured), starts: { java: 0, engine: 0 }, }); const withPlaceholders = (lines: Lines): Lines => ({ server: lines.server.map(([time, text]) => [time, placeholders(text)]), browser: lines.browser.map(([time, text]) => [time, placeholders(text)]), }); const expected = withPlaceholders(javaLines(java)); const actual = withPlaceholders(engineLines(engine, story, firstCall)); for (const program of ['server', 'browser'] as const) { expect(actual[program].map(([, text]) => text), `${program}'s lines`).toEqual(expected[program].map(([, text]) => text)); for (const [index, [javaTime, text]] of expected[program].entries()) { const late = javaTime - actual[program][index]![0]; const most = (isPolled(text) ? POLL_MILLISECONDS : 0) + GAP_TOLERANCE_MILLISECONDS; expect(late, `${program}'s line ${JSON.stringify(text.slice(0, 60))}`).toBeGreaterThanOrEqual(-GAP_TOLERANCE_MILLISECONDS); expect(late, `${program}'s line ${JSON.stringify(text.slice(0, 60))}`).toBeLessThanOrEqual(most); } } }); } });