The page konfig changes the firewall at run time (into the snapshot, back into the project through projectWithSimulationChanges). KonfigCases feeds Java's plug-in directly (25 cases, de and en); RouterWebRun plays two stories over the network. Ten Java bugs named, not adopted. The web server answers a method not written GET or POST with 501, as Java does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
241 lines
11 KiB
TypeScript
241 lines
11 KiB
TypeScript
// 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-<story>.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<string, JavaFrame[]>;
|
|
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<string, Story> = {
|
|
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 </titel> (sw_firewallweblog_msg1);
|
|
* the engine writes </title>. 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<string, (java: JavaRun) => 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('</titel>', '</title>') ?? null;
|
|
const rows = (byCard: Record<string, (string | null)[][]> | 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, (text: string) => string> = {
|
|
ripRoutes: (text) => text.replace(/<td>-?\d+<\/td>(<td>[\d.]+<\/td><td><a )/g, '<td>validity</td>$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<string, (java: JavaRun) => number> = {
|
|
konfigBlock: (java) => java.events.find(([, who, text]) => who === 'browser' && text.startsWith('page 0'))![0] - 250,
|
|
};
|
|
|
|
type Lines = Record<Program, [number, string][]>;
|
|
|
|
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 = <E>(log: readonly LogLine<E>[], 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);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
});
|