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 } /// Unlike `RestTransport` (explicit `request.timeoutInterval = 5` on every request), neither /// this nor `run(_:)` used to bound how long they'd wait at all — `Citadel.SSHClient.connect` /// has no built-in timeout. Live-confirmed as a real, reproducible bug (bugs.md #11, /// 2026-09-17): creating an `/ip pool` entry in the Experte tab hung the app permanently (no /// error, no recovery, force-quit needed) — traced to this connect call, reached via /// `ExpertViewModel.saveEditingItem()`'s mandatory `ensureSessionBackup()`, which opens a /// fresh, dedicated SSH connection (`BackupService`) before every session's first write. A /// connection attempt that stalls (transient network hiccup, or the hAP-lite test router /// itself being slow under load — MIPS 24Kc/650MHz/1 core, seen at 80% CPU this session) had /// no way to ever resolve, so `isApplying` never cleared. `withTimeout` below races the real /// operation against a deadline and cancels whichever loses. private static let connectTimeout: Duration = .seconds(10) private static let commandTimeout: Duration = .seconds(30) func connect() async throws { do { client = try await Self.withTimeout(Self.connectTimeout) { try await SSHClient.connect( host: self.credentials.host, port: self.credentials.sshPort, authenticationMethod: .passwordBased(username: self.credentials.username, password: self.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))") } } /// Races `operation` against `duration`, cancelling whichever loses — the generic mechanism /// behind both `connect()`'s and `run(_:)`'s timeouts (bugs.md #11). `operation` must be /// `@Sendable`: it runs inside a detached task group child, not on the caller's isolation. static func withTimeout(_ duration: Duration, operation: @escaping @Sendable () async throws -> T) async throws -> T { try await withThrowingTaskGroup(of: T.self) { group in group.addTask { try await operation() } group.addTask { try await Task.sleep(for: duration) throw RouterOSError.transportUnavailable("Zeitüberschreitung (\(Int(duration.components.seconds))s) — Router antwortet nicht.") } defer { group.cancelAll() } return try await group.next()! } } 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). /// `whereValue` is quoted/escaped the same way as `RouterOSCommand`'s CLI rendering (see its /// `quoteIfNeeded` doc comment for the live-confirmed injection this prevents) — both current /// callers only ever pass the hardcoded literal `"no"`, but this is a generic, reusable /// `RouterOSTransport` method, so a future caller passing user/device-controlled text (e.g. a /// hostname) must not silently reopen the same command-injection class this app has already /// had to fix once. func fetchFieldValues(menuPath: String, restPath: String, whereField: String, whereValue: String, returnField: String) async throws -> Set { let escapedValue = whereValue .replacingOccurrences(of: "\\", with: "\\\\") .replacingOccurrences(of: "\"", with: "\\\"") let script = ":foreach i in=[\(menuPath) find \(whereField)=\"\(escapedValue)\"] 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) } } /// Forcibly ends already-tracked connections between two networks (bugs.md #7 / Gitea #19) — /// RouterOS' firewall rules only affect *new* connections, so a connection already open /// between two networks at the moment they're marked isolated would otherwise keep flowing /// through the pre-existing "forward established/related -> accept" rule indefinitely. /// `/ip firewall connection` has no interface field, only address fields, so this matches by /// CIDR-range membership instead. No REST equivalent exists for this CIDR-membership query /// language (REST mirrors CRUD over menu paths, not scripting), so — same reasoning as /// `BackupService`/`NetworkToolsService`/`UpdateService` — this always runs over SSH. /// /// **Verification status, honestly (2026-09-17):** `print count-only where src-address in /// ` is live-confirmed to filter by real subnet containment (40 matches for a /// populated /24 vs. 0 for an empty one, and `remove [find where dst-address=]` — /// single equality condition, no `in` — is live-confirmed to actually delete an entry (a /// disposable ICMP ping's tracked connection genuinely disappeared). The compound /// `remove [find where (src-address in A) and (dst-address in B)]` form this function /// actually uses could NOT be cleanly proven live: a first attempt looked successful, but /// that was later traced to ICMP conntrack's very short natural timeout (a few seconds) /// coinciding with the multi-second gap between separate manual SSH round-trips, not the /// `remove` itself. A cleaner retest against a still-*active* long-lived TCP connection (an /// open SSH session) showed the tracked entry reappearing immediately after `remove` — which /// is expected/correct behavior for connection tracking in general (removing the tracking /// state doesn't RST the socket; the very next packet on an actively-flowing connection just /// gets re-tracked as "new"), not proof the `remove` itself is a no-op, but this app has no /// two genuinely separate test networks available to observe the one behavior that actually /// matters here: whether a fresh isolation drop rule catches that re-tracked "new" packet /// instead of silently re-admitting it. The `remove [find where ... in ...]` compound-CIDR /// technique itself is real and community-documented (MikroTik forum), just not end-to-end /// live-verified against this app's specific isolation scenario. Best-effort by design either /// way (see `ConnectionService.flushConnections`'s caller in `SetupViewModel`) — a live /// multi-network test is the natural next verification step. /// /// `networkA`/`networkB` are interpolated directly into the script, so they're validated as /// plain CIDR notation first (digits/dots/slash only) — an unvalidated value here would /// reopen the exact command-injection class already fixed once in /// `RouterOSCommand.quoteIfNeeded` (bugs.md #5). Both directions are removed since either /// isolated network could be the connection's source or destination. func flushConnections(between networkA: String, and networkB: String) async throws { guard Self.isPlainCIDR(networkA), Self.isPlainCIDR(networkB) else { throw RouterOSError.invalidResponse("Ungültiges Netzwerkformat: \(networkA) / \(networkB)") } let script = """ /ip firewall connection remove [find where (src-address in \(networkA)) and (dst-address in \(networkB))] /ip firewall connection remove [find where (src-address in \(networkB)) and (dst-address in \(networkA))] """ _ = try await run(script) } private static func isPlainCIDR(_ value: String) -> Bool { let allowed = CharacterSet(charactersIn: "0123456789./") return !value.isEmpty && value.unicodeScalars.allSatisfy(allowed.contains) } /// 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. /// Wrapped in `withTimeout` for the same reason as `connect()` (bugs.md #11) — a command that /// never returns (router hangs mid-execution, connection drops without a clean error) used to /// block forever with no recovery. The `CommandFailed` handling stays inside the timed /// closure so `output` (partial text collected before the failure) is still in scope to build /// the error detail — the already-established domain error (`RouterOSError.invalidResponse`) /// is what actually crosses the timeout race, not the raw Citadel type. private func run(_ command: String) async throws -> String { guard let client else { throw RouterOSError.notConnected } return try await Self.withTimeout(Self.commandTimeout) { 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)) } } }