Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
09cd5c31fc | ||
|
|
12ce7bea84 | ||
|
|
e3e3c71452 | ||
|
|
3010688b12 | ||
|
|
cc949b54df | ||
|
|
0728817fbc | ||
|
|
036c02c78f | ||
|
|
915c2fb91a | ||
|
|
96fe0acd69 | ||
|
|
2c2fae73eb | ||
|
|
84f5ab21db | ||
|
|
b7af5175dd | ||
|
|
6514eaef86 | ||
|
|
e0136f6ef2 | ||
|
|
9860e16bb0 | ||
|
|
8cc8f1cbb3 |
@@ -0,0 +1,113 @@
|
||||
# Session Handoff: LAN-Scanner, Backup-Verwaltung, Sidebar-Umbau
|
||||
|
||||
**Datum:** 2026-09-17
|
||||
**Projekt:** R:\CC-Projekte\Mikrotik-Installer (Gitea: `admin/Mikrotik-Installer`)
|
||||
**Sitzungsdauer:** ganzer Tag, sehr umfangreich (ein komplettes neues Feature + eine größere Umstrukturierung + mehrere iterative UX-Korrekturen)
|
||||
|
||||
## Aktueller Stand
|
||||
|
||||
**Aufgabe:** Phase 2 der Roadmap (Mac-Paritätsfeatures ohne SSH-Abhängigkeit) begonnen, während v1.0 (Realgerät-Validierung) weiter auf Testhardware wartet.
|
||||
**Phase:** Mehrere Features komplett fertig, vom Nutzer live getestet und bestätigt. Sauberer Abschlusspunkt für heute.
|
||||
**Fortschritt:** Alles committet und nach Gitea gepusht (`origin/main`, aktueller HEAD `12ce7be`). Core-Testsuite: 138 Tests grün. App-Build sauber.
|
||||
|
||||
⚠️ **Wichtig zuerst prüfen:** Laut Memory (`project_v1_realgeraet_wartet_auf_hardware`) wurde die Testhardware für **heute (2026-09-17)** erwartet. In dieser Session kam das Thema nicht auf — zu Beginn der nächsten Session klären, ob die Hardware inzwischen angekommen ist. Falls ja, hat `v1.0 – Realgerät-Validierung` (Issues #1, #2) oberste Priorität vor weiteren Phase-2-Features (siehe Ablaufplan in README).
|
||||
|
||||
## Was wir gemacht haben
|
||||
|
||||
### 1. M-Geräte – LAN-Scanner (Issue #22, abgeschlossen)
|
||||
Neuer Wizard-Schritt „Geräte im Netzwerk": führt DHCP-Leases, ARP-Tabelle und Bridge-Host-Tabelle per MAC-Adresse zu einer nach Anschluss gruppierten Geräteliste zusammen (physischer Anschluss kommt bewusst aus der Bridge-Host-Tabelle, nicht aus ARP/Lease). Aktion „Feste IP-Adresse zuweisen" (RouterOS „Make Static") inkl. Rückgängig-Pfad. Nutzer-Feedback dabei behoben: Nach „Rückgängig machen" wirkte die Zeile wie kaputt (Button ausgegraut ohne Erklärung) — jetzt zeigt sie „Feste IP entfernt" mit Hinweistext, warum das Gerät sich erst nach Trennen/Neuverbinden wieder meldet. Folge-Issue #30 (offen): `make-static`-Aufrufparameter (`.id` vs. `numbers`) gegen echtes Gerät verifizieren.
|
||||
|
||||
### 2. M-Backup-Wartung – Backup-Wiederherstellung (Issue #27, mehrere Iterationen, abgeschlossen)
|
||||
Größtes Stück der Session, mehrfach auf Nutzer-Feedback erweitert:
|
||||
- **v1:** Backup-Wiederherstellung als dritte Option im Moduswahl-Schritt, Modell-Abgleich (Dateiname trägt Gerätemodell) als Brick-Schutz.
|
||||
- **v2:** Seriennummer zusätzlich zum Modell eingebettet und hat Vorrang beim Prüfen — erkennt ein baugleiches, aber physisch anderes Gerät (gleiches Modell, andere Seriennummer), das ein reiner Modell-Abgleich fälschlich durchließe.
|
||||
- **v3 (#33):** Architektur als dritte Prüfstufe, vierstufige Kompatibilität: **Match** / **SameModelDifferentDevice** (gelb, „auf eigene Gefahr" mit Bestätigungs-Checkbox) / **Mismatch** (rot, hart blockiert, kein Override) / **Unknown** (Bestätigungs-Checkbox).
|
||||
- **v4 (#34):** Kompletter Bereich aus dem Interview gelöst — neuer Schritt „Was möchtest du tun?" direkt nach der Verbindung (vor Geräte-Erkennung!), bei „Backup verwalten" werden alle Konfigurations-/Analyse-Schritte übersprungen, eigenständiger `BackupManagementStepViewModel` liest das Gerät selbst aus. Alte „Backup wiederherstellen"-Option in der Moduswahl entfernt (kein Parallel-Mechanismus).
|
||||
- **Manuelles Backup erstellen:** eigener „Neues Backup erstellen"-Button unabhängig vom Wiederherstellen.
|
||||
- Folge-Issue #32 (offen): `/system/backup/load`-Verhalten (Dateiname-Format, Reboot, Passwort-Parameter) gegen echtes Gerät verifizieren.
|
||||
|
||||
### 3. Sidebar-Umbau (Issues #35, #36, abgeschlossen)
|
||||
- **#35 (Bug):** Sidebar-Schrittliste hatte keinen ScrollViewer — bei jetzt 13 Schritten wurde „RouterOS-Update" (letzter Schritt) abgeschnitten. Fix: Logo bleibt fix, Schrittliste läuft in eigenem ScrollViewer.
|
||||
- **#36 (Nutzer-Wunsch „Optik-Renew", eigener Entwurf nach Rückfrage):** Sidebar zeigt jetzt dauerhaft eine Verbindungs-Karte. Nicht verbunden: Kurzauswahl bekannter Router, ein Klick verbindet direkt und springt automatisch weiter. Verbunden: grüner Punkt + Gerätename/-modell + „Wechseln"-Button.
|
||||
- **Folge-Bereinigung:** Nutzer wollte keine Dopplung — „Bekannte Router" komplett aus dem Verbindungs-Schritt (Hauptfenster) entfernt, lebt nur noch in der Sidebar. Dort per Rückfrage ergänzt: kleines Papierkorb-Symbol zum Entfernen je Eintrag; Umbenennen bewusst nicht wiederhergestellt (Name kommt automatisch vom Gerätemodell).
|
||||
|
||||
### 4. Demo-Zugang: drei virtuelle Testgeräte statt einem
|
||||
Auf Wunsch des Nutzers („damit ich das besser beurteilen kann"): `DemoRouterOsClient` unterstützt jetzt mehrere `DemoDeviceProfile`s (alle `admin`/`admin`):
|
||||
- `127.127.127.127` — Test-Router / CHR (Simulator) — bestehend
|
||||
- `127.127.127.128` — Büro-Router / hAP ac2 (RouterBOARD)
|
||||
- `127.127.127.129` — Keller-Switch / CRS326-24G-2S+ (RouterBOARD)
|
||||
|
||||
Alle drei wurden bereits einmal real verbunden und stehen unter „Bekannte Router" (lokale Datei `%AppData%\MikroTik-Installer\saved-routers.json`, nicht Teil des Git-Repos).
|
||||
|
||||
### 5. Sidequest: Gitea-Milestone-Fälligkeitsdaten
|
||||
Alle 12 offenen Milestones auf Fälligkeitsdatum 2026-09-30 gesetzt (Nutzerwunsch für bessere eigene Struktur).
|
||||
|
||||
## Entscheidungen (mit Begründung)
|
||||
|
||||
- **Seriennummer > Architektur > Modell als Prüf-Hierarchie beim Backup-Abgleich** — die Seriennummer identifiziert das konkrete physische Gerät und ist damit die stärkste Prüfung; Architektur/Modell sind Fallbacks für ältere Backups oder Geräte ohne Seriennummer (z. B. virtuelle Testgeräte). Alle drei werden per `_` getrennt in den Dateinamen eingebettet (`_` erzeugt `SanitizeForFileName` nie, Trennung bleibt eindeutig).
|
||||
- **`AppArea`-Enum + `ShouldSkip` statt neuer Navigations-Architektur** — für den Backup-Bereich wurde bewusst der bereits bestehende `ShouldSkip`-Mechanismus wiederverwendet (Schritt „Was möchtest du tun?" setzt `WizardSession.SelectedArea`, alle Konfigurations-Schritte prüfen das), statt eine komplett neue, parallele Navigations-Ebene zu bauen. Deckt sich bewusst mit dem für **M-Experte (Issue #21)** ohnehin vorgesehenen Bedarf an einer Navigation außerhalb der linearen Schritt-Kette — beide sollten bei einem künftigen größeren Umbau gemeinsam betrachtet werden, nicht als zwei parallele Mechanismen.
|
||||
- **`BackupManagementStepViewModel` liest das Gerät selbst aus** — unabhängig von `DeviceOverviewStepViewModel` (der in diesem Bereich übersprungen wird), damit „Backup verwalten" wirklich direkt nach der Verbindung funktioniert, ohne Umweg.
|
||||
- **Manuelle Formular-Eingabe bleibt auf der Verbindungs-Schritt-Seite** — passt nicht kompakt in die schmale Sidebar (260px); nur die Kurzauswahl bekannter Router zog um.
|
||||
- **found.md als Backlog-Kanal etabliert** — der Nutzer schreibt Feedback/Ideen in eine lokale `found.md` im Projekt-Root (nicht Teil des Git-Repos, bewusst nicht committet) und bittet mich, sie in Gitea-Issues umzuwandeln und den Status direkt in der Datei nachzuführen. Funktioniert jetzt für 5 Punkte (siehe unten). **Bei Sessionstart immer prüfen, ob `found.md` neue, noch unbearbeitete Einträge enthält.**
|
||||
- **Scratch-Test-Harness für WPF-Smoke-Tests** — da es keine GUI-Automatisierung für native WPF-Fenster gibt, wurde wiederholt ein kleines Konsolenprogramm im Scratchpad (`wpf-smoke/Program.cs`) genutzt: `[STAThread]` + `new App(); app.InitializeComponent();` + ViewModels/Views direkt instanziieren und durch `MainViewModel.GoNextCommand` navigieren. Funktioniert gut für ViewModel-Logik; **volle `MainWindow`-Instanzen lassen sich darin NICHT rendern** (Pack-URI-Ressourcenauflösung für `Assets/app.ico` schlägt in der isolierten Harness fehl) — für rein visuelle/Layout-Änderungen bleibt nur der Start der echten App zum manuellen Prüfen.
|
||||
- **Scratch-Harness nutzt echte lokale Nutzerdaten** — Vorsicht: Der Harness läuft im selben Prozess-Kontext und schreibt bei vollständiger Navigation durch `DeviceOverviewStepViewModel` in die *echte* `%AppData%\MikroTik-Installer\saved-routers.json` und den echten Windows-Anmeldeinformationsspeicher (nicht sandboxed). Bisher unproblematisch (nur Demo-Zugangsdaten betroffen, bestehende Nutzer-Anpassungen wie `Location: "Test dingens"` blieben unangetastet), aber beim nächsten Mal bewusst im Hinterkopf behalten.
|
||||
|
||||
## Code-Struktur (wichtigste Ergänzungen/Änderungen heute)
|
||||
|
||||
```
|
||||
src/MikrotikInstaller.Core/
|
||||
Devices/LanScan/ NEU — DiscoveredDevice, LanScanService
|
||||
Backup/BackupFileInfo.cs Model/Architecture/SerialNumber (war nur FileName/CreationTime)
|
||||
Backup/BackupRestoreService.cs NEU — CheckCompatibility (4-stufig), RestoreAsync, ListBackupsAsync
|
||||
Backup/RouterBackupService.cs CreateBackupAsync bettet Modell/Architektur/Seriennummer ein
|
||||
Connectivity/Demo/DemoDeviceProfile.cs NEU — Profile-Record für virtuelle Testgeräte
|
||||
Connectivity/Demo/DemoRouterOsClient.cs Mehrere Profile statt einem einzigen Testzugang
|
||||
|
||||
src/MikrotikInstaller.App/
|
||||
ViewModels/AppArea.cs NEU — SetupWizard | BackupManagement
|
||||
ViewModels/Steps/AreaSelectionStepViewModel.cs NEU — "Was möchtest du tun?" (Schritt 2)
|
||||
ViewModels/Steps/BackupManagementStepViewModel.cs NEU — eigenständiger Backup-Bereich
|
||||
ViewModels/Steps/BackupSelectionItemViewModel.cs 4-stufige Kompatibilitäts-Anzeige
|
||||
ViewModels/Steps/LanScan*.cs NEU — LAN-Scanner-Step + Row/Group-ViewModels
|
||||
ViewModels/Steps/ModeSelectionStepViewModel.cs Zurück auf 2 Optionen (Restore-Option entfernt)
|
||||
ViewModels/Steps/SummaryStepViewModel.cs Restore-Zweig entfernt, wieder rein für geplante Änderungen
|
||||
ViewModels/Steps/ConnectionStepViewModel.cs "Bekannte Router"-Editieren entfernt, ConnectToSavedRouterCommand NEU
|
||||
ViewModels/MainViewModel.cs Connection-Property, ConnectedDeviceLabel, QuickConnect-/SwitchConnection-Commands
|
||||
MainWindow.xaml Sidebar: ScrollViewer + dauerhafte Verbindungs-Karte
|
||||
Views/Steps/LanScanStepView.xaml NEU
|
||||
Views/Steps/BackupManagementStepView.xaml NEU
|
||||
Views/Steps/AreaSelectionStepView.xaml NEU
|
||||
Views/Steps/ConnectionStepView.xaml "Bekannte Router"-Liste entfernt
|
||||
```
|
||||
|
||||
## Bekannte Stolperfallen (bereits gelöst, aber gut zu wissen)
|
||||
|
||||
- **`Run.Opacity` existiert nicht in WPF** — mehrfach reingelaufen, immer mit `Foreground="{DynamicResource TextFillColorSecondaryBrush}"` statt `Opacity` auf `<Run>`-Elementen lösen.
|
||||
- **CommunityToolkit `[RelayCommand]`-CanExecute bei parametrisierten Item-Commands** — reagiert nicht automatisch auf Property-Änderungen am gebundenen Item (kein `NotifyCanExecuteChangedFor` möglich, da das Item nicht das ViewModel selbst ist). Lösung: `IsEnabled` in XAML direkt an eine berechnete Property auf dem Item binden (z. B. `CanToggleStatic`), statt sich auf die Command-eigene `CanExecute`-Logik zu verlassen.
|
||||
- **`Border` erlaubt nur ein Kind** — beim Verschachteln von zwei `Visibility`-umschaltbaren `StackPanel`s in einer `Border` erst einen `Grid`-Wrapper einziehen.
|
||||
- **Namespace-Verschachtelung in C#** — `MikrotikInstaller.App.ViewModels.Steps` sieht Typen aus `MikrotikInstaller.App.ViewModels` automatisch (kein `using` nötig), da es sich um eine echte Namespace-Verschachtelung handelt, nicht nur eine Namenskonvention.
|
||||
- **`xUnit TheoryData<T>` hat keinen `IEnumerable<T>`-Konstruktor** in der hier verwendeten Version — Einträge einzeln per `.Add()` in einer kleinen Hilfsmethode aufbauen statt `new TheoryData<int>(Enumerable.Range(...))`.
|
||||
|
||||
## Kontext zum Merken
|
||||
|
||||
- **Feste Arbeitsweise für dieses Projekt** (vom Nutzer explizit angewiesen): nach jedem grünen Testlauf automatisch committen + nach **Gitea** pushen (nie GitHub), danach README + Dokumentation aktualisieren. Weiterhin gültig, siehe Memory `feedback_commit_push_docs_workflow`.
|
||||
- **found.md-Workflow** (neu diese Session, siehe oben) — Notizen dort mit Zeile 1 „Alles hier reingeschrieben, bitte umwandeln für die KI, sowie mit Status versehen" sind eine Aufforderung, sie in Gitea-Issues umzuwandeln und den Status in der Datei selbst nachzuführen.
|
||||
- **Nutzer testet aktiv selbst live** und gibt präzises, umsetzbares Feedback — bei jeder UI-Änderung die echte App neu starten (`Get-Process ... | Stop-Process -Force` davor, sonst `MSB3027`-Build-Fehler) und den Nutzer aktiv zum Testen einladen.
|
||||
- **Rückfragen bei echten Design-Weichenstellungen zahlen sich aus** — sowohl bei „Backup-Bereich eigenständig machen" (#34) als auch beim Sidebar-Umfang (#36) und bei der Bekannte-Router-Bereinigung (heute zuletzt) wurde vor der Umsetzung kurz per `AskUserQuestion` nachgefragt, statt zu raten — hat in allen Fällen zur richtigen Lösung im ersten Versuch geführt.
|
||||
|
||||
## Offene Fragen / Nächste Schritte
|
||||
|
||||
1. [ ] **Zuerst prüfen: ist die Testhardware inzwischen angekommen?** Falls ja, hat `v1.0 – Realgerät-Validierung` (Issues #1, #2) Vorrang vor weiteren Phase-2-Features.
|
||||
2. [ ] Rest von Phase 2: `M-Backup-Wartung`-Rest (Werksreset-Notfallschalter + Firmware-Update, Issue #28) oder `v1.3` SFTP-Backup-Download (Issues #8, #9) — beide noch komplett offen.
|
||||
3. [ ] Issue #31 (aus found.md, noch offen): Verbindungs-Schritt — Benutzername/Passwort nebeneinander statt untereinander, damit man im Standardfenster nicht scrollen muss.
|
||||
4. [ ] Drei Folge-Issues für Echtgerät-Verifizierung, alle bisher nur gegen den Demo-Client getestet: #30 (`make-static`-Parameter), #32 (`/system/backup/load`-Verhalten), #17 (Route-zu-Interface-Zuordnung, aus vorheriger Session).
|
||||
5. [ ] `M-Übersicht-Edit` (Issue #24) — Nodify-Diagramm bearbeitbar machen, baut auf bereits verifizierter Nodify-Integration auf.
|
||||
6. [ ] Größerer Umbau irgendwann fällig: `M-Experte` (Issue #21, neue Wizard-unabhängige Navigation) und die in #34 hinterlegte Notiz, dass dessen Navigations-Bedarf sich mit dem heute gebauten `AppArea`-Mechanismus überschneidet — beim Angehen von #21 unbedingt zusammen betrachten.
|
||||
|
||||
## Dateien zum Wiedereinstieg
|
||||
|
||||
- `README.md` — vollständig aktuell (Schrittliste inkl. „Was möchtest du tun?"/„Backup verwalten", Roadmap-Tabelle, Ablaufplan)
|
||||
- `found.md` (Projekt-Root, nicht im Git) — Backlog-Status, bei Sessionstart auf neue Einträge prüfen
|
||||
- `src/MikrotikInstaller.App/ViewModels/Steps/BackupManagementStepViewModel.cs` + `src/MikrotikInstaller.Core/Backup/BackupRestoreService.cs` — Kern der heutigen Backup-Arbeit
|
||||
- `src/MikrotikInstaller.App/MainWindow.xaml` — Sidebar-Struktur (Verbindungs-Karte + scrollbare Schrittliste)
|
||||
- `.claude/handoffs/2026-09-15-experten-modus-und-ist-zustand-analyse.md` — vorheriger Handoff, Kontext zu Experten-Modus/IST-Zustand-Analyse weiterhin gültig
|
||||
@@ -7,17 +7,18 @@ on:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
# Passe dieses Label an, falls dein Runner unter einem anderen Namen registriert ist
|
||||
# (in Gitea unter Repository/Organisation -> Einstellungen -> Actions -> Runner sichtbar).
|
||||
runs-on: ubuntu-latest
|
||||
# Läuft auf dem selbstgehosteten Windows-Runner (siehe README "CI/CD"), im "host"-Modus ohne
|
||||
# Docker direkt auf dessen eigener PowerShell/Shell mit dem dort installierten .NET 8 SDK.
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- name: Code auschecken
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: .NET SDK einrichten
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: '8.0.x'
|
||||
# Kein actions/setup-dotnet-Schritt: der Runner läuft bewusst im User-Modus (kein Admin), und
|
||||
# setup-dotnet versucht, seine eigene SDK-Kopie nach "C:\Program Files\dotnet" zu installieren —
|
||||
# dafür fehlen dann die Schreibrechte. Das .NET 8 SDK ist auf diesem Rechner bereits vorhanden.
|
||||
- name: .NET-SDK-Version prüfen
|
||||
run: dotnet --version
|
||||
|
||||
# Debug, nicht Release: der interne Demo-Verbindungszugang, den ein Teil der Tests nutzt,
|
||||
# existiert per #if DEBUG nur in Debug-Builds (siehe RouterOsClientFactory.cs) — in Release
|
||||
|
||||
@@ -13,24 +13,32 @@ Ein Windows-Programm, mit dem sich MikroTik-Router/-Switches in wenigen, klar ge
|
||||
|
||||
Schritt für Schritt, mit Erklär-Tooltips (ⓘ) bei jedem Fachbegriff:
|
||||
|
||||
1. **Verbindung** – IP-Adresse, Benutzername, Passwort
|
||||
2. **Geräte-Erkennung** – liest Modell, RouterOS-Version und Schnittstellen aus
|
||||
3. **Vorhandene Netzwerke** – bei einem Bestandsgerät: nach Kategorien gruppierte, anklickbare Grafik der bereits eingerichteten Bridges, VLANs, IP-Adressen, DHCP-Server, WLANs und IP-gebundenen Firewall-/NAT-Regeln (siehe unten); bei werksneuen Geräten erscheint nur ein kurzer Hinweis
|
||||
4. **Modus wählen** – geführter Assistent (Standard) oder **Experten-Modus**: Letzterer blendet in den folgenden Schritten zusätzliche, technischere RouterOS-Optionen ein (siehe unten)
|
||||
5. **Internet-Zugang** – WAN-Schnittstelle, DHCP oder feste IP, NAT
|
||||
6. **Heimnetzwerk (DHCP)** – Bridge, IP-Bereich, DHCP-Server, DNS
|
||||
7. **VLANs** *(optional)* – zusätzliche, getrennte Netzwerke (z. B. Gäste, IoT), im Experten-Modus wahlweise fest einem oder mehreren Anschlüssen zugeordnet (portbasiert, siehe unten)
|
||||
8. **WLAN** *(übersprungen, wenn nicht vorhanden – oder bewusst abwählbar, z. B. bei einem reinen Switch)*
|
||||
9. **Firewall** – sichere Basisregeln, optionale Portfreigaben
|
||||
10. **Zusammenfassung** – alle geplanten Änderungen im Klartext, automatisches Backup auf dem Gerät vor dem Anwenden, **PDF-Export** mit allen Zugangsdaten inkl. WLAN-QR-Code zum Ausdrucken/Aufbewahren
|
||||
11. **RouterOS-Update** *(optional)* – prüft auf eine neuere RouterOS-Version und kann sie mit deutlicher Warnung starten
|
||||
1. **Verbindung** – IP-Adresse, Benutzername, Passwort; bereits erfolgreich verbundene Geräte erscheinen als **„Bekannte Router"** zur Wiederverwendung (siehe unten)
|
||||
2. **Was möchtest du tun?** – Weiche direkt nach dem Verbinden: kompletter Einrichtungs-Assistent (Standard) oder direkt zu **„Backup verwalten"** (siehe unten) — Letzteres überspringt den kompletten restlichen Assistenten
|
||||
3. **Geräte-Erkennung** – liest Modell, RouterOS-Version und Schnittstellen aus
|
||||
4. **Vorhandene Netzwerke** – bei einem Bestandsgerät: nach Kategorien gruppierte, anklickbare Grafik der bereits eingerichteten Bridges, VLANs, IP-Adressen, DHCP-Server, WLANs und IP-gebundenen Firewall-/NAT-Regeln (siehe unten); bei werksneuen Geräten erscheint nur ein kurzer Hinweis
|
||||
5. **Geräte im Netzwerk** – nach Anschluss gruppierte Liste aller aktuell erkannten Geräte (aus DHCP-Leases, ARP- und Bridge-Host-Tabelle zusammengeführt), mit der Möglichkeit, einem Gerät direkt eine feste IP-Adresse zuzuweisen oder das wieder rückgängig zu machen (siehe unten)
|
||||
6. **Modus wählen** – geführter Assistent (Standard) oder **Experten-Modus**: Letzterer blendet in den folgenden Schritten zusätzliche, technischere RouterOS-Optionen ein (siehe unten)
|
||||
7. **Internet-Zugang** – WAN-Schnittstelle, DHCP oder feste IP, NAT
|
||||
8. **Heimnetzwerk (DHCP)** – Bridge, IP-Bereich, DHCP-Server, DNS
|
||||
9. **VLANs** *(optional)* – zusätzliche, getrennte Netzwerke (z. B. Gäste, IoT), im Experten-Modus wahlweise fest einem oder mehreren Anschlüssen zugeordnet (portbasiert, siehe unten)
|
||||
10. **WLAN** *(übersprungen, wenn nicht vorhanden – oder bewusst abwählbar, z. B. bei einem reinen Switch)*
|
||||
11. **Firewall** – sichere Basisregeln, optionale Portfreigaben
|
||||
12. **Zusammenfassung** – alle geplanten Änderungen im Klartext, automatisches Backup auf dem Gerät vor dem Anwenden, **PDF-Export** mit allen Zugangsdaten inkl. WLAN-QR-Code zum Ausdrucken/Aufbewahren
|
||||
13. **RouterOS-Update** *(optional)* – prüft auf eine neuere RouterOS-Version und kann sie mit deutlicher Warnung starten
|
||||
|
||||
Nichts wird am Gerät verändert, bevor der Nutzer in der Zusammenfassung ausdrücklich auf „Jetzt einrichten“ klickt.
|
||||
Nichts wird am Gerät verändert, bevor der Nutzer in der Zusammenfassung ausdrücklich auf „Jetzt einrichten“ klickt (Ausnahme: die explizite „Jetzt wiederherstellen“-Aktion in „Backup verwalten“, siehe unten).
|
||||
|
||||
### Vorhandene Netzwerke (IST-Zustand-Analyse)
|
||||
|
||||
Bei einem bereits konfigurierten Bestandsgerät liest der dritte Schritt automatisch aus, was dort schon eingerichtet ist, und zeichnet es als Diagramm: jeder Knoten ist eine Bridge, ein VLAN, eine IP-Adresse, ein DHCP-Server, ein WLAN, eine IP-gebundene Firewall-/NAT-Regel oder der Internet-Zugang. Die Grafik ist in Kategorie-Spalten von links nach rechts gruppiert (Internet → Netzwerk-Schnittstellen → IP-Adressen → DHCP-Server → Firewall-Regeln) für eine klare Übersicht statt eines einzelnen unübersichtlichen Baums. Ein Klick auf einen Knoten hebt alles hervor, was mit ihm verbunden ist — sowohl den Weg („Hops") bis zum Internet-Zugang als auch alles, was von diesem Knoten abhängt (z. B. zeigt ein Klick auf eine Bridge alle angeschlossenen Ports, VLANs, IP-Adressen und DHCP-Server). Rein informativ — verändert nichts am Gerät und fließt nicht in die vom Assistenten geplante Konfiguration ein. Bei einem werksneuen Gerät ohne bestehende Konfiguration erscheint statt der Grafik nur ein kurzer Hinweis.
|
||||
|
||||
### Geräte im Netzwerk (LAN-Scanner)
|
||||
|
||||
Der vierte Schritt führt DHCP-Leases (`/ip/dhcp-server/lease`), die ARP-Tabelle (`/ip/arp`) und die Bridge-Host-Tabelle (`/interface/bridge/host`) per MAC-Adresse zu einer Geräteliste zusammen, gruppiert nach dem physischen Anschluss, an dem ein Gerät tatsächlich hängt (aus der Bridge-Host-Tabelle — ARP/Lease zeigen bei gebrückten Setups sonst nur die Bridge selbst, nicht den echten Port). Rein informativ bis auf eine Aktion: Bei einem Gerät mit DHCP-Lease lässt sich diese per Klick fest zuweisen (RouterOS „Make Static") oder wieder entfernen. Ein „Aktualisieren"-Button lädt die Liste jederzeit neu.
|
||||
|
||||
RouterOS kennt keinen direkten „wieder dynamisch machen"-Befehl — das Rückgängig-Machen entfernt den Lease-Eintrag komplett. Das betroffene Gerät merkt das aber nicht sofort: Es hält seine bisherige IP-Adresse client-seitig weiter, bis es von sich aus eine neue DHCP-Anfrage stellt (Lease-Renewal-Timer) oder der Link neu aufgebaut wird (Kabel/WLAN trennen und neu verbinden, Neustart). Bis dahin zeigt die Zeile „Feste IP entfernt" mit deaktiviertem Button und einem erklärenden Hinweistext, statt kommentarlos auszugrauen. Wie die IST-Zustand-Analyse verändert dieser Schritt nichts an der vom Assistenten geplanten Soll-Konfiguration.
|
||||
|
||||
### Experten-Modus
|
||||
|
||||
Wer RouterOS kennt, kann im vierten Schritt den Experten-Modus wählen. Die Schritte 5–9 zeigen dann zusätzlich (Laien-Modus bleibt exakt wie zuvor, Defaults entsprechen dem bisherigen Verhalten):
|
||||
@@ -41,12 +49,37 @@ Wer RouterOS kennt, kann im vierten Schritt den Experten-Modus wählen. Die Schr
|
||||
- **WLAN:** WPA2/WPA3/gemischt, SSID verstecken, Band/Kanal festlegen
|
||||
- **Firewall:** Portfreigabe auf eine Quell-IP beschränken, Portbereiche statt Einzelport, Zugriffe protokollieren, IP-/Subnetz-Sperrliste
|
||||
|
||||
### Backup verwalten
|
||||
|
||||
Eigenständiger Bereich, erreichbar direkt im zweiten Schritt („Was möchtest du tun?") — ohne den restlichen Einrichtungs-Assistenten zu durchlaufen. Liest das verbundene Gerät selbst aus und zeigt sofort: vorhandene Backups auf dem Gerät, einen „Neues Backup erstellen"-Button für eine frische, unabhängige Sicherung (z. B. vor eigenen Änderungen direkt in WinBox — erscheint sofort in der Liste) und „Aktualisieren" zum erneuten Auflisten. Ein Backup lässt sich direkt aus der Liste zurückspielen.
|
||||
|
||||
RouterOS prüft beim Zurückspielen selbst nicht, ob ein Backup zum verbundenen Gerät passt — es importiert klaglos eine Fremd-Konfiguration mit ggf. falsch zugeordneten Interfaces. Der Assistent verhindert das mit einer vierstufigen Prüfung: Jedes hier selbst erstellte Backup trägt Gerätemodell, Architektur **und Seriennummer** im Dateinamen (z. B. `mikrotik-installer-RB750Gr3_arm_414403A029A5-20260917-193000.backup`, jeweils per `_` getrennt).
|
||||
|
||||
- **Passt genau** (Modell, Architektur und Seriennummer stimmen überein, oder die Seriennummer ist auf einer Seite nicht bekannt) — direkt wiederherstellbar.
|
||||
- **Gleiches Modell, anderes physisches Gerät** (Modell und Architektur passen, aber eine bekannte, abweichende Seriennummer) — gelb markiert, nur nach ausdrücklicher Bestätigung „auf eigene Gefahr" wiederherstellbar. Erkennt z. B. ein baugleiches Ersatzgerät, das ein reiner Modell-Abgleich fälschlich als identisch durchließe.
|
||||
- **Anderes Modell oder andere Architektur** — rot markiert, ohne Override-Möglichkeit blockiert (echtes Risiko falsch zugeordneter Interfaces).
|
||||
- **Nicht prüfbar** (kein erkennbarer Name, z. B. ein Backup aus WinBox) — nur nach ausdrücklicher Bestätigung, dass es zum Gerät passt.
|
||||
|
||||
Wie beim normalen Anwenden wird vor dem Zurückspielen zusätzlich eine Sicherung des *aktuellen* Zustands erstellt — falls das Zurückspielen nicht das gewünschte Ergebnis war, lässt sich so auch das noch rückgängig machen. Das Gerät startet nach dem Zurückspielen automatisch neu.
|
||||
|
||||
### Portbasierte VLANs
|
||||
|
||||
Im Experten-Modus lässt sich jedem VLAN im VLAN-Schritt zusätzlich eine Checkliste von Anschlüssen zuordnen (z. B. „Ether4"). Geräte, die dort eingesteckt werden, gehören dann automatisch zu diesem Netzwerk – auch ohne eigene VLAN-Unterstützung (802.1Q-Tagging), z. B. ein einzelner Access Point, eine smarte Steckdose oder eine Netzwerkdose im Gästezimmer. Ohne diese Auswahl bleibt ein VLAN wie bisher rein „getaggt" und ist nur über einen VLAN-fähigen Switch/Access Point erreichbar.
|
||||
|
||||
Ein Anschluss kann immer nur einem Netzwerk zugeordnet sein; eine Doppelzuordnung wird erkannt und blockiert „Weiter" mit einer Fehlermeldung. Technisch aktiviert das erst beim Anwenden die Bridge-VLAN-Filterung (`vlan-filtering`) auf der Heimnetz-Bridge – und zwar bewusst als allerletzten Schritt, nachdem alle PVID- und Bridge-VLAN-Tabellen-Einträge stehen, damit dabei keine Verbindung (auch nicht die eigene, laufende Einrichtung) unterbrochen wird. Die zugeordneten Anschlüsse erhalten zusätzlich `frame-types=admit-only-untagged-and-priority-tagged` und `ingress-filtering=yes`, damit ein daran angeschlossenes Gerät sich nicht per manuell getaggten Frames in ein anderes VLAN (z. B. das Heimnetz) einschleichen kann. Anschlüsse ohne eigene VLAN-Zuordnung bleiben unverändert Teil des normalen Heimnetzes.
|
||||
|
||||
### Eingabevalidierung
|
||||
|
||||
Adress-, Bereichs- und Portfelder in WAN, Heimnetzwerk, VLANs und Firewall werden direkt beim Tippen geprüft (IP-/„Adresse/Präfix"-Format, Start ≤ Ende bei Adress- und Portbereichen, Portzahlen 1–65535) und zeigen bei ungültiger Eingabe sofort eine rote, laientaugliche Fehlermeldung statt erst beim Anwenden mit einer RouterOS-Fehlermeldung zu scheitern. „Weiter" bleibt so lange gesperrt, bis alle Felder des aktuellen Schritts gültig sind. Behebt nebenbei einen echten Absturzpfad: Ohne diese Prüfung riefen `LanConfigurator`/`VlanConfigurator` bei jedem Tastendruck ungeprüft `IPAddress.Parse` auf einer möglicherweise unvollständigen Eingabe auf.
|
||||
|
||||
### Bekannte Router
|
||||
|
||||
Nach jeder erfolgreichen Verbindung merkt sich der Assistent Host, Benutzername und (sobald die Geräte-Erkennung gelaufen ist) die Hardware-Bezeichnung als Namensvorschlag — rein lokal, ohne weiteren Geräte-Zugriff. Das Passwort liegt dabei **nicht** in der lokalen Datei, sondern im Windows-Anmeldeinformationsspeicher (Systemsteuerung → Anmeldeinformationsverwaltung).
|
||||
|
||||
Bekannte Router leben ausschließlich als kompakte Kurzauswahl **dauerhaft in der Sidebar**, unabhängig vom aktuellen Schritt — das Hauptfenster selbst (Verbindungs-Schritt) zeigt nur noch das Formular für ein neues, noch unbekanntes Gerät, ohne Dopplung. Ein Klick auf einen Sidebar-Eintrag verbindet direkt (ohne Umweg über das Formular) und springt bei Erfolg automatisch weiter; ein kleines Papierkorb-Symbol entfernt einen Eintrag wieder. Der Name kommt automatisch vom Gerätemodell — eine eigene Umbenennung gibt es bewusst nicht mehr, um die Sidebar kompakt zu halten. Sobald eine Verbindung besteht, zeigt dieselbe Stelle stattdessen Gerätename/-modell (sobald die Geräte-Erkennung gelaufen ist, bis dahin nur die Adresse) mit einem „Wechseln"-Button, um die Verbindung zu trennen und zu einem anderen Gerät zurückzukehren.
|
||||
|
||||
Da viele MikroTik-Geräte werksseitig dieselbe Standardadresse verwenden (`192.168.88.1`/`admin`), würde eine reine Host+Benutzername-Zuordnung mehrere unterschiedliche physische Geräte fälschlich als dasselbe behandeln. Der Assistent liest deshalb zusätzlich die Seriennummer aus (`/system/routerboard`) und legt bei einem Host+Benutzername-Treffer mit **unterschiedlicher** Seriennummer einen eigenen Eintrag an, statt den bestehenden zu überschreiben — kennt eine Seite (noch) keine Seriennummer (z. B. ein virtuelles CHR-Testgerät), wird im Zweifel derselbe Eintrag aktualisiert.
|
||||
|
||||
## Für Nutzer
|
||||
|
||||
- Windows 10/11, keine Installation nötig
|
||||
@@ -84,7 +117,13 @@ dotnet run --project src/MikrotikInstaller.App
|
||||
|
||||
Erzeugt `src/MikrotikInstaller.App/bin/Release/net8.0-windows/win-x64/publish/MikroTik-Installer.exe` – self-contained, single-file, ohne .NET-Runtime-Installation beim Nutzer.
|
||||
|
||||
Es gibt außerdem einen Gitea-Actions-Workflow (`.gitea/workflows/build.yml`), der das bei jedem Push automatisch übernimmt – vorausgesetzt, ein passender Runner ist auf der Gitea-Instanz registriert.
|
||||
Es gibt außerdem einen Gitea-Actions-Workflow (`.gitea/workflows/build.yml`), der das bei jedem Push automatisch übernimmt.
|
||||
|
||||
### CI/CD (Gitea Actions)
|
||||
|
||||
Ein selbstgehosteter Runner (`gitea-runner`, Name „danielpc-windows") läuft im **User-Modus ohne Docker** direkt auf dem Windows-Entwicklungsrechner und nutzt dessen bereits installiertes .NET 8 SDK – daher **kein** `actions/setup-dotnet`-Schritt im Workflow (der würde versuchen, nach `C:\Program Files\dotnet` zu schreiben, wofür dem User-Modus-Runner die Rechte fehlen). `runs-on: windows-latest` im Workflow muss daher exakt dem Label entsprechen, mit dem der Runner registriert wurde (`windows-latest:host`).
|
||||
|
||||
Läuft der Runner nicht dauerhaft im Hintergrund (z. B. nach einem Neustart), bleiben Pushes einfach in der Warteschlange, bis er wieder gestartet wird – nichts geht verloren, es gibt nur keine sofortige Rückmeldung.
|
||||
|
||||
### Interner Testzugang (nur Debug-Builds)
|
||||
|
||||
@@ -209,6 +248,7 @@ classDiagram
|
||||
+string Architecture
|
||||
+bool HasWireless
|
||||
+InterfaceInfo[] Interfaces
|
||||
+string? SerialNumber
|
||||
}
|
||||
|
||||
class InterfaceInfo {
|
||||
@@ -365,16 +405,41 @@ Die MikroTik-Markenfarbe (`#009245`) wurde aus der offiziellen Website übernomm
|
||||
|
||||
## Roadmap
|
||||
|
||||
Die Weiterentwicklung wird als Milestones mit zugehörigen Issues im internen Gitea-Dev-Repository getrackt (nicht auf GitHub, siehe [Für Entwickler](#für-entwickler) zur Remote-Aufteilung):
|
||||
Die Weiterentwicklung wird als Milestones mit zugehörigen Issues im internen Gitea-Dev-Repository getrackt (nicht auf GitHub, siehe [Für Entwickler](#für-entwickler) zur Remote-Aufteilung).
|
||||
|
||||
Neun der Milestones (`M-*`-Präfix) entstanden aus einem Feature-Vergleich mit einer parallel entwickelten, funktional deutlich weiter fortgeschrittenen macOS/SwiftUI-Version desselben Assistenten (unabhängiges privates Repository, unabhängige Implementierung, keine gemeinsame Codebasis) und sollen schrittweise Funktionsparität herstellen.
|
||||
|
||||
| Milestone | Status | Fokus |
|
||||
|---|---|---|
|
||||
| **v1.0 – Realgerät-Validierung** | Offen | Kompletter Wizard-Durchlauf inkl. „Jetzt einrichten" (RouterOS-Befehle, Backup, PDF-Export, Update-Prüfung) gegen ein echtes physisches Gerät verifizieren – größter offener Validierungs-Gap. Dabei insbesondere die portbasierte VLAN-Zuordnung (`vlan-filtering`) gegen ein Echtgerät prüfen, da bisher nur gegen den simulierten Demo-Client getestet |
|
||||
| **v1.1 – Release-Housekeeping** | Offen | `v1.0.0`-Tag-Strategie klären, Copyright-Name in `LICENSE` bestätigen, Trennung Gitea (laufendes Dev) / GitHub (nur Releases) wiederherstellen |
|
||||
| **v1.2 – Eingabevalidierung** | Offen | Rest von Phase 7: Live-Fehlermeldungen bei ungültigen Eingaben (z. B. IP-/Subnetz-Format) direkt im jeweiligen Wizard-Schritt |
|
||||
| **v1.2 – Eingabevalidierung** | ✅ Abgeschlossen | Live-Fehlermeldungen bei ungültigen Eingaben (IP-/Subnetz-Format, Adressbereiche, Portbereiche) direkt in WAN/LAN/VLAN/Firewall, vom Nutzer bestätigt. Dabei nebenbei einen eigenständigen Bug behoben: Firewall-/NAT-Regeln duplizierten sich bei wiederholtem Wizard-Lauf gegen dasselbe Gerät (Issue #19) |
|
||||
| **v1.3 – SFTP-Backup-Download** | Offen | Backup-Download aufs PC per SFTP nachrüsten (bisher liegt das Backup nur auf dem Gerät selbst) |
|
||||
| **IST-Zustand-Analyse** | ✅ Abgeschlossen | Kernfunktion umgesetzt und vom Nutzer bestätigt (Kategorie-Grafik, Firewall-/NAT-Regeln, Teilbaum-Hervorhebung). Offen: **optisches Feintuning** sowie Route-zu-Interface-Zuordnung gegen ein echtes Bestandsgerät verifizieren (derzeit best-effort, RouterOS-versionsabhängig) |
|
||||
| **CI/CD – Gitea-Actions-Runner** | Offen | Runner registrieren und `.gitea/workflows/build.yml` erstmals scharf testen |
|
||||
| **CI/CD – Gitea-Actions-Runner** | ✅ Abgeschlossen | Selbstgehosteter Runner registriert, `build.yml` erfolgreich scharf getestet (Run #26: Tests + Publish + Artefakt-Upload grün). Läuft aktuell nur, solange der Runner-Prozess manuell gestartet ist — kein Autostart/Dienst eingerichtet |
|
||||
| **M-Bekannte-Router – Gespeicherte Verbindungen** | ✅ Abgeschlossen | Zuletzt genutzte Router mit Name/Standort merken, vom Nutzer bestätigt (siehe „Bekannte Router" oben) |
|
||||
| **M-Geräte – LAN-Scanner** | ✅ Abgeschlossen | DHCP-Leases + ARP + Bridge-Hosts je Anschluss zusammengeführt, feste IP-Zuweisung (siehe „Geräte im Netzwerk" oben). Offen: Verhalten von „Make Static" gegen ein echtes Gerät verifizieren (Issue #30) |
|
||||
| **M-Backup-Wartung – Wiederherstellung, Werksreset, Firmware-Update** | Teilweise abgeschlossen | Backup zurückspielen mit vierstufigem Seriennummer-/Architektur-/Modell-Abgleich als Brick-Schutz ✅, eigenständiger „Backup verwalten"-Bereich außerhalb des Interviews ✅ (siehe „Backup verwalten" oben; Verhalten von `/system/backup/load` gegen ein echtes Gerät offen, Issue #32). Noch offen: Werksreset-Notfallschalter, Routerboard-Firmware-Update (Issue #28) |
|
||||
| **M-Übersicht-Edit – Direktes Bearbeiten im Diagramm** | Offen | IST-Zustand-Diagramm von rein lesend auf bearbeitbar (Rückschreiben ans Gerät) erweitern |
|
||||
| **M-SSH – SSH-Fallback-Transport** | Offen | SSH-CLI-Fallback-Verbindung für Geräte/Firmware ohne REST-API, inkl. Hostkey-TOFU |
|
||||
| **M-Netzwerk-Tools – Ping/Traceroute/DNS/Port-Scan** | Offen | Netzwerk-Werkzeuge vom Router aus sowie lokaler TCP-Port-Scan |
|
||||
| **M-Live-Traffic – Live-Durchsatzanzeige** | Offen | Live-Traffic-Anzeige an Interfaces und im LAN-Scanner |
|
||||
| **M-Experte – Generischer RouterOS-Zugriff** | Offen | Eigener Bereich mit generischer Menü-Engine + kuratierten Formularen für die wichtigsten RouterOS-Bereiche, über die bisherigen Experten-Zusatzfelder hinaus |
|
||||
| **M-i18n – Zweisprachigkeit DE/EN** | Offen | Umschaltbare Sprache für die gesamte App |
|
||||
|
||||
### Ablaufplan (Priorisierung)
|
||||
|
||||
Reihenfolge oben in der Tabelle bereits danach sortiert; Begründung nach Phasen:
|
||||
|
||||
1. **Fundament/Sicherheit zuerst** ✅ — `v1.2` (behebt einen echten Absturzpfad) zusammen mit dem eigenständig gefundenen Firewall-/NAT-Idempotenz-Bug (Issue #19, kein Milestone) und `CI/CD` sind abgeschlossen. `v1.0` (Realgerät-Validierung) bleibt offen, bis Testhardware verfügbar ist — bevor auf der bestehenden Basis neue Flächen entstehen, muss sie gegen echte Hardware stehen.
|
||||
2. **Unabhängige Mac-Paritätsfeatures ohne SSH-Abhängigkeit** — `M-Bekannte-Router` ✅ und `M-Geräte`/LAN-Scanner ✅ abgeschlossen, `M-Backup-Wartung` teilweise (Backup-Wiederherstellung ✅, Werksreset/Firmware-Update offen). Noch offen: Rest von `M-Backup-Wartung`, `v1.3` (SFTP-Backup-Download, gleicher Themenbereich, sinnvoll im selben Zug), `M-Übersicht-Edit` (baut auf der bereits verifizierten Nodify-Integration auf).
|
||||
3. **Transport-Fundament** — `M-SSH`: einzige Mac-Funktion, die einen komplett neuen Transport-Layer braucht (Ping/Traceroute/DNS-Auflösung und `/interface monitor-traffic` sind reine CLI-Befehle ohne REST-Äquivalent). Bewusst erst nach Phase 2, damit die risikoarmen Features nicht auf die größere Transport-Arbeit warten müssen.
|
||||
4. **Darauf aufbauend** — `M-Netzwerk-Tools` (Port-Scan-Teil ist unabhängig und könnte vorgezogen werden, Ping/Traceroute/DNS brauchen `M-SSH`) und `M-Live-Traffic` (falls kein REST-Äquivalent für `/interface monitor-traffic` existiert, ebenfalls `M-SSH`-abhängig — das wird als Erstes bei der Umsetzung geprüft).
|
||||
5. **Größtes Einzelstück zuletzt unter den Mac-Milestones** — `M-Experte` (generischer Zugriff auf >300 RouterOS-Menüs) ist der mit Abstand größte Umfang und blockiert nichts anderes; am besten inkrementell über einen längeren Zeitraum, Bereich für Bereich wie beim Mac-Vorbild.
|
||||
6. **Querschnitt zuletzt** — `M-i18n` erst, wenn die UI-Fläche der vorherigen Phasen weitgehend steht, sonst laufend neue unübersetzte Strings.
|
||||
7. **Release-Abschluss** — `v1.1` (Release-Housekeeping) kurz vor dem tatsächlichen `v1.0.0`-Tag, nicht vorher.
|
||||
|
||||
**Abgleichs-Rhythmus mit der Mac-Version:** Kein einmaliger Abgleich, sondern wiederkehrend — nach Abschluss eines `M-*`-Milestones hier oder einer entsprechenden Funktion auf Mac-Seite jeweils kurz prüfen, ob sich die READMEs/Milestones beider Projekte noch decken. RouterOS-API-Eigenheiten, die keine sprach-/framework-spezifische Ursache haben (wie der Firewall-Idempotenz-Bug, Issue #19), werden in beide Projekte übernommen, sobald sie auf einer Seite auffallen.
|
||||
|
||||
## Lizenz
|
||||
|
||||
|
||||
@@ -18,21 +18,32 @@
|
||||
<converters:StringToVisibilityConverter x:Key="StringToVisibilityConverter" />
|
||||
<converters:NullToVisibilityConverter x:Key="NullToVisibilityConverter" />
|
||||
<converters:InverseBooleanConverter x:Key="InverseBooleanConverter" />
|
||||
<converters:EnumToBooleanConverter x:Key="EnumToBooleanConverter" />
|
||||
|
||||
<!-- MikroTik-Markenfarbe (aus mikrotik.com), theme-unabhängig konstant. -->
|
||||
<SolidColorBrush x:Key="MikrotikAccentBrush" Color="#009245" />
|
||||
<SolidColorBrush x:Key="MikrotikAccentSoftBrush" Color="#1A009245" />
|
||||
<SolidColorBrush x:Key="MikrotikRedBrush" Color="#E00000" />
|
||||
<SolidColorBrush x:Key="MikrotikWarningBrush" Color="#C77700" />
|
||||
|
||||
<DataTemplate DataType="{x:Type steps:ConnectionStepViewModel}">
|
||||
<stepViews:ConnectionStepView />
|
||||
</DataTemplate>
|
||||
<DataTemplate DataType="{x:Type steps:AreaSelectionStepViewModel}">
|
||||
<stepViews:AreaSelectionStepView />
|
||||
</DataTemplate>
|
||||
<DataTemplate DataType="{x:Type steps:BackupManagementStepViewModel}">
|
||||
<stepViews:BackupManagementStepView />
|
||||
</DataTemplate>
|
||||
<DataTemplate DataType="{x:Type steps:DeviceOverviewStepViewModel}">
|
||||
<stepViews:DeviceOverviewStepView />
|
||||
</DataTemplate>
|
||||
<DataTemplate DataType="{x:Type steps:NetworkAnalysisStepViewModel}">
|
||||
<stepViews:NetworkAnalysisStepView />
|
||||
</DataTemplate>
|
||||
<DataTemplate DataType="{x:Type steps:LanScanStepViewModel}">
|
||||
<stepViews:LanScanStepView />
|
||||
</DataTemplate>
|
||||
<DataTemplate DataType="{x:Type steps:ModeSelectionStepViewModel}">
|
||||
<stepViews:ModeSelectionStepView />
|
||||
</DataTemplate>
|
||||
|
||||
@@ -47,3 +47,17 @@ public sealed class NullToVisibilityConverter : IValueConverter
|
||||
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) =>
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bindet ein <see cref="RadioButton.IsChecked"/> an einen bestimmten Wert eines Enums (ConverterParameter
|
||||
/// als String, z. B. "ExpertMode"), damit sich mehr als zwei sich gegenseitig ausschließende Optionen über
|
||||
/// eine einzige Enum-Property statt mehrerer verschränkter bool-Properties abbilden lassen.
|
||||
/// </summary>
|
||||
public sealed class EnumToBooleanConverter : IValueConverter
|
||||
{
|
||||
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) =>
|
||||
value is not null && parameter is string parameterString && string.Equals(value.ToString(), parameterString, StringComparison.Ordinal);
|
||||
|
||||
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) =>
|
||||
value is true && parameter is string parameterString ? Enum.Parse(targetType, parameterString) : Binding.DoNothing;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
|
||||
namespace MikrotikInstaller.App.Credentials;
|
||||
|
||||
/// <summary>
|
||||
/// Speichert Passwörter für "Bekannte Router" im Windows-Anmeldeinformationsspeicher
|
||||
/// (Systemsteuerung → Anmeldeinformationsverwaltung) statt im Klartext in
|
||||
/// <c>SavedRoutersStore</c>s JSON-Datei — bewusst hier in App statt Core, da Core plattformneutral
|
||||
/// bleiben soll (TargetFramework net8.0) und dies reine Windows-API (advapi32.dll) ist.
|
||||
///
|
||||
/// Rein lesende/aufräumende Vorgänge (siehe <see cref="TryRead"/>) geben bei Fehlern nur einen
|
||||
/// leeren Zustand zurück statt zu werfen — "Bekannte Router" ist eine Komfortfunktion, ein
|
||||
/// Anmeldeinformationsspeicher-Problem soll nie den Assistenten selbst zum Absturz bringen.
|
||||
/// </summary>
|
||||
internal static class WindowsCredentialStore
|
||||
{
|
||||
private const uint CredTypeGeneric = 1;
|
||||
private const uint CredPersistLocalMachine = 2;
|
||||
private const int ErrorNotFound = 1168;
|
||||
|
||||
public static void Save(string savedRouterId, string username, string password)
|
||||
{
|
||||
var passwordBytes = Encoding.Unicode.GetBytes(password);
|
||||
var blob = Marshal.AllocHGlobal(Math.Max(passwordBytes.Length, 1));
|
||||
try
|
||||
{
|
||||
if (passwordBytes.Length > 0)
|
||||
{
|
||||
Marshal.Copy(passwordBytes, 0, blob, passwordBytes.Length);
|
||||
}
|
||||
|
||||
var credential = new Credential
|
||||
{
|
||||
Type = CredTypeGeneric,
|
||||
TargetName = TargetName(savedRouterId),
|
||||
CredentialBlobSize = (uint)passwordBytes.Length,
|
||||
CredentialBlob = blob,
|
||||
Persist = CredPersistLocalMachine,
|
||||
UserName = username,
|
||||
};
|
||||
|
||||
CredWrite(ref credential, 0);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Marshal.FreeHGlobal(blob);
|
||||
}
|
||||
}
|
||||
|
||||
public static string? TryRead(string savedRouterId)
|
||||
{
|
||||
if (!CredRead(TargetName(savedRouterId), CredTypeGeneric, 0, out var credentialPtr))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var credential = Marshal.PtrToStructure<Credential>(credentialPtr);
|
||||
if (credential.CredentialBlobSize == 0 || credential.CredentialBlob == IntPtr.Zero)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var passwordBytes = new byte[credential.CredentialBlobSize];
|
||||
Marshal.Copy(credential.CredentialBlob, passwordBytes, 0, passwordBytes.Length);
|
||||
return Encoding.Unicode.GetString(passwordBytes);
|
||||
}
|
||||
finally
|
||||
{
|
||||
CredFree(credentialPtr);
|
||||
}
|
||||
}
|
||||
|
||||
public static void Delete(string savedRouterId)
|
||||
{
|
||||
if (!CredDelete(TargetName(savedRouterId), CredTypeGeneric, 0) && Marshal.GetLastWin32Error() != ErrorNotFound)
|
||||
{
|
||||
// Best effort — siehe Klassenkommentar.
|
||||
}
|
||||
}
|
||||
|
||||
private static string TargetName(string savedRouterId) => $"MikroTik-Installer:{savedRouterId}";
|
||||
|
||||
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
|
||||
private struct Credential
|
||||
{
|
||||
public uint Flags;
|
||||
public uint Type;
|
||||
public string TargetName;
|
||||
public string? Comment;
|
||||
public long LastWritten;
|
||||
public uint CredentialBlobSize;
|
||||
public IntPtr CredentialBlob;
|
||||
public uint Persist;
|
||||
public uint AttributeCount;
|
||||
public IntPtr Attributes;
|
||||
public string? TargetAlias;
|
||||
public string? UserName;
|
||||
}
|
||||
|
||||
[DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
|
||||
private static extern bool CredWrite(ref Credential credential, uint flags);
|
||||
|
||||
[DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
|
||||
private static extern bool CredRead(string target, uint type, uint flags, out IntPtr credentialPtr);
|
||||
|
||||
[DllImport("advapi32.dll", SetLastError = true)]
|
||||
private static extern bool CredFree(IntPtr credential);
|
||||
|
||||
[DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
|
||||
private static extern bool CredDelete(string target, uint type, uint flags);
|
||||
}
|
||||
@@ -39,89 +39,154 @@
|
||||
<!-- Sidebar: Marke + Schritt-Übersicht -->
|
||||
<Border Grid.Column="0" Background="{DynamicResource CardBackgroundFillColorDefaultBrush}"
|
||||
BorderBrush="{DynamicResource DividerStrokeColorDefaultBrush}" BorderThickness="0,0,1,0">
|
||||
<StackPanel Margin="20,24,16,24">
|
||||
<StackPanel Orientation="Horizontal" Margin="4,0,0,28">
|
||||
<Grid Margin="20,24,16,24">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="*" />
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<StackPanel Grid.Row="0" Orientation="Horizontal" Margin="4,0,0,20">
|
||||
<Image Source="pack://application:,,,/Assets/app.ico" Width="22" Height="22"
|
||||
Margin="0,0,10,0" VerticalAlignment="Center" />
|
||||
<TextBlock Text="MikroTik Installer" FontWeight="SemiBold" FontSize="15" VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
|
||||
<ItemsControl ItemsSource="{Binding StepNavItems}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<Border Padding="10,8" Margin="0,0,0,2" CornerRadius="6">
|
||||
<Border.Style>
|
||||
<Style TargetType="Border">
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Style.Triggers>
|
||||
<DataTrigger Binding="{Binding IsCurrent}" Value="True">
|
||||
<Setter Property="Background" Value="{StaticResource MikrotikAccentSoftBrush}" />
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</Border.Style>
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<Border Width="22" Height="22" CornerRadius="11" Margin="0,0,10,0">
|
||||
<Border.Style>
|
||||
<Style TargetType="Border">
|
||||
<Setter Property="Background" Value="{DynamicResource ControlFillColorSecondaryBrush}" />
|
||||
<Style.Triggers>
|
||||
<DataTrigger Binding="{Binding IsCurrent}" Value="True">
|
||||
<Setter Property="Background" Value="{StaticResource MikrotikAccentBrush}" />
|
||||
</DataTrigger>
|
||||
<DataTrigger Binding="{Binding IsCompleted}" Value="True">
|
||||
<Setter Property="Background" Value="{StaticResource MikrotikAccentBrush}" />
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</Border.Style>
|
||||
<Grid>
|
||||
<TextBlock Text="{Binding Number}" FontSize="11" FontWeight="SemiBold"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center" Foreground="White">
|
||||
<TextBlock.Style>
|
||||
<Style TargetType="TextBlock">
|
||||
<Style.Triggers>
|
||||
<DataTrigger Binding="{Binding IsCompleted}" Value="True">
|
||||
<Setter Property="Visibility" Value="Collapsed" />
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</TextBlock.Style>
|
||||
</TextBlock>
|
||||
<ui:SymbolIcon Symbol="Checkmark24" FontSize="12" Foreground="White"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center" Visibility="Collapsed">
|
||||
<ui:SymbolIcon.Style>
|
||||
<Style TargetType="ui:SymbolIcon">
|
||||
<Style.Triggers>
|
||||
<DataTrigger Binding="{Binding IsCompleted}" Value="True">
|
||||
<Setter Property="Visibility" Value="Visible" />
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</ui:SymbolIcon.Style>
|
||||
</ui:SymbolIcon>
|
||||
</Grid>
|
||||
</Border>
|
||||
<TextBlock Text="{Binding Title}" VerticalAlignment="Center" TextWrapping="Wrap" MaxWidth="150">
|
||||
<TextBlock.Style>
|
||||
<Style TargetType="TextBlock">
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextFillColorSecondaryBrush}" />
|
||||
<Setter Property="FontWeight" Value="Normal" />
|
||||
<Style.Triggers>
|
||||
<DataTrigger Binding="{Binding IsCurrent}" Value="True">
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextFillColorPrimaryBrush}" />
|
||||
<Setter Property="FontWeight" Value="SemiBold" />
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</TextBlock.Style>
|
||||
</TextBlock>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</StackPanel>
|
||||
<!-- Verbindungs-Karte: dauerhaft sichtbar, unabhängig vom aktuellen Schritt (Issue #36). -->
|
||||
<Border Grid.Row="1" Background="{DynamicResource ControlFillColorSecondaryBrush}" CornerRadius="8" Padding="12" Margin="0,0,0,16">
|
||||
<Grid>
|
||||
<StackPanel Visibility="{Binding Connection.IsConnected, Converter={StaticResource BoolToVisibilityConverter}}">
|
||||
<StackPanel Orientation="Horizontal" Margin="0,0,0,6">
|
||||
<Border Width="8" Height="8" CornerRadius="4" Background="{StaticResource MikrotikAccentBrush}"
|
||||
Margin="0,0,8,0" VerticalAlignment="Center" />
|
||||
<TextBlock Text="Verbunden" FontSize="12" Opacity="0.7" VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
<TextBlock Text="{Binding ConnectedDeviceLabel}" FontWeight="SemiBold" TextWrapping="Wrap" Margin="0,0,0,10" />
|
||||
<ui:Button Content="Wechseln" Appearance="Secondary" HorizontalAlignment="Stretch"
|
||||
Command="{Binding SwitchConnectionCommand}" />
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Visibility="{Binding Connection.IsConnected, Converter={StaticResource BoolToVisibilityConverter}, ConverterParameter=Invert}">
|
||||
<TextBlock Text="Nicht verbunden" FontSize="12" Opacity="0.7" Margin="0,0,0,8" />
|
||||
|
||||
<StackPanel Visibility="{Binding Connection.HasSavedRouters, Converter={StaticResource BoolToVisibilityConverter}}">
|
||||
<ScrollViewer MaxHeight="150" VerticalScrollBarVisibility="Auto">
|
||||
<ItemsControl ItemsSource="{Binding Connection.SavedRouters}"
|
||||
IsEnabled="{Binding Connection.IsTesting, Converter={StaticResource InverseBooleanConverter}}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<Grid Margin="0,0,0,4">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<ui:Button Grid.Column="0" Appearance="Secondary" HorizontalAlignment="Stretch" HorizontalContentAlignment="Left"
|
||||
Command="{Binding DataContext.QuickConnectCommand, RelativeSource={RelativeSource AncestorType=ItemsControl}}"
|
||||
CommandParameter="{Binding}">
|
||||
<StackPanel>
|
||||
<TextBlock Text="{Binding Name}" FontWeight="SemiBold" FontSize="12" />
|
||||
<TextBlock Text="{Binding Subtitle}" FontSize="11" Opacity="0.7" />
|
||||
</StackPanel>
|
||||
</ui:Button>
|
||||
<ui:Button Grid.Column="1" Appearance="Secondary" Margin="4,0,0,0" Padding="6,0" ToolTip="Entfernen"
|
||||
Command="{Binding DataContext.RemoveSavedRouterCommand, RelativeSource={RelativeSource AncestorType=ItemsControl}}"
|
||||
CommandParameter="{Binding}">
|
||||
<ui:SymbolIcon Symbol="Delete20" />
|
||||
</ui:Button>
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</ScrollViewer>
|
||||
</StackPanel>
|
||||
|
||||
<TextBlock Text="Neue Verbindung im ersten Schritt einrichten." FontSize="12" Opacity="0.6" TextWrapping="Wrap"
|
||||
Visibility="{Binding Connection.HasSavedRouters, Converter={StaticResource BoolToVisibilityConverter}, ConverterParameter=Invert}" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- ScrollViewer, damit bei vielen Schritten (z. B. inkl. übersprungener) auch die
|
||||
unteren Einträge erreichbar bleiben, statt am unteren Fensterrand abgeschnitten
|
||||
zu werden (siehe Issue #35). -->
|
||||
<ScrollViewer Grid.Row="2" VerticalScrollBarVisibility="Auto" Padding="0,0,4,0">
|
||||
<ItemsControl ItemsSource="{Binding StepNavItems}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<Border Padding="10,8" Margin="0,0,0,2" CornerRadius="6">
|
||||
<Border.Style>
|
||||
<Style TargetType="Border">
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Style.Triggers>
|
||||
<DataTrigger Binding="{Binding IsCurrent}" Value="True">
|
||||
<Setter Property="Background" Value="{StaticResource MikrotikAccentSoftBrush}" />
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</Border.Style>
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<Border Width="22" Height="22" CornerRadius="11" Margin="0,0,10,0">
|
||||
<Border.Style>
|
||||
<Style TargetType="Border">
|
||||
<Setter Property="Background" Value="{DynamicResource ControlFillColorSecondaryBrush}" />
|
||||
<Style.Triggers>
|
||||
<DataTrigger Binding="{Binding IsCurrent}" Value="True">
|
||||
<Setter Property="Background" Value="{StaticResource MikrotikAccentBrush}" />
|
||||
</DataTrigger>
|
||||
<DataTrigger Binding="{Binding IsCompleted}" Value="True">
|
||||
<Setter Property="Background" Value="{StaticResource MikrotikAccentBrush}" />
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</Border.Style>
|
||||
<Grid>
|
||||
<TextBlock Text="{Binding Number}" FontSize="11" FontWeight="SemiBold"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center" Foreground="White">
|
||||
<TextBlock.Style>
|
||||
<Style TargetType="TextBlock">
|
||||
<Style.Triggers>
|
||||
<DataTrigger Binding="{Binding IsCompleted}" Value="True">
|
||||
<Setter Property="Visibility" Value="Collapsed" />
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</TextBlock.Style>
|
||||
</TextBlock>
|
||||
<ui:SymbolIcon Symbol="Checkmark24" FontSize="12" Foreground="White"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center" Visibility="Collapsed">
|
||||
<ui:SymbolIcon.Style>
|
||||
<Style TargetType="ui:SymbolIcon">
|
||||
<Style.Triggers>
|
||||
<DataTrigger Binding="{Binding IsCompleted}" Value="True">
|
||||
<Setter Property="Visibility" Value="Visible" />
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</ui:SymbolIcon.Style>
|
||||
</ui:SymbolIcon>
|
||||
</Grid>
|
||||
</Border>
|
||||
<TextBlock Text="{Binding Title}" VerticalAlignment="Center" TextWrapping="Wrap" MaxWidth="150">
|
||||
<TextBlock.Style>
|
||||
<Style TargetType="TextBlock">
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextFillColorSecondaryBrush}" />
|
||||
<Setter Property="FontWeight" Value="Normal" />
|
||||
<Style.Triggers>
|
||||
<DataTrigger Binding="{Binding IsCurrent}" Value="True">
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextFillColorPrimaryBrush}" />
|
||||
<Setter Property="FontWeight" Value="SemiBold" />
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</TextBlock.Style>
|
||||
</TextBlock>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</ScrollViewer>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- Inhalt: aktueller Schritt -->
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace MikrotikInstaller.App.ViewModels;
|
||||
|
||||
/// <summary>
|
||||
/// Der grundsätzliche Bereich, den der Nutzer direkt nach dem Verbinden wählt — bestimmt, welche
|
||||
/// Schritte im weiteren Verlauf übersprungen werden (siehe <see cref="Steps.WizardStepViewModelBase.ShouldSkip"/>
|
||||
/// der jeweiligen Schritte).
|
||||
/// </summary>
|
||||
public enum AppArea
|
||||
{
|
||||
/// <summary>Der komplette Einrichtungs-Assistent (Internet-Zugang, Heimnetzwerk, VLANs, WLAN, Firewall, Zusammenfassung).</summary>
|
||||
SetupWizard,
|
||||
|
||||
/// <summary>Direkter Zugriff auf Backups (auflisten, erstellen, wiederherstellen) ohne den restlichen Assistenten.</summary>
|
||||
BackupManagement,
|
||||
}
|
||||
@@ -17,8 +17,11 @@ public partial class MainViewModel : ObservableObject
|
||||
Steps =
|
||||
[
|
||||
new ConnectionStepViewModel(_session),
|
||||
new AreaSelectionStepViewModel(_session),
|
||||
new BackupManagementStepViewModel(_session),
|
||||
new DeviceOverviewStepViewModel(_session),
|
||||
new NetworkAnalysisStepViewModel(_session),
|
||||
new LanScanStepViewModel(_session),
|
||||
new ModeSelectionStepViewModel(_session),
|
||||
new WanStepViewModel(_session),
|
||||
new LanStepViewModel(_session),
|
||||
@@ -31,7 +34,14 @@ public partial class MainViewModel : ObservableObject
|
||||
|
||||
foreach (var step in Steps)
|
||||
{
|
||||
step.PropertyChanged += (_, _) => GoNextCommand.NotifyCanExecuteChanged();
|
||||
step.PropertyChanged += (_, _) =>
|
||||
{
|
||||
GoNextCommand.NotifyCanExecuteChanged();
|
||||
// Grob, aber einfach: jede Step-Änderung kann bedeuten, dass sich _session.Device
|
||||
// (gesetzt von DeviceOverview- oder BackupManagementStepViewModel) inzwischen gefüllt
|
||||
// hat — die Sidebar zeigt das dauerhaft an, unabhängig vom aktuellen Schritt.
|
||||
OnPropertyChanged(nameof(ConnectedDeviceLabel));
|
||||
};
|
||||
}
|
||||
|
||||
CurrentStepIndex = 0;
|
||||
@@ -39,6 +49,12 @@ public partial class MainViewModel : ObservableObject
|
||||
|
||||
public ObservableCollection<WizardStepViewModelBase> Steps { get; }
|
||||
|
||||
/// <summary>Für die dauerhafte Verbindungs-Anzeige in der Sidebar — immer derselbe erste Schritt.</summary>
|
||||
public ConnectionStepViewModel Connection => (ConnectionStepViewModel)Steps[0];
|
||||
|
||||
/// <summary>Geräte-Modell, sobald bekannt (nach Geräte-Erkennung oder Backup-Verwaltung), sonst nur der Host.</summary>
|
||||
public string? ConnectedDeviceLabel => _session.Device is { } device ? $"{device.IdentityName} ({device.BoardModel})" : Connection.Host;
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(CurrentStep))]
|
||||
[NotifyPropertyChangedFor(nameof(ProgressText))]
|
||||
@@ -89,6 +105,33 @@ public partial class MainViewModel : ObservableObject
|
||||
|
||||
private bool CanGoNext() => !IsLastStep && CurrentStep.CanGoNext;
|
||||
|
||||
/// <summary>
|
||||
/// Verbindet über einen Sidebar-Klick auf einen bekannten Router direkt (ohne Umweg über die
|
||||
/// Formularfelder des Verbindungs-Schritts) und geht bei Erfolg automatisch weiter — nur relevant,
|
||||
/// solange noch keine Verbindung besteht, denn nur dann zeigt die Sidebar diese Kurzauswahl.
|
||||
/// </summary>
|
||||
[RelayCommand]
|
||||
private async Task QuickConnectAsync(SavedRouterEntry entry)
|
||||
{
|
||||
await Connection.ConnectToSavedRouterCommand.ExecuteAsync(entry);
|
||||
if (Connection.IsConnected)
|
||||
{
|
||||
await GoNextCommand.ExecuteAsync(null);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Trennt die aktuelle Verbindung und springt zurück zum Verbindungs-Schritt, um ein anderes Gerät zu wählen.</summary>
|
||||
[RelayCommand]
|
||||
private async Task SwitchConnectionAsync()
|
||||
{
|
||||
await _session.DisposeClientAsync();
|
||||
_session.Device = null;
|
||||
Connection.IsConnected = false;
|
||||
|
||||
CurrentStepIndex = 0;
|
||||
await CurrentStep.OnActivatedAsync();
|
||||
}
|
||||
|
||||
/// <summary>Wird ausgelöst, wenn der Nutzer auf dem letzten Schritt „Beenden" klickt.</summary>
|
||||
public event EventHandler? CloseRequested;
|
||||
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
|
||||
namespace MikrotikInstaller.App.ViewModels.Steps;
|
||||
|
||||
/// <summary>
|
||||
/// Zweiter Schritt, direkt nach der Verbindung: Wahl zwischen dem kompletten Einrichtungs-Assistenten
|
||||
/// und der eigenständigen Backup-Verwaltung (auflisten, erstellen, wiederherstellen — ohne den Rest
|
||||
/// des Interviews). Die Wahl steuert <see cref="WizardSession.SelectedArea"/> und damit, welche der
|
||||
/// folgenden Schritte per <see cref="WizardStepViewModelBase.ShouldSkip"/> übersprungen werden.
|
||||
/// Der Einrichtungs-Assistent ist vorausgewählt.
|
||||
/// </summary>
|
||||
public partial class AreaSelectionStepViewModel : WizardStepViewModelBase
|
||||
{
|
||||
private readonly WizardSession _session;
|
||||
|
||||
public AreaSelectionStepViewModel(WizardSession session)
|
||||
: base(
|
||||
title: "Was möchtest du tun?",
|
||||
description: "Wähle, ob du dein Gerät komplett neu einrichten oder nur ein Backup verwalten möchtest.")
|
||||
{
|
||||
_session = session;
|
||||
_isBackupManagement = session.SelectedArea == AppArea.BackupManagement;
|
||||
}
|
||||
|
||||
[ObservableProperty]
|
||||
private bool _isBackupManagement;
|
||||
|
||||
public override Task OnActivatedAsync()
|
||||
{
|
||||
IsBackupManagement = _session.SelectedArea == AppArea.BackupManagement;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
partial void OnIsBackupManagementChanged(bool value) =>
|
||||
_session.SelectedArea = value ? AppArea.BackupManagement : AppArea.SetupWizard;
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using MikrotikInstaller.Core.Backup;
|
||||
using MikrotikInstaller.Core.Connectivity;
|
||||
using MikrotikInstaller.Core.Devices;
|
||||
|
||||
namespace MikrotikInstaller.App.ViewModels.Steps;
|
||||
|
||||
/// <summary>
|
||||
/// Eigenständiger Bereich für alles rund um Backups (auflisten, erstellen, wiederherstellen), erreichbar
|
||||
/// direkt nach der Verbindung über <see cref="AreaSelectionStepViewModel"/> — ohne den restlichen
|
||||
/// Einrichtungs-Assistenten zu durchlaufen. Liest das Gerät (Modell, Architektur, Seriennummer) dafür
|
||||
/// selbst aus, unabhängig von <see cref="DeviceOverviewStepViewModel"/>, die in diesem Bereich
|
||||
/// übersprungen wird. Rein informativ bis auf die Backup-Aktionen selbst — verändert sonst nichts am
|
||||
/// Gerät und ist nicht Teil der vom Assistenten geplanten Soll-Konfiguration.
|
||||
/// </summary>
|
||||
public partial class BackupManagementStepViewModel : WizardStepViewModelBase
|
||||
{
|
||||
private readonly WizardSession _session;
|
||||
|
||||
public BackupManagementStepViewModel(WizardSession session)
|
||||
: base(
|
||||
title: "Backup verwalten",
|
||||
description: "Vorhandene Backups auf diesem Gerät auflisten, ein neues erstellen oder eines zurückspielen.")
|
||||
{
|
||||
_session = session;
|
||||
}
|
||||
|
||||
public ObservableCollection<BackupSelectionItemViewModel> AvailableBackups { get; } = [];
|
||||
|
||||
public override bool ShouldSkip => _session.SelectedArea != AppArea.BackupManagement;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool _isLoadingDevice;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool _isLoadingBackups;
|
||||
|
||||
[ObservableProperty]
|
||||
private string? _errorMessage;
|
||||
|
||||
[ObservableProperty]
|
||||
private RouterDevice? _device;
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyCanExecuteChangedFor(nameof(ApplyRestoreCommand))]
|
||||
private BackupSelectionItemViewModel? _selectedBackup;
|
||||
|
||||
/// <summary>Nur relevant, wenn <see cref="BackupSelectionItemViewModel.RequiresConfirmation"/> beim ausgewählten Backup gesetzt ist.</summary>
|
||||
[ObservableProperty]
|
||||
[NotifyCanExecuteChangedFor(nameof(ApplyRestoreCommand))]
|
||||
private bool _confirmRiskyBackup;
|
||||
|
||||
[ObservableProperty]
|
||||
private string? _createBackupMessage;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool _isRestoring;
|
||||
|
||||
[ObservableProperty]
|
||||
private string? _restoreProgressText;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool _restoreFinished;
|
||||
|
||||
public override bool CanGoNext => true;
|
||||
|
||||
public override async Task OnActivatedAsync()
|
||||
{
|
||||
if (_session.Client is null)
|
||||
{
|
||||
ErrorMessage = "Keine Verbindung zum Gerät.";
|
||||
return;
|
||||
}
|
||||
|
||||
ErrorMessage = null;
|
||||
CreateBackupMessage = null;
|
||||
RestoreFinished = false;
|
||||
IsLoadingDevice = true;
|
||||
|
||||
try
|
||||
{
|
||||
// Bewusst unabhängig von DeviceOverviewStepViewModel (der in diesem Bereich übersprungen
|
||||
// wird) — dieser Schritt muss auch direkt nach der Verbindung, ohne Umweg, funktionieren.
|
||||
Device = await DeviceDiscoveryService.DiscoverAsync(_session.Client);
|
||||
_session.Device = Device;
|
||||
}
|
||||
catch (RouterOsException ex)
|
||||
{
|
||||
ErrorMessage = ex.Message;
|
||||
IsLoadingDevice = false;
|
||||
return;
|
||||
}
|
||||
|
||||
IsLoadingDevice = false;
|
||||
await LoadBackupsAsync();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private Task Refresh() => LoadBackupsAsync();
|
||||
|
||||
private async Task LoadBackupsAsync()
|
||||
{
|
||||
if (_session.Client is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
AvailableBackups.Clear();
|
||||
SelectedBackup = null;
|
||||
ConfirmRiskyBackup = false;
|
||||
IsLoadingBackups = true;
|
||||
|
||||
try
|
||||
{
|
||||
var backups = await BackupRestoreService.ListBackupsAsync(_session.Client);
|
||||
foreach (var backup in backups)
|
||||
{
|
||||
AvailableBackups.Add(new BackupSelectionItemViewModel(backup, Device?.BoardModel, Device?.Architecture, Device?.SerialNumber));
|
||||
}
|
||||
}
|
||||
catch (RouterOsException ex)
|
||||
{
|
||||
ErrorMessage = ex.Message;
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsLoadingBackups = false;
|
||||
CreateBackupNowCommand.NotifyCanExecuteChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private bool CanCreateBackupNow => !IsLoadingBackups && !IsRestoring && _session.Client is not null;
|
||||
|
||||
[RelayCommand(CanExecute = nameof(CanCreateBackupNow))]
|
||||
private async Task CreateBackupNowAsync()
|
||||
{
|
||||
if (_session.Client is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
IsRestoring = true;
|
||||
ErrorMessage = null;
|
||||
CreateBackupMessage = null;
|
||||
CreateBackupNowCommand.NotifyCanExecuteChanged();
|
||||
|
||||
try
|
||||
{
|
||||
RestoreProgressText = "Backup wird erstellt …";
|
||||
var name = await RouterBackupService.CreateBackupAsync(_session.Client, Device?.BoardModel, Device?.Architecture, Device?.SerialNumber);
|
||||
RestoreProgressText = null;
|
||||
CreateBackupMessage = $"Backup „{name}“ wurde erstellt.";
|
||||
|
||||
// Frisch erstelltes Backup soll sofort in der Liste auftauchen.
|
||||
await LoadBackupsAsync();
|
||||
}
|
||||
catch (RouterOsException ex)
|
||||
{
|
||||
RestoreProgressText = null;
|
||||
ErrorMessage = ex.Message;
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsRestoring = false;
|
||||
CreateBackupNowCommand.NotifyCanExecuteChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private bool CanApplyRestore =>
|
||||
!IsRestoring && SelectedBackup is not null
|
||||
&& (SelectedBackup.Matches || (SelectedBackup.RequiresConfirmation && ConfirmRiskyBackup));
|
||||
|
||||
/// <summary>
|
||||
/// Erstellt zuerst eine Sicherung des aktuellen Zustands (falls das Zurückspielen unerwünscht war,
|
||||
/// lässt sich so auch das noch rückgängig machen), spielt danach das ausgewählte Backup zurück.
|
||||
/// Das Gerät startet dabei automatisch neu; die laufende Verbindung geht dabei ggf. verloren, ohne
|
||||
/// dass das ein Fehler wäre.
|
||||
/// </summary>
|
||||
[RelayCommand(CanExecute = nameof(CanApplyRestore))]
|
||||
private async Task ApplyRestoreAsync()
|
||||
{
|
||||
if (_session.Client is null || SelectedBackup is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
IsRestoring = true;
|
||||
ErrorMessage = null;
|
||||
CreateBackupNowCommand.NotifyCanExecuteChanged();
|
||||
|
||||
try
|
||||
{
|
||||
RestoreProgressText = "Sicherung des aktuellen Zustands wird erstellt …";
|
||||
await RouterBackupService.CreateBackupAsync(_session.Client, Device?.BoardModel, Device?.Architecture, Device?.SerialNumber);
|
||||
|
||||
RestoreProgressText = $"„{SelectedBackup.FileName}\" wird zurückgespielt — das Gerät startet danach neu …";
|
||||
await BackupRestoreService.RestoreAsync(
|
||||
_session.Client, SelectedBackup.Backup, Device?.BoardModel, Device?.Architecture, Device?.SerialNumber, ConfirmRiskyBackup, CancellationToken.None);
|
||||
|
||||
RestoreProgressText = null;
|
||||
RestoreFinished = true;
|
||||
}
|
||||
catch (RouterOsException ex)
|
||||
{
|
||||
ErrorMessage = ex.Message;
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
ErrorMessage = ex.Message;
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsRestoring = false;
|
||||
CreateBackupNowCommand.NotifyCanExecuteChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
using MikrotikInstaller.Core.Backup;
|
||||
|
||||
namespace MikrotikInstaller.App.ViewModels.Steps;
|
||||
|
||||
/// <summary>Ein auswählbares Backup in der Backup-Verwaltung, mit Kompatibilitäts-Anzeige.</summary>
|
||||
public sealed class BackupSelectionItemViewModel
|
||||
{
|
||||
public BackupSelectionItemViewModel(BackupFileInfo backup, string? currentBoardModel, string? currentArchitecture, string? currentSerialNumber)
|
||||
{
|
||||
Backup = backup;
|
||||
Compatibility = BackupRestoreService.CheckCompatibility(backup, currentBoardModel, currentArchitecture, currentSerialNumber);
|
||||
}
|
||||
|
||||
public BackupFileInfo Backup { get; }
|
||||
|
||||
public string FileName => Backup.FileName;
|
||||
|
||||
/// <summary>Lokale Zeit, "?" falls das Gerät kein auswertbares Erstellungsdatum geliefert hat.</summary>
|
||||
public string CreationTimeLabel => Backup.CreationTime?.LocalDateTime.ToString("g") ?? "?";
|
||||
|
||||
public BackupCompatibility Compatibility { get; }
|
||||
|
||||
public bool Matches => Compatibility == BackupCompatibility.Match;
|
||||
|
||||
/// <summary>Gleiches Modell + Architektur, aber bekannt andere Seriennummer — auf eigene Gefahr wiederherstellbar.</summary>
|
||||
public bool IsSameModelDifferentDevice => Compatibility == BackupCompatibility.SameModelDifferentDevice;
|
||||
|
||||
public bool IsUnknown => Compatibility == BackupCompatibility.Unknown;
|
||||
|
||||
public bool IsBlocked => Compatibility == BackupCompatibility.Mismatch;
|
||||
|
||||
/// <summary>Sowohl "baugleich, aber anderes Gerät" als auch "gar nicht prüfbar" brauchen die Bestätigungs-Checkbox.</summary>
|
||||
public bool RequiresConfirmation => IsSameModelDifferentDevice || IsUnknown;
|
||||
|
||||
public string StatusLabel => Compatibility switch
|
||||
{
|
||||
BackupCompatibility.Match => "Passt zu diesem Gerät",
|
||||
BackupCompatibility.SameModelDifferentDevice => "Gleiches Modell, anderes physisches Gerät — auf eigene Gefahr wiederherstellbar",
|
||||
BackupCompatibility.Mismatch => $"Anderes Gerät (Modell „{Backup.Model}“) — kann nicht wiederhergestellt werden",
|
||||
_ => "Gerät nicht prüfbar — muss manuell bestätigt werden",
|
||||
};
|
||||
}
|
||||
@@ -1,16 +1,26 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using MikrotikInstaller.App.Credentials;
|
||||
using MikrotikInstaller.Core.Connectivity;
|
||||
using MikrotikInstaller.Core.Devices;
|
||||
|
||||
namespace MikrotikInstaller.App.ViewModels.Steps;
|
||||
|
||||
/// <summary>
|
||||
/// Erster Schritt: Verbindungsdaten zum MikroTik-Gerät erfassen und testen.
|
||||
/// Erst nach einer erfolgreich getesteten Verbindung darf im Assistenten weitergegangen werden.
|
||||
/// Erster Schritt: Verbindungsdaten zum MikroTik-Gerät erfassen und testen — nur noch für neue,
|
||||
/// unbekannte Geräte gedacht. "Bekannte Router" leben seit dem Sidebar-Umbau (MainWindow.xaml)
|
||||
/// dauerhaft dort als Kurzauswahl statt hier im Hauptfenster verdoppelt zu werden; dieses ViewModel
|
||||
/// hält <see cref="SavedRouters"/> aber weiterhin (die Sidebar bindet direkt über
|
||||
/// <c>MainViewModel.Connection</c> darauf) und stellt <see cref="ConnectToSavedRouterCommand"/>/
|
||||
/// <see cref="RemoveSavedRouterCommand"/> dafür bereit. Ein Eintrag wird erst im nächsten Schritt
|
||||
/// (Geräte-Erkennung) tatsächlich gespeichert/aktualisiert — dort sind Modell und Seriennummer für
|
||||
/// den Namensvorschlag bzw. die Unterscheidung zweier Geräte mit gleichem Host+Benutzername bekannt.
|
||||
/// </summary>
|
||||
public partial class ConnectionStepViewModel : WizardStepViewModelBase
|
||||
{
|
||||
private readonly WizardSession _session;
|
||||
private readonly SavedRoutersStore _savedRoutersStore = new();
|
||||
|
||||
public ConnectionStepViewModel(WizardSession session)
|
||||
: base(
|
||||
@@ -18,8 +28,13 @@ public partial class ConnectionStepViewModel : WizardStepViewModelBase
|
||||
description: "Gib die Adresse deines MikroTik-Geräts sowie Benutzername und Passwort ein.")
|
||||
{
|
||||
_session = session;
|
||||
RefreshSavedRouters();
|
||||
}
|
||||
|
||||
public ObservableCollection<SavedRouterEntry> SavedRouters { get; } = [];
|
||||
|
||||
public bool HasSavedRouters => SavedRouters.Count > 0;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _host = string.Empty;
|
||||
|
||||
@@ -49,6 +64,50 @@ public partial class ConnectionStepViewModel : WizardStepViewModelBase
|
||||
|
||||
private bool CanTestConnection => !IsTesting && !string.IsNullOrWhiteSpace(Host) && !string.IsNullOrWhiteSpace(Username);
|
||||
|
||||
public override Task OnActivatedAsync()
|
||||
{
|
||||
RefreshSavedRouters();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private void RefreshSavedRouters()
|
||||
{
|
||||
SavedRouters.Clear();
|
||||
var routers = _savedRoutersStore.Load().OrderByDescending(r => r.LastConnectedAt);
|
||||
|
||||
foreach (var router in routers)
|
||||
{
|
||||
SavedRouters.Add(new SavedRouterEntry(router));
|
||||
}
|
||||
|
||||
OnPropertyChanged(nameof(HasSavedRouters));
|
||||
}
|
||||
|
||||
/// <summary>Nur noch intern von <see cref="ConnectToSavedRouterAsync"/> genutzt — Name/Standort werden nirgends mehr manuell bearbeitet, daher kein eigenes Kommando mehr dafür.</summary>
|
||||
private void UseSavedRouter(SavedRouterEntry entry)
|
||||
{
|
||||
Host = entry.Host;
|
||||
Username = entry.Username;
|
||||
Password = WindowsCredentialStore.TryRead(entry.Id) ?? string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>Ein-Klick-Verbinden für die Sidebar-Kurzauswahl — übernimmt die Zugangsdaten und verbindet direkt, ohne den Umweg über die Formularfelder.</summary>
|
||||
[RelayCommand]
|
||||
private async Task ConnectToSavedRouterAsync(SavedRouterEntry entry)
|
||||
{
|
||||
UseSavedRouter(entry);
|
||||
await TestConnectionAsync();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void RemoveSavedRouter(SavedRouterEntry entry)
|
||||
{
|
||||
_savedRoutersStore.Remove(entry.Id);
|
||||
WindowsCredentialStore.Delete(entry.Id);
|
||||
SavedRouters.Remove(entry);
|
||||
OnPropertyChanged(nameof(HasSavedRouters));
|
||||
}
|
||||
|
||||
[RelayCommand(CanExecute = nameof(CanTestConnection))]
|
||||
private async Task TestConnectionAsync()
|
||||
{
|
||||
@@ -68,6 +127,7 @@ public partial class ConnectionStepViewModel : WizardStepViewModelBase
|
||||
};
|
||||
|
||||
_session.Client = await RouterOsClientFactory.ConnectAsync(credentials);
|
||||
_session.Credentials = credentials;
|
||||
|
||||
IsConnected = true;
|
||||
StatusIsError = false;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using MikrotikInstaller.App.Credentials;
|
||||
using MikrotikInstaller.Core.Connectivity;
|
||||
using MikrotikInstaller.Core.Devices;
|
||||
using MikrotikInstaller.Core.Updates;
|
||||
@@ -13,6 +14,7 @@ namespace MikrotikInstaller.App.ViewModels.Steps;
|
||||
public partial class DeviceOverviewStepViewModel : WizardStepViewModelBase
|
||||
{
|
||||
private readonly WizardSession _session;
|
||||
private readonly SavedRoutersStore _savedRoutersStore = new();
|
||||
|
||||
public DeviceOverviewStepViewModel(WizardSession session)
|
||||
: base(
|
||||
@@ -35,6 +37,9 @@ public partial class DeviceOverviewStepViewModel : WizardStepViewModelBase
|
||||
|
||||
public override bool CanGoNext => Device is not null;
|
||||
|
||||
/// <summary>Bei "Backup verwalten" liest die eigenständige Backup-Sektion das Gerät selbst aus, ohne diesen Schritt.</summary>
|
||||
public override bool ShouldSkip => _session.SelectedArea == AppArea.BackupManagement;
|
||||
|
||||
public IReadOnlyList<InterfaceDisplayItem> Interfaces =>
|
||||
Device?.Interfaces.Select(InterfaceDisplayItem.From).ToList() ?? [];
|
||||
|
||||
@@ -60,6 +65,7 @@ public partial class DeviceOverviewStepViewModel : WizardStepViewModelBase
|
||||
var device = await DeviceDiscoveryService.DiscoverAsync(_session.Client);
|
||||
Device = device;
|
||||
_session.Device = device;
|
||||
RecordSavedRouter(device);
|
||||
|
||||
// Läuft im Hintergrund weiter, ohne die Navigation zu blockieren — ein frisches Gerät
|
||||
// hat an dieser Stelle im Assistenten meist noch keinen Internetzugang, die Prüfung
|
||||
@@ -81,4 +87,21 @@ public partial class DeviceOverviewStepViewModel : WizardStepViewModelBase
|
||||
{
|
||||
_session.UpdateInfo = await RouterOsUpdateService.CheckForUpdatesAsync(client);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Trägt die soeben erfolgreich verbundene Verbindung in "Bekannte Router" ein bzw. aktualisiert
|
||||
/// den passenden bestehenden Eintrag (siehe SavedRoutersStore.RecordConnection). Rein additive
|
||||
/// Komfortfunktion — ein Fehler hier darf die Geräte-Erkennung selbst nicht stören.
|
||||
/// </summary>
|
||||
private void RecordSavedRouter(RouterDevice device)
|
||||
{
|
||||
if (_session.Credentials is not { } credentials)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var saved = _savedRoutersStore.RecordConnection(
|
||||
credentials.Host, credentials.Username, device.BoardModel, device.SerialNumber, DateTimeOffset.Now);
|
||||
WindowsCredentialStore.Save(saved.Id, credentials.Username, credentials.Password);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +31,13 @@ public partial class FirewallStepViewModel : WizardStepViewModelBase
|
||||
|
||||
public bool IsExpertMode => _session.IsExpertMode;
|
||||
|
||||
public override bool CanGoNext => PortForwards.All(IsValid);
|
||||
[ObservableProperty]
|
||||
private string? _blockedAddressesValidationMessage;
|
||||
|
||||
public override bool CanGoNext => PortForwards.All(IsValid) && BlockedAddressesValidationMessage is null;
|
||||
|
||||
/// <summary>Bei "Backup wiederherstellen" wird direkt zur Zusammenfassung gesprungen, ohne die Konfigurations-Schritte.</summary>
|
||||
public override bool ShouldSkip => _session.SelectedArea == AppArea.BackupManagement;
|
||||
|
||||
public override Task OnActivatedAsync()
|
||||
{
|
||||
@@ -44,7 +50,15 @@ public partial class FirewallStepViewModel : WizardStepViewModelBase
|
||||
private void AddPortForward()
|
||||
{
|
||||
var entry = new PortForwardEntry();
|
||||
entry.PropertyChanged += (_, _) => SaveToSession();
|
||||
// ValidationMessage wird von SaveToSession selbst geschrieben (siehe UpdatePortForwardValidation) —
|
||||
// davon ausgenommen, sonst würde sich SaveToSession bei jeder Neuberechnung selbst erneut auslösen.
|
||||
entry.PropertyChanged += (_, args) =>
|
||||
{
|
||||
if (args.PropertyName != nameof(PortForwardEntry.ValidationMessage))
|
||||
{
|
||||
SaveToSession();
|
||||
}
|
||||
};
|
||||
PortForwards.Add(entry);
|
||||
SaveToSession();
|
||||
}
|
||||
@@ -58,14 +72,87 @@ public partial class FirewallStepViewModel : WizardStepViewModelBase
|
||||
|
||||
partial void OnBlockedAddressesTextChanged(string value) => SaveToSession();
|
||||
|
||||
private static bool IsValid(PortForwardEntry entry) =>
|
||||
!string.IsNullOrWhiteSpace(entry.Name)
|
||||
&& !string.IsNullOrWhiteSpace(entry.TargetAddress)
|
||||
&& int.TryParse(entry.ExternalPort, out var externalPort) && externalPort is > 0 and <= 65535
|
||||
&& int.TryParse(entry.InternalPort, out var internalPort) && internalPort is > 0 and <= 65535;
|
||||
private static string? ComputeValidationMessage(PortForwardEntry entry)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(entry.Name))
|
||||
{
|
||||
return "Bitte einen Namen für diese Portfreigabe vergeben.";
|
||||
}
|
||||
|
||||
if (!NetworkValidation.IsValidIPv4(entry.TargetAddress))
|
||||
{
|
||||
return "Ziel-Adresse: keine gültige IP-Adresse.";
|
||||
}
|
||||
|
||||
if (!int.TryParse(entry.ExternalPort, out var externalPort) || externalPort is not (> 0 and <= 65535))
|
||||
{
|
||||
return "Externer Port: bitte eine Zahl zwischen 1 und 65535 eingeben.";
|
||||
}
|
||||
|
||||
if (!int.TryParse(entry.InternalPort, out var internalPort) || internalPort is not (> 0 and <= 65535))
|
||||
{
|
||||
return "Interner Port: bitte eine Zahl zwischen 1 und 65535 eingeben.";
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(entry.AllowedSourceAddress) && !NetworkValidation.IsValidIPv4OrCidr(entry.AllowedSourceAddress))
|
||||
{
|
||||
return "Quell-IP-Einschränkung: keine gültige IP-Adresse oder „Adresse/Präfix\".";
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(entry.ExternalPortRangeEnd))
|
||||
{
|
||||
if (!int.TryParse(entry.ExternalPortRangeEnd, out var externalEnd) || externalEnd is not (> 0 and <= 65535))
|
||||
{
|
||||
return "Externer Portbereich bis: bitte eine Zahl zwischen 1 und 65535 eingeben.";
|
||||
}
|
||||
|
||||
if (externalEnd <= externalPort)
|
||||
{
|
||||
return "Externer Portbereich bis: muss größer als der externe Port sein.";
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(entry.InternalPortRangeEnd))
|
||||
{
|
||||
if (!int.TryParse(entry.InternalPortRangeEnd, out var internalEnd) || internalEnd is not (> 0 and <= 65535))
|
||||
{
|
||||
return "Interner Portbereich bis: bitte eine Zahl zwischen 1 und 65535 eingeben.";
|
||||
}
|
||||
|
||||
if (internalEnd <= internalPort)
|
||||
{
|
||||
return "Interner Portbereich bis: muss größer als der interne Port sein.";
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private void UpdatePortForwardValidation()
|
||||
{
|
||||
foreach (var entry in PortForwards)
|
||||
{
|
||||
entry.ValidationMessage = ComputeValidationMessage(entry);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsValid(PortForwardEntry entry) => entry.ValidationMessage is null;
|
||||
|
||||
private string? ComputeBlockedAddressesValidationMessage()
|
||||
{
|
||||
var invalid = BlockedAddressesText
|
||||
.Split([',', '\n', '\r'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.FirstOrDefault(entry => !NetworkValidation.IsValidIPv4OrCidr(entry));
|
||||
|
||||
return invalid is null
|
||||
? null
|
||||
: $"IP-/Subnetz-Sperrliste: „{invalid}\" ist keine gültige IP-Adresse oder „Adresse/Präfix\".";
|
||||
}
|
||||
|
||||
private void SaveToSession()
|
||||
{
|
||||
UpdatePortForwardValidation();
|
||||
BlockedAddressesValidationMessage = ComputeBlockedAddressesValidationMessage();
|
||||
OnPropertyChanged(nameof(CanGoNext));
|
||||
|
||||
if (!CanGoNext || _session.WanInterfaceName is null || _session.LanBridgeName is null)
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using MikrotikInstaller.Core.Devices.LanScan;
|
||||
|
||||
namespace MikrotikInstaller.App.ViewModels.Steps;
|
||||
|
||||
/// <summary>Eine Zeile im LAN-Scanner: aufbereitete Anzeige eines <see cref="DiscoveredDevice"/> plus Zustand der "Feste IP"-Aktion.</summary>
|
||||
public partial class LanScanDeviceRowViewModel : ObservableObject
|
||||
{
|
||||
public LanScanDeviceRowViewModel(DiscoveredDevice device)
|
||||
{
|
||||
Device = device;
|
||||
}
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(IpAddressDisplay))]
|
||||
[NotifyPropertyChangedFor(nameof(HostNameDisplay))]
|
||||
[NotifyPropertyChangedFor(nameof(StatusLabel))]
|
||||
[NotifyPropertyChangedFor(nameof(ActionLabel))]
|
||||
[NotifyPropertyChangedFor(nameof(CanToggleStatic))]
|
||||
[NotifyPropertyChangedFor(nameof(ActionHint))]
|
||||
private DiscoveredDevice _device;
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(CanToggleStatic))]
|
||||
private bool _isBusy;
|
||||
|
||||
[ObservableProperty]
|
||||
private string? _errorMessage;
|
||||
|
||||
/// <summary>
|
||||
/// Gesetzt, direkt nachdem für dieses Gerät eine feste Lease entfernt wurde. RouterOS kennt danach
|
||||
/// keinen Lease-Eintrag mehr für dieses Gerät — das Gerät selbst merkt das aber nicht sofort: Es hält
|
||||
/// seine bisherige IP-Adresse client-seitig weiter, bis es von sich aus eine neue DHCP-Anfrage stellt
|
||||
/// (Lease-Renewal-Timer) oder der Link neu aufgebaut wird (Kabel/WLAN trennen und neu verbinden,
|
||||
/// Neustart). Rein für die Anzeige — wird bei jedem Neuladen des Schritts wieder zurückgesetzt.
|
||||
/// </summary>
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(StatusLabel))]
|
||||
[NotifyPropertyChangedFor(nameof(ActionHint))]
|
||||
private bool _isAwaitingNewLease;
|
||||
|
||||
public string MacAddress => Device.MacAddress;
|
||||
|
||||
public string IpAddressDisplay => Device.IpAddress ?? "Keine IP bekannt";
|
||||
|
||||
public string HostNameDisplay => string.IsNullOrWhiteSpace(Device.HostName) ? "Unbekanntes Gerät" : Device.HostName;
|
||||
|
||||
public string StatusLabel => Device switch
|
||||
{
|
||||
{ IsStaticLease: true } => "Feste IP-Adresse",
|
||||
{ HasDhcpLease: true } => "Automatisch per DHCP",
|
||||
_ when IsAwaitingNewLease => "Feste IP entfernt",
|
||||
_ => "Kein DHCP-Lease",
|
||||
};
|
||||
|
||||
public string ActionLabel => Device.IsStaticLease ? "Rückgängig machen" : "Feste IP-Adresse zuweisen";
|
||||
|
||||
/// <summary>Ohne DHCP-Lease-Eintrag gibt es nichts, das umgestellt werden könnte.</summary>
|
||||
public bool CanToggleStatic => !IsBusy && Device.LeaseId is not null;
|
||||
|
||||
/// <summary>Erklärt, warum der Button gerade deaktiviert ist, nachdem eine feste Zuweisung entfernt wurde.</summary>
|
||||
public string? ActionHint => IsAwaitingNewLease && Device.LeaseId is null
|
||||
? "Erst wieder möglich, sobald sich das Gerät neu meldet — üblicherweise nach Trennen/Neuverbinden oder wenn seine bisherige Lease-Zeit abläuft, nicht sofort."
|
||||
: null;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
using System.Collections.ObjectModel;
|
||||
|
||||
namespace MikrotikInstaller.App.ViewModels.Steps;
|
||||
|
||||
/// <summary>Alle im LAN-Scanner gefundenen Geräte an einem physischen Anschluss.</summary>
|
||||
public sealed class LanScanPortGroupViewModel(string portLabel, IEnumerable<LanScanDeviceRowViewModel> devices)
|
||||
{
|
||||
public string PortLabel { get; } = portLabel;
|
||||
|
||||
public ObservableCollection<LanScanDeviceRowViewModel> Devices { get; } = new(devices);
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using MikrotikInstaller.Core.Connectivity;
|
||||
using MikrotikInstaller.Core.Devices.LanScan;
|
||||
|
||||
namespace MikrotikInstaller.App.ViewModels.Steps;
|
||||
|
||||
/// <summary>
|
||||
/// Wizard-unabhängig nutzbarer, aber im Assistenten mitlaufender Geräte-Bereich: liest DHCP-Leases,
|
||||
/// ARP-Tabelle und Bridge-Host-Tabelle aus, gruppiert die gefundenen Geräte nach physischem Anschluss
|
||||
/// und erlaubt, eine DHCP-Lease auf Knopfdruck fest zuzuweisen (oder das wieder rückgängig zu machen).
|
||||
/// Rein informativ, bis auf diese eine Aktion — verändert sonst nichts am Gerät und fließt nicht in
|
||||
/// die geplante Soll-Konfiguration ein, siehe <see cref="Analysis.NetworkAnalysisService"/> für das
|
||||
/// analoge Vorgehen auf Netzwerk-Struktur-Ebene.
|
||||
/// </summary>
|
||||
public partial class LanScanStepViewModel : WizardStepViewModelBase
|
||||
{
|
||||
private readonly WizardSession _session;
|
||||
|
||||
public LanScanStepViewModel(WizardSession session)
|
||||
: base(
|
||||
title: "Geräte im Netzwerk",
|
||||
description: "Diese Geräte hat der Router aktuell an seinen Anschlüssen erkannt. Du kannst einem Gerät hier optional eine feste IP-Adresse zuweisen, damit sie sich nicht mehr ändert.")
|
||||
{
|
||||
_session = session;
|
||||
}
|
||||
|
||||
public ObservableCollection<LanScanPortGroupViewModel> PortGroups { get; } = [];
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(ShowEmptyHint))]
|
||||
private bool _isLoading;
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(ShowEmptyHint))]
|
||||
private string? _errorMessage;
|
||||
|
||||
/// <summary>Steuert den Hinweistext für den Fall, dass gar keine Geräte gefunden wurden.</summary>
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(ShowEmptyHint))]
|
||||
private bool _hasDevices;
|
||||
|
||||
public override bool CanGoNext => !IsLoading;
|
||||
|
||||
/// <summary>Bei "Backup verwalten" nicht relevant — die eigenständige Backup-Sektion braucht keinen LAN-Scan.</summary>
|
||||
public override bool ShouldSkip => _session.SelectedArea == AppArea.BackupManagement;
|
||||
|
||||
public bool ShowEmptyHint => !IsLoading && ErrorMessage is null && !HasDevices;
|
||||
|
||||
public override Task OnActivatedAsync() => LoadAsync();
|
||||
|
||||
/// <summary>Auch als manuelles "Aktualisieren" genutzt — z. B. um nach einem Trennen/Neuverbinden zu prüfen, ob ein Gerät wieder eine DHCP-Lease bekommen hat.</summary>
|
||||
[RelayCommand]
|
||||
private Task Retry() => LoadAsync();
|
||||
|
||||
private async Task LoadAsync()
|
||||
{
|
||||
if (_session.Client is null)
|
||||
{
|
||||
ErrorMessage = "Keine Verbindung zum Gerät.";
|
||||
HasDevices = false;
|
||||
return;
|
||||
}
|
||||
|
||||
IsLoading = true;
|
||||
ErrorMessage = null;
|
||||
PortGroups.Clear();
|
||||
|
||||
try
|
||||
{
|
||||
var devices = await LanScanService.ScanAsync(_session.Client);
|
||||
HasDevices = devices.Count > 0;
|
||||
|
||||
foreach (var group in devices.GroupBy(d => d.PortInterfaceName))
|
||||
{
|
||||
var rows = group.Select(d => new LanScanDeviceRowViewModel(d));
|
||||
PortGroups.Add(new LanScanPortGroupViewModel(group.Key ?? "Unbekannter Anschluss", rows));
|
||||
}
|
||||
}
|
||||
catch (RouterOsException ex)
|
||||
{
|
||||
ErrorMessage = ex.Message;
|
||||
HasDevices = false;
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsLoading = false;
|
||||
OnPropertyChanged(nameof(CanGoNext));
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task ToggleStaticAsync(LanScanDeviceRowViewModel row)
|
||||
{
|
||||
if (_session.Client is null || !row.CanToggleStatic)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
row.IsBusy = true;
|
||||
row.ErrorMessage = null;
|
||||
|
||||
try
|
||||
{
|
||||
if (row.Device.IsStaticLease)
|
||||
{
|
||||
row.Device = await LanScanService.UndoStaticAsync(_session.Client, row.Device);
|
||||
row.IsAwaitingNewLease = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
row.Device = await LanScanService.MakeStaticAsync(_session.Client, row.Device);
|
||||
}
|
||||
}
|
||||
catch (RouterOsException ex)
|
||||
{
|
||||
row.ErrorMessage = ex.Message;
|
||||
}
|
||||
finally
|
||||
{
|
||||
row.IsBusy = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -56,13 +56,13 @@ public partial class LanStepViewModel : WizardStepViewModelBase
|
||||
|
||||
public bool IsExpertMode => _session.IsExpertMode;
|
||||
|
||||
public override bool CanGoNext =>
|
||||
AvailableInterfaces.Any(i => i.IsSelected)
|
||||
&& !string.IsNullOrWhiteSpace(BridgeName)
|
||||
&& !string.IsNullOrWhiteSpace(RouterAddress)
|
||||
&& !string.IsNullOrWhiteSpace(DhcpPoolStart)
|
||||
&& !string.IsNullOrWhiteSpace(DhcpPoolEnd)
|
||||
&& !string.IsNullOrWhiteSpace(PrimaryDns);
|
||||
[ObservableProperty]
|
||||
private string? _validationMessage;
|
||||
|
||||
public override bool CanGoNext => ValidationMessage is null;
|
||||
|
||||
/// <summary>Bei "Backup wiederherstellen" wird direkt zur Zusammenfassung gesprungen, ohne die Konfigurations-Schritte.</summary>
|
||||
public override bool ShouldSkip => _session.SelectedArea == AppArea.BackupManagement;
|
||||
|
||||
public override Task OnActivatedAsync()
|
||||
{
|
||||
@@ -103,8 +103,54 @@ public partial class LanStepViewModel : WizardStepViewModelBase
|
||||
|
||||
partial void OnNtpServerChanged(string value) => SaveToSession();
|
||||
|
||||
private string? ComputeValidationMessage()
|
||||
{
|
||||
if (!AvailableInterfaces.Any(i => i.IsSelected))
|
||||
{
|
||||
return "Bitte mindestens einen Anschluss für dein Heimnetzwerk auswählen.";
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(BridgeName))
|
||||
{
|
||||
return "Bitte einen Namen für das Heimnetzwerk vergeben.";
|
||||
}
|
||||
|
||||
if (!NetworkValidation.IsValidIPv4(RouterAddress))
|
||||
{
|
||||
return "Router-Adresse: keine gültige IP-Adresse.";
|
||||
}
|
||||
|
||||
if (!NetworkValidation.IsValidPrefixLength(PrefixLength))
|
||||
{
|
||||
return "Präfix: bitte eine Zahl zwischen 0 und 32 eingeben.";
|
||||
}
|
||||
|
||||
if (!NetworkValidation.IsValidIPv4(DhcpPoolStart) || !NetworkValidation.IsValidIPv4(DhcpPoolEnd))
|
||||
{
|
||||
return "Adressbereich: bitte gültige IP-Adressen für Anfang und Ende eingeben.";
|
||||
}
|
||||
|
||||
if (!NetworkValidation.IsOrderedRange(DhcpPoolStart, DhcpPoolEnd))
|
||||
{
|
||||
return "Adressbereich: die erste Adresse muss vor (oder gleich) der letzten liegen.";
|
||||
}
|
||||
|
||||
if (!NetworkValidation.IsValidIPv4(PrimaryDns))
|
||||
{
|
||||
return "DNS-Server (bevorzugt): keine gültige IP-Adresse.";
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(SecondaryDns) && !NetworkValidation.IsValidIPv4(SecondaryDns))
|
||||
{
|
||||
return "DNS-Server (alternativ): keine gültige IP-Adresse.";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private void SaveToSession()
|
||||
{
|
||||
ValidationMessage = ComputeValidationMessage();
|
||||
OnPropertyChanged(nameof(CanGoNext));
|
||||
|
||||
if (!CanGoNext)
|
||||
|
||||
@@ -3,10 +3,10 @@ using CommunityToolkit.Mvvm.ComponentModel;
|
||||
namespace MikrotikInstaller.App.ViewModels.Steps;
|
||||
|
||||
/// <summary>
|
||||
/// Dritter Schritt (direkt nach der Geräte-Erkennung): Wahl zwischen dem geführten Assistenten
|
||||
/// (Standard, für die meisten Nutzer passend) und dem Experten-Modus, der in den folgenden
|
||||
/// Schritten zusätzliche, technischere RouterOS-Optionen einblendet. Die Wahl blockiert nie das
|
||||
/// Weitergehen — der geführte Assistent ist vorausgewählt.
|
||||
/// Wahl zwischen dem geführten Assistenten (Standard, für die meisten Nutzer passend) und dem
|
||||
/// Experten-Modus, der in den folgenden Schritten zusätzliche, technischere RouterOS-Optionen einblendet.
|
||||
/// Die Wahl blockiert nie das Weitergehen — der geführte Assistent ist vorausgewählt. Bei
|
||||
/// "Backup verwalten" (<see cref="AppArea.BackupManagement"/>) nicht relevant und übersprungen.
|
||||
/// </summary>
|
||||
public partial class ModeSelectionStepViewModel : WizardStepViewModelBase
|
||||
{
|
||||
@@ -24,6 +24,8 @@ public partial class ModeSelectionStepViewModel : WizardStepViewModelBase
|
||||
[ObservableProperty]
|
||||
private bool _isExpertMode;
|
||||
|
||||
public override bool ShouldSkip => _session.SelectedArea == AppArea.BackupManagement;
|
||||
|
||||
public override Task OnActivatedAsync()
|
||||
{
|
||||
IsExpertMode = _session.IsExpertMode;
|
||||
|
||||
@@ -12,11 +12,12 @@ namespace MikrotikInstaller.App.ViewModels.Steps;
|
||||
/// <summary>
|
||||
/// Dritter Schritt: liest aus, welche Netzwerke auf einem Bestandsgerät bereits eingerichtet sind, und
|
||||
/// stellt sie als anklickbare, nach Kategorien gruppierte Grafik dar. Rein informativ — verändert
|
||||
/// nichts am Gerät. Kann nicht per <see cref="ShouldSkip"/> übersprungen werden, obwohl das Ergebnis
|
||||
/// erst nach dem Auslesen feststeht: die Wizard-Navigation prüft <c>ShouldSkip</c>, bevor
|
||||
/// <see cref="OnActivatedAsync"/> für den Ziel-Schritt läuft (siehe <c>MainViewModel.GoNextAsync</c>) —
|
||||
/// zum Zeitpunkt der Prüfung wäre das Ergebnis der Analyse also noch gar nicht bekannt. Stattdessen
|
||||
/// zeigt die View bei leerem Ergebnis einen kurzen Hinweistext statt einer leeren Grafik.
|
||||
/// nichts am Gerät. Kann nicht anhand des eigenen (erst nach dem Auslesen feststehenden) Ergebnisses
|
||||
/// per <see cref="ShouldSkip"/> übersprungen werden: die Wizard-Navigation prüft <c>ShouldSkip</c>,
|
||||
/// bevor <see cref="OnActivatedAsync"/> für den Ziel-Schritt läuft (siehe <c>MainViewModel.GoNextAsync</c>)
|
||||
/// — zum Zeitpunkt der Prüfung wäre das Ergebnis der Analyse also noch gar nicht bekannt. Stattdessen
|
||||
/// zeigt die View bei leerem Ergebnis einen kurzen Hinweistext statt einer leeren Grafik. Übersprungen
|
||||
/// wird dieser Schritt nur aus einem vorher schon feststehenden Grund: dem gewählten <see cref="AppArea"/>.
|
||||
/// </summary>
|
||||
public partial class NetworkAnalysisStepViewModel : WizardStepViewModelBase
|
||||
{
|
||||
@@ -58,6 +59,8 @@ public partial class NetworkAnalysisStepViewModel : WizardStepViewModelBase
|
||||
|
||||
public override bool CanGoNext => !IsLoading;
|
||||
|
||||
public override bool ShouldSkip => _session.SelectedArea == AppArea.BackupManagement;
|
||||
|
||||
public override async Task OnActivatedAsync()
|
||||
{
|
||||
if (_session.Client is null)
|
||||
|
||||
@@ -32,4 +32,8 @@ public partial class PortForwardEntry : ObservableObject
|
||||
|
||||
[ObservableProperty]
|
||||
private string _internalPortRangeEnd = string.Empty;
|
||||
|
||||
/// <summary>Nicht leer, wenn eines der Felder dieser Zeile ein ungültiges Format hat.</summary>
|
||||
[ObservableProperty]
|
||||
private string? _validationMessage;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using MikrotikInstaller.Core.Devices;
|
||||
|
||||
namespace MikrotikInstaller.App.ViewModels.Steps;
|
||||
|
||||
/// <summary>Ein Eintrag in der "Bekannte Router"-Liste im Verbindungs-Schritt.</summary>
|
||||
public partial class SavedRouterEntry : ObservableObject
|
||||
{
|
||||
public SavedRouterEntry(SavedRouter router)
|
||||
{
|
||||
Id = router.Id;
|
||||
Host = router.Host;
|
||||
Username = router.Username;
|
||||
SerialNumber = router.SerialNumber;
|
||||
LastConnectedAt = router.LastConnectedAt;
|
||||
_name = router.Name;
|
||||
_location = router.Location ?? string.Empty;
|
||||
}
|
||||
|
||||
public string Id { get; }
|
||||
|
||||
public string Host { get; }
|
||||
|
||||
public string Username { get; }
|
||||
|
||||
public string? SerialNumber { get; }
|
||||
|
||||
public DateTimeOffset? LastConnectedAt { get; }
|
||||
|
||||
public string Subtitle => $"{Host} · {Username}";
|
||||
|
||||
[ObservableProperty]
|
||||
private string _name;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _location;
|
||||
}
|
||||
@@ -13,7 +13,9 @@ namespace MikrotikInstaller.App.ViewModels.Steps;
|
||||
/// <summary>
|
||||
/// Letzter Schritt: zeigt alle in den vorherigen Schritten geplanten Änderungen im Klartext an.
|
||||
/// Erst wenn der Nutzer hier ausdrücklich "Jetzt einrichten" klickt, wird ein Backup erstellt und
|
||||
/// werden die Änderungen tatsächlich auf dem Gerät angewendet.
|
||||
/// werden die Änderungen tatsächlich auf dem Gerät angewendet. Bei "Backup verwalten"
|
||||
/// (<see cref="AppArea.BackupManagement"/>) nicht relevant und übersprungen — dafür gibt es
|
||||
/// <see cref="BackupManagementStepViewModel"/>.
|
||||
/// </summary>
|
||||
public partial class SummaryStepViewModel : WizardStepViewModelBase
|
||||
{
|
||||
@@ -44,6 +46,8 @@ public partial class SummaryStepViewModel : WizardStepViewModelBase
|
||||
|
||||
public override bool CanGoNext => IsFinished;
|
||||
|
||||
public override bool ShouldSkip => _session.SelectedArea == AppArea.BackupManagement;
|
||||
|
||||
[ObservableProperty]
|
||||
private string? _backupName;
|
||||
|
||||
@@ -107,7 +111,8 @@ public partial class SummaryStepViewModel : WizardStepViewModelBase
|
||||
try
|
||||
{
|
||||
ProgressText = "Sicherung wird auf dem Gerät erstellt …";
|
||||
BackupName = await RouterBackupService.CreateBackupAsync(_session.Client);
|
||||
BackupName = await RouterBackupService.CreateBackupAsync(
|
||||
_session.Client, _session.Device?.BoardModel, _session.Device?.Architecture, _session.Device?.SerialNumber);
|
||||
|
||||
var actions = GetAllActions();
|
||||
for (var i = 0; i < actions.Count; i++)
|
||||
|
||||
@@ -43,4 +43,8 @@ public partial class VlanEntry : ObservableObject
|
||||
/// <summary>Nicht leer, wenn ein hier gewählter Anschluss bereits einem anderen VLAN zugeordnet ist.</summary>
|
||||
[ObservableProperty]
|
||||
private string? _portConflictMessage;
|
||||
|
||||
/// <summary>Nicht leer, wenn eines der Adress-/Bereichsfelder dieser Zeile ein ungültiges Format hat.</summary>
|
||||
[ObservableProperty]
|
||||
private string? _formatValidationMessage;
|
||||
}
|
||||
|
||||
@@ -27,6 +27,9 @@ public partial class VlanStepViewModel : WizardStepViewModelBase
|
||||
|
||||
public override bool CanGoNext => Vlans.All(IsValid);
|
||||
|
||||
/// <summary>Bei "Backup wiederherstellen" wird direkt zur Zusammenfassung gesprungen, ohne die Konfigurations-Schritte.</summary>
|
||||
public override bool ShouldSkip => _session.SelectedArea == AppArea.BackupManagement;
|
||||
|
||||
public override Task OnActivatedAsync()
|
||||
{
|
||||
OnPropertyChanged(nameof(IsExpertMode));
|
||||
@@ -54,11 +57,12 @@ public partial class VlanStepViewModel : WizardStepViewModelBase
|
||||
PoolStart = $"192.168.{octet}.10",
|
||||
PoolEnd = $"192.168.{octet}.254",
|
||||
};
|
||||
// PortConflictMessage wird von SaveToSession selbst geschrieben (siehe UpdatePortConflicts) —
|
||||
// davon ausgenommen, sonst würde sich SaveToSession bei jedem Konflikt selbst neu auslösen.
|
||||
// PortConflictMessage/FormatValidationMessage werden von SaveToSession selbst geschrieben
|
||||
// (siehe UpdatePortConflicts/UpdateFormatValidation) — davon ausgenommen, sonst würde sich
|
||||
// SaveToSession bei jeder Neuberechnung selbst erneut auslösen.
|
||||
entry.PropertyChanged += (_, args) =>
|
||||
{
|
||||
if (args.PropertyName != nameof(VlanEntry.PortConflictMessage))
|
||||
if (args.PropertyName is not (nameof(VlanEntry.PortConflictMessage) or nameof(VlanEntry.FormatValidationMessage)))
|
||||
{
|
||||
SaveToSession();
|
||||
}
|
||||
@@ -119,17 +123,55 @@ public partial class VlanStepViewModel : WizardStepViewModelBase
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Formatprüfung eines VLAN-Eintrags — IP-/Präfix-/Bereichsangaben, keine Portkonflikte (siehe <see cref="UpdatePortConflicts"/>).</summary>
|
||||
private static string? ComputeFormatValidationMessage(VlanEntry entry)
|
||||
{
|
||||
if (!NetworkValidation.IsValidIPv4(entry.RouterAddress))
|
||||
{
|
||||
return "Router-Adresse: keine gültige IP-Adresse.";
|
||||
}
|
||||
|
||||
if (!NetworkValidation.IsValidPrefixLength(entry.PrefixLength))
|
||||
{
|
||||
return "Präfix: bitte eine Zahl zwischen 0 und 32 eingeben.";
|
||||
}
|
||||
|
||||
if (!NetworkValidation.IsValidIPv4(entry.PoolStart) || !NetworkValidation.IsValidIPv4(entry.PoolEnd))
|
||||
{
|
||||
return "Adressbereich: bitte gültige IP-Adressen für Anfang und Ende eingeben.";
|
||||
}
|
||||
|
||||
if (!NetworkValidation.IsOrderedRange(entry.PoolStart, entry.PoolEnd))
|
||||
{
|
||||
return "Adressbereich: die erste Adresse muss vor (oder gleich) der letzten liegen.";
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(entry.DnsServerOverride) && !NetworkValidation.IsValidIPv4(entry.DnsServerOverride))
|
||||
{
|
||||
return "DNS-Server-Override: keine gültige IP-Adresse.";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private void UpdateFormatValidation()
|
||||
{
|
||||
foreach (var entry in Vlans)
|
||||
{
|
||||
entry.FormatValidationMessage = ComputeFormatValidationMessage(entry);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsValid(VlanEntry entry) =>
|
||||
int.TryParse(entry.VlanId, out var id) && id is >= 2 and <= 4094
|
||||
&& !string.IsNullOrWhiteSpace(entry.Name)
|
||||
&& !string.IsNullOrWhiteSpace(entry.RouterAddress)
|
||||
&& !string.IsNullOrWhiteSpace(entry.PoolStart)
|
||||
&& !string.IsNullOrWhiteSpace(entry.PoolEnd)
|
||||
&& entry.PortConflictMessage is null;
|
||||
&& entry.PortConflictMessage is null
|
||||
&& entry.FormatValidationMessage is null;
|
||||
|
||||
private void SaveToSession()
|
||||
{
|
||||
UpdatePortConflicts();
|
||||
UpdateFormatValidation();
|
||||
OnPropertyChanged(nameof(CanGoNext));
|
||||
|
||||
if (!CanGoNext || _session.LanBridgeName is null)
|
||||
|
||||
@@ -52,9 +52,13 @@ public partial class WanStepViewModel : WizardStepViewModelBase
|
||||
|
||||
public bool IsExpertMode => _session.IsExpertMode;
|
||||
|
||||
public override bool CanGoNext =>
|
||||
!string.IsNullOrWhiteSpace(SelectedInterface)
|
||||
&& (!UseStaticAddress || (!string.IsNullOrWhiteSpace(StaticAddress) && !string.IsNullOrWhiteSpace(StaticGateway)));
|
||||
[ObservableProperty]
|
||||
private string? _validationMessage;
|
||||
|
||||
public override bool CanGoNext => ValidationMessage is null;
|
||||
|
||||
/// <summary>Bei "Backup wiederherstellen" wird direkt zur Zusammenfassung gesprungen, ohne die Konfigurations-Schritte.</summary>
|
||||
public override bool ShouldSkip => _session.SelectedArea == AppArea.BackupManagement;
|
||||
|
||||
public override Task OnActivatedAsync()
|
||||
{
|
||||
@@ -87,8 +91,42 @@ public partial class WanStepViewModel : WizardStepViewModelBase
|
||||
|
||||
partial void OnNatSourceAddressChanged(string value) => SaveToSession();
|
||||
|
||||
private string? ComputeValidationMessage()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(SelectedInterface))
|
||||
{
|
||||
return "Bitte eine Internet-Schnittstelle auswählen.";
|
||||
}
|
||||
|
||||
if (UseStaticAddress)
|
||||
{
|
||||
if (!NetworkValidation.IsValidCidr(StaticAddress))
|
||||
{
|
||||
return "Feste IP-Adresse: bitte im Format „Adresse/Präfix\" eingeben, z. B. „203.0.113.5/24\".";
|
||||
}
|
||||
|
||||
if (!NetworkValidation.IsValidIPv4(StaticGateway))
|
||||
{
|
||||
return "Gateway: keine gültige IP-Adresse.";
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(Mtu) && !(int.TryParse(Mtu, out var mtu) && mtu is >= 68 and <= 65535))
|
||||
{
|
||||
return "MTU: bitte eine Zahl zwischen 68 und 65535 eingeben, oder leer lassen.";
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(NatSourceAddress) && !NetworkValidation.IsValidCidr(NatSourceAddress))
|
||||
{
|
||||
return "NAT-Subnetz: bitte im Format „Adresse/Präfix\" eingeben, z. B. „192.168.88.0/24\".";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private void SaveToSession()
|
||||
{
|
||||
ValidationMessage = ComputeValidationMessage();
|
||||
OnPropertyChanged(nameof(CanGoNext));
|
||||
|
||||
_session.WanInterfaceName = SelectedInterface;
|
||||
|
||||
@@ -50,7 +50,8 @@ public partial class WlanStepViewModel : WizardStepViewModelBase
|
||||
|
||||
public bool IsExpertMode => _session.IsExpertMode;
|
||||
|
||||
public override bool ShouldSkip => _session.Device?.HasWireless != true;
|
||||
/// <summary>Auch bei "Backup wiederherstellen" überspringen — nicht nur bei fehlendem WLAN.</summary>
|
||||
public override bool ShouldSkip => _session.SelectedArea == AppArea.BackupManagement || _session.Device?.HasWireless != true;
|
||||
|
||||
public override bool CanGoNext =>
|
||||
SkipWireless
|
||||
|
||||
@@ -14,6 +14,10 @@ public sealed class WizardSession
|
||||
{
|
||||
public IRouterOsClient? Client { get; set; }
|
||||
|
||||
/// <summary>Die zuletzt erfolgreich verwendeten Zugangsdaten — für "Bekannte Router" im
|
||||
/// Geräte-Erkennungs-Schritt, sobald dort Modell/Seriennummer bekannt sind.</summary>
|
||||
public RouterOsCredentials? Credentials { get; set; }
|
||||
|
||||
public RouterDevice? Device { get; set; }
|
||||
|
||||
/// <summary>
|
||||
@@ -22,6 +26,14 @@ public sealed class WizardSession
|
||||
/// </summary>
|
||||
public bool IsExpertMode { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Direkt nach dem Verbinden gewählter Bereich (siehe <see cref="Steps.AreaSelectionStepViewModel"/>).
|
||||
/// <see cref="AppArea.BackupManagement"/> überspringt den kompletten restlichen Assistenten
|
||||
/// (Geräte-Erkennung, IST-Zustand-Analyse, LAN-Scanner, Moduswahl, WAN/LAN/VLAN/WLAN/Firewall,
|
||||
/// Zusammenfassung) und führt direkt zur eigenständigen Backup-Verwaltung.
|
||||
/// </summary>
|
||||
public AppArea SelectedArea { get; set; } = AppArea.SetupWizard;
|
||||
|
||||
/// <summary>Vom WAN-Schritt gewählte Internet-Schnittstelle, damit spätere Schritte sie referenzieren können.</summary>
|
||||
public string? WanInterfaceName { get; set; }
|
||||
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
<UserControl x:Class="MikrotikInstaller.App.Views.Steps.AreaSelectionStepView"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
mc:Ignorable="d"
|
||||
d:DesignWidth="600">
|
||||
<StackPanel Margin="0,16,0,0">
|
||||
<TextBlock Text="{Binding Description}" TextWrapping="Wrap" Margin="0,0,0,24" Opacity="0.8" />
|
||||
|
||||
<Border BorderBrush="{DynamicResource DividerStrokeColorDefaultBrush}" BorderThickness="1" CornerRadius="6" Padding="16" Margin="0,0,0,12">
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto" />
|
||||
<ColumnDefinition Width="*" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<RadioButton Grid.Column="0" GroupName="AppArea" VerticalAlignment="Top" Margin="0,2,12,0"
|
||||
IsChecked="{Binding IsBackupManagement, Converter={StaticResource InverseBooleanConverter}}" />
|
||||
<StackPanel Grid.Column="1">
|
||||
<TextBlock Text="Einrichtungs-Assistent" FontWeight="SemiBold" />
|
||||
<TextBlock Text="Gerät komplett neu einrichten: Internet-Zugang, Heimnetzwerk, VLANs, WLAN und Firewall — Schritt für Schritt." TextWrapping="Wrap" Opacity="0.8" Margin="0,4,0,0" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<Border BorderBrush="{DynamicResource DividerStrokeColorDefaultBrush}" BorderThickness="1" CornerRadius="6" Padding="16">
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto" />
|
||||
<ColumnDefinition Width="*" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<RadioButton Grid.Column="0" GroupName="AppArea" VerticalAlignment="Top" Margin="0,2,12,0"
|
||||
IsChecked="{Binding IsBackupManagement}" />
|
||||
<StackPanel Grid.Column="1">
|
||||
<TextBlock Text="Backup verwalten" FontWeight="SemiBold" />
|
||||
<TextBlock Text="Direkt zu vorhandenen Backups auf diesem Gerät — auflisten, neu erstellen oder ein Backup zurückspielen. Überspringt den restlichen Einrichtungs-Assistenten komplett." TextWrapping="Wrap" Opacity="0.8" Margin="0,4,0,0" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,11 @@
|
||||
using System.Windows.Controls;
|
||||
|
||||
namespace MikrotikInstaller.App.Views.Steps;
|
||||
|
||||
public partial class AreaSelectionStepView : UserControl
|
||||
{
|
||||
public AreaSelectionStepView()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
<UserControl x:Class="MikrotikInstaller.App.Views.Steps.BackupManagementStepView"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
mc:Ignorable="d"
|
||||
d:DesignWidth="600">
|
||||
<StackPanel Margin="0,16,0,0">
|
||||
<TextBlock Text="{Binding Description}" TextWrapping="Wrap" Margin="0,0,0,12" Opacity="0.8" />
|
||||
|
||||
<StackPanel Orientation="Horizontal" Visibility="{Binding IsLoadingDevice, Converter={StaticResource BoolToVisibilityConverter}}" Margin="0,0,0,16">
|
||||
<ui:ProgressRing IsIndeterminate="True" Width="20" Height="20" Margin="0,0,12,0" />
|
||||
<TextBlock Text="Gerät wird ausgelesen …" VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
|
||||
<TextBlock Margin="0,0,0,16" Visibility="{Binding Device, Converter={StaticResource NullToVisibilityConverter}}">
|
||||
<Run Text="Verbunden mit: " Foreground="{DynamicResource TextFillColorSecondaryBrush}" />
|
||||
<Run Text="{Binding Device.IdentityName, Mode=OneWay}" FontWeight="SemiBold" />
|
||||
<Run Text=" (" Foreground="{DynamicResource TextFillColorSecondaryBrush}" />
|
||||
<Run Text="{Binding Device.BoardModel, Mode=OneWay}" Foreground="{DynamicResource TextFillColorSecondaryBrush}" />
|
||||
<Run Text=")" Foreground="{DynamicResource TextFillColorSecondaryBrush}" />
|
||||
</TextBlock>
|
||||
|
||||
<TextBlock Text="{Binding ErrorMessage}" TextWrapping="Wrap" Foreground="{StaticResource MikrotikRedBrush}" Margin="0,0,0,16"
|
||||
Visibility="{Binding ErrorMessage, Converter={StaticResource StringToVisibilityConverter}}" />
|
||||
|
||||
<StackPanel Visibility="{Binding RestoreFinished, Converter={StaticResource BoolToVisibilityConverter}, ConverterParameter=Invert}">
|
||||
<StackPanel Orientation="Horizontal" Margin="0,0,0,12">
|
||||
<TextBlock Text="Vorhandene Backups auf diesem Gerät" FontSize="16" FontWeight="SemiBold" VerticalAlignment="Center" />
|
||||
<ui:Button Content="Aktualisieren" Appearance="Secondary" Margin="12,0,0,0"
|
||||
IsEnabled="{Binding IsLoadingBackups, Converter={StaticResource InverseBooleanConverter}}"
|
||||
Command="{Binding RefreshCommand}">
|
||||
<ui:Button.Icon>
|
||||
<ui:SymbolIcon Symbol="ArrowSync24" />
|
||||
</ui:Button.Icon>
|
||||
</ui:Button>
|
||||
<ui:Button Content="Neues Backup erstellen" Appearance="Secondary" Margin="8,0,0,0"
|
||||
Command="{Binding CreateBackupNowCommand}">
|
||||
<ui:Button.Icon>
|
||||
<ui:SymbolIcon Symbol="Save24" />
|
||||
</ui:Button.Icon>
|
||||
</ui:Button>
|
||||
</StackPanel>
|
||||
|
||||
<TextBlock Text="{Binding CreateBackupMessage}" TextWrapping="Wrap" Foreground="{StaticResource MikrotikAccentBrush}" Margin="0,0,0,12"
|
||||
Visibility="{Binding CreateBackupMessage, Converter={StaticResource StringToVisibilityConverter}}" />
|
||||
|
||||
<StackPanel Orientation="Horizontal" Visibility="{Binding IsLoadingBackups, Converter={StaticResource BoolToVisibilityConverter}}" Margin="0,0,0,12">
|
||||
<ui:ProgressRing IsIndeterminate="True" Width="20" Height="20" Margin="0,0,12,0" />
|
||||
<TextBlock Text="Backups werden ausgelesen …" VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
|
||||
<TextBlock Text="Es wurden keine Backups auf diesem Gerät gefunden." Opacity="0.6" Margin="0,0,0,12">
|
||||
<TextBlock.Style>
|
||||
<Style TargetType="TextBlock">
|
||||
<Setter Property="Visibility" Value="Collapsed" />
|
||||
<Style.Triggers>
|
||||
<MultiDataTrigger>
|
||||
<MultiDataTrigger.Conditions>
|
||||
<Condition Binding="{Binding IsLoadingBackups}" Value="False" />
|
||||
<Condition Binding="{Binding AvailableBackups.Count}" Value="0" />
|
||||
</MultiDataTrigger.Conditions>
|
||||
<Setter Property="Visibility" Value="Visible" />
|
||||
</MultiDataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</TextBlock.Style>
|
||||
</TextBlock>
|
||||
|
||||
<ListBox ItemsSource="{Binding AvailableBackups}" SelectedItem="{Binding SelectedBackup}" Margin="0,0,0,12"
|
||||
BorderThickness="0" Background="Transparent" ScrollViewer.HorizontalScrollBarVisibility="Disabled">
|
||||
<ListBox.ItemContainerStyle>
|
||||
<Style TargetType="ListBoxItem">
|
||||
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
|
||||
<Setter Property="Padding" Value="0" />
|
||||
<Setter Property="Margin" Value="0,0,0,8" />
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="BorderThickness" Value="0" />
|
||||
</Style>
|
||||
</ListBox.ItemContainerStyle>
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<Border BorderBrush="{DynamicResource DividerStrokeColorDefaultBrush}" BorderThickness="1" CornerRadius="6" Padding="12">
|
||||
<Border.Style>
|
||||
<Style TargetType="Border">
|
||||
<Style.Triggers>
|
||||
<DataTrigger Binding="{Binding IsSelected, RelativeSource={RelativeSource AncestorType=ListBoxItem}}" Value="True">
|
||||
<Setter Property="BorderBrush" Value="{StaticResource MikrotikAccentBrush}" />
|
||||
<Setter Property="BorderThickness" Value="2" />
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</Border.Style>
|
||||
<StackPanel>
|
||||
<TextBlock Text="{Binding FileName}" FontWeight="SemiBold" />
|
||||
<TextBlock Text="{Binding CreationTimeLabel}" Opacity="0.6" FontSize="12" />
|
||||
<TextBlock Text="{Binding StatusLabel}" FontSize="12" Margin="0,2,0,0">
|
||||
<TextBlock.Style>
|
||||
<Style TargetType="TextBlock">
|
||||
<Setter Property="Foreground" Value="{StaticResource MikrotikAccentBrush}" />
|
||||
<Style.Triggers>
|
||||
<DataTrigger Binding="{Binding IsSameModelDifferentDevice}" Value="True">
|
||||
<Setter Property="Foreground" Value="{StaticResource MikrotikWarningBrush}" />
|
||||
</DataTrigger>
|
||||
<DataTrigger Binding="{Binding IsUnknown}" Value="True">
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextFillColorSecondaryBrush}" />
|
||||
</DataTrigger>
|
||||
<DataTrigger Binding="{Binding IsBlocked}" Value="True">
|
||||
<Setter Property="Foreground" Value="{StaticResource MikrotikRedBrush}" />
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</TextBlock.Style>
|
||||
</TextBlock>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
|
||||
<CheckBox Content="Ich bestätige, dass ich dieses Backup auf eigene Gefahr wiederherstellen möchte." Margin="0,0,0,12"
|
||||
IsChecked="{Binding ConfirmRiskyBackup}"
|
||||
Visibility="{Binding SelectedBackup.RequiresConfirmation, Converter={StaticResource BoolToVisibilityConverter}, FallbackValue=Collapsed}" />
|
||||
|
||||
<ui:Button Content="Jetzt wiederherstellen" Appearance="Primary"
|
||||
Command="{Binding ApplyRestoreCommand}" />
|
||||
|
||||
<StackPanel Orientation="Horizontal" Margin="0,16,0,0"
|
||||
Visibility="{Binding IsRestoring, Converter={StaticResource BoolToVisibilityConverter}}">
|
||||
<ui:ProgressRing IsIndeterminate="True" Width="20" Height="20" Margin="0,0,12,0" />
|
||||
<TextBlock Text="{Binding RestoreProgressText}" VerticalAlignment="Center" TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Visibility="{Binding RestoreFinished, Converter={StaticResource BoolToVisibilityConverter}}">
|
||||
<TextBlock Text="Fertig! Das Backup wird zurückgespielt." FontWeight="SemiBold" Foreground="{StaticResource MikrotikAccentBrush}" Margin="0,0,0,8" />
|
||||
<TextBlock TextWrapping="Wrap" Opacity="0.8"
|
||||
Text="Das Gerät startet jetzt automatisch neu — eine kurze Verbindungsunterbrechung ist dabei normal. Vor dem Zurückspielen wurde zusätzlich eine Sicherung deines vorherigen Zustands erstellt." />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,11 @@
|
||||
using System.Windows.Controls;
|
||||
|
||||
namespace MikrotikInstaller.App.Views.Steps;
|
||||
|
||||
public partial class BackupManagementStepView : UserControl
|
||||
{
|
||||
public BackupManagementStepView()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
}
|
||||
@@ -76,6 +76,11 @@
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<TextBlock Text="{Binding ValidationMessage}"
|
||||
Foreground="{DynamicResource SystemFillColorCriticalBrush}"
|
||||
Visibility="{Binding ValidationMessage, Converter={StaticResource StringToVisibilityConverter}}"
|
||||
TextWrapping="Wrap" Margin="0,4,0,0" />
|
||||
|
||||
<StackPanel Margin="0,8,0,0"
|
||||
Visibility="{Binding DataContext.IsExpertMode, RelativeSource={RelativeSource AncestorType=ItemsControl}, Converter={StaticResource BoolToVisibilityConverter}}">
|
||||
<Grid Margin="0,0,0,8">
|
||||
@@ -127,6 +132,10 @@
|
||||
</StackPanel>
|
||||
<ui:TextBox Text="{Binding BlockedAddressesText, UpdateSourceTrigger=PropertyChanged}"
|
||||
AcceptsReturn="True" TextWrapping="Wrap" Height="60" />
|
||||
<TextBlock Text="{Binding BlockedAddressesValidationMessage}"
|
||||
Foreground="{DynamicResource SystemFillColorCriticalBrush}"
|
||||
Visibility="{Binding BlockedAddressesValidationMessage, Converter={StaticResource StringToVisibilityConverter}}"
|
||||
TextWrapping="Wrap" Margin="0,4,0,0" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</UserControl>
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
<UserControl x:Class="MikrotikInstaller.App.Views.Steps.LanScanStepView"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml"
|
||||
xmlns:controls="clr-namespace:MikrotikInstaller.App.Views.Controls"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
mc:Ignorable="d"
|
||||
d:DesignWidth="600">
|
||||
<StackPanel Margin="0,16,0,0">
|
||||
<TextBlock Text="{Binding Description}" TextWrapping="Wrap" Margin="0,0,0,12" Opacity="0.8" />
|
||||
|
||||
<ui:Button Content="Aktualisieren" Appearance="Secondary" HorizontalAlignment="Left" Margin="0,0,0,16"
|
||||
IsEnabled="{Binding IsLoading, Converter={StaticResource InverseBooleanConverter}}"
|
||||
Command="{Binding RetryCommand}">
|
||||
<ui:Button.Icon>
|
||||
<ui:SymbolIcon Symbol="ArrowSync24" />
|
||||
</ui:Button.Icon>
|
||||
</ui:Button>
|
||||
|
||||
<StackPanel Orientation="Horizontal" Visibility="{Binding IsLoading, Converter={StaticResource BoolToVisibilityConverter}}">
|
||||
<ui:ProgressRing IsIndeterminate="True" Width="20" Height="20" Margin="0,0,12,0" />
|
||||
<TextBlock Text="Geräte werden ausgelesen …" VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Visibility="{Binding ErrorMessage, Converter={StaticResource StringToVisibilityConverter}}">
|
||||
<TextBlock Text="{Binding ErrorMessage}" TextWrapping="Wrap" Foreground="{StaticResource MikrotikRedBrush}" Margin="0,0,0,12" />
|
||||
</StackPanel>
|
||||
|
||||
<TextBlock Text="Es wurden keine Geräte gefunden." Opacity="0.6"
|
||||
Visibility="{Binding ShowEmptyHint, Converter={StaticResource BoolToVisibilityConverter}}" />
|
||||
|
||||
<ItemsControl ItemsSource="{Binding PortGroups}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<StackPanel Margin="0,0,0,20">
|
||||
<StackPanel Orientation="Horizontal" Margin="0,0,0,8">
|
||||
<ui:SymbolIcon Symbol="PlugConnected24" Margin="0,0,6,0" />
|
||||
<TextBlock Text="{Binding PortLabel}" FontWeight="SemiBold" />
|
||||
</StackPanel>
|
||||
|
||||
<ItemsControl ItemsSource="{Binding Devices}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<Border BorderBrush="{DynamicResource DividerStrokeColorDefaultBrush}" BorderThickness="0,0,0,1" Padding="0,8">
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<StackPanel Grid.Column="0">
|
||||
<TextBlock>
|
||||
<Run Text="{Binding HostNameDisplay, Mode=OneWay}" FontWeight="SemiBold" />
|
||||
<Run Text=" — " Foreground="{DynamicResource TextFillColorSecondaryBrush}" />
|
||||
<Run Text="{Binding IpAddressDisplay, Mode=OneWay}" Foreground="{DynamicResource TextFillColorSecondaryBrush}" />
|
||||
</TextBlock>
|
||||
<TextBlock Text="{Binding MacAddress}" Opacity="0.5" FontSize="12" />
|
||||
<TextBlock Text="{Binding ErrorMessage}" Foreground="{StaticResource MikrotikRedBrush}"
|
||||
TextWrapping="Wrap" FontSize="12"
|
||||
Visibility="{Binding ErrorMessage, Converter={StaticResource StringToVisibilityConverter}}" />
|
||||
<TextBlock Text="{Binding ActionHint}" Opacity="0.6"
|
||||
TextWrapping="Wrap" FontSize="12" Margin="0,2,0,0"
|
||||
Visibility="{Binding ActionHint, Converter={StaticResource StringToVisibilityConverter}}" />
|
||||
</StackPanel>
|
||||
<TextBlock Grid.Column="1" Text="{Binding StatusLabel}" Opacity="0.6" VerticalAlignment="Center" Margin="12,0" />
|
||||
<ui:Button Grid.Column="2" Content="{Binding ActionLabel}" Appearance="Secondary"
|
||||
IsEnabled="{Binding CanToggleStatic}"
|
||||
ToolTip="{Binding ActionHint}"
|
||||
Command="{Binding DataContext.ToggleStaticCommand, RelativeSource={RelativeSource AncestorType={x:Type ItemsControl}, AncestorLevel=2}}"
|
||||
CommandParameter="{Binding}" />
|
||||
</Grid>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</StackPanel>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,11 @@
|
||||
using System.Windows.Controls;
|
||||
|
||||
namespace MikrotikInstaller.App.Views.Steps;
|
||||
|
||||
public partial class LanScanStepView : UserControl
|
||||
{
|
||||
public LanScanStepView()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
}
|
||||
@@ -109,5 +109,10 @@
|
||||
</StackPanel>
|
||||
<ui:TextBox Text="{Binding NtpServer, UpdateSourceTrigger=PropertyChanged}" Margin="0,0,0,16" HorizontalAlignment="Left" MinWidth="220" />
|
||||
</StackPanel>
|
||||
|
||||
<TextBlock Text="{Binding ValidationMessage}"
|
||||
Foreground="{DynamicResource SystemFillColorCriticalBrush}"
|
||||
Visibility="{Binding ValidationMessage, Converter={StaticResource StringToVisibilityConverter}}"
|
||||
TextWrapping="Wrap" Margin="0,4,0,0" />
|
||||
</StackPanel>
|
||||
</UserControl>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<UserControl x:Class="MikrotikInstaller.App.Views.Steps.SummaryStepView"
|
||||
<UserControl x:Class="MikrotikInstaller.App.Views.Steps.SummaryStepView"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml"
|
||||
|
||||
@@ -64,6 +64,11 @@
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<TextBlock Text="{Binding FormatValidationMessage}"
|
||||
Foreground="{DynamicResource SystemFillColorCriticalBrush}"
|
||||
Visibility="{Binding FormatValidationMessage, Converter={StaticResource StringToVisibilityConverter}}"
|
||||
TextWrapping="Wrap" Margin="0,4,0,0" />
|
||||
|
||||
<StackPanel Margin="0,12,0,0"
|
||||
Visibility="{Binding DataContext.IsExpertMode, RelativeSource={RelativeSource AncestorType=ItemsControl}, Converter={StaticResource BoolToVisibilityConverter}}">
|
||||
<Grid Margin="0,0,0,8">
|
||||
|
||||
@@ -60,5 +60,10 @@
|
||||
</StackPanel>
|
||||
<ui:TextBox Text="{Binding NatSourceAddress, UpdateSourceTrigger=PropertyChanged}" Margin="0,0,0,16" />
|
||||
</StackPanel>
|
||||
|
||||
<TextBlock Text="{Binding ValidationMessage}"
|
||||
Foreground="{DynamicResource SystemFillColorCriticalBrush}"
|
||||
Visibility="{Binding ValidationMessage, Converter={StaticResource StringToVisibilityConverter}}"
|
||||
TextWrapping="Wrap" Margin="0,4,0,0" />
|
||||
</StackPanel>
|
||||
</UserControl>
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
using MikrotikInstaller.Core.Backup;
|
||||
using MikrotikInstaller.Core.Tests.Configuration;
|
||||
|
||||
namespace MikrotikInstaller.Core.Tests.Backup;
|
||||
|
||||
public class BackupRestoreServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ListBackupsAsync_FiltersToBackupFilesAndParsesModelOnly()
|
||||
{
|
||||
var client = new RecordingRouterOsClient();
|
||||
client.GetResponses["/file"] =
|
||||
[
|
||||
new Dictionary<string, string> { ["name"] = "mikrotik-installer-RB750Gr3-20260101-120000.backup", ["creation-time"] = "2026-01-01T12:00:00Z" },
|
||||
new Dictionary<string, string> { ["name"] = "export.rsc" },
|
||||
new Dictionary<string, string> { ["name"] = "winbox-backup.backup", ["creation-time"] = "2025-06-01T08:00:00Z" },
|
||||
];
|
||||
|
||||
var backups = await BackupRestoreService.ListBackupsAsync(client);
|
||||
|
||||
Assert.Equal(2, backups.Count);
|
||||
Assert.Equal("RB750Gr3", backups[0].Model);
|
||||
Assert.Null(backups[0].Architecture);
|
||||
Assert.Null(backups[0].SerialNumber);
|
||||
Assert.Null(backups[1].Model);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListBackupsAsync_ParsesModelAndArchitectureWithoutSerial()
|
||||
{
|
||||
var client = new RecordingRouterOsClient();
|
||||
client.GetResponses["/file"] =
|
||||
[
|
||||
new Dictionary<string, string> { ["name"] = "mikrotik-installer-RB750Gr3_arm-20260101-120000.backup", ["creation-time"] = "2026-01-01T12:00:00Z" },
|
||||
];
|
||||
|
||||
var backup = Assert.Single(await BackupRestoreService.ListBackupsAsync(client));
|
||||
|
||||
Assert.Equal("RB750Gr3", backup.Model);
|
||||
Assert.Equal("arm", backup.Architecture);
|
||||
Assert.Null(backup.SerialNumber);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListBackupsAsync_ParsesModelArchitectureAndSerialNumber()
|
||||
{
|
||||
var client = new RecordingRouterOsClient();
|
||||
client.GetResponses["/file"] =
|
||||
[
|
||||
new Dictionary<string, string> { ["name"] = "mikrotik-installer-RB750Gr3_arm_ABC123-20260101-120000.backup", ["creation-time"] = "2026-01-01T12:00:00Z" },
|
||||
];
|
||||
|
||||
var backup = Assert.Single(await BackupRestoreService.ListBackupsAsync(client));
|
||||
|
||||
Assert.Equal("RB750Gr3", backup.Model);
|
||||
Assert.Equal("arm", backup.Architecture);
|
||||
Assert.Equal("ABC123", backup.SerialNumber);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListBackupsAsync_OrdersByCreationTimeDescending()
|
||||
{
|
||||
var client = new RecordingRouterOsClient();
|
||||
client.GetResponses["/file"] =
|
||||
[
|
||||
new Dictionary<string, string> { ["name"] = "older.backup", ["creation-time"] = "2025-01-01T00:00:00Z" },
|
||||
new Dictionary<string, string> { ["name"] = "newer.backup", ["creation-time"] = "2026-01-01T00:00:00Z" },
|
||||
];
|
||||
|
||||
var backups = await BackupRestoreService.ListBackupsAsync(client);
|
||||
|
||||
Assert.Equal("newer.backup", backups[0].FileName);
|
||||
Assert.Equal("older.backup", backups[1].FileName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CheckCompatibility_ExactMatch_IsMatch()
|
||||
{
|
||||
var backup = new BackupFileInfo("irrelevant.backup", "RB750Gr3", "arm", "ABC123", null);
|
||||
|
||||
var result = BackupRestoreService.CheckCompatibility(backup, "RB750Gr3", "arm", "ABC123");
|
||||
|
||||
Assert.Equal(BackupCompatibility.Match, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CheckCompatibility_SameModelAndArchitectureButDifferentSerial_IsSameModelDifferentDevice()
|
||||
{
|
||||
var backup = new BackupFileInfo("irrelevant.backup", "RB750Gr3", "arm", "ABC123", null);
|
||||
|
||||
var result = BackupRestoreService.CheckCompatibility(backup, "RB750Gr3", "arm", "XYZ999");
|
||||
|
||||
Assert.Equal(BackupCompatibility.SameModelDifferentDevice, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CheckCompatibility_SameModelAndArchitectureButNoSerialKnownOnEitherSide_IsMatch()
|
||||
{
|
||||
var backup = new BackupFileInfo("irrelevant.backup", "RB750Gr3", "arm", null, null);
|
||||
|
||||
var result = BackupRestoreService.CheckCompatibility(backup, "RB750Gr3", "arm", null);
|
||||
|
||||
Assert.Equal(BackupCompatibility.Match, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CheckCompatibility_DifferentModel_IsMismatchRegardlessOfSerial()
|
||||
{
|
||||
var backup = new BackupFileInfo("irrelevant.backup", "RB750Gr3", "arm", "ABC123", null);
|
||||
|
||||
var result = BackupRestoreService.CheckCompatibility(backup, "hAP ac2", "arm", "ABC123");
|
||||
|
||||
Assert.Equal(BackupCompatibility.Mismatch, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CheckCompatibility_SameModelButDifferentArchitecture_IsMismatch()
|
||||
{
|
||||
var backup = new BackupFileInfo("irrelevant.backup", "RB750Gr3", "arm", null, null);
|
||||
|
||||
var result = BackupRestoreService.CheckCompatibility(backup, "RB750Gr3", "mipsbe", null);
|
||||
|
||||
Assert.Equal(BackupCompatibility.Mismatch, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CheckCompatibility_ModelOnlyBackupWithMatchingModel_IsMatch()
|
||||
{
|
||||
// Ältere Backups ohne eingebettete Architektur/Seriennummer: reiner Modell-Abgleich als Fallback.
|
||||
var backup = new BackupFileInfo("irrelevant.backup", "RB750Gr3", null, null, null);
|
||||
|
||||
var result = BackupRestoreService.CheckCompatibility(backup, "RB750Gr3", "arm", "ABC123");
|
||||
|
||||
Assert.Equal(BackupCompatibility.Match, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CheckCompatibility_NoModelInFileName_IsUnknown()
|
||||
{
|
||||
var backup = new BackupFileInfo("winbox-backup.backup", null, null, null, null);
|
||||
|
||||
var result = BackupRestoreService.CheckCompatibility(backup, "RB750Gr3", "arm", "ABC123");
|
||||
|
||||
Assert.Equal(BackupCompatibility.Unknown, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RestoreAsync_Match_ExecutesBackupLoad()
|
||||
{
|
||||
var client = new RecordingRouterOsClient();
|
||||
var backup = new BackupFileInfo("mikrotik-installer-RB750Gr3_arm_ABC123-20260101-120000.backup", "RB750Gr3", "arm", "ABC123", null);
|
||||
|
||||
await BackupRestoreService.RestoreAsync(client, backup, "RB750Gr3", "arm", "ABC123", confirmedByUser: false);
|
||||
|
||||
var call = Assert.Single(client.ExecuteCalls);
|
||||
Assert.Equal("/system/backup/load", call.Path);
|
||||
Assert.Equal(backup.FileName, call.Parameters?["name"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RestoreAsync_Mismatch_ThrowsEvenWithConfirmation()
|
||||
{
|
||||
var client = new RecordingRouterOsClient();
|
||||
var backup = new BackupFileInfo("mikrotik-installer-RB750Gr3-20260101-120000.backup", "RB750Gr3", null, null, null);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
BackupRestoreService.RestoreAsync(client, backup, "hAP ac2", "arm", null, confirmedByUser: true));
|
||||
|
||||
Assert.Empty(client.ExecuteCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RestoreAsync_SameModelDifferentDeviceWithoutConfirmation_Throws()
|
||||
{
|
||||
var client = new RecordingRouterOsClient();
|
||||
var backup = new BackupFileInfo("mikrotik-installer-RB750Gr3_arm_ABC123-20260101-120000.backup", "RB750Gr3", "arm", "ABC123", null);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
BackupRestoreService.RestoreAsync(client, backup, "RB750Gr3", "arm", "XYZ999", confirmedByUser: false));
|
||||
|
||||
Assert.Empty(client.ExecuteCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RestoreAsync_SameModelDifferentDeviceWithConfirmation_ExecutesBackupLoad()
|
||||
{
|
||||
var client = new RecordingRouterOsClient();
|
||||
var backup = new BackupFileInfo("mikrotik-installer-RB750Gr3_arm_ABC123-20260101-120000.backup", "RB750Gr3", "arm", "ABC123", null);
|
||||
|
||||
await BackupRestoreService.RestoreAsync(client, backup, "RB750Gr3", "arm", "XYZ999", confirmedByUser: true);
|
||||
|
||||
Assert.Single(client.ExecuteCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RestoreAsync_UnknownWithoutConfirmation_Throws()
|
||||
{
|
||||
var client = new RecordingRouterOsClient();
|
||||
var backup = new BackupFileInfo("winbox-backup.backup", null, null, null, null);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
BackupRestoreService.RestoreAsync(client, backup, "RB750Gr3", "arm", "ABC123", confirmedByUser: false));
|
||||
|
||||
Assert.Empty(client.ExecuteCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RestoreAsync_UnknownWithConfirmation_ExecutesBackupLoad()
|
||||
{
|
||||
var client = new RecordingRouterOsClient();
|
||||
var backup = new BackupFileInfo("winbox-backup.backup", null, null, null, null);
|
||||
|
||||
await BackupRestoreService.RestoreAsync(client, backup, "RB750Gr3", "arm", "ABC123", confirmedByUser: true);
|
||||
|
||||
Assert.Single(client.ExecuteCalls);
|
||||
}
|
||||
}
|
||||
@@ -15,4 +15,65 @@ public class RouterBackupServiceTests
|
||||
Assert.False(string.IsNullOrWhiteSpace(backupName));
|
||||
Assert.Contains(client.ExecuteCalls, c => c.Path == "/system/backup/save" && c.Parameters!["name"] == backupName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateBackupAsync_WithBoardModelOnly_EmbedsSanitizedModelInName()
|
||||
{
|
||||
var client = new RecordingRouterOsClient();
|
||||
|
||||
var backupName = await RouterBackupService.CreateBackupAsync(client, boardModel: "hAP ac2");
|
||||
|
||||
Assert.StartsWith("mikrotik-installer-hAP-ac2-", backupName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateBackupAsync_WithBoardModelAndArchitecture_EmbedsBothSeparatedByUnderscore()
|
||||
{
|
||||
var client = new RecordingRouterOsClient();
|
||||
|
||||
var backupName = await RouterBackupService.CreateBackupAsync(client, boardModel: "hAP ac2", architecture: "arm");
|
||||
|
||||
Assert.StartsWith("mikrotik-installer-hAP-ac2_arm-", backupName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateBackupAsync_WithModelArchitectureAndSerialNumber_EmbedsAllThree()
|
||||
{
|
||||
var client = new RecordingRouterOsClient();
|
||||
|
||||
var backupName = await RouterBackupService.CreateBackupAsync(client, boardModel: "hAP ac2", architecture: "arm", serialNumber: "ABC123");
|
||||
|
||||
Assert.StartsWith("mikrotik-installer-hAP-ac2_arm_ABC123-", backupName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateBackupAsync_WithSerialNumberButNoArchitecture_OmitsSerialNumber()
|
||||
{
|
||||
var client = new RecordingRouterOsClient();
|
||||
|
||||
var backupName = await RouterBackupService.CreateBackupAsync(client, boardModel: "hAP ac2", architecture: null, serialNumber: "ABC123");
|
||||
|
||||
Assert.StartsWith("mikrotik-installer-hAP-ac2-", backupName);
|
||||
Assert.DoesNotContain("ABC123", backupName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateBackupAsync_WithArchitectureButNoModel_OmitsEverything()
|
||||
{
|
||||
var client = new RecordingRouterOsClient();
|
||||
|
||||
var backupName = await RouterBackupService.CreateBackupAsync(client, boardModel: null, architecture: "arm", serialNumber: "ABC123");
|
||||
|
||||
Assert.StartsWith("mikrotik-installer-", backupName);
|
||||
Assert.DoesNotContain("arm", backupName);
|
||||
Assert.DoesNotContain("ABC123", backupName);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("hAP ac2", "hAP-ac2")]
|
||||
[InlineData("CHR (Simulator)", "CHR--Simulator-")]
|
||||
[InlineData("RB750Gr3", "RB750Gr3")]
|
||||
[InlineData("x86_64", "x86-64")]
|
||||
public void SanitizeForFileName_ReplacesNonAlphanumericCharacters(string input, string expected) =>
|
||||
Assert.Equal(expected, RouterBackupService.SanitizeForFileName(input));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
using MikrotikInstaller.Core.Configuration;
|
||||
using MikrotikInstaller.Core.Connectivity;
|
||||
|
||||
namespace MikrotikInstaller.Core.Tests.Configuration;
|
||||
|
||||
public class DuplicateRuleGuardTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ExistsAsync_MatchingSubsetOfFields_ReturnsTrue()
|
||||
{
|
||||
var guard = new DuplicateRuleGuard();
|
||||
var client = new RecordingRouterOsClient();
|
||||
client.GetResponses["/ip/firewall/filter"] =
|
||||
[
|
||||
new Dictionary<string, string> { [".id"] = "*1", ["chain"] = "input", ["action"] = "accept", ["bytes"] = "12345" },
|
||||
];
|
||||
|
||||
var exists = await guard.ExistsAsync(client, "/ip/firewall/filter",
|
||||
new Dictionary<string, string> { ["chain"] = "input", ["action"] = "accept" }, CancellationToken.None);
|
||||
|
||||
Assert.True(exists);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExistsAsync_NoMatchingRow_ReturnsFalse()
|
||||
{
|
||||
var guard = new DuplicateRuleGuard();
|
||||
var client = new RecordingRouterOsClient();
|
||||
client.GetResponses["/ip/firewall/filter"] =
|
||||
[
|
||||
new Dictionary<string, string> { [".id"] = "*1", ["chain"] = "input", ["action"] = "drop" },
|
||||
];
|
||||
|
||||
var exists = await guard.ExistsAsync(client, "/ip/firewall/filter",
|
||||
new Dictionary<string, string> { ["chain"] = "input", ["action"] = "accept" }, CancellationToken.None);
|
||||
|
||||
Assert.False(exists);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExistsAsync_SecondCallForSamePath_DoesNotFetchAgain()
|
||||
{
|
||||
var guard = new DuplicateRuleGuard();
|
||||
var client = new CountingRouterOsClient();
|
||||
|
||||
await guard.ExistsAsync(client, "/ip/firewall/filter", new Dictionary<string, string> { ["chain"] = "input" }, CancellationToken.None);
|
||||
await guard.ExistsAsync(client, "/ip/firewall/filter", new Dictionary<string, string> { ["chain"] = "forward" }, CancellationToken.None);
|
||||
|
||||
Assert.Equal(1, client.GetCallCount);
|
||||
}
|
||||
|
||||
/// <summary>Zählt GetAsync-Aufrufe, um die Einmalig-pro-Pfad-Momentaufnahme von DuplicateRuleGuard zu belegen.</summary>
|
||||
private sealed class CountingRouterOsClient : IRouterOsClient
|
||||
{
|
||||
public int GetCallCount { get; private set; }
|
||||
|
||||
public RouterOsProtocol Protocol => RouterOsProtocol.Rest;
|
||||
|
||||
public Task<IReadOnlyList<IReadOnlyDictionary<string, string>>> GetAsync(string path, CancellationToken cancellationToken = default)
|
||||
{
|
||||
GetCallCount++;
|
||||
return Task.FromResult<IReadOnlyList<IReadOnlyDictionary<string, string>>>([]);
|
||||
}
|
||||
|
||||
public Task<string> AddAsync(string path, IReadOnlyDictionary<string, string> parameters, CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult("*1");
|
||||
|
||||
public Task SetAsync(string path, string id, IReadOnlyDictionary<string, string> parameters, CancellationToken cancellationToken = default) =>
|
||||
Task.CompletedTask;
|
||||
|
||||
public Task RemoveAsync(string path, string id, CancellationToken cancellationToken = default) =>
|
||||
Task.CompletedTask;
|
||||
|
||||
public Task ExecuteAsync(string path, IReadOnlyDictionary<string, string>? parameters = null, CancellationToken cancellationToken = default) =>
|
||||
Task.CompletedTask;
|
||||
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -85,6 +85,62 @@ public class FirewallConfiguratorTests
|
||||
&& c.Parameters["chain"] == "forward" && c.Parameters.GetValueOrDefault("src-address-list") == "blocked" && c.Parameters["action"] == "drop");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BuildActions_AllBaseRulesAlreadyExist_AddsNoneOfThem()
|
||||
{
|
||||
var settings = new FirewallSettings("ether1", "bridge-lan", []);
|
||||
var actions = FirewallConfigurator.BuildActions(settings);
|
||||
var client = new RecordingRouterOsClient();
|
||||
client.GetResponses["/ip/firewall/filter"] =
|
||||
[
|
||||
new Dictionary<string, string> { [".id"] = "*1", ["chain"] = "input", ["connection-state"] = "established,related,untracked", ["action"] = "accept" },
|
||||
new Dictionary<string, string> { [".id"] = "*2", ["chain"] = "input", ["connection-state"] = "invalid", ["action"] = "drop" },
|
||||
new Dictionary<string, string> { [".id"] = "*3", ["chain"] = "input", ["protocol"] = "icmp", ["action"] = "accept" },
|
||||
new Dictionary<string, string> { [".id"] = "*4", ["chain"] = "input", ["in-interface"] = "bridge-lan", ["action"] = "accept" },
|
||||
new Dictionary<string, string> { [".id"] = "*5", ["chain"] = "forward", ["connection-state"] = "established,related,untracked", ["action"] = "accept" },
|
||||
new Dictionary<string, string> { [".id"] = "*6", ["chain"] = "forward", ["connection-state"] = "invalid", ["action"] = "drop" },
|
||||
new Dictionary<string, string> { [".id"] = "*7", ["chain"] = "forward", ["in-interface"] = "ether1", ["connection-state"] = "new", ["action"] = "drop" },
|
||||
new Dictionary<string, string> { [".id"] = "*8", ["chain"] = "input", ["in-interface"] = "ether1", ["action"] = "drop" },
|
||||
];
|
||||
|
||||
foreach (var action in actions)
|
||||
{
|
||||
await action.ApplyAsync(client, CancellationToken.None);
|
||||
}
|
||||
|
||||
Assert.Empty(client.AddCalls.Where(c => c.Path == "/ip/firewall/filter"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BuildActions_PortForwardRulesAlreadyExist_SkipsButAddsGenuinelyNewOnes()
|
||||
{
|
||||
var settings = new FirewallSettings("ether1", "bridge-lan",
|
||||
[new PortForward("Spieleserver", "udp", 25565, "192.168.88.50", 25565)]);
|
||||
var actions = FirewallConfigurator.BuildActions(settings);
|
||||
var client = new RecordingRouterOsClient();
|
||||
var natParameters = new Dictionary<string, string>
|
||||
{
|
||||
[".id"] = "*9",
|
||||
["chain"] = "dstnat",
|
||||
["protocol"] = "udp",
|
||||
["dst-port"] = "25565",
|
||||
["in-interface"] = "ether1",
|
||||
["action"] = "dst-nat",
|
||||
["to-addresses"] = "192.168.88.50",
|
||||
["to-ports"] = "25565",
|
||||
};
|
||||
client.GetResponses["/ip/firewall/nat"] = [natParameters];
|
||||
// Die zugehörige Forward-Filter-Regel existiert bewusst NICHT — muss trotzdem angelegt werden.
|
||||
|
||||
foreach (var action in actions)
|
||||
{
|
||||
await action.ApplyAsync(client, CancellationToken.None);
|
||||
}
|
||||
|
||||
Assert.Empty(client.AddCalls.Where(c => c.Path == "/ip/firewall/nat"));
|
||||
Assert.Contains(client.AddCalls, c => c.Path == "/ip/firewall/filter" && c.Parameters.GetValueOrDefault("dst-address") == "192.168.88.50");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildFriendlySummary_WithoutPortForwards_OnlyMentionsProtection()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
using MikrotikInstaller.Core.Configuration;
|
||||
|
||||
namespace MikrotikInstaller.Core.Tests.Configuration;
|
||||
|
||||
public class NetworkValidationTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("192.168.88.1", true)]
|
||||
[InlineData("0.0.0.0", true)]
|
||||
[InlineData("255.255.255.255", true)]
|
||||
[InlineData("192.168.88.1/24", false)]
|
||||
[InlineData("300.1.1.1", false)]
|
||||
[InlineData("", false)]
|
||||
[InlineData("not-an-ip", false)]
|
||||
[InlineData("::1", false)]
|
||||
public void IsValidIPv4_MatchesExpectation(string value, bool expected) =>
|
||||
Assert.Equal(expected, NetworkValidation.IsValidIPv4(value));
|
||||
|
||||
[Theory]
|
||||
[InlineData("192.168.88.1/24", true)]
|
||||
[InlineData("10.0.0.0/8", true)]
|
||||
[InlineData("192.168.88.1/0", true)]
|
||||
[InlineData("192.168.88.1/32", true)]
|
||||
[InlineData("192.168.88.1/33", false)]
|
||||
[InlineData("192.168.88.1/-1", false)]
|
||||
[InlineData("192.168.88.1", false)]
|
||||
[InlineData("192.168.88.1/", false)]
|
||||
[InlineData("abc/24", false)]
|
||||
public void IsValidCidr_MatchesExpectation(string value, bool expected) =>
|
||||
Assert.Equal(expected, NetworkValidation.IsValidCidr(value));
|
||||
|
||||
[Theory]
|
||||
[InlineData("192.168.88.1", true)]
|
||||
[InlineData("192.168.88.0/24", true)]
|
||||
[InlineData("not-valid", false)]
|
||||
public void IsValidIPv4OrCidr_MatchesExpectation(string value, bool expected) =>
|
||||
Assert.Equal(expected, NetworkValidation.IsValidIPv4OrCidr(value));
|
||||
|
||||
[Theory]
|
||||
[InlineData("192.168.88.10", "192.168.88.254", true)]
|
||||
[InlineData("192.168.88.10", "192.168.88.10", true)]
|
||||
[InlineData("192.168.88.254", "192.168.88.10", false)]
|
||||
[InlineData("192.168.88.10", "not-an-ip", false)]
|
||||
public void IsOrderedRange_MatchesExpectation(string from, string to, bool expected) =>
|
||||
Assert.Equal(expected, NetworkValidation.IsOrderedRange(from, to));
|
||||
}
|
||||
@@ -51,6 +51,30 @@ public class VlanConfiguratorTests
|
||||
Assert.Contains(isolationDrops, c => c.Parameters["in-interface"] == "bridge-lan" && c.Parameters["out-interface"] == "vlan20");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BuildActions_IsolationRulesAlreadyExist_DoesNotDuplicateThem()
|
||||
{
|
||||
var vlans = new List<VlanDefinition>
|
||||
{
|
||||
new(VlanId: 20, Name: "Gäste", RouterAddressCidr: "192.168.20.1/24", DhcpPoolStart: "192.168.20.10", DhcpPoolEnd: "192.168.20.254",
|
||||
IsolateFromLan: true),
|
||||
};
|
||||
var actions = VlanConfigurator.BuildActions("bridge-lan", vlans);
|
||||
var client = new RecordingRouterOsClient();
|
||||
client.GetResponses["/ip/firewall/filter"] =
|
||||
[
|
||||
new Dictionary<string, string> { [".id"] = "*1", ["chain"] = "forward", ["in-interface"] = "vlan20", ["out-interface"] = "bridge-lan", ["action"] = "drop" },
|
||||
new Dictionary<string, string> { [".id"] = "*2", ["chain"] = "forward", ["in-interface"] = "bridge-lan", ["out-interface"] = "vlan20", ["action"] = "drop" },
|
||||
];
|
||||
|
||||
foreach (var action in actions)
|
||||
{
|
||||
await action.ApplyAsync(client, CancellationToken.None);
|
||||
}
|
||||
|
||||
Assert.Empty(client.AddCalls.Where(c => c.Path == "/ip/firewall/filter"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildActions_NoVlans_ReturnsEmptyList()
|
||||
{
|
||||
|
||||
@@ -13,6 +13,66 @@ public class DemoRouterOsClientTests
|
||||
Assert.False(DemoRouterOsClient.Matches(new RouterOsCredentials("127.127.127.127", "admin", "falsch")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Matches_AllVirtualDeviceProfiles()
|
||||
{
|
||||
foreach (var profile in DemoRouterOsClient.Profiles)
|
||||
{
|
||||
Assert.True(DemoRouterOsClient.Matches(new RouterOsCredentials(profile.Host, "admin", "admin")));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Profiles_HaveDistinctHostsAndIdentities()
|
||||
{
|
||||
Assert.True(DemoRouterOsClient.Profiles.Count >= 3, "Es sollten mehrere virtuelle Testgeräte zur Auswahl stehen.");
|
||||
Assert.Equal(DemoRouterOsClient.Profiles.Count, DemoRouterOsClient.Profiles.Select(p => p.Host).Distinct().Count());
|
||||
Assert.Equal(DemoRouterOsClient.Profiles.Count, DemoRouterOsClient.Profiles.Select(p => p.IdentityName).Distinct().Count());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(ProfileIndexes))]
|
||||
public async Task Create_ReportsTheSelectedProfilesIdentity(int profileIndex)
|
||||
{
|
||||
var profile = DemoRouterOsClient.Profiles[profileIndex];
|
||||
var client = DemoRouterOsClient.Create(new RouterOsCredentials(profile.Host, "admin", "admin"));
|
||||
|
||||
var resource = (await client.GetAsync("/system/resource")).Single();
|
||||
var identity = (await client.GetAsync("/system/identity")).Single();
|
||||
var routerboard = (await client.GetAsync("/system/routerboard")).Single();
|
||||
|
||||
Assert.Equal(profile.BoardModel, resource["board-name"]);
|
||||
Assert.Equal(profile.Architecture, resource["architecture-name"]);
|
||||
Assert.Equal(profile.IdentityName, identity["name"]);
|
||||
Assert.Equal(profile.IsRouterBoard ? "yes" : "no", routerboard["routerboard"]);
|
||||
Assert.Equal(profile.SerialNumber, routerboard.GetValueOrDefault("serial-number"));
|
||||
}
|
||||
|
||||
public static TheoryData<int> ProfileIndexes()
|
||||
{
|
||||
var data = new TheoryData<int>();
|
||||
for (var i = 0; i < DemoRouterOsClient.Profiles.Count; i++)
|
||||
{
|
||||
data.Add(i);
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_EachProfileHasABackupThatMatchesItself()
|
||||
{
|
||||
foreach (var profile in DemoRouterOsClient.Profiles)
|
||||
{
|
||||
var client = DemoRouterOsClient.Create(new RouterOsCredentials(profile.Host, "admin", "admin"));
|
||||
var files = await client.GetAsync("/file");
|
||||
|
||||
var sanitizedModel = MikrotikInstaller.Core.Backup.RouterBackupService.SanitizeForFileName(profile.BoardModel);
|
||||
var sanitizedArch = MikrotikInstaller.Core.Backup.RouterBackupService.SanitizeForFileName(profile.Architecture);
|
||||
Assert.Contains(files, f => f["name"].StartsWith($"mikrotik-installer-{sanitizedModel}_{sanitizedArch}"));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAsync_ReturnsSimulatedInterfacesAndResource()
|
||||
{
|
||||
@@ -37,4 +97,15 @@ public class DemoRouterOsClientTests
|
||||
Assert.False(string.IsNullOrEmpty(id));
|
||||
Assert.Contains(rows, r => r["name"] == "bridge-lan" && r[".id"] == id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_BackupSave_AddsFileWithBackupExtension()
|
||||
{
|
||||
var client = new DemoRouterOsClient();
|
||||
|
||||
await client.ExecuteAsync("/system/backup/save", new Dictionary<string, string> { ["name"] = "mein-test-backup" });
|
||||
var files = await client.GetAsync("/file");
|
||||
|
||||
Assert.Contains(files, f => f["name"] == "mein-test-backup.backup");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,6 +47,32 @@ public class DeviceDiscoveryServiceTests
|
||||
Assert.Equal("RB750Gr3", device.BoardModel);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DiscoverAsync_ReadsSerialNumberFromRouterboard()
|
||||
{
|
||||
var client = new FakeRouterOsClient(new Dictionary<string, IReadOnlyList<IReadOnlyDictionary<string, string>>>
|
||||
{
|
||||
["/system/routerboard"] = [new Dictionary<string, string> { ["routerboard"] = "yes", ["model"] = "RB750Gr3", ["serial-number"] = "ABC123456" }],
|
||||
});
|
||||
|
||||
var device = await DeviceDiscoveryService.DiscoverAsync(client);
|
||||
|
||||
Assert.Equal("ABC123456", device.SerialNumber);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DiscoverAsync_NoRouterboard_SerialNumberIsNull()
|
||||
{
|
||||
var client = new FakeRouterOsClient(new Dictionary<string, IReadOnlyList<IReadOnlyDictionary<string, string>>>
|
||||
{
|
||||
["/system/routerboard"] = [new Dictionary<string, string> { ["routerboard"] = "no" }],
|
||||
});
|
||||
|
||||
var device = await DeviceDiscoveryService.DiscoverAsync(client);
|
||||
|
||||
Assert.Null(device.SerialNumber);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DiscoverAsync_NoWirelessInterfaces_HasWirelessIsFalse()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
using MikrotikInstaller.Core.Connectivity;
|
||||
using MikrotikInstaller.Core.Devices.LanScan;
|
||||
using MikrotikInstaller.Core.Tests.Configuration;
|
||||
using MikrotikInstaller.Core.Tests.Devices;
|
||||
|
||||
namespace MikrotikInstaller.Core.Tests.Devices.LanScan;
|
||||
|
||||
public class LanScanServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ScanAsync_MergesLeaseArpAndBridgeHostByMacAddress()
|
||||
{
|
||||
var client = new FakeRouterOsClient(new Dictionary<string, IReadOnlyList<IReadOnlyDictionary<string, string>>>
|
||||
{
|
||||
["/ip/dhcp-server/lease"] =
|
||||
[
|
||||
new Dictionary<string, string> { [".id"] = "*10", ["address"] = "192.168.88.50", ["mac-address"] = "aa:bb:cc:00:00:01", ["host-name"] = "spielserver", ["dynamic"] = "true" },
|
||||
],
|
||||
["/ip/arp"] =
|
||||
[
|
||||
new Dictionary<string, string> { ["address"] = "192.168.88.50", ["mac-address"] = "AA:BB:CC:00:00:01", ["interface"] = "bridge-lan" },
|
||||
],
|
||||
["/interface/bridge/host"] =
|
||||
[
|
||||
new Dictionary<string, string> { ["mac-address"] = "AA:BB:CC:00:00:01", ["on-interface"] = "ether2", ["local"] = "false" },
|
||||
],
|
||||
});
|
||||
|
||||
var devices = await LanScanService.ScanAsync(client);
|
||||
|
||||
var device = Assert.Single(devices);
|
||||
Assert.Equal("AA:BB:CC:00:00:01", device.MacAddress);
|
||||
Assert.Equal("192.168.88.50", device.IpAddress);
|
||||
Assert.Equal("spielserver", device.HostName);
|
||||
Assert.Equal("ether2", device.PortInterfaceName);
|
||||
Assert.True(device.HasDhcpLease);
|
||||
Assert.False(device.IsStaticLease);
|
||||
Assert.Equal("*10", device.LeaseId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ScanAsync_ExcludesRoutersOwnLocalBridgeHostEntries()
|
||||
{
|
||||
var client = new FakeRouterOsClient(new Dictionary<string, IReadOnlyList<IReadOnlyDictionary<string, string>>>
|
||||
{
|
||||
["/interface/bridge/host"] =
|
||||
[
|
||||
new Dictionary<string, string> { ["mac-address"] = "AA:BB:CC:00:00:99", ["on-interface"] = "bridge-lan", ["local"] = "true" },
|
||||
],
|
||||
});
|
||||
|
||||
var devices = await LanScanService.ScanAsync(client);
|
||||
|
||||
Assert.Empty(devices);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ScanAsync_DeviceWithoutDhcpLease_HasNoLeaseIdAndIsNotDhcpManaged()
|
||||
{
|
||||
var client = new FakeRouterOsClient(new Dictionary<string, IReadOnlyList<IReadOnlyDictionary<string, string>>>
|
||||
{
|
||||
["/ip/arp"] =
|
||||
[
|
||||
new Dictionary<string, string> { ["address"] = "192.168.88.70", ["mac-address"] = "AA:BB:CC:00:00:03", ["interface"] = "bridge-lan" },
|
||||
],
|
||||
});
|
||||
|
||||
var devices = await LanScanService.ScanAsync(client);
|
||||
|
||||
var device = Assert.Single(devices);
|
||||
Assert.False(device.HasDhcpLease);
|
||||
Assert.Null(device.LeaseId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MakeStaticAsync_SendsIdAndReloadsUpdatedLease()
|
||||
{
|
||||
var client = new RecordingRouterOsClient();
|
||||
client.GetResponses["/ip/dhcp-server/lease"] =
|
||||
[
|
||||
new Dictionary<string, string> { [".id"] = "*20", ["address"] = "192.168.88.60", ["mac-address"] = "AA:BB:CC:00:00:02", ["dynamic"] = "false" },
|
||||
];
|
||||
|
||||
var device = new DiscoveredDevice("AA:BB:CC:00:00:02", "192.168.88.60", null, "ether3", HasDhcpLease: true, IsStaticLease: false, LeaseId: "*10");
|
||||
|
||||
var updated = await LanScanService.MakeStaticAsync(client, device);
|
||||
|
||||
var executeCall = Assert.Single(client.ExecuteCalls);
|
||||
Assert.Equal("/ip/dhcp-server/lease/make-static", executeCall.Path);
|
||||
Assert.Equal("*10", executeCall.Parameters?[".id"]);
|
||||
|
||||
Assert.True(updated.IsStaticLease);
|
||||
Assert.Equal("*20", updated.LeaseId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MakeStaticAsync_DeviceWithoutLease_Throws()
|
||||
{
|
||||
var client = new RecordingRouterOsClient();
|
||||
var device = new DiscoveredDevice("AA:BB:CC:00:00:02", null, null, null, HasDhcpLease: false, IsStaticLease: false, LeaseId: null);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => LanScanService.MakeStaticAsync(client, device));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UndoStaticAsync_RemovesLeaseAndClearsDhcpState()
|
||||
{
|
||||
var client = new RecordingRouterOsClient();
|
||||
var device = new DiscoveredDevice("AA:BB:CC:00:00:01", "192.168.88.50", "spielserver", "ether2", HasDhcpLease: true, IsStaticLease: true, LeaseId: "*20");
|
||||
|
||||
var updated = await LanScanService.UndoStaticAsync(client, device);
|
||||
|
||||
Assert.False(updated.HasDhcpLease);
|
||||
Assert.False(updated.IsStaticLease);
|
||||
Assert.Null(updated.LeaseId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
using MikrotikInstaller.Core.Devices;
|
||||
|
||||
namespace MikrotikInstaller.Core.Tests.Devices;
|
||||
|
||||
public class SavedRoutersStoreTests : IDisposable
|
||||
{
|
||||
private readonly string _filePath = Path.Combine(Path.GetTempPath(), $"saved-routers-test-{Guid.NewGuid()}.json");
|
||||
|
||||
private SavedRoutersStore CreateStore() => new(_filePath);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (File.Exists(_filePath))
|
||||
{
|
||||
File.Delete(_filePath);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_NoFileYet_ReturnsEmptyList()
|
||||
{
|
||||
Assert.Empty(CreateStore().Load());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RecordConnection_NewHost_CreatesEntryWithSuggestedName()
|
||||
{
|
||||
var store = CreateStore();
|
||||
|
||||
var entry = store.RecordConnection("192.168.88.1", "admin", "hEX", "ABC123", DateTimeOffset.UtcNow);
|
||||
|
||||
Assert.Equal("192.168.88.1", entry.Host);
|
||||
Assert.Equal("admin", entry.Username);
|
||||
Assert.Equal("hEX", entry.Name);
|
||||
Assert.Equal("ABC123", entry.SerialNumber);
|
||||
Assert.Null(entry.Location);
|
||||
Assert.Single(store.Load());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RecordConnection_SameHostUsernameAndSerial_UpdatesExistingInsteadOfDuplicating()
|
||||
{
|
||||
var store = CreateStore();
|
||||
var first = store.RecordConnection("192.168.88.1", "admin", "hEX", "ABC123", DateTimeOffset.UtcNow.AddDays(-1));
|
||||
|
||||
var second = store.RecordConnection("192.168.88.1", "admin", "hEX", "ABC123", DateTimeOffset.UtcNow);
|
||||
|
||||
Assert.Equal(first.Id, second.Id);
|
||||
Assert.Single(store.Load());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RecordConnection_SameHostUsernameDifferentSerial_CreatesSecondEntry()
|
||||
{
|
||||
var store = CreateStore();
|
||||
store.RecordConnection("192.168.88.1", "admin", "hEX", "ABC123", DateTimeOffset.UtcNow);
|
||||
|
||||
store.RecordConnection("192.168.88.1", "admin", "hEX", "XYZ999", DateTimeOffset.UtcNow);
|
||||
|
||||
Assert.Equal(2, store.Load().Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RecordConnection_ExistingEntryWithoutSerial_LearnsSerialInsteadOfDuplicating()
|
||||
{
|
||||
var store = CreateStore();
|
||||
var first = store.RecordConnection("192.168.88.1", "admin", "CHR", serialNumber: null, DateTimeOffset.UtcNow.AddDays(-1));
|
||||
|
||||
var second = store.RecordConnection("192.168.88.1", "admin", "CHR", "ABC123", DateTimeOffset.UtcNow);
|
||||
|
||||
Assert.Equal(first.Id, second.Id);
|
||||
Assert.Equal("ABC123", second.SerialNumber);
|
||||
Assert.Single(store.Load());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_ChangesNameAndLocation()
|
||||
{
|
||||
var store = CreateStore();
|
||||
var entry = store.RecordConnection("192.168.88.1", "admin", "hEX", null, DateTimeOffset.UtcNow);
|
||||
|
||||
store.Update(entry with { Name = "Büro-Router", Location = "1. OG" });
|
||||
|
||||
var reloaded = store.Load().Single();
|
||||
Assert.Equal("Büro-Router", reloaded.Name);
|
||||
Assert.Equal("1. OG", reloaded.Location);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Remove_DeletesEntry()
|
||||
{
|
||||
var store = CreateStore();
|
||||
var entry = store.RecordConnection("192.168.88.1", "admin", "hEX", null, DateTimeOffset.UtcNow);
|
||||
|
||||
store.Remove(entry.Id);
|
||||
|
||||
Assert.Empty(store.Load());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_NewStoreInstanceSamePath_SeesPersistedData()
|
||||
{
|
||||
CreateStore().RecordConnection("192.168.88.1", "admin", "hEX", "ABC123", DateTimeOffset.UtcNow);
|
||||
|
||||
var reloaded = CreateStore().Load();
|
||||
|
||||
Assert.Single(reloaded);
|
||||
Assert.Equal("192.168.88.1", reloaded[0].Host);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace MikrotikInstaller.Core.Backup;
|
||||
|
||||
/// <summary>
|
||||
/// Eine ".backup"-Datei im Dateispeicher eines Geräts. <see cref="Model"/>/<see cref="Architecture"/>/
|
||||
/// <see cref="SerialNumber"/> sind nur gesetzt, wenn der Dateiname dem (hierarchischen) Muster von
|
||||
/// <see cref="RouterBackupService.CreateBackupAsync"/> entspricht — bei einem Backup aus WinBox oder
|
||||
/// einer älteren Version dieses Assistenten fehlen sie teilweise oder ganz, das Gerät ist dann
|
||||
/// entsprechend nur eingeschränkt oder gar nicht automatisch prüfbar. <see cref="SerialNumber"/>
|
||||
/// identifiziert das konkrete physische Gerät (nicht nur dessen Modell) und ist die stärkste der drei
|
||||
/// Prüfungen; siehe <see cref="BackupRestoreService.CheckCompatibility"/>.
|
||||
/// </summary>
|
||||
public sealed record BackupFileInfo(string FileName, string? Model, string? Architecture, string? SerialNumber, DateTimeOffset? CreationTime);
|
||||
@@ -0,0 +1,164 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using MikrotikInstaller.Core.Connectivity;
|
||||
|
||||
namespace MikrotikInstaller.Core.Backup;
|
||||
|
||||
/// <summary>Ergebnis von <see cref="BackupRestoreService.CheckCompatibility"/>, von streng nach lose sortiert.</summary>
|
||||
public enum BackupCompatibility
|
||||
{
|
||||
/// <summary>Seriennummer (oder, falls unbekannt, Modell+Architektur) stimmt mit dem verbundenen Gerät überein.</summary>
|
||||
Match,
|
||||
|
||||
/// <summary>Gleiches Modell und gleiche Architektur, aber eine bekannte, abweichende Seriennummer — vermutlich ein baugleiches, aber physisch anderes Gerät. Auf eigene Gefahr wiederherstellbar.</summary>
|
||||
SameModelDifferentDevice,
|
||||
|
||||
/// <summary>Modell oder Architektur weicht erkennbar vom verbundenen Gerät ab — Restore wird ohne Override blockiert.</summary>
|
||||
Mismatch,
|
||||
|
||||
/// <summary>Kein Modell im Dateinamen erkennbar (z. B. Backup aus WinBox) — nicht automatisch prüfbar.</summary>
|
||||
Unknown,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Listet vorhandene ".backup"-Dateien auf einem Gerät auf und spielt eine davon zurück
|
||||
/// ("/system/backup/load" — startet das Gerät danach automatisch neu).
|
||||
/// </summary>
|
||||
public static partial class BackupRestoreService
|
||||
{
|
||||
public static async Task<IReadOnlyList<BackupFileInfo>> ListBackupsAsync(IRouterOsClient client, CancellationToken cancellationToken = default)
|
||||
{
|
||||
IReadOnlyList<IReadOnlyDictionary<string, string>> files;
|
||||
try
|
||||
{
|
||||
files = await client.GetAsync("/file", cancellationToken);
|
||||
}
|
||||
catch (RouterOsException)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
return files
|
||||
.Where(row => (row.GetValueOrDefault("name") ?? string.Empty).EndsWith(".backup", StringComparison.OrdinalIgnoreCase))
|
||||
.Select(ToBackupFileInfo)
|
||||
.OrderByDescending(b => b.CreationTime)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Prüft, ob ein Backup zum verbundenen Gerät passt — von <see cref="RestoreAsync"/> zum Blockieren
|
||||
/// genutzt und von der UI zur Anzeige, damit beide garantiert dasselbe Ergebnis sehen. Modell und
|
||||
/// Architektur müssen übereinstimmen, sonst <see cref="BackupCompatibility.Mismatch"/> (echtes
|
||||
/// Risiko falsch zugeordneter Interfaces, kein Override). Stimmen beide überein, entscheidet die
|
||||
/// Seriennummer, die das konkrete physische Gerät identifiziert (nicht nur dessen Modell): stimmt
|
||||
/// sie ebenfalls überein oder ist sie auf einer der beiden Seiten unbekannt, gilt es als
|
||||
/// <see cref="BackupCompatibility.Match"/>; weicht eine bekannte Seriennummer ab, ist es vermutlich
|
||||
/// ein baugleiches, aber physisch anderes Gerät (<see cref="BackupCompatibility.SameModelDifferentDevice"/>) —
|
||||
/// auf eigene Gefahr wiederherstellbar, ein reiner Modell-Abgleich würde das fälschlich durchlassen.
|
||||
/// </summary>
|
||||
public static BackupCompatibility CheckCompatibility(
|
||||
BackupFileInfo backup, string? currentBoardModel, string? currentArchitecture, string? currentSerialNumber)
|
||||
{
|
||||
if (backup.Model is null)
|
||||
{
|
||||
return BackupCompatibility.Unknown;
|
||||
}
|
||||
|
||||
var currentModelSanitized = currentBoardModel is null ? null : RouterBackupService.SanitizeForFileName(currentBoardModel);
|
||||
if (!string.Equals(backup.Model, currentModelSanitized, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return BackupCompatibility.Mismatch;
|
||||
}
|
||||
|
||||
if (backup.Architecture is not null)
|
||||
{
|
||||
var currentArchSanitized = currentArchitecture is null ? null : RouterBackupService.SanitizeForFileName(currentArchitecture);
|
||||
if (!string.Equals(backup.Architecture, currentArchSanitized, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return BackupCompatibility.Mismatch;
|
||||
}
|
||||
}
|
||||
|
||||
if (backup.SerialNumber is not null)
|
||||
{
|
||||
var currentSerialSanitized = currentSerialNumber is null ? null : RouterBackupService.SanitizeForFileName(currentSerialNumber);
|
||||
return string.Equals(backup.SerialNumber, currentSerialSanitized, StringComparison.OrdinalIgnoreCase)
|
||||
? BackupCompatibility.Match
|
||||
: BackupCompatibility.SameModelDifferentDevice;
|
||||
}
|
||||
|
||||
return BackupCompatibility.Match;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Spielt ein Backup zurück. RouterOS prüft dabei selbst NICHT, ob das Backup vom selben Gerät
|
||||
/// stammt — es importiert klaglos eine Fremd-Konfiguration mit ggf. falsch zugeordneten Interfaces.
|
||||
/// Dieser Abgleich passiert deshalb hier über <see cref="CheckCompatibility"/>: Bei einem erkannten
|
||||
/// <see cref="BackupCompatibility.Mismatch"/> wird ohne Override blockiert; bei
|
||||
/// <see cref="BackupCompatibility.SameModelDifferentDevice"/> oder <see cref="BackupCompatibility.Unknown"/>
|
||||
/// muss der Aufrufer das ausdrücklich über <paramref name="confirmedByUser"/> bestätigt haben.
|
||||
/// </summary>
|
||||
public static async Task RestoreAsync(
|
||||
IRouterOsClient client,
|
||||
BackupFileInfo backup,
|
||||
string? currentBoardModel,
|
||||
string? currentArchitecture,
|
||||
string? currentSerialNumber,
|
||||
bool confirmedByUser,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
switch (CheckCompatibility(backup, currentBoardModel, currentArchitecture, currentSerialNumber))
|
||||
{
|
||||
case BackupCompatibility.Mismatch:
|
||||
throw new InvalidOperationException(
|
||||
$"Dieses Backup wurde auf einem anderen Gerät erstellt (Modell „{backup.Model}“) als dem aktuell verbundenen. Zum Schutz vor falsch zugeordneten Anschlüssen wird das Wiederherstellen blockiert.");
|
||||
case BackupCompatibility.SameModelDifferentDevice when !confirmedByUser:
|
||||
throw new InvalidOperationException(
|
||||
"Dieses Backup stammt von einem baugleichen, aber physisch anderen Gerät (andere Seriennummer). Bitte ausdrücklich bestätigen, dass du es auf eigene Gefahr wiederherstellen möchtest.");
|
||||
case BackupCompatibility.Unknown when !confirmedByUser:
|
||||
throw new InvalidOperationException(
|
||||
"Bei diesem Backup kann Gerät/Modell nicht automatisch geprüft werden. Bitte ausdrücklich bestätigen, dass es zum verbundenen Gerät passt.");
|
||||
}
|
||||
|
||||
await client.ExecuteAsync("/system/backup/load", new Dictionary<string, string> { ["name"] = backup.FileName }, cancellationToken);
|
||||
}
|
||||
|
||||
private static BackupFileInfo ToBackupFileInfo(IReadOnlyDictionary<string, string> row)
|
||||
{
|
||||
var fileName = row.GetValueOrDefault("name", "?");
|
||||
var creationTime = DateTimeOffset.TryParse(row.GetValueOrDefault("creation-time"), out var parsed) ? parsed : (DateTimeOffset?)null;
|
||||
|
||||
var full = ModelArchSerialPattern().Match(fileName);
|
||||
if (full.Success)
|
||||
{
|
||||
return new BackupFileInfo(fileName, full.Groups["model"].Value, full.Groups["arch"].Value, full.Groups["serial"].Value, creationTime);
|
||||
}
|
||||
|
||||
var archOnly = ModelArchPattern().Match(fileName);
|
||||
if (archOnly.Success)
|
||||
{
|
||||
return new BackupFileInfo(fileName, archOnly.Groups["model"].Value, archOnly.Groups["arch"].Value, null, creationTime);
|
||||
}
|
||||
|
||||
var modelOnly = ModelOnlyPattern().Match(fileName);
|
||||
if (modelOnly.Success)
|
||||
{
|
||||
return new BackupFileInfo(fileName, modelOnly.Groups["model"].Value, null, null, creationTime);
|
||||
}
|
||||
|
||||
return new BackupFileInfo(fileName, null, null, null, creationTime);
|
||||
}
|
||||
|
||||
// "_" trennt die Felder eindeutig, da SanitizeForFileName dieses Zeichen nie erzeugt (nur
|
||||
// Buchstaben/Ziffern bleiben erhalten, alles andere wird zu "-"). Reihenfolge beim Parsen wichtig:
|
||||
// erst das spezifischste (3 Felder), sonst würde z. B. "modell_architektur" fälschlich als
|
||||
// "modell_architektur-als-serial" o. Ä. interpretiert.
|
||||
[GeneratedRegex(@"^mikrotik-installer-(?<model>.+)_(?<arch>[^_]+)_(?<serial>[^_]+)-\d{8}-\d{6}\.backup$", RegexOptions.IgnoreCase)]
|
||||
private static partial Regex ModelArchSerialPattern();
|
||||
|
||||
[GeneratedRegex(@"^mikrotik-installer-(?<model>.+)_(?<arch>[^_]+)-\d{8}-\d{6}\.backup$", RegexOptions.IgnoreCase)]
|
||||
private static partial Regex ModelArchPattern();
|
||||
|
||||
// Ältere Backups (vor Einführung von Architektur/Seriennummer im Dateinamen): nur Modell.
|
||||
[GeneratedRegex(@"^mikrotik-installer-(?<model>.+)-\d{8}-\d{6}\.backup$", RegexOptions.IgnoreCase)]
|
||||
private static partial Regex ModelOnlyPattern();
|
||||
}
|
||||
@@ -8,9 +8,38 @@ namespace MikrotikInstaller.Core.Backup;
|
||||
/// </summary>
|
||||
public static class RouterBackupService
|
||||
{
|
||||
public static async Task<string> CreateBackupAsync(IRouterOsClient client, CancellationToken cancellationToken = default)
|
||||
/// <summary>
|
||||
/// Bettet, wenn bekannt, Gerätemodell, Architektur und Seriennummer sanitisiert in den Dateinamen
|
||||
/// ein — das einzige Mittel, mit dem <see cref="BackupRestoreService"/> später (ohne Dateiinhalt
|
||||
/// lesen zu können, siehe dort) vor einem Restore prüfen kann, ob ein Backup zum verbundenen Gerät
|
||||
/// passt. Jedes Feld wird per "_" vom vorherigen getrennt — ein Zeichen, das
|
||||
/// <see cref="SanitizeForFileName"/> nie erzeugt, die Trennung bleibt also beim Zurücklesen
|
||||
/// eindeutig. Die Felder bauen hierarchisch aufeinander auf (Architektur nur mit Modell, Seriennummer
|
||||
/// nur mit Architektur) — ist ein Feld nicht bekannt, entfällt es und alles danach ebenfalls; ist
|
||||
/// nicht einmal das Modell bekannt, bleibt der Name wie zuvor rein zeitstempelbasiert.
|
||||
/// </summary>
|
||||
public static async Task<string> CreateBackupAsync(
|
||||
IRouterOsClient client,
|
||||
string? boardModel = null,
|
||||
string? architecture = null,
|
||||
string? serialNumber = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var backupName = $"mikrotik-installer-{DateTime.Now:yyyyMMdd-HHmmss}";
|
||||
var identitySuffix = string.Empty;
|
||||
if (boardModel is not null)
|
||||
{
|
||||
identitySuffix = $"-{SanitizeForFileName(boardModel)}";
|
||||
if (architecture is not null)
|
||||
{
|
||||
identitySuffix += $"_{SanitizeForFileName(architecture)}";
|
||||
if (serialNumber is not null)
|
||||
{
|
||||
identitySuffix += $"_{SanitizeForFileName(serialNumber)}";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var backupName = $"mikrotik-installer{identitySuffix}-{DateTime.Now:yyyyMMdd-HHmmss}";
|
||||
|
||||
await client.ExecuteAsync("/system/backup/save", new Dictionary<string, string>
|
||||
{
|
||||
@@ -19,4 +48,8 @@ public static class RouterBackupService
|
||||
|
||||
return backupName;
|
||||
}
|
||||
|
||||
/// <summary>Ersetzt alles außer Buchstaben/Ziffern durch "-", damit ein Modellname sicher Teil eines RouterOS-Dateinamens werden kann.</summary>
|
||||
public static string SanitizeForFileName(string value) =>
|
||||
new(value.Select(c => char.IsLetterOrDigit(c) ? c : '-').ToArray());
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
using MikrotikInstaller.Core.Connectivity;
|
||||
|
||||
namespace MikrotikInstaller.Core.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Verhindert doppelte Firewall-/NAT-Regeln, wenn derselbe Wizard-Lauf erneut gegen denselben
|
||||
/// Router angewendet wird (z. B. nach einem Abbruch oder um etwas zu korrigieren) — anders als
|
||||
/// z. B. eine Bridge (deren Anlegen bei Namenskollision fehlschlägt) haben Firewall-Filter- und
|
||||
/// NAT-Regeln kein eindeutiges Identitätsfeld, ein erneuter Lauf würde sie sonst stillschweigend
|
||||
/// duplizieren.
|
||||
///
|
||||
/// Holt den Ist-Zustand je Menüpfad nur einmal pro <see cref="DuplicateRuleGuard"/>-Instanz
|
||||
/// (nicht pro Regel) und vergleicht spätere Prüfungen gegen diese unveränderte Momentaufnahme —
|
||||
/// damit werden mehrere in diesem Lauf neu hinzugefügte, zufällig identische Regeln nicht
|
||||
/// fälschlich gegeneinander als "schon vorhanden" erkannt. Eine Instanz gilt für die
|
||||
/// <c>BuildActions</c>-Aktionen eines Configurators; siehe dessen Verwendung für den genauen Geltungsbereich.
|
||||
/// </summary>
|
||||
public sealed class DuplicateRuleGuard
|
||||
{
|
||||
private readonly Dictionary<string, Task<IReadOnlyList<IReadOnlyDictionary<string, string>>>> _snapshots = [];
|
||||
|
||||
/// <summary>
|
||||
/// Prüft, ob unter <paramref name="path"/> bereits eine Regel existiert, die alle in
|
||||
/// <paramref name="parameters"/> gesetzten Felder mit denselben Werten trägt (Teilmengen-Vergleich —
|
||||
/// zusätzliche, von uns nicht gesetzte Felder der bestehenden Regel wie <c>.id</c> oder Zähler
|
||||
/// spielen keine Rolle).
|
||||
/// </summary>
|
||||
public async Task<bool> ExistsAsync(
|
||||
IRouterOsClient client, string path, IReadOnlyDictionary<string, string> parameters, CancellationToken ct)
|
||||
{
|
||||
if (!_snapshots.TryGetValue(path, out var snapshotTask))
|
||||
{
|
||||
snapshotTask = client.GetAsync(path, ct);
|
||||
_snapshots[path] = snapshotTask;
|
||||
}
|
||||
|
||||
var snapshot = await snapshotTask;
|
||||
return snapshot.Any(row => parameters.All(kv => row.TryGetValue(kv.Key, out var value) && value == kv.Value));
|
||||
}
|
||||
}
|
||||
@@ -9,18 +9,27 @@ public static class FirewallConfigurator
|
||||
{
|
||||
public static IReadOnlyList<ConfigurationAction> BuildActions(FirewallSettings settings)
|
||||
{
|
||||
// Eine Instanz je BuildActions-Aufruf — hält den Ist-Zustand von /ip/firewall/filter bzw.
|
||||
// /ip/firewall/nat nur für die Dauer dieses Wizard-Laufs fest (siehe DuplicateRuleGuard),
|
||||
// damit ein erneutes Anwenden desselben Laufs keine doppelten Regeln anlegt.
|
||||
var guard = new DuplicateRuleGuard();
|
||||
|
||||
var actions = new List<ConfigurationAction>
|
||||
{
|
||||
InputRule(
|
||||
guard,
|
||||
"Bereits bestehende Verbindungen zum Router weiterhin erlauben",
|
||||
new Dictionary<string, string> { ["connection-state"] = "established,related,untracked", ["action"] = "accept" }),
|
||||
InputRule(
|
||||
guard,
|
||||
"Ungültige Verbindungen zum Router verwerfen",
|
||||
new Dictionary<string, string> { ["connection-state"] = "invalid", ["action"] = "drop" }),
|
||||
InputRule(
|
||||
guard,
|
||||
"Anpingen des Routers erlauben (zur Fehlersuche)",
|
||||
new Dictionary<string, string> { ["protocol"] = "icmp", ["action"] = "accept" }),
|
||||
InputRule(
|
||||
guard,
|
||||
$"Zugriff auf die Router-Einstellungen aus deinem Heimnetz („{settings.LanBridgeName}\") erlauben",
|
||||
new Dictionary<string, string> { ["in-interface"] = settings.LanBridgeName, ["action"] = "accept" }),
|
||||
};
|
||||
@@ -41,9 +50,11 @@ public static class FirewallConfigurator
|
||||
}
|
||||
|
||||
actions.Add(InputRule(
|
||||
guard,
|
||||
"Zugriff auf die Router-Einstellungen von gesperrten Adressen blockieren",
|
||||
new Dictionary<string, string> { ["src-address-list"] = blockListName, ["action"] = "drop" }));
|
||||
actions.Add(ForwardRule(
|
||||
guard,
|
||||
"Datenverkehr von gesperrten Adressen blockieren",
|
||||
new Dictionary<string, string> { ["src-address-list"] = blockListName, ["action"] = "drop" }));
|
||||
}
|
||||
@@ -89,24 +100,40 @@ public static class FirewallConfigurator
|
||||
|
||||
actions.Add(new ConfigurationAction(
|
||||
$"Port {externalPort}/{forward.Protocol.ToUpperInvariant()} von außen zu {forward.TargetAddress}:{internalPort} weiterleiten („{forward.Name}\")",
|
||||
(client, ct) => client.AddAsync("/ip/firewall/nat", natParameters, ct)));
|
||||
async (client, ct) =>
|
||||
{
|
||||
if (!await guard.ExistsAsync(client, "/ip/firewall/nat", natParameters, ct))
|
||||
{
|
||||
await client.AddAsync("/ip/firewall/nat", natParameters, ct);
|
||||
}
|
||||
}));
|
||||
|
||||
actions.Add(new ConfigurationAction(
|
||||
$"Weitergeleiteten Datenverkehr für „{forward.Name}\" durch die Firewall lassen",
|
||||
(client, ct) => client.AddAsync("/ip/firewall/filter", filterParameters, ct)));
|
||||
async (client, ct) =>
|
||||
{
|
||||
if (!await guard.ExistsAsync(client, "/ip/firewall/filter", filterParameters, ct))
|
||||
{
|
||||
await client.AddAsync("/ip/firewall/filter", filterParameters, ct);
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
actions.Add(ForwardRule(
|
||||
guard,
|
||||
"Bereits bestehende Verbindungen durch den Router weiterhin erlauben",
|
||||
new Dictionary<string, string> { ["connection-state"] = "established,related,untracked", ["action"] = "accept" }));
|
||||
actions.Add(ForwardRule(
|
||||
guard,
|
||||
"Ungültige weitergeleitete Verbindungen verwerfen",
|
||||
new Dictionary<string, string> { ["connection-state"] = "invalid", ["action"] = "drop" }));
|
||||
actions.Add(ForwardRule(
|
||||
guard,
|
||||
$"Unangeforderte neue Verbindungen aus dem Internet („{settings.WanInterfaceName}\") blockieren, sofern nicht ausdrücklich freigegeben",
|
||||
new Dictionary<string, string> { ["in-interface"] = settings.WanInterfaceName, ["connection-state"] = "new", ["action"] = "drop" }));
|
||||
|
||||
actions.Add(InputRule(
|
||||
guard,
|
||||
$"Zugriff auf die Router-Einstellungen aus dem Internet („{settings.WanInterfaceName}\") blockieren",
|
||||
new Dictionary<string, string> { ["in-interface"] = settings.WanInterfaceName, ["action"] = "drop" }));
|
||||
|
||||
@@ -130,15 +157,21 @@ public static class FirewallConfigurator
|
||||
return lines;
|
||||
}
|
||||
|
||||
private static ConfigurationAction InputRule(string description, Dictionary<string, string> parameters) =>
|
||||
ChainRule("input", description, parameters);
|
||||
private static ConfigurationAction InputRule(DuplicateRuleGuard guard, string description, Dictionary<string, string> parameters) =>
|
||||
ChainRule(guard, "input", description, parameters);
|
||||
|
||||
private static ConfigurationAction ForwardRule(string description, Dictionary<string, string> parameters) =>
|
||||
ChainRule("forward", description, parameters);
|
||||
private static ConfigurationAction ForwardRule(DuplicateRuleGuard guard, string description, Dictionary<string, string> parameters) =>
|
||||
ChainRule(guard, "forward", description, parameters);
|
||||
|
||||
private static ConfigurationAction ChainRule(string chain, string description, Dictionary<string, string> parameters)
|
||||
private static ConfigurationAction ChainRule(DuplicateRuleGuard guard, string chain, string description, Dictionary<string, string> parameters)
|
||||
{
|
||||
parameters["chain"] = chain;
|
||||
return new ConfigurationAction(description, (client, ct) => client.AddAsync("/ip/firewall/filter", parameters, ct));
|
||||
return new ConfigurationAction(description, async (client, ct) =>
|
||||
{
|
||||
if (!await guard.ExistsAsync(client, "/ip/firewall/filter", parameters, ct))
|
||||
{
|
||||
await client.AddAsync("/ip/firewall/filter", parameters, ct);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
|
||||
namespace MikrotikInstaller.Core.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Reine Format-Validierung für Netzwerk-Eingabefelder im Wizard (IP-Adresse, "Adresse/Präfix",
|
||||
/// Adressbereiche). Prüft nur das Format, nicht die Erreichbarkeit — das bleibt Sache des Geräts
|
||||
/// beim tatsächlichen Anwenden.
|
||||
/// </summary>
|
||||
public static class NetworkValidation
|
||||
{
|
||||
public static bool IsValidIPv4(string value) =>
|
||||
IPAddress.TryParse(value, out var address) && address.AddressFamily == AddressFamily.InterNetwork;
|
||||
|
||||
public static bool IsValidPrefixLength(int value) => value is >= 0 and <= 32;
|
||||
|
||||
/// <summary>Prüft "Adresse/Präfix", z. B. "192.168.88.1/24".</summary>
|
||||
public static bool IsValidCidr(string addressCidr)
|
||||
{
|
||||
var parts = addressCidr.Split('/');
|
||||
return parts.Length == 2
|
||||
&& IsValidIPv4(parts[0])
|
||||
&& int.TryParse(parts[1], out var prefix)
|
||||
&& IsValidPrefixLength(prefix);
|
||||
}
|
||||
|
||||
/// <summary>Einzelne IP-Adresse oder "Adresse/Präfix" — für Felder, die beides zulassen (z. B. NAT-Quelladresse).</summary>
|
||||
public static bool IsValidIPv4OrCidr(string value) => IsValidIPv4(value) || IsValidCidr(value);
|
||||
|
||||
/// <summary>Prüft, ob "from" nicht nach "to" liegt — für Adressbereiche wie einen DHCP-Pool.</summary>
|
||||
public static bool IsOrderedRange(string fromIp, string toIp)
|
||||
{
|
||||
if (!IPAddress.TryParse(fromIp, out var from) || !IPAddress.TryParse(toIp, out var to)
|
||||
|| from.AddressFamily != AddressFamily.InterNetwork || to.AddressFamily != AddressFamily.InterNetwork)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var fromBytes = from.GetAddressBytes();
|
||||
var toBytes = to.GetAddressBytes();
|
||||
for (var i = 0; i < fromBytes.Length; i++)
|
||||
{
|
||||
if (fromBytes[i] != toBytes[i])
|
||||
{
|
||||
return fromBytes[i] < toBytes[i];
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,10 @@ public static class VlanConfigurator
|
||||
{
|
||||
var actions = new List<ConfigurationAction>();
|
||||
var usesAccessPorts = false;
|
||||
// Eine Instanz für den ganzen Aufruf — hält den Ist-Zustand von /ip/firewall/filter nur für
|
||||
// die Dauer dieses Wizard-Laufs fest, damit erneutes Anwenden keine doppelten
|
||||
// Isolationsregeln anlegt (siehe DuplicateRuleGuard).
|
||||
var guard = new DuplicateRuleGuard();
|
||||
|
||||
foreach (var vlan in vlans)
|
||||
{
|
||||
@@ -73,25 +77,39 @@ public static class VlanConfigurator
|
||||
|
||||
if (vlan.IsolateFromLan)
|
||||
{
|
||||
var toLanParameters = new Dictionary<string, string>
|
||||
{
|
||||
["chain"] = "forward",
|
||||
["in-interface"] = interfaceName,
|
||||
["out-interface"] = parentBridgeName,
|
||||
["action"] = "drop",
|
||||
};
|
||||
actions.Add(new ConfigurationAction(
|
||||
$"VLAN „{vlan.Name}\" vom Heimnetz „{parentBridgeName}\" isolieren",
|
||||
(client, ct) => client.AddAsync("/ip/firewall/filter", new Dictionary<string, string>
|
||||
async (client, ct) =>
|
||||
{
|
||||
["chain"] = "forward",
|
||||
["in-interface"] = interfaceName,
|
||||
["out-interface"] = parentBridgeName,
|
||||
["action"] = "drop",
|
||||
}, ct)));
|
||||
if (!await guard.ExistsAsync(client, "/ip/firewall/filter", toLanParameters, ct))
|
||||
{
|
||||
await client.AddAsync("/ip/firewall/filter", toLanParameters, ct);
|
||||
}
|
||||
}));
|
||||
|
||||
var fromLanParameters = new Dictionary<string, string>
|
||||
{
|
||||
["chain"] = "forward",
|
||||
["in-interface"] = parentBridgeName,
|
||||
["out-interface"] = interfaceName,
|
||||
["action"] = "drop",
|
||||
};
|
||||
actions.Add(new ConfigurationAction(
|
||||
$"Heimnetz „{parentBridgeName}\" vor Zugriff aus VLAN „{vlan.Name}\" schützen",
|
||||
(client, ct) => client.AddAsync("/ip/firewall/filter", new Dictionary<string, string>
|
||||
async (client, ct) =>
|
||||
{
|
||||
["chain"] = "forward",
|
||||
["in-interface"] = parentBridgeName,
|
||||
["out-interface"] = interfaceName,
|
||||
["action"] = "drop",
|
||||
}, ct)));
|
||||
if (!await guard.ExistsAsync(client, "/ip/firewall/filter", fromLanParameters, ct))
|
||||
{
|
||||
await client.AddAsync("/ip/firewall/filter", fromLanParameters, ct);
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
if (vlan.AccessPorts is { Count: > 0 } accessPorts)
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace MikrotikInstaller.Core.Connectivity.Demo;
|
||||
|
||||
/// <summary>
|
||||
/// Ein simuliertes Testgerät für <see cref="DemoRouterOsClient"/> — eigener Host, damit sich z. B.
|
||||
/// "Bekannte Router" mit mehreren, klar unterscheidbaren Einträgen ausprobieren lässt, ohne ein
|
||||
/// echtes zweites Gerät zu brauchen. <paramref name="IsRouterBoard"/> false simuliert ein virtuelles
|
||||
/// Gerät ohne RouterBOARD (wie ein CHR, das trotzdem eine Seriennummer melden kann); true simuliert
|
||||
/// echte RouterBOARD-Hardware (meldet Modell zusätzlich über "/system/routerboard").
|
||||
/// </summary>
|
||||
public sealed record DemoDeviceProfile(string Host, string IdentityName, string BoardModel, string Architecture, bool IsRouterBoard, string? SerialNumber);
|
||||
@@ -1,24 +1,55 @@
|
||||
using MikrotikInstaller.Core.Backup;
|
||||
|
||||
namespace MikrotikInstaller.Core.Connectivity.Demo;
|
||||
|
||||
/// <summary>
|
||||
/// Simulierter RouterOS-Client für interne Tests des Assistenten, ganz ohne echtes Gerät.
|
||||
/// Wird ausschließlich aktiviert, wenn exakt die Test-Zugangsdaten verwendet werden
|
||||
/// (<see cref="Matches"/>) — es findet dabei keinerlei Netzwerkverbindung statt, alle Daten
|
||||
/// liegen nur im Arbeitsspeicher dieses Objekts. Nur in Debug-Builds erreichbar
|
||||
/// (siehe <see cref="RouterOsClientFactory"/>), damit dieser Testzugang nicht in der
|
||||
/// veröffentlichten Version landet.
|
||||
/// Wird ausschließlich aktiviert, wenn Benutzername/Passwort den Test-Zugangsdaten entsprechen UND
|
||||
/// der Host einem der <see cref="Profiles"/> entspricht (<see cref="Matches"/>) — es findet dabei
|
||||
/// keinerlei Netzwerkverbindung statt, alle Daten liegen nur im Arbeitsspeicher dieses Objekts. Nur
|
||||
/// in Debug-Builds erreichbar (siehe <see cref="RouterOsClientFactory"/>), damit dieser Testzugang
|
||||
/// nicht in der veröffentlichten Version landet.
|
||||
/// </summary>
|
||||
public sealed class DemoRouterOsClient : IRouterOsClient
|
||||
{
|
||||
public const string DemoHost = "127.127.127.127";
|
||||
public const string DemoUsername = "admin";
|
||||
public const string DemoPassword = "admin";
|
||||
|
||||
private readonly Dictionary<string, List<Dictionary<string, string>>> _data = BuildInitialData();
|
||||
/// <summary>Rückwärtskompatibel: der ursprüngliche, einzelne Testzugang.</summary>
|
||||
public const string DemoHost = "127.127.127.127";
|
||||
|
||||
/// <summary>
|
||||
/// Mehrere virtuelle Testgeräte, alle mit denselben Zugangsdaten — z. B. um "Bekannte Router"
|
||||
/// oder das Backup-Modell-/Architektur-/Seriennummer-Abgleich mit mehreren, klar unterscheidbaren
|
||||
/// Geräten auszuprobieren, ohne echte zweite/dritte Hardware zu brauchen.
|
||||
/// </summary>
|
||||
public static readonly IReadOnlyList<DemoDeviceProfile> Profiles =
|
||||
[
|
||||
new(DemoHost, "Test-Router", "CHR (Simulator)", "x86_64", IsRouterBoard: false, SerialNumber: "DEMO1234AB"),
|
||||
new("127.127.127.128", "Büro-Router", "hAP ac2", "arm", IsRouterBoard: true, SerialNumber: "HAP2AC2DEMO1"),
|
||||
new("127.127.127.129", "Keller-Switch", "CRS326-24G-2S+", "arm", IsRouterBoard: true, SerialNumber: "CRS326DEMO02"),
|
||||
];
|
||||
|
||||
private readonly Dictionary<string, List<Dictionary<string, string>>> _data;
|
||||
private int _nextId = 1;
|
||||
|
||||
public DemoRouterOsClient()
|
||||
: this(Profiles[0])
|
||||
{
|
||||
}
|
||||
|
||||
public DemoRouterOsClient(DemoDeviceProfile profile)
|
||||
{
|
||||
_data = BuildInitialData(profile);
|
||||
}
|
||||
|
||||
public static bool Matches(RouterOsCredentials credentials) =>
|
||||
credentials.Host == DemoHost && credentials.Username == DemoUsername && credentials.Password == DemoPassword;
|
||||
credentials.Username == DemoUsername && credentials.Password == DemoPassword
|
||||
&& Profiles.Any(p => p.Host == credentials.Host);
|
||||
|
||||
/// <summary>Baut den zum Host passenden simulierten Client — <see cref="Matches"/> muss vorher geprüft worden sein.</summary>
|
||||
public static DemoRouterOsClient Create(RouterOsCredentials credentials) =>
|
||||
new(Profiles.First(p => p.Host == credentials.Host));
|
||||
|
||||
public RouterOsProtocol Protocol => RouterOsProtocol.Rest;
|
||||
|
||||
@@ -72,18 +103,53 @@ public sealed class DemoRouterOsClient : IRouterOsClient
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task ExecuteAsync(string path, IReadOnlyDictionary<string, string>? parameters = null, CancellationToken cancellationToken = default) =>
|
||||
Task.CompletedTask;
|
||||
public Task ExecuteAsync(string path, IReadOnlyDictionary<string, string>? parameters = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Simuliert RouterOS "make-static": markiert die betroffene Lease als nicht mehr dynamisch.
|
||||
// Vereinfachung gegenüber echtem RouterOS, das dem Eintrag dabei eine neue ID zuweist — die ID
|
||||
// bleibt hier bewusst gleich, das ändert nichts am Verhalten für Aufrufer (LanScanService fragt
|
||||
// die Lease-Liste nach dem Aufruf ohnehin per MAC-Adresse erneut ab).
|
||||
if (Normalize(path) == "/ip/dhcp-server/lease/make-static"
|
||||
&& parameters is not null && parameters.TryGetValue(".id", out var id)
|
||||
&& _data.TryGetValue("/ip/dhcp-server/lease", out var leases))
|
||||
{
|
||||
var row = leases.FirstOrDefault(r => r.GetValueOrDefault(".id") == id);
|
||||
if (row is not null)
|
||||
{
|
||||
row["dynamic"] = "false";
|
||||
}
|
||||
}
|
||||
|
||||
// Simuliert RouterOS "/system/backup/save": legt die Datei tatsächlich im simulierten
|
||||
// Dateispeicher an (RouterOS hängt dabei die Endung ".backup" an), damit sich "Backup jetzt
|
||||
// erstellen" auch im Demo-Zugang sofort in der Liste wiederfindet.
|
||||
if (Normalize(path) == "/system/backup/save" && parameters is not null && parameters.TryGetValue("name", out var backupName))
|
||||
{
|
||||
if (!_data.TryGetValue("/file", out var files))
|
||||
{
|
||||
files = [];
|
||||
_data["/file"] = files;
|
||||
}
|
||||
|
||||
files.Add(new Dictionary<string, string>
|
||||
{
|
||||
["name"] = $"{backupName}.backup",
|
||||
["creation-time"] = DateTimeOffset.UtcNow.ToString("O"),
|
||||
});
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
|
||||
private static string Normalize(string path) => path.TrimEnd('/');
|
||||
|
||||
private static Dictionary<string, List<Dictionary<string, string>>> BuildInitialData() => new()
|
||||
private static Dictionary<string, List<Dictionary<string, string>>> BuildInitialData(DemoDeviceProfile profile) => new()
|
||||
{
|
||||
["/system/resource"] = [new() { ["board-name"] = "CHR (Simulator)", ["version"] = "7.15.2 (stable)", ["architecture-name"] = "x86_64" }],
|
||||
["/system/identity"] = [new() { ["name"] = "Test-Router" }],
|
||||
["/system/routerboard"] = [new() { ["routerboard"] = "no" }],
|
||||
["/system/resource"] = [new() { ["board-name"] = profile.BoardModel, ["version"] = "7.15.2 (stable)", ["architecture-name"] = profile.Architecture }],
|
||||
["/system/identity"] = [new() { ["name"] = profile.IdentityName }],
|
||||
["/system/routerboard"] = [BuildRouterboardRow(profile)],
|
||||
["/interface"] =
|
||||
[
|
||||
new() { [".id"] = "*1", ["name"] = "ether1", ["type"] = "ether", ["running"] = "true", ["disabled"] = "false" },
|
||||
@@ -142,5 +208,65 @@ public sealed class DemoRouterOsClient : IRouterOsClient
|
||||
[
|
||||
new() { ["chain"] = "forward", ["action"] = "accept", ["protocol"] = "udp", ["dst-address"] = "192.168.88.50" },
|
||||
],
|
||||
|
||||
// Beispieldaten für den LAN-Scanner: drei Geräte am selben Bestandsgerät, eines davon bereits
|
||||
// fest zugewiesen, eines nur per ARP sichtbar (kein DHCP-Lease, z. B. manuell konfigurierte IP).
|
||||
["/ip/dhcp-server/lease"] =
|
||||
[
|
||||
new() { [".id"] = "*10", ["address"] = "192.168.88.50", ["mac-address"] = "AA:BB:CC:00:00:01", ["host-name"] = "spielserver", ["dynamic"] = "false" },
|
||||
new() { [".id"] = "*11", ["address"] = "192.168.88.60", ["mac-address"] = "AA:BB:CC:00:00:02", ["host-name"] = "laptop-anna", ["dynamic"] = "true" },
|
||||
],
|
||||
["/ip/arp"] =
|
||||
[
|
||||
new() { ["address"] = "192.168.88.50", ["mac-address"] = "AA:BB:CC:00:00:01", ["interface"] = "bridge-lan" },
|
||||
new() { ["address"] = "192.168.88.60", ["mac-address"] = "AA:BB:CC:00:00:02", ["interface"] = "bridge-lan" },
|
||||
new() { ["address"] = "192.168.88.70", ["mac-address"] = "AA:BB:CC:00:00:03", ["interface"] = "bridge-lan" },
|
||||
],
|
||||
["/interface/bridge/host"] =
|
||||
[
|
||||
new() { ["mac-address"] = "AA:BB:CC:00:00:01", ["on-interface"] = "ether2", ["bridge"] = "bridge-lan", ["local"] = "false" },
|
||||
new() { ["mac-address"] = "AA:BB:CC:00:00:02", ["on-interface"] = "ether3", ["bridge"] = "bridge-lan", ["local"] = "false" },
|
||||
new() { ["mac-address"] = "AA:BB:CC:00:00:03", ["on-interface"] = "ether3", ["bridge"] = "bridge-lan", ["local"] = "false" },
|
||||
],
|
||||
|
||||
// Beispieldaten für "Backup verwalten", alle vier Kompatibilitäts-Stufen: das erste passt immer
|
||||
// exakt zum jeweiligen Profil (dynamisch aus dessen Modell/Architektur/Seriennummer gebaut),
|
||||
// eines stammt vom selben Modell+Architektur wie das CHR-Profil, aber einem anderen physischen
|
||||
// Gerät (andere Seriennummer — auf eigene Gefahr wiederherstellbar), eines von einem komplett
|
||||
// anderen Modell (älteres Format ohne Architektur/Seriennummer, testet gleichzeitig den
|
||||
// Modell-only-Fallback), eines hat gar keinen erkennbaren Namen (z. B. WinBox).
|
||||
["/file"] =
|
||||
[
|
||||
new() { ["name"] = $"{BuildBackupFileName(profile)}-20260910-120000.backup", ["creation-time"] = "2026-09-10T12:00:00Z" },
|
||||
new() { ["name"] = "mikrotik-installer-CHR--Simulator-_x86-64_ANDERESGERAET99-20260905-100000.backup", ["creation-time"] = "2026-09-05T10:00:00Z" },
|
||||
new() { ["name"] = "mikrotik-installer-RB750Gr3-20260901-090000.backup", ["creation-time"] = "2026-09-01T09:00:00Z" },
|
||||
new() { ["name"] = "winbox-backup.backup", ["creation-time"] = "2026-08-01T08:00:00Z" },
|
||||
],
|
||||
};
|
||||
|
||||
private static Dictionary<string, string> BuildRouterboardRow(DemoDeviceProfile profile)
|
||||
{
|
||||
var row = new Dictionary<string, string> { ["routerboard"] = profile.IsRouterBoard ? "yes" : "no" };
|
||||
|
||||
// Ohne RouterBOARD (z. B. CHR) liefert DeviceDiscoveryService das Modell stattdessen über
|
||||
// "/system/resource"/"board-name" — "model" hier weglassen, sonst würde das simuliert, was ein
|
||||
// echtes CHR-Gerät gerade nicht tut.
|
||||
if (profile.IsRouterBoard)
|
||||
{
|
||||
row["model"] = profile.BoardModel;
|
||||
}
|
||||
|
||||
if (profile.SerialNumber is not null)
|
||||
{
|
||||
row["serial-number"] = profile.SerialNumber;
|
||||
}
|
||||
|
||||
return row;
|
||||
}
|
||||
|
||||
private static string BuildBackupFileName(DemoDeviceProfile profile)
|
||||
{
|
||||
var name = $"mikrotik-installer-{RouterBackupService.SanitizeForFileName(profile.BoardModel)}_{RouterBackupService.SanitizeForFileName(profile.Architecture)}";
|
||||
return profile.SerialNumber is null ? name : $"{name}_{RouterBackupService.SanitizeForFileName(profile.SerialNumber)}";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ public static class RouterOsClientFactory
|
||||
// Endnutzer veröffentlichten Version landet.
|
||||
if (DemoRouterOsClient.Matches(credentials))
|
||||
{
|
||||
return new DemoRouterOsClient();
|
||||
return DemoRouterOsClient.Create(credentials);
|
||||
}
|
||||
#endif
|
||||
|
||||
|
||||
@@ -32,7 +32,8 @@ public static class DeviceDiscoveryService
|
||||
RouterOsVersion: resource.GetValueOrDefault("version", "unbekannt"),
|
||||
Architecture: resource.GetValueOrDefault("architecture-name", "unbekannt"),
|
||||
HasWireless: interfaces.Any(i => i.Kind == InterfaceKind.Wireless),
|
||||
Interfaces: interfaces);
|
||||
Interfaces: interfaces,
|
||||
SerialNumber: routerboard.GetValueOrDefault("serial-number"));
|
||||
}
|
||||
|
||||
private static InterfaceInfo ToInterfaceInfo(IReadOnlyDictionary<string, string> row)
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace MikrotikInstaller.Core.Devices.LanScan;
|
||||
|
||||
/// <summary>
|
||||
/// Ein per MAC-Adresse aus DHCP-Leases, ARP-Tabelle und Bridge-Host-Tabelle zusammengeführtes Gerät
|
||||
/// im lokalen Netzwerk. <see cref="LeaseId"/> ist nur gesetzt, wenn das Gerät einen DHCP-Lease-Eintrag
|
||||
/// hat — nur dann ist "Feste IP-Adresse zuweisen" möglich.
|
||||
/// </summary>
|
||||
public sealed record DiscoveredDevice(
|
||||
string MacAddress,
|
||||
string? IpAddress,
|
||||
string? HostName,
|
||||
string? PortInterfaceName,
|
||||
bool HasDhcpLease,
|
||||
bool IsStaticLease,
|
||||
string? LeaseId);
|
||||
@@ -0,0 +1,171 @@
|
||||
using MikrotikInstaller.Core.Connectivity;
|
||||
|
||||
namespace MikrotikInstaller.Core.Devices.LanScan;
|
||||
|
||||
/// <summary>
|
||||
/// Liest DHCP-Leases, ARP-Tabelle und Bridge-Host-Tabelle aus und führt sie pro Gerät (per
|
||||
/// MAC-Adresse) zu <see cref="DiscoveredDevice"/> zusammen, gruppiert über <see cref="DiscoveredDevice.PortInterfaceName"/>
|
||||
/// nach physischem Anschluss. Rein lesend bis auf die explizite "Feste IP zuweisen"-Aktion
|
||||
/// (<see cref="MakeStaticAsync"/>). Jeder Menüpfad wird einzeln abgesichert wie in
|
||||
/// <see cref="Analysis.NetworkAnalysisService"/>, damit ein auf diesem Gerät nicht unterstützter
|
||||
/// Pfad nicht den gesamten Scan abbricht.
|
||||
/// </summary>
|
||||
public static class LanScanService
|
||||
{
|
||||
public static async Task<IReadOnlyList<DiscoveredDevice>> ScanAsync(IRouterOsClient client, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var leases = await TryGetAsync(client, "/ip/dhcp-server/lease", cancellationToken);
|
||||
var arpEntries = await TryGetAsync(client, "/ip/arp", cancellationToken);
|
||||
var bridgeHosts = await TryGetAsync(client, "/interface/bridge/host", cancellationToken);
|
||||
|
||||
// Der physische Anschluss kommt bewusst aus der Bridge-Host-Tabelle ("on-interface"), nicht aus
|
||||
// ARP oder Lease: deren "interface"-Feld zeigt bei gebrückten Setups nur die Bridge selbst, nicht
|
||||
// den tatsächlichen Port. "local"-Einträge sind die eigenen MAC-Adressen des Routers und werden
|
||||
// ausgelassen, sonst tauchen Router-Schnittstellen selbst als "gefundenes Gerät" auf.
|
||||
var portByMac = bridgeHosts
|
||||
.Where(row => NormalizeMac(row.GetValueOrDefault("mac-address")) is not null && row.GetValueOrDefault("local") != "true")
|
||||
.GroupBy(row => NormalizeMac(row.GetValueOrDefault("mac-address"))!)
|
||||
.ToDictionary(g => g.Key, g => g.First().GetValueOrDefault("on-interface"));
|
||||
|
||||
var devicesByMac = new Dictionary<string, DiscoveredDeviceBuilder>();
|
||||
|
||||
foreach (var row in arpEntries)
|
||||
{
|
||||
var mac = NormalizeMac(row.GetValueOrDefault("mac-address"));
|
||||
if (mac is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var builder = GetOrAdd(devicesByMac, mac);
|
||||
builder.IpAddress ??= row.GetValueOrDefault("address");
|
||||
builder.PortInterfaceName ??= portByMac.GetValueOrDefault(mac) ?? row.GetValueOrDefault("interface");
|
||||
}
|
||||
|
||||
foreach (var row in leases)
|
||||
{
|
||||
var mac = NormalizeMac(row.GetValueOrDefault("mac-address"));
|
||||
if (mac is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var builder = GetOrAdd(devicesByMac, mac);
|
||||
builder.IpAddress = row.GetValueOrDefault("address") ?? builder.IpAddress;
|
||||
builder.HostName = row.GetValueOrDefault("host-name");
|
||||
builder.HasDhcpLease = true;
|
||||
builder.IsStaticLease = row.GetValueOrDefault("dynamic") == "false";
|
||||
builder.LeaseId = row.GetValueOrDefault(".id");
|
||||
builder.PortInterfaceName ??= portByMac.GetValueOrDefault(mac);
|
||||
}
|
||||
|
||||
// Geräte, die nur in der Bridge-Host-Tabelle stehen (kein ARP-Eintrag, keine Lease — z. B. ein
|
||||
// Gerät mit rein statisch vergebener IP ohne jede DHCP-Nutzung), trotzdem aufnehmen, sonst fehlt
|
||||
// ihnen jeder Anschluss-Bezug in der Geräte-Übersicht.
|
||||
foreach (var (mac, port) in portByMac)
|
||||
{
|
||||
var builder = GetOrAdd(devicesByMac, mac);
|
||||
builder.PortInterfaceName ??= port;
|
||||
}
|
||||
|
||||
return devicesByMac.Values
|
||||
.Select(b => b.Build())
|
||||
.OrderBy(d => d.PortInterfaceName ?? "")
|
||||
.ThenBy(d => d.IpAddress)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wandelt die dynamische DHCP-Lease eines Geräts in eine feste um ("RouterOS Make Static").
|
||||
/// Fragt die Lease-Liste danach erneut ab, weil RouterOS dem Eintrag dabei eine neue interne ID
|
||||
/// zuweisen kann — die zurückgegebene <see cref="DiscoveredDevice"/> trägt bereits die aktuelle ID.
|
||||
/// </summary>
|
||||
public static async Task<DiscoveredDevice> MakeStaticAsync(IRouterOsClient client, DiscoveredDevice device, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (device.LeaseId is null)
|
||||
{
|
||||
throw new InvalidOperationException("Dieses Gerät hat keinen DHCP-Lease-Eintrag.");
|
||||
}
|
||||
|
||||
await client.ExecuteAsync(
|
||||
"/ip/dhcp-server/lease/make-static",
|
||||
new Dictionary<string, string> { [".id"] = device.LeaseId },
|
||||
cancellationToken);
|
||||
|
||||
return await ReloadLeaseAsync(client, device, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// "Rückgängig" heißt hier: die feste Lease entfernen. RouterOS kennt keinen direkten
|
||||
/// "wieder dynamisch machen"-Befehl — nach dem Entfernen vergibt der DHCP-Server dem Gerät bei
|
||||
/// dessen nächster Anfrage automatisch wieder eine dynamische Adresse.
|
||||
/// </summary>
|
||||
public static async Task<DiscoveredDevice> UndoStaticAsync(IRouterOsClient client, DiscoveredDevice device, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (device.LeaseId is null)
|
||||
{
|
||||
throw new InvalidOperationException("Dieses Gerät hat keinen DHCP-Lease-Eintrag.");
|
||||
}
|
||||
|
||||
await client.RemoveAsync("/ip/dhcp-server/lease", device.LeaseId, cancellationToken);
|
||||
return device with { HasDhcpLease = false, IsStaticLease = false, LeaseId = null };
|
||||
}
|
||||
|
||||
private static async Task<DiscoveredDevice> ReloadLeaseAsync(IRouterOsClient client, DiscoveredDevice device, CancellationToken cancellationToken)
|
||||
{
|
||||
var leases = await TryGetAsync(client, "/ip/dhcp-server/lease", cancellationToken);
|
||||
var row = leases.FirstOrDefault(r => NormalizeMac(r.GetValueOrDefault("mac-address")) == device.MacAddress);
|
||||
if (row is null)
|
||||
{
|
||||
return device with { IsStaticLease = true };
|
||||
}
|
||||
|
||||
return device with
|
||||
{
|
||||
IpAddress = row.GetValueOrDefault("address") ?? device.IpAddress,
|
||||
IsStaticLease = row.GetValueOrDefault("dynamic") == "false",
|
||||
LeaseId = row.GetValueOrDefault(".id"),
|
||||
};
|
||||
}
|
||||
|
||||
private static DiscoveredDeviceBuilder GetOrAdd(Dictionary<string, DiscoveredDeviceBuilder> devicesByMac, string mac)
|
||||
{
|
||||
if (!devicesByMac.TryGetValue(mac, out var builder))
|
||||
{
|
||||
builder = new DiscoveredDeviceBuilder(mac);
|
||||
devicesByMac[mac] = builder;
|
||||
}
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
private static async Task<IReadOnlyList<IReadOnlyDictionary<string, string>>> TryGetAsync(
|
||||
IRouterOsClient client, string path, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await client.GetAsync(path, cancellationToken);
|
||||
}
|
||||
catch (RouterOsException)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private static string? NormalizeMac(string? mac) =>
|
||||
string.IsNullOrWhiteSpace(mac) ? null : mac.Trim().ToUpperInvariant();
|
||||
|
||||
private sealed class DiscoveredDeviceBuilder(string macAddress)
|
||||
{
|
||||
public string MacAddress { get; } = macAddress;
|
||||
public string? IpAddress { get; set; }
|
||||
public string? HostName { get; set; }
|
||||
public string? PortInterfaceName { get; set; }
|
||||
public bool HasDhcpLease { get; set; }
|
||||
public bool IsStaticLease { get; set; }
|
||||
public string? LeaseId { get; set; }
|
||||
|
||||
public DiscoveredDevice Build() =>
|
||||
new(MacAddress, IpAddress, HostName, PortInterfaceName, HasDhcpLease, IsStaticLease, LeaseId);
|
||||
}
|
||||
}
|
||||
@@ -9,4 +9,7 @@ public sealed record RouterDevice(
|
||||
string RouterOsVersion,
|
||||
string Architecture,
|
||||
bool HasWireless,
|
||||
IReadOnlyList<InterfaceInfo> Interfaces);
|
||||
IReadOnlyList<InterfaceInfo> Interfaces,
|
||||
// Nur bei echten RouterBOARD-Geräten vorhanden, nicht bei CHR/x86 — unterscheidet zwei physische
|
||||
// Geräte, die zufällig dieselbe Adresse und denselben Benutzernamen teilen (siehe SavedRoutersStore).
|
||||
string? SerialNumber = null);
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace MikrotikInstaller.Core.Devices;
|
||||
|
||||
/// <summary>
|
||||
/// Ein Eintrag in "Bekannte Router" (Verbindungs-Schritt) — Host/Benutzername für einen bereits
|
||||
/// einmal erfolgreich verbundenen Router, mit vom Nutzer editierbarem Namen und Standort.
|
||||
/// Das Passwort selbst liegt nicht hier, sondern im Windows-Anmeldeinformationsspeicher
|
||||
/// (siehe <c>WindowsCredentialStore</c> in der App), referenziert über <see cref="Id"/>.
|
||||
/// </summary>
|
||||
public sealed record SavedRouter(
|
||||
string Id,
|
||||
string Host,
|
||||
string Username,
|
||||
string Name,
|
||||
string? Location,
|
||||
// Nur bei echten RouterBOARD-Geräten vorhanden — unterscheidet zwei physische Geräte, die
|
||||
// zufällig Host und Benutzername teilen (z. B. eine wiederverwendete feste IP-Adresse).
|
||||
string? SerialNumber,
|
||||
DateTimeOffset? LastConnectedAt);
|
||||
@@ -0,0 +1,136 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace MikrotikInstaller.Core.Devices;
|
||||
|
||||
/// <summary>
|
||||
/// Persistiert die Liste "Bekannte Router" als JSON-Datei im lokalen Profil des Nutzers — bewusst
|
||||
/// ohne Router-Zugriff dafür nötig, rein lokale Daten (analog zu <c>UserDefaults</c> bei der
|
||||
/// macOS-Schwester-App dieses Projekts). Enthält nie Passwörter — die liegen im
|
||||
/// Windows-Anmeldeinformationsspeicher (siehe <c>WindowsCredentialStore</c> in der App).
|
||||
/// </summary>
|
||||
public sealed class SavedRoutersStore
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true };
|
||||
|
||||
private readonly string _filePath;
|
||||
|
||||
/// <param name="filePath">Nur zum Überschreiben in Tests — Standard: %AppData%\MikroTik-Installer\saved-routers.json.</param>
|
||||
public SavedRoutersStore(string? filePath = null)
|
||||
{
|
||||
_filePath = filePath ?? Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||
"MikroTik-Installer",
|
||||
"saved-routers.json");
|
||||
}
|
||||
|
||||
public IReadOnlyList<SavedRouter> Load()
|
||||
{
|
||||
if (!File.Exists(_filePath))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(_filePath);
|
||||
return JsonSerializer.Deserialize<List<SavedRouter>>(json, JsonOptions) ?? [];
|
||||
}
|
||||
catch (Exception ex) when (ex is JsonException or IOException)
|
||||
{
|
||||
// Beschädigte oder unlesbare Datei blockiert den Assistenten nicht — "Bekannte Router"
|
||||
// ist eine reine Komfortfunktion, kein kritischer Zustand.
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Trägt eine erfolgreiche Verbindung ein: aktualisiert einen bereits bekannten Router (gleicher
|
||||
/// Host + Benutzername + — falls beiden Seiten bekannt — gleiche Seriennummer) oder legt einen
|
||||
/// neuen Eintrag mit <paramref name="suggestedName"/> als Namensvorschlag an. Gibt den
|
||||
/// (ggf. aktualisierten) Eintrag zurück.
|
||||
/// </summary>
|
||||
public SavedRouter RecordConnection(string host, string username, string suggestedName, string? serialNumber, DateTimeOffset connectedAt)
|
||||
{
|
||||
var routers = Load().ToList();
|
||||
var existingIndex = routers.FindIndex(r => Matches(r, host, username, serialNumber));
|
||||
|
||||
if (existingIndex >= 0)
|
||||
{
|
||||
var updated = routers[existingIndex] with
|
||||
{
|
||||
LastConnectedAt = connectedAt,
|
||||
// Seriennummer im Nachhinein lernen, falls der Eintrag noch keine hatte (z. B. vor
|
||||
// Einführung dieses Felds gespeichert) — kein Datenverlust, kein neuer Eintrag nötig.
|
||||
SerialNumber = routers[existingIndex].SerialNumber ?? serialNumber,
|
||||
};
|
||||
routers[existingIndex] = updated;
|
||||
Save(routers);
|
||||
return updated;
|
||||
}
|
||||
|
||||
var entry = new SavedRouter(Guid.NewGuid().ToString(), host, username, suggestedName, Location: null, serialNumber, connectedAt);
|
||||
routers.Add(entry);
|
||||
Save(routers);
|
||||
return entry;
|
||||
}
|
||||
|
||||
/// <summary>Speichert Änderungen an Name/Standort eines bestehenden Eintrags.</summary>
|
||||
public void Update(SavedRouter router)
|
||||
{
|
||||
var routers = Load().ToList();
|
||||
var index = routers.FindIndex(r => r.Id == router.Id);
|
||||
if (index < 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
routers[index] = router;
|
||||
Save(routers);
|
||||
}
|
||||
|
||||
public void Remove(string id)
|
||||
{
|
||||
var routers = Load().ToList();
|
||||
if (routers.RemoveAll(r => r.Id == id) > 0)
|
||||
{
|
||||
Save(routers);
|
||||
}
|
||||
}
|
||||
|
||||
private void Save(IReadOnlyList<SavedRouter> routers)
|
||||
{
|
||||
try
|
||||
{
|
||||
var directory = Path.GetDirectoryName(_filePath);
|
||||
if (!string.IsNullOrEmpty(directory))
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
}
|
||||
|
||||
File.WriteAllText(_filePath, JsonSerializer.Serialize(routers, JsonOptions));
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
// Beschädigt den Assistenten nicht — siehe Load().
|
||||
}
|
||||
}
|
||||
|
||||
private static bool Matches(SavedRouter existing, string host, string username, string? serialNumber)
|
||||
{
|
||||
if (!string.Equals(existing.Host, host, StringComparison.OrdinalIgnoreCase)
|
||||
|| !string.Equals(existing.Username, username, StringComparison.Ordinal))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Kennen beide Seiten eine Seriennummer, muss sie übereinstimmen (unterscheidet zwei
|
||||
// physische Geräte mit gleichem Host+Benutzername). Kennt eine Seite keine, im Zweifel
|
||||
// dasselbe Gerät annehmen, statt unnötig einen zweiten Eintrag anzulegen.
|
||||
if (existing.SerialNumber is not null && serialNumber is not null)
|
||||
{
|
||||
return existing.SerialNumber == serialNumber;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user