Files
RouterOS/RouterOSAssistant/Features/Wizard/Steps/Connect/ConnectViewModel.swift
T
KayandClaude Sonnet 5 3034d8b874 Bug 37: SSH-Hostkey-Trust bei REST-Verbindungen proaktiv etablieren
Dedizierte SSH-Dienste (BackupService u.a.) sind der erste echte
SSH-Kontakt zu einem per REST verbundenen Router und trafen dort auf
einen unbestätigten Hostkey ohne Trust-UI (nur ReviewApplyView/
ExpertMenuDetailView-Fehlertext, kein Bestätigungsweg). Fix: nach
jedem erfolgreichen REST-Connect prüft ConnectionService den
SSH-Hostkey einmalig im Hintergrund und zeigt bei Bedarf denselben
Trust-Dialog wie der SSH-Fallback, ohne die aktive REST-Verbindung neu
aufzubauen. Live bestätigt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 16:23:33 +02:00

203 lines
8.2 KiB
Swift

import Foundation
@MainActor
final class ConnectViewModel: ObservableObject {
@Published var host: String = "192.168.88.1"
@Published var username: String = "admin"
@Published var password: String = ""
@Published var rememberPassword: Bool = true
@Published private(set) var savedRouters: [SavedRouter] = []
@Published private(set) var packageUpdateInfo: PackageUpdateInfo?
@Published private(set) var isCheckingForUpdates = false
@Published var updateCheckError: String?
@Published private(set) var isInstallingUpdate = false
@Published var installUpdateError: String?
@Published private(set) var didSendUpdateInstall = false
@Published private(set) var isUpgradingFirmware = false
@Published var firmwareUpgradeError: String?
@Published var firmwareUpgradeResultMessage: String?
@Published private(set) var isRebooting = false
@Published var rebootError: String?
@Published private(set) var didSendReboot = false
@Published private(set) var interfaceTraffic: [String: InterfaceTraffic] = [:]
let connectionService: ConnectionService
private let keychain = KeychainService()
private let updateService = UpdateService()
private let savedRoutersStore = SavedRoutersStore()
private let trafficMonitor = InterfaceTrafficMonitor()
private var trafficPollingTask: Task<Void, Never>?
init(connectionService: ConnectionService) {
self.connectionService = connectionService
}
func onAppear() {
savedRouters = savedRoutersStore.load()
if let saved = keychain.loadPassword(forHost: host, username: username) {
password = saved
}
}
/// Fills in a known router's host/username (and its remembered password, if any) without
/// connecting yet — the user still reviews/confirms via the normal "Verbinden" button.
func selectSavedRouter(_ router: SavedRouter) {
host = router.host
username = router.username
password = keychain.loadPassword(forHost: router.host, username: router.username, serialNumber: router.serialNumber) ?? ""
}
func renameSavedRouter(_ id: SavedRouter.ID, to newName: String) {
savedRouters = savedRoutersStore.rename(id, to: newName)
}
func updateSavedRouterLocation(_ id: SavedRouter.ID, to newLocation: String) {
savedRouters = savedRoutersStore.updateLocation(id, to: newLocation)
}
func removeSavedRouter(_ id: SavedRouter.ID) {
savedRouters = savedRoutersStore.remove(id)
}
func connect() {
let credentials = RouterOSCredentials(host: host, username: username, password: password)
Task {
await connectionService.connect(with: credentials)
if case .connected = connectionService.state {
let defaultName = connectionService.deviceInfo?.boardName ?? host
// "unbekannt" is RouterOSModels' own fallback when RouterOS reports the
// routerboard menu but leaves this one field out — treated as "no real serial"
// here too, or two such devices would falsely look like the same one.
let rawSerial = connectionService.routerBoardInfo?.serialNumber
let serialNumber = (rawSerial?.isEmpty == false && rawSerial != "unbekannt") ? rawSerial : nil
// Saved only now, not before attempting the connection — the serial (needed to
// keep two same-host/username devices' passwords apart, see `KeychainService`'s
// doc comment) isn't known until the connection actually succeeds. Confirmed
// live (2026-09-16): saving unqualified beforehand made both of two same-model
// routers, left at MikroTik's factory default, silently share one Keychain
// entry — reconnecting to either one always loaded whichever password was saved
// most recently, regardless of which device the user actually selected.
if rememberPassword {
keychain.save(password: password, forHost: host, username: username, serialNumber: serialNumber)
}
savedRouters = savedRoutersStore.recordSuccessfulConnection(
host: host, username: username, defaultName: defaultName, serialNumber: serialNumber
)
}
}
}
/// Starts (or restarts, if already running) periodic traffic polling for whatever interfaces
/// `connectionService.interfaces` currently lists — re-read every tick so newly-appearing
/// interfaces (e.g. a VLAN added via the Setup wizard) get picked up without restarting the
/// poll. 3s cadence: fast enough to feel "live" for a status dot, not so fast it noticeably
/// loads a home router's CPU with a constant stream of SSH round-trips.
func startTrafficPolling(credentials: RouterOSCredentials) {
stopTrafficPolling()
trafficPollingTask = Task {
while !Task.isCancelled {
let names = connectionService.interfaces.map(\.name)
if !names.isEmpty {
let traffic = await trafficMonitor.fetchTraffic(interfaceNames: names, for: credentials)
if !Task.isCancelled {
interfaceTraffic = traffic
}
}
try? await Task.sleep(for: .seconds(3))
}
}
}
func stopTrafficPolling() {
trafficPollingTask?.cancel()
trafficPollingTask = nil
interfaceTraffic = [:]
Task { await trafficMonitor.disconnect() }
}
func trustAndRetry(fingerprint: String) {
Task {
await connectionService.trustCurrentCertificateAndRetry(fingerprint: fingerprint)
}
}
func trustSSHHostKeyAndRetry(fingerprint: String) {
// Two distinct sources share this one confirmation alert (see `ConnectionService.
// pendingSSHTrustFingerprint`'s doc comment): a blocked initial SSH-fallback connect
// needs a full reconnect, a REST connection's background trust probe doesn't.
if case .needsSSHHostKeyConfirmation = connectionService.state {
Task {
await connectionService.trustCurrentSSHHostKeyAndRetry(fingerprint: fingerprint)
}
} else {
connectionService.trustPendingSSHHostKeyAndRetry()
}
}
func dismissSSHHostKeyPrompt() {
connectionService.dismissPendingSSHTrust()
}
func checkForUpdates(for credentials: RouterOSCredentials) {
isCheckingForUpdates = true
updateCheckError = nil
Task {
do {
packageUpdateInfo = try await updateService.checkForUpdates(for: credentials)
} catch {
updateCheckError = error.localizedDescription
}
isCheckingForUpdates = false
}
}
func installUpdate(for credentials: RouterOSCredentials) {
isInstallingUpdate = true
installUpdateError = nil
didSendUpdateInstall = false
Task {
do {
try await updateService.installUpdate(for: credentials)
didSendUpdateInstall = true
} catch {
installUpdateError = error.localizedDescription
}
isInstallingUpdate = false
}
}
func upgradeRouterboardFirmware(for credentials: RouterOSCredentials) {
isUpgradingFirmware = true
firmwareUpgradeError = nil
firmwareUpgradeResultMessage = nil
Task {
do {
firmwareUpgradeResultMessage = try await updateService.upgradeRouterboardFirmware(for: credentials)
} catch {
firmwareUpgradeError = error.localizedDescription
}
isUpgradingFirmware = false
}
}
func reboot(for credentials: RouterOSCredentials) {
isRebooting = true
rebootError = nil
didSendReboot = false
Task {
do {
try await updateService.reboot(for: credentials)
didSendReboot = true
} catch {
rebootError = error.localizedDescription
}
isRebooting = false
}
}
}