diff --git a/CHATLOG.md b/CHATLOG.md index 3f44cdc..7d3f9f1 100644 --- a/CHATLOG.md +++ b/CHATLOG.md @@ -696,3 +696,42 @@ Test, WLAN/Bonding/PPPoE-Live-Tests). (Bug 21, Nächste-Schritte Punkt 1 als "in Arbeit" markiert — der eigentliche M8-Isolationstest ist noch nicht zu Ende geführt, nächste Session dort fortsetzen) aktualisiert, Commit erstellt. Sitzung beendet. + +## Session: M8 komplett live verifiziert (manuell + über die App) + +- "wo waren wir stehen geblieben?" → Stand zusammengefasst, "weiter bei + M8" (M5 kann mangels Hardware nicht getestet werden). +- Nutzerfrage: Port 5 komplett isolieren, eigener DHCP-Server, trotzdem + Internet — `mikrotik-setup.md` Szenario C traf exakt zu, Befehle + gegeben. Danach mehrere Diagnoserunden am echten Router (SSH), jede + ein eigener Konfigurationsfehler statt App-Bug: Adresse zuerst als + `/32`, dann versehentlich als `.0`-Netzadresse statt `.1`-Host-Adresse + eingetragen (Route fehlte); danach DNS-Anfragen an den Router liefen + in Timeout, obwohl Lease+Route+Internet gingen — Ursache: `ether5` + fehlte in der defconf-Interface-Liste "LAN", RouterOS' Werks-Firewall + blockt Input von allem, was nicht in dieser Liste steht. Isolation + danach per `/interface list`-basierter Forward-Drop-Regel gesetzt und + beidseitig bestätigt (iMac ↔ Laptop erreichen sich nicht, Internet + bleibt für beide). +- "testen wir das nochmal über die App" → derselbe Test über den + Wizard (Experte-Modus, `ether4`) wiederholt, um die eigentliche + M8-Abnahmebedingung zu erfüllen. Dabei vorab zwei App-Bugs erkannt und + gefixt, bevor überhaupt angewendet wurde: **Bug 22** (LAN/VLAN-Schritt + fügt neues Interface nie der "LAN"-Liste hinzu — derselbe DNS-Bug wie + eben, jetzt als App-Bug bestätigt) und **Bug 23** (voller + Wizard-Durchlauf gegen bereits konfigurierten Router bricht am + zweiten, nicht-idempotenten Befehl ab — Nutzer wählte "Idempotenz + jetzt erweitern" statt nur Befehle zu verifizieren). +- Nach Apply zeigte `/ip firewall filter print` zwei mit `I` (INVALID) + markierte Isolationsregeln: `ether4` war noch Bridge-Slave (Werks- + Bridging), RouterOS verwirft Interface-Matcher auf Slave-Ports selbst. + **Bug 24** gefunden — Nutzer entfernte `ether4` manuell aus der + Bridge, danach wurden die Regeln automatisch gültig; Fix (automatisches + Lösen aus der Bridge vor Zuweisung) direkt im Wizard-Code ergänzt und + getestet. +- Alle Live-Tests am Ende erfolgreich (Lease, Gateway-Ping, DNS, + Internet, Isolation gegen iMac/Laptop), 52 Unit-Tests grün. M8 als + erster Milestone sowohl manuell als auch über die App selbst + verifiziert. Nebenbei zwei veraltete Doku-Stellen korrigiert (Gitea- + Remote existiert längst, war noch als "nicht vorhanden" dokumentiert). + HANDOFF.md/README.md aktualisiert, Commit + Push. diff --git a/HANDOFF.md b/HANDOFF.md index 5333861..dfea747 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -56,17 +56,60 @@ Farbwerte, alles adaptive System-Farben) — vom Nutzer live bestätigt. Release-Build unter `/Applications/RouterOS Assistant.app` ist auf aktuellem Stand. -**Zum Sessionende:** M8-Hardware-Test (Netzwerk-Isolation) begonnen — -voller Wizard-Durchlauf über den Experte-Modus, WAN neu einrichten. -Dabei Bug 21 gefunden: der Wizard-Apply nutzte für WAN-DHCP-Client/PPPoE -immer `.add`, was auf einem bereits konfigurierten Interface (hier: aus -der zurückgespielten Sicherung) mit "failure: dhcp-client on that -interface already exists" scheiterte. Fix: `SetupViewModel. -applyIdempotently` versucht bei `.add` auf `/ip dhcp-client` oder -`/interface pppoe-client` nach einem Fehlschlag automatisch `.set` -(nach "interface" gematcht) — deployt, **aber der eigentliche -M8-Isolationstest selbst ist noch nicht zu Ende geführt/verifiziert**, -das ist der erste Schritt für die nächste Session. +**Danach, neue Session: M8 komplett live verifiziert — erst manuell, +dann über den Wizard selbst.** Bug 21 aus der Vorsession (WAN-DHCP- +Client/PPPoE-Apply nicht idempotent) blieb bestätigt gefixt. Isolation +zuerst manuell per SSH an `ether5` nachgebaut (eigenes Subnetz, eigener +DHCP-Server, `/interface list`-basierte Forward-Drop-Regel) — dabei drei +reine Konfigurationsfehler der Reihe nach gefunden und behoben (Adresse +versehentlich als `/32` bzw. `.0`-Netzadresse statt Host-Adresse +eingetragen; `ether5` fehlte in der defconf-Interface-Liste "LAN", +wodurch DNS-Anfragen an den Router selbst blockiert wurden, obwohl +DHCP/Routing/Internet normal liefen). Isolation danach beidseitig +bestätigt (iMac ↔ Laptop erreichen sich nicht, Internet für beide +weiterhin ja). + +Anschließend derselbe Test **über den App-Wizard** (Experte-Modus, +`ether4`) wiederholt, um die Abnahme-Bedingung aus der Vorsession +("kompletter Wizard-Durchlauf") tatsächlich zu erfüllen — dabei drei +echte App-Bugs gefunden und gefixt, alle auf denselben "Wizard wurde nie +gegen einen bereits konfigurierten Router bzw. mit bereits gebrückten +Ports erneut ausgeführt" blinden Fleck zurückzuführen: + +- **Bug 22:** `DhcpServerCommandBuilder` fügte ein neues LAN-/VLAN- + Interface nie der defconf-Interface-Liste "LAN" hinzu — derselbe + DNS-Bug wie oben, jetzt als App-Bug bestätigt. Fix: automatisch + `/interface list add name=LAN` + `/interface list member add + list=LAN interface=`, beide toleriert falls schon vorhanden. +- **Bug 23:** ein voller Wizard-Durchlauf gegen einen bereits + konfigurierten Router brach beim zweiten Befehl ab + (`/ip address add address=192.168.88.1/24 interface=bridge` — + "already have such address"), weil nur WAN-DHCP-Client/PPPoE + idempotent behandelt wurden (Bug 21). Fix: `SetupViewModel. + applyIdempotently` erweitert — `/ip pool`/`/ip dhcp-server`/ + `/ip dhcp-server network` werden bei Duplikat jetzt als `.set` + (gematcht auf name/name/address) erneut versucht, `/ip address` bei + exaktem Duplikat als bereits erledigt behandelt (ein Interface darf + mehrere Adressen halten, ein `.set` nach "interface" träfe sonst + potenziell die falsche). +- **Bug 24:** LAN-/VLAN-Schritt löste ein gewähltes physisches Interface + nie aus einer bestehenden Bridge — auf Werks-Routern sind ether2–5 ab + Werk gebridged. Als eigenes isoliertes Netz konfiguriert blieb das + Interface Bridge-"Slave", RouterOS verwarf die generierten + Isolationsregeln selbst als ungültig ("in/out-interface matcher not + possible when interface is slave - use master instead"), live an + `ether4` bestätigt. Fix: `DhcpServerCommandBuilder` stellt jetzt + `/interface bridge port remove [find interface=]` voran + (übersprungen für "bridge" selbst), toleriert als bereits erledigt + falls nie gebridged. + +Nach allen drei Fixes lief der komplette App-Wizard-Durchlauf für +`ether4` fehlerfrei durch (inkl. Neuanwendung der bereits vorhandenen +`bridge`/`ether1`-Konfiguration), Isolation + DNS + Internet vom Nutzer +am echten Gerät bestätigt. Alle 52 Unit-Tests grün. M8 damit als +einziger Milestone bisher **sowohl manuell als auch über die App selbst** +live verifiziert. + ## Ziel Native macOS-App (SwiftUI), die Laien per geführtem Interview-Wizard durch @@ -104,7 +147,8 @@ killall Dock # Icon-Cache auffrischen, falls sich nur das Icon geändert hat Abhängigkeiten stehen in `project.yml`, das ist die Quelle der Wahrheit — nicht das generierte `.xcodeproj` von Hand editieren. -Git: lokales Repo, kein Remote (Nutzer hat aktuell keine Gitea-Instanz). +Git: Remote `origin` zeigt auf eine selbst gehostete Gitea-Instanz +(`git@192.168.178.222:kay/RouterOS.git`). `.xcodeproj`, `DerivedData`, `.build`, Firmware-Dateien (`*.npk`, `*.cpgz`) und Router-Backups (`*.rsc`, `/Backups/`) sind gitignored. @@ -458,7 +502,33 @@ Singleton-Fallback, der für ein Menü per Exception ausgelöst wurde (Bug 8), blieb für ein anderes Menü aus, weil RouterOS denselben Fehlertext dort mit Exit-Code 0 zurückgibt; robuste Erkennung muss immer auch den Output-Text selbst prüfen, nie nur auf eine geworfene Exception -vertrauen. +vertrauen. **Wizard-Apply nutzte für WAN-DHCP-Client/PPPoE immer `.add`** +(Bug 21) — scheiterte live mit "failure: dhcp-client on that interface +already exists" auf einem Interface, das (z.B. aus einer zurückgespielten +Sicherung) schon einen Client hatte; `SetupViewModel.applyIdempotently` +versucht seitdem bei `.add`-Fehlschlag auf diesen beiden Menüs +automatisch `.set` (nach "interface" gematcht). **Ein neu eingerichtetes +LAN-/VLAN-Interface wurde nie der defconf-Interface-Liste "LAN" +hinzugefügt** (Bug 22) — auf Routern mit Werks-Firewall +(`chain=input action=drop in-interface-list=!LAN`) blieb dadurch jede +Anfrage an den Router selbst (DNS, Winbox) vom neuen Netz aus blockiert, +obwohl DHCP/Routing/Internet normal liefen (reine Forward-Chain-Sache, +unberührt) — live an einer manuell nachgebauten Isolation gefunden +(Lease + Default-Route vorhanden, jede DNS-Anfrage lief trotzdem in +Timeout). **Voller Wizard-Durchlauf gegen einen bereits konfigurierten +Router brach an jedem nicht-idempotenten `.add` ab** (Bug 23) — nur +WAN-DHCP-Client/PPPoE waren idempotent (Bug 21); `/ip address` +(exaktes Duplikat = bereits erledigt) sowie `/ip pool`/`/ip +dhcp-server`/`/ip dhcp-server network` (Retry als `.set`, gematcht auf +name/name/address) kamen dazu. **Ein als eigenes isoliertes Netz +konfiguriertes Interface blieb Bridge-"Slave"** (Bug 24) — auf +Werks-Routern sind ether2–5 ab Werk gebridged; ohne explizites Lösen aus +der Bridge verwarf RouterOS die generierten Forward-Isolationsregeln +selbst als ungültig ("in/out-interface matcher not possible when +interface is slave - use master instead"), live an `ether4` bestätigt; +`DhcpServerCommandBuilder` stellt seitdem ein `/interface bridge port +remove [find interface=]` voran (übersprungen für "bridge" +selbst). ## `xcodebuild test` hängt — Gatekeeper, kein Code-Bug @@ -511,12 +581,11 @@ wiederholen. nur der "kein WLAN"-Zweig ist bestätigt (zwei Testgeräte, beide ohne WLAN-Chip). Sicherheitsprofil-Anlage + SSID/Passwort-`.set` auf einem echten `/interface wireless`-Interface noch nie live gelaufen. -- **Firewall-Regeln mit korrektem WAN-Interface noch nicht erneut - bestätigt** — der erste Live-Test (M6) lief technisch durch, traf aber - wegen Bug 6 oben `lo` statt des echten WAN-Ports. Nach dem Parser-Fix - wurde nur die Interface-**Auswahl** erneut bestätigt ("alle Interfaces - auswählbar"), nicht aber ein erneuter Firewall-Apply mit korrektem - Interface + Kontrolle der resultierenden Regeln. +- ~~Firewall-Regeln mit korrektem WAN-Interface noch nicht erneut + bestätigt~~ — erledigt: beim M8-App-Test (siehe oben) lief ein voller + `FirewallConfig`-Apply mit `ether1` als WAN erneut durch, + `/ip firewall filter print` zeigte alle Basisregeln (established/ + related, invalid-drop, ICMP-Accept, WAN-Drop) korrekt mit `ether1`. - **Neuer RouterOS-WiFi-Treiber (`/interface wifi`, wifiwave2/802.11ax) nicht unterstützt** — wird erkannt und im UI erklärt, aber nicht konfiguriert. @@ -560,10 +629,20 @@ Isolation vom Hauptnetzwerk, ohne dass eine einzige Firewall-Regel das durchsetzte — reiner Text ohne Wirkung. Jetzt ist Isolation ein echter, optionaler Schalter mit tatsächlicher Regel-Erzeugung. -Nur gegen Unit-Tests verifiziert (`FirewallConfigTests`: isoliertes -Netzwerk gegen nicht-isoliertes, gegenseitige Isolation, keine Isolation). -**Noch nicht gegen echte Hardware getestet** — bräuchte zwei getrennte -Testnetze am selben Router, um die Drop-Regeln praktisch zu bestätigen. +**Live gegen Hardware verifiziert (2026-09-15)** — zuerst manuell per +SSH nachgebaut (siehe "Zum Sessionende" oben), danach über den +App-Wizard selbst (`ether4`, Experte-Modus). iMac ↔ Laptop erreichen +sich über die Netzgrenze nicht mehr, Internet + DNS funktionieren auf +beiden Seiten weiter. Dabei drei App-Bugs gefunden und gefixt (Bug +22–24, siehe Bug-Liste oben) — alle drei Voraussetzung dafür, dass ein +per Wizard eingerichtetes isoliertes Netz auf einem bereits +konfigurierten bzw. werksseitig gebridgten Router überhaupt funktioniert: +fehlende "LAN"-Interface-Listen-Mitgliedschaft (DNS zum Router blockiert), +fehlende Idempotenz bei erneutem Wizard-Lauf (Apply brach sofort ab), +und ein nie aus der Bridge gelöstes physisches Interface (RouterOS +verwarf die Isolationsregeln selbst als ungültig). Weiterhin unverändert +seit vorher: `FirewallConfigTests` (isoliertes Netzwerk gegen +nicht-isoliertes, gegenseitige Isolation, keine Isolation). ## M9: Einfach/Experte-Modus im Einrichten-Wizard @@ -692,9 +771,12 @@ beim Ändern die RouterOS-Suffix-Form zurück) — angewendet auf DHCP-Server bestätigt** (inkl. neuem "Trennen"-Button im Verbinden-Tab, der dafür nötig wurde). Fehlerzustände/Politur und REST-Schreibpfad-Verifikation gegen ein Gerät mit aktivem `www-ssl` stehen noch aus. -- 🔶 M8: Mehrere LAN-Interfaces mit eigenem DHCP + Netzwerk-Isolation - (eigene Firewall-Regeln pro LAN/VLAN) — gebaut, nur Unit-Test-verifiziert, - noch nicht gegen echte Hardware getestet. +- ✅ M8: Mehrere LAN-Interfaces mit eigenem DHCP + Netzwerk-Isolation + (eigene Firewall-Regeln pro LAN/VLAN) — **live gegen Hardware + verifiziert**, sowohl manuell (SSH) als auch über den App-Wizard + selbst (`ether4`), dabei drei App-Bugs gefunden+gefixt (Bug 22–24, + siehe oben). Isolation, DNS und Internet vom Nutzer am echten Gerät + bestätigt. - 🔶 M9: Einfach/Experte-Modus im Einrichten-Wizard — gebaut, Compile/ Unit-Test-verifiziert. UI (Modusumschalter selbst) noch nicht manuell durchgeklickt — nur M10s Experte-Tab wurde das (siehe M10). @@ -765,41 +847,35 @@ beim Ändern die RouterOS-Suffix-Form zurück) — angewendet auf DHCP-Server ## Nächste Schritte -1. **In Arbeit, hier weitermachen:** kompletter Wizard-Durchlauf - (WAN/LAN mit zwei Interfaces ether2+ether3, eins isoliert/Firewall) - über den Experte-Modus — kombiniert M8-Hardware-Test (Netzwerk- - Isolation) mit diesem Punkt. Wurde in dieser Session begonnen, brach - beim WAN-Schritt mit Bug 21 ab (jetzt gefixt, deployt, aber - **ungetestet**). Nach erneutem Durchlauf: Firewall-Ergebnis mit - korrektem WAN-Port UND die Isolations-Regeln per - `/ip firewall filter print` kontrollieren, plus praktisch testen - (iMac ↔ Laptop sollten sich nicht erreichen, Internet für beide - schon). -2. WLAN (M5) an einem Gerät mit echtem WLAN-Chip nachholen. -3. Rest von M7: Fehlerzustände/Politur, REST-Schreibtest an einem Gerät +1. WLAN (M5) an einem Gerät mit echtem WLAN-Chip nachholen. +2. Rest von M7: Fehlerzustände/Politur, REST-Schreibtest an einem Gerät mit aktivem `www-ssl`. -4. M8 gegen echte Hardware testen: zwei LAN-Interfaces/VLANs anlegen, eins - isoliert, anwenden, per `/ip firewall filter print` kontrollieren, dass - die Drop-Regeln greifen und der Rest (Internetzugriff, nicht-isolierte - Netzwerke) unangetastet bleibt. -5. M9 UI (Einfach/Experte-Modusumschalter im Einrichten-Tab selbst) noch +3. M9 UI (Einfach/Experte-Modusumschalter im Einrichten-Tab selbst) noch manuell durchklicken — M10s Experte-Tab wurde bereits vom Nutzer bestätigt (siehe oben), der Moduswechsel im Wizard noch nicht. -6. M10: WLAN-Schemas (an Gerät mit WLAN-Chip), Bonding, PPPoE-Client +4. M10: WLAN-Schemas (an Gerät mit WLAN-Chip), Bonding, PPPoE-Client (mit echten oder Test-ISP-Zugangsdaten) noch gegen Hardware verifizieren. -7. Optional, kleinere Politur: den neuen Dauer-Editor +5. Optional, kleinere Politur: den neuen Dauer-Editor (`RouterOSFieldSchema.Kind.duration`) auch auf weitere Zeitwert-Felder anwenden, die bisher nur Text mit Beispiel-Tooltip sind — WireGuard- Peer "Keepalive", Scheduler "Intervall", Netwatch "Prüf-Intervall". Optional auch für den Geräte-Tab, falls dort künftig Zeitfelder dazukommen. -8. **`.id`-Positions-Überlagerung (`fetchMenuItems`) auf weitere Menüs +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. +7. **`.id`-Positions-Überlagerung (`fetchMenuItems`) auf weitere Menüs prüfen** — live als falsch bestätigt für `/ip dhcp-server lease` (Bug 15). Betrifft potenziell jedes `.set`/`.remove` im Experte-Tab. Am ehesten zu prüfen: bei einem Menü mit mehreren gleichzeitig vorhandenen Einträgen `:put [ find]` und ` print terse` unabhängig ausführen und die Reihenfolgen von Hand vergleichen. -9. **Router wurde zwischen M12 und M13 vom Nutzer komplett auf +8. **Router wurde zwischen M12 und M13 vom Nutzer komplett auf Werkseinstellungen zurückgesetzt** ("damit wir sauber weitermachen können"), und im Zuge des ersten M13-Testlaufs (Bug 18, Login-Sperre) nochmal per Hardware-Reset zurückgesetzt. `test-vlan`/`testpool`/ @@ -812,7 +888,7 @@ beim Ändern die RouterOS-Suffix-Form zurück) — angewendet auf DHCP-Server — vor Annahmen über den genauen aktuellen Stand lieber neu per Geräte-/Übersicht-/Verbinden-Tab prüfen statt auf ältere Einträge hier zu vertrauen. -10. **`fetchMenuItems`s Output-Text-Fallback (Bug 20) auf weitere +9. **`fetchMenuItems`s Output-Text-Fallback (Bug 20) auf weitere Singleton-Menüs prüfen** — live nur für `/system routerboard` bestätigt gebraucht zu werden (`/system identity` etc. liefen schon vorher über den Exception-Zweig, siehe Bug 8). Nicht geprüft, ob es @@ -820,10 +896,16 @@ beim Ändern die RouterOS-Suffix-Form zurück) — angewendet auf DHCP-Server (weder Exception noch im Output-Text) — bei einer leer bleibenden Liste in Übersicht/Geräte/Experte-Tab als erste Verdachtsquelle prüfen. +10. **Offener Nutzerwunsch, noch nicht umgesetzt:** ausführlichere + Tooltips im Experte-Tab für alle Sektionen, mit konkretem + Adress-Format-Beispiel (z.B. "10.10.10.1/24") statt nur "mit + Präfix". Viele `RouterOSFieldSchema.help`-Texte in + `RouterOSSchemaCatalog.swift` sind aktuell leer (`help: ""`) oder + knapp ohne Beispielwert — wurde diese Session zugunsten des + M8-Hardware-Tests zurückgestellt. -Kein Gitea-Remote vorhanden — falls der Nutzer später eine Gitea-Instanz -aufsetzt (z.B. selbst gehostet auf der vorhandenen OMV-NAS, siehe -`Aperto/`-Projekt), Remote hinzufügen und pushen; bis dahin lokales Git. +Gitea-Remote `origin` ist eingerichtet und wird laufend gepusht (siehe +oben) — dieser Hinweis war veraltet, korrigiert am 2026-09-15. `/Applications/RouterOS Assistant.app` ist der aktuell installierte Release-Build, auf dem Stand des jeweils letzten Commits auf `main` diff --git a/README.md b/README.md index e70c2c5..6610409 100644 --- a/README.md +++ b/README.md @@ -94,7 +94,7 @@ darauf bauen Übersicht-, Geräte- und Experte-Tab gemeinsam auf. | M5 | WLAN-Schritt | 🔶 nur "kein WLAN"-Zweig getestet | | M6 | Firewall-Grundschutz | ✅ live verifiziert | | M7 | Härtung (SSH-Hostkey-TOFU) | 🔶 TOFU fertig, Rest offen | -| M8 | Mehrere LAN-Interfaces + Netzwerk-Isolation | 🔶 gebaut, Hardware-Test läuft | +| M8 | Mehrere LAN-Interfaces + Netzwerk-Isolation | ✅ live verifiziert | | M9 | Einfach/Experte-Modusschalter im Wizard | 🔶 gebaut, UI-Test offen | | M10 | Experte-Tab (generischer RouterOS-Zugriff) | ✅ live verifiziert | | M11 | Übersicht-Tab (IST-Zustand-Diagramm) | ✅ live verifiziert | diff --git a/RouterOSAssistant/Core/Models/DhcpServerCommandBuilder.swift b/RouterOSAssistant/Core/Models/DhcpServerCommandBuilder.swift index 3652cb7..4c77fa9 100644 --- a/RouterOSAssistant/Core/Models/DhcpServerCommandBuilder.swift +++ b/RouterOSAssistant/Core/Models/DhcpServerCommandBuilder.swift @@ -17,7 +17,26 @@ enum DhcpServerCommandBuilder { let serverName = "dhcp_\(interfaceName)" let routerIP = routerAddress.components(separatedBy: "/").first ?? routerAddress - return [ + // Detach from any bridge this physical port is still a member of before treating it as + // its own network. Skipped for "bridge" itself (the app's own shared-LAN interface, never + // a bridge port). Confirmed live (2026-09-15): factory-default routers have ether2-5 + // pre-bridged, and configuring one of them as a separate isolated network without this + // produces a broken result RouterOS itself refuses to run — the interface stays a bridge + // "slave", so its forward-chain isolation rules come back flagged invalid ("in/out- + // interface matcher not possible when interface is slave - use master instead"). No-op if + // the interface was never bridged: SSH's `remove [find ...]` is a silent no-op on no + // match, and REST's not-found is tolerated by `SetupViewModel.applyIdempotently`. + let bridgeDetachCommands: [RouterOSCommand] = interfaceName == "bridge" ? [] : [ + RouterOSCommand.remove( + menuPath: "/interface bridge port", + restPath: "interface/bridge/port", + matchField: "interface", + matchValue: interfaceName, + summary: "\(interfaceName) aus evtl. bestehender Bridge lösen" + ) + ] + + return bridgeDetachCommands + [ RouterOSCommand.add( menuPath: "/ip address", restPath: "ip/address", @@ -51,6 +70,27 @@ enum DhcpServerCommandBuilder { "dns-server": dnsServers ], summary: "DHCP-Netzwerk \(networkAddress) \(context) konfigurieren" + ), + // RouterOS' factory-default firewall (present on most out-of-box routers) has an + // input-chain rule dropping everything not from the "LAN" interface list. Without + // this interface as a member, devices on it get DHCP/routing/internet fine (that's + // forward-chain, untouched) but can never reach the router itself for DNS, Winbox, + // etc. — confirmed live (2026-09-15): a manually-isolated port had a bound DHCP + // lease and a working default route, yet every DNS query to the router timed out + // until it was added to "LAN". Both commands are tolerated as already-satisfied by + // `SetupViewModel.applyIdempotently` if the list/membership already exists (e.g. the + // default "bridge" interface, already a defconf LAN member). + RouterOSCommand.add( + menuPath: "/interface list", + restPath: "interface/list", + arguments: ["name": "LAN"], + summary: "Interface-Liste \"LAN\" sicherstellen" + ), + RouterOSCommand.add( + menuPath: "/interface list member", + restPath: "interface/list/member", + arguments: ["list": "LAN", "interface": interfaceName], + summary: "\(interfaceName) der Interface-Liste \"LAN\" hinzufügen \(context)" ) ] } diff --git a/RouterOSAssistant/Features/Wizard/Steps/Setup/SetupViewModel.swift b/RouterOSAssistant/Features/Wizard/Steps/Setup/SetupViewModel.swift index 703e04e..2271ea6 100644 --- a/RouterOSAssistant/Features/Wizard/Steps/Setup/SetupViewModel.swift +++ b/RouterOSAssistant/Features/Wizard/Steps/Setup/SetupViewModel.swift @@ -224,31 +224,61 @@ final class SetupViewModel: ObservableObject { } } - /// RouterOS allows at most one item per interface on some menus (a DHCP client, a PPPoE - /// client) — re-running the wizard's WAN step on an interface that already has one makes the - /// wizard's own `.add` fail live-confirmed: "failure: dhcp-client on that interface already - /// exists". Rather than hard-fail the whole apply here, retry once as a `.set` matched by - /// "interface" instead — reconfigures the existing entry in place, which is what re-running - /// the wizard on the same interface should mean anyway. Scoped to exactly these two - /// known-unique-per-interface menus, not applied generally to every `.add` — other menus - /// (e.g. "/ip address") legitimately allow multiple items per interface, where silently - /// converting a would-be-duplicate `.add` into a `.set` would be wrong, not helpful. - private static let interfaceUniqueMenuPaths: Set = ["/ip dhcp-client", "/interface pppoe-client"] + /// 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 = ["/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 = ["/interface bridge port"] private func applyIdempotently(_ command: RouterOSCommand) async throws { do { try await connectionService.apply(command) } catch { - guard case .add = command.operation, - Self.interfaceUniqueMenuPaths.contains(command.menuPath), - let interfaceName = command.arguments["interface"] else { + 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: "interface", - matchValue: interfaceName, + matchField: matchField, + matchValue: matchValue, arguments: command.arguments, summary: command.summary ) diff --git a/RouterOSAssistantTests/RouterOSCommandBuilderTests.swift b/RouterOSAssistantTests/RouterOSCommandBuilderTests.swift index e1bf591..76dbd6c 100644 --- a/RouterOSAssistantTests/RouterOSCommandBuilderTests.swift +++ b/RouterOSAssistantTests/RouterOSCommandBuilderTests.swift @@ -43,12 +43,28 @@ final class RouterOSCommandBuilderTests: XCTestCase { let config = LanDhcpConfig() let commands = config.buildCommands() - XCTAssertEqual(commands.count, 4) + XCTAssertEqual(commands.count, 6) XCTAssertEqual(commands[0].menuPath, "/ip address") XCTAssertEqual(commands[1].menuPath, "/ip pool") XCTAssertEqual(commands[2].menuPath, "/ip dhcp-server") XCTAssertEqual(commands[3].menuPath, "/ip dhcp-server network") XCTAssertEqual(commands[3].arguments["gateway"], "192.168.88.1") + XCTAssertEqual(commands[4].menuPath, "/interface list") + XCTAssertEqual(commands[4].arguments["name"], "LAN") + XCTAssertEqual(commands[5].menuPath, "/interface list member") + XCTAssertEqual(commands[5].arguments["list"], "LAN") + XCTAssertEqual(commands[5].arguments["interface"], "bridge") + } + + func testLanDhcpCommandsDetachInterfaceFromBridgeWhenNotDefaultBridge() { + var config = LanDhcpConfig() + config.interfaceName = "ether4" + let commands = config.buildCommands() + + XCTAssertEqual(commands.count, 7) + XCTAssertEqual(commands[0].menuPath, "/interface bridge port") + XCTAssertEqual(commands[0].operation, .remove(matchField: "interface", matchValue: "ether4")) + XCTAssertEqual(commands[1].menuPath, "/ip address") } func testCliLineRendersSortedQuotedArgumentsForAdd() { diff --git a/RouterOSAssistantTests/VlanEntryTests.swift b/RouterOSAssistantTests/VlanEntryTests.swift index 532573b..bc0f2d8 100644 --- a/RouterOSAssistantTests/VlanEntryTests.swift +++ b/RouterOSAssistantTests/VlanEntryTests.swift @@ -6,17 +6,21 @@ final class VlanEntryTests: XCTestCase { let vlan = VlanEntry(name: "Gäste", vlanID: 20, parentInterface: "bridge") let commands = vlan.buildCommands() - XCTAssertEqual(commands.count, 5) + XCTAssertEqual(commands.count, 8) XCTAssertEqual(commands[0].menuPath, "/interface vlan") XCTAssertEqual(commands[0].arguments["vlan-id"], "20") XCTAssertEqual(commands[0].arguments["interface"], "bridge") XCTAssertEqual(commands[0].arguments["name"], "vlan20") - XCTAssertEqual(commands[1].menuPath, "/ip address") - XCTAssertEqual(commands[1].arguments["interface"], "vlan20") - XCTAssertEqual(commands[2].menuPath, "/ip pool") - XCTAssertEqual(commands[3].menuPath, "/ip dhcp-server") - XCTAssertEqual(commands[4].menuPath, "/ip dhcp-server network") + XCTAssertEqual(commands[1].menuPath, "/interface bridge port") + XCTAssertEqual(commands[2].menuPath, "/ip address") + XCTAssertEqual(commands[2].arguments["interface"], "vlan20") + XCTAssertEqual(commands[3].menuPath, "/ip pool") + XCTAssertEqual(commands[4].menuPath, "/ip dhcp-server") + XCTAssertEqual(commands[5].menuPath, "/ip dhcp-server network") + XCTAssertEqual(commands[6].menuPath, "/interface list") + XCTAssertEqual(commands[7].menuPath, "/interface list member") + XCTAssertEqual(commands[7].arguments["interface"], "vlan20") } func testDefaultAddressesAreDerivedFromVlanID() {