diff --git a/HANDOFF.md b/HANDOFF.md new file mode 100644 index 0000000..09dfcfe --- /dev/null +++ b/HANDOFF.md @@ -0,0 +1,151 @@ +# 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 + +```bash +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): + +1. **ConnectView zeigte nie Verbindungsstatus** — `connectionService` war + nur über `viewModel.connectionService` erreichbar (zwei Ebenen tief + verschachteltes ObservableObject). SwiftUI abonniert automatisch nur + Objekte, die eine View direkt per `@StateObject`/`@ObservedObject` hält. + Fix: `ConnectView` hält `connectionService` jetzt zusätzlich selbst. +2. **NIOSSHError.localizedDescription ist nutzlos** — bridged auf + generisches NSError (`"error 1"`), echte Diagnose steckt in + `CustomStringConvertible`. Fix: `String(describing:)` statt + `.localizedDescription`. +3. **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: .all` beim `SSHClient.connect(...)`. +4. **`Citadel.SSHClient.CommandFailed` zeigte nur den Exit-Code** — + Citadels `executeCommand()` verwirft die gesammelte Ausgabe, sobald der + Exit-Code ≠ 0 ist — genau der Text, den RouterOS bei einem Fehler + zurückgibt. Fix: eigene Sammlung über `executeCommandStream()` in + `SSHTransport.run(_:)`, damit die Ausgabe bis zum Fehlschlag erhalten + bleibt. +5. **Picker-Warnungen** ("selection is invalid and does not have an + associated tag") — `wanConfig`/`lanConfig` starteten 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 im `init`. + +**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** — `SSHTransport` nutzt `.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 (`POST` mit 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-ssl` verifizieren + +## 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.