Files
julian.kiedaischandClaude Opus 5.5 7dac57cbbe feat(core): the pages of routers and home routers after Java, measured against Java
Routers and home routers start a web server on port 80 with Java's index,
log and routes pages in the project's language (routerPages.ts); the
firewall keeps a log per node that outlives a new start; RIP routes carry
the telling router's public address. TCP counts sequence numbers and
segments in Java's characters, not bytes. RouterWebRun records six stories,
with firewalls now built by ScenarioNetwork. Not taken from Java: its router
server sometimes never listening (a race in setAktiv), and </titel>.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 17:41:52 +00:00

281 lines
14 KiB
TypeScript

// NAT as Java FILIUS runs it. tools/java-oracle's NatRun ran stories on
// nat-home's network (a PC behind a home router, two hosts on its WAN side)
// in Java FILIUS and wrote every frame each card saw, the story's calls and
// the router's NAT table (nat-java-<story>.java.json). The engine plays the
// same story: each card must send the same frames in the same order, each at
// about the same time from the first frame; the calls must return the same;
// the NAT table must hold the same entries.
//
// Java's servers answer in threads of their own, and its client sends its
// next segment some 90 ms after the last (thread latency); the engine's story
// plays the servers between the client's calls. The order between cards
// follows those threads, so it is not compared. Free ports — a client's, and the WAN port
// the router draws for an entry — are Java's random and the engine's seed:
// both are compared as "port". Java numbers the TCP sockets' initial
// sequence numbers over all sockets; its home router's web server takes the
// first when it listens — in these runs it did not (setAktiv wakes its thread
// only when it already waits), in the engine it always does: each initial
// number is compared by the order it first shows in, and in the tables only
// below the millions. Frames that show within TIE_MILLISECONDS of each other Java's
// packet log writes in any order; they are compared sorted by their text.
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
import type { Id, IPv4DeliveryResult, NetworkRuntimeEngine } 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');
const LAN_PC = '00000000-0000-0000-0000-000000000501';
const GATEWAY = '00000000-0000-0000-0000-000000000502';
const SERVER = '00000000-0000-0000-0000-000000000504';
const OTHER_SERVER = '00000000-0000-0000-0000-000000000505';
const RTT = 2_750;
const TIE_MILLISECONDS = 1;
// As in tcpJava.test.ts: the engine's calls block one after the other, so
// the server answers when the client's send returns, and the client closes
// when the server's send returns — up to two cables' delay each later.
const TCP_TOLERANCE_MILLISECONDS = 250;
const TCP_STORIES = new Set(['tcpOut', 'forward']);
/** The frames by the card that sent them (the source MAC address), in order, times from the first frame. */
function bySender(frames: readonly SeenFrame[]): Map<string, SeenFrame[]> {
const start = Math.min(...frames.map((frame) => frame.time));
const result = new Map<string, SeenFrame[]>();
for (const frame of frames) {
const sender = frame.text.slice(0, 17);
result.set(sender, [...(result.get(sender) ?? []), { time: frame.time - start, text: frame.text }]);
}
return result;
}
interface JavaRun extends JavaTables {
frames: Record<string, JavaFrame[]>;
events: [number, string][];
nat: string[];
}
type Call = (label: string, run: () => string) => void;
const text = (value: string) => new TextEncoder().encode(value);
const decode = (value: Uint8Array | undefined) => (value === undefined ? 'null' : new TextDecoder().decode(value));
const ICMP_CODES: Record<string, string> = { timeExceeded: '11/0', destinationNetworkUnreachable: '3/0', destinationHostUnreachable: '3/1' };
/** A ping in Java's words: the answer's type and code and its sender, or null. */
function ping(engine: NetworkRuntimeEngine, from: Id, target: string, sequenceNumber: number, timeToLive = 64): string {
const result: IPv4DeliveryResult = engine.sendIcmpEcho(from, target, { sequenceNumber, timeToLive });
const observations = engine.state.icmpObservationsByNodeId.get(from) ?? [];
if (result.type === 'icmpError') {
const error = observations.findLast((observation) => observation.message.kind === result.kind);
return error === undefined ? `ICMP ${result.kind}` : `ICMP ${ICMP_CODES[result.kind]} from ${error.packet.senderIPAddress}`;
}
const reply = observations.find((observation) => observation.message.kind === 'echoReply' && observation.message.sequenceNumber === sequenceNumber);
return reply === undefined ? 'null' : `ICMP 0/0 from ${reply.packet.senderIPAddress}`;
}
/** The server's socket on port 7 answers the first datagram with "pong <it>", as NatRun's echo thread. */
function udpEcho(engine: NetworkRuntimeEngine, socket: Id): void {
const received = engine.receiveUdp(socket, 10 * RTT);
if (received !== undefined) engine.sendUdp(socket, text(`pong ${decode(received.datagram.payload)}`));
}
/** NatRun's TCP exchange: the server listens on 80; the client connects, sends "hello", receives, closes. */
function tcpExchange(engine: NetworkRuntimeEngine, call: Call, server: Id, client: Id, target: string, port: number): void {
const listener = engine.openTcpServerSocket(server, 80)!;
const socket = engine.openTcpClientSocket(client, target, port)!;
call('client verbinden', () => (engine.connectTcpWithResult(socket) === 'connected' ? '' : 'failed'));
const accepted = () => engine.state.tcpAcceptedSocketIdsByListenerId.get(listener)![0]!;
call('client senden', () => (engine.sendTcp(socket, text('hello')) ? '' : 'failed'));
engine.sendTcp(accepted(), text(`reply ${decode(engine.receiveTcp(accepted()))}`));
call('client empfangen', () => decode(engine.receiveTcp(socket)));
call('client schliessen', () => {
engine.closeTcpSocket(socket);
return '';
});
}
const STORIES: Record<string, (engine: NetworkRuntimeEngine, call: Call) => void> = {
pingOut: (engine, call) => call('sendProbe 42.0.0.20', () => ping(engine, LAN_PC, '42.0.0.20', 1)),
udpOut: (engine, call) => {
const server = engine.bindUdpSocket(SERVER, { localPort: 7 })!;
const client = engine.bindUdpSocket(LAN_PC, { remoteIPAddress: '42.0.0.20', remotePort: 7 })!;
call('senden 42.0.0.20', () => {
engine.sendUdp(client, text('one'));
return '';
});
call('empfangen', () => {
udpEcho(engine, server);
return decode(engine.receiveUdp(client, 5 * RTT)?.datagram.payload);
});
},
udpTwoRemotes: (engine, call) => {
const server = engine.bindUdpSocket(SERVER, { localPort: 7 })!;
const otherServer = engine.bindUdpSocket(OTHER_SERVER, { localPort: 7 })!;
const first = engine.bindUdpSocket(LAN_PC, { remoteIPAddress: '42.0.0.20', remotePort: 7 })!;
call('senden 42.0.0.20', () => {
engine.sendUdp(first, text('one'));
return '';
});
call('empfangen', () => {
udpEcho(engine, server);
return decode(engine.receiveUdp(first, 5 * RTT)?.datagram.payload);
});
const port = engine.state.socketsById.get(first)!.localPort;
engine.closeSocket(first);
const second = engine.bindUdpSocket(LAN_PC, { localPort: port, remoteIPAddress: '42.0.0.30', remotePort: 7 })!;
call('senden 42.0.0.30', () => {
engine.sendUdp(second, text('two'));
return '';
});
call('empfangen', () => {
udpEcho(engine, otherServer);
return decode(engine.receiveUdp(second, 5 * RTT)?.datagram.payload);
});
},
tcpOut: (engine, call) => tcpExchange(engine, call, SERVER, LAN_PC, '42.0.0.20', 80),
forward: (engine, call) => tcpExchange(engine, call, LAN_PC, SERVER, '42.0.0.10', 8080),
ttlOut: (engine, call) => call('sendProbe 42.0.0.20', () => ping(engine, LAN_PC, '42.0.0.20', 1, 1)),
pingWan: (engine, call) => {
call('sendProbe 42.0.0.10', () => ping(engine, SERVER, '42.0.0.10', 1));
call('sendProbe 192.168.0.10', () => ping(engine, SERVER, '192.168.0.10', 2));
},
};
/**
* Where Java errs, per story: its run corrected, as Java means it.
*
* udpTwoRemotes: Java keeps one entry per LAN endpoint, for the first remote
* end; the second server gets the datagram from the entry's WAN port, but its
* answer finds no entry and stays at the router. The engine adds an entry for
* the second remote end: its answer goes on to the PC, a cable later.
* (`answer` is the frame on the home router's WAN card, the first list it
* shows in.)
*/
const JAVA_BUGS: Record<string, (java: JavaRun) => JavaRun> = {
udpTwoRemotes: (java) => {
const answer = Object.values(java.frames)
.flat()
.find((frame) => frame.payload === 'pong two' && frame.destinationIp === '42.0.0.10')!;
const [lanPort] = java.nat[0]!.match(/192\.168\.0\.10:(\d+)/)!.slice(1);
const onLan: JavaFrame = {
...answer,
time: answer.time + 56,
source: '02:00:00:00:05:22',
destination: '02:00:00:00:05:11',
destinationIp: '192.168.0.10',
destinationPort: Number(lanPort),
};
const wanPort = java.nat[0]!.match(/42\.0\.0\.20:(\d+)/)![1];
const received = java.events.find(([, event]) => event === 'other server received two')!;
return {
// The home router's LAN card sends it as it arrives on the WAN card; the PC's receives it a cable later.
frames: { ...java.frames, [`${GATEWAY}#1`]: [...java.frames[`${GATEWAY}#1`]!, { ...onLan, time: answer.time + 1 }], [LAN_PC]: [...java.frames[LAN_PC]!, onLan] },
events: java.events.map(([time, event], index) =>
index === java.events.length - 1 && event === 'empfangen returned null' ? [received[0] + 168, 'empfangen returned pong two'] : [time, event],
),
nat: [...java.nat, `17 42.0.0.30:${wanPort} -> 192.168.0.10:${lanPort} DynamicEntry`].sort(),
};
},
};
/** Free ports as "port", initial sequence numbers by their order. */
function withPlaceholders(frames: readonly SeenFrame[]): SeenFrame[] {
const bases = new Map<number, number>();
const sequence = (value: string) => {
const number = Number(value);
const base = Math.floor(number / 1_000_000);
if (!bases.has(base)) bases.set(base, bases.size + 1);
return `isn${bases.get(base)}+${number % 1_000_000}`;
};
return frames.map((frame) => ({
time: frame.time,
text: withPorts(frame.text).replace(/(seq|ack)=(\d+)/g, (whole, name: string, value: string) => (value === '0' ? whole : `${name}=${sequence(value)}`)),
}));
}
/** With placeholders, ties sorted: over all cards, Java's log writes frames that show together in any order. */
function normalized(frames: readonly SeenFrame[]): SeenFrame[] {
const texts = withPlaceholders(frames);
const result: SeenFrame[] = [];
for (let start = 0; start < texts.length; ) {
let end = start + 1;
while (end < texts.length && texts[end]!.time - texts[start]!.time <= TIE_MILLISECONDS) end += 1;
const tie = texts.slice(start, end);
const sortedTexts = tie.map((frame) => frame.text).sort();
result.push(...tie.map((frame, index) => ({ time: frame.time, text: sortedTexts[index]! })));
start = end;
}
return result;
}
function withPorts(value: string): string {
return value.replace(/:(\d{5})\b/g, (whole, port: string) => (Number(port) >= 49_152 ? ':port' : whole));
}
/** The router's dynamic entries as NatRun writes them. */
function natTable(engine: NetworkRuntimeEngine): string[] {
const protocols = { tcp: 6, udp: 17, icmp: 1 } as const;
const types = { dynamic: 'DynamicEntry', dynamicFromStatic: 'DynamicEnryFromStatic' } as const;
return engine
.natEntries(GATEWAY)
.filter((entry) => entry.type !== 'static')
.map((entry) => `${protocols[entry.protocol]} ${entry.remoteIPAddress}:${entry.port} -> ${entry.lan.ipAddress}:${entry.lan.port} ${types[entry.type as 'dynamic']}`)
.sort();
}
describe('NAT as Java FILIUS runs it', () => {
it('corrects Java only where it errs', () => {
for (const [name, correct] of Object.entries(JAVA_BUGS)) {
const java = JSON.parse(readFileSync(join(scenarioDirectory, `nat-java-${name}.java.json`), 'utf8')) as JavaRun;
expect(JSON.stringify(correct(java)), name).not.toBe(JSON.stringify(java));
}
});
const scenario = JSON.parse(readFileSync(join(scenarioDirectory, 'nat-home.scenario.json'), 'utf8')) as Scenario;
for (const [name, story] of Object.entries(STORIES)) {
it(name, () => {
const measured = JSON.parse(readFileSync(join(scenarioDirectory, `nat-java-${name}.java.json`), 'utf8')) as JavaRun;
const java = JAVA_BUGS[name]?.(measured) ?? measured;
const { engine, frames } = watchedEngine(scenario);
engine.handle({ type: 'advance', toMilliseconds: 500 });
const events: [number, string][] = [];
story(engine, (label, run) => {
events.push([engine.state.currentTimeMilliseconds, `${label} called`]);
const result = run();
events.push([engine.state.currentTimeMilliseconds, `${label} returned${result === '' ? ' ' : ` ${result}`}`]);
});
engine.handle({ type: 'advance', toMilliseconds: engine.state.currentTimeMilliseconds + 10_000 });
const expected = bySender(normalized(javaFrames(java.frames)));
const actual = bySender(normalized(frames()));
expect([...actual.keys()].sort()).toEqual([...expected.keys()].sort());
for (const [sender, javaList] of expected) {
const engineList = actual.get(sender)!;
for (let index = 0; index < Math.max(javaList.length, engineList.length); index += 1) {
expect(engineList[index]?.text, `${sender} frame #${index + 1}`).toBe(javaList[index]?.text);
expect(Math.abs(engineList[index]!.time - javaList[index]!.time), `${sender} time of frame #${index + 1}`).toBeLessThanOrEqual(
TCP_STORIES.has(name) ? TCP_TOLERANCE_MILLISECONDS : GAP_TOLERANCE_MILLISECONDS,
);
}
}
// A card's own list keeps its order.
expectSameCaptures(engine, scenario, java.frames, {
normalized: withPlaceholders,
tables: measured,
cell: (text) => text.replace(/\b\d+\.(\d{3}\.\d{3})\b/g, 'n.$1'),
tolerance: TCP_STORIES.has(name) ? TCP_TOLERANCE_MILLISECONDS : GAP_TOLERANCE_MILLISECONDS,
});
// The client's calls: Java's server threads write events of their own, which the engine's story has not.
const javaCalls = java.events.filter(([, event]) => !event.startsWith('server') && !event.startsWith('other server'));
expect(events.map(([, event]) => event)).toEqual(javaCalls.map(([, event]) => event));
expect(natTable(engine).map(withPorts)).toEqual(java.nat.map(withPorts));
});
}
});