forked from kay/RouterOS
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
168 lines
8.5 KiB
Swift
168 lines
8.5 KiB
Swift
import Foundation
|
|
|
|
struct BackupRecord: Identifiable, Equatable {
|
|
let id: String
|
|
let createdAt: Date
|
|
let host: String
|
|
let fileURL: URL
|
|
}
|
|
|
|
/// 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 (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
|
|
|
|
private static let customDirectoryKey = "RouterOSAssistant.BackupDirectoryPath"
|
|
|
|
/// User-chosen backup folder, persisted across launches. `nil` means "use the default".
|
|
static var customDirectoryURL: URL? {
|
|
get {
|
|
guard let path = UserDefaults.standard.string(forKey: customDirectoryKey) else { return nil }
|
|
return URL(fileURLWithPath: path, isDirectory: true)
|
|
}
|
|
set { UserDefaults.standard.set(newValue?.path, forKey: customDirectoryKey) }
|
|
}
|
|
|
|
static var defaultDirectoryURL: URL {
|
|
let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
|
|
return base.appendingPathComponent("RouterOSAssistant/Backups", isDirectory: true)
|
|
}
|
|
|
|
private var backupsDirectory: URL {
|
|
let directory = Self.customDirectoryURL ?? Self.defaultDirectoryURL
|
|
try? fileManager.createDirectory(at: directory, withIntermediateDirectories: true)
|
|
return directory
|
|
}
|
|
|
|
@discardableResult
|
|
func createBackup(for credentials: RouterOSCredentials) async throws -> BackupRecord {
|
|
let transport = SSHTransport(credentials: credentials)
|
|
try await transport.connect()
|
|
|
|
let script: String
|
|
do {
|
|
script = try await transport.exportConfiguration()
|
|
} catch {
|
|
await transport.disconnect()
|
|
throw error
|
|
}
|
|
await transport.disconnect()
|
|
|
|
let timestamp = Self.fileTimestampFormatter.string(from: Date())
|
|
let fileName = "\(credentials.host)_\(timestamp).rsc"
|
|
let fileURL = backupsDirectory.appendingPathComponent(fileName)
|
|
try script.write(to: fileURL, atomically: true, encoding: .utf8)
|
|
|
|
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,
|
|
includingPropertiesForKeys: [.creationDateKey]
|
|
) else {
|
|
return []
|
|
}
|
|
|
|
return files
|
|
.filter { $0.pathExtension == "rsc" }
|
|
.compactMap { url -> BackupRecord? in
|
|
let attributes = try? fileManager.attributesOfItem(atPath: url.path)
|
|
let createdAt = (attributes?[.creationDate] as? Date) ?? Date()
|
|
let host = url.deletingPathExtension().lastPathComponent.components(separatedBy: "_").first ?? "unbekannt"
|
|
return BackupRecord(id: url.lastPathComponent, createdAt: createdAt, host: host, fileURL: url)
|
|
}
|
|
.sorted { $0.createdAt > $1.createdAt }
|
|
}
|
|
|
|
private static let fileTimestampFormatter: DateFormatter = {
|
|
let formatter = DateFormatter()
|
|
formatter.dateFormat = "yyyy-MM-dd_HH-mm-ss"
|
|
return formatter
|
|
}()
|
|
}
|