diff --git a/CHANGES.md b/CHANGES.md index 02275fa..108ca7d 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -13,7 +13,7 @@ Swift-Pfade relativ zu `filius-ipad/ios/FiliusPad/TopologyEditor/` (Stand | `src/model/link.ts`, `src/model/graph.ts` | `Model/TopologyLink.swift`, `Model/TopologyGraph.swift` | übertragen, bisher nur die Daten (Abfragen folgen mit dem Editor) | | `src/model/documentation.ts` | `Model/TopologyDocumentation.swift` | übertragen; FNV-Kennung mit BigInt, gegen die IDs aus dem Swift-Import geprüft | | `src/runtime/config.ts` | `State/TopologyEditorState.swift` (Laufzeit-Konfigurationen, WLAN eines Rechners, DHCP, Portweiterleitung, Remote Link ohne LAN-Transport, Programme und ihre Konfiguration, Firewall, DNS-Einträge aus `State/TopologyDNSResolver.swift`, E-Mail-Konten und -Nachrichten aus `State/TopologyEmail.swift`); dazu Echo-Server- und Client-Konfiguration, die Swift nicht speichert | übertragen; Java-Beschriftungen der Oberfläche weggelassen | -| `src/runtime/engine.ts` | `State/TopologyNetworkRuntimeEngine.swift` Z. ~313–3585, ~4472–5187 (UDP), ~4753–4850 | übertragen: Warteschlange, Uhr, Ethernet, Switch, ARP, IPv4, ICMP, Routing, Firewall, UDP-Sockets (binden, freie Ports nach Javas Zufall, senden samt Broadcast, zustellen, empfangen mit Wartezeit, schließen), TCP (Z. ~5187–5913: Server- und Client-Sockets, Handschlag mit Ergebnis, Segmente zu 1460 Byte bis zum PSH, höchstens drei Versuche, Abbau über alle Java-Zustände samt `TIME_WAIT`; seit F2.3c nach Javas `TCPSocket` an Java-Filius gemessen: je Versuch und für `TIME_WAIT` 3 RTT nach der Geschwindigkeit (8250 ms bei 90 %, Swift fest 7500 bzw. 2500 ms), der Zeitablauf zählt ab der Übergabe des Segments, vor ARP; ein Segment beendet `TIME_WAIT`; die Gegenseite schließt beim FIN von selbst; nicht bestätigte Daten schließen mit FIN statt sofort; der angenommene Socket hat die Startnummer des Listeners, der Listener eine neue. seit F2.3e wie Java: SYN+ACK und FIN gehen nie ein zweites Mal hinaus; der Server gibt einen Handschlag nach 3 RTT auf und schließt mit FIN; nach dem FIN wartet ein Socket höchstens fünfmal 3 RTT, jedes Segment beendet eine Wartezeit, dann CLOSED (Java lässt den Zustand stehen). Abweichend von Java: drei SYN statt einem, weil Javas Wiederholungsschleife nach dem ersten Zeitablauf abbricht, und ein wiederholtes SYN in `SYN_RCVD` bekommt das SYN+ACK noch einmal, statt den Socket zu schließen), DHCP (Z. ~5913–6694: Server mit festen Zuordnungen, Adressbereich, Angeboten, Leases und Sperrliste; Client seit F2.3b nach Javas `DHCPClient` neu gebaut und an Java-Filius gemessen: je Versuch eine Nachricht, eine Antwort, die nicht passt, wird gelesen und verworfen; die ARP-Prüfung wartet eine RTT, eine Antwort für die Adresse beendet sie sofort, dann DECLINE; nach einem Zeitablauf wiederholt der Zustand seine Nachricht, nach dem fünften nimmt der Client eine Link-Local-Adresse `169.254.1.x/16` (ARP-Probe, ARP-Reply an alle) und gibt keine alte Adresse zurück; Swift sendete doppelt, wartete nicht auf ARP und kehrte nach zehn Versuchen zur alten Adresse zurück. Abweichend von Java: Nach fünf belegten Link-Local-Adressen gibt der Client auf (Java sucht endlos), und ein ARP-Paket für eine andere Adresse beendet die Prüfung nicht. Seit F2.3d befördert ein Kabel einen Frame nach dem anderen wie Javas `SimplexVerbindung` (ein Frame wartet im Ausgangspuffer, bis der vorige angekommen ist, und erscheint im Protokoll, wenn er losgeht); Swift schickt alle zugleich, die Option `swiftCables` behält das für den Swift-Vergleich. Die RTT der DHCP-Zeiten (Warten, Angebote, Sperrliste) folgt wie in Java der Geschwindigkeit (50 Kabel, 2750 ms bei 90 %); Swift rechnet fest mit 2500 ms. ARP seit F2.4b nach Javas `holeARPTabellenEintrag`, an Java-Filius gemessen: eine Auflösung fragt, wartet eine RTT, fragt ein zweites Mal und gibt nach einer weiteren RTT auf; dann „Host unreachable" beim Absender selbst oder vom Router zurück (bei ICMP nur für Echo-Anfragen); ein wartender Absender wacht nach den übrigen Ereignissen desselben Zeitpunkts auf; der Ping wartet danach eine RTT auf die Antwort. Swift schickt in der Ereignisverarbeitung beide Anfragen zugleich und lässt das Paket ohne Ende warten, wenn die Adresse irgendwo im Netz vorkommt (Wissen, das kein Knoten hat), und blockiert direkte Aufrufe ohne Wartezeit; die Option `swiftArp` behält das für den Swift-Vergleich. Seit F2.4b-2 kehrt ein Senden wie in Java sofort zurück, die Frames reisen, während die Uhr läuft (Swift: der Aufruf kehrt erst zurück, wenn alle Frames angekommen sind; die Option `swiftSends` behält das für den Swift-Vergleich), und UDP und TCP senden über Javas Transport-Thread je Knoten und Protokoll: ein Segment nach dem anderen; solange IP auf ARP wartet, blockiert das nächste `senden`. Wartende Absender kommen in der Reihenfolge an die Reihe, in der sie kamen (Javas Sperre überlässt das dem Zufall). Seit F2.4b-3 geht jedes ankommende Paket wie in Java durch den IP-Thread (UDP, TCP) oder den ICMP-Thread (ICMP) seines Knotens: zustellen, Echo-Reply, weiterleiten, eins nach dem anderen; solange ARP wartet, warten die folgenden. Abweichend von Java weckt eine ARP-Antwort alle, die auf die Adresse warten (Javas `notify` weckt einen beliebigen). DNS seit F2.4a nach Javas `DNSServer`/`Resolver`, an Java-Filius gemessen: Server auf Port 53 mit Einträgen aus dem Modell (die Antwort wiederholt die Frage nicht, RCODE immer 0, ein Verweis mit NS und A im Antwortteil), iterativer Resolver mit 5 RTT je Anfrage, der nur die Server behält, an die er verwiesen wurde; seit F2.4c auf den Ereignissen statt blockierend und mit rekursivem Server nach `DNSServer.answerWithRemoteData` (Nameserver der Zone, dann der eigene DNS-Server, sonst Verweis), abweichend von Java fragt er sich nie selbst (Java endlos). `receiveUdp` kehrt wie Javas `empfangen` zurück, sobald ein Datagramm da ist. Klasse statt Werttyp. Noch nicht übertragen (wirft `NotPortedError`): Gateway-NAT, RIP, Remote Link, WLAN | +| `src/runtime/engine.ts` | `State/TopologyNetworkRuntimeEngine.swift` Z. ~313–3585, ~4472–5187 (UDP), ~4753–4850 | übertragen: Warteschlange, Uhr, Ethernet, Switch, ARP, IPv4, ICMP, Routing, Firewall, UDP-Sockets (binden, freie Ports nach Javas Zufall, senden samt Broadcast, zustellen, empfangen mit Wartezeit, schließen), TCP (Z. ~5187–5913: Server- und Client-Sockets, Handschlag mit Ergebnis, Segmente zu 1460 Byte bis zum PSH, höchstens drei Versuche, Abbau über alle Java-Zustände samt `TIME_WAIT`; seit F2.3c nach Javas `TCPSocket` an Java-Filius gemessen: je Versuch und für `TIME_WAIT` 3 RTT nach der Geschwindigkeit (8250 ms bei 90 %, Swift fest 7500 bzw. 2500 ms), der Zeitablauf zählt ab der Übergabe des Segments, vor ARP; ein Segment beendet `TIME_WAIT`; die Gegenseite schließt beim FIN von selbst; nicht bestätigte Daten schließen mit FIN statt sofort; der angenommene Socket hat die Startnummer des Listeners, der Listener eine neue. seit F2.3e wie Java: SYN+ACK und FIN gehen nie ein zweites Mal hinaus; der Server gibt einen Handschlag nach 3 RTT auf und schließt mit FIN; nach dem FIN wartet ein Socket höchstens fünfmal 3 RTT, jedes Segment beendet eine Wartezeit, dann CLOSED (Java lässt den Zustand stehen). Abweichend von Java: drei SYN statt einem, weil Javas Wiederholungsschleife nach dem ersten Zeitablauf abbricht, und ein wiederholtes SYN in `SYN_RCVD` bekommt das SYN+ACK noch einmal, statt den Socket zu schließen), DHCP (Z. ~5913–6694: Server mit festen Zuordnungen, Adressbereich, Angeboten, Leases und Sperrliste; Client seit F2.3b nach Javas `DHCPClient` neu gebaut und an Java-Filius gemessen: je Versuch eine Nachricht, eine Antwort, die nicht passt, wird gelesen und verworfen; die ARP-Prüfung wartet eine RTT, eine Antwort für die Adresse beendet sie sofort, dann DECLINE; nach einem Zeitablauf wiederholt der Zustand seine Nachricht, nach dem fünften nimmt der Client eine Link-Local-Adresse `169.254.1.x/16` (ARP-Probe, ARP-Reply an alle) und gibt keine alte Adresse zurück; Swift sendete doppelt, wartete nicht auf ARP und kehrte nach zehn Versuchen zur alten Adresse zurück. Abweichend von Java: Nach fünf belegten Link-Local-Adressen gibt der Client auf (Java sucht endlos), und ein ARP-Paket für eine andere Adresse beendet die Prüfung nicht. Seit F2.3d befördert ein Kabel einen Frame nach dem anderen wie Javas `SimplexVerbindung` (ein Frame wartet im Ausgangspuffer, bis der vorige angekommen ist, und erscheint im Protokoll, wenn er losgeht); Swift schickt alle zugleich, die Option `swiftCables` behält das für den Swift-Vergleich. Die RTT der DHCP-Zeiten (Warten, Angebote, Sperrliste) folgt wie in Java der Geschwindigkeit (50 Kabel, 2750 ms bei 90 %); Swift rechnet fest mit 2500 ms. ARP seit F2.4b nach Javas `holeARPTabellenEintrag`, an Java-Filius gemessen: eine Auflösung fragt, wartet eine RTT, fragt ein zweites Mal und gibt nach einer weiteren RTT auf; dann „Host unreachable" beim Absender selbst oder vom Router zurück (bei ICMP nur für Echo-Anfragen); ein wartender Absender wacht nach den übrigen Ereignissen desselben Zeitpunkts auf; der Ping wartet danach eine RTT auf die Antwort. Swift schickt in der Ereignisverarbeitung beide Anfragen zugleich und lässt das Paket ohne Ende warten, wenn die Adresse irgendwo im Netz vorkommt (Wissen, das kein Knoten hat), und blockiert direkte Aufrufe ohne Wartezeit; die Option `swiftArp` behält das für den Swift-Vergleich. Seit F2.4b-2 kehrt ein Senden wie in Java sofort zurück, die Frames reisen, während die Uhr läuft (Swift: der Aufruf kehrt erst zurück, wenn alle Frames angekommen sind; die Option `swiftSends` behält das für den Swift-Vergleich), und UDP und TCP senden über Javas Transport-Thread je Knoten und Protokoll: ein Segment nach dem anderen; solange IP auf ARP wartet, blockiert das nächste `senden`. Wartende Absender kommen in der Reihenfolge an die Reihe, in der sie kamen (Javas Sperre überlässt das dem Zufall). Seit F2.4b-3 geht jedes ankommende Paket wie in Java durch den IP-Thread (UDP, TCP) oder den ICMP-Thread (ICMP) seines Knotens: zustellen, Echo-Reply, weiterleiten, eins nach dem anderen; solange ARP wartet, warten die folgenden. Abweichend von Java weckt eine ARP-Antwort alle, die auf die Adresse warten (Javas `notify` weckt einen beliebigen). DNS seit F2.4a nach Javas `DNSServer`/`Resolver`, an Java-Filius gemessen: Server auf Port 53 mit Einträgen aus dem Modell (die Antwort wiederholt die Frage nicht, RCODE immer 0, ein Verweis mit NS und A im Antwortteil), iterativer Resolver mit 5 RTT je Anfrage, der nur die Server behält, an die er verwiesen wurde; seit F2.4c auf den Ereignissen statt blockierend und mit rekursivem Server nach `DNSServer.answerWithRemoteData` (Nameserver der Zone, dann der eigene DNS-Server, sonst Verweis), abweichend von Java fragt er sich nie selbst (Java endlos). RIP seit F2.5a (Z. ~4700–5090) nach Javas `filius.software.rip`: eine Route mit 0 Hops je eigenem Netz, die nie verfällt; der erste Beacon nach einer Sekunde, dann alle 25,2–35,2 s aus einem Zufall je Router (Java: ein ungesetzter `Random` je Router; der Kern setzt ihn aus Szenario und Router-ID, `ripSeed`; Swift: ein Zufall für alle); eine Nachricht je Schnittstelle ohne die Routen über sie (Split Horizon); eine neue Route oder mehr Hops vom selben Router senden sofort; eine gelernte Route wird erst beim nächsten eigenen Beacon auf 16 gesetzt, wenn sie verfallen ist (Swift: bei jedem Ereignis); Nachrichten von jedem Quellport (Swift: nur 521); ein RIP-Router leitet nur nach seiner Tabelle weiter, wenige Hops vor langer Maske. `receiveUdp` kehrt wie Javas `empfangen` zurück, sobald ein Datagramm da ist. Klasse statt Werttyp. Noch nicht übertragen (wirft `NotPortedError`): Gateway-NAT, Remote Link, WLAN | | `src/runtime/dns.ts` | Java `filius.software.dns.DNSNachricht`, `Query`, `ResourceRecord` | neu nach Java statt nach Swift (Swift ersetzt Javas Textnachrichten durch `FILIUS-DNS-TYPED/1`): Kopfzeile `ID=… QR=… RCODE=… QDCOUNT=… …`, Frage- und Eintragszeilen, Suche nach Namen ohne Rücksicht auf Groß- und Kleinschreibung, NS der längsten Zone, sonst der Wurzel | | `src/runtime/ipv4.ts` | Engine `parseIPv4`/`sameSubnet` | übertragen | | `src/runtime/packets.ts` | Engine, Paketmodelle | übertragen; `Data` → `Uint8Array` | @@ -45,7 +45,7 @@ Swift-Pfade relativ zu `filius-ipad/ios/FiliusPad/TopologyEditor/` (Stand | `src/importProject.ts` | `Persistence/TopologyProjectStore.swift` (Knoten, Ports, Kabel, IP-Konfiguration, Switch/WLAN, Routen, RIP, Firewall, DHCP, Portweiterleitung, Modem, Dateisysteme, Programme, Doku-Elemente des Imports) | übertragen, Eingabe ist Javas Sicht: Name und Port-Anzahl wie Java, abgelehnte Adresse → Swift-Standard, Router-Standardgateway wie Java (Swift verliert es), jede Router-Firewall eingetragen, Konfigurationstexte wie in Swift getrimmt (`swiftTrim`), Rechner ohne Dateisystem leer wie in Java (Swift: Demo-Dateien), alle Programme bleiben (Swift verwirft sechs), Webserver-Dateien bleiben in `/webserver` (Swift: `/www`), keine versteckten Zustandsdateien im Dateisystem, DNS-Einträge so, wie Javas Server sie aus `/dns/hosts` beantwortet, WLAN eines Rechners eingeschaltet nach Javas Funk-Merkmal der Karte (auch ohne SSID; Swift: jede SSID); der Webserver behält Javas Schalter für virtuelle Hosts (Swift: aus); gegen Swifts importiertes Modell auf 13 Dateien geprüft | | `test/importProject.test.ts` | — | neu: Swifts importiertes Modell (13 Dateien, begründete Abweichungen; im Dateisystem ausgenommen nur Swifts versteckte Zustandsdateien), Vollständigkeit aller 23, Pings im Kern (über einen Router; über drei Router mit manuellen Routen und Router-Gateways) | | `src/systemSoftware.ts` | Java `SwitchFirmware`, `Betriebssystem`, `VermittlungsrechnerBetriebssystem`, `GatewayFirmware`, `Weiterleitungstabelle`, `Firewall`, `FirewallRule`, `DHCPServer`, `ModemFirmware`, `Dateisystem`, `Datei` | neu nach Java: SSID, Lernzeit, RIP, manuelle Routen, Router-Firewall (an allen Stellen, an denen die Datei sie erreicht), DHCP (ein kaputter statischer Eintrag fällt allein weg, Java verliert alle folgenden), statische NAT, Modem, Dateisystem (bei Router und Heimrouter das, was die Datei zu den Vorlagen des Konstruktors hinzufügt, oder eine eigene Wurzel, die sie ersetzt), installierte Programme von Rechner und Notebook (eigene Anwendungen eines Projekts als erfasste Bean, `beanCapture.ts`); `Character.isWhitespace` am JDK gemessen; gegen Java geprüft | -| `src/javaIp.ts` | Java `IP.inetAton`/`ipCheck` | neu nach Java; an 46 Fällen mit Java gemessen | +| `src/javaIp.ts` | Java `IP.inetAton`/`ipCheck` | neu nach Java; an 46 Fällen mit Java gemessen; seit F2.5a in `packages/core/src/util/javaIp.ts` (der Kern liest damit RIP-Nachrichten) | | `src/dnsHosts.ts` | Java `DNSServer.leseRecordListe`, `ResourceRecord`, `Dateisystem.holeDatei`; Swift `State/TopologyDNSResolver.swift` (`TopologyDNSHostsFileCodec`) als Vergleich | neu nach Java: Felder nur an einzelnen Leerzeichen, keine Kommentare, ungültige TTL → 3600, Doppelte bleiben; ins Modell kommt nur, was Java beantwortet (Typ A/MX/NS, Name nicht leer); Javas Fehler nicht übernommen: Label mit Ziffer vorn erlaubt, kein Backslash, letztes Label nicht rein numerisch (RFC 1123/3696), Typ in jeder Schreibung (RFC 4343), negative TTL ungültig (RFC 2181); an 76 Fällen mit Java und Swift gemessen | | `src/topology.ts` | Java `SzenarioVerwaltung.netzwerkLaden`, Konstruktoren und Setter der Geräte; Swift `Persistence/TopologyProjectStore.swift` (Parser) als Vergleich | neu nach Java statt Übertragung: Geräteart aus der Klasse statt aus der Beschriftung, Geräte ohne Koordinaten bei 0,0 statt verworfen, Werte durch Javas Setter, die Zeichen-Enden eines Kabels, die Funk-Eigenschaft einer Netzwerkkarte (`wireless`, bis 30.09.2026 übersehen) | | `test/javabeans.test.ts` | — | neu: je XMLDecoder-Regel ein Test; alle 23 Korpus-Dateien, Klasse und Position jedes Geräts wie Java | diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 7b6a8b6..4223a6b 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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'; diff --git a/packages/core/src/runtime/engine.ts b/packages/core/src/runtime/engine.ts index 2fc5bcf..70ec240 100644 --- a/packages/core/src/runtime/engine.ts +++ b/packages/core/src/runtime/engine.ts @@ -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; /** Each DNS server's socket on port 53. */ dnsServerSocketIdsByNodeId: Map; + /** Each RIP router's table (RIPTable.routes): its networks, then what it learned. */ + ripTablesByNodeId: Map; dhcpClientStatusesByNodeId: Map; dhcpClientContextsByNodeId: Map; 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(); /** DNS queries waiting for their answer, by the socket they were sent from. */ private dnsQueriesBySocketId = new Map 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)) { diff --git a/packages/fls/src/javaIp.ts b/packages/core/src/util/javaIp.ts similarity index 100% rename from packages/fls/src/javaIp.ts rename to packages/core/src/util/javaIp.ts diff --git a/packages/core/test/engine.test.ts b/packages/core/test/engine.test.ts index ee45a62..b222aca 100644 --- a/packages/core/test/engine.test.ts +++ b/packages/core/test/engine.test.ts @@ -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); + }); +}); diff --git a/packages/fls/src/dnsHosts.ts b/packages/fls/src/dnsHosts.ts index 48f06c1..4e2dd31 100644 --- a/packages/fls/src/dnsHosts.ts +++ b/packages/fls/src/dnsHosts.ts @@ -14,9 +14,8 @@ // both wrong; see isDomainName), the type A, MX or NS is read in any case // (RFC 4343), and a negative TTL is invalid (RFC 2181). -import type { DnsResourceRecord } from '@mdg-filius/core'; +import { javaIpCheck, javaParseInt, type DnsResourceRecord } from '@mdg-filius/core'; import { javaFileContent } from './javaFileSystem.js'; -import { javaIpCheck, javaParseInt } from './javaIp.js'; import { javaTrim } from './javaString.js'; import type { FlsFileTree } from './systemSoftware.js'; diff --git a/packages/fls/src/importEmail.ts b/packages/fls/src/importEmail.ts index 42cf83e..f39e68c 100644 --- a/packages/fls/src/importEmail.ts +++ b/packages/fls/src/importEmail.ts @@ -24,7 +24,7 @@ import { type EmailServerConfiguration, } from '@mdg-filius/core'; import { addressEntry, type JavaEmail, type JavaEmailAccount, type JavaRecipient } from './javaEmail.js'; -import { javaParseInt } from './javaIp.js'; +import { javaParseInt } from '@mdg-filius/core'; import type { FlsApplication } from './systemSoftware.js'; type Warn = (message: string) => void; diff --git a/packages/fls/src/index.ts b/packages/fls/src/index.ts index 13b5d64..97be29a 100644 --- a/packages/fls/src/index.ts +++ b/packages/fls/src/index.ts @@ -4,7 +4,6 @@ export * from './dnsHosts.js'; export * from './importProject.js'; export * from './javaEmail.js'; export * from './javaFileSystem.js'; -export * from './javaIp.js'; export * from './javaString.js'; export * from './javabeans.js'; export * from './systemSoftware.js'; diff --git a/packages/fls/src/topology.ts b/packages/fls/src/topology.ts index aa2a333..3ff16a0 100644 --- a/packages/fls/src/topology.ts +++ b/packages/fls/src/topology.ts @@ -18,7 +18,7 @@ // mask, upper-cased MAC). import { isJavaObject, isUnresolved, listItems, simpleClassName, type JavaObject, type JavaValue } from './javabeans.js'; -import { javaIpCheck } from './javaIp.js'; +import { javaIpCheck } from '@mdg-filius/core'; import { readSystemSoftware, type FlsSystemSoftware } from './systemSoftware.js'; export type FlsNodeClass = 'Rechner' | 'Notebook' | 'Switch' | 'Vermittlungsrechner' | 'Gateway' | 'Modem'; diff --git a/packages/fls/test/javaIp.test.ts b/packages/fls/test/javaIp.test.ts index b44cc03..86856cf 100644 --- a/packages/fls/test/javaIp.test.ts +++ b/packages/fls/test/javaIp.test.ts @@ -6,7 +6,7 @@ import { readFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; -import { javaIpCheck, javaParseInt } from '../src/javaIp.js'; +import { javaIpCheck, javaParseInt } from '@mdg-filius/core'; const here = dirname(fileURLToPath(import.meta.url)); const measured = JSON.parse(readFileSync(join(here, 'ip-check.java.json'), 'utf8')) as [string, string | null][];