forked from kay/RouterOS
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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user