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
+
+
+
+
+