forked from kay/RouterOS
Eine dynamische/verbundene Route (automatisch angelegt durch eine IP-Adresse auf einem Interface) scheiterte beim Bearbeiten mit "no such item (4)" — bestätigt per /ip route print detail: D-Flag, distance=0. RouterOS' "dynamic"-Flag steht nicht zuverlässig in print terse (dasselbe Problem schon bei DHCP-Leases dokumentiert), aber distance=0 ist ein verlässliches Signal, da keine echte statische Route das je haben kann. Solche Routen bekommen jetzt kein editTarget mehr — ihre .id ist ohnehin nicht stabil, RouterOS kann sie jederzeit neu anlegen. 59 Unit-Tests grün (neuer Test OverviewGraphTests. testDynamicRouteHasNoEditTarget). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YDmUd93KxsYGr2kLTotWnG
295 lines
16 KiB
Swift
295 lines
16 KiB
Swift
import Foundation
|
|
|
|
/// Builds the read-only "Übersicht" topology graph from a live router: fetches the same generic
|
|
/// menus the Expert tool already knows how to read (`ConnectionService.fetchMenuItems`) and wires
|
|
/// them together into nodes/edges using RouterOS' own reference fields (an interface name a DHCP
|
|
/// server points at, a pool name it uses, ...) — never guessed, only what the router itself
|
|
/// returns. Purely additive/read-only: never calls `apply()`, nothing here can change the router.
|
|
@MainActor
|
|
final class OverviewViewModel: ObservableObject {
|
|
@Published private(set) var graph = OverviewGraph()
|
|
@Published private(set) var isLoading = false
|
|
@Published private(set) var loadError: String?
|
|
@Published var selectedNodeID: String?
|
|
|
|
private let connectionService: ConnectionService
|
|
|
|
init(connectionService: ConnectionService) {
|
|
self.connectionService = connectionService
|
|
}
|
|
|
|
func load() async {
|
|
isLoading = true
|
|
loadError = nil
|
|
do {
|
|
async let interfaces = connectionService.fetchMenuItems(menuPath: "/interface", restPath: "interface")
|
|
async let bridgePorts = connectionService.fetchMenuItems(menuPath: "/interface bridge port", restPath: "interface/bridge/port")
|
|
async let vlans = connectionService.fetchMenuItems(menuPath: "/interface vlan", restPath: "interface/vlan")
|
|
async let wireguardPeers = connectionService.fetchMenuItems(menuPath: "/interface wireguard peers", restPath: "interface/wireguard/peers")
|
|
async let addresses = connectionService.fetchMenuItems(menuPath: "/ip address", restPath: "ip/address")
|
|
async let pools = connectionService.fetchMenuItems(menuPath: "/ip pool", restPath: "ip/pool")
|
|
async let dhcpServers = connectionService.fetchMenuItems(menuPath: "/ip dhcp-server", restPath: "ip/dhcp-server")
|
|
async let dhcpNetworks = connectionService.fetchMenuItems(menuPath: "/ip dhcp-server network", restPath: "ip/dhcp-server/network")
|
|
async let dhcpClients = connectionService.fetchMenuItems(menuPath: "/ip dhcp-client", restPath: "ip/dhcp-client")
|
|
async let routes = connectionService.fetchMenuItems(menuPath: "/ip route", restPath: "ip/route")
|
|
async let filterRules = connectionService.fetchMenuItems(menuPath: "/ip firewall filter", restPath: "ip/firewall/filter")
|
|
async let natRules = connectionService.fetchMenuItems(menuPath: "/ip firewall nat", restPath: "ip/firewall/nat")
|
|
async let addressLists = connectionService.fetchMenuItems(menuPath: "/ip firewall address-list", restPath: "ip/firewall/address-list")
|
|
|
|
graph = try await Self.buildGraph(
|
|
interfaces: interfaces,
|
|
bridgePorts: bridgePorts,
|
|
vlans: vlans,
|
|
wireguardPeers: wireguardPeers,
|
|
addresses: addresses,
|
|
pools: pools,
|
|
dhcpServers: dhcpServers,
|
|
dhcpNetworks: dhcpNetworks,
|
|
dhcpClients: dhcpClients,
|
|
routes: routes,
|
|
filterRules: filterRules,
|
|
natRules: natRules,
|
|
addressLists: addressLists
|
|
)
|
|
} catch {
|
|
loadError = error.localizedDescription
|
|
}
|
|
isLoading = false
|
|
}
|
|
|
|
// MARK: - Pure graph construction (testable without a live router)
|
|
|
|
nonisolated static func buildGraph(
|
|
interfaces: [RouterOSMenuItem],
|
|
bridgePorts: [RouterOSMenuItem],
|
|
vlans: [RouterOSMenuItem],
|
|
wireguardPeers: [RouterOSMenuItem],
|
|
addresses: [RouterOSMenuItem],
|
|
pools: [RouterOSMenuItem],
|
|
dhcpServers: [RouterOSMenuItem],
|
|
dhcpNetworks: [RouterOSMenuItem],
|
|
dhcpClients: [RouterOSMenuItem],
|
|
routes: [RouterOSMenuItem],
|
|
filterRules: [RouterOSMenuItem],
|
|
natRules: [RouterOSMenuItem],
|
|
addressLists: [RouterOSMenuItem]
|
|
) -> OverviewGraph {
|
|
var nodes: [OverviewNode] = []
|
|
var edges: [OverviewEdge] = []
|
|
var knownInterfaceNames = Set<String>()
|
|
|
|
// Interfaces: every physical/virtual interface RouterOS reports (ethernet, vlan, bridge,
|
|
// wireless, wireguard, ...). "vlan-id"/parent info is filled in below from "/interface
|
|
// vlan", since the generic "/interface" listing alone doesn't carry it.
|
|
var vlanInfoByName: [String: (vlanID: String, parent: String)] = [:]
|
|
for item in vlans {
|
|
guard let name = item.fields["name"] else { continue }
|
|
vlanInfoByName[name] = (item.fields["vlan-id"] ?? "?", item.fields["interface"] ?? "")
|
|
}
|
|
|
|
for item in interfaces {
|
|
guard let name = item.fields["name"] else { continue }
|
|
let type = item.fields["type"] ?? "unbekannt"
|
|
knownInterfaceNames.insert(name)
|
|
var subtitle = type
|
|
if let vlanInfo = vlanInfoByName[name] {
|
|
subtitle = "VLAN \(vlanInfo.vlanID)"
|
|
}
|
|
if item.fields["disabled"] == "true" || item.fields["disabled"] == "yes" {
|
|
subtitle += " · deaktiviert"
|
|
}
|
|
nodes.append(OverviewNode(
|
|
id: nodeID(.interface, name), category: .interface, kind: type,
|
|
title: name, subtitle: subtitle,
|
|
detail: sortedDetail(item.fields)
|
|
))
|
|
}
|
|
|
|
// VLAN -> parent interface (e.g. a VLAN on ether5, or on a bridge).
|
|
for (vlanName, info) in vlanInfoByName where !info.parent.isEmpty {
|
|
edges.append(OverviewEdge(from: nodeID(.interface, info.parent), to: nodeID(.interface, vlanName), label: "trägt VLAN \(info.vlanID)", kind: .vlan))
|
|
}
|
|
|
|
// Bridge ports: physical/virtual interface -> the bridge it's a member of.
|
|
for item in bridgePorts {
|
|
guard let bridge = item.fields["bridge"], let iface = item.fields["interface"] else { continue }
|
|
edges.append(OverviewEdge(from: nodeID(.interface, iface), to: nodeID(.interface, bridge), label: "Bridge-Port", kind: .bridgePort))
|
|
}
|
|
|
|
// WireGuard peers: shown as small nodes hanging off their WireGuard interface.
|
|
for (index, item) in wireguardPeers.enumerated() {
|
|
guard let iface = item.fields["interface"] else { continue }
|
|
let id = "wgpeer:\(index)"
|
|
let allowed = item.fields["allowed-address"] ?? "?"
|
|
nodes.append(OverviewNode(
|
|
id: id, category: .interface, kind: "wireguard-peer",
|
|
title: "Peer", subtitle: allowed,
|
|
detail: sortedDetail(item.fields),
|
|
editTarget: .init(menuPath: "/interface wireguard peers", restPath: "interface/wireguard/peers", itemID: item.id)
|
|
))
|
|
edges.append(OverviewEdge(from: nodeID(.interface, iface), to: id, label: nil, kind: .wireguardPeer))
|
|
}
|
|
|
|
// IP addresses: hang off the interface they're assigned to.
|
|
var networkToAddressID: [String: String] = [:] // "192.168.88.0" -> "ip:192.168.88.1/24"
|
|
for item in addresses {
|
|
guard let address = item.fields["address"], let iface = item.fields["interface"] else { continue }
|
|
let id = nodeID(.ipAddress, address)
|
|
nodes.append(OverviewNode(
|
|
id: id, category: .ipAddress, kind: "address",
|
|
title: address, subtitle: iface,
|
|
detail: sortedDetail(item.fields),
|
|
editTarget: .init(menuPath: "/ip address", restPath: "ip/address", itemID: item.id)
|
|
))
|
|
edges.append(OverviewEdge(from: nodeID(.interface, iface), to: id, label: nil, kind: .ipAddress))
|
|
if let network = item.fields["network"] {
|
|
networkToAddressID[network] = id
|
|
}
|
|
}
|
|
|
|
// Address pools.
|
|
for item in pools {
|
|
guard let name = item.fields["name"] else { continue }
|
|
nodes.append(OverviewNode(
|
|
id: nodeID(.service, "pool:" + name), category: .service, kind: "pool",
|
|
title: name, subtitle: item.fields["ranges"],
|
|
detail: sortedDetail(item.fields),
|
|
editTarget: .init(menuPath: "/ip pool", restPath: "ip/pool", itemID: item.id)
|
|
))
|
|
}
|
|
|
|
// DHCP servers: interface -> server -> pool it hands out addresses from.
|
|
for item in dhcpServers {
|
|
guard let name = item.fields["name"] else { continue }
|
|
let id = nodeID(.service, "dhcp:" + name)
|
|
nodes.append(OverviewNode(
|
|
id: id, category: .service, kind: "dhcp-server",
|
|
title: name, subtitle: "DHCP-Server",
|
|
detail: sortedDetail(item.fields),
|
|
editTarget: .init(menuPath: "/ip dhcp-server", restPath: "ip/dhcp-server", itemID: item.id)
|
|
))
|
|
if let iface = item.fields["interface"] {
|
|
edges.append(OverviewEdge(from: nodeID(.interface, iface), to: id, label: nil, kind: .dhcp))
|
|
}
|
|
if let pool = item.fields["address-pool"], pool != "static-only" {
|
|
edges.append(OverviewEdge(from: id, to: nodeID(.service, "pool:" + pool), label: "nutzt Pool", kind: .dhcp))
|
|
}
|
|
}
|
|
|
|
// DHCP networks (gateway/DNS options): linked to the IP address whose subnet matches,
|
|
// using RouterOS' own "network" field on /ip address — not recomputed CIDR math.
|
|
for item in dhcpNetworks {
|
|
guard let address = item.fields["address"] else { continue }
|
|
let id = nodeID(.service, "dhcpnet:" + address)
|
|
nodes.append(OverviewNode(
|
|
id: id, category: .service, kind: "dhcp-network",
|
|
title: address, subtitle: item.fields["gateway"].map { "Gateway \($0)" },
|
|
detail: sortedDetail(item.fields),
|
|
editTarget: .init(menuPath: "/ip dhcp-server network", restPath: "ip/dhcp-server/network", itemID: item.id)
|
|
))
|
|
let networkPart = address.split(separator: "/").first.map(String.init) ?? address
|
|
if let addressID = networkToAddressID[networkPart] {
|
|
edges.append(OverviewEdge(from: addressID, to: id, label: "Optionen", kind: .dhcp))
|
|
}
|
|
}
|
|
|
|
// DHCP client (WAN side): interface -> "bezieht Adresse per DHCP".
|
|
for item in dhcpClients {
|
|
guard let iface = item.fields["interface"] else { continue }
|
|
let id = nodeID(.service, "dhcpclient:" + iface)
|
|
nodes.append(OverviewNode(
|
|
id: id, category: .service, kind: "dhcp-client",
|
|
title: "DHCP-Client", subtitle: iface,
|
|
detail: sortedDetail(item.fields),
|
|
editTarget: .init(menuPath: "/ip dhcp-client", restPath: "ip/dhcp-client", itemID: item.id)
|
|
))
|
|
edges.append(OverviewEdge(from: nodeID(.interface, iface), to: id, label: nil, kind: .dhcp))
|
|
}
|
|
|
|
// Static routes: linked to their gateway interface when the gateway *is* an interface
|
|
// name (RouterOS allows either an IP or an interface name here); otherwise shown
|
|
// unconnected rather than guessed.
|
|
for (index, item) in routes.enumerated() {
|
|
guard let dst = item.fields["dst-address"] else { continue }
|
|
let gateway = item.fields["gateway"] ?? "?"
|
|
let id = "route:\(index)"
|
|
// Dynamic/connected routes (auto-created by an IP address on an interface) always
|
|
// report distance=0 — no static route can ever have that (RouterOS enforces 1-255 on
|
|
// `set`). RouterOS' own "dynamic" flag isn't reliably present in `print terse` output
|
|
// (confirmed live for DHCP leases too, see `LanDevice.swift`), so distance=0 is the
|
|
// one dependable signal available here. Editing such a route failed live two ways:
|
|
// resending its own distance=0 was rejected outright, and its `.id` isn't stable
|
|
// (RouterOS can recreate/renumber a dynamic route at any time), so even a
|
|
// comment-only edit could hit "no such item" if the id had since changed underneath.
|
|
let isDynamic = item.fields["distance"] == "0"
|
|
nodes.append(OverviewNode(
|
|
id: id, category: .route, kind: "route",
|
|
title: dst, subtitle: "über \(gateway)",
|
|
detail: sortedDetail(item.fields),
|
|
editTarget: isDynamic ? nil : .init(menuPath: "/ip route", restPath: "ip/route", itemID: item.id)
|
|
))
|
|
if knownInterfaceNames.contains(gateway) {
|
|
edges.append(OverviewEdge(from: nodeID(.interface, gateway), to: id, label: nil, kind: .route))
|
|
}
|
|
}
|
|
|
|
// Address lists: one node per distinct list name, entries folded into its detail.
|
|
var addressListEntries: [String: [String]] = [:]
|
|
for item in addressLists {
|
|
guard let list = item.fields["list"] else { continue }
|
|
addressListEntries[list, default: []].append(item.fields["address"] ?? "?")
|
|
}
|
|
for (list, addressesInList) in addressListEntries.sorted(by: { $0.key < $1.key }) {
|
|
nodes.append(OverviewNode(
|
|
id: nodeID(.firewall, "addrlist:" + list), category: .firewall, kind: "address-list",
|
|
title: list, subtitle: "\(addressesInList.count) Einträge",
|
|
detail: addressesInList.enumerated().map { (key: "Adresse \($0.offset + 1)", value: $0.element) }
|
|
))
|
|
}
|
|
|
|
// Firewall filter + NAT rules: connected to their in/out interface and any address-list
|
|
// they reference — the two things that make a rule's *placement* in the topology visible.
|
|
appendFirewallRules(filterRules, kind: "filter", label: "Filter", menuPath: "/ip firewall filter", restPath: "ip/firewall/filter", into: &nodes, edges: &edges)
|
|
appendFirewallRules(natRules, kind: "nat", label: "NAT", menuPath: "/ip firewall nat", restPath: "ip/firewall/nat", into: &nodes, edges: &edges)
|
|
|
|
return OverviewGraph(nodes: nodes, edges: edges)
|
|
}
|
|
|
|
private nonisolated static func appendFirewallRules(
|
|
_ rules: [RouterOSMenuItem], kind: String, label: String, menuPath: String, restPath: String,
|
|
into nodes: inout [OverviewNode], edges: inout [OverviewEdge]
|
|
) {
|
|
for (index, item) in rules.enumerated() {
|
|
let chain = item.fields["chain"] ?? "?"
|
|
let action = item.fields["action"] ?? "?"
|
|
let id = "\(kind):\(index)"
|
|
nodes.append(OverviewNode(
|
|
id: id, category: .firewall, kind: kind,
|
|
title: "\(label): \(action)", subtitle: item.fields["comment"] ?? chain,
|
|
detail: sortedDetail(item.fields),
|
|
editTarget: .init(menuPath: menuPath, restPath: restPath, itemID: item.id)
|
|
))
|
|
if let inIface = item.fields["in-interface"], !inIface.isEmpty {
|
|
edges.append(OverviewEdge(from: nodeID(.interface, inIface), to: id, label: nil, kind: .firewallInterface))
|
|
}
|
|
if let outIface = item.fields["out-interface"], !outIface.isEmpty {
|
|
edges.append(OverviewEdge(from: id, to: nodeID(.interface, outIface), label: nil, kind: .firewallInterface))
|
|
}
|
|
if let srcList = item.fields["src-address-list"], !srcList.isEmpty {
|
|
edges.append(OverviewEdge(from: nodeID(.firewall, "addrlist:" + srcList), to: id, label: nil, kind: .addressList))
|
|
}
|
|
if let dstList = item.fields["dst-address-list"], !dstList.isEmpty {
|
|
edges.append(OverviewEdge(from: nodeID(.firewall, "addrlist:" + dstList), to: id, label: nil, kind: .addressList))
|
|
}
|
|
}
|
|
}
|
|
|
|
private nonisolated static func nodeID(_ category: OverviewNode.Category, _ key: String) -> String {
|
|
"\(category.rawValue):\(key)"
|
|
}
|
|
|
|
private nonisolated static func sortedDetail(_ fields: [String: String]) -> [(key: String, value: String)] {
|
|
fields.sorted { $0.key < $1.key }.map { (key: $0.key, value: $0.value) }
|
|
}
|
|
}
|