forked from kay/RouterOS
Citadels executeCommand() verwirft die gesammelte Kommandoausgabe, sobald der Exit-Code ungleich 0 ist -- genau der Text, den RouterOS bei einem fehlerhaften Befehl zurückgibt (z.B. falscher Parameter, Interface existiert nicht). Eigene Sammlung über executeCommandStream() behält die Ausgabe bis zum Fehlschlag und hängt sie an die Fehlermeldung an. Aufgefallen beim ersten Schreibtest (M3 "Einrichten"-Tab) gegen echtes Testgerät: Fehler kam nur als nutzloses "Citadel.SSHClient.CommandFailed error 1" durch. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HReLXMbmPvtQ23p1iWiJNW
91 lines
3.7 KiB
Swift
91 lines
3.7 KiB
Swift
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 apply(_ command: RouterOSCommand) async throws {
|
|
_ = try await run(command.cliLine)
|
|
}
|
|
|
|
/// 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)")
|
|
)
|
|
}
|
|
}
|
|
}
|