Experten-Modus hinzufügen: erweiterte RouterOS-Optionen in WAN/LAN/VLAN/WLAN/Firewall
Build / build (push) Canceled after 0s

Neuer Wizard-Schritt "Modus wählen" nach der Geräte-Erkennung, mit dem
zwischen dem geführten Assistenten und einem Experten-Modus gewählt werden
kann. Im Experten-Modus zeigen die bestehenden Schritte zusätzliche,
technischere Felder (DHCP-Feinsteuerung, MTU, WPA3, Portbereiche,
IP-Sperrlisten, VLAN-Isolation u.a.), die im Laien-Modus verborgen bleiben.
Alle neuen Settings-Felder sind optional mit Defaults, die das bisherige
Verhalten unverändert lassen (45 Tests grün, 7 davon neu für die
Experten-Felder).

Zusätzlich zwei beim Live-Testen gefundene Bugs behoben: der letzte Schritt
zeigte einen dauerhaft ausgegrauten "Weiter"-Button statt eines
"Beenden"-Buttons, und die Texte im neuen Moduswahl-Schritt wurden
abgeschnitten (horizontales StackPanel gab TextBlocks unbegrenzte Breite
zum Messen, dadurch griff TextWrapping nicht — behoben durch Grid mit
fester Spaltenbreite).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Sonnet 5 committed 2026-09-15 18:14:04 +02:00
1 parent 831f610753
commit e58480d2ae
36 files changed
+783 -78

No files matched your search

