Files
RouterOS/RouterOSAssistant/Features/Devices/DevicesViewModel.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

442 lines
22 KiB
Swift

import Foundation
/// Builds the "Geräte" tab's LAN device list from a live router — DHCP leases enriched with the
/// physical port each device was learned on (ARP + bridge host table) — grouped into one table
/// per physical port, and drives the two write actions this tab offers: converting a dynamic
/// lease to a permanent static one, and removing a static lease again (RouterOS has no reverse
/// of "make-static", so "back to dynamic" is just removal — see `pendingStaticRemoval`).
@MainActor
final class DevicesViewModel: ObservableObject {
@Published private(set) var devices: [LanDevice] = []
@Published private(set) var portGroups: [DevicePortGroup] = []
@Published private(set) var isLoading = false
@Published private(set) var loadError: String?
@Published var pendingStaticAssignment: LanDevice?
/// The device whose static reservation the user is about to remove — RouterOS has no
/// "make-dynamic" (confirmed against the official DHCP docs: only check-status, make-static,
/// send-reconfigure exist for this menu), so "back to dynamic" is genuinely just removing the
/// static lease; the device gets a fresh dynamic lease on its own next DHCP request, not
/// immediately or necessarily on the same IP.
@Published var pendingStaticRemoval: LanDevice?
@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(),
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 {
isLoading = true
loadError = nil
do {
async let leasesTask = connectionService.fetchMenuItems(menuPath: "/ip dhcp-server lease", restPath: "ip/dhcp-server/lease")
async let arpTask = connectionService.fetchMenuItems(menuPath: "/ip arp", restPath: "ip/arp")
async let bridgeHostsTask = connectionService.fetchMenuItems(menuPath: "/interface bridge host", restPath: "interface/bridge/host")
async let interfacesTask = connectionService.fetchMenuItems(menuPath: "/interface", restPath: "interface")
async let dhcpServersTask = connectionService.fetchMenuItems(menuPath: "/ip dhcp-server", restPath: "ip/dhcp-server")
let leases = try await leasesTask
let arpEntries = try await arpTask
let bridgeHosts = try await bridgeHostsTask
let interfaces = try await interfacesTask
let dhcpServers = try await dhcpServersTask
// Best-effort: if this specific lookup fails (e.g. an older RouterOS version that
// doesn't support filtering "find" on this property), fall back to "nothing known
// static yet" rather than failing the whole tab — matches the safe default already
// used when a lease is entirely unmatched. Returns MAC addresses, not `.id`s — see
// `RouterOSTransport.fetchFieldValues`'s doc comment for why `.id` isn't trustworthy
// for this menu.
let staticMacs = Set((try? await connectionService.fetchFieldValues(
menuPath: "/ip dhcp-server lease", restPath: "ip/dhcp-server/lease",
whereField: "dynamic", whereValue: "no", returnField: "mac-address"
))?.map { $0.lowercased() } ?? [])
let builtDevices = Self.buildDevices(
leases: leases, arpEntries: arpEntries, bridgeHosts: bridgeHosts,
interfaces: interfaces, dhcpServers: dhcpServers, staticMacAddresses: staticMacs
)
devices = builtDevices
portGroups = Self.buildPortGroups(devices: builtDevices, interfaces: interfaces)
} catch {
loadError = error.localizedDescription
}
isLoading = false
}
/// The command "Feste IP zuweisen" would run — shown to the user before it executes, same
/// convention as the rest of the app (Wizard review screen, Expert tool's confirmation
/// dialog).
var pendingCommand: RouterOSCommand? {
guard let device = pendingStaticAssignment, device.hasLease else { return nil }
// Matched by MAC address, not `.id` — deliberately, see `RouterOSTransport.
// fetchFieldValues`'s doc comment for why the `.id` this app would otherwise have on
// hand (`device.leaseID`) isn't trustworthy for this menu.
return .action(
menuPath: "/ip dhcp-server lease", restPath: "ip/dhcp-server/lease",
name: "make-static", matchField: "mac-address", matchValue: device.macAddress,
summary: "Feste IP \(device.ipAddress) für \(device.macAddress)"
)
}
/// The command "Feste Zuweisung entfernen" would run — matched by MAC, same reasoning as
/// `pendingCommand`.
var pendingRemovalCommand: RouterOSCommand? {
guard let device = pendingStaticRemoval, device.hasLease else { return nil }
return .remove(
menuPath: "/ip dhcp-server lease", restPath: "ip/dhcp-server/lease",
matchField: "mac-address", matchValue: device.macAddress,
summary: "Feste Zuweisung entfernen für \(device.macAddress)"
)
}
/// Backs up once per connection before this tab's first write — same shared flag the Expert
/// tool uses (`ConnectionService.hasExpertToolBackedUpThisSession`), so a session that already
/// backed up via one "power tool" doesn't back up again via the other.
private func ensureSessionBackup() async throws {
guard !connectionService.hasExpertToolBackedUpThisSession, let credentials = connectionService.credentials else { return }
_ = try await backupService.createBackup(for: credentials)
connectionService.markExpertToolBackedUpThisSession()
}
func confirmStaticAssignment() async {
guard let command = pendingCommand, let device = pendingStaticAssignment else { return }
isApplying = true
applyError = nil
do {
try await ensureSessionBackup()
try await connectionService.apply(command)
// Confirmed live: RouterOS' SSH CLI can run "make-static [find ...]" without any
// error output while genuinely not converting anything (a mismatched selector) — "no
// error" alone isn't proof it worked (same lesson as Bug 10 in HANDOFF.md, one level
// deeper: even a real, syntactically valid, silently-successful-looking command can
// still not have the intended effect). Verify the lease is now actually in the static
// set before declaring success — by MAC, same reasoning as `pendingCommand`.
let staticMacs = Set((try await connectionService.fetchFieldValues(
menuPath: "/ip dhcp-server lease", restPath: "ip/dhcp-server/lease",
whereField: "dynamic", whereValue: "no", returnField: "mac-address"
)).map { $0.lowercased() })
guard staticMacs.contains(device.macAddress.lowercased()) else {
throw RouterOSError.invalidResponse(
"Befehl lief ohne Fehlermeldung, aber der Router zeigt den Eintrag weiterhin als dynamisch (geprüft über \"find dynamic=no\"). Bitte manuell mit \"/ip dhcp-server lease print\" kontrollieren."
)
}
pendingStaticAssignment = nil
await load()
} catch {
// Clear this on failure too, not just success — otherwise the confirmationDialog's
// `isPresented` binding (tied to `pendingStaticAssignment != nil`) stays "open" while
// SwiftUI also tries to present the error `.alert`, and the two presentations can
// conflict enough that the alert never actually becomes visible — a silent failure
// that looked like nothing happened at all.
pendingStaticAssignment = nil
applyError = error.localizedDescription
}
isApplying = false
}
func cancelStaticAssignment() {
pendingStaticAssignment = nil
}
func confirmStaticRemoval() async {
guard let command = pendingRemovalCommand, let device = pendingStaticRemoval else { return }
isApplying = true
applyError = nil
do {
try await ensureSessionBackup()
try await connectionService.apply(command)
// Same lesson as `confirmStaticAssignment` (Bug 10/17 in HANDOFF.md): empty output
// isn't proof it worked. Verify the lease for this MAC is actually gone — a lingering
// "no error" success is worth less than an honest check.
let remainingLeases = try await connectionService.fetchMenuItems(
menuPath: "/ip dhcp-server lease", restPath: "ip/dhcp-server/lease"
)
let stillPresent = remainingLeases.contains { $0.fields["mac-address"]?.lowercased() == device.macAddress.lowercased() }
guard !stillPresent else {
throw RouterOSError.invalidResponse(
"Befehl lief ohne Fehlermeldung, aber der Lease-Eintrag ist weiterhin vorhanden. Bitte manuell mit \"/ip dhcp-server lease print\" kontrollieren."
)
}
pendingStaticRemoval = nil
await load()
} catch {
pendingStaticRemoval = nil
applyError = error.localizedDescription
}
isApplying = false
}
func cancelStaticRemoval() {
pendingStaticRemoval = nil
}
func dismissApplyError() {
applyError = nil
}
// MARK: - Pure device list construction (testable without a live router)
nonisolated static func buildDevices(
leases: [RouterOSMenuItem],
arpEntries: [RouterOSMenuItem],
bridgeHosts: [RouterOSMenuItem],
interfaces: [RouterOSMenuItem],
dhcpServers: [RouterOSMenuItem],
staticMacAddresses: Set<String> = []
) -> [LanDevice] {
var bridgeNames = Set<String>()
for item in interfaces where item.fields["type"] == "bridge" {
if let name = item.fields["name"] { bridgeNames.insert(name) }
}
// Exact: the bridge host table names the actual physical port a MAC was learned on
// behind a bridge (RouterOS field "on-interface" — confirmed against MikroTik's
// Bridging and Switching docs, not guessed).
var exactPortByMAC: [String: String] = [:]
for item in bridgeHosts {
guard let mac = item.fields["mac-address"]?.lowercased(), let onInterface = item.fields["on-interface"] else { continue }
exactPortByMAC[mac] = onInterface
}
// Fallback: ARP's "interface" field — exact if that interface isn't a bridge, otherwise
// only "somewhere on this bridge" (resolved further by the bridge host table above when
// available).
var arpInterfaceByMAC: [String: String] = [:]
for item in arpEntries {
guard let mac = item.fields["mac-address"]?.lowercased(), let iface = item.fields["interface"] else { continue }
arpInterfaceByMAC[mac] = iface
}
// Last resort: the DHCP server's own configured interface — network-level only, since a
// server can sit on a bridge shared by several physical ports.
var interfaceByDHCPServer: [String: String] = [:]
for item in dhcpServers {
guard let name = item.fields["name"], let iface = item.fields["interface"] else { continue }
interfaceByDHCPServer[name] = iface
}
func resolvePort(mac: String, fallbackServer: String?) -> (port: String?, network: String?) {
if let exact = exactPortByMAC[mac] {
return (exact, nil)
}
if let iface = arpInterfaceByMAC[mac] {
return bridgeNames.contains(iface) ? (nil, iface) : (iface, nil)
}
if let server = fallbackServer, let iface = interfaceByDHCPServer[server] {
return bridgeNames.contains(iface) ? (nil, iface) : (iface, nil)
}
return (nil, nil)
}
var devices: [LanDevice] = []
var seenMACs = Set<String>()
for (index, item) in leases.enumerated() {
guard let address = item.fields["address"], let macRaw = item.fields["mac-address"] else { continue }
let mac = macRaw.lowercased()
seenMACs.insert(mac)
// Confirmed live on a real hEX (two independent checks, "/ip dhcp-server lease
// print"'s flags column before and after converting a lease to static): "dynamic" is
// never emitted by "print terse" for this menu, in EITHER state — not omitted only
// for the common case, genuinely absent from the parseable output entirely. Reading
// the field at all (this app's first two attempts) can't work. Static/dynamic here
// instead comes from `staticMacAddresses`, fetched via RouterOS' `find dynamic=no` —
// matched by MAC, not `.id`, because `.id` from `fetchMenuItems`' positional overlay
// was confirmed live to mis-pair with the wrong lease for this exact menu (a static
// lease's `.id` got attached to a different, still-dynamic lease's row, making it
// wrongly show "Fest" too).
let isStaticLease = staticMacAddresses.contains(mac)
let server = item.fields["server"]
let (port, network) = resolvePort(mac: mac, fallbackServer: server)
devices.append(LanDevice(
// Unique per row, not just the MAC — if RouterOS ever reports two lease entries
// for the same MAC (observed: a device that should be static still showing
// dynamic after conversion, plausibly because RouterOS created a fresh dynamic
// lease alongside the newly-static one), both must render, not silently collapse
// to one via a SwiftUI List/ForEach duplicate-id collision.
id: "lease-\(index)-\(mac)",
ipAddress: address,
macAddress: macRaw,
hostName: item.fields["host-name"],
isStatic: isStaticLease,
hasLease: true,
leaseID: item.id,
dhcpServerName: server,
resolvedPort: port,
networkHint: network,
comment: item.fields["comment"],
rawFields: [(key: ".id", value: item.id)] + item.fields.sorted { $0.key < $1.key }.map { (key: $0.key, value: $0.value) }
))
}
// ARP-only devices: seen on the network but with no matching DHCP lease (statically
// configured outside DHCP, or a stray/foreign device). Shown read-only — there's no
// lease item to convert, and guessing which DHCP server's network a static-IP device
// "belongs to" isn't something this app will do.
for (index, item) in arpEntries.enumerated() {
guard let address = item.fields["address"], let macRaw = item.fields["mac-address"] else { continue }
let mac = macRaw.lowercased()
guard !seenMACs.contains(mac) else { continue }
seenMACs.insert(mac)
let (port, network) = resolvePort(mac: mac, fallbackServer: nil)
devices.append(LanDevice(
id: "arp-\(index)-\(mac)",
ipAddress: address,
macAddress: macRaw,
hostName: nil,
// No lease at all here — "isStatic" (a DHCP *static reservation*) simply doesn't
// apply, so this must not read as "Fest" (a prior version wrongly hardcoded
// `true`, which mislabeled every ARP-only device — including ordinary DHCP
// clients whose lease didn't get matched — as statically assigned). The Geräte
// view branches on `hasLease` first precisely to keep this case visually distinct
// from both "Fest" and "Dynamisch".
isStatic: false,
hasLease: false,
leaseID: nil,
dhcpServerName: nil,
resolvedPort: port,
networkHint: network,
comment: nil,
rawFields: item.fields.sorted { $0.key < $1.key }.map { (key: $0.key, value: $0.value) }
))
}
return devices.sorted { isIPLess(ipSortKey($0.ipAddress), ipSortKey($1.ipAddress)) }
}
/// Groups devices by physical port for the per-port tables, including physical ports with
/// zero devices (so unused ports are visible too) and a catch-all group for devices whose
/// port isn't known exactly (only a bridge/network — see `LanDevice.networkHint`).
nonisolated static func buildPortGroups(devices: [LanDevice], interfaces: [RouterOSMenuItem]) -> [DevicePortGroup] {
let physicalTypes: Set<String> = ["ether", "wlan", "wifi", "wireless", "sfp", "sfp-sfpplus", "sfp28", "combo"]
var physicalPortNames = interfaces
.filter { physicalTypes.contains($0.fields["type"] ?? "") }
.compactMap { $0.fields["name"] }
physicalPortNames.sort(by: naturalPortOrder)
var devicesByPort: [String: [LanDevice]] = [:]
var otherDevices: [LanDevice] = []
for device in devices {
if let port = device.resolvedPort {
devicesByPort[port, default: []].append(device)
} else {
otherDevices.append(device)
}
}
var groups = physicalPortNames.map { port in
DevicePortGroup(id: port, title: port, devices: devicesByPort[port] ?? [])
}
// A resolved port RouterOS reported that isn't in the physical-interface snapshot
// (shouldn't normally happen) still gets its own group, so no device silently vanishes.
let knownPortNames = Set(physicalPortNames)
for (port, portDevices) in devicesByPort.sorted(by: { $0.key < $1.key }) where !knownPortNames.contains(port) {
groups.append(DevicePortGroup(id: port, title: port, devices: portDevices))
}
if !otherDevices.isEmpty {
groups.append(DevicePortGroup(id: "unbekannt", title: "Unbekannter Port", devices: otherDevices))
}
return groups
}
private nonisolated static func naturalPortOrder(_ lhs: String, _ rhs: String) -> Bool {
func split(_ value: String) -> (prefix: String, number: Int) {
let digits = value.reversed().prefix(while: \.isNumber)
let numberPart = String(digits.reversed())
let prefix = String(value.dropLast(numberPart.count))
return (prefix, Int(numberPart) ?? 0)
}
let left = split(lhs)
let right = split(rhs)
return left.prefix == right.prefix ? left.number < right.number : left.prefix < right.prefix
}
private nonisolated static func ipSortKey(_ address: String) -> [Int] {
address.split(separator: ".").compactMap { Int($0) }
}
private nonisolated static func isIPLess(_ lhs: [Int], _ rhs: [Int]) -> Bool {
for (left, right) in zip(lhs, rhs) where left != right {
return left < right
}
return lhs.count < rhs.count
}
}