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
+330 -12
View File
@@ -17,6 +17,9 @@
// one message per attempt, an ARP check that waits a round trip,
// link-local after five failures; round trip times follow the speed)
// processScheduledRuntimeEvent, dropForGlobalPacketLoss (lines ~4753–4850)
// "Gateway NAT/PAT" (lines ~3585–3900; after Java's filius.software.nat,
// measured with Java FILIUS: one entry per LAN endpoint, the expiry of
// Java's GatewayFirmware.checkNAT)
// A cable carries one frame after the other, as Java's SimplexVerbindung
// (Swift: all at once; the option swiftCables keeps that for the comparison).
// ARP after Java's holeARPTabellenEintrag: a lookup asks twice, a round trip
@@ -43,7 +46,7 @@
// advance the virtual clock inside the call, exactly as the Swift port does.
//
// Not ported yet — each place throws NotPortedError instead of guessing:
// gateway NAT, remote links.
// remote links.
import { compareIds, type Id } from '../ids.js';
import { isPcClassEndpoint, stableGeneratedMacAddress } from '../model/node.js';
@@ -51,6 +54,7 @@ import type {
DeviceConfiguration,
DhcpServerConfiguration,
FirewallConfiguration,
GatewayPortForwardingRow,
FirewallRule,
InterfaceConfiguration,
InterfaceKey,
@@ -172,8 +176,7 @@ export type ScheduledEventKind =
readonly pairIdentifier: string;
readonly latencyMilliseconds: number;
readonly frame: EthernetFrame;
}
| { readonly kind: 'natExpirySweep' };
};
export interface ScheduledEvent {
readonly id: number;
@@ -456,6 +459,117 @@ export const RIP_INFINITY = 16;
export const RIP_TIMEOUT_MILLISECONDS = 75_000;
export const RIP_FIRST_BEACON_DELAY_MILLISECONDS = 1_000;
/** GatewayFirmware.retentionTime: a dynamic NAT entry lasts 300 s, times Java's delay factor. */
export const NAT_RETENTION_MILLISECONDS = 300_000;
export type NatProtocol = 'tcp' | 'udp' | 'icmp';
export interface NatEndpoint {
readonly ipAddress: string;
/** A port; for ICMP, the identifier. */
readonly port: number;
}
/**
* An entry of a home router's NAT table (Java NatEntry under its
* PortProtocolPair): the WAN port and the remote end (empty for a static
* entry of the port forwarding) lead to the LAN endpoint.
*/
export interface NatEntry {
readonly protocol: NatProtocol;
readonly remoteIPAddress: string;
readonly port: number;
readonly lan: NatEndpoint;
/** Java NatType: StaticEntry, DynamicEntry, DynamicEnryFromStatic. */
readonly type: 'static' | 'dynamic' | 'dynamicFromStatic';
readonly updatedAtMilliseconds: number;
}
/**
* A port forwarding row as Java's GatewayFirmware.validateData takes it:
* TCP or UDP in any case, or 6 or 17; ports 1…65535 without leading zeros;
* an IPv4 address (EingabenUeberpruefung).
*/
function parsePortForwardingRow(
row: GatewayPortForwardingRow,
): { protocol: 'tcp' | 'udp'; publicPort: number; lanIPAddress: string; lanPort: number } | undefined {
const protocolValue = row.protocolValue.toUpperCase();
const protocol = protocolValue === 'TCP' || protocolValue === '6' ? 'tcp' : protocolValue === 'UDP' || protocolValue === '17' ? 'udp' : undefined;
const port = (value: string) => (/^[1-9][0-9]{0,4}$/.test(value) && Number(value) <= 65_535 ? Number(value) : undefined);
const address =
/^(0*([1-9]?[0-9]?|1[0-9]{1,2}|2[0-4]?[0-9]|25[0-5]))\.((0*(1?[0-9]{1,2}|2[0-4]?[0-9]|25[0-5]))\.){2}(0*(1?[0-9]{1,2}|2[0-4]?[0-9]|25[0-5]))$/;
const publicPort = port(row.publicPortValue);
const lanPort = port(row.lanPortValue);
if (protocol === undefined || publicPort === undefined || lanPort === undefined || !address.test(row.lanIPAddress)) return undefined;
return { protocol, publicPort, lanIPAddress: row.lanIPAddress, lanPort };
}
/** The endpoint a packet from the LAN leaves from, and whether it opens an entry (a TCP SYN, any UDP datagram, any ICMP packet). */
function natOutboundEndpoint(packet: IPv4Packet): { protocol: NatProtocol; port: number; opens: boolean } {
const payload = packet.payload;
switch (payload.kind) {
case 'tcp': {
const flags = payload.segment.flags;
return { protocol: 'tcp', port: payload.segment.sourcePort, opens: (flags & TCP_FLAG.synchronize) !== 0 && (flags & TCP_FLAG.acknowledgement) === 0 };
}
case 'udp':
return { protocol: 'udp', port: payload.datagram.sourcePort, opens: true };
case 'icmp':
return { protocol: 'icmp', port: payload.message.identifier, opens: true };
}
}
/**
* NatGateway.replaceDestination's key: the remote end, the WAN port and the
* protocol; for an ICMP error, those of the packet it carries (whose
* receiver was the remote end and whose source port, or identifier, the WAN
* port); for other ICMP, the sender and the identifier.
*/
function natInboundLookup(packet: IPv4Packet): { remoteIPAddress: string; port: number; protocol: NatProtocol } | undefined {
const payload = packet.payload;
switch (payload.kind) {
case 'tcp':
return { remoteIPAddress: packet.senderIPAddress, port: payload.segment.destinationPort, protocol: 'tcp' };
case 'udp':
return { remoteIPAddress: packet.senderIPAddress, port: payload.datagram.destinationPort, protocol: 'udp' };
case 'icmp': {
const embedded = payload.message.embeddedOriginalPacket;
if (embedded === undefined) return { remoteIPAddress: packet.senderIPAddress, port: payload.message.identifier, protocol: 'icmp' };
const inner = embedded.payload;
const port = inner.kind === 'icmp' ? inner.message.identifier : inner.kind === 'tcp' ? inner.segment.sourcePort : inner.datagram.sourcePort;
return { remoteIPAddress: embedded.receiverIPAddress, port, protocol: inner.kind };
}
}
}
/** NatGateway.replaceSource: the WAN address; TCP and UDP the entry's port (ICMP keeps its identifier). */
function natWithSource(packet: IPv4Packet, ipAddress: string, port: number | undefined): IPv4Packet {
const payload = packet.payload;
if (port === undefined || payload.kind === 'icmp') return { ...packet, senderIPAddress: ipAddress };
return payload.kind === 'tcp'
? { ...packet, senderIPAddress: ipAddress, payload: { kind: 'tcp', segment: { ...payload.segment, sourcePort: port } } }
: { ...packet, senderIPAddress: ipAddress, payload: { kind: 'udp', datagram: { ...payload.datagram, sourcePort: port } } };
}
/** NatGateway.replaceDestination: the LAN endpoint's address; TCP and UDP its port. */
function natWithDestination(packet: IPv4Packet, endpoint: NatEndpoint): IPv4Packet {
const payload = packet.payload;
if (payload.kind === 'icmp') return { ...packet, receiverIPAddress: endpoint.ipAddress };
return payload.kind === 'tcp'
? { ...packet, receiverIPAddress: endpoint.ipAddress, payload: { kind: 'tcp', segment: { ...payload.segment, destinationPort: endpoint.port } } }
: { ...packet, receiverIPAddress: endpoint.ipAddress, payload: { kind: 'udp', datagram: { ...payload.datagram, destinationPort: endpoint.port } } };
}
/** VermittlungsProtokoll.isBroadcast: the limited broadcast, or the broadcast address of the sender's network. */
function isBroadcastFrom(receiverIPAddress: string, senderIPAddress: string, subnetMask: string): boolean {
if (receiverIPAddress === LIMITED_BROADCAST_IP_ADDRESS) return true;
const receiver = parseIPv4(receiverIPAddress);
const sender = parseIPv4(senderIPAddress);
const mask = parseIPv4(subnetMask);
if (receiver === undefined || sender === undefined || mask === undefined) return false;
return maskAddress(receiver, mask) === maskAddress(sender, mask) && (receiver | mask) >>> 0 === 0xffff_ffff;
}
/** RIPMessage: the sender's interface address, its first address, infinity, the timeout, its routes. */
interface RipMessage {
readonly ipAddress: string;
@@ -564,6 +678,8 @@ export interface RuntimeState {
dnsServerSocketIdsByNodeId: Map<Id, Id>;
/** Each RIP router's table (RIPTable.routes): its networks, then what it learned. */
ripTablesByNodeId: Map<Id, RouteRow[]>;
/** Each home router's NAT table (Java NetworkAddressTranslationTable): static and dynamic entries. */
natTablesByNodeId: Map<Id, NatEntry[]>;
dhcpClientStatusesByNodeId: Map<Id, DhcpClientStatus>;
dhcpClientContextsByNodeId: Map<Id, DhcpClientContext>;
nextSocketSequenceNumber: number;
@@ -603,6 +719,7 @@ function initialState(seed: bigint): RuntimeState {
dhcpServerSocketIdsByNodeId: new Map(),
dnsServerSocketIdsByNodeId: new Map(),
ripTablesByNodeId: new Map(),
natTablesByNodeId: new Map(),
dhcpClientStatusesByNodeId: new Map(),
dhcpClientContextsByNodeId: new Map(),
nextSocketSequenceNumber: 1,
@@ -713,6 +830,8 @@ export class NetworkRuntimeEngine {
private nextArpLookupId = 1;
/** Per RIP router: its random for the beacon's pauses and its sockets (RIPBeacon, RIPServer). */
private ripRoutersByNodeId = new Map<Id, { readonly random: JavaCompatibleRandom; readonly beaconSocketId: Id; readonly serverSocketId: Id }>();
/** When the NAT tables started: Java's expiry timer ticks every second from then. */
private natStartedAtMilliseconds = 0;
/** DNS queries waiting for their answer, by the socket they were sent from. */
private dnsQueriesBySocketId = new Map<Id, (response: DnsMessage | undefined) => void>();
/** Per node and kind (`nodeId/udp`, `nodeId/icmp`, …). */
@@ -819,6 +938,7 @@ export class NetworkRuntimeEngine {
state.dnsServerSocketIdsByNodeId.clear();
state.ripTablesByNodeId.clear();
this.ripRoutersByNodeId.clear();
state.natTablesByNodeId.clear();
state.firewallDecisions = [];
return [this.output({ type: 'stopped' })];
}
@@ -1008,8 +1128,6 @@ export class NetworkRuntimeEngine {
case 'remoteLinkDelivery':
case 'lanRemoteLinkTransmission':
throw new NotPortedError('remote links');
case 'natExpirySweep':
throw new NotPortedError('gateway NAT');
}
}
@@ -1080,7 +1198,7 @@ export class NetworkRuntimeEngine {
}
private isUdpPortReserved(nodeId: Id, port: number): boolean {
// Swift also counts a gateway's NAT mappings; gateways do not run here yet.
if (this.isNatPort(nodeId, 'udp', port)) return true;
for (const socket of this.stateValue.socketsById.values()) {
if (socket.nodeId === nodeId && socket.protocol === 'udp' && socket.localPort === port) return true;
}
@@ -1643,6 +1761,7 @@ export class NetworkRuntimeEngine {
/** A port held by an open (not closed) TCP socket that is no listener's child. */
isTcpPortReserved(nodeId: Id, port: number): boolean {
if (this.isNatPort(nodeId, 'tcp', port)) return true;
for (const socket of this.stateValue.socketsById.values()) {
if (
socket.nodeId === nodeId &&
@@ -3177,13 +3296,208 @@ export class NetworkRuntimeEngine {
this.stateValue.topologySnapshot = withSnapshot(this.stateValue.topologySnapshot, { deviceConfigurations, interfaceConfigurations });
}
/**
* Java's GatewayFirmware.starten: each home router's port forwarding
* becomes its static entries (rows Java's input check rejects are left
* out). Dynamic entries expire as Java's timer finds them, checked every
* second from the start (natEntryExpired).
*/
private initializeNatState(): void {
// Gateway NAT (F2): schedules the expiry sweep when a gateway exists.
if (this.stateValue.topologySnapshot.nodes.some((node) => node.kind === 'gateway')) {
throw new NotPortedError('gateway NAT');
const state = this.stateValue;
state.natTablesByNodeId.clear();
this.natStartedAtMilliseconds = state.currentTimeMilliseconds;
for (const node of state.topologySnapshot.nodes) {
if (node.kind !== 'gateway') continue;
const entries: NatEntry[] = [];
for (const row of state.topologySnapshot.portForwardingRowsByNodeId.get(node.id) ?? []) {
const forwarding = parsePortForwardingRow(row);
if (forwarding === undefined) continue;
// Java's HashMap: a second row for the same port and protocol replaces the first.
const index = entries.findIndex((entry) => entry.protocol === forwarding.protocol && entry.port === forwarding.publicPort);
const entry: NatEntry = {
protocol: forwarding.protocol,
remoteIPAddress: '',
port: forwarding.publicPort,
lan: { ipAddress: forwarding.lanIPAddress, port: forwarding.lanPort },
type: 'static',
updatedAtMilliseconds: state.currentTimeMilliseconds,
};
if (index >= 0) entries[index] = entry;
else entries.push(entry);
}
state.natTablesByNodeId.set(node.id, entries);
}
}
/** The home router's NAT table as it stands: static entries, then the dynamic ones that have not expired. */
natEntries(nodeId: Id): readonly NatEntry[] {
this.expireNatEntries(nodeId);
return this.stateValue.natTablesByNodeId.get(nodeId) ?? [];
}
/**
* GatewayFirmware.checkNAT: every second from a second after the start,
* dynamic entries whose last update is 300 s (times Java's delay factor)
* old are removed — at the first such check.
*/
private natEntryExpired(entry: NatEntry, nowMilliseconds: number): boolean {
if (entry.type === 'static') return false;
const start = this.natStartedAtMilliseconds;
const retention = NAT_RETENTION_MILLISECONDS * this.runtimeSpeed.javaDelayFactor;
const firstCheck = start + Math.max(1, Math.ceil((entry.updatedAtMilliseconds + retention - start) / 1_000)) * 1_000;
return nowMilliseconds >= firstCheck;
}
private expireNatEntries(nodeId: Id): void {
const table = this.stateValue.natTablesByNodeId.get(nodeId);
if (table === undefined) return;
const now = this.stateValue.currentTimeMilliseconds;
if (table.some((entry) => this.natEntryExpired(entry, now))) {
this.stateValue.natTablesByNodeId.set(
nodeId,
table.filter((entry) => !this.natEntryExpired(entry, now)),
);
}
}
/** A port a dynamic entry of the home router holds (Java reserves it at the transport layer). */
private isNatPort(nodeId: Id, protocol: 'tcp' | 'udp', port: number): boolean {
return this.natEntries(nodeId).some((entry) => entry.type !== 'static' && entry.protocol === protocol && entry.port === port);
}
/**
* NetworkAddressTranslationTable.find: the dynamic entry for the remote end,
* port and protocol (updated), else the static one for the port, which
* then gets a dynamic entry for the remote end.
*/
private findNatEntry(nodeId: Id, port: number, remoteIPAddress: string, protocol: NatProtocol): NatEndpoint | undefined {
const table = [...this.natEntries(nodeId)];
const now = this.stateValue.currentTimeMilliseconds;
const index = table.findIndex((entry) => entry.type !== 'static' && entry.protocol === protocol && entry.port === port && entry.remoteIPAddress === remoteIPAddress);
if (index >= 0) {
table[index] = { ...table[index]!, updatedAtMilliseconds: now };
this.stateValue.natTablesByNodeId.set(nodeId, table);
return table[index]!.lan;
}
const forwarding = table.find((entry) => entry.type === 'static' && entry.protocol === protocol && entry.port === port);
if (forwarding === undefined) return undefined;
this.putDynamicNatEntry(nodeId, { protocol, remoteIPAddress, port, lan: forwarding.lan, type: 'dynamicFromStatic', updatedAtMilliseconds: now });
return forwarding.lan;
}
/** Java's ConcurrentHashMap.put: an entry for the same remote end, port and protocol is replaced. */
private putDynamicNatEntry(nodeId: Id, entry: NatEntry): void {
const table = [...this.natEntries(nodeId)];
const index = table.findIndex(
(each) => each.type !== 'static' && each.protocol === entry.protocol && each.port === entry.port && each.remoteIPAddress === entry.remoteIPAddress,
);
if (index >= 0) table[index] = entry;
else table.push(entry);
this.stateValue.natTablesByNodeId.set(nodeId, table);
}
/**
* NetworkAddressTranslationTable.findPort: the WAN port of the LAN
* endpoint's entries, 0 without one. (Java looks at the dynamic entries
* first; an endpoint's entries all have the same port.)
*/
private natPortOf(nodeId: Id, lan: NatEndpoint, protocol: NatProtocol): number {
return (
this.natEntries(nodeId).find((entry) => entry.protocol === protocol && entry.lan.ipAddress === lan.ipAddress && entry.lan.port === lan.port)?.port ?? 0
);
}
/**
* NatGateway.insertNewConnection: a LAN endpoint without an entry gets one
* for the remote end, on a free port of the home router (ICMP: its
* identifier).
*
* Java keeps that one entry for the endpoint's later remote ends too: they
* get its WAN port, but their answers find no entry and never reach the LAN
* (measured: udpTwoRemotes). The core adds an entry for each new remote end
* on the same WAN port — unless a port forwarding serves the endpoint,
* whose answers find their way already.
*/
private insertNatConnection(nodeId: Id, protocol: NatProtocol, lan: NatEndpoint, remoteIPAddress: string): void {
const table = this.natEntries(nodeId);
const now = this.stateValue.currentTimeMilliseconds;
const ofEndpoint = table.filter((entry) => entry.protocol === protocol && entry.lan.ipAddress === lan.ipAddress && entry.lan.port === lan.port);
if (ofEndpoint.length === 0) {
const port = protocol === 'tcp' ? this.reserveFreeTcpPort(nodeId) : protocol === 'udp' ? this.reserveFreeUdpPort(nodeId) : lan.port;
if (port === undefined) return;
this.putDynamicNatEntry(nodeId, { protocol, remoteIPAddress, port, lan, type: 'dynamic', updatedAtMilliseconds: now });
return;
}
if (ofEndpoint.some((entry) => entry.type === 'static' || entry.remoteIPAddress === remoteIPAddress)) return;
this.putDynamicNatEntry(nodeId, { protocol, remoteIPAddress, port: this.natPortOf(nodeId, lan, protocol), lan, type: 'dynamic', updatedAtMilliseconds: now });
}
/**
* What Java's NatGatewayLANThread and NatGatewayWANThread do to a packet
* before the home router's IP layer sees it: from the LAN, a packet to
* elsewhere (not the LAN, not a broadcast, not the WAN address) gets the
* WAN address and the entry's port; from the WAN, a packet to the WAN
* address with an entry goes to its LAN endpoint, one to the LAN's
* addresses is dropped, and an echo request to the WAN address is answered
* by the router itself. Both sides pass the router's firewall first.
* Undefined when the packet goes no further.
*/
private translateAtGateway(packet: IPv4Packet, frameIdentity: number | undefined, nodeId: Id, networkInterface: NetworkInterface): IPv4Packet | undefined {
const interfaces = this.networkInterfaces(nodeId);
const wan = interfaces.find((each) => each.index === 0);
const lan = interfaces.find((each) => each.index === 1);
if (wan === undefined || lan === undefined) return packet;
const payload = packet.payload;
const trace = (operation: TraceOperation, detail: string, after?: IPv4Packet) =>
this.recordTrace({
frameIdentity,
packetIdentity: packet.identity,
nodeId,
interfaceId: networkInterface.portId,
direction: networkInterface.portId === wan.portId ? 'inbound' : 'outbound',
layer: 'network',
operation,
beforeHeaders: ipv4Headers(packet),
...(after === undefined ? {} : { afterHeaders: ipv4Headers(after) }),
detail,
});
if (networkInterface.portId === wan.portId) {
if (payload.kind === 'icmp' && payload.message.kind === 'echoRequest' && packet.receiverIPAddress === wan.ipAddress) return packet;
if (sameSubnet(packet.receiverIPAddress, lan.ipAddress, lan.subnetMask)) {
trace('dropped', 'WAN packet addressed to the LAN');
return undefined;
}
if (!this.evaluateFirewall(packet, nodeId, networkInterface.portId, frameIdentity).accepted) return undefined;
if (packet.receiverIPAddress !== wan.ipAddress) return packet;
const lookup = natInboundLookup(packet);
if (lookup === undefined) return packet;
const endpoint = this.findNatEntry(nodeId, lookup.port, lookup.remoteIPAddress, lookup.protocol);
if (endpoint === undefined) return packet;
const translated = natWithDestination(packet, endpoint);
trace('rewritten', 'NAT WAN to LAN', translated);
return translated;
}
if (networkInterface.portId !== lan.portId) return packet;
if (!this.evaluateFirewall(packet, nodeId, networkInterface.portId, frameIdentity).accepted) return undefined;
const outgoing =
!sameSubnet(packet.receiverIPAddress, lan.ipAddress, lan.subnetMask) &&
!isBroadcastFrom(packet.receiverIPAddress, packet.senderIPAddress, lan.subnetMask) &&
packet.receiverIPAddress !== wan.ipAddress;
if (!outgoing) return packet;
if (packet.timeToLive <= 1) {
this.emitIcmpError(nodeId, networkInterface, 'timeExceeded', packet);
return undefined;
}
const endpoint = natOutboundEndpoint(packet);
if (endpoint.opens) this.insertNatConnection(nodeId, endpoint.protocol, { ipAddress: packet.senderIPAddress, port: endpoint.port }, packet.receiverIPAddress);
const port = endpoint.protocol === 'icmp' ? undefined : this.natPortOf(nodeId, { ipAddress: packet.senderIPAddress, port: endpoint.port }, endpoint.protocol);
const translated = natWithSource(packet, wan.ipAddress, port);
trace('rewritten', 'NAT LAN to WAN', translated);
return translated;
}
private isRipEnabledRouter(nodeId: Id): boolean {
return (
this.nodeSnapshot(nodeId)?.kind === 'router' &&
@@ -4064,8 +4378,11 @@ export class NetworkRuntimeEngine {
const node = this.nodeSnapshot(nodeId);
if (node === undefined) return;
// Swift: the gateway branch (WAN/LAN, firewall, NAT rewrite) comes here.
if (node.kind === 'gateway') throw new NotPortedError('gateway NAT');
if (node.kind === 'gateway' && throughThread) {
const translated = this.translateAtGateway(packet, frameIdentity, nodeId, networkInterface);
if (translated === undefined) return;
packet = translated;
}
if (node.kind === 'router') {
const decision = this.evaluateFirewall(packet, nodeId, networkInterface.portId, frameIdentity);
if (!decision.accepted) return;
@@ -4113,7 +4430,8 @@ export class NetworkRuntimeEngine {
if (!this.deliverIPv4Locally(packetForProcessing, nodeId, networkInterface, finished)) finished();
return;
}
if (node.kind !== 'router') {
// Java: routers and home routers forward (IpForwardingEnabled), nothing else.
if (node.kind !== 'router' && node.kind !== 'gateway') {
this.recordTrace({
frameIdentity,
packetIdentity: packetForProcessing.identity,