M9/M10: Einfach/Experte-Modus + Experte-Tab (generischer RouterOS-Zugriff)

M9: Einrichten-Wizard bekommt einen Einfach/Experte-Modusschalter
(ModeStepView). Einfach überspringt VLAN, erlaubt nur ein LAN-Netzwerk
ohne Isolation, Firewall-Grundschutz fest an.

M10: neuer "Experte"-Tab mit generischem Motor (RouterOSMenuItem,
RouterOSCommand.remove, ConnectionService.fetchMenuItems, freies
"eigener Menüpfad"-Feld) plus kuratierten Formularen mit Tooltips
(RouterOSSchemaCatalog) für Firewall/NAT/Mangle/Raw/Adress-Listen,
Interfaces, IP, VPN, WLAN, Queues, System, Werkzeuge.

Live gegen einen hEX-Testrouter verifiziert (erst per SSH, dann vom
Nutzer selbst in der App), dabei 7 reale Bugs gefunden und gefixt —
der wichtigste: RouterOS' SSH-CLI gibt bei fehlgeschlagenen Befehlen
Exit-Code 0 zurück, wodurch apply() app-weit Fehler verschluckte statt
sie zu melden. Danach ergänzt: Bestätigungsdialog vor Anlegen/Ändern
+ Auto-Backup vor dem ersten Experte-Tab-Schreibvorgang je Sitzung
(Angleichung an den Wizard), sowie ein Dauer-Editor (Tage/Std/Min/Sek)
für Lease-/Ablaufzeit-Felder statt Freitext.

