From 13b40dbdb91f18bd87c939e364fcedfe450a7514 Mon Sep 17 00:00:00 2001
From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com>
Date: Mon, 19 Oct 2026 17:49:48 +0200
Subject: [PATCH 1/2] Feature: Delete / Enable / Disable multiple firewall
rules
---
.../Resources/Strings.Designer.cs | 29 ++++
.../Resources/Strings.resx | 11 ++
.../Firewall/Firewall.cs | 96 ++++++++---
.../ViewModels/FirewallViewModel.cs | 156 +++++++++++++++---
Source/NETworkManager/Views/FirewallView.xaml | 39 +++++
.../NETworkManager/Views/FirewallView.xaml.cs | 2 +
Website/docs/application/firewall.md | 24 ++-
Website/docs/changelog/next-release.md | 1 +
8 files changed, 296 insertions(+), 62 deletions(-)
diff --git a/Source/NETworkManager.Localization/Resources/Strings.Designer.cs b/Source/NETworkManager.Localization/Resources/Strings.Designer.cs
index de4abb87d1..80a1962455 100644
--- a/Source/NETworkManager.Localization/Resources/Strings.Designer.cs
+++ b/Source/NETworkManager.Localization/Resources/Strings.Designer.cs
@@ -2981,6 +2981,17 @@ public static string DeleteFirewallRuleMessage {
}
}
+ ///
+ /// Looks up a localized string similar to The selected firewall rules are permanently deleted:
+ ///
+ ///{0}.
+ ///
+ public static string DeleteFirewallRulesMessage {
+ get {
+ return ResourceManager.GetString("DeleteFirewallRulesMessage", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to Delete group.
///
@@ -3120,6 +3131,24 @@ public static string DeleteRule {
}
}
+ ///
+ /// Looks up a localized string similar to Delete rules.
+ ///
+ public static string DeleteRules {
+ get {
+ return ResourceManager.GetString("DeleteRules", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Delete rules ({0}).
+ ///
+ public static string DeleteRulesX {
+ get {
+ return ResourceManager.GetString("DeleteRulesX", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to Delete SNTP server.
///
diff --git a/Source/NETworkManager.Localization/Resources/Strings.resx b/Source/NETworkManager.Localization/Resources/Strings.resx
index b9a01cc5ef..c5c09cdc54 100644
--- a/Source/NETworkManager.Localization/Resources/Strings.resx
+++ b/Source/NETworkManager.Localization/Resources/Strings.resx
@@ -4729,4 +4729,15 @@ Cloudflare may log your IP address and network information. See Cloudflare's pri
Copy rule
+
+ The selected firewall rules are permanently deleted:
+
+{0}
+
+
+ Delete rules
+
+
+ Delete rules ({0})
+
\ No newline at end of file
diff --git a/Source/NETworkManager.Models/Firewall/Firewall.cs b/Source/NETworkManager.Models/Firewall/Firewall.cs
index 915692d24c..3e9f09a2da 100644
--- a/Source/NETworkManager.Models/Firewall/Firewall.cs
+++ b/Source/NETworkManager.Models/Firewall/Firewall.cs
@@ -168,9 +168,7 @@ public static async Task> GetRulesAsync()
}
///
- /// Enables or disables the given by running
- /// Enable-NetFirewallRule or Disable-NetFirewallRule against
- /// the rule's internal .
+ /// Enables or disables the given .
///
///
/// The firewall rule to modify.
@@ -179,43 +177,83 @@ public static async Task> GetRulesAsync()
/// to enable the rule; to disable it.
///
///
- /// Thrown when the PowerShell pipeline reports one or more errors.
+ /// Thrown when the rule no longer exists or the PowerShell pipeline reports one or more errors.
///
- public static async Task SetRuleEnabledAsync(FirewallRule rule, bool enabled)
+ public static Task SetRuleEnabledAsync(FirewallRule rule, bool enabled)
{
- await RunspaceLock.WaitAsync();
- try
- {
- await Task.Run(() =>
- {
- using var ps = SMA.PowerShell.Create();
- ps.Runspace = SharedRunspace;
+ return SetRulesEnabledAsync([rule], enabled);
+ }
- ps.AddScript($@"{(enabled ? "Enable" : "Disable")}-NetFirewallRule -Name '{rule.Id}'");
- ps.Invoke();
+ ///
+ /// Enables or disables the given by running
+ /// Enable-NetFirewallRule or Disable-NetFirewallRule against the rules'
+ /// internal . Rules that are already in the target state are skipped.
+ /// Nothing is changed if one of the rules no longer exists.
+ ///
+ ///
+ /// The firewall rules to modify.
+ ///
+ ///
+ /// to enable the rules; to disable them.
+ ///
+ ///
+ /// Thrown when a rule no longer exists or the PowerShell pipeline reports one or more errors.
+ ///
+ public static Task SetRulesEnabledAsync(IEnumerable rules, bool enabled)
+ {
+ var ids = rules.Where(rule => rule.IsEnabled != enabled).Select(rule => rule.Id).ToList();
- if (ps.Streams.Error.Count > 0)
- throw new Exception(string.Join("; ", ps.Streams.Error));
- });
- }
- finally
- {
- RunspaceLock.Release();
- }
+ return InvokeRuleCommandAsync(enabled ? "Enable-NetFirewallRule" : "Disable-NetFirewallRule", ids);
}
///
- /// Permanently removes the given by running
- /// Remove-NetFirewallRule against the rule's internal .
+ /// Permanently removes the given .
///
///
/// The firewall rule to delete.
///
///
- /// Thrown when the PowerShell pipeline reports one or more errors.
+ /// Thrown when the rule no longer exists or the PowerShell pipeline reports one or more errors.
+ ///
+ public static Task DeleteRuleAsync(FirewallRule rule)
+ {
+ return DeleteRulesAsync([rule]);
+ }
+
+ ///
+ /// Permanently removes the given by running
+ /// Remove-NetFirewallRule against the rules' internal .
+ /// Nothing is deleted if one of the rules no longer exists.
+ ///
+ ///
+ /// The firewall rules to delete.
+ ///
+ ///
+ /// Thrown when a rule no longer exists or the PowerShell pipeline reports one or more errors.
///
- public static async Task DeleteRuleAsync(FirewallRule rule)
+ public static Task DeleteRulesAsync(IEnumerable rules)
{
+ return InvokeRuleCommandAsync("Remove-NetFirewallRule", rules.Select(rule => rule.Id).ToList());
+ }
+
+ ///
+ /// Runs the given firewall cmdlet (e.g. Enable-NetFirewallRule) for all rule IDs in a
+ /// single PowerShell call. All rules must still exist, otherwise nothing is changed (all or nothing).
+ ///
+ ///
+ /// The cmdlet to run. Must accept the rule IDs via -Name.
+ ///
+ ///
+ /// The internal IDs of the rules. Nothing is done if the list is empty.
+ ///
+ ///
+ /// Thrown when a rule no longer exists or the PowerShell pipeline reports one or more errors.
+ ///
+ private static async Task InvokeRuleCommandAsync(string command, IReadOnlyCollection ids)
+ {
+ if (ids.Count == 0)
+ return;
+
await RunspaceLock.WaitAsync();
try
{
@@ -224,7 +262,11 @@ await Task.Run(() =>
using var ps = SMA.PowerShell.Create();
ps.Runspace = SharedRunspace;
- ps.AddScript($@"Remove-NetFirewallRule -Name '{rule.Id}'");
+ // Check that all rules still exist before changing anything
+ ps.AddScript($@"
+$ids = {ToPsArray(ids)}
+Get-NetFirewallRule -Name $ids -ErrorAction Stop | Out-Null
+{command} -Name $ids");
ps.Invoke();
if (ps.Streams.Error.Count > 0)
diff --git a/Source/NETworkManager/ViewModels/FirewallViewModel.cs b/Source/NETworkManager/ViewModels/FirewallViewModel.cs
index 9e05b03eca..706676bee9 100644
--- a/Source/NETworkManager/ViewModels/FirewallViewModel.cs
+++ b/Source/NETworkManager/ViewModels/FirewallViewModel.cs
@@ -28,6 +28,11 @@ public class FirewallViewModel : ViewModelBase
{
#region Variables
+ ///
+ /// Maximum number of rules listed in the delete confirmation dialog.
+ ///
+ private const int DeleteRulesMaxDisplayed = 10;
+
private static readonly ILog Log = LogManager.GetLogger(typeof(FirewallViewModel));
private readonly bool _isLoading;
@@ -76,6 +81,16 @@ public IList SelectedResults
}
} = new ArrayList();
+ ///
+ /// Indicates whether at least one of the selected rules is enabled (used to show "Disable").
+ ///
+ public bool IsAnySelectedEntryEnabled => GetTargetEntries().Any(rule => rule.IsEnabled);
+
+ ///
+ /// Indicates whether at least one of the selected rules is disabled (used to show "Enable").
+ ///
+ public bool IsAnySelectedEntryDisabled => GetTargetEntries().Any(rule => !rule.IsEnabled);
+
///
/// Gets or sets the search text for filtering rules.
///
@@ -109,6 +124,22 @@ public bool IsRefreshing
}
}
+ ///
+ /// Gets or sets whether a rule is currently being modified (added, edited, enabled, disabled or deleted).
+ ///
+ public bool IsModifying
+ {
+ get;
+ set
+ {
+ if (value == field)
+ return;
+
+ field = value;
+ OnPropertyChanged();
+ }
+ }
+
///
/// Gets or sets whether the status message bar is shown.
///
@@ -215,6 +246,8 @@ private async Task AddRule()
childWindow.IsOpen = false;
ConfigurationManager.Current.IsChildWindowOpen = false;
+ IsModifying = true;
+
try
{
await Firewall.AddRuleAsync(BuildRule(instance));
@@ -227,6 +260,8 @@ private async Task AddRule()
StatusMessage = ex.Message;
IsStatusMessageDisplayed = true;
}
+
+ IsModifying = false;
}, _ =>
{
childWindow.IsOpen = false;
@@ -242,42 +277,48 @@ private async Task AddRule()
}
///
- /// Gets the command to enable the selected firewall rule.
- /// Only executable when the rule is currently disabled and modification is allowed.
+ /// Gets the command to enable the selected firewall rule(s).
+ /// Only executable when at least one selected rule is disabled and modification is allowed.
///
- public ICommand EnableRuleCommand => new RelayCommand(parameter => { _ = SetRuleEnabled(SelectedResult, true); }, _ => ModifyRule_CanExecute() && SelectedResult is { IsEnabled: false });
+ public ICommand EnableRuleCommand => new RelayCommand(parameter => { _ = SetRulesEnabled(true); }, _ => ModifyRule_CanExecute() && IsAnySelectedEntryDisabled);
///
- /// Gets the command to disable the selected firewall rule.
- /// Only executable when the rule is currently enabled and modification is allowed.
+ /// Gets the command to disable the selected firewall rule(s).
+ /// Only executable when at least one selected rule is enabled and modification is allowed.
///
- public ICommand DisableRuleCommand => new RelayCommand(parameter => { _ = SetRuleEnabled(SelectedResult, false); }, _ => ModifyRule_CanExecute() && SelectedResult is { IsEnabled: true });
+ public ICommand DisableRuleCommand => new RelayCommand(parameter => { _ = SetRulesEnabled(false); }, _ => ModifyRule_CanExecute() && IsAnySelectedEntryEnabled);
///
- /// Enables or disables the given via PowerShell,
+ /// Enables or disables the selected rule(s) via PowerShell (rules already in the target state are skipped),
/// then reloads the rule list to reflect the updated state.
/// Any PowerShell error is written to the log and shown in the status bar.
///
- ///
- /// The firewall rule to modify.
- ///
///
- /// to enable the rule; to disable it.
+ /// to enable the rules; to disable them.
///
- private async Task SetRuleEnabled(FirewallRule rule, bool enabled)
+ private async Task SetRulesEnabled(bool enabled)
{
+ var rules = GetTargetEntries();
+
+ if (rules.Count == 0)
+ return;
+
+ IsModifying = true;
+
try
{
- await Firewall.SetRuleEnabledAsync(rule, enabled);
+ await Firewall.SetRulesEnabledAsync(rules, enabled);
await Refresh();
}
catch (Exception ex)
{
- Log.Error($"Error while {(enabled ? "enabling" : "disabling")} firewall rule", ex);
+ Log.Error($"Error while {(enabled ? "enabling" : "disabling")} firewall rule(s)", ex);
StatusMessage = ex.Message;
IsStatusMessageDisplayed = true;
}
+
+ IsModifying = false;
}
///
@@ -300,6 +341,8 @@ private async Task EditRule()
childWindow.IsOpen = false;
ConfigurationManager.Current.IsChildWindowOpen = false;
+ IsModifying = true;
+
try
{
await Firewall.DeleteRuleAsync(instance.Entry);
@@ -313,6 +356,8 @@ private async Task EditRule()
StatusMessage = ex.Message;
IsStatusMessageDisplayed = true;
}
+
+ IsModifying = false;
}, _ =>
{
childWindow.IsOpen = false;
@@ -347,6 +392,8 @@ private async Task CopyAsRule()
childWindow.IsOpen = false;
ConfigurationManager.Current.IsChildWindowOpen = false;
+ IsModifying = true;
+
try
{
await Firewall.AddRuleAsync(BuildRule(instance));
@@ -359,6 +406,8 @@ private async Task CopyAsRule()
StatusMessage = ex.Message;
IsStatusMessageDisplayed = true;
}
+
+ IsModifying = false;
}, _ =>
{
childWindow.IsOpen = false;
@@ -374,52 +423,95 @@ private async Task CopyAsRule()
}
///
- /// Gets the command to permanently delete the selected firewall rule.
+ /// Gets the command to permanently delete the selected firewall rule(s).
/// Only executable when a rule is selected and modification is allowed.
///
- public ICommand DeleteRuleCommand => new RelayCommand(parameter => { _ = DeleteRule(); }, _ => ModifyRule_CanExecute() && SelectedResult != null);
+ public ICommand DeleteRuleCommand => new RelayCommand(parameter => { _ = DeleteRules(); }, _ => ModifyRule_CanExecute() && SelectedResult != null);
///
- /// Shows a confirmation dialog and, if confirmed, deletes the selected firewall rule
- /// via PowerShell and reloads the rule list.
- /// Any PowerShell error is written to the log and shown in the status bar.
+ /// Asks for confirmation and permanently deletes the selected rule(s) via PowerShell,
+ /// then reloads the rule list. Any PowerShell error is written to the log and shown in the status bar.
///
- private async Task DeleteRule()
+ private async Task DeleteRules()
{
+ var rules = GetTargetEntries();
+
+ if (rules.Count == 0)
+ return;
+
+ IsModifying = true;
+
+ string message;
+
+ if (rules.Count == 1)
+ {
+ message = string.Format(Strings.DeleteFirewallRuleMessage, rules[0].Name);
+ }
+ else
+ {
+ // Show the first rules, the rest is summarized as "(+X more)"
+ var lines = rules.Take(DeleteRulesMaxDisplayed).Select(rule => rule.Name).ToList();
+
+ if (rules.Count > DeleteRulesMaxDisplayed)
+ lines.Add(string.Format(Strings.PlusXMore, rules.Count - DeleteRulesMaxDisplayed));
+
+ message = string.Format(Strings.DeleteFirewallRulesMessage, string.Join(Environment.NewLine, lines));
+ }
+
var result = await DialogHelper.ShowConfirmationMessageAsync(
Application.Current.MainWindow,
- Strings.DeleteRule,
- string.Format(Strings.DeleteFirewallRuleMessage, SelectedResult.Name),
+ rules.Count == 1 ? Strings.DeleteRule : string.Format(Strings.DeleteRulesX, rules.Count),
+ message,
ChildWindowIcon.Info,
Strings.Delete);
if (!result)
+ {
+ IsModifying = false;
return;
+ }
try
{
- await Firewall.DeleteRuleAsync(SelectedResult);
+ await Firewall.DeleteRulesAsync(rules);
await Refresh();
}
catch (Exception ex)
{
- Log.Error("Error while deleting firewall rule", ex);
+ Log.Error("Error while deleting firewall rule(s)", ex);
StatusMessage = ex.Message;
IsStatusMessageDisplayed = true;
}
+
+ IsModifying = false;
}
///
/// Returns when the application is running as administrator,
- /// no dialog is open, and no child window is open — i.e. it is safe to modify a rule.
+ /// no dialog is open, no child window is open and no rule is being modified — i.e. it is safe to modify a rule.
///
- private static bool ModifyRule_CanExecute()
+ private bool ModifyRule_CanExecute()
{
return ConfigurationManager.Current.IsAdmin &&
Application.Current.MainWindow != null &&
!((MetroWindow)Application.Current.MainWindow).IsAnyDialogOpen &&
- !ConfigurationManager.Current.IsChildWindowOpen;
+ !ConfigurationManager.Current.IsChildWindowOpen &&
+ !IsModifying;
+ }
+
+ ///
+ /// Gets the rules to modify: the multi-selection, or the single selected rule.
+ ///
+ private List GetTargetEntries()
+ {
+ if (SelectedResults != null && SelectedResults.Count > 1)
+ return [.. SelectedResults.Cast()];
+
+ if (SelectedResult != null)
+ return [SelectedResult];
+
+ return [];
}
///
@@ -638,6 +730,16 @@ private static List ParseAddressesString(string value)
return [.. value.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)];
}
+ ///
+ /// Called when a context menu is opened. Updates the selection dependent properties,
+ /// because is changed in place (no change notification).
+ ///
+ public void OnContextMenuOpened()
+ {
+ OnPropertyChanged(nameof(IsAnySelectedEntryEnabled));
+ OnPropertyChanged(nameof(IsAnySelectedEntryDisabled));
+ }
+
///
/// Called when the view becomes visible.
///
diff --git a/Source/NETworkManager/Views/FirewallView.xaml b/Source/NETworkManager/Views/FirewallView.xaml
index dd6e198fb2..2731f6e963 100644
--- a/Source/NETworkManager/Views/FirewallView.xaml
+++ b/Source/NETworkManager/Views/FirewallView.xaml
@@ -227,6 +227,45 @@
+
+
+
+