From 065b42430def307a1596830fe4c92f3256133506 Mon Sep 17 00:00:00 2001 From: Julian Kiedaisch Date: Sat, 3 Oct 2026 20:36:17 +0000 Subject: [PATCH] feat: F4.6g2 the DNS server window, its records being /dns/hosts Oracle DnsWindowRun uses Java's DNS server window as a person does and asks a computer's resolver after each step. The core's DNS server is a program its window changes: on and off (its socket on port 53), recursive resolution, and records that are /dns/hosts, read on every query as Java's server reads them (the reader moves from fls to the core). The snapshot carries every installed DNS server with whether it runs. The room takes the window's actions, sends its view and writes the server back after the run; the editor shows Java's window with its three tabs. Not taken from Java: a cell edit spilling into records of another type, an add losing what another program wrote into the file, an empty mail domain, a removal hitting the first record of the name. A table cell no longer takes the room's value over what is being typed (the web server's too). Co-Authored-By: Claude Opus 5.5 --- CHANGES.md | 7 + packages/core/src/index.ts | 2 + packages/core/src/runtime/apps/dnsServer.ts | 130 ++ packages/core/src/runtime/dnsHosts.ts | 158 ++ packages/core/src/runtime/engine.ts | 49 +- packages/core/src/runtime/snapshot.ts | 7 +- packages/core/test/dnsWindow.java.json | 1290 +++++++++++++++++ packages/core/test/dnsWindow.test.ts | 209 +++ packages/editor/browser/dns.spec.ts | 99 ++ packages/editor/src/desktop/DesktopWindow.tsx | 7 +- .../editor/src/desktop/DnsServerPanel.tsx | 207 +++ .../editor/src/desktop/WebServerPanel.tsx | 28 +- packages/editor/src/desktop/desktop.css | 57 + packages/editor/src/i18n/de.json | 25 + packages/editor/src/i18n/en.json | 25 + packages/editor/test/i18n.test.ts | 24 + packages/fls/src/dnsHosts.ts | 140 +- packages/fls/src/projectSnapshot.ts | 6 +- packages/fls/test/importProject.test.ts | 7 +- packages/protocol/src/dns.ts | 14 + packages/protocol/src/index.ts | 1 + packages/protocol/src/sim.ts | 5 +- packages/sim/src/room.ts | 50 +- packages/sim/test/dnsWindow.test.ts | 113 ++ tools/java-oracle/README.md | 34 + tools/java-oracle/run.sh | 12 +- tools/java-oracle/src/DnsWindowRun.java | 302 ++++ 27 files changed, 2850 insertions(+), 158 deletions(-) create mode 100644 packages/core/src/runtime/apps/dnsServer.ts create mode 100644 packages/core/src/runtime/dnsHosts.ts create mode 100644 packages/core/test/dnsWindow.java.json create mode 100644 packages/core/test/dnsWindow.test.ts create mode 100644 packages/editor/browser/dns.spec.ts create mode 100644 packages/editor/src/desktop/DnsServerPanel.tsx create mode 100644 packages/protocol/src/dns.ts create mode 100644 packages/sim/test/dnsWindow.test.ts create mode 100644 tools/java-oracle/src/DnsWindowRun.java diff --git a/CHANGES.md b/CHANGES.md index 67be343..d626386 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -16,6 +16,7 @@ Swift-Pfade relativ zu `filius-ipad/ios/FiliusPad/TopologyEditor/` (Stand | `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). Gateway-NAT seit F2.6 (Z. ~3585–3900) nach Javas `filius.software.nat`, an Java-Filius gemessen: die Übersetzung vor dem IP-Thread (wie Javas NAT-Threads zwischen Karte und Ethernet), ausgehend WAN-Adresse und der Port des Eintrags (ICMP behält die Kennung), eingehend nach Gegenstelle, Port und Protokoll, sonst nach der Portweiterleitung (legt einen dynamischen Eintrag an), ein Ping an die WAN-Adresse beantwortet der Heimrouter, Pakete von außen an das LAN verschwinden, beide Seiten durch die Firewall des Heimrouters, ausgehend TTL 1 bekommt „Zeit überschritten“; dynamische Einträge verfallen nach 300 s mal Javas Verzögerungsfaktor (Swift fest 300 s), aufgefrischt nur eingehend, seit 01.10.2026 genau in diesem Alter (Java findet sie erst bei seiner sekündlichen Prüfung, bis zu 1 s später; so auch der Switch: Javas `checkSAT` prüft jede Sekunde, der Kern verfällt genau zur Lernzeit); der Heimrouter leitet weiter wie ein Router. Abweichend von Java: ein LAN-Endpunkt bekommt für jede neue Gegenstelle einen Eintrag auf demselben WAN-Port (Java behält nur den für die erste, die Antworten weiterer bleiben am Heimrouter hängen). RIP seit F2.5a (Z. ~4700–5090) nach Javas `filius.software.rip`, seit F2.5b an Java-Filius gemessen (die Pause in Javas `float`-Rechnung): 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 genau nach ihren 75 s auf 16 gesetzt und sofort gemeldet (Java erst beim nächsten eigenen Beacon, bis zu gut 30 s später; Swift bei jedem Ereignis; seit 01.10.2026, vorher wie Java); 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. Seit der Abnahme F2 nach Javas `ARPThread`: ein Eintrag entsteht nur aus einer Anfrage nach der eigenen Adresse oder aus einer Antwort (Swift: aus jedem Paket; `swiftArp` behält das), ein Paket von der Netzadresse wird verworfen; ein Rechner leitet weiter, wenn seine IP-Weiterleitung an ist (`ipForwardingByNodeId`). Seit F2.8b nach Javas Tabellen gemessen: eine ARP-Anfrage trägt als Ziel-MAC die Broadcast-Adresse (Swift: Nullen; `swiftArp` behält sie), eine ICMP-Fehlermeldung Identifier und Sequenznummer 0 (RFC 792, Java; Swift kopiert die des Echos, ein Ping erkennt die Meldung am eingebetteten Echo), und „Host nicht erreichbar“ vom Router trägt das Paket, wie er es weitergeleitet hat (TTL gesenkt). Die Broadcast-Adresse schreibt sie immer groß (Java in der Antwort an 0.0.0.0 klein). Den Mitschnitt je Karte (`capture.ts`) schreibt sie seit F2.8a dort, wo Java ihn schreibt: beim Abgang auf das Kabel und bei der Ankunft an der Karte. Seit F3.4a läuft die Befehlszeile mit (`terminal`, auf den Dateien des Knotens). Seit F3.4b nach Javas `ICMP`/`ICMPThread`, an Java-Filius gemessen: ein Echo im Programm-Thread (`icmpEchoSteps`: Warteschlange leeren, senden mit ARP-Wartezeit, eine RTT auf die erste ICMP-Nachricht warten, gleich welche), ein ICMP-Paket an den Broadcast eines eigenen Netzes geht mit TTL 1 an alle und wird von dort beantwortet, einer für ein fremdes Netz verworfen; Javas Routenwahl (`routeRow`), die ARP-Tabelle mit Javas Broadcast-Zeile (`arpTable`, `removeArpEntry`, `resetArpTable`), DNS im Programm-Thread mit Antwort- und Autoritätsteil (`resolveDnsSteps`), ein UDP-Socket weiß, dass er ohne Gegenstelle lauscht (`listening`); ein Rechner nimmt Pakete an 127.0.0.1 an. Eine ARP-Wartezeit endet wie Javas `wait` nach allem, was zur selben Zeit sonst geschieht (eine Antwort zur selben Millisekunde zählt noch). Seit F3.4c die Uhrzeit eines Frames (`wallTime`) für `tcpdump`. Seit F3.6a läuft das Gnutella-Programm mit (`gnutella`, Schnappschuss `gnutellaByNodeId`), mit eigener Zufallsquelle für die GUIDs (`drawGnutellaGuid`). Seit F3.5a schreibt der Mailserver seine Kontendatei `/mailserver/konten.txt` beim Start und bei jeder Änderung seiner Konten in die Dateien des Knotens (Java schreibt die Benachrichtigung an einen lokalen Absender erst beim nächsten Speichern hinein). Abweichend von Java: eine Echo-Antwort kommt von der gefragten Adresse (RFC 792; Java antwortet von 127.0.0.1, wenn die eigene Adresse gefragt war, und von der nahen Karte, wenn die ferne eines Routers gefragt war), und „Netz nicht erreichbar“ vom Router trägt wie „Host nicht erreichbar“ das Paket, wie er es weitergeleitet hätte, mit Absender (Java bettet ein halb gebautes ohne Absender ein). Seit F3.3b läuft das E-Mail-Programm mit (`emailClient`), seine gesendeten und abgeholten Mails stehen im Schnappschuss (`emailClientsByNodeId`); ein Programm-Thread kann MX nachschlagen (`resolveDnsMailServerSteps`). Seit F3.3a läuft der E-Mail-Server mit (`emailServer`); was er annimmt oder löscht, steht im Schnappschuss (`emailServersByNodeId`), aus dem es zurück ins Projekt geht; die Uhrzeit für seine Benachrichtigungen kommt aus der Option `wallClock`. Seit F3.2c ändert die Seite `konfig` der Router und Heimrouter die Firewall zur Laufzeit; die Änderung steht im Schnappschuss (`firewallConfiguration`), aus dem sie zurück ins Projekt geht. Seit F3.2b starten Router und Heimrouter ihren Webserver auf Port 80 mit Javas Seiten (Javas Server lauscht wegen eines Wettlaufs in `setAktiv` manchmal nie, der Kern immer; `swiftRouters` lässt ihn für den Swift-Vergleich weg), die Firewall führt ein Protokoll je Knoten (Regeltreffer bei TCP und UDP, verworfenes ICMP), das wie Javas Protokollseite einen Neustart überlebt, eine RIP-Route kennt die öffentliche Adresse des meldenden Routers (`hopPublicIp`), und TCP zählt Sequenznummern und Segmentgröße wie Java in Zeichen (UTF-16-Einheiten) statt in Bytes, ohne ein Zeichenpaar zu zerreißen; Bytes, die kein Text sind, zählen als Bytes. Seit F3.2a schließt ein Programm wie Javas `schliessen()` nur auf Bitte (`requestTcpClose`): Das FIN geht zur selben Zeit, nach dem, was der Programm-Thread noch tut. Seit F3.1 laufen Verbinden, Senden, Empfangen und Annehmen auch als Schritte eines Programm-Threads (`connectTcpSteps`, `sendTcpSteps`, `receiveTcpSteps`, `acceptTcpSteps`, `resolveHostSteps`); die blockierenden Aufrufe sind nur noch eine Hülle darum. Abweichend von Java: Ein Verbindungsaufbau ohne Route scheitert sofort (Javas Socket-Thread stirbt an der `RouteNotFoundException`, `verbinden` wartet ewig), eine Nachricht, die vor dem Schließen ankam, wird noch ausgegeben (Java verwirft sie), und Senden unterscheidet wie Java „nicht verbunden", „unterbrochen" und „Zeitüberschreitung". WLAN seit F2.7 an Java-Filius gemessen: zur Laufzeit ist ein Funkkabel ein Kabel wie jedes andere, und eine SSID verbindet nichts (nach ihr legt nur Javas Editor das Funkkabel, `JHostKonfiguration.updateWifiConnection`); Swifts Verbindung über die SSID zur Laufzeit, auch ohne Kabel (`wirelessAssociations`), nicht übernommen. Klasse statt Werttyp. Noch nicht übertragen (wirft `NotPortedError`): Remote Link | | `src/runtime/engine.ts` (ganze Millisekunden) | Java `System.currentTimeMillis` (long) | behoben (F4.6b): `start` und `advance` runden auf ganze Millisekunden ab, wie Javas Uhr zählt. Gefunden im Browser: der Raum gab `performance.now()` mit Bruchteilen weiter, und ein Ping blieb nach der ersten Zeitüberschreitung hängen; alle Tests liefen zuvor mit glatten Takten | | `src/runtime/engine.ts` (leere Nachricht), `test/apps.test.ts` | Java `TCPSocket.erstelleSegmente`, `TCPSocket.run` (gemessen mit `EchoWindowRun`) | behoben (F4.6g1): ein leeres Segment mit PSH an der erwarteten Stelle ist eine leere Nachricht und wird ausgeliefert (vorher galt es als Wiederholung, weil seine Nummer die nächste nicht weiterzählt; Javas Echo beantwortet sie). Java beendet eine Nachricht mit jedem PSH, auch dem eines wiederholten Segments (eine leere Nachricht mehr, wenn ein ACK verloren geht); hier nur mit einem neuen — ein wiederholtes leeres ist an seiner Nummer nicht von einem neuen zu unterscheiden (benannt) | +| `src/runtime/apps/dnsServer.ts`, `src/runtime/dnsHosts.ts`, `src/runtime/engine.ts` (DNS-Server), `src/runtime/snapshot.ts` (`DnsServerSnapshot`), `test/dnsWindow.test.ts`, `test/dnsWindow.java.json` | Java `DNSServer` (`hinzuRecord`, `loescheResourceRecord`, `changeSingleEntry`, `leseRecordListe`, `schreibeRecordListe`), `ResourceRecord`, `GUIApplicationDNSServerWindow`, `DNSConfigTable` (gemessen mit `DnsWindowRun`) | neu (F4.6g2): der DNS-Server als Programm, das sein Fenster ändert — an und aus (sein Socket auf Port 53), rekursive Auflösung, und die Einträge, die `/dns/hosts` sind und bei jeder Anfrage gelesen werden wie in Java (ein anderes Programm, der Texteditor, ändert sie sofort); ohne Datei die der Einstellungen. Der Schnappschuss nimmt jetzt jeden installierten DNS-Server mit, ob er läuft (`isActive`). Der Leser von `/dns/hosts` zieht aus `packages/fls` hierher (`isHostsDomainName` statt `isDomainName`), dazu `newHostsRecord`, `dnsHostsText`, `hostsRecordsOf`. Gegen Javas Fenster geprüft, auch die Antworten des Resolvers. Abweichend von Java: ein hinzugefügter Eintrag behält, was ein anderes Programm inzwischen in die Datei schrieb (Java schreibt seine alte Liste zurück); eine Zelle ändert nur ihren Eintrag (Java auch die folgenden Einträge anderer Art); eine entfernte Zeile ist dieser Eintrag (Java der erste des Namens); ein Eintrag braucht einen Namen (Java nimmt den leeren) | | `src/runtime/engine.ts` (Installation), `src/runtime/apps/installation.ts`, `test/installation.test.ts` | Java `InternetKnotenBetriebssystem.installAndStartAppIfAvailable/entferneSoftware`, `WebServer.erzeugeStandardVerzeichnis`, `PeerToPeerAnwendung`/`EmailServer`/`DNSServer.setSystemSoftware/starten`, `tmpl/webserver_index_*`, `gfx/allgemein/splashscreen-mini.png` | neu (F4.6c): was ein Programm bei der Installation in die Dateien legt (Javas Startseite je Sprache, Bild, `/peer2peer`, `/mailserver/konten.txt`, `/dns/hosts`); `installProgram`/`removeProgram` im laufenden Betrieb — Einstellungen und fehlende Dateien aus dem Schnappschuss des Dokuments, Programm sofort gestartet; entfernt wird ein Server abgeschaltet, die Dateien bleiben. Die Programmerzeugung beim Start läuft über dieselbe `startProgram` in der bisherigen Reihenfolge | | `src/runtime/apps/web.ts` (F4.6d), `test/web.test.ts` | Java `WebBrowser.retrieveImages`, `GUIApplicationWebBrowserWindow.hyperLinkListener`, `WebServer.changeVHostTable/saveVHosts/setUseVHost` (gemessen mit `BrowserWindowRun`) | erweitert (F4.6d): die Zeile einer Seite nennt Host und Pfad, die eines Bildes trägt seine Daten (für das Fenster); `pageReference` und `linkTarget` — relative Bilder und Links zählen vom Ordner der Seite (Java vom Wurzelordner des Servers; für eine Seite im Wurzelordner sind die Anfragen Javas), eine Marke (`#…`) und andere Schemata führen nirgends hin; der Webserver schaltet seine virtuellen Hosts um und schreibt ihre Tabelle, nur mit vollständigen Zeilen (Java schreibt jede Zeile, und beim Lesen rückt eine Zelle einer unvollständigen in die Zeile davor) | | `src/runtime/apps/emailClient.ts`, `src/runtime/apps/emailServer.ts`, `src/runtime/emailAccountsFile.ts`, `src/runtime/engine.ts` (F4.6e) | Java `GUIApplicationEmailAnwendungWindow.kontoSpeichern/emailLoeschen`, `EmailAnwendung.setzeKonto/speichern/removeReceivedMail/removeSentMail`, `GUIApplicationEmailServerWindow`, `EmailServer.setMailDomain/benutzerHinzufuegen/kontoLoeschen` (gemessen mit `MailWindowRun`) | erweitert (F4.6e): das E-Mail-Programm setzt sein Konto (`accountOfForm` mit Javas Mustern für Ports und Benutzernamen, `splitName` wie Javas Dialog: letztes Wort Nachname) und schreibt es nach `/konten.txt` (`clientAccountFileText`, jetzt im Kern statt im Export), löscht eine Mail eines Ordners, prüft ein Formular wie Javas Fenster (`mailFormProblem`) und sendet mit dem Konto, mit dem die Mail geschrieben wurde; der Server setzt seine Domain (nicht leer, Javas Muster), legt ein Konto an (einen vorhandenen Namen in jeder Schreibweise nicht — Javas Fenster meldet es als angelegt) und entfernt eines, sein Startknopf schreibt die eigene Zeile des Fensters; die neue Domain steht sofort in Schnappschuss und Kontendatei (Java erst bei der nächsten Kontenänderung) | @@ -94,6 +95,7 @@ Swift-Pfade relativ zu `filius-ipad/ios/FiliusPad/TopologyEditor/` (Stand | `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; seit der Abnahme F2 `ipForwardingEnabled` jedes Knotens mit IP-Stapel, mit Javas Standard der Klasse (Router und Heimrouter an) | | `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/dnsHosts.ts` (F4.6g2) | — | umgezogen (F4.6g2): der Leser von `/dns/hosts` liegt jetzt im Kern, wo der DNS-Server der Engine die Datei bei jeder Anfrage liest; hier bleibt das Finden der Datei im Baum eines Projekts, `isDomainName` ist `isHostsDomainName` des Kerns | | `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 | | `test/topology.test.ts` | — | neu: alle 23 Korpus-Dateien Feld für Feld wie Java (Geräte, Schnittstellen, Kabel, Doku-Elemente), dazu die Regeln, die der Korpus nicht berührt; Java lädt seit M8 über den vollständigen Ladeweg, eigene Anwendungen (`test/ownApplications.ts`) sind bis B2 ausgenommen | @@ -104,6 +106,7 @@ Swift-Pfade relativ zu `filius-ipad/ios/FiliusPad/TopologyEditor/` (Stand | `test/javaBeansWriter.test.ts`, `test/beans/` | — | neu: 11 Dateien des Schreibers, von Javas `XMLDecoder` gelesen und beschrieben; Erwartungen von Hand | | `src/projectSnapshot.ts` | Swift `TopologyNetworkRuntimeTopologySnapshot(editorState:)` | neu (Abnahme F2): das importierte Projekt als Schnappschuss des Kerns; nur die Kabel verbinden (Swifts Verbindung über die SSID nicht übernommen), nur eingeschaltete DNS-Server laufen; seit F3.1 jeder installierte Echo-Server (ein oder aus, er lässt sich zur Laufzeit schalten) und einfache Client; seit F3.2a jeder Webserver und Browser und die Dateisysteme; seit F3.2c der Rückweg `projectWithSimulationChanges`: die Firewalls aus dem Schnappschuss des Laufs ins Projekt, wie Java sie beim Speichern hat; seit F3.3a die installierten E-Mail-Server (ein oder aus) und auf dem Rückweg ihre Postfächer; seit F3.3b die installierten E-Mail-Programme und auf dem Rückweg ihre Ordner; seit F3.4a die Knoten mit der Befehlszeile (ihre Dateien sind die des Projekts) | | `src/projectSnapshot.ts` (`emailServerAfterRun`) | Java `EmailServer` (gespeichert nach einem Lauf, gemessen mit `SaveAfterRunRun`) | erweitert (F4.6f1): der Rückweg nimmt auch die Domain eines E-Mail-Servers mit; als eigene Funktion für den Rückweg des Raums ins Dokument | +| `src/projectSnapshot.ts` (DNS-Server) | Java `DNSServer.setAktiv` (das Fenster schaltet im Lauf an und aus) | geändert (F4.6g2): jeder installierte DNS-Server kommt in den Schnappschuss, mit `isActive` (vorher nur die laufenden) | | `test/examplesJava.test.ts`, `test/examples/*.java.json` | — | neu (Abnahme F2): fünf Beispiele von Filius (`internetwork_komplett`, `einfaches_rechnernetz_komplett`, `dns_server`, `nat_with_mapped_port`, `Internet_Komplett_…_DE`) mit dem eigenen Leser eingelesen und im Kern gespielt, gegen Java-Filius (`ExampleRun`): je Karte Frames, Zeiten und Tabellenzeilen, die Aufrufe, die Adressen am Ende; gleichzeitig startende DHCP-Clients je Client; zwei benannte Java-Fehler (`JAVA_BUGS`: Ping ohne Gateway wirft eine `NullPointerException`; NAT-Eintrag nur für die erste Gegenstelle) | | `test/classicsJava.test.ts`, `test/exampleRun.ts`, `test/examples/{web,email,gnutella}.java.json` | — | neu (Abnahme F3): die Unterrichtsklassiker auf Beispielen von Filius gegen Java-Filius (`ExampleRun`): Webseite über ihren DNS-Namen (`internetwork_komplett`), E-Mail zwischen zwei Netzen durch NAT und Weitergabe von Server zu Server (`Internet_Komplett_…_DE`), Datei per Gnutella über einen Router finden und holen, Ping, Pong und Query weitergereicht (`zwei_rechnernetze_komplett`, Programme vom Test eingesetzt); je Karte Frames, Zeiten und Tabellenzeilen (ohne RIP), die Zeilen jedes Programms, Mails, Teilnehmer, Ergebnisse und Dateien am Ende; Javas Fehler aus F3.6b und `closedPort` benannt (`JAVA_BUGS`); gleich lange RIP-Wege nach Javas Wahl (`equalRipTies`). `exampleRun.ts`: was beide Beispiel-Tests teilen (Datei öffnen, Javas Namen, Karten nach MAC) | | `src/importProject.ts` (`modemPair`, `modemPort`) | — | zusammengelegt (F4.5a): das Modem-Paar eines Ports an einer Stelle, für Import, Export und das Geräte-Blatt | @@ -160,6 +163,7 @@ Swift-Pfade relativ zu `filius-ipad/ios/FiliusPad/TopologyEditor/` (Stand | `src/web.ts`, `src/sim.ts` (`view`, `start.language`) | Java `GUIApplicationWebBrowserWindow`, `GUIApplicationWebServerWindow` (gemessen mit `BrowserWindowRun`) | neu (F4.6d): der Bildschirmstand eines Programmfensters (`view`, ganz, wenn er sich ändert) — der des Browsers (Adresszeile, Adresse des letzten Abrufs, Seite: Start, nicht erreichbar, Fehler mit Status, HTML mit den gekommenen Bildern, Text, Bild) und der des Webservers (Knopf, Kästchen, Tabelle); `browserTitle` wie Javas Fenster (Programmname, dann die Adresse, dann der Titel der Seite), `htmlTitle`; der Start nennt die Sprache, in der FILIUS für die startende Person läuft | | `src/mail.ts` | Java `GUIApplicationEmailAnwendungWindow` (`posteingangAktualisieren`, `gesendeteAktualisieren`, `replyLayout`), `GUIApplicationEmailServerWindow` (gemessen mit `MailWindowRun`) | neu (F4.6e): die Bildschirmstände von E-Mail-Programm (Konto, Posteingang, Gesendete) und E-Mail-Server (Knopf, Domain, Konten mit Zahl der Mails); die Zeilen der Ordner und die Antwort (an den Absender, „RE:“, zitierter Text) wie Javas Fenster | | `src/echo.ts` | Java `GUIApplicationServerBausteinWindow`, `GUIApplicationClientBausteinWindow` (gemessen mit `EchoWindowRun`) | neu (F4.6g1): die Bildschirmstände von Echo-Server (läuft, Port) und Einfachem Client (verbunden, Adresse, Port); `portOfText`: ein Port, wie ein Socket ihn haben kann (Javas `Integer.parseInt` nimmt jede Zahl und wirft bei anderem Text) | +| `src/dns.ts`, `src/sim.ts` (`dnsServer`) | Java `GUIApplicationDNSServerWindow` (gemessen mit `DnsWindowRun`) | neu (F4.6g2): der Bildschirmstand des DNS-Servers — Knopf, Kästchen, die Zeilen der drei Tabellen, wie `/dns/hosts` sie hält | ## packages/sim @@ -177,6 +181,7 @@ Swift-Pfade relativ zu `filius-ipad/ios/FiliusPad/TopologyEditor/` (Stand | `src/room.ts` (Rückweg), `test/writeBack.test.ts`, `test/fixtures/after-run-*.fls`, `test/terminalWindow.test.ts` | Java `GUIMainMenu.stopSimulation`, `SzenarioVerwaltung.speichern` (gemessen mit `SaveAfterRunRun`) | erweitert (F4.6f1): der Raum schreibt, was ein Lauf hinterlässt (Dateien, laufende Server, virtuelle Hosts, Domain und Konten der E-Mail-Server, Konto und Ordner der E-Mail-Programme, Firewalls, die Adresse eines DHCP-Clients), als `origin: 'sim'` ins Dokument: höchstens einmal je Sekunde (ab dem Start gezählt), nach jeder Aktion in einem Fenster und beim Stopp. Gegen Javas nach dem Lauf gespeichertes Projekt geprüft; die Befehlszeile geht nach einem Neustart im vorher angelegten Ordner weiter wie Javas (die benannte Abweichung aus F4.6b ist weg) | | `src/room.ts` (Dateien in beide Richtungen), `test/writeBack.test.ts` | Plan §7.3 | erweitert (F4.6f2): was eine Person ins Dokument schreibt (der Texteditor), geht im Lauf sofort in die Dateien der Engine; was die Engine an ihren Dateien ändert, geht im Takt der Änderung ins Dokument (nicht erst nach einer Sekunde), damit das Dokument jede Änderung der Engine hat, bevor die nächste einer Person kommt — so behält ein Text, den beide zugleich ändern, beides. Ein Rechner, dessen Programme keine Dateien benutzen, liest seine beim nächsten Start | | `src/room.ts` (Echo), `test/echoWindow.test.ts`, `test/echoWindow.java.json`, `test/fixtures/after-run-echo.fls`, `test/room.test.ts` | Java `GUIApplicationServerBausteinWindow`, `GUIApplicationClientBausteinWindow` (gemessen mit `EchoWindowRun`), `SaveAfterRunRun echo` | erweitert (F4.6g1): der Raum nimmt `setActive` mit Port beim Echo-Server und `connect`/`disconnect`/`send` beim Einfachen Client an, schickt ihre Bildschirmstände und schreibt nach dem Lauf Port und Ziel zurück, wie Javas Projekt sie behält. Gegen Javas Fenster und Javas gespeichertes Projekt geprüft; benannt: drei SYN statt einem (F2.3c) — ein Verbindungsversuch dauert länger | +| `src/room.ts` (DNS-Server), `test/dnsWindow.test.ts` | Java `GUIApplicationDNSServerWindow`, `DNSConfigTable` | erweitert (F4.6g2): der Raum nimmt `setActive`, `setRecursive`, `addRecord`, `removeRecord`, `changeRecord` an, schickt den Bildschirmstand (ohne Zeilen; Javas Fenster hat keine) und schreibt nach dem Lauf zurück, ob der Server läuft, die rekursive Auflösung und die Einträge der Datei in seine Einstellungen | | `browser/m9.spec.ts`, `browser/traceDigest.ts`, `browser/*Entry.ts`, `/playwright.config.ts` | Plan §13 M9 | neu (F4.2): M9 in Chromium — 17 Beispiele, je 30 s mit Eingaben, ergeben Zeichen für Zeichen denselben Mitschnitt wie in Node; der Raum in einem echten Worker | ## packages/editor @@ -209,6 +214,7 @@ Swift-Pfade relativ zu `filius-ipad/ios/FiliusPad/TopologyEditor/` (Stand | `src/desktop/textEditor.ts`, `src/desktop/TextEditorPanel.tsx`, `src/desktop/FileDialog.tsx`, `src/desktop/textBinding.ts`, `src/assets/desktop/datei.png`, `src/assets/desktop/ordner.png` | Java `GUIApplicationTextEditorWindow` (`texteditor_msg3`–`6`), `DMTNFileChooser` (`dmtnfilechooser_msg1`–`11`), `OrdnerInhaltListRenderer` (Javas Symbole aus `gfx/desktop`), gemessen mit `TextEditorWindowRun` | neu (F4.6f2): der Texteditor — „Neu“, „Öffnen“, „Speichern“, „Speichern unter“ über Javas Dateidialog, der Titel mit dem Namen der offenen Datei, Javas Courier; der Text der offenen Datei ist ihr `Y.Text` im Dokument (Plan §7.3): Getipptes steht sofort in der Datei, für alle, die sie offen haben, und für die laufende Simulation (Java hält eine Kopie bis „Speichern“). Strg+Z/Strg+Y nehmen das eigene Getippte zurück. Besser als Java: „Speichern unter“ auf eine vorhandene Datei schreibt sie und bleibt in ihr (Java bleibt in einer Kopie, die kein Ordner hält, jedes spätere „Speichern“ ging verloren); eine gelöschte offene Datei lässt ihren Text als neuen Text, „Speichern“ fragt nach einem Namen (Javas ging verloren); was der Dialog nicht findet oder kein Text ist, sagt er, die offene Datei bleibt; der Dialog zeigt den Pfad des Ordners (Java nur den Namen, die Wurzel als „root“), ein Tippen öffnet einen Ordner oder nimmt einen Dateinamen (Java will einen Doppelklick) | | `src/desktop/fileExplorer.ts`, `src/desktop/windowState.ts`, `src/desktop/FileExplorerPanel.tsx`, `src/desktop/ImageViewerPanel.tsx` | Java `GUIApplicationFileExplorerWindow` (`fileexplorer_msg2`–`19`, `sw_fileexplorer_msg1`–`3`, `sw_fileimporter_msg3`), `GUIApplicationImageViewerWindow` (`imageviewer_msg2`), gemessen mit `FileExplorerWindowRun` | neu (F4.6f3): der Datei-Explorer — Baum aus Ordnern und Dateien, die Einträge des gewählten Ordners mit Javas Symbolen, Javas Menü per Rechtsklick (auf einem Eintrag Löschen, Ausschneiden, Kopieren, Umbenennen; auf der freien Fläche Neuer Ordner, Einfügen), Javas Fragen und das Importfenster; Kopiertes bleibt zum nächsten Einfügen, Ausgeschnittenes nicht. Der Bildbetrachter — „Öffnen“ über Javas Dateidialog, das Bild, sein Name als Titel. Die Dateien sind die des Dokuments, geändert über die Operationen des Kerns. Besser als Java: Baum und Liste folgen den Dateien sofort („Aktualisieren“ entfällt); die Aktionen des Menüs sind auch Knöpfe, ein Finger braucht keinen Rechtsklick; ein Ordner der Liste öffnet mit Doppelklick; eine im Baum gewählte Datei wird in ihrem Ordner gewählt (Java hält sie für einen Ordner, ein neuer Ordner landete unter ihr); was eine Aktion ablehnt, wird gesagt; der neue Name beginnt mit dem alten; der Bildbetrachter zeigt ein Bild statt alle übereinander und sagt, was kein Bild ist. `windowState.ts`: was die Fenster der lokalen Programme teilen (auch der Texteditor) | | `src/desktop/EchoPanels.tsx`, `src/desktop/serverLines.ts` (`simpleClientLine`) | Java `GUIApplicationServerBausteinWindow` (`serverbaustein_msg1`–`3`), `GUIApplicationClientBausteinWindow` (`clientbaustein_msg1`–`6`), `ClientBaustein` (`sw_clientbaustein_msg2`–`6`), `Socket`/`TCPSocket` (`sw_socket_msg1`, `sw_tcpsocket_msg2`, `3`, `9`) | neu (F4.6g1): Echo-Server — Starten/Anhalten mit Port (nur angehalten beschreibbar) und seine Zeilen; Einfacher Client — Server-Adresse und -Port (verbunden gesperrt, nach einer Änderung des Programms wieder dessen Werte), Verbinden/Trennen, Nachricht und Senden (nur verbunden, danach leer), seine Zeilen in Javas Worten. Besser als Java: ein Port, der keiner ist, wird rot markiert und nicht übergeben (Javas Knopf wirft und tut nichts) | +| `src/desktop/DnsServerPanel.tsx`, `src/desktop/WebServerPanel.tsx` (Zelle) | Java `GUIApplicationDNSServerWindow` (`dnsserver_msg1`–`23`), gemessen mit `DnsWindowRun` | neu (F4.6g2): der DNS-Server — Starten/Beenden, das Kästchen der rekursiven Auflösung, drei Reiter mit je zwei Feldern (das Adressfeld beginnt mit der eigenen Adresse), rot, solange die Prüfung sie nicht nimmt, „Hinzufügen“ (sonst Javas gültiges Beispiel), „Auswahl entfernen“ und die Tabelle, deren Zellen beim Verlassen geschrieben werden. Besser als Java: eine Zelle, die die Prüfung nicht nimmt, wird markiert, bevor sie zurückgeht. **Behoben, auch im Webserver (F4.6d):** eine Zelle übernahm den neuen Wert des Raums, der kurz nach einem Enter kommt, auch über das inzwischen Getippte; jetzt nur, solange seit Enter, Escape oder Verlassen nichts getippt ist | | `src/Editor.tsx`, `src/canvas/Canvas.tsx` (Aktionsmodus) | Java `GUIContainer.setActiveSite/showDesktop/closeDialogs/reopenDialogs`, `GUIKnotenItem` (Klick im Aktionsmodus), `GUIEvents.kontextMenueAktionsmodus` (`guievents_msg3`), `JCablePanel` (`farbeBlinken`) | erweitert (F4.6a): im Aktionsmodus ist der Entwurf gesperrt wie bei Java — keine Palette, kein Blatt, nichts bewegt sich, kein Rückgängig, kein Löschen —, Kabel mit Rahmen leuchten grün, ein Tipp auf einen Rechner öffnet seinen Desktop (Langdruck: „Desktop anzeigen“); offene Desktops überstehen den Entwurf und erscheinen beim nächsten Start wieder, die eines entfernten Rechners nicht | | `browser/simulation.spec.ts`, `test/programs.test.ts` | — | neu (F4.6a): Moduswechsel, Sperren, Geschwindigkeit, Kabelaktivität, Desktop mit Titel, Programmen, Netzwerk-Info, Verschieben und Stapeln, Handy, Englisch; die Programmliste gegen Javas `Desktop_*.txt`; seit F4.6b die Befehlszeile (Ping über drei Router, Verlauf, Chips, Strg+C, Behalten, exit) und der Fehlerfall des Raums; seit F4.6c die Software-Installation (sofort lauffähig, Doppelklick, Abwählen) | | `browser/web.spec.ts`, `browser/fixtures/web-pages.fls`, `test/webPagesFixture.test.ts`, `test/serverLines.test.ts`, `src/simulation/useSimulation.ts` (`views`), `src/Editor.tsx` (Sprache beim Start), `src/desktop/DesktopWindow.tsx` | — | neu (F4.6d): Browser und Webserver in Chromium auf einer eigenen Projektdatei (mit den Befehlen des Dokuments gebaut und bei jedem Lauf verglichen): Adresszeile, Titel, Sandbox, Bilder, Marke, Links im Ordner, beide Formulare bis in die Zeilen des Servers, Fehlerseite, nicht erreichbarer Server, Knopf, Kästchen und Tabelle, die Routerseiten auf Englisch; die Zeilen des Servers gegen Javas Fenster | @@ -217,6 +223,7 @@ Swift-Pfade relativ zu `filius-ipad/ios/FiliusPad/TopologyEditor/` (Stand | `browser/text.spec.ts`, `test/textEditor.test.ts`, `test/textBinding.test.ts`, `src/main.tsx` (`filiusDev.insertText`) | — | neu (F4.6f2): Javas Schritte am Fenster des Texteditors nachgespielt (gleich, wo Java recht hat, benannt, wo es irrt); in Chromium: im Lauf installiert, eine Seite über Javas Dialog in den Webserver gespeichert, vom anderen Rechner abgerufen, im Lauf geändert und wieder abgerufen, der Cursor bleibt, wo man tippt, Rückgängig, Ablehnungen im Dialog, eine von der Befehlszeile gelöschte offene Datei, „Neu“, Weitertippen nach einer fremden Änderung vor dem Cursor (die Entwicklungsseite schreibt dafür wie eine andere Person in eine Datei); wohin Cursor und Auswahl bei fremden Änderungen gehen | | `browser/files.spec.ts`, `test/fileExplorer.test.ts` | — | neu (F4.6f3): Javas Schritte an Explorer und Bildbetrachter nachgespielt (gleich, wo Java recht hat, benannt, wo es irrt); in Chromium: im Lauf installiert, Ordner angelegt, über Javas Rechtsklick-Menü kopiert und eingefügt, Javas Meldung beim Einfügen in sich selbst, per Knopf umbenannt und nach „Sicher?“ gelöscht, was die Befehlszeile anlegt sofort sichtbar, ein lokales Bild importiert und im Bildbetrachter gezeigt, eine Textdatei dort abgelehnt | | `browser/echo.spec.ts`, `test/serverLines.test.ts` (Echo) | — | neu (F4.6g1): Echo in Chromium — im Lauf installiert, ein Port, der keiner ist, markiert, Server gestartet, Client verbunden, Felder gesperrt, Nachricht gesendet und zurückbekommen, Trennen; die Zeilen beider Fenster gegen Javas | +| `browser/dns.spec.ts` | — | neu (F4.6g2): der DNS-Server in Chromium auf `email_komplett` — Knopf, Reiter, eigene Adresse im Feld, Javas Beispiel bei Ungültigem, Eintrag hinzugefügt, Zelle geschrieben und eine abgelehnt, Zeile entfernt, MX, Kästchen, Beenden; die Befehlszeile des Web-Servers löst den neuen Namen auf | | `src/sheet/ServicePanels.tsx`, `src/sheet/RowsEditor.tsx`, `src/sheet/SheetTabs.tsx`, `src/sheet/DeviceSheet.tsx` (Seiten) | Java `JDHCPKonfiguration` (`jdhcpkonfiguration_msg1`–`15`), `JPortForwardingDialog` (`jportforwarding_msg4`–`7`, `jgatewayconfiguration_msg19`) | neu (F4.5c): Javas Dialoge als Seiten des Blatts mit „Zurück“ — DHCP-Server (Grundeinstellungen, statische Adresszuweisung; beim Rechner, gesperrt während DHCP die Karte einrichtet, und auf dem LAN-Reiter des Heimrouters), Portfreigaben (Heimrouter); jedes Feld beim Verlassen übernommen statt mit Javas OK-Knopf. Die Zeilentabellen (Weiterleitung, Zuordnungen, Portfreigaben) teilen sich `RowsEditor`: eine neue Zeile, sobald alle Felder gültig sind, Zeile wählen, bearbeiten, löschen. Javas Tooltip zu „Manuelle Einstellungen“ als Text unter dem Kästchen (ein Finger hat kein Überfahren) | | `src/sheet/RouterSheets.tsx`, `src/sheet/NameField.tsx`, `src/canvas/Canvas.tsx` (`highlightedLink`), `src/theme/tokens.css` (`--canvas-cable-active`) | Java `JVermittlungsrechnerKonfiguration`, `JGatewayConfiguration`, `JWeiterleitungsTabelle` (Wörter `jvermittlungsrechnerkonfiguration_msg6`–`26`, `jweiterleitungstabelle_msg3`–`6`), `JCablePanel.farbeBlinken`, `Kabel.setAktiv` | neu (F4.5b): Blatt des Vermittlungsrechners — Reiter Allgemein (Name, Gateway, Automatisches Routing, IP-Weiterleitung nur angezeigt), je Karte (Reiter mit ihrer Adresse, „Verbunden mit …“, Adresse, Maske, MAC) und Weiterleitungstabelle (alle Einträge oder nur die manuellen, Javas Zeilen grau, eine neue Zeile, die mit vier gültigen Feldern übernommen wird, Zelle bearbeiten, Eintrag löschen; bei RIP gesperrt); Blatt des Heimrouters mit Allgemein, LAN, WAN (DHCP sperrt die WAN-Adresse). Das Kabel der offenen Karte leuchtet grün wie bei Java. Javas „Eintrag Löschen“ klein geschrieben | diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index c2ebe25..9c90808 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -22,12 +22,14 @@ export * from './runtime/apps/program.js'; export * from './runtime/apps/installation.js'; export * from './runtime/apps/server.js'; export * from './runtime/apps/echo.js'; +export * from './runtime/apps/dnsServer.js'; export * from './runtime/apps/web.js'; export * from './runtime/apps/routerPages.js'; export * from './runtime/apps/firewallSettings.js'; export * from './runtime/apps/emailServer.js'; export * from './runtime/apps/emailClient.js'; export * from './runtime/apps/filePrograms.js'; +export * from './runtime/dnsHosts.js'; export * from './runtime/apps/gnutella.js'; export * from './runtime/apps/terminal.js'; export * from './runtime/apps/terminalTexts.js'; diff --git a/packages/core/src/runtime/apps/dnsServer.ts b/packages/core/src/runtime/apps/dnsServer.ts new file mode 100644 index 0000000..6de2c74 --- /dev/null +++ b/packages/core/src/runtime/apps/dnsServer.ts @@ -0,0 +1,130 @@ +// FILIUS' DNS server as its window changes it (DNSServer, +// GUIApplicationDNSServerWindow, DNSConfigTable; measured with +// tools/java-oracle's DnsWindowRun): switched on and off (its socket on port +// 53), recursive resolution, and the records — which are /dns/hosts, read on +// every query as Java's server reads them, so that a record another program +// wrote into the file answers at once. A server without the file (a core +// scenario) answers from its settings until its window writes one. +// +// Departures from Java (dnsWindow.test.ts names each): +// - a record added keeps what another program wrote into the file meanwhile +// (Java writes back the list it read before: the file's new lines are lost); +// - a cell changed changes that record only (Java counts only the records of +// the table's type and, past the one changed, writes the value into each +// record of another type up to the next of its own: editing an MX record's +// server turned the following A record's address into that name); +// - a row removed is that record (Java removes the first of the name: with +// two of one name, the other); +// - a record needs a name (Java's pattern takes the empty one: ". MX 3600 x."). + +import type { DnsRecordType, DnsResourceRecord, DnsServerConfiguration } from '../config.js'; +import { DNS_HOSTS_PATH, dnsHostsText, dnsRecordsOf, hostsRecordsOf, isHostsDomainName, newHostsRecord, readDnsHosts, type HostsRecord } from '../dnsHosts.js'; +import type { VirtualFileSystem } from '../../model/virtualFileSystem.js'; +import { javaIpCheck } from '../../util/javaIp.js'; +import { IP_ADDRESS_PATTERN } from './web.js'; + +/** What the server gets from the engine: its files (none yet, or made to write), and its socket opened and closed. */ +export interface DnsServerHost { + readonly files: () => VirtualFileSystem | undefined; + readonly filesToWrite: () => VirtualFileSystem; + readonly open: () => void; + readonly close: () => void; +} + +/** EingabenUeberpruefung.musterIpAdresse with IP.ipCheck, as the window checks an address. */ +const isAddress = (text: string) => IP_ADDRESS_PATTERN.test(text) && javaIpCheck(text) !== null; + +/** The window's check of a record's two parts: a name, and an address (A) or a name (MX, NS). */ +export function validDnsRecord(type: DnsRecordType, domainName: string, rdata: string): boolean { + return domainName !== '' && isHostsDomainName(domainName) && (type === 'A' ? isAddress(rdata) : rdata !== '' && isHostsDomainName(rdata)); +} + +export class DnsServer { + private active: boolean; + private recursive: boolean; + + constructor( + private readonly host: DnsServerHost, + private readonly configuration: DnsServerConfiguration, + isActive: boolean, + ) { + this.active = isActive; + this.recursive = configuration.recursiveResolutionEnabled; + } + + get isActive(): boolean { + return this.active; + } + + get recursiveResolutionEnabled(): boolean { + return this.recursive; + } + + /** The start button: the socket on port 53 opened or closed. */ + setActive(active: boolean): void { + // Once more alike: the port is taken by its own socket, or there is none to close. + this.active = active; + if (active) this.host.open(); + else this.host.close(); + } + + setRecursiveResolutionEnabled(enabled: boolean): void { + this.recursive = enabled; + } + + private file(): string | undefined { + const files = this.host.files(); + return files !== undefined && files.contains(DNS_HOSTS_PATH) && files.entry(DNS_HOSTS_PATH).content.kind === 'text' ? files.textFile(DNS_HOSTS_PATH) : undefined; + } + + /** The records as the window lists them: the valid lines of /dns/hosts (holeResourceRecords), else the settings'. */ + get records(): HostsRecord[] { + const text = this.file(); + return text === undefined ? hostsRecordsOf(this.configuration.records) : readDnsHosts(text); + } + + /** The records a query is answered from. */ + get answering(): DnsResourceRecord[] { + const text = this.file(); + return text === undefined ? [...this.configuration.records] : dnsRecordsOf(readDnsHosts(text), () => {}); + } + + /** The records of a type in the order of the file, each with its place among all (a row of the window's table). */ + rows(type: DnsRecordType): { readonly record: HostsRecord; readonly index: number }[] { + return this.records.flatMap((record, index) => (record.type === type ? [{ record, index }] : [])); + } + + private write(records: readonly HostsRecord[]): void { + const files = this.host.filesToWrite(); + if (!files.contains('/dns')) files.createDirectory('/dns', true); + files.writeTextFile(DNS_HOSTS_PATH, dnsHostsText(records)); + } + + /** "Hinzufügen" (hinzuRecord): at the end of the file as it is now; false when the window's check fails. */ + addRecord(type: DnsRecordType, domainName: string, rdata: string): boolean { + if (!validDnsRecord(type, domainName, rdata)) return false; + this.write([...this.records, newHostsRecord(domainName, type, rdata)]); + return true; + } + + /** "Auswahl entfernen": the table's row of the type; false when there is none. */ + removeRecord(type: DnsRecordType, row: number): boolean { + const target = this.rows(type)[row]; + if (target === undefined) return false; + this.write(this.records.filter((_, index) => index !== target.index)); + return true; + } + + /** A cell of the type's table written (changeSingleEntry): the name (column 0) or the data (column 1) of that record only; false when the value fails the check. */ + changeRecord(type: DnsRecordType, row: number, column: 0 | 1, value: string): boolean { + const target = this.rows(type)[row]; + if (target === undefined) return false; + const trimmed = value.trim(); + const domainName = column === 0 ? trimmed : target.record.domainName; + const rdata = column === 1 ? trimmed : target.record.rdata; + if (!validDnsRecord(type, domainName, rdata)) return false; + const changed = { ...newHostsRecord(domainName, type, rdata), ttl: target.record.ttl }; + this.write(this.records.map((record, index) => (index === target.index ? changed : record))); + return true; + } +} diff --git a/packages/core/src/runtime/dnsHosts.ts b/packages/core/src/runtime/dnsHosts.ts new file mode 100644 index 0000000..230c365 --- /dev/null +++ b/packages/core/src/runtime/dnsHosts.ts @@ -0,0 +1,158 @@ +// Java's DNS server keeps its records in the text file /dns/hosts and reads +// them on every query (DNSServer.leseRecordListe, ResourceRecord(String)) — +// so does the engine's (moved here from packages/fls, which reads the file +// of a project with it, in F4.6g2). +// Measured on packages/fls/test/dns-hosts.cases.txt against Java (dns-hosts.java.json) +// and Swift (dns-hosts.swift.json). Java's format decides where the two +// differ: +// - fields are split at single spaces only: a tab or NBSP joins two fields; +// - there are no comments, no "IN" and no zone-file order; +// - a TTL that is not a number silently becomes 3600; +// - a name that is not a domain name becomes the empty name, and the record +// still counts - it just never answers, like one of another type; +// - duplicates stay. +// Java's errors are not copied (each named in dnsHosts.test.ts): a label may +// start with a digit and holds no backslash (RFC 1123 - Java's pattern has +// both wrong; see isHostsDomainName), the type A, MX or NS is read in any case +// (RFC 4343), and a negative TTL is invalid (RFC 2181). + +import type { DnsRecordType, DnsResourceRecord } from './config.js'; +import { javaIpCheck, javaParseInt } from '../util/javaIp.js'; +import { javaTrim } from './http.js'; + +/** A record of /dns/hosts, as Java's ResourceRecord holds it (the getters). */ +export interface HostsRecord { + readonly domainName: string; + readonly type: string; + readonly ttl: number; + readonly rdata: string; +} + +export const DNS_HOSTS_PATH = '/dns/hosts'; + +// Labels of letters, digits and inner hyphens, an optional final dot; the +// empty string counts too. +const DOMAIN_LABELS = /^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*)?\.?$/; +/** + * A domain name as EingabenUeberpruefung.musterDomain means it (the empty + * string included). Java's pattern wants every label to start with a letter + * and lets a backslash into the labels after the first; instead a label may + * start with a digit (RFC 1123), but the last one must not be all digits + * (RFC 3696), so that no IPv4 address passes for a name. + */ +export function isHostsDomainName(text: string): boolean { + if (!DOMAIN_LABELS.test(text)) return false; + const labels = text.split('.').filter((label) => label !== ''); + return labels.length === 0 || !/^[0-9]+$/.test(labels[labels.length - 1]!); +} + +// EingabenUeberpruefung.musterIpAdresse. +const JAVA_IP_ADDRESS = /^(0*([1-9]?[0-9]?|1[0-9]{1,2}|2[0-4]?[0-9]|25[0-5]))\.((0*(1?[0-9]{1,2}|2[0-4]?[0-9]|25[0-5]))\.){2}(0*(1?[0-9]{1,2}|2[0-4]?[0-9]|25[0-5]))$/; + +const ANSWERED_TYPES = new Set(['A', 'MX', 'NS']); + +/** `DNSServer.leseRecordListe` on the content of /dns/hosts. */ +export function readDnsHosts(content: string): HostsRecord[] { + const records: HostsRecord[] = []; + for (const token of content.split('\n')) { + const line = javaTrim(token); + // line.split(" ", 5).length < 4: fewer than three single spaces. + if (line === '' || line.split(' ').length < 4) continue; + const record = hostsRecord(line); + if (isValidRecord(record)) records.push(record); + } + return records; +} + +/** `new ResourceRecord(line)`: up to four space-separated fields. */ +function hostsRecord(line: string): HostsRecord { + const fields = line + .split(' ') + .filter((field) => field !== '') + .map(javaTrim); + let domainName = ''; + let type = 'A'; + let ttl = 3600; + let rdata = ''; + if (fields[0] !== undefined && isHostsDomainName(fields[0])) { + domainName = fields[0].endsWith('.') ? fields[0] : `${fields[0]}.`; + } + if (fields[1] !== undefined) { + const upper = fields[1].replace(/[a-z]/g, (letter) => letter.toUpperCase()); + type = ANSWERED_TYPES.has(upper) ? upper : fields[1]; + } + if (fields[2] !== undefined) { + const parsed = javaParseInt(fields[2]); + if (parsed !== undefined && parsed >= 0) ttl = parsed; + } + if (fields[3] !== undefined) { + // Java tests ".*\." (no line terminator before the dot); a target with + // one fails the domain check anyway, so endsWith says the same. + const needsDot = (type === 'NS' || type === 'MX') && !fields[3].endsWith('.'); + rdata = needsDot ? `${fields[3]}.` : fields[3]; + } + return { domainName, type, ttl, rdata }; +} + +function isValidRecord(record: HostsRecord): boolean { + switch (record.type) { + case 'A': + return isHostsDomainName(record.domainName) && JAVA_IP_ADDRESS.test(record.rdata); + case 'MX': + case 'NS': + return isHostsDomainName(record.domainName) && isHostsDomainName(record.rdata); + default: + return true; + } +} + +/** + * The records Java answers with, in the model's form: Java looks them up by + * type (A, MX, NS) and by name ignoring case, so a record with the empty + * name or another type never answers and is left out. The file itself stays + * as it is. + */ +export function dnsRecordsOf(records: readonly HostsRecord[], warn: (message: string) => void): DnsResourceRecord[] { + const answered: DnsResourceRecord[] = []; + for (const record of records) { + if (record.domainName === '') continue; + const name = record.domainName === '.' ? '.' : record.domainName.slice(0, -1).toLowerCase(); + switch (record.type) { + case 'A': { + const address = javaIpCheck(record.rdata); + if (address === null) { + warn(`DNS record '${name} A ${record.rdata}' has no usable IPv4 address, left out (it stays in ${DNS_HOSTS_PATH})`); + continue; + } + answered.push({ name, type: 'A', ttlSeconds: record.ttl, target: address }); + break; + } + case 'MX': + case 'NS': + answered.push({ name, type: record.type, ttlSeconds: record.ttl, target: record.rdata.toLowerCase() }); + break; + default: + break; + } + } + return answered; +} + +/** + * A new record as Java's window makes it (new ResourceRecord(name, type, + * rdata)): the name with its final dot, an MX's or NS's target too, TTL 3600. + */ +export function newHostsRecord(domainName: string, type: DnsRecordType, rdata: string): HostsRecord { + const dotted = (text: string) => (text.endsWith('.') ? text : `${text}.`); + return { domainName: dotted(domainName), type, ttl: 3600, rdata: type === 'A' ? rdata : dotted(rdata) }; +} + +/** The records as DNSServer.schreibeRecordListe writes them: ResourceRecord.toString, a line each. */ +export function dnsHostsText(records: readonly HostsRecord[]): string { + return records.map((record) => `${record.domainName} ${record.type} ${record.ttl} ${record.rdata}\n`).join(''); +} + +/** A model's records as lines of /dns/hosts (for a server without the file). */ +export function hostsRecordsOf(records: readonly DnsResourceRecord[]): HostsRecord[] { + return records.filter((record) => record.type === 'A' || record.type === 'MX' || record.type === 'NS').map((record) => newHostsRecord(record.name, record.type, record.target)); +} diff --git a/packages/core/src/runtime/engine.ts b/packages/core/src/runtime/engine.ts index e7b65b1..d9261f1 100644 --- a/packages/core/src/runtime/engine.ts +++ b/packages/core/src/runtime/engine.ts @@ -112,11 +112,12 @@ import { PacketCapture, PRODUCTION_CAPTURE_RETENTION, type CaptureRetentionPolic import { ICMP_TYPE_CODE } from './captureRows.js'; import { JavaCompatibleRandom } from './random.js'; import { routeTableRows, type RouteRow } from './routeTable.js'; -import { EMPTY_TOPOLOGY_SNAPSHOT, withSnapshot, type NodeSnapshot, type TopologySnapshot } from './snapshot.js'; +import { EMPTY_TOPOLOGY_SNAPSHOT, withSnapshot, type DnsServerSnapshot, type NodeSnapshot, type TopologySnapshot } from './snapshot.js'; import { installationFiles } from './apps/installation.js'; import { RuntimeSpeed } from './speed.js'; import { ApplicationThreads, waitUntil, type Steps, type Wait } from './threads.js'; import { EchoServer, SimpleClient } from './apps/echo.js'; +import { DnsServer } from './apps/dnsServer.js'; import { WebBrowser, WebServer, type WebServerPage } from './apps/web.js'; import { EmailClient, type EmailClientFolders } from './apps/emailClient.js'; import { EmailServer } from './apps/emailServer.js'; @@ -934,6 +935,7 @@ export class NetworkRuntimeEngine { /** The programs, by node (initializePrograms). */ private readonly echoServersByNodeId = new Map(); private readonly simpleClientsByNodeId = new Map(); + private readonly dnsServersByNodeId = new Map(); private readonly webServersByNodeId = new Map(); private readonly webBrowsersByNodeId = new Map(); private readonly emailServersByNodeId = new Map(); @@ -1151,6 +1153,11 @@ export class NetworkRuntimeEngine { } /** The node's generic client while the simulation runs, if it has one installed. */ + /** A node's DNS server while the simulation runs (installed, running or not). */ + dnsServer(nodeId: Id): DnsServer | undefined { + return this.dnsServersByNodeId.get(nodeId); + } + simpleClient(nodeId: Id): SimpleClient | undefined { return this.simpleClientsByNodeId.get(nodeId); } @@ -1381,6 +1388,8 @@ export class NetworkRuntimeEngine { firewallConfigurationsByNodeId: program === 'firewall' ? copied(snapshot.firewallConfigurationsByNodeId, source.firewallConfigurationsByNodeId) : snapshot.firewallConfigurationsByNodeId, }); if (running !== undefined && this.hasProgram(nodeId, running)) this.startProgram(nodeId, running); + const dns = program === 'dnsServer' ? state.topologySnapshot.dnsServerConfigurationsByNodeId.get(nodeId) : undefined; + if (dns !== undefined) this.startDnsServer(nodeId, dns); this.applicationThreads.resume(); } @@ -1432,6 +1441,8 @@ export class NetworkRuntimeEngine { state.topologySnapshot = withSnapshot(snapshot, { gnutellaByNodeId: without(snapshot.gnutellaByNodeId) }); break; case 'dnsServer': + this.dnsServersByNodeId.get(nodeId)?.setActive(false); + this.dnsServersByNodeId.delete(nodeId); state.topologySnapshot = withSnapshot(snapshot, { dnsServerConfigurationsByNodeId: without(snapshot.dnsServerConfigurationsByNodeId) }); break; case 'firewall': @@ -2005,14 +2016,34 @@ export class NetworkRuntimeEngine { // ── DNS (after Java's DNSServer and Resolver) ──────────────────────────── - /** Every DNS server switched on listens on port 53. */ + /** Each DNS server installed; the ones switched on listen on port 53. */ private initializeDnsState(): void { const state = this.stateValue; state.dnsServerSocketIdsByNodeId.clear(); - for (const [nodeId, configuration] of state.topologySnapshot.dnsServerConfigurationsByNodeId) { - const socketId = this.bindUdpSocket(nodeId, { localPort: DNS_PORT }); - if (socketId !== undefined) state.dnsServerSocketIdsByNodeId.set(nodeId, socketId); - } + this.dnsServersByNodeId.clear(); + for (const [nodeId, configuration] of state.topologySnapshot.dnsServerConfigurationsByNodeId) this.startDnsServer(nodeId, configuration); + } + + private startDnsServer(nodeId: Id, configuration: DnsServerSnapshot): void { + const server = new DnsServer( + { + files: () => this.stateValue.topologySnapshot.fileSystemsByNodeId.get(nodeId), + filesToWrite: () => this.nodeFiles(nodeId), + open: () => { + const socketId = this.bindUdpSocket(nodeId, { localPort: DNS_PORT }); + if (socketId !== undefined) this.stateValue.dnsServerSocketIdsByNodeId.set(nodeId, socketId); + }, + close: () => { + const socketId = this.stateValue.dnsServerSocketIdsByNodeId.get(nodeId); + if (socketId !== undefined) this.closeSocket(socketId); + this.stateValue.dnsServerSocketIdsByNodeId.delete(nodeId); + }, + }, + configuration, + false, + ); + this.dnsServersByNodeId.set(nodeId, server); + server.setActive(configuration.isActive !== false); } /** @@ -2064,10 +2095,10 @@ export class NetworkRuntimeEngine { * (the NS record of the question's zone and its address). */ private answerDnsQuestion(nodeId: Id, question: DnsQuestion, done: (answers: DnsMessageRecord[]) => void): void { - const configuration = this.stateValue.topologySnapshot.dnsServerConfigurationsByNodeId.get(nodeId); - const records = (configuration?.records ?? []).map(messageRecord); + const server = this.dnsServersByNodeId.get(nodeId); + const records = (server?.answering ?? []).map(messageRecord); const local = localDnsAnswers(question, records); - if (local.length > 0 || configuration?.recursiveResolutionEnabled !== true) { + if (local.length > 0 || server?.recursiveResolutionEnabled !== true) { done(local.length > 0 ? local : dnsReferral(question, records)); return; } diff --git a/packages/core/src/runtime/snapshot.ts b/packages/core/src/runtime/snapshot.ts index c95e9f1..4c40ecb 100644 --- a/packages/core/src/runtime/snapshot.ts +++ b/packages/core/src/runtime/snapshot.ts @@ -75,6 +75,9 @@ export interface LinkSnapshot { readonly targetPortId: Id; } +/** A DNS server as the simulation starts it: its settings, and whether it runs (when not said, it does). */ +export type DnsServerSnapshot = DnsServerConfiguration & { readonly isActive?: boolean }; + export interface TopologySnapshot { /** Sorted by id (Swift `uuidString` order). */ readonly nodes: readonly NodeSnapshot[]; @@ -87,7 +90,7 @@ export interface TopologySnapshot { readonly dhcpClientConfigurationsByNodeId: ReadonlyMap; readonly dhcpServerConfigurationsByNodeId: ReadonlyMap; /** The DNS servers switched on (Java: aktiv), by node. */ - readonly dnsServerConfigurationsByNodeId: ReadonlyMap; + readonly dnsServerConfigurationsByNodeId: ReadonlyMap; readonly firewallConfigurationsByNodeId: ReadonlyMap; /** A home router's port forwarding (Java GatewayFirmware.staticNAT), by node. */ readonly portForwardingRowsByNodeId: ReadonlyMap; @@ -125,7 +128,7 @@ export interface TopologySnapshotInput { ripEnabledByNodeId?: ReadonlyMap; dhcpClientConfigurationsByNodeId?: ReadonlyMap; dhcpServerConfigurationsByNodeId?: ReadonlyMap; - dnsServerConfigurationsByNodeId?: ReadonlyMap; + dnsServerConfigurationsByNodeId?: ReadonlyMap; firewallConfigurationsByNodeId?: ReadonlyMap; portForwardingRowsByNodeId?: ReadonlyMap; switchConfigurationsByNodeId?: ReadonlyMap; diff --git a/packages/core/test/dnsWindow.java.json b/packages/core/test/dnsWindow.java.json new file mode 100644 index 0000000..fff0e2d --- /dev/null +++ b/packages/core/test/dnsWindow.java.json @@ -0,0 +1,1290 @@ +{ + "steps": [ + { + "step": "open", + "button": "Beenden", + "recursive": false, + "fields": { + "aDomainField": "", + "aIpField": "192.168.0.10", + "mxMaildomainField": "", + "mxURLField": "", + "nsDomainField": "", + "nsDomainServerField": "" + }, + "a": [ + [ + "www.filius.de.", + "192.168.0.30" + ], + [ + "seite.filius.de.", + "192.168.0.30" + ], + [ + "andere.filius.de.", + "192.168.0.30" + ], + [ + "leer.filius.de.", + "192.168.0.31" + ], + [ + "mail.filius.de.", + "192.168.0.30" + ], + [ + "mail.andere.de.", + "192.168.0.31" + ] + ], + "mx": [ + [ + "filius.de.", + "mail.filius.de." + ], + [ + "andere.de.", + "mail.andere.de." + ] + ], + "ns": [], + "file": "www.filius.de. A 3600 192.168.0.30\nseite.filius.de. A 3600 192.168.0.30\nandere.filius.de. A 3600 192.168.0.30\nleer.filius.de. A 3600 192.168.0.31\nfilius.de. MX 3600 mail.filius.de.\nmail.filius.de. A 3600 192.168.0.30\nandere.de. MX 3600 mail.andere.de.\nmail.andere.de. A 3600 192.168.0.31\n", + "dialogs": [], + "resolved": { + "www.filius.de": "192.168.0.30", + "web.filius.de": "null", + "neu.schule.de": "null", + "extern.schule.de": "null" + } + }, + { + "step": "A: add an invalid name", + "button": "Beenden", + "recursive": false, + "fields": { + "aDomainField": "kein name!", + "aIpField": "192.168.0.30", + "mxMaildomainField": "", + "mxURLField": "", + "nsDomainField": "", + "nsDomainServerField": "" + }, + "a": [ + [ + "www.filius.de.", + "192.168.0.30" + ], + [ + "seite.filius.de.", + "192.168.0.30" + ], + [ + "andere.filius.de.", + "192.168.0.30" + ], + [ + "leer.filius.de.", + "192.168.0.31" + ], + [ + "mail.filius.de.", + "192.168.0.30" + ], + [ + "mail.andere.de.", + "192.168.0.31" + ] + ], + "mx": [ + [ + "filius.de.", + "mail.filius.de." + ], + [ + "andere.de.", + "mail.andere.de." + ] + ], + "ns": [], + "file": "www.filius.de. A 3600 192.168.0.30\nseite.filius.de. A 3600 192.168.0.30\nandere.filius.de. A 3600 192.168.0.30\nleer.filius.de. A 3600 192.168.0.31\nfilius.de. MX 3600 mail.filius.de.\nmail.filius.de. A 3600 192.168.0.30\nandere.de. MX 3600 mail.andere.de.\nmail.andere.de. A 3600 192.168.0.31\n", + "dialogs": [ + "Eingaben sind ungültig! Valides Beispiel: \n> IP-Adresse: 192.168.0.10\n> Domainname: www.filius.de" + ] + }, + { + "step": "A: add an invalid address", + "button": "Beenden", + "recursive": false, + "fields": { + "aDomainField": "neu.schule.de", + "aIpField": "192.168.0.300", + "mxMaildomainField": "", + "mxURLField": "", + "nsDomainField": "", + "nsDomainServerField": "" + }, + "a": [ + [ + "www.filius.de.", + "192.168.0.30" + ], + [ + "seite.filius.de.", + "192.168.0.30" + ], + [ + "andere.filius.de.", + "192.168.0.30" + ], + [ + "leer.filius.de.", + "192.168.0.31" + ], + [ + "mail.filius.de.", + "192.168.0.30" + ], + [ + "mail.andere.de.", + "192.168.0.31" + ] + ], + "mx": [ + [ + "filius.de.", + "mail.filius.de." + ], + [ + "andere.de.", + "mail.andere.de." + ] + ], + "ns": [], + "file": "www.filius.de. A 3600 192.168.0.30\nseite.filius.de. A 3600 192.168.0.30\nandere.filius.de. A 3600 192.168.0.30\nleer.filius.de. A 3600 192.168.0.31\nfilius.de. MX 3600 mail.filius.de.\nmail.filius.de. A 3600 192.168.0.30\nandere.de. MX 3600 mail.andere.de.\nmail.andere.de. A 3600 192.168.0.31\n", + "dialogs": [ + "Eingaben sind ungültig! Valides Beispiel: \n> IP-Adresse: 192.168.0.10\n> Domainname: www.filius.de" + ] + }, + { + "step": "A: add neu.schule.de", + "button": "Beenden", + "recursive": false, + "fields": { + "aDomainField": "", + "aIpField": "", + "mxMaildomainField": "", + "mxURLField": "", + "nsDomainField": "", + "nsDomainServerField": "" + }, + "a": [ + [ + "www.filius.de.", + "192.168.0.30" + ], + [ + "seite.filius.de.", + "192.168.0.30" + ], + [ + "andere.filius.de.", + "192.168.0.30" + ], + [ + "leer.filius.de.", + "192.168.0.31" + ], + [ + "mail.filius.de.", + "192.168.0.30" + ], + [ + "mail.andere.de.", + "192.168.0.31" + ], + [ + "neu.schule.de.", + "10.0.0.1" + ] + ], + "mx": [ + [ + "filius.de.", + "mail.filius.de." + ], + [ + "andere.de.", + "mail.andere.de." + ] + ], + "ns": [], + "file": "www.filius.de. A 3600 192.168.0.30\nseite.filius.de. A 3600 192.168.0.30\nandere.filius.de. A 3600 192.168.0.30\nleer.filius.de. A 3600 192.168.0.31\nfilius.de. MX 3600 mail.filius.de.\nmail.filius.de. A 3600 192.168.0.30\nandere.de. MX 3600 mail.andere.de.\nmail.andere.de. A 3600 192.168.0.31\nneu.schule.de. A 3600 10.0.0.1\n", + "dialogs": [], + "resolved": { + "www.filius.de": "192.168.0.30", + "web.filius.de": "null", + "neu.schule.de": "10.0.0.1", + "extern.schule.de": "null" + } + }, + { + "step": "A: add neu.schule.de again", + "button": "Beenden", + "recursive": false, + "fields": { + "aDomainField": "", + "aIpField": "", + "mxMaildomainField": "", + "mxURLField": "", + "nsDomainField": "", + "nsDomainServerField": "" + }, + "a": [ + [ + "www.filius.de.", + "192.168.0.30" + ], + [ + "seite.filius.de.", + "192.168.0.30" + ], + [ + "andere.filius.de.", + "192.168.0.30" + ], + [ + "leer.filius.de.", + "192.168.0.31" + ], + [ + "mail.filius.de.", + "192.168.0.30" + ], + [ + "mail.andere.de.", + "192.168.0.31" + ], + [ + "neu.schule.de.", + "10.0.0.1" + ], + [ + "neu.schule.de.", + "10.0.0.2" + ] + ], + "mx": [ + [ + "filius.de.", + "mail.filius.de." + ], + [ + "andere.de.", + "mail.andere.de." + ] + ], + "ns": [], + "file": "www.filius.de. A 3600 192.168.0.30\nseite.filius.de. A 3600 192.168.0.30\nandere.filius.de. A 3600 192.168.0.30\nleer.filius.de. A 3600 192.168.0.31\nfilius.de. MX 3600 mail.filius.de.\nmail.filius.de. A 3600 192.168.0.30\nandere.de. MX 3600 mail.andere.de.\nmail.andere.de. A 3600 192.168.0.31\nneu.schule.de. A 3600 10.0.0.1\nneu.schule.de. A 3600 10.0.0.2\n", + "dialogs": [] + }, + { + "step": "MX: add schule.de", + "button": "Beenden", + "recursive": false, + "fields": { + "aDomainField": "", + "aIpField": "", + "mxMaildomainField": "", + "mxURLField": "", + "nsDomainField": "", + "nsDomainServerField": "" + }, + "a": [ + [ + "www.filius.de.", + "192.168.0.30" + ], + [ + "seite.filius.de.", + "192.168.0.30" + ], + [ + "andere.filius.de.", + "192.168.0.30" + ], + [ + "leer.filius.de.", + "192.168.0.31" + ], + [ + "mail.filius.de.", + "192.168.0.30" + ], + [ + "mail.andere.de.", + "192.168.0.31" + ], + [ + "neu.schule.de.", + "10.0.0.1" + ], + [ + "neu.schule.de.", + "10.0.0.2" + ] + ], + "mx": [ + [ + "filius.de.", + "mail.filius.de." + ], + [ + "andere.de.", + "mail.andere.de." + ], + [ + "schule.de.", + "mail.schule.de." + ] + ], + "ns": [], + "file": "www.filius.de. A 3600 192.168.0.30\nseite.filius.de. A 3600 192.168.0.30\nandere.filius.de. A 3600 192.168.0.30\nleer.filius.de. A 3600 192.168.0.31\nfilius.de. MX 3600 mail.filius.de.\nmail.filius.de. A 3600 192.168.0.30\nandere.de. MX 3600 mail.andere.de.\nmail.andere.de. A 3600 192.168.0.31\nneu.schule.de. A 3600 10.0.0.1\nneu.schule.de. A 3600 10.0.0.2\nschule.de. MX 3600 mail.schule.de.\n", + "dialogs": [] + }, + { + "step": "MX: add with no domain", + "button": "Beenden", + "recursive": false, + "fields": { + "aDomainField": "", + "aIpField": "", + "mxMaildomainField": "", + "mxURLField": "", + "nsDomainField": "", + "nsDomainServerField": "" + }, + "a": [ + [ + "www.filius.de.", + "192.168.0.30" + ], + [ + "seite.filius.de.", + "192.168.0.30" + ], + [ + "andere.filius.de.", + "192.168.0.30" + ], + [ + "leer.filius.de.", + "192.168.0.31" + ], + [ + "mail.filius.de.", + "192.168.0.30" + ], + [ + "mail.andere.de.", + "192.168.0.31" + ], + [ + "neu.schule.de.", + "10.0.0.1" + ], + [ + "neu.schule.de.", + "10.0.0.2" + ] + ], + "mx": [ + [ + "filius.de.", + "mail.filius.de." + ], + [ + "andere.de.", + "mail.andere.de." + ], + [ + "schule.de.", + "mail.schule.de." + ], + [ + ".", + "x." + ] + ], + "ns": [], + "file": "www.filius.de. A 3600 192.168.0.30\nseite.filius.de. A 3600 192.168.0.30\nandere.filius.de. A 3600 192.168.0.30\nleer.filius.de. A 3600 192.168.0.31\nfilius.de. MX 3600 mail.filius.de.\nmail.filius.de. A 3600 192.168.0.30\nandere.de. MX 3600 mail.andere.de.\nmail.andere.de. A 3600 192.168.0.31\nneu.schule.de. A 3600 10.0.0.1\nneu.schule.de. A 3600 10.0.0.2\nschule.de. MX 3600 mail.schule.de.\n. MX 3600 x.\n", + "dialogs": [] + }, + { + "step": "NS: add schule.de", + "button": "Beenden", + "recursive": false, + "fields": { + "aDomainField": "", + "aIpField": "", + "mxMaildomainField": "", + "mxURLField": "", + "nsDomainField": "", + "nsDomainServerField": "" + }, + "a": [ + [ + "www.filius.de.", + "192.168.0.30" + ], + [ + "seite.filius.de.", + "192.168.0.30" + ], + [ + "andere.filius.de.", + "192.168.0.30" + ], + [ + "leer.filius.de.", + "192.168.0.31" + ], + [ + "mail.filius.de.", + "192.168.0.30" + ], + [ + "mail.andere.de.", + "192.168.0.31" + ], + [ + "neu.schule.de.", + "10.0.0.1" + ], + [ + "neu.schule.de.", + "10.0.0.2" + ] + ], + "mx": [ + [ + "filius.de.", + "mail.filius.de." + ], + [ + "andere.de.", + "mail.andere.de." + ], + [ + "schule.de.", + "mail.schule.de." + ], + [ + ".", + "x." + ] + ], + "ns": [ + [ + "schule.de.", + "ns.schule.de." + ] + ], + "file": "www.filius.de. A 3600 192.168.0.30\nseite.filius.de. A 3600 192.168.0.30\nandere.filius.de. A 3600 192.168.0.30\nleer.filius.de. A 3600 192.168.0.31\nfilius.de. MX 3600 mail.filius.de.\nmail.filius.de. A 3600 192.168.0.30\nandere.de. MX 3600 mail.andere.de.\nmail.andere.de. A 3600 192.168.0.31\nneu.schule.de. A 3600 10.0.0.1\nneu.schule.de. A 3600 10.0.0.2\nschule.de. MX 3600 mail.schule.de.\n. MX 3600 x.\nschule.de. NS 3600 ns.schule.de.\n", + "dialogs": [] + }, + { + "step": "A: edit row 0's name to web.filius.de", + "button": "Beenden", + "recursive": false, + "fields": { + "aDomainField": "", + "aIpField": "", + "mxMaildomainField": "", + "mxURLField": "", + "nsDomainField": "", + "nsDomainServerField": "" + }, + "a": [ + [ + "web.filius.de.", + "192.168.0.30" + ], + [ + "seite.filius.de.", + "192.168.0.30" + ], + [ + "andere.filius.de.", + "192.168.0.30" + ], + [ + "leer.filius.de.", + "192.168.0.31" + ], + [ + "mail.filius.de.", + "192.168.0.30" + ], + [ + "mail.andere.de.", + "192.168.0.31" + ], + [ + "neu.schule.de.", + "10.0.0.1" + ], + [ + "neu.schule.de.", + "10.0.0.2" + ] + ], + "mx": [ + [ + "filius.de.", + "mail.filius.de." + ], + [ + "andere.de.", + "mail.andere.de." + ], + [ + "schule.de.", + "mail.schule.de." + ], + [ + ".", + "x." + ] + ], + "ns": [ + [ + "schule.de.", + "ns.schule.de." + ] + ], + "file": "web.filius.de. A 3600 192.168.0.30\nseite.filius.de. A 3600 192.168.0.30\nandere.filius.de. A 3600 192.168.0.30\nleer.filius.de. A 3600 192.168.0.31\nfilius.de. MX 3600 mail.filius.de.\nmail.filius.de. A 3600 192.168.0.30\nandere.de. MX 3600 mail.andere.de.\nmail.andere.de. A 3600 192.168.0.31\nneu.schule.de. A 3600 10.0.0.1\nneu.schule.de. A 3600 10.0.0.2\nschule.de. MX 3600 mail.schule.de.\n. MX 3600 x.\nschule.de. NS 3600 ns.schule.de.\n", + "dialogs": [], + "resolved": { + "www.filius.de": "null", + "web.filius.de": "192.168.0.30", + "neu.schule.de": "10.0.0.1", + "extern.schule.de": "null" + } + }, + { + "step": "A: edit row 1's address to 999.1.1.1", + "button": "Beenden", + "recursive": false, + "fields": { + "aDomainField": "", + "aIpField": "", + "mxMaildomainField": "", + "mxURLField": "", + "nsDomainField": "", + "nsDomainServerField": "" + }, + "a": [ + [ + "web.filius.de.", + "192.168.0.30" + ], + [ + "seite.filius.de.", + "192.168.0.30" + ], + [ + "andere.filius.de.", + "192.168.0.30" + ], + [ + "leer.filius.de.", + "192.168.0.31" + ], + [ + "mail.filius.de.", + "192.168.0.30" + ], + [ + "mail.andere.de.", + "192.168.0.31" + ], + [ + "neu.schule.de.", + "10.0.0.1" + ], + [ + "neu.schule.de.", + "10.0.0.2" + ] + ], + "mx": [ + [ + "filius.de.", + "mail.filius.de." + ], + [ + "andere.de.", + "mail.andere.de." + ], + [ + "schule.de.", + "mail.schule.de." + ], + [ + ".", + "x." + ] + ], + "ns": [ + [ + "schule.de.", + "ns.schule.de." + ] + ], + "file": "web.filius.de. A 3600 192.168.0.30\nseite.filius.de. A 3600 192.168.0.30\nandere.filius.de. A 3600 192.168.0.30\nleer.filius.de. A 3600 192.168.0.31\nfilius.de. MX 3600 mail.filius.de.\nmail.filius.de. A 3600 192.168.0.30\nandere.de. MX 3600 mail.andere.de.\nmail.andere.de. A 3600 192.168.0.31\nneu.schule.de. A 3600 10.0.0.1\nneu.schule.de. A 3600 10.0.0.2\nschule.de. MX 3600 mail.schule.de.\n. MX 3600 x.\nschule.de. NS 3600 ns.schule.de.\n", + "dialogs": [] + }, + { + "step": "A: edit row 2's address", + "button": "Beenden", + "recursive": false, + "fields": { + "aDomainField": "", + "aIpField": "", + "mxMaildomainField": "", + "mxURLField": "", + "nsDomainField": "", + "nsDomainServerField": "" + }, + "a": [ + [ + "web.filius.de.", + "192.168.0.30" + ], + [ + "seite.filius.de.", + "192.168.0.30" + ], + [ + "andere.filius.de.", + "192.168.0.99" + ], + [ + "leer.filius.de.", + "192.168.0.31" + ], + [ + "mail.filius.de.", + "192.168.0.30" + ], + [ + "mail.andere.de.", + "192.168.0.31" + ], + [ + "neu.schule.de.", + "10.0.0.1" + ], + [ + "neu.schule.de.", + "10.0.0.2" + ] + ], + "mx": [ + [ + "filius.de.", + "mail.filius.de." + ], + [ + "andere.de.", + "mail.andere.de." + ], + [ + "schule.de.", + "mail.schule.de." + ], + [ + ".", + "x." + ] + ], + "ns": [ + [ + "schule.de.", + "ns.schule.de." + ] + ], + "file": "web.filius.de. A 3600 192.168.0.30\nseite.filius.de. A 3600 192.168.0.30\nandere.filius.de. A 3600 192.168.0.99\nleer.filius.de. A 3600 192.168.0.31\nfilius.de. MX 3600 mail.filius.de.\nmail.filius.de. A 3600 192.168.0.30\nandere.de. MX 3600 mail.andere.de.\nmail.andere.de. A 3600 192.168.0.31\nneu.schule.de. A 3600 10.0.0.1\nneu.schule.de. A 3600 10.0.0.2\nschule.de. MX 3600 mail.schule.de.\n. MX 3600 x.\nschule.de. NS 3600 ns.schule.de.\n", + "dialogs": [] + }, + { + "step": "MX: edit row 0's server", + "button": "Beenden", + "recursive": false, + "fields": { + "aDomainField": "", + "aIpField": "", + "mxMaildomainField": "", + "mxURLField": "", + "nsDomainField": "", + "nsDomainServerField": "" + }, + "a": [ + [ + "web.filius.de.", + "192.168.0.30" + ], + [ + "seite.filius.de.", + "192.168.0.30" + ], + [ + "andere.filius.de.", + "192.168.0.99" + ], + [ + "leer.filius.de.", + "192.168.0.31" + ], + [ + "mail.filius.de.", + "192.168.0.30" + ], + [ + "mail.andere.de.", + "192.168.0.31" + ], + [ + "neu.schule.de.", + "10.0.0.1" + ], + [ + "neu.schule.de.", + "10.0.0.2" + ] + ], + "mx": [ + [ + "filius.de.", + "post.filius.de." + ], + [ + "andere.de.", + "mail.andere.de." + ], + [ + "schule.de.", + "mail.schule.de." + ], + [ + ".", + "x." + ] + ], + "ns": [ + [ + "schule.de.", + "ns.schule.de." + ] + ], + "file": "web.filius.de. A 3600 192.168.0.30\nseite.filius.de. A 3600 192.168.0.30\nandere.filius.de. A 3600 192.168.0.99\nleer.filius.de. A 3600 192.168.0.31\nfilius.de. MX 3600 post.filius.de.\nmail.filius.de. A 3600 post.filius.de\nandere.de. MX 3600 mail.andere.de.\nmail.andere.de. A 3600 192.168.0.31\nneu.schule.de. A 3600 10.0.0.1\nneu.schule.de. A 3600 10.0.0.2\nschule.de. MX 3600 mail.schule.de.\n. MX 3600 x.\nschule.de. NS 3600 ns.schule.de.\n", + "dialogs": [] + }, + { + "step": "A: remove row 5", + "button": "Beenden", + "recursive": false, + "fields": { + "aDomainField": "", + "aIpField": "", + "mxMaildomainField": "", + "mxURLField": "", + "nsDomainField": "", + "nsDomainServerField": "" + }, + "a": [ + [ + "web.filius.de.", + "192.168.0.30" + ], + [ + "seite.filius.de.", + "192.168.0.30" + ], + [ + "andere.filius.de.", + "192.168.0.99" + ], + [ + "leer.filius.de.", + "192.168.0.31" + ], + [ + "neu.schule.de.", + "10.0.0.1" + ], + [ + "neu.schule.de.", + "10.0.0.2" + ] + ], + "mx": [ + [ + "filius.de.", + "post.filius.de." + ], + [ + "andere.de.", + "mail.andere.de." + ], + [ + "schule.de.", + "mail.schule.de." + ], + [ + ".", + "x." + ] + ], + "ns": [ + [ + "schule.de.", + "ns.schule.de." + ] + ], + "file": "web.filius.de. A 3600 192.168.0.30\nseite.filius.de. A 3600 192.168.0.30\nandere.filius.de. A 3600 192.168.0.99\nleer.filius.de. A 3600 192.168.0.31\nfilius.de. MX 3600 post.filius.de.\nandere.de. MX 3600 mail.andere.de.\nneu.schule.de. A 3600 10.0.0.1\nneu.schule.de. A 3600 10.0.0.2\nschule.de. MX 3600 mail.schule.de.\n. MX 3600 x.\nschule.de. NS 3600 ns.schule.de.\n", + "dialogs": [], + "resolved": { + "www.filius.de": "null", + "web.filius.de": "192.168.0.30", + "neu.schule.de": "10.0.0.1", + "extern.schule.de": "null" + } + }, + { + "step": "recursive on", + "button": "Beenden", + "recursive": true, + "fields": { + "aDomainField": "", + "aIpField": "", + "mxMaildomainField": "", + "mxURLField": "", + "nsDomainField": "", + "nsDomainServerField": "" + }, + "a": [ + [ + "web.filius.de.", + "192.168.0.30" + ], + [ + "seite.filius.de.", + "192.168.0.30" + ], + [ + "andere.filius.de.", + "192.168.0.99" + ], + [ + "leer.filius.de.", + "192.168.0.31" + ], + [ + "neu.schule.de.", + "10.0.0.1" + ], + [ + "neu.schule.de.", + "10.0.0.2" + ] + ], + "mx": [ + [ + "filius.de.", + "post.filius.de." + ], + [ + "andere.de.", + "mail.andere.de." + ], + [ + "schule.de.", + "mail.schule.de." + ], + [ + ".", + "x." + ] + ], + "ns": [ + [ + "schule.de.", + "ns.schule.de." + ] + ], + "file": "web.filius.de. A 3600 192.168.0.30\nseite.filius.de. A 3600 192.168.0.30\nandere.filius.de. A 3600 192.168.0.99\nleer.filius.de. A 3600 192.168.0.31\nfilius.de. MX 3600 post.filius.de.\nandere.de. MX 3600 mail.andere.de.\nneu.schule.de. A 3600 10.0.0.1\nneu.schule.de. A 3600 10.0.0.2\nschule.de. MX 3600 mail.schule.de.\n. MX 3600 x.\nschule.de. NS 3600 ns.schule.de.\n", + "dialogs": [] + }, + { + "step": "button once", + "button": "Starten", + "recursive": true, + "fields": { + "aDomainField": "", + "aIpField": "", + "mxMaildomainField": "", + "mxURLField": "", + "nsDomainField": "", + "nsDomainServerField": "" + }, + "a": [ + [ + "web.filius.de.", + "192.168.0.30" + ], + [ + "seite.filius.de.", + "192.168.0.30" + ], + [ + "andere.filius.de.", + "192.168.0.99" + ], + [ + "leer.filius.de.", + "192.168.0.31" + ], + [ + "neu.schule.de.", + "10.0.0.1" + ], + [ + "neu.schule.de.", + "10.0.0.2" + ] + ], + "mx": [ + [ + "filius.de.", + "post.filius.de." + ], + [ + "andere.de.", + "mail.andere.de." + ], + [ + "schule.de.", + "mail.schule.de." + ], + [ + ".", + "x." + ] + ], + "ns": [ + [ + "schule.de.", + "ns.schule.de." + ] + ], + "file": "web.filius.de. A 3600 192.168.0.30\nseite.filius.de. A 3600 192.168.0.30\nandere.filius.de. A 3600 192.168.0.99\nleer.filius.de. A 3600 192.168.0.31\nfilius.de. MX 3600 post.filius.de.\nandere.de. MX 3600 mail.andere.de.\nneu.schule.de. A 3600 10.0.0.1\nneu.schule.de. A 3600 10.0.0.2\nschule.de. MX 3600 mail.schule.de.\n. MX 3600 x.\nschule.de. NS 3600 ns.schule.de.\n", + "dialogs": [], + "resolved": { + "www.filius.de": "TimeOutException", + "web.filius.de": "TimeOutException", + "neu.schule.de": "TimeOutException", + "extern.schule.de": "TimeOutException" + } + }, + { + "step": "button twice", + "button": "Beenden", + "recursive": true, + "fields": { + "aDomainField": "", + "aIpField": "", + "mxMaildomainField": "", + "mxURLField": "", + "nsDomainField": "", + "nsDomainServerField": "" + }, + "a": [ + [ + "web.filius.de.", + "192.168.0.30" + ], + [ + "seite.filius.de.", + "192.168.0.30" + ], + [ + "andere.filius.de.", + "192.168.0.99" + ], + [ + "leer.filius.de.", + "192.168.0.31" + ], + [ + "neu.schule.de.", + "10.0.0.1" + ], + [ + "neu.schule.de.", + "10.0.0.2" + ] + ], + "mx": [ + [ + "filius.de.", + "post.filius.de." + ], + [ + "andere.de.", + "mail.andere.de." + ], + [ + "schule.de.", + "mail.schule.de." + ], + [ + ".", + "x." + ] + ], + "ns": [ + [ + "schule.de.", + "ns.schule.de." + ] + ], + "file": "web.filius.de. A 3600 192.168.0.30\nseite.filius.de. A 3600 192.168.0.30\nandere.filius.de. A 3600 192.168.0.99\nleer.filius.de. A 3600 192.168.0.31\nfilius.de. MX 3600 post.filius.de.\nandere.de. MX 3600 mail.andere.de.\nneu.schule.de. A 3600 10.0.0.1\nneu.schule.de. A 3600 10.0.0.2\nschule.de. MX 3600 mail.schule.de.\n. MX 3600 x.\nschule.de. NS 3600 ns.schule.de.\n", + "dialogs": [], + "resolved": { + "www.filius.de": "null", + "web.filius.de": "192.168.0.30", + "neu.schule.de": "10.0.0.1", + "extern.schule.de": "null" + } + }, + { + "step": "file changed by another program", + "button": "Beenden", + "recursive": true, + "fields": { + "aDomainField": "", + "aIpField": "", + "mxMaildomainField": "", + "mxURLField": "", + "nsDomainField": "", + "nsDomainServerField": "" + }, + "a": [ + [ + "web.filius.de.", + "192.168.0.30" + ], + [ + "seite.filius.de.", + "192.168.0.30" + ], + [ + "andere.filius.de.", + "192.168.0.99" + ], + [ + "leer.filius.de.", + "192.168.0.31" + ], + [ + "neu.schule.de.", + "10.0.0.1" + ], + [ + "neu.schule.de.", + "10.0.0.2" + ] + ], + "mx": [ + [ + "filius.de.", + "post.filius.de." + ], + [ + "andere.de.", + "mail.andere.de." + ], + [ + "schule.de.", + "mail.schule.de." + ], + [ + ".", + "x." + ] + ], + "ns": [ + [ + "schule.de.", + "ns.schule.de." + ] + ], + "file": "web.filius.de. A 3600 192.168.0.30\nseite.filius.de. A 3600 192.168.0.30\nandere.filius.de. A 3600 192.168.0.99\nleer.filius.de. A 3600 192.168.0.31\nfilius.de. MX 3600 post.filius.de.\nandere.de. MX 3600 mail.andere.de.\nneu.schule.de. A 3600 10.0.0.1\nneu.schule.de. A 3600 10.0.0.2\nschule.de. MX 3600 mail.schule.de.\n. MX 3600 x.\nschule.de. NS 3600 ns.schule.de.\nextern.schule.de. A 3600 10.0.0.9\n", + "dialogs": [], + "resolved": { + "www.filius.de": "null", + "web.filius.de": "192.168.0.30", + "neu.schule.de": "10.0.0.1", + "extern.schule.de": "10.0.0.9" + } + }, + { + "step": "A: add after the file changed", + "button": "Beenden", + "recursive": true, + "fields": { + "aDomainField": "", + "aIpField": "", + "mxMaildomainField": "", + "mxURLField": "", + "nsDomainField": "", + "nsDomainServerField": "" + }, + "a": [ + [ + "web.filius.de.", + "192.168.0.30" + ], + [ + "seite.filius.de.", + "192.168.0.30" + ], + [ + "andere.filius.de.", + "192.168.0.99" + ], + [ + "leer.filius.de.", + "192.168.0.31" + ], + [ + "neu.schule.de.", + "10.0.0.1" + ], + [ + "neu.schule.de.", + "10.0.0.2" + ], + [ + "danach.schule.de.", + "10.0.0.3" + ] + ], + "mx": [ + [ + "filius.de.", + "post.filius.de." + ], + [ + "andere.de.", + "mail.andere.de." + ], + [ + "schule.de.", + "mail.schule.de." + ], + [ + ".", + "x." + ] + ], + "ns": [ + [ + "schule.de.", + "ns.schule.de." + ] + ], + "file": "web.filius.de. A 3600 192.168.0.30\nseite.filius.de. A 3600 192.168.0.30\nandere.filius.de. A 3600 192.168.0.99\nleer.filius.de. A 3600 192.168.0.31\nfilius.de. MX 3600 post.filius.de.\nandere.de. MX 3600 mail.andere.de.\nneu.schule.de. A 3600 10.0.0.1\nneu.schule.de. A 3600 10.0.0.2\nschule.de. MX 3600 mail.schule.de.\n. MX 3600 x.\nschule.de. NS 3600 ns.schule.de.\ndanach.schule.de. A 3600 10.0.0.3\n", + "dialogs": [], + "resolved": { + "www.filius.de": "null", + "web.filius.de": "192.168.0.30", + "neu.schule.de": "10.0.0.1", + "extern.schule.de": "null" + } + }, + { + "step": "A: remove row 0 after the file changed", + "button": "Beenden", + "recursive": true, + "fields": { + "aDomainField": "", + "aIpField": "", + "mxMaildomainField": "", + "mxURLField": "", + "nsDomainField": "", + "nsDomainServerField": "" + }, + "a": [ + [ + "seite.filius.de.", + "192.168.0.30" + ], + [ + "andere.filius.de.", + "192.168.0.99" + ], + [ + "leer.filius.de.", + "192.168.0.31" + ], + [ + "neu.schule.de.", + "10.0.0.1" + ], + [ + "neu.schule.de.", + "10.0.0.2" + ], + [ + "danach.schule.de.", + "10.0.0.3" + ] + ], + "mx": [ + [ + "filius.de.", + "post.filius.de." + ], + [ + "andere.de.", + "mail.andere.de." + ], + [ + "schule.de.", + "mail.schule.de." + ], + [ + ".", + "x." + ] + ], + "ns": [ + [ + "schule.de.", + "ns.schule.de." + ] + ], + "file": "seite.filius.de. A 3600 192.168.0.30\nandere.filius.de. A 3600 192.168.0.99\nleer.filius.de. A 3600 192.168.0.31\nfilius.de. MX 3600 post.filius.de.\nandere.de. MX 3600 mail.andere.de.\nneu.schule.de. A 3600 10.0.0.1\nneu.schule.de. A 3600 10.0.0.2\nschule.de. MX 3600 mail.schule.de.\n. MX 3600 x.\nschule.de. NS 3600 ns.schule.de.\ndanach.schule.de. A 3600 10.0.0.3\n", + "dialogs": [], + "resolved": { + "www.filius.de": "null", + "web.filius.de": "null", + "neu.schule.de": "10.0.0.1", + "extern.schule.de": "null" + } + } + ] +} diff --git a/packages/core/test/dnsWindow.test.ts b/packages/core/test/dnsWindow.test.ts new file mode 100644 index 0000000..68ce191 --- /dev/null +++ b/packages/core/test/dnsWindow.test.ts @@ -0,0 +1,209 @@ +// The DNS server as Java's window changes it, against Java's +// (dnsWindow.java.json, DnsWindowRun) on mail-lan's DNS server: Java's steps +// played on the engine's server — the tables, /dns/hosts, which names the +// first computer's resolver gets, the button and the box — and where Java +// errs, what the server does instead (apps/dnsServer.ts names each): an +// empty name refused, a cell changing its record only, a record added +// keeping what another program wrote into the file. + +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; +import { NetworkRuntimeEngine, validDnsRecord, VirtualFileSystem, withSnapshot, type DnsRecordType, type Steps } from '../src/index.js'; +import { snapshotOf, type Scenario } from './scenario.js'; + +interface JavaStep { + readonly step: string; + readonly button: string; + readonly recursive: boolean; + readonly a: readonly (readonly string[])[]; + readonly mx: readonly (readonly string[])[]; + readonly ns: readonly (readonly string[])[]; + readonly file: string; + readonly dialogs: readonly string[]; + readonly resolved?: Readonly>; +} + +const here = dirname(fileURLToPath(import.meta.url)); +const java = JSON.parse(readFileSync(join(here, 'dnsWindow.java.json'), 'utf8')) as { readonly steps: readonly JavaStep[] }; +const scenario = JSON.parse(readFileSync(join(here, 'scenarios/mail-lan.scenario.json'), 'utf8')) as Scenario; +const CLIENT = '00000000-0000-0000-0000-000000000201'; +const SERVER = '00000000-0000-0000-0000-000000000202'; + +function setUp() { + const engine = new NetworkRuntimeEngine(scenario.seed); + const files = new VirtualFileSystem(); + engine.handle({ type: 'start', snapshot: withSnapshot(snapshotOf(scenario), { fileSystemsByNodeId: new Map([[SERVER, files]]) }), seed: scenario.seed, initialTimeMilliseconds: 0 }); + const run = (milliseconds: number) => engine.handle({ type: 'advance', toMilliseconds: engine.state.currentTimeMilliseconds + milliseconds }); + /** What the first computer's resolver gets for a name, as Java's oracle writes it. */ + const resolve = (name: string): string => { + let answer = '(no answer)'; + engine.startThread( + (function* (): Steps { + const result = yield* engine.resolveDnsAddressSteps(CLIENT, name); + answer = result.type === 'timedOut' ? 'TimeOutException' : String(result.address ?? null); + })(), + ); + run(30_000); + return answer; + }; + return { engine, server: engine.dnsServer(SERVER)!, files, run, resolve }; +} + +describe("the DNS server's window, against Java's", () => { + it("plays Java's steps: the same where Java is right, as named where it errs", () => { + const { server, files, resolve } = setUp(); + const rows = (type: DnsRecordType) => server.rows(type).map(({ record }) => [record.domainName, record.rdata]); + const file = () => (files.contains('/dns/hosts') ? files.textFile('/dns/hosts') : undefined); + // Where Java's file holds what Java got wrong, the file as it should be (named below at each step). + const fixes: ((text: string) => string)[] = []; + const ours = (text: string) => fixes.reduce((each, fix) => fix(each), text); + const shown = (step: JavaStep) => { + const expected = { + button: step.button, + recursive: step.recursive, + a: ours(step.file) === step.file ? step.a : rowsOf(ours(step.file), 'A'), + mx: rowsOf(ours(step.file), 'MX'), + ns: step.ns, + file: ours(step.file), + }; + const actual = { button: server.isActive ? 'Beenden' : 'Starten', recursive: server.recursiveResolutionEnabled, a: rows('A'), mx: rows('MX'), ns: rows('NS'), file: file() ?? ours(step.file) }; + return { expected, actual }; + }; + const at = (name: string) => java.steps.find((step) => step.step === name)!; + const check = (name: string, resolved: Readonly> | undefined = at(name).resolved) => { + const { expected, actual } = shown(at(name)); + expect(actual, name).toEqual(expected); + if (resolved !== undefined) expect(Object.fromEntries(Object.keys(resolved).map((each) => [each, resolve(each)])), name).toEqual(resolved); + }; + + check('open'); + expect([server.addRecord('A', 'kein name!', '192.168.0.30'), server.addRecord('A', 'neu.schule.de', '192.168.0.300')]).toEqual([false, false]); + expect(at('A: add an invalid name').dialogs).toHaveLength(1); + check('A: add an invalid address'); + expect(server.addRecord('A', 'neu.schule.de', '10.0.0.1')).toBe(true); + check('A: add neu.schule.de'); + expect(server.addRecord('A', 'neu.schule.de', '10.0.0.2')).toBe(true); + check('A: add neu.schule.de again'); + expect(server.addRecord('MX', 'schule.de', 'mail.schule.de')).toBe(true); + check('MX: add schule.de'); + // An empty name is refused (Java makes ". MX 3600 x."). + expect(server.addRecord('MX', '', 'x')).toBe(false); + fixes.push((text) => text.replace('. MX 3600 x.\n', '')); + check('MX: add with no domain'); + expect(server.addRecord('NS', 'schule.de', 'ns.schule.de')).toBe(true); + check('NS: add schule.de'); + + expect(server.changeRecord('A', 0, 0, 'web.filius.de')).toBe(true); + check("A: edit row 0's name to web.filius.de"); + expect(server.changeRecord('A', 1, 1, '999.1.1.1')).toBe(false); + check("A: edit row 1's address to 999.1.1.1"); + expect(server.changeRecord('A', 2, 1, '192.168.0.99')).toBe(true); + check("A: edit row 2's address"); + // The MX record's server changes that record only (Java also writes it into the A record after it). + expect(server.changeRecord('MX', 0, 1, 'post.filius.de')).toBe(true); + fixes.push((text) => text.replace('mail.filius.de. A 3600 post.filius.de\n', 'mail.filius.de. A 3600 192.168.0.30\n')); + check("MX: edit row 0's server"); + // Row 5 is mail.andere.de; Java's file loses mail.filius.de too, which its mistake had made invalid. + expect(server.removeRecord('A', 5)).toBe(true); + fixes.push((text) => text.replace('filius.de. MX 3600 post.filius.de.\n', 'filius.de. MX 3600 post.filius.de.\nmail.filius.de. A 3600 192.168.0.30\n')); + check('A: remove row 5'); + + server.setRecursiveResolutionEnabled(true); + check('recursive on'); + server.setActive(false); + check('button once'); + server.setActive(true); + check('button twice'); + + // Another program writes /dns/hosts: answered at once; a record added keeps it (Java's add loses it). + files.writeTextFile('/dns/hosts', `${files.textFile('/dns/hosts')}extern.schule.de. A 3600 10.0.0.9\n`); + check('file changed by another program'); + expect(server.addRecord('A', 'danach.schule.de', '10.0.0.3')).toBe(true); + fixes.push((text) => text.replace('danach.schule.de.', 'extern.schule.de. A 3600 10.0.0.9\ndanach.schule.de.')); + check('A: add after the file changed', { ...at('A: add after the file changed').resolved, 'extern.schule.de': '10.0.0.9' }); + expect(server.removeRecord('A', 0)).toBe(true); + check('A: remove row 0 after the file changed', { ...at('A: remove row 0 after the file changed').resolved, 'extern.schule.de': '10.0.0.9' }); + }); +}); + +/** The rows of a type the window shows for a file. */ +function rowsOf(text: string, type: DnsRecordType): string[][] { + return text + .split('\n') + .filter((line) => line.split(' ')[1] === type) + .map((line) => [line.split(' ')[0]!, line.split(' ')[3]!]); +} + +describe('the DNS server beyond the steps', () => { + it('removes the row chosen of two of one name (Java the first of the name), and changes the row of its type', () => { + const { server, files } = setUp(); + server.addRecord('A', 'zwei.de', '10.0.0.1'); + server.addRecord('A', 'zwei.de', '10.0.0.2'); + const twins = () => server.rows('A').filter(({ record }) => record.domainName === 'zwei.de.'); + expect(twins().map(({ record }) => record.rdata)).toEqual(['10.0.0.1', '10.0.0.2']); + const second = server.rows('A').findIndex(({ record }) => record.rdata === '10.0.0.2'); + expect(server.removeRecord('A', second)).toBe(true); + expect(twins().map(({ record }) => record.rdata)).toEqual(['10.0.0.1']); + expect([server.removeRecord('A', 99), server.changeRecord('NS', 0, 0, 'x.de')]).toEqual([false, false]); + // A record's name and target in any form the file takes: the dots added, the TTL kept. + files.writeTextFile('/dns/hosts', 'alt.de. A 60 10.0.0.5\n'); + expect(server.changeRecord('A', 0, 0, ' neu.de ')).toBe(true); + expect(files.textFile('/dns/hosts')).toBe('neu.de. A 60 10.0.0.5\n'); + }); + + it("answers from its settings until it has a file, and its window's checks are Java's patterns without their errors", () => { + const engine = new NetworkRuntimeEngine(scenario.seed); + engine.handle({ type: 'start', snapshot: snapshotOf(scenario), seed: scenario.seed, initialTimeMilliseconds: 0 }); + const server = engine.dnsServer(SERVER)!; + expect(server.rows('A').map(({ record }) => record.domainName)[0]).toBe('www.filius.de.'); + expect(server.answering.map((record) => record.name)[0]).toBe('www.filius.de'); + expect(engine.state.topologySnapshot.fileSystemsByNodeId.has(SERVER)).toBe(false); + server.addRecord('NS', 'schule.de', 'ns.schule.de'); + expect(engine.state.topologySnapshot.fileSystemsByNodeId.get(SERVER)!.textFile('/dns/hosts').split('\n').at(-2)).toBe('schule.de. NS 3600 ns.schule.de.'); + expect( + [ + ['A', 'a.de', '1.2.3.4'], + ['A', '', '1.2.3.4'], + ['A', 'a.de', ''], + ['A', 'a.de', '1.2.3'], + ['A', '9a.de', '1.2.3.4'], + ['A', 'a.123', '1.2.3.4'], + ['MX', 'a.de', 'mail.a.de'], + ['MX', 'a.de', ''], + ['NS', 'a.de', '1.2.3.4'], + // Java's pattern and IP.ipCheck both: each alone takes one of these. + ['A', 'a.de', '1.2.3.4.'], + ['A', 'a.de', '.1.2.3'], + ['A', 'a.de', '01.2.3.4'], + ].map(([type, name, data]) => validDnsRecord(type as DnsRecordType, name!, data!)), + ).toEqual([true, false, false, false, true, false, true, false, false, false, false, true]); + // A folder named /dns/hosts is no file: the settings' records. + const files = engine.state.topologySnapshot.fileSystemsByNodeId.get(SERVER)!; + files.deleteItem('/dns/hosts'); + files.createDirectory('/dns/hosts'); + expect(server.records.map((record) => record.domainName)[0]).toBe('www.filius.de.'); + }); + + it('runs a server switched off only once it is switched on, and an installed one switched off', () => { + const engine = new NetworkRuntimeEngine(scenario.seed); + const snapshot = snapshotOf(scenario); + const configuration = snapshot.dnsServerConfigurationsByNodeId.get(SERVER)!; + engine.handle({ + type: 'start', + snapshot: withSnapshot(snapshot, { dnsServerConfigurationsByNodeId: new Map([[SERVER, { ...configuration, isActive: false }]]) }), + seed: scenario.seed, + initialTimeMilliseconds: 0, + }); + const server = engine.dnsServer(SERVER)!; + expect([server.isActive, engine.state.dnsServerSocketIdsByNodeId.has(SERVER)]).toEqual([false, false]); + server.setActive(true); + server.setActive(true); + expect([server.isActive, engine.state.dnsServerSocketIdsByNodeId.has(SERVER)]).toEqual([true, true]); + engine.removeProgram(SERVER, 'dnsServer'); + expect([engine.dnsServer(SERVER), engine.state.dnsServerSocketIdsByNodeId.has(SERVER)]).toEqual([undefined, false]); + engine.installProgram(SERVER, 'dnsServer', withSnapshot(snapshot, { dnsServerConfigurationsByNodeId: new Map([[SERVER, { ...configuration, isActive: false }]]) })); + expect(engine.dnsServer(SERVER)?.isActive).toBe(false); + }); +}); diff --git a/packages/editor/browser/dns.spec.ts b/packages/editor/browser/dns.spec.ts new file mode 100644 index 0000000..0478404 --- /dev/null +++ b/packages/editor/browser/dns.spec.ts @@ -0,0 +1,99 @@ +// The DNS server on a computer's desktop in Chromium (F4.6g2), on FILIUS' +// example email_komplett: Java's button and box, the address field with the +// computer's own address, a record refused with Java's valid example, one +// added, a cell written and one refused, a row removed, the MX tab, the +// server stopped; and another computer's command line resolving the new name. + +import { expect, test, type Page } from '@playwright/test'; +import { drawing, onDevice, openEditor, serveEditor } from './editorPage.js'; + +let server: { readonly base: string; readonly close: () => Promise }; + +test.beforeAll(async () => { + server = await serveEditor(); +}); + +test.afterAll(async () => { + await server.close(); +}); + +async function desktopOf(page: Page, name: string) { + const id = (await drawing(page)).nodes.find((node) => node.name === name)!.id; + const at = await onDevice(page, id); + await page.mouse.click(at.x, at.y); + return page.locator(`section[data-desktop="${id}"]`); +} + +test('keeps the records as Java’s DNS window does, and the server answers with them', async ({ page }) => { + test.setTimeout(120_000); + await openEditor(page, server.base, 'email_komplett.fls'); + await page.getByRole('button', { name: 'Wechselt in den Aktionsmodus' }).click(); + await expect(page.locator('svg.filius-canvas')).toHaveAttribute('data-mode', 'simulation'); + const desktop = await desktopOf(page, 'DNS-Server'); + await desktop.locator('[data-program="dnsServer"]').click(); + const window = desktop.locator('[data-program-window="dnsServer"]'); + await expect(window.locator('.program-titlebar h3')).toHaveText('DNS-Server'); + await expect(window.getByRole('button', { name: 'Beenden' })).toBeVisible(); + await expect(window.getByRole('tab')).toHaveText(['Adressen (A)', 'Mailaustausch (MX)', 'Nameserver (NS)']); + const name = window.getByRole('textbox', { name: 'Host-/Domainname:' }); + const address = window.getByRole('textbox', { name: 'IP-Adresse:' }); + await expect(address).toHaveValue('141.99.50.230'); + const rows = window.getByRole('table', { name: 'Einträge' }).locator('tbody tr'); + const before = await rows.count(); + + // Not taken: Java's valid example. + await name.fill('kein name!'); + await expect(name).toHaveAttribute('aria-invalid', 'true'); + await window.getByRole('button', { name: 'Hinzufügen' }).click(); + await expect(window.getByRole('alert')).toHaveText('Eingaben sind ungültig! Valides Beispiel: \n> IP-Adresse: 192.168.0.10\n> Domainname: www.filius.de'); + await name.fill('neu.schule.de'); + await address.fill('10.0.0.1'); + await window.getByRole('button', { name: 'Hinzufügen' }).click(); + await expect(rows).toHaveCount(before + 1); + await expect([await name.inputValue(), await address.inputValue()]).toEqual(['', '']); + const last = rows.last(); + await expect(last.locator('input').first()).toHaveValue('neu.schule.de.'); + + // A cell written; one the check does not take is marked and goes back. + const cell = last.locator('input').nth(1); + await cell.fill('10.0.0.2'); + await cell.press('Enter'); + await expect(cell).toHaveValue('10.0.0.2'); + await cell.fill('10.0.0'); + await expect(cell).toHaveAttribute('aria-invalid', 'true'); + await cell.blur(); + await expect(cell).toHaveValue('10.0.0.2'); + + // A computer whose DNS server it is resolves the new name (the desktop closed, which covered it). + await desktop.locator('.desktop-close').click(); + const web = await desktopOf(page, 'Web-Server'); + await web.locator('[data-program="installation"]').click(); + const installation = web.locator('[data-program-window="installation"]'); + await installation.locator('[data-list="available"] [role="option"]').filter({ hasText: 'Befehlszeile' }).click(); + await installation.getByRole('button', { name: 'Installieren' }).click(); + await installation.getByRole('button', { name: 'Änderungen annehmen' }).click(); + await web.locator('[data-program="commandPrompt"]').click(); + const input = web.locator('input[name="terminalInput"]'); + await input.fill('host neu.schule.de'); + await input.press('Enter'); + await expect(web.locator('.terminal-text')).toContainText('10.0.0.2', { timeout: 30_000 }); + await web.locator('.desktop-close').click(); + await desktopOf(page, 'DNS-Server'); + await desktop.locator('[data-program="dnsServer"]').click(); + + // A row chosen and removed; the MX tab; the box and the button. + await rows.last().locator('td').first().dispatchEvent('pointerdown'); + await window.getByRole('button', { name: 'Auswahl entfernen' }).click(); + await expect(rows).toHaveCount(before); + await window.getByRole('tab', { name: 'Mailaustausch (MX)' }).click(); + await window.getByRole('textbox', { name: 'Maildomain:' }).fill('schule.de'); + await window.getByRole('textbox', { name: 'Domainname Mailserver:' }).fill('mail.schule.de'); + await window.getByRole('button', { name: 'Hinzufügen' }).click(); + await expect(rows.last().locator('input').nth(1)).toHaveValue('mail.schule.de.'); + await window.getByRole('checkbox', { name: 'Aktiviere rekursive Domain-Auflösung' }).click(); + await expect(window.getByRole('checkbox', { name: 'Aktiviere rekursive Domain-Auflösung' })).toBeChecked(); + await window.getByRole('checkbox', { name: 'Aktiviere rekursive Domain-Auflösung' }).click(); + await expect(window.getByRole('checkbox', { name: 'Aktiviere rekursive Domain-Auflösung' })).not.toBeChecked(); + await window.getByRole('button', { name: 'Beenden' }).click(); + await expect(window.getByRole('button', { name: 'Starten' })).toBeVisible(); +}); diff --git a/packages/editor/src/desktop/DesktopWindow.tsx b/packages/editor/src/desktop/DesktopWindow.tsx index 87feec8..94714a0 100644 --- a/packages/editor/src/desktop/DesktopWindow.tsx +++ b/packages/editor/src/desktop/DesktopWindow.tsx @@ -14,7 +14,8 @@ // leaves it; so far the command line (F4.6b), the web browser and the web // server (F4.6d), the e-mail program and server (F4.6e), the text editor // (F4.6f2), the file explorer and the image viewer (F4.6f3), the echo -// server and the generic client (F4.6g1) have one, the others follow. The browser's window takes its title from its page, the +// server and the generic client (F4.6g1), the DNS server (F4.6g2) have one, +// the others follow. The browser's window takes its title from its page, the // text editor's from its open file, the image viewer's from its picture, as // Java's do. @@ -39,6 +40,7 @@ import { TerminalPanel } from './TerminalPanel.js'; import { TextEditorPanel, useTextEditorWindow } from './TextEditorPanel.js'; import { FileExplorerPanel } from './FileExplorerPanel.js'; import { EchoServerPanel, SimpleClientPanel } from './EchoPanels.js'; +import { DnsServerPanel } from './DnsServerPanel.js'; import { ImageViewerPanel, useImageViewer } from './ImageViewerPanel.js'; import { WebServerPanel } from './WebServerPanel.js'; import './desktop.css'; @@ -69,7 +71,7 @@ export interface DesktopWindowProps { export type DesktopWindowName = DesktopProgram | 'installation'; /** The programs whose windows are built so far. */ -const WITH_WINDOW: ReadonlySet = new Set(['commandPrompt', 'webBrowser', 'webServer', 'emailClient', 'emailServer', 'textEditor', 'fileExplorer', 'imageViewer', 'echoServer', 'simpleClient']); +const WITH_WINDOW: ReadonlySet = new Set(['commandPrompt', 'webBrowser', 'webServer', 'emailClient', 'emailServer', 'textEditor', 'fileExplorer', 'imageViewer', 'echoServer', 'simpleClient', 'dnsServer']); /** The document's desktop of a computer, read anew after every change (a program installed, the label changed). */ function useDesktopView(doc: Y.Doc, id: string) { @@ -186,6 +188,7 @@ export function DesktopWindow({ doc, id, simulation, active, x, y, z, program: o {shown === 'imageViewer' ? : null} {shown === 'echoServer' ? : null} {shown === 'simpleClient' ? : null} + {shown === 'dnsServer' ? : null} )} {info ? ( diff --git a/packages/editor/src/desktop/DnsServerPanel.tsx b/packages/editor/src/desktop/DnsServerPanel.tsx new file mode 100644 index 0000000..6c09f92 --- /dev/null +++ b/packages/editor/src/desktop/DnsServerPanel.tsx @@ -0,0 +1,207 @@ +// A computer's DNS server, after Java's GUIApplicationDNSServerWindow +// (measured with tools/java-oracle's DnsWindowRun): the start button +// ("Starten"/"Beenden") and the box for recursive resolution above, three +// tabs — addresses (A), mail exchange (MX), name servers (NS) — each with +// its two fields (the address starts as the computer's own, as Java's), red +// while Java's patterns do not take them, "Hinzufügen" (Java's message when +// they do not: the valid example), "Auswahl entfernen" and the table, whose +// cells are written when they are left. The server is the room's: the panel +// shows its view and hands over what is done; what the server does better +// than Java is in its core (apps/dnsServer.ts). +// +// Better than Java: a cell that its check does not take is marked and goes +// back when it is left (Java's goes back without a word). + +import { useEffect, useState, type KeyboardEvent } from 'react'; +import { validDnsRecord, type DnsRecordType } from '@mdg-filius/core'; +import type { DnsServerView } from '@mdg-filius/protocol'; +import { useTranslate } from '../i18n/i18n.js'; +import type { Simulation } from '../simulation/useSimulation.js'; + +/** Each tab's words: its title, its fields' labels, its columns, Java's message for what it does not take. */ +const TABS: Readonly> = { + A: { tab: 'dnsServer.tabA', fields: ['dnsServer.aName', 'dnsServer.aAddress'], columns: ['dnsServer.aNameColumn', 'dnsServer.aAddressColumn'], invalid: 'dnsServer.invalidA' }, + MX: { tab: 'dnsServer.tabMX', fields: ['dnsServer.mxDomain', 'dnsServer.mxServer'], columns: ['dnsServer.mxDomainColumn', 'dnsServer.mxServerColumn'], invalid: 'dnsServer.invalidMX' }, + NS: { tab: 'dnsServer.tabNS', fields: ['dnsServer.nsDomain', 'dnsServer.nsServer'], columns: ['dnsServer.nsDomainColumn', 'dnsServer.nsServerColumn'], invalid: 'dnsServer.invalidNS' }, +}; + +/** Whether a field's text is what the tab takes there (its other part taken as valid). */ +function fieldValid(type: DnsRecordType, column: 0 | 1, text: string): boolean { + if (text === '') return true; + return column === 0 ? validDnsRecord(type, text, type === 'A' ? '1.1.1.1' : 'a.de') : validDnsRecord(type, 'a.de', text); +} + +/** A cell of the table, written when it is left or Enter is pressed and it changed; one its check does not take goes back. */ +function RecordCell({ + type, + column, + value, + label, + onCommit, +}: { + readonly type: DnsRecordType; + readonly column: 0 | 1; + readonly value: string; + readonly label: string; + readonly onCommit: (value: string) => void; +}) { + const [draft, setDraft] = useState(value); + const [editing, setEditing] = useState(false); + // The server's value, but not over what is being typed since the last Enter, Escape or leaving (it may come just after a cell written). + useEffect(() => { + if (!editing) setDraft(value); + }, [value, editing]); + const valid = draft !== '' && fieldValid(type, column, draft.trim()); + const commit = () => { + setEditing(false); + if (draft !== value && valid) onCommit(draft); + else setDraft(value); + }; + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Enter') commit(); + if (event.key === 'Escape') { + setEditing(false); + setDraft(value); + } + }; + return ( + { + setDraft(event.target.value); + setEditing(true); + }} + onBlur={commit} + onKeyDown={onKeyDown} + /> + ); +} + +export function DnsServerPanel({ node, simulation }: { readonly node: string; readonly simulation: Simulation }) { + const t = useTranslate(); + const topic = `app:${node}/dnsServer` as const; + const { watch, act } = simulation; + useEffect(() => watch([topic, `node:${node}`]), [watch, topic, node]); + const view = simulation.views.get(topic) as DnsServerView | null | undefined; + const ownAddress = simulation.nodes.get(node)?.ipAddress ?? ''; + const [tab, setTab] = useState('A'); + // The address field starts as the computer's own (Java's window takes it when it opens): null until it is typed into or emptied. + const [fields, setFields] = useState>({ A: ['', null], MX: ['', ''], NS: ['', ''] }); + const [chosen, setChosen] = useState(null); + const [notice, setNotice] = useState(null); + if (view === null || view === undefined) return
; + const words = TABS[tab]; + const shownFields = (type: DnsRecordType): readonly [string, string] => [fields[type][0], fields[type][1] ?? ownAddress]; + const [first, second] = shownFields(tab); + const rows = view.rows[tab]; + + const add = () => { + if (!validDnsRecord(tab, first.trim(), second.trim())) { + setNotice(t(words.invalid)); + return; + } + act(node, 'dnsServer', 'addRecord', { type: tab, name: first.trim(), data: second.trim() }); + setFields({ ...fields, [tab]: ['', ''] }); + setNotice(null); + }; + const remove = () => { + if (chosen === null || chosen >= rows.length) return; + act(node, 'dnsServer', 'removeRecord', { type: tab, row: chosen }); + setChosen(null); + }; + + return ( +
+
+ + +
+
+ {(['A', 'MX', 'NS'] as const).map((each) => ( + + ))} +
+
+ {([0, 1] as const).map((column) => ( + + ))} +
+ + +
+ {notice === null ? null : ( +

+ {notice} +

+ )} + + + + + + + + + {rows.map(([name, data], row) => ( + setChosen(row)}> + {([name, data] as const).map((value, column) => ( + + ))} + + ))} + +
{t(words.columns[0])}{t(words.columns[1])}
+ act(node, 'dnsServer', 'changeRecord', { type: tab, row, column, value: written })} + /> +
+
+
+ ); +} diff --git a/packages/editor/src/desktop/WebServerPanel.tsx b/packages/editor/src/desktop/WebServerPanel.tsx index 68a7acd..3a63beb 100644 --- a/packages/editor/src/desktop/WebServerPanel.tsx +++ b/packages/editor/src/desktop/WebServerPanel.tsx @@ -17,15 +17,37 @@ const NO_LINES: readonly LogLine[] = []; /** A cell of the table, written to the server when it is left or Enter is pressed and it changed. */ function HostCell({ value, label, onCommit }: { readonly value: string; readonly label: string; readonly onCommit: (value: string) => void }) { const [draft, setDraft] = useState(value); - useEffect(() => setDraft(value), [value]); + const [editing, setEditing] = useState(false); + // The server's value, but not over what is being typed since the last Enter, Escape or leaving (it may come just after a cell written). + useEffect(() => { + if (!editing) setDraft(value); + }, [value, editing]); const commit = () => { + setEditing(false); if (draft !== value) onCommit(draft); }; const onKeyDown = (event: KeyboardEvent) => { if (event.key === 'Enter') commit(); - if (event.key === 'Escape') setDraft(value); + if (event.key === 'Escape') { + setEditing(false); + setDraft(value); + } }; - return setDraft(event.target.value)} onBlur={commit} onKeyDown={onKeyDown} />; + return ( + { + setDraft(event.target.value); + setEditing(true); + }} + onBlur={commit} + onKeyDown={onKeyDown} + /> + ); } export function WebServerPanel({ node, simulation }: { readonly node: string; readonly simulation: Simulation }) { diff --git a/packages/editor/src/desktop/desktop.css b/packages/editor/src/desktop/desktop.css index d950c49..748d913 100644 --- a/packages/editor/src/desktop/desktop.css +++ b/packages/editor/src/desktop/desktop.css @@ -1083,3 +1083,60 @@ padding: 4px 6px; resize: vertical; } + +/* The DNS server (GUIApplicationDNSServerWindow): button and box, three tabs + * of two fields, two buttons and a table whose cells are written in place. */ +.dns-server-page { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + gap: 6px; +} + +.dns-server input:not([type='checkbox']) { + min-height: 32px; + padding: 0 6px; + border: 1px solid #a0a0a0; + background: #ffffff; + color: #000000; + font: 16px Dialog, sans-serif; +} + +.dns-server [aria-invalid='true'] { + border-color: #ff0000; + color: #ff0000; +} + +.dns-server-notice { + white-space: pre-line; +} + +.dns-server-table { + width: 100%; + border-collapse: collapse; + background: #ffffff; +} + +.dns-server-table th { + padding: 4px 6px; + border-bottom: 1px solid #a0a0a0; + text-align: left; + font-weight: normal; +} + +.dns-server-table td { + padding: 2px; + border-bottom: 1px solid #dddddd; +} + +.dns-server-table td input { + width: 100%; + box-sizing: border-box; + border-color: transparent; +} + +.dns-server-table tr[aria-selected='true'] td, +.dns-server-table tr[aria-selected='true'] input { + background: #b8cfe5; +} diff --git a/packages/editor/src/i18n/de.json b/packages/editor/src/i18n/de.json index 4a78859..d117e3d 100644 --- a/packages/editor/src/i18n/de.json +++ b/packages/editor/src/i18n/de.json @@ -59,6 +59,31 @@ "dhcp.static": "Statische Adresszuweisung", "dhcp.subnetMask": "Netzmaske", "dhcp.upperBound": "Adress-Obergrenze", + "dnsServer.aAddress": "IP-Adresse:", + "dnsServer.aAddressColumn": "IP-Adresse", + "dnsServer.aName": "Host-/Domainname:", + "dnsServer.aNameColumn": "Host-/Domainname", + "dnsServer.add": "Hinzufügen", + "dnsServer.invalidA": "Eingaben sind ungültig! Valides Beispiel: \n> IP-Adresse: 192.168.0.10\n> Domainname: www.filius.de", + "dnsServer.invalidMX": "Eingaben sind ungültig! Valides Beispiel: \n> Maildomain: filius.de\n> Mailserver: mail.filius.de", + "dnsServer.invalidNS": "Eingaben sind ungültig! Valides Beispiel: \n> Domainname: filius.de\n> Nameserver: dns.filius.de", + "dnsServer.mxDomain": "Maildomain:", + "dnsServer.mxDomainColumn": "Maildomain", + "dnsServer.mxServer": "Domainname Mailserver:", + "dnsServer.mxServerColumn": "Domainname Mailserver", + "dnsServer.nsDomain": "Domain:", + "dnsServer.nsDomainColumn": "Domain", + "dnsServer.nsServer": "Nameserver:", + "dnsServer.nsServerColumn": "Nameserver", + "dnsServer.recursive": "Aktiviere rekursive Domain-Auflösung", + "dnsServer.remove": "Auswahl entfernen", + "dnsServer.row": "Zeile {row}", + "dnsServer.start": "Starten", + "dnsServer.stop": "Beenden", + "dnsServer.tabA": "Adressen (A)", + "dnsServer.tabMX": "Mailaustausch (MX)", + "dnsServer.tabNS": "Nameserver (NS)", + "dnsServer.tables": "Einträge", "echoServer.log": "Ausgabe", "echoServer.port": "Port:", "echoServer.start": "Starten", diff --git a/packages/editor/src/i18n/en.json b/packages/editor/src/i18n/en.json index 20b583f..0da91f8 100644 --- a/packages/editor/src/i18n/en.json +++ b/packages/editor/src/i18n/en.json @@ -59,6 +59,31 @@ "dhcp.static": "Static Address Assignment", "dhcp.subnetMask": "Netmask", "dhcp.upperBound": "Upper bound of address", + "dnsServer.aAddress": "IP address:", + "dnsServer.aAddressColumn": "IP address", + "dnsServer.aName": "Host/domain name:", + "dnsServer.aNameColumn": "Host/domain name", + "dnsServer.add": "Add", + "dnsServer.invalidA": "Input invalid! Valid example: \n> IP address: 192.168.0.10\n> Domain name: www.filius.de", + "dnsServer.invalidMX": "Input invalid! Valid example: \n> Mail domain: filius.de\n> Mail server: mail.filius.de!", + "dnsServer.invalidNS": "Input invalid! Valid example: \n> Domain name: filius.de\n> Name server: dns.filius.de", + "dnsServer.mxDomain": "Mail domain:", + "dnsServer.mxDomainColumn": "Mail domain", + "dnsServer.mxServer": "Mail server domain name:", + "dnsServer.mxServerColumn": "Mail server domain name", + "dnsServer.nsDomain": "Domain:", + "dnsServer.nsDomainColumn": "Domain", + "dnsServer.nsServer": "Name server:", + "dnsServer.nsServerColumn": "Name server", + "dnsServer.recursive": "Enable recursive domain resolution", + "dnsServer.remove": "Remove entry", + "dnsServer.row": "Row {row}", + "dnsServer.start": "Start", + "dnsServer.stop": "Stop", + "dnsServer.tabA": "Address (A)", + "dnsServer.tabMX": "Mail exchange (MX)", + "dnsServer.tabNS": "Name server (NS)", + "dnsServer.tables": "Records", "echoServer.log": "Output", "echoServer.port": "Port:", "echoServer.start": "Start", diff --git a/packages/editor/test/i18n.test.ts b/packages/editor/test/i18n.test.ts index 0c91933..0116c82 100644 --- a/packages/editor/test/i18n.test.ts +++ b/packages/editor/test/i18n.test.ts @@ -61,6 +61,26 @@ const FROM_JAVA: Readonly> = { 'simpleClient.failure.unresolved': 'sw_socket_msg1', 'simpleClient.failure.timedOut': 'sw_tcpsocket_msg2', 'simpleClient.failure.failed': 'sw_tcpsocket_msg3', + 'dnsServer.start': 'dnsserver_msg1', + 'dnsServer.stop': 'dnsserver_msg14', + 'dnsServer.tabA': 'dnsserver_msg2', + 'dnsServer.tabMX': 'dnsserver_msg3', + 'dnsServer.tabNS': 'dnsserver_msg15', + 'dnsServer.aName': 'dnsserver_msg4', + 'dnsServer.aAddress': 'dnsserver_msg5', + 'dnsServer.add': 'dnsserver_msg6', + 'dnsServer.remove': 'dnsserver_msg7', + 'dnsServer.aNameColumn': 'dnsserver_msg8', + 'dnsServer.aAddressColumn': 'dnsserver_msg9', + 'dnsServer.mxDomain': 'dnsserver_msg10', + 'dnsServer.mxServer': 'dnsserver_msg11', + 'dnsServer.mxDomainColumn': 'dnsserver_msg12', + 'dnsServer.mxServerColumn': 'dnsserver_msg13', + 'dnsServer.nsDomain': 'dnsserver_msg16', + 'dnsServer.nsServer': 'dnsserver_msg17', + 'dnsServer.nsDomainColumn': 'dnsserver_msg18', + 'dnsServer.nsServerColumn': 'dnsserver_msg19', + 'dnsServer.recursive': 'dnsserver_msg20', 'textEditor.new': 'texteditor_msg3', 'textEditor.open': 'texteditor_msg4', 'textEditor.save': 'texteditor_msg5', @@ -253,6 +273,10 @@ const FROM_JAVA_MENDED: Readonly java.replace(' \\n ', ' ').replace('Vermittlungsrechnerns', 'Vermittlungsrechners')], // A label above its list, without the colon of Java's label beside it. 'sheet.network': ['jhostkonfiguration_msg15', (java) => java.replace(/:$/, '')], + // Java's message dialog breaks its lines with \n. + 'dnsServer.invalidA': ['dnsserver_msg21', (java) => java.replaceAll('\\n', '\n')], + 'dnsServer.invalidNS': ['dnsserver_msg22', (java) => java.replaceAll('\\n', '\n')], + 'dnsServer.invalidMX': ['dnsserver_msg23', (java) => java.replaceAll('\\n', '\n')], // Java writes "Eintrag Löschen". 'sheet.deleteEntry': ['jvermittlungsrechnerkonfiguration_msg13', (java) => java.replace('Löschen', 'löschen')], // Java's word, then what it joins or which card. diff --git a/packages/fls/src/dnsHosts.ts b/packages/fls/src/dnsHosts.ts index 4e2dd31..df0a675 100644 --- a/packages/fls/src/dnsHosts.ts +++ b/packages/fls/src/dnsHosts.ts @@ -1,141 +1,17 @@ -// Java's DNS server keeps its records in the text file /dns/hosts and reads -// them on every query (DNSServer.leseRecordListe, ResourceRecord(String)). -// Measured on test/dns-hosts.cases.txt against Java (dns-hosts.java.json) -// and Swift (dns-hosts.swift.json). Java's format decides where the two -// differ: -// - fields are split at single spaces only: a tab or NBSP joins two fields; -// - there are no comments, no "IN" and no zone-file order; -// - a TTL that is not a number silently becomes 3600; -// - a name that is not a domain name becomes the empty name, and the record -// still counts - it just never answers, like one of another type; -// - duplicates stay. -// Java's errors are not copied (each named in dnsHosts.test.ts): a label may -// start with a digit and holds no backslash (RFC 1123 - Java's pattern has -// 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). +// Java's DNS server keeps its records in the text file /dns/hosts; reading +// it lives in the core (runtime/dnsHosts.ts), whose engine answers from the +// file as Java's server does. Here: finding the file in a project's tree. -import { javaIpCheck, javaParseInt, type DnsResourceRecord } from '@mdg-filius/core'; +import { isHostsDomainName } from '@mdg-filius/core'; import { javaFileContent } from './javaFileSystem.js'; -import { javaTrim } from './javaString.js'; import type { FlsFileTree } from './systemSoftware.js'; -/** A record of /dns/hosts, as Java's ResourceRecord holds it (the getters). */ -export interface HostsRecord { - readonly domainName: string; - readonly type: string; - readonly ttl: number; - readonly rdata: string; -} +export { DNS_HOSTS_PATH, dnsRecordsOf, readDnsHosts, type HostsRecord } from '@mdg-filius/core'; -export const DNS_HOSTS_PATH = '/dns/hosts'; - -// Labels of letters, digits and inner hyphens, an optional final dot; the -// empty string counts too. -const DOMAIN_LABELS = /^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*)?\.?$/; -/** - * A domain name as EingabenUeberpruefung.musterDomain means it (the empty - * string included). Java's pattern wants every label to start with a letter - * and lets a backslash into the labels after the first; instead a label may - * start with a digit (RFC 1123), but the last one must not be all digits - * (RFC 3696), so that no IPv4 address passes for a name. - */ -export function isDomainName(text: string): boolean { - if (!DOMAIN_LABELS.test(text)) return false; - const labels = text.split('.').filter((label) => label !== ''); - return labels.length === 0 || !/^[0-9]+$/.test(labels[labels.length - 1]!); -} - -// EingabenUeberpruefung.musterIpAdresse. -const JAVA_IP_ADDRESS = - /^(0*([1-9]?[0-9]?|1[0-9]{1,2}|2[0-4]?[0-9]|25[0-5]))\.((0*(1?[0-9]{1,2}|2[0-4]?[0-9]|25[0-5]))\.){2}(0*(1?[0-9]{1,2}|2[0-4]?[0-9]|25[0-5]))$/; +/** A domain name as Java's patterns should mean it (core's isHostsDomainName). */ +export const isDomainName = isHostsDomainName; /** The content of /dns/hosts as Java's DNS server finds it (see javaFileSystem.ts). */ export function javaHostsFileContent(root: FlsFileTree | undefined): string | undefined { - return javaFileContent(root, DNS_HOSTS_PATH); -} - -const ANSWERED_TYPES = new Set(['A', 'MX', 'NS']); - -/** `DNSServer.leseRecordListe` on the content of /dns/hosts. */ -export function readDnsHosts(content: string): HostsRecord[] { - const records: HostsRecord[] = []; - for (const token of content.split('\n')) { - const line = javaTrim(token); - // line.split(" ", 5).length < 4: fewer than three single spaces. - if (line === '' || line.split(' ').length < 4) continue; - const record = hostsRecord(line); - if (isValidRecord(record)) records.push(record); - } - return records; -} - -/** `new ResourceRecord(line)`: up to four space-separated fields. */ -function hostsRecord(line: string): HostsRecord { - const fields = line.split(' ').filter((field) => field !== '').map(javaTrim); - let domainName = ''; - let type = 'A'; - let ttl = 3600; - let rdata = ''; - if (fields[0] !== undefined && isDomainName(fields[0])) { - domainName = fields[0].endsWith('.') ? fields[0] : `${fields[0]}.`; - } - if (fields[1] !== undefined) { - const upper = fields[1].replace(/[a-z]/g, (letter) => letter.toUpperCase()); - type = ANSWERED_TYPES.has(upper) ? upper : fields[1]; - } - if (fields[2] !== undefined) { - const parsed = javaParseInt(fields[2]); - if (parsed !== undefined && parsed >= 0) ttl = parsed; - } - if (fields[3] !== undefined) { - // Java tests ".*\." (no line terminator before the dot); a target with - // one fails the domain check anyway, so endsWith says the same. - const needsDot = (type === 'NS' || type === 'MX') && !fields[3].endsWith('.'); - rdata = needsDot ? `${fields[3]}.` : fields[3]; - } - return { domainName, type, ttl, rdata }; -} - -function isValidRecord(record: HostsRecord): boolean { - switch (record.type) { - case 'A': - return isDomainName(record.domainName) && JAVA_IP_ADDRESS.test(record.rdata); - case 'MX': - case 'NS': - return isDomainName(record.domainName) && isDomainName(record.rdata); - default: - return true; - } -} - -/** - * The records Java answers with, in the model's form: Java looks them up by - * type (A, MX, NS) and by name ignoring case, so a record with the empty - * name or another type never answers and is left out. The file itself stays - * as it is. - */ -export function dnsRecordsOf(records: readonly HostsRecord[], warn: (message: string) => void): DnsResourceRecord[] { - const answered: DnsResourceRecord[] = []; - for (const record of records) { - if (record.domainName === '') continue; - const name = record.domainName === '.' ? '.' : record.domainName.slice(0, -1).toLowerCase(); - switch (record.type) { - case 'A': { - const address = javaIpCheck(record.rdata); - if (address === null) { - warn(`DNS record '${name} A ${record.rdata}' has no usable IPv4 address, left out (it stays in ${DNS_HOSTS_PATH})`); - continue; - } - answered.push({ name, type: 'A', ttlSeconds: record.ttl, target: address }); - break; - } - case 'MX': - case 'NS': - answered.push({ name, type: record.type, ttlSeconds: record.ttl, target: record.rdata.toLowerCase() }); - break; - default: - break; - } - } - return answered; + return javaFileContent(root, '/dns/hosts'); } diff --git a/packages/fls/src/projectSnapshot.ts b/packages/fls/src/projectSnapshot.ts index 022cd04..46188a1 100644 --- a/packages/fls/src/projectSnapshot.ts +++ b/packages/fls/src/projectSnapshot.ts @@ -17,13 +17,13 @@ // /mailserver/konten.txt at once), and an e-mail program's inbox and sent // mails. -import { portSnapshot, topologySnapshot, type DnsServerConfiguration, type EchoServerSnapshot, type EmailServerConfiguration, type EmailServerSnapshot, type Id, type WebServerSnapshot, type TopologySnapshot } from '@mdg-filius/core'; +import { portSnapshot, topologySnapshot, type DnsServerSnapshot, type EchoServerSnapshot, type EmailServerConfiguration, type EmailServerSnapshot, type Id, type WebServerSnapshot, type TopologySnapshot } from '@mdg-filius/core'; import type { ImportedProject } from './importProject.js'; export function projectSnapshot(project: ImportedProject): TopologySnapshot { - const dnsServers = new Map(); + const dnsServers = new Map(); for (const [nodeId, configuration] of project.dnsServerConfigurations) { - if (project.activePrograms.get(nodeId)?.has('dnsServer') === true) dnsServers.set(nodeId, configuration); + dnsServers.set(nodeId, { ...configuration, isActive: project.activePrograms.get(nodeId)?.has('dnsServer') === true }); } const echoServers = new Map(); for (const [nodeId, configuration] of project.echoServerConfigurations) { diff --git a/packages/fls/test/importProject.test.ts b/packages/fls/test/importProject.test.ts index be1ff2f..addb29c 100644 --- a/packages/fls/test/importProject.test.ts +++ b/packages/fls/test/importProject.test.ts @@ -714,7 +714,7 @@ describe('importProject rules', () => { expect(project.hostWirelessConfigurations.get(withoutNetwork!)).toEqual({ isEnabled: true, ssid: '' }); }); - it('runs only the DNS servers that are switched on (projectSnapshot)', () => { + it('runs every DNS server, switched on or off, and says which is on (projectSnapshot)', () => { const dnsServer = (isActive: boolean): FlsApplication => ({ key: 'dns', javaClass: 'filius.software.dns.DNSServer', source: flsNode('Rechner').source, isActive }); const project = importProject( topology({ @@ -728,7 +728,10 @@ describe('importProject rules', () => { const [on, off] = project.graph.nodes.map((node) => node.id); expect([...project.dnsServerConfigurations.keys()]).toEqual([on, off]); - expect([...projectSnapshot(project).dnsServerConfigurationsByNodeId.keys()]).toEqual([on]); + expect([...projectSnapshot(project).dnsServerConfigurationsByNodeId].map(([id, server]) => [id, server.isActive])).toEqual([ + [on, true], + [off, false], + ]); }); it('runs every echo server, switched on or off, and every generic client (projectSnapshot)', () => { diff --git a/packages/protocol/src/dns.ts b/packages/protocol/src/dns.ts new file mode 100644 index 0000000..21f0b75 --- /dev/null +++ b/packages/protocol/src/dns.ts @@ -0,0 +1,14 @@ +// What the DNS server's window shows, as the room keeps it and every client +// shows it (plan §7.3), after GUIApplicationDNSServerWindow (measured with +// tools/java-oracle's DnsWindowRun): its button, the box for recursive +// resolution, and the rows of its three tables — name and address (A), mail +// domain and mail server (MX), domain and name server (NS) — as /dns/hosts +// holds them. + +export type DnsTable = 'A' | 'MX' | 'NS'; + +export interface DnsServerView { + readonly active: boolean; + readonly recursive: boolean; + readonly rows: Readonly>; +} diff --git a/packages/protocol/src/index.ts b/packages/protocol/src/index.ts index fefc88f..967184a 100644 --- a/packages/protocol/src/index.ts +++ b/packages/protocol/src/index.ts @@ -2,6 +2,7 @@ export * from './host.js'; export * from './sim.js'; export * from './terminal.js'; +export * from './dns.js'; export * from './echo.js'; export * from './mail.js'; export * from './web.js'; diff --git a/packages/protocol/src/sim.ts b/packages/protocol/src/sim.ts index bd4e84b..be6b85b 100644 --- a/packages/protocol/src/sim.ts +++ b/packages/protocol/src/sim.ts @@ -9,7 +9,7 @@ import type { CaptureRow, PageLanguage } from '@mdg-filius/core'; export const SIM_PROTOCOL_VERSION = 1; /** The programs whose windows a client can watch and act in. */ -export const APP_KINDS = ['terminal', 'webBrowser', 'webServer', 'emailClient', 'emailServer', 'echoServer', 'simpleClient', 'gnutella'] as const; +export const APP_KINDS = ['terminal', 'webBrowser', 'webServer', 'emailClient', 'emailServer', 'echoServer', 'simpleClient', 'gnutella', 'dnsServer'] as const; export type AppKind = (typeof APP_KINDS)[number]; /** @@ -78,7 +78,8 @@ export type HostMessage = * does not run, a browser that never ran): the browser's (BrowserView), the * web server's (WebServerView), the e-mail program's and server's * (EmailClientView, EmailServerView), the echo server's and the generic - * client's (EchoServerView, SimpleClientView). + * client's (EchoServerView, SimpleClientView), the DNS server's + * (DnsServerView). */ | { readonly v: 1; readonly kind: 'view'; readonly node: string; readonly app: AppKind; readonly view: unknown } /** diff --git a/packages/sim/src/room.ts b/packages/sim/src/room.ts index 920fbd5..e1d3216 100644 --- a/packages/sim/src/room.ts +++ b/packages/sim/src/room.ts @@ -71,6 +71,7 @@ import { type Topic, type EmailClientView, type EmailServerView, + type DnsServerView, type EchoServerView, type SimpleClientView, type TraceFrame, @@ -244,6 +245,12 @@ export class SimulationRoom { active.echoServer = echo.isActive; settings.echoServer = { port: echo.port }; } + const dns = this.engine.dnsServer(id); + const dnsSettings = setting('dnsServer'); + if (dns !== undefined && dnsSettings !== undefined) { + active.dnsServer = dns.isActive; + settings.dnsServer = { ...(dnsSettings as object), recursiveResolutionEnabled: dns.recursiveResolutionEnabled, records: dns.answering }; + } const client = this.engine.simpleClient(id); if (client !== undefined) settings.simpleClient = { targetAddress: client.targetAddress, targetPort: client.targetPort }; settings.emailClient = snapshot.emailClientsByNodeId.get(id); @@ -454,6 +461,38 @@ export class SimulationRoom { return this.actInEchoServer(input); case 'simpleClient': return this.actInSimpleClient(input); + case 'dnsServer': + return this.actInDnsServer(input); + default: + return 'unknownAction'; + } + } + + /** The start button, the box for recursive resolution, a table's "Hinzufügen", "Auswahl entfernen" and a cell written. */ + private actInDnsServer(input: Extract): ErrorCode | undefined { + const server = this.engine.dnsServer(input.node); + if (server === undefined) return 'unknownApp'; + const { args } = input; + const type = args.type === 'A' || args.type === 'MX' || args.type === 'NS' ? args.type : undefined; + const row = Number.isInteger(args.row) ? (args.row as number) : undefined; + switch (input.action) { + case 'setActive': + if (typeof args.active !== 'boolean') return 'badArgs'; + server.setActive(args.active); + return undefined; + case 'setRecursive': + if (typeof args.enabled !== 'boolean') return 'badArgs'; + server.setRecursiveResolutionEnabled(args.enabled); + return undefined; + case 'addRecord': + if (type === undefined || typeof args.name !== 'string' || typeof args.data !== 'string') return 'badArgs'; + return server.addRecord(type, args.name, args.data) ? undefined : 'badArgs'; + case 'removeRecord': + if (type === undefined || row === undefined) return 'badArgs'; + return server.removeRecord(type, row) ? undefined : 'badArgs'; + case 'changeRecord': + if (type === undefined || row === undefined || (args.column !== 0 && args.column !== 1) || typeof args.value !== 'string') return 'badArgs'; + return server.changeRecord(type, row, args.column, args.value) ? undefined : 'badArgs'; default: return 'unknownAction'; } @@ -634,6 +673,13 @@ export class SimulationRoom { const view: EmailClientView = { account: client.account, inbox: client.inbox.map((entry) => entry.mail), sent: client.sent.map((entry) => entry.mail) }; return view; } + case 'dnsServer': { + const server = this.engine.dnsServer(node); + if (server === undefined) return null; + const rows = (type: 'A' | 'MX' | 'NS') => server.rows(type).map(({ record }) => [record.domainName, record.rdata] as const); + const view: DnsServerView = { active: server.isActive, recursive: server.recursiveResolutionEnabled, rows: { A: rows('A'), MX: rows('MX'), NS: rows('NS') } }; + return view; + } case 'echoServer': { const server = this.engine.echoServer(node); if (server === undefined) return null; @@ -725,8 +771,8 @@ export class SimulationRoom { client.viewSent.set(topic, key); client.send(message({ kind: 'view', node: node!, app: app as AppKind, view })); } - // The browser's lines are its view. - if (app === 'webBrowser') continue; + // The browser's lines are its view; the DNS server's window has none. + if (app === 'webBrowser' || app === 'dnsServer') continue; } const sent = client.appSent.get(topic); if (app === 'terminal') { diff --git a/packages/sim/test/dnsWindow.test.ts b/packages/sim/test/dnsWindow.test.ts new file mode 100644 index 0000000..6f59332 --- /dev/null +++ b/packages/sim/test/dnsWindow.test.ts @@ -0,0 +1,113 @@ +// The DNS server's window in the room, on FILIUS' example email_komplett: +// its view (button, box, the three tables as /dns/hosts holds them), its +// actions, /dns/hosts written by another into the document and shown at +// once, and what a run leaves in the project (the file, whether it runs, +// recursive resolution, the records in its settings). The server's steps +// against Java's window are packages/core/test/dnsWindow.test.ts. + +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; +import * as Y from 'yjs'; +import { changeFiles, fileText, readProject, writeProject } from '@mdg-filius/doc'; +import { openProjectArchive } from '@mdg-filius/fls'; +import type { DnsServerView, HostMessage } from '@mdg-filius/protocol'; +import { SimulationRoom, TICK_MILLISECONDS } from '../src/index.js'; + +const here = dirname(fileURLToPath(import.meta.url)); +const corpus = join(here, '../../../corpus/java-examples'); + +function setUp() { + let ids = 0; + const doc = new Y.Doc(); + writeProject( + doc, + openProjectArchive(new Uint8Array(readFileSync(join(corpus, 'email_komplett.fls'))), () => `00000000-0000-0000-0000-${(++ids).toString(16).padStart(12, '0')}`), + ); + const id = readProject(doc).graph.nodes.find((node) => node.displayName === 'DNS-Server')!.id; + let now = 0; + const room = new SimulationRoom(doc, { now: () => now, seed: () => 5 }); + let view: DnsServerView | null | undefined; + const errors: string[] = []; + const lines: HostMessage[] = []; + const client = room.connect((message: HostMessage) => { + if (message.kind === 'app') lines.push(message); + if (message.kind === 'view' && message.app === 'dnsServer') view = message.view as DnsServerView | null; + if (message.kind === 'error') errors.push(message.code); + }); + const send = (fields: object) => room.receive(client, { v: 1, ...fields }); + send({ kind: 'sub', topics: [`app:${id}/dnsServer`] }); + send({ kind: 'start' }); + const run = (milliseconds: number) => { + for (let elapsed = 0; elapsed < milliseconds; elapsed += TICK_MILLISECONDS) { + now += TICK_MILLISECONDS; + room.tick(); + } + }; + run(TICK_MILLISECONDS); + const act = (action: string, args: object = {}) => send({ kind: 'input', node: id, app: 'dnsServer', action, args, reqId: action }); + return { doc, id, room, view: () => view, errors, lines, act, run, send }; +} + +describe("the DNS server's window in the room", () => { + it('shows the server and its tables, takes its actions, and shows what another writes into /dns/hosts at once', () => { + const { doc, id, view, act, run, errors, lines } = setUp(); + // Java's DNS window has no lines: none are sent. + expect(lines).toEqual([]); + const hosts = () => fileText(doc, id, '/dns/hosts')!.toString(); + expect(view()!.active).toBe(true); + const before = view()!.rows.A.length; + act('addRecord', { type: 'A', name: 'neu.schule.de', data: '10.0.0.1' }); + act('addRecord', { type: 'MX', name: 'schule.de', data: 'mail.schule.de' }); + act('addRecord', { type: 'NS', name: 'schule.de', data: 'ns.schule.de' }); + expect(view()!.rows.A.at(-1)).toEqual(['neu.schule.de.', '10.0.0.1']); + expect([view()!.rows.MX.at(-1), view()!.rows.NS.at(-1)]).toEqual([ + ['schule.de.', 'mail.schule.de.'], + ['schule.de.', 'ns.schule.de.'], + ]); + // In the document's file at once. + expect(hosts().endsWith('neu.schule.de. A 3600 10.0.0.1\nschule.de. MX 3600 mail.schule.de.\nschule.de. NS 3600 ns.schule.de.\n')).toBe(true); + act('changeRecord', { type: 'A', row: before, column: 1, value: '10.0.0.2' }); + expect(view()!.rows.A.at(-1)).toEqual(['neu.schule.de.', '10.0.0.2']); + act('removeRecord', { type: 'NS', row: 0 }); + expect(view()!.rows.NS).toEqual([]); + act('setRecursive', { enabled: true }); + act('setActive', { active: false }); + expect([view()!.active, view()!.recursive]).toEqual([false, true]); + expect(errors).toEqual([]); + // The text editor writes the file: the table shows it. + changeFiles(doc, id, 'me', (files) => files.writeTextFile('/dns/hosts', `${files.textFile('/dns/hosts')}extern.schule.de. A 3600 10.0.0.9\n`)); + run(TICK_MILLISECONDS); + expect(view()!.rows.A.at(-1)).toEqual(['extern.schule.de.', '10.0.0.9']); + }); + + it('leaves in the project what the run made of the server', () => { + const { doc, id, act, send } = setUp(); + act('addRecord', { type: 'A', name: 'neu.schule.de', data: '10.0.0.1' }); + act('setRecursive', { enabled: true }); + act('setActive', { active: false }); + send({ kind: 'stop' }); + const project = readProject(doc); + expect(project.activePrograms.get(id)?.has('dnsServer') ?? false).toBe(false); + expect(project.dnsServerConfigurations.get(id)!.recursiveResolutionEnabled).toBe(true); + expect(project.dnsServerConfigurations.get(id)!.records.at(-1)).toEqual({ name: 'neu.schule.de', type: 'A', ttlSeconds: 3600, target: '10.0.0.1' }); + expect(project.virtualFileSystems.get(id)!.textFile('/dns/hosts').endsWith('neu.schule.de. A 3600 10.0.0.1\n')).toBe(true); + }); + + it('refuses what the window never hands over', () => { + const { act, errors } = setUp(); + act('setActive', { active: 'no' }); + act('setRecursive', {}); + act('addRecord', { type: 'TXT', name: 'a.de', data: 'x' }); + act('addRecord', { type: 'A', name: '', data: '10.0.0.1' }); + act('addRecord', { type: 'A', name: 'a.de', data: 5 }); + act('removeRecord', { type: 'A', row: 1.5 }); + act('removeRecord', { type: 'NS', row: 0 }); + act('changeRecord', { type: 'A', row: 0, column: 2, value: 'x' }); + act('changeRecord', { type: 'A', row: 0, column: 1, value: 'kein' }); + act('changeRecord', { type: 'A', row: 0, column: 1, value: 3 }); + act('restart'); + expect(errors).toEqual(['badArgs', 'badArgs', 'badArgs', 'badArgs', 'badArgs', 'badArgs', 'badArgs', 'badArgs', 'badArgs', 'badArgs', 'unknownAction']); + }); +}); diff --git a/tools/java-oracle/README.md b/tools/java-oracle/README.md index be9589e..f8568ef 100644 --- a/tools/java-oracle/README.md +++ b/tools/java-oracle/README.md @@ -1493,3 +1493,37 @@ Gemessen (03.10.2026): konnte nicht bestimmt werden!“. * Nach dem Lauf speichert Java den Port des Servers, dass er läuft, und Adresse und Port, die der Client zuletzt nahm. + +### DNS-Server (F4.6g2) + +`DnsWindowRun` öffnet Javas `GUIApplicationDNSServerWindow` auf dem +DNS-Server von mail-lan (Einträge A, MX gemischt) und benutzt es wie eine +Person: Startknopf, Kästchen „Aktiviere rekursive Domain-Auflösung“, je +Reiter (A, MX, NS) die zwei Felder und „Hinzufügen“ (auch mit dem, was Java +nicht nimmt), eine Tabellenzelle bearbeitet (wie Javas Tabelle sie beim Ende +der Bearbeitung schreibt), eine Zeile gewählt und „Auswahl entfernen“; +daneben schreibt ein anderes Programm in `/dns/hosts`. Je Schritt: Knopf, +Kästchen, Felder, die drei Tabellen, die Datei, Javas Dialoge und was der +Resolver des ersten Rechners für einige Namen bekommt +(`packages/core/test/dnsWindow.java.json`). + +Gemessen (03.10.2026): + +* Javas DNS-Server liest `/dns/hosts` bei jeder Anfrage: Ein von außen + eingetragener Name wird sofort beantwortet. Angehalten laufen Anfragen in + die Zeitüberschreitung. +* Das Adressfeld zeigt anfangs die eigene Adresse; nach „Hinzufügen“ sind + beide Felder leer. Was Javas Muster nicht nehmen, meldet ein Dialog mit + einem gültigen Beispiel; eine ungültige Zelle geht still zurück. + Doppelte Einträge sind erlaubt. Ein neuer Eintrag bekommt seinen Punkt am + Namen (MX und NS auch am Ziel) und TTL 3600. +* Javas Fehler: + * Eine bearbeitete Zelle zählt nur die Einträge ihrer Art und schreibt + nach dem Treffer in jeden Eintrag anderer Art bis zum nächsten eigenen: + Der Mailserver eines MX-Eintrags landete als „Adresse“ im folgenden + A-Eintrag, der dann ungültig ist und beim nächsten Schreiben verschwand. + * „Hinzufügen“ schreibt die Liste zurück, die Java vorher gelesen hat: + Was ein anderes Programm inzwischen in die Datei schrieb, ist weg. + * Eine leere Maildomain wird genommen (`. MX 3600 x.`). + * „Auswahl entfernen“ entfernt den ersten Eintrag des Namens, bei zwei + gleichen Namen also womöglich den anderen. diff --git a/tools/java-oracle/run.sh b/tools/java-oracle/run.sh index 5080a46..bcd0a30 100755 --- a/tools/java-oracle/run.sh +++ b/tools/java-oracle/run.sh @@ -21,7 +21,7 @@ if [ ! -f "$upstream/target/filius.jar" ]; then fi classpath="$upstream/target/filius.jar:$upstream/target/lib/*" mkdir -p "$here/.build" -javac -encoding UTF-8 -d "$here/.build" -cp "$classpath" "$here/src/FlsOracle.java" "$here/src/GetterDefaults.java" "$here/src/IpCheckCases.java" "$here/src/DnsHostsCases.java" "$here/src/EmailFileCases.java" "$here/src/BeansDump.java" "$here/src/Base64Cases.java" "$here/src/ScenarioNetwork.java" "$here/src/DhcpRun.java" "$here/src/TcpRun.java" "$here/src/DnsRun.java" "$here/src/ArpRun.java" "$here/src/RipRun.java" "$here/src/NatRun.java" "$here/src/WlanRun.java" "$here/src/ExampleRun.java" "$here/src/EchoRun.java" "$here/src/WebRun.java" "$here/src/RouterWebRun.java" "$here/src/KonfigCases.java" "$here/src/EmailRun.java" "$here/src/MailCases.java" "$here/src/EmailClientRun.java" "$here/src/TerminalCases.java" "$here/src/TerminalRun.java" "$here/src/FileCases.java" "$here/src/GnutellaRun.java" "$here/src/NewDeviceRun.java" "$here/src/SheetRun.java" "$here/src/TerminalWindowRun.java" "$here/src/InstallRun.java" "$here/src/BrowserWindowRun.java" "$here/src/MailWindowRun.java" "$here/src/SaveAfterRunRun.java" "$here/src/TextEditorWindowRun.java" "$here/src/FileExplorerWindowRun.java" "$here/src/EchoWindowRun.java" +javac -encoding UTF-8 -d "$here/.build" -cp "$classpath" "$here/src/FlsOracle.java" "$here/src/GetterDefaults.java" "$here/src/IpCheckCases.java" "$here/src/DnsHostsCases.java" "$here/src/EmailFileCases.java" "$here/src/BeansDump.java" "$here/src/Base64Cases.java" "$here/src/ScenarioNetwork.java" "$here/src/DhcpRun.java" "$here/src/TcpRun.java" "$here/src/DnsRun.java" "$here/src/ArpRun.java" "$here/src/RipRun.java" "$here/src/NatRun.java" "$here/src/WlanRun.java" "$here/src/ExampleRun.java" "$here/src/EchoRun.java" "$here/src/WebRun.java" "$here/src/RouterWebRun.java" "$here/src/KonfigCases.java" "$here/src/EmailRun.java" "$here/src/MailCases.java" "$here/src/EmailClientRun.java" "$here/src/TerminalCases.java" "$here/src/TerminalRun.java" "$here/src/FileCases.java" "$here/src/GnutellaRun.java" "$here/src/NewDeviceRun.java" "$here/src/SheetRun.java" "$here/src/TerminalWindowRun.java" "$here/src/InstallRun.java" "$here/src/BrowserWindowRun.java" "$here/src/MailWindowRun.java" "$here/src/SaveAfterRunRun.java" "$here/src/TextEditorWindowRun.java" "$here/src/FileExplorerWindowRun.java" "$here/src/EchoWindowRun.java" "$here/src/DnsWindowRun.java" # FILIUS writes its settings below $HOME; keep them out of the real one. mkdir -p "$here/.build/home" # Removed first: a crashed run must not leave the previous result standing. @@ -432,6 +432,16 @@ HOME="$here/.build/home" xvfb-run -a java -Duser.language=de -Duser.country=DE - "$root/packages/core/test/scenarios/mail-lan.scenario.json" "$output" 2>&1 | grep -v 'logback\|^$\|^\s*at ' || true test -s "$output" +# FILIUS' DNS server window used as a person does — the button, the box, +# each tab's fields and buttons, a cell edited, a row removed, /dns/hosts +# changed beside it, the first computer's resolver asked (DnsWindowRun): the +# DNS server's window (packages/core/test/dnsWindow.test.ts). +output="$root/packages/core/test/dnsWindow.java.json" +rm -f "$output" +HOME="$here/.build/home" xvfb-run -a java -Duser.language=de -Duser.country=DE -cp "$classpath:$here/.build" DnsWindowRun \ + "$root/packages/core/test/scenarios/mail-lan.scenario.json" "$output" 2>&1 | grep -v 'logback\|^$\|^\s*at ' || true +test -s "$output" + # FILIUS' own examples as Java FILIUS runs them, opened and started as FILIUS # does (ExampleRun): the acceptance of F2 (packages/fls/test/examplesJava.test.ts) # and the classics of F3 (web, email, gnutella; packages/fls/test/classicsJava.test.ts). diff --git a/tools/java-oracle/src/DnsWindowRun.java b/tools/java-oracle/src/DnsWindowRun.java new file mode 100644 index 0000000..495617b --- /dev/null +++ b/tools/java-oracle/src/DnsWindowRun.java @@ -0,0 +1,302 @@ +// Uses FILIUS' own DNS server window (GUIApplicationDNSServerWindow on a +// GUIDesktopPanel) on mail-lan's DNS server as a person does: the start +// button, the check box for recursive resolution, each tab's two fields and +// "Hinzufügen" (also with what Java does not take), a table cell edited (as +// Java's table writes it when its editing stops), a row chosen and "Auswahl +// entfernen"; and /dns/hosts changed beside it as another program would. +// Java's message dialogs are answered and written down. After each step it +// writes what the window shows (the button, the box, the fields, the three +// tables), the file, and what the first computer's resolver gets for a few +// names. The reference for the room's DNS server window +// (packages/sim/test/dnsWindow.test.ts). +// +// Usage: java -cp filius.jar:lib/*:classes DnsWindowRun + +import filius.gui.anwendungssicht.DNSConfigTable; +import filius.gui.anwendungssicht.GUIApplicationDNSServerWindow; +import filius.gui.anwendungssicht.GUIDesktopPanel; +import filius.software.system.Betriebssystem; +import filius.software.system.Datei; +import filius.software.system.InternetKnotenBetriebssystem; + +import java.awt.Component; +import java.awt.Container; +import java.awt.Window; +import java.io.FileOutputStream; +import java.lang.reflect.Field; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; +import javax.swing.JButton; +import javax.swing.JCheckBox; +import javax.swing.JDialog; +import javax.swing.JFrame; +import javax.swing.JOptionPane; +import javax.swing.JTable; +import javax.swing.JTextField; +import javax.swing.SwingUtilities; +import javax.swing.table.TableModel; + +public class DnsWindowRun { + static final String CLIENT = "00000000-0000-0000-0000-000000000201"; + static final String SERVER = "00000000-0000-0000-0000-000000000202"; + static final String[] NAMES = { "www.filius.de", "web.filius.de", "neu.schule.de", "extern.schule.de" }; + + static final List steps = new ArrayList<>(); + static final List dialogs = new ArrayList<>(); + static GUIApplicationDNSServerWindow window; + static Betriebssystem server; + static InternetKnotenBetriebssystem client; + + static Object field(Class owner, Object object, String name) throws Exception { + Field field = owner.getDeclaredField(name); + field.setAccessible(true); + return field.get(object); + } + + @SuppressWarnings("unchecked") + static T find(Container container, Class kind) { + for (Component component : container.getComponents()) { + if (kind.isInstance(component)) return (T) component; + if (component instanceof Container) { + T found = find((Container) component, kind); + if (found != null) return found; + } + } + return null; + } + + static void watchDialogs() { + Thread watcher = new Thread(() -> { + while (true) { + for (Window each : Window.getWindows()) { + if (!(each instanceof JDialog) || !each.isVisible()) continue; + JOptionPane pane = find((Container) each, JOptionPane.class); + if (pane == null) continue; + synchronized (dialogs) { + dialogs.add(String.valueOf(pane.getMessage())); + } + pane.setValue(JOptionPane.OK_OPTION); + each.setVisible(false); + } + try { + Thread.sleep(50); + } catch (InterruptedException error) { + return; + } + } + }); + watcher.setDaemon(true); + watcher.start(); + } + + static void onEdt(Runnable action) throws Exception { + SwingUtilities.invokeLater(action); + Thread.sleep(500); + } + + static List> rows(JTable table) { + List> rows = new ArrayList<>(); + TableModel model = table.getModel(); + for (int row = 0; row < model.getRowCount(); row++) { + List cells = new ArrayList<>(); + for (int column = 0; column < model.getColumnCount(); column++) cells.add(String.valueOf(model.getValueAt(row, column))); + rows.add(cells); + } + return rows; + } + + static String file() { + Datei hosts = server.getDateisystem().holeDatei("/dns/hosts"); + return hosts == null ? null : hosts.getDateiInhalt(); + } + + /** What the first computer's resolver gets for the name: the address, or the exception's class. */ + static String resolve(String name) throws Exception { + AtomicReference result = new AtomicReference<>("(no answer)"); + Thread thread = new Thread(() -> { + try { + result.set(String.valueOf(client.holeDNSClient().holeIPAdresse(name))); + } catch (Exception error) { + result.set(error.getClass().getSimpleName()); + } + }); + thread.start(); + thread.join(30000); + return result.get(); + } + + static void step(String name, boolean ask) throws Exception { + Class w = GUIApplicationDNSServerWindow.class; + Map result = new LinkedHashMap<>(); + result.put("step", name); + result.put("button", ((JButton) field(w, window, "buttonStart")).getText()); + result.put("recursive", ((JCheckBox) field(w, window, "recResolution")).isSelected()); + Map fields = new LinkedHashMap<>(); + for (String each : new String[] { "aDomainField", "aIpField", "mxMaildomainField", "mxURLField", "nsDomainField", "nsDomainServerField" }) { + fields.put(each, ((JTextField) field(w, window, each)).getText()); + } + result.put("fields", fields); + result.put("a", rows((JTable) field(w, window, "aRecordsTable"))); + result.put("mx", rows((JTable) field(w, window, "mxRecordsTable"))); + result.put("ns", rows((JTable) field(w, window, "nsRecordsTable"))); + result.put("file", file()); + synchronized (dialogs) { + result.put("dialogs", new ArrayList<>(dialogs)); + dialogs.clear(); + } + if (ask) { + Map answers = new LinkedHashMap<>(); + for (String each : NAMES) answers.put(each, resolve(each)); + result.put("resolved", answers); + } + steps.add(result); + } + + static void type(String name, String text) throws Exception { + JTextField field = (JTextField) field(GUIApplicationDNSServerWindow.class, window, name); + onEdt(() -> field.setText(text)); + } + + /** The tab's "Hinzufügen" or "Auswahl entfernen" (the n-th of that text in the window). */ + static void press(String text, int index) throws Exception { + List found = new ArrayList<>(); + collect(window, text, found); + JButton button = found.get(index); + onEdt(button::doClick); + } + + static void collect(Container container, String text, List found) { + for (Component component : container.getComponents()) { + if (component instanceof JButton && text.equals(((JButton) component).getText())) found.add((JButton) component); + if (component instanceof Container) collect((Container) component, text, found); + } + } + + /** A cell written as Java's table writes it: edited, then its editing stopped. */ + static void edit(String table, int row, int column, String value) throws Exception { + DNSConfigTable each = (DNSConfigTable) field(GUIApplicationDNSServerWindow.class, window, table); + onEdt(() -> { + each.editCellAt(row, column); + ((JTextField) each.getEditorComponent()).setText(value); + each.getCellEditor().stopCellEditing(); + }); + } + + static void choose(String table, int row) throws Exception { + JTable each = (JTable) field(GUIApplicationDNSServerWindow.class, window, table); + onEdt(() -> each.setRowSelectionInterval(row, row)); + } + + public static void main(String[] args) throws Exception { + try { + run(args); + } catch (Throwable error) { + error.printStackTrace(); + System.exit(1); + } + System.exit(0); + } + + static void run(String[] args) throws Exception { + ScenarioNetwork network = ScenarioNetwork.read(args[0]); + server = (Betriebssystem) network.nodes.get(SERVER).getSystemSoftware(); + client = (InternetKnotenBetriebssystem) network.nodes.get(CLIENT).getSystemSoftware(); + network.start(); + Thread.sleep(500); + watchDialogs(); + + JFrame frame = new JFrame(); + GUIDesktopPanel desktop = new GUIDesktopPanel(server); + frame.getContentPane().add(desktop); + window = (GUIApplicationDNSServerWindow) desktop.starteAnwendung("filius.software.dns.DNSServer"); + SwingUtilities.invokeAndWait(() -> { + frame.pack(); + frame.setVisible(true); + }); + Thread.sleep(500); + step("open", true); + + // A records: what Java does not take, then a new one; the fields after it. + type("aDomainField", "kein name!"); + type("aIpField", "192.168.0.30"); + press("Hinzufügen", 0); + step("A: add an invalid name", false); + type("aDomainField", "neu.schule.de"); + type("aIpField", "192.168.0.300"); + press("Hinzufügen", 0); + step("A: add an invalid address", false); + type("aDomainField", "neu.schule.de"); + type("aIpField", "10.0.0.1"); + press("Hinzufügen", 0); + step("A: add neu.schule.de", true); + type("aDomainField", "neu.schule.de"); + type("aIpField", "10.0.0.2"); + press("Hinzufügen", 0); + step("A: add neu.schule.de again", false); + + // MX and NS. + type("mxMaildomainField", "schule.de"); + type("mxURLField", "mail.schule.de"); + press("Hinzufügen", 1); + step("MX: add schule.de", false); + type("mxMaildomainField", ""); + type("mxURLField", "x"); + press("Hinzufügen", 1); + step("MX: add with no domain", false); + type("nsDomainField", "schule.de"); + type("nsDomainServerField", "ns.schule.de"); + press("Hinzufügen", 2); + step("NS: add schule.de", false); + + // A table cell edited: the first A record's name; an invalid value; the address of the third. + edit("aRecordsTable", 0, 0, "web.filius.de"); + step("A: edit row 0's name to web.filius.de", true); + edit("aRecordsTable", 1, 1, "999.1.1.1"); + step("A: edit row 1's address to 999.1.1.1", false); + edit("aRecordsTable", 2, 1, "192.168.0.99"); + step("A: edit row 2's address", false); + edit("mxRecordsTable", 0, 1, "post.filius.de"); + step("MX: edit row 0's server", false); + + // A row chosen and removed: the second neu.schule.de. + choose("aRecordsTable", 5); + press("Auswahl entfernen", 0); + step("A: remove row 5", true); + + // Recursive resolution, start and stop. + JCheckBox recursive = (JCheckBox) field(GUIApplicationDNSServerWindow.class, window, "recResolution"); + onEdt(recursive::doClick); + step("recursive on", false); + JButton start = (JButton) field(GUIApplicationDNSServerWindow.class, window, "buttonStart"); + onEdt(start::doClick); + Thread.sleep(1000); + step("button once", true); + onEdt(start::doClick); + Thread.sleep(1000); + step("button twice", true); + + // Another program writes /dns/hosts; the window, the answers; then a record added in the window. + Datei hosts = server.getDateisystem().holeDatei("/dns/hosts"); + onEdt(() -> hosts.setDateiInhalt(hosts.getDateiInhalt() + "extern.schule.de. A 3600 10.0.0.9\n")); + step("file changed by another program", true); + type("aDomainField", "danach.schule.de"); + type("aIpField", "10.0.0.3"); + press("Hinzufügen", 0); + step("A: add after the file changed", true); + choose("aRecordsTable", 0); + press("Auswahl entfernen", 0); + step("A: remove row 0 after the file changed", true); + + Map output = new LinkedHashMap<>(); + output.put("steps", steps); + try (FileOutputStream out = new FileOutputStream(args[1])) { + out.write((FlsOracle.Json.write(output, "") + "\n").getBytes(StandardCharsets.UTF_8)); + } + network.stop(); + System.out.println("dns window steps: " + steps.size()); + } +}