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
@@ -20,7 +20,7 @@ struct RouterOSAssistantApp: App {
OverviewView(connectionService: connectionService)
.tabItem { Label(L10n.t("Übersicht", appLanguage), systemImage: "point.3.connected.trianglepath.dotted") }
DevicesView(connectionService: connectionService)
.tabItem { Label(L10n.t("Geräte", appLanguage), systemImage: "laptopcomputer.and.iphone") }
.tabItem { Label(L10n.t("LAN-Scanner", appLanguage), systemImage: "laptopcomputer.and.iphone") }
ExpertView(connectionService: connectionService)
.tabItem { Label(L10n.t("Experte", appLanguage), systemImage: "wrench.and.screwdriver") }
BackupListView(connectionService: connectionService)
@@ -31,7 +31,9 @@ struct RouterOSAssistantApp: App {
Button {
appLanguage = (appLanguage == "de") ? "en" : "de"
} label: {
Text(appLanguage == "de" ? "EN" : "DE")
// Flag of the language a tap switches TO matches the "DE"/"EN" text
// labels this replaces, which showed the same "switch to" target.
Text(appLanguage == "de" ? "🇬🇧" : "🇩🇪")
}
.help(appLanguage == "de" ? "Switch to English" : "Auf Deutsch umschalten")
}
@@ -18,7 +18,7 @@ enum L10n {
"Verbinden": "Connect",
"Einrichten": "Setup",
"Übersicht": "Topology",
"Geräte": "Devices",
"LAN-Scanner": "LAN Scanner",
"Experte": "Expert",
"Sicherungen": "Backups",
"Firewall: Filter-Regeln": "Firewall: Filter Rules",
@@ -0,0 +1,13 @@
import Foundation
/// Raw output of an on-demand network diagnostic (ping/traceroute/DNS lookup) run from the
/// router against a chosen LAN device see `NetworkToolsService`. Shown verbatim rather than
/// parsed into a structured UI: RouterOS' exact output format for these tools isn't verified
/// against live hardware yet (same caution this app applies elsewhere after finding real
/// formatting surprises in `/interface print`/`monitor-traffic` output), so showing the raw text
/// is both safer and until confirmed otherwise the only honest option.
struct NetworkToolResult: Identifiable {
let id = UUID()
let title: String
let output: String
}
@@ -0,0 +1,16 @@
import Foundation
/// Result of a `PortScanner.scan(...)` run against one LAN device, ready for display
/// `DevicesView` colors each entry red (open), green (closed), or grey (unreachable/no response).
struct PortScanResult: Identifiable {
struct Entry: Identifiable {
var id: Int { port }
let port: Int
let serviceName: String?
let status: PortScanner.PortStatus
}
let id = UUID()
let deviceLabel: String
let entries: [Entry]
}
@@ -55,6 +55,17 @@ final class SSHTransport: RouterOSTransport {
/// Not exposed over REST: this is a CLI-only command with no documented REST equivalent, so
/// callers needing it (see `InterfaceTrafficMonitor`) always use a dedicated SSH connection,
/// same reasoning as `BackupService`/`UpdateService`.
/// Runs an arbitrary RouterOS CLI command and returns its raw text output the one
/// deliberate escape hatch out of the otherwise-private `run(_:)`, for `NetworkToolsService`'s
/// on-demand diagnostics (ping/traceroute/DNS lookup have no menu-item/REST shape to go
/// through the generic `fetchMenuItems` machinery). Callers are responsible for sanitizing any
/// untrusted value (e.g. a DHCP-supplied hostname) before interpolating it into `command`
/// RouterOS' console treats ";" as a command separator, so an unsanitized value could inject
/// a second command.
func runDiagnosticCommand(_ command: String) async throws -> String {
try await run(command)
}
func fetchInterfaceTraffic(interfaceName: String) async throws -> InterfaceTraffic {
let output = try await run("/interface monitor-traffic \(interfaceName) once")
let fields = RouterOSCliParser.parseSingletonItem(output).fields
@@ -0,0 +1,67 @@
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
}
}
}
@@ -0,0 +1,125 @@
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
}
}
@@ -1,6 +1,6 @@
import SwiftUI
/// "Geräte" tab: LAN scanner one table per physical Ethernet/WLAN port, each listing the
/// "LAN-Scanner" tab: one table per physical Ethernet/WLAN port, each listing the
/// devices resolved onto it (name/IP/MAC/status), plus right-click actions to give a device a
/// permanent static IP (Winbox's "Make Static") or remove that reservation again.
struct DevicesView: View {
@@ -69,7 +69,19 @@ struct DevicesView: View {
.formStyle(.grouped)
}
}
.navigationTitle("Geräte")
.overlay(alignment: .bottom) {
if viewModel.isRunningNetworkTool {
HStack(spacing: 6) {
ProgressView().controlSize(.small)
Text("Führe Netzwerk-Test aus…").font(.caption)
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(.regularMaterial, in: Capsule())
.padding(.bottom, 12)
}
}
.navigationTitle("LAN-Scanner")
.toolbar {
ToolbarItem {
Button {
@@ -78,9 +90,14 @@ struct DevicesView: View {
if viewModel.isLoading {
ProgressView().controlSize(.small)
} else {
Label("Aktualisieren", systemImage: "arrow.clockwise")
Label("Neu scannen", systemImage: "arrow.clockwise")
}
}
// Per explicit request: a plain icon+text toolbar button up in the corner got
// overlooked ("wird übersehen") `.borderedProminent` gives it a filled,
// colored background so it visually stands out from the window chrome instead
// of blending into it.
.buttonStyle(.borderedProminent)
.disabled(viewModel.isLoading)
}
}
@@ -89,9 +106,87 @@ struct DevicesView: View {
await viewModel.load()
}
}
.withStaticAssignmentDialogs(
viewModel: viewModel,
showStaticConfirmation: $showStaticConfirmation,
showRemovalConfirmation: $showRemovalConfirmation
)
.withDeviceDetailSheets(viewModel: viewModel, rawFieldsDevice: $rawFieldsDevice)
}
}
@ViewBuilder
private func deviceMenu(for device: LanDevice) -> some View {
if device.hasLease {
if device.isStatic {
Button {
viewModel.pendingStaticRemoval = device
showRemovalConfirmation = true
} label: {
Label("Feste Zuweisung entfernen", systemImage: "pin.slash")
}
} else {
Button {
viewModel.pendingStaticAssignment = device
showStaticConfirmation = true
} label: {
Label("Feste IP zuweisen", systemImage: "pin.fill")
}
}
} else {
Text("Kein DHCP-Lease — feste Zuweisung hier nicht möglich")
}
Divider()
Menu {
Button {
viewModel.runPing(for: device)
} label: {
Label("Ping", systemImage: "dot.radiowaves.left.and.right")
}
Button {
viewModel.runTraceroute(for: device)
} label: {
Label("Traceroute", systemImage: "point.topleft.down.curvedto.point.bottomright.up")
}
if let hostName = device.hostName, !hostName.isEmpty {
Button {
viewModel.runDnsLookup(for: device)
} label: {
Label("DNS-Auflösung (nslookup)", systemImage: "magnifyingglass")
}
}
Divider()
Button {
viewModel.runPortScan(for: device)
} label: {
Label("Port-Scan", systemImage: "list.bullet.rectangle.portrait")
}
} label: {
Label("Netzwerk-Tools", systemImage: "stethoscope")
}
.disabled(viewModel.isRunningNetworkTool)
Divider()
Button {
rawFieldsDevice = device
} label: {
Label("Rohdaten anzeigen", systemImage: "list.bullet.rectangle")
}
}
}
/// Split out of `body` same reasoning as `ConnectView`'s dialog-splitting extensions (Bug 19 in
/// HANDOFF.md): too many `.alert`/`.confirmationDialog`/`.sheet` modifiers chained onto one view
/// times out the Swift type-checker with a misleading error location, not an actual logic issue.
private extension View {
func withStaticAssignmentDialogs(
viewModel: DevicesViewModel,
showStaticConfirmation: Binding<Bool>,
showRemovalConfirmation: Binding<Bool>
) -> some View {
self
.confirmationDialog(
"Feste IP-Adresse zuweisen?",
isPresented: $showStaticConfirmation,
isPresented: showStaticConfirmation,
titleVisibility: .visible
) {
Button("Zuweisen") {
@@ -104,13 +199,17 @@ struct DevicesView: View {
"\(device.ipAddress) wird dauerhaft an \(device.macAddress)"
+ (device.hostName.map { " (\($0))" } ?? "")
+ " gebunden — die Adresse ändert sich für dieses Gerät nicht mehr automatisch."
+ "\n\nRückgängig machen: hier im LAN-Scanner-Tab per Rechtsklick auf das Gerät"
+ "\"Feste Zuweisung entfernen\". Das Gerät bekommt danach aber nicht sofort"
+ " automatisch eine neue Adresse — dafür muss es die Verbindung kurz trennen und"
+ " neu aufbauen (Netzwerkkabel kurz ziehen/stecken, WLAN aus/an, oder neu starten)."
+ "\n\nBefehl: \(viewModel.pendingCommand?.cliLine ?? "")"
)
}
}
.confirmationDialog(
"Feste Zuweisung entfernen?",
isPresented: $showRemovalConfirmation,
isPresented: showRemovalConfirmation,
titleVisibility: .visible
) {
Button("Entfernen", role: .destructive) {
@@ -142,39 +241,37 @@ struct DevicesView: View {
} message: { error in
Text(error)
}
.sheet(item: $rawFieldsDevice) { device in
RawFieldsSheet(device: device) { rawFieldsDevice = nil }
}
}
}
@ViewBuilder
private func deviceMenu(for device: LanDevice) -> some View {
if device.hasLease {
if device.isStatic {
Button {
viewModel.pendingStaticRemoval = device
showRemovalConfirmation = true
} label: {
Label("Feste Zuweisung entfernen", systemImage: "pin.slash")
}
} else {
Button {
viewModel.pendingStaticAssignment = device
showStaticConfirmation = true
} label: {
Label("Feste IP zuweisen", systemImage: "pin.fill")
}
func withDeviceDetailSheets(viewModel: DevicesViewModel, rawFieldsDevice: Binding<LanDevice?>) -> some View {
self
.sheet(item: rawFieldsDevice) { device in
RawFieldsSheet(device: device) { rawFieldsDevice.wrappedValue = nil }
}
.sheet(item: Binding(
get: { viewModel.networkToolResult },
set: { if $0 == nil { viewModel.networkToolResult = nil } }
)) { result in
NetworkToolResultSheet(result: result) { viewModel.networkToolResult = nil }
}
.sheet(item: Binding(
get: { viewModel.portScanResult },
set: { if $0 == nil { viewModel.portScanResult = nil } }
)) { result in
PortScanResultSheet(result: result) { viewModel.portScanResult = nil }
}
.alert(
"Netzwerk-Test fehlgeschlagen",
isPresented: Binding(
get: { viewModel.networkToolError != nil },
set: { if !$0 { viewModel.dismissNetworkToolError() } }
),
presenting: viewModel.networkToolError
) { _ in
Button("OK") {}
} message: { error in
Text(error)
}
} else {
Text("Kein DHCP-Lease — feste Zuweisung hier nicht möglich")
}
Divider()
Button {
rawFieldsDevice = device
} label: {
Label("Rohdaten anzeigen", systemImage: "list.bullet.rectangle")
}
}
}
@@ -284,6 +381,108 @@ private struct RawFieldsSheet: View {
}
}
/// Raw text output of a ping/traceroute/DNS-Auflösung run against a device see
/// `NetworkToolResult`'s doc comment for why this isn't parsed into a nicer structured view.
private struct NetworkToolResultSheet: View {
let result: NetworkToolResult
let onClose: () -> Void
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text(result.title)
.font(.headline)
Text("Ausgeführt vom Router aus (eigene SSH-Verbindung) — testet die Erreichbarkeit vom Router zu diesem Gerät, nicht von diesem Mac.")
.font(.caption)
.foregroundStyle(.secondary)
Divider()
ScrollView {
Text(result.output.isEmpty ? "(keine Ausgabe)" : result.output)
.font(.system(.caption, design: .monospaced))
.frame(maxWidth: .infinity, alignment: .leading)
.textSelection(.enabled)
}
HStack {
Spacer()
Button("Schließen") { onClose() }
}
}
.padding()
.frame(minWidth: 420, minHeight: 320)
}
}
/// Colored per-port result of a `PortScanner` run red (open), green (closed), grey
/// (unreachable/no response), per explicit request. Run directly from this Mac, not the router
/// (see `PortScanner`'s doc comment), so the caption here says so different from
/// `NetworkToolResultSheet`'s "run from the router" caption right above it in this same file.
private struct PortScanResultSheet: View {
let result: PortScanResult
let onClose: () -> Void
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Port-Scan: \(result.deviceLabel)")
.font(.headline)
Text("TCP-Verbindungsversuch auf gängige Ports, ausgeführt von diesem Mac aus (nicht vom Router) — rot = offen, grün = geschlossen (Gerät antwortet, aber nichts lauscht dort), grau = keine Antwort (Firewall, Gerät aus, oder Port gefiltert).")
.font(.caption)
.foregroundStyle(.secondary)
Divider()
ScrollView {
VStack(spacing: 0) {
ForEach(Array(result.entries.enumerated()), id: \.element.id) { index, entry in
if index > 0 { Divider() }
PortScanRow(entry: entry)
}
}
}
HStack {
Spacer()
Button("Schließen") { onClose() }
}
}
.padding()
.frame(minWidth: 420, minHeight: 420)
}
}
private struct PortScanRow: View {
let entry: PortScanResult.Entry
private var color: Color {
switch entry.status {
case .open: return .red
case .closed: return .green
case .unreachable: return .secondary
}
}
private var statusText: String {
switch entry.status {
case .open: return "Offen"
case .closed: return "Geschlossen"
case .unreachable: return "Keine Antwort"
}
}
var body: some View {
HStack {
Circle().fill(color).frame(width: 8, height: 8)
Text("\(entry.port)")
.font(.system(.caption, design: .monospaced))
.frame(width: 50, alignment: .leading)
Text(entry.serviceName ?? "-")
.font(.caption)
.foregroundStyle(.secondary)
.frame(width: 140, alignment: .leading)
Spacer()
Text(statusText)
.font(.caption)
.foregroundStyle(color)
}
.padding(.vertical, 3)
}
}
#Preview {
DevicesView(connectionService: ConnectionService())
}
@@ -22,12 +22,84 @@ final class DevicesViewModel: ObservableObject {
@Published private(set) var isApplying = false
@Published private(set) var applyError: String?
/// Set once a ping/traceroute/DNS lookup finishes `.sheet(item:)` in `DevicesView` shows its
/// raw output. `nil` in between runs and while one is in progress.
@Published var networkToolResult: NetworkToolResult?
@Published private(set) var isRunningNetworkTool = false
@Published var networkToolError: String?
/// Set once a port scan finishes separate from `networkToolResult` since it needs a
/// structured (colored per-port) display, not raw text. Runs directly from this Mac via
/// `PortScanner`, not through the router see its own doc comment for why.
@Published var portScanResult: PortScanResult?
private let connectionService: ConnectionService
private let backupService: BackupService
private let networkToolsService: NetworkToolsService
init(connectionService: ConnectionService, backupService: BackupService = BackupService()) {
init(
connectionService: ConnectionService,
backupService: BackupService = BackupService(),
networkToolsService: NetworkToolsService = NetworkToolsService()
) {
self.connectionService = connectionService
self.backupService = backupService
self.networkToolsService = networkToolsService
}
func runPing(for device: LanDevice) {
runNetworkTool(title: "Ping: \(device.ipAddress)") { [networkToolsService] credentials in
try await networkToolsService.ping(address: device.ipAddress, for: credentials)
}
}
func runTraceroute(for device: LanDevice) {
runNetworkTool(title: "Traceroute: \(device.ipAddress)") { [networkToolsService] credentials in
try await networkToolsService.traceroute(address: device.ipAddress, for: credentials)
}
}
func runDnsLookup(for device: LanDevice) {
guard let hostName = device.hostName, !hostName.isEmpty else { return }
runNetworkTool(title: "DNS-Auflösung: \(hostName)") { [networkToolsService] credentials in
try await networkToolsService.resolve(hostname: hostName, for: credentials)
}
}
func dismissNetworkToolError() {
networkToolError = nil
}
/// Runs directly from this Mac (no router credentials involved), so it doesn't go through
/// `runNetworkTool(title:action:)` but still respects the same `isRunningNetworkTool` busy
/// flag so a scan and a ping/traceroute/DNS lookup can't overlap and race on the same overlay.
func runPortScan(for device: LanDevice) {
guard !isRunningNetworkTool else { return }
isRunningNetworkTool = true
networkToolError = nil
let label = (device.hostName?.isEmpty == false ? "\(device.hostName!) (\(device.ipAddress))" : device.ipAddress)
Task {
let results = await PortScanner.scan(host: device.ipAddress)
portScanResult = PortScanResult(
deviceLabel: label,
entries: results.map { PortScanResult.Entry(port: $0.port.port, serviceName: $0.port.serviceName, status: $0.status) }
)
isRunningNetworkTool = false
}
}
private func runNetworkTool(title: String, action: @escaping (RouterOSCredentials) async throws -> String) {
guard let credentials = connectionService.credentials, !isRunningNetworkTool else { return }
isRunningNetworkTool = true
networkToolError = nil
Task {
do {
let output = try await action(credentials)
networkToolResult = NetworkToolResult(title: title, output: output)
} catch {
networkToolError = error.localizedDescription
}
isRunningNetworkTool = false
}
}
func load() async {