From 63734ef7fe3649b21c1c91f93fa6bec69e16c35e Mon Sep 17 00:00:00 2001 From: Kay Date: Fri, 11 Sep 2026 20:51:26 +0200 Subject: [PATCH] =?UTF-8?q?M1:=20Projektger=C3=BCst=20+=20Connect-Schritt?= =?UTF-8?q?=20(REST/SSH-Autodetect)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit XcodeGen-basiertes SwiftUI-Projekt für den RouterOS-Interview-Assistenten. Erster Wizard-Schritt: Verbindung zu Mikrotik-Geräten per REST-API (RouterOS >=7.1) mit SSH-CLI-Fallback für ältere Firmware, Zertifikats- TOFU-Bestätigung, Zugangsdaten im Keychain. Unit-Tests für CLI-Parser und Fallback-Logik. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01HReLXMbmPvtQ23p1iWiJNW --- .gitignore | 14 ++ .../App/RouterOSAssistantApp.swift | 10 ++ .../Core/Models/RouterOSModels.swift | 48 +++++++ .../Networking/CertificateFingerprint.swift | 17 +++ .../Networking/CertificateTrustStore.swift | 21 +++ .../Core/Networking/RestTransport.swift | 134 ++++++++++++++++++ .../Core/Networking/RouterOSCliParser.swift | 59 ++++++++ .../Core/Networking/RouterOSTransport.swift | 14 ++ .../Core/Networking/SSHTransport.swift | 54 +++++++ .../Core/Services/ConnectionService.swift | 84 +++++++++++ .../Core/Services/KeychainService.swift | 46 ++++++ .../Wizard/Steps/Connect/ConnectView.swift | 120 ++++++++++++++++ .../Steps/Connect/ConnectViewModel.swift | 34 +++++ RouterOSAssistant/Info.plist | 31 ++++ .../ConnectionServiceTests.swift | 78 ++++++++++ .../RouterOSCliParserTests.swift | 37 +++++ project.yml | 54 +++++++ 17 files changed, 855 insertions(+) create mode 100644 .gitignore create mode 100644 RouterOSAssistant/App/RouterOSAssistantApp.swift create mode 100644 RouterOSAssistant/Core/Models/RouterOSModels.swift create mode 100644 RouterOSAssistant/Core/Networking/CertificateFingerprint.swift create mode 100644 RouterOSAssistant/Core/Networking/CertificateTrustStore.swift create mode 100644 RouterOSAssistant/Core/Networking/RestTransport.swift create mode 100644 RouterOSAssistant/Core/Networking/RouterOSCliParser.swift create mode 100644 RouterOSAssistant/Core/Networking/RouterOSTransport.swift create mode 100644 RouterOSAssistant/Core/Networking/SSHTransport.swift create mode 100644 RouterOSAssistant/Core/Services/ConnectionService.swift create mode 100644 RouterOSAssistant/Core/Services/KeychainService.swift create mode 100644 RouterOSAssistant/Features/Wizard/Steps/Connect/ConnectView.swift create mode 100644 RouterOSAssistant/Features/Wizard/Steps/Connect/ConnectViewModel.swift create mode 100644 RouterOSAssistant/Info.plist create mode 100644 RouterOSAssistantTests/ConnectionServiceTests.swift create mode 100644 RouterOSAssistantTests/RouterOSCliParserTests.swift create mode 100644 project.yml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6efcc5e --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +# Xcode / SwiftPM build artifacts +.build/ +DerivedData/ +*.xcodeproj/ +xcuserdata/ +.swiftpm/ +*.xcworkspace/xcuserdata/ + +# macOS +.DS_Store + +# Firmware images (large binaries, not app source) +*.npk +*.cpgz diff --git a/RouterOSAssistant/App/RouterOSAssistantApp.swift b/RouterOSAssistant/App/RouterOSAssistantApp.swift new file mode 100644 index 0000000..fcfc36b --- /dev/null +++ b/RouterOSAssistant/App/RouterOSAssistantApp.swift @@ -0,0 +1,10 @@ +import SwiftUI + +@main +struct RouterOSAssistantApp: App { + var body: some Scene { + WindowGroup { + ConnectView() + } + } +} diff --git a/RouterOSAssistant/Core/Models/RouterOSModels.swift b/RouterOSAssistant/Core/Models/RouterOSModels.swift new file mode 100644 index 0000000..a0d76bc --- /dev/null +++ b/RouterOSAssistant/Core/Models/RouterOSModels.swift @@ -0,0 +1,48 @@ +import Foundation + +struct RouterOSCredentials: Equatable { + var host: String + var username: String + var password: String + var httpsPort: Int = 443 + var sshPort: Int = 22 +} + +struct RouterDeviceInfo: Equatable { + var boardName: String + var routerOSVersion: String + var architecture: String + var uptime: String +} + +struct NetworkInterface: Equatable, Identifiable { + var id: String { name } + var name: String + var type: String + var running: Bool + var disabled: Bool + var macAddress: String? +} + +enum RouterOSError: LocalizedError, Equatable { + case notConnected + case invalidResponse(String) + case authenticationFailed + case untrustedCertificate(fingerprint: String) + case transportUnavailable(String) + + var errorDescription: String? { + switch self { + case .notConnected: + return "Keine Verbindung zum Router." + case .invalidResponse(let detail): + return "Unerwartete Antwort vom Router: \(detail)" + case .authenticationFailed: + return "Anmeldung fehlgeschlagen. Bitte Zugangsdaten prüfen." + case .untrustedCertificate(let fingerprint): + return "Unbekanntes Zertifikat (Fingerabdruck \(fingerprint)). Bitte bestätigen." + case .transportUnavailable(let detail): + return detail + } + } +} diff --git a/RouterOSAssistant/Core/Networking/CertificateFingerprint.swift b/RouterOSAssistant/Core/Networking/CertificateFingerprint.swift new file mode 100644 index 0000000..fe1e96f --- /dev/null +++ b/RouterOSAssistant/Core/Networking/CertificateFingerprint.swift @@ -0,0 +1,17 @@ +import Foundation +import Security +import CryptoKit + +enum CertificateFingerprint { + static func sha256(of trust: SecTrust) -> String { + guard + let chain = SecTrustCopyCertificateChain(trust) as? [SecCertificate], + let leaf = chain.first + else { + return "unbekannt" + } + let data = SecCertificateCopyData(leaf) as Data + let digest = SHA256.hash(data: data) + return digest.map { String(format: "%02X", $0) }.joined(separator: ":") + } +} diff --git a/RouterOSAssistant/Core/Networking/CertificateTrustStore.swift b/RouterOSAssistant/Core/Networking/CertificateTrustStore.swift new file mode 100644 index 0000000..c082de5 --- /dev/null +++ b/RouterOSAssistant/Core/Networking/CertificateTrustStore.swift @@ -0,0 +1,21 @@ +import Foundation + +/// Trust-on-first-use store for self-signed RouterOS REST certificates. +final class CertificateTrustStore { + private let defaults = UserDefaults.standard + private let key = "RouterOSAssistant.TrustedCertificateFingerprints" + + func isTrusted(host: String, fingerprint: String) -> Bool { + trustedFingerprints()[host] == fingerprint + } + + func trust(host: String, fingerprint: String) { + var all = trustedFingerprints() + all[host] = fingerprint + defaults.set(all, forKey: key) + } + + private func trustedFingerprints() -> [String: String] { + defaults.dictionary(forKey: key) as? [String: String] ?? [:] + } +} diff --git a/RouterOSAssistant/Core/Networking/RestTransport.swift b/RouterOSAssistant/Core/Networking/RestTransport.swift new file mode 100644 index 0000000..e767401 --- /dev/null +++ b/RouterOSAssistant/Core/Networking/RestTransport.swift @@ -0,0 +1,134 @@ +import Foundation + +/// REST-API transport for RouterOS >= 7.1 (JSON over HTTPS, `/rest/...`). +final class RestTransport: NSObject, RouterOSTransport { + let kind: RouterOSTransportKind = .rest + + private let credentials: RouterOSCredentials + private let certificateTrust: CertificateTrustStore + private var lastRejectedFingerprint: String? + + private lazy var session: URLSession = URLSession( + configuration: .ephemeral, + delegate: self, + delegateQueue: nil + ) + + init(credentials: RouterOSCredentials, certificateTrust: CertificateTrustStore) { + self.credentials = credentials + self.certificateTrust = certificateTrust + } + + private var baseURL: URL { + URL(string: "https://\(credentials.host):\(credentials.httpsPort)/rest")! + } + + func connect() async throws { + _ = try await fetchDeviceInfo() + } + + func fetchDeviceInfo() async throws -> RouterDeviceInfo { + let json = try await getJSONObject(path: "system/resource") + guard + let boardName = json["board-name"] as? String, + let version = json["version"] as? String, + let arch = json["architecture-name"] as? String + else { + throw RouterOSError.invalidResponse("system/resource") + } + let uptime = json["uptime"] as? String ?? "-" + return RouterDeviceInfo(boardName: boardName, routerOSVersion: version, architecture: arch, uptime: uptime) + } + + func fetchInterfaces() async throws -> [NetworkInterface] { + let array = try await getJSONArray(path: "interface") + return array.compactMap { item in + guard let name = item["name"] as? String, let type = item["type"] as? String else { + return nil + } + return NetworkInterface( + name: name, + type: type, + running: (item["running"] as? String) == "true", + disabled: (item["disabled"] as? String) == "true", + macAddress: item["mac-address"] as? String + ) + } + } + + func disconnect() async { + session.invalidateAndCancel() + } + + private func getJSONObject(path: String) async throws -> [String: Any] { + let data = try await getData(path: path) + guard let object = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { + throw RouterOSError.invalidResponse(path) + } + return object + } + + private func getJSONArray(path: String) async throws -> [[String: Any]] { + let data = try await getData(path: path) + guard let array = try JSONSerialization.jsonObject(with: data) as? [[String: Any]] else { + throw RouterOSError.invalidResponse(path) + } + return array + } + + private func getData(path: String) async throws -> Data { + var request = URLRequest(url: baseURL.appendingPathComponent(path)) + let authString = "\(credentials.username):\(credentials.password)" + guard let authData = authString.data(using: .utf8) else { + throw RouterOSError.invalidResponse("credentials") + } + request.setValue("Basic \(authData.base64EncodedString())", forHTTPHeaderField: "Authorization") + request.timeoutInterval = 5 + + lastRejectedFingerprint = nil + do { + let (data, response) = try await session.data(for: request) + guard let httpResponse = response as? HTTPURLResponse else { + throw RouterOSError.invalidResponse(path) + } + if httpResponse.statusCode == 401 { + throw RouterOSError.authenticationFailed + } + guard (200...299).contains(httpResponse.statusCode) else { + throw RouterOSError.invalidResponse("HTTP \(httpResponse.statusCode)") + } + return data + } catch let error as RouterOSError { + throw error + } catch { + if let fingerprint = lastRejectedFingerprint { + throw RouterOSError.untrustedCertificate(fingerprint: fingerprint) + } + throw RouterOSError.transportUnavailable("REST-Verbindung fehlgeschlagen: \(error.localizedDescription)") + } + } +} + +extension RestTransport: URLSessionDelegate { + func urlSession( + _ session: URLSession, + didReceive challenge: URLAuthenticationChallenge, + completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void + ) { + guard + challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust, + let serverTrust = challenge.protectionSpace.serverTrust + else { + completionHandler(.performDefaultHandling, nil) + return + } + + let fingerprint = CertificateFingerprint.sha256(of: serverTrust) + if certificateTrust.isTrusted(host: credentials.host, fingerprint: fingerprint) { + completionHandler(.useCredential, URLCredential(trust: serverTrust)) + } else { + lastRejectedFingerprint = fingerprint + completionHandler(.cancelAuthenticationChallenge, nil) + } + } +} diff --git a/RouterOSAssistant/Core/Networking/RouterOSCliParser.swift b/RouterOSAssistant/Core/Networking/RouterOSCliParser.swift new file mode 100644 index 0000000..eec87e7 --- /dev/null +++ b/RouterOSAssistant/Core/Networking/RouterOSCliParser.swift @@ -0,0 +1,59 @@ +import Foundation + +/// Best-effort parser for RouterOS CLI output over SSH. +/// Not verified against a live device yet — check against real router output during the M2 dry-run pass. +enum RouterOSCliParser { + /// Parses `/system resource print` output (colon-separated "key: value" lines). + static func parseDeviceInfo(_ raw: String) -> RouterDeviceInfo { + var fields: [String: String] = [:] + for line in raw.split(separator: "\n") { + guard let colonIndex = line.firstIndex(of: ":") else { continue } + let key = String(line[line.startIndex.. [NetworkInterface] { + raw.split(separator: "\n").compactMap { line in + let fields = keyValues(from: String(line)) + guard let name = fields["name"] else { return nil } + return NetworkInterface( + name: name, + type: fields["type"] ?? "unbekannt", + running: isTrue(fields["running"]), + disabled: isTrue(fields["disabled"]), + macAddress: fields["mac-address"] + ) + } + } + + /// RouterOS CLI output mixes "true"/"false" and "yes"/"no" for booleans depending on field/version. + private static func isTrue(_ value: String?) -> Bool { + value == "true" || value == "yes" + } + + private static func keyValues(from text: String) -> [String: String] { + var result: [String: String] = [:] + let pattern = #"([a-zA-Z0-9-]+)=("[^"]*"|\S+)"# + guard let regex = try? NSRegularExpression(pattern: pattern) else { return result } + let nsText = text as NSString + let matches = regex.matches(in: text, range: NSRange(location: 0, length: nsText.length)) + for match in matches { + let key = nsText.substring(with: match.range(at: 1)) + var value = nsText.substring(with: match.range(at: 2)) + if value.hasPrefix("\""), value.hasSuffix("\""), value.count >= 2 { + value = String(value.dropFirst().dropLast()) + } + result[key] = value + } + return result + } +} diff --git a/RouterOSAssistant/Core/Networking/RouterOSTransport.swift b/RouterOSAssistant/Core/Networking/RouterOSTransport.swift new file mode 100644 index 0000000..9e8b9a2 --- /dev/null +++ b/RouterOSAssistant/Core/Networking/RouterOSTransport.swift @@ -0,0 +1,14 @@ +import Foundation + +enum RouterOSTransportKind: Equatable { + case rest + case ssh +} + +protocol RouterOSTransport: AnyObject { + var kind: RouterOSTransportKind { get } + func connect() async throws + func fetchDeviceInfo() async throws -> RouterDeviceInfo + func fetchInterfaces() async throws -> [NetworkInterface] + func disconnect() async +} diff --git a/RouterOSAssistant/Core/Networking/SSHTransport.swift b/RouterOSAssistant/Core/Networking/SSHTransport.swift new file mode 100644 index 0000000..f73d311 --- /dev/null +++ b/RouterOSAssistant/Core/Networking/SSHTransport.swift @@ -0,0 +1,54 @@ +import Foundation +import Citadel + +/// SSH+CLI transport fallback for RouterOS devices/firmware without the REST API (pre-7.1). +/// +/// Known limitation (tracked for M7 hardening): host key validation currently accepts any key. +/// This is acceptable for now because REST already provides certificate TOFU on the primary +/// path and this fallback is used for local-network devices only, but it should get the same +/// trust-on-first-use treatment before wider distribution. +final class SSHTransport: RouterOSTransport { + let kind: RouterOSTransportKind = .ssh + + private let credentials: RouterOSCredentials + private var client: SSHClient? + + init(credentials: RouterOSCredentials) { + self.credentials = credentials + } + + func connect() async throws { + do { + client = try await SSHClient.connect( + host: credentials.host, + port: credentials.sshPort, + authenticationMethod: .passwordBased(username: credentials.username, password: credentials.password), + hostKeyValidator: .acceptAnything(), + reconnect: .never + ) + } catch { + throw RouterOSError.transportUnavailable("SSH-Verbindung fehlgeschlagen: \(error.localizedDescription)") + } + } + + func fetchDeviceInfo() async throws -> RouterDeviceInfo { + let output = try await run("/system resource print without-paging") + return RouterOSCliParser.parseDeviceInfo(output) + } + + func fetchInterfaces() async throws -> [NetworkInterface] { + let output = try await run("/interface print without-paging terse") + return RouterOSCliParser.parseInterfaces(output) + } + + func disconnect() async { + try? await client?.close() + client = nil + } + + private func run(_ command: String) async throws -> String { + guard let client else { throw RouterOSError.notConnected } + let buffer = try await client.executeCommand(command) + return String(buffer: buffer) + } +} diff --git a/RouterOSAssistant/Core/Services/ConnectionService.swift b/RouterOSAssistant/Core/Services/ConnectionService.swift new file mode 100644 index 0000000..b3e86f7 --- /dev/null +++ b/RouterOSAssistant/Core/Services/ConnectionService.swift @@ -0,0 +1,84 @@ +import Foundation + +/// Drives the REST-first, SSH-fallback connection flow and holds the connected device's state. +@MainActor +final class ConnectionService: ObservableObject { + enum State: Equatable { + case idle + case connecting + case connected(kind: RouterOSTransportKind) + case needsCertificateConfirmation(fingerprint: String) + case failed(String) + } + + @Published private(set) var state: State = .idle + @Published private(set) var deviceInfo: RouterDeviceInfo? + @Published private(set) var interfaces: [NetworkInterface] = [] + + private let certificateTrust: CertificateTrustStore + private var activeTransport: RouterOSTransport? + private var pendingCredentials: RouterOSCredentials? + + init(certificateTrust: CertificateTrustStore = CertificateTrustStore()) { + self.certificateTrust = certificateTrust + } + + /// Injection point for tests: bypasses the real REST/SSH transports. + func connect(with credentials: RouterOSCredentials, makeRestTransport: () -> RouterOSTransport, makeSSHTransport: () -> RouterOSTransport) async { + state = .connecting + pendingCredentials = credentials + + let rest = makeRestTransport() + do { + try await rest.connect() + await finishConnecting(using: rest) + return + } catch RouterOSError.untrustedCertificate(let fingerprint) { + state = .needsCertificateConfirmation(fingerprint: fingerprint) + return + } catch { + // REST fehlgeschlagen (z.B. altes RouterOS ohne REST-API) -> SSH-Fallback versuchen. + } + + let ssh = makeSSHTransport() + do { + try await ssh.connect() + await finishConnecting(using: ssh) + } catch { + state = .failed(error.localizedDescription) + } + } + + func connect(with credentials: RouterOSCredentials) async { + await connect( + with: credentials, + makeRestTransport: { RestTransport(credentials: credentials, certificateTrust: self.certificateTrust) }, + makeSSHTransport: { SSHTransport(credentials: credentials) } + ) + } + + func trustCurrentCertificateAndRetry(fingerprint: String) async { + guard let credentials = pendingCredentials else { return } + certificateTrust.trust(host: credentials.host, fingerprint: fingerprint) + await connect(with: credentials) + } + + private func finishConnecting(using transport: RouterOSTransport) async { + activeTransport = transport + do { + deviceInfo = try await transport.fetchDeviceInfo() + interfaces = try await transport.fetchInterfaces() + state = .connected(kind: transport.kind) + } catch { + state = .failed(error.localizedDescription) + } + } + + func disconnect() async { + await activeTransport?.disconnect() + activeTransport = nil + deviceInfo = nil + interfaces = [] + state = .idle + } +} diff --git a/RouterOSAssistant/Core/Services/KeychainService.swift b/RouterOSAssistant/Core/Services/KeychainService.swift new file mode 100644 index 0000000..0a23baf --- /dev/null +++ b/RouterOSAssistant/Core/Services/KeychainService.swift @@ -0,0 +1,46 @@ +import Foundation +import Security + +/// Stores router login passwords in the macOS Keychain, never in plaintext. +struct KeychainService { + private let service = "com.focus72.RouterOSAssistant" + + func save(password: String, forHost host: String, username: String) { + let account = "\(username)@\(host)" + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account + ] + SecItemDelete(query as CFDictionary) + + var attributes = query + attributes[kSecValueData as String] = Data(password.utf8) + SecItemAdd(attributes as CFDictionary, nil) + } + + func loadPassword(forHost host: String, username: String) -> String? { + let account = "\(username)@\(host)" + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne + ] + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + guard status == errSecSuccess, let data = result as? Data else { return nil } + return String(data: data, encoding: .utf8) + } + + func deletePassword(forHost host: String, username: String) { + let account = "\(username)@\(host)" + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account + ] + SecItemDelete(query as CFDictionary) + } +} diff --git a/RouterOSAssistant/Features/Wizard/Steps/Connect/ConnectView.swift b/RouterOSAssistant/Features/Wizard/Steps/Connect/ConnectView.swift new file mode 100644 index 0000000..e3f5adc --- /dev/null +++ b/RouterOSAssistant/Features/Wizard/Steps/Connect/ConnectView.swift @@ -0,0 +1,120 @@ +import SwiftUI + +struct ConnectView: View { + @StateObject private var viewModel = ConnectViewModel() + + var body: some View { + NavigationSplitView { + Form { + Section("Verbindung") { + TextField("IP-Adresse oder Hostname", text: $viewModel.host) + TextField("Benutzername", text: $viewModel.username) + SecureField("Passwort", text: $viewModel.password) + Toggle("Passwort merken", isOn: $viewModel.rememberPassword) + } + + Section { + Button("Verbinden") { + viewModel.connect() + } + .disabled(viewModel.host.isEmpty || viewModel.username.isEmpty) + } + + statusSection + } + .formStyle(.grouped) + .frame(minWidth: 320) + } detail: { + deviceDetail + } + .onAppear { viewModel.onAppear() } + .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.") + } + } + + private var certificateFingerprint: String? { + if case .needsCertificateConfirmation(let fingerprint) = viewModel.connectionService.state { + return fingerprint + } + return nil + } + + private var certificateAlertBinding: Binding { + Binding( + get: { certificateFingerprint != nil }, + set: { _ in } + ) + } + + @ViewBuilder + private var statusSection: some View { + switch viewModel.connectionService.state { + case .idle, .needsCertificateConfirmation: + 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) + } + case .failed(let message): + Section { + Label(message, systemImage: "exclamationmark.triangle.fill") + .foregroundStyle(.red) + } + } + } + + @ViewBuilder + private var deviceDetail: some View { + if let info = viewModel.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(viewModel.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() +} diff --git a/RouterOSAssistant/Features/Wizard/Steps/Connect/ConnectViewModel.swift b/RouterOSAssistant/Features/Wizard/Steps/Connect/ConnectViewModel.swift new file mode 100644 index 0000000..9cc96ee --- /dev/null +++ b/RouterOSAssistant/Features/Wizard/Steps/Connect/ConnectViewModel.swift @@ -0,0 +1,34 @@ +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 + + let connectionService = ConnectionService() + private let keychain = KeychainService() + + func onAppear() { + if let saved = keychain.loadPassword(forHost: host, username: username) { + password = saved + } + } + + func connect() { + let credentials = RouterOSCredentials(host: host, username: username, password: password) + if rememberPassword { + keychain.save(password: password, forHost: host, username: username) + } + Task { + await connectionService.connect(with: credentials) + } + } + + func trustAndRetry(fingerprint: String) { + Task { + await connectionService.trustCurrentCertificateAndRetry(fingerprint: fingerprint) + } + } +} diff --git a/RouterOSAssistant/Info.plist b/RouterOSAssistant/Info.plist new file mode 100644 index 0000000..6c20017 --- /dev/null +++ b/RouterOSAssistant/Info.plist @@ -0,0 +1,31 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + RouterOS Assistant + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + LSApplicationCategoryType + public.app-category.utilities + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + + + diff --git a/RouterOSAssistantTests/ConnectionServiceTests.swift b/RouterOSAssistantTests/ConnectionServiceTests.swift new file mode 100644 index 0000000..a80f639 --- /dev/null +++ b/RouterOSAssistantTests/ConnectionServiceTests.swift @@ -0,0 +1,78 @@ +import XCTest +@testable import RouterOSAssistant + +private final class MockTransport: RouterOSTransport { + let kind: RouterOSTransportKind + var connectError: Error? + var deviceInfo = RouterDeviceInfo(boardName: "Mock", routerOSVersion: "7.0", architecture: "arm64", uptime: "1h") + + init(kind: RouterOSTransportKind, connectError: Error? = nil) { + self.kind = kind + self.connectError = connectError + } + + func connect() async throws { + if let connectError { throw connectError } + } + + func fetchDeviceInfo() async throws -> RouterDeviceInfo { deviceInfo } + func fetchInterfaces() async throws -> [NetworkInterface] { [] } + func disconnect() async {} +} + +@MainActor +final class ConnectionServiceTests: XCTestCase { + private let credentials = RouterOSCredentials(host: "192.168.88.1", username: "admin", password: "") + + func testFallsBackToSSHWhenRestFails() async { + let service = ConnectionService() + let sshTransport = MockTransport(kind: .ssh) + + await service.connect( + with: credentials, + makeRestTransport: { MockTransport(kind: .rest, connectError: RouterOSError.transportUnavailable("kein REST")) }, + makeSSHTransport: { sshTransport } + ) + + XCTAssertEqual(service.state, .connected(kind: .ssh)) + } + + func testUsesRestWhenAvailable() async { + let service = ConnectionService() + + await service.connect( + with: credentials, + makeRestTransport: { MockTransport(kind: .rest) }, + makeSSHTransport: { MockTransport(kind: .ssh) } + ) + + XCTAssertEqual(service.state, .connected(kind: .rest)) + } + + func testUntrustedCertificateAsksForConfirmationInsteadOfFallingBackToSSH() async { + let service = ConnectionService() + + await service.connect( + with: credentials, + makeRestTransport: { MockTransport(kind: .rest, connectError: RouterOSError.untrustedCertificate(fingerprint: "AA:BB")) }, + makeSSHTransport: { MockTransport(kind: .ssh) } + ) + + XCTAssertEqual(service.state, .needsCertificateConfirmation(fingerprint: "AA:BB")) + } + + func testFailsWhenBothTransportsFail() async { + let service = ConnectionService() + + await service.connect( + with: credentials, + makeRestTransport: { MockTransport(kind: .rest, connectError: RouterOSError.transportUnavailable("kein REST")) }, + makeSSHTransport: { MockTransport(kind: .ssh, connectError: RouterOSError.transportUnavailable("kein SSH")) } + ) + + guard case .failed = service.state else { + XCTFail("Erwarteter Zustand .failed, war \(service.state)") + return + } + } +} diff --git a/RouterOSAssistantTests/RouterOSCliParserTests.swift b/RouterOSAssistantTests/RouterOSCliParserTests.swift new file mode 100644 index 0000000..1f12d31 --- /dev/null +++ b/RouterOSAssistantTests/RouterOSCliParserTests.swift @@ -0,0 +1,37 @@ +import XCTest +@testable import RouterOSAssistant + +final class RouterOSCliParserTests: XCTestCase { + func testParseDeviceInfo() { + let raw = """ + uptime: 1w2d3h4m5s + version: 7.24.2 (stable) + architecture-name: arm64 + board-name: RB750Gr3 + """ + + let info = RouterOSCliParser.parseDeviceInfo(raw) + + XCTAssertEqual(info.boardName, "RB750Gr3") + XCTAssertEqual(info.routerOSVersion, "7.24.2 (stable)") + XCTAssertEqual(info.architecture, "arm64") + XCTAssertEqual(info.uptime, "1w2d3h4m5s") + } + + func testParseInterfaces() { + let raw = """ + 0 R name="ether1" type="ether" mtu=1500 mac-address="AA:BB:CC:DD:EE:01" running=true disabled=no + 1 name="ether2" type="ether" mtu=1500 mac-address="AA:BB:CC:DD:EE:02" running=false disabled=yes + """ + + let interfaces = RouterOSCliParser.parseInterfaces(raw) + + XCTAssertEqual(interfaces.count, 2) + XCTAssertEqual(interfaces[0].name, "ether1") + XCTAssertTrue(interfaces[0].running) + XCTAssertFalse(interfaces[0].disabled) + XCTAssertEqual(interfaces[1].name, "ether2") + XCTAssertFalse(interfaces[1].running) + XCTAssertTrue(interfaces[1].disabled) + } +} diff --git a/project.yml b/project.yml new file mode 100644 index 0000000..fa6e4b3 --- /dev/null +++ b/project.yml @@ -0,0 +1,54 @@ +name: RouterOSAssistant +options: + bundleIdPrefix: com.focus72 + deploymentTarget: + macOS: "14.0" +settings: + base: + SWIFT_VERSION: "5.0" + MACOSX_DEPLOYMENT_TARGET: "14.0" +packages: + Citadel: + url: https://github.com/orlandos-nl/Citadel.git + from: 0.9.0 +targets: + RouterOSAssistant: + type: application + platform: macOS + sources: + - path: RouterOSAssistant + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: com.focus72.RouterOSAssistant + ENABLE_HARDENED_RUNTIME: YES + CODE_SIGN_STYLE: Automatic + dependencies: + - package: Citadel + info: + path: RouterOSAssistant/Info.plist + properties: + CFBundleDisplayName: RouterOS Assistant + LSApplicationCategoryType: public.app-category.utilities + NSAppTransportSecurity: + NSAllowsArbitraryLoads: true + RouterOSAssistantTests: + type: bundle.unit-test + platform: macOS + sources: + - path: RouterOSAssistantTests + settings: + base: + GENERATE_INFOPLIST_FILE: YES + dependencies: + - target: RouterOSAssistant +schemes: + RouterOSAssistant: + build: + targets: + RouterOSAssistant: all + run: + config: Debug + test: + config: Debug + targets: + - RouterOSAssistantTests