Files
RouterOS/RouterOSAssistant/Core/Services/PortScanner.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

126 lines
5.9 KiB
Swift

import Foundation
import Network
/// TCP port scan of one LAN device, run directly from this Mac (not through the router) — unlike
/// `NetworkToolsService`'s ping/traceroute/DNS lookup, RouterOS has no built-in port-scanning
/// command to route this through, and a port scan is naturally "can I, sitting on this network,
/// reach that port" rather than a router-perspective diagnostic anyway. Uses `Network.framework`
/// (`NWConnection`) — a plain TCP connect attempt per port, no raw sockets/root privileges needed.
enum PortScanner {
enum PortStatus: Equatable {
/// The connection was accepted — something is listening and responding.
case open
/// The connection was actively refused (TCP RST) — the host is reachable, but nothing is
/// listening on this port.
case closed
/// No response before the timeout — could mean a firewall silently drops the packet, the
/// host is off/unreachable, or the port is filtered. Deliberately not conflated with
/// "closed": a real refusal proves the host is there, a timeout proves nothing either way.
case unreachable
}
struct PortInfo {
let port: Int
/// Common service name for well-known ports, shown alongside the number for readability —
/// nil for anything not in the curated list below.
let serviceName: String?
}
/// A practical, TCP-only default set — common device/service ports worth checking on a LAN
/// device (web UIs, remote access, file sharing, printers, media/streaming). Not exhaustive;
/// this is a helpful quick check, not a security audit tool.
static let commonPorts: [PortInfo] = [
PortInfo(port: 21, serviceName: "FTP"),
PortInfo(port: 22, serviceName: "SSH"),
PortInfo(port: 23, serviceName: "Telnet"),
PortInfo(port: 25, serviceName: "SMTP"),
PortInfo(port: 53, serviceName: "DNS"),
PortInfo(port: 80, serviceName: "HTTP"),
PortInfo(port: 110, serviceName: "POP3"),
PortInfo(port: 139, serviceName: "NetBIOS"),
PortInfo(port: 143, serviceName: "IMAP"),
PortInfo(port: 443, serviceName: "HTTPS"),
PortInfo(port: 445, serviceName: "SMB"),
PortInfo(port: 554, serviceName: "RTSP"),
PortInfo(port: 993, serviceName: "IMAPS"),
PortInfo(port: 995, serviceName: "POP3S"),
PortInfo(port: 3389, serviceName: "RDP"),
PortInfo(port: 5000, serviceName: "UPnP/AirPlay"),
PortInfo(port: 8080, serviceName: "HTTP-Alt"),
PortInfo(port: 8443, serviceName: "HTTPS-Alt"),
PortInfo(port: 9100, serviceName: "Drucker (JetDirect)")
]
/// Scans every port in `ports` concurrently and returns a status per port. `timeout` bounds
/// how long a non-responding (filtered/unreachable) port is waited on — the slowest possible
/// total run time, not the typical one, since open/closed ports usually resolve almost
/// immediately.
static func scan(host: String, ports: [PortInfo] = commonPorts, timeout: TimeInterval = 1.5) async -> [(port: PortInfo, status: PortStatus)] {
await withTaskGroup(of: (PortInfo, PortStatus).self) { group in
for portInfo in ports {
group.addTask {
let status = await scanOnePort(host: host, port: portInfo.port, timeout: timeout)
return (portInfo, status)
}
}
var results: [(PortInfo, PortStatus)] = []
for await result in group {
results.append(result)
}
return results.sorted { $0.0.port < $1.0.port }
}
}
private static func scanOnePort(host: String, port: Int, timeout: TimeInterval) async -> PortStatus {
guard let nwPort = NWEndpoint.Port(rawValue: UInt16(port)) else { return .unreachable }
return await withCheckedContinuation { continuation in
let connection = NWConnection(host: NWEndpoint.Host(host), port: nwPort, using: .tcp)
let lock = NSLock()
var didResume = false
let resumeOnce: (PortStatus) -> Void = { status in
lock.lock()
defer { lock.unlock() }
guard !didResume else { return }
didResume = true
connection.cancel()
continuation.resume(returning: status)
}
connection.stateUpdateHandler = { state in
switch state {
case .ready:
resumeOnce(.open)
case .failed(let error):
resumeOnce(Self.isConnectionRefused(error) ? .closed : .unreachable)
case .waiting(let error):
// A refused TCP connection surfaces here, not via `.failed` — confirmed live
// (2026-09-15): a definitely-closed loopback port kept reporting `.unreachable`
// because this case fell through the `default: break` and just sat until the
// timeout fired. `NWConnection` treats most `.waiting` reasons as transient/
// retryable (e.g. no network path yet), which is correct to keep waiting on —
// but a refusal is conclusive, not transient, so it resolves immediately.
if Self.isConnectionRefused(error) {
resumeOnce(.closed)
}
default:
break
}
}
connection.start(queue: .global(qos: .userInitiated))
DispatchQueue.global(qos: .userInitiated).asyncAfter(deadline: .now() + timeout) {
resumeOnce(.unreachable)
}
}
}
private static func isConnectionRefused(_ error: NWError) -> Bool {
if case .posix(let code) = error, code == .ECONNREFUSED {
return true
}
return false
}
}