TerminalRun adds nine stories: traceroute in the LAN, through the router, unreachable, without a route, to no one, to the host itself, errors and Ctrl+C; tcpdump on two hosts. traceroute counts failures across hops as Java does and takes an error that carries its probe; tcpdump tells each frame's highest capture row with its time of day, at once. Departures from Java: traceroute tells its summary before the end mark (Java after it, where its window shows no text) and calls a host at the 20th hop reached. arpsend is not built. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
591 lines
30 KiB
TypeScript
591 lines
30 KiB
TypeScript
// The command line's network commands as Java FILIUS runs them.
|
|
// tools/java-oracle's TerminalRun typed lines into FILIUS' command line on
|
|
// the hosts of terminal-net at set times and wrote every frame each card
|
|
// saw, Java's tables of them, and everything each command line told its
|
|
// window (terminal-java-<story>.java.json). The engine types the same lines
|
|
// at the same times (apps/terminal.ts): each card's frames in the same
|
|
// order, with the same content and rows, at about the same time; the same
|
|
// texts and busy marks, at about the same time.
|
|
|
|
import { readFileSync } from 'node:fs';
|
|
import { dirname, join } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { describe, expect, it } from 'vitest';
|
|
import { NetworkRuntimeEngine, withSnapshot } from '../src/index.js';
|
|
import { expectSameCaptures, GAP_TOLERANCE_MILLISECONDS, watchedEngine, withClientPortText, type JavaFrame, type JavaTables, type SeenFrame } from './javaRun.js';
|
|
import { POLL_MILLISECONDS } from './programLines.js';
|
|
import { snapshotOf, type Scenario } from './scenario.js';
|
|
|
|
const scenarioDirectory = join(dirname(fileURLToPath(import.meta.url)), 'scenarios');
|
|
|
|
const CLIENT = '00000000-0000-0000-0000-000000000401';
|
|
const DNS = '00000000-0000-0000-0000-000000000402';
|
|
const WEB = '00000000-0000-0000-0000-000000000403';
|
|
const ECHO = '00000000-0000-0000-0000-000000000404';
|
|
const FAR = '00000000-0000-0000-0000-000000000406';
|
|
const HOSTS = [CLIENT, DNS, WEB, ECHO, FAR];
|
|
|
|
type Told = ['text' | 'busy', string];
|
|
|
|
interface JavaRun extends JavaTables {
|
|
frames: Record<string, JavaFrame[]>;
|
|
acts: [number, string, string][];
|
|
/** time, host, kind (call, text or busy), text */
|
|
events: [number, string, string, string][];
|
|
}
|
|
|
|
const STORIES = [
|
|
'pingLan',
|
|
'pingRouted',
|
|
'pingNobody',
|
|
'pingUnreachable',
|
|
'pingBroadcast',
|
|
'pingSelf',
|
|
'pingErrors',
|
|
'interrupt',
|
|
'dns',
|
|
'dnsTimeout',
|
|
'arp',
|
|
'netstat',
|
|
'traceLan',
|
|
'traceRouted',
|
|
'traceUnreachable',
|
|
'traceNoRoute',
|
|
'traceNobody',
|
|
'traceSelf',
|
|
'traceErrors',
|
|
'traceInterrupt',
|
|
'tcpdump',
|
|
] as const;
|
|
|
|
type Lines = Record<string, [number, Told][]>;
|
|
|
|
function javaLines(java: JavaRun): Lines {
|
|
const lines: Lines = Object.fromEntries(HOSTS.map((host) => [host, []]));
|
|
for (const [time, host, kind, text] of java.events) {
|
|
if (kind === 'call') continue;
|
|
lines[host]!.push([time, [kind as Told[0], text]]);
|
|
}
|
|
return lines;
|
|
}
|
|
|
|
function engineLines(engine: NetworkRuntimeEngine): Lines {
|
|
return Object.fromEntries(
|
|
HOSTS.map((host) => [
|
|
host,
|
|
engine.terminal(host)!.log.all.map((line): [number, Told] => [line.timeMilliseconds, line.kind === 'text' ? ['text', line.text] : ['busy', String(line.busy)]]),
|
|
]),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* A text with placeholders: a client's port; a ping's time (compared on its
|
|
* own); the rows of an ARP or netstat table sorted (Java lists them in a
|
|
* HashMap's order, the engine in the order learned, or made).
|
|
*/
|
|
function lineText(text: string): string {
|
|
const table = /^(\| [^\n]*\|\n-+\n)((?:\|[^\n]*\n)*)(-+\n)?$/.exec(text);
|
|
// Sorted within each protocol of netstat's, the protocols in the order they come (TCP, then UDP, in Java too).
|
|
const rows = table?.[2]!.split('\n').filter(Boolean) ?? [];
|
|
const protocols = [...new Set(rows.map((row) => /^\| (TCP|UDP) /.exec(row)?.[1] ?? ''))];
|
|
const protocol = (row: string) => protocols.indexOf(/^\| (TCP|UDP) /.exec(row)?.[1] ?? '');
|
|
rows.sort((lhs, rhs) => protocol(lhs) - protocol(rhs) || (lhs < rhs ? -1 : lhs > rhs ? 1 : 0));
|
|
const sorted = table === null ? text : `${table[1]}${rows.join('\n')}\n${table[3] ?? ''}`;
|
|
// tcpdump: a frame's time of day, a ping's identifier, a DNS query's ID.
|
|
return withClientPortText(sorted)
|
|
.replace(/time=\d+ms/, 'time=…ms')
|
|
.replace(/^\n {2}\d{2}:\d{2}:\d{2}\.\d{3} /, '\n <time> ')
|
|
.replace(/Identifier: \d+/, 'Identifier: x')
|
|
.replace(/ID=\d+/, 'ID=x');
|
|
}
|
|
|
|
const pingTime = (text: string) => Number(/time=(\d+)ms/.exec(text)?.[1] ?? 0);
|
|
|
|
/** The identifier a ping draws at random (Java: new Random()). */
|
|
const cellText = (text: string) => text.replace(/Identifier: \d+/, 'Identifier: x').replace(/identifier=\d+/, 'identifier=x');
|
|
|
|
/** Java's run with what each line told replaced where Java errs. */
|
|
function withTold(java: JavaRun, change: (host: string, text: string, time: number) => string | undefined): JavaRun {
|
|
return {
|
|
...java,
|
|
events: java.events.flatMap(([time, host, kind, text]): [number, string, string, string][] => {
|
|
if (kind !== 'text') return [[time, host, kind, text]];
|
|
const changed = change(host, text, time);
|
|
return changed === undefined ? [] : [[time, host, kind, changed]];
|
|
}),
|
|
};
|
|
}
|
|
|
|
/** The answers to an echo from `from` to the client, in Java's frames and rows, as from `to`. */
|
|
function withEchoReplySource(java: JavaRun, from: string, to: string, after: number): JavaRun {
|
|
const frames = Object.fromEntries(
|
|
Object.entries(java.frames).map(([card, list]) => [
|
|
card,
|
|
list.map((frame) => (frame.icmp === 0 && frame.sourceIp === from && frame.time >= after ? { ...frame, sourceIp: to } : frame)),
|
|
]),
|
|
);
|
|
const replies = (card: string) =>
|
|
new Set(java.frames[card]!.flatMap((frame, index) => (frame.icmp === 0 && frame.sourceIp === from && frame.time >= after ? [String(index + 1)] : [])));
|
|
const rows = (tables: Record<string, (string | null)[][]> | undefined) =>
|
|
tables === undefined
|
|
? undefined
|
|
: Object.fromEntries(
|
|
Object.entries(tables).map(([card, list]) => [card, list.map((row) => (replies(card).has(row[0]!) && row[1] === from ? [row[0]!, to, ...row.slice(2)] : row))]),
|
|
);
|
|
return { ...java, frames, rows: rows(java.rows), layeredRows: rows(java.layeredRows) };
|
|
}
|
|
|
|
/** Java's tables with each cell changed. */
|
|
function withCells(java: JavaRun, change: (cell: string) => string): JavaRun {
|
|
const tables = (list: Record<string, (string | null)[][]> | undefined) =>
|
|
list === undefined
|
|
? undefined
|
|
: Object.fromEntries(Object.entries(list).map(([card, rows]) => [card, rows.map((row) => row.map((cell) => (cell === null ? null : change(cell))))]));
|
|
return { ...java, rows: tables(java.rows), layeredRows: tables(java.layeredRows) };
|
|
}
|
|
|
|
/** traceroute: the summary told before the end mark (Java tells it after, where its window shows no more text). */
|
|
function withSummaryBeforeEnd(java: JavaRun): JavaRun {
|
|
const events = [...java.events];
|
|
for (let index = 0; index + 1 < events.length; index += 1) {
|
|
const [time, host, kind, text] = events[index]!;
|
|
const next = events.findIndex((each, at) => at > index && each[1] === host);
|
|
if (kind !== 'busy' || text !== 'false' || next === -1 || events[next]![2] !== 'text' || !events[next]![3].startsWith('\n\n')) continue;
|
|
const summary = events[next]!;
|
|
events.splice(next, 1);
|
|
events.splice(index, 0, [time, host, 'text', summary[3]]);
|
|
index += 1;
|
|
}
|
|
return { ...java, events };
|
|
}
|
|
|
|
const USAGE_PING = 'ping - test connection to other computer\nUsage: ping <remote host> \n <remote host> the remote address as IP address or host name\n';
|
|
|
|
/**
|
|
* Where Java errs: its run corrected, as Java means it.
|
|
* - Java's ping names its first parameter where it means the host (-b in the
|
|
* statistics of ping -b), and takes any first word for -b (ping -x pings).
|
|
* - An echo's answer comes from the address asked (RFC 792: the addresses
|
|
* reversed): Java answers from 127.0.0.1 when the host's own address is
|
|
* asked, from the near card when a router's far address is.
|
|
* - netstat on a UDP server lists its listening socket only: Java also lists
|
|
* a socket for each address and port it ever answered (never closed).
|
|
* - A router's network unreachable carries the echo as it would have
|
|
* forwarded it, with its sender (Java's is half made: sender null).
|
|
* - traceroute tells its summary before the end mark (withSummaryBeforeEnd);
|
|
* the host unreachable a host gives itself comes from its address (Java:
|
|
* 127.0.0.1, as its answer to itself).
|
|
*/
|
|
const JAVA_BUGS: Partial<Record<(typeof STORIES)[number], (java: JavaRun) => JavaRun>> = {
|
|
pingBroadcast: (java) =>
|
|
withTold(java, (host, text, time) => {
|
|
const target = host === CLIENT && time >= 7_000 ? '255.255.255.255' : '192.168.0.255';
|
|
return text.replace('--- -b ', `--- ${target} `).replace('From -b (', `From ${target} (`);
|
|
}),
|
|
pingErrors: (java) => {
|
|
const typed = java.acts.find(([, , line]) => line.startsWith('ping -x'))![0];
|
|
const corrected = withTold(java, (_, text, time) => (time < typed ? text : undefined));
|
|
return {
|
|
...corrected,
|
|
frames: Object.fromEntries(Object.entries(java.frames).map(([card, list]) => [card, list.filter((frame) => frame.time < typed)])),
|
|
events: [...corrected.events.filter(([time, , kind]) => time < typed || kind !== 'busy'), [typed, CLIENT, 'text', USAGE_PING]],
|
|
};
|
|
},
|
|
pingSelf: (java) => withTold(java, (_, text, time) => (time < 6_000 ? text.replace('From 127.0.0.1:', 'From 192.168.0.20:') : text)),
|
|
pingRouted: (java) => {
|
|
const typed = java.acts.find(([, , line]) => line === 'ping 10.0.0.1')![0];
|
|
return withEchoReplySource(
|
|
withTold(java, (_, text, time) => (time >= typed ? text.replace('From 192.168.0.1:', 'From 10.0.0.1:') : text)),
|
|
'192.168.0.1',
|
|
'10.0.0.1',
|
|
typed,
|
|
);
|
|
},
|
|
pingUnreachable: (java) => withCells(java, (cell) => cell.replace('sender=null;', 'sender=192.168.0.20;')),
|
|
traceLan: withSummaryBeforeEnd,
|
|
traceRouted: (java) => {
|
|
const typed = java.acts.find(([, , line]) => line === 'traceroute 10.0.0.1')![0];
|
|
return withEchoReplySource(
|
|
withSummaryBeforeEnd(withTold(java, (_, text, time) => (time >= typed && text === '192.168.0.1' ? '10.0.0.1' : text))),
|
|
'192.168.0.1',
|
|
'10.0.0.1',
|
|
typed,
|
|
);
|
|
},
|
|
traceUnreachable: withSummaryBeforeEnd,
|
|
traceNoRoute: (java) => withSummaryBeforeEnd(withCells(java, (cell) => cell.replace('sender=null;', 'sender=192.168.0.20;'))),
|
|
traceNobody: (java) => withSummaryBeforeEnd(withTold(java, (_, text) => text.replace('127.0.0.1', '192.168.0.20'))),
|
|
traceSelf: (java) => withSummaryBeforeEnd(withTold(java, (_, text, time) => (time < 1_500 ? text.replace('127.0.0.1', '192.168.0.20') : text))),
|
|
traceInterrupt: withSummaryBeforeEnd,
|
|
netstat: (java) => withTold(java, (host, text) => (host === DNS ? text.replace(/\| UDP {5}\| {4}192\.168\.0\.10:53 {4}\|[^\n]*CONNECTED +\|\n/g, '') : text)),
|
|
};
|
|
|
|
/**
|
|
* Where the frames are compared only up to a time (Java's): pingErrors, the
|
|
* ping Java sends for ping -x; traceUnreachable, from the sixth probe on:
|
|
* Java's router logs it 2.5 s late. Its ICMP thread waits for ARP inside
|
|
* the lock of its buffer (ProtokollThread.run), the card's thread blocks on
|
|
* handing it the next frame, and the frames behind are read, and logged,
|
|
* only when ARP gives up; the engine logs a frame when it comes. (The texts
|
|
* are the same either way.)
|
|
*/
|
|
/** traceroute's sixth probe: its numbers count on from 42 * 23. */
|
|
const TRACEROUTE_SIXTH_PROBE = 42 * 23 + 6;
|
|
|
|
const UNTIL: Partial<Record<(typeof STORIES)[number], (java: JavaRun) => number>> = {
|
|
pingErrors: (java) => java.acts.find(([, , line]) => line.startsWith('ping -x'))![0],
|
|
traceUnreachable: (java) => java.frames[CLIENT]!.find((frame) => frame.icmp === 8 && frame.sequence === TRACEROUTE_SIXTH_PROBE)!.time + 50,
|
|
};
|
|
|
|
/** The hosts that answer a broadcast echo at once: which answer comes first is a race of Java's threads. */
|
|
const BROADCAST_ANSWER = /From 192\.168\.0\.(1|10|30|31): icmp_seq/;
|
|
|
|
/**
|
|
* pingBroadcast: the hosts answer each broadcast echo at once, and the order
|
|
* of their ARP requests and answers on the cables is a race of Java's
|
|
* threads. Its frames are compared second by second (each ping's), as a
|
|
* set; Java's tables, which go frame by frame, are left out.
|
|
*/
|
|
const RACING = new Set(['pingBroadcast']);
|
|
const bySecond = (frames: SeenFrame[]): SeenFrame[] =>
|
|
frames
|
|
.map((frame) => ({ time: Math.floor((frame.time - 900) / 1_000) * 1_000 + 900, text: frame.text }))
|
|
.sort((lhs, rhs) => lhs.time - rhs.time || lhs.text.localeCompare(rhs.text));
|
|
|
|
describe('the command line on the network as Java FILIUS runs it', () => {
|
|
it('corrects Java only where it errs, and cuts only after the story began', () => {
|
|
for (const [name, correct] of Object.entries(JAVA_BUGS)) {
|
|
const java = JSON.parse(readFileSync(join(scenarioDirectory, `terminal-java-${name}.java.json`), 'utf8')) as JavaRun;
|
|
expect(JSON.stringify(correct(java)), name).not.toBe(JSON.stringify(java));
|
|
expect(UNTIL[name as (typeof STORIES)[number]]?.(java) ?? Number.POSITIVE_INFINITY, name).toBeGreaterThan(1_000);
|
|
}
|
|
});
|
|
|
|
// ICMPThread.waitForIcmpResponse misses its wake-up when the answer is there before it waits (a race of Java's
|
|
// threads: an echo to the host itself, the host unreachable a host gives itself), and waits a whole round trip.
|
|
// The engine answers at once; a recording with a missed wake-up is made again (tools/java-oracle/run.sh).
|
|
it("was recorded without a missed wake-up of Java's", () => {
|
|
const told = (name: string) =>
|
|
(JSON.parse(readFileSync(join(scenarioDirectory, `terminal-java-${name}.java.json`), 'utf8')) as JavaRun).events.filter(([, , kind]) => kind === 'text');
|
|
for (const [, , , text] of told('pingSelf')) expect(pingTime(text), `pingSelf: ${JSON.stringify(text)}`).toBeLessThan(1_000);
|
|
const timeouts = told('pingNobody').filter(([, , , text]) => text.includes('Timeout!'));
|
|
for (const [index, [time, , , text]] of timeouts.slice(1).entries()) {
|
|
if (text.includes('icmp_seq=1 ')) continue;
|
|
expect(time - timeouts[index]![0], 'pingNobody: two ARP round trips per echo').toBeLessThan(6_000);
|
|
}
|
|
});
|
|
|
|
const scenario = JSON.parse(readFileSync(join(scenarioDirectory, 'terminal-net.scenario.json'), 'utf8')) as Scenario;
|
|
|
|
for (const name of STORIES) {
|
|
it(name, () => {
|
|
const measured = JSON.parse(readFileSync(join(scenarioDirectory, `terminal-java-${name}.java.json`), 'utf8')) as JavaRun;
|
|
const java = JAVA_BUGS[name]?.(measured) ?? measured;
|
|
const snapshot = withSnapshot(snapshotOf(scenario), {
|
|
language: 'de',
|
|
terminalNodeIds: new Set(HOSTS),
|
|
...(name === 'netstat' || name === 'tcpdump'
|
|
? {
|
|
echoServersByNodeId: new Map([
|
|
[ECHO, { port: 55555, isActive: true }],
|
|
[DNS, { port: 55555, isActive: true }],
|
|
]),
|
|
simpleClientsByNodeId: new Map([[CLIENT, { targetAddress: '', targetPort: 55555 }]]),
|
|
}
|
|
: {}),
|
|
});
|
|
const { engine } = watchedEngine(scenario, snapshot);
|
|
const acts: [number, () => void][] = java.acts.map(([time, host, line]) => [
|
|
time,
|
|
() => {
|
|
if (line === '^C') engine.terminal(host)!.interrupt();
|
|
else if (line === 'disconnect') engine.simpleClient(CLIENT)!.disconnect();
|
|
else if (line === 'connect') engine.simpleClient(CLIENT)!.connect('192.168.0.31', 55555);
|
|
else engine.terminal(host)!.enter(line);
|
|
},
|
|
]);
|
|
if (name === 'netstat') acts.unshift([500, () => engine.simpleClient(CLIENT)!.connect('192.168.0.31', 55555)]);
|
|
for (const [time, act] of acts) {
|
|
engine.handle({ type: 'advance', toMilliseconds: time });
|
|
act();
|
|
}
|
|
engine.handle({ type: 'advance', toMilliseconds: Math.max(...java.events.map(([time]) => time)) + 5_000 });
|
|
|
|
const racing = RACING.has(name);
|
|
expectSameCaptures(engine, scenario, java.frames, {
|
|
text: (text) => withClientPortText(text).replace(/ID=\d+/g, 'ID=x'),
|
|
...(racing ? { normalized: bySecond, starts: { java: 0, engine: 0 } } : { tables: java }),
|
|
cell: cellText,
|
|
until: UNTIL[name]?.(measured),
|
|
});
|
|
if (racing) {
|
|
// ICMP.sendBroadcast: with TTL 1, to every card.
|
|
const echoes = engine.capture.frames(scenario.nodes.find((node) => node.id === CLIENT)!.ports[0]!.id).flatMap(({ frame }) => {
|
|
const packet = frame.payload.kind === 'ipv4' ? frame.payload.packet : undefined;
|
|
return packet?.payload.kind === 'icmp' && packet.payload.message.kind === 'echoRequest' ? [[frame.destinationMacAddress, packet.timeToLive]] : [];
|
|
});
|
|
expect(echoes).toEqual(Array.from({ length: 8 }, () => ['FF:FF:FF:FF:FF:FF', 1]));
|
|
}
|
|
|
|
const expected = javaLines(java);
|
|
const actual = engineLines(engine);
|
|
const told = ([kind, text]: Told): Told => [kind, lineText(name === 'pingBroadcast' ? text.replace(BROADCAST_ANSWER, 'From <broadcast>: icmp_seq') : text)];
|
|
for (const host of HOSTS) {
|
|
expect(
|
|
actual[host]!.map(([, line]) => told(line)),
|
|
`${host}'s lines`,
|
|
).toEqual(expected[host]!.map(([, line]) => told(line)));
|
|
for (const [index, [javaTime, line]] of expected[host]!.entries()) {
|
|
const [engineTime, engineLine] = actual[host]![index]!;
|
|
const late = javaTime - engineTime;
|
|
expect(Math.abs(late), `${host}'s line ${JSON.stringify(line[1].slice(0, 60))}: Java ${late} ms later`).toBeLessThanOrEqual(
|
|
// tcpdump: Java looks for new frames every 100 ms.
|
|
GAP_TOLERANCE_MILLISECONDS + (name === 'tcpdump' ? POLL_MILLISECONDS : 0),
|
|
);
|
|
expect(Math.abs(pingTime(line[1]) - pingTime(engineLine[1])), `${host}'s ping time in ${JSON.stringify(line[1])}`).toBeLessThanOrEqual(GAP_TOLERANCE_MILLISECONDS);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
});
|
|
|
|
describe('the command line on the network beyond the stories', () => {
|
|
const scenario = JSON.parse(readFileSync(join(scenarioDirectory, 'terminal-net.scenario.json'), 'utf8')) as Scenario;
|
|
const started = () => watchedEngine(scenario, withSnapshot(snapshotOf(scenario), { language: 'de', terminalNodeIds: new Set(HOSTS) })).engine;
|
|
const texts = (engine: NetworkRuntimeEngine) => engine.terminal(CLIENT)!.log.all.flatMap((line) => (line.kind === 'text' ? [line.text] : []));
|
|
const echoIdentifiers = (engine: NetworkRuntimeEngine) =>
|
|
engine.capture.frames(scenario.nodes.find((node) => node.id === CLIENT)!.ports[0]!.id).flatMap(({ frame }) => {
|
|
const packet = frame.payload.kind === 'ipv4' ? frame.payload.packet : undefined;
|
|
return packet?.payload.kind === 'icmp' && packet.payload.message.kind === 'echoRequest' ? [packet.payload.message.identifier] : [];
|
|
});
|
|
|
|
it('sends no echo for a ping whose Ctrl+C came before it began', () => {
|
|
const engine = started();
|
|
engine.terminal(CLIENT)!.enter('host www.filius.de');
|
|
engine.terminal(CLIENT)!.enter('ping 192.168.0.31');
|
|
engine.terminal(CLIENT)!.interrupt();
|
|
engine.handle({ type: 'advance', toMilliseconds: 5_000 });
|
|
expect(texts(engine).slice(-2)).toEqual([
|
|
'PING 192.168.0.31 (192.168.0.31)',
|
|
'\n--- 192.168.0.31 Paketstatistik ---\n0 Paket(e) gesendet, 0 Paket(e) empfangen, 0% Paketverlust\n',
|
|
]);
|
|
expect(echoIdentifiers(engine)).toEqual([]);
|
|
});
|
|
|
|
it("counts no answer to another ping's echo, and waits out its second", () => {
|
|
const engine = started();
|
|
engine.terminal(CLIENT)!.enter('ping 192.168.0.31');
|
|
// An echo reply with the first echo's number, but another identifier (a multiple of 23 is drawn), first in the queue.
|
|
const reply = { kind: 'echoReply', identifier: 1, sequenceNumber: 1, data: new Uint8Array() } as const;
|
|
const packet = {
|
|
identity: 0,
|
|
senderIPAddress: '192.168.0.31',
|
|
receiverIPAddress: '192.168.0.20',
|
|
timeToLive: 64,
|
|
protocol: 'icmp',
|
|
payload: { kind: 'icmp', message: reply },
|
|
} as const;
|
|
const queue = engine.state.icmpObservationsByNodeId;
|
|
queue.set(CLIENT, [...(queue.get(CLIENT) ?? []), { nodeId: CLIENT, packet, message: reply }]);
|
|
engine.handle({ type: 'advance', toMilliseconds: 6_000 });
|
|
const lines = engine.terminal(CLIENT)!.log.all.flatMap((line) => (line.kind === 'text' ? [[line.timeMilliseconds, line.text] as const] : []));
|
|
expect(lines.map(([, text]) => text.replace(/time=\d+ms/, 'time=…ms'))).toEqual([
|
|
'PING 192.168.0.31 (192.168.0.31)',
|
|
'\nFrom 192.168.0.31: icmp_seq=2 ttl=64 time=…ms',
|
|
'\nFrom 192.168.0.31: icmp_seq=3 ttl=64 time=…ms',
|
|
'\nFrom 192.168.0.31: icmp_seq=4 ttl=64 time=…ms',
|
|
'\n--- 192.168.0.31 Paketstatistik ---\n4 Paket(e) gesendet, 3 Paket(e) empfangen, 25% Paketverlust\n',
|
|
]);
|
|
expect(lines[1]![0]).toBeGreaterThanOrEqual(1_000);
|
|
});
|
|
|
|
it('goes on with the next identifier, from one drawn at random', () => {
|
|
const engine = started();
|
|
engine.terminal(CLIENT)!.enter('ping 192.168.0.31');
|
|
engine.terminal(CLIENT)!.enter('ping 192.168.0.30');
|
|
engine.handle({ type: 'advance', toMilliseconds: 10_000 });
|
|
const identifiers = echoIdentifiers(engine);
|
|
expect(identifiers).toHaveLength(8);
|
|
expect(identifiers[0]! % 23).toBe(0);
|
|
expect(identifiers[0]!).toBeLessThanOrEqual(42 * 23);
|
|
expect(identifiers).toEqual([...Array(4).fill(identifiers[0]), ...Array(4).fill(identifiers[0]! + 1)]);
|
|
});
|
|
|
|
/** An ICMP message put first in the client's queue, as if it had come (an answer to another echo). */
|
|
const queueEchoReply = (engine: NetworkRuntimeEngine, sequenceNumber: number) => {
|
|
const reply = { kind: 'echoReply', identifier: 1, sequenceNumber, data: new Uint8Array() } as const;
|
|
const packet = {
|
|
identity: 0,
|
|
senderIPAddress: '192.168.0.31',
|
|
receiverIPAddress: '192.168.0.20',
|
|
timeToLive: 64,
|
|
protocol: 'icmp',
|
|
payload: { kind: 'icmp', message: reply },
|
|
} as const;
|
|
const queue = engine.state.icmpObservationsByNodeId;
|
|
queue.set(CLIENT, [...(queue.get(CLIENT) ?? []), { nodeId: CLIENT, packet, message: reply }]);
|
|
};
|
|
|
|
it('takes an echo reply with another number for a timeout, and sends the next echo at once', () => {
|
|
const engine = started();
|
|
engine.terminal(CLIENT)!.enter('ping 192.168.0.31');
|
|
queueEchoReply(engine, 9);
|
|
engine.handle({ type: 'advance', toMilliseconds: 6_000 });
|
|
const lines = engine.terminal(CLIENT)!.log.all.flatMap((line) => (line.kind === 'text' ? [[line.timeMilliseconds, line.text] as const] : []));
|
|
expect(lines[1]![1]).toBe('\nFrom 192.168.0.31 (192.168.0.31): icmp_seq=1 -- Timeout!');
|
|
// The next echo goes at once, and the late answer to the first comes first: a timeout too.
|
|
expect(lines[2]![1]).toBe('\nFrom 192.168.0.31 (192.168.0.31): icmp_seq=2 -- Timeout!');
|
|
expect(lines[2]![0] - lines[1]![0]).toBeLessThan(500);
|
|
});
|
|
|
|
it('takes the first ICMP message that came, not the last', () => {
|
|
const engine = started();
|
|
engine.terminal(CLIENT)!.enter('ping 192.168.0.99');
|
|
// First the answer to another echo, then (when ARP gives up) the host unreachable the client gives itself.
|
|
queueEchoReply(engine, 1);
|
|
engine.handle({ type: 'advance', toMilliseconds: 30_000 });
|
|
expect(texts(engine).slice(1)).toEqual([
|
|
'\nFrom 192.168.0.99 (192.168.0.99): icmp_seq=2 -- Timeout!',
|
|
'\nFrom 192.168.0.99 (192.168.0.99): icmp_seq=3 -- Timeout!',
|
|
'\nFrom 192.168.0.99 (192.168.0.99): icmp_seq=4 -- Timeout!',
|
|
'\n--- 192.168.0.99 Paketstatistik ---\n4 Paket(e) gesendet, 0 Paket(e) empfangen, 100% Paketverlust\n',
|
|
]);
|
|
});
|
|
|
|
it('rounds the loss (Math.round)', () => {
|
|
const engine = started();
|
|
engine.terminal(CLIENT)!.enter('ping 192.168.0.31');
|
|
queueEchoReply(engine, 1);
|
|
engine.handle({ type: 'advance', toMilliseconds: 1_000 });
|
|
queueEchoReply(engine, 2);
|
|
engine.handle({ type: 'advance', toMilliseconds: 2_500 });
|
|
engine.terminal(CLIENT)!.interrupt();
|
|
engine.handle({ type: 'advance', toMilliseconds: 6_000 });
|
|
expect(texts(engine).at(-1)).toBe('\n--- 192.168.0.31 Paketstatistik ---\n3 Paket(e) gesendet, 1 Paket(e) empfangen, 67% Paketverlust\n');
|
|
});
|
|
|
|
it("refuses the broadcast of the route's network, whatever its mask", () => {
|
|
const wide = { ...scenario, devices: { ...scenario.devices, [CLIENT]: { ...scenario.devices![CLIENT]!, mask: '255.255.0.0' } } } as Scenario;
|
|
const engine = watchedEngine(wide, withSnapshot(snapshotOf(wide), { language: 'de', terminalNodeIds: new Set([CLIENT]) })).engine;
|
|
engine.terminal(CLIENT)!.enter('ping 192.168.255.255');
|
|
expect(texts(engine)).toEqual(['Ping zu Broadcast-Adressen wird nicht unterstützt.']);
|
|
});
|
|
|
|
it('draws the first identifier at random', () => {
|
|
const drawn = new Set(Array.from({ length: 10 }, (_, seed) => new NetworkRuntimeEngine(seed).drawIcmpIdentifier()));
|
|
expect(drawn.size).toBeGreaterThan(1);
|
|
});
|
|
|
|
it('routes by the first of rows with the same mask (determineRouteFromStaticRoutingTable)', () => {
|
|
const snapshot = withSnapshot(snapshotOf(scenario), {
|
|
manualRoutesByNodeId: new Map([[CLIENT, [{ destinationNetwork: '192.168.0.0', subnetMask: '255.255.255.0', gateway: '192.168.0.99', interfaceIPAddress: '192.168.0.20' }]]]),
|
|
});
|
|
const engine = watchedEngine(scenario, snapshot).engine;
|
|
const rows = engine.forwardingTable(CLIENT).filter((row) => row.destinationNetwork === '192.168.0.0' && row.subnetMask === '255.255.255.0');
|
|
expect(rows).toHaveLength(2);
|
|
expect(engine.routeRow(CLIENT, '192.168.0.31')).toEqual(rows[0]);
|
|
});
|
|
|
|
/**
|
|
* 19 routers in a row, the host behind the last: 20 hops. Java says the
|
|
* host could not be reached (it asks ttl >= 20 after the loop, also when
|
|
* the 20th hop answered); measured only up to two hops (traceRouted).
|
|
*/
|
|
it('says a host was reached at the 20th hop', () => {
|
|
const id = (number: number) => `00000000-0000-0000-0000-${String(number).padStart(12, '0')}`;
|
|
const ROUTERS = 19;
|
|
const client = id(1);
|
|
const host = id(2);
|
|
const router = (index: number) => id(100 + index);
|
|
const port = (node: number, side: number) => id(1000 + node * 2 + side);
|
|
const nodes: Scenario['nodes'] = [
|
|
{ id: client, kind: 'pc', ports: [{ id: port(1, 0), label: 'eth0' }] },
|
|
{ id: host, kind: 'pc', ports: [{ id: port(2, 0), label: 'eth0' }] },
|
|
...Array.from({ length: ROUTERS }, (_, index) => ({
|
|
id: router(index + 1),
|
|
kind: 'router' as const,
|
|
ports: [
|
|
{ id: port(100 + index + 1, 0), label: 'eth0' },
|
|
{ id: port(100 + index + 1, 1), label: 'eth1' },
|
|
],
|
|
})),
|
|
];
|
|
const ends = [port(1, 0), ...Array.from({ length: ROUTERS }, (_, index) => [port(101 + index, 0), port(101 + index, 1)]).flat(), port(2, 0)];
|
|
const owner = (portId: string) => nodes.find((node) => node.ports.some((each) => each.id === portId))!.id;
|
|
const links: Scenario['links'] = Array.from({ length: ROUTERS + 1 }, (_, index) => ({
|
|
id: id(5000 + index),
|
|
sourceNode: owner(ends[2 * index]!),
|
|
sourcePort: ends[2 * index]!,
|
|
targetNode: owner(ends[2 * index + 1]!),
|
|
targetPort: ends[2 * index + 1]!,
|
|
}));
|
|
// Network k is 10.k.0.0/24; router i has 10.i.0.1 towards the client and 10.(i+1).0.2 towards the host.
|
|
const mask = '255.255.255.0';
|
|
const chain: Scenario = {
|
|
seed: 7,
|
|
nodes,
|
|
links,
|
|
devices: {
|
|
[client]: { ip: '10.1.0.10', mask, gateway: '10.1.0.1' },
|
|
[host]: { ip: `10.${ROUTERS + 1}.0.10`, mask, gateway: `10.${ROUTERS + 1}.0.2` },
|
|
...Object.fromEntries(Array.from({ length: ROUTERS }, (_, index) => [router(index + 1), { ip: `10.${index + 1}.0.1`, mask }])),
|
|
},
|
|
interfaces: Array.from({ length: ROUTERS }, (_, index) => [
|
|
{ node: router(index + 1), port: port(101 + index, 0), ip: `10.${index + 1}.0.1`, mask },
|
|
{ node: router(index + 1), port: port(101 + index, 1), ip: `10.${index + 2}.0.2`, mask },
|
|
]).flat(),
|
|
routes: Object.fromEntries(
|
|
Array.from({ length: ROUTERS }, (_, index) => {
|
|
const number = index + 1;
|
|
const back = number === 1 ? [] : [{ destination: '10.1.0.0', mask, gateway: `10.${number}.0.2`, interface: `10.${number}.0.1` }];
|
|
const on = number === ROUTERS ? [] : [{ destination: `10.${ROUTERS + 1}.0.0`, mask, gateway: `10.${number + 1}.0.1`, interface: `10.${number + 1}.0.2` }];
|
|
return [router(number), [...back, ...on]];
|
|
}),
|
|
),
|
|
};
|
|
const engine = watchedEngine(chain, withSnapshot(snapshotOf(chain), { language: 'de', terminalNodeIds: new Set([client]) })).engine;
|
|
engine.terminal(client)!.enter(`traceroute 10.${ROUTERS + 1}.0.10`);
|
|
engine.handle({ type: 'advance', toMilliseconds: 120_000 });
|
|
const told = engine.terminal(client)!.log.all.flatMap((line) => (line.kind === 'text' ? [line.text] : []));
|
|
expect(told.slice(-3)).toEqual([' 20 ', '10.20.0.10', '\n\n10.20.0.10 wurde nach 20 Sprüngen (hops) erreicht.']);
|
|
});
|
|
|
|
it("takes no echo reply of another identifier for traceroute's probe", () => {
|
|
const engine = started();
|
|
engine.terminal(CLIENT)!.enter('traceroute 192.168.0.31');
|
|
queueEchoReply(engine, 42 * 23 + 1);
|
|
engine.handle({ type: 'advance', toMilliseconds: 10_000 });
|
|
// The first probe gets the other reply: a star (and then the late answer to it comes first for the next).
|
|
expect(texts(engine).slice(1, 3)).toEqual([' 1 ', '* ']);
|
|
});
|
|
|
|
it('dumps only the frames from its start, each with its time of day', () => {
|
|
const scenario = JSON.parse(readFileSync(join(scenarioDirectory, 'terminal-net.scenario.json'), 'utf8')) as Scenario;
|
|
// The first frame comes about 110 ms after 3 s: at a time of day with two digits of milliseconds (Java pads to three).
|
|
const base = new Date(2026, 9, 2, 10, 37, 6, 900).getTime();
|
|
const engine = new NetworkRuntimeEngine(scenario.seed, undefined, { wallClock: () => base + engine.state.currentTimeMilliseconds });
|
|
engine.handle({
|
|
type: 'start',
|
|
snapshot: withSnapshot(snapshotOf(scenario), { language: 'de', terminalNodeIds: new Set(HOSTS) }),
|
|
seed: scenario.seed,
|
|
initialTimeMilliseconds: 0,
|
|
});
|
|
engine.terminal(CLIENT)!.enter('host www.filius.de');
|
|
engine.handle({ type: 'advance', toMilliseconds: 3_000 });
|
|
engine.terminal(ECHO)!.enter('tcpdump');
|
|
engine.terminal(CLIENT)!.enter('ping 192.168.0.31');
|
|
engine.handle({ type: 'advance', toMilliseconds: 3_900 });
|
|
const card = scenario.nodes.find((node) => node.id === ECHO)!.ports[0]!.id;
|
|
const first = engine.capture.frames(card).find((frame) => frame.timeMilliseconds >= 3_000)!;
|
|
const time = new Date(base + first.timeMilliseconds);
|
|
const two = (value: number) => String(value).padStart(2, '0');
|
|
expect(time.getMilliseconds()).toBeLessThan(100);
|
|
const expected = `${two(time.getHours())}:${two(time.getMinutes())}:${two(time.getSeconds())}.${String(time.getMilliseconds()).padStart(3, '0')}`;
|
|
const dumped = engine.terminal(ECHO)!.log.all.flatMap((line) => (line.kind === 'text' ? [line.text] : []));
|
|
// The ARP request of the lookup before is not dumped: the ping's ARP request first.
|
|
expect(dumped[1]).toBe(
|
|
`\n ${expected} ARP 192.168.0.20 > 192.168.0.31 Suche nach MAC f\u00fcr 192.168.0.31 [op=REQUEST, sender=02:00:00:00:04:11|192.168.0.20, target=FF:FF:FF:FF:FF:FF|192.168.0.31]`,
|
|
);
|
|
});
|
|
});
|