diff --git a/src/MikrotikInstaller.App/App.xaml b/src/MikrotikInstaller.App/App.xaml index 92bc9cb..6b1955b 100644 --- a/src/MikrotikInstaller.App/App.xaml +++ b/src/MikrotikInstaller.App/App.xaml @@ -37,6 +37,9 @@ + + + diff --git a/src/MikrotikInstaller.App/ViewModels/AppArea.cs b/src/MikrotikInstaller.App/ViewModels/AppArea.cs index dad2924..8ade670 100644 --- a/src/MikrotikInstaller.App/ViewModels/AppArea.cs +++ b/src/MikrotikInstaller.App/ViewModels/AppArea.cs @@ -12,4 +12,7 @@ public enum AppArea /// Direkter Zugriff auf Backups (auflisten, erstellen, wiederherstellen) ohne den restlichen Assistenten. BackupManagement, + + /// Generischer Zugriff auf RouterOS-Menüs plus Werkzeuge (Issue #21), nur mit „Professioneller Netzwerker“. + Expert, } diff --git a/src/MikrotikInstaller.App/ViewModels/MainViewModel.cs b/src/MikrotikInstaller.App/ViewModels/MainViewModel.cs index 92b6afb..a4c00d8 100644 --- a/src/MikrotikInstaller.App/ViewModels/MainViewModel.cs +++ b/src/MikrotikInstaller.App/ViewModels/MainViewModel.cs @@ -23,6 +23,7 @@ public partial class MainViewModel : ObservableObject new ConnectionStepViewModel(_session), new AreaSelectionStepViewModel(_session), new BackupManagementStepViewModel(_session), + new ExpertStepViewModel(_session), new DeviceOverviewStepViewModel(_session), new NetworkAnalysisStepViewModel(_session), new LanScanStepViewModel(_session), diff --git a/src/MikrotikInstaller.App/ViewModels/Steps/AreaSelectionStepViewModel.cs b/src/MikrotikInstaller.App/ViewModels/Steps/AreaSelectionStepViewModel.cs index 7a96351..e8b4071 100644 --- a/src/MikrotikInstaller.App/ViewModels/Steps/AreaSelectionStepViewModel.cs +++ b/src/MikrotikInstaller.App/ViewModels/Steps/AreaSelectionStepViewModel.cs @@ -19,18 +19,26 @@ public partial class AreaSelectionStepViewModel : WizardStepViewModelBase description: "Wähle, ob du dein Gerät komplett neu einrichten oder Backups und Wartung erledigen möchtest.") { _session = session; - _isBackupManagement = session.SelectedArea == AppArea.BackupManagement; + _area = session.SelectedArea; } [ObservableProperty] - private bool _isBackupManagement; + private AppArea _area; + + /// Der Experte-Bereich ist nur mit „Professioneller Netzwerker“ sichtbar (Mac v1.3.0). + public bool ShowExpert => UserSettings.Current.ProfessionalMode; public override Task OnActivatedAsync() { - IsBackupManagement = _session.SelectedArea == AppArea.BackupManagement; + if (_session.SelectedArea == AppArea.Expert && !ShowExpert) + { + _session.SelectedArea = AppArea.SetupWizard; + } + + Area = _session.SelectedArea; + OnPropertyChanged(nameof(ShowExpert)); return Task.CompletedTask; } - partial void OnIsBackupManagementChanged(bool value) => - _session.SelectedArea = value ? AppArea.BackupManagement : AppArea.SetupWizard; + partial void OnAreaChanged(AppArea value) => _session.SelectedArea = value; } diff --git a/src/MikrotikInstaller.App/ViewModels/Steps/DeviceOverviewStepViewModel.cs b/src/MikrotikInstaller.App/ViewModels/Steps/DeviceOverviewStepViewModel.cs index a8d5012..24ea8b3 100644 --- a/src/MikrotikInstaller.App/ViewModels/Steps/DeviceOverviewStepViewModel.cs +++ b/src/MikrotikInstaller.App/ViewModels/Steps/DeviceOverviewStepViewModel.cs @@ -42,7 +42,7 @@ public partial class DeviceOverviewStepViewModel : WizardStepViewModelBase public override bool CanGoNext => Device is not null; /// Bei "Backup verwalten" liest die eigenständige Backup-Sektion das Gerät selbst aus, ohne diesen Schritt. - public override bool ShouldSkip => _session.SelectedArea == AppArea.BackupManagement; + public override bool ShouldSkip => _session.SelectedArea != AppArea.SetupWizard; private readonly List _serialClicks = []; diff --git a/src/MikrotikInstaller.App/ViewModels/Steps/ExpertStepViewModel.cs b/src/MikrotikInstaller.App/ViewModels/Steps/ExpertStepViewModel.cs new file mode 100644 index 0000000..a7f11df --- /dev/null +++ b/src/MikrotikInstaller.App/ViewModels/Steps/ExpertStepViewModel.cs @@ -0,0 +1,434 @@ +using System.Collections.ObjectModel; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using MikrotikInstaller.Core.Connectivity; +using MikrotikInstaller.Core.Expert; + +namespace MikrotikInstaller.App.ViewModels.Steps; + +/// Ein Eintrag in der Menüliste links: ein kuratiertes Menü oder ein Werkzeug. +public sealed record ExpertMenuItem(string Title, string Group, string? Path, MenuSchema? Schema, string? Tool = null); + +/// Ein Listeneintrag eines Menüs. +public sealed class ExpertEntryViewModel(IReadOnlyDictionary row, MenuSchema? schema) +{ + public IReadOnlyDictionary Row { get; } = row; + + public string? Id => Row.GetValueOrDefault(".id"); + + /// Die wichtigsten Spalten des Menüs, sonst die ersten sinnvollen Felder. + public string Summary + { + get + { + var keys = schema?.Columns is { Count: > 0 } columns + ? columns + : Row.Keys.Where(k => !k.StartsWith('.') && k is not ("disabled" or "dynamic" or "invalid")).Take(4).ToList(); + var parts = keys.Select(k => Row.GetValueOrDefault(k)).Where(v => !string.IsNullOrEmpty(v)); + var text = string.Join(" · ", parts); + return text.Length > 0 ? text : Id ?? "(Eintrag)"; + } + } + + public bool IsDisabled => ExpertMenus.IsDisabled(Row); + + public bool IsDynamic => ExpertMenus.IsTrue(Row, "dynamic"); + + public bool CanDelete => ExpertMenus.CanDelete(schema, Row); +} + +/// Ein Feld im Bearbeiten-Bereich, mit Hilfe aus dem Katalog. +public partial class ExpertFieldViewModel : ObservableObject +{ + public ExpertFieldViewModel(string key, string label, string help, string kind, IReadOnlyList options, string original) + { + Key = key; + Label = label; + Help = help; + Kind = kind; + Options = options; + Original = kind == "bool" ? NormalizeBool(original) : original; + _value = Original; + } + + public string Key { get; } + + public string Label { get; } + + public string Help { get; } + + public string Kind { get; } + + public IReadOnlyList Options { get; } + + public string Original { get; } + + public bool IsBool => Kind == "bool"; + + public bool HasOptions => !IsBool && Options.Count > 0; + + public bool IsText => !IsBool && Options.Count == 0; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsChecked))] + private string _value; + + public bool IsChecked + { + get => Value == "true"; + set => Value = value ? "true" : "false"; + } + + /// RouterOS liefert "true"/"false", akzeptiert aber auch "yes"/"no" — einheitlich vergleichen. + private static string NormalizeBool(string value) => value switch + { + "yes" or "true" => "true", + "no" or "false" => "false", + _ => value, + }; +} + +/// +/// Experte-Bereich (Issue #21, Mac „Experte“-Tab): kuratierte RouterOS-Menüs mit Feldhilfen und ein +/// eigener Menüpfad für alles andere. Auflisten, bearbeiten, anlegen, löschen — jede Änderung zeigt vorher +/// den genauen Befehl, vor der ersten Änderung entsteht eine Sicherung. Nur mit „Professioneller Netzwerker“. +/// +public partial class ExpertStepViewModel : WizardStepViewModelBase +{ + private readonly WizardSession _session; + private IReadOnlyDictionary? _editingRow; + private bool _safetyBackupDone; + + public ExpertStepViewModel(WizardSession session) + : base( + title: "Experte", + description: "Direkter Zugriff auf RouterOS-Menüs. Jede Änderung zeigt vorher den genauen Befehl; vor der ersten Änderung wird eine Sicherung erstellt.") + { + _session = session; + Menus = [.. ExpertTools.Items, .. MenuCatalog.All.Select(m => new ExpertMenuItem(m.Title, m.Group, m.Path, m))]; + Tools = new ExpertTools(session); + } + + public override bool ShouldSkip => _session.SelectedArea != AppArea.Expert; + + public IReadOnlyList Menus { get; } + + /// Werkzeuge (Netzwerke isolieren, Neues LAN, Port in Bridge, Pakete). + public ExpertTools Tools { get; } + + public ObservableCollection Entries { get; } = []; + + public ObservableCollection Fields { get; } = []; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsMenuSelected))] + [NotifyPropertyChangedFor(nameof(IsToolSelected))] + [NotifyPropertyChangedFor(nameof(CanAdd))] + private ExpertMenuItem? _selectedMenu; + + public bool IsMenuSelected => SelectedMenu is { Tool: null }; + + public bool IsToolSelected => SelectedMenu is { Tool: not null }; + + public bool CanAdd => SelectedMenu?.Schema is not { Singleton: true } and not { FixedList: true } && IsMenuSelected; + + [ObservableProperty] + private string _customPath = string.Empty; + + [ObservableProperty] + private bool _isLoading; + + [ObservableProperty] + private string? _errorMessage; + + [ObservableProperty] + private string? _statusMessage; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsEditing))] + private string? _editorTitle; + + public bool IsEditing => EditorTitle is not null; + + /// Zusätzliche Parameter als „name=wert“-Zeilen, z. B. für Felder ohne Katalog-Eintrag. + [ObservableProperty] + private string _extraParameters = string.Empty; + + [ObservableProperty] + [NotifyCanExecuteChangedFor(nameof(SaveCommand))] + private string? _previewText; + + public override async Task OnActivatedAsync() + { + await Tools.ActivateAsync(); + if (SelectedMenu is { Path: not null }) + { + await LoadEntriesAsync(); + } + } + + partial void OnSelectedMenuChanged(ExpertMenuItem? value) + { + Tools.ActiveTool = value?.Tool; + CloseEditor(); + Entries.Clear(); + ErrorMessage = null; + StatusMessage = null; + if (value is { Path: not null }) + { + _ = LoadEntriesAsync(); + } + } + + [RelayCommand] + private void OpenCustomPath() + { + if (string.IsNullOrWhiteSpace(CustomPath)) + { + return; + } + + var path = MenuCatalog.NormalizePath(CustomPath); + SelectedMenu = Menus.FirstOrDefault(m => m.Path == path) + ?? new ExpertMenuItem(path, "Eigener Menüpfad", path, MenuCatalog.Find(path)); + } + + [RelayCommand] + private Task Refresh() => LoadEntriesAsync(); + + private async Task LoadEntriesAsync() + { + if (_session.Client is not { } client || SelectedMenu?.Path is not { } path) + { + return; + } + + IsLoading = true; + ErrorMessage = null; + Entries.Clear(); + try + { + foreach (var row in await client.GetAsync(path)) + { + Entries.Add(new ExpertEntryViewModel(row, SelectedMenu.Schema)); + } + + if (SelectedMenu.Schema is { Singleton: true } && Entries.Count > 0) + { + EditEntry(Entries[0]); + } + } + catch (RouterOsException ex) + { + ErrorMessage = DescribeLoadError(path, ex); + } + finally + { + IsLoading = false; + } + } + + /// Fehlt ein Bereich, weil er ein eigenes Paket ist (z. B. Hotspot auf dem hAP lite), verständlich erklären (Mac v1.11.0). + private static string DescribeLoadError(string path, RouterOsException ex) => + path.StartsWith("/ip/hotspot", StringComparison.OrdinalIgnoreCase) + ? "Hotspot ist auf diesem Router ein eigenes Zusatzpaket und noch nicht installiert. Unter „Werkzeuge → Pakete“ lässt sich das Paket „hotspot“ installieren." + : ex.Message.Contains("nicht unterstützt", StringComparison.Ordinal) + ? $"Den Menüpfad „{path}“ gibt es auf diesem Router nicht (RouterOS-Version oder fehlendes Paket)." + : ex.Message; + + [RelayCommand] + private void EditEntry(ExpertEntryViewModel entry) + { + _editingRow = entry.Row; + EditorTitle = entry.IsDynamic ? "Eintrag ansehen (dynamisch, von RouterOS verwaltet)" : "Eintrag bearbeiten"; + BuildFields(entry.Row); + } + + [RelayCommand] + private void NewEntry() + { + _editingRow = null; + EditorTitle = "Neuer Eintrag"; + BuildFields(null); + } + + [RelayCommand] + private void CloseEditor() + { + EditorTitle = null; + Fields.Clear(); + ExtraParameters = string.Empty; + PreviewText = null; + } + + private void BuildFields(IReadOnlyDictionary? row) + { + Fields.Clear(); + ExtraParameters = string.Empty; + var schema = SelectedMenu?.Schema; + + foreach (var field in schema?.Fields ?? []) + { + var options = field.Kind switch + { + "enum" => field.Options ?? [], + "interfacePick" => Tools.InterfaceNames, + "bool" => [], + _ => [], + }; + var item = new ExpertFieldViewModel(field.Key, field.Label, field.Help, field.Kind, options, row?.GetValueOrDefault(field.Key) ?? string.Empty); + if (row is null && field.Default is { } defaultValue) + { + // Neuer Eintrag: Katalog-Standardwert vorschlagen (zählt als Änderung, wird also mitgeschickt). + item.Value = defaultValue; + } + + AddField(item); + } + + // Weitere Felder des Eintrags, die der Katalog nicht kennt (oder alle, bei einem eigenen Menüpfad). + foreach (var (key, value) in row ?? new Dictionary()) + { + if (key.StartsWith('.') || Fields.Any(f => f.Key == key) || key is "dynamic" or "invalid" or "running" or "default") + { + continue; + } + + AddField(new ExpertFieldViewModel(key, key, "Weiterer Parameter dieses Eintrags.", value is "true" or "false" ? "bool" : "text", [], value)); + } + + UpdatePreview(); + } + + private void AddField(ExpertFieldViewModel field) + { + field.PropertyChanged += (_, _) => UpdatePreview(); + Fields.Add(field); + } + + partial void OnExtraParametersChanged(string value) => UpdatePreview(); + + private Dictionary CollectChanges() + { + var original = _editingRow is null ? null : Fields.ToDictionary(f => f.Key, f => f.Original); + var edited = Fields.ToDictionary(f => f.Key, f => f.Value); + foreach (var line in ExtraParameters.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + var separator = line.IndexOf('='); + if (separator > 0) + { + edited[line[..separator].Trim()] = line[(separator + 1)..].Trim(); + } + } + + return ExpertMenus.Changes(original, edited); + } + + private void UpdatePreview() + { + if (!IsEditing || SelectedMenu?.Path is not { } path) + { + PreviewText = null; + return; + } + + var changes = CollectChanges(); + PreviewText = changes.Count == 0 + ? null + : SelectedMenu.Schema is { Singleton: true } + ? ExpertMenus.Preview(path, "set", null, changes) + : ExpertMenus.Preview(path, _editingRow is null ? "add" : "set", _editingRow?.GetValueOrDefault(".id"), changes); + } + + private bool CanSave => PreviewText is not null && !IsLoading; + + [RelayCommand(CanExecute = nameof(CanSave))] + private async Task SaveAsync() + { + if (_session.Client is not { } client || SelectedMenu?.Path is not { } path) + { + return; + } + + var changes = CollectChanges(); + if (_editingRow is not null && SelectedMenu.Schema is not { Singleton: true } && !_editingRow.ContainsKey(".id")) + { + // Ohne ID würde aus „ändern“ ein neuer Eintrag. + ErrorMessage = "Dieser Eintrag hat keine ID und kann hier nicht geändert werden."; + return; + } + + IsLoading = true; + ErrorMessage = null; + try + { + await EnsureSafetyBackupAsync(client); + StatusMessage = $"Wird ausgeführt: {PreviewText}"; + await ExpertMenus.SaveAsync(client, SelectedMenu.Schema, path, _editingRow?.GetValueOrDefault(".id"), changes); + StatusMessage = $"Gespeichert: {PreviewText}"; + CloseEditor(); + } + catch (RouterOsException ex) + { + ErrorMessage = ex.Message; + StatusMessage = null; + } + finally + { + IsLoading = false; + } + + if (ErrorMessage is null) + { + await LoadEntriesAsync(); + } + } + + [RelayCommand] + private async Task DeleteEntryAsync(ExpertEntryViewModel entry) + { + if (_session.Client is not { } client || SelectedMenu?.Path is not { } path || entry.Id is not { } id || !entry.CanDelete) + { + return; + } + + var preview = ExpertMenus.Preview(path, "remove", id, new Dictionary()); + if (System.Windows.MessageBox.Show($"Diesen Eintrag löschen?\n\n{entry.Summary}\n\nBefehl: {preview}", "Eintrag löschen", + System.Windows.MessageBoxButton.YesNo, System.Windows.MessageBoxImage.Warning) != System.Windows.MessageBoxResult.Yes) + { + return; + } + + IsLoading = true; + ErrorMessage = null; + try + { + await EnsureSafetyBackupAsync(client); + await client.RemoveAsync(path, id); + StatusMessage = $"Gelöscht: {preview}"; + } + catch (RouterOsException ex) + { + ErrorMessage = ex.Message; + } + finally + { + IsLoading = false; + } + + await LoadEntriesAsync(); + } + + /// Vor der ersten Änderung im Experte-Bereich eine Sicherung (eine Handsicherung der letzten 10 Minuten zählt). + private async Task EnsureSafetyBackupAsync(IRouterOsClient client) + { + if (_safetyBackupDone) + { + return; + } + + StatusMessage = "Sicherung vor der ersten Änderung wird erstellt …"; + await _session.CreateSafetyBackupAsync(client); + _safetyBackupDone = true; + } +} diff --git a/src/MikrotikInstaller.App/ViewModels/Steps/ExpertTools.cs b/src/MikrotikInstaller.App/ViewModels/Steps/ExpertTools.cs new file mode 100644 index 0000000..d551b7b --- /dev/null +++ b/src/MikrotikInstaller.App/ViewModels/Steps/ExpertTools.cs @@ -0,0 +1,459 @@ +using System.Collections.ObjectModel; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using MikrotikInstaller.Core.Configuration; +using MikrotikInstaller.Core.Connectivity; +using MikrotikInstaller.Core.Expert; + +namespace MikrotikInstaller.App.ViewModels.Steps; + +/// Ein Paket in der Paketliste mit Vormerkung. +public partial class PackageRowViewModel(RouterPackage package) : ObservableObject +{ + public RouterPackage Package { get; } = package; + + /// Installiert: Haken = behalten; verfügbar: Haken = installieren. + [ObservableProperty] + private bool _isWanted = package.IsInstalled; + + public bool CanChange => !Package.IsSystemPackage && Package.Scheduled.Length == 0; + + public string StatusLabel => Package switch + { + { IsScheduledForInstall: true } => "zur Installation vorgemerkt (nach Neustart)", + { IsScheduledForUninstall: true } => "zum Entfernen vorgemerkt (nach Neustart)", + { IsInstalled: true } => $"installiert, {Package.Version}", + _ => $"verfügbar, {Package.SizeBytes / 1024} KiB", + }; +} + +/// +/// Werkzeuge im Experte-Bereich (Mac v1.3.0–v1.11.3): Netzwerke isolieren, Neues LAN anlegen, +/// Port in Bridge einfügen, Pakete. Jedes Werkzeug zeigt seinen Plan vorab als Liste und führt ihn +/// mit Fortschritt und Abschluss-Übersicht aus; vorher entsteht eine Sicherung. +/// +public partial class ExpertTools : ObservableObject +{ + public static readonly IReadOnlyList Items = + [ + new("Netzwerke isolieren", "Werkzeuge", null, null, "isolate"), + new("Neues LAN anlegen", "Werkzeuge", null, null, "newlan"), + new("Port in Bridge einfügen", "Werkzeuge", null, null, "rejoin"), + new("Pakete", "Werkzeuge", null, null, "packages"), + ]; + + private readonly WizardSession _session; + private List> _interfaces = []; + private List> _bridgePorts = []; + + public ExpertTools(WizardSession session) + { + _session = session; + NewLanLease.PropertyChanged += (_, _) => RefreshPlan(); + } + + /// Alle Interface-Namen (auch für Auswahlfelder im Menü-Editor). + public List InterfaceNames { get; private set; } = []; + + public ObservableCollection Bridges { get; } = []; + + /// Anschlüsse (Ethernet/WLAN), die nicht in einer Bridge hängen — Kandidaten für „Port in Bridge einfügen“. + public ObservableCollection FreePorts { get; } = []; + + /// Anschlüsse (Ethernet/WLAN) für „Neues LAN anlegen“. + public ObservableCollection PhysicalPorts { get; } = []; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsIsolate), nameof(IsNewLan), nameof(IsRejoin), nameof(IsPackages))] + private string? _activeTool; + + public bool IsIsolate => ActiveTool == "isolate"; + + public bool IsNewLan => ActiveTool == "newlan"; + + public bool IsRejoin => ActiveTool == "rejoin"; + + public bool IsPackages => ActiveTool == "packages"; + + /// Was das Werkzeug ausführen wird — Klartext, vor dem Anwenden. + public ObservableCollection PlanLines { get; } = []; + + public ObservableCollection CompletedSteps { get; } = []; + + private IReadOnlyList _plan = []; + + [ObservableProperty] + [NotifyCanExecuteChangedFor(nameof(ApplyPlanCommand))] + private bool _isBusy; + + [ObservableProperty] + private string? _toolError; + + [ObservableProperty] + private string? _toolWarning; + + [ObservableProperty] + private string? _progressText; + + [ObservableProperty] + private int _progressValue; + + [ObservableProperty] + private int _progressMaximum = 1; + + // --- Netzwerke isolieren --- + [ObservableProperty] + private string? _isolateInterface; + + [ObservableProperty] + private string? _isolateFrom; + + // --- Neues LAN anlegen --- + [ObservableProperty] + private string? _newLanInterface; + + [ObservableProperty] + private string _newLanAddress = "10.10.10.1/24"; + + [ObservableProperty] + private string _newLanPoolStart = "10.10.10.10"; + + [ObservableProperty] + private string _newLanPoolEnd = "10.10.10.200"; + + [ObservableProperty] + private bool _newLanIsolate = true; + + public LeaseTimeEditor NewLanLease { get; } = new(); + + // --- Port in Bridge einfügen --- + [ObservableProperty] + private string? _rejoinPort; + + [ObservableProperty] + private string? _rejoinBridge; + + // --- Pakete --- + public ObservableCollection Packages { get; } = []; + + [ObservableProperty] + private string? _packageInfo; + + public async Task ActivateAsync() + { + if (_session.Client is not { } client) + { + return; + } + + try + { + _interfaces = (await client.GetAsync("/interface")).ToList(); + _bridgePorts = (await client.GetAsync("/interface/bridge/port")).ToList(); + } + catch (RouterOsException ex) + { + ToolError = ex.Message; + return; + } + + InterfaceNames = _interfaces.Select(i => i.GetValueOrDefault("name")).OfType().ToList(); + Replace(Bridges, _interfaces.Where(i => i.GetValueOrDefault("type") == "bridge").Select(i => i["name"])); + var physical = _interfaces.Where(i => i.GetValueOrDefault("type") is "ether" or "wlan" or "wifi" or "wireless") + .Select(i => i.GetValueOrDefault("name")).OfType().ToList(); + Replace(PhysicalPorts, physical); + Replace(FreePorts, physical.Where(p => !_bridgePorts.Any(bp => bp.GetValueOrDefault("interface") == p))); + RejoinBridge ??= Bridges.FirstOrDefault(); + IsolateFrom ??= Bridges.FirstOrDefault(); + RefreshPlan(); + } + + private static void Replace(ObservableCollection target, IEnumerable items) + { + target.Clear(); + foreach (var item in items) + { + target.Add(item); + } + } + + partial void OnActiveToolChanged(string? value) + { + ToolError = null; + CompletedSteps.Clear(); + if (value == "packages") + { + _ = LoadPackagesAsync(refresh: false); + } + + RefreshPlan(); + } + + partial void OnIsolateInterfaceChanged(string? value) => RefreshPlan(); + + partial void OnIsolateFromChanged(string? value) => RefreshPlan(); + + partial void OnNewLanInterfaceChanged(string? value) => RefreshPlan(); + + partial void OnNewLanAddressChanged(string value) => RefreshPlan(); + + partial void OnNewLanPoolStartChanged(string value) => RefreshPlan(); + + partial void OnNewLanPoolEndChanged(string value) => RefreshPlan(); + + partial void OnNewLanIsolateChanged(bool value) => RefreshPlan(); + + partial void OnRejoinPortChanged(string? value) => RefreshPlan(); + + partial void OnRejoinBridgeChanged(string? value) => RefreshPlan(); + + private void RefreshPlan() => _ = BuildPlanAsync(); + + private async Task BuildPlanAsync() + { + ToolWarning = null; + _plan = []; + try + { + _plan = ActiveTool switch + { + "isolate" => await PlanIsolateAsync(), + "newlan" => await PlanNewLanAsync(), + "rejoin" when RejoinPort is { } port && RejoinBridge is { } bridge && _session.Client is { } client + => await ExpertToolPlans.RejoinBridgeAsync(client, port, bridge), + _ => [], + }; + } + catch (RouterOsException ex) + { + ToolError = ex.Message; + } + + Replace(PlanLines, _plan.Select(a => a.Description)); + ApplyPlanCommand.NotifyCanExecuteChanged(); + } + + private async Task> PlanIsolateAsync() + { + if (IsolateInterface is not { } iface || IsolateFrom is not { } other || iface == other || _session.Client is not { } client) + { + return []; + } + + var address = (await client.GetAsync("/ip/address")).FirstOrDefault(a => a.GetValueOrDefault("interface") == iface)?.GetValueOrDefault("address"); + if (address is null) + { + ToolWarning = $"„{iface}“ hat keine IP-Adresse — erst ein eigenes Netz anlegen (z. B. mit „Neues LAN anlegen“)."; + return []; + } + + var network = IpNetworkHelper.GetNetworkCidr(address); + if (_session.Credentials?.Host is { } host && IpNetworkHelper.Contains(network, host)) + { + ToolWarning = $"Du bist über {host} mit dem Router verbunden, also aus dem Netz, das isoliert werden soll. Die Isolation würde dich aussperren — bitte über ein anderes Netz verbinden."; + return []; + } + + return ExpertToolPlans.Isolate(iface, other, network); + } + + private async Task> PlanNewLanAsync() + { + if (NewLanInterface is not { } iface || _session.Client is not { } client) + { + return []; + } + + if (!NetworkValidation.IsValidCidr(NewLanAddress) || !NetworkValidation.IsValidIPv4(NewLanPoolStart) || !NetworkValidation.IsValidIPv4(NewLanPoolEnd) + || !NetworkValidation.IsOrderedRange(NewLanPoolStart, NewLanPoolEnd)) + { + ToolWarning = "Bitte Adresse mit Präfix (z. B. 10.10.10.1/24) und einen gültigen Adressbereich angeben."; + return []; + } + + var conflicts = await PortConflicts.FindAsync(client, [iface]); + var bridge = _bridgePorts.FirstOrDefault(p => p.GetValueOrDefault("interface") == iface)?.GetValueOrDefault("bridge"); + var warnings = new List(); + if (bridge is not null) + { + warnings.Add($"„{iface}“ hängt in der Bridge „{bridge}“ und wird herausgenommen. Ist dein PC an diesem Anschluss angeschlossen, bricht die Verbindung ab."); + } + + if (conflicts.FirstOrDefault(c => c.Address is not null && c.Address == _session.Credentials?.Host) is { } own) + { + warnings.Add($"Achtung: Über {own.Address} auf „{iface}“ bist du gerade verbunden — die Verbindung bricht ab."); + } + + ToolWarning = warnings.Count > 0 ? string.Join(" ", warnings) : null; + return ExpertToolPlans.NewLan( + new NewLanSettings(iface, NewLanAddress, NewLanPoolStart, NewLanPoolEnd, NewLanLease.Value, NewLanIsolate ? Bridges.FirstOrDefault() : null), + conflicts, + bridge); + } + + private bool CanApplyPlan => !IsBusy && _plan.Count > 0; + + [RelayCommand(CanExecute = nameof(CanApplyPlan))] + private async Task ApplyPlanAsync() + { + if (_session.Client is not { } client) + { + return; + } + + var plan = _plan; + IsBusy = true; + _session.IsDeviceBusy = true; + ToolError = null; + CompletedSteps.Clear(); + ProgressMaximum = plan.Count + 1; + ProgressValue = 0; + try + { + ProgressText = "Sicherung wird erstellt …"; + CompletedSteps.Add($"Sicherung „{await _session.CreateSafetyBackupAsync(client)}“"); + ProgressValue = 1; + for (var i = 0; i < plan.Count; i++) + { + ProgressText = $"Schritt {i + 2} von {ProgressMaximum}: {plan[i].Description}"; + await plan[i].ApplyAsync(client, CancellationToken.None); + CompletedSteps.Add(plan[i].Description); + ProgressValue = i + 2; + } + + ProgressText = null; + } + catch (RouterOsException ex) + { + ToolError = $"{ex.Message} (abgebrochen bei: {ProgressText})"; + ProgressText = null; + } + finally + { + IsBusy = false; + _session.IsDeviceBusy = false; + } + + // Ein Isolations-Schalter darf nicht unbemerkt auf das nächste Netz wirken (Mac v1.3.0). + NewLanInterface = null; + IsolateInterface = null; + RejoinPort = null; + await ActivateAsync(); + } + + [RelayCommand] + private Task RefreshPackagesAsync() => LoadPackagesAsync(refresh: true); + + private async Task LoadPackagesAsync(bool refresh) + { + if (_session.Client is not { } client) + { + return; + } + + IsBusy = true; + PackageInfo = "Paketliste wird vom MikroTik-Server geholt …"; + try + { + var (packages, free) = await PackageService.ListAsync(client, refresh); + Packages.Clear(); + foreach (var package in packages) + { + var row = new PackageRowViewModel(package); + row.PropertyChanged += (_, _) => UpdatePackageInfo(free); + Packages.Add(row); + } + + UpdatePackageInfo(free); + } + catch (RouterOsException ex) + { + ToolError = ex.Message; + PackageInfo = null; + } + finally + { + IsBusy = false; + ApplyPackagesCommand.NotifyCanExecuteChanged(); + } + } + + private void UpdatePackageInfo(long? freeBytes) + { + var install = Packages.Where(p => p.IsWanted && p.Package.IsAvailable).ToList(); + var remove = Packages.Where(p => !p.IsWanted && p.Package.IsInstalled).ToList(); + var needed = install.Sum(p => p.Package.SizeBytes); + var lines = new List(); + if (install.Count + remove.Count == 0) + { + lines.Add("Haken setzen zum Installieren, Haken entfernen zum Entfernen."); + } + else + { + lines.Add($"Vorgemerkt: {install.Count} installieren, {remove.Count} entfernen. Der Router lädt die Pakete von MikroTik herunter und startet neu."); + } + + if (freeBytes is { } free && needed > free) + { + lines.Add($"Zu wenig Speicher: benötigt {needed / 1024} KiB, frei {free / 1024} KiB."); + } + + if (remove.Any(p => p.Package.IsWirelessDriver)) + { + lines.Add("Achtung: Ein WLAN-Treiber wird entfernt — das WLAN des Routers ist danach aus."); + } + + PackageInfo = string.Join(" ", lines); + ApplyPackagesCommand.NotifyCanExecuteChanged(); + } + + private bool CanApplyPackages => + !IsBusy && Packages.Any(p => (p.IsWanted && p.Package.IsAvailable) || (!p.IsWanted && p.Package.IsInstalled && !p.Package.IsSystemPackage)); + + [RelayCommand(CanExecute = nameof(CanApplyPackages))] + private async Task ApplyPackagesAsync() + { + if (_session.Client is not { } client) + { + return; + } + + var install = Packages.Where(p => p.IsWanted && p.Package.IsAvailable).Select(p => p.Package).ToList(); + var remove = Packages.Where(p => !p.IsWanted && p.Package.IsInstalled && !p.Package.IsSystemPackage).Select(p => p.Package).ToList(); + var commands = install.Select(p => $"/system package enable {p.Name}").Concat(remove.Select(p => $"/system package uninstall {p.Name}")).Append("/system package apply-changes"); + if (System.Windows.MessageBox.Show( + $"Diese Befehle werden ausgeführt, danach startet der Router neu:\n\n{string.Join("\n", commands)}", + "Pakete ändern", System.Windows.MessageBoxButton.OKCancel, System.Windows.MessageBoxImage.Question) != System.Windows.MessageBoxResult.OK) + { + return; + } + + IsBusy = true; + _session.IsDeviceBusy = true; + ToolError = null; + try + { + PackageInfo = "Sicherung wird erstellt …"; + await _session.CreateSafetyBackupAsync(client); + PackageInfo = "Änderungen werden vorgemerkt und angewendet …"; + await PackageService.ApplyAsync(client, install, remove); + PackageInfo = "Der Router lädt die Pakete herunter und startet neu — die Verbindung wird danach automatisch wiederhergestellt …"; + _session.IsDeviceBusy = false; + if (await _session.ReconnectAsync(TimeSpan.FromSeconds(30))) + { + await LoadPackagesAsync(refresh: false); + var missing = install.Where(p => Packages.FirstOrDefault(r => r.Package.Name == p.Name)?.Package.IsInstalled != true).Select(p => p.Name).ToList(); + PackageInfo = missing.Count == 0 ? "Fertig — alle Änderungen sind übernommen." : $"Nicht installiert: {string.Join(", ", missing)}."; + } + } + catch (Exception ex) when (ex is RouterOsException or InvalidOperationException) + { + ToolError = ex.Message; + } + finally + { + IsBusy = false; + _session.IsDeviceBusy = false; + } + } +} diff --git a/src/MikrotikInstaller.App/ViewModels/Steps/FirewallStepViewModel.cs b/src/MikrotikInstaller.App/ViewModels/Steps/FirewallStepViewModel.cs index 7934c72..c6cb59d 100644 --- a/src/MikrotikInstaller.App/ViewModels/Steps/FirewallStepViewModel.cs +++ b/src/MikrotikInstaller.App/ViewModels/Steps/FirewallStepViewModel.cs @@ -37,7 +37,7 @@ public partial class FirewallStepViewModel : WizardStepViewModelBase public override bool CanGoNext => PortForwards.All(IsValid) && BlockedAddressesValidationMessage is null; /// Bei "Backup wiederherstellen" wird direkt zur Zusammenfassung gesprungen, ohne die Konfigurations-Schritte. - public override bool ShouldSkip => _session.SelectedArea == AppArea.BackupManagement; + public override bool ShouldSkip => _session.SelectedArea != AppArea.SetupWizard; public override Task OnActivatedAsync() { diff --git a/src/MikrotikInstaller.App/ViewModels/Steps/LanScanStepViewModel.cs b/src/MikrotikInstaller.App/ViewModels/Steps/LanScanStepViewModel.cs index 98d977f..e597bfd 100644 --- a/src/MikrotikInstaller.App/ViewModels/Steps/LanScanStepViewModel.cs +++ b/src/MikrotikInstaller.App/ViewModels/Steps/LanScanStepViewModel.cs @@ -45,7 +45,7 @@ public partial class LanScanStepViewModel : WizardStepViewModelBase public override bool CanGoNext => !IsLoading; /// Bei "Backup verwalten" nicht relevant — die eigenständige Backup-Sektion braucht keinen LAN-Scan. - public override bool ShouldSkip => _session.SelectedArea == AppArea.BackupManagement; + public override bool ShouldSkip => _session.SelectedArea != AppArea.SetupWizard; public bool ShowEmptyHint => !IsLoading && ErrorMessage is null && !HasDevices; diff --git a/src/MikrotikInstaller.App/ViewModels/Steps/LanStepViewModel.cs b/src/MikrotikInstaller.App/ViewModels/Steps/LanStepViewModel.cs index 4f3b5c9..242a9c5 100644 --- a/src/MikrotikInstaller.App/ViewModels/Steps/LanStepViewModel.cs +++ b/src/MikrotikInstaller.App/ViewModels/Steps/LanStepViewModel.cs @@ -82,7 +82,7 @@ public partial class LanStepViewModel : WizardStepViewModelBase public override bool CanGoNext => ValidationMessage is null; /// Bei "Backup wiederherstellen" wird direkt zur Zusammenfassung gesprungen, ohne die Konfigurations-Schritte. - public override bool ShouldSkip => _session.SelectedArea == AppArea.BackupManagement; + public override bool ShouldSkip => _session.SelectedArea != AppArea.SetupWizard; public override async Task OnActivatedAsync() { diff --git a/src/MikrotikInstaller.App/ViewModels/Steps/ModeSelectionStepViewModel.cs b/src/MikrotikInstaller.App/ViewModels/Steps/ModeSelectionStepViewModel.cs index fc677b6..25a830b 100644 --- a/src/MikrotikInstaller.App/ViewModels/Steps/ModeSelectionStepViewModel.cs +++ b/src/MikrotikInstaller.App/ViewModels/Steps/ModeSelectionStepViewModel.cs @@ -25,7 +25,7 @@ public partial class ModeSelectionStepViewModel : WizardStepViewModelBase private bool _isExpertMode; /// Ohne „Professioneller Netzwerker" gibt es nichts zu wählen — dann immer der geführte Assistent. - public override bool ShouldSkip => _session.SelectedArea == AppArea.BackupManagement || !UserSettings.Current.ProfessionalMode; + public override bool ShouldSkip => _session.SelectedArea != AppArea.SetupWizard || !UserSettings.Current.ProfessionalMode; public override Task OnActivatedAsync() { diff --git a/src/MikrotikInstaller.App/ViewModels/Steps/NetworkAnalysisStepViewModel.cs b/src/MikrotikInstaller.App/ViewModels/Steps/NetworkAnalysisStepViewModel.cs index 4abb40d..739c543 100644 --- a/src/MikrotikInstaller.App/ViewModels/Steps/NetworkAnalysisStepViewModel.cs +++ b/src/MikrotikInstaller.App/ViewModels/Steps/NetworkAnalysisStepViewModel.cs @@ -59,7 +59,7 @@ public partial class NetworkAnalysisStepViewModel : WizardStepViewModelBase public override bool CanGoNext => !IsLoading; - public override bool ShouldSkip => _session.SelectedArea == AppArea.BackupManagement; + public override bool ShouldSkip => _session.SelectedArea != AppArea.SetupWizard; public override async Task OnActivatedAsync() { diff --git a/src/MikrotikInstaller.App/ViewModels/Steps/SummaryStepViewModel.cs b/src/MikrotikInstaller.App/ViewModels/Steps/SummaryStepViewModel.cs index b457d27..c0527d7 100644 --- a/src/MikrotikInstaller.App/ViewModels/Steps/SummaryStepViewModel.cs +++ b/src/MikrotikInstaller.App/ViewModels/Steps/SummaryStepViewModel.cs @@ -56,7 +56,7 @@ public partial class SummaryStepViewModel : WizardStepViewModelBase public override bool CanGoNext => IsFinished; - public override bool ShouldSkip => _session.SelectedArea == AppArea.BackupManagement; + public override bool ShouldSkip => _session.SelectedArea != AppArea.SetupWizard; [ObservableProperty] private string? _backupName; diff --git a/src/MikrotikInstaller.App/ViewModels/Steps/VlanStepViewModel.cs b/src/MikrotikInstaller.App/ViewModels/Steps/VlanStepViewModel.cs index 20bf821..04a772d 100644 --- a/src/MikrotikInstaller.App/ViewModels/Steps/VlanStepViewModel.cs +++ b/src/MikrotikInstaller.App/ViewModels/Steps/VlanStepViewModel.cs @@ -28,7 +28,7 @@ public partial class VlanStepViewModel : WizardStepViewModelBase public override bool CanGoNext => Vlans.All(IsValid); /// Bei "Backup wiederherstellen" wird direkt zur Zusammenfassung gesprungen, ohne die Konfigurations-Schritte. - public override bool ShouldSkip => _session.SelectedArea == AppArea.BackupManagement; + public override bool ShouldSkip => _session.SelectedArea != AppArea.SetupWizard; public override Task OnActivatedAsync() { diff --git a/src/MikrotikInstaller.App/ViewModels/Steps/WanStepViewModel.cs b/src/MikrotikInstaller.App/ViewModels/Steps/WanStepViewModel.cs index 2950b6a..49cbad2 100644 --- a/src/MikrotikInstaller.App/ViewModels/Steps/WanStepViewModel.cs +++ b/src/MikrotikInstaller.App/ViewModels/Steps/WanStepViewModel.cs @@ -58,7 +58,7 @@ public partial class WanStepViewModel : WizardStepViewModelBase public override bool CanGoNext => ValidationMessage is null; /// Bei "Backup wiederherstellen" wird direkt zur Zusammenfassung gesprungen, ohne die Konfigurations-Schritte. - public override bool ShouldSkip => _session.SelectedArea == AppArea.BackupManagement; + public override bool ShouldSkip => _session.SelectedArea != AppArea.SetupWizard; public override Task OnActivatedAsync() { diff --git a/src/MikrotikInstaller.App/ViewModels/Steps/WlanStepViewModel.cs b/src/MikrotikInstaller.App/ViewModels/Steps/WlanStepViewModel.cs index dbddad4..65bfda2 100644 --- a/src/MikrotikInstaller.App/ViewModels/Steps/WlanStepViewModel.cs +++ b/src/MikrotikInstaller.App/ViewModels/Steps/WlanStepViewModel.cs @@ -51,7 +51,7 @@ public partial class WlanStepViewModel : WizardStepViewModelBase public bool IsExpertMode => _session.IsExpertMode; /// Auch bei "Backup wiederherstellen" überspringen — nicht nur bei fehlendem WLAN. - public override bool ShouldSkip => _session.SelectedArea == AppArea.BackupManagement || _session.Device?.HasWireless != true; + public override bool ShouldSkip => _session.SelectedArea != AppArea.SetupWizard || _session.Device?.HasWireless != true; public override bool CanGoNext => SkipWireless diff --git a/src/MikrotikInstaller.App/Views/Steps/AreaSelectionStepView.xaml b/src/MikrotikInstaller.App/Views/Steps/AreaSelectionStepView.xaml index b6f5dd6..1dfb419 100644 --- a/src/MikrotikInstaller.App/Views/Steps/AreaSelectionStepView.xaml +++ b/src/MikrotikInstaller.App/Views/Steps/AreaSelectionStepView.xaml @@ -16,7 +16,7 @@ + IsChecked="{Binding Area, Converter={StaticResource EnumToBooleanConverter}, ConverterParameter=SetupWizard}" /> @@ -31,12 +31,28 @@ + IsChecked="{Binding Area, Converter={StaticResource EnumToBooleanConverter}, ConverterParameter=BackupManagement}" /> + + + + + + + + + + + + + + diff --git a/src/MikrotikInstaller.App/Views/Steps/ExpertStepView.xaml b/src/MikrotikInstaller.App/Views/Steps/ExpertStepView.xaml new file mode 100644 index 0000000..c6ba33a --- /dev/null +++ b/src/MikrotikInstaller.App/Views/Steps/ExpertStepView.xaml @@ -0,0 +1,374 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/MikrotikInstaller.App/Views/Steps/ExpertStepView.xaml.cs b/src/MikrotikInstaller.App/Views/Steps/ExpertStepView.xaml.cs new file mode 100644 index 0000000..5abeb8f --- /dev/null +++ b/src/MikrotikInstaller.App/Views/Steps/ExpertStepView.xaml.cs @@ -0,0 +1,9 @@ +namespace MikrotikInstaller.App.Views.Steps; + +public partial class ExpertStepView : System.Windows.Controls.UserControl +{ + public ExpertStepView() + { + InitializeComponent(); + } +} diff --git a/src/MikrotikInstaller.Core.Tests/Expert/ExpertMenusTests.cs b/src/MikrotikInstaller.Core.Tests/Expert/ExpertMenusTests.cs new file mode 100644 index 0000000..cab7839 --- /dev/null +++ b/src/MikrotikInstaller.Core.Tests/Expert/ExpertMenusTests.cs @@ -0,0 +1,61 @@ +using MikrotikInstaller.Core.Expert; +using MikrotikInstaller.Core.Tests.Configuration; + +namespace MikrotikInstaller.Core.Tests.Expert; + +public class ExpertMenusTests +{ + [Fact] + public void Catalog_LoadsCuratedMenusWithFieldHelp() + { + Assert.True(MenuCatalog.All.Count >= 40); + var filter = MenuCatalog.Find("ip firewall filter"); + Assert.NotNull(filter); + Assert.Contains(filter.Fields, f => f.Key == "action" && f.Kind == "enum" && f.Options!.Contains("drop")); + Assert.True(MenuCatalog.Find("/ip/dns")!.Singleton); + Assert.True(MenuCatalog.Find("/ip/service")!.FixedList); + // Jedes Menü hat Titel und Kurzbeschreibung, jedes Feld einen Hilfetext. + Assert.All(MenuCatalog.All, m => Assert.False(string.IsNullOrWhiteSpace(m.Title + m.Summary))); + Assert.All(MenuCatalog.All.SelectMany(m => m.Fields), f => Assert.False(string.IsNullOrWhiteSpace(f.Help))); + } + + [Theory] + [InlineData("dynamic", "true", false)] + [InlineData("default", "true", false)] + [InlineData("name", "default-trial", false)] + [InlineData("comment", "eigene Regel", true)] + public void CanDelete_HidesForRouterOwnedEntries(string key, string value, bool expected) => + Assert.Equal(expected, ExpertMenus.CanDelete(MenuCatalog.Find("/ip/firewall/filter"), new Dictionary { [".id"] = "*1", [key] = value })); + + [Fact] + public void CanDelete_NeverForFixedListOrSingleton() + { + var row = new Dictionary { [".id"] = "*1", ["name"] = "telnet" }; + Assert.False(ExpertMenus.CanDelete(MenuCatalog.Find("/ip/service"), row)); + Assert.False(ExpertMenus.CanDelete(MenuCatalog.Find("/ip/dns"), row)); + } + + [Fact] + public void Changes_OnlyModifiedFields() + { + var original = new Dictionary { [".id"] = "*1", ["address"] = "10.0.0.1/24", ["interface"] = "bridge" }; + var edited = new Dictionary { [".id"] = "*1", ["address"] = "10.0.0.2/24", ["interface"] = "bridge" }; + + Assert.Equal(new Dictionary { ["address"] = "10.0.0.2/24" }, ExpertMenus.Changes(original, edited)); + } + + [Fact] + public void Preview_ShowsRouterOsCommand() => + Assert.Equal("/ip address set *1 address=10.0.0.2/24 comment=\"mein Netz\"", + ExpertMenus.Preview("/ip/address", "set", "*1", new Dictionary { ["address"] = "10.0.0.2/24", ["comment"] = "mein Netz" })); + + [Fact] + public async Task SaveAsync_SingletonUsesSetWithoutId() + { + var client = new RecordingRouterOsClient(); + + await ExpertMenus.SaveAsync(client, MenuCatalog.Find("/ip/dns"), "/ip/dns", null, new Dictionary { ["servers"] = "9.9.9.9" }); + + Assert.Equal("/ip/dns/set", Assert.Single(client.ExecuteCalls).Path); + } +} diff --git a/src/MikrotikInstaller.Core.Tests/Expert/ExpertToolsTests.cs b/src/MikrotikInstaller.Core.Tests/Expert/ExpertToolsTests.cs new file mode 100644 index 0000000..3ecdaf1 --- /dev/null +++ b/src/MikrotikInstaller.Core.Tests/Expert/ExpertToolsTests.cs @@ -0,0 +1,65 @@ +using MikrotikInstaller.Core.Configuration; +using MikrotikInstaller.Core.Expert; +using MikrotikInstaller.Core.Tests.Configuration; + +namespace MikrotikInstaller.Core.Tests.Expert; + +public class ExpertToolsTests +{ + [Fact] + public async Task NewLan_TakesPortOutOfBridgeAndCreatesIsolatedNetwork() + { + var client = new RecordingRouterOsClient(); + client.GetResponses["/interface/bridge/port"] = [new Dictionary { [".id"] = "*P4", ["bridge"] = "bridge", ["interface"] = "ether4" }]; + + var actions = ExpertToolPlans.NewLan(new NewLanSettings("ether4", "10.10.10.1/24", "10.10.10.10", "10.10.10.200", "1d", "bridge"), [], "bridge"); + foreach (var action in actions) + { + await action.ApplyAsync(client, CancellationToken.None); + } + + Assert.Contains(client.RemoveCalls, c => c == ("/interface/bridge/port", "*P4")); + Assert.Contains(client.AddCalls, c => c.Path == "/ip/address" && c.Parameters["interface"] == "ether4"); + Assert.Contains(client.AddCalls, c => c.Path == "/ip/dhcp-server" && c.Parameters["lease-time"] == "1d" && c.Parameters["name"] == "dhcp-ether4"); + Assert.Contains(client.AddCalls, c => c.Path == "/ip/firewall/filter" && c.Parameters.GetValueOrDefault("comment") == "Isolation ether4 -> bridge"); + } + + [Fact] + public async Task RejoinBridge_CleansUpPortAndAddsItToBridge() + { + var client = new RecordingRouterOsClient(); + client.GetResponses["/ip/address"] = [new Dictionary { [".id"] = "*A", ["address"] = "10.10.10.1/24", ["interface"] = "ether4" }]; + client.GetResponses["/ip/dhcp-server"] = [new Dictionary { [".id"] = "*S", ["name"] = "dhcp-ether4", ["interface"] = "ether4", ["address-pool"] = "pool-ether4" }]; + client.GetResponses["/ip/pool"] = [new Dictionary { [".id"] = "*PL", ["name"] = "pool-ether4" }]; + client.GetResponses["/ip/dhcp-server/network"] = [new Dictionary { [".id"] = "*N", ["address"] = "10.10.10.0/24" }]; + client.GetResponses["/ip/firewall/filter"] = + [ + new Dictionary { [".id"] = "*F1", ["chain"] = "forward", ["in-interface"] = "ether4", ["out-interface"] = "bridge", ["action"] = "drop", ["comment"] = "Isolation ether4 -> bridge" }, + new Dictionary { [".id"] = "*F2", ["chain"] = "forward", ["in-interface"] = "bridge", ["out-interface"] = "ether4", ["action"] = "drop" }, + new Dictionary { [".id"] = "*F3", ["chain"] = "forward", ["in-interface"] = "ether4", ["action"] = "accept", ["comment"] = "eigene Regel" }, + ]; + + foreach (var action in await ExpertToolPlans.RejoinBridgeAsync(client, "ether4", "bridge")) + { + await action.ApplyAsync(client, CancellationToken.None); + } + + Assert.Equal(["*A", "*S", "*N", "*PL", "*F1", "*F2"], client.RemoveCalls.Select(c => c.Id)); + Assert.Contains(client.AddCalls, c => c.Path == "/interface/bridge/port" && c.Parameters["bridge"] == "bridge" && c.Parameters["interface"] == "ether4"); + } + + [Fact] + public async Task Packages_ScheduleThenApply_AndRefuseSystemPackageRemoval() + { + PackageService.PollDelay = TimeSpan.Zero; + var client = new RecordingRouterOsClient(); + var hotspot = new RouterPackage("*7", "hotspot", IsAvailable: true, IsDisabled: true, "", 300_000, "7.15.2"); + var routeros = new RouterPackage("*1", "routeros", false, false, "", 12_000_000, "7.15.2"); + + await PackageService.ApplyAsync(client, [hotspot], []); + + Assert.Contains(client.ExecuteCalls, c => c.Path == "/system/package/enable" && c.Parameters![".id"] == "*7"); + Assert.Equal("/system/package/apply-changes", client.ExecuteCalls[^1].Path); + await Assert.ThrowsAsync(() => PackageService.ApplyAsync(client, [], [routeros])); + } +} diff --git a/src/MikrotikInstaller.Core/Configuration/IpNetworkHelper.cs b/src/MikrotikInstaller.Core/Configuration/IpNetworkHelper.cs index be022a4..75e1f89 100644 --- a/src/MikrotikInstaller.Core/Configuration/IpNetworkHelper.cs +++ b/src/MikrotikInstaller.Core/Configuration/IpNetworkHelper.cs @@ -2,7 +2,7 @@ using System.Net; namespace MikrotikInstaller.Core.Configuration; -internal static class IpNetworkHelper +public static class IpNetworkHelper { /// Berechnet aus "192.168.88.1/24" die Netzwerkadresse "192.168.88.0/24". public static string GetNetworkCidr(string addressCidr) diff --git a/src/MikrotikInstaller.Core/Connectivity/Demo/DemoRouterOsClient.cs b/src/MikrotikInstaller.Core/Connectivity/Demo/DemoRouterOsClient.cs index 12e5506..5954797 100644 --- a/src/MikrotikInstaller.Core/Connectivity/Demo/DemoRouterOsClient.cs +++ b/src/MikrotikInstaller.Core/Connectivity/Demo/DemoRouterOsClient.cs @@ -40,6 +40,15 @@ public sealed class DemoRouterOsClient : IRouterOsClient public DemoRouterOsClient(DemoDeviceProfile profile) { _data = BuildInitialData(profile); + + // Wie ein echter Router: jeder Listeneintrag hat eine ID (Einstellungs-Menüs wie /ip/dns nicht). + foreach (var (path, rows) in _data) + { + foreach (var row in rows.Where(r => !r.ContainsKey(".id") && path is not ("/system/resource" or "/system/identity" or "/system/routerboard" or "/ip/dns" or "/system/package/update"))) + { + row[".id"] = $"*D{_nextId++}"; + } + } } public static bool Matches(RouterOsCredentials credentials) => @@ -110,6 +119,11 @@ public sealed class DemoRouterOsClient : IRouterOsClient public Task ExecuteAsync(string path, IReadOnlyDictionary? parameters = null, CancellationToken cancellationToken = default) { + if (Normalize(path) == "/system/package/apply-changes") + { + return RunAsync(path, parameters, cancellationToken); + } + // Simuliert RouterOS "make-static": markiert die betroffene Lease als nicht mehr dynamisch. // Vereinfachung gegenüber echtem RouterOS, das dem Eintrag dabei eine neue ID zuweist — die ID // bleibt hier bewusst gleich, das ändert nichts am Verhalten für Aufrufer (LanScanService fragt @@ -179,6 +193,33 @@ public sealed class DemoRouterOsClient : IRouterOsClient ]; case "/execute": return [new Dictionary { ["ret"] = "192.168.88.70" }]; + case "/system/package/enable" or "/system/package/uninstall" or "/system/package/disable": + var package = _data["/system/package"].FirstOrDefault(p => p.GetValueOrDefault(".id") == parameters?.GetValueOrDefault(".id")); + if (package is not null) + { + package["scheduled"] = path.EndsWith("enable", StringComparison.Ordinal) && package["available"] == "true" ? "scheduled for install" + : path.EndsWith("uninstall", StringComparison.Ordinal) ? "scheduled for uninstall" : string.Empty; + } + + return []; + case "/system/package/apply-changes": + // Simulierter Neustart: Vorgemerktes wird installiert bzw. entfernt. + foreach (var p in _data["/system/package"].ToList()) + { + if (p["scheduled"].Contains("uninstall")) + { + p["available"] = "true"; + } + else if (p["scheduled"].Contains("install")) + { + p["available"] = "false"; + p["disabled"] = "false"; + } + + p["scheduled"] = string.Empty; + } + + return []; default: await ExecuteAsync(path, parameters, cancellationToken); return []; @@ -191,7 +232,7 @@ public sealed class DemoRouterOsClient : IRouterOsClient private static Dictionary>> BuildInitialData(DemoDeviceProfile profile) => new() { - ["/system/resource"] = [new() { ["board-name"] = profile.BoardModel, ["version"] = "7.15.2 (stable)", ["architecture-name"] = profile.Architecture }], + ["/system/resource"] = [new() { ["board-name"] = profile.BoardModel, ["version"] = "7.15.2 (stable)", ["architecture-name"] = profile.Architecture, ["free-hdd-space"] = "8000000" }], ["/system/identity"] = [new() { ["name"] = profile.IdentityName }], ["/system/routerboard"] = [BuildRouterboardRow(profile)], ["/interface"] = @@ -207,6 +248,13 @@ public sealed class DemoRouterOsClient : IRouterOsClient [ new() { ["installed-version"] = "7.15.2", ["latest-version"] = "7.16.1", ["status"] = "New version is available" }, ], + ["/system/package"] = + [ + new() { [".id"] = "*P1", ["name"] = "routeros", ["version"] = "7.15.2", ["available"] = "false", ["disabled"] = "false", ["scheduled"] = "", ["size"] = "12000000" }, + new() { [".id"] = "*P2", ["name"] = "wifi-qcom-ac", ["version"] = "7.15.2", ["available"] = "false", ["disabled"] = "false", ["scheduled"] = "", ["size"] = "3000000" }, + new() { [".id"] = "*P3", ["name"] = "hotspot", ["version"] = "7.15.2", ["available"] = "true", ["disabled"] = "true", ["scheduled"] = "", ["size"] = "300000" }, + new() { [".id"] = "*P4", ["name"] = "container", ["version"] = "7.15.2", ["available"] = "true", ["disabled"] = "true", ["scheduled"] = "", ["size"] = "100000" }, + ], // Ab hier: Beispieldaten für die IST-Zustand-Analyse (bereits vorhandene Konfiguration eines // simulierten Bestandsgeräts), damit sich der neue Analyse-Schritt über den Testzugang diff --git a/src/MikrotikInstaller.Core/Expert/ExpertToolPlans.cs b/src/MikrotikInstaller.Core/Expert/ExpertToolPlans.cs new file mode 100644 index 0000000..8d8f38c --- /dev/null +++ b/src/MikrotikInstaller.Core/Expert/ExpertToolPlans.cs @@ -0,0 +1,126 @@ +using MikrotikInstaller.Core.Configuration; +using MikrotikInstaller.Core.Connectivity; + +namespace MikrotikInstaller.Core.Expert; + +/// Eingaben für „Neues LAN anlegen“ (Mac v1.3.0): ein eigenes Netz mit DHCP auf einem einzelnen Anschluss. +public sealed record NewLanSettings( + string InterfaceName, string RouterAddressCidr, string PoolStart, string PoolEnd, string? LeaseTime, string? IsolateFromInterface); + +/// +/// Pläne der Experte-Werkzeuge als -Listen — dieselbe Form wie im +/// Assistenten, damit die Oberfläche sie vorab als Klartext zeigen und mit Fortschritt ausführen kann. +/// +public static class ExpertToolPlans +{ + /// „Netzwerke isolieren“ (Mac v1.3.0): Baustein aus dem VLAN-Schritt, inkl. Verwaltungs-Sperre und Verbindungs-Kappung. + public static IReadOnlyList Isolate(string isolatedInterface, string otherInterface, string isolatedNetworkCidr) => + NetworkIsolation.BuildActions(new DuplicateRuleGuard(), isolatedInterface, otherInterface, isolatedNetworkCidr); + + /// + /// „Neues LAN anlegen“: nimmt den Anschluss ggf. aus seiner Bridge, räumt Port-Konflikte und legt Adresse, + /// Pool, DHCP-Server und DHCP-Netzwerk an — optional isoliert vom angegebenen Netz. + /// + public static IReadOnlyList NewLan(NewLanSettings settings, IReadOnlyList conflicts, string? currentBridge) + { + var iface = settings.InterfaceName; + var routerIp = settings.RouterAddressCidr.Split('/')[0]; + var networkCidr = IpNetworkHelper.GetNetworkCidr(settings.RouterAddressCidr); + var poolName = $"pool-{iface}"; + var actions = new List(PortConflicts.BuildReleaseActions(conflicts)); + + if (currentBridge is not null) + { + actions.Add(new ConfigurationAction( + $"„{iface}“ aus der Bridge „{currentBridge}“ herausnehmen", + async (client, ct) => + { + var port = (await client.GetAsync("/interface/bridge/port", ct)).FirstOrDefault(p => p.GetValueOrDefault("interface") == iface); + if (port?.GetValueOrDefault(".id") is { } id) + { + await client.RemoveAsync("/interface/bridge/port", id, ct); + } + })); + } + + actions.Add(new ConfigurationAction($"IP-Adresse {settings.RouterAddressCidr} auf „{iface}“ einrichten", + (client, ct) => client.AddAsync("/ip/address", new Dictionary { ["address"] = settings.RouterAddressCidr, ["interface"] = iface }, ct))); + actions.Add(new ConfigurationAction($"Adressbereich {settings.PoolStart}-{settings.PoolEnd} reservieren", + (client, ct) => RouterOsUpsert.UpsertAsync(client, "/ip/pool", new Dictionary { ["name"] = poolName }, + new Dictionary { ["name"] = poolName, ["ranges"] = $"{settings.PoolStart}-{settings.PoolEnd}" }, cancellationToken: ct))); + + var server = new Dictionary { ["interface"] = iface, ["address-pool"] = poolName, ["disabled"] = "no" }; + if (!string.IsNullOrEmpty(settings.LeaseTime)) + { + server["lease-time"] = settings.LeaseTime; + } + + actions.Add(new ConfigurationAction($"DHCP-Server auf „{iface}“ aktivieren", + (client, ct) => RouterOsUpsert.UpsertAsync(client, "/ip/dhcp-server", new Dictionary { ["interface"] = iface }, server, + new Dictionary { ["name"] = $"dhcp-{iface}" }, ct))); + actions.Add(new ConfigurationAction($"Netzwerk-Einstellungen (Gateway/DNS {routerIp}) für „{iface}“ hinterlegen", + (client, ct) => RouterOsUpsert.UpsertAsync(client, "/ip/dhcp-server/network", new Dictionary { ["address"] = networkCidr }, + new Dictionary { ["address"] = networkCidr, ["gateway"] = routerIp, ["dns-server"] = routerIp }, cancellationToken: ct))); + + if (settings.IsolateFromInterface is { Length: > 0 } other) + { + actions.AddRange(Isolate(iface, other, networkCidr)); + } + + return actions; + } + + /// + /// „Port in Bridge einfügen“ (Mac v1.4.0, auch WLAN seit v1.11.3): der Rückweg zu „Neues LAN anlegen“. + /// Räumt Adresse, DHCP-Client/-Server samt Pool und DHCP-Netzwerk sowie Isolationsregeln des Anschlusses + /// und hängt ihn in die Bridge. + /// + public static async Task> RejoinBridgeAsync( + IRouterOsClient client, string port, string bridge, CancellationToken ct = default) + { + var conflicts = await PortConflicts.FindAsync(client, [port], ct); + var actions = new List(PortConflicts.BuildReleaseActions(conflicts)); + + var addresses = conflicts.Where(c => c.Path == "/ip/address" && c.Address is not null).Select(c => c.Address!).ToList(); + var networks = await client.GetAsync("/ip/dhcp-server/network", ct); + foreach (var network in networks.Where(n => n.GetValueOrDefault("address") is { } cidr && addresses.Any(a => IpNetworkHelper.Contains(cidr, a)))) + { + actions.Add(new ConfigurationAction($"DHCP-Netzwerk {network["address"]} von „{port}“ entfernen", + (c, token) => c.RemoveAsync("/ip/dhcp-server/network", network[".id"], token))); + } + + var servers = await client.GetAsync("/ip/dhcp-server", ct); + var pools = await client.GetAsync("/ip/pool", ct); + foreach (var server in servers.Where(s => s.GetValueOrDefault("interface") == port)) + { + if (pools.FirstOrDefault(p => p.GetValueOrDefault("name") == server.GetValueOrDefault("address-pool")) is { } pool) + { + actions.Add(new ConfigurationAction($"Adressbereich „{pool["name"]}“ von „{port}“ entfernen", + (c, token) => c.RemoveAsync("/ip/pool", pool[".id"], token))); + } + } + + // Isolationsregeln: mit Kommentar „Isolation …“ oder ältere Drop-Regeln ohne Kommentar, die den Anschluss betreffen. + var rules = await client.GetAsync("/ip/firewall/filter", ct); + foreach (var rule in rules.Where(r => IsIsolationRuleFor(r, port))) + { + actions.Add(new ConfigurationAction($"Isolationsregel „{rule.GetValueOrDefault("comment") ?? rule.GetValueOrDefault("chain")}“ entfernen", + (c, token) => c.RemoveAsync("/ip/firewall/filter", rule[".id"], token))); + } + + actions.Add(new ConfigurationAction($"„{port}“ in die Bridge „{bridge}“ einhängen", + (c, token) => RouterOsUpsert.UpsertAsync(c, "/interface/bridge/port", new Dictionary { ["interface"] = port }, + new Dictionary { ["bridge"] = bridge, ["interface"] = port }, cancellationToken: token))); + return actions; + } + + private static bool IsIsolationRuleFor(IReadOnlyDictionary rule, string port) + { + var touchesPort = rule.GetValueOrDefault("in-interface") == port || rule.GetValueOrDefault("out-interface") == port; + var comment = rule.GetValueOrDefault("comment") ?? string.Empty; + return touchesPort && rule.ContainsKey(".id") + && (comment.StartsWith("Isolation", StringComparison.Ordinal) + || (comment.Length == 0 && rule.GetValueOrDefault("action") == "drop" && rule.GetValueOrDefault("chain") == "forward" + && rule.ContainsKey("in-interface") && rule.ContainsKey("out-interface"))); + } +} diff --git a/src/MikrotikInstaller.Core/Expert/MenuCatalog.cs b/src/MikrotikInstaller.Core/Expert/MenuCatalog.cs new file mode 100644 index 0000000..84cca64 --- /dev/null +++ b/src/MikrotikInstaller.Core/Expert/MenuCatalog.cs @@ -0,0 +1,105 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using MikrotikInstaller.Core.Connectivity; + +namespace MikrotikInstaller.Core.Expert; + +/// Ein Feld eines kuratierten Menüs mit laienverständlicher Hilfe. +/// text, bool, int, enum, pick, interfacePick, duration, dataVolume. +public sealed record FieldSchema( + string Key, string Label, string Help, string Kind, + IReadOnlyList? Options = null, string? PickPath = null, string? PickField = null, + string? Default = null, bool Required = false); + +/// Menü ohne Einträge, nur Einstellungen (z. B. /ip/dns) — Änderungen per "set" ohne ID. +/// Feste Liste (z. B. /ip/service): bearbeiten ja, anlegen/löschen nein. +public sealed record MenuSchema( + string Path, string Group, string Title, string Summary, string Explanation, string? Warning, + IReadOnlyList Fields, IReadOnlyList Columns, bool Singleton = false, bool FixedList = false); + +/// +/// Kuratierte RouterOS-Menüs für den Experte-Bereich (Issue #21/#62), übernommen aus dem Katalog der +/// Mac-Version (RouterOSSchemaCatalog, per Skript nach menu-catalog.json übersetzt). Alles andere bleibt +/// über „eigener Menüpfad“ generisch erreichbar. +/// +public static class MenuCatalog +{ + private static readonly Lazy> Menus = new(Load); + + public static IReadOnlyList All => Menus.Value; + + public static MenuSchema? Find(string path) => + All.FirstOrDefault(m => string.Equals(m.Path, NormalizePath(path), StringComparison.OrdinalIgnoreCase)); + + /// "ip firewall filter", "/ip/firewall/filter/" → "/ip/firewall/filter". + public static string NormalizePath(string path) => + "/" + string.Join('/', path.Split([' ', '/'], StringSplitOptions.RemoveEmptyEntries)); + + private static IReadOnlyList Load() + { + using var stream = typeof(MenuCatalog).Assembly.GetManifestResourceStream("MikrotikInstaller.Core.Expert.menu-catalog.json") + ?? throw new InvalidOperationException("menu-catalog.json fehlt als eingebettete Ressource."); + return JsonSerializer.Deserialize>(stream, new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + }) ?? []; + } +} + +/// Regeln und Schreibzugriffe des Experte-Bereichs, unabhängig von der Oberfläche testbar. +public static class ExpertMenus +{ + /// + /// Dynamische Einträge (von RouterOS selbst erzeugt), Standard-Einträge (z. B. Profil „default“) und + /// feste Listen lehnt RouterOS beim Löschen ab — dort gar keinen Löschen-Knopf zeigen (Mac v1.6.0, Mac #40). + /// + public static bool CanDelete(MenuSchema? schema, IReadOnlyDictionary row) => + schema is not { Singleton: true } and not { FixedList: true } + && !IsTrue(row, "dynamic") && !IsTrue(row, "default") && !IsTrue(row, "builtin") + && !(row.GetValueOrDefault("name")?.StartsWith("default", StringComparison.OrdinalIgnoreCase) ?? false) + && row.ContainsKey(".id"); + + public static bool IsDisabled(IReadOnlyDictionary row) => IsTrue(row, "disabled"); + + public static bool IsTrue(IReadOnlyDictionary row, string key) => + row.GetValueOrDefault(key) is "true" or "yes"; + + /// Nur geänderte, nicht schreibgeschützte Felder — leer heißt: nichts zu speichern. + public static Dictionary Changes( + IReadOnlyDictionary? original, IReadOnlyDictionary edited) => + edited + .Where(kv => !kv.Key.StartsWith('.') && (original?.GetValueOrDefault(kv.Key) ?? string.Empty) != kv.Value) + .Where(kv => original is not null || kv.Value.Length > 0) + .ToDictionary(kv => kv.Key, kv => kv.Value); + + /// Der Befehl, der gleich ausgeführt wird, in RouterOS-Schreibweise — nichts passiert unangekündigt. + public static string Preview(string path, string verb, string? id, IReadOnlyDictionary parameters) + { + var words = new List { $"{MenuCatalog.NormalizePath(path).Replace('/', ' ').Trim()} {verb}".Insert(0, "/") }; + if (id is not null) + { + words.Add(id); + } + + words.AddRange(parameters.Select(kv => kv.Value.Contains(' ') || kv.Value.Length == 0 ? $"{kv.Key}=\"{kv.Value}\"" : $"{kv.Key}={kv.Value}")); + return string.Join(' ', words); + } + + public static async Task SaveAsync( + IRouterOsClient client, MenuSchema? schema, string path, string? id, IReadOnlyDictionary changes, CancellationToken ct = default) + { + if (schema is { Singleton: true }) + { + await client.ExecuteAsync($"{MenuCatalog.NormalizePath(path)}/set", changes, ct); + } + else if (id is null) + { + await client.AddAsync(path, changes, ct); + } + else + { + await client.SetAsync(path, id, changes, ct); + } + } +} diff --git a/src/MikrotikInstaller.Core/Expert/PackageService.cs b/src/MikrotikInstaller.Core/Expert/PackageService.cs new file mode 100644 index 0000000..535a3ba --- /dev/null +++ b/src/MikrotikInstaller.Core/Expert/PackageService.cs @@ -0,0 +1,186 @@ +using System.Text.RegularExpressions; +using MikrotikInstaller.Core.Connectivity; + +namespace MikrotikInstaller.Core.Expert; + +/// Ein Eintrag aus /system/package (Mac v1.11.0, live geprüft an CHR 7.24.5 und hAP lite). +/// Von MikroTik angeboten, nicht installiert. +/// RouterOS' Hinweis auf eine vorgemerkte Änderung, leer wenn nichts vorgemerkt ist. +public sealed partial record RouterPackage(string Id, string Name, bool IsAvailable, bool IsDisabled, string Scheduled, long SizeBytes, string Version) +{ + public bool IsInstalled => !IsAvailable; + + public bool IsScheduledForInstall => Scheduled.Contains("install") && !Scheduled.Contains("uninstall"); + + public bool IsScheduledForUninstall => Scheduled.Contains("uninstall"); + + /// Das Grundpaket — wird nie zum Entfernen angeboten (RouterOS würde es sogar vormerken). + public bool IsSystemPackage => Name == "routeros" || Name.StartsWith("routeros-", StringComparison.Ordinal); + + /// WLAN-Treiber: Entfernen schaltet das WLAN des Routers ab. + public bool IsWirelessDriver => Name == "wireless" || Name.StartsWith("wifi", StringComparison.Ordinal); + + public string? Description => PackageService.Descriptions.GetValueOrDefault(Name); + + /// Nur RouterOS' eigenes Namensmuster — alles andere könnte einen zweiten Befehl tragen. + public static bool IsSafeName(string name) => SafeName().IsMatch(name); + + [GeneratedRegex("^[a-z0-9-]+$")] + private static partial Regex SafeName(); +} + +/// +/// Zusatzpakete installieren/entfernen (Experte → Pakete). Ablauf wie in der Mac-Version: Liste vom +/// MikroTik-Server auffrischen, Änderungen per enable/uninstall vormerken, mit apply-changes in einem +/// Rutsch herunterladen und neu starten. Wird eine Vormerkung abgelehnt, werden die bereits gesetzten +/// zurückgenommen, damit beim nächsten Neustart nichts Halbgeplantes passiert. +/// +public static class PackageService +{ + public static readonly IReadOnlyDictionary Descriptions = new Dictionary + { + ["routeros"] = "Das Betriebssystem des Routers selbst. Kann nicht entfernt werden.", + ["wireless"] = "Treiber und Werkzeuge für WLAN (bis 802.11ac) und 60-GHz-Funk.", + ["wifi-qcom"] = "Pflicht-Treiber für WLAN nach 802.11ax (Wi-Fi 6).", + ["wifi-qcom-ac"] = "Optionaler WLAN-Treiber für passende 802.11ac-Funkchips.", + ["hotspot"] = "Hotspot: Anmeldeseite (Login-Portal) für Gäste-WLAN oder Gäste-LAN.", + ["calea"] = "Datenerfassung für US-Behörden (Gesetz CALEA). Im Heimnetz nicht nötig.", + ["container"] = "Lässt Linux-Container direkt auf dem Router laufen.", + ["dude"] = "The Dude: Überwachung des ganzen Netzwerks mit Netzwerkkarte.", + ["gps"] = "Unterstützung für GPS-Empfänger.", + ["iot"] = "Internet der Dinge: MQTT, LoRa, Bluetooth, GPIO und Modbus (je nach Hardware).", + ["lora"] = "Platzhalter für alte LoRa-Einstellungen. LoRa steckt heute im Paket iot.", + ["lte"] = "Treiber für das eingebaute LTE-Modem, nur beim SXT LTE nötig.", + ["netinstall"] = "Netinstall-Server: spielt RouterOS über das Netzwerk auf andere MikroTik-Geräte.", + ["openflow"] = "OpenFlow: Steuerung durch einen externen SDN-Controller.", + ["rose-storage"] = "Rechenzentrums-Funktionen für Datenträger: RAID, rsync, iSCSI, NFS, SMB und mehr.", + ["tr069-client"] = "TR-069: Fernverwaltung durch einen Internetanbieter.", + ["ups"] = "Überwachung einer angeschlossenen APC-USV (unterbrechungsfreie Stromversorgung).", + ["user-manager"] = "User Manager: Benutzerverwaltung für Hotspot und andere Dienste.", + ["zerotier"] = "ZeroTier: virtuelles Netzwerk über das Internet.", + ["extra-nic"] = "Zusätzliche Netzwerkkarten-Treiber für Nicht-MikroTik-Hardware.", + ["switch-marvell"] = "Pflicht-Treiber für Switches der Serie CRS8xx.", + ["iot-bt-extra"] = "Unterstützung für USB-Bluetooth-Adapter (ab Bluetooth LE 4.0).", + }; + + /// Wartezeit-Takt beim Abfragen von RouterOS-Zuständen; in Tests 0. + public static TimeSpan PollDelay { get; set; } = TimeSpan.FromSeconds(1); + + /// Paketliste plus freier Speicher. Leere Liste verfügbarer Pakete (z. B. nach einem Neustart) wird einmal aufgefrischt. + public static async Task<(IReadOnlyList Packages, long? FreeBytes)> ListAsync(IRouterOsClient client, bool refresh = false, CancellationToken ct = default) + { + if (refresh) + { + await RefreshAvailableAsync(client, ct); + } + + var packages = await ReadAsync(client, ct); + if (!refresh && !packages.Any(p => p.IsAvailable)) + { + await RefreshAvailableAsync(client, ct); + packages = await ReadAsync(client, ct); + } + + var resource = (await client.GetAsync("/system/resource", ct)).FirstOrDefault(); + long? free = long.TryParse(resource?.GetValueOrDefault("free-hdd-space"), out var bytes) ? bytes : null; + return (packages, free); + } + + /// Merkt alle Änderungen vor und wendet sie gemeinsam an — ein Download, ein Neustart. + public static async Task ApplyAsync(IRouterOsClient client, IReadOnlyList install, IReadOnlyList uninstall, CancellationToken ct = default) + { + if (install.Concat(uninstall).FirstOrDefault(p => !RouterPackage.IsSafeName(p.Name) || (p.IsSystemPackage && uninstall.Contains(p))) is { } refused) + { + throw new InvalidOperationException($"Das Paket „{refused.Name}“ kann nicht geändert werden."); + } + + if (install.Count > 0) + { + await RefreshAvailableAsync(client, ct); + } + + var scheduled = new List<(RouterPackage Package, string Undo)>(); + try + { + foreach (var package in install) + { + await client.RunAsync("/system/package/enable", new Dictionary { [".id"] = package.Id }, ct); + scheduled.Add((package, "disable")); + } + + foreach (var package in uninstall) + { + await client.RunAsync("/system/package/uninstall", new Dictionary { [".id"] = package.Id }, ct); + scheduled.Add((package, "enable")); + } + } + catch (RouterOsException) + { + foreach (var (package, undo) in scheduled) + { + try + { + await client.RunAsync($"/system/package/{undo}", new Dictionary { [".id"] = package.Id }, ct); + } + catch (RouterOsException) + { + // Best effort. + } + } + + throw; + } + + try + { + await client.ExecuteAsync("/system/package/apply-changes", cancellationToken: ct); + } + catch (RouterOsException ex) when (ex.IsConnectionLost) + { + // Erwartet: Der Router lädt herunter und startet neu. + } + } + + private static async Task> ReadAsync(IRouterOsClient client, CancellationToken ct) => + (await client.GetAsync("/system/package", ct)) + .Where(r => r.GetValueOrDefault("name") is { } name && RouterPackage.IsSafeName(name)) + .Select(r => new RouterPackage( + r.GetValueOrDefault(".id") ?? r["name"], + r["name"], + r.GetValueOrDefault("available") is "true" or "yes", + r.GetValueOrDefault("disabled") is "true" or "yes", + r.GetValueOrDefault("scheduled") ?? string.Empty, + long.TryParse(r.GetValueOrDefault("size"), out var size) ? size : 0, + r.GetValueOrDefault("version") ?? string.Empty)) + .OrderBy(p => p.IsAvailable) + .ThenBy(p => p.Name, StringComparer.Ordinal) + .ToList(); + + /// + /// Fragt beim MikroTik-Server nach verfügbaren Paketen. Solange RouterOS „finding out latest version“ + /// meldet, ist die Liste unvollständig und apply-changes würde still ignoriert (Mac, live CHR 7.24.5). + /// + private static async Task RefreshAvailableAsync(IRouterOsClient client, CancellationToken ct) + { + try + { + await client.ExecuteAsync("/system/package/update/check-for-updates", cancellationToken: ct); + } + catch (RouterOsException) + { + return; + } + + // ponytail: 60 Abfragen im Sekundentakt, danach trotzdem weiter; ein verpasstes Ende fällt beim Ergebnis auf. + for (var i = 0; i < 60; i++) + { + var status = (await client.GetAsync("/system/package/update", ct)).FirstOrDefault()?.GetValueOrDefault("status") ?? string.Empty; + if (!status.Contains("finding out", StringComparison.OrdinalIgnoreCase) && !status.Contains("checking", StringComparison.OrdinalIgnoreCase)) + { + return; + } + + await Task.Delay(PollDelay, ct); + } + } +} diff --git a/src/MikrotikInstaller.Core/Expert/menu-catalog.json b/src/MikrotikInstaller.Core/Expert/menu-catalog.json new file mode 100644 index 0000000..0af5ddb --- /dev/null +++ b/src/MikrotikInstaller.Core/Expert/menu-catalog.json @@ -0,0 +1,2262 @@ +[ + { + "path": "/ip/firewall/filter", + "group": "Firewall: Filter-Regeln", + "title": "Filter-Regeln", + "summary": "Entscheidet, ob Pakete durchgelassen (accept), verworfen (drop/reject) oder weiter geprüft werden.", + "explanation": "input = Zugriffe auf den Router selbst, forward = Verkehr, der durch den Router hindurchgeht (z.B. LAN↔Internet oder zwischen zwei Netzen), output = vom Router ausgehender Verkehr. Regeln werden von oben nach unten geprüft; die erste passende Regel entscheidet.", + "fields": [ + { + "key": "chain", + "label": "Chain", + "help": "input = an den Router, forward = durch den Router, output = vom Router aus. Eigene Chains (Sprungziele über \"jump\") sind ebenfalls möglich. Beispiel: forward", + "kind": "text", + "default": "forward", + "required": true + }, + { + "key": "action", + "label": "Aktion", + "help": "Was mit passenden Paketen geschieht. reject schickt zusätzlich eine Fehlermeldung zurück, drop verwirft stillschweigend.", + "kind": "enum", + "options": [ + "accept", + "drop", + "reject", + "log", + "jump", + "return", + "add-src-to-address-list", + "add-dst-to-address-list", + "fasttrack-connection", + "passthrough" + ], + "default": "accept", + "required": true + }, + { + "key": "src-address", + "label": "Quell-Adresse", + "help": "Einzel-IP oder Netz mit Präfix, z.B. 192.168.88.5 oder 192.168.88.0/24. Leer = beliebig.", + "kind": "text" + }, + { + "key": "dst-address", + "label": "Ziel-Adresse", + "help": "Einzel-IP oder Netz mit Präfix, z.B. 192.168.88.5 oder 192.168.88.0/24. Leer = beliebig.", + "kind": "text" + }, + { + "key": "src-address-list", + "label": "Quelle in Adress-Liste", + "help": "Nur Pakete, deren Absender in dieser zuvor angelegten Adress-Liste steht.", + "kind": "pick", + "pickPath": "/ip/firewall/address-list", + "pickField": "list" + }, + { + "key": "dst-address-list", + "label": "Ziel in Adress-Liste", + "help": "Nur Pakete, deren Ziel in dieser zuvor angelegten Adress-Liste steht.", + "kind": "pick", + "pickPath": "/ip/firewall/address-list", + "pickField": "list" + }, + { + "key": "in-interface", + "label": "Eingangs-Interface", + "help": "Nur Pakete, die über dieses Interface hereinkommen, z.B. ether1 oder bridge.", + "kind": "interfacePick" + }, + { + "key": "out-interface", + "label": "Ausgangs-Interface", + "help": "Nur Pakete, die über dieses Interface hinausgehen, z.B. ether1 oder bridge.", + "kind": "interfacePick" + }, + { + "key": "protocol", + "label": "Protokoll", + "help": "IP-Protokoll. Leer = alle.", + "kind": "enum", + "options": [ + "tcp", + "udp", + "icmp", + "gre", + "ipsec-esp", + "ipsec-ah" + ] + }, + { + "key": "dst-port", + "label": "Ziel-Port(s)", + "help": "Nur bei tcp/udp sinnvoll. Einzelner Port (z.B. 80) oder Bereich (z.B. 8000-8100).", + "kind": "text" + }, + { + "key": "connection-state", + "label": "Verbindungsstatus", + "help": "Status laut Connection-Tracking. \"established,related\" ist die übliche \"schon erlaubte Verbindung\"-Regel.", + "kind": "enum", + "options": [ + "new", + "established", + "related", + "invalid", + "untracked" + ] + }, + { + "key": "layer7-protocol", + "label": "Layer7-Protokoll", + "help": "Name eines vorher unter \"/ip firewall layer7-protocol\" angelegten Musters (z.B. Erkennung bestimmter Apps). Kostet spürbar CPU, mit Bedacht einsetzen.", + "kind": "text" + }, + { + "key": "comment", + "label": "Kommentar", + "help": "Nur zur eigenen Wiedererkennung, ohne technische Wirkung.", + "kind": "text" + }, + { + "key": "disabled", + "label": "Deaktiviert", + "help": "Regel inaktiv schalten, ohne sie zu löschen.", + "kind": "bool", + "default": "no" + } + ], + "columns": [ + "chain", + "action", + "in-interface", + "out-interface", + "comment" + ], + "warning": "Reihenfolge zählt. Neue Regeln landen am Ende der Liste — eine bereits vorhandene, weiter oben stehende Regel kann eine neue Regel unerreichbar machen. Position danach mit \"/ip firewall filter print\" kontrollieren, notfalls mit \"move\" verschieben." + }, + { + "path": "/ip/firewall/nat", + "group": "Firewall: NAT (Portweiterleitung etc.)", + "title": "NAT-Regeln", + "summary": "Übersetzt Adressen — Internetfreigabe (srcnat/masquerade) und Portweiterleitung (dstnat).", + "explanation": "srcnat ändert die Absenderadresse ausgehender Pakete (z.B. private LAN-IP → öffentliche WAN-IP). dstnat ändert die Zieladresse eingehender Pakete (z.B. Anfrage an die WAN-IP auf Port 80 → internen Server auf 192.168.88.10:80 umleiten).", + "fields": [ + { + "key": "chain", + "label": "Chain", + "help": "srcnat = Absenderadresse ändern (Internetfreigabe). dstnat = Zieladresse ändern (Portweiterleitung).", + "kind": "enum", + "options": [ + "srcnat", + "dstnat" + ], + "default": "srcnat", + "required": true + }, + { + "key": "action", + "label": "Aktion", + "help": "masquerade = automatisches NAT über die aktuelle WAN-IP (empfohlen bei wechselnder IP). src-nat = feste NAT-Adresse. dst-nat = Ziel umschreiben (Portweiterleitung). redirect = auf den Router selbst umlenken.", + "kind": "enum", + "options": [ + "masquerade", + "src-nat", + "dst-nat", + "netmap", + "redirect", + "same" + ], + "default": "masquerade", + "required": true + }, + { + "key": "src-address", + "label": "Quell-Adresse", + "help": "Einzel-IP oder Netz mit Präfix, z.B. 192.168.88.0/24. Leer = beliebig.", + "kind": "text" + }, + { + "key": "dst-address", + "label": "Ziel-Adresse", + "help": "Einzel-IP oder Netz mit Präfix, z.B. 192.168.88.0/24. Leer = beliebig.", + "kind": "text" + }, + { + "key": "in-interface", + "label": "Eingangs-Interface", + "help": "Bei dstnat meist der WAN-Port (woher die Anfrage aus dem Internet kommt), z.B. ether1.", + "kind": "interfacePick" + }, + { + "key": "out-interface", + "label": "Ausgangs-Interface", + "help": "Bei srcnat/masquerade meist der WAN-Port, z.B. ether1.", + "kind": "interfacePick" + }, + { + "key": "protocol", + "label": "Protokoll", + "help": "Nötig, damit Ports geprüft werden können.", + "kind": "enum", + "options": [ + "tcp", + "udp", + "icmp" + ] + }, + { + "key": "dst-port", + "label": "Ziel-Port der Anfrage", + "help": "Der Port, auf den die Anfrage von außen ankommt, z.B. 8080.", + "kind": "text" + }, + { + "key": "to-addresses", + "label": "Weiterleiten an (interne IP)", + "help": "Nur bei dst-nat: die interne IP-Adresse, an die weitergeleitet wird, z.B. 192.168.88.10.", + "kind": "text" + }, + { + "key": "to-ports", + "label": "Weiterleiten an (interner Port)", + "help": "Nur bei dst-nat: interner Port, falls abweichend vom Ziel-Port (z.B. extern 8080 → intern 80).", + "kind": "text" + }, + { + "key": "comment", + "label": "Kommentar", + "help": "Nur zur Wiedererkennung.", + "kind": "text" + }, + { + "key": "disabled", + "label": "Deaktiviert", + "help": "Regel inaktiv schalten, ohne sie zu löschen.", + "kind": "bool", + "default": "no" + } + ], + "columns": [ + "chain", + "action", + "comment" + ], + "warning": "Eine dstnat-Regel (Portweiterleitung) macht ein internes Gerät direkt aus dem Internet erreichbar — nur für Dienste einrichten, die das wirklich sein sollen, und danach den entsprechenden Port in der Filter-Tabelle nicht blockieren." + }, + { + "path": "/ip/firewall/mangle", + "group": "Firewall: Mangle (Markierung/QoS-Vorbereitung)", + "title": "Mangle-Regeln", + "summary": "Markiert Verbindungen/Pakete zur späteren Weiterverarbeitung (z.B. durch Queues).", + "explanation": "Mangle selbst verändert nicht, wie ein Paket behandelt wird — es klebt nur eine Markierung darauf. Erst eine andere Regel (typischerweise eine Queue oder eine Routing-Regel), die genau diese Markierung als Bedingung abfragt, wird dadurch wirksam.", + "fields": [ + { + "key": "chain", + "label": "Chain", + "help": "Verarbeitungspunkt im Router-internen Paketfluss.", + "kind": "enum", + "options": [ + "prerouting", + "input", + "forward", + "output", + "postrouting" + ], + "default": "forward", + "required": true + }, + { + "key": "action", + "label": "Aktion", + "help": "mark-connection markiert die ganze Verbindung (wirkt auf alle ihre Pakete), mark-packet nur einzelne Pakete.", + "kind": "enum", + "options": [ + "mark-connection", + "mark-packet", + "mark-routing", + "change-mss", + "change-ttl", + "set-priority", + "accept", + "passthrough" + ], + "default": "mark-connection", + "required": true + }, + { + "key": "new-connection-mark", + "label": "Name der Verbindungsmarkierung", + "help": "Frei wählbarer Name, den z.B. eine Queue später wiedererkennt.", + "kind": "text" + }, + { + "key": "new-packet-mark", + "label": "Name der Paketmarkierung", + "help": "Frei wählbarer Name für die Paketmarkierung.", + "kind": "text" + }, + { + "key": "passthrough", + "label": "Weitere Mangle-Regeln noch prüfen", + "help": "Ja (Standard) lässt nachfolgende Mangle-Regeln diese Verbindung noch zusätzlich prüfen.", + "kind": "bool", + "default": "yes" + }, + { + "key": "src-address", + "label": "Quell-Adresse", + "help": "Einzel-IP oder Netz mit Präfix, z.B. 192.168.88.0/24. Leer = beliebig.", + "kind": "text" + }, + { + "key": "dst-address", + "label": "Ziel-Adresse", + "help": "Einzel-IP oder Netz mit Präfix, z.B. 192.168.88.0/24. Leer = beliebig.", + "kind": "text" + }, + { + "key": "in-interface", + "label": "Eingangs-Interface", + "help": "Nur Pakete, die über dieses Interface hereinkommen, z.B. ether1 oder bridge.", + "kind": "interfacePick" + }, + { + "key": "out-interface", + "label": "Ausgangs-Interface", + "help": "Nur Pakete, die über dieses Interface hinausgehen, z.B. ether1 oder bridge.", + "kind": "interfacePick" + }, + { + "key": "protocol", + "label": "Protokoll", + "help": "IP-Protokoll. Leer = alle.", + "kind": "enum", + "options": [ + "tcp", + "udp", + "icmp" + ] + }, + { + "key": "dst-port", + "label": "Ziel-Port(s)", + "help": "Nur bei tcp/udp sinnvoll. Einzelner Port (z.B. 80) oder Bereich (z.B. 8000-8100).", + "kind": "text" + }, + { + "key": "comment", + "label": "Kommentar", + "help": "Nur zur Wiedererkennung.", + "kind": "text" + }, + { + "key": "disabled", + "label": "Deaktiviert", + "help": "Regel inaktiv schalten, ohne sie zu löschen.", + "kind": "bool", + "default": "no" + } + ], + "columns": [ + "chain", + "action", + "comment" + ], + "warning": "Eine Markierung ohne etwas, das sie auswertet (z.B. keine passende Queue), hat keinerlei sichtbaren Effekt — das ist der häufigste Verwirrungspunkt bei Mangle." + }, + { + "path": "/ip/firewall/raw", + "group": "Firewall: Raw (vor Connection-Tracking)", + "title": "Raw-Regeln", + "summary": "Läuft vor jeder Connection-Tracking-Verarbeitung — meist zur Entlastung oder für DDoS-Grobfilter.", + "explanation": "Regeln hier greifen, bevor RouterOS eine Verbindung überhaupt \"kennt\" (Connection-Tracking). Damit lässt sich z.B. bekannt uninteressanter Verkehr sehr günstig verwerfen, oder gezielt vom Tracking ausnehmen (notrack).", + "fields": [ + { + "key": "chain", + "label": "Chain", + "help": "prerouting = eingehende Pakete, bevor der Router sie verarbeitet. output = vom Router selbst erzeugte Pakete.", + "kind": "enum", + "options": [ + "prerouting", + "output" + ], + "default": "prerouting", + "required": true + }, + { + "key": "action", + "label": "Aktion", + "help": "notrack = von der Connection-Tracking-Verfolgung ausnehmen (siehe Warnhinweis oben).", + "kind": "enum", + "options": [ + "accept", + "drop", + "notrack" + ], + "default": "accept", + "required": true + }, + { + "key": "src-address", + "label": "Quell-Adresse", + "help": "Einzel-IP oder Netz mit Präfix, z.B. 192.168.88.0/24. Leer = beliebig.", + "kind": "text" + }, + { + "key": "dst-address", + "label": "Ziel-Adresse", + "help": "Einzel-IP oder Netz mit Präfix, z.B. 192.168.88.0/24. Leer = beliebig.", + "kind": "text" + }, + { + "key": "in-interface", + "label": "Eingangs-Interface", + "help": "Nur Pakete, die über dieses Interface hereinkommen, z.B. ether1.", + "kind": "interfacePick" + }, + { + "key": "protocol", + "label": "Protokoll", + "help": "IP-Protokoll. Leer = alle.", + "kind": "enum", + "options": [ + "tcp", + "udp", + "icmp" + ] + }, + { + "key": "dst-port", + "label": "Ziel-Port(s)", + "help": "Nur bei tcp/udp sinnvoll. Einzelner Port (z.B. 80) oder Bereich (z.B. 8000-8100).", + "kind": "text" + }, + { + "key": "comment", + "label": "Kommentar", + "help": "Nur zur eigenen Wiedererkennung.", + "kind": "text" + }, + { + "key": "disabled", + "label": "Deaktiviert", + "help": "Regel inaktiv schalten, ohne sie zu löschen.", + "kind": "bool", + "default": "no" + } + ], + "columns": [ + "chain", + "action", + "comment" + ], + "warning": "notrack nimmt den betroffenen Verkehr aus dem Connection-Tracking heraus — danach greifen dafür keine Filter-/NAT-Regeln mehr, die sich auf connection-state oder NAT verlassen. Nur gezielt einsetzen." + }, + { + "path": "/ip/firewall/address-list", + "group": "Firewall: Adress-Listen", + "title": "Adress-Listen", + "summary": "Benannte Gruppen von IP-Adressen/Netzen, die Filter/NAT/Mangle-Regeln als Bedingung referenzieren können.", + "explanation": "Statt in jeder Regel einzelne IPs aufzuzählen, legst du hier eine benannte Liste an (z.B. \"gesperrt\" oder \"vertraut\") und verweist in Filter-/NAT-/Mangle-Regeln per \"src-address-list\"/\"dst-address-list\" darauf. Einträge können dauerhaft oder mit Ablaufzeit (timeout) sein.", + "fields": [ + { + "key": "list", + "label": "Listen-Name", + "help": "Frei wählbarer Name, wird in anderen Regeln referenziert, z.B. gesperrt.", + "kind": "text", + "required": true + }, + { + "key": "address", + "label": "Adresse", + "help": "Einzel-IP oder Netz mit Präfix, z.B. 192.168.88.5 oder 10.0.0.0/24.", + "kind": "text", + "required": true + }, + { + "key": "timeout", + "label": "Ablaufzeit", + "help": "Optional. Nach dieser Zeit wird der Eintrag automatisch entfernt. Alles auf 0 lassen = bleibt dauerhaft, bis manuell entfernt.", + "kind": "duration" + }, + { + "key": "comment", + "label": "Kommentar", + "help": "Nur zur eigenen Wiedererkennung.", + "kind": "text" + } + ], + "columns": [ + "list", + "address" + ] + }, + { + "path": "/interface", + "group": "Interfaces (Bridge, VLAN, VPN-Tunnel …)", + "title": "Alle Interfaces (generisch)", + "summary": "Gemeinsame Felder, die für jedes Interface gelten, unabhängig vom Typ (Ethernet, Bridge, WLAN, WireGuard, VLAN, ...).", + "explanation": "RouterOS listet hier alle Interfaces zusammen. Typ-spezifische Felder (z.B. die VLAN-ID eines VLAN-Interfaces) liegen in den jeweils eigenen Menüs (z.B. \"VLAN-Interfaces\") — hier nur das, was für jeden Interface-Typ gleich funktioniert.", + "fields": [ + { + "key": "name", + "label": "Name", + "help": "Der aktuelle Name dieses Interfaces, z.B. ether5 oder vlan20 — nicht zu verwechseln mit \"default-name\" (Werksname, weiter unten bei \"Weitere Parameter\", nicht änderbar).", + "kind": "text", + "required": true + }, + { + "key": "comment", + "label": "Kommentar", + "help": "Nur zur eigenen Wiedererkennung, ohne technische Wirkung.", + "kind": "text" + }, + { + "key": "disabled", + "label": "Deaktiviert", + "help": "Interface inaktiv schalten, ohne es zu löschen.", + "kind": "bool", + "default": "no" + } + ], + "columns": [ + "name" + ], + "warning": "RouterOS liefert hier zusätzlich einen \"default-name\" (Werksname des Ports) mit — der erscheint unten bei \"Weitere Parameter\", ist aber nicht änderbar (\"bad parameter default-name\", live bestätigt). Zum Umbenennen nur das Feld \"Name\" oben verwenden." + }, + { + "path": "/interface/bridge", + "group": "Interfaces (Bridge, VLAN, VPN-Tunnel …)", + "title": "Bridge", + "summary": "Fasst mehrere physische Ports zu einem gemeinsamen Layer-2-Netzwerk zusammen.", + "explanation": "Geräte an gebrückten Ports verhalten sich, als hingen sie am selben Netzwerk-Kabel. Eine IP-Adresse wird meist auf die Bridge selbst gelegt, nicht auf die einzelnen Ports.", + "fields": [ + { + "key": "name", + "label": "Name", + "help": "Frei wählbar, z.B. bridge-lan.", + "kind": "text", + "required": true + }, + { + "key": "vlan-filtering", + "label": "VLAN-Filterung (802.1Q)", + "help": "Aktiviert echte VLAN-Trennung über diese Bridge — nötig, wenn mehrere VLANs über dieselben Bridge-Ports laufen sollen.", + "kind": "bool", + "default": "no" + }, + { + "key": "disabled", + "label": "Deaktiviert", + "help": "Bridge inaktiv schalten, ohne sie zu löschen.", + "kind": "bool", + "default": "no" + } + ], + "columns": [ + "name" + ] + }, + { + "path": "/interface/bridge/port", + "group": "Interfaces (Bridge, VLAN, VPN-Tunnel …)", + "title": "Bridge-Ports", + "summary": "Ordnet einen physischen Port einer Bridge zu.", + "explanation": "Erst danach ist der Port Teil des Bridge-Netzwerks.", + "fields": [ + { + "key": "bridge", + "label": "Bridge", + "help": "Name der Bridge, z.B. bridge-lan.", + "kind": "text", + "required": true + }, + { + "key": "interface", + "label": "Physischer Port", + "help": "Der Port, der der Bridge hinzugefügt wird, z.B. ether2.", + "kind": "interfacePick", + "required": true + }, + { + "key": "pvid", + "label": "Port-VLAN-ID (PVID)", + "help": "Nur mit aktivierter VLAN-Filterung relevant: VLAN, dem untagged ankommender Verkehr an diesem Port zugeordnet wird, z.B. 20.", + "kind": "int" + } + ], + "columns": [ + "bridge", + "interface" + ] + }, + { + "path": "/interface/vlan", + "group": "Interfaces (Bridge, VLAN, VPN-Tunnel …)", + "title": "VLAN-Interfaces", + "summary": "Virtuelle, getaggte Sub-Interfaces auf einem physischen Port oder einer Bridge.", + "explanation": "Ein eigenes logisches Netzwerk auf demselben Kabel, unterschieden durch eine VLAN-Kennung im Ethernet-Rahmen.", + "fields": [ + { + "key": "name", + "label": "Name", + "help": "Frei wählbar, z.B. vlan20-gaeste.", + "kind": "text", + "required": true + }, + { + "key": "vlan-id", + "label": "VLAN-ID", + "help": "Eindeutige Kennung, 2–4094, z.B. 20.", + "kind": "int", + "required": true + }, + { + "key": "interface", + "label": "Basis-Interface", + "help": "Physischer Port oder Bridge, auf dem dieses VLAN aufsetzt, z.B. bridge.", + "kind": "interfacePick", + "required": true + }, + { + "key": "disabled", + "label": "Deaktiviert", + "help": "VLAN-Interface inaktiv schalten, ohne es zu löschen.", + "kind": "bool", + "default": "no" + } + ], + "columns": [ + "name", + "vlan-id", + "interface" + ] + }, + { + "path": "/interface/wireguard", + "group": "Interfaces (Bridge, VLAN, VPN-Tunnel …)", + "title": "WireGuard-Interfaces", + "summary": "Moderner, schlanker VPN-Tunnel-Typ.", + "explanation": "Ein WireGuard-Interface allein stellt noch keine Verbindung her — dazu gehören Gegenstellen (siehe \"WireGuard-Peers\") mit öffentlichem Schlüssel und erlaubten Adressen.", + "fields": [ + { + "key": "name", + "label": "Name", + "help": "Frei wählbar, z.B. wg-heim.", + "kind": "text", + "required": true + }, + { + "key": "listen-port", + "label": "Listen-Port (UDP)", + "help": "Port, auf dem dieser Tunnel auf eingehende Verbindungen lauscht, z.B. 51820.", + "kind": "int", + "default": "51820" + }, + { + "key": "private-key", + "label": "Privater Schlüssel", + "help": "Geheim halten. Leer lassen, damit RouterOS automatisch einen erzeugt.", + "kind": "text" + }, + { + "key": "disabled", + "label": "Deaktiviert", + "help": "Interface inaktiv schalten, ohne es zu löschen.", + "kind": "bool", + "default": "no" + } + ], + "columns": [ + "name", + "listen-port" + ], + "warning": "Erst hier das Interface anlegen (mit Listen-Port), danach unter WireGuard-Peers die Gegenstellen eintragen. Der private Schlüssel wird beim Anlegen automatisch erzeugt, falls nicht angegeben." + }, + { + "path": "/interface/wireguard/peers", + "group": "Interfaces (Bridge, VLAN, VPN-Tunnel …)", + "title": "WireGuard-Peers", + "summary": "Gegenstellen (Clients/andere Router) eines WireGuard-Tunnels.", + "explanation": "Jede Gegenstelle braucht ihren eigenen öffentlichen Schlüssel und eine Angabe, welche Adressen über sie geroutet werden.", + "fields": [ + { + "key": "interface", + "label": "WireGuard-Interface", + "help": "Name des zuvor angelegten WireGuard-Interfaces.", + "kind": "pick", + "pickPath": "/interface/wireguard", + "pickField": "name", + "required": true + }, + { + "key": "public-key", + "label": "Öffentlicher Schlüssel der Gegenstelle", + "help": "Vom Gerät der Gegenstelle kopieren (z.B. per \"wg show public-key\").", + "kind": "text", + "required": true + }, + { + "key": "allowed-address", + "label": "Erlaubte Adressen", + "help": "Welche IP-Adressen/Netze über diesen Peer laufen dürfen, mit Präfix, z.B. 10.10.10.2/32.", + "kind": "text" + }, + { + "key": "endpoint-address", + "label": "Feste Adresse der Gegenstelle", + "help": "Nur nötig, wenn diese Gegenstelle selbst erreichbar sein muss (Site-to-Site), z.B. eine feste öffentliche IP oder ein DNS-Name. Bei Roadwarrior-Clients, die sich selbst melden, leer lassen.", + "kind": "text" + }, + { + "key": "endpoint-port", + "label": "Port der Gegenstelle", + "help": "Meist derselbe Port wie der Listen-Port der Gegenstelle, z.B. 51820.", + "kind": "text" + }, + { + "key": "persistent-keepalive", + "label": "Keepalive", + "help": "Hält die Verbindung durch NAT/Firewalls am Leben. Wichtig bei Clients hinter NAT.", + "kind": "duration" + } + ], + "columns": [ + "interface", + "allowed-address" + ] + }, + { + "path": "/interface/pppoe-client", + "group": "Interfaces (Bridge, VLAN, VPN-Tunnel …)", + "title": "PPPoE-Client", + "summary": "DSL-Einwahl-Client, meist auf dem WAN-Port.", + "explanation": "Ersetzt eine feste/DHCP-WAN-Adresse durch eine PPPoE-Einwahl beim Provider.", + "fields": [ + { + "key": "name", + "label": "Name", + "help": "Frei wählbar, z.B. pppoe-wan.", + "kind": "text", + "required": true + }, + { + "key": "interface", + "label": "Physischer Port", + "help": "Der Port, über den die Einwahl läuft, meist der WAN-Port, z.B. ether1.", + "kind": "interfacePick", + "required": true + }, + { + "key": "user", + "label": "Benutzername", + "help": "Zugangsdaten des Providers.", + "kind": "text", + "required": true + }, + { + "key": "password", + "label": "Passwort", + "help": "Zugangsdaten des Providers.", + "kind": "text", + "required": true + }, + { + "key": "disabled", + "label": "Deaktiviert", + "help": "Einwahl inaktiv schalten, ohne sie zu löschen.", + "kind": "bool", + "default": "no" + } + ], + "columns": [ + "name", + "interface" + ] + }, + { + "path": "/interface/bonding", + "group": "Interfaces (Bridge, VLAN, VPN-Tunnel …)", + "title": "Bonding", + "summary": "Bündelt mehrere physische Ports zu einer logischen, ausfalltoleranten/schnelleren Verbindung.", + "explanation": "", + "fields": [ + { + "key": "name", + "label": "Name", + "help": "Frei wählbar, z.B. bond1.", + "kind": "text", + "required": true + }, + { + "key": "slaves", + "label": "Gebündelte Ports", + "help": "Kommagetrennte Liste physischer Ports, z.B. ether2,ether3.", + "kind": "text" + }, + { + "key": "mode", + "label": "Modus", + "help": "802.3ad (LACP) braucht einen kompatiblen, entsprechend konfigurierten Switch.", + "kind": "enum", + "options": [ + "802.3ad", + "active-backup", + "balance-rr", + "balance-xor", + "broadcast" + ] + } + ], + "columns": [ + "name", + "mode" + ] + }, + { + "path": "/ip/address", + "group": "IP-Adressierung & Dienste", + "title": "IP-Adressen", + "summary": "Weist Interfaces IP-Adressen zu.", + "explanation": "Jede IP-Adresse hängt an genau einem Interface (physischer Port, Bridge oder VLAN).", + "fields": [ + { + "key": "address", + "label": "Adresse", + "help": "IP-Adresse mit Netzmaske als Präfix (Router-Adresse in diesem Netz), z.B. 192.168.88.1/24 oder 10.10.10.1/24. Das /24 legt fest, wie viele Geräte in dieses Netz passen (/24 = bis zu 254).", + "kind": "text", + "required": true + }, + { + "key": "interface", + "label": "Interface", + "help": "Interface, dem diese Adresse zugewiesen wird, z.B. bridge oder ether4.", + "kind": "interfacePick", + "required": true + }, + { + "key": "disabled", + "label": "Deaktiviert", + "help": "Adresse inaktiv schalten, ohne sie zu löschen.", + "kind": "bool", + "default": "no" + } + ], + "columns": [ + "address", + "interface" + ] + }, + { + "path": "/ip/pool", + "group": "IP-Adressierung & Dienste", + "title": "Adress-Pools", + "summary": "Adressbereiche, aus denen DHCP-Server oder PPP-Profile Adressen vergeben.", + "explanation": "", + "fields": [ + { + "key": "name", + "label": "Name", + "help": "Frei wählbar, z.B. dhcp_pool_lan.", + "kind": "text", + "required": true + }, + { + "key": "ranges", + "label": "Bereich(e)", + "help": "Von-bis-Adresse ohne Präfix, z.B. 192.168.88.10-192.168.88.254. Mehrere Bereiche kommagetrennt.", + "kind": "text", + "required": true + } + ], + "columns": [ + "name", + "ranges" + ] + }, + { + "path": "/ip/dhcp-server", + "group": "IP-Adressierung & Dienste", + "title": "DHCP-Server", + "summary": "Vergibt automatisch IP-Adressen an Geräte in einem Netzwerk.", + "explanation": "Für die üblichen Fälle deckt das bereits der Einrichten-Assistent (LAN-/VLAN-Schritt) ab — hier direkter Zugriff für Sonderfälle.", + "fields": [ + { + "key": "name", + "label": "Name", + "help": "Frei wählbar, z.B. dhcp_lan.", + "kind": "text", + "required": true + }, + { + "key": "interface", + "label": "Interface", + "help": "Netzwerk, in dem dieser Server Adressen vergibt, z.B. bridge.", + "kind": "interfacePick", + "required": true + }, + { + "key": "address-pool", + "label": "Adress-Pool", + "help": "Name eines zuvor angelegten Adress-Pools.", + "kind": "pick", + "pickPath": "/ip/pool", + "pickField": "name", + "required": true + }, + { + "key": "lease-time", + "label": "Lease-Zeit", + "help": "Wie lange ein Gerät seine Adresse behält, bevor sie erneuert werden muss, z.B. 1d oder 12h.", + "kind": "duration" + }, + { + "key": "disabled", + "label": "Deaktiviert", + "help": "DHCP-Server inaktiv schalten, ohne ihn zu löschen.", + "kind": "bool", + "default": "no" + } + ], + "columns": [ + "name", + "interface" + ] + }, + { + "path": "/ip/dhcp-server/network", + "group": "IP-Adressierung & Dienste", + "title": "DHCP-Netzwerke", + "summary": "Gateway/DNS/Optionen, die ein DHCP-Server an seine Klienten verteilt.", + "explanation": "Getrennt vom DHCP-Server selbst, weil dieselben Netzwerk-Optionen für mehrere DHCP-Server gelten können.", + "fields": [ + { + "key": "address", + "label": "Netz", + "help": "Für dieses Netz gelten die folgenden Optionen — die Netzadresse mit Präfix, z.B. 192.168.88.0/24.", + "kind": "text", + "required": true + }, + { + "key": "gateway", + "label": "Gateway", + "help": "In der Regel die Router-Adresse in diesem Netz, ohne Präfix, z.B. 192.168.88.1.", + "kind": "text", + "required": true + }, + { + "key": "dns-server", + "label": "DNS-Server", + "help": "Meist der Router selbst, z.B. 192.168.88.1. Mehrere Server kommagetrennt möglich.", + "kind": "text" + } + ], + "columns": [ + "address", + "gateway" + ] + }, + { + "path": "/ip/dhcp-client", + "group": "IP-Adressierung & Dienste", + "title": "DHCP-Client (WAN)", + "summary": "Bezieht automatisch eine IP-Adresse vom Internetanbieter.", + "explanation": "", + "fields": [ + { + "key": "interface", + "label": "Interface", + "help": "Meist der WAN-Port, z.B. ether1.", + "kind": "interfacePick", + "required": true + }, + { + "key": "add-default-route", + "label": "Standardroute übernehmen", + "help": "Übernimmt die vom Provider mitgeteilte Standardroute ins Internet.", + "kind": "bool", + "default": "yes" + }, + { + "key": "use-peer-dns", + "label": "DNS-Server übernehmen", + "help": "Übernimmt die vom Provider mitgeteilten DNS-Server.", + "kind": "bool", + "default": "yes" + }, + { + "key": "disabled", + "label": "Deaktiviert", + "help": "DHCP-Client inaktiv schalten, ohne ihn zu löschen.", + "kind": "bool", + "default": "no" + } + ], + "columns": [ + "interface" + ] + }, + { + "path": "/ip/dns", + "group": "IP-Adressierung & Dienste", + "title": "DNS-Einstellungen", + "summary": "Namensauflösung des Routers selbst (und optional als DNS-Server fürs LAN).", + "explanation": "", + "fields": [ + { + "key": "servers", + "label": "DNS-Server", + "help": "Ein oder mehrere Server, kommagetrennt, z.B. 1.1.1.1,8.8.8.8.", + "kind": "text" + }, + { + "key": "allow-remote-requests", + "label": "Als DNS-Server fürs LAN erlauben", + "help": "Lässt Geräte im LAN den Router selbst als DNS-Server nutzen. Ohne das schlagen DNS-Anfragen von Geräten an den Router fehl, selbst wenn sie ihn als DNS-Server eingetragen haben.", + "kind": "bool", + "default": "no" + } + ], + "columns": [ + "servers" + ], + "singleton": true + }, + { + "path": "/ip/service", + "group": "IP-Adressierung & Dienste", + "title": "Verwaltungsdienste", + "summary": "Schaltet Zugriffswege auf den Router (Winbox, API, SSH, WWW/REST, Telnet, FTP) an/aus und ändert deren Port.", + "explanation": "Jeder aktive Dienst ist ein potenzieller Angriffspunkt aus dem jeweils erreichbaren Netz — nicht benötigte Dienste deaktivieren.", + "fields": [ + { + "key": "name", + "label": "Dienst", + "help": "Welcher Verwaltungsdienst geändert wird.", + "kind": "enum", + "options": [ + "www", + "www-ssl", + "ssh", + "api", + "api-ssl", + "winbox", + "ftp", + "telnet" + ], + "required": true + }, + { + "key": "port", + "label": "Port", + "help": "Auf welchem Port der Dienst lauscht, z.B. 22 für ssh.", + "kind": "int" + }, + { + "key": "available-from", + "label": "Erlaubt von", + "help": "Optional: nur von dieser Adresse/diesem Netz aus erreichbar, mit Präfix, z.B. 192.168.88.0/24.", + "kind": "text" + }, + { + "key": "disabled", + "label": "Deaktiviert", + "help": "Dienst inaktiv schalten, ohne den Eintrag zu löschen.", + "kind": "bool", + "default": "no" + } + ], + "columns": [ + "name", + "port" + ], + "warning": "Den gerade genutzten Zugriffsweg hier abzuschalten oder umzuportieren kann die eigene Verbindung sofort kappen — Vorsicht bei www-ssl (REST-API dieser App) und ssh.", + "fixedList": true + }, + { + "path": "/ip/hotspot", + "group": "IP-Adressierung & Dienste", + "title": "Hotspot", + "summary": "Login-Portal für Gäste-WLAN/-LAN mit Umleitung auf eine Anmeldeseite.", + "explanation": "So funktioniert es: Ein Gerät verbindet sich mit dem Hotspot-Netz und bekommt per DHCP eine Adresse. Beim ersten Aufruf einer Webseite leitet der Router auf seine Anmeldeseite um, erst nach der Anmeldung geht es ins Internet. Bausteine: Server (hier, auf welchem Interface), Server-Profile (Anmeldeart, Login-Seite), Benutzer und Benutzer-Profile (Zugangsdaten, Grenzen für Zeit, Daten, Bandbreite) und Walled Garden (Seiten, die ohne Anmeldung erreichbar sind), jeweils ein eigenes Menü in diesem Bereich. Reihenfolge beim Einrichten: Gästenetz mit DHCP-Server, Server-Profil, Hotspot-Server, Benutzer-Profil, Benutzer. Auf manchen Routern (z.B. hAP lite) ist Hotspot ein eigenes Paket, siehe System > Pakete.", + "fields": [ + { + "key": "name", + "label": "Name", + "help": "Frei wählbar, z.B. hotspot-gast.", + "kind": "text", + "required": true + }, + { + "key": "interface", + "label": "Interface", + "help": "Das Gästenetz, in dem sich Geräte anmelden müssen, z.B. wlan-gast. Nicht die normale LAN-Bridge.", + "kind": "interfacePick", + "required": true + }, + { + "key": "profile", + "label": "Server-Profil", + "help": "Anmeldeart und Login-Seite, siehe \"Hotspot: Server-Profile\".", + "kind": "pick", + "pickPath": "/ip/hotspot/profile", + "pickField": "name", + "default": "default" + }, + { + "key": "address-pool", + "label": "Adress-Pool", + "help": "Optional: Adressen für Geräte mit fest eingestellter, unpassender IP. Meist derselbe Pool wie der DHCP-Server des Gästenetzes.", + "kind": "pick", + "pickPath": "/ip/pool", + "pickField": "name" + }, + { + "key": "idle-timeout", + "label": "Abmelden bei Inaktivität", + "help": "Nach so langer Zeit ohne Datenverkehr wird ein Gerät abgemeldet, z.B. 5m. none = nie.", + "kind": "text", + "default": "5m" + }, + { + "key": "addresses-per-mac", + "label": "Adressen je Gerät", + "help": "Wie viele IP-Adressen ein Gerät (MAC-Adresse) gleichzeitig nutzen darf.", + "kind": "int", + "default": "2" + }, + { + "key": "disabled", + "label": "Deaktiviert", + "help": "Hotspot-Server inaktiv schalten, ohne ihn zu löschen.", + "kind": "bool", + "default": "no" + } + ], + "columns": [ + "name", + "interface", + "profile" + ], + "warning": "Den Hotspot nicht auf das normale LAN legen, sonst muss sich jedes eigene Gerät anmelden. Besser ein eigenes, isoliertes Gästenetz anlegen (eigenes WLAN-Interface oder VLAN, z.B. mit \"Neues LAN anlegen\") und den Hotspot dort einrichten." + }, + { + "path": "/ip/hotspot/profile", + "group": "IP-Adressierung & Dienste", + "title": "Hotspot: Server-Profile", + "summary": "Wie sich Gäste anmelden und unter welcher Adresse die Login-Seite erreichbar ist.", + "explanation": "Ein Server-Profil kann von mehreren Hotspot-Servern genutzt werden. Das Profil \"default\" legt RouterOS selbst an. Eine eigene Anmeldeseite lädt man beim Bearbeiten eines Profils unten mit \"Login-Seite hochladen\" hoch.", + "fields": [ + { + "key": "name", + "label": "Name", + "help": "Frei wählbar, z.B. gast.", + "kind": "text", + "required": true + }, + { + "key": "hotspot-address", + "label": "Hotspot-Adresse", + "help": "Adresse des Routers im Gästenetz, ohne Präfix, z.B. 192.168.50.1.", + "kind": "text" + }, + { + "key": "dns-name", + "label": "DNS-Name", + "help": "Name der Login-Seite, z.B. login.gast. Leer = Anmeldung über die IP-Adresse.", + "kind": "text" + }, + { + "key": "login-by", + "label": "Anmeldeart", + "help": "Kommagetrennt: http-chap, http-pap, https, cookie, mac, trial, mac-cookie. Üblich: http-chap,cookie.", + "kind": "text", + "default": "cookie,http-chap" + }, + { + "key": "http-cookie-lifetime", + "label": "Cookie gültig", + "help": "So lange bleibt ein Gerät nach der Anmeldung per Cookie angemeldet (nur mit Anmeldeart cookie).", + "kind": "duration", + "default": "3d" + }, + { + "key": "html-directory", + "label": "Ordner der Login-Seite", + "help": "Ordner im Dateisystem des Routers mit den HTML-Seiten.", + "kind": "text", + "default": "hotspot" + }, + { + "key": "use-radius", + "label": "RADIUS verwenden", + "help": "Benutzer über einen externen RADIUS-Server prüfen statt über die Benutzerliste des Routers.", + "kind": "bool", + "default": "no" + } + ], + "columns": [ + "name", + "hotspot-address", + "dns-name" + ] + }, + { + "path": "/ip/hotspot/user/profile", + "group": "IP-Adressierung & Dienste", + "title": "Hotspot: Benutzer-Profile", + "summary": "Gemeinsame Grenzen für Gruppen von Hotspot-Benutzern: Bandbreite, Zeit, Geräte.", + "explanation": "Jeder Hotspot-Benutzer bekommt genau ein Profil. Das Profil \"default\" legt RouterOS selbst an.", + "fields": [ + { + "key": "name", + "label": "Name", + "help": "Frei wählbar, z.B. gast-tag.", + "kind": "text", + "required": true + }, + { + "key": "rate-limit", + "label": "Bandbreite", + "help": "Upload/Download je Benutzer, z.B. 2M/10M. Leer = unbegrenzt.", + "kind": "text" + }, + { + "key": "shared-users", + "label": "Geräte je Benutzer", + "help": "Wie viele Geräte sich gleichzeitig mit denselben Zugangsdaten anmelden dürfen.", + "kind": "int", + "default": "1" + }, + { + "key": "session-timeout", + "label": "Max. Sitzungsdauer", + "help": "Nach dieser Zeit wird ein Gerät abgemeldet und muss sich neu anmelden. Leer = unbegrenzt.", + "kind": "duration" + }, + { + "key": "idle-timeout", + "label": "Abmelden bei Inaktivität", + "help": "Nach so langer Zeit ohne Datenverkehr wird ein Gerät abgemeldet, z.B. 10m. none = nie.", + "kind": "text", + "default": "none" + }, + { + "key": "address-list", + "label": "Adress-Liste", + "help": "Optional: Angemeldete Geräte kommen in diese Firewall-Adress-Liste, z.B. für eigene Regeln.", + "kind": "text" + } + ], + "columns": [ + "name", + "rate-limit", + "shared-users" + ] + }, + { + "path": "/ip/hotspot/user", + "group": "IP-Adressierung & Dienste", + "title": "Hotspot: Benutzer", + "summary": "Zugangsdaten für die Anmeldeseite, optional mit eigenen Grenzen.", + "explanation": "Der Eintrag \"default-trial\" gehört RouterOS (Zähler für die Anmeldeart trial) und bleibt am besten unverändert.", + "fields": [ + { + "key": "name", + "label": "Benutzername", + "help": "Wird auf der Anmeldeseite eingegeben.", + "kind": "text", + "required": true + }, + { + "key": "password", + "label": "Passwort", + "help": "Wird auf der Anmeldeseite eingegeben.", + "kind": "text" + }, + { + "key": "profile", + "label": "Benutzer-Profil", + "help": "Grenzen für Bandbreite und Zeit, siehe \"Hotspot: Benutzer-Profile\".", + "kind": "pick", + "pickPath": "/ip/hotspot/user/profile", + "pickField": "name", + "default": "default" + }, + { + "key": "server", + "label": "Hotspot-Server", + "help": "Name des Hotspot-Servers, an dem dieser Benutzer gilt. all = an allen.", + "kind": "text", + "default": "all" + }, + { + "key": "limit-uptime", + "label": "Zeitguthaben", + "help": "Gesamte Online-Zeit, danach ist keine Anmeldung mehr möglich. Leer = unbegrenzt.", + "kind": "duration" + }, + { + "key": "limit-bytes-total", + "label": "Datenvolumen", + "help": "Gesamtes Datenvolumen (Download und Upload), danach ist keine Anmeldung mehr möglich. In MB oder GB, oder eine Vorgabe wählen. Leer = unbegrenzt.", + "kind": "dataVolume" + }, + { + "key": "mac-address", + "label": "MAC-Adresse", + "help": "Optional: Benutzer nur an diesem einen Gerät erlauben.", + "kind": "text" + }, + { + "key": "comment", + "label": "Kommentar", + "help": "Freitext, z.B. für wen der Zugang ist.", + "kind": "text" + }, + { + "key": "disabled", + "label": "Deaktiviert", + "help": "Zugang sperren, ohne ihn zu löschen.", + "kind": "bool", + "default": "no" + } + ], + "columns": [ + "name", + "profile", + "comment" + ] + }, + { + "path": "/ip/hotspot/walled-garden", + "group": "IP-Adressierung & Dienste", + "title": "Hotspot: Walled Garden", + "summary": "Webseiten, die Gäste schon vor der Anmeldung erreichen (z.B. Impressum, Bezahlseite).", + "explanation": "Gilt für Webzugriffe (HTTP), geprüft wird der Hostname. Für andere Dienste oder reine IP-Adressen gibt es \"Hotspot: Walled Garden (IP)\".", + "fields": [ + { + "key": "dst-host", + "label": "Ziel-Host", + "help": "Hostname, * als Platzhalter erlaubt, z.B. *.example.com.", + "kind": "text", + "required": true + }, + { + "key": "action", + "label": "Aktion", + "help": "allow = ohne Anmeldung erreichbar, deny = auch nach der Anmeldung gesperrt.", + "kind": "enum", + "options": [ + "allow", + "deny" + ], + "default": "allow" + }, + { + "key": "dst-port", + "label": "Ziel-Port", + "help": "Optional, z.B. 443.", + "kind": "text" + }, + { + "key": "path", + "label": "Pfad", + "help": "Optional: nur diese Unterseite, z.B. /agb.", + "kind": "text" + }, + { + "key": "server", + "label": "Hotspot-Server", + "help": "Optional: nur für diesen Hotspot-Server.", + "kind": "text" + }, + { + "key": "comment", + "label": "Kommentar", + "help": "Freitext.", + "kind": "text" + }, + { + "key": "disabled", + "label": "Deaktiviert", + "help": "Eintrag inaktiv schalten, ohne ihn zu löschen.", + "kind": "bool", + "default": "no" + } + ], + "columns": [ + "dst-host", + "action", + "comment" + ] + }, + { + "path": "/ip/hotspot/walled-garden/ip", + "group": "IP-Adressierung & Dienste", + "title": "Hotspot: Walled Garden (IP)", + "summary": "IP-Adressen und Ports, die Gäste schon vor der Anmeldung erreichen.", + "explanation": "Für Dienste, die keine Webseiten sind, oder Ziele ohne Hostnamen. Für Webseiten reicht meist \"Hotspot: Walled Garden\".", + "fields": [ + { + "key": "dst-address", + "label": "Ziel-Adresse", + "help": "IP-Adresse oder Netz, z.B. 192.0.2.10 oder 192.0.2.0/24.", + "kind": "text", + "required": true + }, + { + "key": "action", + "label": "Aktion", + "help": "accept = ohne Anmeldung erreichbar, drop/reject = gesperrt.", + "kind": "enum", + "options": [ + "accept", + "drop", + "reject" + ], + "default": "accept" + }, + { + "key": "protocol", + "label": "Protokoll", + "help": "Optional, z.B. tcp oder udp.", + "kind": "text" + }, + { + "key": "dst-port", + "label": "Ziel-Port", + "help": "Optional, nur mit Protokoll tcp oder udp, z.B. 53.", + "kind": "text" + }, + { + "key": "server", + "label": "Hotspot-Server", + "help": "Optional: nur für diesen Hotspot-Server.", + "kind": "text" + }, + { + "key": "comment", + "label": "Kommentar", + "help": "Freitext.", + "kind": "text" + }, + { + "key": "disabled", + "label": "Deaktiviert", + "help": "Eintrag inaktiv schalten, ohne ihn zu löschen.", + "kind": "bool", + "default": "no" + } + ], + "columns": [ + "dst-address", + "protocol", + "dst-port", + "action" + ] + }, + { + "path": "/ip/route", + "group": "Routing", + "title": "Statische Routen", + "summary": "Feste, manuell eingetragene Wegewahl zu Netzen, die nicht direkt angeschlossen sind.", + "explanation": "Für alles außer \"Standard-Internet über eine WAN-Schnittstelle\" (das übernimmt bereits die DHCP-Client-/PPPoE-Route automatisch).", + "fields": [ + { + "key": "dst-address", + "label": "Zielnetz", + "help": "Netz mit Präfix, z.B. 10.0.0.0/24, oder 0.0.0.0/0 für eine Standardroute.", + "kind": "text", + "required": true + }, + { + "key": "gateway", + "label": "Gateway", + "help": "Nächster Hop — IP-Adresse ohne Präfix (z.B. 192.168.88.254) oder Name eines Interfaces (z.B. ether1).", + "kind": "text", + "required": true + }, + { + "key": "distance", + "label": "Distanz", + "help": "Priorität bei mehreren passenden Routen zum selben Ziel — kleinere Zahl wird bevorzugt, z.B. 1.", + "kind": "int" + }, + { + "key": "comment", + "label": "Kommentar", + "help": "Nur zur eigenen Wiedererkennung.", + "kind": "text" + }, + { + "key": "disabled", + "label": "Deaktiviert", + "help": "Route inaktiv schalten, ohne sie zu löschen.", + "kind": "bool", + "default": "no" + } + ], + "columns": [ + "dst-address", + "gateway" + ] + }, + { + "path": "/routing/ospf/instance", + "group": "Routing", + "title": "OSPF-Instanzen", + "summary": "Dynamisches Innennetz-Routing-Protokoll — tauscht Routen automatisch mit anderen OSPF-Routern aus.", + "explanation": "Nur relevant, wenn mehrere Router im selben Netz eigenständig Routen lernen sollen.", + "fields": [], + "columns": [], + "warning": "Fehlkonfiguriertes OSPF kann Routen zu bestehenden Netzen überschreiben. Nur mit Netzwerkplan einsetzen." + }, + { + "path": "/routing/bgp/connection", + "group": "Routing", + "title": "BGP-Verbindungen", + "summary": "Routing-Protokoll für Verbindungen zwischen unabhängigen Netzen/Providern.", + "explanation": "Für Privat-/Kleinnetz i.d.R. nicht nötig — relevant bei eigenem Provider-unabhängigem Adressraum (Multihoming).", + "fields": [], + "columns": [] + }, + { + "path": "/ppp/secret", + "group": "VPN-Server/Clients", + "title": "PPP-Benutzer", + "summary": "Zugangsdaten für PPPoE-/L2TP-/PPTP-/OpenVPN-Einwahl in den Router.", + "explanation": "Jeder Benutzer kann optional einem Profil zugeordnet werden, das IP-Pool/DNS/Verschlüsselung vorgibt.", + "fields": [ + { + "key": "name", + "label": "Benutzername", + "help": "Frei wählbar, z.B. gast1.", + "kind": "text", + "required": true + }, + { + "key": "password", + "label": "Passwort", + "help": "Ausreichend lang und zufällig wählen.", + "kind": "text", + "required": true + }, + { + "key": "service", + "label": "Dienst", + "help": "Für welche Einwahl-Art dieser Benutzer gilt.", + "kind": "enum", + "options": [ + "any", + "pppoe", + "l2tp", + "pptp", + "ovpn", + "sstp" + ], + "default": "any" + }, + { + "key": "profile", + "label": "Profil", + "help": "Name eines zuvor angelegten PPP-Profils.", + "kind": "pick", + "pickPath": "/ppp/profile", + "pickField": "name" + }, + { + "key": "local-address", + "label": "Lokale Adresse", + "help": "IP-Adresse des Routers innerhalb dieser Verbindung, ohne Präfix, z.B. 10.10.10.1.", + "kind": "text" + }, + { + "key": "remote-address", + "label": "Adresse für den Client", + "help": "Feste IP für diesen Benutzer (z.B. 10.10.10.2), oder Name eines Adress-Pools.", + "kind": "text" + }, + { + "key": "disabled", + "label": "Deaktiviert", + "help": "Benutzer inaktiv schalten, ohne ihn zu löschen.", + "kind": "bool", + "default": "no" + } + ], + "columns": [ + "name", + "service" + ] + }, + { + "path": "/ppp/profile", + "group": "VPN-Server/Clients", + "title": "PPP-Profile", + "summary": "Vorlagen (IP-Pool, DNS, Verschlüsselung) für PPP-Benutzer.", + "explanation": "", + "fields": [ + { + "key": "name", + "label": "Name", + "help": "Frei wählbar, z.B. vpn-clients.", + "kind": "text", + "required": true + }, + { + "key": "local-address", + "label": "Lokale Adresse", + "help": "IP-Adresse des Routers, ohne Präfix, z.B. 10.10.10.1.", + "kind": "text" + }, + { + "key": "remote-address", + "label": "Adress-Pool für Clients", + "help": "Name eines zuvor angelegten Adress-Pools.", + "kind": "text" + }, + { + "key": "dns-server", + "label": "DNS-Server für Clients", + "help": "Ein oder mehrere Server, kommagetrennt, z.B. 1.1.1.1,8.8.8.8.", + "kind": "text" + } + ], + "columns": [ + "name" + ] + }, + { + "path": "/interface/l2tp-server/server", + "group": "VPN-Server/Clients", + "title": "L2TP-VPN-Server", + "summary": "Nimmt eingehende L2TP-VPN-Einwahlen entgegen.", + "explanation": "Ein einzelnes Server-weites An/Aus mit gemeinsamen Verschlüsselungs-Einstellungen — Benutzer selbst kommen von \"PPP-Benutzer\".", + "fields": [], + "columns": [] + }, + { + "path": "/interface/ovpn-server/server", + "group": "VPN-Server/Clients", + "title": "OpenVPN-Server", + "summary": "Nimmt eingehende OpenVPN-Einwahlen entgegen.", + "explanation": "Braucht zusätzlich ein Zertifikat (\"/certificate\") — Benutzer selbst kommen von \"PPP-Benutzer\".", + "fields": [], + "columns": [] + }, + { + "path": "/interface/wireless", + "group": "WLAN / CAPsMAN", + "title": "WLAN (Legacy-Treiber)", + "summary": "WLAN-Interfaces auf älteren/Standard-Wireless-Chips.", + "explanation": "Diese Interfaces existieren bereits ab Werk (ein WLAN-Chip = ein Interface) — hier werden sie nur konfiguriert, nicht neu angelegt. Ein Sicherheitsprofil (siehe \"WLAN-Sicherheitsprofile\") muss vorher angelegt sein.", + "fields": [ + { + "key": "ssid", + "label": "Netzwerkname (SSID)", + "help": "Der Name, den WLAN-Geräte in der Netzwerkliste sehen, z.B. MeinWLAN.", + "kind": "text", + "required": true + }, + { + "key": "security-profile", + "label": "Sicherheitsprofil", + "help": "Name eines zuvor unter \"WLAN-Sicherheitsprofile\" angelegten Profils.", + "kind": "text" + }, + { + "key": "mode", + "label": "Modus", + "help": "ap-bridge = Access Point (Normalfall), station = als Client mit einem anderen AP verbinden.", + "kind": "enum", + "options": [ + "ap-bridge", + "station", + "bridge" + ], + "default": "ap-bridge" + }, + { + "key": "disabled", + "label": "Deaktiviert", + "help": "WLAN-Interface inaktiv schalten, ohne es zu löschen.", + "kind": "bool", + "default": "no" + } + ], + "columns": [ + "ssid", + "mode" + ] + }, + { + "path": "/interface/wireless/security-profiles", + "group": "WLAN / CAPsMAN", + "title": "WLAN-Sicherheitsprofile", + "summary": "Verschlüsselung/Passwort-Vorlagen für WLAN-Interfaces (Legacy-Treiber).", + "explanation": "Ein Profil wird angelegt und dann bei einem WLAN-Interface als \"security-profile\" eingetragen.", + "fields": [ + { + "key": "name", + "label": "Name", + "help": "Frei wählbar, z.B. heimnetz-wpa2.", + "kind": "text", + "required": true + }, + { + "key": "mode", + "label": "Modus", + "help": "dynamic-keys ist der übliche WPA/WPA2-Modus.", + "kind": "enum", + "options": [ + "none", + "static-keys-required", + "dynamic-keys" + ], + "default": "dynamic-keys" + }, + { + "key": "authentication-types", + "label": "Authentifizierung", + "help": "wpa2-psk = WPA2 mit gemeinsamem Passwort (Heimnetz-Standard).", + "kind": "enum", + "options": [ + "wpa-psk", + "wpa2-psk", + "wpa-psk,wpa2-psk", + "wpa-eap", + "wpa2-eap" + ] + }, + { + "key": "wpa2-pre-shared-key", + "label": "WPA2-Passwort", + "help": "Mindestens 8 Zeichen.", + "kind": "text" + } + ], + "columns": [ + "name", + "authentication-types" + ] + }, + { + "path": "/interface/wifi", + "group": "WLAN / CAPsMAN", + "title": "WLAN (neuer wifiwave2/802.11ax-Treiber)", + "summary": "WLAN-Interfaces auf neueren Wireless-Chips.", + "explanation": "Anderes, verschachteltes Konfigurationsschema als der Legacy-Treiber (Punkt-Notation wie \"security.passphrase\") — nicht mit \"/interface wireless\" mischen. Nur auf Geräten mit entsprechend neuem WLAN-Chip vorhanden.", + "fields": [ + { + "key": "ssid", + "label": "Netzwerkname (SSID)", + "help": "Der Name, den WLAN-Geräte in der Netzwerkliste sehen, z.B. MeinWLAN.", + "kind": "text", + "required": true + }, + { + "key": "security.authentication-types", + "label": "Authentifizierung", + "help": "wpa2-psk,wpa3-psk deckt sowohl ältere als auch neuere Geräte ab.", + "kind": "enum", + "options": [ + "wpa2-psk", + "wpa3-psk", + "wpa2-psk,wpa3-psk" + ] + }, + { + "key": "security.passphrase", + "label": "Passwort", + "help": "Mindestens 8 Zeichen.", + "kind": "text" + }, + { + "key": "datapath.bridge", + "label": "Ziel-Bridge", + "help": "Name der Bridge, der dieses WLAN-Netz zugeordnet wird (üblicherweise dieselbe wie das kabelgebundene LAN), z.B. bridge.", + "kind": "text" + }, + { + "key": "disabled", + "label": "Deaktiviert", + "help": "WLAN-Interface inaktiv schalten, ohne es zu löschen.", + "kind": "bool", + "default": "no" + } + ], + "columns": [ + "ssid" + ], + "warning": "Diese App hat den .set-Pfad für den neuen Treiber noch nie gegen echte Hardware getestet — nach dem Anwenden unbedingt prüfen." + }, + { + "path": "/caps-man/manager", + "group": "WLAN / CAPsMAN", + "title": "CAPsMAN-Zentrale", + "summary": "Zentrale Verwaltung mehrerer WLAN-Access-Points von einem Router aus.", + "explanation": "Nur relevant mit mehreren WLAN-Access-Points, die zentral verwaltet werden sollen — eigenes, umfangreiches Konfigurationsschema (Konfigurationen/Kanäle/Datapaths).", + "fields": [], + "columns": [] + }, + { + "path": "/queue/simple", + "group": "Queues / Bandbreiten-Steuerung", + "title": "Einfache Bandbreiten-Begrenzung", + "summary": "Begrenzt Up-/Download einer IP-Adresse oder eines Netzes.", + "explanation": "Reicht für die meisten Heim-/Kleinnetz-Fälle ohne Mangle-Markierungen aus.", + "fields": [ + { + "key": "name", + "label": "Name", + "help": "Frei wählbar, z.B. limit-gast.", + "kind": "text", + "required": true + }, + { + "key": "target", + "label": "Ziel", + "help": "IP-Adresse oder Netz, das begrenzt wird, mit Präfix, z.B. 192.168.88.50/32.", + "kind": "text", + "required": true + }, + { + "key": "max-limit", + "label": "Max. Bandbreite (Upload/Download)", + "help": "Zwei Werte getrennt durch \"/\", z.B. 10M/50M (Upload/Download).", + "kind": "text", + "required": true + }, + { + "key": "burst-limit", + "label": "Burst-Bandbreite", + "help": "Optional: kurzzeitig erlaubte höhere Bandbreite, z.B. 15M/60M.", + "kind": "text" + }, + { + "key": "disabled", + "label": "Deaktiviert", + "help": "Begrenzung inaktiv schalten, ohne sie zu löschen.", + "kind": "bool", + "default": "no" + } + ], + "columns": [ + "name", + "target", + "max-limit" + ] + }, + { + "path": "/queue/tree", + "group": "Queues / Bandbreiten-Steuerung", + "title": "Queue-Baum", + "summary": "Erweiterte, hierarchische Bandbreiten-Steuerung basierend auf Mangle-Markierungen.", + "explanation": "Statt einer festen Adresse wirkt ein Queue-Baum-Eintrag auf Verkehr mit einer bestimmten Mangle-Markierung.", + "fields": [ + { + "key": "name", + "label": "Name", + "help": "Frei wählbar, z.B. queue-gast.", + "kind": "text", + "required": true + }, + { + "key": "parent", + "label": "Übergeordnet", + "help": "Ein Interface (z.B. der WAN-Port, etwa ether1) oder der Name eines anderen Queue-Baum-Eintrags.", + "kind": "text", + "required": true + }, + { + "key": "packet-mark", + "label": "Paket-/Verbindungsmarkierung", + "help": "Name der Mangle-Markierung, auf die dieser Eintrag wirkt.", + "kind": "text" + }, + { + "key": "max-limit", + "label": "Max. Bandbreite", + "help": "Einzelwert, z.B. 20M.", + "kind": "text" + }, + { + "key": "disabled", + "label": "Deaktiviert", + "help": "Begrenzung inaktiv schalten, ohne sie zu löschen.", + "kind": "bool", + "default": "no" + } + ], + "columns": [ + "name", + "parent", + "max-limit" + ], + "warning": "Setzt eine passende Mangle-Regel voraus, die genau diese Markierung setzt (siehe Firewall: Mangle) — ohne die greift ein Queue-Baum-Eintrag ins Leere." + }, + { + "path": "/system/identity", + "group": "System", + "title": "Router-Name", + "summary": "Der Name, unter dem sich der Router meldet (z.B. in Winbox/Terminal-Prompt).", + "explanation": "", + "fields": [ + { + "key": "name", + "label": "Name", + "help": "Frei wählbar, z.B. MeinRouter.", + "kind": "text", + "required": true + } + ], + "columns": [ + "name" + ], + "singleton": true + }, + { + "path": "/system/clock", + "group": "System", + "title": "Uhrzeit/Zeitzone", + "summary": "", + "explanation": "", + "fields": [ + { + "key": "time-zone-name", + "label": "Zeitzone", + "help": "Z.B. Europe/Berlin.", + "kind": "text" + } + ], + "columns": [ + "time-zone-name" + ], + "singleton": true + }, + { + "path": "/system/routerboard/mode-button", + "group": "System", + "title": "Mode-Taste", + "summary": "Steuert, welches Skript beim Drücken der physischen Mode-Taste am Router ausgeführt wird.", + "explanation": "Manche RouterBOARD-Geräte (z.B. hEX, cAP, hAP ac², LtAP mini, einige CCR/CRS) haben eine physische Mode-Taste an der Seite. Hier lässt sich festlegen, ob und wie lange sie gedrückt werden muss, damit ein zuvor unter \"Skripte\" angelegtes Skript ausgeführt wird.", + "fields": [ + { + "key": "enabled", + "label": "Aktiviert", + "help": "Mode-Taste ein-/ausschalten.", + "kind": "bool", + "default": "no" + }, + { + "key": "on-event", + "label": "Auszuführendes Skript", + "help": "Name eines zuvor unter \"Skripte\" angelegten Skripts.", + "kind": "pick", + "pickPath": "/system/script", + "pickField": "name" + }, + { + "key": "hold-time", + "label": "Haltedauer (Min..Max)", + "help": "Wie lange die Taste gedrückt gehalten werden muss, als Zeitspanne Min..Max, z.B. \"3s..5s\". Verfügbar ab RouterOS 6.47beta60.", + "kind": "text" + } + ], + "columns": [ + "enabled", + "on-event" + ], + "warning": "Ab RouterOS 7.1rc4 muss jede Aktivierung oder Änderung dieser Einstellung zusätzlich durch einen physischen Tastendruck (Reset- oder Mode-Taste) innerhalb von 60 Sekunden am Gerät selbst bestätigt werden — eine Änderung über diese App allein reicht nicht.", + "singleton": true + }, + { + "path": "/system/ntp/client", + "group": "System", + "title": "Zeitserver (NTP)", + "summary": "Hält die Router-Uhr über einen Zeitserver synchron.", + "explanation": "Die Server-Liste selbst liegt in einem eigenen Menü (\"NTP-Zeitserver-Liste\") — hier nur Ein/Aus und Modus.", + "fields": [ + { + "key": "enabled", + "label": "Aktiviert", + "help": "Zeitsynchronisation ein-/ausschalten.", + "kind": "bool", + "default": "yes" + }, + { + "key": "mode", + "label": "Modus", + "help": "Fast immer \"unicast\" (direkte Anfrage an feste Server).", + "kind": "enum", + "options": [ + "unicast", + "broadcast", + "multicast", + "manycast" + ], + "default": "unicast" + } + ], + "columns": [ + "enabled", + "mode" + ], + "warning": "Falsche Systemzeit kann Zertifikatsprüfungen (HTTPS/REST) und Log-Zeitstempel durcheinanderbringen.", + "singleton": true + }, + { + "path": "/system/ntp/client/servers", + "group": "System", + "title": "NTP-Zeitserver-Liste", + "summary": "Die Zeitserver, die der Client abfragt.", + "explanation": "\"servers\" ist bei RouterOS 7.x kein Feld am NTP-Client selbst, sondern eine eigene Liste — jeder Server ist ein eigener Eintrag hier (statt kommagetrennt in einem Textfeld).", + "fields": [ + { + "key": "address", + "label": "Adresse", + "help": "Hostname oder IP eines Zeitservers, z.B. pool.ntp.org.", + "kind": "text", + "required": true + }, + { + "key": "disabled", + "label": "Deaktiviert", + "help": "Diesen Server deaktivieren, ohne ihn zu löschen.", + "kind": "bool", + "default": "no" + }, + { + "key": "comment", + "label": "Kommentar", + "help": "Nur zur eigenen Wiedererkennung.", + "kind": "text" + } + ], + "columns": [ + "address", + "disabled" + ] + }, + { + "path": "/system/logging", + "group": "System", + "title": "Protokollierung", + "summary": "Was der Router mitschreibt und wohin (Speicher, Datei, Remote-Syslog, E-Mail).", + "explanation": "Besteht aus \"rules\" (was protokolliert wird) und \"actions\" (wohin) — hier generischer Zugriff, verwandte Teile über \"Eigener Menüpfad\" (z.B. \"/system logging action\").", + "fields": [], + "columns": [] + }, + { + "path": "/system/scheduler", + "group": "System", + "title": "Zeitplaner", + "summary": "Führt ein hinterlegtes Skript zu festen Zeiten/Intervallen aus.", + "explanation": "", + "fields": [ + { + "key": "name", + "label": "Name", + "help": "Frei wählbar, z.B. taeglicher-neustart.", + "kind": "text", + "required": true + }, + { + "key": "start-time", + "label": "Startzeit", + "help": "Z.B. 00:00:00 oder \"startup\".", + "kind": "text" + }, + { + "key": "interval", + "label": "Intervall", + "help": "Wie oft wiederholt wird. Alles auf 0 lassen = nur einmal zur Startzeit.", + "kind": "duration" + }, + { + "key": "on-event", + "label": "Auszuführendes Skript", + "help": "Name eines zuvor unter \"Skripte\" angelegten Skripts.", + "kind": "pick", + "pickPath": "/system/script", + "pickField": "name" + }, + { + "key": "disabled", + "label": "Deaktiviert", + "help": "Zeitplan inaktiv schalten, ohne ihn zu löschen.", + "kind": "bool", + "default": "no" + } + ], + "columns": [ + "name", + "interval" + ], + "warning": "Ein fehlerhaftes geplantes Skript kann unbeaufsichtigt wiederholt Änderungen vornehmen — vor dem Einplanen einmal manuell testen." + }, + { + "path": "/system/script", + "group": "System", + "title": "Skripte", + "summary": "Gespeicherte RouterOS-Befehlsfolgen, die manuell oder per Scheduler ausgeführt werden.", + "explanation": "", + "fields": [ + { + "key": "name", + "label": "Name", + "help": "Frei wählbar, z.B. backup-taeglich.", + "kind": "text", + "required": true + }, + { + "key": "source", + "label": "Skript-Inhalt", + "help": "RouterOS-Befehle, z.B. \":log info \\\"Test\\\"\".", + "kind": "text" + } + ], + "columns": [ + "name" + ], + "warning": "Skripte laufen mit den Rechten des Routers selbst — kein Unterschied zu manuell eingegebenen Befehlen." + }, + { + "path": "/user", + "group": "System", + "title": "Benutzerkonten", + "summary": "Zugangskonten für den Router (Winbox/SSH/REST/Terminal).", + "explanation": "", + "fields": [ + { + "key": "name", + "label": "Benutzername", + "help": "Frei wählbar, z.B. admin2.", + "kind": "text", + "required": true + }, + { + "key": "password", + "label": "Passwort", + "help": "Ausreichend lang und zufällig wählen.", + "kind": "text" + }, + { + "key": "group", + "label": "Rechte-Gruppe", + "help": "full = Vollzugriff, write = ohne Benutzerverwaltung, read = nur lesen.", + "kind": "enum", + "options": [ + "full", + "write", + "read" + ], + "default": "full" + }, + { + "key": "disabled", + "label": "Deaktiviert", + "help": "Konto inaktiv schalten, ohne es zu löschen.", + "kind": "bool", + "default": "no" + } + ], + "columns": [ + "name", + "group" + ], + "warning": "Das zum Verbinden genutzte eigene Konto hier nicht versehentlich löschen oder herabstufen." + }, + { + "path": "/tool/netwatch", + "group": "Werkzeuge & Überwachung", + "title": "Netwatch", + "summary": "Überwacht laufend, ob eine Adresse erreichbar ist, und kann bei Statuswechsel ein Skript auslösen.", + "explanation": "", + "fields": [ + { + "key": "host", + "label": "Zu überwachende Adresse", + "help": "IP-Adresse oder Hostname, ohne Präfix, z.B. 192.168.88.1 oder 8.8.8.8.", + "kind": "text", + "required": true + }, + { + "key": "interval", + "label": "Prüf-Intervall", + "help": "Wie oft die Erreichbarkeit geprüft wird.", + "kind": "duration", + "default": "10s" + }, + { + "key": "up-script", + "label": "Skript bei Erreichbarkeit", + "help": "Name eines Skripts unter \"Skripte\".", + "kind": "text" + }, + { + "key": "down-script", + "label": "Skript bei Nichterreichbarkeit", + "help": "Name eines Skripts unter \"Skripte\".", + "kind": "text" + }, + { + "key": "disabled", + "label": "Deaktiviert", + "help": "Überwachung inaktiv schalten, ohne sie zu löschen.", + "kind": "bool", + "default": "no" + } + ], + "columns": [ + "host" + ] + }, + { + "path": "/tool/e-mail", + "group": "Werkzeuge & Überwachung", + "title": "E-Mail-Versand", + "summary": "Postausgangs-Server, den Scheduler/Netwatch/Skripte für Benachrichtigungen nutzen.", + "explanation": "Ein einzelner, geräteweiter Satz Einstellungen — kein Menü mit mehreren Einträgen.", + "fields": [ + { + "key": "address", + "label": "Mailserver-Adresse", + "help": "Hostname oder IP-Adresse des Mailservers, z.B. smtp.gmail.com.", + "kind": "text", + "required": true + }, + { + "key": "port", + "label": "Port", + "help": "Meist 587 (STARTTLS) oder 465 (SSL).", + "kind": "int" + }, + { + "key": "from", + "label": "Absenderadresse", + "help": "Z.B. router@example.com.", + "kind": "text" + }, + { + "key": "user", + "label": "Benutzername", + "help": "Zugangsdaten des Mailservers.", + "kind": "text" + }, + { + "key": "password", + "label": "Passwort", + "help": "Zugangsdaten des Mailservers.", + "kind": "text" + } + ], + "columns": [ + "address" + ], + "singleton": true + } +] \ No newline at end of file diff --git a/src/MikrotikInstaller.Core/MikrotikInstaller.Core.csproj b/src/MikrotikInstaller.Core/MikrotikInstaller.Core.csproj index 6b38360..af9e9df 100644 --- a/src/MikrotikInstaller.Core/MikrotikInstaller.Core.csproj +++ b/src/MikrotikInstaller.Core/MikrotikInstaller.Core.csproj @@ -9,6 +9,11 @@ embedded + + + + +