forked from kay/RouterOS
RouterOSCommand unterstützt jetzt neben .add auch .set (bestehenden Eintrag ändern statt neuen anzulegen) — nötig, weil WLAN-Interfaces schon vor jeder Konfiguration existieren. SSH löst das per CLI-eigenem "set [find field=value] ..." inline auf; REST hat dafür keine Entsprechung und muss den Eintrag erst per GET suchen (matchField/ matchValue), seine .id auslesen, dann PATCH auf restPath/<id> senden (RestTransport.findItemID). Mit dem Nutzer abgestimmte Entscheidung gegen die einfachere "WLAN nur über SSH"-Variante. WifiNetworkConfig: pro erkanntem Legacy-Wireless-Interface (/interface wireless, type=wlan) eine SSID/Passwort-Konfiguration, Sicherheitsprofil (WPA2) wird zuerst angelegt, dann per set mit dem Interface verknüpft. Geräte ohne WLAN zeigen einen Hinweistext statt des Formulars (User-Anforderung: muss berücksichtigt werden). Geräte mit dem neueren "wifi"-Treiber (type=wifi, wifiwave2/802.11ax) werden erkannt, aber bewusst nicht unterstützt -- anderes Menü, eigener Umbau nötig, dazu Hinweistext. cliPath wurde in allen bisherigen Command-Buildern (Wan/Lan/Vlan) zu menuPath + .add migriert, da RouterOSCommand jetzt operation-basiert ist statt den Aktionswort im Pfad-String zu verstecken. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HReLXMbmPvtQ23p1iWiJNW
167 lines
6.8 KiB
Swift
167 lines
6.8 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
|
|
)
|
|
}
|
|
}
|
|
|
|
/// 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)
|
|
}
|
|
}
|
|
}
|