diff --git a/src/MikrotikInstaller.App/App.xaml b/src/MikrotikInstaller.App/App.xaml index b06a565..ee6bbc3 100644 --- a/src/MikrotikInstaller.App/App.xaml +++ b/src/MikrotikInstaller.App/App.xaml @@ -30,6 +30,9 @@ + + + diff --git a/src/MikrotikInstaller.App/MainWindow.xaml b/src/MikrotikInstaller.App/MainWindow.xaml index 3ca8802..33bec91 100644 --- a/src/MikrotikInstaller.App/MainWindow.xaml +++ b/src/MikrotikInstaller.App/MainWindow.xaml @@ -147,7 +147,10 @@ - + + diff --git a/src/MikrotikInstaller.App/MainWindow.xaml.cs b/src/MikrotikInstaller.App/MainWindow.xaml.cs index cca8f2d..62ff983 100644 --- a/src/MikrotikInstaller.App/MainWindow.xaml.cs +++ b/src/MikrotikInstaller.App/MainWindow.xaml.cs @@ -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); diff --git a/src/MikrotikInstaller.App/ViewModels/MainViewModel.cs b/src/MikrotikInstaller.App/ViewModels/MainViewModel.cs index e7211dd..7981b93 100644 --- a/src/MikrotikInstaller.App/ViewModels/MainViewModel.cs +++ b/src/MikrotikInstaller.App/ViewModels/MainViewModel.cs @@ -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; + /// Wird ausgelöst, wenn der Nutzer auf dem letzten Schritt „Beenden" klickt. + public event EventHandler? CloseRequested; + + [RelayCommand] + private void Finish() => CloseRequested?.Invoke(this, EventArgs.Empty); + /// Wird beim Schließen des Hauptfensters aufgerufen, um die Geräteverbindung sauber zu beenden. public Task DisposeSessionAsync() => _session.DisposeClientAsync(); } diff --git a/src/MikrotikInstaller.App/ViewModels/Steps/FirewallStepViewModel.cs b/src/MikrotikInstaller.App/ViewModels/Steps/FirewallStepViewModel.cs index 0116e4e..7d4a21c 100644 --- a/src/MikrotikInstaller.App/ViewModels/Steps/FirewallStepViewModel.cs +++ b/src/MikrotikInstaller.App/ViewModels/Steps/FirewallStepViewModel.cs @@ -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 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); } diff --git a/src/MikrotikInstaller.App/ViewModels/Steps/LanStepViewModel.cs b/src/MikrotikInstaller.App/ViewModels/Steps/LanStepViewModel.cs index 307e488..1442b43 100644 --- a/src/MikrotikInstaller.App/ViewModels/Steps/LanStepViewModel.cs +++ b/src/MikrotikInstaller.App/ViewModels/Steps/LanStepViewModel.cs @@ -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); diff --git a/src/MikrotikInstaller.App/ViewModels/Steps/ModeSelectionStepViewModel.cs b/src/MikrotikInstaller.App/ViewModels/Steps/ModeSelectionStepViewModel.cs new file mode 100644 index 0000000..7aa2b06 --- /dev/null +++ b/src/MikrotikInstaller.App/ViewModels/Steps/ModeSelectionStepViewModel.cs @@ -0,0 +1,34 @@ +using CommunityToolkit.Mvvm.ComponentModel; + +namespace MikrotikInstaller.App.ViewModels.Steps; + +/// +/// 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. +/// +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; +} diff --git a/src/MikrotikInstaller.App/ViewModels/Steps/PortForwardEntry.cs b/src/MikrotikInstaller.App/ViewModels/Steps/PortForwardEntry.cs index 9cf9d8a..cbef93a 100644 --- a/src/MikrotikInstaller.App/ViewModels/Steps/PortForwardEntry.cs +++ b/src/MikrotikInstaller.App/ViewModels/Steps/PortForwardEntry.cs @@ -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; } diff --git a/src/MikrotikInstaller.App/ViewModels/Steps/VlanEntry.cs b/src/MikrotikInstaller.App/ViewModels/Steps/VlanEntry.cs index ae52c0b..ea9e940 100644 --- a/src/MikrotikInstaller.App/ViewModels/Steps/VlanEntry.cs +++ b/src/MikrotikInstaller.App/ViewModels/Steps/VlanEntry.cs @@ -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; } diff --git a/src/MikrotikInstaller.App/ViewModels/Steps/VlanStepViewModel.cs b/src/MikrotikInstaller.App/ViewModels/Steps/VlanStepViewModel.cs index b4d793b..c5ece60 100644 --- a/src/MikrotikInstaller.App/ViewModels/Steps/VlanStepViewModel.cs +++ b/src/MikrotikInstaller.App/ViewModels/Steps/VlanStepViewModel.cs @@ -23,10 +23,13 @@ public partial class VlanStepViewModel : WizardStepViewModelBase public ObservableCollection 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; diff --git a/src/MikrotikInstaller.App/ViewModels/Steps/WanStepViewModel.cs b/src/MikrotikInstaller.App/ViewModels/Steps/WanStepViewModel.cs index 2ff0d92..4c181f2 100644 --- a/src/MikrotikInstaller.App/ViewModels/Steps/WanStepViewModel.cs +++ b/src/MikrotikInstaller.App/ViewModels/Steps/WanStepViewModel.cs @@ -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); diff --git a/src/MikrotikInstaller.App/ViewModels/Steps/WlanStepViewModel.cs b/src/MikrotikInstaller.App/ViewModels/Steps/WlanStepViewModel.cs index 9aac5b5..479a6d5 100644 --- a/src/MikrotikInstaller.App/ViewModels/Steps/WlanStepViewModel.cs +++ b/src/MikrotikInstaller.App/ViewModels/Steps/WlanStepViewModel.cs @@ -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 SecurityModes { get; } = Enum.GetValues(); + + 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); } diff --git a/src/MikrotikInstaller.App/ViewModels/WizardSession.cs b/src/MikrotikInstaller.App/ViewModels/WizardSession.cs index 2858944..4a7a7aa 100644 --- a/src/MikrotikInstaller.App/ViewModels/WizardSession.cs +++ b/src/MikrotikInstaller.App/ViewModels/WizardSession.cs @@ -16,6 +16,12 @@ public sealed class WizardSession public RouterDevice? Device { get; set; } + /// + /// 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). + /// + public bool IsExpertMode { get; set; } + /// Vom WAN-Schritt gewählte Internet-Schnittstelle, damit spätere Schritte sie referenzieren können. public string? WanInterfaceName { get; set; } diff --git a/src/MikrotikInstaller.App/Views/Steps/FirewallStepView.xaml b/src/MikrotikInstaller.App/Views/Steps/FirewallStepView.xaml index 9778b65..6a7177a 100644 --- a/src/MikrotikInstaller.App/Views/Steps/FirewallStepView.xaml +++ b/src/MikrotikInstaller.App/Views/Steps/FirewallStepView.xaml @@ -75,13 +75,58 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + + + + + + + + + diff --git a/src/MikrotikInstaller.App/Views/Steps/LanStepView.xaml b/src/MikrotikInstaller.App/Views/Steps/LanStepView.xaml index 64f6513..af54840 100644 --- a/src/MikrotikInstaller.App/Views/Steps/LanStepView.xaml +++ b/src/MikrotikInstaller.App/Views/Steps/LanStepView.xaml @@ -87,5 +87,27 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/MikrotikInstaller.App/Views/Steps/ModeSelectionStepView.xaml b/src/MikrotikInstaller.App/Views/Steps/ModeSelectionStepView.xaml new file mode 100644 index 0000000..422112a --- /dev/null +++ b/src/MikrotikInstaller.App/Views/Steps/ModeSelectionStepView.xaml @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/MikrotikInstaller.App/Views/Steps/ModeSelectionStepView.xaml.cs b/src/MikrotikInstaller.App/Views/Steps/ModeSelectionStepView.xaml.cs new file mode 100644 index 0000000..49f85d8 --- /dev/null +++ b/src/MikrotikInstaller.App/Views/Steps/ModeSelectionStepView.xaml.cs @@ -0,0 +1,11 @@ +using System.Windows.Controls; + +namespace MikrotikInstaller.App.Views.Steps; + +public partial class ModeSelectionStepView : UserControl +{ + public ModeSelectionStepView() + { + InitializeComponent(); + } +} diff --git a/src/MikrotikInstaller.App/Views/Steps/VlanStepView.xaml b/src/MikrotikInstaller.App/Views/Steps/VlanStepView.xaml index 4cd3829..c2ed645 100644 --- a/src/MikrotikInstaller.App/Views/Steps/VlanStepView.xaml +++ b/src/MikrotikInstaller.App/Views/Steps/VlanStepView.xaml @@ -63,6 +63,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/MikrotikInstaller.App/Views/Steps/WanStepView.xaml b/src/MikrotikInstaller.App/Views/Steps/WanStepView.xaml index df0af09..710b04f 100644 --- a/src/MikrotikInstaller.App/Views/Steps/WanStepView.xaml +++ b/src/MikrotikInstaller.App/Views/Steps/WanStepView.xaml @@ -35,5 +35,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/MikrotikInstaller.App/Views/Steps/WlanStepView.xaml b/src/MikrotikInstaller.App/Views/Steps/WlanStepView.xaml index 8831050..148a651 100644 --- a/src/MikrotikInstaller.App/Views/Steps/WlanStepView.xaml +++ b/src/MikrotikInstaller.App/Views/Steps/WlanStepView.xaml @@ -32,6 +32,28 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/src/MikrotikInstaller.Core.Tests/Configuration/FirewallConfiguratorTests.cs b/src/MikrotikInstaller.Core.Tests/Configuration/FirewallConfiguratorTests.cs index 1e2052d..f402514 100644 --- a/src/MikrotikInstaller.Core.Tests/Configuration/FirewallConfiguratorTests.cs +++ b/src/MikrotikInstaller.Core.Tests/Configuration/FirewallConfiguratorTests.cs @@ -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() { diff --git a/src/MikrotikInstaller.Core.Tests/Configuration/LanConfiguratorTests.cs b/src/MikrotikInstaller.Core.Tests/Configuration/LanConfiguratorTests.cs index c4b5dcd..1c8701e 100644 --- a/src/MikrotikInstaller.Core.Tests/Configuration/LanConfiguratorTests.cs +++ b/src/MikrotikInstaller.Core.Tests/Configuration/LanConfiguratorTests.cs @@ -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() { diff --git a/src/MikrotikInstaller.Core.Tests/Configuration/VlanConfiguratorTests.cs b/src/MikrotikInstaller.Core.Tests/Configuration/VlanConfiguratorTests.cs index ccb37ee..5af4950 100644 --- a/src/MikrotikInstaller.Core.Tests/Configuration/VlanConfiguratorTests.cs +++ b/src/MikrotikInstaller.Core.Tests/Configuration/VlanConfiguratorTests.cs @@ -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 + { + 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() { diff --git a/src/MikrotikInstaller.Core.Tests/Configuration/WanConfiguratorTests.cs b/src/MikrotikInstaller.Core.Tests/Configuration/WanConfiguratorTests.cs index 6234fa0..b378fe4 100644 --- a/src/MikrotikInstaller.Core.Tests/Configuration/WanConfiguratorTests.cs +++ b/src/MikrotikInstaller.Core.Tests/Configuration/WanConfiguratorTests.cs @@ -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() { diff --git a/src/MikrotikInstaller.Core.Tests/Configuration/WirelessConfiguratorTests.cs b/src/MikrotikInstaller.Core.Tests/Configuration/WirelessConfiguratorTests.cs index 0a1ee41..d3f23ea 100644 --- a/src/MikrotikInstaller.Core.Tests/Configuration/WirelessConfiguratorTests.cs +++ b/src/MikrotikInstaller.Core.Tests/Configuration/WirelessConfiguratorTests.cs @@ -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() { diff --git a/src/MikrotikInstaller.Core/Configuration/FirewallConfigurator.cs b/src/MikrotikInstaller.Core/Configuration/FirewallConfigurator.cs index d1d4ff8..4074408 100644 --- a/src/MikrotikInstaller.Core/Configuration/FirewallConfigurator.cs +++ b/src/MikrotikInstaller.Core/Configuration/FirewallConfigurator.cs @@ -25,32 +25,75 @@ public static class FirewallConfigurator new Dictionary { ["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 + { + ["list"] = blockListName, + ["address"] = address, + }, ct))); + } + + actions.Add(InputRule( + "Zugriff auf die Router-Einstellungen von gesperrten Adressen blockieren", + new Dictionary { ["src-address-list"] = blockListName, ["action"] = "drop" })); + actions.Add(ForwardRule( + "Datenverkehr von gesperrten Adressen blockieren", + new Dictionary { ["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 + { + ["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 + { + ["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 - { - ["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 - { - ["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( diff --git a/src/MikrotikInstaller.Core/Configuration/FirewallSettings.cs b/src/MikrotikInstaller.Core/Configuration/FirewallSettings.cs index a85bf9e..b284b59 100644 --- a/src/MikrotikInstaller.Core/Configuration/FirewallSettings.cs +++ b/src/MikrotikInstaller.Core/Configuration/FirewallSettings.cs @@ -1,9 +1,21 @@ namespace MikrotikInstaller.Core.Configuration; /// Eine Portfreigabe: leitet einen Port von außen zu einem bestimmten Gerät im Heimnetz weiter. -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 PortForwards); + IReadOnlyList PortForwards, + // Experten-Option: CIDR-Adressen/-Bereiche, die grundsätzlich blockiert werden (leer = kein Sperrlisten-Eintrag, wie bisher). + IReadOnlyList? BlockedAddresses = null); diff --git a/src/MikrotikInstaller.Core/Configuration/LanConfigurator.cs b/src/MikrotikInstaller.Core/Configuration/LanConfigurator.cs index ae17ab1..93d53ea 100644 --- a/src/MikrotikInstaller.Core/Configuration/LanConfigurator.cs +++ b/src/MikrotikInstaller.Core/Configuration/LanConfigurator.cs @@ -48,24 +48,36 @@ public static class LanConfigurator ["ranges"] = $"{settings.DhcpPoolStart}-{settings.DhcpPoolEnd}", }, ct))); + var dhcpServerParameters = new Dictionary + { + ["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 - { - ["name"] = dhcpServerName, - ["interface"] = settings.BridgeName, - ["address-pool"] = poolName, - ["disabled"] = "no", - }, ct))); + (client, ct) => client.AddAsync("/ip/dhcp-server", dhcpServerParameters, ct))); + + var networkParameters = new Dictionary + { + ["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 - { - ["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 + { + ["enabled"] = "yes", + ["servers"] = settings.NtpServer, + }, ct))); + } + return actions; } diff --git a/src/MikrotikInstaller.Core/Configuration/LanSettings.cs b/src/MikrotikInstaller.Core/Configuration/LanSettings.cs index 57f34b9..f6d901d 100644 --- a/src/MikrotikInstaller.Core/Configuration/LanSettings.cs +++ b/src/MikrotikInstaller.Core/Configuration/LanSettings.cs @@ -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); diff --git a/src/MikrotikInstaller.Core/Configuration/VlanConfigurator.cs b/src/MikrotikInstaller.Core/Configuration/VlanConfigurator.cs index 27f85ee..5bf047a 100644 --- a/src/MikrotikInstaller.Core/Configuration/VlanConfigurator.cs +++ b/src/MikrotikInstaller.Core/Configuration/VlanConfigurator.cs @@ -43,15 +43,21 @@ public static class VlanConfigurator ["ranges"] = $"{vlan.DhcpPoolStart}-{vlan.DhcpPoolEnd}", }, ct))); + var dhcpServerParameters = new Dictionary + { + ["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 - { - ["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 + { + ["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 + { + ["chain"] = "forward", + ["in-interface"] = parentBridgeName, + ["out-interface"] = interfaceName, + ["action"] = "drop", + }, ct))); + } } return actions; diff --git a/src/MikrotikInstaller.Core/Configuration/VlanSettings.cs b/src/MikrotikInstaller.Core/Configuration/VlanSettings.cs index 40244ba..32bf7d0 100644 --- a/src/MikrotikInstaller.Core/Configuration/VlanSettings.cs +++ b/src/MikrotikInstaller.Core/Configuration/VlanSettings.cs @@ -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); diff --git a/src/MikrotikInstaller.Core/Configuration/WanConfigurator.cs b/src/MikrotikInstaller.Core/Configuration/WanConfigurator.cs index ac7a99e..b517d4d 100644 --- a/src/MikrotikInstaller.Core/Configuration/WanConfigurator.cs +++ b/src/MikrotikInstaller.Core/Configuration/WanConfigurator.cs @@ -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 + { + ["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 - { - ["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 + { + ["numbers"] = settings.InterfaceName, + ["mtu"] = settings.Mtu.Value.ToString(), + }, ct))); + } return actions; } diff --git a/src/MikrotikInstaller.Core/Configuration/WanSettings.cs b/src/MikrotikInstaller.Core/Configuration/WanSettings.cs index c5dbf8e..871124b 100644 --- a/src/MikrotikInstaller.Core/Configuration/WanSettings.cs +++ b/src/MikrotikInstaller.Core/Configuration/WanSettings.cs @@ -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); diff --git a/src/MikrotikInstaller.Core/Configuration/WirelessConfigurator.cs b/src/MikrotikInstaller.Core/Configuration/WirelessConfigurator.cs index 3e83bb0..bba1f44 100644 --- a/src/MikrotikInstaller.Core/Configuration/WirelessConfigurator.cs +++ b/src/MikrotikInstaller.Core/Configuration/WirelessConfigurator.cs @@ -6,34 +6,57 @@ public static class WirelessConfigurator public static IReadOnlyList 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 + { + ["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 + { + ["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 - { - ["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 - { - ["numbers"] = settings.InterfaceName, - ["ssid"] = settings.Ssid, - ["security-profile"] = profileName, - ["disabled"] = "no", - }, ct)), + (client, ct) => client.ExecuteAsync("/interface/wireless/set", setParameters, ct)), ]; } /// Kurze, laientaugliche Zusammenfassung für den Zusammenfassungs-Schritt (keine RouterOS-Fachbegriffe). public static IReadOnlyList 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.", + ]; } diff --git a/src/MikrotikInstaller.Core/Configuration/WirelessSecurityMode.cs b/src/MikrotikInstaller.Core/Configuration/WirelessSecurityMode.cs new file mode 100644 index 0000000..833211c --- /dev/null +++ b/src/MikrotikInstaller.Core/Configuration/WirelessSecurityMode.cs @@ -0,0 +1,14 @@ +namespace MikrotikInstaller.Core.Configuration; + +/// WLAN-Sicherheitsstandard für das erzeugte Sicherheitsprofil. +public enum WirelessSecurityMode +{ + /// WPA2-PSK — kompatibel mit praktisch jedem Gerät, bisheriger Standardwert. + Wpa2, + + /// WPA3-PSK — moderner, sicherer, aber nicht von jedem älteren Gerät unterstützt. + Wpa3, + + /// WPA2/WPA3 gemischt — ältere und neuere Geräte können sich gleichzeitig verbinden. + Wpa2Wpa3Mixed, +} diff --git a/src/MikrotikInstaller.Core/Configuration/WirelessSettings.cs b/src/MikrotikInstaller.Core/Configuration/WirelessSettings.cs index 5a86ea5..b389e6d 100644 --- a/src/MikrotikInstaller.Core/Configuration/WirelessSettings.cs +++ b/src/MikrotikInstaller.Core/Configuration/WirelessSettings.cs @@ -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);