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