Größte offene Härtungslücke geschlossen: SSHTransport nutzte bisher .acceptAnything() für Host-Key-Validierung, akzeptierte also jeden Schlüssel ohne Prüfung -- ein Man-in-the-Middle im lokalen Netz wäre unbemerkt geblieben. Jetzt Trust-on-first-use wie beim REST-Zertifikat: - SSHHostKeyFingerprint: SHA256 über NIOSSHPublicKey.write(to:) (die SSH-Wire-Format-Bytes des Schlüssels) -- exakt die Bytes, die auch OpenSSH für seine SHA256:-Fingerabdrücke hasht. Per Unit-Test gegen einen echten ssh-keygen-erzeugten Testschlüssel kreuzgeprüft (SHA256:Hllxv6LLoHl2XTIXGGjUYJHbPFoH2F7iMrR74C5J95g), nicht geraten. - SSHHostKeyTrustStore: UserDefaults-Persistenz pro Host, Pendant zu CertificateTrustStore. - SSHTransport conformt jetzt selbst zu NIOSSHClientServerAuthentication- Delegate (wie RestTransport zu URLSessionDelegate) und übergibt sich selbst als .custom(self) Host-Key-Validator. - ConnectionService: neuer State .needsSSHHostKeyConfirmation, eigener Bestätigungs-Retry-Pfad (trustCurrentSSHHostKeyAndRetry), analog zum bestehenden Zertifikat-Flow. - ConnectView: zweiter Bestätigungsdialog mit Warnhinweis, dass ein geänderter Fingerabdruck bei zuvor schon verbundenen Routern auf ein manipuliertes Netzwerk hindeuten könnte. BackupService/FactoryResetService bekommen die TOFU-Prüfung automatisch mit (SSHTransport-Default-Parameter, gleicher UserDefaults-Speicher), ohne eigene Bestätigungs-UI -- in der Praxis unkritisch, da der Verbinden-Tab das Vertrauen immer zuerst herstellt. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HReLXMbmPvtQ23p1iWiJNW
199 lines
7.8 KiB
Swift
199 lines
7.8 KiB
Swift
import SwiftUI
|
|
|
|
struct ConnectView: View {
|
|
@ObservedObject private var connectionService: ConnectionService
|
|
@StateObject private var viewModel: ConnectViewModel
|
|
@StateObject private var backupViewModel = BackupViewModel()
|
|
|
|
init(connectionService: ConnectionService) {
|
|
self.connectionService = connectionService
|
|
_viewModel = StateObject(wrappedValue: ConnectViewModel(connectionService: connectionService))
|
|
}
|
|
|
|
var body: some View {
|
|
NavigationSplitView {
|
|
Form {
|
|
Section("Verbindung") {
|
|
TextField("IP-Adresse oder Hostname", text: $viewModel.host)
|
|
.help("Die Adresse deines Routers im Netzwerk. Werkseinstellung bei Mikrotik ist meist 192.168.88.1.")
|
|
TextField("Benutzername", text: $viewModel.username)
|
|
.help("Der Admin-Benutzername deines Routers. Werkseinstellung ist meist \"admin\".")
|
|
SecureField("Passwort", text: $viewModel.password)
|
|
.help("Das Passwort für diesen Benutzer. Bei unverändertem Werkszustand oft leer.")
|
|
Toggle("Passwort merken", isOn: $viewModel.rememberPassword)
|
|
.help("Speichert das Passwort verschlüsselt in der macOS-Schlüsselbundverwaltung, damit du es nicht jedes Mal neu eingeben musst.")
|
|
}
|
|
|
|
Section {
|
|
Button("Verbinden") {
|
|
viewModel.connect()
|
|
}
|
|
.disabled(viewModel.host.isEmpty || viewModel.username.isEmpty)
|
|
}
|
|
|
|
statusSection
|
|
}
|
|
.formStyle(.grouped)
|
|
.frame(minWidth: 320)
|
|
} detail: {
|
|
deviceDetail
|
|
}
|
|
.onAppear {
|
|
viewModel.onAppear()
|
|
backupViewModel.load()
|
|
}
|
|
.alert(
|
|
"Unbekanntes Zertifikat",
|
|
isPresented: certificateAlertBinding,
|
|
presenting: certificateFingerprint
|
|
) { fingerprint in
|
|
Button("Vertrauen und verbinden") {
|
|
viewModel.trustAndRetry(fingerprint: fingerprint)
|
|
}
|
|
Button("Abbrechen", role: .cancel) {}
|
|
} message: { fingerprint in
|
|
Text("Der Router hat sich mit einem unbekannten Zertifikat gemeldet.\nFingerabdruck: \(fingerprint)\n\nNur bestätigen, wenn dies dein eigenes Gerät im lokalen Netzwerk ist.")
|
|
}
|
|
.alert(
|
|
"Unbekannter SSH-Schlüssel",
|
|
isPresented: sshHostKeyAlertBinding,
|
|
presenting: sshHostKeyFingerprint
|
|
) { fingerprint in
|
|
Button("Vertrauen und verbinden") {
|
|
viewModel.trustSSHHostKeyAndRetry(fingerprint: fingerprint)
|
|
}
|
|
Button("Abbrechen", role: .cancel) {}
|
|
} message: { fingerprint in
|
|
Text("Der Router hat sich mit einem unbekannten SSH-Schlüssel gemeldet.\nFingerabdruck: \(fingerprint)\n\nNur bestätigen, wenn dies dein eigenes Gerät im lokalen Netzwerk ist. Falls du diesen Router schon einmal verbunden hattest und sich der Fingerabdruck geändert hat, könnte das auf ein manipuliertes Netzwerk hindeuten — im Zweifel nicht bestätigen.")
|
|
}
|
|
.alert(
|
|
"Sicherung fehlgeschlagen",
|
|
isPresented: Binding(
|
|
get: { backupViewModel.errorMessage != nil },
|
|
set: { _ in backupViewModel.errorMessage = nil }
|
|
),
|
|
presenting: backupViewModel.errorMessage
|
|
) { _ in
|
|
Button("OK") {}
|
|
} message: { message in
|
|
Text(message)
|
|
}
|
|
}
|
|
|
|
private var certificateFingerprint: String? {
|
|
if case .needsCertificateConfirmation(let fingerprint) = connectionService.state {
|
|
return fingerprint
|
|
}
|
|
return nil
|
|
}
|
|
|
|
private var certificateAlertBinding: Binding<Bool> {
|
|
Binding(
|
|
get: { certificateFingerprint != nil },
|
|
set: { _ in }
|
|
)
|
|
}
|
|
|
|
private var sshHostKeyFingerprint: String? {
|
|
if case .needsSSHHostKeyConfirmation(let fingerprint) = connectionService.state {
|
|
return fingerprint
|
|
}
|
|
return nil
|
|
}
|
|
|
|
private var sshHostKeyAlertBinding: Binding<Bool> {
|
|
Binding(
|
|
get: { sshHostKeyFingerprint != nil },
|
|
set: { _ in }
|
|
)
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var statusSection: some View {
|
|
switch connectionService.state {
|
|
case .idle, .needsCertificateConfirmation, .needsSSHHostKeyConfirmation:
|
|
EmptyView()
|
|
case .connecting:
|
|
Section {
|
|
HStack {
|
|
ProgressView()
|
|
Text("Verbinde…")
|
|
}
|
|
}
|
|
case .connected(let kind):
|
|
Section {
|
|
Label("Verbunden (\(kind == .rest ? "REST-API" : "SSH"))", systemImage: "checkmark.circle.fill")
|
|
.foregroundStyle(.green)
|
|
|
|
Button {
|
|
if let credentials = connectionService.credentials {
|
|
backupViewModel.createBackup(for: credentials)
|
|
}
|
|
} label: {
|
|
if backupViewModel.isCreatingBackup {
|
|
HStack {
|
|
ProgressView()
|
|
Text("Sichere…")
|
|
}
|
|
} else {
|
|
Label("Jetzt sichern", systemImage: "square.and.arrow.down")
|
|
}
|
|
}
|
|
.disabled(backupViewModel.isCreatingBackup)
|
|
.help("Sichert die aktuelle Router-Konfiguration — sinnvoll direkt nach dem Verbinden, bevor du im Einrichten-Tab etwas änderst.")
|
|
|
|
if let lastBackup = backupViewModel.backups.first(where: { $0.host == connectionService.credentials?.host }) {
|
|
Label(
|
|
"Zuletzt gesichert: \(lastBackup.createdAt.formatted(date: .abbreviated, time: .standard))",
|
|
systemImage: "checkmark"
|
|
)
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
case .failed(let message):
|
|
Section {
|
|
Label(message, systemImage: "exclamationmark.triangle.fill")
|
|
.foregroundStyle(.red)
|
|
}
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var deviceDetail: some View {
|
|
if let info = connectionService.deviceInfo {
|
|
List {
|
|
Section("Gerät") {
|
|
LabeledContent("Modell", value: info.boardName)
|
|
LabeledContent("RouterOS-Version", value: info.routerOSVersion)
|
|
LabeledContent("Architektur", value: info.architecture)
|
|
LabeledContent("Laufzeit", value: info.uptime)
|
|
}
|
|
Section("Interfaces") {
|
|
ForEach(connectionService.interfaces) { interface in
|
|
HStack {
|
|
Image(systemName: interface.running ? "circle.fill" : "circle")
|
|
.foregroundStyle(interface.running ? .green : .secondary)
|
|
.font(.caption)
|
|
VStack(alignment: .leading) {
|
|
Text(interface.name).bold()
|
|
Text(interface.type).font(.caption).foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
ContentUnavailableView(
|
|
"Nicht verbunden",
|
|
systemImage: "network.slash",
|
|
description: Text("Verbinde dich mit deinem Router, um Geräteinformationen zu sehen.")
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
#Preview {
|
|
ConnectView(connectionService: ConnectionService())
|
|
}
|