diff --git a/.gitignore b/.gitignore index 6efcc5e..b9f5f15 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,8 @@ xcuserdata/ # Firmware images (large binaries, not app source) *.npk *.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 diff --git a/RouterOSAssistant/Core/Networking/SSHTransport.swift b/RouterOSAssistant/Core/Networking/SSHTransport.swift index 515f17f..d64b501 100644 --- a/RouterOSAssistant/Core/Networking/SSHTransport.swift +++ b/RouterOSAssistant/Core/Networking/SSHTransport.swift @@ -70,6 +70,12 @@ final class SSHTransport: RouterOSTransport { _ = 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 /// `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 diff --git a/RouterOSAssistant/Core/Services/FactoryResetService.swift b/RouterOSAssistant/Core/Services/FactoryResetService.swift new file mode 100644 index 0000000..2f33fb6 --- /dev/null +++ b/RouterOSAssistant/Core/Services/FactoryResetService.swift @@ -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() + } +} diff --git a/RouterOSAssistant/Features/Backup/BackupListView.swift b/RouterOSAssistant/Features/Backup/BackupListView.swift index 90a03c4..3bfda16 100644 --- a/RouterOSAssistant/Features/Backup/BackupListView.swift +++ b/RouterOSAssistant/Features/Backup/BackupListView.swift @@ -5,6 +5,7 @@ struct BackupListView: View { @ObservedObject var connectionService: ConnectionService @StateObject private var viewModel = BackupViewModel() @State private var showFolderPicker = false + @State private var showFactoryResetConfirmation = false var body: some View { NavigationStack { @@ -42,6 +43,38 @@ struct BackupListView: View { Button("Standard verwenden") { viewModel.resetBackupDirectoryToDefault() } .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) .frame(maxWidth: .infinity, alignment: .leading) @@ -55,6 +88,25 @@ struct BackupListView: View { 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") .toolbar { ToolbarItem { @@ -85,6 +137,18 @@ struct BackupListView: View { } message: { message in 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() } } diff --git a/RouterOSAssistant/Features/Backup/BackupViewModel.swift b/RouterOSAssistant/Features/Backup/BackupViewModel.swift index dd14c8a..90267c5 100644 --- a/RouterOSAssistant/Features/Backup/BackupViewModel.swift +++ b/RouterOSAssistant/Features/Backup/BackupViewModel.swift @@ -8,7 +8,12 @@ final class BackupViewModel: ObservableObject { @Published private(set) var backupDirectoryPath: String = BackupService.customDirectoryURL?.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 factoryResetService = FactoryResetService() func load() { backups = backupService.listBackups() @@ -39,4 +44,23 @@ final class BackupViewModel: ObservableObject { 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 + } + } }