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])
+ }
+}