forked from kay/RouterOS
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:
+48
@@ -946,3 +946,51 @@ Test, WLAN/Bonding/PPPoE-Live-Tests).
|
|||||||
CHATLOG.md aktualisiert, Commit + Push (inkl. der noch nicht live
|
CHATLOG.md aktualisiert, Commit + Push (inkl. der noch nicht live
|
||||||
getesteten Port-Konflikt-Prüfung + Fertig-Button, auf ausdrücklichen
|
getesteten Port-Konflikt-Prüfung + Fertig-Button, auf ausdrücklichen
|
||||||
Nutzerwunsch mitgespeichert).
|
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
@@ -1204,6 +1204,71 @@ grün, wartet auf Test durch den Nutzer in Xcode):
|
|||||||
("das vierschieben funktioniert super, die Linien folgen auch" /
|
("das vierschieben funktioniert super, die Linien folgen auch" /
|
||||||
"ok, der button ist da und funktioniert" / "funktioniert").
|
"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
|
## Nächste Schritte
|
||||||
|
|
||||||
1. ~~M16: restliche `RouterOSSchemaCatalog.swift`-Sektionen übersetzen~~
|
1. ~~M16: restliche `RouterOSSchemaCatalog.swift`-Sektionen übersetzen~~
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ those.
|
|||||||
2. [Verbinden](#1-verbinden)
|
2. [Verbinden](#1-verbinden)
|
||||||
3. [Einrichten (Wizard)](#2-einrichten-wizard)
|
3. [Einrichten (Wizard)](#2-einrichten-wizard)
|
||||||
4. [Übersicht](#3-übersicht)
|
4. [Übersicht](#3-übersicht)
|
||||||
5. [Geräte](#4-geräte)
|
5. [LAN-Scanner](#4-lan-scanner)
|
||||||
6. [Experte](#5-experte)
|
6. [Experte](#5-experte)
|
||||||
7. [Sicherungen](#6-sicherungen)
|
7. [Sicherungen](#6-sicherungen)
|
||||||
|
|
||||||
@@ -37,7 +37,7 @@ those.
|
|||||||
|
|
||||||
RouterOS Assistant ist eine native macOS-App, die Mikrotik-RouterOS-Router
|
RouterOS Assistant ist eine native macOS-App, die Mikrotik-RouterOS-Router
|
||||||
per geführtem Wizard einrichtet und über fünf Tabs verwaltet: Verbinden,
|
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
|
dem Router per REST-API (bevorzugt) oder SSH (Fallback) — beides wird
|
||||||
automatisch gewählt, ohne dass du dich darum kümmern musst.
|
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
|
"Zurücksetzen"-Button in der Toolbar stellt die ursprüngliche
|
||||||
Spalten-Anordnung wieder her.
|
Spalten-Anordnung wieder her.
|
||||||
|
|
||||||
## 4. Geräte
|
## 4. LAN-Scanner
|
||||||
|
|
||||||
LAN-Scanner: zeigt alle Geräte im Netzwerk (aus DHCP-Leases und ARP-
|
Zeigt alle Geräte im Netzwerk (aus DHCP-Leases und ARP-Tabelle),
|
||||||
Tabelle), gruppiert nach physischem Port.
|
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
|
- Rechtsklick auf ein Gerät mit dynamischer Adresse → "Feste IP
|
||||||
zuweisen" (macht die aktuelle Adresse dauerhaft, RouterOS' "Make
|
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
|
- Rechtsklick auf ein Gerät mit fester Adresse → "Feste Zuweisung
|
||||||
entfernen" (Rückweg zu dynamisch — Gerät muss danach kurz die
|
entfernen" (Rückweg zu dynamisch — Gerät muss danach kurz die
|
||||||
Verbindung trennen/neu verbinden, um eine neue Adresse zu bekommen).
|
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
|
## 5. Experte
|
||||||
|
|
||||||
@@ -180,7 +194,7 @@ Tooltips und Warnhinweisen zu jedem Feld:
|
|||||||
2. [Connect](#1-connect)
|
2. [Connect](#1-connect)
|
||||||
3. [Setup Wizard](#2-setup-wizard)
|
3. [Setup Wizard](#2-setup-wizard)
|
||||||
4. [Topology](#3-topology)
|
4. [Topology](#3-topology)
|
||||||
5. [Devices](#4-devices)
|
5. [LAN Scanner](#4-lan-scanner)
|
||||||
6. [Expert](#5-expert)
|
6. [Expert](#5-expert)
|
||||||
7. [Backups](#6-backups)
|
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
|
RouterOS Assistant is a native macOS app that sets up and manages
|
||||||
MikroTik RouterOS routers through a guided wizard and five tabs:
|
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
|
router over its REST API (preferred) or SSH (fallback) — chosen
|
||||||
automatically, no configuration needed.
|
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
|
it) — connection lines follow live. A "Reset" button in the toolbar
|
||||||
restores the original column layout.
|
restores the original column layout.
|
||||||
|
|
||||||
## 4. Devices
|
## 4. LAN Scanner
|
||||||
|
|
||||||
LAN scanner: shows every device on the network (from DHCP leases and the
|
Shows every device on the network (from DHCP leases and the ARP table),
|
||||||
ARP table), grouped by physical port.
|
grouped by physical port. "Rescan" button re-queries leases/ARP/bridge
|
||||||
|
host table.
|
||||||
|
|
||||||
- Right-click a device with a dynamic address → "Assign static IP"
|
- 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
|
- Right-click a device with a static address → "Remove static
|
||||||
assignment" (back to dynamic — the device needs to briefly
|
assignment" (back to dynamic — the device needs to briefly
|
||||||
disconnect/reconnect afterwards to get a fresh address).
|
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
|
## 5. Expert
|
||||||
|
|
||||||
|
|||||||
@@ -47,9 +47,15 @@ Befehl, der ausgeführt wird — nichts passiert unangekündigt.
|
|||||||
zurückschreiben. Verbindungslinien laufen animiert in Flussrichtung
|
zurückschreiben. Verbindungslinien laufen animiert in Flussrichtung
|
||||||
("von → nach"); Kästchen lassen sich frei verschieben (Linien folgen
|
("von → nach"); Kästchen lassen sich frei verschieben (Linien folgen
|
||||||
live mit), ein Button setzt die Anordnung zurück.
|
live mit), ein Button setzt die Anordnung zurück.
|
||||||
- **Geräte** — LAN-Scanner (DHCP-Leases + ARP + Bridge-Host-Tabelle),
|
- **LAN-Scanner** (vormals "Geräte") — DHCP-Leases + ARP + Bridge-Host-
|
||||||
eine Tabelle pro physischem Port. Rechtsklick weist einem Gerät eine
|
Tabelle, eine Tabelle pro physischem Port. Rechtsklick weist einem Gerät
|
||||||
feste IP-Adresse zu (RouterOS' "Make Static") oder entfernt sie wieder.
|
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
|
- **Experte** — kuratierte Formulare (mit Tooltips/Warnhinweisen) für
|
||||||
die wichtigsten RouterOS-Bereiche (Firewall/NAT/Mangle/Raw komplett,
|
die wichtigsten RouterOS-Bereiche (Firewall/NAT/Mangle/Raw komplett,
|
||||||
plus Interfaces/IP/VPN/WLAN/Queues/System/Werkzeuge), plus ein
|
plus Interfaces/IP/VPN/WLAN/Queues/System/Werkzeuge), plus ein
|
||||||
@@ -72,7 +78,7 @@ flowchart TD
|
|||||||
Connect["Verbinden"]
|
Connect["Verbinden"]
|
||||||
Setup["Einrichten-Wizard"]
|
Setup["Einrichten-Wizard"]
|
||||||
Overview["Übersicht"]
|
Overview["Übersicht"]
|
||||||
Devices["Geräte"]
|
Devices["LAN-Scanner"]
|
||||||
Expert["Experte"]
|
Expert["Experte"]
|
||||||
Backup["Sicherungen"]
|
Backup["Sicherungen"]
|
||||||
end
|
end
|
||||||
@@ -89,6 +95,8 @@ flowchart TD
|
|||||||
|
|
||||||
Backup -. "eigene SSH-Verbindung<br/>(Backup/Restore/Werksreset/Update)" .-> SSH2["SSHTransport"]
|
Backup -. "eigene SSH-Verbindung<br/>(Backup/Restore/Werksreset/Update)" .-> SSH2["SSHTransport"]
|
||||||
Connect -. "eigene SSH-Verbindung<br/>(InterfaceTrafficMonitor, 3s-Polling)" .-> SSH3["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)"]
|
Connect --> Saved["SavedRoutersStore<br/>(Bekannte Router)"]
|
||||||
Saved --> Defaults[("UserDefaults<br/>(lokal, kein Router-Zugriff)")]
|
Saved --> Defaults[("UserDefaults<br/>(lokal, kein Router-Zugriff)")]
|
||||||
@@ -97,18 +105,25 @@ flowchart TD
|
|||||||
SSH --> Router
|
SSH --> Router
|
||||||
SSH2 --> Router
|
SSH2 --> Router
|
||||||
SSH3 --> Router
|
SSH3 --> Router
|
||||||
|
SSH4 --> Router
|
||||||
```
|
```
|
||||||
|
|
||||||
`ConnectionService` ist der einzige zwischen den Tabs geteilte State
|
`ConnectionService` ist der einzige zwischen den Tabs geteilte State
|
||||||
(REST-zuerst, SSH-Fallback). Einmalige System-Aktionen (Backup,
|
(REST-zuerst, SSH-Fallback). Einmalige System-Aktionen (Backup,
|
||||||
Wiederherstellung, Werksreset, Updates) laufen bewusst über eine jeweils
|
Wiederherstellung, Werksreset, Updates) laufen bewusst über eine jeweils
|
||||||
eigene, dedizierte SSH-Verbindung statt über den aktiven Live-Transport —
|
eigene, dedizierte SSH-Verbindung statt über den aktiven Live-Transport —
|
||||||
`InterfaceTrafficMonitor` folgt demselben Muster für sein 3-Sekunden-
|
`InterfaceTrafficMonitor` (3-Sekunden-Polling der Live-Interface-
|
||||||
Polling der Live-Interface-Durchsätze (`/interface monitor-traffic` ist
|
Durchsätze) und `NetworkToolsService` (Ping/Traceroute/DNS-Auflösung im
|
||||||
ein reiner CLI-Befehl ohne REST-Äquivalent). Ein generischer Menü-Engine
|
LAN-Scanner-Tab) folgen demselben Muster, da die zugrundeliegenden
|
||||||
(`fetchMenuItems`/`fetchFieldValues`) macht praktisch jedes RouterOS-Menü
|
RouterOS-Befehle (`/interface monitor-traffic`, `/ping`,
|
||||||
lesbar, auch ohne kuratiertes Schema — darauf bauen Übersicht-, Geräte-
|
`/tool traceroute`, `/resolve`) reine CLI-Befehle ohne REST-Äquivalent
|
||||||
und Experte-Tab gemeinsam auf, ebenso `ConnectionService.
|
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
|
checkPortConflict(interfaceName:)` für die LAN-Port-Konflikt-Prüfung im
|
||||||
Einrichten-Assistenten. `SavedRoutersStore` (Bekannte Router) ist bewusst
|
Einrichten-Assistenten. `SavedRoutersStore` (Bekannte Router) ist bewusst
|
||||||
komplett lokal — ein JSON-Array in `UserDefaults`, kein Router-Zugriff;
|
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 |
|
| M17 | Bekannte Router (Verbinden-Tab), Standort-Feld, Scroll-Cap | ✅ live verifiziert |
|
||||||
| M18 | Live-Traffic-Anzeige an Interfaces (Verbinden-Tab) | ✅ live verifiziert |
|
| M18 | Live-Traffic-Anzeige an Interfaces (Verbinden-Tab) | ✅ live verifiziert |
|
||||||
| M19 | Übersicht-Tab: Flussanimation + verschiebbare Knoten | ✅ 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 |
|
| — | LAN-Port-Konflikt-Prüfung + "Fertig"-Button (Einrichten) | 🔶 gebaut, Live-Test offen |
|
||||||
|
|
||||||
Ausführlicher Stand inkl. aller gefundenen Bugs, offener Punkte und
|
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
|
│ │ │ ├── RouterOSMenuItem.swift — generisches Item für jeden RouterOS-Menüpfad
|
||||||
│ │ │ ├── RouterOSSchema.swift / RouterOSSchemaCatalog.swift — kuratierte Formular-Schemas (Experte-Tab)
|
│ │ │ ├── RouterOSSchema.swift / RouterOSSchemaCatalog.swift — kuratierte Formular-Schemas (Experte-Tab)
|
||||||
│ │ │ ├── OverviewGraph.swift — Knoten/Kanten-Modell fürs Übersicht-Diagramm
|
│ │ │ ├── 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)
|
│ │ │ ├── SavedRouter.swift — ein Eintrag in "Bekannte Router" (Host/Name/Standort)
|
||||||
│ │ │ ├── PortConflict.swift — LAN-Port-Konflikt-Prüfung (Bridge/Adresse/DHCP-Client/PPPoE)
|
│ │ │ ├── PortConflict.swift — LAN-Port-Konflikt-Prüfung (Bridge/Adresse/DHCP-Client/PPPoE)
|
||||||
│ │ │ ├── InterfaceTraffic.swift — ein Live-Durchsatz-Sample für die Traffic-Anzeige
|
│ │ │ ├── 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
|
│ │ │ └── RouterOSModels.swift — Credentials, DeviceInfo, RouterBoardInfo, PackageUpdateInfo, Fehler
|
||||||
│ │ ├── Networking/
|
│ │ ├── Networking/
|
||||||
│ │ │ ├── RouterOSTransport.swift — Protocol: connect/fetch.../apply/disconnect
|
│ │ │ ├── RouterOSTransport.swift — Protocol: connect/fetch.../apply/disconnect
|
||||||
@@ -172,14 +190,16 @@ RouterOS/
|
|||||||
│ │ ├── UpdateService.swift — Software-/Firmware-Update, Neustart
|
│ │ ├── UpdateService.swift — Software-/Firmware-Update, Neustart
|
||||||
│ │ ├── KeychainService.swift — Passwort-Speicherung
|
│ │ ├── KeychainService.swift — Passwort-Speicherung
|
||||||
│ │ ├── SavedRoutersStore.swift — "Bekannte Router"-Liste (JSON in UserDefaults)
|
│ │ ├── 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/
|
│ ├── Core/Localization/
|
||||||
│ │ └── L10n.swift — DE/EN-Übersetzungshelfer (Dictionary-Lookup, kein String Catalog)
|
│ │ └── L10n.swift — DE/EN-Übersetzungshelfer (Dictionary-Lookup, kein String Catalog)
|
||||||
│ ├── Features/
|
│ ├── Features/
|
||||||
│ │ ├── Wizard/Steps/Connect/ — Verbinden-Tab
|
│ │ ├── Wizard/Steps/Connect/ — Verbinden-Tab
|
||||||
│ │ ├── Wizard/Steps/Setup/ — Einrichten-Tab (Mode→Wan→Lan→Vlan→Wifi→Firewall→Review)
|
│ │ ├── Wizard/Steps/Setup/ — Einrichten-Tab (Mode→Wan→Lan→Vlan→Wifi→Firewall→Review)
|
||||||
│ │ ├── Overview/ — Übersicht-Tab
|
│ │ ├── Overview/ — Übersicht-Tab
|
||||||
│ │ ├── Devices/ — Geräte-Tab
|
│ │ ├── Devices/ — LAN-Scanner-Tab
|
||||||
│ │ ├── Expert/ — Experte-Tab
|
│ │ ├── Expert/ — Experte-Tab
|
||||||
│ │ └── Backup/ — Sicherungen-Tab
|
│ │ └── Backup/ — Sicherungen-Tab
|
||||||
│ └── Resources/Assets.xcassets/ — App-Icon
|
│ └── Resources/Assets.xcassets/ — App-Icon
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ struct RouterOSAssistantApp: App {
|
|||||||
OverviewView(connectionService: connectionService)
|
OverviewView(connectionService: connectionService)
|
||||||
.tabItem { Label(L10n.t("Übersicht", appLanguage), systemImage: "point.3.connected.trianglepath.dotted") }
|
.tabItem { Label(L10n.t("Übersicht", appLanguage), systemImage: "point.3.connected.trianglepath.dotted") }
|
||||||
DevicesView(connectionService: connectionService)
|
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)
|
ExpertView(connectionService: connectionService)
|
||||||
.tabItem { Label(L10n.t("Experte", appLanguage), systemImage: "wrench.and.screwdriver") }
|
.tabItem { Label(L10n.t("Experte", appLanguage), systemImage: "wrench.and.screwdriver") }
|
||||||
BackupListView(connectionService: connectionService)
|
BackupListView(connectionService: connectionService)
|
||||||
@@ -31,7 +31,9 @@ struct RouterOSAssistantApp: App {
|
|||||||
Button {
|
Button {
|
||||||
appLanguage = (appLanguage == "de") ? "en" : "de"
|
appLanguage = (appLanguage == "de") ? "en" : "de"
|
||||||
} label: {
|
} 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")
|
.help(appLanguage == "de" ? "Switch to English" : "Auf Deutsch umschalten")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ enum L10n {
|
|||||||
"Verbinden": "Connect",
|
"Verbinden": "Connect",
|
||||||
"Einrichten": "Setup",
|
"Einrichten": "Setup",
|
||||||
"Übersicht": "Topology",
|
"Übersicht": "Topology",
|
||||||
"Geräte": "Devices",
|
"LAN-Scanner": "LAN Scanner",
|
||||||
"Experte": "Expert",
|
"Experte": "Expert",
|
||||||
"Sicherungen": "Backups",
|
"Sicherungen": "Backups",
|
||||||
"Firewall: Filter-Regeln": "Firewall: Filter Rules",
|
"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
|
/// 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,
|
/// callers needing it (see `InterfaceTrafficMonitor`) always use a dedicated SSH connection,
|
||||||
/// same reasoning as `BackupService`/`UpdateService`.
|
/// 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 {
|
func fetchInterfaceTraffic(interfaceName: String) async throws -> InterfaceTraffic {
|
||||||
let output = try await run("/interface monitor-traffic \(interfaceName) once")
|
let output = try await run("/interface monitor-traffic \(interfaceName) once")
|
||||||
let fields = RouterOSCliParser.parseSingletonItem(output).fields
|
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
|
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
|
/// 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.
|
/// permanent static IP (Winbox's "Make Static") or remove that reservation again.
|
||||||
struct DevicesView: View {
|
struct DevicesView: View {
|
||||||
@@ -69,7 +69,19 @@ struct DevicesView: View {
|
|||||||
.formStyle(.grouped)
|
.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 {
|
.toolbar {
|
||||||
ToolbarItem {
|
ToolbarItem {
|
||||||
Button {
|
Button {
|
||||||
@@ -78,9 +90,14 @@ struct DevicesView: View {
|
|||||||
if viewModel.isLoading {
|
if viewModel.isLoading {
|
||||||
ProgressView().controlSize(.small)
|
ProgressView().controlSize(.small)
|
||||||
} else {
|
} 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)
|
.disabled(viewModel.isLoading)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -89,9 +106,87 @@ struct DevicesView: View {
|
|||||||
await viewModel.load()
|
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(
|
.confirmationDialog(
|
||||||
"Feste IP-Adresse zuweisen?",
|
"Feste IP-Adresse zuweisen?",
|
||||||
isPresented: $showStaticConfirmation,
|
isPresented: showStaticConfirmation,
|
||||||
titleVisibility: .visible
|
titleVisibility: .visible
|
||||||
) {
|
) {
|
||||||
Button("Zuweisen") {
|
Button("Zuweisen") {
|
||||||
@@ -104,13 +199,17 @@ struct DevicesView: View {
|
|||||||
"\(device.ipAddress) wird dauerhaft an \(device.macAddress)"
|
"\(device.ipAddress) wird dauerhaft an \(device.macAddress)"
|
||||||
+ (device.hostName.map { " (\($0))" } ?? "")
|
+ (device.hostName.map { " (\($0))" } ?? "")
|
||||||
+ " gebunden — die Adresse ändert sich für dieses Gerät nicht mehr automatisch."
|
+ " 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 ?? "")"
|
+ "\n\nBefehl: \(viewModel.pendingCommand?.cliLine ?? "")"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.confirmationDialog(
|
.confirmationDialog(
|
||||||
"Feste Zuweisung entfernen?",
|
"Feste Zuweisung entfernen?",
|
||||||
isPresented: $showRemovalConfirmation,
|
isPresented: showRemovalConfirmation,
|
||||||
titleVisibility: .visible
|
titleVisibility: .visible
|
||||||
) {
|
) {
|
||||||
Button("Entfernen", role: .destructive) {
|
Button("Entfernen", role: .destructive) {
|
||||||
@@ -142,39 +241,37 @@ struct DevicesView: View {
|
|||||||
} message: { error in
|
} message: { error in
|
||||||
Text(error)
|
Text(error)
|
||||||
}
|
}
|
||||||
.sheet(item: $rawFieldsDevice) { device in
|
|
||||||
RawFieldsSheet(device: device) { rawFieldsDevice = nil }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
func withDeviceDetailSheets(viewModel: DevicesViewModel, rawFieldsDevice: Binding<LanDevice?>) -> some View {
|
||||||
private func deviceMenu(for device: LanDevice) -> some View {
|
self
|
||||||
if device.hasLease {
|
.sheet(item: rawFieldsDevice) { device in
|
||||||
if device.isStatic {
|
RawFieldsSheet(device: device) { rawFieldsDevice.wrappedValue = nil }
|
||||||
Button {
|
}
|
||||||
viewModel.pendingStaticRemoval = device
|
.sheet(item: Binding(
|
||||||
showRemovalConfirmation = true
|
get: { viewModel.networkToolResult },
|
||||||
} label: {
|
set: { if $0 == nil { viewModel.networkToolResult = nil } }
|
||||||
Label("Feste Zuweisung entfernen", systemImage: "pin.slash")
|
)) { result in
|
||||||
}
|
NetworkToolResultSheet(result: result) { viewModel.networkToolResult = nil }
|
||||||
} else {
|
}
|
||||||
Button {
|
.sheet(item: Binding(
|
||||||
viewModel.pendingStaticAssignment = device
|
get: { viewModel.portScanResult },
|
||||||
showStaticConfirmation = true
|
set: { if $0 == nil { viewModel.portScanResult = nil } }
|
||||||
} label: {
|
)) { result in
|
||||||
Label("Feste IP zuweisen", systemImage: "pin.fill")
|
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 {
|
#Preview {
|
||||||
DevicesView(connectionService: ConnectionService())
|
DevicesView(connectionService: ConnectionService())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,12 +22,84 @@ final class DevicesViewModel: ObservableObject {
|
|||||||
@Published private(set) var isApplying = false
|
@Published private(set) var isApplying = false
|
||||||
@Published private(set) var applyError: String?
|
@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 connectionService: ConnectionService
|
||||||
private let backupService: BackupService
|
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.connectionService = connectionService
|
||||||
self.backupService = backupService
|
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 {
|
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])
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user