forked from kay/RouterOS
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:
+222
@@ -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 11–13,
|
||||
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 7–13), 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
@@ -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 7–13 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" (16px–1024px)
|
||||
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
|
||||
|
||||
- ✅ M1–M4: 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 7–13), 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, 2–4094.", required: true),
|
||||
RouterOSFieldSchema(key: "interface", label: "Basis-Interface", kind: .interfacePick,
|
||||
help: "Physischer Port oder Bridge, auf dem dieses VLAN aufsetzt.", required: true),
|
||||
RouterOSFieldSchema(key: "disabled", label: "Deaktiviert", kind: .bool, help: "", defaultValue: "no")
|
||||
],
|
||||
listColumns: ["name", "vlan-id", "interface"]
|
||||
),
|
||||
RouterOSMenuSchema(
|
||||
menuPath: "/interface wireguard", restPath: "interface/wireguard", category: .interfaces,
|
||||
displayName: "WireGuard-Interfaces",
|
||||
summary: "Moderner, schlanker VPN-Tunnel-Typ.",
|
||||
explanation: "Ein WireGuard-Interface allein stellt noch keine Verbindung her — dazu gehören Gegenstellen (siehe \"WireGuard-Peers\") mit öffentlichem Schlüssel und erlaubten Adressen.",
|
||||
warning: "Erst hier das Interface anlegen (mit Listen-Port), danach unter WireGuard-Peers die Gegenstellen eintragen. Der private Schlüssel wird beim Anlegen automatisch erzeugt, falls nicht angegeben.",
|
||||
fields: [
|
||||
RouterOSFieldSchema(key: "name", label: "Name", kind: .text, help: "Frei wählbar, z.B. wg-heim.", required: true),
|
||||
RouterOSFieldSchema(key: "listen-port", label: "Listen-Port (UDP)", kind: .int,
|
||||
help: "Port, auf dem dieser Tunnel auf eingehende Verbindungen lauscht, z.B. 51820.", defaultValue: "51820"),
|
||||
RouterOSFieldSchema(key: "private-key", label: "Privater Schlüssel", kind: .text,
|
||||
help: "Geheim halten. Leer lassen, damit RouterOS automatisch einen erzeugt."),
|
||||
RouterOSFieldSchema(key: "disabled", label: "Deaktiviert", kind: .bool, help: "", defaultValue: "no")
|
||||
],
|
||||
listColumns: ["name", "listen-port"]
|
||||
),
|
||||
RouterOSMenuSchema(
|
||||
menuPath: "/interface wireguard peers", restPath: "interface/wireguard/peers", category: .interfaces,
|
||||
displayName: "WireGuard-Peers",
|
||||
summary: "Gegenstellen (Clients/andere Router) eines WireGuard-Tunnels.",
|
||||
explanation: "Jede Gegenstelle braucht ihren eigenen öffentlichen Schlüssel und eine Angabe, welche Adressen über sie geroutet werden.",
|
||||
fields: [
|
||||
RouterOSFieldSchema(key: "interface", label: "WireGuard-Interface",
|
||||
kind: .menuItemPick(menuPath: "/interface wireguard", restPath: "interface/wireguard", valueField: "name"),
|
||||
help: "Name des zuvor angelegten WireGuard-Interfaces.", required: true),
|
||||
RouterOSFieldSchema(key: "public-key", label: "Öffentlicher Schlüssel der Gegenstelle", kind: .text, help: "", required: true),
|
||||
RouterOSFieldSchema(key: "allowed-address", label: "Erlaubte Adressen", kind: .text,
|
||||
help: "Welche IP-Adressen/Netze über diesen Peer laufen dürfen, z.B. 10.10.10.2/32."),
|
||||
RouterOSFieldSchema(key: "endpoint-address", label: "Feste Adresse der Gegenstelle", kind: .text,
|
||||
help: "Nur nötig, wenn diese Gegenstelle selbst erreichbar sein muss (Site-to-Site). Bei Roadwarrior-Clients, die sich selbst melden, leer lassen."),
|
||||
RouterOSFieldSchema(key: "endpoint-port", label: "Port der Gegenstelle", kind: .text, help: ""),
|
||||
RouterOSFieldSchema(key: "persistent-keepalive", label: "Keepalive (Sekunden)", kind: .text,
|
||||
help: "Z.B. 25 — hält die Verbindung durch NAT/Firewalls am Leben. Wichtig bei Clients hinter NAT.")
|
||||
],
|
||||
listColumns: ["interface", "allowed-address"]
|
||||
),
|
||||
RouterOSMenuSchema(
|
||||
menuPath: "/interface pppoe-client", restPath: "interface/pppoe-client", category: .interfaces,
|
||||
displayName: "PPPoE-Client",
|
||||
summary: "DSL-Einwahl-Client, meist auf dem WAN-Port.",
|
||||
explanation: "Ersetzt eine feste/DHCP-WAN-Adresse durch eine PPPoE-Einwahl beim Provider.",
|
||||
fields: [
|
||||
RouterOSFieldSchema(key: "name", label: "Name", kind: .text, help: "Frei wählbar, z.B. pppoe-wan.", required: true),
|
||||
RouterOSFieldSchema(key: "interface", label: "Physischer Port", kind: .interfacePick,
|
||||
help: "Der Port, über den die Einwahl läuft, meist der WAN-Port.", required: true),
|
||||
RouterOSFieldSchema(key: "user", label: "Benutzername", kind: .text, help: "Zugangsdaten des Providers.", required: true),
|
||||
RouterOSFieldSchema(key: "password", label: "Passwort", kind: .text, help: "", required: true),
|
||||
RouterOSFieldSchema(key: "disabled", label: "Deaktiviert", kind: .bool, help: "", defaultValue: "no")
|
||||
],
|
||||
listColumns: ["name", "interface"]
|
||||
),
|
||||
RouterOSMenuSchema(
|
||||
menuPath: "/interface bonding", restPath: "interface/bonding", category: .interfaces,
|
||||
displayName: "Bonding",
|
||||
summary: "Bündelt mehrere physische Ports zu einer logischen, ausfalltoleranten/schnelleren Verbindung.",
|
||||
explanation: "",
|
||||
fields: [
|
||||
RouterOSFieldSchema(key: "name", label: "Name", kind: .text, help: "Frei wählbar.", required: true),
|
||||
RouterOSFieldSchema(key: "slaves", label: "Gebündelte Ports", kind: .text,
|
||||
help: "Kommagetrennte Liste physischer Ports, z.B. ether2,ether3."),
|
||||
RouterOSFieldSchema(key: "mode", label: "Modus", kind: .enumPick(options: [
|
||||
"802.3ad", "active-backup", "balance-rr", "balance-xor", "broadcast"
|
||||
]), help: "802.3ad (LACP) braucht einen kompatiblen, entsprechend konfigurierten Switch.")
|
||||
],
|
||||
listColumns: ["name", "mode"]
|
||||
)
|
||||
]
|
||||
|
||||
// MARK: - IP addressing & services
|
||||
|
||||
private static let ipFamily: [RouterOSMenuSchema] = [
|
||||
RouterOSMenuSchema(
|
||||
menuPath: "/ip address", restPath: "ip/address", category: .ipAddressing,
|
||||
displayName: "IP-Adressen",
|
||||
summary: "Weist Interfaces IP-Adressen zu.",
|
||||
explanation: "Jede IP-Adresse hängt an genau einem Interface (physischer Port, Bridge oder VLAN).",
|
||||
fields: [
|
||||
RouterOSFieldSchema(key: "address", label: "Adresse", kind: .text,
|
||||
help: "Mit Präfix, z.B. 192.168.88.1/24.", required: true),
|
||||
RouterOSFieldSchema(key: "interface", label: "Interface", kind: .interfacePick, help: "", required: true),
|
||||
RouterOSFieldSchema(key: "disabled", label: "Deaktiviert", kind: .bool, help: "", defaultValue: "no")
|
||||
],
|
||||
listColumns: ["address", "interface"]
|
||||
),
|
||||
RouterOSMenuSchema(
|
||||
menuPath: "/ip pool", restPath: "ip/pool", category: .ipAddressing,
|
||||
displayName: "Adress-Pools",
|
||||
summary: "Adressbereiche, aus denen DHCP-Server oder PPP-Profile Adressen vergeben.",
|
||||
explanation: "",
|
||||
fields: [
|
||||
RouterOSFieldSchema(key: "name", label: "Name", kind: .text, help: "Frei wählbar.", required: true),
|
||||
RouterOSFieldSchema(key: "ranges", label: "Bereich(e)", kind: .text,
|
||||
help: "Z.B. 192.168.88.10-192.168.88.254. Mehrere Bereiche kommagetrennt.", required: true)
|
||||
],
|
||||
listColumns: ["name", "ranges"]
|
||||
),
|
||||
RouterOSMenuSchema(
|
||||
menuPath: "/ip dhcp-server", restPath: "ip/dhcp-server", category: .ipAddressing,
|
||||
displayName: "DHCP-Server",
|
||||
summary: "Vergibt automatisch IP-Adressen an Geräte in einem Netzwerk.",
|
||||
explanation: "Für die üblichen Fälle deckt das bereits der Einrichten-Assistent (LAN-/VLAN-Schritt) ab — hier direkter Zugriff für Sonderfälle.",
|
||||
fields: [
|
||||
RouterOSFieldSchema(key: "name", label: "Name", kind: .text, help: "Frei wählbar.", required: true),
|
||||
RouterOSFieldSchema(key: "interface", label: "Interface", kind: .interfacePick,
|
||||
help: "Netzwerk, in dem dieser Server Adressen vergibt.", required: true),
|
||||
RouterOSFieldSchema(key: "address-pool", label: "Adress-Pool",
|
||||
kind: .menuItemPick(menuPath: "/ip pool", restPath: "ip/pool", valueField: "name"),
|
||||
help: "Name eines zuvor angelegten Adress-Pools.", required: true),
|
||||
RouterOSFieldSchema(key: "lease-time", label: "Lease-Zeit", kind: .duration,
|
||||
help: "Wie lange ein Gerät seine Adresse behält, bevor sie erneuert werden muss."),
|
||||
RouterOSFieldSchema(key: "disabled", label: "Deaktiviert", kind: .bool, help: "", defaultValue: "no")
|
||||
],
|
||||
listColumns: ["name", "interface"]
|
||||
),
|
||||
RouterOSMenuSchema(
|
||||
menuPath: "/ip dhcp-server network", restPath: "ip/dhcp-server/network", category: .ipAddressing,
|
||||
displayName: "DHCP-Netzwerke",
|
||||
summary: "Gateway/DNS/Optionen, die ein DHCP-Server an seine Klienten verteilt.",
|
||||
explanation: "Getrennt vom DHCP-Server selbst, weil dieselben Netzwerk-Optionen für mehrere DHCP-Server gelten können.",
|
||||
fields: [
|
||||
RouterOSFieldSchema(key: "address", label: "Netz", kind: .text,
|
||||
help: "Für dieses Netz gelten die folgenden Optionen, z.B. 192.168.88.0/24.", required: true),
|
||||
RouterOSFieldSchema(key: "gateway", label: "Gateway", kind: .text,
|
||||
help: "In der Regel die Router-Adresse in diesem Netz.", required: true),
|
||||
RouterOSFieldSchema(key: "dns-server", label: "DNS-Server", kind: .text, help: "Meist der Router selbst.")
|
||||
],
|
||||
listColumns: ["address", "gateway"]
|
||||
),
|
||||
RouterOSMenuSchema(
|
||||
menuPath: "/ip dhcp-client", restPath: "ip/dhcp-client", category: .ipAddressing,
|
||||
displayName: "DHCP-Client (WAN)",
|
||||
summary: "Bezieht automatisch eine IP-Adresse vom Internetanbieter.",
|
||||
explanation: "",
|
||||
fields: [
|
||||
RouterOSFieldSchema(key: "interface", label: "Interface", kind: .interfacePick,
|
||||
help: "Meist der WAN-Port.", required: true),
|
||||
RouterOSFieldSchema(key: "add-default-route", label: "Standardroute übernehmen", kind: .bool,
|
||||
help: "Übernimmt die vom Provider mitgeteilte Standardroute ins Internet.", defaultValue: "yes"),
|
||||
RouterOSFieldSchema(key: "use-peer-dns", label: "DNS-Server übernehmen", kind: .bool,
|
||||
help: "Übernimmt die vom Provider mitgeteilten DNS-Server.", defaultValue: "yes"),
|
||||
RouterOSFieldSchema(key: "disabled", label: "Deaktiviert", kind: .bool, help: "", defaultValue: "no")
|
||||
],
|
||||
listColumns: ["interface"]
|
||||
),
|
||||
RouterOSMenuSchema(
|
||||
menuPath: "/ip dns", restPath: "ip/dns", category: .ipAddressing,
|
||||
displayName: "DNS-Einstellungen",
|
||||
summary: "Namensauflösung des Routers selbst (und optional als DNS-Server fürs LAN).",
|
||||
explanation: "",
|
||||
fields: [
|
||||
RouterOSFieldSchema(key: "servers", label: "DNS-Server", kind: .text,
|
||||
help: "Ein oder mehrere Server, kommagetrennt, z.B. 1.1.1.1,8.8.8.8."),
|
||||
RouterOSFieldSchema(key: "allow-remote-requests", label: "Als DNS-Server fürs LAN erlauben", kind: .bool,
|
||||
help: "Lässt Geräte im LAN den Router selbst als DNS-Server nutzen.", defaultValue: "no")
|
||||
],
|
||||
listColumns: ["servers"],
|
||||
isSingleton: true
|
||||
),
|
||||
RouterOSMenuSchema(
|
||||
menuPath: "/ip service", restPath: "ip/service", category: .ipAddressing,
|
||||
displayName: "Verwaltungsdienste",
|
||||
summary: "Schaltet Zugriffswege auf den Router (Winbox, API, SSH, WWW/REST, Telnet, FTP) an/aus und ändert deren Port.",
|
||||
explanation: "Jeder aktive Dienst ist ein potenzieller Angriffspunkt aus dem jeweils erreichbaren Netz — nicht benötigte Dienste deaktivieren.",
|
||||
warning: "Den gerade genutzten Zugriffsweg hier abzuschalten oder umzuportieren kann die eigene Verbindung sofort kappen — Vorsicht bei www-ssl (REST-API dieser App) und ssh.",
|
||||
fields: [
|
||||
RouterOSFieldSchema(key: "name", label: "Dienst", kind: .enumPick(options: [
|
||||
"www", "www-ssl", "ssh", "api", "api-ssl", "winbox", "ftp", "telnet"
|
||||
]), help: "Welcher Verwaltungsdienst geändert wird.", required: true),
|
||||
RouterOSFieldSchema(key: "port", label: "Port", kind: .int, help: "Auf welchem Port der Dienst lauscht."),
|
||||
RouterOSFieldSchema(key: "available-from", label: "Erlaubt von", kind: .text,
|
||||
help: "Optional: nur von dieser Adresse/diesem Netz aus erreichbar, z.B. 192.168.88.0/24."),
|
||||
RouterOSFieldSchema(key: "disabled", label: "Deaktiviert", kind: .bool, help: "", defaultValue: "no")
|
||||
],
|
||||
listColumns: ["name", "port"]
|
||||
),
|
||||
generic("/ip hotspot", "ip/hotspot", .ipAddressing, "Hotspot",
|
||||
"Login-Portal für Gäste-WLAN/-LAN mit Umleitung auf eine Anmeldeseite.",
|
||||
"Besteht aus mehreren zusammenhängenden Teilen (Server, Server-Profil, Benutzer-Profil, Benutzer) — hier generischer Zugriff auf \"/ip hotspot\" selbst, verwandte Teile über \"Eigener Menüpfad\" (z.B. \"/ip hotspot user\")."),
|
||||
RouterOSMenuSchema(
|
||||
menuPath: "/ip route", restPath: "ip/route", category: .routing,
|
||||
displayName: "Statische Routen",
|
||||
summary: "Feste, manuell eingetragene Wegewahl zu Netzen, die nicht direkt angeschlossen sind.",
|
||||
explanation: "Für alles außer \"Standard-Internet über eine WAN-Schnittstelle\" (das übernimmt bereits die DHCP-Client-/PPPoE-Route automatisch).",
|
||||
fields: [
|
||||
RouterOSFieldSchema(key: "dst-address", label: "Zielnetz", kind: .text,
|
||||
help: "Z.B. 10.0.0.0/24, oder 0.0.0.0/0 für eine Standardroute.", required: true),
|
||||
RouterOSFieldSchema(key: "gateway", label: "Gateway", kind: .text,
|
||||
help: "Nächster Hop — IP-Adresse oder Name eines Interfaces.", required: true),
|
||||
RouterOSFieldSchema(key: "distance", label: "Distanz", kind: .int,
|
||||
help: "Priorität bei mehreren passenden Routen zum selben Ziel — kleinere Zahl wird bevorzugt."),
|
||||
RouterOSFieldSchema(key: "comment", label: "Kommentar", kind: .text, help: ""),
|
||||
RouterOSFieldSchema(key: "disabled", label: "Deaktiviert", kind: .bool, help: "", defaultValue: "no")
|
||||
],
|
||||
listColumns: ["dst-address", "gateway"]
|
||||
)
|
||||
]
|
||||
|
||||
// MARK: - Routing (dynamic protocols)
|
||||
|
||||
private static let routingFamily: [RouterOSMenuSchema] = [
|
||||
generic("/routing ospf instance", "routing/ospf/instance", .routing, "OSPF-Instanzen",
|
||||
"Dynamisches Innennetz-Routing-Protokoll — tauscht Routen automatisch mit anderen OSPF-Routern aus.",
|
||||
"Nur relevant, wenn mehrere Router im selben Netz eigenständig Routen lernen sollen.",
|
||||
warning: "Fehlkonfiguriertes OSPF kann Routen zu bestehenden Netzen überschreiben. Nur mit Netzwerkplan einsetzen."),
|
||||
generic("/routing bgp connection", "routing/bgp/connection", .routing, "BGP-Verbindungen",
|
||||
"Routing-Protokoll für Verbindungen zwischen unabhängigen Netzen/Providern.",
|
||||
"Für Privat-/Kleinnetz i.d.R. nicht nötig — relevant bei eigenem Provider-unabhängigem Adressraum (Multihoming).")
|
||||
]
|
||||
|
||||
// MARK: - VPN
|
||||
|
||||
private static let vpnFamily: [RouterOSMenuSchema] = [
|
||||
RouterOSMenuSchema(
|
||||
menuPath: "/ppp secret", restPath: "ppp/secret", category: .vpn,
|
||||
displayName: "PPP-Benutzer",
|
||||
summary: "Zugangsdaten für PPPoE-/L2TP-/PPTP-/OpenVPN-Einwahl in den Router.",
|
||||
explanation: "Jeder Benutzer kann optional einem Profil zugeordnet werden, das IP-Pool/DNS/Verschlüsselung vorgibt.",
|
||||
fields: [
|
||||
RouterOSFieldSchema(key: "name", label: "Benutzername", kind: .text, help: "", required: true),
|
||||
RouterOSFieldSchema(key: "password", label: "Passwort", kind: .text, help: "", required: true),
|
||||
RouterOSFieldSchema(key: "service", label: "Dienst", kind: .enumPick(options: [
|
||||
"any", "pppoe", "l2tp", "pptp", "ovpn", "sstp"
|
||||
]), help: "Für welche Einwahl-Art dieser Benutzer gilt.", defaultValue: "any"),
|
||||
RouterOSFieldSchema(key: "profile", label: "Profil",
|
||||
kind: .menuItemPick(menuPath: "/ppp profile", restPath: "ppp/profile", valueField: "name"),
|
||||
help: "Name eines zuvor angelegten PPP-Profils."),
|
||||
RouterOSFieldSchema(key: "local-address", label: "Lokale Adresse", kind: .text,
|
||||
help: "IP-Adresse des Routers innerhalb dieser Verbindung."),
|
||||
RouterOSFieldSchema(key: "remote-address", label: "Adresse für den Client", kind: .text,
|
||||
help: "Feste IP für diesen Benutzer, oder Name eines Adress-Pools."),
|
||||
RouterOSFieldSchema(key: "disabled", label: "Deaktiviert", kind: .bool, help: "", defaultValue: "no")
|
||||
],
|
||||
listColumns: ["name", "service"]
|
||||
),
|
||||
RouterOSMenuSchema(
|
||||
menuPath: "/ppp profile", restPath: "ppp/profile", category: .vpn,
|
||||
displayName: "PPP-Profile",
|
||||
summary: "Vorlagen (IP-Pool, DNS, Verschlüsselung) für PPP-Benutzer.",
|
||||
explanation: "",
|
||||
fields: [
|
||||
RouterOSFieldSchema(key: "name", label: "Name", kind: .text, help: "", required: true),
|
||||
RouterOSFieldSchema(key: "local-address", label: "Lokale Adresse", kind: .text, help: "IP-Adresse des Routers."),
|
||||
RouterOSFieldSchema(key: "remote-address", label: "Adress-Pool für Clients", kind: .text,
|
||||
help: "Name eines zuvor angelegten Adress-Pools."),
|
||||
RouterOSFieldSchema(key: "dns-server", label: "DNS-Server für Clients", kind: .text, help: "")
|
||||
],
|
||||
listColumns: ["name"]
|
||||
),
|
||||
generic("/interface l2tp-server server", "interface/l2tp-server/server", .vpn, "L2TP-VPN-Server",
|
||||
"Nimmt eingehende L2TP-VPN-Einwahlen entgegen.",
|
||||
"Ein einzelnes Server-weites An/Aus mit gemeinsamen Verschlüsselungs-Einstellungen — Benutzer selbst kommen von \"PPP-Benutzer\"."),
|
||||
generic("/interface ovpn-server server", "interface/ovpn-server/server", .vpn, "OpenVPN-Server",
|
||||
"Nimmt eingehende OpenVPN-Einwahlen entgegen.",
|
||||
"Braucht zusätzlich ein Zertifikat (\"/certificate\") — Benutzer selbst kommen von \"PPP-Benutzer\".")
|
||||
]
|
||||
|
||||
// MARK: - Wireless
|
||||
|
||||
private static let wirelessFamily: [RouterOSMenuSchema] = [
|
||||
RouterOSMenuSchema(
|
||||
menuPath: "/interface wireless", restPath: "interface/wireless", category: .wireless,
|
||||
displayName: "WLAN (Legacy-Treiber)",
|
||||
summary: "WLAN-Interfaces auf älteren/Standard-Wireless-Chips.",
|
||||
explanation: "Diese Interfaces existieren bereits ab Werk (ein WLAN-Chip = ein Interface) — hier werden sie nur konfiguriert, nicht neu angelegt. Ein Sicherheitsprofil (siehe \"WLAN-Sicherheitsprofile\") muss vorher angelegt sein.",
|
||||
fields: [
|
||||
RouterOSFieldSchema(key: "ssid", label: "Netzwerkname (SSID)", kind: .text, help: "", required: true),
|
||||
RouterOSFieldSchema(key: "security-profile", label: "Sicherheitsprofil", kind: .text,
|
||||
help: "Name eines zuvor unter \"WLAN-Sicherheitsprofile\" angelegten Profils."),
|
||||
RouterOSFieldSchema(key: "mode", label: "Modus", kind: .enumPick(options: ["ap-bridge", "station", "bridge"]),
|
||||
help: "ap-bridge = Access Point (Normalfall), station = als Client mit einem anderen AP verbinden.", defaultValue: "ap-bridge"),
|
||||
RouterOSFieldSchema(key: "disabled", label: "Deaktiviert", kind: .bool, help: "", defaultValue: "no")
|
||||
],
|
||||
listColumns: ["ssid", "mode"]
|
||||
),
|
||||
RouterOSMenuSchema(
|
||||
menuPath: "/interface wireless security-profiles", restPath: "interface/wireless/security-profiles", category: .wireless,
|
||||
displayName: "WLAN-Sicherheitsprofile",
|
||||
summary: "Verschlüsselung/Passwort-Vorlagen für WLAN-Interfaces (Legacy-Treiber).",
|
||||
explanation: "Ein Profil wird angelegt und dann bei einem WLAN-Interface als \"security-profile\" eingetragen.",
|
||||
fields: [
|
||||
RouterOSFieldSchema(key: "name", label: "Name", kind: .text, help: "", required: true),
|
||||
RouterOSFieldSchema(key: "mode", label: "Modus", kind: .enumPick(options: ["none", "static-keys-required", "dynamic-keys"]),
|
||||
help: "dynamic-keys ist der übliche WPA/WPA2-Modus.", defaultValue: "dynamic-keys"),
|
||||
RouterOSFieldSchema(key: "authentication-types", label: "Authentifizierung", kind: .enumPick(options: [
|
||||
"wpa-psk", "wpa2-psk", "wpa-psk,wpa2-psk", "wpa-eap", "wpa2-eap"
|
||||
]), help: "wpa2-psk = WPA2 mit gemeinsamem Passwort (Heimnetz-Standard)."),
|
||||
RouterOSFieldSchema(key: "wpa2-pre-shared-key", label: "WPA2-Passwort", kind: .text, help: "Mindestens 8 Zeichen.")
|
||||
],
|
||||
listColumns: ["name", "authentication-types"]
|
||||
),
|
||||
RouterOSMenuSchema(
|
||||
menuPath: "/interface wifi", restPath: "interface/wifi", category: .wireless,
|
||||
displayName: "WLAN (neuer wifiwave2/802.11ax-Treiber)",
|
||||
summary: "WLAN-Interfaces auf neueren Wireless-Chips.",
|
||||
explanation: "Anderes, verschachteltes Konfigurationsschema als der Legacy-Treiber (Punkt-Notation wie \"security.passphrase\") — nicht mit \"/interface wireless\" mischen. Nur auf Geräten mit entsprechend neuem WLAN-Chip vorhanden.",
|
||||
warning: "Diese App hat den .set-Pfad für den neuen Treiber noch nie gegen echte Hardware getestet (siehe HANDOFF.md) — nach dem Anwenden unbedingt prüfen.",
|
||||
fields: [
|
||||
RouterOSFieldSchema(key: "ssid", label: "Netzwerkname (SSID)", kind: .text, help: "", required: true),
|
||||
RouterOSFieldSchema(key: "security.authentication-types", label: "Authentifizierung", kind: .enumPick(options: [
|
||||
"wpa2-psk", "wpa3-psk", "wpa2-psk,wpa3-psk"
|
||||
]), help: "wpa2-psk,wpa3-psk deckt sowohl ältere als auch neuere Geräte ab."),
|
||||
RouterOSFieldSchema(key: "security.passphrase", label: "Passwort", kind: .text, help: "Mindestens 8 Zeichen."),
|
||||
RouterOSFieldSchema(key: "datapath.bridge", label: "Ziel-Bridge", kind: .text,
|
||||
help: "Name der Bridge, der dieses WLAN-Netz zugeordnet wird (üblicherweise dieselbe wie das kabelgebundene LAN)."),
|
||||
RouterOSFieldSchema(key: "disabled", label: "Deaktiviert", kind: .bool, help: "", defaultValue: "no")
|
||||
],
|
||||
listColumns: ["ssid"]
|
||||
),
|
||||
generic("/caps-man manager", "caps-man/manager", .wireless, "CAPsMAN-Zentrale",
|
||||
"Zentrale Verwaltung mehrerer WLAN-Access-Points von einem Router aus.",
|
||||
"Nur relevant mit mehreren WLAN-Access-Points, die zentral verwaltet werden sollen — eigenes, umfangreiches Konfigurationsschema (Konfigurationen/Kanäle/Datapaths).")
|
||||
]
|
||||
|
||||
// MARK: - Queues / QoS
|
||||
|
||||
private static let queueFamily: [RouterOSMenuSchema] = [
|
||||
RouterOSMenuSchema(
|
||||
menuPath: "/queue simple", restPath: "queue/simple", category: .queues,
|
||||
displayName: "Einfache Bandbreiten-Begrenzung",
|
||||
summary: "Begrenzt Up-/Download einer IP-Adresse oder eines Netzes.",
|
||||
explanation: "Reicht für die meisten Heim-/Kleinnetz-Fälle ohne Mangle-Markierungen aus.",
|
||||
fields: [
|
||||
RouterOSFieldSchema(key: "name", label: "Name", kind: .text, help: "Frei wählbar.", required: true),
|
||||
RouterOSFieldSchema(key: "target", label: "Ziel", kind: .text,
|
||||
help: "IP-Adresse oder Netz, das begrenzt wird, z.B. 192.168.88.50/32.", required: true),
|
||||
RouterOSFieldSchema(key: "max-limit", label: "Max. Bandbreite (Upload/Download)", kind: .text,
|
||||
help: "Zwei Werte getrennt durch \"/\", z.B. 10M/50M (Upload/Download).", required: true),
|
||||
RouterOSFieldSchema(key: "burst-limit", label: "Burst-Bandbreite", kind: .text,
|
||||
help: "Optional: kurzzeitig erlaubte höhere Bandbreite, z.B. 15M/60M."),
|
||||
RouterOSFieldSchema(key: "disabled", label: "Deaktiviert", kind: .bool, help: "", defaultValue: "no")
|
||||
],
|
||||
listColumns: ["name", "target", "max-limit"]
|
||||
),
|
||||
RouterOSMenuSchema(
|
||||
menuPath: "/queue tree", restPath: "queue/tree", category: .queues,
|
||||
displayName: "Queue-Baum",
|
||||
summary: "Erweiterte, hierarchische Bandbreiten-Steuerung basierend auf Mangle-Markierungen.",
|
||||
explanation: "Statt einer festen Adresse wirkt ein Queue-Baum-Eintrag auf Verkehr mit einer bestimmten Mangle-Markierung.",
|
||||
warning: "Setzt eine passende Mangle-Regel voraus, die genau diese Markierung setzt (siehe Firewall: Mangle) — ohne die greift ein Queue-Baum-Eintrag ins Leere.",
|
||||
fields: [
|
||||
RouterOSFieldSchema(key: "name", label: "Name", kind: .text, help: "Frei wählbar.", required: true),
|
||||
RouterOSFieldSchema(key: "parent", label: "Übergeordnet", kind: .text,
|
||||
help: "Ein Interface (z.B. der WAN-Port) oder der Name eines anderen Queue-Baum-Eintrags.", required: true),
|
||||
RouterOSFieldSchema(key: "packet-mark", label: "Paket-/Verbindungsmarkierung", kind: .text,
|
||||
help: "Name der Mangle-Markierung, auf die dieser Eintrag wirkt."),
|
||||
RouterOSFieldSchema(key: "max-limit", label: "Max. Bandbreite", kind: .text, help: "Einzelwert, z.B. 20M."),
|
||||
RouterOSFieldSchema(key: "disabled", label: "Deaktiviert", kind: .bool, help: "", defaultValue: "no")
|
||||
],
|
||||
listColumns: ["name", "parent", "max-limit"]
|
||||
)
|
||||
]
|
||||
|
||||
// MARK: - System
|
||||
|
||||
private static let systemFamily: [RouterOSMenuSchema] = [
|
||||
RouterOSMenuSchema(
|
||||
menuPath: "/system identity", restPath: "system/identity", category: .system,
|
||||
displayName: "Router-Name",
|
||||
summary: "Der Name, unter dem sich der Router meldet (z.B. in Winbox/Terminal-Prompt).",
|
||||
explanation: "",
|
||||
fields: [RouterOSFieldSchema(key: "name", label: "Name", kind: .text, help: "", required: true)],
|
||||
listColumns: ["name"],
|
||||
isSingleton: true
|
||||
),
|
||||
RouterOSMenuSchema(
|
||||
menuPath: "/system clock", restPath: "system/clock", category: .system,
|
||||
displayName: "Uhrzeit/Zeitzone", summary: "", explanation: "",
|
||||
fields: [
|
||||
RouterOSFieldSchema(key: "time-zone-name", label: "Zeitzone", kind: .text,
|
||||
help: "Z.B. Europe/Berlin.")
|
||||
],
|
||||
listColumns: ["time-zone-name"],
|
||||
isSingleton: true
|
||||
),
|
||||
RouterOSMenuSchema(
|
||||
menuPath: "/system ntp client", restPath: "system/ntp/client", category: .system,
|
||||
displayName: "Zeitserver (NTP)",
|
||||
summary: "Hält die Router-Uhr über einen Zeitserver synchron.",
|
||||
explanation: "",
|
||||
warning: "Falsche Systemzeit kann Zertifikatsprüfungen (HTTPS/REST) und Log-Zeitstempel durcheinanderbringen.",
|
||||
fields: [
|
||||
RouterOSFieldSchema(key: "enabled", label: "Aktiviert", kind: .bool, help: "", defaultValue: "yes"),
|
||||
RouterOSFieldSchema(key: "servers", label: "Zeitserver", kind: .text,
|
||||
help: "Ein oder mehrere Server, kommagetrennt, z.B. pool.ntp.org.")
|
||||
],
|
||||
listColumns: ["servers"],
|
||||
isSingleton: true
|
||||
),
|
||||
generic("/system logging", "system/logging", .system, "Protokollierung",
|
||||
"Was der Router mitschreibt und wohin (Speicher, Datei, Remote-Syslog, E-Mail).",
|
||||
"Besteht aus \"rules\" (was protokolliert wird) und \"actions\" (wohin) — hier generischer Zugriff, verwandte Teile über \"Eigener Menüpfad\" (z.B. \"/system logging action\")."),
|
||||
RouterOSMenuSchema(
|
||||
menuPath: "/system scheduler", restPath: "system/scheduler", category: .system,
|
||||
displayName: "Zeitplaner",
|
||||
summary: "Führt ein hinterlegtes Skript zu festen Zeiten/Intervallen aus.",
|
||||
explanation: "",
|
||||
warning: "Ein fehlerhaftes geplantes Skript kann unbeaufsichtigt wiederholt Änderungen vornehmen — vor dem Einplanen einmal manuell testen.",
|
||||
fields: [
|
||||
RouterOSFieldSchema(key: "name", label: "Name", kind: .text, help: "", required: true),
|
||||
RouterOSFieldSchema(key: "start-time", label: "Startzeit", kind: .text, help: "Z.B. 00:00:00 oder \"startup\"."),
|
||||
RouterOSFieldSchema(key: "interval", label: "Intervall", kind: .text,
|
||||
help: "Wie oft wiederholt wird, z.B. 1d oder 01:00:00. Leer = nur einmal zur Startzeit."),
|
||||
RouterOSFieldSchema(key: "on-event", label: "Auszuführendes Skript",
|
||||
kind: .menuItemPick(menuPath: "/system script", restPath: "system/script", valueField: "name"),
|
||||
help: "Name eines zuvor unter \"Skripte\" angelegten Skripts."),
|
||||
RouterOSFieldSchema(key: "disabled", label: "Deaktiviert", kind: .bool, help: "", defaultValue: "no")
|
||||
],
|
||||
listColumns: ["name", "interval"]
|
||||
),
|
||||
RouterOSMenuSchema(
|
||||
menuPath: "/system script", restPath: "system/script", category: .system,
|
||||
displayName: "Skripte",
|
||||
summary: "Gespeicherte RouterOS-Befehlsfolgen, die manuell oder per Scheduler ausgeführt werden.",
|
||||
explanation: "",
|
||||
warning: "Skripte laufen mit den Rechten des Routers selbst — kein Unterschied zu manuell eingegebenen Befehlen.",
|
||||
fields: [
|
||||
RouterOSFieldSchema(key: "name", label: "Name", kind: .text, help: "", required: true),
|
||||
RouterOSFieldSchema(key: "source", label: "Skript-Inhalt", kind: .text,
|
||||
help: "RouterOS-Befehle, z.B. \":log info \\\"Test\\\"\".")
|
||||
],
|
||||
listColumns: ["name"]
|
||||
),
|
||||
RouterOSMenuSchema(
|
||||
menuPath: "/user", restPath: "user", category: .system,
|
||||
displayName: "Benutzerkonten",
|
||||
summary: "Zugangskonten für den Router (Winbox/SSH/REST/Terminal).",
|
||||
explanation: "",
|
||||
warning: "Das zum Verbinden genutzte eigene Konto hier nicht versehentlich löschen oder herabstufen.",
|
||||
fields: [
|
||||
RouterOSFieldSchema(key: "name", label: "Benutzername", kind: .text, help: "", required: true),
|
||||
RouterOSFieldSchema(key: "password", label: "Passwort", kind: .text, help: ""),
|
||||
RouterOSFieldSchema(key: "group", label: "Rechte-Gruppe", kind: .enumPick(options: ["full", "write", "read"]),
|
||||
help: "full = Vollzugriff, write = ohne Benutzerverwaltung, read = nur lesen.", defaultValue: "full"),
|
||||
RouterOSFieldSchema(key: "disabled", label: "Deaktiviert", kind: .bool, help: "", defaultValue: "no")
|
||||
],
|
||||
listColumns: ["name", "group"]
|
||||
)
|
||||
]
|
||||
|
||||
// MARK: - Tools
|
||||
|
||||
private static let toolFamily: [RouterOSMenuSchema] = [
|
||||
RouterOSMenuSchema(
|
||||
menuPath: "/tool netwatch", restPath: "tool/netwatch", category: .tools,
|
||||
displayName: "Netwatch",
|
||||
summary: "Überwacht laufend, ob eine Adresse erreichbar ist, und kann bei Statuswechsel ein Skript auslösen.",
|
||||
explanation: "",
|
||||
fields: [
|
||||
RouterOSFieldSchema(key: "host", label: "Zu überwachende Adresse", kind: .text, help: "", required: true),
|
||||
RouterOSFieldSchema(key: "interval", label: "Prüf-Intervall", kind: .text, help: "Z.B. 00:00:10.", defaultValue: "00:00:10"),
|
||||
RouterOSFieldSchema(key: "up-script", label: "Skript bei Erreichbarkeit", kind: .text,
|
||||
help: "Name eines Skripts unter \"Skripte\"."),
|
||||
RouterOSFieldSchema(key: "down-script", label: "Skript bei Nichterreichbarkeit", kind: .text, help: ""),
|
||||
RouterOSFieldSchema(key: "disabled", label: "Deaktiviert", kind: .bool, help: "", defaultValue: "no")
|
||||
],
|
||||
listColumns: ["host"]
|
||||
),
|
||||
RouterOSMenuSchema(
|
||||
menuPath: "/tool e-mail", restPath: "tool/e-mail", category: .tools,
|
||||
displayName: "E-Mail-Versand",
|
||||
summary: "Postausgangs-Server, den Scheduler/Netwatch/Skripte für Benachrichtigungen nutzen.",
|
||||
explanation: "Ein einzelner, geräteweiter Satz Einstellungen — kein Menü mit mehreren Einträgen.",
|
||||
fields: [
|
||||
RouterOSFieldSchema(key: "address", label: "Mailserver-Adresse", kind: .text, help: "", required: true),
|
||||
RouterOSFieldSchema(key: "port", label: "Port", kind: .int, help: "Meist 587 (STARTTLS) oder 465 (SSL)."),
|
||||
RouterOSFieldSchema(key: "from", label: "Absenderadresse", kind: .text, help: ""),
|
||||
RouterOSFieldSchema(key: "user", label: "Benutzername", kind: .text, help: ""),
|
||||
RouterOSFieldSchema(key: "password", label: "Passwort", kind: .text, help: "")
|
||||
],
|
||||
listColumns: ["address"],
|
||||
isSingleton: true
|
||||
)
|
||||
]
|
||||
|
||||
private static func generic(
|
||||
_ menuPath: String,
|
||||
_ restPath: String,
|
||||
_ category: RouterOSMenuCategory,
|
||||
_ displayName: String,
|
||||
_ summary: String,
|
||||
_ explanation: String = "",
|
||||
warning: String? = nil
|
||||
) -> RouterOSMenuSchema {
|
||||
RouterOSMenuSchema(
|
||||
menuPath: menuPath,
|
||||
restPath: restPath,
|
||||
category: category,
|
||||
displayName: displayName,
|
||||
summary: summary,
|
||||
explanation: explanation.isEmpty ? summary : explanation,
|
||||
warning: warning,
|
||||
fields: []
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -66,13 +66,48 @@ final class RestTransport: NSObject, RouterOSTransport {
|
||||
/// `POST /rest/<restPath>`. `.set` has no CLI-style inline lookup on REST, so it first
|
||||
/// `GET`s the collection to find the item whose `matchField` equals `matchValue`, reads
|
||||
/// its RouterOS-internal `.id`, then `PATCH`es `restPath/<id>`.
|
||||
func fetchMenuItems(menuPath: String, restPath: String) async throws -> [RouterOSMenuItem] {
|
||||
let data = try await send(path: restPath, method: "GET", jsonBody: nil)
|
||||
if let array = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] {
|
||||
return array.map { Self.menuItem(from: $0) }
|
||||
}
|
||||
// Singleton menu (e.g. "/ip dns") — REST returns one JSON object, not an array.
|
||||
if let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
|
||||
return [Self.menuItem(from: object)]
|
||||
}
|
||||
throw RouterOSError.invalidResponse(restPath)
|
||||
}
|
||||
|
||||
private static func menuItem(from item: [String: Any]) -> RouterOSMenuItem {
|
||||
var fields: [String: String] = [:]
|
||||
var id = ""
|
||||
for (key, value) in item {
|
||||
let stringValue = (value as? String) ?? String(describing: value)
|
||||
if key == ".id" {
|
||||
id = stringValue
|
||||
} else {
|
||||
fields[key] = stringValue
|
||||
}
|
||||
}
|
||||
return RouterOSMenuItem(id: id.isEmpty ? "singleton" : id, fields: fields)
|
||||
}
|
||||
|
||||
func apply(_ command: RouterOSCommand) async throws {
|
||||
switch command.operation {
|
||||
case .add:
|
||||
_ = try await send(path: command.restPath, method: "POST", jsonBody: command.arguments)
|
||||
case .set(let matchField, let matchValue):
|
||||
// Empty matchField = singleton menu (e.g. "/ip dns") — PATCH the resource directly,
|
||||
// there's no list/id to look up. See RouterOSCommand.cliLine for the SSH counterpart.
|
||||
if matchField.isEmpty {
|
||||
_ = try await send(path: command.restPath, method: "PATCH", jsonBody: command.arguments)
|
||||
} else {
|
||||
let itemID = try await findItemID(path: command.restPath, matchField: matchField, matchValue: matchValue)
|
||||
_ = try await send(path: "\(command.restPath)/\(itemID)", method: "PATCH", jsonBody: command.arguments)
|
||||
}
|
||||
case .remove(let matchField, let matchValue):
|
||||
let itemID = try await findItemID(path: command.restPath, matchField: matchField, matchValue: matchValue)
|
||||
_ = try await send(path: "\(command.restPath)/\(itemID)", method: "PATCH", jsonBody: command.arguments)
|
||||
_ = try await send(path: "\(command.restPath)/\(itemID)", method: "DELETE", jsonBody: nil)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -40,6 +40,36 @@ enum RouterOSCliParser {
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses `<any menu> print without-paging terse` output generically — used by the Expert
|
||||
/// tool for menus without a curated parser (i.e. almost all of them). Each line's `.id=*N`
|
||||
/// field (present in terse output) becomes the item's `id`; everything else becomes `fields`.
|
||||
/// A line without `.id` falls back to a synthesized "row-<index>" rather than being dropped,
|
||||
/// so a still-listable-but-not-editable item is visible instead of silently missing.
|
||||
static func parseGenericItems(_ raw: String) -> [RouterOSMenuItem] {
|
||||
raw.split(whereSeparator: \.isNewline).enumerated().compactMap { index, line in
|
||||
var fields = keyValues(from: String(line))
|
||||
guard !fields.isEmpty else { return nil }
|
||||
let id = fields.removeValue(forKey: ".id") ?? "row-\(index)"
|
||||
return RouterOSMenuItem(id: id, fields: fields)
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses `<singleton menu> print` output (colon-separated "key: value" lines, same shape as
|
||||
/// `/system resource print`) into one generic item with the fixed id "singleton" — used for
|
||||
/// menus that hold exactly one settable item rather than a list (confirmed live: these
|
||||
/// reject "print terse" with "bad parameter terse").
|
||||
static func parseSingletonItem(_ raw: String) -> RouterOSMenuItem {
|
||||
var fields: [String: String] = [:]
|
||||
for line in raw.split(whereSeparator: \.isNewline) {
|
||||
guard let colonIndex = line.firstIndex(of: ":") else { continue }
|
||||
let key = String(line[line.startIndex..<colonIndex]).trimmingCharacters(in: .whitespaces)
|
||||
let value = String(line[line.index(after: colonIndex)...]).trimmingCharacters(in: .whitespaces)
|
||||
guard !key.isEmpty else { continue }
|
||||
fields[key] = value
|
||||
}
|
||||
return RouterOSMenuItem(id: "singleton", fields: fields)
|
||||
}
|
||||
|
||||
/// RouterOS CLI output mixes "true"/"false" and "yes"/"no" for booleans depending on field/version.
|
||||
private static func isTrue(_ value: String?) -> Bool {
|
||||
value == "true" || value == "yes"
|
||||
@@ -47,7 +77,7 @@ enum RouterOSCliParser {
|
||||
|
||||
private static func keyValues(from text: String) -> [String: String] {
|
||||
var result: [String: String] = [:]
|
||||
let pattern = #"([a-zA-Z0-9-]+)=("[^"]*"|\S+)"#
|
||||
let pattern = #"([.a-zA-Z0-9-]+)=("[^"]*"|\S+)"#
|
||||
guard let regex = try? NSRegularExpression(pattern: pattern) else { return result }
|
||||
let nsText = text as NSString
|
||||
let matches = regex.matches(in: text, range: NSRange(location: 0, length: nsText.length))
|
||||
|
||||
@@ -11,6 +11,9 @@ protocol RouterOSTransport: AnyObject {
|
||||
func fetchDeviceInfo() async throws -> RouterDeviceInfo
|
||||
func fetchInterfaces() async throws -> [NetworkInterface]
|
||||
func fetchFirewallRuleCounts() async throws -> FirewallRuleCounts
|
||||
/// Lists existing items under any RouterOS menu — the generic read side of the Expert tool,
|
||||
/// works for menus without a curated `RouterOSMenuSchema` too.
|
||||
func fetchMenuItems(menuPath: String, restPath: String) async throws -> [RouterOSMenuItem]
|
||||
func apply(_ command: RouterOSCommand) async throws
|
||||
func disconnect() async
|
||||
}
|
||||
|
||||
@@ -67,8 +67,48 @@ final class SSHTransport: RouterOSTransport {
|
||||
return FirewallRuleCounts(filterRuleCount: filterCount, natRuleCount: natCount)
|
||||
}
|
||||
|
||||
/// RouterOS 7.24.2 (verified live against a hEX test device) does *not* include `.id` in
|
||||
/// `print terse` CLI output, unlike REST's JSON, which always does — every field parses
|
||||
/// fine, but every item's `id` would otherwise fall back to a synthetic, unusable "row-N".
|
||||
/// `:put [<menuPath> find]` returns the real internal IDs (e.g. "*C;*1;*2") in the same
|
||||
/// order as `print terse` (also verified live: position 0 → first ID, position 1 → second),
|
||||
/// so they're overlaid onto the parsed items by position here.
|
||||
func fetchMenuItems(menuPath: String, restPath: String) async throws -> [RouterOSMenuItem] {
|
||||
let output: String
|
||||
do {
|
||||
output = try await run("\(menuPath) print without-paging terse")
|
||||
} catch RouterOSError.invalidResponse(let detail) where detail.contains("bad parameter terse") {
|
||||
// Singleton menu (e.g. "/ip dns", "/system identity") — no list, no "terse" support.
|
||||
let plain = try await run("\(menuPath) print without-paging")
|
||||
return [RouterOSCliParser.parseSingletonItem(plain)]
|
||||
}
|
||||
var items = RouterOSCliParser.parseGenericItems(output)
|
||||
|
||||
let idOutput = try await run(":put [\(menuPath) find]")
|
||||
let ids = idOutput
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.split(separator: ";")
|
||||
.map(String.init)
|
||||
for index in items.indices where index < ids.count {
|
||||
items[index] = RouterOSMenuItem(id: ids[index], fields: items[index].fields)
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
/// RouterOS' SSH CLI exits 0 even when a command fails — confirmed live: both
|
||||
/// `/ip dhcp-server add ...` on an interface that already has one ("failure: server or
|
||||
/// relay with such interface already exists") and an invalid action ("syntax error (line 1
|
||||
/// column 46)") returned exit status 0, meaning `run()`'s exit-code check alone silently
|
||||
/// treats every such failure as success. A mutating command (add/set/remove) is always
|
||||
/// silent on success in every case observed live this session (15+ menu families) — so any
|
||||
/// non-empty output here is treated as the error text, since there is no more reliable
|
||||
/// signal available over this transport.
|
||||
func apply(_ command: RouterOSCommand) async throws {
|
||||
_ = try await run(command.cliLine)
|
||||
let output = try await run(command.cliLine)
|
||||
let trimmed = output.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard trimmed.isEmpty else {
|
||||
throw RouterOSError.invalidResponse(trimmed)
|
||||
}
|
||||
}
|
||||
|
||||
/// Restores RouterOS' own vendor-default configuration and reboots the device. See
|
||||
|
||||
@@ -22,6 +22,15 @@ final class ConnectionService: ObservableObject {
|
||||
private let certificateTrust: CertificateTrustStore
|
||||
private let sshHostKeyTrust: SSHHostKeyTrustStore
|
||||
private var activeTransport: RouterOSTransport?
|
||||
/// Whether the Expert tool has already made its one automatic safety backup for this
|
||||
/// connection — it backs up before its first write, not before every single edit (unlike
|
||||
/// the Einrichten-Wizard, which backs up before every "Jetzt anwenden"). Reset on
|
||||
/// `disconnect()` so a new connection gets a fresh backup again.
|
||||
private(set) var hasExpertToolBackedUpThisSession = false
|
||||
|
||||
func markExpertToolBackedUpThisSession() {
|
||||
hasExpertToolBackedUpThisSession = true
|
||||
}
|
||||
|
||||
init(
|
||||
certificateTrust: CertificateTrustStore = CertificateTrustStore(),
|
||||
@@ -90,6 +99,12 @@ final class ConnectionService: ObservableObject {
|
||||
return try await activeTransport.fetchFirewallRuleCounts()
|
||||
}
|
||||
|
||||
/// Generic read for the Expert tool — lists existing items under any RouterOS menu path.
|
||||
func fetchMenuItems(menuPath: String, restPath: String) async throws -> [RouterOSMenuItem] {
|
||||
guard let activeTransport else { throw RouterOSError.notConnected }
|
||||
return try await activeTransport.fetchMenuItems(menuPath: menuPath, restPath: restPath)
|
||||
}
|
||||
|
||||
private func finishConnecting(using transport: RouterOSTransport) async {
|
||||
activeTransport = transport
|
||||
do {
|
||||
@@ -107,6 +122,7 @@ final class ConnectionService: ObservableObject {
|
||||
deviceInfo = nil
|
||||
interfaces = []
|
||||
credentials = nil
|
||||
hasExpertToolBackedUpThisSession = false
|
||||
state = .idle
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,302 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ExpertMenuDetailView: View {
|
||||
@ObservedObject var viewModel: ExpertViewModel
|
||||
let schema: RouterOSMenuSchema
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section {
|
||||
Text(schema.explanation)
|
||||
if let warning = schema.warning {
|
||||
Label(warning, systemImage: "exclamationmark.triangle")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
if viewModel.isLoading {
|
||||
ProgressView()
|
||||
} else if let error = viewModel.loadError {
|
||||
Text(error).foregroundStyle(.red)
|
||||
} else if viewModel.items.isEmpty {
|
||||
Text("Keine Einträge unter \(schema.menuPath).").foregroundStyle(.secondary)
|
||||
} else {
|
||||
ForEach(viewModel.items) { item in
|
||||
HStack {
|
||||
Button {
|
||||
viewModel.startEditing(item)
|
||||
} label: {
|
||||
Text(rowLabel(for: item))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
Spacer()
|
||||
// Not ".swipeActions" — that's an iOS/iPadOS gesture with no
|
||||
// equivalent on macOS List rows, so it never showed anything here.
|
||||
if !schema.isSingleton {
|
||||
Button(role: .destructive) {
|
||||
viewModel.pendingRemoval = item
|
||||
} label: {
|
||||
Image(systemName: "trash")
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.help("Eintrag löschen")
|
||||
}
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
HStack {
|
||||
Text(schema.isSingleton ? "Einstellungen" : "Einträge")
|
||||
Spacer()
|
||||
if !schema.isSingleton {
|
||||
Button("Neu hinzufügen") { viewModel.startNewItem() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.navigationTitle(schema.displayName)
|
||||
.task(id: schema.id) { await viewModel.reloadItems() }
|
||||
.sheet(item: $viewModel.editingItem) { item in
|
||||
ExpertItemEditView(
|
||||
viewModel: viewModel,
|
||||
schema: schema,
|
||||
isNew: item.id.isEmpty
|
||||
)
|
||||
}
|
||||
.confirmationDialog(
|
||||
"Eintrag wirklich löschen?",
|
||||
isPresented: Binding(
|
||||
get: { viewModel.pendingRemoval != nil },
|
||||
set: { if !$0 { viewModel.pendingRemoval = nil } }
|
||||
),
|
||||
presenting: viewModel.pendingRemoval
|
||||
) { item in
|
||||
Button("Löschen", role: .destructive) {
|
||||
Task { await viewModel.confirmRemoval(item) }
|
||||
}
|
||||
Button("Abbrechen", role: .cancel) {}
|
||||
} message: { item in
|
||||
Text(rowLabel(for: item))
|
||||
}
|
||||
}
|
||||
|
||||
private func rowLabel(for item: RouterOSMenuItem) -> String {
|
||||
let columns = schema.listColumns.isEmpty ? Array(item.fields.keys.sorted()) : schema.listColumns
|
||||
let parts = columns.compactMap { key in item.fields[key].map { "\(key)=\($0)" } }
|
||||
return parts.isEmpty ? item.id : parts.joined(separator: " · ")
|
||||
}
|
||||
}
|
||||
|
||||
/// Add/edit sheet: curated fields (with tooltips) from the schema, plus a free-form "weitere
|
||||
/// Parameter" list for anything the schema doesn't curate — so every field RouterOS actually
|
||||
/// supports stays reachable even where this app hasn't described it yet.
|
||||
private struct ExpertItemEditView: View {
|
||||
@ObservedObject var viewModel: ExpertViewModel
|
||||
let schema: RouterOSMenuSchema
|
||||
let isNew: Bool
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var showApplyConfirmation = false
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
ForEach(schema.fields) { field in
|
||||
fieldEditor(for: field)
|
||||
}
|
||||
|
||||
Section("Weitere Parameter (frei)") {
|
||||
Text("Für alles, was oben nicht als eigenes Feld aufgeführt ist — RouterOS-Parametername genau wie in der Dokumentation.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
ForEach($viewModel.extraFields) { $extra in
|
||||
HStack {
|
||||
TextField("Parameter", text: $extra.key)
|
||||
TextField("Wert", text: $extra.value)
|
||||
Button(role: .destructive) {
|
||||
viewModel.extraFields.removeAll { $0.id == extra.id }
|
||||
} label: {
|
||||
Image(systemName: "minus.circle")
|
||||
}
|
||||
}
|
||||
}
|
||||
Button("Parameter hinzufügen") {
|
||||
viewModel.extraFields.append(.init())
|
||||
}
|
||||
}
|
||||
|
||||
if let command = viewModel.pendingCommand {
|
||||
Section("Wird ausgeführt") {
|
||||
Text(command.cliLine)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
if let error = viewModel.applyError {
|
||||
Text(error).foregroundStyle(.red)
|
||||
}
|
||||
|
||||
Section {
|
||||
HStack {
|
||||
Button("Abbrechen") {
|
||||
viewModel.cancelEditing()
|
||||
dismiss()
|
||||
}
|
||||
Spacer()
|
||||
Button(isNew ? "Anlegen" : "Speichern") {
|
||||
showApplyConfirmation = true
|
||||
}
|
||||
.disabled(viewModel.isApplying || viewModel.pendingCommand == nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.frame(minWidth: 420, minHeight: 480)
|
||||
.confirmationDialog(
|
||||
"Jetzt am Router anwenden?",
|
||||
isPresented: $showApplyConfirmation,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button(isNew ? "Anlegen" : "Speichern") {
|
||||
Task {
|
||||
await viewModel.saveEditingItem()
|
||||
if viewModel.applyError == nil { dismiss() }
|
||||
}
|
||||
}
|
||||
Button("Abbrechen", role: .cancel) {}
|
||||
} message: {
|
||||
if let command = viewModel.pendingCommand {
|
||||
Text(command.cliLine)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func fieldEditor(for field: RouterOSFieldSchema) -> some View {
|
||||
let binding = Binding<String>(
|
||||
get: { viewModel.formValues[field.key] ?? "" },
|
||||
set: { viewModel.formValues[field.key] = $0 }
|
||||
)
|
||||
Group {
|
||||
switch field.kind {
|
||||
case .text, .int:
|
||||
TextField(field.label, text: binding)
|
||||
case .bool:
|
||||
Toggle(field.label, isOn: Binding(
|
||||
get: { binding.wrappedValue == "yes" || binding.wrappedValue == "true" },
|
||||
set: { binding.wrappedValue = $0 ? "yes" : "no" }
|
||||
))
|
||||
case .enumPick(let options):
|
||||
Picker(field.label, selection: binding) {
|
||||
Text("–").tag("")
|
||||
ForEach(options, id: \.self) { option in
|
||||
Text(option).tag(option)
|
||||
}
|
||||
}
|
||||
case .interfacePick:
|
||||
Picker(field.label, selection: binding) {
|
||||
Text("–").tag("")
|
||||
ForEach(viewModel.liveInterfaceNames, id: \.self) { name in
|
||||
Text(name).tag(name)
|
||||
}
|
||||
}
|
||||
case .menuItemPick(let menuPath, _, _):
|
||||
let options = viewModel.crossReferenceOptions[menuPath] ?? []
|
||||
Picker(field.label, selection: binding) {
|
||||
Text("–").tag("")
|
||||
ForEach(options, id: \.self) { option in
|
||||
Text(option).tag(option)
|
||||
}
|
||||
}
|
||||
case .duration:
|
||||
HStack {
|
||||
Text(field.label)
|
||||
Spacer()
|
||||
DurationFieldEditor(value: binding)
|
||||
}
|
||||
}
|
||||
}
|
||||
.help(field.help.isEmpty ? field.label : field.help)
|
||||
}
|
||||
}
|
||||
|
||||
/// Day/hour/minute/second steppers for a RouterOS time value (e.g. "1d12h30m") instead of a
|
||||
/// bare text field where the suffix syntax isn't obvious. Reads any combination of RouterOS'
|
||||
/// "<number><unit>" tokens (or a bare number, which RouterOS treats as seconds) and always
|
||||
/// writes back the explicit suffixed form.
|
||||
private struct DurationFieldEditor: View {
|
||||
@Binding var value: String
|
||||
|
||||
@State private var days = 0
|
||||
@State private var hours = 0
|
||||
@State private var minutes = 0
|
||||
@State private var seconds = 0
|
||||
@State private var hasInitialized = false
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 10) {
|
||||
component("T", $days, 0...365)
|
||||
component("Std", $hours, 0...23)
|
||||
component("Min", $minutes, 0...59)
|
||||
component("Sek", $seconds, 0...59)
|
||||
}
|
||||
.onAppear {
|
||||
guard !hasInitialized else { return }
|
||||
hasInitialized = true
|
||||
parse(value)
|
||||
}
|
||||
.onChange(of: days) { _, _ in commit() }
|
||||
.onChange(of: hours) { _, _ in commit() }
|
||||
.onChange(of: minutes) { _, _ in commit() }
|
||||
.onChange(of: seconds) { _, _ in commit() }
|
||||
}
|
||||
|
||||
private func component(_ unit: String, _ binding: Binding<Int>, _ range: ClosedRange<Int>) -> some View {
|
||||
VStack(spacing: 2) {
|
||||
Stepper(value: binding, in: range) {
|
||||
Text("\(binding.wrappedValue)")
|
||||
.monospacedDigit()
|
||||
.frame(minWidth: 26)
|
||||
}
|
||||
Text(unit)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
private func parse(_ raw: String) {
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespaces)
|
||||
guard !trimmed.isEmpty else { return }
|
||||
if let bareSeconds = Int(trimmed) {
|
||||
seconds = bareSeconds % 60
|
||||
minutes = (bareSeconds / 60) % 60
|
||||
hours = (bareSeconds / 3600) % 24
|
||||
days = bareSeconds / 86400
|
||||
return
|
||||
}
|
||||
guard let regex = try? NSRegularExpression(pattern: #"(\d+)([dhms])"#) else { return }
|
||||
let nsText = trimmed as NSString
|
||||
for match in regex.matches(in: trimmed, range: NSRange(location: 0, length: nsText.length)) {
|
||||
guard let amount = Int(nsText.substring(with: match.range(at: 1))) else { continue }
|
||||
switch nsText.substring(with: match.range(at: 2)) {
|
||||
case "d": days = amount
|
||||
case "h": hours = amount
|
||||
case "m": minutes = amount
|
||||
case "s": seconds = amount
|
||||
default: break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func commit() {
|
||||
var parts: [String] = []
|
||||
if days > 0 { parts.append("\(days)d") }
|
||||
if hours > 0 { parts.append("\(hours)h") }
|
||||
if minutes > 0 { parts.append("\(minutes)m") }
|
||||
if seconds > 0 { parts.append("\(seconds)s") }
|
||||
value = parts.joined()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ExpertView: View {
|
||||
@ObservedObject var connectionService: ConnectionService
|
||||
@StateObject private var viewModel: ExpertViewModel
|
||||
|
||||
init(connectionService: ConnectionService) {
|
||||
self.connectionService = connectionService
|
||||
_viewModel = StateObject(wrappedValue: ExpertViewModel(connectionService: connectionService))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if connectionService.credentials == nil {
|
||||
ContentUnavailableView(
|
||||
"Nicht verbunden",
|
||||
systemImage: "network.slash",
|
||||
description: Text("Verbinde dich zuerst im Tab \"Verbinden\" mit deinem Router.")
|
||||
)
|
||||
} else {
|
||||
NavigationSplitView {
|
||||
List {
|
||||
ForEach(RouterOSMenuCategory.allCases) { category in
|
||||
let schemas = RouterOSSchemaCatalog.schemas(in: category)
|
||||
if !schemas.isEmpty {
|
||||
Section(category.rawValue) {
|
||||
ForEach(schemas) { schema in
|
||||
Button(schema.displayName) {
|
||||
viewModel.open(schema)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section("Eigener Menüpfad") {
|
||||
Text("Jeder RouterOS-Menüpfad ist erreichbar, auch wenn er oben nicht gelistet ist — die Felder erscheinen dann generisch (Schlüssel/Wert), ohne kuratierte Erklärung.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
TextField("Menüpfad, z.B. /ip firewall filter", text: $viewModel.customMenuPath)
|
||||
.help("Der RouterOS-CLI-Pfad, z.B. \"/interface wireguard\" oder \"/routing ospf instance\".")
|
||||
TextField("REST-Pfad, z.B. ip/firewall/filter", text: $viewModel.customRestPath)
|
||||
.help("Derselbe Pfad in REST-API-Schreibweise: führende \"/\" weg, Leerzeichen zu \"/\".")
|
||||
Button("Öffnen") { viewModel.openCustomPath() }
|
||||
.disabled(viewModel.customMenuPath.isEmpty || viewModel.customRestPath.isEmpty)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Experte")
|
||||
} detail: {
|
||||
if let schema = viewModel.selectedSchema {
|
||||
ExpertMenuDetailView(
|
||||
viewModel: viewModel,
|
||||
schema: schema
|
||||
)
|
||||
} else {
|
||||
ContentUnavailableView(
|
||||
"Kein Menü ausgewählt",
|
||||
systemImage: "list.bullet.rectangle",
|
||||
description: Text("Wähle links einen RouterOS-Bereich aus.")
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
ExpertView(connectionService: ConnectionService())
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
final class ExpertViewModel: ObservableObject {
|
||||
/// One raw, uncurated key=value field — the escape hatch that keeps every RouterOS menu
|
||||
/// reachable even where `RouterOSMenuSchema.fields` doesn't (yet) curate every parameter.
|
||||
struct ExtraField: Identifiable, Equatable {
|
||||
let id = UUID()
|
||||
var key: String = ""
|
||||
var value: String = ""
|
||||
}
|
||||
|
||||
@Published var selectedSchema: RouterOSMenuSchema?
|
||||
@Published var customMenuPath: String = ""
|
||||
@Published var customRestPath: String = ""
|
||||
|
||||
@Published private(set) var items: [RouterOSMenuItem] = []
|
||||
@Published private(set) var isLoading = false
|
||||
@Published private(set) var loadError: String?
|
||||
/// Options for `.menuItemPick` fields, keyed by that field's referenced `menuPath` — e.g.
|
||||
/// existing pool names for a DHCP server's "address-pool" field. Refreshed alongside
|
||||
/// `items` so a pool/profile/script created moments earlier already shows up.
|
||||
@Published private(set) var crossReferenceOptions: [String: [String]] = [:]
|
||||
/// Live interface names for `.interfacePick` fields, refreshed alongside `items` — unlike
|
||||
/// `ConnectionService.interfaces` (fetched once at connect time), this picks up interfaces
|
||||
/// created after connecting (e.g. a VLAN added minutes earlier in this same Expert tab).
|
||||
/// Confirmed live: a DHCP server's "interface" field didn't offer a just-created VLAN until
|
||||
/// this was added.
|
||||
@Published private(set) var liveInterfaceNames: [String] = []
|
||||
|
||||
/// Non-nil while the add/edit sheet is open. An empty `id` means "new item".
|
||||
@Published var editingItem: RouterOSMenuItem?
|
||||
@Published var formValues: [String: String] = [:]
|
||||
@Published var extraFields: [ExtraField] = []
|
||||
|
||||
@Published private(set) var isApplying = false
|
||||
@Published private(set) var applyError: String?
|
||||
@Published var pendingRemoval: RouterOSMenuItem?
|
||||
|
||||
private let connectionService: ConnectionService
|
||||
private let backupService: BackupService
|
||||
|
||||
init(connectionService: ConnectionService, backupService: BackupService = BackupService()) {
|
||||
self.connectionService = connectionService
|
||||
self.backupService = backupService
|
||||
}
|
||||
|
||||
/// Backs up the current config once per connection, right before the Expert tool's first
|
||||
/// write — mirrors the Einrichten-Wizard's "always back up before applying" habit, just
|
||||
/// once per session here rather than before every single edit (an Expert-tool session can
|
||||
/// easily be a dozen small edits in a row).
|
||||
private func ensureSessionBackup() async throws {
|
||||
guard !connectionService.hasExpertToolBackedUpThisSession, let credentials = connectionService.credentials else { return }
|
||||
_ = try await backupService.createBackup(for: credentials)
|
||||
connectionService.markExpertToolBackedUpThisSession()
|
||||
}
|
||||
|
||||
func open(_ schema: RouterOSMenuSchema) {
|
||||
selectedSchema = schema
|
||||
items = []
|
||||
loadError = nil
|
||||
Task { await reloadItems() }
|
||||
}
|
||||
|
||||
/// Opens any RouterOS menu path the user types in directly, generic key=value form —
|
||||
/// the guarantee that literally any menu is reachable, curated or not.
|
||||
func openCustomPath() {
|
||||
let menuPath = customMenuPath.trimmingCharacters(in: .whitespaces)
|
||||
let restPath = customRestPath.trimmingCharacters(in: .whitespaces)
|
||||
guard !menuPath.isEmpty, !restPath.isEmpty else { return }
|
||||
open(RouterOSMenuSchema(
|
||||
menuPath: menuPath,
|
||||
restPath: restPath,
|
||||
category: .system,
|
||||
displayName: menuPath,
|
||||
summary: "Eigener Menüpfad — generischer Zugriff ohne kuratierte Felder.",
|
||||
explanation: "Alle Felder, die der Router für diesen Pfad zurückgibt, erscheinen unten als freie Schlüssel/Wert-Paare. Prüfe die RouterOS-Dokumentation für die genaue Bedeutung."
|
||||
))
|
||||
}
|
||||
|
||||
func reloadItems() async {
|
||||
guard let schema = selectedSchema else { return }
|
||||
isLoading = true
|
||||
loadError = nil
|
||||
do {
|
||||
items = try await connectionService.fetchMenuItems(menuPath: schema.menuPath, restPath: schema.restPath)
|
||||
} catch {
|
||||
loadError = error.localizedDescription
|
||||
}
|
||||
isLoading = false
|
||||
await loadCrossReferenceOptions(for: schema)
|
||||
await loadLiveInterfaceNames(for: schema)
|
||||
}
|
||||
|
||||
private func loadLiveInterfaceNames(for schema: RouterOSMenuSchema) async {
|
||||
guard schema.fields.contains(where: {
|
||||
if case .interfacePick = $0.kind { return true } else { return false }
|
||||
}) else { return }
|
||||
do {
|
||||
let interfaceItems = try await connectionService.fetchMenuItems(menuPath: "/interface", restPath: "interface")
|
||||
liveInterfaceNames = interfaceItems.compactMap { $0.fields["name"] }.sorted()
|
||||
} catch {
|
||||
liveInterfaceNames = []
|
||||
}
|
||||
}
|
||||
|
||||
private func loadCrossReferenceOptions(for schema: RouterOSMenuSchema) async {
|
||||
for field in schema.fields {
|
||||
guard case .menuItemPick(let menuPath, let restPath, let valueField) = field.kind else { continue }
|
||||
do {
|
||||
let referencedItems = try await connectionService.fetchMenuItems(menuPath: menuPath, restPath: restPath)
|
||||
let values = Set(referencedItems.compactMap { $0.fields[valueField] }).sorted()
|
||||
crossReferenceOptions[menuPath] = values
|
||||
} catch {
|
||||
crossReferenceOptions[menuPath] = []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func startNewItem() {
|
||||
guard let schema = selectedSchema else { return }
|
||||
editingItem = RouterOSMenuItem(id: "", fields: [:])
|
||||
formValues = Dictionary(uniqueKeysWithValues: schema.fields.compactMap { field in
|
||||
field.defaultValue.map { (field.key, $0) }
|
||||
})
|
||||
extraFields = []
|
||||
applyError = nil
|
||||
}
|
||||
|
||||
func startEditing(_ item: RouterOSMenuItem) {
|
||||
guard let schema = selectedSchema else { return }
|
||||
editingItem = item
|
||||
let curatedKeys = Set(schema.fields.map(\.key))
|
||||
formValues = item.fields.filter { curatedKeys.contains($0.key) }
|
||||
extraFields = item.fields
|
||||
.filter { !curatedKeys.contains($0.key) }
|
||||
.sorted { $0.key < $1.key }
|
||||
.map { ExtraField(key: $0.key, value: $0.value) }
|
||||
applyError = nil
|
||||
}
|
||||
|
||||
func cancelEditing() {
|
||||
editingItem = nil
|
||||
formValues = [:]
|
||||
extraFields = []
|
||||
applyError = nil
|
||||
}
|
||||
|
||||
/// The command that saving the current edit sheet would run — shown to the user before it
|
||||
/// actually executes, consistent with the rest of the app never applying silently.
|
||||
var pendingCommand: RouterOSCommand? {
|
||||
guard let schema = selectedSchema, let editingItem else { return nil }
|
||||
var arguments = formValues.filter { !$0.value.isEmpty }
|
||||
// RouterOS' CLI parser rejects "true"/"false" for boolean parameters — it only accepts
|
||||
// "yes"/"no" (confirmed live: "disabled=false" on "/interface vlan add" produced
|
||||
// "syntax error (line 1 column 30)"; "disabled=no" succeeded). Schema defaults are
|
||||
// occasionally written as "true"/"false" for readability, so normalize here regardless
|
||||
// of where the value came from rather than trust every call site to get it right.
|
||||
for field in schema.fields {
|
||||
guard case .bool = field.kind, let value = arguments[field.key] else { continue }
|
||||
if value == "true" { arguments[field.key] = "yes" }
|
||||
else if value == "false" { arguments[field.key] = "no" }
|
||||
}
|
||||
for extra in extraFields where !extra.key.isEmpty {
|
||||
arguments[extra.key] = extra.value
|
||||
}
|
||||
let isNew = editingItem.id.isEmpty
|
||||
let summary = "\(isNew ? "Neu anlegen" : "Ändern") unter \(schema.menuPath)"
|
||||
if isNew {
|
||||
return .add(menuPath: schema.menuPath, restPath: schema.restPath, arguments: arguments, summary: summary)
|
||||
} else if schema.isSingleton {
|
||||
// No "[find ...]" for a singleton menu — see RouterOSCommand.cliLine.
|
||||
return .set(
|
||||
menuPath: schema.menuPath, restPath: schema.restPath,
|
||||
matchField: "", matchValue: "",
|
||||
arguments: arguments, summary: summary
|
||||
)
|
||||
} else {
|
||||
return .set(
|
||||
menuPath: schema.menuPath, restPath: schema.restPath,
|
||||
matchField: ".id", matchValue: editingItem.id,
|
||||
arguments: arguments, summary: summary
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func saveEditingItem() async {
|
||||
guard let command = pendingCommand else { return }
|
||||
isApplying = true
|
||||
applyError = nil
|
||||
do {
|
||||
try await ensureSessionBackup()
|
||||
try await connectionService.apply(command)
|
||||
cancelEditing()
|
||||
await reloadItems()
|
||||
} catch {
|
||||
applyError = error.localizedDescription
|
||||
}
|
||||
isApplying = false
|
||||
}
|
||||
|
||||
func confirmRemoval(_ item: RouterOSMenuItem) async {
|
||||
guard let schema = selectedSchema else { return }
|
||||
isApplying = true
|
||||
applyError = nil
|
||||
do {
|
||||
let command = RouterOSCommand.remove(
|
||||
menuPath: schema.menuPath, restPath: schema.restPath,
|
||||
matchField: ".id", matchValue: item.id,
|
||||
summary: "Löschen unter \(schema.menuPath)"
|
||||
)
|
||||
try await ensureSessionBackup()
|
||||
try await connectionService.apply(command)
|
||||
pendingRemoval = nil
|
||||
await reloadItems()
|
||||
} catch {
|
||||
applyError = error.localizedDescription
|
||||
}
|
||||
isApplying = false
|
||||
}
|
||||
}
|
||||
@@ -6,14 +6,18 @@ struct FirewallStepView: View {
|
||||
var body: some View {
|
||||
Form {
|
||||
Section {
|
||||
Toggle(
|
||||
"Firewall-Grundschutz einrichten (empfohlen)",
|
||||
isOn: Binding(
|
||||
get: { viewModel.firewallSectionEnabled },
|
||||
set: { viewModel.setFirewallSectionEnabled($0) }
|
||||
if viewModel.mode == .expert {
|
||||
Toggle(
|
||||
"Firewall-Grundschutz einrichten (empfohlen)",
|
||||
isOn: Binding(
|
||||
get: { viewModel.firewallSectionEnabled },
|
||||
set: { viewModel.setFirewallSectionEnabled($0) }
|
||||
)
|
||||
)
|
||||
)
|
||||
.help("Schützt deinen Router und deine Geräte vor unaufgeforderten Zugriffen aus dem Internet und ermöglicht deinen Geräten den Internetzugriff (NAT).")
|
||||
.help("Schützt deinen Router und deine Geräte vor unaufgeforderten Zugriffen aus dem Internet und ermöglicht deinen Geräten den Internetzugriff (NAT).")
|
||||
} else {
|
||||
Text("Firewall-Grundschutz ist im einfachen Modus immer aktiv.")
|
||||
}
|
||||
Text(
|
||||
"Richtet einen Standard-Schutz ein: Internetfreigabe (NAT) für dein Heimnetz, "
|
||||
+ "und blockiert unaufgeforderte Zugriffe aus dem Internet auf deinen Router "
|
||||
|
||||
@@ -31,20 +31,24 @@ struct LanStepView: View {
|
||||
TextField("DNS-Server", text: $config.dnsServers)
|
||||
.help("Welcher Server Geräten in diesem Netzwerk Internetadressen in Namen übersetzt (z.B. www.google.de). Meist der Router selbst.")
|
||||
|
||||
Toggle("Von anderen Netzwerken isolieren", isOn: $config.isolated)
|
||||
.help("Verhindert Datenverkehr zwischen diesem und allen anderen konfigurierten LAN-/VLAN-Netzwerken. Der Internetzugriff bleibt erhalten. Wird im Firewall-Schritt umgesetzt.")
|
||||
if viewModel.mode == .expert {
|
||||
Toggle("Von anderen Netzwerken isolieren", isOn: $config.isolated)
|
||||
.help("Verhindert Datenverkehr zwischen diesem und allen anderen konfigurierten LAN-/VLAN-Netzwerken. Der Internetzugriff bleibt erhalten. Wird im Firewall-Schritt umgesetzt.")
|
||||
|
||||
if viewModel.lanConfigs.count > 1 {
|
||||
Button("Netzwerk entfernen", role: .destructive) {
|
||||
viewModel.removeLan(config.id)
|
||||
if viewModel.lanConfigs.count > 1 {
|
||||
Button("Netzwerk entfernen", role: .destructive) {
|
||||
viewModel.removeLan(config.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
Button("Weiteres LAN-Netzwerk hinzufügen") {
|
||||
viewModel.addLan()
|
||||
if viewModel.mode == .expert {
|
||||
Section {
|
||||
Button("Weiteres LAN-Netzwerk hinzufügen") {
|
||||
viewModel.addLan()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ModeStepView: View {
|
||||
@ObservedObject var viewModel: SetupViewModel
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section {
|
||||
Picker("Modus", selection: $viewModel.mode) {
|
||||
Text("Einfach").tag(SetupMode.simple)
|
||||
Text("Experte").tag(SetupMode.expert)
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
}
|
||||
|
||||
Section {
|
||||
switch viewModel.mode {
|
||||
case .simple:
|
||||
Text("Grundeinrichtung: Internetverbindung (WAN), ein Heimnetzwerk (LAN) mit DHCP, WLAN und ein fest aktivierter Firewall-Grundschutz.")
|
||||
Text("Kein VLAN, keine mehreren getrennten Netzwerke, keine Netzwerk-Isolation — für später jederzeit über den Expertenmodus nachrüstbar.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
case .expert:
|
||||
Text("Voller Zugriff: mehrere LAN-Interfaces mit je eigenem DHCP-Server, VLANs, Netzwerk-Isolation zwischen Netzwerken, und alle Firewall-Optionen.")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
HStack {
|
||||
Spacer()
|
||||
Button("Weiter") { viewModel.goNext() }
|
||||
}
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.navigationTitle("Modus wählen")
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
ModeStepView(viewModel: SetupViewModel(connectionService: ConnectionService()))
|
||||
}
|
||||
@@ -20,6 +20,8 @@ struct SetupView: View {
|
||||
)
|
||||
} else {
|
||||
switch viewModel.step {
|
||||
case .mode:
|
||||
ModeStepView(viewModel: viewModel)
|
||||
case .wan:
|
||||
WanStepView(viewModel: viewModel, availableInterfaces: configurableInterfaces)
|
||||
case .lan:
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import Foundation
|
||||
|
||||
enum SetupStep: Int, CaseIterable {
|
||||
case mode
|
||||
case wan
|
||||
case lan
|
||||
case vlan
|
||||
@@ -9,9 +10,18 @@ enum SetupStep: Int, CaseIterable {
|
||||
case review
|
||||
}
|
||||
|
||||
/// Simple restricts the wizard to basic setup (single LAN, fixed firewall baseline, no VLANs,
|
||||
/// no per-port DHCP/isolation) for users who just want a working router. Expert exposes every
|
||||
/// RouterOS feature the wizard supports.
|
||||
enum SetupMode {
|
||||
case simple
|
||||
case expert
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class SetupViewModel: ObservableObject {
|
||||
@Published var step: SetupStep = .wan
|
||||
@Published var step: SetupStep = .mode
|
||||
@Published var mode: SetupMode = .simple
|
||||
@Published var wanConfig = WanConfig(interfaceName: "ether1")
|
||||
@Published var lanConfigs: [LanDhcpConfig] = [LanDhcpConfig()]
|
||||
@Published private(set) var vlanSectionEnabled = false
|
||||
@@ -142,13 +152,52 @@ final class SetupViewModel: ObservableObject {
|
||||
}
|
||||
|
||||
func goNext() {
|
||||
guard let next = SetupStep(rawValue: step.rawValue + 1) else { return }
|
||||
step = next
|
||||
switch step {
|
||||
case .mode:
|
||||
if mode == .simple { applySimpleModeConstraints() }
|
||||
step = .wan
|
||||
case .wan:
|
||||
step = .lan
|
||||
case .lan:
|
||||
step = (mode == .expert) ? .vlan : .wifi
|
||||
case .vlan:
|
||||
step = .wifi
|
||||
case .wifi:
|
||||
step = .firewall
|
||||
case .firewall:
|
||||
step = .review
|
||||
case .review:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
func goBack() {
|
||||
guard let previous = SetupStep(rawValue: step.rawValue - 1) else { return }
|
||||
step = previous
|
||||
switch step {
|
||||
case .mode:
|
||||
break
|
||||
case .wan:
|
||||
step = .mode
|
||||
case .lan:
|
||||
step = .wan
|
||||
case .vlan:
|
||||
step = .lan
|
||||
case .wifi:
|
||||
step = (mode == .expert) ? .vlan : .lan
|
||||
case .firewall:
|
||||
step = .wifi
|
||||
case .review:
|
||||
step = .firewall
|
||||
}
|
||||
}
|
||||
|
||||
/// Simple mode allows only one LAN network, no isolation, no VLANs, and a fixed firewall
|
||||
/// baseline — strip anything an earlier Expert-mode choice may have left configured.
|
||||
private func applySimpleModeConstraints() {
|
||||
vlanSectionEnabled = false
|
||||
vlans = []
|
||||
if lanConfigs.count > 1 { lanConfigs = Array(lanConfigs.prefix(1)) }
|
||||
if !lanConfigs.isEmpty { lanConfigs[0].isolated = false }
|
||||
setFirewallSectionEnabled(true)
|
||||
}
|
||||
|
||||
func apply(credentials: RouterOSCredentials) {
|
||||
|
||||
@@ -18,6 +18,7 @@ private final class MockTransport: RouterOSTransport {
|
||||
func fetchDeviceInfo() async throws -> RouterDeviceInfo { deviceInfo }
|
||||
func fetchInterfaces() async throws -> [NetworkInterface] { [] }
|
||||
func fetchFirewallRuleCounts() async throws -> FirewallRuleCounts { FirewallRuleCounts(filterRuleCount: 0, natRuleCount: 0) }
|
||||
func fetchMenuItems(menuPath: String, restPath: String) async throws -> [RouterOSMenuItem] { [] }
|
||||
func apply(_ command: RouterOSCommand) async throws {}
|
||||
func disconnect() async {}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user