forked from kay/RouterOS
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:
@@ -2,9 +2,17 @@ import SwiftUI
|
|||||||
|
|
||||||
@main
|
@main
|
||||||
struct RouterOSAssistantApp: App {
|
struct RouterOSAssistantApp: App {
|
||||||
|
@StateObject private var session = SessionStore()
|
||||||
|
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
WindowGroup {
|
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
|
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 {
|
private func run(_ command: String) async throws -> String {
|
||||||
guard let client else { throw RouterOSError.notConnected }
|
guard let client else { throw RouterOSError.notConnected }
|
||||||
let buffer = try await client.executeCommand(command)
|
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
|
import SwiftUI
|
||||||
|
|
||||||
struct ConnectView: View {
|
struct ConnectView: View {
|
||||||
|
@EnvironmentObject private var session: SessionStore
|
||||||
@StateObject private var viewModel = ConnectViewModel()
|
@StateObject private var viewModel = ConnectViewModel()
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -28,6 +29,15 @@ struct ConnectView: View {
|
|||||||
deviceDetail
|
deviceDetail
|
||||||
}
|
}
|
||||||
.onAppear { viewModel.onAppear() }
|
.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(
|
.alert(
|
||||||
"Unbekanntes Zertifikat",
|
"Unbekanntes Zertifikat",
|
||||||
isPresented: certificateAlertBinding,
|
isPresented: certificateAlertBinding,
|
||||||
@@ -116,5 +126,5 @@ struct ConnectView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
ConnectView()
|
ConnectView().environmentObject(SessionStore())
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user