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, // 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 { // 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) } 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) } func apply(_ command: RouterOSCommand) async throws { _ = try await run(command.cliLine) } /// 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") } /// 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)") ) } } }