diff --git a/HANDOFF.md b/HANDOFF.md index 3c78ef0..d7192f6 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -1329,14 +1329,28 @@ Claude-Memory `future-language-support.md` für Details. Peer "Keepalive", Scheduler "Intervall", Netwatch "Prüf-Intervall". Optional auch für den Geräte-Tab, falls dort künftig Zeitfelder dazukommen. -6. **Firewall-NAT-/Filter-`add`-Befehle sind bei wiederholtem Wizard-Lauf - weiterhin nicht idempotent** — im Gegensatz zu den bei Bug 22–24 - gefixten Menüs lässt RouterOS identische NAT-/Filter-Regeln mehrfach - zu (kein Fehler, kein Abbruch), aber jeder erneute Wizard-Durchlauf - häuft doppelte Regeln an. Nicht blockierend (heute so live - beobachtet, Apply lief trotzdem durch), aber auf Dauer Regel-Bloat — - bei Gelegenheit auf "vorhandene identische Regel überspringen" - umstellen. +6. ~~Firewall-NAT-/Filter-`add`-Befehle sind bei wiederholtem Wizard-Lauf + weiterhin nicht idempotent~~ — behoben (2026-09-16): im Gegensatz zu den bei Bug 22–24 + gefixten Menüs (dort per `.set`-Retry auf einem eindeutigen Feld wie + `name`/`interface` gelöst) haben Firewall-Filter/NAT-Regeln kein + eindeutiges Identitätsfeld — "dieselbe Regel" heißt hier "identisches + Argument-Set". `SetupViewModel.apply(credentials:)` holt deshalb + einmalig (nicht pro Befehl) eine Live-Momentaufnahme der bestehenden + `/ip firewall filter`- und `/ip firewall nat`-Regeln + (`connectionService.fetchMenuItems`), und `isDuplicateFirewallRule` + vergleicht jeden geplanten `.add`-Befehl (ohne das rein schreibseitige + `"place-before"`-Argument, das nie ein gespeichertes RouterOS-Feld + ist) gegen diese Momentaufnahme — bei Treffer wird der Befehl + übersprungen und im Ablauf-Log als "bereits vorhanden, übersprungen" + vermerkt statt erneut ausgeführt. Einmalige Momentaufnahme statt + Nachfrage pro Befehl, damit in derselben Wizard-Ausführung neu + hinzugefügte Regeln (z.B. eine zweite Isolationsregel) nicht + versehentlich gegen sich selbst als Duplikat erkannt werden, aber + Regeln aus einem früheren Wizard-Lauf trotzdem erkannt werden. + Live bestätigt: kompletter Wizard mit aktivierter Firewall-Sektion + zweimal hintereinander gegen denselben Router angewendet, Filter-/ + NAT-Regelanzahl blieb beim zweiten Durchlauf unverändert + ("passt, anzahlen ändern sich nicht"). 7. ~~`.id`-Positions-Überlagerung (`fetchMenuItems`) auf weitere Menüs prüfen~~ — am 2026-09-16 live an `/ip address`/`/ip route`/erneut `/ip dhcp-server lease` geprüft, keine erneute Fehlzuordnung diff --git a/RouterOSAssistant/Features/Wizard/Steps/Setup/SetupViewModel.swift b/RouterOSAssistant/Features/Wizard/Steps/Setup/SetupViewModel.swift index 1ba2ebb..228ce08 100644 --- a/RouterOSAssistant/Features/Wizard/Steps/Setup/SetupViewModel.swift +++ b/RouterOSAssistant/Features/Wizard/Steps/Setup/SetupViewModel.swift @@ -275,6 +275,27 @@ final class SetupViewModel: ObservableObject { 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 = ["/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 @@ -286,7 +307,23 @@ final class SetupViewModel: ObservableObject { 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) }