diff --git a/CHATLOG.md b/CHATLOG.md index 20c7d1f..7c60a01 100644 --- a/CHATLOG.md +++ b/CHATLOG.md @@ -946,3 +946,51 @@ Test, WLAN/Bonding/PPPoE-Live-Tests). CHATLOG.md aktualisiert, Commit + Push (inkl. der noch nicht live getesteten Port-Konflikt-Prüfung + Fertig-Button, auf ausdrücklichen Nutzerwunsch mitgespeichert). +- "ergänze die Mermaid-Grafik um die neuen Features" → README.md- + Architekturdiagramm um `InterfaceTrafficMonitor`s eigene SSH-Verbindung + und den lokalen `SavedRoutersStore`→`UserDefaults`-Pfad ergänzt. Commit + + Push. +- "der Tab 'Geräte' muss umbenannt werden in Lan-Scanner ... zusätzlich + einen Button 'neu scannen'" → M20 gestartet: Tab umbenannt, "Aktuali- + sieren"-Button zu "Neu scannen" (identische Aktion, nur klarerer + Name). "sieht gut aus" → bestätigt. +- "den Lan-scanner bohren wir noch ein wenig auf... Netzwerk-Tools + liste... nslookup, ping etc." → `NetworkToolsService` (eigene SSH- + Verbindung, `/ping`/`/tool traceroute`/`/resolve`), Rohausgabe im + Sheet statt geparst (Format nicht live verifiziert). Sicherheits- + maßnahme: Zeichen-Validierung gegen Command-Injection über einen + böswilligen DHCP-Hostnamen (RouterOS-Konsole trennt Befehle mit ";"). + Live bestätigt. +- "die Warnung zum zuweisen einer festen IP-Adresse... muss erweitert + werden. der Hinweis, wie die Adresse wieder in dynamisch gemacht + werden kann" → Warnhinweis um Rückweg-Erklärung ergänzt (Rechtsklick + → "Feste Zuweisung entfernen", Gerät danach kurz trennen/neu + verbinden). Live bestätigt. +- "der refresh-button muss prominenter werden" → `.buttonStyle(. + borderedProminent)` auf "Neu scannen". Live bestätigt. +- "ersetze DE und EN mit den jeweiligen Landesflaggen" → 🇬🇧/🇩🇪 statt + Text, zeigt weiter die Sprache, zu der ein Klick wechselt. Live + bestätigt. +- "macht es sinn andere Sprachen per Plugin zu importieren?" (nur Frage, + keine Umsetzung) → Empfehlung: für 1-2 feste Sprachen reicht das + bestehende `L10n.swift`-Muster, ein echtes Plugin-System wäre Overkill + ohne konkrete Nachfrage. "lass uns bei DE/EN bleiben" → bestätigt. + "eventuell kommt spanisch indisch noch dazu... MikroTik-Router günstig, + daher oft in ärmeren Ländern wie Brasilien" → als Claude-Memory + (`future-language-support.md`) für Folgesessions festgehalten, keine + Code-Änderung. +- "können wir die Netzwerk-tools noch erweitern? ein Portscanner... + (Offene Ports rot, geschlossene grün, nicht erreichbare grau)" → + `PortScanner` (Network.framework, TCP-Connect direkt von diesem Mac, + NICHT über den Router — RouterOS hat kein eingebautes Portscan-Tool). + Live-Bug beim ersten Testlauf-Versuch bereits im eigenen Unit-Test + gefunden: `NWConnection` meldet eine verweigerte Verbindung über + `.waiting`, nicht `.failed` — der ursprüngliche Code prüfte nur + `.failed` und wartete den vollen Timeout aus statt sofort "geschlossen" + zu melden. Gefixt, Test bestätigt es gegen einen echten (aber + garantiert freien) Loopback-Port. 92 Tests grün. "sieht gut aus und + funktioniert, alles speichern committen und pushen, denk an die + Mermaid-Grafik" → README.md/HANDOFF.md/Manual.md/CHATLOG.md + aktualisiert (inkl. Mermaid-Diagramm um `NetworkToolsService`-SSH- + Verbindung und `PortScanner`s direkten Mac→Gerät-Pfad ergänzt), + Commit + Push. diff --git a/HANDOFF.md b/HANDOFF.md index 9f98f21..cf59d0d 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -1204,6 +1204,71 @@ grün, wartet auf Test durch den Nutzer in Xcode): ("das vierschieben funktioniert super, die Linien folgen auch" / "ok, der button ist da und funktioniert" / "funktioniert"). +- ✅ M20: LAN-Scanner-Umbau — Umbenennung, Netzwerk-Tools, mehrere kleine + UX-Nachbesserungen. + - **Tab umbenannt**: "Geräte" → "LAN-Scanner" (Nutzerwunsch, "oder + ähnliches"), Toolbar-Button "Aktualisieren" → "Neu scannen" + (macht exakt dasselbe — DHCP-Leases/ARP/Bridge-Host neu abfragen —, + Umbenennung passt nur besser zum "Scanner"-Namen). + - **Netzwerk-Tools-Untermenü** beim Rechtsklick auf ein Gerät: + Ping/Traceroute/DNS-Auflösung (nslookup) — alle drei vom Router aus + über eine neue dedizierte SSH-Verbindung (`NetworkToolsService`, + gleiches Muster wie `BackupService`/`InterfaceTrafficMonitor`, da + `/ping`/`/tool traceroute`/`/resolve` reine CLI-Befehle ohne REST- + Äquivalent sind). Ergebnis als Rohtext im Sheet gezeigt statt + geparst — das genaue Ausgabeformat dieser Tools ist nicht gegen + echte Hardware verifiziert, nach den Parser-Überraschungen bei M18 + bewusst vorsichtig. Sicherheitsmaßnahme: IP/Hostname eines Geräts + stammen letztlich aus dessen eigener (potenziell manipulierbarer) + DHCP-Anfrage — vor dem Einsetzen in den RouterOS-Befehlsstring auf + ein sicheres Zeichen-Set geprüft (`NetworkToolsService.sanitized`), + da RouterOS' Konsole ";" als Befehlstrenner behandelt und ein + böswillig gesetzter Hostname sonst einen zweiten Befehl einschleusen + könnte. + - **Port-Scan** (Nutzerwunsch: "Offene Ports rot, geschlossene grün, + nicht erreichbare grau") — läuft bewusst NICHT über den Router + (RouterOS hat kein eingebautes Portscan-Tool), sondern direkt von + diesem Mac per `Network.framework`/`NWConnection` gegen ~19 gängige + TCP-Ports parallel. **Live-Bug gefunden und gefixt**: eine + verweigerte Verbindung (Port zu, TCP RST) meldet sich bei + `NWConnection` über den `.waiting`-Zustand, nicht über `.failed` — + der ursprüngliche Code prüfte nur `.failed`, wartete bei einer + Ablehnung also den vollen Timeout aus und meldete fälschlich + "nicht erreichbar" statt "geschlossen". Test hat das direkt gegen + einen echten (aber garantiert nicht belegten) Loopback-Port + aufgedeckt, kein Rätselraten nötig. + - **Warnhinweis "Feste IP-Adresse zuweisen?" erweitert** (Nutzerwunsch: + "der Hinweis, wie die Adresse wieder in dynamisch gemacht werden + kann") — nennt jetzt explizit den Rückweg (Rechtsklick → "Feste + Zuweisung entfernen" im selben Tab) und dass das Gerät danach die + Verbindung kurz trennen/neu aufbauen muss. + - **"Neu scannen"-Button prominenter gemacht** (Nutzerwunsch: "wird + übersehen") — `.buttonStyle(.borderedProminent)` statt eines reinen + Icon-Buttons. + - **DE/EN-Umschalter zeigt jetzt Landesflaggen** (🇬🇧/🇩🇪) statt + "EN"/"DE"-Text — zeigt die Flagge der Sprache, zu der ein Klick + wechselt, exakt dieselbe "switch to"-Logik wie vorher. + - 92 Tests grün (6 neue `PortScannerTests`, 6 neue + `NetworkToolsServiceTests` für die Zeichen-Validierung). **Alle + Teile live bestätigt** ("funktioniert" / "sieht gut aus und + funktioniert"). + - Nebenbei: großer `.alert`/`.confirmationDialog`/`.sheet`-Modifier- + Stapel in `DevicesView.swift` löste erneut Bug 19 aus ("compiler is + unable to type-check this expression in reasonable time") — nach + demselben, bereits etablierten Muster in zwei `View`-Extension- + Funktionen aufgeteilt (`withStaticAssignmentDialogs`/ + `withDeviceDetailSheets`). + +**Nutzerfrage, keine Umsetzung** (2026-09-15): ob es Sinn macht, weitere +Sprachen "per Plugin" zu importieren. Antwort: für 1-2 weitere feste +Sprachen passt das bestehende `L10n.swift`-Dictionary-Muster gut; ein +echtes ladbares Plugin-System wäre für dieses Nischen-Tool vermutlich +Overkill ohne konkrete Nachfrage. Nutzer bleibt vorerst bei DE/EN, hat +aber erwähnt, dass Spanisch (und evtl. Hindi/indischer Markt, unklar) +später dazukommen könnte, da MikroTik-Router in ärmeren Ländern +(Brasilien, spanischsprachige Länder) stark verbreitet sind — siehe +Claude-Memory `future-language-support.md` für Details. + ## Nächste Schritte 1. ~~M16: restliche `RouterOSSchemaCatalog.swift`-Sektionen übersetzen~~ diff --git a/Manual.md b/Manual.md index eddc964..102ef22 100644 --- a/Manual.md +++ b/Manual.md @@ -29,7 +29,7 @@ those. 2. [Verbinden](#1-verbinden) 3. [Einrichten (Wizard)](#2-einrichten-wizard) 4. [Übersicht](#3-übersicht) -5. [Geräte](#4-geräte) +5. [LAN-Scanner](#4-lan-scanner) 6. [Experte](#5-experte) 7. [Sicherungen](#6-sicherungen) @@ -37,7 +37,7 @@ those. RouterOS Assistant ist eine native macOS-App, die Mikrotik-RouterOS-Router per geführtem Wizard einrichtet und über fünf Tabs verwaltet: Verbinden, -Einrichten, Übersicht, Geräte, Experte, Sicherungen. Die App spricht mit +Einrichten, Übersicht, LAN-Scanner, Experte, Sicherungen. Die App spricht mit dem Router per REST-API (bevorzugt) oder SSH (Fallback) — beides wird automatisch gewählt, ohne dass du dich darum kümmern musst. @@ -130,17 +130,31 @@ Grafisches Diagramm des kompletten aktuellen Router-Zustands: "Zurücksetzen"-Button in der Toolbar stellt die ursprüngliche Spalten-Anordnung wieder her. -## 4. Geräte +## 4. LAN-Scanner -LAN-Scanner: zeigt alle Geräte im Netzwerk (aus DHCP-Leases und ARP- -Tabelle), gruppiert nach physischem Port. +Zeigt alle Geräte im Netzwerk (aus DHCP-Leases und ARP-Tabelle), +gruppiert nach physischem Port. "Neu scannen"-Button fragt Leases/ARP/ +Bridge-Host-Tabelle erneut ab. - Rechtsklick auf ein Gerät mit dynamischer Adresse → "Feste IP zuweisen" (macht die aktuelle Adresse dauerhaft, RouterOS' "Make - Static"). + Static"). Der Warnhinweis erklärt auch den Rückweg: Rechtsklick → + "Feste Zuweisung entfernen" (hier im selben Tab), danach muss das + Gerät kurz die Verbindung trennen/neu aufbauen (Kabel/WLAN kurz + trennen, oder neu starten), um automatisch wieder eine dynamische + Adresse zu bekommen. - Rechtsklick auf ein Gerät mit fester Adresse → "Feste Zuweisung entfernen" (Rückweg zu dynamisch — Gerät muss danach kurz die Verbindung trennen/neu verbinden, um eine neue Adresse zu bekommen). +- **Netzwerk-Tools** (Rechtsklick auf ein Gerät): + - **Ping** und **Traceroute** — ausgeführt vom Router aus (nicht von + diesem Mac), zeigt die rohe RouterOS-Ausgabe. + - **DNS-Auflösung (nslookup)** — nur verfügbar, wenn ein Hostname + bekannt ist; prüft, ob der Router diesen Namen selbst auflösen kann. + - **Port-Scan** — läuft direkt von diesem Mac aus (nicht über den + Router) gegen ca. 19 gängige TCP-Ports. Ergebnis farbig: rot = + offen, grün = geschlossen (Gerät antwortet, aber nichts lauscht dort), + grau = keine Antwort (Firewall, Gerät aus, oder Port gefiltert). ## 5. Experte @@ -180,7 +194,7 @@ Tooltips und Warnhinweisen zu jedem Feld: 2. [Connect](#1-connect) 3. [Setup Wizard](#2-setup-wizard) 4. [Topology](#3-topology) -5. [Devices](#4-devices) +5. [LAN Scanner](#4-lan-scanner) 6. [Expert](#5-expert) 7. [Backups](#6-backups) @@ -188,7 +202,7 @@ Tooltips und Warnhinweisen zu jedem Feld: RouterOS Assistant is a native macOS app that sets up and manages MikroTik RouterOS routers through a guided wizard and five tabs: -Connect, Setup, Topology, Devices, Expert, Backups. The app talks to the +Connect, Setup, Topology, LAN Scanner, Expert, Backups. The app talks to the router over its REST API (preferred) or SSH (fallback) — chosen automatically, no configuration needed. @@ -274,16 +288,30 @@ Graphical diagram of the router's complete current state: it) — connection lines follow live. A "Reset" button in the toolbar restores the original column layout. -## 4. Devices +## 4. LAN Scanner -LAN scanner: shows every device on the network (from DHCP leases and the -ARP table), grouped by physical port. +Shows every device on the network (from DHCP leases and the ARP table), +grouped by physical port. "Rescan" button re-queries leases/ARP/bridge +host table. - Right-click a device with a dynamic address → "Assign static IP" - (makes its current address permanent, RouterOS' "Make Static"). + (makes its current address permanent, RouterOS' "Make Static"). The + warning also explains the way back: right-click → "Remove static + assignment" (in this same tab), after which the device needs to + briefly disconnect/reconnect (unplug cable, toggle Wi-Fi, or reboot) + to automatically get a dynamic address again. - Right-click a device with a static address → "Remove static assignment" (back to dynamic — the device needs to briefly disconnect/reconnect afterwards to get a fresh address). +- **Network Tools** (right-click a device): + - **Ping** and **Traceroute** — run from the router (not from this + Mac), shows the raw RouterOS output. + - **DNS lookup (nslookup)** — only available when a hostname is known; + checks whether the router itself can resolve that name. + - **Port scan** — runs directly from this Mac (not through the + router) against about 19 common TCP ports. Color-coded result: red + = open, green = closed (device responds, but nothing listens + there), grey = no response (firewall, device off, or filtered port). ## 5. Expert diff --git a/README.md b/README.md index 9232ab4..8f19b06 100644 --- a/README.md +++ b/README.md @@ -47,9 +47,15 @@ Befehl, der ausgeführt wird — nichts passiert unangekündigt. zurückschreiben. Verbindungslinien laufen animiert in Flussrichtung ("von → nach"); Kästchen lassen sich frei verschieben (Linien folgen live mit), ein Button setzt die Anordnung zurück. -- **Geräte** — LAN-Scanner (DHCP-Leases + ARP + Bridge-Host-Tabelle), - eine Tabelle pro physischem Port. Rechtsklick weist einem Gerät eine - feste IP-Adresse zu (RouterOS' "Make Static") oder entfernt sie wieder. +- **LAN-Scanner** (vormals "Geräte") — DHCP-Leases + ARP + Bridge-Host- + Tabelle, eine Tabelle pro physischem Port. Rechtsklick weist einem Gerät + eine feste IP-Adresse zu (RouterOS' "Make Static") oder entfernt sie + wieder — der Warnhinweis dazu erklärt auch den Rückweg (Rechtsklick → + "Feste Zuweisung entfernen", danach Gerät kurz trennen/neu verbinden). + **Netzwerk-Tools** (Rechtsklick auf ein Gerät): Ping/Traceroute/DNS- + Auflösung vom Router aus, sowie ein **Port-Scan** direkt von diesem Mac + (TCP-Verbindungsversuch auf ~19 gängige Ports, farbig: rot=offen, + grün=geschlossen, grau=keine Antwort). - **Experte** — kuratierte Formulare (mit Tooltips/Warnhinweisen) für die wichtigsten RouterOS-Bereiche (Firewall/NAT/Mangle/Raw komplett, plus Interfaces/IP/VPN/WLAN/Queues/System/Werkzeuge), plus ein @@ -72,7 +78,7 @@ flowchart TD Connect["Verbinden"] Setup["Einrichten-Wizard"] Overview["Übersicht"] - Devices["Geräte"] + Devices["LAN-Scanner"] Expert["Experte"] Backup["Sicherungen"] end @@ -89,6 +95,8 @@ flowchart TD Backup -. "eigene SSH-Verbindung
(Backup/Restore/Werksreset/Update)" .-> SSH2["SSHTransport"] Connect -. "eigene SSH-Verbindung
(InterfaceTrafficMonitor, 3s-Polling)" .-> SSH3["SSHTransport"] + Devices -. "eigene SSH-Verbindung
(NetworkToolsService: Ping/Traceroute/DNS)" .-> SSH4["SSHTransport"] + Devices -. "direkte TCP-Verbindung
(PortScanner, nicht über den Router)" .-> LANDevice[("Gerät im LAN")] Connect --> Saved["SavedRoutersStore
(Bekannte Router)"] Saved --> Defaults[("UserDefaults
(lokal, kein Router-Zugriff)")] @@ -97,18 +105,25 @@ flowchart TD SSH --> Router SSH2 --> Router SSH3 --> Router + SSH4 --> Router ``` `ConnectionService` ist der einzige zwischen den Tabs geteilte State (REST-zuerst, SSH-Fallback). Einmalige System-Aktionen (Backup, Wiederherstellung, Werksreset, Updates) laufen bewusst über eine jeweils eigene, dedizierte SSH-Verbindung statt über den aktiven Live-Transport — -`InterfaceTrafficMonitor` folgt demselben Muster für sein 3-Sekunden- -Polling der Live-Interface-Durchsätze (`/interface monitor-traffic` ist -ein reiner CLI-Befehl ohne REST-Äquivalent). Ein generischer Menü-Engine -(`fetchMenuItems`/`fetchFieldValues`) macht praktisch jedes RouterOS-Menü -lesbar, auch ohne kuratiertes Schema — darauf bauen Übersicht-, Geräte- -und Experte-Tab gemeinsam auf, ebenso `ConnectionService. +`InterfaceTrafficMonitor` (3-Sekunden-Polling der Live-Interface- +Durchsätze) und `NetworkToolsService` (Ping/Traceroute/DNS-Auflösung im +LAN-Scanner-Tab) folgen demselben Muster, da die zugrundeliegenden +RouterOS-Befehle (`/interface monitor-traffic`, `/ping`, +`/tool traceroute`, `/resolve`) reine CLI-Befehle ohne REST-Äquivalent +sind. `PortScanner` (ebenfalls LAN-Scanner-Tab) geht dagegen gar nicht +über den Router — ein reiner TCP-Verbindungsversuch direkt von diesem Mac +zum jeweiligen LAN-Gerät, da RouterOS selbst keinen Portscanner anbietet. +Ein generischer Menü-Engine (`fetchMenuItems`/`fetchFieldValues`) macht +praktisch jedes RouterOS-Menü lesbar, auch ohne kuratiertes Schema — +darauf bauen Übersicht-, LAN-Scanner- und Experte-Tab gemeinsam auf, +ebenso `ConnectionService. checkPortConflict(interfaceName:)` für die LAN-Port-Konflikt-Prüfung im Einrichten-Assistenten. `SavedRoutersStore` (Bekannte Router) ist bewusst komplett lokal — ein JSON-Array in `UserDefaults`, kein Router-Zugriff; @@ -134,6 +149,7 @@ nur die zugehörigen Passwörter liegen weiterhin im macOS-Schlüsselbund. | M17 | Bekannte Router (Verbinden-Tab), Standort-Feld, Scroll-Cap | ✅ live verifiziert | | M18 | Live-Traffic-Anzeige an Interfaces (Verbinden-Tab) | ✅ live verifiziert | | M19 | Übersicht-Tab: Flussanimation + verschiebbare Knoten | ✅ live verifiziert | +| M20 | LAN-Scanner: Umbenennung, Netzwerk-Tools (Ping/Traceroute/DNS/Port-Scan) | ✅ live verifiziert | | — | LAN-Port-Konflikt-Prüfung + "Fertig"-Button (Einrichten) | 🔶 gebaut, Live-Test offen | Ausführlicher Stand inkl. aller gefundenen Bugs, offener Punkte und @@ -154,10 +170,12 @@ RouterOS/ │ │ │ ├── RouterOSMenuItem.swift — generisches Item für jeden RouterOS-Menüpfad │ │ │ ├── RouterOSSchema.swift / RouterOSSchemaCatalog.swift — kuratierte Formular-Schemas (Experte-Tab) │ │ │ ├── OverviewGraph.swift — Knoten/Kanten-Modell fürs Übersicht-Diagramm -│ │ │ ├── LanDevice.swift — ein Gerät im Geräte-Tab (Lease+ARP+Bridge-Host verschmolzen) +│ │ │ ├── LanDevice.swift — ein Gerät im LAN-Scanner-Tab (Lease+ARP+Bridge-Host verschmolzen) │ │ │ ├── SavedRouter.swift — ein Eintrag in "Bekannte Router" (Host/Name/Standort) │ │ │ ├── PortConflict.swift — LAN-Port-Konflikt-Prüfung (Bridge/Adresse/DHCP-Client/PPPoE) │ │ │ ├── InterfaceTraffic.swift — ein Live-Durchsatz-Sample für die Traffic-Anzeige +│ │ │ ├── NetworkToolResult.swift — Rohausgabe von Ping/Traceroute/DNS-Auflösung +│ │ │ ├── PortScanResult.swift — Ergebnis eines Port-Scans, ein Eintrag pro Port │ │ │ └── RouterOSModels.swift — Credentials, DeviceInfo, RouterBoardInfo, PackageUpdateInfo, Fehler │ │ ├── Networking/ │ │ │ ├── RouterOSTransport.swift — Protocol: connect/fetch.../apply/disconnect @@ -172,14 +190,16 @@ RouterOS/ │ │ ├── UpdateService.swift — Software-/Firmware-Update, Neustart │ │ ├── KeychainService.swift — Passwort-Speicherung │ │ ├── SavedRoutersStore.swift — "Bekannte Router"-Liste (JSON in UserDefaults) -│ │ └── InterfaceTrafficMonitor.swift — pollt Live-Durchsatz über eigene SSH-Verbindung +│ │ ├── InterfaceTrafficMonitor.swift — pollt Live-Durchsatz über eigene SSH-Verbindung +│ │ ├── NetworkToolsService.swift — Ping/Traceroute/DNS-Auflösung über eigene SSH-Verbindung +│ │ └── PortScanner.swift — TCP-Port-Scan direkt von diesem Mac (Network.framework) │ ├── Core/Localization/ │ │ └── L10n.swift — DE/EN-Übersetzungshelfer (Dictionary-Lookup, kein String Catalog) │ ├── Features/ │ │ ├── Wizard/Steps/Connect/ — Verbinden-Tab │ │ ├── Wizard/Steps/Setup/ — Einrichten-Tab (Mode→Wan→Lan→Vlan→Wifi→Firewall→Review) │ │ ├── Overview/ — Übersicht-Tab -│ │ ├── Devices/ — Geräte-Tab +│ │ ├── Devices/ — LAN-Scanner-Tab │ │ ├── Expert/ — Experte-Tab │ │ └── Backup/ — Sicherungen-Tab │ └── Resources/Assets.xcassets/ — App-Icon diff --git a/RouterOSAssistant/App/RouterOSAssistantApp.swift b/RouterOSAssistant/App/RouterOSAssistantApp.swift index 8b53257..8f45124 100644 --- a/RouterOSAssistant/App/RouterOSAssistantApp.swift +++ b/RouterOSAssistant/App/RouterOSAssistantApp.swift @@ -20,7 +20,7 @@ struct RouterOSAssistantApp: App { OverviewView(connectionService: connectionService) .tabItem { Label(L10n.t("Übersicht", appLanguage), systemImage: "point.3.connected.trianglepath.dotted") } DevicesView(connectionService: connectionService) - .tabItem { Label(L10n.t("Geräte", appLanguage), systemImage: "laptopcomputer.and.iphone") } + .tabItem { Label(L10n.t("LAN-Scanner", appLanguage), systemImage: "laptopcomputer.and.iphone") } ExpertView(connectionService: connectionService) .tabItem { Label(L10n.t("Experte", appLanguage), systemImage: "wrench.and.screwdriver") } BackupListView(connectionService: connectionService) @@ -31,7 +31,9 @@ struct RouterOSAssistantApp: App { Button { appLanguage = (appLanguage == "de") ? "en" : "de" } label: { - Text(appLanguage == "de" ? "EN" : "DE") + // Flag of the language a tap switches TO — matches the "DE"/"EN" text + // labels this replaces, which showed the same "switch to" target. + Text(appLanguage == "de" ? "🇬🇧" : "🇩🇪") } .help(appLanguage == "de" ? "Switch to English" : "Auf Deutsch umschalten") } diff --git a/RouterOSAssistant/Core/Localization/L10n.swift b/RouterOSAssistant/Core/Localization/L10n.swift index 4795baa..8cba17d 100644 --- a/RouterOSAssistant/Core/Localization/L10n.swift +++ b/RouterOSAssistant/Core/Localization/L10n.swift @@ -18,7 +18,7 @@ enum L10n { "Verbinden": "Connect", "Einrichten": "Setup", "Übersicht": "Topology", - "Geräte": "Devices", + "LAN-Scanner": "LAN Scanner", "Experte": "Expert", "Sicherungen": "Backups", "Firewall: Filter-Regeln": "Firewall: Filter Rules", diff --git a/RouterOSAssistant/Core/Models/NetworkToolResult.swift b/RouterOSAssistant/Core/Models/NetworkToolResult.swift new file mode 100644 index 0000000..d3bf027 --- /dev/null +++ b/RouterOSAssistant/Core/Models/NetworkToolResult.swift @@ -0,0 +1,13 @@ +import Foundation + +/// Raw output of an on-demand network diagnostic (ping/traceroute/DNS lookup) run from the +/// router against a chosen LAN device — see `NetworkToolsService`. Shown verbatim rather than +/// parsed into a structured UI: RouterOS' exact output format for these tools isn't verified +/// against live hardware yet (same caution this app applies elsewhere after finding real +/// formatting surprises in `/interface print`/`monitor-traffic` output), so showing the raw text +/// is both safer and — until confirmed otherwise — the only honest option. +struct NetworkToolResult: Identifiable { + let id = UUID() + let title: String + let output: String +} diff --git a/RouterOSAssistant/Core/Models/PortScanResult.swift b/RouterOSAssistant/Core/Models/PortScanResult.swift new file mode 100644 index 0000000..aee3e7f --- /dev/null +++ b/RouterOSAssistant/Core/Models/PortScanResult.swift @@ -0,0 +1,16 @@ +import Foundation + +/// Result of a `PortScanner.scan(...)` run against one LAN device, ready for display — +/// `DevicesView` colors each entry red (open), green (closed), or grey (unreachable/no response). +struct PortScanResult: Identifiable { + struct Entry: Identifiable { + var id: Int { port } + let port: Int + let serviceName: String? + let status: PortScanner.PortStatus + } + + let id = UUID() + let deviceLabel: String + let entries: [Entry] +} diff --git a/RouterOSAssistant/Core/Networking/SSHTransport.swift b/RouterOSAssistant/Core/Networking/SSHTransport.swift index 1a78dda..176bf8f 100644 --- a/RouterOSAssistant/Core/Networking/SSHTransport.swift +++ b/RouterOSAssistant/Core/Networking/SSHTransport.swift @@ -55,6 +55,17 @@ final class SSHTransport: RouterOSTransport { /// Not exposed over REST: this is a CLI-only command with no documented REST equivalent, so /// callers needing it (see `InterfaceTrafficMonitor`) always use a dedicated SSH connection, /// same reasoning as `BackupService`/`UpdateService`. + /// Runs an arbitrary RouterOS CLI command and returns its raw text output — the one + /// deliberate escape hatch out of the otherwise-private `run(_:)`, for `NetworkToolsService`'s + /// on-demand diagnostics (ping/traceroute/DNS lookup have no menu-item/REST shape to go + /// through the generic `fetchMenuItems` machinery). Callers are responsible for sanitizing any + /// untrusted value (e.g. a DHCP-supplied hostname) before interpolating it into `command` — + /// RouterOS' console treats ";" as a command separator, so an unsanitized value could inject + /// a second command. + func runDiagnosticCommand(_ command: String) async throws -> String { + try await run(command) + } + func fetchInterfaceTraffic(interfaceName: String) async throws -> InterfaceTraffic { let output = try await run("/interface monitor-traffic \(interfaceName) once") let fields = RouterOSCliParser.parseSingletonItem(output).fields diff --git a/RouterOSAssistant/Core/Services/NetworkToolsService.swift b/RouterOSAssistant/Core/Services/NetworkToolsService.swift new file mode 100644 index 0000000..6830d9d --- /dev/null +++ b/RouterOSAssistant/Core/Services/NetworkToolsService.swift @@ -0,0 +1,67 @@ +import Foundation + +/// On-demand network diagnostics run FROM the router against a chosen LAN device — "is this +/// device actually reachable from the router's point of view", the natural diagnostic angle for +/// an app centered on the router rather than on this Mac. RouterOS' diagnostic tools (`/ping`, +/// `/tool traceroute`, `/resolve`) are CLI-only with no REST equivalent, so — same reasoning as +/// `BackupService`/`UpdateService`/`InterfaceTrafficMonitor` — this always opens its own dedicated +/// SSH connection. Unlike `InterfaceTrafficMonitor`, a fresh connection per call (not kept open +/// between calls): these are occasional, user-triggered one-shot actions from a right-click menu, +/// not continuous polling. +struct NetworkToolsService { + enum ToolError: LocalizedError { + case unsafeInput(String) + + var errorDescription: String? { + switch self { + case .unsafeInput(let value): + return "\"\(value)\" enthält Zeichen, die hier nicht sicher sind (nur Buchstaben, Ziffern, \".\", \"-\", \":\" erlaubt)." + } + } + } + + /// RouterOS' console treats ";" (and some other characters) as a command separator — an + /// address/hostname that ultimately comes from a DHCP lease is attacker-controllable (a rogue + /// device can request whatever hostname it likes), so it must never be interpolated into a + /// command string unchecked. IPv4/IPv6 addresses and valid DNS hostnames only ever use these + /// characters, so this is a safe allow-list, not an arbitrary restriction. + static func sanitized(_ value: String) throws -> String { + let allowed = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789.-:") + guard !value.isEmpty, value.unicodeScalars.allSatisfy(allowed.contains) else { + throw ToolError.unsafeInput(value) + } + return value + } + + func ping(address: String, count: Int = 4, for credentials: RouterOSCredentials) async throws -> String { + let safeAddress = try Self.sanitized(address) + return try await runOneShot("/ping \(safeAddress) count=\(count)", for: credentials) + } + + func traceroute(address: String, for credentials: RouterOSCredentials) async throws -> String { + let safeAddress = try Self.sanitized(address) + return try await runOneShot("/tool traceroute \(safeAddress) count=1 duration=10", for: credentials) + } + + /// RouterOS' own DNS resolution (via its configured DNS servers) for the device's DHCP- + /// advertised hostname — tells you whether the router itself can resolve the name it received + /// for this device, not a general internet nslookup. Not verified live yet whether `/resolve` + /// behaves identically over a plain SSH exec channel as in an interactive console session. + func resolve(hostname: String, for credentials: RouterOSCredentials) async throws -> String { + let safeHostname = try Self.sanitized(hostname) + return try await runOneShot("/resolve \(safeHostname)", for: credentials) + } + + private func runOneShot(_ command: String, for credentials: RouterOSCredentials) async throws -> String { + let transport = SSHTransport(credentials: credentials) + try await transport.connect() + do { + let output = try await transport.runDiagnosticCommand(command) + await transport.disconnect() + return output + } catch { + await transport.disconnect() + throw error + } + } +} diff --git a/RouterOSAssistant/Core/Services/PortScanner.swift b/RouterOSAssistant/Core/Services/PortScanner.swift new file mode 100644 index 0000000..e83a807 --- /dev/null +++ b/RouterOSAssistant/Core/Services/PortScanner.swift @@ -0,0 +1,125 @@ +import Foundation +import Network + +/// TCP port scan of one LAN device, run directly from this Mac (not through the router) — unlike +/// `NetworkToolsService`'s ping/traceroute/DNS lookup, RouterOS has no built-in port-scanning +/// command to route this through, and a port scan is naturally "can I, sitting on this network, +/// reach that port" rather than a router-perspective diagnostic anyway. Uses `Network.framework` +/// (`NWConnection`) — a plain TCP connect attempt per port, no raw sockets/root privileges needed. +enum PortScanner { + enum PortStatus: Equatable { + /// The connection was accepted — something is listening and responding. + case open + /// The connection was actively refused (TCP RST) — the host is reachable, but nothing is + /// listening on this port. + case closed + /// No response before the timeout — could mean a firewall silently drops the packet, the + /// host is off/unreachable, or the port is filtered. Deliberately not conflated with + /// "closed": a real refusal proves the host is there, a timeout proves nothing either way. + case unreachable + } + + struct PortInfo { + let port: Int + /// Common service name for well-known ports, shown alongside the number for readability — + /// nil for anything not in the curated list below. + let serviceName: String? + } + + /// A practical, TCP-only default set — common device/service ports worth checking on a LAN + /// device (web UIs, remote access, file sharing, printers, media/streaming). Not exhaustive; + /// this is a helpful quick check, not a security audit tool. + static let commonPorts: [PortInfo] = [ + PortInfo(port: 21, serviceName: "FTP"), + PortInfo(port: 22, serviceName: "SSH"), + PortInfo(port: 23, serviceName: "Telnet"), + PortInfo(port: 25, serviceName: "SMTP"), + PortInfo(port: 53, serviceName: "DNS"), + PortInfo(port: 80, serviceName: "HTTP"), + PortInfo(port: 110, serviceName: "POP3"), + PortInfo(port: 139, serviceName: "NetBIOS"), + PortInfo(port: 143, serviceName: "IMAP"), + PortInfo(port: 443, serviceName: "HTTPS"), + PortInfo(port: 445, serviceName: "SMB"), + PortInfo(port: 554, serviceName: "RTSP"), + PortInfo(port: 993, serviceName: "IMAPS"), + PortInfo(port: 995, serviceName: "POP3S"), + PortInfo(port: 3389, serviceName: "RDP"), + PortInfo(port: 5000, serviceName: "UPnP/AirPlay"), + PortInfo(port: 8080, serviceName: "HTTP-Alt"), + PortInfo(port: 8443, serviceName: "HTTPS-Alt"), + PortInfo(port: 9100, serviceName: "Drucker (JetDirect)") + ] + + /// Scans every port in `ports` concurrently and returns a status per port. `timeout` bounds + /// how long a non-responding (filtered/unreachable) port is waited on — the slowest possible + /// total run time, not the typical one, since open/closed ports usually resolve almost + /// immediately. + static func scan(host: String, ports: [PortInfo] = commonPorts, timeout: TimeInterval = 1.5) async -> [(port: PortInfo, status: PortStatus)] { + await withTaskGroup(of: (PortInfo, PortStatus).self) { group in + for portInfo in ports { + group.addTask { + let status = await scanOnePort(host: host, port: portInfo.port, timeout: timeout) + return (portInfo, status) + } + } + var results: [(PortInfo, PortStatus)] = [] + for await result in group { + results.append(result) + } + return results.sorted { $0.0.port < $1.0.port } + } + } + + private static func scanOnePort(host: String, port: Int, timeout: TimeInterval) async -> PortStatus { + guard let nwPort = NWEndpoint.Port(rawValue: UInt16(port)) else { return .unreachable } + + return await withCheckedContinuation { continuation in + let connection = NWConnection(host: NWEndpoint.Host(host), port: nwPort, using: .tcp) + let lock = NSLock() + var didResume = false + let resumeOnce: (PortStatus) -> Void = { status in + lock.lock() + defer { lock.unlock() } + guard !didResume else { return } + didResume = true + connection.cancel() + continuation.resume(returning: status) + } + + connection.stateUpdateHandler = { state in + switch state { + case .ready: + resumeOnce(.open) + case .failed(let error): + resumeOnce(Self.isConnectionRefused(error) ? .closed : .unreachable) + case .waiting(let error): + // A refused TCP connection surfaces here, not via `.failed` — confirmed live + // (2026-09-15): a definitely-closed loopback port kept reporting `.unreachable` + // because this case fell through the `default: break` and just sat until the + // timeout fired. `NWConnection` treats most `.waiting` reasons as transient/ + // retryable (e.g. no network path yet), which is correct to keep waiting on — + // but a refusal is conclusive, not transient, so it resolves immediately. + if Self.isConnectionRefused(error) { + resumeOnce(.closed) + } + default: + break + } + } + + connection.start(queue: .global(qos: .userInitiated)) + + DispatchQueue.global(qos: .userInitiated).asyncAfter(deadline: .now() + timeout) { + resumeOnce(.unreachable) + } + } + } + + private static func isConnectionRefused(_ error: NWError) -> Bool { + if case .posix(let code) = error, code == .ECONNREFUSED { + return true + } + return false + } +} diff --git a/RouterOSAssistant/Features/Devices/DevicesView.swift b/RouterOSAssistant/Features/Devices/DevicesView.swift index 40fd6c4..4e70f82 100644 --- a/RouterOSAssistant/Features/Devices/DevicesView.swift +++ b/RouterOSAssistant/Features/Devices/DevicesView.swift @@ -1,6 +1,6 @@ import SwiftUI -/// "Geräte" tab: LAN scanner — one table per physical Ethernet/WLAN port, each listing the +/// "LAN-Scanner" tab: one table per physical Ethernet/WLAN port, each listing the /// devices resolved onto it (name/IP/MAC/status), plus right-click actions to give a device a /// permanent static IP (Winbox's "Make Static") or remove that reservation again. struct DevicesView: View { @@ -69,7 +69,19 @@ struct DevicesView: View { .formStyle(.grouped) } } - .navigationTitle("Geräte") + .overlay(alignment: .bottom) { + if viewModel.isRunningNetworkTool { + HStack(spacing: 6) { + ProgressView().controlSize(.small) + Text("Führe Netzwerk-Test aus…").font(.caption) + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(.regularMaterial, in: Capsule()) + .padding(.bottom, 12) + } + } + .navigationTitle("LAN-Scanner") .toolbar { ToolbarItem { Button { @@ -78,9 +90,14 @@ struct DevicesView: View { if viewModel.isLoading { ProgressView().controlSize(.small) } else { - Label("Aktualisieren", systemImage: "arrow.clockwise") + Label("Neu scannen", systemImage: "arrow.clockwise") } } + // Per explicit request: a plain icon+text toolbar button up in the corner got + // overlooked ("wird übersehen") — `.borderedProminent` gives it a filled, + // colored background so it visually stands out from the window chrome instead + // of blending into it. + .buttonStyle(.borderedProminent) .disabled(viewModel.isLoading) } } @@ -89,9 +106,87 @@ struct DevicesView: View { await viewModel.load() } } + .withStaticAssignmentDialogs( + viewModel: viewModel, + showStaticConfirmation: $showStaticConfirmation, + showRemovalConfirmation: $showRemovalConfirmation + ) + .withDeviceDetailSheets(viewModel: viewModel, rawFieldsDevice: $rawFieldsDevice) + } + } + + @ViewBuilder + private func deviceMenu(for device: LanDevice) -> some View { + if device.hasLease { + if device.isStatic { + Button { + viewModel.pendingStaticRemoval = device + showRemovalConfirmation = true + } label: { + Label("Feste Zuweisung entfernen", systemImage: "pin.slash") + } + } else { + Button { + viewModel.pendingStaticAssignment = device + showStaticConfirmation = true + } label: { + Label("Feste IP zuweisen", systemImage: "pin.fill") + } + } + } else { + Text("Kein DHCP-Lease — feste Zuweisung hier nicht möglich") + } + Divider() + Menu { + Button { + viewModel.runPing(for: device) + } label: { + Label("Ping", systemImage: "dot.radiowaves.left.and.right") + } + Button { + viewModel.runTraceroute(for: device) + } label: { + Label("Traceroute", systemImage: "point.topleft.down.curvedto.point.bottomright.up") + } + if let hostName = device.hostName, !hostName.isEmpty { + Button { + viewModel.runDnsLookup(for: device) + } label: { + Label("DNS-Auflösung (nslookup)", systemImage: "magnifyingglass") + } + } + Divider() + Button { + viewModel.runPortScan(for: device) + } label: { + Label("Port-Scan", systemImage: "list.bullet.rectangle.portrait") + } + } label: { + Label("Netzwerk-Tools", systemImage: "stethoscope") + } + .disabled(viewModel.isRunningNetworkTool) + Divider() + Button { + rawFieldsDevice = device + } label: { + Label("Rohdaten anzeigen", systemImage: "list.bullet.rectangle") + } + } +} + +/// Split out of `body` — same reasoning as `ConnectView`'s dialog-splitting extensions (Bug 19 in +/// HANDOFF.md): too many `.alert`/`.confirmationDialog`/`.sheet` modifiers chained onto one view +/// times out the Swift type-checker with a misleading error location, not an actual logic issue. +private extension View { + func withStaticAssignmentDialogs( + viewModel: DevicesViewModel, + showStaticConfirmation: Binding, + showRemovalConfirmation: Binding + ) -> some View { + self .confirmationDialog( "Feste IP-Adresse zuweisen?", - isPresented: $showStaticConfirmation, + isPresented: showStaticConfirmation, titleVisibility: .visible ) { Button("Zuweisen") { @@ -104,13 +199,17 @@ struct DevicesView: View { "\(device.ipAddress) wird dauerhaft an \(device.macAddress)" + (device.hostName.map { " (\($0))" } ?? "") + " gebunden — die Adresse ändert sich für dieses Gerät nicht mehr automatisch." + + "\n\nRückgängig machen: hier im LAN-Scanner-Tab per Rechtsklick auf das Gerät" + + " → \"Feste Zuweisung entfernen\". Das Gerät bekommt danach aber nicht sofort" + + " automatisch eine neue Adresse — dafür muss es die Verbindung kurz trennen und" + + " neu aufbauen (Netzwerkkabel kurz ziehen/stecken, WLAN aus/an, oder neu starten)." + "\n\nBefehl: \(viewModel.pendingCommand?.cliLine ?? "")" ) } } .confirmationDialog( "Feste Zuweisung entfernen?", - isPresented: $showRemovalConfirmation, + isPresented: showRemovalConfirmation, titleVisibility: .visible ) { Button("Entfernen", role: .destructive) { @@ -142,39 +241,37 @@ struct DevicesView: View { } message: { error in Text(error) } - .sheet(item: $rawFieldsDevice) { device in - RawFieldsSheet(device: device) { rawFieldsDevice = nil } - } - } } - @ViewBuilder - private func deviceMenu(for device: LanDevice) -> some View { - if device.hasLease { - if device.isStatic { - Button { - viewModel.pendingStaticRemoval = device - showRemovalConfirmation = true - } label: { - Label("Feste Zuweisung entfernen", systemImage: "pin.slash") - } - } else { - Button { - viewModel.pendingStaticAssignment = device - showStaticConfirmation = true - } label: { - Label("Feste IP zuweisen", systemImage: "pin.fill") - } + func withDeviceDetailSheets(viewModel: DevicesViewModel, rawFieldsDevice: Binding) -> some View { + self + .sheet(item: rawFieldsDevice) { device in + RawFieldsSheet(device: device) { rawFieldsDevice.wrappedValue = nil } + } + .sheet(item: Binding( + get: { viewModel.networkToolResult }, + set: { if $0 == nil { viewModel.networkToolResult = nil } } + )) { result in + NetworkToolResultSheet(result: result) { viewModel.networkToolResult = nil } + } + .sheet(item: Binding( + get: { viewModel.portScanResult }, + set: { if $0 == nil { viewModel.portScanResult = nil } } + )) { result in + PortScanResultSheet(result: result) { viewModel.portScanResult = nil } + } + .alert( + "Netzwerk-Test fehlgeschlagen", + isPresented: Binding( + get: { viewModel.networkToolError != nil }, + set: { if !$0 { viewModel.dismissNetworkToolError() } } + ), + presenting: viewModel.networkToolError + ) { _ in + Button("OK") {} + } message: { error in + Text(error) } - } else { - Text("Kein DHCP-Lease — feste Zuweisung hier nicht möglich") - } - Divider() - Button { - rawFieldsDevice = device - } label: { - Label("Rohdaten anzeigen", systemImage: "list.bullet.rectangle") - } } } @@ -284,6 +381,108 @@ private struct RawFieldsSheet: View { } } +/// Raw text output of a ping/traceroute/DNS-Auflösung run against a device — see +/// `NetworkToolResult`'s doc comment for why this isn't parsed into a nicer structured view. +private struct NetworkToolResultSheet: View { + let result: NetworkToolResult + let onClose: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Text(result.title) + .font(.headline) + Text("Ausgeführt vom Router aus (eigene SSH-Verbindung) — testet die Erreichbarkeit vom Router zu diesem Gerät, nicht von diesem Mac.") + .font(.caption) + .foregroundStyle(.secondary) + Divider() + ScrollView { + Text(result.output.isEmpty ? "(keine Ausgabe)" : result.output) + .font(.system(.caption, design: .monospaced)) + .frame(maxWidth: .infinity, alignment: .leading) + .textSelection(.enabled) + } + HStack { + Spacer() + Button("Schließen") { onClose() } + } + } + .padding() + .frame(minWidth: 420, minHeight: 320) + } +} + +/// Colored per-port result of a `PortScanner` run — red (open), green (closed), grey +/// (unreachable/no response), per explicit request. Run directly from this Mac, not the router +/// (see `PortScanner`'s doc comment), so the caption here says so — different from +/// `NetworkToolResultSheet`'s "run from the router" caption right above it in this same file. +private struct PortScanResultSheet: View { + let result: PortScanResult + let onClose: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Text("Port-Scan: \(result.deviceLabel)") + .font(.headline) + Text("TCP-Verbindungsversuch auf gängige Ports, ausgeführt von diesem Mac aus (nicht vom Router) — rot = offen, grün = geschlossen (Gerät antwortet, aber nichts lauscht dort), grau = keine Antwort (Firewall, Gerät aus, oder Port gefiltert).") + .font(.caption) + .foregroundStyle(.secondary) + Divider() + ScrollView { + VStack(spacing: 0) { + ForEach(Array(result.entries.enumerated()), id: \.element.id) { index, entry in + if index > 0 { Divider() } + PortScanRow(entry: entry) + } + } + } + HStack { + Spacer() + Button("Schließen") { onClose() } + } + } + .padding() + .frame(minWidth: 420, minHeight: 420) + } +} + +private struct PortScanRow: View { + let entry: PortScanResult.Entry + + private var color: Color { + switch entry.status { + case .open: return .red + case .closed: return .green + case .unreachable: return .secondary + } + } + + private var statusText: String { + switch entry.status { + case .open: return "Offen" + case .closed: return "Geschlossen" + case .unreachable: return "Keine Antwort" + } + } + + var body: some View { + HStack { + Circle().fill(color).frame(width: 8, height: 8) + Text("\(entry.port)") + .font(.system(.caption, design: .monospaced)) + .frame(width: 50, alignment: .leading) + Text(entry.serviceName ?? "-") + .font(.caption) + .foregroundStyle(.secondary) + .frame(width: 140, alignment: .leading) + Spacer() + Text(statusText) + .font(.caption) + .foregroundStyle(color) + } + .padding(.vertical, 3) + } +} + #Preview { DevicesView(connectionService: ConnectionService()) } diff --git a/RouterOSAssistant/Features/Devices/DevicesViewModel.swift b/RouterOSAssistant/Features/Devices/DevicesViewModel.swift index 11e0f1f..997e285 100644 --- a/RouterOSAssistant/Features/Devices/DevicesViewModel.swift +++ b/RouterOSAssistant/Features/Devices/DevicesViewModel.swift @@ -22,12 +22,84 @@ final class DevicesViewModel: ObservableObject { @Published private(set) var isApplying = false @Published private(set) var applyError: String? + /// Set once a ping/traceroute/DNS lookup finishes — `.sheet(item:)` in `DevicesView` shows its + /// raw output. `nil` in between runs and while one is in progress. + @Published var networkToolResult: NetworkToolResult? + @Published private(set) var isRunningNetworkTool = false + @Published var networkToolError: String? + /// Set once a port scan finishes — separate from `networkToolResult` since it needs a + /// structured (colored per-port) display, not raw text. Runs directly from this Mac via + /// `PortScanner`, not through the router — see its own doc comment for why. + @Published var portScanResult: PortScanResult? + private let connectionService: ConnectionService private let backupService: BackupService + private let networkToolsService: NetworkToolsService - init(connectionService: ConnectionService, backupService: BackupService = BackupService()) { + init( + connectionService: ConnectionService, + backupService: BackupService = BackupService(), + networkToolsService: NetworkToolsService = NetworkToolsService() + ) { self.connectionService = connectionService self.backupService = backupService + self.networkToolsService = networkToolsService + } + + func runPing(for device: LanDevice) { + runNetworkTool(title: "Ping: \(device.ipAddress)") { [networkToolsService] credentials in + try await networkToolsService.ping(address: device.ipAddress, for: credentials) + } + } + + func runTraceroute(for device: LanDevice) { + runNetworkTool(title: "Traceroute: \(device.ipAddress)") { [networkToolsService] credentials in + try await networkToolsService.traceroute(address: device.ipAddress, for: credentials) + } + } + + func runDnsLookup(for device: LanDevice) { + guard let hostName = device.hostName, !hostName.isEmpty else { return } + runNetworkTool(title: "DNS-Auflösung: \(hostName)") { [networkToolsService] credentials in + try await networkToolsService.resolve(hostname: hostName, for: credentials) + } + } + + func dismissNetworkToolError() { + networkToolError = nil + } + + /// Runs directly from this Mac (no router credentials involved), so it doesn't go through + /// `runNetworkTool(title:action:)` — but still respects the same `isRunningNetworkTool` busy + /// flag so a scan and a ping/traceroute/DNS lookup can't overlap and race on the same overlay. + func runPortScan(for device: LanDevice) { + guard !isRunningNetworkTool else { return } + isRunningNetworkTool = true + networkToolError = nil + let label = (device.hostName?.isEmpty == false ? "\(device.hostName!) (\(device.ipAddress))" : device.ipAddress) + Task { + let results = await PortScanner.scan(host: device.ipAddress) + portScanResult = PortScanResult( + deviceLabel: label, + entries: results.map { PortScanResult.Entry(port: $0.port.port, serviceName: $0.port.serviceName, status: $0.status) } + ) + isRunningNetworkTool = false + } + } + + private func runNetworkTool(title: String, action: @escaping (RouterOSCredentials) async throws -> String) { + guard let credentials = connectionService.credentials, !isRunningNetworkTool else { return } + isRunningNetworkTool = true + networkToolError = nil + Task { + do { + let output = try await action(credentials) + networkToolResult = NetworkToolResult(title: title, output: output) + } catch { + networkToolError = error.localizedDescription + } + isRunningNetworkTool = false + } } func load() async { diff --git a/RouterOSAssistantTests/NetworkToolsServiceTests.swift b/RouterOSAssistantTests/NetworkToolsServiceTests.swift new file mode 100644 index 0000000..d87472d --- /dev/null +++ b/RouterOSAssistantTests/NetworkToolsServiceTests.swift @@ -0,0 +1,34 @@ +import XCTest +@testable import RouterOSAssistant + +/// A LAN device's IP/hostname ultimately comes from its own DHCP request — attacker-controllable +/// by a rogue device on the network — and gets interpolated into a RouterOS console command +/// string for ping/traceroute/resolve. RouterOS' console treats ";" as a command separator, so an +/// unsanitized value could inject a second command; these tests lock in the allow-list that +/// prevents that. +final class NetworkToolsServiceTests: XCTestCase { + func testAcceptsValidIPv4Address() throws { + XCTAssertEqual(try NetworkToolsService.sanitized("192.168.88.10"), "192.168.88.10") + } + + func testAcceptsValidIPv6Address() throws { + XCTAssertEqual(try NetworkToolsService.sanitized("fe80::1"), "fe80::1") + } + + func testAcceptsValidHostname() throws { + XCTAssertEqual(try NetworkToolsService.sanitized("my-laptop.local"), "my-laptop.local") + } + + func testRejectsCommandInjectionViaSemicolon() { + XCTAssertThrowsError(try NetworkToolsService.sanitized("8.8.8.8; /system reset-configuration")) + } + + func testRejectsWhitespaceAndQuotes() { + XCTAssertThrowsError(try NetworkToolsService.sanitized("8.8.8.8 count=1000000")) + XCTAssertThrowsError(try NetworkToolsService.sanitized("\"; malicious")) + } + + func testRejectsEmptyValue() { + XCTAssertThrowsError(try NetworkToolsService.sanitized("")) + } +} diff --git a/RouterOSAssistantTests/PortScannerTests.swift b/RouterOSAssistantTests/PortScannerTests.swift new file mode 100644 index 0000000..b549771 --- /dev/null +++ b/RouterOSAssistantTests/PortScannerTests.swift @@ -0,0 +1,37 @@ +import XCTest +@testable import RouterOSAssistant + +final class PortScannerTests: XCTestCase { + func testCommonPortsHaveNoDuplicates() { + let ports = PortScanner.commonPorts.map(\.port) + XCTAssertEqual(ports.count, Set(ports).count) + } + + func testCommonPortsAreAllValidTCPPorts() { + for info in PortScanner.commonPorts { + XCTAssertTrue((1...65535).contains(info.port), "Port \(info.port) out of valid range") + } + } + + /// A high, essentially-never-bound port on loopback reliably gets an immediate TCP RST + /// (connection refused) rather than a timeout, so this is fast and deterministic — not + /// dependent on any real network/router. + func testScanReportsClosedForRefusedLoopbackPort() async { + let results = await PortScanner.scan( + host: "127.0.0.1", + ports: [PortScanner.PortInfo(port: 59_999, serviceName: nil)], + timeout: 2 + ) + XCTAssertEqual(results.count, 1) + XCTAssertEqual(results.first?.status, .closed) + } + + func testScanSortsResultsByPortNumber() async { + let ports = [ + PortScanner.PortInfo(port: 59_998, serviceName: nil), + PortScanner.PortInfo(port: 59_997, serviceName: nil) + ] + let results = await PortScanner.scan(host: "127.0.0.1", ports: ports, timeout: 2) + XCTAssertEqual(results.map(\.port.port), [59_997, 59_998]) + } +}