A web page by its DNS name (internetwork_komplett), an e-mail between two networks through NAT and MX relay (Internet_Komplett_..._DE), a file found and fetched with Gnutella across a router (zwei_rechnernetze_komplett): ExampleRun records frames, tables, program lines and end states; the core plays the same calls and matches card by card. Equal RIP ties take Java's choice, RIP beacons are left out, the search's two connections compared flow by flow. Also: a core test that an equal RIP route from another router neither replaces nor refreshes the route, and nine stale type errors in core tests fixed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
328 lines
16 KiB
TypeScript
328 lines
16 KiB
TypeScript
// What tools/java-oracle's DhcpRun and TcpRun recorded in Java FILIUS
|
|
// (<name>.java.json: every frame each card saw, FILIUS' own packet log), and
|
|
// the same view of an engine run: each frame when it first shows on any
|
|
// host's card, as a line of text. Shared by dhcpJava.test.ts and
|
|
// tcpJava.test.ts.
|
|
//
|
|
// Not only the frames of one card: FILIUS' log misses the DISCOVER of the
|
|
// DHCP 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; where they do not, it is Java's threads (a sender blocked
|
|
// by a busy cable wakes late, up to some 110 ms measured), the log's lost
|
|
// DISCOVER above, or a story whose calls the engine makes one after the
|
|
// other. A gap may differ by GAP_TOLERANCE_MILLISECONDS; a wrong round trip
|
|
// time (250 ms) may not. A cable carries one frame after the other in both
|
|
// (engine.test.ts checks that: one cable's delay is below this tolerance).
|
|
|
|
import { expect } from 'vitest';
|
|
import {
|
|
captureCommentText,
|
|
captureRows,
|
|
javaTextLength,
|
|
NetworkRuntimeEngine,
|
|
TCP_FLAG,
|
|
type CapturedFrame,
|
|
type CaptureLayer,
|
|
type EthernetFrame,
|
|
type TopologySnapshot,
|
|
} from '../src/index.js';
|
|
import { snapshotOf, type Scenario } from './scenario.js';
|
|
|
|
export const GAP_TOLERANCE_MILLISECONDS = 120;
|
|
const DUPLICATE_WINDOW_MILLISECONDS = 300;
|
|
|
|
export 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 | null;
|
|
icmp?: number;
|
|
icmpCode?: number;
|
|
tcpFlags?: string;
|
|
sequence?: number;
|
|
acknowledgement?: number;
|
|
}
|
|
|
|
export interface SeenFrame {
|
|
readonly time: number;
|
|
readonly text: string;
|
|
}
|
|
|
|
export 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 icmpText(macs, frame.icmp, frame.icmpCode!, frame.sourceIp!, frame.destinationIp!, frame.sequence!);
|
|
const ends = `${frame.sourceIp}:${frame.sourcePort} > ${frame.destinationIp}:${frame.destinationPort}`;
|
|
if (frame.tcpFlags !== undefined) {
|
|
return `${macs} TCP ${ends} ${frame.tcpFlags || '-'} seq=${frame.sequence} ack=${frame.acknowledgement} length=${(frame.payload ?? '').length}`;
|
|
}
|
|
return `${macs} UDP ${ends} ${frame.payload}`;
|
|
}
|
|
|
|
/** The ICMP type numbers Java logs. */
|
|
/** Type and code of each ICMP message the engine knows. */
|
|
const ICMP_TYPES: Record<string, [number, number]> = {
|
|
echoReply: [0, 0],
|
|
destinationNetworkUnreachable: [3, 0],
|
|
destinationHostUnreachable: [3, 1],
|
|
echoRequest: [8, 0],
|
|
timeExceeded: [11, 0],
|
|
};
|
|
|
|
/** The sequence number only for echo messages: an error carries none of its own. */
|
|
function icmpText(macs: string, type: number, code: number, source: string, destination: string, sequence: number): string {
|
|
const echo = type === 0 || type === 8 ? ` seq=${sequence}` : '';
|
|
return `${macs} ICMP ${type}/${code} ${source} > ${destination}${echo}`;
|
|
}
|
|
|
|
const FLAG_LETTERS: [number, string][] = [
|
|
[TCP_FLAG.synchronize, 'S'],
|
|
[TCP_FLAG.acknowledgement, 'A'],
|
|
[TCP_FLAG.finish, 'F'],
|
|
[TCP_FLAG.push, 'P'],
|
|
[TCP_FLAG.reset, 'R'],
|
|
];
|
|
|
|
export 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 === 'icmp') {
|
|
const message = packet.payload.message;
|
|
const [type, code] = ICMP_TYPES[message.kind]!;
|
|
return icmpText(macs, type, code, packet.senderIPAddress, packet.receiverIPAddress, message.sequenceNumber);
|
|
}
|
|
if (packet.payload.kind === 'tcp') {
|
|
const segment = packet.payload.segment;
|
|
const flags = FLAG_LETTERS.filter(([bit]) => (segment.flags & bit) !== 0).map(([, letter]) => letter).join('');
|
|
return `${macs} TCP ${packet.senderIPAddress}:${segment.sourcePort} > ${packet.receiverIPAddress}:${segment.destinationPort} ${flags || '-'} seq=${segment.sequenceNumber} ack=${segment.acknowledgementNumber} length=${javaTextLength(segment.payload)}`;
|
|
}
|
|
const datagram = packet.payload.datagram;
|
|
return `${macs} UDP ${packet.senderIPAddress}:${datagram.sourcePort} > ${packet.receiverIPAddress}:${datagram.destinationPort} ${new TextDecoder().decode(datagram.payload)}`;
|
|
}
|
|
|
|
/**
|
|
* A started engine on the scenario's network (or on `snapshot`, made from
|
|
* it) whose frames can be read afterwards as FILIUS logs them.
|
|
*/
|
|
export function watchedEngine(scenario: Scenario, snapshot: TopologySnapshot = snapshotOf(scenario)) {
|
|
const engine = new NetworkRuntimeEngine(scenario.seed);
|
|
const framesByIdentity = new Map<number, EthernetFrame>();
|
|
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, seed: scenario.seed, initialTimeMilliseconds: 0 });
|
|
|
|
/** Every frame when it first shows on a host's card (sent, or received). */
|
|
const frames = (): SeenFrame[] => {
|
|
const hosts = new Set(scenario.nodes.filter((node) => node.kind !== 'networkSwitch').map((node) => node.id));
|
|
const firstSeen = new Map<number, SeenFrame>();
|
|
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)!) });
|
|
}
|
|
return [...firstSeen.values()];
|
|
};
|
|
return { engine, frames };
|
|
}
|
|
|
|
/** Java's frames of all cards, each once: the same text within the window is the same frame. */
|
|
export function javaFrames(framesByNode: Record<string, JavaFrame[]>, windowMilliseconds = DUPLICATE_WINDOW_MILLISECONDS): SeenFrame[] {
|
|
const all = Object.values(framesByNode)
|
|
.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 <= windowMilliseconds)) continue;
|
|
frames.push(frame);
|
|
}
|
|
return frames;
|
|
}
|
|
|
|
/** Consecutive frames: the text, and the time since the one before. */
|
|
export function gaps(frames: readonly SeenFrame[]) {
|
|
return frames.map((frame, index) => ({ text: frame.text, gap: index === 0 ? 0 : frame.time - frames[index - 1]!.time }));
|
|
}
|
|
|
|
/**
|
|
* Each card's packet capture against Java's (the Lauscher's list of the
|
|
* card, as the oracle wrote it: under the node's id for its first card,
|
|
* `id#index` for the others): the same frames in the same order, each at
|
|
* about the same time (both counted from the run's first frame, or from
|
|
* `starts`). `text`
|
|
* puts placeholders in a frame's text (ports, IDs a test cannot predict),
|
|
* `normalized` does so for a card's whole list; with `until` (Java's time),
|
|
* only the frames before it; with `from`, only those from it.
|
|
*/
|
|
/** A card to compare: the key Java's lists have it under, and its port in the engine. */
|
|
export interface CaptureCard {
|
|
readonly key: string;
|
|
readonly portId: string;
|
|
}
|
|
|
|
/** A scenario's cards: its hosts' ports, under the node's id for the first card and `id#index` for the others (ScenarioNetwork). */
|
|
export function scenarioCards(scenario: Scenario): CaptureCard[] {
|
|
return scenario.nodes
|
|
.filter((node) => node.kind !== 'networkSwitch')
|
|
.flatMap((node) => node.ports.map((port, index) => ({ key: index === 0 ? node.id : `${node.id}#${index}`, portId: port.id })));
|
|
}
|
|
|
|
export function expectSameCaptures(
|
|
engine: NetworkRuntimeEngine,
|
|
cardsOrScenario: Scenario | readonly CaptureCard[],
|
|
java: Record<string, JavaFrame[]>,
|
|
options: {
|
|
text?: (text: string) => string;
|
|
normalized?: (frames: SeenFrame[], card: string) => SeenFrame[];
|
|
from?: number | undefined;
|
|
until?: number | undefined;
|
|
tolerance?: number;
|
|
starts?: { java: number; engine: number };
|
|
/** Java's tables of the cards (ScenarioNetwork.writeRows), compared for the frames compared. */
|
|
tables?: JavaTables;
|
|
/** Placeholders in a table cell, beyond those of tableCell. */
|
|
cell?: (text: string) => string;
|
|
/**
|
|
* A frame's flow, by its text (after `text`). Where frames of different
|
|
* flows may come in either order (Java's threads, a few ten
|
|
* milliseconds apart), a card's frames are compared flow by flow, the
|
|
* flows by their names: each flow's frames in order, at about the same
|
|
* time, with their rows.
|
|
*/
|
|
flow?: ((text: string) => string) | undefined;
|
|
/** Which frames are compared, by their text (after `text`); all where not given. */
|
|
compared?: ((text: string) => boolean) | undefined;
|
|
} = {},
|
|
): void {
|
|
const text = options.text ?? ((each: string) => each);
|
|
const engineByCard = new Map<string, SeenFrame[]>();
|
|
const capturedByCard = new Map<string, readonly CapturedFrame[]>();
|
|
const cards = 'nodes' in cardsOrScenario ? scenarioCards(cardsOrScenario) : cardsOrScenario;
|
|
for (const { key, portId } of cards) {
|
|
capturedByCard.set(key, engine.capture.frames(portId));
|
|
engineByCard.set(key, capturedByCard.get(key)!.map((each) => ({ time: each.timeMilliseconds, text: text(engineText(each.frame)) })));
|
|
}
|
|
const javaByCard = new Map(Object.entries(java).map(([card, frames]) => [card, frames.map((frame) => ({ time: frame.time, text: text(javaText(frame)) }))]));
|
|
expect([...engineByCard.keys()].sort()).toEqual([...javaByCard.keys()].sort());
|
|
const first = (byCard: Map<string, SeenFrame[]>) => Math.min(...[...byCard.values()].flat().map((frame) => frame.time));
|
|
const engineStart = options.starts?.engine ?? first(engineByCard);
|
|
const javaStart = options.starts?.java ?? first(javaByCard);
|
|
const from = (options.from ?? Number.NEGATIVE_INFINITY) - javaStart;
|
|
const until = (options.until ?? Number.POSITIVE_INFINITY) - javaStart;
|
|
const normalized = options.normalized ?? ((frames: SeenFrame[], _card: string) => frames);
|
|
const compared = options.compared ?? (() => true);
|
|
const window = (frames: SeenFrame[], start: number) =>
|
|
frames
|
|
.filter((frame) => frame.time - start >= from && frame.time - start < until && compared(frame.text))
|
|
.map((frame) => ({ time: frame.time - start, text: frame.text }));
|
|
const inWindow = (time: number, start: number) => time - start >= from && time - start < until;
|
|
// Stable: a flow's frames keep their order.
|
|
const inFlows = <T>(items: T[], textOf: (item: T) => string): T[] =>
|
|
options.flow === undefined
|
|
? items
|
|
: items
|
|
.map((item, index) => ({ item, index, flow: options.flow!(textOf(item)) }))
|
|
.sort((lhs, rhs) => (lhs.flow < rhs.flow ? -1 : lhs.flow > rhs.flow ? 1 : lhs.index - rhs.index))
|
|
.map(({ item }) => item);
|
|
for (const [card, javaList] of javaByCard) {
|
|
if (options.tables !== undefined) {
|
|
// The rows of the frames in the window, frame by frame.
|
|
const javaNumbers = inFlows(
|
|
javaList.flatMap((frame, index) => (inWindow(frame.time, javaStart) && compared(frame.text) ? [index + 1] : [])),
|
|
(number) => javaList[number - 1]!.text,
|
|
);
|
|
const engineFrames = inFlows(
|
|
capturedByCard.get(card)!.filter((each) => inWindow(each.timeMilliseconds, engineStart) && compared(text(engineText(each.frame)))),
|
|
(each) => text(engineText(each.frame)),
|
|
);
|
|
for (const [view, withAddresses] of [['rows', true], ['layeredRows', false]] as const) {
|
|
const javaRows = options.tables[view]?.[card];
|
|
if (javaRows === undefined) continue;
|
|
const engineRows = captureRows(engineFrames, { withAddresses });
|
|
const cell = (value: string | null) => (value === null ? null : (options.cell ?? ((each: string) => each))(tableCell(value)));
|
|
for (const [index, number] of javaNumbers.entries()) {
|
|
const frame = engineFrames[index];
|
|
const expectedRows = javaRows.filter((row) => row[0] === String(number)).map((row) => row.slice(1).map(cell));
|
|
// A frame a test added where Java errs (JAVA_BUGS) has no table of Java's.
|
|
if (expectedRows.length === 0 && number > Math.max(0, ...javaRows.map((row) => Number(row[0])))) continue;
|
|
const actualRows = engineRows
|
|
.filter((row) => row.number === frame?.number)
|
|
.map((row) => [row.source, row.destination, row.protocol, JAVA_LAYERS[row.layer], captureCommentText(row.comment, JAVA_WORDS, javaNumber)].map(cell));
|
|
expect(actualRows, `${card} ${view} of frame #${index + 1}`).toEqual(expectedRows);
|
|
}
|
|
}
|
|
}
|
|
const expected = inFlows(normalized(window(javaList, javaStart), card), (frame) => frame.text);
|
|
const actual = inFlows(normalized(window(engineByCard.get(card)!, engineStart), card), (frame) => frame.text);
|
|
for (let index = 0; index < Math.max(expected.length, actual.length); index += 1) {
|
|
expect(actual[index]?.text, `${card} frame #${index + 1}`).toBe(expected[index]?.text);
|
|
expect(Math.abs(actual[index]!.time - expected[index]!.time), `${card} time of frame #${index + 1}`).toBeLessThanOrEqual(
|
|
options.tolerance ?? GAP_TOLERANCE_MILLISECONDS,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/** A client's free port as "client": Java draws it at random, the engine from its seed. */
|
|
export function withClientPortText(text: string): string {
|
|
return text.replace(/:(\d{5})\b/g, (whole, port: string) => (Number(port) >= 49_152 ? ':client' : whole));
|
|
}
|
|
|
|
/** Java's capture tables per card: number, source, destination, protocol, layer, comment (ScenarioNetwork.writeRows). */
|
|
export interface JavaTables {
|
|
rows?: Record<string, (string | null)[][]> | undefined;
|
|
layeredRows?: Record<string, (string | null)[][]> | undefined;
|
|
}
|
|
|
|
/** Java's layer names and comment words: its German message bundle, the locale it runs in (Locale.GERMANY). */
|
|
const JAVA_LAYERS: Record<CaptureLayer, string> = { networkAccess: 'Netzzugang', internet: 'Vermittlung', transport: 'Transport', application: 'Anwendung' };
|
|
const JAVA_WORDS = { protocol: 'Protokoll', searchForMac: 'Suche nach MAC f\u00fcr', macIs: 'MAC ist' };
|
|
|
|
/** NumberFormat for Locale.GERMANY: groups of three digits with dots. */
|
|
function javaNumber(value: number): string {
|
|
return String(value).replace(/\B(?=(\d{3})+(?!\d))/g, '.');
|
|
}
|
|
|
|
/**
|
|
* Placeholders every test needs in a table cell: free ports, DNS message IDs,
|
|
* IP packet IDs, link-local addresses. And two departures from Java: its ARP
|
|
* reply to 0.0.0.0 writes the broadcast address in lower case
|
|
* (ARP.sendArpReply), everything else in upper case — the engine always in
|
|
* upper case (better than Java where nothing depends on it); and Java
|
|
* misspells "ICMP Time Exeeded" (a typo, not taken).
|
|
*/
|
|
function tableCell(text: string): string {
|
|
return withClientPortText(text)
|
|
.replaceAll('ff:ff:ff:ff:ff:ff', 'FF:FF:FF:FF:FF:FF')
|
|
.replace('ICMP Time Exeeded (poof)', 'ICMP Time Exceeded (poof)')
|
|
.replace(/^(\d{5})$/, (whole, port: string) => (Number(port) >= 49_152 ? 'client' : whole))
|
|
.replace(/ID=\d+/g, 'ID=x')
|
|
.replace(/\[id=\d+/g, '[id=x')
|
|
.replace(/169\.254\.1\.\d+/g, '169.254.1.x');
|
|
}
|