16 Commits
Author SHA1 Message Date
adminandClaude Sonnet 5 09cd5c31fc Session-Handoff: LAN-Scanner, Backup-Verwaltung, Sidebar-Umbau
Build / build (push) Waiting to run
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 21:48:00 +02:00
adminandClaude Sonnet 5 12ce7bea84 Bekannte Router nicht mehr im Hauptfenster doppeln, nur noch in der Sidebar
Build / build (push) Waiting to run
Nutzer-Feedback: Seit dem Sidebar-Umbau gab es "Bekannte Router" doppelt —
als vollständige Bearbeitungsliste im Verbindungs-Schritt (Hauptfenster) und
als Kurzauswahl in der Sidebar. Der Verbindungs-Schritt zeigt jetzt nur noch
das Formular für ein neues, unbekanntes Gerät.

Rückfrage zur Konsequenz: Bearbeiten (Name/Standort) und Entfernen gab es
bisher nur in der jetzt entfernten Liste. Auf Nutzerwunsch zieht "Entfernen"
kompakt in die Sidebar (kleines Papierkorb-Symbol neben jedem Eintrag);
Umbenennen entfällt bewusst — der Name kommt weiterhin automatisch vom
Gerätemodell.

Aufgeräumt: UseSavedRouterCommand (kein eigenes Kommando mehr nötig, nur noch
intern von ConnectToSavedRouterAsync genutzt) und SavedRouterEntry.ToSavedRouter
(war ausschließlich für die jetzt entfernte Bearbeiten-Persistenz da) entfernt.

Core-Suite unverändert grün (138 Tests). Nicht-destruktiver Smoke-Test
bestätigt, dass die Sidebar weiterhin alle bekannten Router korrekt lädt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 21:37:53 +02:00
adminandClaude Sonnet 5 e3e3c71452 Demo-Zugang: zwei weitere virtuelle Testgeräte für die manuelle UI-Prüfung
Build / build (push) Waiting to run
Bisher gab es nur ein einzelnes simuliertes Testgerät (127.127.127.127,
"CHR (Simulator)"). Um Funktionen wie "Bekannte Router" oder den Sidebar-
Umbau mit mehreren, klar unterscheidbaren Einträgen ausprobieren zu können,
ohne echte zweite/dritte Hardware zu brauchen, unterstützt DemoRouterOsClient
jetzt mehrere Profile (DemoDeviceProfile): zusätzlich 127.127.127.128
("Büro-Router", hAP ac2, RouterBOARD) und 127.127.127.129 ("Keller-Switch",
CRS326-24G-2S+, RouterBOARD) — alle mit denselben Zugangsdaten (admin/admin).

Jedes Profil bekommt dynamisch eine zu sich selbst passende Demo-Backup-Datei
in "Backup verwalten" (statt nur das ursprüngliche CHR-Profil). Die simulierte
"/system/routerboard"-Antwort unterscheidet jetzt zwischen "kein RouterBOARD"
(wie beim ursprünglichen CHR-Profil, unverändertes Verhalten) und "echtes
RouterBOARD" (neue Profile, meldet Modell zusätzlich über routerboard.model).

