From 20f8d5e0bfe396cff8b63c56f76a6f9fcc16dfea Mon Sep 17 00:00:00 2001 From: Kay Date: Mon, 14 Sep 2026 15:21:05 +0200 Subject: [PATCH] M13: Backup-Wiederherstellung, mit Modell-Schutz und Login-Erhalt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Letzter offener Punkt aus HANDOFF.md: gespeicherte .rsc-Backups lassen sich jetzt wieder einspielen. Weg: Backup-Datei per SFTP auf den Router hochladen (Citadel, die bereits eingebundene SSH-Bibliothek, hat einen SFTP-Client), dann RouterOS' offiziell dokumentierter Restore-Weg in einem Rutsch: /system reset-configuration no-defaults=yes run-after-reset= (kompletter Wipe + sofortiges Wiederanwenden, sicherer als ein Re-Import auf eine bestehende, andere Config). Vor dem Bestätigungsdialog wird das Routermodell abgeglichen (/system routerboard prints "model"-Feld gegen die "# model = ..."- Kopfzeile der Sicherung) und bei Mismatch komplett blockiert, um ein Brick-Risiko durch falsches Modell zu vermeiden - der Dialog selbst warnt zusaetzlich prominent davor. Ein ernster Bug live gefunden und gefixt: der erste echte Restore-Test sperrte den Router komplett aus (RouterOS exportiert nie Passwoerter, no-defaults=yes loescht zusaetzlich den Werks-Admin-Account), nur per Hardware-Reset behebbar. Fix: das aktuell verwendete App-Login wird jetzt vorne ins Restore-Skript eingefuegt, noch vor dem eigentlichen Sicherungsinhalt, da RouterOS den Import beim ersten Fehler irgendwo im Skript komplett abbricht. Zwei Tests fuer die Escaping-Logik ergaenzt. Nebenbei: BackupListView mit den neuen Restore-Dialogen liess sich nicht mehr kompilieren (SwiftUI-Typpruefung timeoutete bei der langen Modifier-Kette) - Restore-Dialoge in eine eigene @ViewBuilder-Property ausgelagert. HANDOFF.md/CHATLOG.md aktualisiert: M13, Bug 18+19, "Backup- Wiederherstellung fehlt" aus den offenen Punkten entfernt, Hinweis auf den zweiten (ungewollten) Werksreset waehrend der Session. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01CTgRxJTzaQwaRkngbaE1GJ --- CHATLOG.md | 73 ++++++++ HANDOFF.md | 176 ++++++++++++------ .../Core/Networking/SSHTransport.swift | 33 ++++ .../Core/Services/BackupService.swift | 84 ++++++++- .../Features/Backup/BackupListView.swift | 147 ++++++++++++++- .../Features/Backup/BackupViewModel.swift | 22 +++ .../BackupServiceTests.swift | 37 ++++ 7 files changed, 502 insertions(+), 70 deletions(-) diff --git a/CHATLOG.md b/CHATLOG.md index 692c5d3..6d34cf5 100644 --- a/CHATLOG.md +++ b/CHATLOG.md @@ -557,3 +557,76 @@ offenen Punkte unverändert (siehe HANDOFF.md Nächste Schritte). (M12-Beschreibung/Nächste-Schritte/Bekannte-Einschränkungen aktualisiert, "Zurück auf dynamisch" als erledigt markiert) aktualisiert, Commit erstellt. + +## Session: Backup-Wiederherstellung (M13) + +- "gut, ich setze den Router wieder auf werkseinstellungen zurück, damit + wir sauber weitermachen können" → "bin wieder verbunden, weiter geht's" + → "kümmern wir um die Backups. wir haben ja schon einige gemacht. diese + sind aber aktuell nicht wieder einspielbar." — letzter offener Punkt + aus HANDOFF.md. +- Vor dem Bauen per Recherche-Agent gegen die offizielle RouterOS-Doku + geprüft: `/import file-name=...` ist der Restore-Befehl, Dateien müssen + vorher auf den Router (Upload-Weg dort nicht dokumentiert), Re-Import + über eine bestehende Config ist riskant, offizieller Restore-Weg ist + `reset-configuration no-defaults=yes run-after-reset=...`. Citadel + (bereits eingebundene SSH-Bibliothek) hat einen SFTP-Client — direkt im + Quellcode bestätigt statt angenommen. +- Live-Test des Datei-Uploads gemeinsam mit dem Nutzer über mehrere + Runden (`ssh-keygen -R` wegen geändertem Host-Key nach Werksreset, + `sftp`, `/file print`, `/import`) — bestätigt: Dateien landen unter + `flash/`, `/import`/`/file remove` brauchen den vollen `flash/`-Pfad, + bare Dateiname scheitert mit "file does not exist". +- "ja, bau das so, füge im Warnhinweis promiment dazu, das nur die + Backup-Datei für das entsprechende Modell verwendet werden darf, sonst + brick. vielleicht ist es sonnvoll vor dem Einspielen die Routermodelle + ... vergleichen" → zweite Recherche-Runde zum Export-Header-Format + (`# model = ...`), währenddessen der Nutzer den echten Header einer + eigenen Backup-Datei direkt gezeigt hat (schneller als die Recherche). + Wichtige Korrektur währenddessen selbst gefunden: die `model`-Zeile + entspricht `/system routerboard print`s `model`-Feld, nicht dem + `board-name`, den die App sonst kennt (oft der Marketing-Name, z.B. + "hEX" statt "RB750Gr3") — ein Vergleich gegen das falsche Feld hätte + selbst beim exakt gleichen Router fälschlich "passt nicht" gemeldet. +- Gebaut: SFTP-Upload + `reset-configuration`+`run-after-reset` in + `SSHTransport`/`BackupService`, Modell-Abgleich vor dem + Bestätigungsdialog (blockt komplett bei Mismatch), Gefahrenzone-Dialog + mit dem geforderten Brick-Warnhinweis. Kompilierfehler unterwegs: + SwiftUI-Typprüfung timeoutete bei der langen `BackupListView`- + Modifier-Kette (Bug 19) — Restore-Dialoge in eine eigene + `@ViewBuilder`-Property ausgelagert. +- "ok, das Backup an sich scheint zu funktionieren, eine verbindung zum + wiederhergestellten Router funktioniert nicht: Flascher Username oder + Passwort. Nur ein Hard-Reset hilft dann noch" — ernster Bug (18): + RouterOS-Exporte enthalten nie Passwörter, `no-defaults=yes` löscht + zusätzlich den Werks-Admin-Account, nach dem Restore also kein Login + mehr vorhanden, nur per Hardware-Reset behebbar. Fix: aktuelles + App-Login wird jetzt vorne ins Restore-Skript eingefügt (vor dem + eigentlichen Sicherungsinhalt, da RouterOS den Import beim ersten + Fehler abbricht), inkl. zwei Tests für die Escaping-Logik. +- "alles gut, ist ja ein Testgerät :)" — Nutzer gelassen trotz + Aussperrung. "gut, das hat funktioniert." — Restore inkl. Login-Erhalt + danach bestätigt. +- "ja, committen und beides aktualisieren" → dieser Eintrag, HANDOFF.md + (Bug 18+19, M13, aktualisierte Bekannte Einschränkungen/Nächste + Schritte inkl. Hinweis auf den zweiten Werksreset/Hardware-Reset + während der Session) aktualisiert, Commit erstellt. + +## Stand am Ende dieser Session — Backup-Wiederherstellung (M13) + +M13 gebaut und live bestätigt — der letzte seit langem offene Punkt aus +HANDOFF.md ("Backup-Wiederherstellung fehlt komplett") ist erledigt. Der +Weg dahin war der bisher risikoreichste dieser App: ein echter, live +reproduzierter Aussperr-Vorfall (Bug 18), der den Nutzer zu einem +Hardware-Reset zwang, bevor der Fix (Login-Erhalt vorangestellt im +Restore-Skript) das behoben hat. Modell-Abgleich vor dem Restore +(RB750Gr3 vs. "hEX" — wichtige Feld-Verwechslung selbst noch vor dem +Bauen gefunden und vermieden) und der geforderte Brick-Warnhinweis sind +mit drin. Router wurde im Lauf der Session zweimal komplett +zurückgesetzt (einmal gewollt für einen sauberen Stand, einmal +ungewollt durch Bug 18) — alle älteren Hinweise auf spezifische +Test-Konfiguration (`test-vlan`, `dhcp5` etc.) aus früheren Sessions +sind dadurch hinfällig, siehe HANDOFF.md Nächste Schritte. Offen bleibt +weiterhin die `.id`-Positionsüberlagerungs-Frage für den Experte-Tab +(Bug 15) und alle länger schon offenen Punkte (M7-Rest, M8-Hardware- +Test, WLAN/Bonding/PPPoE-Live-Tests). diff --git a/HANDOFF.md b/HANDOFF.md index 033e7da..510fd34 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -13,23 +13,28 @@ beide live gegen den hEX-Testrouter verifiziert, sieben Bugs dabei gefunden und gefixt (Bug 7–13). Alles bis hierhin committed (`c9ecd3a`). -**Neu in dieser Session:** zwei weitere Tabs. **M11 "Übersicht"** zeigt -die komplette aktuelle Router-Konfiguration als Diagramm (Interfaces → -IP-Adressen → DHCP/Pools → Routen → Firewall/NAT, mit echten, aus -RouterOS' eigenen Referenzfeldern abgeleiteten Verbindungslinien, -farblich nach Verbindungsart getrennt, Hover/Klick hebt Zusammenhänge -hervor). **M12 "Geräte"** ist ein LAN-Scanner (DHCP-Leases + ARP + -Bridge-Host-Tabelle, gruppiert als eine Tabelle pro physischem Port) mit -Rechtsklick-Aktion "Feste IP zuweisen" (RouterOS' "Make Static", per -`/ip dhcp-server lease make-static`) sowie ihr Gegenstück "Feste -Zuweisung entfernen" (RouterOS kennt kein `make-dynamic`, nur `remove` — -vorher per Recherche-Agent gegen die offizielle Doku geprüft statt -geraten). Beide Tabs live gegen den hEX-Testrouter verifiziert — M12 -dabei besonders gründlich, weil die Fest/Dynamisch-Erkennung und die -Zuweisung selbst sich als überraschend zäh erwiesen: **vier** echte Bugs -gefunden und gefixt (Bug 14–17 unten), am Ende vom Nutzer selbst -bestätigt ("das funktioniert jetzt super", und für den Rückweg "das hat -sofort funktioniert"). Release-Build unter +**Neu in dieser Session:** drei weitere Tabs/Features. **M11 "Übersicht"** +zeigt die komplette aktuelle Router-Konfiguration als Diagramm +(Interfaces → IP-Adressen → DHCP/Pools → Routen → Firewall/NAT, mit +echten, aus RouterOS' eigenen Referenzfeldern abgeleiteten +Verbindungslinien, farblich nach Verbindungsart getrennt, Hover/Klick +hebt Zusammenhänge hervor). **M12 "Geräte"** ist ein LAN-Scanner +(DHCP-Leases + ARP + Bridge-Host-Tabelle, gruppiert als eine Tabelle pro +physischem Port) mit Rechtsklick-Aktion "Feste IP zuweisen" (RouterOS' +"Make Static", per `/ip dhcp-server lease make-static`) sowie ihr +Gegenstück "Feste Zuweisung entfernen" (RouterOS kennt kein +`make-dynamic`, nur `remove` — vorher per Recherche-Agent gegen die +offizielle Doku geprüft statt geraten). **M13 Backup-Wiederherstellung** +im Sicherungen-Tab: `.rsc`-Backup per SFTP hochladen, dann RouterOS' +dokumentierter `reset-configuration`+`run-after-reset`-Restore-Weg, mit +Modell-Abgleich vor dem Bestätigungsdialog (Brick-Schutz bei falschem +Routermodell) und automatischem Login-Erhalt (siehe Bug 18). Alle drei +Tabs/Features live gegen den hEX-Testrouter verifiziert, dabei M12 (vier +Bugs, 14–17) und M13 (ein besonders ernster Bug, 18: erster Testlauf +sperrte den Router komplett aus, nur per Hardware-Reset behebbar) beide +zäher als erwartet, am Ende aber jeweils vom Nutzer selbst bestätigt +("das funktioniert jetzt super" / "das hat sofort funktioniert" / "gut, +das hat funktioniert"). Release-Build unter `/Applications/RouterOS Assistant.app` ist auf aktuellem Stand. ## Ziel @@ -92,13 +97,13 @@ RouterOSAssistant/ Networking/ RouterOSTransport.swift — Protocol: connect/fetchDeviceInfo/fetchInterfaces/fetchFirewallRuleCounts/fetchMenuItems/fetchFieldValues/apply/disconnect RestTransport.swift — REST-API (RouterOS ≥7.1), Zertifikats-TOFU, .set via GET+PATCH (findItemID), .remove via GET+DELETE, fetchMenuItems (Array- oder Singleton-Objekt), fetchFieldValues (Query-Filter, unverifiziert) - SSHTransport.swift — SSH-Fallback via Citadel, CLI-Text-Parsing, resetToFactoryDefaults(), eigene Hostkey-TOFU, fetchMenuItems (generisch, inkl. Singleton-Fallback), fetchFieldValues (`:foreach`+`find`+`get`, M12/Bug 14+17) + SSHTransport.swift — SSH-Fallback via Citadel, CLI-Text-Parsing, resetToFactoryDefaults(), eigene Hostkey-TOFU, fetchMenuItems (generisch, inkl. Singleton-Fallback), fetchFieldValues (`:foreach`+`find`+`get`, M12/Bug 14+17), uploadScript (SFTP, M13), applyRestoreScript (reset-configuration+run-after-reset, M13) RouterOSCliParser.swift — parst `/system resource print`, `/interface print terse`, und generisch jeden Menüpfad (parseGenericItems/parseSingletonItem, M10) CertificateTrustStore.swift / CertificateFingerprint.swift — TOFU für REST-Zertifikate SSHHostKeyTrustStore.swift / SSHHostKeyFingerprint.swift — TOFU für SSH-Hostkeys (M7) Services/ ConnectionService.swift — zentraler App-State: REST-zuerst-SSH-Fallback, hält credentials/interfaces/deviceInfo, plus generisches fetchMenuItems - BackupService.swift — Config-Export (`/export terse`) über eigene SSH-Verbindung, wählbarer Zielordner + BackupService.swift — Config-Export (`/export terse`) über eigene SSH-Verbindung, wählbarer Zielordner, restoreBackup (M13: SFTP-Upload + reset-configuration+run-after-reset + Login-Erhalt), backupModel (Modell-Header-Parsing) FactoryResetService.swift — /system reset-configuration über eigene SSH-Verbindung ("Gefahrenzone") KeychainService.swift — Passwort-Speicherung Features/ @@ -307,6 +312,37 @@ erreichen (siehe Bug 1 unten). — nacktes `find` mit einer Bedingung, plus `get feld` auf eine einzelne bekannte `.id`, verbunden über eine `:foreach`-Schleife (`RouterOSTransport.fetchFieldValues`/`SSHTransport.fetchFieldValues`). +18. **Kritisch, live so passiert: Backup-Wiederherstellung sperrte den + Login komplett aus, nur ein Hardware-Reset half noch** — beim ersten + Live-Test der neuen Restore-Funktion (M13). Ursache doppelt: RouterOS' + `/export` kann Benutzerpasswörter grundsätzlich nie exportieren + (offiziell dokumentiert), und der für den Restore genutzte + `no-defaults=yes`-Reset löscht zusätzlich den mitgelieferten + Standard-Admin-Account komplett — nach dem Restore existierte also + weder aus der Sicherung noch als Werksvorgabe ein funktionierender + Login. Fix: `BackupService.restoreBackup` stellt dem hochgeladenen + Skript jetzt einen kleinen, selbst generierten Block voran (nicht + angehängt — RouterOS bricht den Import beim ersten Fehler ab, ein + Login-Erhalt am Ende hätte also nie gegriffen, falls irgendwo vorher + im eigentlichen Sicherungsskript ein Fehler auftritt), der exakt das + aktuell in der App verwendete Login per `:if ([/user find + name=...] = "") do={add ...} else={set ... password=...}` neu anlegt + bzw. das Passwort neu setzt — das einzige Passwort, das die App + zuverlässig kennt. Beim erneuten Live-Test danach vom Nutzer bestätigt + ("das hat funktioniert"). +19. **SwiftUI-Typprüfung bricht bei zu langen Modifier-Ketten ab, kein + Logikfehler** — `BackupListView` warf nach dem Hinzufügen weiterer + `.confirmationDialog`/`.alert`-Modifiere für den Restore-Dialog + `"the compiler is unable to type-check this expression in reasonable + time"`, mit einer irreführenden Zeilenangabe (zeigte auf einen ganz + gewöhnlichen, bereits woanders im selben File funktionierenden + `Binding`-Ausdruck). Fix: die Restore-spezifischen Dialoge/Alerts in + eine eigene `@ViewBuilder`-Computed-Property ausgelagert, statt alles + in einer einzigen `body`-Kette zu verketten — teilt die + Typinferenz-Last auf zwei kleinere Ausdrücke auf. Bei künftigen + Views mit vielen `.alert`/`.confirmationDialog`/`.sheet`-Modifiern in + Folge vorbeugend gleich aufteilen, statt erst bei diesem Fehler zu + reagieren. **Lehren:** Citadel/NIOSSH-Fehler immer mit `String(describing:)` loggen, nie `.localizedDescription`. Jede View, die ein ObservableObject aus einem @@ -345,7 +381,18 @@ nicht, dass `get [find ...] feld` als eine Einheit genauso funktioniert. dasselbe State-Feld koppeln** (Bug 16) — `isPresented`s Setter feuert bei jedem Knopfdruck, nicht nur bei Abbrechen; ein eigenes, unabhängiges Bool fürs Zeigen/Verstecken hält die Nutzlast so lange am Leben, wie die -bestätigte Aktion sie braucht. +bestätigte Aktion sie braucht. **RouterOS-Backups über `/export` enthalten +grundsätzlich nie Passwörter, und `no-defaults=yes` löscht auch den +Werks-Admin-Account** (Bug 18) — jede Restore-Funktion, die einen +kompletten Reset vor dem Re-Import macht, muss den Login selbst wieder +herstellen, sonst folgt ein kompletter Aussperr-Vorfall (nur per +Hardware-Reset behebbar, live so passiert). Login-Erhalt gehört an den +*Anfang* des Skripts, nicht ans Ende — RouterOS bricht den Import beim +ersten Fehler irgendwo im Skript komplett ab. **SwiftUI-Typprüfung kann +bei langen Modifier-Ketten mit einer irreführenden Fehlerzeile +timeouten** (Bug 19) — kein Hinweis auf einen echten Logikfehler an der +genannten Stelle; bei vielen `.alert`/`.confirmationDialog` in Folge +vorbeugend in eigene `@ViewBuilder`-Properties aufteilen. ## `xcodebuild test` hängt — Gatekeeper, kein Code-Bug @@ -364,15 +411,21 @@ wiederholen. ## Bekannte Einschränkungen (bewusst, nicht vergessen) -- **Backup-Wiederherstellung fehlt komplett** — Nutzer wies explizit - darauf hin (2026-09-14): `BackupService` kann Backups nur erzeugen - (`createBackup`) und auflisten (`listBackups`), es gibt **keine** - Funktion, ein gespeichertes `.rsc`-Backup zurück auf den Router zu - spielen. Einzige verwandte Funktion ist "Werkseinstellungen - wiederherstellen" (`FactoryResetService`, Gefahrenzone im - Sicherungen-Tab) — das ist RouterOS' eigener Werksreset, kein Zurück- - spielen einer konkreten Sicherung. Noch nicht umgesetzt, für später - vorgemerkt (siehe Nächste Schritte). +- **Restore nur für dasselbe Routermodell, RouterOS erzwingt das selbst + nicht** — die App blockt bei erkanntem Modell-Mismatch (per Recherche + bestätigt: RouterOS importiert eine Fremd-Modell-Config anstandslos, + potenziell mit falsch zugeordneten Interfaces). Schutz greift nur, wenn + die Backup-Datei die `# model = ...`-Kopfzeile hat (RouterOS ≥ mind. + 6.49.5) — bei sehr alten/handbearbeiteten Dateien ohne diese Zeile + läuft der Restore ungeprüft durch (Dialog weist explizit auf + "unbekannt" hin, blockt aber nicht). +- **Restore-Skript-Datei bleibt nach der Wiederherstellung auf dem + Router liegen** (`flash/routerosassistant-restore.rsc`) — kein + automatisches Aufräumen, weil der Router direkt im Zuge des Befehls + neu startet und die Verbindung dabei abbricht; ein erneutes Verbinden + nur zum Löschen dieser einen Datei erschien nicht aufwandsgerecht. + Harmlos (überschreibt sich beim nächsten Restore selbst), aber bewusst + in Kauf genommen, nicht vergessen. - **REST-Pfad ungetestet für Schreibvorgänge** — auf beiden bisherigen Testgeräten war `www-ssl` (Port 443) aus, jeder Schreibtest lief über SSH. Der REST-`apply()`-Pfad (`POST`/`PATCH`, `findItemID` für `.set`) @@ -612,6 +665,18 @@ beim Ändern die RouterOS-Suffix-Form zurück) — angewendet auf DHCP-Server `make-dynamic`, nur `remove` + Client muss neu verbinden), nicht geraten. Nutzer bestätigte den vollen Kreislauf (fest zuweisen → entfernen → Kabel/WLAN trennen+verbinden → wieder dynamisch). +- ✅ M13: Backup-Wiederherstellung — `BackupService.restoreBackup` + (SFTP-Upload des `.rsc`-Skripts + RouterOS' dokumentierter + `reset-configuration no-defaults=yes run-after-reset=...`-Restore-Weg), + Modell-Abgleich vor dem Bestätigungsdialog (`/system routerboard`s + `model`-Feld gegen die `# model = ...`-Kopfzeile der Sicherung, blockt + bei Mismatch komplett), Gefahrenzone-UI mit explizitem Brick-Warnhinweis + im Sicherungen-Tab. **Live gegen Hardware verifiziert** — erster + Testlauf sperrte den Router komplett aus (Bug 18: keine Passwörter im + Export + `no-defaults=yes` löscht auch den Werks-Admin), Fix + (Login-Erhalt vorangestellt) danach vom Nutzer bestätigt ("das hat + funktioniert"). Restore-Weg selbst (Modell-Match, SFTP-Upload, + reset+run-after-reset) lief dagegen schon im ersten Versuch fehlerfrei. ## Nächste Schritte @@ -632,42 +697,29 @@ beim Ändern die RouterOS-Suffix-Form zurück) — angewendet auf DHCP-Server bestätigt (siehe oben), der Moduswechsel im Wizard noch nicht. 6. M10: WLAN-Schemas (an Gerät mit WLAN-Chip), Bonding, PPPoE-Client (mit echten oder Test-ISP-Zugangsdaten) noch gegen Hardware verifizieren. -7. `dhcp5`/`pool5` auf `ether5` sind Test-Überbleibsel aus einer früheren - Session — mit dem Nutzer klären, ob die noch gebraucht werden oder - aufgeräumt werden können (aktuell blockieren sie `ether5` für einen - neuen direkt-auf-dem-Port angelegten DHCP-Server). -8. **Nicht anrühren:** `test-vlan` (VLAN-ID 20, ether5), `testpool` - (192.168.90.10-250) und `testdhcp` (DHCP-Server auf `test-vlan`) sind - **echte, vom Nutzer selbst über die App angelegte Konfiguration** aus - dieser Session, keine Test-Artefakte zum Aufräumen. -9. Optional, kleinere Politur: den neuen Dauer-Editor +7. Optional, kleinere Politur: den neuen Dauer-Editor (`RouterOSFieldSchema.Kind.duration`) auch auf weitere Zeitwert-Felder anwenden, die bisher nur Text mit Beispiel-Tooltip sind — WireGuard- Peer "Keepalive", Scheduler "Intervall", Netwatch "Prüf-Intervall". -10. **Backup-Wiederherstellung bauen** — vom Nutzer explizit gewünscht, - noch nicht begonnen. Vermutlich: `BackupService` um eine - `restoreBackup(_:for:)`-Funktion erweitern, die den Inhalt eines - gewählten `.rsc`-Backups über SSH auf den Router einspielt (RouterOS - kann `.rsc`-Skripte per `/import file-name=...` ausführen, dazu muss - die Datei vorher aufs Gerät hochgeladen werden — Weg noch nicht - verifiziert, vor dem Bauen live prüfen statt zu raten), plus ein - "Wiederherstellen"-Button pro Eintrag im Sicherungen-Tab - (`BackupListView`) mit Bestätigungsdialog (ähnlich der bestehenden - Werksreset-Gefahrenzone). -11. **`.id`-Positions-Überlagerung (`fetchMenuItems`) auf weitere Menüs - prüfen** — live als falsch bestätigt für `/ip dhcp-server lease` - (Bug 15). Betrifft potenziell jedes `.set`/`.remove` im Experte-Tab. - Am ehesten zu prüfen: bei einem Menü mit mehreren gleichzeitig - vorhandenen Einträgen `:put [ find]` und ` print terse` - unabhängig ausführen und die Reihenfolgen von Hand vergleichen. -12. Optional: den neuen Dauer-Editor auch für den Geräte-Tab nutzen, falls - dort künftig Zeitfelder (z.B. eine Lease-Zeit-Anzeige) dazukommen. -13. `Kay-Uwes-iMac` (ether2, `defconf`) und der Test-Laptop `DEDELLB2M6GK3` - (ether3, `dhcp5`/`defconf` je nach Testrunde) sind **echte Geräte des - Nutzers**, mit denen M12 live durchgetestet wurde — keine - Test-Artefakte, nicht aufräumen. Beide Geräte am Ende der Session - wieder dynamisch (kompletter Kreislauf fest→entfernt→dynamisch vom - Nutzer selbst am Laptop bestätigt). + Optional auch für den Geräte-Tab, falls dort künftig Zeitfelder + dazukommen. +8. **`.id`-Positions-Überlagerung (`fetchMenuItems`) auf weitere Menüs + prüfen** — live als falsch bestätigt für `/ip dhcp-server lease` + (Bug 15). Betrifft potenziell jedes `.set`/`.remove` im Experte-Tab. + Am ehesten zu prüfen: bei einem Menü mit mehreren gleichzeitig + vorhandenen Einträgen `:put [ find]` und ` print terse` + unabhängig ausführen und die Reihenfolgen von Hand vergleichen. +9. **Router wurde zwischen M12 und M13 vom Nutzer komplett auf + Werkseinstellungen zurückgesetzt** ("damit wir sauber weitermachen + können"), und im Zuge des ersten M13-Testlaufs (Bug 18, Login-Sperre) + nochmal per Hardware-Reset zurückgesetzt. `test-vlan`/`testpool`/ + `testdhcp`/`dhcp5`/`pool5` aus früheren Sessions existieren dadurch + nicht mehr — **nicht mehr relevant, nicht danach suchen.** Aktueller + Stand: `Kay-Uwes-iMac` an ether2 und Test-Laptop `DEDELLB2M6GK3` an + ether3, beide dynamisch, plus was auch immer die zuletzt erfolgreich + getestete Backup-Wiederherstellung zurückgespielt hat — vor + Annahmen über den genauen aktuellen Stand lieber neu per Geräte-/ + Übersicht-Tab prüfen statt auf ältere Einträge hier zu vertrauen. Kein Gitea-Remote vorhanden — falls der Nutzer später eine Gitea-Instanz aufsetzt (z.B. selbst gehostet auf der vorhandenen OMV-NAS, siehe diff --git a/RouterOSAssistant/Core/Networking/SSHTransport.swift b/RouterOSAssistant/Core/Networking/SSHTransport.swift index 6530796..f7dd51a 100644 --- a/RouterOSAssistant/Core/Networking/SSHTransport.swift +++ b/RouterOSAssistant/Core/Networking/SSHTransport.swift @@ -137,6 +137,39 @@ final class SSHTransport: RouterOSTransport { _ = try await run("/system reset-configuration no-defaults=no skip-backup=no") } + /// Uploads a `.rsc` script's text content to the router's own file storage via SFTP — the + /// only documented way found to get a file from this Mac onto the router (RouterOS' SSH + /// server has no documented SCP support, but DOES accept SFTP: confirmed live against a real + /// hEX with a plain `sftp` CLI session; Citadel, already a dependency, ships an SFTP client). + /// `remoteName` must be the full `flash/...`-prefixed form — confirmed live that RouterOS' + /// `/file` and `/import` reject a bare filename ("file does not exist") even though `/file + /// print` lists the same file that way too. + func uploadScript(remoteName: String, contents: String) async throws { + guard let client else { throw RouterOSError.notConnected } + let sftp = try await client.openSFTP() + do { + try await sftp.withFile(filePath: remoteName, flags: [.write, .create, .truncate]) { file in + try await file.write(ByteBuffer(string: contents)) + } + } catch { + try? await sftp.close() + throw error + } + try await sftp.close() + } + + /// The RouterOS-documented restore workflow in one command: wipe the entire configuration + /// (`no-defaults=yes`, not even the vendor defaults — a genuinely blank slate) and + /// immediately re-apply the given already-uploaded script. Safer than `/import`-ing straight + /// over a live, different configuration, which MikroTik's own docs describe as needing a + /// reset first (every `add`-type line in the script would otherwise risk colliding with + /// whatever's already there). Reboots the device; like `resetToFactoryDefaults()`, the + /// connection dying mid-command is the expected outcome, not a failure — ignored here the + /// same way. + func applyRestoreScript(remoteName: String) async throws { + _ = try await run("/system reset-configuration no-defaults=yes run-after-reset=\(remoteName)") + } + /// Runs a command via `executeCommandStream` (not the simpler `executeCommand`), because /// `executeCommand` discards whatever output it already collected the moment the command /// exits non-zero — exactly the RouterOS error text we need. Collecting the stream ourselves diff --git a/RouterOSAssistant/Core/Services/BackupService.swift b/RouterOSAssistant/Core/Services/BackupService.swift index bee5357..114d12b 100644 --- a/RouterOSAssistant/Core/Services/BackupService.swift +++ b/RouterOSAssistant/Core/Services/BackupService.swift @@ -7,12 +7,14 @@ struct BackupRecord: Identifiable, Equatable { let fileURL: URL } -/// Creates and lists local, human-readable configuration backups (`/export terse`). +/// Creates, lists, and restores local, human-readable configuration backups (`/export terse`). /// /// Backups always go over SSH, independent of whether the live wizard session is using /// REST or SSH — RouterOS's REST API mirrors config menus but has no generic "export the /// whole config as a script" endpoint, while `/export` over SSH is well established. -/// This means creating a backup requires SSH access on the router (enabled by default). +/// This means creating (and restoring) a backup requires SSH access on the router (enabled by +/// default). Restoring uploads the script back to the router via SFTP and runs RouterOS' own +/// documented reset-then-reapply workflow — see `restoreBackup(_:for:)`. final class BackupService { private let fileManager = FileManager.default @@ -60,6 +62,84 @@ final class BackupService { return BackupRecord(id: fileName, createdAt: Date(), host: credentials.host, fileURL: fileURL) } + /// The router hardware model a backup was taken from, e.g. "RB750Gr3" — parsed from + /// `/export`'s own header comment (`# model = RB750Gr3`), confirmed live in a real backup + /// this app produced (`exportConfiguration()` → `/export terse`, RouterOS 7.24.2). Returns + /// nil if the file has no such line (very old or hand-edited backup) — callers must treat + /// that as "unknown", never as "compatible". + static func backupModel(from fileURL: URL) -> String? { + guard let contents = try? String(contentsOf: fileURL, encoding: .utf8) else { return nil } + let prefix = "# model = " + for line in contents.split(separator: "\n", maxSplits: 20).prefix(20) { + if line.hasPrefix(prefix) { + return String(line.dropFirst(prefix.count)).trimmingCharacters(in: .whitespaces) + } + } + return nil + } + + /// Wipes the router's entire configuration and replaces it with the one in `record` — + /// uploads the backup script to the router's own file storage via SFTP, then runs RouterOS' + /// documented reset+run-after-reset restore workflow (see `SSHTransport.applyRestoreScript`). + /// Callers are responsible for the model-compatibility check (`backupModel(from:)` vs. the + /// connected router's board name) — this function does not guess whether the backup is safe + /// to apply, it just applies it. + func restoreBackup(_ record: BackupRecord, for credentials: RouterOSCredentials) async throws { + let backupContents = try String(contentsOf: record.fileURL, encoding: .utf8) + // Confirmed live (the hard way): without this, a restore locks the router out entirely, + // recoverable only via a physical hardware reset. Two compounding reasons: RouterOS' + // `/export` can never include user account passwords at all (officially documented — + // "system user passwords ... can not be exported"), and `no-defaults=yes` also wipes the + // vendor's own default admin account, so after a restore there is no working login left + // whatsoever, on either side. Prepended (not appended) so login access is recreated + // before anything else in the backup script runs — pre-7.16 RouterOS halts import + // entirely on the first error, so if login recreation came last and something earlier in + // the backup's own content failed, the router would stay locked out anyway. Putting it + // first means the worst case is now "config partially applied, but still reachable to + // fix it" instead of "hard reset required" — even if the backup's own export happens to + // also (re-)touch this same username later (possible, since usernames without passwords + // ARE included), that's at most one harmless "already have such user" line failing. + let contents = Self.loginPreservationScript(username: credentials.username, password: credentials.password) + + "\n\n" + backupContents + let transport = SSHTransport(credentials: credentials) + try await transport.connect() + do { + try await transport.uploadScript(remoteName: Self.restoreScriptRemoteName, contents: contents) + } catch { + await transport.disconnect() + throw error + } + // The router reboots as part of this command; the connection dying mid-command instead + // of returning a clean response is the expected outcome here, not a failure. + try? await transport.applyRestoreScript(remoteName: Self.restoreScriptRemoteName) + await transport.disconnect() + } + + /// Re-creates (or repassword-s, if the backup script itself adds this same username without + /// a password later) exactly the login currently in use — the only credentials this app can + /// actually know are correct, since RouterOS never exports passwords for any account. + static func loginPreservationScript(username: String, password: String) -> String { + let user = escapeForRouterOSScript(username) + let pass = escapeForRouterOSScript(password) + return """ + :if ([/user find name="\(user)"] = "") do={ + /user add name="\(user)" password="\(pass)" group=full + } else={ + /user set [find name="\(user)"] password="\(pass)" + } + """ + } + + private static func escapeForRouterOSScript(_ value: String) -> String { + value.replacingOccurrences(of: "\\", with: "\\\\").replacingOccurrences(of: "\"", with: "\\\"") + } + + /// Fixed name, not a fresh one per restore — this script only ever needs to exist for the + /// few seconds between upload and the reset command consuming it, and RouterOS' own + /// "*.auto.rsc" auto-import convention doesn't apply here since this app triggers the import + /// explicitly via `run-after-reset` rather than relying on upload-triggered auto-execution. + private static let restoreScriptRemoteName = "flash/routerosassistant-restore.rsc" + func listBackups() -> [BackupRecord] { guard let files = try? fileManager.contentsOfDirectory( at: backupsDirectory, diff --git a/RouterOSAssistant/Features/Backup/BackupListView.swift b/RouterOSAssistant/Features/Backup/BackupListView.swift index 3bfda16..93ffc28 100644 --- a/RouterOSAssistant/Features/Backup/BackupListView.swift +++ b/RouterOSAssistant/Features/Backup/BackupListView.swift @@ -6,9 +6,84 @@ struct BackupListView: View { @StateObject private var viewModel = BackupViewModel() @State private var showFolderPicker = false @State private var showFactoryResetConfirmation = false + /// Plain, independent Bool — same reasoning as the Geräte-Tab's confirmation dialogs + /// (`showStaticConfirmation` there): `.confirmationDialog`'s `isPresented` setter fires on + /// every dismissal including the confirming button, so it must never double as the payload. + @State private var showRestoreConfirmation = false + @State private var pendingRestoreBackup: BackupRecord? + /// Set instead of opening the confirmation dialog at all when the backup's own recorded + /// model doesn't match the connected router — restoring the wrong device's backup risks + /// bricking it (mismatched interface count/model-specific config), so this blocks the action + /// outright rather than just warning. + @State private var modelMismatchMessage: String? + /// The connected router's own `model` field (from `/system routerboard print`, fetched fresh + /// each time — not cached across the connection, matching this app's "state that can change + /// must be reloaded, not cached at connect time" lesson). Kept for the confirmation dialog's + /// text once a restore is allowed to proceed. + @State private var pendingRestoreCurrentModel: String? var body: some View { NavigationStack { + restoreAwareContent + } + .onAppear { viewModel.load() } + } + + /// Split out of `body` because the combined modifier chain (backup list + Gefahrenzone + + /// factory-reset dialogs + restore dialogs, all on one view) made the Swift type-checker + /// time out ("unable to type-check this expression in reasonable time") — no logic issue, + /// purely a compiler-inference limit on very long SwiftUI modifier chains. + @ViewBuilder + private var restoreAwareContent: some View { + baseContent + .confirmationDialog( + "Sicherung wirklich wiederherstellen?", + isPresented: $showRestoreConfirmation, + titleVisibility: .visible + ) { + Button("Wiederherstellen", role: .destructive) { + if let credentials = connectionService.credentials, let backup = pendingRestoreBackup { + viewModel.restore(backup, for: credentials) + } + pendingRestoreBackup = nil + } + Button("Abbrechen", role: .cancel) { pendingRestoreBackup = nil } + } message: { + Text(restoreWarningText) + } + .onChange(of: viewModel.didSendRestore) { _, didSend in + if didSend { + Task { await connectionService.disconnect() } + } + } + .alert( + "Falsches Routermodell", + isPresented: Binding( + get: { modelMismatchMessage != nil }, + set: { if !$0 { modelMismatchMessage = nil } } + ), + presenting: modelMismatchMessage + ) { _ in + Button("OK") {} + } message: { message in + Text(message) + } + .alert( + "Wiederherstellung fehlgeschlagen", + isPresented: Binding( + get: { viewModel.restoreError != nil }, + set: { _ in viewModel.restoreError = nil } + ), + presenting: viewModel.restoreError + ) { _ in + Button("OK") {} + } message: { message in + Text(message) + } + } + + @ViewBuilder + private var baseContent: some View { Group { if viewModel.backups.isEmpty { ContentUnavailableView( @@ -18,11 +93,25 @@ struct BackupListView: View { ) } else { List(viewModel.backups) { backup in - VStack(alignment: .leading) { - Text(backup.host).bold() - Text(backup.createdAt.formatted(date: .abbreviated, time: .standard)) - .font(.caption) - .foregroundStyle(.secondary) + HStack { + VStack(alignment: .leading) { + Text(backup.host).bold() + Text(backup.createdAt.formatted(date: .abbreviated, time: .standard)) + .font(.caption) + .foregroundStyle(.secondary) + if let model = BackupService.backupModel(from: backup.fileURL) { + Text(model).font(.caption2).foregroundStyle(.secondary) + } + } + Spacer() + Button { + beginRestore(backup) + } label: { + Label("Wiederherstellen", systemImage: "tray.and.arrow.up") + } + .buttonStyle(.borderless) + .disabled(connectionService.credentials == nil || viewModel.isRestoring) + .help("Diese Sicherung auf den verbundenen Router zurückspielen — nur für exakt dasselbe Routermodell.") } } } @@ -75,6 +164,13 @@ struct BackupListView: View { ) .font(.caption) } + if viewModel.didSendRestore { + Label( + "Wiederherstellung gesendet — der Router startet jetzt neu (kann 1-2 Minuten dauern). Verbinde dich danach im Tab \"Verbinden\" erneut.", + systemImage: "arrow.clockwise" + ) + .font(.caption) + } } .padding(8) .frame(maxWidth: .infinity, alignment: .leading) @@ -149,8 +245,47 @@ struct BackupListView: View { } message: { message in Text(message) } + } + + /// Checks the backup's recorded model against the connected router's own `model` field + /// before ever showing the destructive confirmation dialog — blocks outright on a confirmed + /// mismatch (see `modelMismatchMessage`'s doc comment), proceeds otherwise (including when + /// either side's model is unknown, since refusing every restore just because an old backup + /// predates the "# model =" line would make the feature useless — the confirmation dialog's + /// own text still states plainly when a model couldn't be determined). + private func beginRestore(_ backup: BackupRecord) { + Task { + let backupModel = BackupService.backupModel(from: backup.fileURL) + let currentModel = await currentRouterboardModel() + pendingRestoreCurrentModel = currentModel + if let backupModel, let currentModel, backupModel != currentModel { + modelMismatchMessage = "Diese Sicherung stammt von einem \(backupModel), der verbundene Router meldet sich als \(currentModel). Wiederherstellen abgebrochen, um das Gerät nicht unbrauchbar zu machen (\"brick\")." + return + } + pendingRestoreBackup = backup + showRestoreConfirmation = true } - .onAppear { viewModel.load() } + } + + /// `/system routerboard print`'s `model` field (e.g. "RB750Gr3") — confirmed via research + /// that this, not `ConnectionService.deviceInfo?.boardName` (`/system resource print`'s + /// `board-name`, often a marketing name like "hEX"), is what a backup's own "# model =" + /// header line actually corresponds to; comparing against `boardName` would have produced + /// false mismatches even on the exact same device. + private func currentRouterboardModel() async -> String? { + guard let items = try? await connectionService.fetchMenuItems(menuPath: "/system routerboard", restPath: "system/routerboard") else { + return nil + } + return items.first?.fields["model"] + } + + private var restoreWarningText: String { + let backupModel = pendingRestoreBackup.flatMap { BackupService.backupModel(from: $0.fileURL) } ?? "unbekannt" + let currentModel = pendingRestoreCurrentModel ?? "unbekannt" + let modelLine: String = "WICHTIG: Diese Sicherung darf nur auf genau das Routermodell zurückgespielt werden, von dem sie stammt — sonst kann der Router unbrauchbar werden (\"brick\"). Sicherung: \(backupModel). Verbundener Router: \(currentModel)." + let effectLine: String = "Dies löscht ALLE aktuellen Einstellungen restlos (auch die Werks-Grundkonfiguration, nicht nur deine eigenen Änderungen) und ersetzt sie durch den Inhalt der Sicherung. Der Router startet neu, diese App verliert danach die Verbindung." + let failureLine: String = "Falls die Wiederherstellung fehlschlägt, bleibt der Router leer stehen, nicht auf Werkseinstellungen zurückgefallen." + return [modelLine, effectLine, failureLine].joined(separator: " ") } } diff --git a/RouterOSAssistant/Features/Backup/BackupViewModel.swift b/RouterOSAssistant/Features/Backup/BackupViewModel.swift index 90267c5..077fb4f 100644 --- a/RouterOSAssistant/Features/Backup/BackupViewModel.swift +++ b/RouterOSAssistant/Features/Backup/BackupViewModel.swift @@ -12,6 +12,10 @@ final class BackupViewModel: ObservableObject { @Published var resetError: String? @Published private(set) var didSendFactoryReset = false + @Published private(set) var isRestoring = false + @Published var restoreError: String? + @Published private(set) var didSendRestore = false + private let backupService = BackupService() private let factoryResetService = FactoryResetService() @@ -63,4 +67,22 @@ final class BackupViewModel: ObservableObject { isResettingToFactoryDefaults = false } } + + /// Wipes the router's config and replaces it with `record`'s. Model-compatibility checking + /// happens in the View before this is ever called (needs `ConnectionService.deviceInfo`, + /// which this view model doesn't hold) — this only performs the already-confirmed restore. + func restore(_ record: BackupRecord, for credentials: RouterOSCredentials) { + isRestoring = true + restoreError = nil + didSendRestore = false + Task { + do { + try await backupService.restoreBackup(record, for: credentials) + didSendRestore = true + } catch { + restoreError = error.localizedDescription + } + isRestoring = false + } + } } diff --git a/RouterOSAssistantTests/BackupServiceTests.swift b/RouterOSAssistantTests/BackupServiceTests.swift index 6ba5211..261001b 100644 --- a/RouterOSAssistantTests/BackupServiceTests.swift +++ b/RouterOSAssistantTests/BackupServiceTests.swift @@ -17,4 +17,41 @@ final class BackupServiceTests: XCTestCase { BackupService.customDirectoryURL = url XCTAssertEqual(BackupService.customDirectoryURL?.path, url.path) } + + func testLoginPreservationScriptEscapesQuotesAndBackslashes() { + let script = BackupService.loginPreservationScript(username: "ad\"min", password: "pa\\ss\"word") + XCTAssertTrue(script.contains("name=\"ad\\\"min\"")) + XCTAssertTrue(script.contains("password=\"pa\\\\ss\\\"word\"")) + } + + func testLoginPreservationScriptRecreatesOrRepasswordsCurrentLogin() { + let script = BackupService.loginPreservationScript(username: "admin", password: "secret") + XCTAssertTrue(script.contains("[/user find name=\"admin\"]")) + XCTAssertTrue(script.contains("/user add name=\"admin\" password=\"secret\" group=full")) + XCTAssertTrue(script.contains("/user set [find name=\"admin\"] password=\"secret\"")) + } + + func testBackupModelParsesHeaderLine() { + let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent("test-backup-\(UUID().uuidString).rsc") + let contents = """ + # 2026-09-14 12:00:00 by RouterOS 7.24.2 + # software id = TEST-1234 + # + # model = RB750Gr3 + # serial number = ABC123 + /interface bridge add name=bridge + """ + try? contents.write(to: tempURL, atomically: true, encoding: .utf8) + defer { try? FileManager.default.removeItem(at: tempURL) } + + XCTAssertEqual(BackupService.backupModel(from: tempURL), "RB750Gr3") + } + + func testBackupModelReturnsNilWithoutHeader() { + let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent("test-backup-\(UUID().uuidString).rsc") + try? "/interface bridge add name=bridge".write(to: tempURL, atomically: true, encoding: .utf8) + defer { try? FileManager.default.removeItem(at: tempURL) } + + XCTAssertNil(BackupService.backupModel(from: tempURL)) + } }