feat(core): home router NAT after Java, measured with Java FILIUS

The core translates before the IP thread, as Java's NAT threads sit
between card and Ethernet: out, the WAN address and the entry's port (ICMP
keeps its identifier); in, by remote end, port and protocol, else by the
port forwarding, which then makes a dynamic entry; a ping at the WAN
address is answered by the router, packets from outside for the LAN are
dropped, both sides pass the router's firewall, TTL 1 going out gets time
exceeded. Dynamic entries expire at Java's check each second, 300 s times
the delay factor old (55 minutes at 90 %), refreshed only by what comes in.
The home router forwards as a router does.

Measured and not taken: when a LAN endpoint sends to a second remote end,
Java gives it the entry's WAN port but no entry, and the answer stays at
the router; the core adds an entry for each new remote end on that port.

The oracle now records every card of a node (the second card of routers
was missing); the ARP and RIP runs are recorded anew and still match.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-30 20:06:04 +00:00
co-authored by Claude Opus 5.5
parent 5505e8e8af
commit 7a77bc1b43
23 changed files with 2915 additions and 748 deletions
+257
View File
@@ -0,0 +1,257 @@
// 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, and its home router's web server (not
// in the engine yet) takes the first: each is compared by the order it first
// shows in. 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 { GAP_TOLERANCE_MILLISECONDS, javaFrames, watchedEngine, type JavaFrame, type SeenFrame } 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 {
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));
/** 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): string {
const result: IPv4DeliveryResult = engine.sendIcmpEcho(from, target, { sequenceNumber });
if (result.type === 'icmpError') return `ICMP ${result.kind}`;
const reply = (engine.state.icmpObservationsByNodeId.get(from) ?? []).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),
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.
*/
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 {
frames: { ...java.frames, [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, ties sorted. */
function normalized(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}`;
};
const texts = 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)}`)),
}));
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,
);
}
}
// 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));
});
}
});