Wiederholter Wizard-Lauf gegen einen bereits konfigurierten Router hat bisher jedes Mal dieselben NAT-/Filter-Regeln erneut angelegt (RouterOS lehnt Duplikate hier nicht ab). SetupViewModel.apply holt jetzt einmalig eine Live-Momentaufnahme der bestehenden Regeln und überspringt geplante .add-Befehle mit identischem Argument-Set (ohne das rein schreibseitige place-before). Live bestätigt: zweimaliger Wizard-Lauf, Regelanzahl blieb beim zweiten Mal unverändert. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
401 lines
18 KiB
Swift
401 lines
18 KiB
Swift
import Foundation
|
|
|
|
enum SetupStep: Int, CaseIterable {
|
|
case mode
|
|
case wan
|
|
case lan
|
|
case vlan
|
|
case wifi
|
|
case firewall
|
|
case review
|
|
}
|
|
|
|
/// Simple restricts the wizard to basic setup (single LAN, fixed firewall baseline, no VLANs,
|
|
/// no per-port DHCP/isolation) for users who just want a working router. Expert exposes every
|
|
/// RouterOS feature the wizard supports.
|
|
enum SetupMode {
|
|
case simple
|
|
case expert
|
|
}
|
|
|
|
@MainActor
|
|
final class SetupViewModel: ObservableObject {
|
|
@Published var step: SetupStep = .mode
|
|
@Published var mode: SetupMode = .simple
|
|
@Published var wanConfig = WanConfig(interfaceName: "ether1")
|
|
@Published var lanConfigs: [LanDhcpConfig] = [LanDhcpConfig()]
|
|
@Published private(set) var vlanSectionEnabled = false
|
|
@Published var vlans: [VlanEntry] = []
|
|
@Published var wifiNetworks: [WifiNetworkConfig] = []
|
|
@Published private(set) var unsupportedWifiInterfaces: [NetworkInterface] = []
|
|
@Published private(set) var firewallSectionEnabled = false
|
|
@Published private(set) var existingFirewallRuleCounts: FirewallRuleCounts?
|
|
@Published private(set) var isLoadingFirewallRuleCounts = false
|
|
@Published private(set) var firewallRuleCountsError: String?
|
|
|
|
/// Live port-conflict result per LAN config, once checked — nil means either "not checked
|
|
/// yet" or "checked, port is free". See `checkPortConflict(for:)`.
|
|
@Published private(set) var lanPortConflicts: [LanDhcpConfig.ID: PortConflict] = [:]
|
|
@Published private(set) var isCheckingPortConflict: Set<LanDhcpConfig.ID> = []
|
|
/// A conflict the user has explicitly (twice-confirmed) opted to clear — only these get their
|
|
/// `resolutionCommands()` added to `plannedCommands`. Cleared whenever the port selection
|
|
/// changes, so switching to a different, already-acknowledged port re-asks.
|
|
@Published var acknowledgedPortConflicts: Set<LanDhcpConfig.ID> = []
|
|
|
|
@Published private(set) var isApplying = false
|
|
@Published private(set) var applyLog: [String] = []
|
|
@Published private(set) var applyError: String?
|
|
@Published private(set) var didApplySuccessfully = false
|
|
|
|
private let connectionService: ConnectionService
|
|
private let backupService: BackupService
|
|
|
|
init(connectionService: ConnectionService, backupService: BackupService = BackupService()) {
|
|
self.connectionService = connectionService
|
|
self.backupService = backupService
|
|
prepareDefaults(from: connectionService.interfaces)
|
|
}
|
|
|
|
var plannedCommands: [RouterOSCommand] {
|
|
wanConfig.buildCommands()
|
|
+ lanConfigs.flatMap { config -> [RouterOSCommand] in
|
|
let resolution = acknowledgedPortConflicts.contains(config.id)
|
|
? (lanPortConflicts[config.id]?.resolutionCommands() ?? [])
|
|
: []
|
|
return resolution + config.buildCommands()
|
|
}
|
|
+ vlans.flatMap { $0.buildCommands() }
|
|
+ wifiNetworks.flatMap { $0.buildCommands() }
|
|
+ (firewallSectionEnabled
|
|
? FirewallConfig(wanInterface: wanConfig.interfaceName, networks: networkSegments).buildCommands()
|
|
: [])
|
|
}
|
|
|
|
/// All configured LAN + VLAN networks, for the firewall isolation rules.
|
|
private var networkSegments: [FirewallConfig.NetworkSegment] {
|
|
lanConfigs.map { FirewallConfig.NetworkSegment(interfaceName: $0.interfaceName, isolated: $0.isolated) }
|
|
+ vlans.map { FirewallConfig.NetworkSegment(interfaceName: $0.interfaceName, isolated: $0.isolated) }
|
|
}
|
|
|
|
/// Interface names of networks marked isolated, for display on the Firewall step.
|
|
var isolatedNetworkNames: [String] {
|
|
networkSegments.filter(\.isolated).map(\.interfaceName)
|
|
}
|
|
|
|
func setFirewallSectionEnabled(_ enabled: Bool) {
|
|
firewallSectionEnabled = enabled
|
|
if enabled {
|
|
loadExistingFirewallRuleCounts()
|
|
} else {
|
|
existingFirewallRuleCounts = nil
|
|
firewallRuleCountsError = nil
|
|
}
|
|
}
|
|
|
|
func loadExistingFirewallRuleCounts() {
|
|
guard firewallSectionEnabled, existingFirewallRuleCounts == nil, !isLoadingFirewallRuleCounts else { return }
|
|
isLoadingFirewallRuleCounts = true
|
|
firewallRuleCountsError = nil
|
|
Task {
|
|
do {
|
|
existingFirewallRuleCounts = try await connectionService.fetchFirewallRuleCounts()
|
|
} catch {
|
|
firewallRuleCountsError = error.localizedDescription
|
|
}
|
|
isLoadingFirewallRuleCounts = false
|
|
}
|
|
}
|
|
|
|
func setVlanSectionEnabled(_ enabled: Bool) {
|
|
vlanSectionEnabled = enabled
|
|
if !enabled { vlans = [] }
|
|
}
|
|
|
|
func addVlan() {
|
|
let usedIDs = Set(vlans.map(\.vlanID))
|
|
var nextID = 20
|
|
while usedIDs.contains(nextID) { nextID += 1 }
|
|
vlans.append(VlanEntry(vlanID: nextID, parentInterface: lanConfigs.first?.interfaceName ?? "bridge"))
|
|
}
|
|
|
|
func removeVlan(_ id: VlanEntry.ID) {
|
|
vlans.removeAll { $0.id == id }
|
|
}
|
|
|
|
func addLan() {
|
|
let usedNames = Set([wanConfig.interfaceName] + lanConfigs.map(\.interfaceName))
|
|
let nextInterface = connectionService.interfaces.first { !usedNames.contains($0.name) }
|
|
lanConfigs.append(LanDhcpConfig(interfaceName: nextInterface?.name ?? "bridge"))
|
|
}
|
|
|
|
func removeLan(_ id: LanDhcpConfig.ID) {
|
|
guard lanConfigs.count > 1 else { return }
|
|
lanConfigs.removeAll { $0.id == id }
|
|
lanPortConflicts[id] = nil
|
|
acknowledgedPortConflicts.remove(id)
|
|
isCheckingPortConflict.remove(id)
|
|
}
|
|
|
|
/// Live-checks whether the port currently picked for this LAN config already carries other
|
|
/// configuration (bridge membership, an existing address, WAN dial-up) — called when the LAN
|
|
/// step appears and whenever its interface Picker selection changes. Any prior acknowledgement
|
|
/// is dropped: switching ports means re-asking, since a previously-cleared conflict on the old
|
|
/// port says nothing about the newly picked one. Best-effort — a failed check (e.g. transient
|
|
/// connection hiccup) must not block the wizard; the user simply doesn't get the extra warning
|
|
/// for that attempt, same as before this feature existed.
|
|
func checkPortConflict(for configID: LanDhcpConfig.ID) {
|
|
guard let config = lanConfigs.first(where: { $0.id == configID }) else { return }
|
|
acknowledgedPortConflicts.remove(configID)
|
|
lanPortConflicts[configID] = nil
|
|
isCheckingPortConflict.insert(configID)
|
|
Task {
|
|
defer { isCheckingPortConflict.remove(configID) }
|
|
lanPortConflicts[configID] = try? await connectionService.checkPortConflict(interfaceName: config.interfaceName)
|
|
}
|
|
}
|
|
|
|
/// The user has been shown what's on this port and, after two explicit confirmations, chose
|
|
/// to have the app clear it — see `PortConflictWarningView` in `LanStepView.swift`.
|
|
func acknowledgePortConflict(for configID: LanDhcpConfig.ID) {
|
|
acknowledgedPortConflicts.insert(configID)
|
|
}
|
|
|
|
/// Blocks "Weiter" on the LAN step until every conflicting port has either been acknowledged
|
|
/// (app will clear it) or the user picked a different, actually-free port instead.
|
|
func hasUnresolvedPortConflict(for configID: LanDhcpConfig.ID) -> Bool {
|
|
lanPortConflicts[configID] != nil && !acknowledgedPortConflicts.contains(configID)
|
|
}
|
|
|
|
/// Replaces the placeholder WAN/LAN interface names ("ether1"/"bridge") with real ones
|
|
/// from the connected device, whenever the current value isn't actually one of its
|
|
/// interfaces — otherwise the Picker selections don't match any of their tags.
|
|
func prepareDefaults(from interfaces: [NetworkInterface]) {
|
|
guard !interfaces.isEmpty else { return }
|
|
let names = Set(interfaces.map(\.name))
|
|
|
|
if !names.contains(wanConfig.interfaceName) {
|
|
if let firstEthernet = interfaces.first(where: { $0.type.lowercased().contains("ether") }) {
|
|
wanConfig.interfaceName = firstEthernet.name
|
|
} else {
|
|
wanConfig.interfaceName = interfaces[0].name
|
|
}
|
|
}
|
|
|
|
if !lanConfigs.isEmpty, !names.contains(lanConfigs[0].interfaceName) {
|
|
if let bridge = interfaces.first(where: { $0.type.lowercased().contains("bridge") }) {
|
|
lanConfigs[0].interfaceName = bridge.name
|
|
} else if let fallback = interfaces.first(where: { $0.name != wanConfig.interfaceName }) {
|
|
lanConfigs[0].interfaceName = fallback.name
|
|
} else {
|
|
lanConfigs[0].interfaceName = interfaces[0].name
|
|
}
|
|
}
|
|
|
|
if wifiNetworks.isEmpty {
|
|
let legacyWireless = interfaces.filter { $0.type.lowercased() == "wlan" }
|
|
wifiNetworks = legacyWireless.map { WifiNetworkConfig(interfaceName: $0.name) }
|
|
}
|
|
unsupportedWifiInterfaces = interfaces.filter { $0.type.lowercased() == "wifi" }
|
|
}
|
|
|
|
func goNext() {
|
|
switch step {
|
|
case .mode:
|
|
if mode == .simple { applySimpleModeConstraints() }
|
|
step = .wan
|
|
case .wan:
|
|
step = .lan
|
|
case .lan:
|
|
step = (mode == .expert) ? .vlan : .wifi
|
|
case .vlan:
|
|
step = .wifi
|
|
case .wifi:
|
|
step = .firewall
|
|
case .firewall:
|
|
step = .review
|
|
case .review:
|
|
break
|
|
}
|
|
}
|
|
|
|
func goBack() {
|
|
switch step {
|
|
case .mode:
|
|
break
|
|
case .wan:
|
|
step = .mode
|
|
case .lan:
|
|
step = .wan
|
|
case .vlan:
|
|
step = .lan
|
|
case .wifi:
|
|
step = (mode == .expert) ? .vlan : .lan
|
|
case .firewall:
|
|
step = .wifi
|
|
case .review:
|
|
step = .firewall
|
|
}
|
|
}
|
|
|
|
/// Simple mode allows only one LAN network, no isolation, no VLANs, and a fixed firewall
|
|
/// baseline — strip anything an earlier Expert-mode choice may have left configured.
|
|
private func applySimpleModeConstraints() {
|
|
vlanSectionEnabled = false
|
|
vlans = []
|
|
if lanConfigs.count > 1 { lanConfigs = Array(lanConfigs.prefix(1)) }
|
|
if !lanConfigs.isEmpty { lanConfigs[0].isolated = false }
|
|
setFirewallSectionEnabled(true)
|
|
}
|
|
|
|
/// Ends the wizard after a successful apply — without this, the completed review screen just
|
|
/// sits there with a disabled "Jetzt anwenden" and no way forward except "Zurück" (which would
|
|
/// re-walk now-stale steps against the router state this apply just changed). Resets to a
|
|
/// fresh run and re-reads live interface defaults, so a follow-up run (e.g. adding one more
|
|
/// LAN afterwards) starts from the router's actual current state rather than this session's
|
|
/// now-outdated in-memory one.
|
|
func finish() {
|
|
step = .mode
|
|
mode = .simple
|
|
wanConfig = WanConfig(interfaceName: "ether1")
|
|
lanConfigs = [LanDhcpConfig()]
|
|
vlanSectionEnabled = false
|
|
vlans = []
|
|
wifiNetworks = []
|
|
unsupportedWifiInterfaces = []
|
|
firewallSectionEnabled = false
|
|
existingFirewallRuleCounts = nil
|
|
isLoadingFirewallRuleCounts = false
|
|
firewallRuleCountsError = nil
|
|
lanPortConflicts = [:]
|
|
isCheckingPortConflict = []
|
|
acknowledgedPortConflicts = []
|
|
applyLog = []
|
|
applyError = nil
|
|
didApplySuccessfully = false
|
|
prepareDefaults(from: connectionService.interfaces)
|
|
}
|
|
|
|
/// Firewall filter/NAT menus have no unique identifying field (name, interface, …) to retry
|
|
/// an `.add` as a `.set` against — unlike the menus in `retryAsSetMenuPaths` below, a rule is
|
|
/// only "the same rule" if its whole argument set matches. RouterOS itself happily accepts
|
|
/// identical filter/NAT rules added repeatedly (no error, no dedup) — live-observed: a
|
|
/// second full wizard run against an already-configured router just keeps appending the same
|
|
/// rules again. Checked once per `apply()` against a live snapshot (see `apply(credentials:)`),
|
|
/// not per-command, since fetching after every single add would make an already-configured
|
|
/// router's rules invisible to the check for rules added earlier in the same run.
|
|
private static let firewallRuleMenuPaths: Set<String> = ["/ip firewall filter", "/ip firewall nat"]
|
|
|
|
/// `RouterOSCommand.add(...)`'s `"place-before"` argument is a write-time positional
|
|
/// instruction (where to insert), not a stored RouterOS property — it never appears in
|
|
/// `fetchMenuItems`' parsed fields, so it must be excluded here, or the comparison would
|
|
/// spuriously fail on it for every single command.
|
|
private func isDuplicateFirewallRule(_ command: RouterOSCommand, in existingItems: [RouterOSMenuItem]) -> Bool {
|
|
let comparableArguments = command.arguments.filter { $0.key != "place-before" }
|
|
return existingItems.contains { item in
|
|
comparableArguments.allSatisfy { key, value in item.fields[key] == value }
|
|
}
|
|
}
|
|
|
|
func apply(credentials: RouterOSCredentials) {
|
|
isApplying = true
|
|
applyError = nil
|
|
applyLog = []
|
|
didApplySuccessfully = false
|
|
|
|
Task {
|
|
do {
|
|
applyLog.append("Sichere aktuelle Konfiguration…")
|
|
_ = try await backupService.createBackup(for: credentials)
|
|
|
|
var existingFirewallItems: [String: [RouterOSMenuItem]] = [:]
|
|
if plannedCommands.contains(where: { Self.firewallRuleMenuPaths.contains($0.menuPath) }) {
|
|
for menuPath in Self.firewallRuleMenuPaths {
|
|
existingFirewallItems[menuPath] = try await connectionService.fetchMenuItems(
|
|
menuPath: menuPath,
|
|
restPath: menuPath == "/ip firewall filter" ? "ip/firewall/filter" : "ip/firewall/nat"
|
|
)
|
|
}
|
|
}
|
|
|
|
for command in plannedCommands {
|
|
if case .add = command.operation,
|
|
let existing = existingFirewallItems[command.menuPath],
|
|
isDuplicateFirewallRule(command, in: existing) {
|
|
applyLog.append("\(command.summary) — bereits vorhanden, übersprungen")
|
|
continue
|
|
}
|
|
applyLog.append(command.summary)
|
|
try await applyIdempotently(command)
|
|
}
|
|
|
|
didApplySuccessfully = true
|
|
} catch {
|
|
applyError = error.localizedDescription
|
|
}
|
|
isApplying = false
|
|
}
|
|
}
|
|
|
|
/// Menus where a duplicate `.add` should instead reconfigure the existing entry in place —
|
|
/// re-running any wizard step against an already-configured router hits this on every menu
|
|
/// that enforces uniqueness (dhcp-client/pppoe-client: one per interface, live-confirmed
|
|
/// "failure: dhcp-client on that interface already exists"; pool/dhcp-server: one per name;
|
|
/// dhcp-server network: one per address). The value is which field of the (deterministic,
|
|
/// app-generated) arguments identifies the existing entry to match on for the `.set` retry.
|
|
/// "/ip address" is deliberately not here — an interface can legitimately hold several
|
|
/// addresses, so matching a `.set` by "interface" alone could reconfigure the wrong one;
|
|
/// see `duplicateTolerantMenuPaths` below for how that menu is handled instead.
|
|
private static let retryAsSetMenuPaths: [String: String] = [
|
|
"/ip dhcp-client": "interface",
|
|
"/interface pppoe-client": "interface",
|
|
"/ip pool": "name",
|
|
"/ip dhcp-server": "name",
|
|
"/ip dhcp-server network": "address"
|
|
]
|
|
|
|
/// Menus where a duplicate `.add` means the desired state already holds, with nothing
|
|
/// meaningful left to update — "/interface list"/"/interface list member" (fixed,
|
|
/// hardcoded arguments; list membership is binary, no ".set" equivalent) and "/ip address"
|
|
/// (the address string itself is the app's only identifying argument here — if RouterOS
|
|
/// already has that exact address on that exact interface, this add's whole job is already
|
|
/// done, and matching a `.set` by "interface" would risk touching a different address on a
|
|
/// multi-address interface instead, per the note above).
|
|
private static let duplicateTolerantMenuPaths: Set<String> = ["/interface list", "/interface list member", "/ip address"]
|
|
|
|
/// Menus where a `.remove` finding no match means the desired state already holds — used for
|
|
/// `DhcpServerCommandBuilder`'s unconditional "detach this interface from any bridge" step,
|
|
/// which runs even for interfaces that were never bridged (the common case). SSH's
|
|
/// `remove [find ...]` is already a silent no-op there; REST's `findItemID` throws
|
|
/// "not found" instead (see `RestTransport.apply`), so that specific failure needs to be
|
|
/// swallowed here to keep both transports behaving the same way.
|
|
private static let missingTolerantRemoveMenuPaths: Set<String> = ["/interface bridge port"]
|
|
|
|
private func applyIdempotently(_ command: RouterOSCommand) async throws {
|
|
do {
|
|
try await connectionService.apply(command)
|
|
} catch {
|
|
if case .remove = command.operation, Self.missingTolerantRemoveMenuPaths.contains(command.menuPath) {
|
|
return
|
|
}
|
|
guard case .add = command.operation else { throw error }
|
|
|
|
if Self.duplicateTolerantMenuPaths.contains(command.menuPath) {
|
|
return
|
|
}
|
|
guard let matchField = Self.retryAsSetMenuPaths[command.menuPath],
|
|
let matchValue = command.arguments[matchField] else {
|
|
throw error
|
|
}
|
|
let retryCommand = RouterOSCommand.set(
|
|
menuPath: command.menuPath,
|
|
restPath: command.restPath,
|
|
matchField: matchField,
|
|
matchValue: matchValue,
|
|
arguments: command.arguments,
|
|
summary: command.summary
|
|
)
|
|
try await connectionService.apply(retryCommand)
|
|
}
|
|
}
|
|
}
|