// 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-.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 { const start = Math.min(...frames.map((frame) => frame.time)); const result = new Map(); 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; 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 ", 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 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 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(); 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)); }); } });