forked from kay/RouterOS
M1: Projektgerüst + Connect-Schritt (REST/SSH-Autodetect)
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HReLXMbmPvtQ23p1iWiJNW
This commit is contained in:
+14
@@ -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
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
@main
|
||||||
|
struct RouterOSAssistantApp: App {
|
||||||
|
var body: some Scene {
|
||||||
|
WindowGroup {
|
||||||
|
ConnectView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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: ":")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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] ?? [:]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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..<colonIndex]).trimmingCharacters(in: .whitespaces)
|
||||||
|
let value = String(line[line.index(after: colonIndex)...]).trimmingCharacters(in: .whitespaces)
|
||||||
|
fields[key] = value
|
||||||
|
}
|
||||||
|
return RouterDeviceInfo(
|
||||||
|
boardName: fields["board-name"] ?? "unbekannt",
|
||||||
|
routerOSVersion: fields["version"] ?? "unbekannt",
|
||||||
|
architecture: fields["architecture-name"] ?? "unbekannt",
|
||||||
|
uptime: fields["uptime"] ?? "-"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses `/interface print terse` output (one line per interface, `key=value` pairs).
|
||||||
|
static func parseInterfaces(_ raw: String) -> [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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Bool> {
|
||||||
|
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()
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
|
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||||
|
<key>CFBundleDisplayName</key>
|
||||||
|
<string>RouterOS Assistant</string>
|
||||||
|
<key>CFBundleExecutable</key>
|
||||||
|
<string>$(EXECUTABLE_NAME)</string>
|
||||||
|
<key>CFBundleIdentifier</key>
|
||||||
|
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||||
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
|
<string>6.0</string>
|
||||||
|
<key>CFBundleName</key>
|
||||||
|
<string>$(PRODUCT_NAME)</string>
|
||||||
|
<key>CFBundlePackageType</key>
|
||||||
|
<string>APPL</string>
|
||||||
|
<key>CFBundleShortVersionString</key>
|
||||||
|
<string>1.0</string>
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>1</string>
|
||||||
|
<key>LSApplicationCategoryType</key>
|
||||||
|
<string>public.app-category.utilities</string>
|
||||||
|
<key>NSAppTransportSecurity</key>
|
||||||
|
<dict>
|
||||||
|
<key>NSAllowsArbitraryLoads</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+54
@@ -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
|
||||||
Reference in New Issue
Block a user