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>
This commit is contained in:
Kay
2026-09-16 16:23:33 +02:00
co-authored by Claude Sonnet 5
parent 9de9311bee
commit 3034d8b874
4 changed files with 94 additions and 7 deletions
@@ -16,6 +16,16 @@ final class ConnectionService: ObservableObject {
@Published private(set) var deviceInfo: RouterDeviceInfo?
@Published private(set) var routerBoardInfo: RouterBoardInfo?
@Published private(set) var interfaces: [NetworkInterface] = []
/// Set when a REST connection succeeds but the router's SSH host key is still untrusted
/// distinct from `state`'s `.needsSSHHostKeyConfirmation` (which blocks the initial connect
/// attempt itself). Dedicated SSH-only services (`BackupService`, `UpdateService`,
/// `NetworkToolsService`, `InterfaceTrafficMonitor`, `FactoryResetService`) always need SSH
/// regardless of the active transport a REST-first connection never establishes SSH trust
/// on its own, so their first real use (e.g. the automatic pre-apply backup) used to dead-end
/// with a raw "Unbekannter SSH-Schlüssel" error and no way to confirm it (Bug 13, see
/// HANDOFF.md). Checked proactively right after every successful REST connect instead, so
/// trust is already established by the time any of those services runs.
@Published private(set) var pendingSSHTrustFingerprint: String?
/// Credentials of the current (or last attempted) connection, shared with features
/// that need their own dedicated connection, e.g. BackupService's SSH export.
@Published private(set) var credentials: RouterOSCredentials?
@@ -50,6 +60,7 @@ final class ConnectionService: ObservableObject {
do {
try await rest.connect()
await finishConnecting(using: rest)
await verifySSHTrust(makeSSHTransport: makeSSHTransport)
return
} catch RouterOSError.untrustedCertificate(let fingerprint) {
state = .needsCertificateConfirmation(fingerprint: fingerprint)
@@ -89,6 +100,39 @@ final class ConnectionService: ObservableObject {
await connect(with: credentials)
}
/// Confirms `pendingSSHTrustFingerprint` unlike `trustCurrentSSHHostKeyAndRetry`, no
/// reconnect needed: the active REST transport is unaffected, this only unblocks whichever
/// dedicated SSH service runs next (see `pendingSSHTrustFingerprint`'s doc comment).
func trustPendingSSHHostKeyAndRetry() {
guard let credentials, let fingerprint = pendingSSHTrustFingerprint else { return }
sshHostKeyTrust.trust(host: credentials.host, fingerprint: fingerprint)
pendingSSHTrustFingerprint = nil
}
func dismissPendingSSHTrust() {
pendingSSHTrustFingerprint = nil
}
/// Best-effort SSH host-key probe run once right after a successful REST connect see
/// `pendingSSHTrustFingerprint`'s doc comment for why this is needed at all. A throwaway SSH
/// connection is the only way to learn the router's host key (there's no way to ask for it
/// without actually connecting); closed immediately after, since this connection is never
/// otherwise used. Any error other than `untrustedSSHHostKey` is swallowed deliberately REST
/// already connected successfully, so e.g. a wrong SSH port or a transient network hiccup here
/// must not disrupt the working connection; it'll surface normally whenever a dedicated SSH
/// service actually needs it.
private func verifySSHTrust(makeSSHTransport: () -> RouterOSTransport) async {
let ssh = makeSSHTransport()
do {
try await ssh.connect()
await ssh.disconnect()
} catch RouterOSError.untrustedSSHHostKey(let fingerprint) {
pendingSSHTrustFingerprint = fingerprint
} catch {
// Swallowed see doc comment above.
}
}
/// Applies a single configuration change on the active transport (REST or SSH).
func apply(_ command: RouterOSCommand) async throws {
guard let activeTransport else { throw RouterOSError.notConnected }
@@ -183,6 +227,7 @@ final class ConnectionService: ObservableObject {
interfaces = []
credentials = nil
hasExpertToolBackedUpThisSession = false
pendingSSHTrustFingerprint = nil
state = .idle
}
}
@@ -146,7 +146,9 @@ struct ConnectView: View {
Button(L10n.t("Vertrauen und verbinden", appLanguage)) {
viewModel.trustSSHHostKeyAndRetry(fingerprint: fingerprint)
}
Button(L10n.t("Abbrechen", appLanguage), role: .cancel) {}
Button(L10n.t("Abbrechen", appLanguage), role: .cancel) {
viewModel.dismissSSHHostKeyPrompt()
}
} message: { fingerprint in
Text(L10n.t("Der Router hat sich mit einem unbekannten SSH-Schlüssel gemeldet.", appLanguage)
+ "\n" + L10n.t("Fingerabdruck:", appLanguage) + " \(fingerprint)\n\n"
@@ -184,7 +186,7 @@ struct ConnectView: View {
if case .needsSSHHostKeyConfirmation(let fingerprint) = connectionService.state {
return fingerprint
}
return nil
return connectionService.pendingSSHTrustFingerprint
}
private var sshHostKeyAlertBinding: Binding<Bool> {
@@ -127,11 +127,22 @@ final class ConnectViewModel: ObservableObject {
}
func trustSSHHostKeyAndRetry(fingerprint: String) {
Task {
await connectionService.trustCurrentSSHHostKeyAndRetry(fingerprint: fingerprint)
// 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