feat(core): RIP after Java's code
Where the Swift template departs from Java's filius.software.rip, Java's code rules: a learned route gets 16 hops at the router's next beacon after it expired (RIPTable.check), not when it expires; messages count from any source port; each router draws its pauses from a random of its own (Java: an unseeded Random per router), which the core seeds from the scenario and the router's id (ripSeed), so that the Java oracle can give it the same. A route of hop count 0 to each own network, the first beacon after a second, then every 25.2 to 35.2 s; a message per interface without the routes through it; a new route or a longer one from the same router sends at once; forwarding by the table alone, fewest hops before longest mask. Messages are read with Java's parser (javaIpCheck, javaParseInt, moved from fls to core for it). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -24,6 +24,7 @@ import {
|
||||
icmpMessage,
|
||||
interfaceKey,
|
||||
JavaCompatibleRandom,
|
||||
ripSeed,
|
||||
NetworkRuntimeEngine,
|
||||
portSnapshot,
|
||||
stableMacAddress,
|
||||
@@ -379,15 +380,6 @@ describe('UDP sockets', () => {
|
||||
expect(arrival(true)).toEqual({ returnedAt: 165, waiting: 1, receivedAt: 165 });
|
||||
});
|
||||
|
||||
it('draws ephemeral ports without disturbing the seeded RIP jitter', () => {
|
||||
const nodeId = uuid(0x15f1);
|
||||
const input = { nodes: [pc(nodeId, uuid(0x15f2))], links: [], deviceConfigurations: new Map([[nodeId, device('192.168.10.10')]]) };
|
||||
const socketEngine = startedEngine(input);
|
||||
const ripOnlyEngine = startedEngine(input);
|
||||
|
||||
expect(socketEngine.bindUdpSocket(nodeId)).toBeDefined();
|
||||
expect(socketEngine.nextRipJitterMilliseconds()).toBe(ripOnlyEngine.nextRipJitterMilliseconds());
|
||||
});
|
||||
|
||||
it('broadcasts on every interface, and the receiver replies to its last peer', () => {
|
||||
const [routerId, routerPortA, routerPortB, pcAId, pcAPort, pcBId, pcBPort] = [0x15e1, 0x15e2, 0x15e3, 0x15e4, 0x15e5, 0x15e6, 0x15e7].map(
|
||||
@@ -1925,3 +1917,229 @@ describe("Java's IP and ICMP threads", () => {
|
||||
expect(engine.state.currentTimeMilliseconds).toBe(500);
|
||||
});
|
||||
});
|
||||
|
||||
describe('RIP', () => {
|
||||
const [routerAId, routerAShared, routerALan, routerBId, routerBShared, routerBLan] = [0x1501, 0x1511, 0x1512, 0x1502, 0x1513, 0x1514].map(uuid) as [
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
];
|
||||
const [lanPcId, lanPcPort] = [0x1503, 0x1515].map(uuid) as [string, string];
|
||||
/**
|
||||
* Router A (10.0.0.1, 192.168.0.1/24) and B (10.0.0.2, 172.16.0.1/16) on
|
||||
* one cable, both with RIP; a PC on A's LAN if asked for.
|
||||
*/
|
||||
const ripTopology = (options: { manualRoutes?: boolean; lanPc?: boolean } = {}) =>
|
||||
startedEngine(
|
||||
{
|
||||
nodes: [
|
||||
{ id: routerAId, kind: 'router', ports: [portSnapshot(routerAShared, 'rt1'), portSnapshot(routerALan, 'rt2')] },
|
||||
{ id: routerBId, kind: 'router', ports: [portSnapshot(routerBShared, 'rt1'), portSnapshot(routerBLan, 'rt2')] },
|
||||
...(options.lanPc === true ? [pc(lanPcId, lanPcPort)] : []),
|
||||
],
|
||||
links: [
|
||||
link(routerAId, routerAShared, routerBId, routerBShared),
|
||||
...(options.lanPc === true ? [link(routerAId, routerALan, lanPcId, lanPcPort)] : []),
|
||||
],
|
||||
...(options.lanPc === true ? { deviceConfigurations: new Map([[lanPcId, device('192.168.0.2', { gateway: '192.168.0.1' })]]) } : {}),
|
||||
interfaceConfigurations: new Map([
|
||||
[interfaceKey(routerAId, routerAShared), { ipAddress: '10.0.0.1', subnetMask: '255.255.255.0' }],
|
||||
[interfaceKey(routerAId, routerALan), { ipAddress: '192.168.0.1', subnetMask: '255.255.255.0' }],
|
||||
[interfaceKey(routerBId, routerBShared), { ipAddress: '10.0.0.2', subnetMask: '255.255.255.0' }],
|
||||
[interfaceKey(routerBId, routerBLan), { ipAddress: '172.16.0.1', subnetMask: '255.255.0.0' }],
|
||||
]),
|
||||
...(options.manualRoutes === true
|
||||
? { manualRoutesByNodeId: new Map([[routerAId, [{ destinationNetwork: '203.0.113.0', subnetMask: '255.255.255.0', gateway: '10.0.0.2', interfaceIPAddress: '10.0.0.1' }]]]) }
|
||||
: {}),
|
||||
ripEnabledByNodeId: new Map([
|
||||
[routerAId, true],
|
||||
[routerBId, true],
|
||||
]),
|
||||
},
|
||||
15,
|
||||
);
|
||||
const route = (engine: NetworkRuntimeEngine, nodeId: string, network: string) =>
|
||||
engine.state.ripTablesByNodeId.get(nodeId)?.find((each) => each.destinationNetwork === network);
|
||||
const beacons = (engine: NetworkRuntimeEngine, nodeId: string) =>
|
||||
engine.state.pendingEvents.filter((event) => event.kind.kind === 'ripBeacon' && event.kind.nodeId === nodeId).map((event) => event.deadlineMilliseconds);
|
||||
const advertisements = (engine: NetworkRuntimeEngine, receiverId: string) =>
|
||||
engine.state.packetTraces
|
||||
.filter((trace) => trace.nodeId === receiverId && trace.detail === 'UDP queued' && trace.beforeHeaders.some((header) => header.name === 'destinationPort' && header.value === '520'))
|
||||
.map((trace) => trace.timeMilliseconds);
|
||||
/** When the router sent its table (on its first interface). */
|
||||
const sentTables = (engine: NetworkRuntimeEngine, nodeId: string) =>
|
||||
engine.state.packetTraces
|
||||
.filter((trace) => trace.nodeId === nodeId && trace.detail?.startsWith('RIP advertisement') === true)
|
||||
.map((trace) => trace.timeMilliseconds)
|
||||
.filter((time, index, times) => times.indexOf(time) === index);
|
||||
/** A message as a router at 10.0.0.2 sends it, from any port, straight to A: the time it arrives. */
|
||||
const fromB = (engine: NetworkRuntimeEngine, text: string, sourcePort = 521): number => {
|
||||
const identity = engine.allocatePacketIdentity();
|
||||
engine.sendIPv4Packet(routerBId, {
|
||||
identity,
|
||||
senderIPAddress: '10.0.0.2',
|
||||
receiverIPAddress: '10.0.0.1',
|
||||
timeToLive: 1,
|
||||
protocol: 'udp',
|
||||
payload: { kind: 'udp', datagram: { sourcePort, destinationPort: 520, payload: new TextEncoder().encode(text) } },
|
||||
});
|
||||
while (!(engine.state.deliveredIPv4PacketsByNodeId.get(routerAId) ?? []).some((delivered) => delivered.packet.identity === identity)) {
|
||||
engine.handle({ type: 'advance', toMilliseconds: Math.min(...engine.state.pendingEvents.map((event) => event.deadlineMilliseconds)) });
|
||||
}
|
||||
return engine.state.currentTimeMilliseconds;
|
||||
};
|
||||
|
||||
it('starts with a route of hop count 0 to each own network that never expires, the first beacon after a second', () => {
|
||||
const engine = ripTopology();
|
||||
expect(engine.state.ripTablesByNodeId.get(routerAId)).toEqual([
|
||||
{ destinationNetwork: '10.0.0.0', subnetMask: '255.255.255.0', nextHop: '10.0.0.1', interfaceIPAddress: '10.0.0.1', origin: 'connected', isEditable: false, metric: 0 },
|
||||
{ destinationNetwork: '192.168.0.0', subnetMask: '255.255.255.0', nextHop: '192.168.0.1', interfaceIPAddress: '192.168.0.1', origin: 'connected', isEditable: false, metric: 0 },
|
||||
]);
|
||||
expect(beacons(engine, routerAId)).toEqual([1_000]);
|
||||
// The server's socket on 520, the beacon's on 521.
|
||||
expect(engine.bindUdpSocket(routerAId, { localPort: 520 })).toBeUndefined();
|
||||
expect(engine.bindUdpSocket(routerAId, { localPort: 521 })).toBeUndefined();
|
||||
});
|
||||
|
||||
it("converges with split horizon, each router's pauses drawn from a random of its own", () => {
|
||||
const engine = ripTopology();
|
||||
engine.handle({ type: 'advance', toMilliseconds: 2_000 });
|
||||
|
||||
expect(route(engine, routerAId, '172.16.0.0')).toMatchObject({ nextHop: '10.0.0.2', interfaceIPAddress: '10.0.0.1', metric: 1, origin: 'rip', expiresAtMilliseconds: 1_110 + 75_000 });
|
||||
expect(route(engine, routerBId, '192.168.0.0')).toMatchObject({ nextHop: '10.0.0.1', metric: 1 });
|
||||
const payloads = (engine.state.deliveredIPv4PacketsByNodeId.get(routerBId) ?? [])
|
||||
.filter((delivered) => delivered.packet.senderIPAddress === '10.0.0.1')
|
||||
.map((delivered) => (delivered.packet.payload.kind === 'udp' ? new TextDecoder().decode(delivered.packet.payload.datagram.payload) : ''));
|
||||
// A's first message, on the cable to B: its LAN, not the shared network.
|
||||
expect(payloads[0]).toBe('10.0.0.1\n10.0.0.1\n16\n75000\n192.168.0.0 255.255.255.0 0');
|
||||
// Both beacons at 1 s; each learns a route 55 ms later and sends at once, then pauses.
|
||||
expect(advertisements(engine, routerBId)).toEqual([1_055, 1_110]);
|
||||
const random = new JavaCompatibleRandom(ripSeed(15, routerAId));
|
||||
random.nextRipJitterMilliseconds();
|
||||
expect(beacons(engine, routerAId)).toEqual([1_055 + random.nextRipJitterMilliseconds()]);
|
||||
});
|
||||
|
||||
it('names its first address as the public one in every message', () => {
|
||||
const engine = ripTopology({ lanPc: true });
|
||||
engine.handle({ type: 'advance', toMilliseconds: 2_000 });
|
||||
const payloads = (engine.state.deliveredIPv4PacketsByNodeId.get(lanPcId) ?? []).map((delivered) =>
|
||||
delivered.packet.payload.kind === 'udp' ? new TextDecoder().decode(delivered.packet.payload.datagram.payload) : '',
|
||||
);
|
||||
// On the LAN: from 192.168.0.1, public 10.0.0.1; after it learned B's network.
|
||||
expect(payloads.at(-1)).toBe('192.168.0.1\n10.0.0.1\n16\n75000\n10.0.0.0 255.255.255.0 0\n172.16.0.0 255.255.0.0 1');
|
||||
});
|
||||
|
||||
it('draws the pauses unmoved by the ports sockets draw', () => {
|
||||
const plain = ripTopology();
|
||||
const withSocket = ripTopology();
|
||||
withSocket.bindUdpSocket(routerAId);
|
||||
plain.handle({ type: 'advance', toMilliseconds: 2_000 });
|
||||
withSocket.handle({ type: 'advance', toMilliseconds: 2_000 });
|
||||
expect(beacons(withSocket, routerAId)).toEqual(beacons(plain, routerAId));
|
||||
});
|
||||
|
||||
it('marks a learned route unreachable at the first beacon after it expired, and keeps it (Java: RIPTable.check)', () => {
|
||||
const engine = ripTopology();
|
||||
engine.handle({ type: 'advance', toMilliseconds: 2_000 });
|
||||
engine.setGlobalPacketLossEnabled(true);
|
||||
const expiry = route(engine, routerAId, '172.16.0.0')!.expiresAtMilliseconds!;
|
||||
engine.handle({ type: 'advance', toMilliseconds: expiry + 1 });
|
||||
expect(route(engine, routerAId, '172.16.0.0')?.metric).toBe(1);
|
||||
const [beacon] = beacons(engine, routerAId) as [number];
|
||||
engine.handle({ type: 'advance', toMilliseconds: beacon - 1 });
|
||||
expect(route(engine, routerAId, '172.16.0.0')?.metric).toBe(1);
|
||||
engine.handle({ type: 'advance', toMilliseconds: beacon });
|
||||
expect(route(engine, routerAId, '172.16.0.0')).toMatchObject({ metric: 16, expiresAtMilliseconds: expiry });
|
||||
});
|
||||
|
||||
it('routes with its table alone: a manual route does not count', () => {
|
||||
const engine = ripTopology({ manualRoutes: true });
|
||||
const packet: IPv4Packet = {
|
||||
identity: engine.allocatePacketIdentity(),
|
||||
senderIPAddress: '10.0.0.1',
|
||||
receiverIPAddress: '203.0.113.20',
|
||||
timeToLive: 64,
|
||||
protocol: 'udp',
|
||||
payload: { kind: 'udp', datagram: { sourcePort: 6000, destinationPort: 7000, payload: new Uint8Array() } },
|
||||
};
|
||||
expect(engine.sendIPv4Packet(routerAId, packet)).toEqual({ type: 'icmpError', packetIdentity: packet.identity, nodeId: routerAId, kind: 'destinationNetworkUnreachable' });
|
||||
});
|
||||
|
||||
it('forwards along the fewest hops, then the longest mask, and not along 16 hops', () => {
|
||||
const engine = ripTopology();
|
||||
// The /24 through a router at 10.0.0.3, which is not there.
|
||||
fromB(engine, '10.0.0.3\n10.0.0.3\n16\n75000\n203.0.113.0 255.255.255.0 4');
|
||||
fromB(engine, '10.0.0.2\n10.0.0.2\n16\n75000\n203.0.0.0 255.255.0.0 2\n40.0.0.0 255.0.0.0 3');
|
||||
fromB(engine, '10.0.0.2\n10.0.0.2\n16\n75000\n40.0.0.0 255.0.0.0 15');
|
||||
const packet: IPv4Packet = {
|
||||
identity: engine.allocatePacketIdentity(),
|
||||
senderIPAddress: '192.168.0.1',
|
||||
receiverIPAddress: '203.0.113.20',
|
||||
timeToLive: 64,
|
||||
protocol: 'udp',
|
||||
payload: { kind: 'udp', datagram: { sourcePort: 6000, destinationPort: 7000, payload: new Uint8Array() } },
|
||||
};
|
||||
engine.sendIPv4Packet(routerAId, packet);
|
||||
letFramesArrive(engine);
|
||||
// Through the /16 with 3 hops, not the /24 with 5.
|
||||
expect(engine.state.packetTraces.some((trace) => trace.nodeId === routerBId && trace.packetIdentity === packet.identity)).toBe(true);
|
||||
expect(route(engine, routerAId, '203.0.113.0')).toMatchObject({ metric: 5, nextHop: '10.0.0.3' });
|
||||
// 40.0.0.0 went to 16 hops from the router it went through: no route.
|
||||
expect(route(engine, routerAId, '40.0.0.0')?.metric).toBe(16);
|
||||
const unreachable: IPv4Packet = { ...packet, identity: engine.allocatePacketIdentity(), receiverIPAddress: '40.0.0.1' };
|
||||
expect(engine.sendIPv4Packet(routerAId, unreachable)).toEqual({
|
||||
type: 'icmpError',
|
||||
packetIdentity: unreachable.identity,
|
||||
nodeId: routerAId,
|
||||
kind: 'destinationNetworkUnreachable',
|
||||
});
|
||||
});
|
||||
|
||||
it("learns after Java's rules, from a message of any port, and ignores what is not for it", () => {
|
||||
const engine = ripTopology();
|
||||
let arrived = fromB(engine, '10.0.0.2\n10.0.0.2\n16\n75000\n30.0.0.0 255.0.0.0 3\n31.0.0.0 255.0.0.0 15\n32.0.0.0 255.0.0.0 16', 4000);
|
||||
expect(route(engine, routerAId, '30.0.0.0')).toMatchObject({ metric: 4, nextHop: '10.0.0.2', expiresAtMilliseconds: arrived + 75_000 });
|
||||
// 15 + 1 is unreachable: not taken.
|
||||
expect(route(engine, routerAId, '31.0.0.0')).toBeUndefined();
|
||||
expect(route(engine, routerAId, '32.0.0.0')).toBeUndefined();
|
||||
// A new route sends the table at once.
|
||||
expect(sentTables(engine, routerAId)).toContain(arrived);
|
||||
// Own networks stay; the message's own infinity counts too.
|
||||
arrived = fromB(engine, '10.0.0.2\n10.0.0.2\n4\n75000\n192.168.0.0 255.255.255.0 0\n33.0.0.0 255.0.0.0 4');
|
||||
expect(route(engine, routerAId, '192.168.0.0')).toMatchObject({ metric: 0, nextHop: '192.168.0.1' });
|
||||
expect(route(engine, routerAId, '33.0.0.0')).toBeUndefined();
|
||||
expect(sentTables(engine, routerAId)).not.toContain(arrived);
|
||||
// Shorter from the router it goes through: taken, not sent at once.
|
||||
arrived = fromB(engine, '10.0.0.2\n10.0.0.2\n16\n75000\n30.0.0.0 255.0.0.0 1');
|
||||
expect(route(engine, routerAId, '30.0.0.0')?.metric).toBe(2);
|
||||
expect(sentTables(engine, routerAId)).not.toContain(arrived);
|
||||
// Longer from the router it goes through: taken, and sent at once.
|
||||
arrived = fromB(engine, '10.0.0.2\n10.0.0.2\n16\n75000\n30.0.0.0 255.0.0.0 6');
|
||||
expect(route(engine, routerAId, '30.0.0.0')?.metric).toBe(7);
|
||||
expect(sentTables(engine, routerAId)).toContain(arrived);
|
||||
// Shorter through another router: it goes there now; not shorter: it stays.
|
||||
fromB(engine, '10.0.0.3\n10.0.0.3\n16\n75000\n30.0.0.0 255.0.0.0 2');
|
||||
expect(route(engine, routerAId, '30.0.0.0')).toMatchObject({ metric: 3, nextHop: '10.0.0.3', interfaceIPAddress: '10.0.0.1' });
|
||||
fromB(engine, '10.0.0.2\n10.0.0.2\n16\n75000\n30.0.0.0 255.0.0.0 2');
|
||||
expect(route(engine, routerAId, '30.0.0.0')).toMatchObject({ metric: 3, nextHop: '10.0.0.3' });
|
||||
// Empty lines at the end do not count (Java's split).
|
||||
fromB(engine, '10.0.0.2\n10.0.0.2\n16\n75000\n39.0.0.0 255.0.0.0 1\n\n');
|
||||
expect(route(engine, routerAId, '39.0.0.0')?.metric).toBe(2);
|
||||
// From elsewhere, or from itself, or not a message: nothing.
|
||||
fromB(engine, '10.9.0.2\n10.0.0.2\n16\n75000\n34.0.0.0 255.0.0.0 1');
|
||||
fromB(engine, '10.0.0.1\n10.0.0.1\n16\n75000\n35.0.0.0 255.0.0.0 1');
|
||||
fromB(engine, '10.0.0.2\n10.0.0.2\n16\n0\n36.0.0.0 255.0.0.0 1');
|
||||
fromB(engine, '10.0.0.2\n10.0.0.2\n16\n75000\n37.0.0.0 255.0.0.0 1\n38.0.0.0 255.0.0.0');
|
||||
for (const network of ['34.0.0.0', '35.0.0.0', '36.0.0.0', '37.0.0.0']) expect(route(engine, routerAId, network), network).toBeUndefined();
|
||||
});
|
||||
|
||||
it('forgets its tables on stop', () => {
|
||||
const engine = ripTopology();
|
||||
engine.handle({ type: 'advance', toMilliseconds: 2_000 });
|
||||
engine.handle({ type: 'stop' });
|
||||
expect(engine.state.ripTablesByNodeId.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user