Fasst Stand nach M1-M4 zusammen: Aufbau, alle Live-Test-Fixes gegen echtes Testgerät (SwiftUI-Observation, NIOSSHError/CommandFailed- Fehlertexte, SSH-Algorithmen, Picker-Defaults), bewusste Scope- Entscheidungen (VLAN ohne Port-Tagging) und offene Punkte für M5-M7. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HReLXMbmPvtQ23p1iWiJNW
7.9 KiB
RouterOS Assistant — Handoff
Stand: nach M4 (VLAN-Schritt), alle Milestones M1–M4 gegen ein echtes physisches Mikrotik-Testgerät (RouterOS 7.24.2) verifiziert.
Ziel
Native macOS-App (SwiftUI), die Laien per geführtem Interview-Wizard durch
die häufigsten Mikrotik-RouterOS-Konfigurationen führt: Internet-Anschluss,
Heimnetzwerk/DHCP, zusätzliche VLAN-Netzwerke, (geplant: WLAN, Firewall).
Vollständiger Plan/Kontext: ~/.claude/plans/purrfect-puzzling-bunny.md
(ursprüngliche Architekturentscheidung — seither an mehreren Stellen
weiterentwickelt, siehe unten).
Setup / Bauen
cd RouterOS
xcodegen generate # erzeugt RouterOSAssistant.xcodeproj (nicht in git)
open RouterOSAssistant.xcodeproj # oder direkt per xcodebuild
xcodebuild -project RouterOSAssistant.xcodeproj -scheme RouterOSAssistant \
-destination 'platform=macOS' build
xcodebuild -project RouterOSAssistant.xcodeproj -scheme RouterOSAssistant \
-destination 'platform=macOS' test
xcodegen ist via Homebrew installiert (brew install xcodegen). Projekt-
Struktur/Targets/Package-Abhängigkeiten stehen in project.yml, das ist die
Quelle der Wahrheit — nicht das generierte .xcodeproj von Hand editieren.
Git: lokales Repo, kein Remote (Nutzer hat aktuell keine Gitea-Instanz).
.xcodeproj, DerivedData, .build sowie die vorhandenen Firmware-Dateien
(*.npk, *.cpgz) sind gitignored.
Architektur
RouterOSAssistant/
App/RouterOSAssistantApp.swift — 3 Tabs, teilen sich EINE ConnectionService-Instanz
Core/
Models/
RouterOSCommand.swift — eine Änderung, zwei Renderer (CLI-Zeile / REST-JSON)
WanConfig.swift, LanDhcpConfig.swift, VlanEntry.swift — bauen je RouterOSCommand-Listen
DhcpServerCommandBuilder.swift — geteilte "Adresse+Pool+Server+Netzwerk"-Logik (LAN + VLAN)
RouterOSModels.swift — Credentials, DeviceInfo, Interface, RouterOSError
Networking/
RouterOSTransport.swift — Protocol: connect/fetchDeviceInfo/fetchInterfaces/apply/disconnect
RestTransport.swift — REST-API (RouterOS ≥7.1), Zertifikats-TOFU
SSHTransport.swift — SSH-Fallback via Citadel, CLI-Text-Parsing
RouterOSCliParser.swift — parst `/system resource print` und `/interface print terse`
CertificateTrustStore.swift / CertificateFingerprint.swift — TOFU nur für REST bisher
Services/
ConnectionService.swift — zentraler App-State: REST-zuerst-SSH-Fallback, hält credentials/interfaces/deviceInfo
BackupService.swift — Config-Export (`/export terse`) über eigene SSH-Verbindung
KeychainService.swift — Passwort-Speicherung
Features/
Wizard/Steps/Connect/ — Verbinden-Tab
Wizard/Steps/Setup/ — Einrichten-Tab: Wan → Lan → Vlan → Review/Apply
Backup/ — Sicherungen-Tab
RouterOSAssistantTests/ — reine Unit-Tests (Command-Builder, CLI-Parser, Fallback-Logik via Mock-Transport)
Wichtiges Architekturprinzip: RouterOSCommand unterstützt nur "add"
(CLI: ... add key=value ..., REST: POST). Kein "set"/PATCH auf
bestehende Einträge — deshalb hat der VLAN-Schritt keine Port-Zuweisung
(Access/Trunk), das bräuchte RouterOS Bridge-VLAN-Filtering mit set.
Mit dem Nutzer abgestimmt, bewusste Scope-Entscheidung.
ConnectionService ist der einzige geteilte State. Alle drei Tabs
bekommen dieselbe Instanz von der App-Ebene injiziert (kein
@EnvironmentObject, explizite Übergabe im Init). Achtung SwiftUI-Falle
(siehe unten): jede View, die connectionService-Felder liest, muss ihn
selbst als @ObservedObject halten — nicht nur transitiv über ein anderes
ViewModel erreichen.
Was während der Live-Tests kaputt war und wie es gefixt wurde
Reihenfolge der Bugs, alle gegen echtes Testgerät gefunden, jeweils als
eigener Commit (git log zeigt Details):
- ConnectView zeigte nie Verbindungsstatus —
connectionServicewar nur überviewModel.connectionServiceerreichbar (zwei Ebenen tief verschachteltes ObservableObject). SwiftUI abonniert automatisch nur Objekte, die eine View direkt per@StateObject/@ObservedObjecthält. Fix:ConnectViewhältconnectionServicejetzt zusätzlich selbst. - NIOSSHError.localizedDescription ist nutzlos — bridged auf
generisches NSError (
"error 1"), echte Diagnose steckt inCustomStringConvertible. Fix:String(describing:)statt.localizedDescription. - SSH-Verbindung scheiterte mit
keyExchangeNegotiationFailure— RouterOS bietet nur Legacy-Algorithmen (diffie-hellman-group14-sha1, RSA-Hostkeys) an, Citadels Default-Algorithmenliste enthält die nicht. Fix:algorithms: .allbeimSSHClient.connect(...). Citadel.SSHClient.CommandFailedzeigte nur den Exit-Code — CitadelsexecuteCommand()verwirft die gesammelte Ausgabe, sobald der Exit-Code ≠ 0 ist — genau der Text, den RouterOS bei einem Fehler zurückgibt. Fix: eigene Sammlung überexecuteCommandStream()inSSHTransport.run(_:), damit die Ausgabe bis zum Fehlschlag erhalten bleibt.- Picker-Warnungen ("selection is invalid and does not have an
associated tag") —
wanConfig/lanConfigstarteten mit festen Platzhaltern ("ether1"/"bridge"), die nicht zu den echten Interface-Namen des Geräts passen mussten. Fix:prepareDefaults()korrigiert jetzt beide anhand eines "ist der Wert überhaupt in der Liste"-Checks, läuft schon iminit.
Lehre für weitere Arbeit: Citadel/NIOSSH-Fehler grundsätzlich mit
String(describing:) loggen, nie .localizedDescription vertrauen. Jede
neue View, die ein ObservableObject aus einem ViewModel liest, muss es
selbst separat als @ObservedObject halten.
Bekannte Einschränkungen (bewusst, nicht vergessen)
- SSH-Hostkey-TOFU fehlt —
SSHTransportnutzt.acceptAnything(), kein Trust-on-first-use wie bei REST. Kommentiert im Code als M7-Punkt. - CLI-Parser (
RouterOSCliParser) ist jetzt gegen ein reales Gerät verifiziert (Verbinden-Tab zeigte korrekte Modell/Version/Interfaces), aber nur gegen dieses eine Testgerät/diese RouterOS-Version. - Kein garantiertes Auto-Rollback bei Verbindungsabbruch während "Jetzt anwenden" — nur Backup-vorher + Bestätigungspflicht. Steht auch im UI-Text auf dem Übersichtsschritt.
- VLAN ohne Port-Zuweisung (siehe oben, Architekturprinzip).
- REST-Pfad ungetestet für Schreibvorgänge — beim Testgerät ist
www-ssl(Port 443) aus, daher lief jeder bisherige Schreibtest über SSH. Der REST-apply()-Pfad (POSTmit JSON-Body) ist nur gegen Mocks getestet, nicht gegen ein echtes Gerät mit aktiver REST-API.
Stand der Milestones
- ✅ M1: Projektgerüst, Connect-Schritt (REST/SSH-Autodetect, Keychain, Zertifikats-TOFU)
- ✅ M2: Backup-Service, Sicherungen-Tab
- ✅ M3: WAN + LAN/DHCP-Wizard, Anwenden-Logik mit Vorab-Backup
- ✅ M4: VLAN-Schritt (separates virtuelles Netz, kein Port-Tagging)
- ⬜ M5: WLAN-Schritt (SSID/Passwort, nur bei erkannter WLAN-Hardware)
- ⬜ M6: Firewall-Schritt (sicherer Standard: NAT/Masquerade, WAN→LAN blocken)
- ⬜ M7: Härtung — SSH-Hostkey-TOFU, Fehlerzustände, Politur, ggf. REST-Schreibpfad
gegen echtes Gerät mit aktivem
www-sslverifizieren
Nächste Schritte
M5 (WLAN) oder M6 (Firewall) als Nächstes — Nutzer hat noch nicht festgelegt, was zuerst kommt. Vor Firewall-Arbeit besonders vorsichtig sein: falsch gesetzte Regeln können den Fernzugriff auf den Router kappen, unbedingt Backup-Pflicht vor Anwenden beibehalten und im Testgerät bleiben, nicht am Produktivrouter.
Kein Gitea-Remote vorhanden — falls der Nutzer später eine Gitea-Instanz
aufsetzt (z.B. selbst gehostet auf der vorhandenen OMV-NAS, wo bereits
andere Docker-Dienste laufen, siehe Aperto/-Projekt), Remote hinzufügen
und pushen; bis dahin bleibt es bei lokalem Git.