Files
RouterOS/RouterOSAssistant/Core/Services/NetworkToolsService.swift
T
KayandClaude Sonnet 5 04325e3bbd M20: LAN-Scanner umbenannt + Netzwerk-Tools (Ping/Traceroute/DNS/Port-Scan)
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
2026-09-15 22:41:23 +02:00

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
}
}
}