diff --git a/HANDOFF.md b/HANDOFF.md index cdc7c6b..e2b9bb8 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -994,8 +994,13 @@ Unit-Tests grün (inkl. neuer `ExpertViewModelTests` und ("Signal Router"-Motiv). - 🔶 M7: Härtung — SSH-Hostkey-TOFU **fertig, gegen echte Hardware bestätigt** (inkl. neuem "Trennen"-Button im Verbinden-Tab, der dafür - nötig wurde). Fehlerzustände/Politur und REST-Schreibpfad-Verifikation - gegen ein Gerät mit aktivem `www-ssl` stehen noch aus. + nötig wurde). REST-Schreibpfad gegen `www-ssl` seit 2026-09-16 verifiziert + (Bug 30–32). Dabei zusätzlich Bug 37 gefixt: REST-Erstverbindungen + etablieren jetzt automatisch auch SSH-Trust im Hintergrund, statt dass + dedizierte SSH-Dienste (Backup u.a.) beim ersten Zugriff mit einem + unbestätigbaren Hostkey-Fehler dead-enden (live bestätigt, "passt"). + Verbleibend: REST-Fehlerzustände bei Verbindungsabbruch mitten im Apply + noch nicht gezielt geprüft. - ✅ M8: Mehrere LAN-Interfaces mit eigenem DHCP + Netzwerk-Isolation (eigene Firewall-Regeln pro LAN/VLAN) — **live gegen Hardware verifiziert**, sowohl manuell (SSH) als auch über den App-Wizard @@ -1802,7 +1807,31 @@ verallgemeinert** (2026-09-16, beim Live-Test von M26 gefunden): (war er) — DHCP-Netzwerk-Anlage danach live erfolgreich. Falls dieses Symptom nochmal auftritt: zuerst per `defaults read` prüfen, ob der Trust wirklich gespeichert wurde, nicht nur der UI-Bestätigung - vertrauen. + vertrauen. **Bug 37, richtig gefixt (2026-09-16):** genau dieses Symptom + erneut live reproduziert (neuer Testrouter, REST verbunden, erster + Wizard-Apply scheiterte mit "Unbekannter SSH-Schlüssel", reiner + `applyError`-Text ohne Bestätigungsmöglichkeit in `ReviewApplyView`). + Root Cause diesmal wirklich behoben statt nur umgangen: `ConnectionService. + connect` kehrt bei erfolgreichem REST-Connect sofort zurück, ohne je + einen SSH-Verbindungsversuch zu machen — der SSH-Host-Key dieses Hosts + wurde also nie geprüft/vertraut, `SSHHostKeyTrustStore` hatte schlicht + keinen Eintrag dafür. Dedizierte SSH-Dienste (`BackupService` u.a., s.o.) + sind aber der erste tatsächliche SSH-Kontakt zu diesem Host — treffen + dort auf einen komplett neuen, unbestätigten Schlüssel, aber `ReviewApplyView`/ + `ExpertMenuDetailView` haben nur ein simples `.alert` mit "OK", keinen + Trust-Button. Fix: neues `ConnectionService.pendingSSHTrustFingerprint` + (getrennt von `state`) — nach jedem erfolgreichen REST-Connect prüft + `verifySSHTrust` einmalig per Wegwerf-`SSHTransport` den Host-Key im + Hintergrund; bei `untrustedSSHHostKey` wird der Fingerabdruck dort + abgelegt, `ConnectView` zeigt dafür denselben Trust-Dialog wie beim + reinen SSH-Fallback (`sshHostKeyFingerprint` liest jetzt beide Quellen), + Bestätigen ruft `trustPendingSSHHostKeyAndRetry()` (kein Reconnect nötig, + REST-Verbindung bleibt unberührt) statt `trustCurrentSSHHostKeyAndRetry` + (das reconnectet komplett neu, nur für den echten Fallback-Fall + gebraucht). Damit ist der Host-Key schon beim Verbinden bestätigt, lange + bevor Backup/Wizard-Apply/Experte-Tab ihn zum ersten Mal brauchen. Live + bestätigt ("passt") — REST-Connect zum Testrouter zeigte den Trust-Dialog + sofort, danach lief ein Wizard-Apply ohne den Dead-End-Fehler durch. 14. ~~M26 (Seriennummer bei "Bekannte Router") noch live testen~~ — erledigt, siehe M27 oben (dabei zwei weitere Bugs gefunden und gefixt: geteilter Schlüsselbund-Eintrag, `/system routerboard`s diff --git a/RouterOSAssistant/Core/Services/ConnectionService.swift b/RouterOSAssistant/Core/Services/ConnectionService.swift index 732dad0..dc22b20 100644 --- a/RouterOSAssistant/Core/Services/ConnectionService.swift +++ b/RouterOSAssistant/Core/Services/ConnectionService.swift @@ -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 } } diff --git a/RouterOSAssistant/Features/Wizard/Steps/Connect/ConnectView.swift b/RouterOSAssistant/Features/Wizard/Steps/Connect/ConnectView.swift index 3dff1a3..1f754d6 100644 --- a/RouterOSAssistant/Features/Wizard/Steps/Connect/ConnectView.swift +++ b/RouterOSAssistant/Features/Wizard/Steps/Connect/ConnectView.swift @@ -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 { diff --git a/RouterOSAssistant/Features/Wizard/Steps/Connect/ConnectViewModel.swift b/RouterOSAssistant/Features/Wizard/Steps/Connect/ConnectViewModel.swift index b31c56e..d83a81c 100644 --- a/RouterOSAssistant/Features/Wizard/Steps/Connect/ConnectViewModel.swift +++ b/RouterOSAssistant/Features/Wizard/Steps/Connect/ConnectViewModel.swift @@ -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