import Foundation /// On-demand network diagnostics run FROM the router against a chosen LAN device — "is this /// device actually reachable from the router's point of view", the natural diagnostic angle for /// an app centered on the router rather than on this Mac. RouterOS' diagnostic tools (`/ping`, /// `/tool traceroute`, `/resolve`) are CLI-only with no REST equivalent, so — same reasoning as /// `BackupService`/`UpdateService`/`InterfaceTrafficMonitor` — this always opens its own dedicated /// SSH connection. Unlike `InterfaceTrafficMonitor`, a fresh connection per call (not kept open /// between calls): these are occasional, user-triggered one-shot actions from a right-click menu, /// not continuous polling. struct NetworkToolsService { enum ToolError: LocalizedError { case unsafeInput(String) var errorDescription: String? { switch self { case .unsafeInput(let value): return "\"\(value)\" enthält Zeichen, die hier nicht sicher sind (nur Buchstaben, Ziffern, \".\", \"-\", \":\" erlaubt)." } } } /// RouterOS' console treats ";" (and some other characters) as a command separator — an /// address/hostname that ultimately comes from a DHCP lease is attacker-controllable (a rogue /// device can request whatever hostname it likes), so it must never be interpolated into a /// command string unchecked. IPv4/IPv6 addresses and valid DNS hostnames only ever use these /// characters, so this is a safe allow-list, not an arbitrary restriction. static func sanitized(_ value: String) throws -> String { let allowed = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789.-:") guard !value.isEmpty, value.unicodeScalars.allSatisfy(allowed.contains) else { throw ToolError.unsafeInput(value) } return value } func ping(address: String, count: Int = 4, for credentials: RouterOSCredentials) async throws -> String { let safeAddress = try Self.sanitized(address) return try await runOneShot("/ping \(safeAddress) count=\(count)", for: credentials) } func traceroute(address: String, for credentials: RouterOSCredentials) async throws -> String { let safeAddress = try Self.sanitized(address) return try await runOneShot("/tool traceroute \(safeAddress) count=1 duration=10", for: credentials) } /// RouterOS' own DNS resolution (via its configured DNS servers) for the device's DHCP- /// advertised hostname — tells you whether the router itself can resolve the name it received /// for this device, not a general internet nslookup. Not verified live yet whether `/resolve` /// behaves identically over a plain SSH exec channel as in an interactive console session. func resolve(hostname: String, for credentials: RouterOSCredentials) async throws -> String { let safeHostname = try Self.sanitized(hostname) return try await runOneShot("/resolve \(safeHostname)", for: credentials) } private func runOneShot(_ command: String, for credentials: RouterOSCredentials) async throws -> String { let transport = SSHTransport(credentials: credentials) try await transport.connect() do { let output = try await transport.runDiagnosticCommand(command) await transport.disconnect() return output } catch { await transport.disconnect() throw error } } }