M8: Netzwerk-Isolation live verifiziert (manuell + über den Wizard)

Isolation zuerst manuell per SSH nachgebaut (ether5), danach über den
App-Wizard (Experte-Modus, ether4), um die eigentliche Abnahme-
Bedingung ("kompletter Wizard-Durchlauf") zu erfüllen. Dabei drei
App-Bugs gefunden und gefixt:

- Bug 22: neues LAN-/VLAN-Interface wurde nie der defconf-Interface-
  Liste "LAN" hinzugefügt, wodurch DNS-Anfragen an den Router selbst
  blockiert blieben (Werks-Firewall droppt Input von allem außerhalb
  dieser Liste).
- Bug 23: ein voller Wizard-Durchlauf gegen einen bereits konfigurierten
  Router brach am ersten nicht-idempotenten Add-Befehl ab
  (/ip address, /ip pool, /ip dhcp-server, /ip dhcp-server network).
- Bug 24: ein als eigenes isoliertes Netz konfiguriertes Interface
  blieb Bridge-"Slave" (Werks-Bridging), wodurch RouterOS die
  generierten Isolationsregeln selbst als ungültig verwarf.

Alle drei in DhcpServerCommandBuilder/SetupViewModel gefixt, 52 Unit-
Tests grün, Isolation+DNS+Internet am echten Gerät bestätigt. M8 auf
live verifiziert gesetzt. Nebenbei zwei veraltete Doku-Stellen zum
Gitea-Remote korrigiert.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YDmUd93KxsYGr2kLTotWnG
This commit is contained in:
Kay
2026-09-15 10:28:09 +02:00
co-authored by Claude Sonnet 5
parent 19fa1d6ef4
commit fc3b2ca039
7 changed files with 286 additions and 75 deletions
+39
View File
@@ -696,3 +696,42 @@ Test, WLAN/Bonding/PPPoE-Live-Tests).
(Bug 21, Nächste-Schritte Punkt 1 als "in Arbeit" markiert — der (Bug 21, Nächste-Schritte Punkt 1 als "in Arbeit" markiert — der
eigentliche M8-Isolationstest ist noch nicht zu Ende geführt, nächste eigentliche M8-Isolationstest ist noch nicht zu Ende geführt, nächste
Session dort fortsetzen) aktualisiert, Commit erstellt. Sitzung beendet. 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.
+133 -51
View File
@@ -56,17 +56,60 @@ Farbwerte, alles adaptive System-Farben) — vom Nutzer live bestätigt.
Release-Build unter `/Applications/RouterOS Assistant.app` ist auf Release-Build unter `/Applications/RouterOS Assistant.app` ist auf
aktuellem Stand. aktuellem Stand.
**Zum Sessionende:** M8-Hardware-Test (Netzwerk-Isolation) begonnen — **Danach, neue Session: M8 komplett live verifiziert — erst manuell,
voller Wizard-Durchlauf über den Experte-Modus, WAN neu einrichten. dann über den Wizard selbst.** Bug 21 aus der Vorsession (WAN-DHCP-
Dabei Bug 21 gefunden: der Wizard-Apply nutzte für WAN-DHCP-Client/PPPoE Client/PPPoE-Apply nicht idempotent) blieb bestätigt gefixt. Isolation
immer `.add`, was auf einem bereits konfigurierten Interface (hier: aus zuerst manuell per SSH an `ether5` nachgebaut (eigenes Subnetz, eigener
der zurückgespielten Sicherung) mit "failure: dhcp-client on that DHCP-Server, `/interface list`-basierte Forward-Drop-Regel) — dabei drei
interface already exists" scheiterte. Fix: `SetupViewModel. reine Konfigurationsfehler der Reihe nach gefunden und behoben (Adresse
applyIdempotently` versucht bei `.add` auf `/ip dhcp-client` oder versehentlich als `/32` bzw. `.0`-Netzadresse statt Host-Adresse
`/interface pppoe-client` nach einem Fehlschlag automatisch `.set` eingetragen; `ether5` fehlte in der defconf-Interface-Liste "LAN",
(nach "interface" gematcht) — deployt, **aber der eigentliche wodurch DNS-Anfragen an den Router selbst blockiert wurden, obwohl
M8-Isolationstest selbst ist noch nicht zu Ende geführt/verifiziert**, DHCP/Routing/Internet normal liefen). Isolation danach beidseitig
das ist der erste Schritt für die nächste Session. 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=<name>`, 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 ether25 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=<name>]` 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 ## Ziel
Native macOS-App (SwiftUI), die Laien per geführtem Interview-Wizard durch 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 — Abhängigkeiten stehen in `project.yml`, das ist die Quelle der Wahrheit —
nicht das generierte `.xcodeproj` von Hand editieren. 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`) `.xcodeproj`, `DerivedData`, `.build`, Firmware-Dateien (`*.npk`, `*.cpgz`)
und Router-Backups (`*.rsc`, `/Backups/`) sind gitignored. 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 (Bug 8), blieb für ein anderes Menü aus, weil RouterOS denselben
Fehlertext dort mit Exit-Code 0 zurückgibt; robuste Erkennung muss immer 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 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 ether25 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=<name>]` voran (übersprungen für "bridge"
selbst).
## `xcodebuild test` hängt — Gatekeeper, kein Code-Bug ## `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 nur der "kein WLAN"-Zweig ist bestätigt (zwei Testgeräte, beide ohne
WLAN-Chip). Sicherheitsprofil-Anlage + SSID/Passwort-`.set` auf einem WLAN-Chip). Sicherheitsprofil-Anlage + SSID/Passwort-`.set` auf einem
echten `/interface wireless`-Interface noch nie live gelaufen. echten `/interface wireless`-Interface noch nie live gelaufen.
- **Firewall-Regeln mit korrektem WAN-Interface noch nicht erneut - ~~Firewall-Regeln mit korrektem WAN-Interface noch nicht erneut
bestätigt**der erste Live-Test (M6) lief technisch durch, traf aber bestätigt~~ — erledigt: beim M8-App-Test (siehe oben) lief ein voller
wegen Bug 6 oben `lo` statt des echten WAN-Ports. Nach dem Parser-Fix `FirewallConfig`-Apply mit `ether1` als WAN erneut durch,
wurde nur die Interface-**Auswahl** erneut bestätigt ("alle Interfaces `/ip firewall filter print` zeigte alle Basisregeln (established/
auswählbar"), nicht aber ein erneuter Firewall-Apply mit korrektem related, invalid-drop, ICMP-Accept, WAN-Drop) korrekt mit `ether1`.
Interface + Kontrolle der resultierenden Regeln.
- **Neuer RouterOS-WiFi-Treiber (`/interface wifi`, wifiwave2/802.11ax) - **Neuer RouterOS-WiFi-Treiber (`/interface wifi`, wifiwave2/802.11ax)
nicht unterstützt** — wird erkannt und im UI erklärt, aber nicht nicht unterstützt** — wird erkannt und im UI erklärt, aber nicht
konfiguriert. 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, durchsetzte — reiner Text ohne Wirkung. Jetzt ist Isolation ein echter,
optionaler Schalter mit tatsächlicher Regel-Erzeugung. optionaler Schalter mit tatsächlicher Regel-Erzeugung.
Nur gegen Unit-Tests verifiziert (`FirewallConfigTests`: isoliertes **Live gegen Hardware verifiziert (2026-09-15)** — zuerst manuell per
Netzwerk gegen nicht-isoliertes, gegenseitige Isolation, keine Isolation). SSH nachgebaut (siehe "Zum Sessionende" oben), danach über den
**Noch nicht gegen echte Hardware getestet** — bräuchte zwei getrennte App-Wizard selbst (`ether4`, Experte-Modus). iMac ↔ Laptop erreichen
Testnetze am selben Router, um die Drop-Regeln praktisch zu bestätigen. sich über die Netzgrenze nicht mehr, Internet + DNS funktionieren auf
beiden Seiten weiter. Dabei drei App-Bugs gefunden und gefixt (Bug
2224, 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 ## 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 bestätigt** (inkl. neuem "Trennen"-Button im Verbinden-Tab, der dafür
nötig wurde). Fehlerzustände/Politur und REST-Schreibpfad-Verifikation nötig wurde). Fehlerzustände/Politur und REST-Schreibpfad-Verifikation
gegen ein Gerät mit aktivem `www-ssl` stehen noch aus. gegen ein Gerät mit aktivem `www-ssl` stehen noch aus.
- 🔶 M8: Mehrere LAN-Interfaces mit eigenem DHCP + Netzwerk-Isolation - M8: Mehrere LAN-Interfaces mit eigenem DHCP + Netzwerk-Isolation
(eigene Firewall-Regeln pro LAN/VLAN) — gebaut, nur Unit-Test-verifiziert, (eigene Firewall-Regeln pro LAN/VLAN) — **live gegen Hardware
noch nicht gegen echte Hardware getestet. verifiziert**, sowohl manuell (SSH) als auch über den App-Wizard
selbst (`ether4`), dabei drei App-Bugs gefunden+gefixt (Bug 2224,
siehe oben). Isolation, DNS und Internet vom Nutzer am echten Gerät
bestätigt.
- 🔶 M9: Einfach/Experte-Modus im Einrichten-Wizard — gebaut, Compile/ - 🔶 M9: Einfach/Experte-Modus im Einrichten-Wizard — gebaut, Compile/
Unit-Test-verifiziert. UI (Modusumschalter selbst) noch nicht manuell Unit-Test-verifiziert. UI (Modusumschalter selbst) noch nicht manuell
durchgeklickt — nur M10s Experte-Tab wurde das (siehe M10). 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 ## Nächste Schritte
1. **In Arbeit, hier weitermachen:** kompletter Wizard-Durchlauf 1. WLAN (M5) an einem Gerät mit echtem WLAN-Chip nachholen.
(WAN/LAN mit zwei Interfaces ether2+ether3, eins isoliert/Firewall) 2. Rest von M7: Fehlerzustände/Politur, REST-Schreibtest an einem Gerät
ü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
mit aktivem `www-ssl`. mit aktivem `www-ssl`.
4. M8 gegen echte Hardware testen: zwei LAN-Interfaces/VLANs anlegen, eins 3. M9 UI (Einfach/Experte-Modusumschalter im Einrichten-Tab selbst) noch
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
manuell durchklicken — M10s Experte-Tab wurde bereits vom Nutzer manuell durchklicken — M10s Experte-Tab wurde bereits vom Nutzer
bestätigt (siehe oben), der Moduswechsel im Wizard noch nicht. 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. (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 (`RouterOSFieldSchema.Kind.duration`) auch auf weitere Zeitwert-Felder
anwenden, die bisher nur Text mit Beispiel-Tooltip sind — WireGuard- anwenden, die bisher nur Text mit Beispiel-Tooltip sind — WireGuard-
Peer "Keepalive", Scheduler "Intervall", Netwatch "Prüf-Intervall". Peer "Keepalive", Scheduler "Intervall", Netwatch "Prüf-Intervall".
Optional auch für den Geräte-Tab, falls dort künftig Zeitfelder Optional auch für den Geräte-Tab, falls dort künftig Zeitfelder
dazukommen. 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 2224
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` prüfen** — live als falsch bestätigt für `/ip dhcp-server lease`
(Bug 15). Betrifft potenziell jedes `.set`/`.remove` im Experte-Tab. (Bug 15). Betrifft potenziell jedes `.set`/`.remove` im Experte-Tab.
Am ehesten zu prüfen: bei einem Menü mit mehreren gleichzeitig Am ehesten zu prüfen: bei einem Menü mit mehreren gleichzeitig
vorhandenen Einträgen `:put [<Pfad> find]` und `<Pfad> print terse` vorhandenen Einträgen `:put [<Pfad> find]` und `<Pfad> print terse`
unabhängig ausführen und die Reihenfolgen von Hand vergleichen. 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 Werkseinstellungen zurückgesetzt** ("damit wir sauber weitermachen
können"), und im Zuge des ersten M13-Testlaufs (Bug 18, Login-Sperre) können"), und im Zuge des ersten M13-Testlaufs (Bug 18, Login-Sperre)
nochmal per Hardware-Reset zurückgesetzt. `test-vlan`/`testpool`/ 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 — vor Annahmen über den genauen aktuellen Stand lieber neu per
Geräte-/Übersicht-/Verbinden-Tab prüfen statt auf ältere Einträge hier Geräte-/Übersicht-/Verbinden-Tab prüfen statt auf ältere Einträge hier
zu vertrauen. 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` Singleton-Menüs prüfen** — live nur für `/system routerboard`
bestätigt gebraucht zu werden (`/system identity` etc. liefen schon bestätigt gebraucht zu werden (`/system identity` etc. liefen schon
vorher über den Exception-Zweig, siehe Bug 8). Nicht geprüft, ob es 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 (weder Exception noch im Output-Text) — bei einer leer bleibenden
Liste in Übersicht/Geräte/Experte-Tab als erste Verdachtsquelle Liste in Übersicht/Geräte/Experte-Tab als erste Verdachtsquelle
prüfen. 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 Gitea-Remote `origin` ist eingerichtet und wird laufend gepusht (siehe
aufsetzt (z.B. selbst gehostet auf der vorhandenen OMV-NAS, siehe oben) — dieser Hinweis war veraltet, korrigiert am 2026-09-15.
`Aperto/`-Projekt), Remote hinzufügen und pushen; bis dahin lokales Git.
`/Applications/RouterOS Assistant.app` ist der aktuell installierte `/Applications/RouterOS Assistant.app` ist der aktuell installierte
Release-Build, auf dem Stand des jeweils letzten Commits auf `main` Release-Build, auf dem Stand des jeweils letzten Commits auf `main`
+1 -1
View File
@@ -94,7 +94,7 @@ darauf bauen Übersicht-, Geräte- und Experte-Tab gemeinsam auf.
| M5 | WLAN-Schritt | 🔶 nur "kein WLAN"-Zweig getestet | | M5 | WLAN-Schritt | 🔶 nur "kein WLAN"-Zweig getestet |
| M6 | Firewall-Grundschutz | ✅ live verifiziert | | M6 | Firewall-Grundschutz | ✅ live verifiziert |
| M7 | Härtung (SSH-Hostkey-TOFU) | 🔶 TOFU fertig, Rest offen | | 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 | | M9 | Einfach/Experte-Modusschalter im Wizard | 🔶 gebaut, UI-Test offen |
| M10 | Experte-Tab (generischer RouterOS-Zugriff) | ✅ live verifiziert | | M10 | Experte-Tab (generischer RouterOS-Zugriff) | ✅ live verifiziert |
| M11 | Übersicht-Tab (IST-Zustand-Diagramm) | ✅ live verifiziert | | M11 | Übersicht-Tab (IST-Zustand-Diagramm) | ✅ live verifiziert |
@@ -17,7 +17,26 @@ enum DhcpServerCommandBuilder {
let serverName = "dhcp_\(interfaceName)" let serverName = "dhcp_\(interfaceName)"
let routerIP = routerAddress.components(separatedBy: "/").first ?? routerAddress 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( RouterOSCommand.add(
menuPath: "/ip address", menuPath: "/ip address",
restPath: "ip/address", restPath: "ip/address",
@@ -51,6 +70,27 @@ enum DhcpServerCommandBuilder {
"dns-server": dnsServers "dns-server": dnsServers
], ],
summary: "DHCP-Netzwerk \(networkAddress) \(context) konfigurieren" 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)"
) )
] ]
} }
@@ -224,31 +224,61 @@ final class SetupViewModel: ObservableObject {
} }
} }
/// RouterOS allows at most one item per interface on some menus (a DHCP client, a PPPoE /// Menus where a duplicate `.add` should instead reconfigure the existing entry in place
/// client) re-running the wizard's WAN step on an interface that already has one makes the /// re-running any wizard step against an already-configured router hits this on every menu
/// wizard's own `.add` fail live-confirmed: "failure: dhcp-client on that interface already /// that enforces uniqueness (dhcp-client/pppoe-client: one per interface, live-confirmed
/// exists". Rather than hard-fail the whole apply here, retry once as a `.set` matched by /// "failure: dhcp-client on that interface already exists"; pool/dhcp-server: one per name;
/// "interface" instead reconfigures the existing entry in place, which is what re-running /// dhcp-server network: one per address). The value is which field of the (deterministic,
/// the wizard on the same interface should mean anyway. Scoped to exactly these two /// app-generated) arguments identifies the existing entry to match on for the `.set` retry.
/// known-unique-per-interface menus, not applied generally to every `.add` other menus /// "/ip address" is deliberately not here an interface can legitimately hold several
/// (e.g. "/ip address") legitimately allow multiple items per interface, where silently /// addresses, so matching a `.set` by "interface" alone could reconfigure the wrong one;
/// converting a would-be-duplicate `.add` into a `.set` would be wrong, not helpful. /// see `duplicateTolerantMenuPaths` below for how that menu is handled instead.
private static let interfaceUniqueMenuPaths: Set<String> = ["/ip dhcp-client", "/interface pppoe-client"] 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 { private func applyIdempotently(_ command: RouterOSCommand) async throws {
do { do {
try await connectionService.apply(command) try await connectionService.apply(command)
} catch { } catch {
guard case .add = command.operation, if case .remove = command.operation, Self.missingTolerantRemoveMenuPaths.contains(command.menuPath) {
Self.interfaceUniqueMenuPaths.contains(command.menuPath), return
let interfaceName = command.arguments["interface"] else { }
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 throw error
} }
let retryCommand = RouterOSCommand.set( let retryCommand = RouterOSCommand.set(
menuPath: command.menuPath, menuPath: command.menuPath,
restPath: command.restPath, restPath: command.restPath,
matchField: "interface", matchField: matchField,
matchValue: interfaceName, matchValue: matchValue,
arguments: command.arguments, arguments: command.arguments,
summary: command.summary summary: command.summary
) )
@@ -43,12 +43,28 @@ final class RouterOSCommandBuilderTests: XCTestCase {
let config = LanDhcpConfig() let config = LanDhcpConfig()
let commands = config.buildCommands() let commands = config.buildCommands()
XCTAssertEqual(commands.count, 4) XCTAssertEqual(commands.count, 6)
XCTAssertEqual(commands[0].menuPath, "/ip address") XCTAssertEqual(commands[0].menuPath, "/ip address")
XCTAssertEqual(commands[1].menuPath, "/ip pool") XCTAssertEqual(commands[1].menuPath, "/ip pool")
XCTAssertEqual(commands[2].menuPath, "/ip dhcp-server") XCTAssertEqual(commands[2].menuPath, "/ip dhcp-server")
XCTAssertEqual(commands[3].menuPath, "/ip dhcp-server network") XCTAssertEqual(commands[3].menuPath, "/ip dhcp-server network")
XCTAssertEqual(commands[3].arguments["gateway"], "192.168.88.1") 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() { func testCliLineRendersSortedQuotedArgumentsForAdd() {
+10 -6
View File
@@ -6,17 +6,21 @@ final class VlanEntryTests: XCTestCase {
let vlan = VlanEntry(name: "Gäste", vlanID: 20, parentInterface: "bridge") let vlan = VlanEntry(name: "Gäste", vlanID: 20, parentInterface: "bridge")
let commands = vlan.buildCommands() let commands = vlan.buildCommands()
XCTAssertEqual(commands.count, 5) XCTAssertEqual(commands.count, 8)
XCTAssertEqual(commands[0].menuPath, "/interface vlan") XCTAssertEqual(commands[0].menuPath, "/interface vlan")
XCTAssertEqual(commands[0].arguments["vlan-id"], "20") XCTAssertEqual(commands[0].arguments["vlan-id"], "20")
XCTAssertEqual(commands[0].arguments["interface"], "bridge") XCTAssertEqual(commands[0].arguments["interface"], "bridge")
XCTAssertEqual(commands[0].arguments["name"], "vlan20") XCTAssertEqual(commands[0].arguments["name"], "vlan20")
XCTAssertEqual(commands[1].menuPath, "/ip address") XCTAssertEqual(commands[1].menuPath, "/interface bridge port")
XCTAssertEqual(commands[1].arguments["interface"], "vlan20") XCTAssertEqual(commands[2].menuPath, "/ip address")
XCTAssertEqual(commands[2].menuPath, "/ip pool") XCTAssertEqual(commands[2].arguments["interface"], "vlan20")
XCTAssertEqual(commands[3].menuPath, "/ip dhcp-server") XCTAssertEqual(commands[3].menuPath, "/ip pool")
XCTAssertEqual(commands[4].menuPath, "/ip dhcp-server network") 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() { func testDefaultAddressesAreDerivedFromVlanID() {