8 neue Unit-Tests, bestehende 132 unverändert grün. Die beiden neuen Router
wurden testweise bereits verbunden und erscheinen jetzt unter "Bekannte
Router" (lokale Datei, unabhängig vom Git-Repository).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 21:31:50 +02:00
adminandClaude Sonnet 5 3010688b12 Sidebar: dauerhafte Verbindungs-Karte mit Kurzauswahl bekannter Router (#36)
Build / build (push) Waiting to run
Nutzer-Wunsch "Optik-Renew": Sidebar zeigt jetzt unabhängig vom aktuellen
Schritt eine Verbindungs-Karte statt nur Logo + Schritt-Liste.

- Nicht verbunden: kompakte Liste bekannter Router (Name + Host/Benutzername),
  ein Klick verbindet direkt und springt bei Erfolg automatisch weiter
  (ConnectionStepViewModel.ConnectToSavedRouterCommand kombiniert
  UseSavedRouter+TestConnectionAsync; MainViewModel.QuickConnectCommand ruft
  das auf und geht bei Erfolg per GoNextCommand weiter).
- Verbunden: grüner Punkt + Gerätename/-modell (MainViewModel.
  ConnectedDeviceLabel — fällt auf den Host zurück, bis die Geräte-Erkennung
  gelaufen ist) + "Wechseln"-Button (trennt Verbindung, setzt session.Device
  zurück, springt zum Verbindungs-Schritt).

Manuelle Formular-Eingabe für neue Geräte bleibt bewusst auf der
Verbindungs-Schritt-Seite — passt nicht kompakt in die schmale Sidebar.

Als eigener Entwurf umgesetzt (kein Referenz-Screenshot vorgegeben), im
bestehenden WPF-UI/Fluent-Stil. WPF-Smoke-Test für ConnectedDeviceLabel-
Übergänge und den Wechseln-Ablauf; QuickConnect selbst per Code-Review
verifiziert (dünner Wrapper um bereits getestete Pfade, im Scratch-Harness
nicht end-to-end testbar wegen internem WindowsCredentialStore-Zugriffs).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 21:15:53 +02:00
adminandClaude Sonnet 5 cc949b54df Sidebar: Schritt-Übersicht scrollbar machen (#35)
Build / build (push) Waiting to run
Nutzer-Feedback: Der letzte Schritt "RouterOS-Update" war in der Sidebar nicht
sichtbar. Ursache: die Schritt-Liste steckte in einem einfachen StackPanel
ohne ScrollViewer — mit inzwischen 13 Schritten (nach Ergänzung von "Was
möchtest du tun?" und "Backup verwalten") überschritten die unteren Einträge
bei kleineren Fensterhöhen den sichtbaren Bereich, ohne dass sich dorthin
scrollen ließ.

Logo-Kopfzeile bleibt fix (Grid-Row Auto), darunter ScrollViewer mit
VerticalScrollBarVisibility=Auto um die Schritt-Liste (Grid-Row *).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 21:06:19 +02:00
adminandClaude Sonnet 5 0728817fbc Backup: vierstufiger Kompatibilitäts-Abgleich + eigenständiger Bereich (#33, #34)
Build / build (push) Waiting to run
#33 — Architektur als dritte Prüfstufe: RouterBackupService bettet zusätzlich
RouterDevice.Architecture in den Backup-Dateinamen ein (Modell_Architektur_
Seriennummer). BackupRestoreService.CheckCompatibility liefert jetzt vier
Stufen statt zwei: Match, SameModelDifferentDevice (gleiches Modell+Architektur,
bekannt andere Seriennummer — gelb markiert, nur mit ausdrücklicher "auf eigene
Gefahr"-Bestätigung wiederherstellbar), Mismatch (anderes Modell/andere
Architektur — weiterhin hart blockiert) und Unknown. Ältere Backups ohne
eingebettete Architektur/Seriennummer fallen weiterhin auf den Modell-Abgleich
zurück.

#34 — Backup-Bereich aus dem Interview gelöst: neuer Schritt "Was möchtest du
tun?" (AreaSelectionStepViewModel) direkt nach der Verbindung, setzt
WizardSession.SelectedArea (AppArea-Enum). Bei "Backup verwalten" überspringen
alle Konfigurations-/Analyse-Schritte per ShouldSkip, direkt zum neuen
BackupManagementStepViewModel — liest das Gerät eigenständig aus (unabhängig
von DeviceOverviewStepViewModel) und bündelt Auflisten/Erstellen/
Wiederherstellen. Die alte "Backup wiederherstellen"-Option in der Moduswahl
sowie WizardSession.IsRestoreBackupMode wurden entfernt (kein Parallel-
Mechanismus zur neuen Navigation) — SummaryStepViewModel ist wieder rein für
die geplanten Assistent-Änderungen zuständig. Der ShouldSkip-basierte Ansatz
deckt sich bewusst mit dem für M-Experte (#21) vorgesehenen Bedarf an
Navigation außerhalb der linearen Schritt-Kette.

17 neue/angepasste Unit-Tests (Core: 132 gesamt), WPF-Smoke-Test über die
komplette MainViewModel-Navigation (Connect → Bereich wählen → Backup
verwalten direkt erreicht, alle vier Kompatibilitäts-Stufen, Rückwärts-
Navigation) sowie Regressionscheck des normalen Assistenten-Pfads.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 20:59:16 +02:00
adminandClaude Sonnet 5 036c02c78f Backup-Wiederherstellen: eigenständigen "Neues Backup erstellen"-Button ergänzen (#27)
Build / build (push) Waiting to run
Nutzer-Feedback: bisher gab es nur automatische Backups direkt vor dem Anwenden
bzw. vor einem Restore, aber keine Möglichkeit, unabhängig davon auf Knopfdruck
eine Sicherung anzulegen (z. B. vor eigenen Änderungen direkt in WinBox).

Neuer Button im "Backup wiederherstellen"-Bildschirm ruft denselben
RouterBackupService wie die automatischen Backups auf (inkl. Modell-/
Seriennummer-Einbettung) und lädt die Backup-Liste danach neu, damit die neue
Datei sofort erscheint. DemoRouterOsClient simuliert "/system/backup/save"
jetzt auch tatsächlich als neuen Dateieintrag, damit sich das im Demo-Zugang
end-to-end durchklicken lässt.

Neuer Unit-Test für den Demo-Client, WPF-Smoke-Test für den kompletten
Erstellen-und-sofort-in-der-Liste-sehen-Ablauf.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 20:08:31 +02:00
adminandClaude Sonnet 5 915c2fb91a Backup-Restore: Seriennummer als vorrangige Geräte-Prüfung vor Modell (#27)
Build / build (push) Waiting to run
Nutzer-Feedback: reiner Modell-Abgleich ließe ein baugleiches, aber physisch
anderes Gerät (gleiches Modell, andere Seriennummer) fälschlich als passend
durch. RouterBackupService bettet die Seriennummer jetzt zusätzlich zum Modell
in den Dateinamen ein (getrennt per "_", ein Zeichen, das die Sanitisierung nie
erzeugt und die Trennung beim Zurücklesen eindeutig hält).

BackupRestoreService.CheckCompatibility bündelt die Prüf-Priorität (Seriennummer
vor Modell vor "unbekannt") an einer Stelle, die sowohl RestoreAsync zum
Blockieren als auch die neue BackupSelectionItemViewModel zur Anzeige nutzen —
UI und tatsächliche Durchsetzung können so nicht auseinanderlaufen. Modell bleibt
Fallback für ältere Backups ohne eingebettete Seriennummer oder Geräte, die keine
liefern (z. B. virtuelle Testgeräte).

10 neue Unit-Tests, WPF-Smoke-Test für alle vier Kompatibilitäts-Zustände
(exakter Treffer, gleiches Modell/andere Seriennummer, anderes Modell, unbekannt)
sowie Regressionscheck der gesamten Core-Suite (125 Tests).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 20:02:57 +02:00
adminandClaude Sonnet 5 96fe0acd69 Backup-Wiederherstellung mit Modell-Abgleich (M-Backup-Wartung, #27)
Build / build (push) Waiting to run
Neue Option "Backup wiederherstellen" im Moduswahl-Schritt (WizardMode-Enum ersetzt
das bisherige reine IsExpertMode-bool-Radiobutton-Paar; EnumToBooleanConverter für
mehr als zwei sich ausschließende Optionen). Überspringt WAN/LAN/VLAN/WLAN/Firewall
per ShouldSkip und zeigt in der Zusammenfassung die auf dem Gerät vorhandenen
.backup-Dateien zur Auswahl statt der geplanten Änderungen.

RouterOS prüft beim Zurückspielen selbst nicht, ob ein Backup zum verbundenen Gerät
passt. Da .backup-Dateiinhalte ohne SFTP (v1.3, noch offen) nicht lesbar sind, macht
RouterBackupService das Gerätemodell stattdessen Teil des Dateinamens (eigene
Backups); BackupRestoreService parst es beim Auflisten zurück und blockiert bei
erkanntem Mismatch ohne Override-Möglichkeit. Unbekannte Backups (z. B. aus WinBox)
brauchen eine explizite Bestätigung per Checkbox. Vor dem Zurückspielen wird wie
beim normalen Anwenden zusätzlich eine Sicherung des aktuellen Zustands erstellt.

11 neue Unit-Tests, WPF-Smoke-Test für alle drei Modell-Zustände sowie
Regressionscheck des normalen (Nicht-Restore-)Modus.

Folge-Issue #32 angelegt: exaktes Verhalten von "/system/backup/load" (Dateiname-
Format, Reboot/Verbindungsabbruch, Passwort-Parameter) gegen echtes Gerät verifizieren.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 19:55:03 +02:00
adminandClaude Sonnet 5 2c2fae73eb LAN-Scanner: Rückgängig-Zustand nach Entfernen der festen Lease erklären
Build / build (push) Waiting to run
Nutzer-Feedback: Nach "Feste IP zuweisen" → "Rückgängig machen" wirkte die Zeile wie
kaputt (Button einfach ausgegraut, Status nur "Kein DHCP-Lease"). Technischer Hintergrund
bestätigt: RouterOS kennt kein "wieder dynamisch machen", UndoStaticAsync entfernt den
Lease-Eintrag komplett — das Gerät merkt das aber nicht sofort, sondern hält seine IP
client-seitig, bis es sich neu meldet (Renewal-Timer oder Trennen/Neuverbinden). Die Zeile
zeigt das jetzt explizit ("Feste IP entfernt" + erklärender Hinweistext/Tooltip statt
kommentarlosem Ausgrauen). Zusätzlich: immer sichtbarer "Aktualisieren"-Button, um nach
einem Trennen/Neuverbinden ohne Schritt-Wechsel neu zu scannen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 19:15:15 +02:00
adminandClaude Sonnet 5 84f5ab21db LAN-Scanner: Geräte-Übersicht je Anschluss mit fester IP-Zuweisung (M-Geräte, #22)
Build / build (push) Waiting to run
Neuer Wizard-Schritt "Geräte im Netzwerk" nach der IST-Zustand-Analyse: LanScanService
(Core) führt DHCP-Leases, ARP-Tabelle und Bridge-Host-Tabelle per MAC-Adresse zusammen
und gruppiert nach physischem Anschluss (aus der Bridge-Host-Tabelle, nicht aus
ARP/Lease, da deren "interface"-Feld bei gebrückten Setups nur die Bridge zeigt).
Erlaubt, eine DHCP-Lease per Klick fest zuzuweisen (RouterOS "Make Static") oder das
wieder rückgängig zu machen. Rein informativ bis auf diese eine Aktion.

Folge-Issue #30 angelegt: ob RouterOS für "make-static" wirklich ".id" statt "numbers"
erwartet, ist bislang unverifiziert (nur gegen den Demo-Client getestet).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 19:03:05 +02:00
adminandClaude Sonnet 5 b7af5175dd Bekannte Router: gespeicherte Verbindungen im Verbindungs-Schritt
Build / build (push) Successful in 37s
Nach erfolgreicher Verbindung wird Host/Benutzername/Modellname lokal
gemerkt (SavedRoutersStore, JSON unter %AppData%), mit editierbarem
Namen und Standort-Feld. Das Passwort liegt separat im Windows-
Anmeldeinformationsspeicher (WindowsCredentialStore, P/Invoke auf
advapi32.dll) statt im Klartext -- bewusst in App statt Core, da Core
plattformneutral bleibt.

Da viele MikroTik-Geraete werksseitig dieselbe Standardadresse nutzen
(192.168.88.1/admin), waere Host+Benutzername allein nicht eindeutig:
RouterDevice liest jetzt zusaetzlich die Seriennummer aus
(/system/routerboard), SavedRoutersStore legt bei Host+Benutzername-
Treffer mit unterschiedlicher Seriennummer einen eigenen Eintrag an
statt den bestehenden zu ueberschreiben. Ansatz 1:1 von der parallel
entwickelten Mac-Version uebernommen (dort M26/M27), vom Nutzer live
bestaetigt.

README zusaetzlich um den auf dem mac-version-merge-Branch liegen
gebliebenen Ablaufplan/M-*-Milestone-Katalog ergaenzt, der bisher nicht
in main uebernommen war, und auf den aktuellen Stand gebracht (v1.2,
CI/CD, M-Bekannte-Router als abgeschlossen markiert).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 21:08:01 +02:00
adminandClaude Sonnet 5 6514eaef86 README: CI/CD-Milestone als abgeschlossen dokumentieren
Build / build (push) Successful in 34s
Selbstgehosteter Windows-Runner registriert und verbunden, Workflow
erfolgreich scharf getestet (Run #26). Neuer Abschnitt erklaert den
User-Modus-Runner ohne Docker und warum kein setup-dotnet-Schritt noetig
ist (fehlende Schreibrechte nach "C:\Program Files\dotnet").

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 20:48:35 +02:00
adminandClaude Sonnet 5 e0136f6ef2 CI: setup-dotnet-Schritt entfernen (fehlende Schreibrechte im User-Modus)
Build / build (push) Successful in 36s
Der selbstgehostete Runner laeuft bewusst im User-Modus (kein Admin).
actions/setup-dotnet@v4 versucht aber, eine eigene SDK-Kopie nach
"C:\Program Files\dotnet" zu installieren, wofuer die Schreibrechte
fehlen -- erster Testlauf (#25) ist deshalb dort fehlgeschlagen. Das
.NET 8 SDK ist auf dem Runner-Rechner ohnehin schon installiert, der
Schritt war nur fuer gehostete/leere Runner noetig.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 20:47:05 +02:00
adminandClaude Sonnet 5 9860e16bb0 CI: runs-on auf selbstgehosteten Windows-Runner umgestellt
Build / build (push) Failing after 15s
Runner "danielpc-windows" (host-Modus, kein Docker) auf diesem Windows-
Rechner registriert und mit dem Repo verbunden (Issue #10). Label von
ubuntu-latest auf windows-latest umgestellt, damit der Workflow ihn
tatsaechlich trifft (Issue #11) -- passt ausserdem besser zum Windows-
exe-Build-Ziel als ein Linux-Runner.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 20:45:30 +02:00
adminandClaude Sonnet 5 8cc8f1cbb3 Live-Eingabevalidierung (v1.2) + Firewall/NAT-Idempotenz-Fix (#19)
Build / build (push) Canceled after 0s
Echte Formatpruefung (IP/CIDR/Praefix/Adressbereiche/Portbereiche) statt
reiner Nicht-leer-Pruefung in WAN, LAN, VLAN und Firewall, mit sofortiger
roter Fehlermeldung je Feld/Zeile statt stillem Blockieren von "Weiter".
Neuer NetworkValidation-Baustein in Core (24 Tests). Behebt nebenbei einen
echten Absturzpfad: LanConfigurator/VlanConfigurator riefen IPAddress.Parse
bisher ungeprueft bei jedem Tastendruck auf.

Zusaetzlich Issue #19 behoben: Firewall-/NAT-Regeln ohne eindeutiges
Identitaetsfeld duplizierten sich bei wiederholtem Wizard-Lauf gegen
dasselbe Geraet. Neuer DuplicateRuleGuard-Baustein holt den Ist-Zustand
einmal pro Wizard-Lauf (nicht pro Regel, um Selbst-Kollisionen innerhalb
desselben Laufs zu vermeiden) und ueberspringt bereits vorhandene Regeln.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 20:38:54 +02:00
66 changed files with 3445 additions and 180 deletions
@@ -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
+8 -7
View File
@@ -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
+81 -16
View File
@@ -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 59 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 165535) 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
+11
View File
@@ -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);
}
+143 -78
View File
@@ -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;
}
}