M20: LAN-Scanner umbenannt + Netzwerk-Tools (Ping/Traceroute/DNS/Port-Scan)

Tab "Geräte" -> "LAN-Scanner", Refresh-Button "Neu scannen" + prominenter
Stil. Neues Netzwerk-Tools-Menü: Ping/Traceroute/DNS-Auflösung über
NetworkToolsService (eigene SSH-Verbindung, wie Backup/InterfaceTraffic-
Monitor), mit Zeichen-Validierung gegen Command-Injection ueber einen
boeswilligen DHCP-Hostnamen. Port-Scan laeuft direkt von diesem Mac ueber
Network.framework (RouterOS hat kein eingebautes Portscan-Tool) - dabei
einen echten NWConnection-Bug gefunden (verweigerte Verbindung meldet sich
ueber .waiting, nicht .failed) und per Unit-Test gegen einen Loopback-Port
aufgedeckt und gefixt.

Zusaetzlich: Warnhinweis bei "Feste IP zuweisen" erklaert jetzt den
Rueckweg. DE/EN-Umschalter zeigt Landesflaggen statt Text.

92 Tests gruen. HANDOFF.md/README.md (inkl. Mermaid-Diagramm)/Manual.md/
CHATLOG.md aktualisiert.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YDmUd93KxsYGr2kLTotWnG
This commit is contained in:
Kay
2026-09-15 22:41:23 +02:00
co-authored by Claude Sonnet 5
parent b5933fa44b
commit 04325e3bbd
15 changed files with 801 additions and 64 deletions
+48
View File
@@ -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.
+65
View File
@@ -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~~
+40 -12
View File
@@ -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
+33 -13
View File
@@ -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<br/>(Backup/Restore/Werksreset/Update)" .-> SSH2["SSHTransport"]
Connect -. "eigene SSH-Verbindung<br/>(InterfaceTrafficMonitor, 3s-Polling)" .-> SSH3["SSHTransport"]
Devices -. "eigene SSH-Verbindung<br/>(NetworkToolsService: Ping/Traceroute/DNS)" .-> SSH4["SSHTransport"]
Devices -. "direkte TCP-Verbindung<br/>(PortScanner, nicht über den Router)" .-> LANDevice[("Gerät im LAN")]
Connect --> Saved["SavedRoutersStore<br/>(Bekannte Router)"]
Saved --> Defaults[("UserDefaults<br/>(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
@@ -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")
}
@@ -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",
@@ -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
}
@@ -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]
}
@@ -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
@@ -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
}
}
}
@@ -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
}
}
@@ -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<Bool>,
showRemovalConfirmation: Binding<Bool>
) -> 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,38 +241,36 @@ 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")
func withDeviceDetailSheets(viewModel: DevicesViewModel, rawFieldsDevice: Binding<LanDevice?>) -> some View {
self
.sheet(item: rawFieldsDevice) { device in
RawFieldsSheet(device: device) { rawFieldsDevice.wrappedValue = nil }
}
} else {
Button {
viewModel.pendingStaticAssignment = device
showStaticConfirmation = true
} label: {
Label("Feste IP zuweisen", systemImage: "pin.fill")
.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 }
}
} else {
Text("Kein DHCP-Lease — feste Zuweisung hier nicht möglich")
}
Divider()
Button {
rawFieldsDevice = device
} label: {
Label("Rohdaten anzeigen", systemImage: "list.bullet.rectangle")
.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)
}
}
}
@@ -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())
}
@@ -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 {
@@ -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(""))
}
}
@@ -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])
}
}