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:
Kay
2026-09-11 20:51:26 +02:00
co-authored by Claude Sonnet 5
commit 63734ef7fe
17 changed files with 855 additions and 0 deletions
+14
View File
@@ -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)
}
}
}
+31
View File
@@ -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
View File
@@ -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