Details zu allen Bugs/Fixes: HANDOFF.md, CHATLOG.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EW3r6rW1xCf6UT5jNvt6rn
This commit is contained in:
Kay
2026-09-14 00:03:50 +02:00
co-authored by Claude Sonnet 5
parent 97fc216b0c
commit c9ecd3a0a9
21 changed files with 2252 additions and 32 deletions
+222
View File
@@ -231,3 +231,225 @@ TOFU (M7) ist fertig und live bestätigt. Offene Punkte: Firewall-Regeln
mit korrektem WAN-Port nach dem Werksreset noch nicht erneut kontrolliert,
WLAN-`.set`-Pfad weiterhin ohne Testgerät mit echtem WLAN-Chip, Rest von
M7 (Fehlerzustände/Politur, REST-Schreibtest mit aktivem `www-ssl`).
## M8 (separate Session, hier nachträglich kurz notiert)
Nutzer, neuer Tag: Frage, ob mehrere Router-Interfaces in einem
Konfigurationsvorgang mit je eigenem DHCP-Server (und optional VLAN)
einrichtbar sind. Antwort ja → als M8 gebaut:
`SetupViewModel.lanConfigs: [LanDhcpConfig]` (mehrere LAN-Netzwerke statt
einem) plus Isolations-Toggle pro LAN-/VLAN-Eintrag, der bei aktivem
Firewall-Grundschutz paarweise Forward-Drop-Regeln erzeugt
(`FirewallConfig.NetworkSegment`). Kompiliert, Release gebaut und nach
`/Applications` deployt, `HANDOFF.md` + eine `mikrotik-setup.md`-
Referenzdatei (RB750Gr3-Szenarien: gemeinsame Bridge, komplett getrennte
Netze, Port aus Bridge lösen) gespeichert. Nur Unit-Test-verifiziert,
nicht gegen Hardware.
## Session 2026-09-13: Einfach/Experte-Modus + Experte-Tab
- "wir machen zwischendurch mal was anderes. ich brauche einen einfachen
Modus und einen Experten-Modus" — einfacher Modus: nur Grundeinrichtung
(kein VLAN, kein Mehrfach-DHCP, keine Port-Trennung), Experte: voller
Zugriff. Rückfrage per AskUserQuestion zum genauen Schnitt (WAN+LAN vs.
+WLAN/Firewall) — Nutzer wählte "WAN+LAN+WLAN+Firewall, Firewall fest
an". Als M9 gebaut: neuer erster Wizard-Schritt `ModeStepView`,
`SetupStep.goNext/goBack` von linearem `rawValue+1` auf expliziten
`switch` umgebaut (VLAN-Schritt wird im Einfach-Modus übersprungen),
Isolations-Toggle/Mehrfach-LAN im LAN-Schritt nur im Experte-Modus
sichtbar. Build+Tests grün.
- "wir machen weiter bei Expert. hier wird es umfangreich. [...] Ich
brauche nun Zugriff/Einstellungsmöglichkeit auf alle Funktionen, was
RouterOS bietet (Isolation, Firewalling, NAT alles was der Router
kann). Wir wollen hier ein echtes Experten-Tool bauen." — vor dem Bauen
per WebSearch/WebFetch in die offizielle RouterOS-Dokumentation
(manual.mikrotik.com, help.mikrotik.com) eingelesen: Firewall-Struktur
(Filter/NAT/Mangle/Raw-Chains, Adress-Listen, Connection-Tracking,
Layer7), volle CLI-Menübaum-Übersicht (300+ Top-Level-Menüs).
- Versuch, den Umfang mit einer AskUserQuestion einzugrenzen (nur
Firewall/NAT vs. zusätzlich VPN/Routing vs. wirklich alles) — **vom
Nutzer abgelehnt**: "alles was du findest,, der komplette Umfang des
RouterOS soll eingestellt werden könnn." Als Feedback-Memory
gespeichert: bei bereits klar geäußertem vollem Scope nicht nochmal
nachfragen, sondern eine Architektur bauen, die diesen Scope praktisch
abdeckt.
- Architekturentscheidung (da 300+ Menüs unmöglich einzeln von Hand
modelliert werden können): zweistufig. (1) Generischer Motor —
`RouterOSMenuItem`, `RouterOSCliParser.parseGenericItems`, neue
`RouterOSCommand.remove`-Operation, `ConnectionService.fetchMenuItems`
+ Transport-Implementierungen, dazu ein freies "eigener Menüpfad"-Feld
im UI — deckt technisch jeden Pfad ab, auch unkuratierte, als rohes
Schlüssel/Wert-Formular. (2) Kuratierte Schemas
(`RouterOSSchemaCatalog`) mit echten Formularfeldern, Tooltips,
Erklärtext und Warnhinweisen — komplette Firewall-Familie
(Filter/NAT/Mangle/Raw/Adress-Listen) sofort voll kuratiert, weitere
Bereiche (Interfaces/IP/VPN/WLAN/Queues/System/Werkzeuge) zunächst nur
generisch gelistet. Neuer "Experte"-Tab (`ExpertView`/
`ExpertViewModel`/`ExpertMenuDetailView`) in die App eingehängt. Build
+ alle Tests grün.
- "machen wir mit (a) weiter" (mehr Kategorien kuratieren statt sofort
Live-Test) — Interfaces (Bridge, Bridge-Port, VLAN, WireGuard+Peers,
PPPoE-Client, Bonding), IP (Adressen, Pools, DHCP-Server+Netzwerk,
DHCP-Client, DNS, Verwaltungsdienste, Routen), VPN (PPP-Secret/Profil),
WLAN (Legacy + neuer wifiwave2-Treiber + Sicherheitsprofile — Feldnamen
für den neuen Treiber vorab per WebSearch aus der offiziellen
Dokumentation verifiziert, nicht geraten), Queues (Simple/Tree), System
(Identity/Clock/NTP/Scheduler/Skripte/Nutzer), Werkzeuge (Netwatch/
E-Mail) kuratiert. OSPF/BGP und der neue WLAN-Treiber-Feldnamen-Versuch
per WebFetch gegenrecherchiert, aber für Routing keine ausreichend
verifizierte Quelle gefunden → bewusst generisch belassen statt
geraten. Build+Tests grün.
- "machen wir einen Livetest" → Zugangsdaten erhalten, Testansatz erklärt
(direktes SSH statt UI-Automatisierung, da kein Tool für native-macOS-
UI-Steuerung verfügbar ist). `sshpass` fehlte, `expect`-Skript als
Ersatz gebaut. Alter SSH-Host-Key in `known_hosts` stimmte nicht mehr
(Router war zwischenzeitlich zurückgesetzt worden) — alten Eintrag
entfernt, dabei eine unabhängig kaputte Zeile in `known_hosts`
mitgefunden und bereinigt.
- Vor jeder Änderung Config-Backup (`/export terse`) gezogen. Systematisch
jede kuratierte Menü-Familie einmal komplett durchgespielt (anlegen →
mit `print` prüfen → wieder entfernen): Firewall Filter/NAT/Mangle/Raw/
Adress-Listen, VLAN, WireGuard+Peer, IP-Pool, DHCP-Server+Netzwerk,
Route, PPP-Secret/Profil, Queue Simple, Scheduler+Skript, Netwatch —
alle Feldnamen bestätigt.
- **Zwei echte Bugs gefunden:** (1) `/ip service`s Zugriffsfilter-Feld
heißt `available-from`, nicht wie im Schema angenommen `address`. (2)
RouterOS 7.24.2 zeigt `.id` gar nicht in `print terse`-Ausgaben (anders
als angenommen), und Menüs mit nur einem Eintrag statt einer Liste
(`/ip dns`, `/system identity`, `/system clock`, `/system ntp client`,
`/tool e-mail`) lehnen `terse` komplett ab. Live herausgefunden, dass
`:put [<Pfad> find]` die echten internen IDs in exakt der Reihenfolge
von `print terse` liefert (per Stichprobe kreuzgeprüft) — als Fix
genutzt, plus neuer `RouterOSMenuSchema.isSingleton`-Schalter mit
eigenem Fetch-/Set-Pfad für die Ein-Eintrag-Menüs. Fix live gegen
`/system identity` bestätigt (Name testweise geändert, zurückgesetzt).
- Nach dem Test: `/export terse` erneut gezogen und gegen das
Ausgangs-Backup verglichen — bis auf den Zeitstempel-Kommentar
byte-identisch, alle Testeinträge sauber entfernt.
- "alles nochmal speichern (Handoff, Chatlog, Memory)" → dieser Eintrag,
`HANDOFF.md` (M9/M10-Abschnitte, Bug 7/8, aktualisierte
Einschränkungen/Nächste-Schritte) und Memory aktualisiert.
## Nachtrag: erster Release-Deploy + Nutzer testet selbst in der App
- "baue Release neu und deploye nach /Applications" → Release gebaut,
altes `/Applications/RouterOS Assistant.app` ersetzt.
- Nutzer meldete direkt danach: "wenn ich einen DHCP Server neu anlege,
kann ich den vorher angelegten Adress-Pool nicht in der Auswahl sehen."
→ Bug 9 gefunden: Cross-Referenz-Felder (DHCP-Server→Pool, PPP-Secret→
Profil, Scheduler→Skript, Filter→Adress-Liste, WireGuard-Peer→
Interface) waren reiner Freitext statt Auswahl. Neuer Feldtyp
`RouterOSFieldSchema.Kind.menuItemPick` gebaut, lädt beim Öffnen eines
Menüs live die Namen aus dem referenzierten Menü. Build+Tests grün,
Nutzer bestätigte "ja bitte" → Release neu gebaut+deployt.
- Nutzer: "die auswahl funktioniert jetzt, aber der DHCP-Server wird nicht
angelegt." → Frage nach Fehlermeldung: keine. Live reproduziert: exakt
derselbe `/ip dhcp-server add`-Befehl über direktes SSH lieferte
`"failure: server or relay with such interface already exists"` **mit
Exit-Status 0** (per `expect`-Skript + `catch wait result` geprüft,
zusätzlich mit einem zweiten Fehlerfall — ungültiger `action=`-Wert,
`"syntax error"` — bestätigt). Bug 10 gefunden: `SSHTransport.apply()`
erkannte Fehler nur am Exit-Code, der bei RouterOS' SSH-CLI aber nie
ungleich 0 wird. Betraf `apply()` app-weit, nicht nur den Experte-Tab.
Fix: jede nicht-leere Ausgabe eines add/set/remove-Befehls gilt jetzt
als Fehler (laut allen 15+ live getesteten Befehlsfamilien sind
erfolgreiche mutierende Befehle immer still). Nutzer wollte zusätzlich
einen echten Test: VLAN mit eigenem DHCP-Server auf einem freien
Interface komplett neu durchgespielt (VLAN→IP→Pool→DHCP-Server→
Netzwerk), lief fehlerfrei, danach entfernt. Release neu gebaut+deployt.
## Nachtrag: Nutzer testet erstmals selbst in der App-UI
- "leg jetzt VLAN mit eigenem DHCP-Server an, aber mir fehlen komplett die
'löschen-Buttons'" — zwei Anliegen. Live-Test des DHCP-Server-Flows mit
`disabled=no` bestätigte den Bug-10-Fix nochmal. Für die Lösch-Buttons:
Ursache war `.swipeActions` — eine iOS/iPadOS-Wischgeste ohne
Entsprechung auf macOS-Listen (Bug 12). Fix: sichtbarer
Papierkorb-Icon-Button je Zeile. Release neu gebaut+deployt.
- Nutzer probierte danach selbst: "klappt, leg jetzt VLAN mit eigenem
DHCP-Server an, aber ..." — parallel wieder ein Live-Test von mir
(VLAN→IP→Pool→DHCP-Server mit `disabled=no`, lief durch, aufgeräumt).
- "es erscheint keine Fehlermeldung" beim VLAN-Anlegen selbst (nach dem
Bug-10-Fix, vor dem nächsten) → Nutzer beschrieb genauen Ablauf
(Experte-Tab, VLAN-Interfaces, Name TEST-VLAN, ID 20, ether5,
`"Unerwartete Antwort vom Router: syntax error (line 1 column 30)"`).
Live mit der exakt gleichen generierten Befehlszeile reproduziert:
`disabled=false` → derselbe Fehler; `disabled=no` → Erfolg. Bug 11:
RouterOS-CLI akzeptiert nur `yes`/`no`, nie `true`/`false` — Schema-
Defaults waren aber durchgängig als `"true"/"false"` geschrieben (23×
falsch). Fix in `ExpertViewModel.pendingCommand` (normalisiert jeden
Bool-Wert vor dem Senden) plus Katalog-Strings bereinigt. Release neu
gebaut+deployt — Nutzer bestätigte: "klappt".
- Nutzer testete darauf selbst weiter in der App: VLAN anlegen/zuweisen/
löschen funktionierte. Beim DHCP-Server-Zuweisen auf `ether5` kam
`"failure: server or relay with such interface already exists"`
kein neuer Bug, sondern derselbe bereits bekannte Fall (ether5 hatte
bereits `dhcp5` aus einer früheren Session). Nutzer wählte "Weg 1":
DHCP-Server aufs VLAN statt auf `ether5` — meldete dann: "TEST-VLAN
steht nicht in der Auswahl". Bug 13 gefunden: `.interfacePick` las aus
`ConnectionService.interfaces`, einmalig beim Verbinden befüllt, nie
danach aktualisiert — anders als die neueren `.menuItemPick`-Felder.
Fix: neues `ExpertViewModel.liveInterfaceNames`, lädt bei jedem
Menü-Öffnen frisch via `/interface print terse`; die stale
`availableInterfaces`-Parameter-Durchreichung komplett entfernt.
Release neu gebaut+deployt.
- Nutzer: "super, das hat funktioniert." — VLAN erschien direkt in der
DHCP-Server-Interface-Auswahl, ohne erneutes Verbinden.
- "alles nochmal speichern" → dieser Eintrag, `HANDOFF.md` (Bug 1113,
aktualisierte M10-Zusammenfassung, Milestones, Nächste-Schritte) und
Memory aktualisiert.
## Nachtrag: Sicherheits-Angleichung + Dauer-Editor
- "die änderungen, welche ich im expert-Modus mache, werden diese sofort
geschrieben, oder ist eine Bestätigung erforderlich? wenn eine
bestätigung erforderlich, vermisse ich den Button dafür." → Antwort:
sofort, kein Bestätigungsschritt für Anlegen/Ändern (nur Löschen hatte
einen Dialog) — wich vom Wizard ab (Übersicht + Pflicht-Backup vor
"Jetzt anwenden"). Nutzer: "ja, beides einbauen." → Bestätigungsdialog
vor Anlegen/Ändern ergänzt (zeigt den exakten Befehl), plus
`ConnectionService.hasExpertToolBackedUpThisSession` +
`ExpertViewModel.ensureSessionBackup()`: automatisches Backup vor dem
ersten Schreibvorgang je Verbindung, nicht vor jeder einzelnen Änderung.
Build+Tests grün, Release neu gebaut+deployt.
- "in welche zeit (sekunden, Minuten oder Stunden wird die Lease-Zeit
angegeben? das war beim anlegen des DHCP-Server nicht deutlich." — live
geprüft statt geraten: RouterOS-Zeitwerte sind Zahl+Einheit
(s/m/h/d/w, kombinierbar wie `1d12h30m`), eine reine Zahl gilt als
Sekunden (`90` → intern `1m30s`). Tooltip entsprechend präzisiert,
Build+Tests grün.
- "kannst du mir dazu ein sinnvoll gestaltetes Auswahlfenster erzeugen?
ein Tooltip wäre sehr hilfreich" → neuer Feldtyp
`RouterOSFieldSchema.Kind.duration` + `DurationFieldEditor` (vier
Stepper: Tage/Std/Min/Sek, parst bestehende Werte per Regex/Bare-Zahl
beim Öffnen, schreibt die RouterOS-Suffix-Form zurück), angewendet auf
DHCP-Server "Lease-Zeit" und Adress-Listen "Ablaufzeit". Build+Tests
grün, Release neu gebaut+deployt.
- Nutzer: "ok, funktioniert, alles nochmal speichern (Handoff, Chatlog,
Memory) und dann schluß für heute, danke für die coole session." →
dieser Eintrag, `HANDOFF.md` (dritter/vierter Nachtrag, Milestones,
Nächste-Schritte inkl. Hinweis: `test-vlan`/`testpool`/`testdhcp` sind
echte Nutzer-Konfiguration, kein Test-Überbleibsel) und Memory
aktualisiert. Sitzung beendet.
## Stand am Ende dieser Session (2026-09-14)
M9 (Einfach/Experte-Modus) und M10 (Experte-Tab: generischer Motor +
kuratierte Firewall/Interfaces/IP/VPN/WLAN/Queues/System/Werkzeuge-
Schemas) gebaut und **live in der App vom Nutzer selbst bestätigt**
(VLAN anlegen, DHCP-Server mit Adress-Pool zuweisen, löschen) — nicht mehr
nur per SSH nachgestellt wie im ersten Test-Durchgang. Dabei sieben reale
Bugs gefunden und gefixt (Bug 713), jeder Fund mit einem Release-Rebuild
+ Deploy gefolgt. Bug 10 (RouterOS-SSH-Exit-Code immer 0, Fehler wurden
app-weit verschluckt) ist der wichtigste Fund der Session. Danach zwei
Nutzerwünsche ergänzt: Bestätigungsdialog + Auto-Backup vor dem ersten
Experte-Tab-Schreibvorgang (Angleichung an den Wizard), und ein
Dauer-Editor (Tage/Std/Min/Sek) für Lease-/Ablaufzeit-Felder statt
Freitext. Release-Build unter `/Applications/RouterOS Assistant.app` ist
auf aktuellem Stand, noch nicht committed. Offen: M9s Modusumschalter
selbst noch nicht manuell durchgeklickt, WLAN/Bonding/PPPoE-Client-Schemas
ungetestet (kein WLAN-Chip / riskant), einige IP-Schreibpfade nur lesend
geprüft, `dhcp5`/`pool5` auf `ether5` als Test-Überbleibsel noch zu
klären, Dauer-Editor könnte auf weitere Zeitfelder ausgeweitet werden,
plus alle bereits vorher offenen Punkte (M7-Rest, M8-Hardware-Test,
Firewall-WAN-Port-Recheck).
+305 -9
View File
@@ -6,8 +6,25 @@ teilweise gegen zwei echte physische Mikrotik-Testgeräte verifiziert
kritischer Parser-Bug gefunden und gefixt, siehe unten). Danach noch
mehrere Nutzer-Wünsche umgesetzt: Tooltips, wählbarer Backup-Ordner,
Schnell-Backup im Verbinden-Tab, "Werkseinstellungen wiederherstellen",
eigenes App-Icon. Release-Build liegt unter
`/Applications/RouterOS Assistant.app`.
eigenes App-Icon.
Neu in dieser Session: Einrichten-Wizard hat jetzt einen Einfach/Experte-
Modusschalter (M9), und ein komplett neuer "Experte"-Tab (M10) gibt
Formularzugriff auf einen großen, aber bewusst kuratierten Ausschnitt von
RouterOS (Firewall/NAT/Mangle/Raw/Adress-Listen mit vollen Tooltips,
plus Interfaces/IP/VPN/WLAN/Queues/System/Werkzeuge, plus ein "eigener
Menüpfad"-Feld für alles, was nicht kuratiert ist). Live gegen den
hEX-Testrouter (RouterOS 7.24.2) verifiziert — erst per direktem SSH,
dann zusätzlich vom Nutzer selbst in der laufenden App (VLAN anlegen,
DHCP-Server mit Adress-Pool zuweisen, löschen) — dabei **sieben** echte
Bugs gefunden und gefixt (Bug 713 unten, nach jedem Fund Release neu
gebaut und deployt). **Bug 10 ist der wichtigste Fund dieser Session:**
RouterOS' SSH-CLI gibt bei fehlgeschlagenen Befehlen trotzdem Exit-Code 0
zurück — betraf `apply()` app-weit, nicht nur den Experte-Tab (siehe
unten). Release-Build unter `/Applications/RouterOS Assistant.app` ist
auf aktuellem Arbeitsstand (alle M9/M10-Änderungen + alle Bugfixes
deployt und vom Nutzer bestätigt) — **noch nicht committed** (siehe
`git status`), letzter Commit ist weiterhin `97fc216`.
## Ziel
@@ -57,25 +74,29 @@ RouterOSAssistant/
App/RouterOSAssistantApp.swift — 3 Tabs, teilen sich EINE ConnectionService-Instanz
Core/
Models/
RouterOSCommand.swift — eine Änderung, zwei Operationen (.add/.set), zwei Renderer (CLI-Zeile / REST-JSON)
RouterOSCommand.swift — eine Änderung, drei Operationen (.add/.set/.remove), zwei Renderer (CLI-Zeile / REST-JSON)
WanConfig.swift, LanDhcpConfig.swift, VlanEntry.swift, WifiNetworkConfig.swift, FirewallConfig.swift — bauen je RouterOSCommand-Listen
DhcpServerCommandBuilder.swift — geteilte "Adresse+Pool+Server+Netzwerk"-Logik (LAN + VLAN)
RouterOSModels.swift — Credentials, DeviceInfo, Interface, RouterOSError
RouterOSMenuItem.swift — generisches Item (id + [String:String] fields) für JEDEN RouterOS-Menüpfad (M10)
RouterOSSchema.swift — RouterOSFieldSchema/RouterOSMenuSchema/RouterOSMenuCategory: kuratiertes Formular-Schema fürs Experte-Tool (M10)
RouterOSSchemaCatalog.swift — die eigentliche Menü-Liste: Firewall-Familie voll kuratiert, Rest teils kuratiert/teils generisch (M10)
Networking/
RouterOSTransport.swift — Protocol: connect/fetchDeviceInfo/fetchInterfaces/fetchFirewallRuleCounts/apply/disconnect
RestTransport.swift — REST-API (RouterOS ≥7.1), Zertifikats-TOFU, .set via GET+PATCH (findItemID)
SSHTransport.swift — SSH-Fallback via Citadel, CLI-Text-Parsing, resetToFactoryDefaults(), eigene Hostkey-TOFU
RouterOSCliParser.swift — parst `/system resource print` und `/interface print terse`
RouterOSTransport.swift — Protocol: connect/fetchDeviceInfo/fetchInterfaces/fetchFirewallRuleCounts/fetchMenuItems/apply/disconnect
RestTransport.swift — REST-API (RouterOS ≥7.1), Zertifikats-TOFU, .set via GET+PATCH (findItemID), .remove via GET+DELETE, fetchMenuItems (Array- oder Singleton-Objekt)
SSHTransport.swift — SSH-Fallback via Citadel, CLI-Text-Parsing, resetToFactoryDefaults(), eigene Hostkey-TOFU, fetchMenuItems (generisch, inkl. Singleton-Fallback)
RouterOSCliParser.swift — parst `/system resource print`, `/interface print terse`, und generisch jeden Menüpfad (parseGenericItems/parseSingletonItem, M10)
CertificateTrustStore.swift / CertificateFingerprint.swift — TOFU für REST-Zertifikate
SSHHostKeyTrustStore.swift / SSHHostKeyFingerprint.swift — TOFU für SSH-Hostkeys (M7)
Services/
ConnectionService.swift — zentraler App-State: REST-zuerst-SSH-Fallback, hält credentials/interfaces/deviceInfo
ConnectionService.swift — zentraler App-State: REST-zuerst-SSH-Fallback, hält credentials/interfaces/deviceInfo, plus generisches fetchMenuItems
BackupService.swift — Config-Export (`/export terse`) über eigene SSH-Verbindung, wählbarer Zielordner
FactoryResetService.swift — /system reset-configuration über eigene SSH-Verbindung ("Gefahrenzone")
KeychainService.swift — Passwort-Speicherung
Features/
Wizard/Steps/Connect/ — Verbinden-Tab (inkl. Schnell-Backup-Button nach erfolgreicher Verbindung)
Wizard/Steps/Setup/ — Einrichten-Tab: Wan → Lan → Vlan → Wifi → Firewall → Review/Apply
Wizard/Steps/Setup/ — Einrichten-Tab: Mode → Wan → Lan → (Vlan nur Experte) → Wifi → Firewall → Review/Apply
Expert/ — Experte-Tab (M10): ExpertView (Kategorie-/Menüliste + "eigener Pfad"), ExpertViewModel, ExpertMenuDetailView (Item-Liste + Add/Edit-Sheet)
Backup/ — Sicherungen-Tab (Ordner wählen, Gefahrenzone: Werkseinstellungen wiederherstellen)
Resources/Assets.xcassets/AppIcon.appiconset/ — App-Icon "Signal Router" (16px1024px)
RouterOSAssistantTests/ — reine Unit-Tests (Command-Builder, CLI-Parser, Fallback-Logik via Mock-Transport)
@@ -139,11 +160,112 @@ erreichen (siehe Bug 1 unten).
Auswahllisten gefiltert. Regressionstest mit echten `\r\n`-getrennten
hEX-Daten in `RouterOSCliParserTests`.
7. **`/ip service`: Feld heißt `available-from`, nicht `address`** — beim
Kuratieren des Experte-Schemas geraten statt geprüft. Live gegen den
hEX bestätigt: `/ip service print` zeigt tatsächlich `available-from=`;
ein `set ... address=...` hätte den Zugriffsfilter stillschweigend
nicht gesetzt. Schema korrigiert (`RouterOSSchemaCatalog.swift`).
8. **RouterOS 7.24.2 zeigt `.id` gar nicht in `print terse`, und
Singleton-Menüs verweigern `terse` komplett** — Annahme beim Bau des
generischen Experte-Motors (M10) war, `.id=*N` sei Teil jeder
`print terse`-Zeile (wie bei REST-JSON). Live-Test zeigte: RouterOS
7.24.2 liefert dort gar kein `.id`-Feld; Menüs mit genau einem Eintrag
statt einer Liste (`/ip dns`, `/system identity`, `/system clock`,
`/system ntp client`, `/tool e-mail`) lehnen `terse` sogar komplett ab
(`"bad parameter terse"`). Fix: `:put [<Menüpfad> find]` liefert die
echten internen IDs (z.B. `*C;*1;*2`) — live bestätigt, dass die
Reihenfolge exakt der von `print terse` entspricht, damit per Position
zuordenbar. Für Singleton-Menüs neuer `RouterOSMenuSchema.isSingleton`-
Schalter: Fetch fällt auf normales `print` (Doppelpunkt-Format,
`parseSingletonItem`) zurück, `.set` läuft ohne `[find ...]` direkt
(`matchField: ""` als Sentinel in `RouterOSCommand`/beiden Transports).
Beides live gegen `/system identity` durchgespielt (Name testweise
geändert, Fehlertext exakt geprüft, zurückgesetzt).
9. **Cross-Referenz-Felder (z.B. DHCP-Server → Adress-Pool) waren reiner
Freitext** — Nutzer meldete: "kann den vorher angelegten Adress-Pool
nicht in der Auswahl sehen". Betraf jedes Feld, das den Namen eines
Eintrags aus einem *anderen* Menü referenziert (DHCP-Server→Pool,
PPP-Benutzer→Profil, Scheduler→Skript, Firewall-Filter→Adress-Liste,
WireGuard-Peer→Interface) — alle als `.text` statt als Auswahl gebaut.
Fix: neuer Feldtyp `RouterOSFieldSchema.Kind.menuItemPick(menuPath:
restPath:valueField:)`, lädt beim Öffnen eines Menüs live die
existierenden Namen aus dem referenzierten Menü
(`ExpertViewModel.loadCrossReferenceOptions`) und zeigt sie als Picker.
Auf alle fünf betroffenen Felder angewendet.
10. **Kritisch, app-weit: RouterOS' SSH-CLI gibt bei fehlgeschlagenen
Befehlen Exit-Code 0 zurück** — Nutzer meldete: DHCP-Server-Anlage
zeigte keine Fehlermeldung, wurde aber auch nicht angelegt. Live
reproduziert: `/ip dhcp-server add ...` auf einem Interface mit
bereits bestehendem Server lieferte `"failure: server or relay with
such interface already exists"` **und Exit-Status 0**; ein ungültiger
`action=`-Wert lieferte `"syntax error (...)"`, ebenfalls Exit-Status
0 (per `expect`-Skript mit `catch wait result` geprüft). `SSHTransport.
apply()` warf bisher nur bei einem von Citadel gemeldeten
Nicht-Null-Exit-Code — also nie. **Betraf jeden `.apply()`-Aufruf über
SSH app-weit**, nicht nur den Experte-Tab: der komplette Einrichten-
Wizard (WAN/LAN/VLAN/Firewall) lief bei jedem SSH-Schreibvorgang mit
demselben blinden Fleck, seit M3. Fix: `apply()` behandelt jede
nicht-leere Ausgabe eines add/set/remove-Befehls als Fehler — laut
allen in dieser Session live getesteten Befehlsfamilien (15+) sind
erfolgreiche mutierende Befehle immer still. Nach dem Fix live end-to-
end bestätigt: komplette Kette VLAN→IP-Adresse→Pool→DHCP-Server→
DHCP-Netzwerk auf einem freien Interface angelegt, lief fehlerfrei
durch, danach vollständig wieder entfernt.
11. **Bool-Felder im Experte-Schema als `"true"/"false"` statt `"yes"/"no"`
geschrieben** — Nutzer meldete nach Bug-10-Fix: VLAN-Anlage zeigte
jetzt `"Unerwartete Antwort vom Router: syntax error (line 1 column
30)"`. Live reproduziert mit der exakt von der App erzeugten
Befehlszeile (`disabled=false`) — identischer Fehler; mit
`disabled=no` lief es durch. RouterOS' CLI akzeptiert nur `yes`/`no`
für Bool-Parameter, nicht `true`/`false`. Betraf praktisch jedes
kuratierte Schema (23× `"false"`, 4× `"true"` als `defaultValue`).
Fix zweigleisig: `ExpertViewModel.pendingCommand` normalisiert jeden
`.bool`-Feldwert vor dem Senden auf `yes`/`no` (Schutz unabhängig von
der Quelle), zusätzlich Katalog-Strings selbst bereinigt. Live erneut
end-to-end bestätigt (VLAN→IP→Pool→DHCP-Server mit `disabled=no`).
12. **Lösch-Buttons fehlten komplett im Experte-Tab** — Nutzer: "mir
fehlen komplett die Löschen-Buttons". Ursache: `.swipeActions(...)`
ist eine iOS/iPadOS-Wischgeste ohne Entsprechung auf macOS-Listen —
dort erscheint schlicht nichts, unabhängig vom Code. Fix: sichtbarer
Papierkorb-Icon-Button je Zeile statt Wisch-Geste (nur bei
Nicht-Singleton-Menüs, wo Löschen überhaupt Sinn ergibt). Lösch-Pfad
(ID-Ermittlung + `remove [find .id=...]`) live gegen das vom Nutzer
selbst angelegte VLAN nachvollzogen, funktioniert.
13. **Interface-Auswahl (`.interfacePick`) war einmalig beim Verbinden
eingefroren** — Nutzer legte ein VLAN an, danach fehlte es in der
Interface-Auswahl beim Anlegen eines DHCP-Servers. Ursache:
`.interfacePick` las aus `ConnectionService.interfaces`, nur einmal
bei `finishConnecting()` befüllt, nie danach aktualisiert — anders als
die neueren `.menuItemPick`-Felder (Pool/Profil/Skript), die bei
jedem Menü-Öffnen frisch laden. Fix: neues
`ExpertViewModel.liveInterfaceNames`, per `/interface print terse` bei
jedem `reloadItems()` aktualisiert; `availableInterfaces`-Parameter-
Durchreichung (`ExpertView``ExpertMenuDetailView`
`ExpertItemEditView`) komplett entfernt, da überflüssig. Danach vom
Nutzer selbst in der App bestätigt: neu angelegtes VLAN erscheint
sofort in der DHCP-Server-Interface-Auswahl.
**Lehren:** Citadel/NIOSSH-Fehler immer mit `String(describing:)` loggen,
nie `.localizedDescription`. Jede View, die ein ObservableObject aus einem
ViewModel liest, muss es selbst separat als `@ObservedObject` halten.
Zeilen-Splitting bei SSH-Ausgaben grundsätzlich mit `\.isNewline` statt
festem `"\n"`, da sich das je nach Gerät/Version unterscheiden kann.
Bei generischem RouterOS-Tooling (M10) nichts über CLI-Ausgabeformate
annehmen, was nicht live geprüft ist — `.id`-Verhalten und `terse`-Support
unterscheiden sich zwischen Menütypen und RouterOS-Versionen. **RouterOS'
SSH-Exit-Code ist kein verlässliches Erfolgssignal** — Ausgabetext ist die
einzige verlässliche Fehlerquelle über diesen Transport (Bug 10); REST
ist davon nicht betroffen (prüft HTTP-Statuscodes korrekt). RouterOS'
CLI will `yes`/`no` für Bools, nie `true`/`false` (Bug 11) — an einer
einzigen Stelle normalisieren (`pendingCommand`) statt an jedem
Schema-Eintrag hoffen, es richtig geschrieben zu haben. SwiftUI-
Interaktionsmuster nicht plattform-blind übernehmen — `.swipeActions`
sieht in der Doku harmlos aus, ist aber faktisch iOS/iPadOS-only (Bug 12).
Jeder Zustand, der sich während der Nutzung ändern kann (Interface-Liste
nach Anlegen eines VLANs), gehört live nachgeladen, nicht einmalig beim
Verbinden gecacht (Bug 13) — dasselbe Prinzip wie `crossReferenceOptions`
für Pool/Profil/Skript, nur nicht von Anfang an konsequent auf alle
Auswahllisten angewendet.
## `xcodebuild test` hängt — Gatekeeper, kein Code-Bug
@@ -162,6 +284,15 @@ wiederholen.
## Bekannte Einschränkungen (bewusst, nicht vergessen)
- **Backup-Wiederherstellung fehlt komplett** — Nutzer wies explizit
darauf hin (2026-09-14): `BackupService` kann Backups nur erzeugen
(`createBackup`) und auflisten (`listBackups`), es gibt **keine**
Funktion, ein gespeichertes `.rsc`-Backup zurück auf den Router zu
spielen. Einzige verwandte Funktion ist "Werkseinstellungen
wiederherstellen" (`FactoryResetService`, Gefahrenzone im
Sicherungen-Tab) — das ist RouterOS' eigener Werksreset, kein Zurück-
spielen einer konkreten Sicherung. Noch nicht umgesetzt, für später
vorgemerkt (siehe Nächste Schritte).
- **REST-Pfad ungetestet für Schreibvorgänge** — auf beiden bisherigen
Testgeräten war `www-ssl` (Port 443) aus, jeder Schreibtest lief über
SSH. Der REST-`apply()`-Pfad (`POST`/`PATCH`, `findItemID` für `.set`)
@@ -187,6 +318,20 @@ wiederholen.
- ~~SSH-Hostkey-TOFU ungetestet~~ — **bestätigt gegen echte Hardware**:
erste Verbindung zeigte den "Unbekannter SSH-Schlüssel"-Dialog, nach
Bestätigen + Trennen + erneutem Verbinden lief es ohne Rückfrage durch.
- **Experte-Tool (M10): mehrere kuratierte Bereiche noch nicht gegen
Hardware bestätigt** — WLAN (Legacy- und neuer wifiwave2-Treiber:
Testgerät hat keinen WLAN-Chip), Bonding und PPPoE-Client (beide riskant
für die eigene Erreichbarkeit/brauchen echte ISP-Zugangsdaten) nur gegen
RouterOS-Dokumentation kuratiert, nicht live geprüft. `/ip address`,
`/ip dhcp-client`, `/ip dns`, `/ip service` nur lesend geprüft (Feldnamen
bestätigt), keine Schreibtests — Risiko, sich selbst auszusperren
(SSH/REST-Dienst) bzw. die WAN-Verbindung zu kappen.
- **Experte-Tool: einige RouterOS-Bereiche bewusst nur generisch, nicht
kuratiert** — Hotspot, Logging (beide mehrteilig, kein flaches Schema),
CAPsMAN, L2TP-/OpenVPN-Server, OSPF/BGP-Routing. Grund: keine verifizierte
Quelle für die genauen Feldnamen, lieber generischer Schlüssel/Wert-
Zugriff als geratene Felder (siehe Bug 7 oben, warum das eine berechtigte
Sorge ist).
## M8: Mehrere LAN-Interfaces + Netzwerk-Isolation
@@ -210,6 +355,114 @@ 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.
## M9: Einfach/Experte-Modus im Einrichten-Wizard
Nutzerwunsch: zwei Modi statt einem Wizard-Durchlauf für alle. Neuer
erster Schritt `SetupStep.mode` (`ModeStepView`, Segmented Picker).
- **Einfach**: WAN → LAN (nur ein Netzwerk, kein Hinzufügen/Entfernen,
kein Isolations-Toggle) → WLAN → Firewall (Grundschutz fest an, kein
Toggle) → Review. VLAN-Schritt wird komplett übersprungen.
- **Experte**: unveränderter voller Flow (Multi-LAN, VLAN, Isolation,
Firewall-Toggle) — das, was vorher der einzige Wizard-Pfad war.
Beim Umschalten auf Einfach werden evtl. im Expertenmodus gesetzte Werte
zurückgesetzt (`SetupViewModel.applySimpleModeConstraints`). Navigation
(`goNext`/`goBack`) von linearem `SetupStep.rawValue+1` auf expliziten
`switch` umgebaut (nötig wegen bedingtem VLAN-Skip). Nur gegen Unit-Tests
+ Compile/Test-Build verifiziert, **nicht gegen echte Hardware** (der
Live-Test dieser Session lief komplett über direktes SSH, nicht über die
App-UI).
## M10: Experte-Tab — generischer RouterOS-Konfigurationszugriff
Nutzerwunsch: "Zugriff auf alle Funktionen, was RouterOS bietet ...
alles was du findest, der komplette Umfang". RouterOS hat 300+ Menüs
(siehe `/console inspect` / CLI-Referenz) — statt für jedes einzelne ein
Swift-Modell zu bauen (unrealistisch), zweistufige Architektur:
1. **Generischer Motor** (deckt technisch jeden Menüpfad ab):
`RouterOSMenuItem` + `RouterOSCliParser.parseGenericItems`/
`parseSingletonItem`, `RouterOSCommand.remove` (neu, neben add/set),
`ConnectionService.fetchMenuItems` + Transport-Implementierungen.
Im UI: freies "eigener Menüpfad"-Eingabefeld — jeder RouterOS-Pfad ist
erreichbar, auch ohne kuratiertes Schema, dann als rohes
Schlüssel/Wert-Formular.
2. **Kuratierte Schemas** (`RouterOSSchemaCatalog`) mit echten
Formularfeldern, Tooltips, Erklärtext und Warnhinweisen: die komplette
Firewall-Familie (Filter/NAT/Mangle/Raw/Adress-Listen — der explizit
genannte Schwerpunkt), dazu Interfaces (Bridge, VLAN, WireGuard+Peers,
PPPoE-Client, Bonding), IP (Adressen, Pools, DHCP-Server+Netzwerk,
DHCP-Client, DNS, Verwaltungsdienste, Routen), VPN (PPP-Secret/Profile),
WLAN (Legacy + neuer wifiwave2-Treiber + Sicherheitsprofile), Queues
(Simple/Tree), System (Identity/Clock/NTP/Scheduler/Skripte/Nutzer),
Werkzeuge (Netwatch/E-Mail). Bewusst nicht kuratiert (siehe oben):
Hotspot, Logging, CAPsMAN, L2TP-/OVPN-Server, OSPF/BGP.
**Live gegen den hEX-Testrouter verifiziert** (Add→Print→Set→Remove-
Zyklus je Familie, Router danach nachweislich unverändert — Config-Export
vorher/nachher byte-identisch bis auf Zeitstempel): Firewall
Filter/NAT/Mangle/Raw/Adress-Listen, VLAN, WireGuard+Peers, IP-Pool,
DHCP-Server+Netzwerk, statische Route, PPP-Secret/Profile, Queue Simple,
Scheduler+Skript, Netwatch, sowie der neue Singleton-Pfad
(`/system identity`). Dabei zwei Bugs gefunden und gefixt, siehe Bug 7/8
oben. **Nicht** gegen Hardware getestet: WLAN (kein Chip im Testgerät),
Bonding, PPPoE-Client (beide riskant/brauchen echte Zugangsdaten),
Schreibzugriff auf `/ip address`/`/ip dhcp-client`/`/ip dns`/
`/ip service` (nur lesend geprüft, Risiko Selbstaussperrung/WAN-Ausfall).
**Nachtrag (Nutzer-Feedback-Runde, gleiche Session):** Nutzer meldete zwei
reale Probleme beim gedanklichen/App-Testen des DHCP-Server-Formulars —
Bug 9 (Adress-Pool-Feld ohne Auswahl) und Bug 10 (fehlgeschlagenes Anlegen
ohne jede Fehlermeldung). Beide gefunden und gefixt (siehe oben). Bug 10s
Fix wurde erneut end-to-end live bestätigt: komplette Kette
VLAN → IP-Adresse → Pool → DHCP-Server → DHCP-Netzwerk auf einem freien
Interface (statt des zuvor kollidierenden `ether5`) angelegt, kein
Fehlertext (weil tatsächlich erfolgreich), anschließend vollständig
wieder entfernt.
**Zweiter Nachtrag — jetzt tatsächlich durch die App-UI getestet (Nutzer
selbst, gleiche Session):** Nutzer legte VLAN + DHCP-Server über den
Experte-Tab live an, fand dabei drei weitere reale Bugs (11: yes/no,
12: fehlende Lösch-Buttons, 13: eingefrorene Interface-Auswahl, siehe
oben) — alle gefunden, gefixt, nach jedem Fix Release neu gebaut und
deployt, vom Nutzer direkt in der App erneut getestet. Damit ist die
SwiftUI-Oberfläche des Experte-Tabs (VLAN anlegen, einem DHCP-Server
zuweisen, Adress-Pool wählen, löschen) **erstmals tatsächlich manuell
durchgeklickt und vom Nutzer als funktionierend bestätigt** — nicht mehr
nur die zugrundeliegende RouterOS-Syntax per SSH. Bestätigt: RouterOS
erlaubt nur einen DHCP-Server pro Interface — der Versuch, ihn direkt auf
`ether5` zu setzen, scheiterte zurecht (dort hängt bereits `dhcp5` aus
einer früheren Session), auf dem VLAN-Interface funktioniert es.
**Dritter Nachtrag — Sicherheits-Angleichung ans übrige App-Verhalten:**
Nutzer fragte, ob Experte-Tab-Änderungen sofort geschrieben werden oder
eine Bestätigung braucht — Antwort war "sofort, kein eigener
Bestätigungs-Button" (nur Löschen hatte einen Dialog). Das wich vom
Einrichten-Wizard ab (Übersicht + Pflicht-Backup vor "Jetzt anwenden").
Auf Wunsch angeglichen:
- **Bestätigungsdialog vor Anlegen/Ändern**: "Anlegen"/"Speichern" öffnet
jetzt zuerst einen `.confirmationDialog` mit dem exakten Befehl, erst
ein zweiter Klick führt ihn aus (`ExpertMenuDetailView`).
- **Auto-Backup einmal pro Verbindung**: `ConnectionService.
hasExpertToolBackedUpThisSession` (zurückgesetzt bei `disconnect()`),
`ExpertViewModel.ensureSessionBackup()` zieht vor dem allerersten
Schreibvorgang im Experte-Tab automatisch ein Backup (wie der Wizard),
aber nur einmal pro Sitzung, nicht vor jeder einzelnen Änderung (ein
Experte-Tab-Durchgang sind oft viele kleine Edits hintereinander).
**Vierter Nachtrag — Dauer-Editor:** Nutzer fragte, in welcher Einheit
die DHCP-Server-Lease-Zeit angegeben wird. Live geprüft: RouterOS-
Zeitwerte sind Zahl+Einheit (`s`/`m`/`h`/`d`/`w`, kombinierbar wie
`1d12h30m`), eine bare Zahl gilt als Sekunden (bestätigt: `90` → intern
`1m30s`). Nutzerwunsch danach: "ein sinnvoll gestaltetes Auswahlfenster
... ein Tooltip wäre hilfreich" statt nur Tooltip-Text. Neuer Feldtyp
`RouterOSFieldSchema.Kind.duration` + `DurationFieldEditor` (vier
Stepper: Tage/Std/Min/Sek, parst bestehende Werte beim Öffnen, schreibt
beim Ändern die RouterOS-Suffix-Form zurück) — angewendet auf DHCP-Server
"Lease-Zeit" und Adress-Listen "Ablaufzeit". Nutzer bestätigte danach:
"funktioniert".
## Stand der Milestones
- ✅ M1M4: Projektgerüst, Connect, Backup, WAN/LAN/DHCP, VLAN — gegen
@@ -232,6 +485,22 @@ Testnetze am selben Router, um die Drop-Regeln praktisch zu bestätigen.
- 🔶 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.
- 🔶 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).
- ✅ M10: Experte-Tab (generischer RouterOS-Zugriff + kuratierte Firewall-
und weitere Schemas) — Kern-Logik **live gegen Hardware verifiziert**,
UI (VLAN anlegen/löschen, DHCP-Server mit Adress-Pool zuweisen) **vom
Nutzer selbst in der laufenden App bestätigt funktionierend** — sieben
reale Bugs dabei gefunden und gefixt (Bug 713), danach zusätzlich
Bestätigungsdialog vor Anlegen/Ändern + einmaliges Auto-Backup pro
Sitzung (Angleichung an den Wizard) sowie ein Dauer-Editor (Tage/Std/
Min/Sek statt Freitext) für Lease-/Ablaufzeit-Felder ergänzt, alles vom
Nutzer bestätigt. Weiterhin ungetestet: WLAN (kein Chip im Testgerät),
Bonding, PPPoE-Client (beide riskant/brauchen echte Zugangsdaten),
Schreibzugriff auf `/ip address`/`/ip dhcp-client`/`/ip dns`/
`/ip service` (Risiko Selbstaussperrung/
WAN-Ausfall).
## Nächste Schritte
@@ -247,6 +516,33 @@ Testnetze am selben Router, um die Drop-Regeln praktisch zu bestätigen.
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
bestätigt (siehe oben), der Moduswechsel im Wizard noch nicht.
6. M10: WLAN-Schemas (an Gerät mit WLAN-Chip), Bonding, PPPoE-Client
(mit echten oder Test-ISP-Zugangsdaten) noch gegen Hardware verifizieren.
7. `dhcp5`/`pool5` auf `ether5` sind Test-Überbleibsel aus einer früheren
Session — mit dem Nutzer klären, ob die noch gebraucht werden oder
aufgeräumt werden können (aktuell blockieren sie `ether5` für einen
neuen direkt-auf-dem-Port angelegten DHCP-Server).
8. **Nicht anrühren:** `test-vlan` (VLAN-ID 20, ether5), `testpool`
(192.168.90.10-250) und `testdhcp` (DHCP-Server auf `test-vlan`) sind
**echte, vom Nutzer selbst über die App angelegte Konfiguration** aus
dieser Session, keine Test-Artefakte zum Aufräumen.
9. 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".
10. **Backup-Wiederherstellung bauen** — vom Nutzer explizit gewünscht,
noch nicht begonnen. Vermutlich: `BackupService` um eine
`restoreBackup(_:for:)`-Funktion erweitern, die den Inhalt eines
gewählten `.rsc`-Backups über SSH auf den Router einspielt (RouterOS
kann `.rsc`-Skripte per `/import file-name=...` ausführen, dazu muss
die Datei vorher aufs Gerät hochgeladen werden — Weg noch nicht
verifiziert, vor dem Bauen live prüfen statt zu raten), plus ein
"Wiederherstellen"-Button pro Eintrag im Sicherungen-Tab
(`BackupListView`) mit Bestätigungsdialog (ähnlich der bestehenden
Werksreset-Gefahrenzone).
Kein Gitea-Remote vorhanden — falls der Nutzer später eine Gitea-Instanz
aufsetzt (z.B. selbst gehostet auf der vorhandenen OMV-NAS, siehe
@@ -11,6 +11,8 @@ struct RouterOSAssistantApp: App {
.tabItem { Label("Verbinden", systemImage: "network") }
SetupView(connectionService: connectionService)
.tabItem { Label("Einrichten", systemImage: "checklist") }
ExpertView(connectionService: connectionService)
.tabItem { Label("Experte", systemImage: "wrench.and.screwdriver") }
BackupListView(connectionService: connectionService)
.tabItem { Label("Sicherungen", systemImage: "clock.arrow.circlepath") }
}
@@ -10,6 +10,9 @@ struct RouterOSCommand: Equatable, Identifiable {
/// field=value] ...`; REST has no such inline lookup, so it needs a GET first to find
/// the item's `.id`, then `PATCH restPath/<id>`).
case set(matchField: String, matchValue: String)
/// Deletes an existing item matched by one field's value (SSH: `<menuPath> remove [find
/// field=value]`; REST again needs a GET-for-id first, then `DELETE restPath/<id>`).
case remove(matchField: String, matchValue: String)
}
var id: String {
@@ -18,6 +21,8 @@ struct RouterOSCommand: Equatable, Identifiable {
return "add:\(menuPath):\(summary)"
case .set(let field, let value):
return "set:\(menuPath):\(field)=\(value):\(summary)"
case .remove(let field, let value):
return "remove:\(menuPath):\(field)=\(value):\(summary)"
}
}
@@ -52,6 +57,22 @@ struct RouterOSCommand: Equatable, Identifiable {
)
}
static func remove(
menuPath: String,
restPath: String,
matchField: String,
matchValue: String,
summary: String
) -> RouterOSCommand {
RouterOSCommand(
menuPath: menuPath,
restPath: restPath,
operation: .remove(matchField: matchField, matchValue: matchValue),
arguments: [:],
summary: summary
)
}
/// Renders as a RouterOS CLI line, e.g. `/ip address add address=192.168.88.1/24 interface=bridge`
/// or `/interface wireless set [find name=wlan1] ssid=Home`.
var cliLine: String {
@@ -60,8 +81,16 @@ struct RouterOSCommand: Equatable, Identifiable {
case .add:
return args.isEmpty ? "\(menuPath) add" : "\(menuPath) add \(args)"
case .set(let field, let value):
// Empty matchField = singleton menu (e.g. "/ip dns", "/system identity"): these hold
// exactly one item and are "set" directly, with no "[find ...]" there's nothing to
// find. Confirmed live against RouterOS 7.24.2.
guard !field.isEmpty else {
return args.isEmpty ? "\(menuPath) set" : "\(menuPath) set \(args)"
}
let finder = "[find \(field)=\(Self.quoteIfNeeded(value))]"
return args.isEmpty ? "\(menuPath) set \(finder)" : "\(menuPath) set \(finder) \(args)"
case .remove(let field, let value):
return "\(menuPath) remove [find \(field)=\(Self.quoteIfNeeded(value))]"
}
}
@@ -0,0 +1,14 @@
import Foundation
/// One existing item under any RouterOS menu (e.g. one firewall filter rule, one DHCP lease,
/// one WireGuard peer), fetched live from the device. Generic on purpose it's what lets the
/// Expert tool browse/edit/remove items under menus that don't have a curated `RouterOSMenuSchema`
/// yet, not just the ones this app knows the field layout for.
struct RouterOSMenuItem: Identifiable, Equatable {
/// RouterOS' internal ".id" (e.g. "*1" over SSH terse output, "*1" or a numeric string over
/// REST) needed to `set`/`remove` this exact item later. Falls back to a synthesized
/// "row-<n>" if a transport ever returns an item without one, so the UI never crashes on it,
/// though such an item can then only be re-created, not edited/removed in place.
let id: String
var fields: [String: String]
}
@@ -0,0 +1,115 @@
import Foundation
/// One configurable field of a curated RouterOS menu drives the Expert tool's add/edit form
/// (label, input kind, tooltip) instead of a bare key=value box.
struct RouterOSFieldSchema: Identifiable, Equatable {
enum Kind: Equatable {
case text
case bool
case int
/// Fixed choice list, e.g. a chain name or an action.
case enumPick(options: [String])
/// Populated at runtime from the connected device's interfaces.
case interfacePick
/// Populated at runtime from the existing items of another RouterOS menu e.g. a DHCP
/// server's "address-pool" field should offer the pools already defined under
/// "/ip pool", not require typing the name from memory. `valueField` is which field of
/// that menu's items to show/use (e.g. "name", or "list" for address-lists).
case menuItemPick(menuPath: String, restPath: String, valueField: String)
/// A RouterOS time value (e.g. "1d12h30m" or a bare number of seconds) edited as
/// separate day/hour/minute/second steppers instead of free text, since the suffix
/// syntax isn't obvious (confirmed live: a bare number is seconds, units combine in one
/// string, e.g. DHCP "lease-time").
case duration
}
var id: String { key }
/// The actual RouterOS parameter name, e.g. "chain", "action", "dst-port".
let key: String
let label: String
let kind: Kind
/// Tooltip: what this field does.
let help: String
let defaultValue: String?
/// Whether a value is required before the item can be added.
let required: Bool
init(key: String, label: String, kind: Kind, help: String, defaultValue: String? = nil, required: Bool = false) {
self.key = key
self.label = label
self.kind = kind
self.help = help
self.defaultValue = defaultValue
self.required = required
}
}
enum RouterOSMenuCategory: String, CaseIterable, Identifiable {
case firewallFilter = "Firewall: Filter-Regeln"
case firewallNat = "Firewall: NAT (Portweiterleitung etc.)"
case firewallMangle = "Firewall: Mangle (Markierung/QoS-Vorbereitung)"
case firewallRaw = "Firewall: Raw (vor Connection-Tracking)"
case firewallAddressLists = "Firewall: Adress-Listen"
case interfaces = "Interfaces (Bridge, VLAN, VPN-Tunnel...)"
case ipAddressing = "IP-Adressierung & Dienste"
case routing = "Routing"
case vpn = "VPN-Server/Clients"
case wireless = "WLAN / CAPsMAN"
case queues = "Queues / Bandbreiten-Steuerung"
case system = "System"
case tools = "Werkzeuge & Überwachung"
var id: String { rawValue }
}
/// Describes one RouterOS menu the Expert tool can browse/edit. `fields` is the curated form;
/// an empty array means "no curated form yet" the Expert tool falls back to a fully generic
/// key=value editor for that menu, so every menu is still reachable even before it's curated.
struct RouterOSMenuSchema: Identifiable, Equatable {
var id: String { menuPath }
let menuPath: String
let restPath: String
let category: RouterOSMenuCategory
let displayName: String
/// One-line "what is this" shown above the item list.
let summary: String
/// Longer "was passiert, wenn ich hier etwas ändere" explanation.
let explanation: String
/// Dependency/gotcha callout, shown as a warning box when present.
let warning: String?
let fields: [RouterOSFieldSchema]
/// A couple of field keys to show per row in the item list (e.g. ["chain", "action"]) so
/// entries are recognizable without opening each one. Falls back to showing every field.
let listColumns: [String]
/// True for menus that hold exactly one settable item, not a list (e.g. "/ip dns",
/// "/system identity") confirmed live against RouterOS 7.24.2: these reject
/// "print terse" outright ("bad parameter terse"), only support plain "print" (colon-value
/// output) and a direct "set key=value" with no `[find ...]`, and can't be added to or
/// removed. The Expert tool skips the add/remove UI and the transports fetch/apply them
/// differently for menus flagged this way.
let isSingleton: Bool
init(
menuPath: String,
restPath: String,
category: RouterOSMenuCategory,
displayName: String,
summary: String,
explanation: String,
warning: String? = nil,
fields: [RouterOSFieldSchema] = [],
listColumns: [String] = [],
isSingleton: Bool = false
) {
self.menuPath = menuPath
self.restPath = restPath
self.category = category
self.displayName = displayName
self.summary = summary
self.explanation = explanation
self.warning = warning
self.fields = fields
self.listColumns = listColumns
self.isSingleton = isSingleton
}
}
@@ -0,0 +1,721 @@
import Foundation
/// Curated catalog of RouterOS menus for the Expert tool. Depth is intentionally uneven: the
/// Firewall/NAT/Mangle/Address-List family (what was explicitly asked for) has full field
/// schemas with tooltips and warnings; everything else has enough to browse/add/remove items
/// generically (empty `fields`, so the Expert tool falls back to a raw key=value form for those)
/// while still being properly labeled and explained. RouterOS has 300+ menus in total this
/// catalog is a curated shortlist of the ones that matter for a home/small-office router, not
/// an exhaustive mirror of the OS. Anything not listed here is still reachable in the Expert
/// tool via its "eigener Menüpfad" free-entry field, generically.
enum RouterOSSchemaCatalog {
static let all: [RouterOSMenuSchema] = firewallFamily + interfaceFamily + ipFamily
+ routingFamily + vpnFamily + wirelessFamily + queueFamily + systemFamily + toolFamily
static func schemas(in category: RouterOSMenuCategory) -> [RouterOSMenuSchema] {
all.filter { $0.category == category }
}
// MARK: - Firewall (fully curated)
private static let firewallFamily: [RouterOSMenuSchema] = [
RouterOSMenuSchema(
menuPath: "/ip firewall filter",
restPath: "ip/firewall/filter",
category: .firewallFilter,
displayName: "Filter-Regeln",
summary: "Entscheidet, ob Pakete durchgelassen (accept), verworfen (drop/reject) oder weiter geprüft werden.",
explanation: "input = Zugriffe auf den Router selbst, forward = Verkehr, der durch den Router hindurchgeht (z.B. LAN↔Internet oder zwischen zwei Netzen), output = vom Router ausgehender Verkehr. Regeln werden von oben nach unten geprüft; die erste passende Regel entscheidet.",
warning: "Reihenfolge zählt. Neue Regeln landen am Ende der Liste — eine bereits vorhandene, weiter oben stehende Regel kann eine neue Regel unerreichbar machen. Position danach mit \"/ip firewall filter print\" kontrollieren, notfalls mit \"move\" verschieben.",
fields: [
RouterOSFieldSchema(key: "chain", label: "Chain", kind: .text,
help: "input = an den Router, forward = durch den Router, output = vom Router aus. Eigene Chains (Sprungziele über \"jump\") sind ebenfalls möglich.",
defaultValue: "forward", required: true),
RouterOSFieldSchema(key: "action", label: "Aktion", kind: .enumPick(options: [
"accept", "drop", "reject", "log", "jump", "return",
"add-src-to-address-list", "add-dst-to-address-list", "fasttrack-connection", "passthrough"
]), help: "Was mit passenden Paketen geschieht. reject schickt zusätzlich eine Fehlermeldung zurück, drop verwirft stillschweigend.",
defaultValue: "accept", required: true),
RouterOSFieldSchema(key: "src-address", label: "Quell-Adresse", kind: .text,
help: "Einzel-IP oder Netz (z.B. 192.168.88.0/24). Leer = beliebig."),
RouterOSFieldSchema(key: "dst-address", label: "Ziel-Adresse", kind: .text,
help: "Einzel-IP oder Netz. Leer = beliebig."),
RouterOSFieldSchema(key: "src-address-list", label: "Quelle in Adress-Liste",
kind: .menuItemPick(menuPath: "/ip firewall address-list", restPath: "ip/firewall/address-list", valueField: "list"),
help: "Nur Pakete, deren Absender in dieser zuvor angelegten Adress-Liste steht."),
RouterOSFieldSchema(key: "dst-address-list", label: "Ziel in Adress-Liste",
kind: .menuItemPick(menuPath: "/ip firewall address-list", restPath: "ip/firewall/address-list", valueField: "list"),
help: "Nur Pakete, deren Ziel in dieser zuvor angelegten Adress-Liste steht."),
RouterOSFieldSchema(key: "in-interface", label: "Eingangs-Interface", kind: .interfacePick,
help: "Nur Pakete, die über dieses Interface hereinkommen."),
RouterOSFieldSchema(key: "out-interface", label: "Ausgangs-Interface", kind: .interfacePick,
help: "Nur Pakete, die über dieses Interface hinausgehen."),
RouterOSFieldSchema(key: "protocol", label: "Protokoll", kind: .enumPick(options: [
"tcp", "udp", "icmp", "gre", "ipsec-esp", "ipsec-ah"
]), help: "IP-Protokoll. Leer = alle."),
RouterOSFieldSchema(key: "dst-port", label: "Ziel-Port(s)", kind: .text,
help: "Nur bei tcp/udp sinnvoll. Einzelner Port (80) oder Bereich (8000-8100)."),
RouterOSFieldSchema(key: "connection-state", label: "Verbindungsstatus", kind: .enumPick(options: [
"new", "established", "related", "invalid", "untracked"
]), help: "Status laut Connection-Tracking. \"established,related\" ist die übliche \"schon erlaubte Verbindung\"-Regel."),
RouterOSFieldSchema(key: "layer7-protocol", label: "Layer7-Protokoll", kind: .text,
help: "Name eines vorher unter \"/ip firewall layer7-protocol\" angelegten Musters (z.B. Erkennung bestimmter Apps). Kostet spürbar CPU, mit Bedacht einsetzen."),
RouterOSFieldSchema(key: "comment", label: "Kommentar", kind: .text,
help: "Nur zur eigenen Wiedererkennung, ohne technische Wirkung."),
RouterOSFieldSchema(key: "disabled", label: "Deaktiviert", kind: .bool,
help: "Regel inaktiv schalten, ohne sie zu löschen.", defaultValue: "no")
],
listColumns: ["chain", "action", "comment"]
),
RouterOSMenuSchema(
menuPath: "/ip firewall nat",
restPath: "ip/firewall/nat",
category: .firewallNat,
displayName: "NAT-Regeln",
summary: "Übersetzt Adressen — Internetfreigabe (srcnat/masquerade) und Portweiterleitung (dstnat).",
explanation: "srcnat ändert die Absenderadresse ausgehender Pakete (z.B. private LAN-IP → öffentliche WAN-IP). dstnat ändert die Zieladresse eingehender Pakete (z.B. Anfrage an die WAN-IP auf Port 80 → internen Server auf 192.168.88.10:80 umleiten).",
warning: "Eine dstnat-Regel (Portweiterleitung) macht ein internes Gerät direkt aus dem Internet erreichbar — nur für Dienste einrichten, die das wirklich sein sollen, und danach den entsprechenden Port in der Filter-Tabelle nicht blockieren.",
fields: [
RouterOSFieldSchema(key: "chain", label: "Chain", kind: .enumPick(options: ["srcnat", "dstnat"]),
help: "srcnat = Absenderadresse ändern (Internetfreigabe). dstnat = Zieladresse ändern (Portweiterleitung).",
defaultValue: "srcnat", required: true),
RouterOSFieldSchema(key: "action", label: "Aktion", kind: .enumPick(options: [
"masquerade", "src-nat", "dst-nat", "netmap", "redirect", "same"
]), help: "masquerade = automatisches NAT über die aktuelle WAN-IP (empfohlen bei wechselnder IP). src-nat = feste NAT-Adresse. dst-nat = Ziel umschreiben (Portweiterleitung). redirect = auf den Router selbst umlenken.",
defaultValue: "masquerade", required: true),
RouterOSFieldSchema(key: "src-address", label: "Quell-Adresse", kind: .text, help: "Leer = beliebig."),
RouterOSFieldSchema(key: "dst-address", label: "Ziel-Adresse", kind: .text, help: "Leer = beliebig."),
RouterOSFieldSchema(key: "in-interface", label: "Eingangs-Interface", kind: .interfacePick,
help: "Bei dstnat meist der WAN-Port (woher die Anfrage aus dem Internet kommt)."),
RouterOSFieldSchema(key: "out-interface", label: "Ausgangs-Interface", kind: .interfacePick,
help: "Bei srcnat/masquerade meist der WAN-Port."),
RouterOSFieldSchema(key: "protocol", label: "Protokoll", kind: .enumPick(options: ["tcp", "udp", "icmp"]),
help: "Nötig, damit Ports geprüft werden können."),
RouterOSFieldSchema(key: "dst-port", label: "Ziel-Port der Anfrage", kind: .text,
help: "Der Port, auf den die Anfrage von außen ankommt (z.B. 8080)."),
RouterOSFieldSchema(key: "to-addresses", label: "Weiterleiten an (interne IP)", kind: .text,
help: "Nur bei dst-nat: die interne IP-Adresse, an die weitergeleitet wird."),
RouterOSFieldSchema(key: "to-ports", label: "Weiterleiten an (interner Port)", kind: .text,
help: "Nur bei dst-nat: interner Port, falls abweichend vom Ziel-Port (z.B. extern 8080 → intern 80)."),
RouterOSFieldSchema(key: "comment", label: "Kommentar", kind: .text, help: "Nur zur Wiedererkennung."),
RouterOSFieldSchema(key: "disabled", label: "Deaktiviert", kind: .bool,
help: "Regel inaktiv schalten, ohne sie zu löschen.", defaultValue: "no")
],
listColumns: ["chain", "action", "comment"]
),
RouterOSMenuSchema(
menuPath: "/ip firewall mangle",
restPath: "ip/firewall/mangle",
category: .firewallMangle,
displayName: "Mangle-Regeln",
summary: "Markiert Verbindungen/Pakete zur späteren Weiterverarbeitung (z.B. durch Queues).",
explanation: "Mangle selbst verändert nicht, wie ein Paket behandelt wird — es klebt nur eine Markierung darauf. Erst eine andere Regel (typischerweise eine Queue oder eine Routing-Regel), die genau diese Markierung als Bedingung abfragt, wird dadurch wirksam.",
warning: "Eine Markierung ohne etwas, das sie auswertet (z.B. keine passende Queue), hat keinerlei sichtbaren Effekt — das ist der häufigste Verwirrungspunkt bei Mangle.",
fields: [
RouterOSFieldSchema(key: "chain", label: "Chain", kind: .enumPick(options: [
"prerouting", "input", "forward", "output", "postrouting"
]), help: "Verarbeitungspunkt im Router-internen Paketfluss.", defaultValue: "forward", required: true),
RouterOSFieldSchema(key: "action", label: "Aktion", kind: .enumPick(options: [
"mark-connection", "mark-packet", "mark-routing", "change-mss", "change-ttl", "set-priority", "accept", "passthrough"
]), help: "mark-connection markiert die ganze Verbindung (wirkt auf alle ihre Pakete), mark-packet nur einzelne Pakete.",
defaultValue: "mark-connection", required: true),
RouterOSFieldSchema(key: "new-connection-mark", label: "Name der Verbindungsmarkierung", kind: .text,
help: "Frei wählbarer Name, den z.B. eine Queue später wiedererkennt."),
RouterOSFieldSchema(key: "new-packet-mark", label: "Name der Paketmarkierung", kind: .text,
help: "Frei wählbarer Name für die Paketmarkierung."),
RouterOSFieldSchema(key: "passthrough", label: "Weitere Mangle-Regeln noch prüfen", kind: .bool,
help: "Ja (Standard) lässt nachfolgende Mangle-Regeln diese Verbindung noch zusätzlich prüfen.", defaultValue: "yes"),
RouterOSFieldSchema(key: "src-address", label: "Quell-Adresse", kind: .text, help: "Leer = beliebig."),
RouterOSFieldSchema(key: "dst-address", label: "Ziel-Adresse", kind: .text, help: "Leer = beliebig."),
RouterOSFieldSchema(key: "in-interface", label: "Eingangs-Interface", kind: .interfacePick, help: ""),
RouterOSFieldSchema(key: "out-interface", label: "Ausgangs-Interface", kind: .interfacePick, help: ""),
RouterOSFieldSchema(key: "protocol", label: "Protokoll", kind: .enumPick(options: ["tcp", "udp", "icmp"]), help: ""),
RouterOSFieldSchema(key: "dst-port", label: "Ziel-Port(s)", kind: .text, help: "Nur bei tcp/udp."),
RouterOSFieldSchema(key: "comment", label: "Kommentar", kind: .text, help: "Nur zur Wiedererkennung."),
RouterOSFieldSchema(key: "disabled", label: "Deaktiviert", kind: .bool, help: "", defaultValue: "no")
],
listColumns: ["chain", "action", "comment"]
),
RouterOSMenuSchema(
menuPath: "/ip firewall raw",
restPath: "ip/firewall/raw",
category: .firewallRaw,
displayName: "Raw-Regeln",
summary: "Läuft vor jeder Connection-Tracking-Verarbeitung — meist zur Entlastung oder für DDoS-Grobfilter.",
explanation: "Regeln hier greifen, bevor RouterOS eine Verbindung überhaupt \"kennt\" (Connection-Tracking). Damit lässt sich z.B. bekannt uninteressanter Verkehr sehr günstig verwerfen, oder gezielt vom Tracking ausnehmen (notrack).",
warning: "notrack nimmt den betroffenen Verkehr aus dem Connection-Tracking heraus — danach greifen dafür keine Filter-/NAT-Regeln mehr, die sich auf connection-state oder NAT verlassen. Nur gezielt einsetzen.",
fields: [
RouterOSFieldSchema(key: "chain", label: "Chain", kind: .enumPick(options: ["prerouting", "output"]),
help: "", defaultValue: "prerouting", required: true),
RouterOSFieldSchema(key: "action", label: "Aktion", kind: .enumPick(options: ["accept", "drop", "notrack"]),
help: "notrack = von der Connection-Tracking-Verfolgung ausnehmen (siehe Warnhinweis oben).",
defaultValue: "accept", required: true),
RouterOSFieldSchema(key: "src-address", label: "Quell-Adresse", kind: .text, help: ""),
RouterOSFieldSchema(key: "dst-address", label: "Ziel-Adresse", kind: .text, help: ""),
RouterOSFieldSchema(key: "in-interface", label: "Eingangs-Interface", kind: .interfacePick, help: ""),
RouterOSFieldSchema(key: "protocol", label: "Protokoll", kind: .enumPick(options: ["tcp", "udp", "icmp"]), help: ""),
RouterOSFieldSchema(key: "dst-port", label: "Ziel-Port(s)", kind: .text, help: ""),
RouterOSFieldSchema(key: "comment", label: "Kommentar", kind: .text, help: ""),
RouterOSFieldSchema(key: "disabled", label: "Deaktiviert", kind: .bool, help: "", defaultValue: "no")
],
listColumns: ["chain", "action", "comment"]
),
RouterOSMenuSchema(
menuPath: "/ip firewall address-list",
restPath: "ip/firewall/address-list",
category: .firewallAddressLists,
displayName: "Adress-Listen",
summary: "Benannte Gruppen von IP-Adressen/Netzen, die Filter/NAT/Mangle-Regeln als Bedingung referenzieren können.",
explanation: "Statt in jeder Regel einzelne IPs aufzuzählen, legst du hier eine benannte Liste an (z.B. \"gesperrt\" oder \"vertraut\") und verweist in Filter-/NAT-/Mangle-Regeln per \"src-address-list\"/\"dst-address-list\" darauf. Einträge können dauerhaft oder mit Ablaufzeit (timeout) sein.",
fields: [
RouterOSFieldSchema(key: "list", label: "Listen-Name", kind: .text,
help: "Frei wählbarer Name, wird in anderen Regeln referenziert.", required: true),
RouterOSFieldSchema(key: "address", label: "Adresse", kind: .text,
help: "Einzel-IP oder Netz, z.B. 192.168.88.5 oder 10.0.0.0/24.", required: true),
RouterOSFieldSchema(key: "timeout", label: "Ablaufzeit", kind: .duration,
help: "Optional. Nach dieser Zeit wird der Eintrag automatisch entfernt. Alles auf 0 lassen = bleibt dauerhaft, bis manuell entfernt."),
RouterOSFieldSchema(key: "comment", label: "Kommentar", kind: .text, help: "")
],
listColumns: ["list", "address"]
)
]
// MARK: - Interfaces (generic, browsing + free-form fields)
private static let interfaceFamily: [RouterOSMenuSchema] = [
RouterOSMenuSchema(
menuPath: "/interface bridge", restPath: "interface/bridge", category: .interfaces,
displayName: "Bridge",
summary: "Fasst mehrere physische Ports zu einem gemeinsamen Layer-2-Netzwerk zusammen.",
explanation: "Geräte an gebrückten Ports verhalten sich, als hingen sie am selben Netzwerk-Kabel. Eine IP-Adresse wird meist auf die Bridge selbst gelegt, nicht auf die einzelnen Ports.",
fields: [
RouterOSFieldSchema(key: "name", label: "Name", kind: .text, help: "Frei wählbar, z.B. bridge-lan.", required: true),
RouterOSFieldSchema(key: "vlan-filtering", label: "VLAN-Filterung (802.1Q)", kind: .bool,
help: "Aktiviert echte VLAN-Trennung über diese Bridge — nötig, wenn mehrere VLANs über dieselben Bridge-Ports laufen sollen.", defaultValue: "no"),
RouterOSFieldSchema(key: "disabled", label: "Deaktiviert", kind: .bool, help: "", defaultValue: "no")
],
listColumns: ["name"]
),
RouterOSMenuSchema(
menuPath: "/interface bridge port", restPath: "interface/bridge/port", category: .interfaces,
displayName: "Bridge-Ports",
summary: "Ordnet einen physischen Port einer Bridge zu.",
explanation: "Erst danach ist der Port Teil des Bridge-Netzwerks.",
fields: [
RouterOSFieldSchema(key: "bridge", label: "Bridge", kind: .text, help: "Name der Bridge, z.B. bridge-lan.", required: true),
RouterOSFieldSchema(key: "interface", label: "Physischer Port", kind: .interfacePick,
help: "Der Port, der der Bridge hinzugefügt wird.", required: true),
RouterOSFieldSchema(key: "pvid", label: "Port-VLAN-ID (PVID)", kind: .int,
help: "Nur mit aktivierter VLAN-Filterung relevant: VLAN, dem untagged ankommender Verkehr an diesem Port zugeordnet wird.")
],
listColumns: ["bridge", "interface"]
),
RouterOSMenuSchema(
menuPath: "/interface vlan", restPath: "interface/vlan", category: .interfaces,
displayName: "VLAN-Interfaces",
summary: "Virtuelle, getaggte Sub-Interfaces auf einem physischen Port oder einer Bridge.",
explanation: "Ein eigenes logisches Netzwerk auf demselben Kabel, unterschieden durch eine VLAN-Kennung im Ethernet-Rahmen.",
fields: [
RouterOSFieldSchema(key: "name", label: "Name", kind: .text, help: "Frei wählbar, z.B. vlan20-gaeste.", required: true),
RouterOSFieldSchema(key: "vlan-id", label: "VLAN-ID", kind: .int, help: "Eindeutige Kennung, 24094.", required: true),
RouterOSFieldSchema(key: "interface", label: "Basis-Interface", kind: .interfacePick,
help: "Physischer Port oder Bridge, auf dem dieses VLAN aufsetzt.", required: true),
RouterOSFieldSchema(key: "disabled", label: "Deaktiviert", kind: .bool, help: "", defaultValue: "no")
],
listColumns: ["name", "vlan-id", "interface"]
),
RouterOSMenuSchema(
menuPath: "/interface wireguard", restPath: "interface/wireguard", category: .interfaces,
displayName: "WireGuard-Interfaces",
summary: "Moderner, schlanker VPN-Tunnel-Typ.",
explanation: "Ein WireGuard-Interface allein stellt noch keine Verbindung her — dazu gehören Gegenstellen (siehe \"WireGuard-Peers\") mit öffentlichem Schlüssel und erlaubten Adressen.",
warning: "Erst hier das Interface anlegen (mit Listen-Port), danach unter WireGuard-Peers die Gegenstellen eintragen. Der private Schlüssel wird beim Anlegen automatisch erzeugt, falls nicht angegeben.",
fields: [
RouterOSFieldSchema(key: "name", label: "Name", kind: .text, help: "Frei wählbar, z.B. wg-heim.", required: true),
RouterOSFieldSchema(key: "listen-port", label: "Listen-Port (UDP)", kind: .int,
help: "Port, auf dem dieser Tunnel auf eingehende Verbindungen lauscht, z.B. 51820.", defaultValue: "51820"),
RouterOSFieldSchema(key: "private-key", label: "Privater Schlüssel", kind: .text,
help: "Geheim halten. Leer lassen, damit RouterOS automatisch einen erzeugt."),
RouterOSFieldSchema(key: "disabled", label: "Deaktiviert", kind: .bool, help: "", defaultValue: "no")
],
listColumns: ["name", "listen-port"]
),
RouterOSMenuSchema(
menuPath: "/interface wireguard peers", restPath: "interface/wireguard/peers", category: .interfaces,
displayName: "WireGuard-Peers",
summary: "Gegenstellen (Clients/andere Router) eines WireGuard-Tunnels.",
explanation: "Jede Gegenstelle braucht ihren eigenen öffentlichen Schlüssel und eine Angabe, welche Adressen über sie geroutet werden.",
fields: [
RouterOSFieldSchema(key: "interface", label: "WireGuard-Interface",
kind: .menuItemPick(menuPath: "/interface wireguard", restPath: "interface/wireguard", valueField: "name"),
help: "Name des zuvor angelegten WireGuard-Interfaces.", required: true),
RouterOSFieldSchema(key: "public-key", label: "Öffentlicher Schlüssel der Gegenstelle", kind: .text, help: "", required: true),
RouterOSFieldSchema(key: "allowed-address", label: "Erlaubte Adressen", kind: .text,
help: "Welche IP-Adressen/Netze über diesen Peer laufen dürfen, z.B. 10.10.10.2/32."),
RouterOSFieldSchema(key: "endpoint-address", label: "Feste Adresse der Gegenstelle", kind: .text,
help: "Nur nötig, wenn diese Gegenstelle selbst erreichbar sein muss (Site-to-Site). Bei Roadwarrior-Clients, die sich selbst melden, leer lassen."),
RouterOSFieldSchema(key: "endpoint-port", label: "Port der Gegenstelle", kind: .text, help: ""),
RouterOSFieldSchema(key: "persistent-keepalive", label: "Keepalive (Sekunden)", kind: .text,
help: "Z.B. 25 — hält die Verbindung durch NAT/Firewalls am Leben. Wichtig bei Clients hinter NAT.")
],
listColumns: ["interface", "allowed-address"]
),
RouterOSMenuSchema(
menuPath: "/interface pppoe-client", restPath: "interface/pppoe-client", category: .interfaces,
displayName: "PPPoE-Client",
summary: "DSL-Einwahl-Client, meist auf dem WAN-Port.",
explanation: "Ersetzt eine feste/DHCP-WAN-Adresse durch eine PPPoE-Einwahl beim Provider.",
fields: [
RouterOSFieldSchema(key: "name", label: "Name", kind: .text, help: "Frei wählbar, z.B. pppoe-wan.", required: true),
RouterOSFieldSchema(key: "interface", label: "Physischer Port", kind: .interfacePick,
help: "Der Port, über den die Einwahl läuft, meist der WAN-Port.", required: true),
RouterOSFieldSchema(key: "user", label: "Benutzername", kind: .text, help: "Zugangsdaten des Providers.", required: true),
RouterOSFieldSchema(key: "password", label: "Passwort", kind: .text, help: "", required: true),
RouterOSFieldSchema(key: "disabled", label: "Deaktiviert", kind: .bool, help: "", defaultValue: "no")
],
listColumns: ["name", "interface"]
),
RouterOSMenuSchema(
menuPath: "/interface bonding", restPath: "interface/bonding", category: .interfaces,
displayName: "Bonding",
summary: "Bündelt mehrere physische Ports zu einer logischen, ausfalltoleranten/schnelleren Verbindung.",
explanation: "",
fields: [
RouterOSFieldSchema(key: "name", label: "Name", kind: .text, help: "Frei wählbar.", required: true),
RouterOSFieldSchema(key: "slaves", label: "Gebündelte Ports", kind: .text,
help: "Kommagetrennte Liste physischer Ports, z.B. ether2,ether3."),
RouterOSFieldSchema(key: "mode", label: "Modus", kind: .enumPick(options: [
"802.3ad", "active-backup", "balance-rr", "balance-xor", "broadcast"
]), help: "802.3ad (LACP) braucht einen kompatiblen, entsprechend konfigurierten Switch.")
],
listColumns: ["name", "mode"]
)
]
// MARK: - IP addressing & services
private static let ipFamily: [RouterOSMenuSchema] = [
RouterOSMenuSchema(
menuPath: "/ip address", restPath: "ip/address", category: .ipAddressing,
displayName: "IP-Adressen",
summary: "Weist Interfaces IP-Adressen zu.",
explanation: "Jede IP-Adresse hängt an genau einem Interface (physischer Port, Bridge oder VLAN).",
fields: [
RouterOSFieldSchema(key: "address", label: "Adresse", kind: .text,
help: "Mit Präfix, z.B. 192.168.88.1/24.", required: true),
RouterOSFieldSchema(key: "interface", label: "Interface", kind: .interfacePick, help: "", required: true),
RouterOSFieldSchema(key: "disabled", label: "Deaktiviert", kind: .bool, help: "", defaultValue: "no")
],
listColumns: ["address", "interface"]
),
RouterOSMenuSchema(
menuPath: "/ip pool", restPath: "ip/pool", category: .ipAddressing,
displayName: "Adress-Pools",
summary: "Adressbereiche, aus denen DHCP-Server oder PPP-Profile Adressen vergeben.",
explanation: "",
fields: [
RouterOSFieldSchema(key: "name", label: "Name", kind: .text, help: "Frei wählbar.", required: true),
RouterOSFieldSchema(key: "ranges", label: "Bereich(e)", kind: .text,
help: "Z.B. 192.168.88.10-192.168.88.254. Mehrere Bereiche kommagetrennt.", required: true)
],
listColumns: ["name", "ranges"]
),
RouterOSMenuSchema(
menuPath: "/ip dhcp-server", restPath: "ip/dhcp-server", category: .ipAddressing,
displayName: "DHCP-Server",
summary: "Vergibt automatisch IP-Adressen an Geräte in einem Netzwerk.",
explanation: "Für die üblichen Fälle deckt das bereits der Einrichten-Assistent (LAN-/VLAN-Schritt) ab — hier direkter Zugriff für Sonderfälle.",
fields: [
RouterOSFieldSchema(key: "name", label: "Name", kind: .text, help: "Frei wählbar.", required: true),
RouterOSFieldSchema(key: "interface", label: "Interface", kind: .interfacePick,
help: "Netzwerk, in dem dieser Server Adressen vergibt.", required: true),
RouterOSFieldSchema(key: "address-pool", label: "Adress-Pool",
kind: .menuItemPick(menuPath: "/ip pool", restPath: "ip/pool", valueField: "name"),
help: "Name eines zuvor angelegten Adress-Pools.", required: true),
RouterOSFieldSchema(key: "lease-time", label: "Lease-Zeit", kind: .duration,
help: "Wie lange ein Gerät seine Adresse behält, bevor sie erneuert werden muss."),
RouterOSFieldSchema(key: "disabled", label: "Deaktiviert", kind: .bool, help: "", defaultValue: "no")
],
listColumns: ["name", "interface"]
),
RouterOSMenuSchema(
menuPath: "/ip dhcp-server network", restPath: "ip/dhcp-server/network", category: .ipAddressing,
displayName: "DHCP-Netzwerke",
summary: "Gateway/DNS/Optionen, die ein DHCP-Server an seine Klienten verteilt.",
explanation: "Getrennt vom DHCP-Server selbst, weil dieselben Netzwerk-Optionen für mehrere DHCP-Server gelten können.",
fields: [
RouterOSFieldSchema(key: "address", label: "Netz", kind: .text,
help: "Für dieses Netz gelten die folgenden Optionen, z.B. 192.168.88.0/24.", required: true),
RouterOSFieldSchema(key: "gateway", label: "Gateway", kind: .text,
help: "In der Regel die Router-Adresse in diesem Netz.", required: true),
RouterOSFieldSchema(key: "dns-server", label: "DNS-Server", kind: .text, help: "Meist der Router selbst.")
],
listColumns: ["address", "gateway"]
),
RouterOSMenuSchema(
menuPath: "/ip dhcp-client", restPath: "ip/dhcp-client", category: .ipAddressing,
displayName: "DHCP-Client (WAN)",
summary: "Bezieht automatisch eine IP-Adresse vom Internetanbieter.",
explanation: "",
fields: [
RouterOSFieldSchema(key: "interface", label: "Interface", kind: .interfacePick,
help: "Meist der WAN-Port.", required: true),
RouterOSFieldSchema(key: "add-default-route", label: "Standardroute übernehmen", kind: .bool,
help: "Übernimmt die vom Provider mitgeteilte Standardroute ins Internet.", defaultValue: "yes"),
RouterOSFieldSchema(key: "use-peer-dns", label: "DNS-Server übernehmen", kind: .bool,
help: "Übernimmt die vom Provider mitgeteilten DNS-Server.", defaultValue: "yes"),
RouterOSFieldSchema(key: "disabled", label: "Deaktiviert", kind: .bool, help: "", defaultValue: "no")
],
listColumns: ["interface"]
),
RouterOSMenuSchema(
menuPath: "/ip dns", restPath: "ip/dns", category: .ipAddressing,
displayName: "DNS-Einstellungen",
summary: "Namensauflösung des Routers selbst (und optional als DNS-Server fürs LAN).",
explanation: "",
fields: [
RouterOSFieldSchema(key: "servers", label: "DNS-Server", kind: .text,
help: "Ein oder mehrere Server, kommagetrennt, z.B. 1.1.1.1,8.8.8.8."),
RouterOSFieldSchema(key: "allow-remote-requests", label: "Als DNS-Server fürs LAN erlauben", kind: .bool,
help: "Lässt Geräte im LAN den Router selbst als DNS-Server nutzen.", defaultValue: "no")
],
listColumns: ["servers"],
isSingleton: true
),
RouterOSMenuSchema(
menuPath: "/ip service", restPath: "ip/service", category: .ipAddressing,
displayName: "Verwaltungsdienste",
summary: "Schaltet Zugriffswege auf den Router (Winbox, API, SSH, WWW/REST, Telnet, FTP) an/aus und ändert deren Port.",
explanation: "Jeder aktive Dienst ist ein potenzieller Angriffspunkt aus dem jeweils erreichbaren Netz — nicht benötigte Dienste deaktivieren.",
warning: "Den gerade genutzten Zugriffsweg hier abzuschalten oder umzuportieren kann die eigene Verbindung sofort kappen — Vorsicht bei www-ssl (REST-API dieser App) und ssh.",
fields: [
RouterOSFieldSchema(key: "name", label: "Dienst", kind: .enumPick(options: [
"www", "www-ssl", "ssh", "api", "api-ssl", "winbox", "ftp", "telnet"
]), help: "Welcher Verwaltungsdienst geändert wird.", required: true),
RouterOSFieldSchema(key: "port", label: "Port", kind: .int, help: "Auf welchem Port der Dienst lauscht."),
RouterOSFieldSchema(key: "available-from", label: "Erlaubt von", kind: .text,
help: "Optional: nur von dieser Adresse/diesem Netz aus erreichbar, z.B. 192.168.88.0/24."),
RouterOSFieldSchema(key: "disabled", label: "Deaktiviert", kind: .bool, help: "", defaultValue: "no")
],
listColumns: ["name", "port"]
),
generic("/ip hotspot", "ip/hotspot", .ipAddressing, "Hotspot",
"Login-Portal für Gäste-WLAN/-LAN mit Umleitung auf eine Anmeldeseite.",
"Besteht aus mehreren zusammenhängenden Teilen (Server, Server-Profil, Benutzer-Profil, Benutzer) — hier generischer Zugriff auf \"/ip hotspot\" selbst, verwandte Teile über \"Eigener Menüpfad\" (z.B. \"/ip hotspot user\")."),
RouterOSMenuSchema(
menuPath: "/ip route", restPath: "ip/route", category: .routing,
displayName: "Statische Routen",
summary: "Feste, manuell eingetragene Wegewahl zu Netzen, die nicht direkt angeschlossen sind.",
explanation: "Für alles außer \"Standard-Internet über eine WAN-Schnittstelle\" (das übernimmt bereits die DHCP-Client-/PPPoE-Route automatisch).",
fields: [
RouterOSFieldSchema(key: "dst-address", label: "Zielnetz", kind: .text,
help: "Z.B. 10.0.0.0/24, oder 0.0.0.0/0 für eine Standardroute.", required: true),
RouterOSFieldSchema(key: "gateway", label: "Gateway", kind: .text,
help: "Nächster Hop — IP-Adresse oder Name eines Interfaces.", required: true),
RouterOSFieldSchema(key: "distance", label: "Distanz", kind: .int,
help: "Priorität bei mehreren passenden Routen zum selben Ziel — kleinere Zahl wird bevorzugt."),
RouterOSFieldSchema(key: "comment", label: "Kommentar", kind: .text, help: ""),
RouterOSFieldSchema(key: "disabled", label: "Deaktiviert", kind: .bool, help: "", defaultValue: "no")
],
listColumns: ["dst-address", "gateway"]
)
]
// MARK: - Routing (dynamic protocols)
private static let routingFamily: [RouterOSMenuSchema] = [
generic("/routing ospf instance", "routing/ospf/instance", .routing, "OSPF-Instanzen",
"Dynamisches Innennetz-Routing-Protokoll — tauscht Routen automatisch mit anderen OSPF-Routern aus.",
"Nur relevant, wenn mehrere Router im selben Netz eigenständig Routen lernen sollen.",
warning: "Fehlkonfiguriertes OSPF kann Routen zu bestehenden Netzen überschreiben. Nur mit Netzwerkplan einsetzen."),
generic("/routing bgp connection", "routing/bgp/connection", .routing, "BGP-Verbindungen",
"Routing-Protokoll für Verbindungen zwischen unabhängigen Netzen/Providern.",
"Für Privat-/Kleinnetz i.d.R. nicht nötig — relevant bei eigenem Provider-unabhängigem Adressraum (Multihoming).")
]
// MARK: - VPN
private static let vpnFamily: [RouterOSMenuSchema] = [
RouterOSMenuSchema(
menuPath: "/ppp secret", restPath: "ppp/secret", category: .vpn,
displayName: "PPP-Benutzer",
summary: "Zugangsdaten für PPPoE-/L2TP-/PPTP-/OpenVPN-Einwahl in den Router.",
explanation: "Jeder Benutzer kann optional einem Profil zugeordnet werden, das IP-Pool/DNS/Verschlüsselung vorgibt.",
fields: [
RouterOSFieldSchema(key: "name", label: "Benutzername", kind: .text, help: "", required: true),
RouterOSFieldSchema(key: "password", label: "Passwort", kind: .text, help: "", required: true),
RouterOSFieldSchema(key: "service", label: "Dienst", kind: .enumPick(options: [
"any", "pppoe", "l2tp", "pptp", "ovpn", "sstp"
]), help: "Für welche Einwahl-Art dieser Benutzer gilt.", defaultValue: "any"),
RouterOSFieldSchema(key: "profile", label: "Profil",
kind: .menuItemPick(menuPath: "/ppp profile", restPath: "ppp/profile", valueField: "name"),
help: "Name eines zuvor angelegten PPP-Profils."),
RouterOSFieldSchema(key: "local-address", label: "Lokale Adresse", kind: .text,
help: "IP-Adresse des Routers innerhalb dieser Verbindung."),
RouterOSFieldSchema(key: "remote-address", label: "Adresse für den Client", kind: .text,
help: "Feste IP für diesen Benutzer, oder Name eines Adress-Pools."),
RouterOSFieldSchema(key: "disabled", label: "Deaktiviert", kind: .bool, help: "", defaultValue: "no")
],
listColumns: ["name", "service"]
),
RouterOSMenuSchema(
menuPath: "/ppp profile", restPath: "ppp/profile", category: .vpn,
displayName: "PPP-Profile",
summary: "Vorlagen (IP-Pool, DNS, Verschlüsselung) für PPP-Benutzer.",
explanation: "",
fields: [
RouterOSFieldSchema(key: "name", label: "Name", kind: .text, help: "", required: true),
RouterOSFieldSchema(key: "local-address", label: "Lokale Adresse", kind: .text, help: "IP-Adresse des Routers."),
RouterOSFieldSchema(key: "remote-address", label: "Adress-Pool für Clients", kind: .text,
help: "Name eines zuvor angelegten Adress-Pools."),
RouterOSFieldSchema(key: "dns-server", label: "DNS-Server für Clients", kind: .text, help: "")
],
listColumns: ["name"]
),
generic("/interface l2tp-server server", "interface/l2tp-server/server", .vpn, "L2TP-VPN-Server",
"Nimmt eingehende L2TP-VPN-Einwahlen entgegen.",
"Ein einzelnes Server-weites An/Aus mit gemeinsamen Verschlüsselungs-Einstellungen — Benutzer selbst kommen von \"PPP-Benutzer\"."),
generic("/interface ovpn-server server", "interface/ovpn-server/server", .vpn, "OpenVPN-Server",
"Nimmt eingehende OpenVPN-Einwahlen entgegen.",
"Braucht zusätzlich ein Zertifikat (\"/certificate\") — Benutzer selbst kommen von \"PPP-Benutzer\".")
]
// MARK: - Wireless
private static let wirelessFamily: [RouterOSMenuSchema] = [
RouterOSMenuSchema(
menuPath: "/interface wireless", restPath: "interface/wireless", category: .wireless,
displayName: "WLAN (Legacy-Treiber)",
summary: "WLAN-Interfaces auf älteren/Standard-Wireless-Chips.",
explanation: "Diese Interfaces existieren bereits ab Werk (ein WLAN-Chip = ein Interface) — hier werden sie nur konfiguriert, nicht neu angelegt. Ein Sicherheitsprofil (siehe \"WLAN-Sicherheitsprofile\") muss vorher angelegt sein.",
fields: [
RouterOSFieldSchema(key: "ssid", label: "Netzwerkname (SSID)", kind: .text, help: "", required: true),
RouterOSFieldSchema(key: "security-profile", label: "Sicherheitsprofil", kind: .text,
help: "Name eines zuvor unter \"WLAN-Sicherheitsprofile\" angelegten Profils."),
RouterOSFieldSchema(key: "mode", label: "Modus", kind: .enumPick(options: ["ap-bridge", "station", "bridge"]),
help: "ap-bridge = Access Point (Normalfall), station = als Client mit einem anderen AP verbinden.", defaultValue: "ap-bridge"),
RouterOSFieldSchema(key: "disabled", label: "Deaktiviert", kind: .bool, help: "", defaultValue: "no")
],
listColumns: ["ssid", "mode"]
),
RouterOSMenuSchema(
menuPath: "/interface wireless security-profiles", restPath: "interface/wireless/security-profiles", category: .wireless,
displayName: "WLAN-Sicherheitsprofile",
summary: "Verschlüsselung/Passwort-Vorlagen für WLAN-Interfaces (Legacy-Treiber).",
explanation: "Ein Profil wird angelegt und dann bei einem WLAN-Interface als \"security-profile\" eingetragen.",
fields: [
RouterOSFieldSchema(key: "name", label: "Name", kind: .text, help: "", required: true),
RouterOSFieldSchema(key: "mode", label: "Modus", kind: .enumPick(options: ["none", "static-keys-required", "dynamic-keys"]),
help: "dynamic-keys ist der übliche WPA/WPA2-Modus.", defaultValue: "dynamic-keys"),
RouterOSFieldSchema(key: "authentication-types", label: "Authentifizierung", kind: .enumPick(options: [
"wpa-psk", "wpa2-psk", "wpa-psk,wpa2-psk", "wpa-eap", "wpa2-eap"
]), help: "wpa2-psk = WPA2 mit gemeinsamem Passwort (Heimnetz-Standard)."),
RouterOSFieldSchema(key: "wpa2-pre-shared-key", label: "WPA2-Passwort", kind: .text, help: "Mindestens 8 Zeichen.")
],
listColumns: ["name", "authentication-types"]
),
RouterOSMenuSchema(
menuPath: "/interface wifi", restPath: "interface/wifi", category: .wireless,
displayName: "WLAN (neuer wifiwave2/802.11ax-Treiber)",
summary: "WLAN-Interfaces auf neueren Wireless-Chips.",
explanation: "Anderes, verschachteltes Konfigurationsschema als der Legacy-Treiber (Punkt-Notation wie \"security.passphrase\") — nicht mit \"/interface wireless\" mischen. Nur auf Geräten mit entsprechend neuem WLAN-Chip vorhanden.",
warning: "Diese App hat den .set-Pfad für den neuen Treiber noch nie gegen echte Hardware getestet (siehe HANDOFF.md) — nach dem Anwenden unbedingt prüfen.",
fields: [
RouterOSFieldSchema(key: "ssid", label: "Netzwerkname (SSID)", kind: .text, help: "", required: true),
RouterOSFieldSchema(key: "security.authentication-types", label: "Authentifizierung", kind: .enumPick(options: [
"wpa2-psk", "wpa3-psk", "wpa2-psk,wpa3-psk"
]), help: "wpa2-psk,wpa3-psk deckt sowohl ältere als auch neuere Geräte ab."),
RouterOSFieldSchema(key: "security.passphrase", label: "Passwort", kind: .text, help: "Mindestens 8 Zeichen."),
RouterOSFieldSchema(key: "datapath.bridge", label: "Ziel-Bridge", kind: .text,
help: "Name der Bridge, der dieses WLAN-Netz zugeordnet wird (üblicherweise dieselbe wie das kabelgebundene LAN)."),
RouterOSFieldSchema(key: "disabled", label: "Deaktiviert", kind: .bool, help: "", defaultValue: "no")
],
listColumns: ["ssid"]
),
generic("/caps-man manager", "caps-man/manager", .wireless, "CAPsMAN-Zentrale",
"Zentrale Verwaltung mehrerer WLAN-Access-Points von einem Router aus.",
"Nur relevant mit mehreren WLAN-Access-Points, die zentral verwaltet werden sollen — eigenes, umfangreiches Konfigurationsschema (Konfigurationen/Kanäle/Datapaths).")
]
// MARK: - Queues / QoS
private static let queueFamily: [RouterOSMenuSchema] = [
RouterOSMenuSchema(
menuPath: "/queue simple", restPath: "queue/simple", category: .queues,
displayName: "Einfache Bandbreiten-Begrenzung",
summary: "Begrenzt Up-/Download einer IP-Adresse oder eines Netzes.",
explanation: "Reicht für die meisten Heim-/Kleinnetz-Fälle ohne Mangle-Markierungen aus.",
fields: [
RouterOSFieldSchema(key: "name", label: "Name", kind: .text, help: "Frei wählbar.", required: true),
RouterOSFieldSchema(key: "target", label: "Ziel", kind: .text,
help: "IP-Adresse oder Netz, das begrenzt wird, z.B. 192.168.88.50/32.", required: true),
RouterOSFieldSchema(key: "max-limit", label: "Max. Bandbreite (Upload/Download)", kind: .text,
help: "Zwei Werte getrennt durch \"/\", z.B. 10M/50M (Upload/Download).", required: true),
RouterOSFieldSchema(key: "burst-limit", label: "Burst-Bandbreite", kind: .text,
help: "Optional: kurzzeitig erlaubte höhere Bandbreite, z.B. 15M/60M."),
RouterOSFieldSchema(key: "disabled", label: "Deaktiviert", kind: .bool, help: "", defaultValue: "no")
],
listColumns: ["name", "target", "max-limit"]
),
RouterOSMenuSchema(
menuPath: "/queue tree", restPath: "queue/tree", category: .queues,
displayName: "Queue-Baum",
summary: "Erweiterte, hierarchische Bandbreiten-Steuerung basierend auf Mangle-Markierungen.",
explanation: "Statt einer festen Adresse wirkt ein Queue-Baum-Eintrag auf Verkehr mit einer bestimmten Mangle-Markierung.",
warning: "Setzt eine passende Mangle-Regel voraus, die genau diese Markierung setzt (siehe Firewall: Mangle) — ohne die greift ein Queue-Baum-Eintrag ins Leere.",
fields: [
RouterOSFieldSchema(key: "name", label: "Name", kind: .text, help: "Frei wählbar.", required: true),
RouterOSFieldSchema(key: "parent", label: "Übergeordnet", kind: .text,
help: "Ein Interface (z.B. der WAN-Port) oder der Name eines anderen Queue-Baum-Eintrags.", required: true),
RouterOSFieldSchema(key: "packet-mark", label: "Paket-/Verbindungsmarkierung", kind: .text,
help: "Name der Mangle-Markierung, auf die dieser Eintrag wirkt."),
RouterOSFieldSchema(key: "max-limit", label: "Max. Bandbreite", kind: .text, help: "Einzelwert, z.B. 20M."),
RouterOSFieldSchema(key: "disabled", label: "Deaktiviert", kind: .bool, help: "", defaultValue: "no")
],
listColumns: ["name", "parent", "max-limit"]
)
]
// MARK: - System
private static let systemFamily: [RouterOSMenuSchema] = [
RouterOSMenuSchema(
menuPath: "/system identity", restPath: "system/identity", category: .system,
displayName: "Router-Name",
summary: "Der Name, unter dem sich der Router meldet (z.B. in Winbox/Terminal-Prompt).",
explanation: "",
fields: [RouterOSFieldSchema(key: "name", label: "Name", kind: .text, help: "", required: true)],
listColumns: ["name"],
isSingleton: true
),
RouterOSMenuSchema(
menuPath: "/system clock", restPath: "system/clock", category: .system,
displayName: "Uhrzeit/Zeitzone", summary: "", explanation: "",
fields: [
RouterOSFieldSchema(key: "time-zone-name", label: "Zeitzone", kind: .text,
help: "Z.B. Europe/Berlin.")
],
listColumns: ["time-zone-name"],
isSingleton: true
),
RouterOSMenuSchema(
menuPath: "/system ntp client", restPath: "system/ntp/client", category: .system,
displayName: "Zeitserver (NTP)",
summary: "Hält die Router-Uhr über einen Zeitserver synchron.",
explanation: "",
warning: "Falsche Systemzeit kann Zertifikatsprüfungen (HTTPS/REST) und Log-Zeitstempel durcheinanderbringen.",
fields: [
RouterOSFieldSchema(key: "enabled", label: "Aktiviert", kind: .bool, help: "", defaultValue: "yes"),
RouterOSFieldSchema(key: "servers", label: "Zeitserver", kind: .text,
help: "Ein oder mehrere Server, kommagetrennt, z.B. pool.ntp.org.")
],
listColumns: ["servers"],
isSingleton: true
),
generic("/system logging", "system/logging", .system, "Protokollierung",
"Was der Router mitschreibt und wohin (Speicher, Datei, Remote-Syslog, E-Mail).",
"Besteht aus \"rules\" (was protokolliert wird) und \"actions\" (wohin) — hier generischer Zugriff, verwandte Teile über \"Eigener Menüpfad\" (z.B. \"/system logging action\")."),
RouterOSMenuSchema(
menuPath: "/system scheduler", restPath: "system/scheduler", category: .system,
displayName: "Zeitplaner",
summary: "Führt ein hinterlegtes Skript zu festen Zeiten/Intervallen aus.",
explanation: "",
warning: "Ein fehlerhaftes geplantes Skript kann unbeaufsichtigt wiederholt Änderungen vornehmen — vor dem Einplanen einmal manuell testen.",
fields: [
RouterOSFieldSchema(key: "name", label: "Name", kind: .text, help: "", required: true),
RouterOSFieldSchema(key: "start-time", label: "Startzeit", kind: .text, help: "Z.B. 00:00:00 oder \"startup\"."),
RouterOSFieldSchema(key: "interval", label: "Intervall", kind: .text,
help: "Wie oft wiederholt wird, z.B. 1d oder 01:00:00. Leer = nur einmal zur Startzeit."),
RouterOSFieldSchema(key: "on-event", label: "Auszuführendes Skript",
kind: .menuItemPick(menuPath: "/system script", restPath: "system/script", valueField: "name"),
help: "Name eines zuvor unter \"Skripte\" angelegten Skripts."),
RouterOSFieldSchema(key: "disabled", label: "Deaktiviert", kind: .bool, help: "", defaultValue: "no")
],
listColumns: ["name", "interval"]
),
RouterOSMenuSchema(
menuPath: "/system script", restPath: "system/script", category: .system,
displayName: "Skripte",
summary: "Gespeicherte RouterOS-Befehlsfolgen, die manuell oder per Scheduler ausgeführt werden.",
explanation: "",
warning: "Skripte laufen mit den Rechten des Routers selbst — kein Unterschied zu manuell eingegebenen Befehlen.",
fields: [
RouterOSFieldSchema(key: "name", label: "Name", kind: .text, help: "", required: true),
RouterOSFieldSchema(key: "source", label: "Skript-Inhalt", kind: .text,
help: "RouterOS-Befehle, z.B. \":log info \\\"Test\\\"\".")
],
listColumns: ["name"]
),
RouterOSMenuSchema(
menuPath: "/user", restPath: "user", category: .system,
displayName: "Benutzerkonten",
summary: "Zugangskonten für den Router (Winbox/SSH/REST/Terminal).",
explanation: "",
warning: "Das zum Verbinden genutzte eigene Konto hier nicht versehentlich löschen oder herabstufen.",
fields: [
RouterOSFieldSchema(key: "name", label: "Benutzername", kind: .text, help: "", required: true),
RouterOSFieldSchema(key: "password", label: "Passwort", kind: .text, help: ""),
RouterOSFieldSchema(key: "group", label: "Rechte-Gruppe", kind: .enumPick(options: ["full", "write", "read"]),
help: "full = Vollzugriff, write = ohne Benutzerverwaltung, read = nur lesen.", defaultValue: "full"),
RouterOSFieldSchema(key: "disabled", label: "Deaktiviert", kind: .bool, help: "", defaultValue: "no")
],
listColumns: ["name", "group"]
)
]
// MARK: - Tools
private static let toolFamily: [RouterOSMenuSchema] = [
RouterOSMenuSchema(
menuPath: "/tool netwatch", restPath: "tool/netwatch", category: .tools,
displayName: "Netwatch",
summary: "Überwacht laufend, ob eine Adresse erreichbar ist, und kann bei Statuswechsel ein Skript auslösen.",
explanation: "",
fields: [
RouterOSFieldSchema(key: "host", label: "Zu überwachende Adresse", kind: .text, help: "", required: true),
RouterOSFieldSchema(key: "interval", label: "Prüf-Intervall", kind: .text, help: "Z.B. 00:00:10.", defaultValue: "00:00:10"),
RouterOSFieldSchema(key: "up-script", label: "Skript bei Erreichbarkeit", kind: .text,
help: "Name eines Skripts unter \"Skripte\"."),
RouterOSFieldSchema(key: "down-script", label: "Skript bei Nichterreichbarkeit", kind: .text, help: ""),
RouterOSFieldSchema(key: "disabled", label: "Deaktiviert", kind: .bool, help: "", defaultValue: "no")
],
listColumns: ["host"]
),
RouterOSMenuSchema(
menuPath: "/tool e-mail", restPath: "tool/e-mail", category: .tools,
displayName: "E-Mail-Versand",
summary: "Postausgangs-Server, den Scheduler/Netwatch/Skripte für Benachrichtigungen nutzen.",
explanation: "Ein einzelner, geräteweiter Satz Einstellungen — kein Menü mit mehreren Einträgen.",
fields: [
RouterOSFieldSchema(key: "address", label: "Mailserver-Adresse", kind: .text, help: "", required: true),
RouterOSFieldSchema(key: "port", label: "Port", kind: .int, help: "Meist 587 (STARTTLS) oder 465 (SSL)."),
RouterOSFieldSchema(key: "from", label: "Absenderadresse", kind: .text, help: ""),
RouterOSFieldSchema(key: "user", label: "Benutzername", kind: .text, help: ""),
RouterOSFieldSchema(key: "password", label: "Passwort", kind: .text, help: "")
],
listColumns: ["address"],
isSingleton: true
)
]
private static func generic(
_ menuPath: String,
_ restPath: String,
_ category: RouterOSMenuCategory,
_ displayName: String,
_ summary: String,
_ explanation: String = "",
warning: String? = nil
) -> RouterOSMenuSchema {
RouterOSMenuSchema(
menuPath: menuPath,
restPath: restPath,
category: category,
displayName: displayName,
summary: summary,
explanation: explanation.isEmpty ? summary : explanation,
warning: warning,
fields: []
)
}
}
@@ -66,13 +66,48 @@ final class RestTransport: NSObject, RouterOSTransport {
/// `POST /rest/<restPath>`. `.set` has no CLI-style inline lookup on REST, so it first
/// `GET`s the collection to find the item whose `matchField` equals `matchValue`, reads
/// its RouterOS-internal `.id`, then `PATCH`es `restPath/<id>`.
func fetchMenuItems(menuPath: String, restPath: String) async throws -> [RouterOSMenuItem] {
let data = try await send(path: restPath, method: "GET", jsonBody: nil)
if let array = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] {
return array.map { Self.menuItem(from: $0) }
}
// Singleton menu (e.g. "/ip dns") REST returns one JSON object, not an array.
if let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
return [Self.menuItem(from: object)]
}
throw RouterOSError.invalidResponse(restPath)
}
private static func menuItem(from item: [String: Any]) -> RouterOSMenuItem {
var fields: [String: String] = [:]
var id = ""
for (key, value) in item {
let stringValue = (value as? String) ?? String(describing: value)
if key == ".id" {
id = stringValue
} else {
fields[key] = stringValue
}
}
return RouterOSMenuItem(id: id.isEmpty ? "singleton" : id, fields: fields)
}
func apply(_ command: RouterOSCommand) async throws {
switch command.operation {
case .add:
_ = try await send(path: command.restPath, method: "POST", jsonBody: command.arguments)
case .set(let matchField, let matchValue):
// Empty matchField = singleton menu (e.g. "/ip dns") PATCH the resource directly,
// there's no list/id to look up. See RouterOSCommand.cliLine for the SSH counterpart.
if matchField.isEmpty {
_ = try await send(path: command.restPath, method: "PATCH", jsonBody: command.arguments)
} else {
let itemID = try await findItemID(path: command.restPath, matchField: matchField, matchValue: matchValue)
_ = try await send(path: "\(command.restPath)/\(itemID)", method: "PATCH", jsonBody: command.arguments)
}
case .remove(let matchField, let matchValue):
let itemID = try await findItemID(path: command.restPath, matchField: matchField, matchValue: matchValue)
_ = try await send(path: "\(command.restPath)/\(itemID)", method: "PATCH", jsonBody: command.arguments)
_ = try await send(path: "\(command.restPath)/\(itemID)", method: "DELETE", jsonBody: nil)
}
}
@@ -40,6 +40,36 @@ enum RouterOSCliParser {
}
}
/// Parses `<any menu> print without-paging terse` output generically used by the Expert
/// tool for menus without a curated parser (i.e. almost all of them). Each line's `.id=*N`
/// field (present in terse output) becomes the item's `id`; everything else becomes `fields`.
/// A line without `.id` falls back to a synthesized "row-<index>" rather than being dropped,
/// so a still-listable-but-not-editable item is visible instead of silently missing.
static func parseGenericItems(_ raw: String) -> [RouterOSMenuItem] {
raw.split(whereSeparator: \.isNewline).enumerated().compactMap { index, line in
var fields = keyValues(from: String(line))
guard !fields.isEmpty else { return nil }
let id = fields.removeValue(forKey: ".id") ?? "row-\(index)"
return RouterOSMenuItem(id: id, fields: fields)
}
}
/// Parses `<singleton menu> print` output (colon-separated "key: value" lines, same shape as
/// `/system resource print`) into one generic item with the fixed id "singleton" used for
/// menus that hold exactly one settable item rather than a list (confirmed live: these
/// reject "print terse" with "bad parameter terse").
static func parseSingletonItem(_ raw: String) -> RouterOSMenuItem {
var fields: [String: String] = [:]
for line in raw.split(whereSeparator: \.isNewline) {
guard let colonIndex = line.firstIndex(of: ":") else { continue }
let key = String(line[line.startIndex..<colonIndex]).trimmingCharacters(in: .whitespaces)
let value = String(line[line.index(after: colonIndex)...]).trimmingCharacters(in: .whitespaces)
guard !key.isEmpty else { continue }
fields[key] = value
}
return RouterOSMenuItem(id: "singleton", fields: fields)
}
/// RouterOS CLI output mixes "true"/"false" and "yes"/"no" for booleans depending on field/version.
private static func isTrue(_ value: String?) -> Bool {
value == "true" || value == "yes"
@@ -47,7 +77,7 @@ enum RouterOSCliParser {
private static func keyValues(from text: String) -> [String: String] {
var result: [String: String] = [:]
let pattern = #"([a-zA-Z0-9-]+)=("[^"]*"|\S+)"#
let pattern = #"([.a-zA-Z0-9-]+)=("[^"]*"|\S+)"#
guard let regex = try? NSRegularExpression(pattern: pattern) else { return result }
let nsText = text as NSString
let matches = regex.matches(in: text, range: NSRange(location: 0, length: nsText.length))
@@ -11,6 +11,9 @@ protocol RouterOSTransport: AnyObject {
func fetchDeviceInfo() async throws -> RouterDeviceInfo
func fetchInterfaces() async throws -> [NetworkInterface]
func fetchFirewallRuleCounts() async throws -> FirewallRuleCounts
/// Lists existing items under any RouterOS menu the generic read side of the Expert tool,
/// works for menus without a curated `RouterOSMenuSchema` too.
func fetchMenuItems(menuPath: String, restPath: String) async throws -> [RouterOSMenuItem]
func apply(_ command: RouterOSCommand) async throws
func disconnect() async
}
@@ -67,8 +67,48 @@ final class SSHTransport: RouterOSTransport {
return FirewallRuleCounts(filterRuleCount: filterCount, natRuleCount: natCount)
}
/// RouterOS 7.24.2 (verified live against a hEX test device) does *not* include `.id` in
/// `print terse` CLI output, unlike REST's JSON, which always does every field parses
/// fine, but every item's `id` would otherwise fall back to a synthetic, unusable "row-N".
/// `:put [<menuPath> find]` returns the real internal IDs (e.g. "*C;*1;*2") in the same
/// order as `print terse` (also verified live: position 0 first ID, position 1 second),
/// so they're overlaid onto the parsed items by position here.
func fetchMenuItems(menuPath: String, restPath: String) async throws -> [RouterOSMenuItem] {
let output: String
do {
output = try await run("\(menuPath) print without-paging terse")
} catch RouterOSError.invalidResponse(let detail) where detail.contains("bad parameter terse") {
// Singleton menu (e.g. "/ip dns", "/system identity") no list, no "terse" support.
let plain = try await run("\(menuPath) print without-paging")
return [RouterOSCliParser.parseSingletonItem(plain)]
}
var items = RouterOSCliParser.parseGenericItems(output)
let idOutput = try await run(":put [\(menuPath) find]")
let ids = idOutput
.trimmingCharacters(in: .whitespacesAndNewlines)
.split(separator: ";")
.map(String.init)
for index in items.indices where index < ids.count {
items[index] = RouterOSMenuItem(id: ids[index], fields: items[index].fields)
}
return items
}
/// RouterOS' SSH CLI exits 0 even when a command fails confirmed live: both
/// `/ip dhcp-server add ...` on an interface that already has one ("failure: server or
/// relay with such interface already exists") and an invalid action ("syntax error (line 1
/// column 46)") returned exit status 0, meaning `run()`'s exit-code check alone silently
/// treats every such failure as success. A mutating command (add/set/remove) is always
/// silent on success in every case observed live this session (15+ menu families) so any
/// non-empty output here is treated as the error text, since there is no more reliable
/// signal available over this transport.
func apply(_ command: RouterOSCommand) async throws {
_ = try await run(command.cliLine)
let output = try await run(command.cliLine)
let trimmed = output.trimmingCharacters(in: .whitespacesAndNewlines)
guard trimmed.isEmpty else {
throw RouterOSError.invalidResponse(trimmed)
}
}
/// Restores RouterOS' own vendor-default configuration and reboots the device. See
@@ -22,6 +22,15 @@ final class ConnectionService: ObservableObject {
private let certificateTrust: CertificateTrustStore
private let sshHostKeyTrust: SSHHostKeyTrustStore
private var activeTransport: RouterOSTransport?
/// Whether the Expert tool has already made its one automatic safety backup for this
/// connection it backs up before its first write, not before every single edit (unlike
/// the Einrichten-Wizard, which backs up before every "Jetzt anwenden"). Reset on
/// `disconnect()` so a new connection gets a fresh backup again.
private(set) var hasExpertToolBackedUpThisSession = false
func markExpertToolBackedUpThisSession() {
hasExpertToolBackedUpThisSession = true
}
init(
certificateTrust: CertificateTrustStore = CertificateTrustStore(),
@@ -90,6 +99,12 @@ final class ConnectionService: ObservableObject {
return try await activeTransport.fetchFirewallRuleCounts()
}
/// Generic read for the Expert tool lists existing items under any RouterOS menu path.
func fetchMenuItems(menuPath: String, restPath: String) async throws -> [RouterOSMenuItem] {
guard let activeTransport else { throw RouterOSError.notConnected }
return try await activeTransport.fetchMenuItems(menuPath: menuPath, restPath: restPath)
}
private func finishConnecting(using transport: RouterOSTransport) async {
activeTransport = transport
do {
@@ -107,6 +122,7 @@ final class ConnectionService: ObservableObject {
deviceInfo = nil
interfaces = []
credentials = nil
hasExpertToolBackedUpThisSession = false
state = .idle
}
}
@@ -0,0 +1,302 @@
import SwiftUI
struct ExpertMenuDetailView: View {
@ObservedObject var viewModel: ExpertViewModel
let schema: RouterOSMenuSchema
var body: some View {
Form {
Section {
Text(schema.explanation)
if let warning = schema.warning {
Label(warning, systemImage: "exclamationmark.triangle")
.font(.caption)
.foregroundStyle(.orange)
}
}
Section {
if viewModel.isLoading {
ProgressView()
} else if let error = viewModel.loadError {
Text(error).foregroundStyle(.red)
} else if viewModel.items.isEmpty {
Text("Keine Einträge unter \(schema.menuPath).").foregroundStyle(.secondary)
} else {
ForEach(viewModel.items) { item in
HStack {
Button {
viewModel.startEditing(item)
} label: {
Text(rowLabel(for: item))
}
.buttonStyle(.plain)
Spacer()
// Not ".swipeActions" that's an iOS/iPadOS gesture with no
// equivalent on macOS List rows, so it never showed anything here.
if !schema.isSingleton {
Button(role: .destructive) {
viewModel.pendingRemoval = item
} label: {
Image(systemName: "trash")
}
.buttonStyle(.plain)
.help("Eintrag löschen")
}
}
.contentShape(Rectangle())
}
}
} header: {
HStack {
Text(schema.isSingleton ? "Einstellungen" : "Einträge")
Spacer()
if !schema.isSingleton {
Button("Neu hinzufügen") { viewModel.startNewItem() }
}
}
}
}
.formStyle(.grouped)
.navigationTitle(schema.displayName)
.task(id: schema.id) { await viewModel.reloadItems() }
.sheet(item: $viewModel.editingItem) { item in
ExpertItemEditView(
viewModel: viewModel,
schema: schema,
isNew: item.id.isEmpty
)
}
.confirmationDialog(
"Eintrag wirklich löschen?",
isPresented: Binding(
get: { viewModel.pendingRemoval != nil },
set: { if !$0 { viewModel.pendingRemoval = nil } }
),
presenting: viewModel.pendingRemoval
) { item in
Button("Löschen", role: .destructive) {
Task { await viewModel.confirmRemoval(item) }
}
Button("Abbrechen", role: .cancel) {}
} message: { item in
Text(rowLabel(for: item))
}
}
private func rowLabel(for item: RouterOSMenuItem) -> String {
let columns = schema.listColumns.isEmpty ? Array(item.fields.keys.sorted()) : schema.listColumns
let parts = columns.compactMap { key in item.fields[key].map { "\(key)=\($0)" } }
return parts.isEmpty ? item.id : parts.joined(separator: " · ")
}
}
/// Add/edit sheet: curated fields (with tooltips) from the schema, plus a free-form "weitere
/// Parameter" list for anything the schema doesn't curate so every field RouterOS actually
/// supports stays reachable even where this app hasn't described it yet.
private struct ExpertItemEditView: View {
@ObservedObject var viewModel: ExpertViewModel
let schema: RouterOSMenuSchema
let isNew: Bool
@Environment(\.dismiss) private var dismiss
@State private var showApplyConfirmation = false
var body: some View {
Form {
ForEach(schema.fields) { field in
fieldEditor(for: field)
}
Section("Weitere Parameter (frei)") {
Text("Für alles, was oben nicht als eigenes Feld aufgeführt ist — RouterOS-Parametername genau wie in der Dokumentation.")
.font(.caption)
.foregroundStyle(.secondary)
ForEach($viewModel.extraFields) { $extra in
HStack {
TextField("Parameter", text: $extra.key)
TextField("Wert", text: $extra.value)
Button(role: .destructive) {
viewModel.extraFields.removeAll { $0.id == extra.id }
} label: {
Image(systemName: "minus.circle")
}
}
}
Button("Parameter hinzufügen") {
viewModel.extraFields.append(.init())
}
}
if let command = viewModel.pendingCommand {
Section("Wird ausgeführt") {
Text(command.cliLine)
.font(.system(.caption, design: .monospaced))
.foregroundStyle(.secondary)
}
}
if let error = viewModel.applyError {
Text(error).foregroundStyle(.red)
}
Section {
HStack {
Button("Abbrechen") {
viewModel.cancelEditing()
dismiss()
}
Spacer()
Button(isNew ? "Anlegen" : "Speichern") {
showApplyConfirmation = true
}
.disabled(viewModel.isApplying || viewModel.pendingCommand == nil)
}
}
}
.formStyle(.grouped)
.frame(minWidth: 420, minHeight: 480)
.confirmationDialog(
"Jetzt am Router anwenden?",
isPresented: $showApplyConfirmation,
titleVisibility: .visible
) {
Button(isNew ? "Anlegen" : "Speichern") {
Task {
await viewModel.saveEditingItem()
if viewModel.applyError == nil { dismiss() }
}
}
Button("Abbrechen", role: .cancel) {}
} message: {
if let command = viewModel.pendingCommand {
Text(command.cliLine)
}
}
}
@ViewBuilder
private func fieldEditor(for field: RouterOSFieldSchema) -> some View {
let binding = Binding<String>(
get: { viewModel.formValues[field.key] ?? "" },
set: { viewModel.formValues[field.key] = $0 }
)
Group {
switch field.kind {
case .text, .int:
TextField(field.label, text: binding)
case .bool:
Toggle(field.label, isOn: Binding(
get: { binding.wrappedValue == "yes" || binding.wrappedValue == "true" },
set: { binding.wrappedValue = $0 ? "yes" : "no" }
))
case .enumPick(let options):
Picker(field.label, selection: binding) {
Text("").tag("")
ForEach(options, id: \.self) { option in
Text(option).tag(option)
}
}
case .interfacePick:
Picker(field.label, selection: binding) {
Text("").tag("")
ForEach(viewModel.liveInterfaceNames, id: \.self) { name in
Text(name).tag(name)
}
}
case .menuItemPick(let menuPath, _, _):
let options = viewModel.crossReferenceOptions[menuPath] ?? []
Picker(field.label, selection: binding) {
Text("").tag("")
ForEach(options, id: \.self) { option in
Text(option).tag(option)
}
}
case .duration:
HStack {
Text(field.label)
Spacer()
DurationFieldEditor(value: binding)
}
}
}
.help(field.help.isEmpty ? field.label : field.help)
}
}
/// Day/hour/minute/second steppers for a RouterOS time value (e.g. "1d12h30m") instead of a
/// bare text field where the suffix syntax isn't obvious. Reads any combination of RouterOS'
/// "<number><unit>" tokens (or a bare number, which RouterOS treats as seconds) and always
/// writes back the explicit suffixed form.
private struct DurationFieldEditor: View {
@Binding var value: String
@State private var days = 0
@State private var hours = 0
@State private var minutes = 0
@State private var seconds = 0
@State private var hasInitialized = false
var body: some View {
HStack(spacing: 10) {
component("T", $days, 0...365)
component("Std", $hours, 0...23)
component("Min", $minutes, 0...59)
component("Sek", $seconds, 0...59)
}
.onAppear {
guard !hasInitialized else { return }
hasInitialized = true
parse(value)
}
.onChange(of: days) { _, _ in commit() }
.onChange(of: hours) { _, _ in commit() }
.onChange(of: minutes) { _, _ in commit() }
.onChange(of: seconds) { _, _ in commit() }
}
private func component(_ unit: String, _ binding: Binding<Int>, _ range: ClosedRange<Int>) -> some View {
VStack(spacing: 2) {
Stepper(value: binding, in: range) {
Text("\(binding.wrappedValue)")
.monospacedDigit()
.frame(minWidth: 26)
}
Text(unit)
.font(.caption2)
.foregroundStyle(.secondary)
}
}
private func parse(_ raw: String) {
let trimmed = raw.trimmingCharacters(in: .whitespaces)
guard !trimmed.isEmpty else { return }
if let bareSeconds = Int(trimmed) {
seconds = bareSeconds % 60
minutes = (bareSeconds / 60) % 60
hours = (bareSeconds / 3600) % 24
days = bareSeconds / 86400
return
}
guard let regex = try? NSRegularExpression(pattern: #"(\d+)([dhms])"#) else { return }
let nsText = trimmed as NSString
for match in regex.matches(in: trimmed, range: NSRange(location: 0, length: nsText.length)) {
guard let amount = Int(nsText.substring(with: match.range(at: 1))) else { continue }
switch nsText.substring(with: match.range(at: 2)) {
case "d": days = amount
case "h": hours = amount
case "m": minutes = amount
case "s": seconds = amount
default: break
}
}
}
private func commit() {
var parts: [String] = []
if days > 0 { parts.append("\(days)d") }
if hours > 0 { parts.append("\(hours)h") }
if minutes > 0 { parts.append("\(minutes)m") }
if seconds > 0 { parts.append("\(seconds)s") }
value = parts.joined()
}
}
@@ -0,0 +1,71 @@
import SwiftUI
struct ExpertView: View {
@ObservedObject var connectionService: ConnectionService
@StateObject private var viewModel: ExpertViewModel
init(connectionService: ConnectionService) {
self.connectionService = connectionService
_viewModel = StateObject(wrappedValue: ExpertViewModel(connectionService: connectionService))
}
var body: some View {
Group {
if connectionService.credentials == nil {
ContentUnavailableView(
"Nicht verbunden",
systemImage: "network.slash",
description: Text("Verbinde dich zuerst im Tab \"Verbinden\" mit deinem Router.")
)
} else {
NavigationSplitView {
List {
ForEach(RouterOSMenuCategory.allCases) { category in
let schemas = RouterOSSchemaCatalog.schemas(in: category)
if !schemas.isEmpty {
Section(category.rawValue) {
ForEach(schemas) { schema in
Button(schema.displayName) {
viewModel.open(schema)
}
.buttonStyle(.plain)
}
}
}
}
Section("Eigener Menüpfad") {
Text("Jeder RouterOS-Menüpfad ist erreichbar, auch wenn er oben nicht gelistet ist — die Felder erscheinen dann generisch (Schlüssel/Wert), ohne kuratierte Erklärung.")
.font(.caption)
.foregroundStyle(.secondary)
TextField("Menüpfad, z.B. /ip firewall filter", text: $viewModel.customMenuPath)
.help("Der RouterOS-CLI-Pfad, z.B. \"/interface wireguard\" oder \"/routing ospf instance\".")
TextField("REST-Pfad, z.B. ip/firewall/filter", text: $viewModel.customRestPath)
.help("Derselbe Pfad in REST-API-Schreibweise: führende \"/\" weg, Leerzeichen zu \"/\".")
Button("Öffnen") { viewModel.openCustomPath() }
.disabled(viewModel.customMenuPath.isEmpty || viewModel.customRestPath.isEmpty)
}
}
.navigationTitle("Experte")
} detail: {
if let schema = viewModel.selectedSchema {
ExpertMenuDetailView(
viewModel: viewModel,
schema: schema
)
} else {
ContentUnavailableView(
"Kein Menü ausgewählt",
systemImage: "list.bullet.rectangle",
description: Text("Wähle links einen RouterOS-Bereich aus.")
)
}
}
}
}
}
}
#Preview {
ExpertView(connectionService: ConnectionService())
}
@@ -0,0 +1,221 @@
import Foundation
@MainActor
final class ExpertViewModel: ObservableObject {
/// One raw, uncurated key=value field the escape hatch that keeps every RouterOS menu
/// reachable even where `RouterOSMenuSchema.fields` doesn't (yet) curate every parameter.
struct ExtraField: Identifiable, Equatable {
let id = UUID()
var key: String = ""
var value: String = ""
}
@Published var selectedSchema: RouterOSMenuSchema?
@Published var customMenuPath: String = ""
@Published var customRestPath: String = ""
@Published private(set) var items: [RouterOSMenuItem] = []
@Published private(set) var isLoading = false
@Published private(set) var loadError: String?
/// Options for `.menuItemPick` fields, keyed by that field's referenced `menuPath` e.g.
/// existing pool names for a DHCP server's "address-pool" field. Refreshed alongside
/// `items` so a pool/profile/script created moments earlier already shows up.
@Published private(set) var crossReferenceOptions: [String: [String]] = [:]
/// Live interface names for `.interfacePick` fields, refreshed alongside `items` unlike
/// `ConnectionService.interfaces` (fetched once at connect time), this picks up interfaces
/// created after connecting (e.g. a VLAN added minutes earlier in this same Expert tab).
/// Confirmed live: a DHCP server's "interface" field didn't offer a just-created VLAN until
/// this was added.
@Published private(set) var liveInterfaceNames: [String] = []
/// Non-nil while the add/edit sheet is open. An empty `id` means "new item".
@Published var editingItem: RouterOSMenuItem?
@Published var formValues: [String: String] = [:]
@Published var extraFields: [ExtraField] = []
@Published private(set) var isApplying = false
@Published private(set) var applyError: String?
@Published var pendingRemoval: RouterOSMenuItem?
private let connectionService: ConnectionService
private let backupService: BackupService
init(connectionService: ConnectionService, backupService: BackupService = BackupService()) {
self.connectionService = connectionService
self.backupService = backupService
}
/// Backs up the current config once per connection, right before the Expert tool's first
/// write mirrors the Einrichten-Wizard's "always back up before applying" habit, just
/// once per session here rather than before every single edit (an Expert-tool session can
/// easily be a dozen small edits in a row).
private func ensureSessionBackup() async throws {
guard !connectionService.hasExpertToolBackedUpThisSession, let credentials = connectionService.credentials else { return }
_ = try await backupService.createBackup(for: credentials)
connectionService.markExpertToolBackedUpThisSession()
}
func open(_ schema: RouterOSMenuSchema) {
selectedSchema = schema
items = []
loadError = nil
Task { await reloadItems() }
}
/// Opens any RouterOS menu path the user types in directly, generic key=value form
/// the guarantee that literally any menu is reachable, curated or not.
func openCustomPath() {
let menuPath = customMenuPath.trimmingCharacters(in: .whitespaces)
let restPath = customRestPath.trimmingCharacters(in: .whitespaces)
guard !menuPath.isEmpty, !restPath.isEmpty else { return }
open(RouterOSMenuSchema(
menuPath: menuPath,
restPath: restPath,
category: .system,
displayName: menuPath,
summary: "Eigener Menüpfad — generischer Zugriff ohne kuratierte Felder.",
explanation: "Alle Felder, die der Router für diesen Pfad zurückgibt, erscheinen unten als freie Schlüssel/Wert-Paare. Prüfe die RouterOS-Dokumentation für die genaue Bedeutung."
))
}
func reloadItems() async {
guard let schema = selectedSchema else { return }
isLoading = true
loadError = nil
do {
items = try await connectionService.fetchMenuItems(menuPath: schema.menuPath, restPath: schema.restPath)
} catch {
loadError = error.localizedDescription
}
isLoading = false
await loadCrossReferenceOptions(for: schema)
await loadLiveInterfaceNames(for: schema)
}
private func loadLiveInterfaceNames(for schema: RouterOSMenuSchema) async {
guard schema.fields.contains(where: {
if case .interfacePick = $0.kind { return true } else { return false }
}) else { return }
do {
let interfaceItems = try await connectionService.fetchMenuItems(menuPath: "/interface", restPath: "interface")
liveInterfaceNames = interfaceItems.compactMap { $0.fields["name"] }.sorted()
} catch {
liveInterfaceNames = []
}
}
private func loadCrossReferenceOptions(for schema: RouterOSMenuSchema) async {
for field in schema.fields {
guard case .menuItemPick(let menuPath, let restPath, let valueField) = field.kind else { continue }
do {
let referencedItems = try await connectionService.fetchMenuItems(menuPath: menuPath, restPath: restPath)
let values = Set(referencedItems.compactMap { $0.fields[valueField] }).sorted()
crossReferenceOptions[menuPath] = values
} catch {
crossReferenceOptions[menuPath] = []
}
}
}
func startNewItem() {
guard let schema = selectedSchema else { return }
editingItem = RouterOSMenuItem(id: "", fields: [:])
formValues = Dictionary(uniqueKeysWithValues: schema.fields.compactMap { field in
field.defaultValue.map { (field.key, $0) }
})
extraFields = []
applyError = nil
}
func startEditing(_ item: RouterOSMenuItem) {
guard let schema = selectedSchema else { return }
editingItem = item
let curatedKeys = Set(schema.fields.map(\.key))
formValues = item.fields.filter { curatedKeys.contains($0.key) }
extraFields = item.fields
.filter { !curatedKeys.contains($0.key) }
.sorted { $0.key < $1.key }
.map { ExtraField(key: $0.key, value: $0.value) }
applyError = nil
}
func cancelEditing() {
editingItem = nil
formValues = [:]
extraFields = []
applyError = nil
}
/// The command that saving the current edit sheet would run shown to the user before it
/// actually executes, consistent with the rest of the app never applying silently.
var pendingCommand: RouterOSCommand? {
guard let schema = selectedSchema, let editingItem else { return nil }
var arguments = formValues.filter { !$0.value.isEmpty }
// RouterOS' CLI parser rejects "true"/"false" for boolean parameters it only accepts
// "yes"/"no" (confirmed live: "disabled=false" on "/interface vlan add" produced
// "syntax error (line 1 column 30)"; "disabled=no" succeeded). Schema defaults are
// occasionally written as "true"/"false" for readability, so normalize here regardless
// of where the value came from rather than trust every call site to get it right.
for field in schema.fields {
guard case .bool = field.kind, let value = arguments[field.key] else { continue }
if value == "true" { arguments[field.key] = "yes" }
else if value == "false" { arguments[field.key] = "no" }
}
for extra in extraFields where !extra.key.isEmpty {
arguments[extra.key] = extra.value
}
let isNew = editingItem.id.isEmpty
let summary = "\(isNew ? "Neu anlegen" : "Ändern") unter \(schema.menuPath)"
if isNew {
return .add(menuPath: schema.menuPath, restPath: schema.restPath, arguments: arguments, summary: summary)
} else if schema.isSingleton {
// No "[find ...]" for a singleton menu see RouterOSCommand.cliLine.
return .set(
menuPath: schema.menuPath, restPath: schema.restPath,
matchField: "", matchValue: "",
arguments: arguments, summary: summary
)
} else {
return .set(
menuPath: schema.menuPath, restPath: schema.restPath,
matchField: ".id", matchValue: editingItem.id,
arguments: arguments, summary: summary
)
}
}
func saveEditingItem() async {
guard let command = pendingCommand else { return }
isApplying = true
applyError = nil
do {
try await ensureSessionBackup()
try await connectionService.apply(command)
cancelEditing()
await reloadItems()
} catch {
applyError = error.localizedDescription
}
isApplying = false
}
func confirmRemoval(_ item: RouterOSMenuItem) async {
guard let schema = selectedSchema else { return }
isApplying = true
applyError = nil
do {
let command = RouterOSCommand.remove(
menuPath: schema.menuPath, restPath: schema.restPath,
matchField: ".id", matchValue: item.id,
summary: "Löschen unter \(schema.menuPath)"
)
try await ensureSessionBackup()
try await connectionService.apply(command)
pendingRemoval = nil
await reloadItems()
} catch {
applyError = error.localizedDescription
}
isApplying = false
}
}
@@ -6,14 +6,18 @@ struct FirewallStepView: View {
var body: some View {
Form {
Section {
Toggle(
"Firewall-Grundschutz einrichten (empfohlen)",
isOn: Binding(
get: { viewModel.firewallSectionEnabled },
set: { viewModel.setFirewallSectionEnabled($0) }
if viewModel.mode == .expert {
Toggle(
"Firewall-Grundschutz einrichten (empfohlen)",
isOn: Binding(
get: { viewModel.firewallSectionEnabled },
set: { viewModel.setFirewallSectionEnabled($0) }
)
)
)
.help("Schützt deinen Router und deine Geräte vor unaufgeforderten Zugriffen aus dem Internet und ermöglicht deinen Geräten den Internetzugriff (NAT).")
.help("Schützt deinen Router und deine Geräte vor unaufgeforderten Zugriffen aus dem Internet und ermöglicht deinen Geräten den Internetzugriff (NAT).")
} else {
Text("Firewall-Grundschutz ist im einfachen Modus immer aktiv.")
}
Text(
"Richtet einen Standard-Schutz ein: Internetfreigabe (NAT) für dein Heimnetz, "
+ "und blockiert unaufgeforderte Zugriffe aus dem Internet auf deinen Router "
@@ -31,20 +31,24 @@ struct LanStepView: View {
TextField("DNS-Server", text: $config.dnsServers)
.help("Welcher Server Geräten in diesem Netzwerk Internetadressen in Namen übersetzt (z.B. www.google.de). Meist der Router selbst.")
Toggle("Von anderen Netzwerken isolieren", isOn: $config.isolated)
.help("Verhindert Datenverkehr zwischen diesem und allen anderen konfigurierten LAN-/VLAN-Netzwerken. Der Internetzugriff bleibt erhalten. Wird im Firewall-Schritt umgesetzt.")
if viewModel.mode == .expert {
Toggle("Von anderen Netzwerken isolieren", isOn: $config.isolated)
.help("Verhindert Datenverkehr zwischen diesem und allen anderen konfigurierten LAN-/VLAN-Netzwerken. Der Internetzugriff bleibt erhalten. Wird im Firewall-Schritt umgesetzt.")
if viewModel.lanConfigs.count > 1 {
Button("Netzwerk entfernen", role: .destructive) {
viewModel.removeLan(config.id)
if viewModel.lanConfigs.count > 1 {
Button("Netzwerk entfernen", role: .destructive) {
viewModel.removeLan(config.id)
}
}
}
}
}
Section {
Button("Weiteres LAN-Netzwerk hinzufügen") {
viewModel.addLan()
if viewModel.mode == .expert {
Section {
Button("Weiteres LAN-Netzwerk hinzufügen") {
viewModel.addLan()
}
}
}
@@ -0,0 +1,43 @@
import SwiftUI
struct ModeStepView: View {
@ObservedObject var viewModel: SetupViewModel
var body: some View {
Form {
Section {
Picker("Modus", selection: $viewModel.mode) {
Text("Einfach").tag(SetupMode.simple)
Text("Experte").tag(SetupMode.expert)
}
.pickerStyle(.segmented)
}
Section {
switch viewModel.mode {
case .simple:
Text("Grundeinrichtung: Internetverbindung (WAN), ein Heimnetzwerk (LAN) mit DHCP, WLAN und ein fest aktivierter Firewall-Grundschutz.")
Text("Kein VLAN, keine mehreren getrennten Netzwerke, keine Netzwerk-Isolation — für später jederzeit über den Expertenmodus nachrüstbar.")
.font(.caption)
.foregroundStyle(.secondary)
case .expert:
Text("Voller Zugriff: mehrere LAN-Interfaces mit je eigenem DHCP-Server, VLANs, Netzwerk-Isolation zwischen Netzwerken, und alle Firewall-Optionen.")
.foregroundStyle(.secondary)
}
}
Section {
HStack {
Spacer()
Button("Weiter") { viewModel.goNext() }
}
}
}
.formStyle(.grouped)
.navigationTitle("Modus wählen")
}
}
#Preview {
ModeStepView(viewModel: SetupViewModel(connectionService: ConnectionService()))
}
@@ -20,6 +20,8 @@ struct SetupView: View {
)
} else {
switch viewModel.step {
case .mode:
ModeStepView(viewModel: viewModel)
case .wan:
WanStepView(viewModel: viewModel, availableInterfaces: configurableInterfaces)
case .lan:
@@ -1,6 +1,7 @@
import Foundation
enum SetupStep: Int, CaseIterable {
case mode
case wan
case lan
case vlan
@@ -9,9 +10,18 @@ enum SetupStep: Int, CaseIterable {
case review
}
/// Simple restricts the wizard to basic setup (single LAN, fixed firewall baseline, no VLANs,
/// no per-port DHCP/isolation) for users who just want a working router. Expert exposes every
/// RouterOS feature the wizard supports.
enum SetupMode {
case simple
case expert
}
@MainActor
final class SetupViewModel: ObservableObject {
@Published var step: SetupStep = .wan
@Published var step: SetupStep = .mode
@Published var mode: SetupMode = .simple
@Published var wanConfig = WanConfig(interfaceName: "ether1")
@Published var lanConfigs: [LanDhcpConfig] = [LanDhcpConfig()]
@Published private(set) var vlanSectionEnabled = false
@@ -142,13 +152,52 @@ final class SetupViewModel: ObservableObject {
}
func goNext() {
guard let next = SetupStep(rawValue: step.rawValue + 1) else { return }
step = next
switch step {
case .mode:
if mode == .simple { applySimpleModeConstraints() }
step = .wan
case .wan:
step = .lan
case .lan:
step = (mode == .expert) ? .vlan : .wifi
case .vlan:
step = .wifi
case .wifi:
step = .firewall
case .firewall:
step = .review
case .review:
break
}
}
func goBack() {
guard let previous = SetupStep(rawValue: step.rawValue - 1) else { return }
step = previous
switch step {
case .mode:
break
case .wan:
step = .mode
case .lan:
step = .wan
case .vlan:
step = .lan
case .wifi:
step = (mode == .expert) ? .vlan : .lan
case .firewall:
step = .wifi
case .review:
step = .firewall
}
}
/// Simple mode allows only one LAN network, no isolation, no VLANs, and a fixed firewall
/// baseline strip anything an earlier Expert-mode choice may have left configured.
private func applySimpleModeConstraints() {
vlanSectionEnabled = false
vlans = []
if lanConfigs.count > 1 { lanConfigs = Array(lanConfigs.prefix(1)) }
if !lanConfigs.isEmpty { lanConfigs[0].isolated = false }
setFirewallSectionEnabled(true)
}
func apply(credentials: RouterOSCredentials) {
@@ -18,6 +18,7 @@ private final class MockTransport: RouterOSTransport {
func fetchDeviceInfo() async throws -> RouterDeviceInfo { deviceInfo }
func fetchInterfaces() async throws -> [NetworkInterface] { [] }
func fetchFirewallRuleCounts() async throws -> FirewallRuleCounts { FirewallRuleCounts(filterRuleCount: 0, natRuleCount: 0) }
func fetchMenuItems(menuPath: String, restPath: String) async throws -> [RouterOSMenuItem] { [] }
func apply(_ command: RouterOSCommand) async throws {}
func disconnect() async {}
}