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:
@@ -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));
|
||||
});
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user