Files
RouterOS/RouterOSAssistant/Core/Networking/RestTransport.swift
T
KayandClaude Sonnet 5 772549c991 M6: Firewall-Schritt (opt-in Sicherheits-Grundschutz)
Standardmäßig aus (Toggle wie VLAN) -- höchstes Risiko aller bisherigen
Schritte, falsche Regeln können Fernzugriff kappen. Preset ist
Mikrotiks eigener Standard-Ansatz (unverändert seit Jahren in
RouterOS-Werkskonfigurationen): NAT/Masquerade auf WAN, established/
related erlauben, invalid verwerfen, unaufgeforderte WAN-Verbindungen
zu LAN-Geräten blocken (außer explizitem Port-Forward via
connection-nat-state=!dstnat).

Jede neue Regel bekommt ein place-before mit aufsteigendem Index,
damit sie vor eventuell schon vorhandenen Regeln des Routers landet --
sonst könnte eine bereits vorhandene "alles blocken"-Regel unsere
neuen Regeln wirkungslos machen. NAT und Filter sind getrennte,
unabhängig nummerierte RouterOS-Listen.

Vor dem Anwenden zeigt der Schritt die Anzahl bereits vorhandener
Filter-/NAT-Regeln (neuer fetchFirewallRuleCounts()-Aufruf in
RouterOSTransport/RestTransport/SSHTransport/ConnectionService) --
Transparenz, bevor auf einem möglicherweise schon konfigurierten
Router weitere Regeln landen. Nutzer-Entscheidung, extra Lese-Aufruf
in Kauf zu nehmen statt nur Warntext.

Build + Test-Compile (build-for-testing) sind grün. Der eigentliche
Testlauf (xcodebuild test) hängt aktuell an einem macOS-Gatekeeper-
Netzwerk-Check für ad-hoc-signierte Binaries (amfid: "adhoc signed or
signed by an unknown certificate chain", GK performScan über
syspolicyd) -- kein Code-Bug, tritt nur bei CLI-Testläufen auf, nicht
beim normalen Xcode-Cmd+R-Weg. Nutzer verifiziert M6 deshalb direkt in
Xcode.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HReLXMbmPvtQ23p1iWiJNW
2026-09-12 19:57:53 +02:00

173 lines
7.1 KiB
Swift

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 fetchFirewallRuleCounts() async throws -> FirewallRuleCounts {
let filterItems = try await getJSONArray(path: "ip/firewall/filter")
let natItems = try await getJSONArray(path: "ip/firewall/nat")
return FirewallRuleCounts(filterRuleCount: filterItems.count, natRuleCount: natItems.count)
}
/// Creates or modifies the item described by `command`. `.add` is a plain
/// `POST /rest/<restPath>`. `.set` has no CLI-style inline lookup on REST, so it first
/// `GET`s the collection to find the item whose `matchField` equals `matchValue`, reads
/// its RouterOS-internal `.id`, then `PATCH`es `restPath/<id>`.
func apply(_ command: RouterOSCommand) async throws {
switch command.operation {
case .add:
_ = try await send(path: command.restPath, method: "POST", jsonBody: command.arguments)
case .set(let matchField, let matchValue):
let itemID = try await findItemID(path: command.restPath, matchField: matchField, matchValue: matchValue)
_ = try await send(path: "\(command.restPath)/\(itemID)", method: "PATCH", jsonBody: command.arguments)
}
}
private func findItemID(path: String, matchField: String, matchValue: String) async throws -> String {
let items = try await getJSONArray(path: path)
guard let match = items.first(where: { ($0[matchField] as? String) == matchValue }) else {
throw RouterOSError.invalidResponse("Kein Eintrag mit \(matchField)=\(matchValue) unter \(path) gefunden")
}
guard let id = match[".id"] as? String else {
throw RouterOSError.invalidResponse("Eintrag unter \(path) hat keine .id")
}
return id
}
func disconnect() async {
session.invalidateAndCancel()
}
private func getJSONObject(path: String) async throws -> [String: Any] {
let data = try await send(path: path, method: "GET", jsonBody: nil)
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 send(path: path, method: "GET", jsonBody: nil)
guard let array = try JSONSerialization.jsonObject(with: data) as? [[String: Any]] else {
throw RouterOSError.invalidResponse(path)
}
return array
}
private func send(path: String, method: String, jsonBody: [String: String]?) async throws -> Data {
var request = URLRequest(url: baseURL.appendingPathComponent(path))
request.httpMethod = method
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
if let jsonBody {
request.httpBody = try JSONSerialization.data(withJSONObject: jsonBody)
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
}
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)
}
}
}