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
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.
+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
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=<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
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 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
@@ -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
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
@@ -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 2224,
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 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`
(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 [<Pfad> find]` und `<Pfad> 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`
+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 |
| 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 |
@@ -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)"
)
]
}
@@ -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<String> = ["/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<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 {
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
)
@@ -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() {
+10 -6
View File
@@ -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() {