diff --git a/Source/NETworkManager.Controls/ObservableRangeCollection.cs b/Source/NETworkManager.Controls/ObservableRangeCollection.cs new file mode 100644 index 0000000000..f222a46263 --- /dev/null +++ b/Source/NETworkManager.Controls/ObservableRangeCollection.cs @@ -0,0 +1,35 @@ +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Collections.Specialized; +using System.ComponentModel; + +namespace NETworkManager.Controls; + +/// +/// that can replace all items at once with a single +/// notification (instead of one notification per item). +/// Bound views (e.g. a filtered / sorted ) are refreshed only once, +/// which is much faster for large lists. +/// +/// The type of the items. +public class ObservableRangeCollection : ObservableCollection +{ + /// + /// Replaces all items with the given items and raises a single + /// notification. + /// + /// The new items. + public void ReplaceAll(IEnumerable items) + { + CheckReentrancy(); + + Items.Clear(); + + foreach (var item in items) + Items.Add(item); + + OnPropertyChanged(new PropertyChangedEventArgs(nameof(Count))); + OnPropertyChanged(new PropertyChangedEventArgs("Item[]")); + OnCollectionChanged(new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset)); + } +} \ No newline at end of file diff --git a/Source/NETworkManager.Documentation/DocumentationManager.cs b/Source/NETworkManager.Documentation/DocumentationManager.cs index 8ef97fb873..4a436cf7dc 100644 --- a/Source/NETworkManager.Documentation/DocumentationManager.cs +++ b/Source/NETworkManager.Documentation/DocumentationManager.cs @@ -272,6 +272,7 @@ public static DocumentationIdentifier GetIdentifierBySettingsName(SettingsName n SettingsName.WebConsole => GetIdentifierByApplicationName(ApplicationName.WebConsole), SettingsName.SNMP => GetIdentifierByApplicationName(ApplicationName.SNMP), SettingsName.SNTPLookup => GetIdentifierByApplicationName(ApplicationName.SNTPLookup), + SettingsName.Firewall => GetIdentifierByApplicationName(ApplicationName.Firewall), SettingsName.WakeOnLAN => GetIdentifierByApplicationName(ApplicationName.WakeOnLAN), SettingsName.RDAP => GetIdentifierByApplicationName(ApplicationName.RDAP), SettingsName.BitCalculator => GetIdentifierByApplicationName(ApplicationName.BitCalculator), diff --git a/Source/NETworkManager.Localization/Resources/Strings.Designer.cs b/Source/NETworkManager.Localization/Resources/Strings.Designer.cs index 80a1962455..f009997dd5 100644 --- a/Source/NETworkManager.Localization/Resources/Strings.Designer.cs +++ b/Source/NETworkManager.Localization/Resources/Strings.Designer.cs @@ -1220,6 +1220,15 @@ public static string ApplyWindowsKeyCombinationsLikeAltTab { } } + /// + /// Looks up a localized string similar to App package. + /// + public static string AppPackage { + get { + return ResourceManager.GetString("AppPackage", resourceCulture); + } + } + /// /// Looks up a localized string similar to Are you sure?. /// @@ -2358,6 +2367,19 @@ public static string CopyEntry { } } + /// + /// Looks up a localized string similar to The rule "{0}" uses the following properties, which are not supported by NETworkManager and are not copied: + /// + ///{1} + /// + ///The new rule may apply to more traffic than the original rule (e.g. to all services of a program instead of a single one). Please review the new rule carefully before saving it.. + /// + public static string CopyFirewallRuleHiddenPropertiesMessage { + get { + return ResourceManager.GetString("CopyFirewallRuleHiddenPropertiesMessage", resourceCulture); + } + } + /// /// Looks up a localized string similar to Copy. /// @@ -3383,6 +3405,15 @@ public static string DisableRule { } } + /// + /// Looks up a localized string similar to Disable rules ({0}). + /// + public static string DisableRulesX { + get { + return ResourceManager.GetString("DisableRulesX", resourceCulture); + } + } + /// /// Looks up a localized string similar to Disclaimer. /// @@ -3744,6 +3775,15 @@ public static string EditEntryDots { } } + /// + /// Looks up a localized string similar to Editing rules that are not managed by NETworkManager is not supported (yet), because they may use properties that can't be shown in the dialog (e.g. rule group, service or ICMP types). Use "Copy as..." to create a managed copy of the rule.. + /// + public static string EditFirewallRuleNotManagedMessage { + get { + return ResourceManager.GetString("EditFirewallRuleNotManagedMessage", resourceCulture); + } + } + /// /// Looks up a localized string similar to Edit group. /// @@ -3954,6 +3994,15 @@ public static string EnableRule { } } + /// + /// Looks up a localized string similar to Enable rules ({0}). + /// + public static string EnableRulesX { + get { + return ResourceManager.GetString("EnableRulesX", resourceCulture); + } + } + /// /// Looks up a localized string similar to Encryption. /// @@ -5016,6 +5065,51 @@ public static string FirewallRuleDirection_Outbound { } } + /// + /// Looks up a localized string similar to Managed by Group Policy. The rule can't be changed locally, but it can be copied.. + /// + public static string FirewallRuleManagedByGroupPolicyMessage { + get { + return ResourceManager.GetString("FirewallRuleManagedByGroupPolicyMessage", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Managed by Mobile Device Management (MDM), e.g. Microsoft Intune. The rule can't be changed locally, but it can be copied.. + /// + public static string FirewallRuleManagedByMDMMessage { + get { + return ResourceManager.GetString("FirewallRuleManagedByMDMMessage", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Created by Windows at runtime (e.g. dynamic or hardcoded rule). The rule can't be changed locally, but it can be copied.. + /// + public static string FirewallRuleManagedByOtherMessage { + get { + return ResourceManager.GetString("FirewallRuleManagedByOtherMessage", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Managed by NETworkManager.. + /// + public static string FirewallRuleManagedMessage { + get { + return ResourceManager.GetString("FirewallRuleManagedMessage", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Not managed by NETworkManager (e.g. a Windows built-in rule or a rule created by another application). The rule can be enabled, disabled, deleted or copied, but editing is not supported (yet).. + /// + public static string FirewallRuleNotManagedMessage { + get { + return ResourceManager.GetString("FirewallRuleNotManagedMessage", resourceCulture); + } + } + /// /// Looks up a localized string similar to Firewall rules. /// @@ -5025,6 +5119,44 @@ public static string FirewallRules { } } + /// + /// Looks up a localized string similar to At least one of the selected rules is managed by a policy (e.g. Group Policy or MDM) or created by Windows at runtime and can't be changed locally.. + /// + public static string FirewallRulesManagedByPolicyMessage { + get { + return ResourceManager.GetString("FirewallRulesManagedByPolicyMessage", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to One or more of the selected firewall rules no longer exist (e.g. deleted in the Windows Firewall management console). Nothing was changed.. + /// + public static string FirewallRulesNoLongerExistMessage { + get { + return ResourceManager.GetString("FirewallRulesNoLongerExistMessage", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The following rules are not managed by NETworkManager (e.g. Windows built-in rules or rules created by other applications). Changing them may affect the network functionality of Windows or other applications: + /// + ///{0}. + /// + public static string FirewallRulesNotManagedWarningMessage { + get { + return ResourceManager.GetString("FirewallRulesNotManagedWarningMessage", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to This rule uses values that are not supported by NETworkManager (e.g. port keywords like RPC, a combination of interface types, a protocol like IGMP or the program System). Editing or copying the rule is not supported (yet), because these values would be lost and the new rule could apply to more traffic (e.g. to all ports).. + /// + public static string FirewallRuleUnsupportedValuesMessage { + get { + return ResourceManager.GetString("FirewallRuleUnsupportedValuesMessage", resourceCulture); + } + } + /// /// Looks up a localized string similar to First usable IP address. /// @@ -5455,6 +5587,15 @@ public static string HelpMessage_ExperimentalFeatures { } } + /// + /// Looks up a localized string similar to Show all active Windows Firewall rules (e.g. Windows built-in rules, rules created by other applications or from Group Policy) instead of only the rules managed by NETworkManager. Rules not managed by NETworkManager can be enabled, disabled, deleted or copied, but not edited. Rules from a policy (e.g. Group Policy or MDM) can only be copied.. + /// + public static string HelpMessage_FirewallShowAllRules { + get { + return ResourceManager.GetString("HelpMessage_FirewallShowAllRules", resourceCulture); + } + } + /// /// Looks up a localized string similar to Number of backups that are retained before the oldest one is deleted.. /// @@ -5863,6 +6004,15 @@ public static string HTTPStatusCode { } } + /// + /// Looks up a localized string similar to ICMP types. + /// + public static string IcmpTypes { + get { + return ResourceManager.GetString("IcmpTypes", resourceCulture); + } + } + /// /// Looks up a localized string similar to ICMPv4. /// @@ -6863,6 +7013,15 @@ public static string LldpCdp { } } + /// + /// Looks up a localized string similar to Loading firewall rules.... + /// + public static string LoadingFirewallRulesDots { + get { + return ResourceManager.GetString("LoadingFirewallRulesDots", resourceCulture); + } + } + /// /// Looks up a localized string similar to Local addresses. /// @@ -7556,6 +7715,15 @@ public static string Network { } } + /// + /// Looks up a localized string similar to Network adapters. + /// + public static string NetworkAdapters { + get { + return ResourceManager.GetString("NetworkAdapters", resourceCulture); + } + } + /// /// Looks up a localized string similar to Network address. /// @@ -7808,6 +7976,15 @@ public static string NoEnabledNetworkAdaptersFound { } } + /// + /// Looks up a localized string similar to No firewall rules found. Use "Add rule..." to create a rule or enable "Show all rules" in the settings to show all Windows Firewall rules.. + /// + public static string NoFirewallRulesFoundMessage { + get { + return ResourceManager.GetString("NoFirewallRulesFoundMessage", resourceCulture); + } + } + /// /// Looks up a localized string similar to No matching commands!. /// @@ -11599,6 +11776,15 @@ public static string ShowAddressBar { } } + /// + /// Looks up a localized string similar to Show all rules. + /// + public static string ShowAllRules { + get { + return ResourceManager.GetString("ShowAllRules", resourceCulture); + } + } + /// /// Looks up a localized string similar to Show closed ports. /// @@ -13175,6 +13361,15 @@ public static string Username { } } + /// + /// Looks up a localized string similar to Users and authentication. + /// + public static string UsersAndAuthentication { + get { + return ResourceManager.GetString("UsersAndAuthentication", resourceCulture); + } + } + /// /// Looks up a localized string similar to Use SSL (LDAPS). /// diff --git a/Source/NETworkManager.Localization/Resources/Strings.resx b/Source/NETworkManager.Localization/Resources/Strings.resx index c5c09cdc54..8a727ed0af 100644 --- a/Source/NETworkManager.Localization/Resources/Strings.resx +++ b/Source/NETworkManager.Localization/Resources/Strings.resx @@ -4313,6 +4313,75 @@ You can copy your profile files from “{0}” to “{1}” to migrate your exis Firewall rules + + Show all active Windows Firewall rules (e.g. Windows built-in rules, rules created by other applications or from Group Policy) instead of only the rules managed by NETworkManager. Rules not managed by NETworkManager can be enabled, disabled, deleted or copied, but not edited. Rules from a policy (e.g. Group Policy or MDM) can only be copied. + + + This rule uses values that are not supported by NETworkManager (e.g. port keywords like RPC, a combination of interface types, a protocol like IGMP or the program System). Editing or copying the rule is not supported (yet), because these values would be lost and the new rule could apply to more traffic (e.g. to all ports). + + + Managed by Group Policy. The rule can't be changed locally, but it can be copied. + + + Managed by Mobile Device Management (MDM), e.g. Microsoft Intune. The rule can't be changed locally, but it can be copied. + + + Created by Windows at runtime (e.g. dynamic or hardcoded rule). The rule can't be changed locally, but it can be copied. + + + At least one of the selected rules is managed by a policy (e.g. Group Policy or MDM) or created by Windows at runtime and can't be changed locally. + + + One or more of the selected firewall rules no longer exist (e.g. deleted in the Windows Firewall management console). Nothing was changed. + + + Loading firewall rules... + + + No firewall rules found. Use "Add rule..." to create a rule or enable "Show all rules" in the settings to show all Windows Firewall rules. + + + ICMP types + + + Network adapters + + + Users and authentication + + + App package + + + The rule "{0}" uses the following properties, which are not supported by NETworkManager and are not copied: + +{1} + +The new rule may apply to more traffic than the original rule (e.g. to all services of a program instead of a single one). Please review the new rule carefully before saving it. + + + Show all rules + + + Managed by NETworkManager. + + + Not managed by NETworkManager (e.g. a Windows built-in rule or a rule created by another application). The rule can be enabled, disabled, deleted or copied, but editing is not supported (yet). + + + Enable rules ({0}) + + + Disable rules ({0}) + + + The following rules are not managed by NETworkManager (e.g. Windows built-in rules or rules created by other applications). Changing them may affect the network functionality of Windows or other applications: + +{0} + + + Editing rules that are not managed by NETworkManager is not supported (yet), because they may use properties that can't be shown in the dialog (e.g. rule group, service or ICMP types). Use "Copy as..." to create a managed copy of the rule. + Windows Firewall settings diff --git a/Source/NETworkManager.Models/Export/ExportManager.FirewallRule.cs b/Source/NETworkManager.Models/Export/ExportManager.FirewallRule.cs index ec8cb81f8f..589c32cac7 100644 --- a/Source/NETworkManager.Models/Export/ExportManager.FirewallRule.cs +++ b/Source/NETworkManager.Models/Export/ExportManager.FirewallRule.cs @@ -71,7 +71,7 @@ private static void CreateCsv(IEnumerable collection, string fileP $"{CsvHelper.QuoteString(rule.LocalAddressesDisplay)}," + $"{CsvHelper.QuoteString(rule.RemoteAddressesDisplay)}," + $"{CsvHelper.QuoteString(rule.NetworkProfilesDisplay)}," + - $"{rule.InterfaceType}," + + $"{CsvHelper.QuoteString(rule.InterfaceTypeDisplay)}," + $"{CsvHelper.QuoteString(rule.ProgramDisplay)}," + $"{CsvHelper.QuoteString(rule.Description)}"); @@ -98,7 +98,7 @@ from rule in collection new XElement(nameof(FirewallRule.LocalAddresses), rule.LocalAddressesDisplay), new XElement(nameof(FirewallRule.RemoteAddresses), rule.RemoteAddressesDisplay), new XElement(nameof(FirewallRule.NetworkProfiles), rule.NetworkProfilesDisplay), - new XElement(nameof(FirewallRule.InterfaceType), rule.InterfaceType), + new XElement(nameof(FirewallRule.InterfaceType), rule.InterfaceTypeDisplay), new XElement(nameof(FirewallRule.Program), rule.ProgramDisplay), new XElement(nameof(FirewallRule.Description), rule.Description))))); @@ -127,7 +127,7 @@ private static void CreateJson(IReadOnlyList collection, string fi LocalAddresses = rule.LocalAddressesDisplay, RemoteAddresses = rule.RemoteAddressesDisplay, NetworkProfiles = rule.NetworkProfilesDisplay, - InterfaceType = rule.InterfaceType.ToString(), + InterfaceType = rule.InterfaceTypeDisplay, Program = rule.ProgramDisplay, rule.Description }; diff --git a/Source/NETworkManager.Models/Firewall/Firewall.cs b/Source/NETworkManager.Models/Firewall/Firewall.cs index baab14b0e6..70d32bae36 100644 --- a/Source/NETworkManager.Models/Firewall/Firewall.cs +++ b/Source/NETworkManager.Models/Firewall/Firewall.cs @@ -64,18 +64,56 @@ public class Firewall #endregion + /// + /// PowerShell expression that maps a rule ($rule) and its filters ($portFilter, + /// $addressFilter, $appFilter, $ifTypeFilter, $svcFilter, $ifFilter, $secFilter) + /// to the object parsed by . + /// Shared by the query for rules managed by NETworkManager and the query for all rules. + /// + private const string RuleProjectionScript = @"[PSCustomObject]@{ + Id = $rule.ID + DisplayName = $rule.DisplayName + Enabled = ($rule.Enabled -eq 'True') + Description = $rule.Description + Direction = [string]$rule.Direction + Action = [string]$rule.Action + Protocol = $portFilter.Protocol + LocalPort = ($portFilter.LocalPort -join ',') + RemotePort = ($portFilter.RemotePort -join ',') + LocalAddress = ($addressFilter.LocalAddress -join ',') + RemoteAddress = ($addressFilter.RemoteAddress -join ',') + Profile = [string]$rule.Profile + InterfaceType = [string]$ifTypeFilter.InterfaceType + Program = [string]$appFilter.Program + SourceType = [string]$rule.PolicyStoreSourceType + IcmpType = ($portFilter.IcmpType -join ',') + Service = [string]$svcFilter.Service + Package = [string]$appFilter.Package + InterfaceAlias = ($ifFilter.InterfaceAlias -join ',') + LocalUser = [string]$secFilter.LocalUser + RemoteUser = [string]$secFilter.RemoteUser + RemoteMachine = [string]$secFilter.RemoteMachine + Authentication = [string]$secFilter.Authentication + Encryption = [string]$secFilter.Encryption + }"; + #region Methods /// - /// Retrieves all Windows Firewall rules whose display name starts with - /// and maps each one to a object. + /// Retrieves Windows Firewall rules and maps each one to a object. + /// When is , only rules from the local store whose display name + /// starts with are returned; otherwise all active rules on the system are returned + /// (including rules from Group Policy, MDM or created by Windows at runtime, see ). /// PowerShell errors during the query are logged as warnings; errors for individual /// rules are caught so a single malformed rule does not abort the entire load. /// + /// + /// to query only rules managed by NETworkManager; to query all system firewall rules. + /// /// /// A list of objects representing the matching rules. /// - public static async Task> GetRulesAsync() + public static async Task> GetRulesAsync(bool onlyManagedRules = true) { await RunspaceLock.WaitAsync(); try @@ -87,30 +125,59 @@ public static async Task> GetRulesAsync() using var ps = SMA.PowerShell.Create(); ps.Runspace = SharedRunspace; - ps.AddScript($@" + if (onlyManagedRules) + { + ps.AddScript($@" Get-NetFirewallRule -DisplayName '{RuleIdentifier}*' | ForEach-Object {{ $rule = $_ $portFilter = $rule | Get-NetFirewallPortFilter $addressFilter = $rule | Get-NetFirewallAddressFilter $appFilter = $rule | Get-NetFirewallApplicationFilter $ifTypeFilter = $rule | Get-NetFirewallInterfaceTypeFilter - [PSCustomObject]@{{ - Id = $rule.ID - DisplayName = $rule.DisplayName - Enabled = ($rule.Enabled -eq 'True') - Description = $rule.Description - Direction = [string]$rule.Direction - Action = [string]$rule.Action - Protocol = $portFilter.Protocol - LocalPort = ($portFilter.LocalPort -join ',') - RemotePort = ($portFilter.RemotePort -join ',') - LocalAddress = ($addressFilter.LocalAddress -join ',') - RemoteAddress = ($addressFilter.RemoteAddress -join ',') - Profile = [string]$rule.Profile - InterfaceType = [string]$ifTypeFilter.InterfaceType - Program = $appFilter.Program - }} + $svcFilter = $rule | Get-NetFirewallServiceFilter + $ifFilter = $rule | Get-NetFirewallInterfaceFilter + $secFilter = $rule | Get-NetFirewallSecurityFilter + {RuleProjectionScript} }}"); + } + else + { + ps.AddScript($@" +$rules = Get-NetFirewallRule -PolicyStore ActiveStore +$pf = @{{}}; Get-NetFirewallPortFilter -PolicyStore ActiveStore | ForEach-Object {{ $pf[$_.InstanceID] = $_ }} +$af = @{{}}; Get-NetFirewallAddressFilter -PolicyStore ActiveStore | ForEach-Object {{ $af[$_.InstanceID] = $_ }} +$app = @{{}}; Get-NetFirewallApplicationFilter -PolicyStore ActiveStore | ForEach-Object {{ $app[$_.InstanceID] = $_ }} +$if = @{{}}; Get-NetFirewallInterfaceTypeFilter -PolicyStore ActiveStore | ForEach-Object {{ $if[$_.InstanceID] = $_ }} +$sf = @{{}}; Get-NetFirewallServiceFilter -PolicyStore ActiveStore | ForEach-Object {{ $sf[$_.InstanceID] = $_ }} +$inf = @{{}}; Get-NetFirewallInterfaceFilter -PolicyStore ActiveStore | ForEach-Object {{ $inf[$_.InstanceID] = $_ }} +$sec = @{{}}; Get-NetFirewallSecurityFilter -PolicyStore ActiveStore | ForEach-Object {{ $sec[$_.InstanceID] = $_ }} +# The filters are matched by InstanceID. If an ID occurs more than once (e.g. a local and a Group Policy rule +# with the same ID), the filters of these rules are queried per rule, so they are not mixed up. +$count = @{{}}; foreach ($rule in $rules) {{ $count[$rule.InstanceID]++ }} +$list = [System.Collections.Generic.List[PSCustomObject]]::new($rules.Count) +foreach ($rule in $rules) {{ + $id = $rule.InstanceID + if ($count[$id] -gt 1) {{ + $portFilter = $rule | Get-NetFirewallPortFilter + $addressFilter = $rule | Get-NetFirewallAddressFilter + $appFilter = $rule | Get-NetFirewallApplicationFilter + $ifTypeFilter = $rule | Get-NetFirewallInterfaceTypeFilter + $svcFilter = $rule | Get-NetFirewallServiceFilter + $ifFilter = $rule | Get-NetFirewallInterfaceFilter + $secFilter = $rule | Get-NetFirewallSecurityFilter + }} else {{ + $portFilter = $pf[$id] + $addressFilter = $af[$id] + $appFilter = $app[$id] + $ifTypeFilter = $if[$id] + $svcFilter = $sf[$id] + $ifFilter = $inf[$id] + $secFilter = $sec[$id] + }} + $list.Add({RuleProjectionScript}) +}} +$list"); + } var results = ps.Invoke(); @@ -125,27 +192,37 @@ public static async Task> GetRulesAsync() try { var displayName = result.Properties["DisplayName"]?.Value?.ToString() ?? string.Empty; + var source = ParseSource(result.Properties["SourceType"]?.Value?.ToString()); + + // Rules from a policy can't be changed locally, even if they have the NETworkManager prefix + var isManaged = source == FirewallRuleSource.Local && + displayName.StartsWith(RuleIdentifier, StringComparison.Ordinal); var rule = new FirewallRule { Id = result.Properties["Id"]?.Value?.ToString() ?? string.Empty, IsEnabled = result.Properties["Enabled"]?.Value as bool? == true, - Name = displayName.StartsWith(RuleIdentifier, StringComparison.Ordinal) - ? displayName[RuleIdentifier.Length..] - : displayName, + IsManaged = isManaged, + Source = source, + Name = isManaged ? displayName[RuleIdentifier.Length..] : displayName, Description = result.Properties["Description"]?.Value?.ToString() ?? string.Empty, Direction = ParseDirection(result.Properties["Direction"]?.Value?.ToString()), Action = ParseAction(result.Properties["Action"]?.Value?.ToString()), Protocol = ParseProtocol(result.Properties["Protocol"]?.Value?.ToString()), - LocalPorts = ParsePorts(result.Properties["LocalPort"]?.Value?.ToString()), - RemotePorts = ParsePorts(result.Properties["RemotePort"]?.Value?.ToString()), + LocalPorts = ParsePorts(result.Properties["LocalPort"]?.Value?.ToString(), out var localPortKeywords), + LocalPortKeywords = localPortKeywords, + RemotePorts = ParsePorts(result.Properties["RemotePort"]?.Value?.ToString(), out var remotePortKeywords), + RemotePortKeywords = remotePortKeywords, LocalAddresses = ParseAddresses(result.Properties["LocalAddress"]?.Value?.ToString()), RemoteAddresses = ParseAddresses(result.Properties["RemoteAddress"]?.Value?.ToString()), NetworkProfiles = ParseProfile(result.Properties["Profile"]?.Value?.ToString()), - InterfaceType = ParseInterfaceType(result.Properties["InterfaceType"]?.Value?.ToString()), + InterfaceType = ParseInterfaceType(result.Properties["InterfaceType"]?.Value?.ToString(), out var interfaceTypeRaw), + InterfaceTypeRaw = interfaceTypeRaw, + HiddenProperties = ParseHiddenProperties(result), }; - var program = result.Properties["Program"]?.Value as string; + // Use ToString() (not "as string"), because the value can be wrapped in a PSObject + var program = result.Properties["Program"]?.Value?.ToString(); if (!string.IsNullOrWhiteSpace(program) && !program.Equals("Any", StringComparison.OrdinalIgnoreCase)) rule.Program = new FirewallRuleProgram(program); @@ -189,20 +266,6 @@ public static Task SetRulesEnabledAsync(IEnumerable rules, bool en rules.Select(rule => rule.Id).ToList()); } - /// - /// Permanently removes the given . - /// - /// - /// The firewall rule to delete. - /// - /// - /// 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 . @@ -226,13 +289,16 @@ public static Task DeleteRulesAsync(IEnumerable rules) /// rules may already be changed. /// /// - /// The cmdlet to run. Must accept the rule IDs via -Name. + /// The cmdlet to run. Must accept the rules via the pipeline. /// /// /// The internal IDs of the rules. Nothing is done if the list is empty. /// + /// + /// Thrown when a rule no longer exists. + /// /// - /// Thrown when a rule no longer exists or the PowerShell pipeline reports one or more errors. + /// Thrown when the PowerShell pipeline reports one or more errors. /// private static async Task InvokeRuleCommandAsync(string command, IReadOnlyCollection ids) { @@ -247,12 +313,101 @@ await Task.Run(() => using var ps = SMA.PowerShell.Create(); ps.Runspace = SharedRunspace; - // Check that all rules still exist before changing anything ps.AddScript($@" +{BuildGetRulesByIdScript(ids)} +$rules | {command}"); + var results = ps.Invoke(); + + ThrowIfRuleNotFound(results); + + if (ps.Streams.Error.Count > 0) + throw new Exception(string.Join("; ", ps.Streams.Error)); + }); + } + finally + { + RunspaceLock.Release(); + } + } + + /// + /// Builds a PowerShell script that gets the rules with the given IDs and stores them in $rules. + /// If one of the rules no longer exists, is returned and the script is stopped, + /// so nothing is changed in this case (see ). + /// The IDs of rules not created by NETworkManager can contain wildcard characters (e.g. "[x64]" in + /// rules created by Windows for a program). Since -Name supports wildcards (including the backtick as + /// escape character), these rules are found by comparing the names instead, otherwise a different or no rule could be changed. + /// + /// + /// The internal IDs of the rules. + /// + private static string BuildGetRulesByIdScript(IReadOnlyCollection ids) => $@" $ids = {ToPsArray(ids)} -Get-NetFirewallRule -Name $ids -ErrorAction Stop | Out-Null -{command} -Name $ids"); - ps.Invoke(); +if ($ids | Where-Object {{ $_ -match '[\[\]\*\?`]' }}) {{ + $rules = @(Get-NetFirewallRule | Where-Object {{ $ids -contains $_.Name }}) +}} else {{ + $rules = @(Get-NetFirewallRule -Name $ids -ErrorAction SilentlyContinue) +}} +if ($rules.Count -ne $ids.Count) {{ + return '{RuleNotFoundMarker}' +}}"; + + /// + /// Value returned by the script of if a rule no longer exists. + /// + private const string RuleNotFoundMarker = "NETworkManager_FirewallRuleNotFound"; + + /// + /// Throws a if the script of + /// returned . + /// + /// The results of the PowerShell invocation. + private static void ThrowIfRuleNotFound(IEnumerable results) + { + if (results.Any(result => result?.BaseObject is string value && value == RuleNotFoundMarker)) + throw new FirewallRuleNotFoundException(); + } + + /// + /// Updates the existing rule with the properties of + /// using Set-NetFirewallRule. Only changed properties are set, so properties that are not + /// supported by NETworkManager (e.g. rule group, service or ICMP types) and the internal ID are kept. + /// Nothing is done if no property was changed. + /// + /// + /// The rule as it was loaded (used to identify the rule and to detect the changes). + /// + /// + /// The rule with the new properties. + /// + /// + /// Thrown when the rule no longer exists. + /// + /// + /// Thrown when the PowerShell pipeline reports one or more errors. + /// + public static async Task UpdateRuleAsync(FirewallRule original, FirewallRule updated) + { + var paramsScript = BuildUpdateParamsScript(original, updated); + + if (paramsScript == null) + return; + + await RunspaceLock.WaitAsync(); + try + { + await Task.Run(() => + { + using var ps = SMA.PowerShell.Create(); + ps.Runspace = SharedRunspace; + + ps.AddScript($@" +{BuildGetRulesByIdScript([original.Id])} +{paramsScript} +$rules | Set-NetFirewallRule @params"); + var results = ps.Invoke(); + + ThrowIfRuleNotFound(results); if (ps.Streams.Error.Count > 0) throw new Exception(string.Join("; ", ps.Streams.Error)); @@ -264,6 +419,96 @@ await Task.Run(() => } } + /// + /// Builds the PowerShell hashtable $params for Set-NetFirewallRule with all properties + /// that differ between and . + /// + /// The rule as it was loaded. + /// The rule with the new properties. + /// The script, or if no property was changed. + private static string BuildUpdateParamsScript(FirewallRule original, FirewallRule updated) + { + var sb = new StringBuilder(); + + void Add(string key, string value) => sb.AppendLine($"$params['{key}'] = {value}"); + + if (!string.Equals(original.Name, updated.Name, StringComparison.Ordinal)) + { + var prefix = original.IsManaged ? RuleIdentifier : string.Empty; + Add("NewDisplayName", $"'{prefix}{PowerShellHelper.EscapeSingleQuotes(updated.Name)}'"); + } + + if (original.IsEnabled != updated.IsEnabled) + Add("Enabled", $"'{(updated.IsEnabled ? "True" : "False")}'"); + + if (!string.Equals(original.Description ?? string.Empty, updated.Description ?? string.Empty, StringComparison.Ordinal)) + Add("Description", $"'{PowerShellHelper.EscapeSingleQuotes(updated.Description ?? string.Empty)}'"); + + if (original.Direction != updated.Direction) + Add("Direction", $"'{updated.Direction}'"); + + if (original.Action != updated.Action) + Add("Action", $"'{updated.Action}'"); + + var protocolChanged = original.Protocol != updated.Protocol; + + if (protocolChanged) + { + Add("Protocol", $"'{GetProtocolString(updated.Protocol)}'"); + + // ICMP types (e.g. set in the Windows Firewall management console) are only valid for the ICMP protocol + // they were set for, so they are reset if the protocol is changed (otherwise the change could be rejected) + Add("IcmpType", "'Any'"); + } + + // Ports are only allowed for TCP / UDP, so they are reset to "Any" if the protocol is changed to another one + var portsAllowed = updated.Protocol is FirewallProtocol.TCP or FirewallProtocol.UDP; + + if (protocolChanged || PortsChanged(original.LocalPorts, updated.LocalPorts)) + Add("LocalPort", portsAllowed && updated.LocalPorts.Count > 0 + ? ToPsArray(updated.LocalPorts.Select(p => p.ToString())) + : "'Any'"); + + if (protocolChanged || PortsChanged(original.RemotePorts, updated.RemotePorts)) + Add("RemotePort", portsAllowed && updated.RemotePorts.Count > 0 + ? ToPsArray(updated.RemotePorts.Select(p => p.ToString())) + : "'Any'"); + + if (AddressesChanged(original.LocalAddresses, updated.LocalAddresses)) + Add("LocalAddress", updated.LocalAddresses.Count > 0 ? ToPsArray(updated.LocalAddresses) : "'Any'"); + + if (AddressesChanged(original.RemoteAddresses, updated.RemoteAddresses)) + Add("RemoteAddress", updated.RemoteAddresses.Count > 0 ? ToPsArray(updated.RemoteAddresses) : "'Any'"); + + if (!string.Equals(original.Program?.Name, updated.Program?.Name, StringComparison.OrdinalIgnoreCase)) + Add("Program", updated.Program != null && !string.IsNullOrWhiteSpace(updated.Program.Name) + ? $"'{PowerShellHelper.EscapeSingleQuotes(updated.Program.Name)}'" + : "'Any'"); + + if (original.InterfaceType != updated.InterfaceType) + Add("InterfaceType", $"'{GetInterfaceTypeString(updated.InterfaceType)}'"); + + if (!original.NetworkProfiles.SequenceEqual(updated.NetworkProfiles)) + Add("Profile", $"'{GetProfileString(updated.NetworkProfiles)}'"); + + if (sb.Length == 0) + return null; + + return "$params = @{}" + Environment.NewLine + sb; + } + + /// + /// Indicates whether the ports differ (in the given order). + /// + private static bool PortsChanged(List original, List updated) => + FirewallRule.PortsToString(original, ',', false) != FirewallRule.PortsToString(updated, ',', false); + + /// + /// Indicates whether the addresses differ (in the given order, case-insensitive). + /// + private static bool AddressesChanged(List original, List updated) => + !string.Equals(string.Join(",", original), string.Join(",", updated), StringComparison.OrdinalIgnoreCase); + /// /// Creates a new Windows Firewall rule with the properties specified in . /// The rule's is prefixed with so @@ -366,8 +611,9 @@ private static string ToPsArray(IEnumerable values) => FirewallProtocol.UDP => "UDP", FirewallProtocol.ICMPv4 => "ICMPv4", FirewallProtocol.ICMPv6 => "ICMPv6", - FirewallProtocol.GRE => "GRE", - FirewallProtocol.L2TP => "L2TP", + // Only TCP, UDP, ICMPv4 and ICMPv6 are documented as names, all other protocols are passed by number + FirewallProtocol.GRE => "47", + FirewallProtocol.L2TP => "115", _ => ((int)protocol).ToString() }; @@ -464,13 +710,19 @@ private static FirewallProtocol ParseProtocol(string value) /// Parses a comma-separated port string (e.g. "80,443,8080-8090") to a list of /// objects. /// Returns an empty list when the value is blank or "Any". + /// Port keywords (e.g. "RPC", "RPCEPMap", "IPHTTPSIn"), which are used by + /// Windows built-in rules but are not supported by NETworkManager, are returned in . /// /// /// The raw comma-separated port string returned by PowerShell. /// - private static List ParsePorts(string value) + /// + /// The port keywords found in . + /// + private static List ParsePorts(string value, out List keywords) { var list = new List(); + keywords = []; if (string.IsNullOrWhiteSpace(value) || value.Equals("Any", StringComparison.OrdinalIgnoreCase)) return list; @@ -489,6 +741,10 @@ private static List ParsePorts(string value) { list.Add(new FirewallPortSpecification(port)); } + else + { + keywords.Add(token); + } } return list; @@ -567,15 +823,91 @@ private static List ParseAddresses(string value) return addresses; } + /// + /// Determines the of a rule. A restriction is set if its value + /// is not empty and not "Any" (authentication / encryption: not "NotRequired"). + /// + /// The rule object returned by PowerShell (see ). + private static FirewallRuleHiddenProperties ParseHiddenProperties(SMA.PSObject result) + { + string Get(string name) => result.Properties[name]?.Value?.ToString(); + + static bool IsSet(string value) => + !string.IsNullOrWhiteSpace(value) && !value.Equals("Any", StringComparison.OrdinalIgnoreCase); + + static bool IsRequired(string value) => + !string.IsNullOrWhiteSpace(value) && !value.Equals("NotRequired", StringComparison.OrdinalIgnoreCase); + + var properties = FirewallRuleHiddenProperties.None; + + if (IsSet(Get("IcmpType"))) + properties |= FirewallRuleHiddenProperties.IcmpTypes; + + if (IsSet(Get("Service"))) + properties |= FirewallRuleHiddenProperties.Service; + + if (IsSet(Get("Package"))) + properties |= FirewallRuleHiddenProperties.Package; + + if (IsSet(Get("InterfaceAlias"))) + properties |= FirewallRuleHiddenProperties.InterfaceAlias; + + if (IsSet(Get("LocalUser")) || IsSet(Get("RemoteUser")) || IsSet(Get("RemoteMachine")) || + IsRequired(Get("Authentication")) || IsRequired(Get("Encryption"))) + properties |= FirewallRuleHiddenProperties.Security; + + return properties; + } + + /// + /// Parses a PowerShell policy store source type string (e.g. "Local", "GroupPolicy") to a + /// value. Defaults to if empty + /// and to for other sources (e.g. "Dynamic", "Hardcoded"). + /// + /// + /// The raw string value returned by PowerShell. + /// + private static FirewallRuleSource ParseSource(string value) + { + if (string.IsNullOrWhiteSpace(value)) + return FirewallRuleSource.Local; + + return value switch + { + "Local" => FirewallRuleSource.Local, + "GroupPolicy" => FirewallRuleSource.GroupPolicy, + "MDM" => FirewallRuleSource.MDM, + _ => FirewallRuleSource.Other, + }; + } + /// /// Parses a PowerShell interface-type string (e.g. "Wired") to a /// value. Defaults to . + /// A combination of interface types (e.g. "Wired, Wireless") is not supported by NETworkManager; + /// in this case is returned and the raw value is returned in + /// . A combination of all interface types is the same as "Any". /// /// /// The raw string value returned by PowerShell. /// - private static FirewallInterfaceType ParseInterfaceType(string value) + /// + /// The raw value if it is an unsupported combination of interface types; otherwise, . + /// + private static FirewallInterfaceType ParseInterfaceType(string value, out string raw) { + raw = null; + + if (value != null && value.Contains(',')) + { + var types = value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + + if (!(types.Contains("Wired") && types.Contains("Wireless") && types.Contains("RemoteAccess"))) + raw = value; + + return FirewallInterfaceType.Any; + } + return value switch { "Wired" => FirewallInterfaceType.Wired, diff --git a/Source/NETworkManager.Models/Firewall/FirewallRule.cs b/Source/NETworkManager.Models/Firewall/FirewallRule.cs index bb3ea76d36..6fd01c5a90 100644 --- a/Source/NETworkManager.Models/Firewall/FirewallRule.cs +++ b/Source/NETworkManager.Models/Firewall/FirewallRule.cs @@ -18,6 +18,24 @@ public class FirewallRule /// public string Id { get; set; } + /// + /// Indicates whether the rule is managed by NETworkManager (i.e. its display name starts with + /// the NETworkManager prefix). Rules not managed by NETworkManager (e.g. Windows built-in rules) + /// may have properties that are not supported by NETworkManager and can therefore not be edited. + /// + public bool IsManaged { get; set; } + + /// + /// Where the rule comes from (e.g. local store or Group Policy). + /// + public FirewallRuleSource Source { get; set; } = FirewallRuleSource.Local; + + /// + /// Indicates whether the rule is managed by a policy (e.g. Group Policy or MDM) or created by Windows at runtime, + /// i.e. it does not come from the local store. Such rules can't be changed locally (only copied). + /// + public bool IsManagedByPolicy => Source != FirewallRuleSource.Local; + /// /// Human-readable display name of the firewall rule. Used for display purposes /// or as an identifier in various contexts. @@ -135,6 +153,70 @@ public bool[] NetworkProfiles public FirewallInterfaceType InterfaceType { get; set; } = FirewallInterfaceType.Any; + /// + /// Local port keywords (e.g. "RPC", "RPCEPMap", "IPHTTPSIn") used by Windows built-in rules. + /// Not supported by NETworkManager (only shown). + /// + public List LocalPortKeywords { get; set; } = []; + + /// + /// Remote port keywords (e.g. "IPHTTPSOut") used by Windows built-in rules. + /// Not supported by NETworkManager (only shown). + /// + public List RemotePortKeywords { get; set; } = []; + + /// + /// Raw interface type if the rule uses a combination of interface types (e.g. "Wired, Wireless"), + /// which is not supported by NETworkManager; otherwise, null. is + /// in this case. + /// + public string InterfaceTypeRaw { get; set; } + + /// + /// Properties that are not supported (and not shown) by NETworkManager, but restrict the traffic the rule + /// applies to (e.g. ICMP types or a service, set in the Windows Firewall management console). They are kept + /// when the rule is edited, but not when it is copied. + /// + public FirewallRuleHiddenProperties HiddenProperties { get; set; } + + /// + /// Indicates whether the rule has . A copy of the rule would apply to more traffic. + /// + public bool HasHiddenProperties => HiddenProperties != FirewallRuleHiddenProperties.None; + + /// + /// Protocols supported by NETworkManager (e.g. available in the add / edit rule dialog). + /// Other protocols (e.g. IPv6 = 41 or IGMP = 2) are only shown. + /// + public static IReadOnlyList SupportedProtocols { get; } = + [ + FirewallProtocol.Any, + FirewallProtocol.TCP, + FirewallProtocol.UDP, + FirewallProtocol.ICMPv4, + FirewallProtocol.ICMPv6, + FirewallProtocol.GRE, + FirewallProtocol.L2TP + ]; + + /// + /// Program keyword used by Windows built-in rules for the system process (e.g. File and Printer Sharing). + /// Not supported by NETworkManager, because the program must be a path to an existing file. + /// + private const string SystemProgramKeyword = "System"; + + /// + /// Indicates whether the rule uses values that are not supported by NETworkManager + /// (port keywords, a combination of interface types, a protocol that is not in + /// or the program keyword System). Such rules can't be edited or copied, because these values + /// would be lost and the new rule could apply to more traffic. + /// + public bool HasUnsupportedValues => LocalPortKeywords.Count > 0 || + RemotePortKeywords.Count > 0 || + InterfaceTypeRaw != null || + !SupportedProtocols.Contains(Protocol) || + string.Equals(Program?.Name, SystemProgramKeyword, System.StringComparison.OrdinalIgnoreCase); + /// /// Represents the operation to be performed or executed. /// @@ -160,17 +242,40 @@ public FirewallRule() /// Program path, or null when no program restriction is set. public string ProgramDisplay => Program?.ToString(); + /// + /// File name of the program (e.g. "putty.exe" for "%ProgramFiles%\PuTTY\putty.exe", or the keyword "System"), + /// or null when no program restriction is set. + /// + public string ProgramFileName + { + get + { + var name = Program?.Name; + + if (string.IsNullOrEmpty(name)) + return null; + + return name[(name.LastIndexOfAny(['\\', '/']) + 1)..]; + } + } + /// Local addresses as a human-readable string (e.g. "192.168.1.0/24; LocalSubnet"). Returns null when unrestricted. public string LocalAddressesDisplay => LocalAddresses.Count == 0 ? null : string.Join("; ", LocalAddresses); /// Local ports as a human-readable string (e.g. "80; 443; 8080-8090"). Returns null when unrestricted. - public string LocalPortsDisplay => LocalPorts.Count == 0 ? null : PortsToString(LocalPorts); + public string LocalPortsDisplay => PortsToDisplayString(LocalPorts, LocalPortKeywords); /// Remote addresses as a human-readable string (e.g. "10.0.0.0/8"). Returns null when unrestricted. public string RemoteAddressesDisplay => RemoteAddresses.Count == 0 ? null : string.Join("; ", RemoteAddresses); /// Remote ports as a human-readable string (e.g. "80; 443"). Returns null when unrestricted. - public string RemotePortsDisplay => RemotePorts.Count == 0 ? null : PortsToString(RemotePorts); + public string RemotePortsDisplay => PortsToDisplayString(RemotePorts, RemotePortKeywords); + + /// + /// Interface type as string (e.g. "Wired"). For a combination of interface types, which is not supported by + /// NETworkManager, the raw value (e.g. "Wired, Wireless") is returned. Not localized (e.g. used for export). + /// + public string InterfaceTypeDisplay => InterfaceTypeRaw ?? InterfaceType.ToString(); /// /// Network profiles (Domain / Private / Public) as a comma-separated string. @@ -212,5 +317,19 @@ public string NetworkProfilesDisplay return string.Join(delimiter, ports.Select(port => port.ToString())); } + + /// + /// Converts ports and port keywords (e.g. "RPC") to a human-readable string (e.g. "135; RPC"). + /// + /// The ports. + /// The port keywords. + /// The ports as string, or null when unrestricted. + private static string PortsToDisplayString(List ports, List keywords) + { + if (ports.Count == 0 && keywords.Count == 0) + return null; + + return string.Join("; ", ports.Select(port => port.ToString()).Concat(keywords)); + } #endregion } \ No newline at end of file diff --git a/Source/NETworkManager.Models/Firewall/FirewallRuleHiddenProperties.cs b/Source/NETworkManager.Models/Firewall/FirewallRuleHiddenProperties.cs new file mode 100644 index 0000000000..b02eedfe0a --- /dev/null +++ b/Source/NETworkManager.Models/Firewall/FirewallRuleHiddenProperties.cs @@ -0,0 +1,42 @@ +using System; + +namespace NETworkManager.Models.Firewall; + +/// +/// Properties of a firewall rule that are not supported (and not shown) by NETworkManager, but restrict +/// the traffic the rule applies to. If they are lost (e.g. when a rule is copied), the new rule applies +/// to more traffic than the original rule. +/// +[Flags] +public enum FirewallRuleHiddenProperties +{ + /// + /// No hidden properties. + /// + None = 0, + + /// + /// ICMP types (e.g. only echo request). + /// + IcmpTypes = 1, + + /// + /// Windows service (e.g. only a single service hosted by svchost.exe). + /// + Service = 2, + + /// + /// App package (e.g. only a single Microsoft Store app). + /// + Package = 4, + + /// + /// Network adapters (interface aliases, e.g. only the VPN adapter). + /// + InterfaceAlias = 8, + + /// + /// Security conditions (local / remote users or computers, required authentication or encryption). + /// + Security = 16 +} \ No newline at end of file diff --git a/Source/NETworkManager.Models/Firewall/FirewallRuleNotFoundException.cs b/Source/NETworkManager.Models/Firewall/FirewallRuleNotFoundException.cs new file mode 100644 index 0000000000..746274dcb0 --- /dev/null +++ b/Source/NETworkManager.Models/Firewall/FirewallRuleNotFoundException.cs @@ -0,0 +1,17 @@ +using System; + +namespace NETworkManager.Models.Firewall; + +/// +/// Exception thrown when one or more firewall rules no longer exist (e.g. deleted in the Windows Firewall +/// management console). Nothing is changed in this case. Used to show a localized message. +/// +public class FirewallRuleNotFoundException : Exception +{ + /// + /// Initializes a new instance of the class. + /// + public FirewallRuleNotFoundException() : base("One or more firewall rules no longer exist.") + { + } +} \ No newline at end of file diff --git a/Source/NETworkManager.Models/Firewall/FirewallRuleProgram.cs b/Source/NETworkManager.Models/Firewall/FirewallRuleProgram.cs index 46cb44d6c3..8aaf7fa8fc 100644 --- a/Source/NETworkManager.Models/Firewall/FirewallRuleProgram.cs +++ b/Source/NETworkManager.Models/Firewall/FirewallRuleProgram.cs @@ -59,6 +59,10 @@ public FirewallRuleProgram() /// /// Construct program reference for firewall rule. + /// The path is converted to a full path, because Windows Firewall matches programs by their full path. + /// Paths with environment variables (e.g. %SystemRoot%\System32\svchost.exe) and the keyword + /// System (used by Windows built-in rules) are kept as they are, because they would otherwise be + /// resolved relative to the current directory. /// /// public FirewallRuleProgram(string pathToExe) @@ -66,18 +70,20 @@ public FirewallRuleProgram(string pathToExe) ArgumentNullException.ThrowIfNull(pathToExe); var exe = new FileInfo(pathToExe); Executable = exe; - Name = exe.FullName; + Name = pathToExe.Contains('%') || pathToExe.Equals("System", StringComparison.OrdinalIgnoreCase) + ? pathToExe + : exe.FullName; } #endregion #region Methods /// - /// Convert the full file path to string. + /// Convert the file path (as stored in the rule) to string. /// /// public override string ToString() { - return Executable?.FullName; + return Name; } /// @@ -88,7 +94,7 @@ public object Clone() { try { - return new FirewallRuleProgram(Executable?.FullName); + return new FirewallRuleProgram(Name); } catch (ArgumentNullException) { diff --git a/Source/NETworkManager.Models/Firewall/FirewallRuleSource.cs b/Source/NETworkManager.Models/Firewall/FirewallRuleSource.cs new file mode 100644 index 0000000000..1d8ecc7d86 --- /dev/null +++ b/Source/NETworkManager.Models/Firewall/FirewallRuleSource.cs @@ -0,0 +1,28 @@ +namespace NETworkManager.Models.Firewall; + +/// +/// Defines where a firewall rule comes from (based on the PolicyStoreSourceType of the rule). +/// Only rules can be changed locally. +/// +public enum FirewallRuleSource +{ + /// + /// Rule from the local store (e.g. created by NETworkManager, an application or in the Windows Firewall management console). + /// + Local, + + /// + /// Rule from a Group Policy (domain or local Group Policy). + /// + GroupPolicy, + + /// + /// Rule from a Mobile Device Management (MDM) policy (e.g. Microsoft Intune). + /// + MDM, + + /// + /// Rule from another source (e.g. dynamic, generated or hardcoded rules created by Windows at runtime). + /// + Other +} \ No newline at end of file diff --git a/Source/NETworkManager.Settings/GlobalStaticConfiguration.cs b/Source/NETworkManager.Settings/GlobalStaticConfiguration.cs index 8b50e98f0a..d880a6c7d2 100644 --- a/Source/NETworkManager.Settings/GlobalStaticConfiguration.cs +++ b/Source/NETworkManager.Settings/GlobalStaticConfiguration.cs @@ -267,6 +267,7 @@ public static class GlobalStaticConfiguration // Application: Firewall public static ExportFileType Firewall_ExportFileType => ExportFileType.Csv; + public static bool Firewall_ShowAllRules => false; // Application: Discovery Protocol public static DiscoveryProtocol DiscoveryProtocol_Protocol => DiscoveryProtocol.LldpCdp; diff --git a/Source/NETworkManager.Settings/SettingsInfo.cs b/Source/NETworkManager.Settings/SettingsInfo.cs index e5067acfe0..2338dddb2e 100644 --- a/Source/NETworkManager.Settings/SettingsInfo.cs +++ b/Source/NETworkManager.Settings/SettingsInfo.cs @@ -3312,6 +3312,19 @@ public ExportFileType Firewall_ExportFileType } } = GlobalStaticConfiguration.Firewall_ExportFileType; + public bool Firewall_ShowAllRules + { + get; + set + { + if (value == field) + return; + + field = value; + OnPropertyChanged(); + } + } = GlobalStaticConfiguration.Firewall_ShowAllRules; + #endregion #region Discovery Protocol diff --git a/Source/NETworkManager.Settings/SettingsViewManager.cs b/Source/NETworkManager.Settings/SettingsViewManager.cs index 36a02eea09..d5d13ddd1a 100644 --- a/Source/NETworkManager.Settings/SettingsViewManager.cs +++ b/Source/NETworkManager.Settings/SettingsViewManager.cs @@ -74,6 +74,8 @@ public static class SettingsViewManager SettingsGroup.Application), new(SettingsName.SNTPLookup, ApplicationManager.GetIcon(ApplicationName.SNTPLookup), SettingsGroup.Application), + new(SettingsName.Firewall, ApplicationManager.GetIcon(ApplicationName.Firewall), + SettingsGroup.Application), new(SettingsName.WakeOnLAN, ApplicationManager.GetIcon(ApplicationName.WakeOnLAN), SettingsGroup.Application), new(SettingsName.RDAP, ApplicationManager.GetIcon(ApplicationName.RDAP), diff --git a/Source/NETworkManager.Validators/EmptyOrFileExistsValidator.cs b/Source/NETworkManager.Validators/EmptyOrFileExistsValidator.cs index d669e71e4d..b1d78c86b3 100644 --- a/Source/NETworkManager.Validators/EmptyOrFileExistsValidator.cs +++ b/Source/NETworkManager.Validators/EmptyOrFileExistsValidator.cs @@ -1,4 +1,5 @@ -using System.Globalization; +using System; +using System.Globalization; using System.IO; using System.Windows.Controls; using NETworkManager.Localization.Resources; @@ -7,12 +8,22 @@ namespace NETworkManager.Validators; public class EmptyOrFileExistsValidator : ValidationRule { + /// + /// Expand environment variables (e.g. %SystemRoot%) before checking if the file exists. + /// Only enable this if the consumer of the path expands them as well (e.g. Windows Firewall). + /// + public bool ExpandEnvironmentVariables { get; set; } + public override ValidationResult Validate(object value, CultureInfo cultureInfo) { if (string.IsNullOrEmpty(value as string)) return ValidationResult.ValidResult; - return File.Exists((string)value) + var path = ExpandEnvironmentVariables + ? Environment.ExpandEnvironmentVariables((string)value) + : (string)value; + + return File.Exists(path) ? ValidationResult.ValidResult : new ValidationResult(false, Strings.FileDoesNotExist); } diff --git a/Source/NETworkManager/DialogHelper.cs b/Source/NETworkManager/DialogHelper.cs index 036a12e30a..ddf550274e 100644 --- a/Source/NETworkManager/DialogHelper.cs +++ b/Source/NETworkManager/DialogHelper.cs @@ -99,9 +99,9 @@ public static async Task ShowConfirmationMessageAsync(Window parentWindow, } /// - /// Formats the given items as a list (one item per line) to show them in a dialog message, - /// e.g. the entries that will be deleted. Only the first items are - /// listed, the remaining items are summarized as "(+X more)". + /// Formats the given items as a bulleted list (one item per line, prefixed with "- ") to show them + /// in a dialog message, e.g. the entries that will be deleted. Only the first + /// items are listed, the remaining items are summarized as "(+X more)". /// /// The items to list. /// The maximum number of items to list. @@ -109,7 +109,7 @@ public static async Task ShowConfirmationMessageAsync(Window parentWindow, public static string FormatItemList(IEnumerable items, int maxItems = 10) { var itemList = items.ToList(); - var lines = itemList.Take(maxItems).ToList(); + var lines = itemList.Take(maxItems).Select(item => $"- {item}").ToList(); if (itemList.Count > maxItems) lines.Add(string.Format(Strings.PlusXMore, itemList.Count - maxItems)); diff --git a/Source/NETworkManager/ViewModels/FirewallRuleViewModel.cs b/Source/NETworkManager/ViewModels/FirewallRuleViewModel.cs index f21f1c4cbb..1419beaac3 100644 --- a/Source/NETworkManager/ViewModels/FirewallRuleViewModel.cs +++ b/Source/NETworkManager/ViewModels/FirewallRuleViewModel.cs @@ -89,16 +89,7 @@ public FirewallRuleViewModel(Action okCommand, /// /// Protocols available in the protocol drop-down. /// - public IEnumerable Protocols { get; } = - [ - FirewallProtocol.Any, - FirewallProtocol.TCP, - FirewallProtocol.UDP, - FirewallProtocol.ICMPv4, - FirewallProtocol.ICMPv6, - FirewallProtocol.GRE, - FirewallProtocol.L2TP - ]; + public IEnumerable Protocols { get; } = FirewallRule.SupportedProtocols; /// /// Directions available in the direction drop-down. diff --git a/Source/NETworkManager/ViewModels/FirewallSettingsViewModel.cs b/Source/NETworkManager/ViewModels/FirewallSettingsViewModel.cs new file mode 100644 index 0000000000..c3898f4f28 --- /dev/null +++ b/Source/NETworkManager/ViewModels/FirewallSettingsViewModel.cs @@ -0,0 +1,58 @@ +using NETworkManager.Settings; + +namespace NETworkManager.ViewModels; + +/// +/// ViewModel for the Firewall settings. +/// +public class FirewallSettingsViewModel : ViewModelBase +{ + #region Variables + + private readonly bool _isLoading; + + /// + /// Gets or sets whether to show all system firewall rules or only rules managed by NETworkManager. + /// + public bool ShowAllRules + { + get; + set + { + if (value == field) + return; + + if (!_isLoading) + SettingsManager.Current.Firewall_ShowAllRules = value; + + field = value; + OnPropertyChanged(); + } + } + + #endregion + + #region Constructor, load settings + + /// + /// Initializes a new instance of the class. + /// + public FirewallSettingsViewModel() + { + _isLoading = true; + + LoadSettings(); + + _isLoading = false; + } + + /// + /// Loads the settings into the ViewModel. + /// + private void LoadSettings() + { + ShowAllRules = SettingsManager.Current.Firewall_ShowAllRules; + } + + #endregion +} \ No newline at end of file diff --git a/Source/NETworkManager/ViewModels/FirewallViewModel.cs b/Source/NETworkManager/ViewModels/FirewallViewModel.cs index 35d6039af0..8232fdab9f 100644 --- a/Source/NETworkManager/ViewModels/FirewallViewModel.cs +++ b/Source/NETworkManager/ViewModels/FirewallViewModel.cs @@ -1,6 +1,7 @@ using log4net; using MahApps.Metro.Controls; using MahApps.Metro.SimpleChildWindow; +using NETworkManager.Controls; using NETworkManager.Localization.Resources; using NETworkManager.Models; using NETworkManager.Models.Export; @@ -37,7 +38,7 @@ public class FirewallViewModel : ViewModelBase /// /// Gets the loaded firewall rules. /// - public ObservableCollection Results { get; } = []; + public ObservableRangeCollection Results { get; } = new(); /// /// Gets the filtered/sorted view over . @@ -86,6 +87,20 @@ public IList SelectedResults /// public bool IsAnySelectedEntryDisabled => GetSelectedRules().Any(rule => !rule.IsEnabled); + /// + /// Indicates whether at least one of the selected rules is managed by a policy (e.g. Group Policy or MDM) + /// or created by Windows at runtime. Such rules can't be changed locally, so enable, disable and delete + /// are blocked for the whole selection (also for mixed selections). + /// The selection is only checked if the list contains such rules (see ), + /// because this is evaluated often (e.g. ). + /// + public bool IsAnySelectedRuleManagedByPolicy => _hasRulesManagedByPolicy && GetSelectedRules().Any(rule => rule.IsManagedByPolicy); + + /// + /// Indicates whether the loaded rules contain at least one rule managed by a policy. Updated on refresh. + /// + private bool _hasRulesManagedByPolicy; + /// /// Gets or sets the search text for filtering rules. /// @@ -103,6 +118,23 @@ public string RulesSearch } } + /// + /// Gets whether all system firewall rules are shown or only rules managed by NETworkManager. + /// Configured in the settings (see ). + /// + public bool ShowAllRules => SettingsManager.Current.Firewall_ShowAllRules; + + /// + /// Indicates whether the rules must be reloaded (e.g. because was changed + /// in the settings). Checked when the view becomes visible and after a running refresh. + /// + private bool _refreshPending; + + /// + /// Indicates whether the view is currently visible. + /// + private bool _isViewVisible = true; + /// /// Gets or sets whether a refresh is currently running. /// @@ -119,6 +151,24 @@ public bool IsRefreshing } } + /// + /// Gets or sets whether the rules are loaded initially. The rule list is replaced by a loading indicator + /// in this case. Other refreshes (e.g. after a rule was modified or the settings were changed) only show + /// the status bar indicator (see ), so the list stays visible. + /// + public bool IsLoading + { + get; + set + { + if (value == field) + return; + + field = value; + OnPropertyChanged(); + } + } + /// /// Gets or sets whether a rule is currently being modified (added, edited, enabled, disabled or deleted). /// @@ -199,6 +249,9 @@ public FirewallViewModel() // Load firewall rules _ = Refresh(true); + // Reload the rules when the settings are changed (e.g. show all rules) + SettingsManager.Current.PropertyChanged += SettingsManager_PropertyChanged; + _isLoading = false; } @@ -258,19 +311,24 @@ private async Task AddRule() /// /// Gets the command to enable the selected firewall rule(s). - /// Only executable when at least one selected rule is disabled and modification is allowed. + /// Only executable when at least one selected rule is disabled, no selected rule is managed by a policy + /// and modification is allowed. /// - public ICommand EnableRuleCommand => new RelayCommand(parameter => { _ = SetRulesEnabled(true); }, _ => ModifyRule_CanExecute() && IsAnySelectedEntryDisabled); + public ICommand EnableRuleCommand => new RelayCommand(parameter => { _ = SetRulesEnabled(true); }, + _ => ModifyRule_CanExecute() && IsAnySelectedEntryDisabled && !IsAnySelectedRuleManagedByPolicy); /// /// Gets the command to disable the selected firewall rule(s). - /// Only executable when at least one selected rule is enabled and modification is allowed. + /// Only executable when at least one selected rule is enabled, no selected rule is managed by a policy + /// and modification is allowed. /// - public ICommand DisableRuleCommand => new RelayCommand(parameter => { _ = SetRulesEnabled(false); }, _ => ModifyRule_CanExecute() && IsAnySelectedEntryEnabled); + public ICommand DisableRuleCommand => new RelayCommand(parameter => { _ = SetRulesEnabled(false); }, + _ => ModifyRule_CanExecute() && IsAnySelectedEntryEnabled && !IsAnySelectedRuleManagedByPolicy); /// /// Enables or disables the selected rule(s) via PowerShell, /// then reloads the rule list to reflect the updated state. + /// If at least one rule is not managed by NETworkManager, the user must confirm the change first. /// Any PowerShell error is written to the log and shown in the status bar. /// /// @@ -283,19 +341,44 @@ private async Task SetRulesEnabled(bool enabled) if (rules.Count == 0) return; + var notManagedWarning = GetNotManagedRulesWarning(rules); + + if (notManagedWarning != null) + { + var title = rules.Count == 1 + ? enabled ? Strings.EnableRule : Strings.DisableRule + : string.Format(enabled ? Strings.EnableRulesX : Strings.DisableRulesX, rules.Count); + + var result = await DialogHelper.ShowConfirmationMessageAsync( + Application.Current.MainWindow, + title, + notManagedWarning, + ChildWindowIcon.Warn, + enabled ? Strings.Enable : Strings.Disable); + + if (!result) + return; + } + await ModifyRulesAsync(() => Firewall.SetRulesEnabledAsync(rules, enabled), $"Error while {(enabled ? "enabling" : "disabling")} firewall rule(s)"); } /// /// Gets the command to open the dialog for editing the selected firewall rule. - /// Only executable when exactly one rule is selected and modification is allowed. + /// Only executable when exactly one rule managed by NETworkManager is selected and modification is allowed. + /// Rules not managed by NETworkManager can't be edited (not supported yet), because they may use properties + /// that can't be shown in the dialog (e.g. group, service or ICMP types). Rules with unsupported values + /// (e.g. port keywords like RPC, see ) can't be edited either, + /// because the dialog can't show or keep these values. /// - public ICommand EditRuleCommand => new RelayCommand(parameter => { _ = EditRule(); }, _ => ModifyRule_CanExecute() && IsSingleRuleSelected); + public ICommand EditRuleCommand => new RelayCommand(parameter => { _ = EditRule(); }, + _ => ModifyRule_CanExecute() && IsSingleRuleSelected && SelectedResult.IsManaged && !SelectedResult.HasUnsupportedValues); /// /// Opens the edit-firewall-rule dialog pre-filled with the selected rule's properties. - /// On confirmation, deletes the old rule, creates the updated rule via PowerShell, + /// On confirmation, updates the changed properties of the rule via PowerShell (other properties and the + /// internal ID are kept, see ), /// and refreshes the rule list. /// private async Task EditRule() @@ -307,11 +390,8 @@ private async Task EditRule() childWindow.IsOpen = false; ConfigurationManager.Current.IsChildWindowOpen = false; - await ModifyRulesAsync(async () => - { - await Firewall.DeleteRuleAsync(instance.Entry); - await Firewall.AddRuleAsync(BuildRule(instance)); - }, "Error while editing firewall rule"); + await ModifyRulesAsync(() => Firewall.UpdateRuleAsync(instance.Entry, BuildRule(instance)), + "Error while editing firewall rule"); }, _ => { childWindow.IsOpen = false; @@ -329,16 +409,37 @@ await ModifyRulesAsync(async () => /// /// Gets the command to copy the selected firewall rule. /// Only executable when exactly one rule is selected and modification is allowed. + /// Rules with unsupported values (e.g. port keywords like RPC, see ) + /// can't be copied, because the values would be lost and the copy would apply to more traffic (e.g. all ports). /// - public ICommand CopyAsRuleCommand => new RelayCommand(parameter => { _ = CopyAsRule(); }, _ => ModifyRule_CanExecute() && IsSingleRuleSelected); + public ICommand CopyAsRuleCommand => new RelayCommand(parameter => { _ = CopyAsRule(); }, + _ => ModifyRule_CanExecute() && IsSingleRuleSelected && !SelectedResult.HasUnsupportedValues); /// /// Opens the rule dialog pre-filled with the selected rule's properties (name suffixed with " - Copy"). /// On confirmation, creates a new rule via PowerShell (the selected rule is not changed) /// and refreshes the rule list. + /// If the rule has properties that are not supported by NETworkManager and restrict the traffic + /// (see ), the user must first acknowledge that they are not copied. /// private async Task CopyAsRule() { + var rule = SelectedResult; + + if (rule.HasHiddenProperties) + { + var result = await DialogHelper.ShowConfirmationMessageAsync( + Application.Current.MainWindow, + Strings.CopyRule, + string.Format(Strings.CopyFirewallRuleHiddenPropertiesMessage, rule.Name, + GetHiddenPropertiesString(rule.HiddenProperties)), + ChildWindowIcon.Warn, + Strings.Continue); + + if (!result) + return; + } + var childWindow = new FirewallRuleChildWindow(); var childWindowViewModel = new FirewallRuleViewModel(async instance => @@ -351,7 +452,7 @@ private async Task CopyAsRule() { childWindow.IsOpen = false; ConfigurationManager.Current.IsChildWindowOpen = false; - }, SelectedResult, EditMode.Copy); + }, rule, EditMode.Copy); childWindow.Title = Strings.CopyRule; childWindow.DataContext = childWindowViewModel; @@ -363,13 +464,15 @@ private async Task CopyAsRule() /// /// Gets the command to permanently delete the selected firewall rule(s). - /// Only executable when a rule is selected and modification is allowed. + /// Only executable when a rule is selected, no selected rule is managed by a policy and modification is allowed. /// - public ICommand DeleteRuleCommand => new RelayCommand(parameter => { _ = DeleteRules(); }, _ => ModifyRule_CanExecute() && SelectedResult != null); + public ICommand DeleteRuleCommand => new RelayCommand(parameter => { _ = DeleteRules(); }, + _ => ModifyRule_CanExecute() && SelectedResult != null && !IsAnySelectedRuleManagedByPolicy); /// /// 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. + /// then reloads the rule list. If at least one rule is not managed by NETworkManager, a warning + /// is added to the confirmation. Any PowerShell error is written to the log and shown in the status bar. /// private async Task DeleteRules() { @@ -379,14 +482,19 @@ private async Task DeleteRules() return; var message = rules.Count == 1 - ? string.Format(Strings.DeleteFirewallRuleMessage, rules[0].Name) + ? string.Format(Strings.DeleteFirewallRuleMessage, DialogHelper.FormatItemList([rules[0].Name])) : string.Format(Strings.DeleteFirewallRulesMessage, DialogHelper.FormatItemList(rules.Select(rule => rule.Name))); + var notManagedWarning = GetNotManagedRulesWarning(rules); + + if (notManagedWarning != null) + message += Environment.NewLine + Environment.NewLine + notManagedWarning; + var result = await DialogHelper.ShowConfirmationMessageAsync( Application.Current.MainWindow, rules.Count == 1 ? Strings.DeleteRule : string.Format(Strings.DeleteRulesX, rules.Count), message, - ChildWindowIcon.Info, + notManagedWarning != null ? ChildWindowIcon.Warn : ChildWindowIcon.Info, Strings.Delete); if (!result) @@ -434,6 +542,49 @@ private IEnumerable GetSelectedRules() /// private List GetTargetEntries() => [.. GetSelectedRules()]; + /// + /// Converts the hidden properties of a rule to a localized, bulleted list (e.g. "- ICMP types", "- Service"). + /// + /// The hidden properties. + private static string GetHiddenPropertiesString(FirewallRuleHiddenProperties properties) + { + var names = new List(); + + if (properties.HasFlag(FirewallRuleHiddenProperties.IcmpTypes)) + names.Add(Strings.IcmpTypes); + + if (properties.HasFlag(FirewallRuleHiddenProperties.Service)) + names.Add(Strings.Service); + + if (properties.HasFlag(FirewallRuleHiddenProperties.Package)) + names.Add(Strings.AppPackage); + + if (properties.HasFlag(FirewallRuleHiddenProperties.InterfaceAlias)) + names.Add(Strings.NetworkAdapters); + + if (properties.HasFlag(FirewallRuleHiddenProperties.Security)) + names.Add(Strings.UsersAndAuthentication); + + return DialogHelper.FormatItemList(names); + } + + /// + /// Builds a warning which lists the given rules that are not managed by NETworkManager + /// (e.g. Windows built-in rules), because changing them may affect Windows or other applications. + /// + /// The rules to check. + /// The warning, or if all rules are managed by NETworkManager. + private static string GetNotManagedRulesWarning(List rules) + { + var notManagedRules = rules.Where(rule => !rule.IsManaged).ToList(); + + if (notManagedRules.Count == 0) + return null; + + return string.Format(Strings.FirewallRulesNotManagedWarningMessage, + DialogHelper.FormatItemList(notManagedRules.Select(rule => rule.Name))); + } + /// /// Runs the given modification and reloads the rule list afterwards, also if the modification /// failed (e.g. a rule was removed in the meantime), so the list always shows the actual state. @@ -465,13 +616,21 @@ private async Task ModifyRulesAsync(Func action, string errorLogMessage) // If the refresh failed too, keep its message, so it's visible that the list is not up to date. if (error != null) { - StatusMessage = refreshed ? error.Message : error.Message + Environment.NewLine + StatusMessage; + var errorMessage = error is FirewallRuleNotFoundException + ? Strings.FirewallRulesNoLongerExistMessage + : error.Message; + + StatusMessage = refreshed ? errorMessage : errorMessage + Environment.NewLine + StatusMessage; IsStatusMessageDisplayed = true; } } finally { IsModifying = false; + + // The settings were changed while the rules were modified (see OnViewVisible) + if (_refreshPending && _isViewVisible) + _ = Refresh(); } } @@ -580,7 +739,8 @@ await DialogHelper.ShowMessageAsync(Application.Current.MainWindow, Strings.Erro #region Methods /// - /// Loads all NETworkManager firewall rules from the system via PowerShell and + /// Loads the NETworkManager firewall rules (or all rules, if is enabled) + /// from the system via PowerShell and /// replaces the contents of with the new list. /// Updates throughout to reflect loading progress. /// @@ -595,6 +755,10 @@ private async Task Refresh(bool init = false) return false; IsRefreshing = true; + + // Show the loading indicator instead of the (empty) list on the initial load, see IsLoading + IsLoading = init; + StatusMessage = Strings.RefreshingDots; IsStatusMessageDisplayed = true; @@ -603,14 +767,19 @@ private async Task Refresh(bool init = false) try { - var rules = await Firewall.GetRulesAsync(); + // Reset right before the setting is read, so only a change during the query triggers another refresh (see finally) + _refreshPending = false; + + var rules = await Firewall.GetRulesAsync(onlyManagedRules: !ShowAllRules); Application.Current.Dispatcher.Invoke(() => { - Results.Clear(); + // Replace all rules with a single notification, so the view is filtered / sorted only once + // (can be 1000+ rules if all rules are shown). ResultsView.DeferRefresh() can't be used, because + // the DataGrid accesses the view (selection / current item) while the items are changed. + Results.ReplaceAll(rules); - foreach (var rule in rules) - Results.Add(rule); + _hasRulesManagedByPolicy = rules.Any(rule => rule.IsManagedByPolicy); }); StatusMessage = string.Format(Strings.ReloadedAtX, DateTime.Now.ToShortTimeString()); @@ -630,6 +799,11 @@ private async Task Refresh(bool init = false) finally { IsRefreshing = false; + IsLoading = false; + + // The settings were changed during the refresh, reload with the new settings + if (_refreshPending && _isViewVisible) + _ = Refresh(); } } @@ -706,13 +880,37 @@ public void OnContextMenuOpened() { OnPropertyChanged(nameof(IsAnySelectedEntryEnabled)); OnPropertyChanged(nameof(IsAnySelectedEntryDisabled)); + OnPropertyChanged(nameof(IsAnySelectedRuleManagedByPolicy)); } /// - /// Called when the view becomes visible. + /// Called when a setting is changed. Marks the rules to be reloaded if a setting changed that + /// affects the rule list. The rules are reloaded when the view becomes visible again + /// (the settings are opened in place of the view, see ). + /// + private void SettingsManager_PropertyChanged(object sender, PropertyChangedEventArgs e) + { + switch (e.PropertyName) + { + case nameof(SettingsInfo.Firewall_ShowAllRules): + OnPropertyChanged(nameof(ShowAllRules)); + + _refreshPending = true; + break; + } + } + + /// + /// Called when the view becomes visible. Reloads the rules if a relevant setting was changed in the meantime. /// public void OnViewVisible() { + _isViewVisible = true; + + // If rules are being modified, the refresh is done afterwards (see ModifyRulesAsync), + // so the error message of the modification is not mixed up with the status of this refresh + if (_refreshPending && !IsModifying) + _ = Refresh(); } /// @@ -720,6 +918,7 @@ public void OnViewVisible() /// public void OnViewHide() { + _isViewVisible = false; } #endregion diff --git a/Source/NETworkManager/ViewModels/HostsFileEditorViewModel.cs b/Source/NETworkManager/ViewModels/HostsFileEditorViewModel.cs index b4fb4c9a89..7b5c389961 100644 --- a/Source/NETworkManager/ViewModels/HostsFileEditorViewModel.cs +++ b/Source/NETworkManager/ViewModels/HostsFileEditorViewModel.cs @@ -570,7 +570,8 @@ private async Task DeleteEntryAction() if (entries.Count == 1) { - message = string.Format(Strings.DeleteHostsFileEntryMessage, entries[0].IPAddress, entries[0].Hostname, + // Prefixed with "- " like the bulleted list for multiple entries (see DialogHelper.FormatItemList) + message = string.Format(Strings.DeleteHostsFileEntryMessage, $"- {entries[0].IPAddress}", entries[0].Hostname, string.IsNullOrEmpty(entries[0].Comment) ? "" : $"# {entries[0].Comment}"); } else diff --git a/Source/NETworkManager/ViewModels/SettingsViewModel.cs b/Source/NETworkManager/ViewModels/SettingsViewModel.cs index 73f5a30a2a..900f4db799 100644 --- a/Source/NETworkManager/ViewModels/SettingsViewModel.cs +++ b/Source/NETworkManager/ViewModels/SettingsViewModel.cs @@ -133,6 +133,7 @@ public SettingsViewInfo SelectedSettingsView private WebConsoleSettingsView _webConsoleSettingsView; private SNMPSettingsView _snmpSettingsView; private SNTPLookupSettingsView _sntpLookupSettingsView; + private FirewallSettingsView _firewallSettingsView; private WakeOnLANSettingsView _wakeOnLANSettingsView; private RDAPSettingsView _rdapSettingsView; private BitCalculatorSettingsView _bitCalculatorSettingsView; @@ -332,6 +333,11 @@ private void ChangeSettingsContent(SettingsViewInfo settingsViewInfo) SettingsContent = _sntpLookupSettingsView; break; + case SettingsName.Firewall: + _firewallSettingsView ??= new FirewallSettingsView(); + + SettingsContent = _firewallSettingsView; + break; case SettingsName.WakeOnLAN: _wakeOnLANSettingsView ??= new WakeOnLANSettingsView(); diff --git a/Source/NETworkManager/Views/FirewallRuleChildWindow.xaml b/Source/NETworkManager/Views/FirewallRuleChildWindow.xaml index 3b97546304..e01a0e94b4 100644 --- a/Source/NETworkManager/Views/FirewallRuleChildWindow.xaml +++ b/Source/NETworkManager/Views/FirewallRuleChildWindow.xaml @@ -27,7 +27,7 @@ - + @@ -112,6 +126,7 @@ @@ -127,6 +142,28 @@ Command="{Binding Path=EditRuleCommand}" wpfHelpers:PermissionToolTipHelper.Text="{x:Static localization:Strings.FirewallAdminMessage}" wpfHelpers:PermissionToolTipHelper.HasPermission="{Binding Source={x:Static settings:ConfigurationManager.Current}, Path=IsAdmin}"> + + + @@ -139,6 +176,20 @@ Command="{Binding Path=CopyAsRuleCommand}" wpfHelpers:PermissionToolTipHelper.Text="{x:Static localization:Strings.FirewallAdminMessage}" wpfHelpers:PermissionToolTipHelper.HasPermission="{Binding Source={x:Static settings:ConfigurationManager.Current}, Path=IsAdmin}"> + + + @@ -149,6 +200,7 @@ @@ -203,9 +255,19 @@ CommandParameter="{Binding Path=SelectedResult.ProgramDisplay}" Style="{StaticResource CopyMenuItem}" /> + Command="{Binding Path=CopyDataToClipboardCommand}"> + + + + @@ -242,6 +305,7 @@ @@ -255,6 +319,7 @@ @@ -280,6 +345,7 @@ + + + + + + + + - + + + + + + + + + + + + + + + + + + diff --git a/Source/NETworkManager/Views/FirewallView.xaml.cs b/Source/NETworkManager/Views/FirewallView.xaml.cs index 61d85c59b6..1e3ba55bdf 100644 --- a/Source/NETworkManager/Views/FirewallView.xaml.cs +++ b/Source/NETworkManager/Views/FirewallView.xaml.cs @@ -1,6 +1,8 @@ using System; using System.Windows; using System.Windows.Controls; +using System.Windows.Controls.Primitives; +using System.Windows.Input; using System.Windows.Media; using NETworkManager.ViewModels; @@ -56,6 +58,24 @@ private void ExpandRowDetails_OnClick(object sender, RoutedEventArgs e) } } + /// + /// Opens the edit dialog for the rule when a row is double-clicked (if the rule can be edited). + /// Double-clicks on buttons / checkboxes (e.g. the expand/collapse chevron) and in the row details + /// (e.g. to select text) are ignored. + /// + private void DataGridRow_MouseDoubleClick(object sender, MouseButtonEventArgs e) + { + for (var element = e.OriginalSource as DependencyObject; element != null && element != sender; + element = element is Visual ? VisualTreeHelper.GetParent(element) : LogicalTreeHelper.GetParent(element)) + { + if (element is ButtonBase or DataGridDetailsPresenter) + return; + } + + if (_viewModel.EditRuleCommand.CanExecute(null)) + _viewModel.EditRuleCommand.Execute(null); + } + /// /// Offload event for toggling view to the view model. /// diff --git a/Website/docs/application/firewall.md b/Website/docs/application/firewall.md index 9d08cf8e20..d1a5106ea1 100644 --- a/Website/docs/application/firewall.md +++ b/Website/docs/application/firewall.md @@ -6,7 +6,7 @@ keywords: [NETworkManager, Windows Firewall, firewall rules, inbound, outbound, # Firewall -The **Firewall** allows you to view, add, edit, enable, disable, or delete Windows Firewall rules managed by NETworkManager. Rules are identified by a `NETworkManager_` prefix in their display name so that only rules created through NETworkManager are shown in this view. +The **Firewall** allows you to view, add, edit, enable, disable, or delete Windows Firewall rules managed by NETworkManager. Rules are identified by a `NETworkManager_` prefix in their display name so that only rules created through NETworkManager are shown in this view. Enable [**Show all rules**](#show-all-rules) in the settings to show all Windows Firewall rules. :::info @@ -22,6 +22,8 @@ Without administrator privileges, the view is read-only. Use the **Restart as ad ![Firewall](../img/firewall.png) +The **Program** column shows the file name of the program the rule applies to (e.g. `putty.exe`). The full path (e.g. `%ProgramFiles%\PuTTY\putty.exe`) is shown as tooltip and in the row details. + ### Toolbar | Button | Description | @@ -29,6 +31,34 @@ Without administrator privileges, the view is read-only. Use the **Restart as ad | **Add rule...** | Opens a dialog to create a new firewall rule | | **Windows Firewall Settings** | Opens the Windows Firewall management console (`WF.msc`) | +### Rules not managed by NETworkManager + +By default, only rules managed by NETworkManager (display name prefixed with `NETworkManager_`) are shown. If [**Show all rules**](#show-all-rules) is enabled in the settings, all active Windows Firewall rules are shown (e.g. Windows built-in rules, rules created by other applications or from Group Policy). + +When all rules are shown, an additional column shows where a rule comes from. Click the column header to sort by it. + +| Icon | Description | +|------|-------------| +| Shield | Managed by NETworkManager. All actions are available. | +| Crossed-out shield | Not managed by NETworkManager (e.g. Windows built-in rule or created by another application). The rule can be enabled, disabled, deleted or copied, but not edited. | +| Shield with lock | Managed by a policy (Group Policy or Mobile Device Management (MDM), e.g. Microsoft Intune) or created by Windows at runtime (e.g. dynamic or hardcoded rules). The rule can't be changed locally, it can only be copied. | + +If at least one of the selected rules is managed by a policy, **Enable**, **Disable** and **Delete** are disabled for the whole selection. + +Rules not managed by NETworkManager can be enabled, disabled, deleted (also together with other selected rules) or used as a template via **Copy as...** (the copy is created as a new rule managed by NETworkManager), but editing them is not supported (yet). These rules may use properties that can't be shown in the edit dialog (e.g. rule group, service, ICMP types, edge traversal or app packages). The **Edit** action is disabled for these rules. + +Some rules (mostly Windows built-in rules) use values that are not supported by NETworkManager, such as port keywords (e.g. `RPC`, `RPCEPMap`, `IPHTTPSIn`), a combination of interface types (e.g. `Wired, Wireless`), a protocol that is not listed in the dialog (e.g. `IGMP` or `IPv6`) or the program `System`. These values are shown as they are, but such rules can neither be **edited** nor used for **Copy as...**, because the dialog can't show or keep these values and the new rule could apply to more traffic (e.g. to all ports). This also applies to rules managed by NETworkManager that were changed outside of NETworkManager (e.g. in the Windows Firewall management console). + +When enabling, disabling or deleting rules and at least one of the selected rules is not managed by NETworkManager, a warning listing these rules is shown, which must be confirmed (for delete, the warning is added to the existing confirmation). + +When using **Copy as...** on a rule that uses properties which are not supported by NETworkManager but restrict the traffic (ICMP types, a service, an app package, specific network adapters, or users / required authentication, e.g. set in the Windows Firewall management console), a warning listing these properties is shown first, which must be confirmed. These properties are not copied, so the new rule may apply to more traffic than the original rule (e.g. to all services of a program instead of a single one). Review the new rule carefully before saving it. When editing a rule, these properties are kept. + +:::warning + +Be careful when disabling or deleting Windows built-in rules or rules created by other applications, as this may break network functionality of Windows or the application. + +::: + ### Context menu Multiple rules can be selected (`Ctrl` / `Shift` + click) to enable, disable or delete them at once. @@ -37,8 +67,8 @@ Multiple rules can be selected (`Ctrl` / `Shift` + click) to enable, disable or |--------|-------------| | **Enable** | Enables the selected rule or rules | | **Disable** | Disables the selected rule or rules | -| **Edit** | Opens the edit dialog for the selected rule (single rule only) | -| **Copy as** | Opens the dialog pre-filled with the selected rule to create a new rule based on it (single rule only) | +| **Edit** | Opens the edit dialog for the selected rule (single rule only, only rules managed by NETworkManager without [unsupported values](#rules-not-managed-by-networkmanager)) | +| **Copy as** | Opens the dialog pre-filled with the selected rule to create a new rule based on it (single rule only, not for rules with [unsupported values](#rules-not-managed-by-networkmanager)) | | **Delete** | Removes the selected rule or rules (after confirmation) | | **Copy** | Copies the rule information to the clipboard (single rule only) | | **Export** | Exports the selected rules (or all rules) to a file | @@ -48,7 +78,7 @@ Multiple rules can be selected (`Ctrl` / `Shift` + click) to enable, disable or | Key | Action | |-----|--------| | `F5` | Refresh | -| `F2` | Edit selected rule | +| `F2` / Double-click | Edit selected rule (only if the rule can be edited) | | `Del` | Delete selected rule or rules | :::note @@ -61,6 +91,8 @@ Before enabling, disabling or deleting, it is checked that all selected rules st The **Add rule** dialog is opened by clicking the **Add rule...** button below the rule list. The same dialog (with the values pre-filled) is used to **edit** an existing rule, or to create a new rule based on an existing one via **Copy as...** in the context menu (the name is suffixed with ` - Copy`, the original rule is not changed). +When editing a rule, only the changed properties are updated. Properties that are not supported by NETworkManager (e.g. rule group, service or ICMP types set in the Windows Firewall management console) are kept. If the protocol is changed, ICMP types are reset to `Any`, because they are only valid for the ICMP protocol they were set for. + ![Add rule](../img/firewall-rule.png) The display name of every rule created with NETworkManager is automatically prefixed with `NETworkManager_` so it can be picked up by NETworkManager on the next refresh. The prefix is hidden in the user interface. @@ -263,7 +295,7 @@ Full path to an executable the rule applies to. When set, the rule only matches :::note -If a path is provided, the file must exist on the local computer. +If a path is provided, the file must exist on the local computer. Environment variables (e.g. `%SystemRoot%\System32\svchost.exe`) are supported. An empty value means **Any** program. @@ -303,3 +335,15 @@ Network profiles the rule applies to. At least one profile must be selected. The last enabled profile cannot be unchecked — at least one profile must remain selected. ::: + +## Settings + +### Show all rules + +Show all active Windows Firewall rules (e.g. Windows built-in rules, rules created by other applications or from Group Policy / MDM) instead of only the rules managed by NETworkManager. The rule list is reloaded when the setting is changed. + +See [Rules not managed by NETworkManager](#rules-not-managed-by-networkmanager) for what can be done with these rules. + +**Type:** `Boolean` + +**Default:** `Disabled` diff --git a/Website/docs/changelog/next-release.md b/Website/docs/changelog/next-release.md index fb962aef29..f1a0e31af8 100644 --- a/Website/docs/changelog/next-release.md +++ b/Website/docs/changelog/next-release.md @@ -89,11 +89,24 @@ Release date: **xx.xx.2026** - Added **Copy as...** to the context menu to create a new rule based on an existing one (name suffixed with ` - Copy`). [0544698](https://github.com/BornToBeRoot/NETworkManager/commit/0544698aefc325ab36ca4c8e80250e1fe0b62d4a) - Multiple rules can now be selected to enable, disable or delete them at once. If one of the selected rules was removed in the meantime, nothing is changed. [#3627](https://github.com/BornToBeRoot/NETworkManager/pull/3627) +- A loading indicator is shown while the rules are loaded initially, and a message is shown if no rules are found. [#3630](https://github.com/BornToBeRoot/NETworkManager/pull/3630) +- A rule can now be edited by double-clicking it. [#3630](https://github.com/BornToBeRoot/NETworkManager/pull/3630) +- Added a **Program** column that shows the file name of the program (full path as tooltip). [#3630](https://github.com/BornToBeRoot/NETworkManager/pull/3630) +- New **Settings > Firewall** page. [#3630](https://github.com/BornToBeRoot/NETworkManager/pull/3630) +- Added the **Show all rules** option (**Settings > Firewall**) to show all active Windows Firewall rules (including rules from Group Policy / MDM) instead of only the rules managed by NETworkManager. A new column with an icon shows whether a rule is managed by NETworkManager, not managed, or managed by a policy. Rules managed by a policy can only be copied. Rules not managed by NETworkManager can be enabled, disabled, deleted (after confirming a warning) or used as a template via **Copy as...** (with a warning if the rule uses properties that are not copied, e.g. ICMP types, a service, an app package, specific network adapters or users / required authentication), but editing them is not supported (yet). Rules with values that are not supported by NETworkManager (e.g. port keywords like `RPC`, a combination of interface types, a protocol like `IGMP` or the program `System`) are shown correctly, but can't be edited or copied. [#3630](https://github.com/BornToBeRoot/NETworkManager/pull/3630) ## Bug Fixes - Fixed changes to lists in the settings (e.g. query histories, port profiles, custom commands) not being saved if no other setting was changed in the same session. This happened since the settings are stored as JSON. [#3607](https://github.com/BornToBeRoot/NETworkManager/pull/3607) +**Firewall** + +- Fixed a horizontal scrollbar appearing in the add / edit / copy rule dialog when the name or description is very long. [#3630](https://github.com/BornToBeRoot/NETworkManager/pull/3630) +- Fixed port keywords (e.g. `RPC`) and combinations of interface types being shown as **Any**. Editing a rule that was changed outside of NETworkManager to use such values no longer silently removes them (the rule can't be edited in this case). [#3630](https://github.com/BornToBeRoot/NETworkManager/pull/3630) +- Fixed the program of a rule not being shown in the row details and the edit dialog. Saving the edit dialog then removed the program from the rule, so the rule applied to all programs. [#3630](https://github.com/BornToBeRoot/NETworkManager/pull/3630) +- Fixed editing a rule deleting and re-creating it, which removed properties that are not supported by NETworkManager (e.g. ICMP types or a service set in the Windows Firewall management console). Only the changed properties are updated now (ICMP types are reset if the protocol is changed). [#3630](https://github.com/BornToBeRoot/NETworkManager/pull/3630) +- Fixed program paths with environment variables (e.g. `%SystemRoot%\System32\svchost.exe`) being rejected as non-existent in the add / edit / copy rule dialog. [#3630](https://github.com/BornToBeRoot/NETworkManager/pull/3630) + **Ping Monitor** - Fixed long error messages (e.g. from a failed host resolution) in the tooltip of the error icon not being wrapped. [#3626](https://github.com/BornToBeRoot/NETworkManager/pull/3626)