forked from kay/RouterOS
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:
+32
-3
@@ -994,8 +994,13 @@ Unit-Tests grün (inkl. neuer `ExpertViewModelTests` und
|
|||||||
("Signal Router"-Motiv).
|
("Signal Router"-Motiv).
|
||||||
- 🔶 M7: Härtung — SSH-Hostkey-TOFU **fertig, gegen echte Hardware
|
- 🔶 M7: Härtung — SSH-Hostkey-TOFU **fertig, gegen echte Hardware
|
||||||
bestätigt** (inkl. neuem "Trennen"-Button im Verbinden-Tab, der dafür
|
bestätigt** (inkl. neuem "Trennen"-Button im Verbinden-Tab, der dafür
|
||||||
nötig wurde). Fehlerzustände/Politur und REST-Schreibpfad-Verifikation
|
nötig wurde). REST-Schreibpfad gegen `www-ssl` seit 2026-09-16 verifiziert
|
||||||
gegen ein Gerät mit aktivem `www-ssl` stehen noch aus.
|
(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
|
- ✅ M8: Mehrere LAN-Interfaces mit eigenem DHCP + Netzwerk-Isolation
|
||||||
(eigene Firewall-Regeln pro LAN/VLAN) — **live gegen Hardware
|
(eigene Firewall-Regeln pro LAN/VLAN) — **live gegen Hardware
|
||||||
verifiziert**, sowohl manuell (SSH) als auch über den App-Wizard
|
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
|
(war er) — DHCP-Netzwerk-Anlage danach live erfolgreich. Falls
|
||||||
dieses Symptom nochmal auftritt: zuerst per `defaults read` prüfen,
|
dieses Symptom nochmal auftritt: zuerst per `defaults read` prüfen,
|
||||||
ob der Trust wirklich gespeichert wurde, nicht nur der UI-Bestätigung
|
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~~ —
|
14. ~~M26 (Seriennummer bei "Bekannte Router") noch live testen~~ —
|
||||||
erledigt, siehe M27 oben (dabei zwei weitere Bugs gefunden und
|
erledigt, siehe M27 oben (dabei zwei weitere Bugs gefunden und
|
||||||
gefixt: geteilter Schlüsselbund-Eintrag, `/system routerboard`s
|
gefixt: geteilter Schlüsselbund-Eintrag, `/system routerboard`s
|
||||||
|
|||||||
@@ -16,6 +16,16 @@ final class ConnectionService: ObservableObject {
|
|||||||
@Published private(set) var deviceInfo: RouterDeviceInfo?
|
@Published private(set) var deviceInfo: RouterDeviceInfo?
|
||||||
@Published private(set) var routerBoardInfo: RouterBoardInfo?
|
@Published private(set) var routerBoardInfo: RouterBoardInfo?
|
||||||
@Published private(set) var interfaces: [NetworkInterface] = []
|
@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
|
/// Credentials of the current (or last attempted) connection, shared with features
|
||||||
/// that need their own dedicated connection, e.g. BackupService's SSH export.
|
/// that need their own dedicated connection, e.g. BackupService's SSH export.
|
||||||
@Published private(set) var credentials: RouterOSCredentials?
|
@Published private(set) var credentials: RouterOSCredentials?
|
||||||
@@ -50,6 +60,7 @@ final class ConnectionService: ObservableObject {
|
|||||||
do {
|
do {
|
||||||
try await rest.connect()
|
try await rest.connect()
|
||||||
await finishConnecting(using: rest)
|
await finishConnecting(using: rest)
|
||||||
|
await verifySSHTrust(makeSSHTransport: makeSSHTransport)
|
||||||
return
|
return
|
||||||
} catch RouterOSError.untrustedCertificate(let fingerprint) {
|
} catch RouterOSError.untrustedCertificate(let fingerprint) {
|
||||||
state = .needsCertificateConfirmation(fingerprint: fingerprint)
|
state = .needsCertificateConfirmation(fingerprint: fingerprint)
|
||||||
@@ -89,6 +100,39 @@ final class ConnectionService: ObservableObject {
|
|||||||
await connect(with: credentials)
|
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).
|
/// Applies a single configuration change on the active transport (REST or SSH).
|
||||||
func apply(_ command: RouterOSCommand) async throws {
|
func apply(_ command: RouterOSCommand) async throws {
|
||||||
guard let activeTransport else { throw RouterOSError.notConnected }
|
guard let activeTransport else { throw RouterOSError.notConnected }
|
||||||
@@ -183,6 +227,7 @@ final class ConnectionService: ObservableObject {
|
|||||||
interfaces = []
|
interfaces = []
|
||||||
credentials = nil
|
credentials = nil
|
||||||
hasExpertToolBackedUpThisSession = false
|
hasExpertToolBackedUpThisSession = false
|
||||||
|
pendingSSHTrustFingerprint = nil
|
||||||
state = .idle
|
state = .idle
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -146,7 +146,9 @@ struct ConnectView: View {
|
|||||||
Button(L10n.t("Vertrauen und verbinden", appLanguage)) {
|
Button(L10n.t("Vertrauen und verbinden", appLanguage)) {
|
||||||
viewModel.trustSSHHostKeyAndRetry(fingerprint: fingerprint)
|
viewModel.trustSSHHostKeyAndRetry(fingerprint: fingerprint)
|
||||||
}
|
}
|
||||||
Button(L10n.t("Abbrechen", appLanguage), role: .cancel) {}
|
Button(L10n.t("Abbrechen", appLanguage), role: .cancel) {
|
||||||
|
viewModel.dismissSSHHostKeyPrompt()
|
||||||
|
}
|
||||||
} message: { fingerprint in
|
} message: { fingerprint in
|
||||||
Text(L10n.t("Der Router hat sich mit einem unbekannten SSH-Schlüssel gemeldet.", appLanguage)
|
Text(L10n.t("Der Router hat sich mit einem unbekannten SSH-Schlüssel gemeldet.", appLanguage)
|
||||||
+ "\n" + L10n.t("Fingerabdruck:", appLanguage) + " \(fingerprint)\n\n"
|
+ "\n" + L10n.t("Fingerabdruck:", appLanguage) + " \(fingerprint)\n\n"
|
||||||
@@ -184,7 +186,7 @@ struct ConnectView: View {
|
|||||||
if case .needsSSHHostKeyConfirmation(let fingerprint) = connectionService.state {
|
if case .needsSSHHostKeyConfirmation(let fingerprint) = connectionService.state {
|
||||||
return fingerprint
|
return fingerprint
|
||||||
}
|
}
|
||||||
return nil
|
return connectionService.pendingSSHTrustFingerprint
|
||||||
}
|
}
|
||||||
|
|
||||||
private var sshHostKeyAlertBinding: Binding<Bool> {
|
private var sshHostKeyAlertBinding: Binding<Bool> {
|
||||||
|
|||||||
@@ -127,11 +127,22 @@ final class ConnectViewModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func trustSSHHostKeyAndRetry(fingerprint: String) {
|
func trustSSHHostKeyAndRetry(fingerprint: String) {
|
||||||
Task {
|
// Two distinct sources share this one confirmation alert (see `ConnectionService.
|
||||||
await connectionService.trustCurrentSSHHostKeyAndRetry(fingerprint: fingerprint)
|
// 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) {
|
func checkForUpdates(for credentials: RouterOSCredentials) {
|
||||||
isCheckingForUpdates = true
|
isCheckingForUpdates = true
|
||||||
updateCheckError = nil
|
updateCheckError = nil
|
||||||
|
|||||||
Reference in New Issue
Block a user