+3
View File
@@ -30,6 +30,9 @@
<DataTemplate DataType="{x:Type steps:DeviceOverviewStepViewModel}">
<stepViews:DeviceOverviewStepView />
</DataTemplate>
<DataTemplate DataType="{x:Type steps:ModeSelectionStepViewModel}">
<stepViews:ModeSelectionStepView />
</DataTemplate>
<DataTemplate DataType="{x:Type steps:WanStepViewModel}">
<stepViews:WanStepView />
</DataTemplate>
+4 -1
View File
@@ -147,7 +147,10 @@
</Grid.ColumnDefinitions>
<ui:Button Grid.Column="1" Content="Zurück" Command="{Binding GoBackCommand}" Margin="0,0,8,0" />
<ui:Button Grid.Column="2" Content="Weiter" Appearance="Primary" Command="{Binding GoNextCommand}" />
<ui:Button Grid.Column="2" Content="Weiter" Appearance="Primary" Command="{Binding GoNextCommand}"
Visibility="{Binding IsLastStep, Converter={StaticResource BoolToVisibilityConverter}, ConverterParameter=Invert}" />
<ui:Button Grid.Column="2" Content="Beenden" Appearance="Primary" Command="{Binding FinishCommand}"
Visibility="{Binding IsLastStep, Converter={StaticResource BoolToVisibilityConverter}}" />
</Grid>
</Border>
</Grid>
@@ -11,6 +11,11 @@ public partial class MainWindow : FluentWindow
InitializeComponent();
Closing += OnClosing;
if (DataContext is MainViewModel viewModel)
{
viewModel.CloseRequested += (_, _) => Close();
}
// Folgt Hell/Dunkel-Wechseln des Windows-Systemthemes zur Laufzeit;
// updateAccents:false, damit unser MikroTik-Grün-Akzent dabei erhalten bleibt.
SystemThemeWatcher.Watch(this, WindowBackdropType.Mica, updateAccents: false);
@@ -18,6 +18,7 @@ public partial class MainViewModel : ObservableObject
[
new ConnectionStepViewModel(_session),
new DeviceOverviewStepViewModel(_session),
new ModeSelectionStepViewModel(_session),
new WanStepViewModel(_session),
new LanStepViewModel(_session),
new VlanStepViewModel(_session),
@@ -87,6 +88,12 @@ public partial class MainViewModel : ObservableObject
private bool CanGoNext() => !IsLastStep && CurrentStep.CanGoNext;
/// <summary>Wird ausgelöst, wenn der Nutzer auf dem letzten Schritt „Beenden" klickt.</summary>
public event EventHandler? CloseRequested;
[RelayCommand]
private void Finish() => CloseRequested?.Invoke(this, EventArgs.Empty);
/// <summary>Wird beim Schließen des Hauptfensters aufgerufen, um die Geräteverbindung sauber zu beenden.</summary>
public Task DisposeSessionAsync() => _session.DisposeClientAsync();
}
@@ -1,4 +1,5 @@
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using MikrotikInstaller.Core.Configuration;
@@ -24,10 +25,17 @@ public partial class FirewallStepViewModel : WizardStepViewModelBase
public IReadOnlyList<string> Protocols { get; } = ["tcp", "udp"];
// Experten-Feld — nur im Experten-Modus sichtbar, leer bedeutet: keine Sperrliste (wie bisher).
[ObservableProperty]
private string _blockedAddressesText = string.Empty;
public bool IsExpertMode => _session.IsExpertMode;
public override bool CanGoNext => PortForwards.All(IsValid);
public override Task OnActivatedAsync()
{
OnPropertyChanged(nameof(IsExpertMode));
SaveToSession();
return Task.CompletedTask;
}
@@ -48,6 +56,8 @@ public partial class FirewallStepViewModel : WizardStepViewModelBase
SaveToSession();
}
partial void OnBlockedAddressesTextChanged(string value) => SaveToSession();
private static bool IsValid(PortForwardEntry entry) =>
!string.IsNullOrWhiteSpace(entry.Name)
&& !string.IsNullOrWhiteSpace(entry.TargetAddress)
@@ -66,10 +76,21 @@ public partial class FirewallStepViewModel : WizardStepViewModelBase
}
var forwards = PortForwards
.Select(e => new PortForward(e.Name, e.Protocol, int.Parse(e.ExternalPort), e.TargetAddress, int.Parse(e.InternalPort)))
.Select(e => new PortForward(
e.Name, e.Protocol, int.Parse(e.ExternalPort), e.TargetAddress, int.Parse(e.InternalPort),
string.IsNullOrWhiteSpace(e.AllowedSourceAddress) ? null : e.AllowedSourceAddress,
e.LogRule,
int.TryParse(e.ExternalPortRangeEnd, out var extEnd) ? extEnd : null,
int.TryParse(e.InternalPortRangeEnd, out var intEnd) ? intEnd : null))
.ToList();
var settings = new FirewallSettings(_session.WanInterfaceName, _session.LanBridgeName, forwards);
var blockedAddresses = BlockedAddressesText
.Split([',', '\n', '\r'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.ToList();
var settings = new FirewallSettings(
_session.WanInterfaceName, _session.LanBridgeName, forwards,
blockedAddresses.Count > 0 ? blockedAddresses : null);
_session.FirewallSettings = settings;
_session.FirewallActions = FirewallConfigurator.BuildActions(settings);
}
@@ -44,6 +44,18 @@ public partial class LanStepViewModel : WizardStepViewModelBase
[ObservableProperty]
private string _secondaryDns = "8.8.8.8";
// Experten-Felder — nur im Experten-Modus sichtbar, leer bedeutet: RouterOS-Standardverhalten.
[ObservableProperty]
private string _dhcpLeaseTime = string.Empty;
[ObservableProperty]
private string _domainName = string.Empty;
[ObservableProperty]
private string _ntpServer = string.Empty;
public bool IsExpertMode => _session.IsExpertMode;
public override bool CanGoNext =>
AvailableInterfaces.Any(i => i.IsSelected)
&& !string.IsNullOrWhiteSpace(BridgeName)
@@ -54,6 +66,8 @@ public partial class LanStepViewModel : WizardStepViewModelBase
public override Task OnActivatedAsync()
{
OnPropertyChanged(nameof(IsExpertMode));
AvailableInterfaces.Clear();
var candidates = (_session.Device?.Interfaces ?? []).Where(
i => i.Kind == InterfaceKind.Ethernet && i.Name != _session.WanInterfaceName);
@@ -83,6 +97,12 @@ public partial class LanStepViewModel : WizardStepViewModelBase
partial void OnSecondaryDnsChanged(string value) => SaveToSession();
partial void OnDhcpLeaseTimeChanged(string value) => SaveToSession();
partial void OnDomainNameChanged(string value) => SaveToSession();
partial void OnNtpServerChanged(string value) => SaveToSession();
private void SaveToSession()
{
OnPropertyChanged(nameof(CanGoNext));
@@ -104,7 +124,10 @@ public partial class LanStepViewModel : WizardStepViewModelBase
DhcpPoolStart,
DhcpPoolEnd,
PrimaryDns,
string.IsNullOrWhiteSpace(SecondaryDns) ? null : SecondaryDns);
string.IsNullOrWhiteSpace(SecondaryDns) ? null : SecondaryDns,
string.IsNullOrWhiteSpace(DhcpLeaseTime) ? null : DhcpLeaseTime,
string.IsNullOrWhiteSpace(DomainName) ? null : DomainName,
string.IsNullOrWhiteSpace(NtpServer) ? null : NtpServer);
_session.LanSettings = settings;
_session.LanActions = LanConfigurator.BuildActions(settings);
@@ -0,0 +1,34 @@
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.
/// </summary>
public partial class ModeSelectionStepViewModel : WizardStepViewModelBase
{
private readonly WizardSession _session;
public ModeSelectionStepViewModel(WizardSession session)
: base(
title: "Modus wählen",
description: "Der geführte Assistent passt für die meisten Haushalte. Kennst du dich mit RouterOS aus, kannst du den Experten-Modus wählen — die folgenden Schritte zeigen dann zusätzliche, technischere Einstellungen.")
{
_session = session;
_isExpertMode = session.IsExpertMode;
}
[ObservableProperty]
private bool _isExpertMode;
public override Task OnActivatedAsync()
{
IsExpertMode = _session.IsExpertMode;
return Task.CompletedTask;
}
partial void OnIsExpertModeChanged(bool value) => _session.IsExpertMode = value;
}
@@ -19,4 +19,17 @@ public partial class PortForwardEntry : ObservableObject
[ObservableProperty]
private string _internalPort = string.Empty;
// Experten-Felder — nur im Experten-Modus sichtbar.
[ObservableProperty]
private string _allowedSourceAddress = string.Empty;
[ObservableProperty]
private bool _logRule;
[ObservableProperty]
private string _externalPortRangeEnd = string.Empty;
[ObservableProperty]
private string _internalPortRangeEnd = string.Empty;
}
@@ -19,4 +19,17 @@ public partial class VlanEntry : ObservableObject
[ObservableProperty]
private string _poolEnd = string.Empty;
// Experten-Felder — nur im Experten-Modus sichtbar.
[ObservableProperty]
private int _prefixLength = 24;
[ObservableProperty]
private bool _isolateFromLan;
[ObservableProperty]
private string _dnsServerOverride = string.Empty;
[ObservableProperty]
private string _dhcpLeaseTime = string.Empty;
}
@@ -23,10 +23,13 @@ public partial class VlanStepViewModel : WizardStepViewModelBase
public ObservableCollection<VlanEntry> Vlans { get; } = [];
public bool IsExpertMode => _session.IsExpertMode;
public override bool CanGoNext => Vlans.All(IsValid);
public override Task OnActivatedAsync()
{
OnPropertyChanged(nameof(IsExpertMode));
SaveToSession();
return Task.CompletedTask;
}
@@ -76,7 +79,15 @@ public partial class VlanStepViewModel : WizardStepViewModelBase
}
var definitions = Vlans
.Select(v => new VlanDefinition(int.Parse(v.VlanId), v.Name, $"{v.RouterAddress}/24", v.PoolStart, v.PoolEnd))
.Select(v => new VlanDefinition(
int.Parse(v.VlanId),
v.Name,
$"{v.RouterAddress}/{v.PrefixLength}",
v.PoolStart,
v.PoolEnd,
v.IsolateFromLan,
string.IsNullOrWhiteSpace(v.DnsServerOverride) ? null : v.DnsServerOverride,
string.IsNullOrWhiteSpace(v.DhcpLeaseTime) ? null : v.DhcpLeaseTime))
.ToList();
_session.VlanDefinitions = definitions;
@@ -37,12 +37,29 @@ public partial class WanStepViewModel : WizardStepViewModelBase
[ObservableProperty]
private string _staticGateway = string.Empty;
// Experten-Felder — nur im Experten-Modus sichtbar, Defaults entsprechen dem bisherigen Verhalten.
[ObservableProperty]
private bool _useDhcpDefaultRoute = true;
[ObservableProperty]
private bool _useDhcpDns = true;
[ObservableProperty]
private string _mtu = string.Empty;
[ObservableProperty]
private string _natSourceAddress = string.Empty;
public bool IsExpertMode => _session.IsExpertMode;
public override bool CanGoNext =>
!string.IsNullOrWhiteSpace(SelectedInterface)
&& (!UseStaticAddress || (!string.IsNullOrWhiteSpace(StaticAddress) && !string.IsNullOrWhiteSpace(StaticGateway)));
public override Task OnActivatedAsync()
{
OnPropertyChanged(nameof(IsExpertMode));
AvailableInterfaces.Clear();
foreach (var iface in _session.Device?.Interfaces.Where(i => i.Kind == InterfaceKind.Ethernet) ?? [])
{
@@ -62,6 +79,14 @@ public partial class WanStepViewModel : WizardStepViewModelBase
partial void OnStaticGatewayChanged(string value) => SaveToSession();
partial void OnUseDhcpDefaultRouteChanged(bool value) => SaveToSession();
partial void OnUseDhcpDnsChanged(bool value) => SaveToSession();
partial void OnMtuChanged(string value) => SaveToSession();
partial void OnNatSourceAddressChanged(string value) => SaveToSession();
private void SaveToSession()
{
OnPropertyChanged(nameof(CanGoNext));
@@ -75,9 +100,12 @@ public partial class WanStepViewModel : WizardStepViewModelBase
return;
}
var mtu = int.TryParse(Mtu, out var mtuValue) ? mtuValue : (int?)null;
var natSourceAddress = string.IsNullOrWhiteSpace(NatSourceAddress) ? null : NatSourceAddress;
var settings = UseStaticAddress
? new WanSettings(SelectedInterface!, WanAddressMode.Static, StaticAddress, StaticGateway)
: new WanSettings(SelectedInterface!, WanAddressMode.Dhcp);
? new WanSettings(SelectedInterface!, WanAddressMode.Static, StaticAddress, StaticGateway, UseDhcpDefaultRoute, UseDhcpDns, mtu, natSourceAddress)
: new WanSettings(SelectedInterface!, WanAddressMode.Dhcp, UseDhcpDefaultRoute: UseDhcpDefaultRoute, UseDhcpDns: UseDhcpDns, Mtu: mtu, NatSourceAddress: natSourceAddress);
_session.WanSettings = settings;
_session.WanActions = WanConfigurator.BuildActions(settings);
@@ -36,6 +36,20 @@ public partial class WlanStepViewModel : WizardStepViewModelBase
[ObservableProperty]
private bool _skipWireless;
// Experten-Felder — nur im Experten-Modus sichtbar, Defaults entsprechen dem bisherigen Verhalten.
[ObservableProperty]
private WirelessSecurityMode _securityMode = WirelessSecurityMode.Wpa2;
[ObservableProperty]
private bool _hideSsid;
[ObservableProperty]
private string _band = string.Empty;
public IReadOnlyList<WirelessSecurityMode> SecurityModes { get; } = Enum.GetValues<WirelessSecurityMode>();
public bool IsExpertMode => _session.IsExpertMode;
public override bool ShouldSkip => _session.Device?.HasWireless != true;
public override bool CanGoNext =>
@@ -44,6 +58,8 @@ public partial class WlanStepViewModel : WizardStepViewModelBase
public override Task OnActivatedAsync()
{
OnPropertyChanged(nameof(IsExpertMode));
AvailableInterfaces.Clear();
foreach (var iface in _session.Device?.Interfaces.Where(i => i.Kind == InterfaceKind.Wireless) ?? [])
{
@@ -63,6 +79,12 @@ public partial class WlanStepViewModel : WizardStepViewModelBase
partial void OnSkipWirelessChanged(bool value) => SaveToSession();
partial void OnSecurityModeChanged(WirelessSecurityMode value) => SaveToSession();
partial void OnHideSsidChanged(bool value) => SaveToSession();
partial void OnBandChanged(string value) => SaveToSession();
private void SaveToSession()
{
OnPropertyChanged(nameof(CanGoNext));
@@ -74,7 +96,9 @@ public partial class WlanStepViewModel : WizardStepViewModelBase
return;
}
var settings = new WirelessSettings(SelectedInterface!, Ssid, Password);
var settings = new WirelessSettings(
SelectedInterface!, Ssid, Password, SecurityMode, HideSsid,
string.IsNullOrWhiteSpace(Band) ? null : Band);
_session.WirelessSettings = settings;
_session.WirelessActions = WirelessConfigurator.BuildActions(settings);
}
@@ -16,6 +16,12 @@ public sealed class WizardSession
public RouterDevice? Device { get; set; }
/// <summary>
/// Ob der Nutzer im Moduswahl-Schritt den Experten-Modus gewählt hat. Steuert, ob die
/// nachfolgenden Schritte zusätzliche, technischere Felder anzeigen. Standard: Laien-Modus (false).
/// </summary>
public bool IsExpertMode { get; set; }
/// <summary>Vom WAN-Schritt gewählte Internet-Schnittstelle, damit spätere Schritte sie referenzieren können.</summary>
public string? WanInterfaceName { get; set; }
@@ -75,13 +75,58 @@
<ui:TextBox Text="{Binding InternalPort, UpdateSourceTrigger=PropertyChanged}" />
</StackPanel>
</Grid>
<StackPanel Margin="0,8,0,0"
Visibility="{Binding DataContext.IsExpertMode, RelativeSource={RelativeSource AncestorType=ItemsControl}, Converter={StaticResource BoolToVisibilityConverter}}">
<Grid Margin="0,0,0,8">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="12" />
<ColumnDefinition Width="130" />
<ColumnDefinition Width="12" />
<ColumnDefinition Width="130" />
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="0">
<StackPanel Orientation="Horizontal" Margin="0,0,0,4">
<TextBlock Text="Nur von dieser Adresse erreichbar (optional)" />
<controls:InfoHint Text="Beschränkt diese Portfreigabe auf eine bestimmte Quell-IP-Adresse statt für jeden aus dem Internet." />
</StackPanel>
<ui:TextBox Text="{Binding AllowedSourceAddress, UpdateSourceTrigger=PropertyChanged}" />
</StackPanel>
<StackPanel Grid.Column="2">
<StackPanel Orientation="Horizontal" Margin="0,0,0,4">
<TextBlock Text="bis Port außen (Bereich)" />
<controls:InfoHint Text="Optional: Endpunkt eines Portbereichs statt eines einzelnen Ports." />
</StackPanel>
<ui:TextBox Text="{Binding ExternalPortRangeEnd, UpdateSourceTrigger=PropertyChanged}" />
</StackPanel>
<StackPanel Grid.Column="4">
<TextBlock Text="bis Port intern (Bereich)" Margin="0,0,0,4" />
<ui:TextBox Text="{Binding InternalPortRangeEnd, UpdateSourceTrigger=PropertyChanged}" />
</StackPanel>
</Grid>
<StackPanel Orientation="Horizontal">
<CheckBox Content="Zugriffe auf diese Regel protokollieren" IsChecked="{Binding LogRule}" />
<controls:InfoHint Text="Schreibt jeden Treffer dieser Regel ins RouterOS-Systemprotokoll — nützlich zur Fehlersuche, erzeugt aber zusätzliche Log-Einträge." />
</StackPanel>
</StackPanel>
</StackPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<ui:Button Content="+ Portfreigabe hinzufügen" Appearance="Secondary" HorizontalAlignment="Left"
<ui:Button Content="+ Portfreigabe hinzufügen" Appearance="Secondary" HorizontalAlignment="Left" Margin="0,0,0,20"
Command="{Binding AddPortForwardCommand}" />
<StackPanel Visibility="{Binding IsExpertMode, Converter={StaticResource BoolToVisibilityConverter}}">
<TextBlock Text="Erweiterte Einstellungen" FontWeight="SemiBold" Margin="0,0,0,12" />
<StackPanel Orientation="Horizontal" Margin="0,0,0,4">
<TextBlock Text="Adressen sperren (optional)" />
<controls:InfoHint Text="Eine oder mehrere IP-Adressen bzw. Subnetze (CIDR), die grundsätzlich blockiert werden, z. B. '203.0.113.0/24'. Mit Komma oder Zeilenumbruch trennen." />
</StackPanel>
<ui:TextBox Text="{Binding BlockedAddressesText, UpdateSourceTrigger=PropertyChanged}"
AcceptsReturn="True" TextWrapping="Wrap" Height="60" />
</StackPanel>
</StackPanel>
</UserControl>
@@ -87,5 +87,27 @@
<ui:TextBox Text="{Binding SecondaryDns, UpdateSourceTrigger=PropertyChanged}" />
</StackPanel>
</Grid>
<StackPanel Visibility="{Binding IsExpertMode, Converter={StaticResource BoolToVisibilityConverter}}">
<TextBlock Text="Erweiterte Einstellungen" FontWeight="SemiBold" Margin="0,8,0,12" />
<StackPanel Orientation="Horizontal" Margin="0,0,0,4">
<TextBlock Text="DHCP-Lease-Zeit (optional)" />
<controls:InfoHint Text="Wie lange eine vergebene Adresse einem Gerät zugeordnet bleibt, z. B. '1d' für einen Tag. Leer lassen für den RouterOS-Standardwert." />
</StackPanel>
<ui:TextBox Text="{Binding DhcpLeaseTime, UpdateSourceTrigger=PropertyChanged}" Margin="0,0,0,16" HorizontalAlignment="Left" MinWidth="120" />
<StackPanel Orientation="Horizontal" Margin="0,0,0,4">
<TextBlock Text="Domainname (optional)" />
<controls:InfoHint Text="Ein interner Domainname für dein Heimnetz, den der DHCP-Server an Geräte weitergibt (z. B. 'heim.lan')." />
</StackPanel>
<ui:TextBox Text="{Binding DomainName, UpdateSourceTrigger=PropertyChanged}" Margin="0,0,0,16" HorizontalAlignment="Left" MinWidth="220" />
<StackPanel Orientation="Horizontal" Margin="0,0,0,4">
<TextBlock Text="Zeitserver / NTP (optional)" />
<controls:InfoHint Text="Ein NTP-Server, über den der Router seine Systemzeit synchronisiert, z. B. 'pool.ntp.org'." />
</StackPanel>
<ui:TextBox Text="{Binding NtpServer, UpdateSourceTrigger=PropertyChanged}" Margin="0,0,0,16" HorizontalAlignment="Left" MinWidth="220" />
</StackPanel>
</StackPanel>
</UserControl>
@@ -0,0 +1,42 @@
<UserControl x:Class="MikrotikInstaller.App.Views.Steps.ModeSelectionStepView"
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="WizardMode" VerticalAlignment="Top" Margin="0,2,12,0"
IsChecked="{Binding IsExpertMode, Converter={StaticResource InverseBooleanConverter}}" />
<StackPanel Grid.Column="1">
<TextBlock Text="Geführter Assistent" FontWeight="SemiBold" />
<TextBlock Text="Die einfachen, laientauglichen Fragen aus den nächsten Schritten reichen für die meisten Haushalte völlig aus." 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="WizardMode" VerticalAlignment="Top" Margin="0,2,12,0"
IsChecked="{Binding IsExpertMode}" />
<StackPanel Grid.Column="1">
<TextBlock Text="Experten-Modus" FontWeight="SemiBold" />
<TextBlock Text="Jeder folgende Schritt zeigt zusätzliche, technischere RouterOS-Einstellungen (z. B. DHCP-Feinsteuerung, WPA3, Portfreigaben mit Quell-IP-Filter). Nur wählen, wenn dir diese Begriffe etwas sagen." 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 ModeSelectionStepView : UserControl
{
public ModeSelectionStepView()
{
InitializeComponent();
}
}
@@ -63,6 +63,42 @@
<ui:TextBox Text="{Binding PoolEnd, UpdateSourceTrigger=PropertyChanged}" />
</StackPanel>
</Grid>
<StackPanel Margin="0,12,0,0"
Visibility="{Binding DataContext.IsExpertMode, RelativeSource={RelativeSource AncestorType=ItemsControl}, Converter={StaticResource BoolToVisibilityConverter}}">
<Grid Margin="0,0,0,8">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="100" />
<ColumnDefinition Width="12" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="0">
<StackPanel Orientation="Horizontal" Margin="0,0,0,4">
<TextBlock Text="Präfix" />
<controls:InfoHint Text="Gibt an, wie groß dieses VLAN-Netzwerk ist. 24 erlaubt bis zu 254 Geräte." />
</StackPanel>
<ui:TextBox Text="{Binding PrefixLength, UpdateSourceTrigger=PropertyChanged}" />
</StackPanel>
<StackPanel Grid.Column="2">
<StackPanel Orientation="Horizontal" Margin="0,0,0,4">
<TextBlock Text="DNS-Server-Override (optional)" />
<controls:InfoHint Text="Nutzt einen eigenen DNS-Server für dieses VLAN statt der Router-Adresse selbst." />
</StackPanel>
<ui:TextBox Text="{Binding DnsServerOverride, UpdateSourceTrigger=PropertyChanged}" />
</StackPanel>
</Grid>
<StackPanel Orientation="Horizontal" Margin="0,0,0,8">
<TextBlock Text="DHCP-Lease-Zeit (optional)" />
<controls:InfoHint Text="Wie lange eine vergebene Adresse einem Gerät in diesem VLAN zugeordnet bleibt, z. B. '1d'." />
<ui:TextBox Text="{Binding DhcpLeaseTime, UpdateSourceTrigger=PropertyChanged}" Margin="12,0,0,0" MinWidth="100" />
</StackPanel>
<StackPanel Orientation="Horizontal">
<CheckBox Content="Von deinem Heimnetz isolieren" IsChecked="{Binding IsolateFromLan}" />
<controls:InfoHint Text="Geräte in diesem VLAN können dann nicht mehr auf dein Heimnetz zugreifen und umgekehrt — sinnvoll z. B. für ein Gästenetz." />
</StackPanel>
</StackPanel>
</StackPanel>
</Border>
</DataTemplate>
@@ -35,5 +35,30 @@
</StackPanel>
<ui:TextBox Text="{Binding StaticGateway, UpdateSourceTrigger=PropertyChanged}" Margin="0,0,0,16" />
</StackPanel>
<StackPanel Visibility="{Binding IsExpertMode, Converter={StaticResource BoolToVisibilityConverter}}">
<TextBlock Text="Erweiterte Einstellungen" FontWeight="SemiBold" Margin="0,8,0,12" />
<StackPanel Orientation="Horizontal" Margin="0,0,0,8">
<CheckBox Content="Standardroute vom Internetanbieter übernehmen (nur bei DHCP)" IsChecked="{Binding UseDhcpDefaultRoute}" />
<controls:InfoHint Text="Legt fest, ob der DHCP-Client automatisch die Standardroute ins Internet einträgt. In den meisten Fällen aktiviert lassen." />
</StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,0,0,16">
<CheckBox Content="DNS-Server vom Internetanbieter übernehmen (nur bei DHCP)" IsChecked="{Binding UseDhcpDns}" />
<controls:InfoHint Text="Legt fest, ob der DHCP-Client die vom Anbieter gelieferten DNS-Server übernimmt." />
</StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,0,0,4">
<TextBlock Text="MTU (optional)" />
<controls:InfoHint Text="Maximale Paketgröße auf dieser Schnittstelle. Nur ändern, wenn dein Anbieter (z. B. bei PPPoE/DSL) einen bestimmten Wert vorgibt." />
</StackPanel>
<ui:TextBox Text="{Binding Mtu, UpdateSourceTrigger=PropertyChanged}" Margin="0,0,0,16" HorizontalAlignment="Left" MinWidth="120" />
<StackPanel Orientation="Horizontal" Margin="0,0,0,4">
<TextBlock Text="NAT nur für dieses Subnetz (optional)" />
<controls:InfoHint Text="Beschränkt die Internet-Weiterleitung (Masquerade) auf ein bestimmtes Subnetz, z. B. '192.168.88.0/24', statt für dein gesamtes Heimnetz." />
</StackPanel>
<ui:TextBox Text="{Binding NatSourceAddress, UpdateSourceTrigger=PropertyChanged}" Margin="0,0,0,16" />
</StackPanel>
</StackPanel>
</UserControl>
@@ -32,6 +32,28 @@
<TextBlock Text="WLAN-Passwort (mindestens 8 Zeichen)" Margin="0,0,0,4" />
<ui:PasswordBox Password="{Binding Password, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" Margin="0,0,0,16" HorizontalAlignment="Left" MinWidth="300" />
<StackPanel Visibility="{Binding IsExpertMode, Converter={StaticResource BoolToVisibilityConverter}}">
<TextBlock Text="Erweiterte Einstellungen" FontWeight="SemiBold" Margin="0,8,0,12" />
<StackPanel Orientation="Horizontal" Margin="0,0,0,4">
<TextBlock Text="Sicherheitsmodus" />
<controls:InfoHint Text="WPA2 ist mit praktisch jedem Gerät kompatibel. WPA3 ist sicherer, wird aber nicht von jedem älteren Gerät unterstützt. Gemischt erlaubt beides gleichzeitig." />
</StackPanel>
<ComboBox ItemsSource="{Binding SecurityModes}" SelectedItem="{Binding SecurityMode}"
Margin="0,0,0,16" HorizontalAlignment="Left" MinWidth="180" />
<StackPanel Orientation="Horizontal" Margin="0,0,0,16">
<CheckBox Content="WLAN-Name (SSID) verstecken" IsChecked="{Binding HideSsid}" />
<controls:InfoHint Text="Dein WLAN erscheint dann nicht mehr in der Liste verfügbarer Netzwerke — Geräte müssen den Namen manuell eingeben. Bietet keinen echten Sicherheitsgewinn, nur weniger Sichtbarkeit." />
</StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,0,0,4">
<TextBlock Text="Band/Kanal (optional)" />
<controls:InfoHint Text="Schränkt das Funkband ein, z. B. '5ghz-a/n/ac' oder '2ghz-b/g/n'. Leer lassen, damit RouterOS automatisch wählt." />
</StackPanel>
<ui:TextBox Text="{Binding Band, UpdateSourceTrigger=PropertyChanged}" Margin="0,0,0,16" HorizontalAlignment="Left" MinWidth="220" />
</StackPanel>
</StackPanel>
</StackPanel>
</UserControl>
@@ -47,6 +47,44 @@ public class FirewallConfiguratorTests
Assert.True(allowIndex >= 0 && dropIndex >= 0 && allowIndex < dropIndex);
}
[Fact]
public async Task BuildActions_ExpertOptions_AppliesSourceAddressLogAndPortRange()
{
var settings = new FirewallSettings("ether1", "bridge-lan",
[new PortForward("Spieleserver", "udp", 25565, "192.168.88.50", 25565,
AllowedSourceAddress: "203.0.113.0/24", LogRule: true, ExternalPortRangeEnd: 25575, InternalPortRangeEnd: 25575)]);
var actions = FirewallConfigurator.BuildActions(settings);
var client = new RecordingRouterOsClient();
foreach (var action in actions)
{
await action.ApplyAsync(client, CancellationToken.None);
}
Assert.Contains(client.AddCalls, c => c.Path == "/ip/firewall/nat"
&& c.Parameters["src-address"] == "203.0.113.0/24" && c.Parameters["log"] == "yes"
&& c.Parameters["dst-port"] == "25565-25575" && c.Parameters["to-ports"] == "25565-25575");
}
[Fact]
public async Task BuildActions_WithBlockedAddresses_AddsAddressListAndDropRules()
{
var settings = new FirewallSettings("ether1", "bridge-lan", [], BlockedAddresses: ["203.0.113.5", "198.51.100.0/24"]);
var actions = FirewallConfigurator.BuildActions(settings);
var client = new RecordingRouterOsClient();
foreach (var action in actions)
{
await action.ApplyAsync(client, CancellationToken.None);
}
Assert.Equal(2, client.AddCalls.Count(c => c.Path == "/ip/firewall/address-list" && c.Parameters["list"] == "blocked"));
Assert.Contains(client.AddCalls, c => c.Path == "/ip/firewall/filter"
&& c.Parameters["chain"] == "input" && c.Parameters.GetValueOrDefault("src-address-list") == "blocked" && c.Parameters["action"] == "drop");
Assert.Contains(client.AddCalls, c => c.Path == "/ip/firewall/filter"
&& c.Parameters["chain"] == "forward" && c.Parameters.GetValueOrDefault("src-address-list") == "blocked" && c.Parameters["action"] == "drop");
}
[Fact]
public void BuildFriendlySummary_WithoutPortForwards_OnlyMentionsProtection()
{
@@ -47,6 +47,24 @@ public class LanConfiguratorTests
&& c.Parameters!["servers"] == "1.1.1.1,8.8.8.8" && c.Parameters["allow-remote-requests"] == "yes");
}
[Fact]
public async Task BuildActions_ExpertOptions_AppliesLeaseTimeDomainNameAndNtpServer()
{
var settings = DefaultSettings() with { DhcpLeaseTime = "1d", DomainName = "heim.lan", NtpServer = "pool.ntp.org" };
var actions = LanConfigurator.BuildActions(settings);
var client = new RecordingRouterOsClient();
foreach (var action in actions)
{
await action.ApplyAsync(client, CancellationToken.None);
}
Assert.Contains(client.AddCalls, c => c.Path == "/ip/dhcp-server" && c.Parameters["lease-time"] == "1d");
Assert.Contains(client.AddCalls, c => c.Path == "/ip/dhcp-server/network" && c.Parameters["domain-name"] == "heim.lan");
Assert.Contains(client.ExecuteCalls, c => c.Path == "/system/ntp/client/set"
&& c.Parameters!["enabled"] == "yes" && c.Parameters["servers"] == "pool.ntp.org");
}
[Fact]
public void BuildFriendlySummary_MentionsNetworkAndMemberInterfaces()
{
@@ -27,6 +27,30 @@ public class VlanConfiguratorTests
Assert.Contains(client.AddCalls, c => c.Path == "/ip/dhcp-server/network" && c.Parameters["address"] == "192.168.20.0/24");
}
[Fact]
public async Task BuildActions_ExpertOptions_AppliesDnsOverrideLeaseTimeAndIsolation()
{
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, DnsServerOverride: "9.9.9.9", DhcpLeaseTime: "12h"),
};
var actions = VlanConfigurator.BuildActions("bridge-lan", vlans);
var client = new RecordingRouterOsClient();
foreach (var action in actions)
{
await action.ApplyAsync(client, CancellationToken.None);
}
Assert.Contains(client.AddCalls, c => c.Path == "/ip/dhcp-server" && c.Parameters["lease-time"] == "12h");
Assert.Contains(client.AddCalls, c => c.Path == "/ip/dhcp-server/network" && c.Parameters["dns-server"] == "9.9.9.9");
var isolationDrops = client.AddCalls.Where(c => c.Path == "/ip/firewall/filter" && c.Parameters["action"] == "drop").ToList();
Assert.Contains(isolationDrops, c => c.Parameters["in-interface"] == "vlan20" && c.Parameters["out-interface"] == "bridge-lan");
Assert.Contains(isolationDrops, c => c.Parameters["in-interface"] == "bridge-lan" && c.Parameters["out-interface"] == "vlan20");
}
[Fact]
public void BuildActions_NoVlans_ReturnsEmptyList()
{
@@ -46,6 +46,34 @@ public class WanConfiguratorTests
&& c.Parameters["action"] == "masquerade" && c.Parameters["out-interface"] == "ether1");
}
[Fact]
public async Task BuildActions_ExpertOptions_AppliesDhcpFlagsMtuAndNatSourceAddress()
{
var actions = WanConfigurator.BuildActions(new WanSettings(
"ether1", WanAddressMode.Dhcp,
UseDhcpDefaultRoute: false, UseDhcpDns: false, Mtu: 1492, NatSourceAddress: "192.168.88.0/24"));
var client = new RecordingRouterOsClient();
foreach (var action in actions)
{
await action.ApplyAsync(client, CancellationToken.None);
}
Assert.Contains(client.AddCalls, c => c.Path == "/ip/dhcp-client"
&& c.Parameters["add-default-route"] == "no" && c.Parameters["use-peer-dns"] == "no");
Assert.Contains(client.AddCalls, c => c.Path == "/ip/firewall/nat" && c.Parameters["src-address"] == "192.168.88.0/24");
Assert.Contains(client.ExecuteCalls, c => c.Path == "/interface/ethernet/set"
&& c.Parameters!["numbers"] == "ether1" && c.Parameters["mtu"] == "1492");
}
[Fact]
public void BuildActions_WithoutExpertOptions_MatchesPreviousBehavior()
{
var actions = WanConfigurator.BuildActions(new WanSettings("ether1", WanAddressMode.Dhcp));
Assert.Equal(2, actions.Count);
}
[Fact]
public void BuildFriendlySummary_ContainsNoRouterOsJargon()
{
@@ -21,6 +21,24 @@ public class WirelessConfiguratorTests
&& c.Parameters!["numbers"] == "wlan1" && c.Parameters["ssid"] == "Mein-WLAN" && c.Parameters["disabled"] == "no");
}
[Fact]
public async Task BuildActions_ExpertOptions_AppliesWpa3HideSsidAndBand()
{
var actions = WirelessConfigurator.BuildActions(new WirelessSettings(
"wlan1", "Mein-WLAN", "sicheres-passwort", WirelessSecurityMode.Wpa3, HideSsid: true, Band: "5ghz-a/n/ac"));
var client = new RecordingRouterOsClient();
foreach (var action in actions)
{
await action.ApplyAsync(client, CancellationToken.None);
}
Assert.Contains(client.AddCalls, c => c.Path == "/interface/wireless/security-profiles"
&& c.Parameters["authentication-types"] == "wpa3-psk" && c.Parameters["wpa3-pre-shared-key"] == "sicheres-passwort");
Assert.Contains(client.ExecuteCalls, c => c.Path == "/interface/wireless/set"
&& c.Parameters!["hide-ssid"] == "yes" && c.Parameters["band"] == "5ghz-a/n/ac");
}
[Fact]
public void BuildFriendlySummary_MentionsSsidWithoutExposingPassword()
{
@@ -25,32 +25,75 @@ public static class FirewallConfigurator
new Dictionary<string, string> { ["in-interface"] = settings.LanBridgeName, ["action"] = "accept" }),
};
if (settings.BlockedAddresses is { Count: > 0 } blockedAddresses)
{
const string blockListName = "blocked";
foreach (var address in blockedAddresses)
{
actions.Add(new ConfigurationAction(
$"„{address}\" zur Sperrliste hinzufügen",
(client, ct) => client.AddAsync("/ip/firewall/address-list", new Dictionary<string, string>
{
["list"] = blockListName,
["address"] = address,
}, ct)));
}
actions.Add(InputRule(
"Zugriff auf die Router-Einstellungen von gesperrten Adressen blockieren",
new Dictionary<string, string> { ["src-address-list"] = blockListName, ["action"] = "drop" }));
actions.Add(ForwardRule(
"Datenverkehr von gesperrten Adressen blockieren",
new Dictionary<string, string> { ["src-address-list"] = blockListName, ["action"] = "drop" }));
}
// Portfreigaben müssen vor der abschließenden Sperrregel stehen, damit sie greifen.
foreach (var forward in settings.PortForwards)
{
var externalPort = forward.ExternalPortRangeEnd is int externalEnd
? $"{forward.ExternalPort}-{externalEnd}"
: forward.ExternalPort.ToString();
var internalPort = forward.InternalPortRangeEnd is int internalEnd
? $"{forward.InternalPort}-{internalEnd}"
: forward.InternalPort.ToString();
var natParameters = new Dictionary<string, string>
{
["chain"] = "dstnat",
["protocol"] = forward.Protocol,
["dst-port"] = externalPort,
["in-interface"] = settings.WanInterfaceName,
["action"] = "dst-nat",
["to-addresses"] = forward.TargetAddress,
["to-ports"] = internalPort,
};
var filterParameters = new Dictionary<string, string>
{
["chain"] = "forward",
["protocol"] = forward.Protocol,
["dst-address"] = forward.TargetAddress,
["dst-port"] = internalPort,
["action"] = "accept",
};
if (!string.IsNullOrWhiteSpace(forward.AllowedSourceAddress))
{
natParameters["src-address"] = forward.AllowedSourceAddress;
filterParameters["src-address"] = forward.AllowedSourceAddress;
}
if (forward.LogRule)
{
natParameters["log"] = "yes";
filterParameters["log"] = "yes";
}
actions.Add(new ConfigurationAction(
$"Port {forward.ExternalPort}/{forward.Protocol.ToUpperInvariant()} von außen zu {forward.TargetAddress}:{forward.InternalPort} weiterleiten („{forward.Name}\")",
(client, ct) => client.AddAsync("/ip/firewall/nat", new Dictionary<string, string>
{
["chain"] = "dstnat",
["protocol"] = forward.Protocol,
["dst-port"] = forward.ExternalPort.ToString(),
["in-interface"] = settings.WanInterfaceName,
["action"] = "dst-nat",
["to-addresses"] = forward.TargetAddress,
["to-ports"] = forward.InternalPort.ToString(),
}, ct)));
$"Port {externalPort}/{forward.Protocol.ToUpperInvariant()} von außen zu {forward.TargetAddress}:{internalPort} weiterleiten („{forward.Name}\")",
(client, ct) => 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", new Dictionary<string, string>
{
["chain"] = "forward",
["protocol"] = forward.Protocol,
["dst-address"] = forward.TargetAddress,
["dst-port"] = forward.InternalPort.ToString(),
["action"] = "accept",
}, ct)));
(client, ct) => client.AddAsync("/ip/firewall/filter", filterParameters, ct)));
}
actions.Add(ForwardRule(
@@ -1,9 +1,21 @@
namespace MikrotikInstaller.Core.Configuration;
/// <summary>Eine Portfreigabe: leitet einen Port von außen zu einem bestimmten Gerät im Heimnetz weiter.</summary>
public sealed record PortForward(string Name, string Protocol, int ExternalPort, string TargetAddress, int InternalPort);
public sealed record PortForward(
string Name,
string Protocol,
int ExternalPort,
string TargetAddress,
int InternalPort,
// Experten-Optionen — null/false bedeutet: bisheriges Verhalten (von überall erreichbar, keine Log-Einträge).
string? AllowedSourceAddress = null,
bool LogRule = false,
int? ExternalPortRangeEnd = null,
int? InternalPortRangeEnd = null);
public sealed record FirewallSettings(
string WanInterfaceName,
string LanBridgeName,
IReadOnlyList<PortForward> PortForwards);
IReadOnlyList<PortForward> PortForwards,
// Experten-Option: CIDR-Adressen/-Bereiche, die grundsätzlich blockiert werden (leer = kein Sperrlisten-Eintrag, wie bisher).
IReadOnlyList<string>? BlockedAddresses = null);
@@ -48,24 +48,36 @@ public static class LanConfigurator
["ranges"] = $"{settings.DhcpPoolStart}-{settings.DhcpPoolEnd}",
}, ct)));
var dhcpServerParameters = new Dictionary<string, string>
{
["name"] = dhcpServerName,
["interface"] = settings.BridgeName,
["address-pool"] = poolName,
["disabled"] = "no",
};
if (!string.IsNullOrWhiteSpace(settings.DhcpLeaseTime))
{
dhcpServerParameters["lease-time"] = settings.DhcpLeaseTime;
}
actions.Add(new ConfigurationAction(
$"DHCP-Server auf „{settings.BridgeName}\" aktivieren, damit Geräte automatisch eine Adresse bekommen",
(client, ct) => client.AddAsync("/ip/dhcp-server", new Dictionary<string, string>
{
["name"] = dhcpServerName,
["interface"] = settings.BridgeName,
["address-pool"] = poolName,
["disabled"] = "no",
}, ct)));
(client, ct) => client.AddAsync("/ip/dhcp-server", dhcpServerParameters, ct)));
var networkParameters = new Dictionary<string, string>
{
["address"] = networkCidr,
["gateway"] = routerAddress,
["dns-server"] = routerAddress,
};
if (!string.IsNullOrWhiteSpace(settings.DomainName))
{
networkParameters["domain-name"] = settings.DomainName;
}
actions.Add(new ConfigurationAction(
$"Netzwerk-Einstellungen (Gateway {routerAddress}, DNS) für das Heimnetzwerk hinterlegen",
(client, ct) => client.AddAsync("/ip/dhcp-server/network", new Dictionary<string, string>
{
["address"] = networkCidr,
["gateway"] = routerAddress,
["dns-server"] = routerAddress,
}, ct)));
(client, ct) => client.AddAsync("/ip/dhcp-server/network", networkParameters, ct)));
var upstreamDns = settings.SecondaryDnsServer is null
? settings.PrimaryDnsServer
@@ -79,6 +91,17 @@ public static class LanConfigurator
["allow-remote-requests"] = "yes",
}, ct)));
if (!string.IsNullOrWhiteSpace(settings.NtpServer))
{
actions.Add(new ConfigurationAction(
$"Zeitserver (NTP) {settings.NtpServer} einrichten",
(client, ct) => client.ExecuteAsync("/system/ntp/client/set", new Dictionary<string, string>
{
["enabled"] = "yes",
["servers"] = settings.NtpServer,
}, ct)));
}
return actions;
}
@@ -7,4 +7,8 @@ public sealed record LanSettings(
string DhcpPoolStart,
string DhcpPoolEnd,
string PrimaryDnsServer,
string? SecondaryDnsServer);
string? SecondaryDnsServer,
// Experten-Optionen — null/leer bedeutet: RouterOS-Standardverhalten, wie bisher.
string? DhcpLeaseTime = null,
string? DomainName = null,
string? NtpServer = null);
@@ -43,15 +43,21 @@ public static class VlanConfigurator
["ranges"] = $"{vlan.DhcpPoolStart}-{vlan.DhcpPoolEnd}",
}, ct)));
var dhcpServerParameters = new Dictionary<string, string>
{
["name"] = dhcpServerName,
["interface"] = interfaceName,
["address-pool"] = poolName,
["disabled"] = "no",
};
if (!string.IsNullOrWhiteSpace(vlan.DhcpLeaseTime))
{
dhcpServerParameters["lease-time"] = vlan.DhcpLeaseTime;
}
actions.Add(new ConfigurationAction(
$"DHCP-Server für VLAN „{vlan.Name}\" aktivieren",
(client, ct) => client.AddAsync("/ip/dhcp-server", new Dictionary<string, string>
{
["name"] = dhcpServerName,
["interface"] = interfaceName,
["address-pool"] = poolName,
["disabled"] = "no",
}, ct)));
(client, ct) => client.AddAsync("/ip/dhcp-server", dhcpServerParameters, ct)));
actions.Add(new ConfigurationAction(
$"Netzwerk-Einstellungen (Gateway {routerAddress}) für VLAN „{vlan.Name}\" hinterlegen",
@@ -59,8 +65,31 @@ public static class VlanConfigurator
{
["address"] = networkCidr,
["gateway"] = routerAddress,
["dns-server"] = routerAddress,
["dns-server"] = string.IsNullOrWhiteSpace(vlan.DnsServerOverride) ? routerAddress : vlan.DnsServerOverride,
}, ct)));
if (vlan.IsolateFromLan)
{
actions.Add(new ConfigurationAction(
$"VLAN „{vlan.Name}\" vom Heimnetz „{parentBridgeName}\" isolieren",
(client, ct) => client.AddAsync("/ip/firewall/filter", new Dictionary<string, string>
{
["chain"] = "forward",
["in-interface"] = interfaceName,
["out-interface"] = parentBridgeName,
["action"] = "drop",
}, ct)));
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>
{
["chain"] = "forward",
["in-interface"] = parentBridgeName,
["out-interface"] = interfaceName,
["action"] = "drop",
}, ct)));
}
}
return actions;
@@ -6,4 +6,8 @@ public sealed record VlanDefinition(
string Name,
string RouterAddressCidr,
string DhcpPoolStart,
string DhcpPoolEnd);
string DhcpPoolEnd,
// Experten-Optionen — Defaults entsprechen dem bisherigen (Laien-)Verhalten.
bool IsolateFromLan = false,
string? DnsServerOverride = null,
string? DhcpLeaseTime = null);
@@ -15,6 +15,8 @@ public static class WanConfigurator
{
["interface"] = settings.InterfaceName,
["disabled"] = "no",
["add-default-route"] = settings.UseDhcpDefaultRoute ? "yes" : "no",
["use-peer-dns"] = settings.UseDhcpDns ? "yes" : "no",
}, ct)));
}
else
@@ -36,14 +38,33 @@ public static class WanConfigurator
}, ct)));
}
var natParameters = new Dictionary<string, string>
{
["chain"] = "srcnat",
["out-interface"] = settings.InterfaceName,
["action"] = "masquerade",
};
if (settings.NatSourceAddress is not null)
{
natParameters["src-address"] = settings.NatSourceAddress;
}
actions.Add(new ConfigurationAction(
$"Datenverkehr aus deinem Heimnetz über „{settings.InterfaceName}\" ins Internet weiterleiten (NAT)",
(client, ct) => client.AddAsync("/ip/firewall/nat", new Dictionary<string, string>
{
["chain"] = "srcnat",
["out-interface"] = settings.InterfaceName,
["action"] = "masquerade",
}, ct)));
settings.NatSourceAddress is null
? $"Datenverkehr aus deinem Heimnetz über „{settings.InterfaceName}\" ins Internet weiterleiten (NAT)"
: $"Datenverkehr aus {settings.NatSourceAddress} über „{settings.InterfaceName}\" ins Internet weiterleiten (NAT)",
(client, ct) => client.AddAsync("/ip/firewall/nat", natParameters, ct)));
if (settings.Mtu is not null)
{
actions.Add(new ConfigurationAction(
$"MTU auf „{settings.InterfaceName}\" auf {settings.Mtu} setzen",
(client, ct) => client.ExecuteAsync("/interface/ethernet/set", new Dictionary<string, string>
{
["numbers"] = settings.InterfaceName,
["mtu"] = settings.Mtu.Value.ToString(),
}, ct)));
}
return actions;
}
@@ -13,4 +13,9 @@ public sealed record WanSettings(
string InterfaceName,
WanAddressMode Mode,
string? StaticAddressCidr = null,
string? StaticGateway = null);
string? StaticGateway = null,
// Experten-Optionen — Defaults entsprechen dem bisherigen (Laien-)Verhalten.
bool UseDhcpDefaultRoute = true,
bool UseDhcpDns = true,
int? Mtu = null,
string? NatSourceAddress = null);
@@ -6,34 +6,57 @@ public static class WirelessConfigurator
public static IReadOnlyList<ConfigurationAction> BuildActions(WirelessSettings settings)
{
var profileName = $"profile-{settings.InterfaceName}";
var authenticationTypes = settings.SecurityMode switch
{
WirelessSecurityMode.Wpa3 => "wpa3-psk",
WirelessSecurityMode.Wpa2Wpa3Mixed => "wpa2-psk,wpa3-psk",
_ => "wpa2-psk",
};
var setParameters = new Dictionary<string, string>
{
["numbers"] = settings.InterfaceName,
["ssid"] = settings.Ssid,
["security-profile"] = profileName,
["disabled"] = "no",
["hide-ssid"] = settings.HideSsid ? "yes" : "no",
};
if (!string.IsNullOrWhiteSpace(settings.Band))
{
setParameters["band"] = settings.Band;
}
var securityProfileParameters = new Dictionary<string, string>
{
["name"] = profileName,
["mode"] = "dynamic-keys",
["authentication-types"] = authenticationTypes,
["wpa2-pre-shared-key"] = settings.Password,
};
if (settings.SecurityMode is WirelessSecurityMode.Wpa3 or WirelessSecurityMode.Wpa2Wpa3Mixed)
{
securityProfileParameters["wpa3-pre-shared-key"] = settings.Password;
}
return
[
new ConfigurationAction(
$"WLAN-Sicherheitsprofil für „{settings.Ssid}\" anlegen (WPA2)",
(client, ct) => client.AddAsync("/interface/wireless/security-profiles", new Dictionary<string, string>
{
["name"] = profileName,
["mode"] = "dynamic-keys",
["authentication-types"] = "wpa2-psk",
["wpa2-pre-shared-key"] = settings.Password,
}, ct)),
$"WLAN-Sicherheitsprofil für „{settings.Ssid}\" anlegen ({authenticationTypes})",
(client, ct) => client.AddAsync("/interface/wireless/security-profiles", securityProfileParameters, ct)),
// "numbers" ist die RouterOS-API-Konvention, um einen Eintrag anhand seines Namens statt
// seiner internen .id zu adressieren — vermeidet einen zusätzlichen Nachschlage-Aufruf.
new ConfigurationAction(
$"WLAN „{settings.Ssid}\" auf „{settings.InterfaceName}\" aktivieren",
(client, ct) => client.ExecuteAsync("/interface/wireless/set", new Dictionary<string, string>
{
["numbers"] = settings.InterfaceName,
["ssid"] = settings.Ssid,
["security-profile"] = profileName,
["disabled"] = "no",
}, ct)),
(client, ct) => client.ExecuteAsync("/interface/wireless/set", setParameters, ct)),
];
}
/// <summary>Kurze, laientaugliche Zusammenfassung für den Zusammenfassungs-Schritt (keine RouterOS-Fachbegriffe).</summary>
public static IReadOnlyList<string> BuildFriendlySummary(WirelessSettings settings) =>
[$"WLAN „{settings.Ssid}\" ist eingerichtet und mit einem Passwort geschützt."];
[
settings.HideSsid
? $"WLAN „{settings.Ssid}\" ist eingerichtet, mit einem Passwort geschützt und in der Netzwerkliste unsichtbar (muss manuell eingegeben werden)."
: $"WLAN „{settings.Ssid}\" ist eingerichtet und mit einem Passwort geschützt.",
];
}
@@ -0,0 +1,14 @@
namespace MikrotikInstaller.Core.Configuration;
/// <summary>WLAN-Sicherheitsstandard für das erzeugte Sicherheitsprofil.</summary>
public enum WirelessSecurityMode
{
/// <summary>WPA2-PSK — kompatibel mit praktisch jedem Gerät, bisheriger Standardwert.</summary>
Wpa2,
/// <summary>WPA3-PSK — moderner, sicherer, aber nicht von jedem älteren Gerät unterstützt.</summary>
Wpa3,
/// <summary>WPA2/WPA3 gemischt — ältere und neuere Geräte können sich gleichzeitig verbinden.</summary>
Wpa2Wpa3Mixed,
}
@@ -1,3 +1,10 @@
namespace MikrotikInstaller.Core.Configuration;
public sealed record WirelessSettings(string InterfaceName, string Ssid, string Password);
public sealed record WirelessSettings(
string InterfaceName,
string Ssid,
string Password,
// Experten-Optionen — Defaults entsprechen dem bisherigen (Laien-)Verhalten (WPA2, sichtbare SSID, Auto-Band).
WirelessSecurityMode SecurityMode = WirelessSecurityMode.Wpa2,
bool HideSsid = false,
string? Band = null);