diff --git a/CHATLOG.md b/CHATLOG.md index 34b4303..12c4eaf 100644 --- a/CHATLOG.md +++ b/CHATLOG.md @@ -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 [ 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). diff --git a/HANDOFF.md b/HANDOFF.md index c211c17..f4f92ba 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -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 [ 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 diff --git a/RouterOSAssistant/App/RouterOSAssistantApp.swift b/RouterOSAssistant/App/RouterOSAssistantApp.swift index da9d542..6d25b2d 100644 --- a/RouterOSAssistant/App/RouterOSAssistantApp.swift +++ b/RouterOSAssistant/App/RouterOSAssistantApp.swift @@ -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") } } diff --git a/RouterOSAssistant/Core/Models/RouterOSCommand.swift b/RouterOSAssistant/Core/Models/RouterOSCommand.swift index 8458336..a3741c8 100644 --- a/RouterOSAssistant/Core/Models/RouterOSCommand.swift +++ b/RouterOSAssistant/Core/Models/RouterOSCommand.swift @@ -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/`). case set(matchField: String, matchValue: String) + /// Deletes an existing item matched by one field's value (SSH: ` remove [find + /// field=value]`; REST again needs a GET-for-id first, then `DELETE restPath/`). + 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))]" } } diff --git a/RouterOSAssistant/Core/Models/RouterOSMenuItem.swift b/RouterOSAssistant/Core/Models/RouterOSMenuItem.swift new file mode 100644 index 0000000..3b73267 --- /dev/null +++ b/RouterOSAssistant/Core/Models/RouterOSMenuItem.swift @@ -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-" 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] +} diff --git a/RouterOSAssistant/Core/Models/RouterOSSchema.swift b/RouterOSAssistant/Core/Models/RouterOSSchema.swift new file mode 100644 index 0000000..3f34688 --- /dev/null +++ b/RouterOSAssistant/Core/Models/RouterOSSchema.swift @@ -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 + } +} diff --git a/RouterOSAssistant/Core/Models/RouterOSSchemaCatalog.swift b/RouterOSAssistant/Core/Models/RouterOSSchemaCatalog.swift new file mode 100644 index 0000000..bb0a25e --- /dev/null +++ b/RouterOSAssistant/Core/Models/RouterOSSchemaCatalog.swift @@ -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: [] + ) + } +} diff --git a/RouterOSAssistant/Core/Networking/RestTransport.swift b/RouterOSAssistant/Core/Networking/RestTransport.swift index 5d064b2..946d878 100644 --- a/RouterOSAssistant/Core/Networking/RestTransport.swift +++ b/RouterOSAssistant/Core/Networking/RestTransport.swift @@ -66,13 +66,48 @@ final class RestTransport: NSObject, RouterOSTransport { /// `POST /rest/`. `.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/`. + 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) } } diff --git a/RouterOSAssistant/Core/Networking/RouterOSCliParser.swift b/RouterOSAssistant/Core/Networking/RouterOSCliParser.swift index fd71d84..b72078c 100644 --- a/RouterOSAssistant/Core/Networking/RouterOSCliParser.swift +++ b/RouterOSAssistant/Core/Networking/RouterOSCliParser.swift @@ -40,6 +40,36 @@ enum RouterOSCliParser { } } + /// Parses ` 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-" 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 ` 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.. 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)) diff --git a/RouterOSAssistant/Core/Networking/RouterOSTransport.swift b/RouterOSAssistant/Core/Networking/RouterOSTransport.swift index 22bee3c..23b7881 100644 --- a/RouterOSAssistant/Core/Networking/RouterOSTransport.swift +++ b/RouterOSAssistant/Core/Networking/RouterOSTransport.swift @@ -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 } diff --git a/RouterOSAssistant/Core/Networking/SSHTransport.swift b/RouterOSAssistant/Core/Networking/SSHTransport.swift index 70a2f95..c1e8973 100644 --- a/RouterOSAssistant/Core/Networking/SSHTransport.swift +++ b/RouterOSAssistant/Core/Networking/SSHTransport.swift @@ -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 [ 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 diff --git a/RouterOSAssistant/Core/Services/ConnectionService.swift b/RouterOSAssistant/Core/Services/ConnectionService.swift index 6710c48..8729f0c 100644 --- a/RouterOSAssistant/Core/Services/ConnectionService.swift +++ b/RouterOSAssistant/Core/Services/ConnectionService.swift @@ -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 } } diff --git a/RouterOSAssistant/Features/Expert/ExpertMenuDetailView.swift b/RouterOSAssistant/Features/Expert/ExpertMenuDetailView.swift new file mode 100644 index 0000000..4415a9c --- /dev/null +++ b/RouterOSAssistant/Features/Expert/ExpertMenuDetailView.swift @@ -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( + 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' +/// "" 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, _ range: ClosedRange) -> 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() + } +} diff --git a/RouterOSAssistant/Features/Expert/ExpertView.swift b/RouterOSAssistant/Features/Expert/ExpertView.swift new file mode 100644 index 0000000..e35f08a --- /dev/null +++ b/RouterOSAssistant/Features/Expert/ExpertView.swift @@ -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()) +} diff --git a/RouterOSAssistant/Features/Expert/ExpertViewModel.swift b/RouterOSAssistant/Features/Expert/ExpertViewModel.swift new file mode 100644 index 0000000..1819d52 --- /dev/null +++ b/RouterOSAssistant/Features/Expert/ExpertViewModel.swift @@ -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 + } +} diff --git a/RouterOSAssistant/Features/Wizard/Steps/Setup/FirewallStepView.swift b/RouterOSAssistant/Features/Wizard/Steps/Setup/FirewallStepView.swift index d3f27d8..93fa52d 100644 --- a/RouterOSAssistant/Features/Wizard/Steps/Setup/FirewallStepView.swift +++ b/RouterOSAssistant/Features/Wizard/Steps/Setup/FirewallStepView.swift @@ -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 " diff --git a/RouterOSAssistant/Features/Wizard/Steps/Setup/LanStepView.swift b/RouterOSAssistant/Features/Wizard/Steps/Setup/LanStepView.swift index 1e82dc5..96c57fa 100644 --- a/RouterOSAssistant/Features/Wizard/Steps/Setup/LanStepView.swift +++ b/RouterOSAssistant/Features/Wizard/Steps/Setup/LanStepView.swift @@ -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() + } } } diff --git a/RouterOSAssistant/Features/Wizard/Steps/Setup/ModeStepView.swift b/RouterOSAssistant/Features/Wizard/Steps/Setup/ModeStepView.swift new file mode 100644 index 0000000..f6128da --- /dev/null +++ b/RouterOSAssistant/Features/Wizard/Steps/Setup/ModeStepView.swift @@ -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())) +} diff --git a/RouterOSAssistant/Features/Wizard/Steps/Setup/SetupView.swift b/RouterOSAssistant/Features/Wizard/Steps/Setup/SetupView.swift index 492134c..048dc58 100644 --- a/RouterOSAssistant/Features/Wizard/Steps/Setup/SetupView.swift +++ b/RouterOSAssistant/Features/Wizard/Steps/Setup/SetupView.swift @@ -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: diff --git a/RouterOSAssistant/Features/Wizard/Steps/Setup/SetupViewModel.swift b/RouterOSAssistant/Features/Wizard/Steps/Setup/SetupViewModel.swift index 4a22438..68b18d4 100644 --- a/RouterOSAssistant/Features/Wizard/Steps/Setup/SetupViewModel.swift +++ b/RouterOSAssistant/Features/Wizard/Steps/Setup/SetupViewModel.swift @@ -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) { diff --git a/RouterOSAssistantTests/ConnectionServiceTests.swift b/RouterOSAssistantTests/ConnectionServiceTests.swift index 08c17d0..94fe1db 100644 --- a/RouterOSAssistantTests/ConnectionServiceTests.swift +++ b/RouterOSAssistantTests/ConnectionServiceTests.swift @@ -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 {} }