M2: Backup-Service + Sicherungen-Tab

Config-Export (/export terse) über eine dedizierte SSH-Verbindung,
unabhängig vom aktiven Live-Transport (REST oder SSH) — RouterOS' REST-API
hat keinen generischen Export-Endpunkt. Lokale Sicherungen unter
~/Library/Application Support/RouterOSAssistant/Backups/. Neuer
"Sicherungen"-Tab mit manuellem "Jetzt sichern"-Button, SessionStore
teilt die Zugangsdaten der aktiven Verbindung zwischen den Tabs.

Der bestehende Connect-Schritt dient bereits als reiner Lese-Modus
(nur GET-Aufrufe) — ein zusätzlicher Dry-Run-Schalter entfällt, da es
vor M3 noch keine Schreibpfade gibt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HReLXMbmPvtQ23p1iWiJNW
This commit is contained in:
Kay
2026-09-11 20:58:39 +02:00
co-authored by Claude Sonnet 5
parent 63734ef7fe
commit c4ed26b8dd
7 changed files with 197 additions and 2 deletions
@@ -2,9 +2,17 @@ import SwiftUI
@main
struct RouterOSAssistantApp: App {
@StateObject private var session = SessionStore()
var body: some Scene {
WindowGroup {
ConnectView()
TabView {
ConnectView()
.tabItem { Label("Verbinden", systemImage: "network") }
BackupListView()
.tabItem { Label("Sicherungen", systemImage: "clock.arrow.circlepath") }
}
.environmentObject(session)
}
}
}
@@ -46,6 +46,11 @@ final class SSHTransport: RouterOSTransport {
client = nil
}
/// Full human-readable config export (`/export terse`), used for local backups.
func exportConfiguration() async throws -> String {
try await run("/export terse")
}
private func run(_ command: String) async throws -> String {
guard let client else { throw RouterOSError.notConnected }
let buffer = try await client.executeCommand(command)
@@ -0,0 +1,72 @@
import Foundation
struct BackupRecord: Identifiable, Equatable {
let id: String
let createdAt: Date
let host: String
let fileURL: URL
}
/// Creates and lists 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).
final class BackupService {
private let fileManager = FileManager.default
private var backupsDirectory: URL {
let base = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
let directory = base.appendingPathComponent("RouterOSAssistant/Backups", isDirectory: true)
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)
}
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
}()
}
@@ -0,0 +1,8 @@
import Foundation
/// Holds the currently connected router's credentials so other screens (e.g. Backup)
/// can act on the same device without re-prompting for login.
@MainActor
final class SessionStore: ObservableObject {
@Published var credentials: RouterOSCredentials?
}
@@ -0,0 +1,64 @@
import SwiftUI
struct BackupListView: View {
@EnvironmentObject private var session: SessionStore
@StateObject private var viewModel = BackupViewModel()
var body: some View {
NavigationStack {
Group {
if viewModel.backups.isEmpty {
ContentUnavailableView(
"Keine Sicherungen",
systemImage: "clock.arrow.circlepath",
description: Text("Erstelle eine Sicherung, bevor du Änderungen am Router vornimmst.")
)
} 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)
}
}
}
}
.navigationTitle("Sicherungen")
.toolbar {
ToolbarItem {
Button {
if let credentials = session.credentials {
viewModel.createBackup(for: credentials)
}
} label: {
if viewModel.isCreatingBackup {
ProgressView()
} else {
Label("Jetzt sichern", systemImage: "square.and.arrow.down")
}
}
.disabled(session.credentials == nil || viewModel.isCreatingBackup)
.help(session.credentials == nil ? "Zuerst im Tab 'Verbinden' mit dem Router verbinden." : "Sicherung jetzt erstellen")
}
}
.alert(
"Sicherung fehlgeschlagen",
isPresented: Binding(
get: { viewModel.errorMessage != nil },
set: { _ in viewModel.errorMessage = nil }
),
presenting: viewModel.errorMessage
) { _ in
Button("OK") {}
} message: { message in
Text(message)
}
}
.onAppear { viewModel.load() }
}
}
#Preview {
BackupListView().environmentObject(SessionStore())
}
@@ -0,0 +1,28 @@
import Foundation
@MainActor
final class BackupViewModel: ObservableObject {
@Published private(set) var backups: [BackupRecord] = []
@Published private(set) var isCreatingBackup = false
@Published var errorMessage: String?
private let backupService = BackupService()
func load() {
backups = backupService.listBackups()
}
func createBackup(for credentials: RouterOSCredentials) {
isCreatingBackup = true
errorMessage = nil
Task {
do {
_ = try await backupService.createBackup(for: credentials)
load()
} catch {
errorMessage = error.localizedDescription
}
isCreatingBackup = false
}
}
}
@@ -1,6 +1,7 @@
import SwiftUI
struct ConnectView: View {
@EnvironmentObject private var session: SessionStore
@StateObject private var viewModel = ConnectViewModel()
var body: some View {
@@ -28,6 +29,15 @@ struct ConnectView: View {
deviceDetail
}
.onAppear { viewModel.onAppear() }
.onChange(of: viewModel.connectionService.state) { _, newState in
if case .connected = newState {
session.credentials = RouterOSCredentials(
host: viewModel.host,
username: viewModel.username,
password: viewModel.password
)
}
}
.alert(
"Unbekanntes Zertifikat",
isPresented: certificateAlertBinding,
@@ -116,5 +126,5 @@ struct ConnectView: View {
}
#Preview {
ConnectView()
ConnectView().environmentObject(SessionStore())
}