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
126 lines
5.9 KiB
Swift
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
|
|
}
|
|
}
|