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
This commit is contained in:
Kay
2026-09-15 22:41:23 +02:00
co-authored by Claude Sonnet 5
parent b5933fa44b
commit 04325e3bbd
15 changed files with 801 additions and 64 deletions
@@ -0,0 +1,34 @@
import XCTest
@testable import RouterOSAssistant
/// A LAN device's IP/hostname ultimately comes from its own DHCP request attacker-controllable
/// by a rogue device on the network and gets interpolated into a RouterOS console command
/// string for ping/traceroute/resolve. RouterOS' console treats ";" as a command separator, so an
/// unsanitized value could inject a second command; these tests lock in the allow-list that
/// prevents that.
final class NetworkToolsServiceTests: XCTestCase {
func testAcceptsValidIPv4Address() throws {
XCTAssertEqual(try NetworkToolsService.sanitized("192.168.88.10"), "192.168.88.10")
}
func testAcceptsValidIPv6Address() throws {
XCTAssertEqual(try NetworkToolsService.sanitized("fe80::1"), "fe80::1")
}
func testAcceptsValidHostname() throws {
XCTAssertEqual(try NetworkToolsService.sanitized("my-laptop.local"), "my-laptop.local")
}
func testRejectsCommandInjectionViaSemicolon() {
XCTAssertThrowsError(try NetworkToolsService.sanitized("8.8.8.8; /system reset-configuration"))
}
func testRejectsWhitespaceAndQuotes() {
XCTAssertThrowsError(try NetworkToolsService.sanitized("8.8.8.8 count=1000000"))
XCTAssertThrowsError(try NetworkToolsService.sanitized("\"; malicious"))
}
func testRejectsEmptyValue() {
XCTAssertThrowsError(try NetworkToolsService.sanitized(""))
}
}
@@ -0,0 +1,37 @@
import XCTest
@testable import RouterOSAssistant
final class PortScannerTests: XCTestCase {
func testCommonPortsHaveNoDuplicates() {
let ports = PortScanner.commonPorts.map(\.port)
XCTAssertEqual(ports.count, Set(ports).count)
}
func testCommonPortsAreAllValidTCPPorts() {
for info in PortScanner.commonPorts {
XCTAssertTrue((1...65535).contains(info.port), "Port \(info.port) out of valid range")
}
}
/// A high, essentially-never-bound port on loopback reliably gets an immediate TCP RST
/// (connection refused) rather than a timeout, so this is fast and deterministic not
/// dependent on any real network/router.
func testScanReportsClosedForRefusedLoopbackPort() async {
let results = await PortScanner.scan(
host: "127.0.0.1",
ports: [PortScanner.PortInfo(port: 59_999, serviceName: nil)],
timeout: 2
)
XCTAssertEqual(results.count, 1)
XCTAssertEqual(results.first?.status, .closed)
}
func testScanSortsResultsByPortNumber() async {
let ports = [
PortScanner.PortInfo(port: 59_998, serviceName: nil),
PortScanner.PortInfo(port: 59_997, serviceName: nil)
]
let results = await PortScanner.scan(host: "127.0.0.1", ports: ports, timeout: 2)
XCTAssertEqual(results.map(\.port.port), [59_997, 59_998])
}
}