Experten-Modus hinzufügen: erweiterte RouterOS-Optionen in WAN/LAN/VLAN/WLAN/Firewall
Build / build (push) Canceled after 0s
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:
1 parent
831f610753
commit
e58480d2ae
36 files changed
+783
-78
No files matched your search
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
Reference in new issue
Block a user