forked from kay/RouterOS
Tab "Geräte" -> "LAN-Scanner", Refresh-Button "Neu scannen" + prominenter Stil. Neues Netzwerk-Tools-Menü: Ping/Traceroute/DNS-Auflösung über NetworkToolsService (eigene SSH-Verbindung, wie Backup/InterfaceTraffic- Monitor), mit Zeichen-Validierung gegen Command-Injection ueber einen boeswilligen DHCP-Hostnamen. Port-Scan laeuft direkt von diesem Mac ueber Network.framework (RouterOS hat kein eingebautes Portscan-Tool) - dabei einen echten NWConnection-Bug gefunden (verweigerte Verbindung meldet sich ueber .waiting, nicht .failed) und per Unit-Test gegen einen Loopback-Port aufgedeckt und gefixt. Zusaetzlich: Warnhinweis bei "Feste IP zuweisen" erklaert jetzt den Rueckweg. DE/EN-Umschalter zeigt Landesflaggen statt Text. 92 Tests gruen. HANDOFF.md/README.md (inkl. Mermaid-Diagramm)/Manual.md/ CHATLOG.md aktualisiert. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YDmUd93KxsYGr2kLTotWnG
68 lines
3.5 KiB
Swift
68 lines
3.5 KiB
Swift
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
|
|
}
|
|
}
|
|
}
|