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:
@@ -39,6 +39,7 @@ import {
|
||||
type EthernetFrame,
|
||||
type EthernetPayload,
|
||||
type FirewallConfiguration,
|
||||
type NatEntry,
|
||||
type IPv4Packet,
|
||||
type NodeSnapshot,
|
||||
type RuntimeOutput,
|
||||
@@ -2147,3 +2148,240 @@ describe('RIP', () => {
|
||||
expect(engine.state.ripTablesByNodeId.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Home router NAT', () => {
|
||||
const [lanPcId, lanPcPort, gatewayId, wanPort, lanPort, wanPcId, wanPcPort] = [0x1c01, 0x1c02, 0x1c03, 0x1c04, 0x1c05, 0x1c06, 0x1c07].map(uuid) as [
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
];
|
||||
/** PC 192.168.0.10 — home router (WAN 42.0.0.10/8, LAN 192.168.0.1/24) — PC 42.0.0.20, each on its own cable. */
|
||||
const home = (options: { forwarding?: [string, string, string, string][]; firewall?: FirewallConfiguration; speed?: number } = {}) => {
|
||||
const engine = new NetworkRuntimeEngine(14);
|
||||
if (options.speed !== undefined) engine.setSimulationSpeed(options.speed);
|
||||
engine.handle({
|
||||
type: 'start',
|
||||
snapshot: topologySnapshot({
|
||||
nodes: [
|
||||
pc(lanPcId, lanPcPort),
|
||||
{ id: gatewayId, kind: 'gateway', ports: [portSnapshot(wanPort, 'wan0'), portSnapshot(lanPort, 'lan0')] },
|
||||
pc(wanPcId, wanPcPort),
|
||||
],
|
||||
links: [link(lanPcId, lanPcPort, gatewayId, lanPort), link(gatewayId, wanPort, wanPcId, wanPcPort)],
|
||||
deviceConfigurations: new Map([
|
||||
[lanPcId, device('192.168.0.10', { gateway: '192.168.0.1' })],
|
||||
[wanPcId, device('42.0.0.20', { mask: '255.0.0.0', gateway: '42.0.0.10' })],
|
||||
]),
|
||||
interfaceConfigurations: new Map([
|
||||
[interfaceKey(gatewayId, wanPort), { ipAddress: '42.0.0.10', subnetMask: '255.0.0.0' }],
|
||||
[interfaceKey(gatewayId, lanPort), { ipAddress: '192.168.0.1', subnetMask: '255.255.255.0' }],
|
||||
]),
|
||||
portForwardingRowsByNodeId: new Map([
|
||||
[gatewayId, (options.forwarding ?? []).map(([protocolValue, publicPortValue, lanIPAddress, lanPortValue]) => ({ protocolValue, publicPortValue, lanIPAddress, lanPortValue }))],
|
||||
]),
|
||||
...(options.firewall === undefined ? {} : { firewallConfigurationsByNodeId: new Map([[gatewayId, options.firewall]]) }),
|
||||
}),
|
||||
seed: 14,
|
||||
initialTimeMilliseconds: 0,
|
||||
});
|
||||
return engine;
|
||||
};
|
||||
const udpOut = (engine: NetworkRuntimeEngine, localPort = 5000) => {
|
||||
engine.sendUdp(engine.bindUdpSocket(lanPcId, { localPort, remoteIPAddress: '42.0.0.20', remotePort: 7 })!, new TextEncoder().encode('out'));
|
||||
letFramesArrive(engine);
|
||||
};
|
||||
|
||||
it("takes the port forwarding rows Java's input check takes, a later row for the same port replacing an earlier one", () => {
|
||||
const engine = home({
|
||||
forwarding: [
|
||||
['tcp', '8080', '192.168.0.10', '80'],
|
||||
['17', '53', '192.168.0.10', '53'],
|
||||
['6', '8080', '192.168.0.11', '81'],
|
||||
['ICMP', '1', '192.168.0.10', '1'],
|
||||
['TCP', '080', '192.168.0.10', '80'],
|
||||
['TCP', '65536', '192.168.0.10', '80'],
|
||||
['UDP', '9', '192.168.0.300', '9'],
|
||||
],
|
||||
});
|
||||
expect(engine.natEntries(gatewayId).map((entry) => `${entry.protocol} ${entry.port} ${entry.lan.ipAddress}:${entry.lan.port} ${entry.type}`)).toEqual([
|
||||
'tcp 8080 192.168.0.11:81 static',
|
||||
'udp 53 192.168.0.10:53 static',
|
||||
]);
|
||||
});
|
||||
|
||||
it('holds the WAN port of an entry against the router’s own sockets', () => {
|
||||
const engine = home();
|
||||
udpOut(engine);
|
||||
const [entry] = engine.natEntries(gatewayId) as [NatEntry];
|
||||
expect(engine.bindUdpSocket(gatewayId, { localPort: entry.port })).toBeUndefined();
|
||||
});
|
||||
|
||||
it("drops an entry at the first of Java's checks, each second from the start, when it is 300 s times the delay factor old; answers refresh it", () => {
|
||||
const engine = home({ speed: 100 });
|
||||
udpOut(engine);
|
||||
// At 100 %, the factor is 1: 300 s. The entry came at 15 ms (three cables of 5 ms); it is gone at the check at 301 s.
|
||||
const [entry] = engine.natEntries(gatewayId) as [NatEntry];
|
||||
expect(entry.updatedAtMilliseconds).toBe(15);
|
||||
engine.handle({ type: 'advance', toMilliseconds: 300_999 });
|
||||
expect(engine.natEntries(gatewayId)).toHaveLength(1);
|
||||
engine.handle({ type: 'advance', toMilliseconds: 301_000 });
|
||||
expect(engine.natEntries(gatewayId)).toHaveLength(0);
|
||||
|
||||
// Another, now refreshed by an answer, and not by what goes out.
|
||||
udpOut(engine, 5001);
|
||||
const [fresh] = engine.natEntries(gatewayId) as [NatEntry];
|
||||
engine.handle({ type: 'advance', toMilliseconds: 400_000 });
|
||||
udpOut(engine, 5001);
|
||||
expect(engine.natEntries(gatewayId)[0]?.updatedAtMilliseconds).toBe(fresh.updatedAtMilliseconds);
|
||||
const answer = engine.bindUdpSocket(wanPcId, { localPort: 7, remoteIPAddress: '42.0.0.10', remotePort: fresh.port })!;
|
||||
engine.sendUdp(answer, new TextEncoder().encode('back'));
|
||||
letFramesArrive(engine);
|
||||
expect(engine.natEntries(gatewayId)[0]!.updatedAtMilliseconds).toBeGreaterThan(400_000);
|
||||
});
|
||||
|
||||
it('passes both sides through the router’s firewall', () => {
|
||||
const engine = home({ firewall: firewallConfiguration({ isActive: true, defaultPolicy: 'drop' }) });
|
||||
udpOut(engine);
|
||||
expect(engine.natEntries(gatewayId)).toEqual([]);
|
||||
expect(engine.state.firewallDecisions.some((decision) => !decision.accepted && decision.nodeId === gatewayId)).toBe(true);
|
||||
});
|
||||
|
||||
it('answers a packet going out with TTL 1 with time exceeded, and drops what comes from the WAN for the LAN', () => {
|
||||
const engine = home();
|
||||
engine.sendIPv4Packet(lanPcId, {
|
||||
identity: engine.allocatePacketIdentity(),
|
||||
senderIPAddress: '192.168.0.10',
|
||||
receiverIPAddress: '42.0.0.20',
|
||||
timeToLive: 1,
|
||||
protocol: 'udp',
|
||||
payload: { kind: 'udp', datagram: { sourcePort: 5000, destinationPort: 7, payload: new Uint8Array() } },
|
||||
});
|
||||
letFramesArrive(engine);
|
||||
expect((engine.state.icmpObservationsByNodeId.get(lanPcId) ?? []).map((observation) => observation.message.kind)).toEqual(['timeExceeded']);
|
||||
expect(engine.natEntries(gatewayId)).toEqual([]);
|
||||
|
||||
engine.sendIPv4Packet(wanPcId, {
|
||||
identity: engine.allocatePacketIdentity(),
|
||||
senderIPAddress: '42.0.0.20',
|
||||
receiverIPAddress: '192.168.0.10',
|
||||
timeToLive: 64,
|
||||
protocol: 'udp',
|
||||
payload: { kind: 'udp', datagram: { sourcePort: 7, destinationPort: 5000, payload: new Uint8Array() } },
|
||||
});
|
||||
letFramesArrive(engine);
|
||||
expect(engine.state.packetTraces.some((trace) => trace.nodeId === gatewayId && trace.detail === 'WAN packet addressed to the LAN')).toBe(true);
|
||||
expect((engine.state.deliveredIPv4PacketsByNodeId.get(lanPcId) ?? []).filter((delivered) => delivered.packet.protocol === 'udp')).toEqual([]);
|
||||
});
|
||||
|
||||
const raw = (engine: NetworkRuntimeEngine, from: string, packet: Omit<IPv4Packet, 'identity' | 'timeToLive'>) => {
|
||||
const identity = engine.allocatePacketIdentity();
|
||||
engine.sendIPv4Packet(from, { identity, timeToLive: 64, ...packet });
|
||||
letFramesArrive(engine);
|
||||
return identity;
|
||||
};
|
||||
const echo = (identifier: number) => ({ kind: 'icmp' as const, message: icmpMessage('echoRequest', { identifier, sequenceNumber: 1 }) });
|
||||
|
||||
it('keeps static entries, and dynamic ones for 300 s times the delay factor (55 minutes at 90 %)', () => {
|
||||
const engine = home({ forwarding: [['UDP', '53', '192.168.0.10', '53']] });
|
||||
udpOut(engine);
|
||||
engine.handle({ type: 'advance', toMilliseconds: 3_300_000 });
|
||||
expect(engine.natEntries(gatewayId).map((entry) => entry.type)).toEqual(['static', 'dynamic']);
|
||||
engine.handle({ type: 'advance', toMilliseconds: 3_302_000 });
|
||||
expect(engine.natEntries(gatewayId).map((entry) => entry.type)).toEqual(['static']);
|
||||
});
|
||||
|
||||
it('opens an entry with a SYN, not with other TCP segments; they leave with port 0', () => {
|
||||
const engine = home();
|
||||
raw(engine, lanPcId, {
|
||||
senderIPAddress: '192.168.0.10',
|
||||
receiverIPAddress: '42.0.0.20',
|
||||
protocol: 'tcp',
|
||||
payload: { kind: 'tcp', segment: { sourcePort: 5000, destinationPort: 80, sequenceNumber: 1, acknowledgementNumber: 1, flags: TCP_FLAG.acknowledgement, payload: new Uint8Array() } },
|
||||
});
|
||||
expect(engine.natEntries(gatewayId)).toEqual([]);
|
||||
const arrived = engine.state.packetTraces.find((trace) => trace.nodeId === wanPcId && trace.detail === 'TCP destination port closed')!;
|
||||
expect(arrived.beforeHeaders.find((header) => header.name === 'sourcePort')?.value).toBe('0');
|
||||
});
|
||||
|
||||
it('lets broadcasts from the LAN be', () => {
|
||||
const engine = home();
|
||||
engine.sendUdp(engine.bindUdpSocket(lanPcId, { localPort: 5000 })!, new TextEncoder().encode('all'), { ipAddress: '255.255.255.255', port: 7 });
|
||||
letFramesArrive(engine);
|
||||
expect(engine.natEntries(gatewayId)).toEqual([]);
|
||||
expect(engine.state.icmpObservationsByNodeId.get(lanPcId) ?? []).toEqual([]);
|
||||
});
|
||||
|
||||
it('replaces an entry for the same remote end, port and protocol (Java: a map), as two hosts pinging with one identifier do', () => {
|
||||
const engine = home();
|
||||
raw(engine, lanPcId, { senderIPAddress: '192.168.0.10', receiverIPAddress: '42.0.0.20', protocol: 'icmp', payload: echo(1) });
|
||||
raw(engine, lanPcId, { senderIPAddress: '192.168.0.11', receiverIPAddress: '42.0.0.20', protocol: 'icmp', payload: echo(1) });
|
||||
expect(engine.natEntries(gatewayId).map((entry) => `${entry.remoteIPAddress}:${entry.port} ${entry.lan.ipAddress}:${entry.lan.port}`)).toEqual([
|
||||
'42.0.0.20:1 192.168.0.11:1',
|
||||
]);
|
||||
});
|
||||
|
||||
it('answers a ping at its WAN address itself, even when an entry would fit', () => {
|
||||
const engine = home();
|
||||
raw(engine, lanPcId, { senderIPAddress: '192.168.0.10', receiverIPAddress: '42.0.0.20', protocol: 'icmp', payload: echo(1) });
|
||||
raw(engine, wanPcId, { senderIPAddress: '42.0.0.20', receiverIPAddress: '42.0.0.10', protocol: 'icmp', payload: echo(1) });
|
||||
const replies = (engine.state.icmpObservationsByNodeId.get(wanPcId) ?? []).filter((observation) => observation.message.kind === 'echoReply');
|
||||
expect(replies.map((observation) => observation.packet.senderIPAddress)).toContain('42.0.0.10');
|
||||
expect(engine.state.packetTraces.filter((trace) => trace.nodeId === lanPcId && trace.detail === 'ICMP echo reply')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('sends an ICMP error for a packet it translated to the LAN endpoint, found by the packet it carries', () => {
|
||||
const engine = home();
|
||||
udpOut(engine);
|
||||
const [entry] = engine.natEntries(gatewayId) as [NatEntry];
|
||||
raw(engine, wanPcId, {
|
||||
senderIPAddress: '42.0.0.20',
|
||||
receiverIPAddress: '42.0.0.10',
|
||||
protocol: 'icmp',
|
||||
payload: {
|
||||
kind: 'icmp',
|
||||
message: icmpMessage('destinationHostUnreachable', {
|
||||
embeddedOriginalPacket: {
|
||||
identity: 1,
|
||||
senderIPAddress: '42.0.0.10',
|
||||
receiverIPAddress: '42.0.0.20',
|
||||
timeToLive: 63,
|
||||
protocol: 'udp',
|
||||
payload: { kind: 'udp', datagram: { sourcePort: entry.port, destinationPort: 7, payload: new Uint8Array() } },
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
expect((engine.state.icmpObservationsByNodeId.get(lanPcId) ?? []).map((observation) => observation.message.kind)).toEqual(['destinationHostUnreachable']);
|
||||
});
|
||||
|
||||
it('lets a forwarded endpoint reach a new remote end through its forwarding, whose answer makes the entry', () => {
|
||||
const engine = home({ forwarding: [['UDP', '53', '192.168.0.10', '53']] });
|
||||
udpOut(engine, 53);
|
||||
const answer = engine.bindUdpSocket(wanPcId, { localPort: 7, remoteIPAddress: '42.0.0.10', remotePort: 53 })!;
|
||||
engine.sendUdp(answer, new TextEncoder().encode('back'));
|
||||
letFramesArrive(engine);
|
||||
expect(engine.natEntries(gatewayId).map((entry) => `${entry.type} ${entry.remoteIPAddress}:${entry.port}`)).toEqual(['static :53', 'dynamicFromStatic 42.0.0.20:53']);
|
||||
});
|
||||
|
||||
it('drops at its WAN side what its firewall drops', () => {
|
||||
const engine = home({ firewall: firewallConfiguration({ isActive: true, defaultPolicy: 'drop' }) });
|
||||
const identity = raw(engine, wanPcId, {
|
||||
senderIPAddress: '42.0.0.20',
|
||||
receiverIPAddress: '42.0.0.10',
|
||||
protocol: 'udp',
|
||||
payload: { kind: 'udp', datagram: { sourcePort: 7, destinationPort: 9, payload: new Uint8Array() } },
|
||||
});
|
||||
expect(engine.state.packetTraces.some((trace) => trace.nodeId === gatewayId && trace.packetIdentity === identity && trace.layer === 'transport')).toBe(false);
|
||||
});
|
||||
|
||||
it('forgets its tables on stop', () => {
|
||||
const engine = home();
|
||||
udpOut(engine);
|
||||
engine.handle({ type: 'stop' });
|
||||
expect(engine.state.natTablesByNodeId.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user