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,14 +66,49 @@ 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: "DELETE", jsonBody: nil)
}
}
private func findItemID(path: String, matchField: String, matchValue: String) async throws -> String {
@@ -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,6 +6,7 @@ struct FirewallStepView: View {
var body: some View {
Form {
Section {
if viewModel.mode == .expert {
Toggle(
"Firewall-Grundschutz einrichten (empfohlen)",
isOn: Binding(
@@ -14,6 +15,9 @@ struct FirewallStepView: View {
)
)
.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,6 +31,7 @@ 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.")
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.")
@@ -41,12 +42,15 @@ struct LanStepView: View {
}
}
}
}
if viewModel.mode == .expert {
Section {
Button("Weiteres LAN-Netzwerk hinzufügen") {
viewModel.addLan()
}
}
}
Section {
HStack {
@@ -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 {}
}