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
442 lines
22 KiB
Swift
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
|
|
}
|
|
}
|