Werkseinstellungen wiederherstellen (Gefahrenzone im Sicherungen-Tab)

RouterOS bringt eine eigene Standardkonfiguration mit, wiederherstellbar
über /system reset-configuration no-defaults=no -- restauriert die vom
Hersteller ausgelieferte Konfiguration (nicht eine leere), RouterOS legt
dabei selbst zusätzlich ein Backup an (skip-backup=no, Standard).

FactoryResetService läuft wie BackupService immer über eine eigene
SSH-Verbindung (kein verifiziertes REST-Äquivalent, passt nicht ins
add/set-Modell von RouterOSCommand). Vor dem Zurücksetzen erstellt die
App zusätzlich selbst ein Backup (best-effort). Verbindung zum Router
bricht durch den Reboot erwartungsgemäß ab -- wird nicht als Fehler
behandelt, ConnectionService trennt sich danach selbst.

UI: rot markierte "Gefahrenzone" im Sicherungen-Tab, destruktiver
Bestätigungsdialog vor dem Ausführen, klarer Hinweistext was verloren
geht.

Außerdem .gitignore um /Backups/ und *.rsc ergänzt -- beim Testen des
wählbaren Backup-Ordners landete ein echter Router-Export im
Projektordner, gehört nicht ins Repo.

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-12 20:52:23 +02:00
co-authored by Claude Sonnet 5
parent 9993f84311
commit a93a61c84f
5 changed files with 120 additions and 0 deletions
+5
View File
@@ -12,3 +12,8 @@ xcuserdata/
# Firmware images (large binaries, not app source) # Firmware images (large binaries, not app source)
*.npk *.npk
*.cpgz *.cpgz
# Router config backups (real device data, not app source) — created here when
# testing the app's "choose backup folder" feature with this directory selected.
/Backups/
*.rsc
@@ -70,6 +70,12 @@ final class SSHTransport: RouterOSTransport {
_ = try await run(command.cliLine) _ = try await run(command.cliLine)
} }
/// Restores RouterOS' own vendor-default configuration and reboots the device. See
/// FactoryResetService for why this bypasses the RouterOSCommand add/set model entirely.
func resetToFactoryDefaults() async throws {
_ = try await run("/system reset-configuration no-defaults=no skip-backup=no")
}
/// Runs a command via `executeCommandStream` (not the simpler `executeCommand`), because /// Runs a command via `executeCommandStream` (not the simpler `executeCommand`), because
/// `executeCommand` discards whatever output it already collected the moment the command /// `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 /// exits non-zero exactly the RouterOS error text we need. Collecting the stream ourselves
@@ -0,0 +1,21 @@
import Foundation
/// Restores the router's own vendor-default configuration via RouterOS' built-in
/// `/system reset-configuration` not a restore of one of our own backup files, the actual
/// factory default the device shipped with. Always runs over a dedicated SSH connection, same
/// reasoning as BackupService: this is a one-shot system action, not a menu item to add/set,
/// so it doesn't fit the REST-mirrors-menu-items model RouterOSCommand assumes, and there's no
/// verified REST equivalent to fall back on.
final class FactoryResetService {
/// `no-defaults=no` restores the vendor default config (not a blank slate); RouterOS'
/// own `skip-backup=no` default already saves an automatic backup on the router itself
/// right before resetting, on top of whatever backup this app already made.
func resetToVendorDefaults(for credentials: RouterOSCredentials) async throws {
let transport = SSHTransport(credentials: credentials)
try await transport.connect()
// The router reboots immediately after this command; the connection dying mid-command
// instead of returning a clean response is the expected outcome here, not a failure.
try? await transport.resetToFactoryDefaults()
await transport.disconnect()
}
}
@@ -5,6 +5,7 @@ struct BackupListView: View {
@ObservedObject var connectionService: ConnectionService @ObservedObject var connectionService: ConnectionService
@StateObject private var viewModel = BackupViewModel() @StateObject private var viewModel = BackupViewModel()
@State private var showFolderPicker = false @State private var showFolderPicker = false
@State private var showFactoryResetConfirmation = false
var body: some View { var body: some View {
NavigationStack { NavigationStack {
@@ -42,6 +43,38 @@ struct BackupListView: View {
Button("Standard verwenden") { viewModel.resetBackupDirectoryToDefault() } Button("Standard verwenden") { viewModel.resetBackupDirectoryToDefault() }
.help("Setzt den Speicherort zurück auf den App-eigenen Standardordner.") .help("Setzt den Speicherort zurück auf den App-eigenen Standardordner.")
} }
Divider()
.padding(.vertical, 4)
Text("Gefahrenzone")
.font(.caption.bold())
.foregroundStyle(.red)
Text("Setzt den Router komplett auf die vom Hersteller mitgelieferte Standardkonfiguration zurück — alle bisherigen Änderungen (Internet, Heimnetz, VLANs, WLAN, Firewall) gehen verloren. Der Router startet danach neu.")
.font(.caption)
.foregroundStyle(.secondary)
Button(role: .destructive) {
showFactoryResetConfirmation = true
} label: {
if viewModel.isResettingToFactoryDefaults {
HStack {
ProgressView()
Text("Setze zurück…")
}
} else {
Label("Werkseinstellungen wiederherstellen", systemImage: "exclamationmark.triangle")
}
}
.disabled(connectionService.credentials == nil || viewModel.isResettingToFactoryDefaults)
.help("Stellt die vom Hersteller vorinstallierte Standardkonfiguration des Routers wieder her. Nur für den Notfall, falls etwas schiefgelaufen ist.")
if viewModel.didSendFactoryReset {
Label(
"Befehl 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) .padding(8)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
@@ -55,6 +88,25 @@ struct BackupListView: View {
viewModel.setBackupDirectory(url) viewModel.setBackupDirectory(url)
} }
} }
.confirmationDialog(
"Werkseinstellungen wirklich wiederherstellen?",
isPresented: $showFactoryResetConfirmation,
titleVisibility: .visible
) {
Button("Zurücksetzen", role: .destructive) {
if let credentials = connectionService.credentials {
viewModel.resetToFactoryDefaults(for: credentials)
}
}
Button("Abbrechen", role: .cancel) {}
} message: {
Text("Dies löscht ALLE bisherigen Einstellungen (Internet, Heimnetz, VLANs, WLAN, Firewall) und setzt den Router auf die Werks-Standardkonfiguration zurück. Der Router startet neu, diese App verliert danach die Verbindung. Vorher wird zusätzlich automatisch eine Sicherung erstellt.")
}
.onChange(of: viewModel.didSendFactoryReset) { _, didSend in
if didSend {
Task { await connectionService.disconnect() }
}
}
.navigationTitle("Sicherungen") .navigationTitle("Sicherungen")
.toolbar { .toolbar {
ToolbarItem { ToolbarItem {
@@ -85,6 +137,18 @@ struct BackupListView: View {
} message: { message in } message: { message in
Text(message) Text(message)
} }
.alert(
"Zurücksetzen fehlgeschlagen",
isPresented: Binding(
get: { viewModel.resetError != nil },
set: { _ in viewModel.resetError = nil }
),
presenting: viewModel.resetError
) { _ in
Button("OK") {}
} message: { message in
Text(message)
}
} }
.onAppear { viewModel.load() } .onAppear { viewModel.load() }
} }
@@ -8,7 +8,12 @@ final class BackupViewModel: ObservableObject {
@Published private(set) var backupDirectoryPath: String = BackupService.customDirectoryURL?.path @Published private(set) var backupDirectoryPath: String = BackupService.customDirectoryURL?.path
?? BackupService.defaultDirectoryURL.path ?? BackupService.defaultDirectoryURL.path
@Published private(set) var isResettingToFactoryDefaults = false
@Published var resetError: String?
@Published private(set) var didSendFactoryReset = false
private let backupService = BackupService() private let backupService = BackupService()
private let factoryResetService = FactoryResetService()
func load() { func load() {
backups = backupService.listBackups() backups = backupService.listBackups()
@@ -39,4 +44,23 @@ final class BackupViewModel: ObservableObject {
isCreatingBackup = false isCreatingBackup = false
} }
} }
/// Backs up first (best-effort a failed backup shouldn't block the reset itself, since
/// RouterOS makes its own backup as part of the reset anyway), then restores the router's
/// vendor-default configuration and reboots it.
func resetToFactoryDefaults(for credentials: RouterOSCredentials) {
isResettingToFactoryDefaults = true
resetError = nil
didSendFactoryReset = false
Task {
do {
_ = try? await backupService.createBackup(for: credentials)
try await factoryResetService.resetToVendorDefaults(for: credentials)
didSendFactoryReset = true
} catch {
resetError = error.localizedDescription
}
isResettingToFactoryDefaults = false
}
}
} }