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:
Kay
2026-09-14 15:21:05 +02:00
co-authored by Claude Sonnet 5
parent 8e99ab8e41
commit 20f8d5e0bf
7 changed files with 502 additions and 70 deletions
@@ -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
}
}
}