forked from kay/RouterOS
M13: Backup-Wiederherstellung, mit Modell-Schutz und Login-Erhalt
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=<datei> (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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CTgRxJTzaQwaRkngbaE1GJ
This commit is contained in:
+73
@@ -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).
|
||||
|
||||
+114
-62
@@ -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 <id> 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 [<Pfad> find]` und `<Pfad> 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 [<Pfad> find]` und `<Pfad> 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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: " ")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user