import Foundation import Citadel import NIOCore import NIOSSH /// SSH+CLI transport fallback for RouterOS devices/firmware without the REST API (pre-7.1). final class SSHTransport: RouterOSTransport { let kind: RouterOSTransportKind = .ssh private let credentials: RouterOSCredentials private let hostKeyTrust: SSHHostKeyTrustStore private var client: SSHClient? init(credentials: RouterOSCredentials, hostKeyTrust: SSHHostKeyTrustStore = SSHHostKeyTrustStore()) { self.credentials = credentials self.hostKeyTrust = hostKeyTrust } 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: .custom(self), reconnect: .never, // RouterOS' SSH server typically only offers legacy algorithms // (diffie-hellman-group14-sha1 key exchange, RSA host keys) that // Citadel's defaults don't include — `.all` adds them. algorithms: .all ) } catch let error as RouterOSError { throw error } catch { // NIOSSHError's `.localizedDescription` is a useless generic NSError-bridged string // ("The operation couldn't be completed."); its real diagnostics only surface via // CustomStringConvertible, which `String(describing:)` picks up. throw RouterOSError.transportUnavailable("SSH-Verbindung fehlgeschlagen: \(String(describing: error))") } } 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) } /// Live throughput for one interface — RouterOS' `/interface monitor-traffic once` is /// a standard, stable CLI command (documented single-interface usage), not a menu item, so /// it's implemented directly here rather than through the generic `fetchMenuItems` machinery. /// Not exposed over REST: this is a CLI-only command with no documented REST equivalent, so /// callers needing it (see `InterfaceTrafficMonitor`) always use a dedicated SSH connection, /// same reasoning as `BackupService`/`UpdateService`. /// Runs an arbitrary RouterOS CLI command and returns its raw text output — the one /// deliberate escape hatch out of the otherwise-private `run(_:)`, for `NetworkToolsService`'s /// on-demand diagnostics (ping/traceroute/DNS lookup have no menu-item/REST shape to go /// through the generic `fetchMenuItems` machinery). Callers are responsible for sanitizing any /// untrusted value (e.g. a DHCP-supplied hostname) before interpolating it into `command` — /// RouterOS' console treats ";" as a command separator, so an unsanitized value could inject /// a second command. func runDiagnosticCommand(_ command: String) async throws -> String { try await run(command) } func fetchInterfaceTraffic(interfaceName: String) async throws -> InterfaceTraffic { let output = try await run("/interface monitor-traffic \(interfaceName) once") let fields = RouterOSCliParser.parseSingletonItem(output).fields return InterfaceTraffic( rxBitsPerSecond: Self.parseBitsPerSecond(fields["rx-bits-per-second"]), txBitsPerSecond: Self.parseBitsPerSecond(fields["tx-bits-per-second"]) ) } /// RouterOS reports these as human-formatted strings with a unit suffix (e.g. "50.7kbps", /// "34.0kbps", or plain "0" when idle), never a bare integer — confirmed live (2026-09-15, /// hEX/RouterOS 7.x). Longer/more specific suffixes are checked before shorter ones ("kbps" /// before "bps") since "kbps" itself ends with "bps" too. static func parseBitsPerSecond(_ raw: String?) -> Int { guard let trimmed = raw?.trimmingCharacters(in: .whitespaces), !trimmed.isEmpty else { return 0 } let unitsBySpecificity: [(suffix: String, multiplier: Double)] = [ ("Gbps", 1_000_000_000), ("Mbps", 1_000_000), ("kbps", 1_000), ("bps", 1) ] for unit in unitsBySpecificity where trimmed.hasSuffix(unit.suffix) { let numberPart = trimmed.dropLast(unit.suffix.count) if let value = Double(numberPart) { return Int(value * unit.multiplier) } } return Int(trimmed) ?? 0 } func disconnect() async { try? await client?.close() client = nil } /// Full human-readable config export (`/export terse`), used for local backups. func exportConfiguration() async throws -> String { try await run("/export terse") } func fetchFirewallRuleCounts() async throws -> FirewallRuleCounts { let filterOutput = try await run("/ip firewall filter print count-only") let natOutput = try await run("/ip firewall nat print count-only") let filterCount = Int(filterOutput.trimmingCharacters(in: .whitespacesAndNewlines)) ?? 0 let natCount = Int(natOutput.trimmingCharacters(in: .whitespacesAndNewlines)) ?? 0 return FirewallRuleCounts(filterRuleCount: filterCount, natRuleCount: natCount) } /// RouterOS 7.24.2 (verified live against a hEX test device) does *not* include `.id` in /// `print terse` CLI output, unlike REST's JSON, which always does — every field parses /// fine, but every item's `id` would otherwise fall back to a synthetic, unusable "row-N". /// `:put [ find]` returns the real internal IDs (e.g. "*C;*1;*2") in the same /// order as `print terse` (also verified live: position 0 → first ID, position 1 → second), /// so they're overlaid onto the parsed items by position here. func fetchMenuItems(menuPath: String, restPath: String) async throws -> [RouterOSMenuItem] { let output: String do { output = try await run("\(menuPath) print without-paging terse") } catch RouterOSError.invalidResponse(let detail) where Self.rejectsTerse(detail) { // Singleton menu (e.g. "/ip dns", "/system identity") — no list, no "terse" support. let plain = try await run("\(menuPath) print without-paging") return [RouterOSCliParser.parseSingletonItem(plain)] } // Same singleton rejection, but confirmed live (for "/system routerboard") that RouterOS // doesn't always report it as a command failure Citadel would throw on — it can come // back as plain output with exit code 0 instead, exactly the "exit code isn't reliable" // lesson from Bug 10 applying to a *read* command here, not just add/set/remove. Without // this, such a menu's data silently comes back empty rather than throwing or falling // back — no crash, no error, just nothing, which is worse than either. if Self.rejectsTerse(output) { let plain = try await run("\(menuPath) print without-paging") return [RouterOSCliParser.parseSingletonItem(plain)] } var items = RouterOSCliParser.parseGenericItems(output) let idOutput = try await run(":put [\(menuPath) find]") let ids = idOutput .trimmingCharacters(in: .whitespacesAndNewlines) .split(separator: ";") .map(String.init) for index in items.indices where index < ids.count { items[index] = RouterOSMenuItem(id: ids[index], fields: items[index].fields) } return items } /// RouterOS rejects `terse` on a singleton menu in more than one wording, confirmed live /// across two different devices/RouterOS versions (2026-09-16): "bad parameter terse" /// (original hEX test device) and a harder parser error, "expected end of command (line 1 /// column N)" (a second, different router) — both were seen on `/system routerboard`, the /// second one also on `/system package update`, silently leaving Routerboard info (including /// the serial number "Bekannte Router" needs to tell two same-address devices apart) and the /// software-update check both empty with no visible error. Scoped safely to this one call /// site: `detail`/`output` here only ever come from running `" print /// without-paging terse"`, so any parser error on that exact line is — by construction — /// about the trailing "terse" token, not some unrelated syntax problem elsewhere. private static func rejectsTerse(_ text: String) -> Bool { text.contains("bad parameter terse") || text.contains("expected end of command") } /// `:foreach i in=[ find whereField=whereValue] do={:put [ get $i /// returnField]}` — built from two independently confirmed-live primitives only: a bare /// `find` with one condition (verified repeatedly this session, e.g. /// `find dynamic=no` reliably returning exactly the right id), and `get field` on a /// single, already-known id (standard, unambiguous RouterOS syntax). An earlier version tried /// `get [find ...] returnField` as one combined call to do this in a single round trip — /// that specific combined form was never actually verified and was confirmed live to be /// wrong (a real make-static conversion — confirmed via Winbox — wasn't found by it). `:put` /// inside `:foreach` prints one value per line, so this splits on newlines, not ";" (the /// semicolon-joined shape only applies to a single `:put [ find ...]` list). func fetchFieldValues(menuPath: String, restPath: String, whereField: String, whereValue: String, returnField: String) async throws -> Set { let script = ":foreach i in=[\(menuPath) find \(whereField)=\(whereValue)] do={:put [\(menuPath) get $i \(returnField)]}" let output = try await run(script) let values = output .split(whereSeparator: \.isNewline) .map { $0.trimmingCharacters(in: .whitespaces) } .filter { !$0.isEmpty } return Set(values) } /// RouterOS' SSH CLI exits 0 even when a command fails — confirmed live: both /// `/ip dhcp-server add ...` on an interface that already has one ("failure: server or /// relay with such interface already exists") and an invalid action ("syntax error (line 1 /// column 46)") returned exit status 0, meaning `run()`'s exit-code check alone silently /// treats every such failure as success. A mutating command (add/set/remove) is always /// silent on success in every case observed live this session (15+ menu families) — so any /// non-empty output here is treated as the error text, since there is no more reliable /// signal available over this transport. func apply(_ command: RouterOSCommand) async throws { let output = try await run(command.cliLine) let trimmed = output.trimmingCharacters(in: .whitespacesAndNewlines) guard trimmed.isEmpty else { throw RouterOSError.invalidResponse(trimmed) } } /// Restores RouterOS' own vendor-default configuration and reboots the device. See /// FactoryResetService for why this bypasses the RouterOSCommand add/set model entirely. func resetToFactoryDefaults() async throws { _ = try await run("/system reset-configuration no-defaults=no skip-backup=no") } /// Uploads a `.rsc` script's text content to the router's own file storage via SFTP — the /// only documented way found to get a file from this Mac onto the router (RouterOS' SSH /// server has no documented SCP support, but DOES accept SFTP: confirmed live against a real /// hEX with a plain `sftp` CLI session; Citadel, already a dependency, ships an SFTP client). /// `remoteName` must be the full `flash/...`-prefixed form — confirmed live that RouterOS' /// `/file` and `/import` reject a bare filename ("file does not exist") even though `/file /// print` lists the same file that way too. func uploadScript(remoteName: String, contents: String) async throws { guard let client else { throw RouterOSError.notConnected } let sftp = try await client.openSFTP() do { try await sftp.withFile(filePath: remoteName, flags: [.write, .create, .truncate]) { file in try await file.write(ByteBuffer(string: contents)) } } catch { try? await sftp.close() throw error } try await sftp.close() } /// The RouterOS-documented restore workflow in one command: wipe the entire configuration /// (`no-defaults=yes`, not even the vendor defaults — a genuinely blank slate) and /// immediately re-apply the given already-uploaded script. Safer than `/import`-ing straight /// over a live, different configuration, which MikroTik's own docs describe as needing a /// reset first (every `add`-type line in the script would otherwise risk colliding with /// whatever's already there). Reboots the device; like `resetToFactoryDefaults()`, the /// connection dying mid-command is the expected outcome, not a failure — ignored here the /// same way. func applyRestoreScript(remoteName: String) async throws { _ = try await run("/system reset-configuration no-defaults=yes run-after-reset=\(remoteName)") } /// Reboots immediately — needed after `upgradeRouterboardFirmware()`, which (confirmed via /// docs) does not reboot on its own. Confirmed live: RouterOS' normally-interactive "Reboot, /// yes? [y/n]" console prompt (same class of prompt `resetToFactoryDefaults()` and /// `upgradeRouterboardFirmware()` also have) doesn't block this app's non-interactive SSH /// exec — both of those ran fine without any special handling, this follows the same /// established precedent. func reboot() async throws { _ = try? await run("/system reboot") } /// Triggers a fresh check against MikroTik's update servers — confirmed via docs that this /// is one of two required steps (the other, reading the result, happens separately via /// `fetchMenuItems("/system package update", ...)`, which now correctly falls back to the /// singleton-item parser for this menu too). Requires the router to have working internet /// access; a failure surfaces as the "status" field becoming an "ERROR: ..." string, not a /// thrown exception here. func checkForPackageUpdates() async throws { _ = try? await run("/system package update check-for-updates") } /// Downloads and installs the checked update. Confirmed via docs: this reboots the router /// automatically on success, no separate reboot step — the connection dying mid-command is /// the expected outcome, not a failure. func installPackageUpdate() async throws { _ = try? await run("/system package update install") } /// Applies a newer RouterBOARD bootloader firmware — only becomes available after installing /// a newer RouterOS package first (the firmware file ships bundled inside RouterOS packages, /// confirmed via docs). Unlike this app's other mutating commands, success here is NOT /// silent — RouterOS returns real confirmation text ("Firmware upgraded successfully, please /// reboot for changes to take effect!"), so the raw output is returned rather than treated as /// an error on any non-empty response. Confirmed via docs this command is normally /// interactive in Winbox/console ("Do you really want to upgrade firmware? [y/n]") — not /// verified whether that prompt applies over this app's non-interactive SSH exec too, though /// `/system reset-configuration` (also normally interactive) has run fine this way all /// session, so it's likely safe; flagged to the user in the UI regardless. Requires a manual /// `/system reboot` afterward — not automatic (confirmed via docs). func upgradeRouterboardFirmware() async throws -> String { try await run("/system routerboard upgrade") } /// Runs a command via `executeCommandStream` (not the simpler `executeCommand`), because /// `executeCommand` discards whatever output it already collected the moment the command /// exits non-zero — exactly the RouterOS error text we need. Collecting the stream ourselves /// keeps that text available even when the command fails. private func run(_ command: String) async throws -> String { guard let client else { throw RouterOSError.notConnected } var output = "" do { let stream = try await client.executeCommandStream(command) for try await chunk in stream { switch chunk { case .stdout(let buffer), .stderr(let buffer): output += String(buffer: buffer) } } return output } catch let failure as SSHClient.CommandFailed { let detail = output.trimmingCharacters(in: .whitespacesAndNewlines) throw RouterOSError.invalidResponse( "RouterOS meldete Fehler (Exit-Code \(failure.exitCode)) für \"\(command)\"" + (detail.isEmpty ? "" : ": \(detail)") ) } } } extension SSHTransport: NIOSSHClientServerAuthenticationDelegate { func validateHostKey(hostKey: NIOSSHPublicKey, validationCompletePromise: EventLoopPromise) { let fingerprint = SSHHostKeyFingerprint.sha256(of: hostKey) if hostKeyTrust.isTrusted(host: credentials.host, fingerprint: fingerprint) { validationCompletePromise.succeed(()) } else { validationCompletePromise.fail(RouterOSError.untrustedSSHHostKey(fingerprint: fingerprint)) } } }