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:
@@ -16,4 +16,5 @@ export * from './runtime/snapshot.js';
|
||||
export * from './runtime/speed.js';
|
||||
export * from './runtime/trace.js';
|
||||
export * from './util/equal.js';
|
||||
export * from './util/javaIp.js';
|
||||
export * from './util/swiftString.js';
|
||||
|
||||
@@ -4,8 +4,9 @@
|
||||
// "Deterministic runtime engine" (lines ~984–1284)
|
||||
// "Ethernet, ARP, IPv4, and ICMP packet runtime" (lines ~1284–3585,
|
||||
// without remote link, wireless and gateway NAT; with the firewall)
|
||||
// "UDP sockets and Router RIP" (lines ~4472–5187, the
|
||||
// UDP sockets; RIP not yet)
|
||||
// "UDP sockets and Router RIP" (lines ~4472–5187; RIP
|
||||
// after Java's filius.software.rip: expiry checked at the beacon, a
|
||||
// random of its own per router, any source port)
|
||||
// "Java-compatible TCP sockets" (lines ~5187–5913; the
|
||||
// timing and closing after Java's TCPSocket, measured with Java
|
||||
// FILIUS: 3 round trips per attempt and for TIME_WAIT, the other end
|
||||
@@ -42,7 +43,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, RIP routing, remote links.
|
||||
// gateway NAT, remote links.
|
||||
|
||||
import { compareIds, type Id } from '../ids.js';
|
||||
import { isPcClassEndpoint, stableGeneratedMacAddress } from '../model/node.js';
|
||||
@@ -64,7 +65,8 @@ import {
|
||||
firewallConfiguration,
|
||||
interfaceKey,
|
||||
} from './config.js';
|
||||
import { maskAddress, parseIPv4, sameSubnet } from './ipv4.js';
|
||||
import { formatIPv4, maskAddress, parseIPv4, sameSubnet } from './ipv4.js';
|
||||
import { javaIpCheck, javaParseInt } from '../util/javaIp.js';
|
||||
import {
|
||||
forwardingClone,
|
||||
icmpMessage,
|
||||
@@ -449,6 +451,83 @@ export const UDP_EPHEMERAL_PORT_LOWER_BOUND = 49_152;
|
||||
export const UDP_EPHEMERAL_PORT_UPPER_BOUND_EXCLUSIVE = 65_535;
|
||||
export const RIP_DESTINATION_PORT = 520;
|
||||
export const RIP_SOURCE_PORT = 521;
|
||||
/** RIPTable: 16 hops is unreachable, a beacon every 30 s (drawn 25.2–35.2 s), a learned route lasts 75 s. */
|
||||
export const RIP_INFINITY = 16;
|
||||
export const RIP_TIMEOUT_MILLISECONDS = 75_000;
|
||||
export const RIP_FIRST_BEACON_DELAY_MILLISECONDS = 1_000;
|
||||
|
||||
/** RIPMessage: the sender's interface address, its first address, infinity, the timeout, its routes. */
|
||||
interface RipMessage {
|
||||
readonly ipAddress: string;
|
||||
readonly publicIPAddress: string;
|
||||
readonly infinity: number;
|
||||
readonly timeoutMilliseconds: number;
|
||||
readonly routes: RipMessageRoute[];
|
||||
}
|
||||
|
||||
interface RipMessageRoute {
|
||||
readonly network: string;
|
||||
readonly mask: string;
|
||||
readonly hops: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* RIPMessage.addRoute: a network goes in once. Java keeps the fewer hops of
|
||||
* the two; only a router's own networks can be in its table twice (two
|
||||
* interfaces in one network), both with hop count 0.
|
||||
*/
|
||||
function addRipMessageRoute(message: RipMessage, route: RipMessageRoute): void {
|
||||
if (!message.routes.some((each) => each.network === route.network && each.mask === route.mask)) message.routes.push(route);
|
||||
}
|
||||
|
||||
/** RIPMessage.toString: four lines, then a line `network mask hops` per route. */
|
||||
function encodeRipMessage(message: RipMessage): string {
|
||||
return [
|
||||
message.ipAddress,
|
||||
message.publicIPAddress,
|
||||
String(message.infinity),
|
||||
String(message.timeoutMilliseconds),
|
||||
...message.routes.map((route) => `${route.network} ${route.mask} ${route.hops}`),
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* RIPMessage(String): undefined where Java throws. Java's split drops the
|
||||
* empty lines at the end; a route line's first three fields count, split at
|
||||
* each space.
|
||||
*/
|
||||
function decodeRipMessage(text: string): RipMessage | undefined {
|
||||
const lines = text.split('\n');
|
||||
while (lines.length > 0 && lines[lines.length - 1] === '') lines.pop();
|
||||
if (lines.length < 4) return undefined;
|
||||
const ipAddress = javaIpCheck(lines[0]!);
|
||||
const publicIPAddress = javaIpCheck(lines[1]!);
|
||||
const infinity = javaParseInt(lines[2]!);
|
||||
const timeoutMilliseconds = javaParseInt(lines[3]!);
|
||||
if (ipAddress === null || publicIPAddress === null || infinity === undefined || timeoutMilliseconds === undefined) return undefined;
|
||||
if (timeoutMilliseconds <= 0 || infinity <= 0) return undefined;
|
||||
const routes: RipMessageRoute[] = [];
|
||||
for (const line of lines.slice(4)) {
|
||||
const fields = line.split(' ');
|
||||
while (fields.length > 0 && fields[fields.length - 1] === '') fields.pop();
|
||||
if (fields.length < 3) return undefined;
|
||||
const network = javaIpCheck(fields[0]!);
|
||||
const mask = javaIpCheck(fields[1]!);
|
||||
const hops = javaParseInt(fields[2]!);
|
||||
if (network === null || mask === null || hops === undefined || hops < 0) return undefined;
|
||||
routes.push({ network, mask, hops });
|
||||
}
|
||||
return { ipAddress, publicIPAddress, infinity, timeoutMilliseconds, routes };
|
||||
}
|
||||
|
||||
/** The seed of a router's RIP random: the scenario's, mixed with the router's id (FNV-1a). */
|
||||
export function ripSeed(seed: bigint | number, nodeId: Id): bigint {
|
||||
let hash = 0x811c_9dc5;
|
||||
for (let index = 0; index < nodeId.length; index += 1) {
|
||||
hash = Math.imul(hash ^ nodeId.charCodeAt(index), 0x0100_0193) >>> 0;
|
||||
}
|
||||
return BigInt(seed) ^ 0x5249_5000n ^ BigInt(hash);
|
||||
}
|
||||
|
||||
/** Swift's deterministicSocketID: the sequence number as the UUID's last twelve hex digits. */
|
||||
export function deterministicSocketId(sequence: number): Id {
|
||||
@@ -483,6 +562,8 @@ export interface RuntimeState {
|
||||
dhcpServerSocketIdsByNodeId: Map<Id, Id>;
|
||||
/** Each DNS server's socket on port 53. */
|
||||
dnsServerSocketIdsByNodeId: Map<Id, Id>;
|
||||
/** Each RIP router's table (RIPTable.routes): its networks, then what it learned. */
|
||||
ripTablesByNodeId: Map<Id, RouteRow[]>;
|
||||
dhcpClientStatusesByNodeId: Map<Id, DhcpClientStatus>;
|
||||
dhcpClientContextsByNodeId: Map<Id, DhcpClientContext>;
|
||||
nextSocketSequenceNumber: number;
|
||||
@@ -521,6 +602,7 @@ function initialState(seed: bigint): RuntimeState {
|
||||
dhcpLastOfferedAddressByServerNodeId: new Map(),
|
||||
dhcpServerSocketIdsByNodeId: new Map(),
|
||||
dnsServerSocketIdsByNodeId: new Map(),
|
||||
ripTablesByNodeId: new Map(),
|
||||
dhcpClientStatusesByNodeId: new Map(),
|
||||
dhcpClientContextsByNodeId: new Map(),
|
||||
nextSocketSequenceNumber: 1,
|
||||
@@ -629,6 +711,8 @@ export class NetworkRuntimeEngine {
|
||||
/** ARP lookups waiting for their answer (Java: a thread in holeARPTabellenEintrag). */
|
||||
private arpLookups: ArpLookup[] = [];
|
||||
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 }>();
|
||||
/** 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`, …). */
|
||||
@@ -704,7 +788,7 @@ export class NetworkRuntimeEngine {
|
||||
this.dnsIdRandom = new JavaCompatibleRandom(seed ^ 0x444e_5349n);
|
||||
this.isProcessingScheduledEvents = false;
|
||||
this.cableBusyUntilByPort.clear();
|
||||
this.initializeRipState();
|
||||
this.initializeRipState(seed);
|
||||
this.initializeDhcpState();
|
||||
this.initializeDnsState();
|
||||
this.initializeNatState();
|
||||
@@ -733,6 +817,8 @@ export class NetworkRuntimeEngine {
|
||||
state.tcpAcceptedSocketIdsByListenerId.clear();
|
||||
this.clearDhcpState();
|
||||
state.dnsServerSocketIdsByNodeId.clear();
|
||||
state.ripTablesByNodeId.clear();
|
||||
this.ripRoutersByNodeId.clear();
|
||||
state.firewallDecisions = [];
|
||||
return [this.output({ type: 'stopped' })];
|
||||
}
|
||||
@@ -758,10 +844,6 @@ export class NetworkRuntimeEngine {
|
||||
}
|
||||
}
|
||||
|
||||
nextRipJitterMilliseconds(): number {
|
||||
return this.random.nextRipJitterMilliseconds();
|
||||
}
|
||||
|
||||
allocateFrameIdentity(): number {
|
||||
return this.stateValue.nextFrameIdentity++;
|
||||
}
|
||||
@@ -836,14 +918,12 @@ export class NetworkRuntimeEngine {
|
||||
const [event] = this.stateValue.pendingEvents.splice(nextEventIndex, 1) as [ScheduledEvent];
|
||||
this.stateValue.currentTimeMilliseconds = event.deadlineMilliseconds;
|
||||
this.ageAllSwitchSatEntries();
|
||||
this.expireRipRoutes();
|
||||
this.processScheduledRuntimeEvent(event.kind);
|
||||
outputs.push(this.output({ type: 'fired', event }));
|
||||
}
|
||||
|
||||
this.stateValue.currentTimeMilliseconds = targetTimeMilliseconds;
|
||||
this.ageAllSwitchSatEntries();
|
||||
this.expireRipRoutes();
|
||||
outputs.push(
|
||||
this.output({ type: 'advanced', fromMilliseconds: startTime, toMilliseconds: targetTimeMilliseconds }),
|
||||
);
|
||||
@@ -905,7 +985,8 @@ export class NetworkRuntimeEngine {
|
||||
case 'parityMarker':
|
||||
return;
|
||||
case 'ripBeacon':
|
||||
throw new NotPortedError('RIP beacons');
|
||||
this.sendRipBeacon(kind.nodeId);
|
||||
return;
|
||||
case 'dhcpClientStart':
|
||||
this.runDhcpClient(kind.nodeId);
|
||||
return;
|
||||
@@ -1213,13 +1294,6 @@ export class NetworkRuntimeEngine {
|
||||
|
||||
/** Into the queue of every UDP socket on the destination port, which then answers to the sender. */
|
||||
private deliverUdp(packet: IPv4Packet, datagram: UdpDatagram, nodeId: Id, networkInterface: NetworkInterface): void {
|
||||
if (
|
||||
datagram.destinationPort === RIP_DESTINATION_PORT &&
|
||||
datagram.sourcePort === RIP_SOURCE_PORT &&
|
||||
this.isRipEnabledRouter(nodeId)
|
||||
) {
|
||||
throw new NotPortedError('RIP advertisements');
|
||||
}
|
||||
this.processDhcpDatagram(packet, datagram, nodeId, networkInterface);
|
||||
const state = this.stateValue;
|
||||
const matching = [...state.socketsById.values()]
|
||||
@@ -1246,6 +1320,7 @@ export class NetworkRuntimeEngine {
|
||||
});
|
||||
if (datagram.destinationPort === DHCP_CLIENT_PORT && this.readDhcpResponse(nodeId)) this.advanceDhcpClient(nodeId);
|
||||
if (datagram.destinationPort === DNS_PORT) this.serveDnsQueries(nodeId);
|
||||
if (datagram.destinationPort === RIP_DESTINATION_PORT) this.serveRipMessages(nodeId);
|
||||
for (const socket of matching) {
|
||||
if (!this.dnsQueriesBySocketId.has(socket.id)) continue;
|
||||
const received = this.receiveUdp(socket.id);
|
||||
@@ -2212,13 +2287,202 @@ export class NetworkRuntimeEngine {
|
||||
|
||||
// ── Later slices hook in here ────────────────────────────────────────────
|
||||
|
||||
private initializeRipState(): void {
|
||||
// RIP (F2): builds the per-router tables and schedules the first beacons.
|
||||
for (const node of this.stateValue.topologySnapshot.nodes) {
|
||||
if (this.isRipEnabledRouter(node.id)) throw new NotPortedError('RIP');
|
||||
/**
|
||||
* Java's router with RIP switched on (VermittlungsrechnerBetriebssystem
|
||||
* .starten): a route of hop count 0 to each of its networks that never
|
||||
* expires, the server on port 520, the beacon on 521, its first a second
|
||||
* after the start. Java draws the beacon's pauses from a new Random per
|
||||
* router; the core seeds it from the scenario and the router.
|
||||
*/
|
||||
private initializeRipState(seed: bigint): void {
|
||||
const state = this.stateValue;
|
||||
state.ripTablesByNodeId.clear();
|
||||
this.ripRoutersByNodeId.clear();
|
||||
for (const node of state.topologySnapshot.nodes) {
|
||||
if (!this.isRipEnabledRouter(node.id)) continue;
|
||||
state.ripTablesByNodeId.set(
|
||||
node.id,
|
||||
this.networkInterfaces(node.id).flatMap((networkInterface) => {
|
||||
const address = parseIPv4(networkInterface.ipAddress);
|
||||
const mask = parseIPv4(networkInterface.subnetMask);
|
||||
if (address === undefined || mask === undefined) return [];
|
||||
return [
|
||||
{
|
||||
destinationNetwork: formatIPv4(maskAddress(address, mask)),
|
||||
subnetMask: networkInterface.subnetMask,
|
||||
nextHop: networkInterface.ipAddress,
|
||||
interfaceIPAddress: networkInterface.ipAddress,
|
||||
origin: 'connected' as const,
|
||||
isEditable: false,
|
||||
metric: 0,
|
||||
},
|
||||
];
|
||||
}),
|
||||
);
|
||||
const serverSocketId = this.bindUdpSocket(node.id, { localPort: RIP_DESTINATION_PORT });
|
||||
const beaconSocketId = this.bindUdpSocket(node.id, {
|
||||
localPort: RIP_SOURCE_PORT,
|
||||
remoteIPAddress: LIMITED_BROADCAST_IP_ADDRESS,
|
||||
remotePort: RIP_DESTINATION_PORT,
|
||||
});
|
||||
if (serverSocketId === undefined || beaconSocketId === undefined) continue;
|
||||
this.ripRoutersByNodeId.set(node.id, { random: new JavaCompatibleRandom(ripSeed(seed, node.id)), beaconSocketId, serverSocketId });
|
||||
this.schedule(state.currentTimeMilliseconds + RIP_FIRST_BEACON_DELAY_MILLISECONDS, { kind: 'ripBeacon', nodeId: node.id });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* RIPBeacon.announce, one round: learned routes past their time get hop
|
||||
* count 16 (RIPTable.check — only here, not when they expire), the table
|
||||
* goes out on every interface, the next round comes 25.2 to 35.2 s later.
|
||||
*/
|
||||
private sendRipBeacon(nodeId: Id): void {
|
||||
const router = this.ripRoutersByNodeId.get(nodeId);
|
||||
const table = this.stateValue.ripTablesByNodeId.get(nodeId);
|
||||
if (router === undefined || table === undefined) return;
|
||||
const now = this.stateValue.currentTimeMilliseconds;
|
||||
this.stateValue.ripTablesByNodeId.set(
|
||||
nodeId,
|
||||
table.map((route) =>
|
||||
route.expiresAtMilliseconds !== undefined && route.expiresAtMilliseconds < now ? { ...route, metric: RIP_INFINITY } : route,
|
||||
),
|
||||
);
|
||||
this.advertiseRipRoutes(nodeId, router.beaconSocketId);
|
||||
this.scheduleRipBeacon(nodeId, now + router.random.nextRipJitterMilliseconds());
|
||||
}
|
||||
|
||||
/** One pending beacon per router, the earlier one wins (Java: setNextBeacon, then notify). */
|
||||
private scheduleRipBeacon(nodeId: Id, deadlineMilliseconds: number): void {
|
||||
const state = this.stateValue;
|
||||
const pending = state.pendingEvents.filter((event) => event.kind.kind === 'ripBeacon' && event.kind.nodeId === nodeId);
|
||||
if (pending.some((event) => event.deadlineMilliseconds <= deadlineMilliseconds)) return;
|
||||
state.pendingEvents = state.pendingEvents.filter((event) => !(event.kind.kind === 'ripBeacon' && event.kind.nodeId === nodeId));
|
||||
this.schedule(deadlineMilliseconds, { kind: 'ripBeacon', nodeId });
|
||||
}
|
||||
|
||||
/**
|
||||
* RIPBeacon.broadcast: per interface a message from its address (the
|
||||
* socket bound to it) to 255.255.255.255:520, with every route that does
|
||||
* not go out on that interface (split horizon).
|
||||
*/
|
||||
private advertiseRipRoutes(nodeId: Id, socketId: Id): void {
|
||||
const table = this.stateValue.ripTablesByNodeId.get(nodeId) ?? [];
|
||||
const interfaces = this.networkInterfaces(nodeId);
|
||||
const publicIPAddress = interfaces[0]?.ipAddress;
|
||||
if (publicIPAddress === undefined) return;
|
||||
for (const networkInterface of interfaces) {
|
||||
const message: RipMessage = {
|
||||
ipAddress: networkInterface.ipAddress,
|
||||
publicIPAddress,
|
||||
infinity: RIP_INFINITY,
|
||||
timeoutMilliseconds: RIP_TIMEOUT_MILLISECONDS,
|
||||
routes: [],
|
||||
};
|
||||
for (const route of table) {
|
||||
if (route.interfaceIPAddress === networkInterface.ipAddress) continue;
|
||||
addRipMessageRoute(message, { network: route.destinationNetwork, mask: route.subnetMask, hops: route.metric ?? RIP_INFINITY });
|
||||
}
|
||||
const socket = this.stateValue.socketsById.get(socketId);
|
||||
if (socket === undefined) return;
|
||||
this.stateValue.socketsById.set(socketId, { ...socket, localIPAddress: networkInterface.ipAddress });
|
||||
this.recordTrace({
|
||||
nodeId,
|
||||
interfaceId: networkInterface.portId,
|
||||
direction: 'outbound',
|
||||
layer: 'application',
|
||||
operation: 'sent',
|
||||
detail: `RIP advertisement routes=${message.routes.length}`,
|
||||
});
|
||||
this.sendUdp(socketId, new TextEncoder().encode(encodeRipMessage(message)));
|
||||
}
|
||||
}
|
||||
|
||||
/** RIPServerMitarbeiter.verarbeiteNachricht for every message on the server's socket. */
|
||||
private serveRipMessages(nodeId: Id): void {
|
||||
const router = this.ripRoutersByNodeId.get(nodeId);
|
||||
if (router === undefined) return;
|
||||
for (let received = this.receiveUdp(router.serverSocketId); received !== undefined; received = this.receiveUdp(router.serverSocketId)) {
|
||||
const message = decodeRipMessage(new TextDecoder().decode(received.datagram.payload));
|
||||
if (message !== undefined) this.learnRipRoutes(nodeId, message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A neighbour's table: each route one hop further (16 at most), taken
|
||||
* when it is new, shorter, or from the router the route already goes
|
||||
* through; a new route or a longer one from there sends the table at once.
|
||||
* Messages from an address in none of the router's networks, or from its
|
||||
* own, are ignored.
|
||||
*/
|
||||
private learnRipRoutes(nodeId: Id, message: RipMessage): void {
|
||||
const state = this.stateValue;
|
||||
const interfaceIPAddress = this.networkInterfaces(nodeId).find((each) => sameSubnet(each.ipAddress, message.ipAddress, each.subnetMask))?.ipAddress;
|
||||
if (interfaceIPAddress === undefined || interfaceIPAddress === message.ipAddress) return;
|
||||
const table = [...(state.ripTablesByNodeId.get(nodeId) ?? [])];
|
||||
const expiresAtMilliseconds = state.currentTimeMilliseconds + message.timeoutMilliseconds;
|
||||
let announce = false;
|
||||
for (const entry of message.routes) {
|
||||
const hops = entry.hops >= message.infinity || entry.hops + 1 >= RIP_INFINITY ? RIP_INFINITY : entry.hops + 1;
|
||||
const index = table.findIndex((route) => route.destinationNetwork === entry.network && route.subnetMask === entry.mask);
|
||||
const route = table[index];
|
||||
if (route !== undefined) {
|
||||
const routeHops = route.metric ?? RIP_INFINITY;
|
||||
if (route.nextHop !== message.ipAddress && routeHops <= hops) continue;
|
||||
let { nextHop, interfaceIPAddress: routeInterface } = route;
|
||||
if (routeHops > hops) {
|
||||
nextHop = message.ipAddress;
|
||||
routeInterface = interfaceIPAddress;
|
||||
} else if (routeHops < hops) {
|
||||
announce = true;
|
||||
}
|
||||
table[index] = { ...route, nextHop, interfaceIPAddress: routeInterface, metric: hops, expiresAtMilliseconds };
|
||||
} else if (hops < RIP_INFINITY) {
|
||||
table.push({
|
||||
destinationNetwork: entry.network,
|
||||
subnetMask: entry.mask,
|
||||
nextHop: message.ipAddress,
|
||||
interfaceIPAddress,
|
||||
origin: 'rip',
|
||||
isEditable: false,
|
||||
metric: hops,
|
||||
expiresAtMilliseconds,
|
||||
});
|
||||
announce = true;
|
||||
}
|
||||
}
|
||||
state.ripTablesByNodeId.set(nodeId, table);
|
||||
if (announce) this.scheduleRipBeacon(nodeId, state.currentTimeMilliseconds);
|
||||
}
|
||||
|
||||
/**
|
||||
* Weiterleitungstabelle.determineRouteFromDynamicRoutingTable: of the routes
|
||||
* with fewer than 16 hops that hold the address, the one with the fewest,
|
||||
* then the longest mask; to the address itself when it lies in the
|
||||
* interface's network, else to the route's next router.
|
||||
*/
|
||||
private ripRouteForPacket(nodeId: Id, targetIPAddress: string): PacketRoute {
|
||||
const target = parseIPv4(targetIPAddress);
|
||||
if (target === undefined) return { type: 'unavailable' };
|
||||
let selected: RouteRow | undefined;
|
||||
let selectedHops = RIP_INFINITY - 1;
|
||||
let selectedMask = -1;
|
||||
for (const route of this.stateValue.ripTablesByNodeId.get(nodeId) ?? []) {
|
||||
const destination = parseIPv4(route.destinationNetwork);
|
||||
const mask = parseIPv4(route.subnetMask);
|
||||
const hops = route.metric ?? RIP_INFINITY;
|
||||
if (destination === undefined || mask === undefined || destination !== maskAddress(target, mask)) continue;
|
||||
if (selectedHops < hops) continue;
|
||||
if (selectedHops > hops || selectedMask < mask) {
|
||||
selected = route;
|
||||
selectedHops = hops;
|
||||
selectedMask = mask;
|
||||
}
|
||||
}
|
||||
const egress = this.networkInterfaces(nodeId).find((candidate) => candidate.ipAddress === selected?.interfaceIPAddress);
|
||||
if (selected === undefined || egress === undefined) return { type: 'unavailable' };
|
||||
return { type: 'egress', interface: egress, nextHop: sameSubnet(egress.ipAddress, targetIPAddress, egress.subnetMask) ? targetIPAddress : selected.nextHop };
|
||||
}
|
||||
|
||||
private clearDhcpState(): void {
|
||||
const state = this.stateValue;
|
||||
state.dhcpOffersByIPAddress.clear();
|
||||
@@ -2920,10 +3184,6 @@ export class NetworkRuntimeEngine {
|
||||
}
|
||||
}
|
||||
|
||||
private expireRipRoutes(): void {
|
||||
// RIP (F2): marks timed-out learned routes with metric infinity.
|
||||
}
|
||||
|
||||
private isRipEnabledRouter(nodeId: Id): boolean {
|
||||
return (
|
||||
this.nodeSnapshot(nodeId)?.kind === 'router' &&
|
||||
@@ -4066,7 +4326,7 @@ export class NetworkRuntimeEngine {
|
||||
if (targetIPAddress === LOCALHOST_IP_ADDRESS) return { type: 'local', interface: interfaces[0] };
|
||||
const local = interfaces.find((candidate) => candidate.ipAddress === targetIPAddress);
|
||||
if (local !== undefined) return { type: 'local', interface: local };
|
||||
if (this.isRipEnabledRouter(nodeId)) throw new NotPortedError('RIP routing');
|
||||
if (this.isRipEnabledRouter(nodeId)) return this.ripRouteForPacket(nodeId, targetIPAddress);
|
||||
if (preferredInterfaceId !== undefined) {
|
||||
const preferred = interfaces.find((candidate) => candidate.portId === preferredInterfaceId);
|
||||
if (preferred !== undefined && sameSubnet(preferred.ipAddress, targetIPAddress, preferred.subnetMask)) {
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
// What Java FILIUS stores when a file sets an interface's IP address or mask:
|
||||
// NetzwerkInterface.setIp(ip) keeps IP.ipCheck(ip), which is null for text
|
||||
// that is not an address and the canonical dotted quad otherwise.
|
||||
// Java counterpart: filius.software.vermittlungsschicht.IP.inetAton/ipCheck.
|
||||
//
|
||||
// Measured against Java (test/ip-check.cases.txt, tools/java-oracle), since
|
||||
// the rules are wider than they look:
|
||||
// - StringTokenizer(".") skips empty tokens: "10..0.0.1" is 10.0.0.1;
|
||||
// - Integer.parseInt takes a sign and leading zeros: "+010" is 10, "-0" is 0;
|
||||
// - and any Unicode decimal digit in the BMP: Arabic-Indic "١٠" is 10 —
|
||||
// parseInt reads UTF-16 units, so "𝟏" (a surrogate pair) is no digit.
|
||||
|
||||
/**
|
||||
* `Integer.parseInt(text)`: an optional sign, then one or more decimal digits
|
||||
* of any script; undefined where Java throws NumberFormatException.
|
||||
*/
|
||||
export function javaParseInt(text: string): number | undefined {
|
||||
const characters = text.split('');
|
||||
let index = 0;
|
||||
let negative = false;
|
||||
if (characters[0] === '-' || characters[0] === '+') {
|
||||
negative = characters[0] === '-';
|
||||
index = 1;
|
||||
}
|
||||
if (index >= characters.length) return undefined;
|
||||
let value = 0;
|
||||
for (; index < characters.length; index++) {
|
||||
const digit = decimalDigitValue(characters[index]!);
|
||||
if (digit === undefined) return undefined;
|
||||
value = value * 10 + digit;
|
||||
// Past the int range Java throws; the octet check would reject it anyway.
|
||||
if (value > 2 ** 31) return undefined;
|
||||
}
|
||||
if (!negative && value > 2 ** 31 - 1) return undefined;
|
||||
return negative ? -value : value;
|
||||
}
|
||||
|
||||
/**
|
||||
* `Character.digit(c, 10)` for one UTF-16 unit: Unicode lays out every
|
||||
* script's digits as a run of ten from zero to nine, so the value is the
|
||||
* distance from the start of the run, modulo ten (in case two runs touch).
|
||||
*/
|
||||
function decimalDigitValue(unit: string): number | undefined {
|
||||
if (!/^\p{Nd}$/u.test(unit)) return undefined;
|
||||
let code = unit.charCodeAt(0);
|
||||
let offset = 0;
|
||||
while (/^\p{Nd}$/u.test(String.fromCharCode(code - 1))) {
|
||||
code -= 1;
|
||||
offset += 1;
|
||||
}
|
||||
return offset % 10;
|
||||
}
|
||||
|
||||
/** `IP.ipCheck(text)`: four octets 0…255 as `a.b.c.d`, or null. */
|
||||
export function javaIpCheck(text: string): string | null {
|
||||
const tokens = text.split('.').filter((token) => token !== '');
|
||||
if (tokens.length !== 4) return null;
|
||||
const octets: number[] = [];
|
||||
for (const token of tokens) {
|
||||
const octet = javaParseInt(token);
|
||||
if (octet === undefined || octet < 0 || octet > 255) return null;
|
||||
octets.push(octet);
|
||||
}
|
||||
return octets.join('.');
|
||||
}
|
||||
@@ -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