diff --git a/Source/NETworkManager.Converters/BoolArrayToFwRuleCategoriesConverter.cs b/Source/NETworkManager.Converters/BoolArrayToFwRuleCategoriesConverter.cs new file mode 100644 index 0000000000..24beacb641 --- /dev/null +++ b/Source/NETworkManager.Converters/BoolArrayToFwRuleCategoriesConverter.cs @@ -0,0 +1,53 @@ +using System; +using System.Globalization; +using System.Linq; +using System.Windows.Data; +using NETworkManager.Models.Network; +using NETworkManager.Localization.Resources; + + +namespace NETworkManager.Converters; + +public class BoolArrayToFwRuleCategoriesConverter : IValueConverter +{ + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + if (targetType != typeof(string)) return null; + const int expectedLength = 3; + var fallback = GetTranslation(Enum.GetName(NetworkProfiles.NotConfigured), false); + if (value is not bool[] { Length: expectedLength } boolArray) + return fallback; + var result = string.Empty; + var numSelected = boolArray.CountAny(true); + switch (numSelected) + { + case 0: + return fallback; + case < 2: + return GetTranslation(Enum.GetName(typeof(NetworkProfiles), + Array.FindIndex(boolArray, b => b)), false); + } + + if (boolArray.All(b => b)) + return Strings.All; + + for (var i = 0; i < expectedLength; i++) + { + if (boolArray[i]) + result += $", {GetTranslation(Enum.GetName(typeof(NetworkProfiles), i), true)}"; + } + + return result[2..]; + + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } + + private static string GetTranslation(string key, bool trimmed) + { + return Strings.ResourceManager.GetString(trimmed ? $"{key}_Short3" : key, Strings.Culture); + } +} \ No newline at end of file diff --git a/Source/NETworkManager.Converters/CollectionPropertyBooleanOrConverter.cs b/Source/NETworkManager.Converters/CollectionPropertyBooleanOrConverter.cs new file mode 100644 index 0000000000..1b23919988 --- /dev/null +++ b/Source/NETworkManager.Converters/CollectionPropertyBooleanOrConverter.cs @@ -0,0 +1,63 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Globalization; +using System.Reflection; +using System.Windows.Data; +using NETworkManager.Interfaces.ViewModels; + +namespace NETworkManager.Converters +{ + /// + /// A generic converter that checks a property of items in a collection. + /// If ANY item's property is considered "present" (not null, not empty), it returns Visible. + /// + /// The type of item in the collection. + public class CollectionPropertyBooleanOrConverter : IValueConverter + { + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + // 1. Validate inputs + if (value is not IEnumerable collection) + return false; + + if (parameter is not string propertyName || string.IsNullOrEmpty(propertyName)) + return false; + + // 2. Get PropertyInfo via Reflection or cache. + if (!Cache.TryGetValue(propertyName, out var propertyInfo)) + { + propertyInfo = typeof(T).GetProperty(propertyName); + Cache.TryAdd(propertyName, propertyInfo); + } + + if (propertyInfo == null) + return false; + + // 3. Iterate collection and check property + foreach (var item in collection) + { + if (item == null) continue; + + var propValue = propertyInfo.GetValue(item); + + if (propValue is true) + return true; + } + + return false; + } + + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } + + private ConcurrentDictionary Cache { get; } = new(); + } + + // Concrete implementation for XAML usage + public class FirewallRuleViewModelBooleanOrConverter : CollectionPropertyBooleanOrConverter; +} + diff --git a/Source/NETworkManager.Converters/CollectionPropertyVisibilityConverter.cs b/Source/NETworkManager.Converters/CollectionPropertyVisibilityConverter.cs new file mode 100644 index 0000000000..6e069a87c7 --- /dev/null +++ b/Source/NETworkManager.Converters/CollectionPropertyVisibilityConverter.cs @@ -0,0 +1,90 @@ +using System; +using System.Collections; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Globalization; +using System.Reflection; +using System.Windows; +using System.Windows.Data; +using NETworkManager.Interfaces.ViewModels; + +namespace NETworkManager.Converters +{ + /// + /// A generic converter that checks a property of items in a collection. + /// If ANY item's property is considered "present" (not null, not empty), it returns Visible. + /// + /// The type of item in the collection. + public class CollectionPropertyVisibilityConverter : IValueConverter + { + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + // 1. Validate inputs + if (value is not IEnumerable collection) + return Visibility.Collapsed; + + if (parameter is not string propertyName || string.IsNullOrEmpty(propertyName)) + return Visibility.Collapsed; + + // 2. Get PropertyInfo via Reflection or cache. + if (!Cache.TryGetValue(propertyName, out var propertyInfo)) + { + propertyInfo = typeof(T).GetProperty(propertyName); + Cache.TryAdd(propertyName, propertyInfo); + } + + if (propertyInfo == null) + return Visibility.Collapsed; + + // 3. Iterate collection and check property + foreach (var item in collection) + { + if (item == null) continue; + + var propValue = propertyInfo.GetValue(item); + + if (HasContent(propValue)) + { + return Visibility.Visible; + } + } + + return Visibility.Collapsed; + } + + private static bool HasContent(object value) + { + if (value == null) return false; + + // Handle Strings + if (value is string str) + return !string.IsNullOrWhiteSpace(str); + + // Handle Collections (Lists, Arrays, etc.) + if (value is ICollection col) + return col.Count > 0; + + // Handle Generic Enumerable (fallback) + if (value is IEnumerable enumValue) + { + var enumerator = enumValue.GetEnumerator(); + var result = enumerator.MoveNext(); // Has at least one item + (enumerator as IDisposable)?.Dispose(); + return result; + } + + // Default: If it's an object and not null, it's "True" + return true; + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } + + private ConcurrentDictionary Cache { get; } = new(); + } + + // Concrete implementation for XAML usage + public class FirewallRuleViewModelVisibilityConverter : CollectionPropertyVisibilityConverter; +} diff --git a/Source/NETworkManager.Converters/EmptyToIntMaxValueConverter.cs b/Source/NETworkManager.Converters/EmptyToIntMaxValueConverter.cs new file mode 100644 index 0000000000..01db260ede --- /dev/null +++ b/Source/NETworkManager.Converters/EmptyToIntMaxValueConverter.cs @@ -0,0 +1,34 @@ +using System; +using System.Globalization; +using System.Windows.Data; + +namespace NETworkManager.Converters; + +public class EmptyToIntMaxValueConverter : IValueConverter +{ + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + const int fallback = int.MaxValue; + if (targetType == typeof(int)) + { + if (value is not string strValue) + return fallback; + if (string.IsNullOrWhiteSpace(strValue)) + return fallback; + if (!int.TryParse(strValue, out int parsedIntValue)) + return fallback; + return parsedIntValue; + } + + if (targetType != typeof(string)) + return null; + if (value is not int intValue) + return null; + return intValue is fallback ? string.Empty : intValue.ToString(); + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + { + return Convert(value, targetType, parameter, culture); + } +} \ No newline at end of file diff --git a/Source/NETworkManager.Converters/EnumToIntConverter.cs b/Source/NETworkManager.Converters/EnumToIntConverter.cs new file mode 100644 index 0000000000..1dbaa4e5dc --- /dev/null +++ b/Source/NETworkManager.Converters/EnumToIntConverter.cs @@ -0,0 +1,61 @@ +using System; +using System.Globalization; +using System.Windows.Data; + + +namespace NETworkManager.Converters; + +/// +/// A value converter that facilitates the conversion between enumeration values and their corresponding integer indices or string names. +/// +/// +/// This converter is designed to either: +/// - Convert an enumeration value to its integer index within the enumeration. +/// - Convert an enumeration value to its string representation. +/// When converting back, the same logic is applied to handle appropriate conversion. +/// +public class EnumToIntConverter : IValueConverter +{ + /// + /// Converts an Enum value to its corresponding integer index or string representation + /// based on the target type. If the target type is an Enum, the method attempts + /// to return the name of the enum value. If the provided value is an Enum, it + /// returns the integer index of the value within the Enum's definition. + /// + /// The value to convert. This can be an Enum instance or null. + /// The target type for the conversion. Typically either Enum or int. + /// An optional parameter for additional input, not used in this method. + /// The culture information for the conversion process. + /// + /// If the targetType is an Enum, a string representation of the Enum name is returned. + /// If the value is an Enum, the integer index of the Enum value is returned. + /// If neither condition is met, null is returned. + /// + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + if (targetType.IsEnum) + { + return value is null ? string.Empty : + Enum.GetName(targetType, value); + } + if (value is Enum enumVal) + { + return Array.IndexOf(Enum.GetValues(enumVal.GetType()), enumVal); + } + + return null; + } + + /// Converts a value back into its corresponding enumeration value or integer representation. + /// This method is typically used in two-way data bindings where an integer or string needs + /// to be converted back to the corresponding enum value. + /// The value to be converted back. This can be an integer or a string representation of an enumeration value. + /// The type of the target object, typically the type of the enumeration. + /// An optional parameter for the conversion. Not used in this implementation. + /// The culture information to use during the conversion process. + /// The enumeration value corresponding to the input value. + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + { + return Convert(value, targetType, parameter, culture); + } +} \ No newline at end of file diff --git a/Source/NETworkManager.Converters/EnumToStringConverter.cs b/Source/NETworkManager.Converters/EnumToStringConverter.cs new file mode 100644 index 0000000000..5eb327e300 --- /dev/null +++ b/Source/NETworkManager.Converters/EnumToStringConverter.cs @@ -0,0 +1,86 @@ +using System; +using System.Collections; +using System.Globalization; +using System.Resources; +using System.Windows.Data; +using NETworkManager.Localization.Resources; + +namespace NETworkManager.Converters; + +/// +/// A converter used to transform an value into its corresponding string, +/// using localization resources for string mapping. Also provides functionality to convert +/// localized or raw string values back to the corresponding value. +/// +/// +/// This class is not guaranteed to be thread-safe. It should be used only in the context of the application’s +/// UI thread or with proper synchronization for shared use cases. +/// +/// +public sealed class EnumToStringConverter : IValueConverter +{ + /// Converts an enumeration value to its localized string representation and vice versa. + /// + /// The value to convert. This can be an enumeration value or a string. + /// + /// + /// The expected type of the target binding, typically the type of the enumeration being converted. + /// + /// + /// An optional parameter to use in the conversion process, if applicable. + /// + /// + /// The culture information to use during the conversion, affecting localization. + /// + /// + /// A string representing the localized name of the enumeration, or the enumeration corresponding + /// to the given string. If the conversion fails, a fallback enumeration value or string is returned. + /// + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + if (value is not Enum enumValue) + { + string fallback = Enum.ToObject(targetType, 255) as string; + if (value is not string strVal) + return fallback; + fallback = strVal; + ResourceSet resourceSet = Strings.ResourceManager.GetResourceSet(Strings.Culture, + false, true); + string foundKey = null; + if (resourceSet is null) + return fallback; + foreach (DictionaryEntry item in resourceSet) + { + if (item.Value as string != strVal && item.Key as string != strVal) + continue; + foundKey = item.Key as string; + break; + } + + if (foundKey is null || !Enum.TryParse(targetType, foundKey, out var result)) + return fallback; + return result; + } + + var enumString = Enum.GetName(enumValue.GetType(), value); + if (enumString is null) + return string.Empty; + return Strings.ResourceManager.GetString(enumString, Strings.Culture) ?? enumString; + } + + /// + /// Converts a value back from a string representation to its corresponding enumeration value. + /// + /// The value to be converted back. Expected to be a string representation of an enumeration. + /// The type of the enumeration to which the value will be converted. + /// An optional parameter that can be used for custom conversion logic. + /// The culture information to use during the conversion process. + /// + /// Returns the enumeration value corresponding to the string representation. If the conversion fails, + /// a default value of the enumeration is returned. + /// + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + { + return Convert(value, targetType, parameter, culture); + } +} \ No newline at end of file diff --git a/Source/NETworkManager.Converters/FirewallRuleProgramConverter.cs b/Source/NETworkManager.Converters/FirewallRuleProgramConverter.cs new file mode 100644 index 0000000000..accec2a1d1 --- /dev/null +++ b/Source/NETworkManager.Converters/FirewallRuleProgramConverter.cs @@ -0,0 +1,42 @@ +using System; +using System.Globalization; +using System.IO; +using System.Windows.Data; +using NETworkManager.Models.Firewall; + +namespace NETworkManager.Converters; + +/// +/// Convert a program reference to a string and vice versa. +/// +public class FirewallRuleProgramConverter : IValueConverter +{ + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + if (targetType == typeof(FirewallRuleProgram)) + { + if (value is not string program) + return null; + if (string.IsNullOrWhiteSpace(program)) + return null; + try + { + var exe = new FirewallRuleProgram(program); + return exe; + } + catch (ArgumentNullException) + { + return null; + } + } + + if (targetType != typeof(string)) + return null; + return value is not FirewallRuleProgram prog ? null : prog.ToString(); + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + { + return Convert(value, targetType, parameter, culture); + } +} \ No newline at end of file diff --git a/Source/NETworkManager.Converters/IntZeroToFalseConverter.cs b/Source/NETworkManager.Converters/IntZeroToFalseConverter.cs new file mode 100644 index 0000000000..592da208fe --- /dev/null +++ b/Source/NETworkManager.Converters/IntZeroToFalseConverter.cs @@ -0,0 +1,26 @@ +using System; +using System.Globalization; +using System.Windows.Data; + +namespace NETworkManager.Converters; +/// +/// Convert a value of 0 to the boolean false. +/// +public class IntZeroToFalseConverter : IValueConverter +{ + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + return value switch + { + null => false, + int i => i is not 0, + bool boolean => boolean ? 1 : 0, + _ => false + }; + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + { + return Convert(value, targetType, parameter, culture); + } +} \ No newline at end of file diff --git a/Source/NETworkManager.Converters/NETworkManager.Converters.csproj b/Source/NETworkManager.Converters/NETworkManager.Converters.csproj index 0457284fc5..8c721cf425 100644 --- a/Source/NETworkManager.Converters/NETworkManager.Converters.csproj +++ b/Source/NETworkManager.Converters/NETworkManager.Converters.csproj @@ -20,6 +20,7 @@ + diff --git a/Source/NETworkManager.Converters/PortRangeToPortSpecificationConverter.cs b/Source/NETworkManager.Converters/PortRangeToPortSpecificationConverter.cs new file mode 100644 index 0000000000..fd9e954def --- /dev/null +++ b/Source/NETworkManager.Converters/PortRangeToPortSpecificationConverter.cs @@ -0,0 +1,61 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Windows.Data; +using NETworkManager.Models.Firewall; +using NETworkManager.Settings; + +namespace NETworkManager.Converters; + +/// +/// Converts a port range or port to an instance of FirewallPortSpecification. +/// +public class PortRangeToPortSpecificationConverter : IValueConverter +{ + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + char portDelimiter = ';'; + if (targetType == typeof(List)) + { + if (value is not string input) + return null; + const char rangeDelimiter = '-'; + List resultList = []; + var portList = input.Split(portDelimiter); + foreach (var port in portList) + { + var trimmedPort = port.Trim(); + if (trimmedPort.Contains(rangeDelimiter)) + { + var portRange = trimmedPort.Split(rangeDelimiter); + if (!int.TryParse(portRange[0], out var startPort)) + return null; + if (!int.TryParse(portRange[1], out var endPort)) + return null; + resultList.Add(new FirewallPortSpecification(startPort, endPort)); + } + else + { + if (!int.TryParse(port, out var portNumber)) + return null; + resultList.Add(new FirewallPortSpecification(portNumber)); + } + } + return resultList; + } + + if (targetType != typeof(string)) + return null; + if (value is not List portSpecs) + return string.Empty; + string result = portSpecs + .Aggregate("", (current, portSpecification) => current + $"{portSpecification}{portDelimiter} "); + return result.Length > 0 ? result[..^2] : string.Empty; + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + { + return Convert(value, targetType, parameter, culture); + } +} \ No newline at end of file diff --git a/Source/NETworkManager.Converters/SizeFactorConverter.cs b/Source/NETworkManager.Converters/SizeFactorConverter.cs new file mode 100644 index 0000000000..e2a23b1e5a --- /dev/null +++ b/Source/NETworkManager.Converters/SizeFactorConverter.cs @@ -0,0 +1,32 @@ +using System; +using System.Globalization; +using System.Windows.Data; + +namespace NETworkManager.Converters; +/// +/// Multiplies a value by a factor given with the parameter. +/// +/// +/// Useful for setting sizes relative to window size. +/// +public class SizeFactorConverter : IValueConverter +{ + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + try + { + double theValue = System.Convert.ToDouble(value, CultureInfo.InvariantCulture); + double factor = System.Convert.ToDouble(parameter, CultureInfo.InvariantCulture); + return theValue * factor; + } + catch (Exception e) when (e is FormatException or InvalidCastException or OverflowException) + { + return 0.0; + } + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } +} \ No newline at end of file diff --git a/Source/NETworkManager.Interfaces/NETworkManager.Interfaces.csproj b/Source/NETworkManager.Interfaces/NETworkManager.Interfaces.csproj new file mode 100644 index 0000000000..422d2aaa6d --- /dev/null +++ b/Source/NETworkManager.Interfaces/NETworkManager.Interfaces.csproj @@ -0,0 +1,14 @@ + + + + net10.0-windows10.0.22621.0 + enable + enable + NETworkManager.Interfaces + + + + + + + diff --git a/Source/NETworkManager.Interfaces/ViewModels/IFirewallRuleViewModel.cs b/Source/NETworkManager.Interfaces/ViewModels/IFirewallRuleViewModel.cs new file mode 100644 index 0000000000..0590f8b277 --- /dev/null +++ b/Source/NETworkManager.Interfaces/ViewModels/IFirewallRuleViewModel.cs @@ -0,0 +1,30 @@ +using NETworkManager.Models.Firewall; + +namespace NETworkManager.Interfaces.ViewModels; + +/// +/// Interface to allow converters and validators access to the firewall rule view model, +/// in this case validating with the other network profile checkboxes. +/// +public interface IFirewallRuleViewModel +{ + public bool NetworkProfileDomain { get; } + + public bool NetworkProfilePrivate { get; } + + public bool NetworkProfilePublic { get; } + + public List? LocalPorts { get; } + + public List? RemotePorts { get; } + + public FirewallRuleProgram? Program { get; } + + public int MaxLengthName { get; } + + public string? UserDefinedName { get; } + + public bool NameHasError { get; set; } + + public bool HasError { get; } +} \ No newline at end of file diff --git a/Source/NETworkManager.Interfaces/ViewModels/IFirewallViewModel.cs b/Source/NETworkManager.Interfaces/ViewModels/IFirewallViewModel.cs new file mode 100644 index 0000000000..8daca21889 --- /dev/null +++ b/Source/NETworkManager.Interfaces/ViewModels/IFirewallViewModel.cs @@ -0,0 +1,22 @@ +using System.Collections.ObjectModel; +using System.Windows.Input; + +namespace NETworkManager.Interfaces.ViewModels; + +public interface IFirewallViewModel +{ + public ObservableCollection FirewallRulesInterface { get; } + + public ICommand ExpandAllProfileGroupsCommand { get; } + + public ICommand CollapseAllProfileGroupsCommand { get; } + + public int MaxLengthHistory { get; } + + public static IFirewallViewModel? Instance { get; set; } + + public static void SetInstance(IFirewallViewModel? viewModel) + { + Instance = viewModel; + } +} \ No newline at end of file diff --git a/Source/NETworkManager.Interfaces/ViewModels/IProfileViewModel.cs b/Source/NETworkManager.Interfaces/ViewModels/IProfileViewModel.cs new file mode 100644 index 0000000000..5a9f24ccca --- /dev/null +++ b/Source/NETworkManager.Interfaces/ViewModels/IProfileViewModel.cs @@ -0,0 +1,15 @@ +namespace NETworkManager.Interfaces.ViewModels; + +// ReSharper disable InconsistentNaming +public interface IProfileViewModel +{ + #region General + public string Name { get; } + #endregion + + #region Firewall + public bool Firewall_Enabled { get; set; } + + public IFirewallViewModel Firewall_IViewModel { get; set; } + #endregion +} \ No newline at end of file diff --git a/Source/NETworkManager.Localization/Resources/Strings.Designer.cs b/Source/NETworkManager.Localization/Resources/Strings.Designer.cs index 5264d10300..4dd707481e 100644 --- a/Source/NETworkManager.Localization/Resources/Strings.Designer.cs +++ b/Source/NETworkManager.Localization/Resources/Strings.Designer.cs @@ -6201,6 +6201,15 @@ public static string NetworkPacketsCaptureAdminMessage { } } + /// + /// Looks up a localized string similar to Network profile. + /// + public static string NetworkProfile { + get { + return ResourceManager.GetString("NetworkProfile", resourceCulture); + } + } + /// /// Looks up a localized string similar to Networks. /// diff --git a/Source/NETworkManager.Localization/Resources/Strings.resx b/Source/NETworkManager.Localization/Resources/Strings.resx index 4e6ebe5412..3789592b0d 100644 --- a/Source/NETworkManager.Localization/Resources/Strings.resx +++ b/Source/NETworkManager.Localization/Resources/Strings.resx @@ -3945,6 +3945,9 @@ If you click Cancel, the profile file will remain unencrypted. A restart is required to apply changes such as language settings. + + Network profile + Could not parse or resolve any of the specified DNS servers. diff --git a/Source/NETworkManager.Models/Firewall/Firewall.cs b/Source/NETworkManager.Models/Firewall/Firewall.cs new file mode 100644 index 0000000000..2214745296 --- /dev/null +++ b/Source/NETworkManager.Models/Firewall/Firewall.cs @@ -0,0 +1,160 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using log4net; +using NETworkManager.Models.Network; +using NETworkManager.Utilities; + +namespace NETworkManager.Models.Firewall; + +/// +/// Represents a firewall configuration and management class that provides functionalities +/// for applying and managing firewall rules based on a specified profile. +/// +public class Firewall +{ + #region Variables + + /// + /// The Logger. + /// + private readonly ILog Log = LogManager.GetLogger(typeof(Firewall)); + + #endregion + + #region Methods + + /// + /// Applies the firewall rules specified in the list to the system's firewall settings. + /// + /// A list of objects representing the firewall rules to be applied. + /// Returns true if the rules were successfully applied; otherwise, false. + private bool ApplyRules(List rules) + { + // If there are no rules to apply, return true as there is nothing to do. + if (rules.Count is 0) + return true; + + // Start by clearing all existing rules for the current profile to ensure a clean state. + var sb = new StringBuilder(GetClearAllRulesCommand()); + sb.Append("; "); + + foreach (var rule in rules) + { + try + { + var ruleSb = new StringBuilder($"New-NetFirewallRule -DisplayName '{SanitizeStringArguments(rule.Name)}'"); + + if (!string.IsNullOrEmpty(rule.Description)) + ruleSb.Append($" -Description '{SanitizeStringArguments(rule.Description)}'"); + + ruleSb.Append($" -Direction {Enum.GetName(rule.Direction)}"); + + if (rule.LocalPorts.Count > 0 && rule.Protocol is FirewallProtocol.TCP or FirewallProtocol.UDP) + ruleSb.Append($" -LocalPort {FirewallRule.PortsToString(rule.LocalPorts, ',', false)}"); + + if (rule.RemotePorts.Count > 0 && rule.Protocol is FirewallProtocol.TCP or FirewallProtocol.UDP) + ruleSb.Append($" -RemotePort {FirewallRule.PortsToString(rule.RemotePorts, ',', false)}"); + + ruleSb.Append(rule.Protocol is FirewallProtocol.Any + ? " -Protocol Any" + : $" -Protocol {(int)rule.Protocol}"); + + if (!string.IsNullOrWhiteSpace(rule.Program?.Name)) + { + if (File.Exists(rule.Program.Name)) + ruleSb.Append($" -Program '{SanitizeStringArguments(rule.Program.Name)}'"); + else + { + Log.Warn($"Program path '{rule.Program.Name}' in rule '{rule.Name}' does not exist. Skipping rule."); + continue; + } + } + + if (rule.InterfaceType is not FirewallInterfaceType.Any) + ruleSb.Append($" -InterfaceType {Enum.GetName(rule.InterfaceType)}"); + + // If not all network profiles are enabled, specify the ones that are. + if (!rule.NetworkProfiles.All(x => x)) + { + var profiles = Enumerable.Range(0, rule.NetworkProfiles.Length) + .Where(i => rule.NetworkProfiles[i]) + .Select(i => Enum.GetName(typeof(NetworkProfiles), i)); + + ruleSb.Append($" -Profile {string.Join(',', profiles)}"); + } + + ruleSb.Append($" -Action {Enum.GetName(rule.Action)}; "); + + sb.Append(ruleSb); + } + catch (ArgumentException ex) + { + Log.Warn($"Failed to build firewall rule '{rule.Name}': {ex.Message}"); + } + } + + // Remove the trailing "; " from the last command. + sb.Length -= 2; + + var command = sb.ToString(); + + Log.Debug($"Applying rules:{Environment.NewLine}{command}"); + + try + { + PowerShellHelper.ExecuteCommand(command, true); + return true; + } + catch (Exception) + { + return false; + } + } + + /// + /// Removes all existing firewall rules associated with the specified profile. + /// + /// + /// This method clears all configured firewall rules for the current profile. + /// It is useful for resetting the rules to a clean state. Errors or exceptions + /// during the operation are logged using the configured logging mechanism. + /// + public static void ClearAllRules() + { + PowerShellHelper.ExecuteCommand(GetClearAllRulesCommand(), true); + } + + /// + /// Generates a command string that removes all Windows Firewall rules with a display name starting with 'NwM_'. + /// + /// A command string that can be executed in PowerShell to remove the specified firewall rules. + private static string GetClearAllRulesCommand() + { + return "Remove-NetFirewallRule -DisplayName 'NwM_*'"; + } + + /// + /// Sanitizes string arguments by replacing single quotes with double single quotes to prevent issues in PowerShell command execution. + /// + /// The input string to be sanitized. + /// A sanitized string with single quotes escaped. + private static string SanitizeStringArguments(string value) + { + return value.Replace("'", "''"); + } + + /// + /// Applies firewall rules asynchronously by invoking the ApplyRules method in a separate task. + /// + /// A list of firewall rules to apply. + /// A task representing the asynchronous operation. The task result contains a boolean indicating whether the rules were successfully applied. + public async Task ApplyRulesAsync(List rules) + { + await Task.Run(() => ApplyRules(rules)); + } + #endregion +} diff --git a/Source/NETworkManager.Models/Firewall/FirewallInterfaceType.cs b/Source/NETworkManager.Models/Firewall/FirewallInterfaceType.cs new file mode 100644 index 0000000000..438291b6d9 --- /dev/null +++ b/Source/NETworkManager.Models/Firewall/FirewallInterfaceType.cs @@ -0,0 +1,27 @@ +namespace NETworkManager.Models.Firewall; + +/// +/// Defines the types of network interfaces that can be used in firewall rules. +/// +public enum FirewallInterfaceType +{ + /// + /// Any interface type. + /// + Any = -1, + + /// + /// Wired interface types, e.g. Ethernet. + /// + Wired, + + /// + /// Wireless interface types, e.g. Wi-Fi. + /// + Wireless, + + /// + /// Remote interface types, e.g. VPN, L2TP, OpenVPN, etc. + /// + RemoteAccess +} diff --git a/Source/NETworkManager.Models/Firewall/FirewallPortLocation.cs b/Source/NETworkManager.Models/Firewall/FirewallPortLocation.cs new file mode 100644 index 0000000000..549cb35744 --- /dev/null +++ b/Source/NETworkManager.Models/Firewall/FirewallPortLocation.cs @@ -0,0 +1,17 @@ +namespace NETworkManager.Models.Firewall; + +/// +/// Ports of local host or remote host. +/// +public enum FirewallPortLocation +{ + /// + /// Ports of local host. + /// + LocalPorts, + + /// + /// Ports of remote host. + /// + RemotePorts +} \ No newline at end of file diff --git a/Source/NETworkManager.Models/Firewall/FirewallPortSpecification.cs b/Source/NETworkManager.Models/Firewall/FirewallPortSpecification.cs new file mode 100644 index 0000000000..ae26b65d61 --- /dev/null +++ b/Source/NETworkManager.Models/Firewall/FirewallPortSpecification.cs @@ -0,0 +1,66 @@ +// ReSharper disable MemberCanBePrivate.Global +// Needed for serialization. +namespace NETworkManager.Models.Firewall; + +/// +/// Represents a specification for defining and validating firewall ports. +/// +/// +/// This class is used to encapsulate rules and configurations for +/// managing firewall port restrictions or allowances. It provides +/// properties and methods to define a range of acceptable ports or +/// individual port specifications. +/// +public class FirewallPortSpecification +{ + /// + /// Gets or sets the start point or initial value of a process, range, or operation. + /// + /// + /// The Start property typically represents the beginning state or position for sequential + /// processing or iteration. The exact usage of this property may vary depending on the context of + /// the class or object it belongs to. + /// + public int Start { get; set; } + + /// + /// Gets or sets the endpoint or final state of a process, range, or operation. + /// + /// + /// This property typically represents the termination position, time, or value + /// in a sequence, operation, or any bounded context. Its specific meaning may vary + /// depending on the context in which it is used. + /// + public int End { get; set; } + + /// + /// For serializing. + /// + public FirewallPortSpecification() + { + Start = -1; + End = -1; + } + + /// + /// Represents the specification for a firewall port, detailing its configuration + /// and rules for inbound or outbound network traffic. + /// + public FirewallPortSpecification(int start, int end = -1) + { + Start = start; + End = end; + } + + /// + /// Returns a string that represents the current object. + /// + /// A string that represents the current instance of the object. + public override string ToString() + { + if (Start is 0) + return string.Empty; + + return End is -1 or 0 ? $"{Start}" : $"{Start}-{End}"; + } +} diff --git a/Source/NETworkManager.Models/Firewall/FirewallProtocol.cs b/Source/NETworkManager.Models/Firewall/FirewallProtocol.cs new file mode 100644 index 0000000000..da54f95e6e --- /dev/null +++ b/Source/NETworkManager.Models/Firewall/FirewallProtocol.cs @@ -0,0 +1,122 @@ +// ReSharper disable InconsistentNaming +namespace NETworkManager.Models.Firewall; + +/// +/// Specifies the network protocols supported by the firewall configuration. +/// Each protocol is represented by its respective protocol number as defined in +/// the Internet Assigned Numbers Authority (IANA) protocol registry. +/// This enumeration is used to identify traffic based on its protocol type +/// for filtering or access control purposes in the firewall. +/// +public enum FirewallProtocol +{ + /// + /// Denotes the Transmission Control Protocol (TCP) used in firewall configurations. + /// TCP is a fundamental protocol within the Internet Protocol Suite, ensuring reliable + /// communication by delivering a stream of data packets in sequence with error checking + /// between networked devices. + /// + TCP = 6, + + /// + /// Represents the User Datagram Protocol (UDP) in the context of firewall rules. + /// UDP is a connectionless protocol within the Internet Protocol (IP) suite that + /// allows for minimal latency by transmitting datagrams without guaranteeing delivery, + /// order, or error recovery. + /// + UDP = 17, + + /// + /// Represents the Internet Control Message Protocol (ICMPv4) in the context of firewall rules. + /// ICMP is used by network devices, such as routers, to send error messages and operational + /// information, indicating issues like unreachable network destinations. + /// + ICMPv4 = 1, + + /// + /// Represents the Internet Control Message Protocol for IPv6 (ICMPv6) in the context of firewall rules. + /// ICMPv6 is a supporting protocol in the Internet Protocol version 6 (IPv6) suite and is used for + /// diagnostic and error-reporting purposes, as well as for functions such as Neighbor Discovery Protocol (NDP). + /// + ICMPv6 = 58, + + /// + /// Represents the IPv6 Hop-by-Hop Option (HOPOPT) protocol in the context of firewall rules. + /// HOPOPT is a special protocol used in IPv6 for carrying optional information that must be examined + /// by each node along the packet's delivery path. + /// + HOPOPT = 0, + + /// + /// Represents the Generic Routing Encapsulation (GRE) protocol in the context of firewall rules. + /// GRE is a tunneling protocol developed to encapsulate a wide variety of network layer protocols + /// inside virtual point-to-point links. It is commonly used in creating VPNs and enabling the + /// transport of multicast traffic and non-IP protocols across IP networks. + /// + GRE = 47, + + /// + /// Represents the Internet Protocol Version 6 (IPv6) in the context of firewall rules. + /// IPv6 is the most recent version of the Internet Protocol (IP) and provides identification + /// and location addressing for devices across networks, enabling communication over the internet. + /// + IPv6 = 41, + + /// + /// Represents the IPv6-Route protocol in the context of firewall rules. + /// IPv6-Route is used for routing header information in IPv6 packets, which + /// specifies the list of one or more intermediate nodes a packet should pass + /// through before reaching its destination. + /// + IPv6_Route = 43, + + /// + /// Represents the IPv6 Fragmentation Header (IPv6_Frag) in the context of firewall rules. + /// The IPv6 Fragmentation Header is used to support fragmentation and reassembly of + /// packets in IPv6 networks. It facilitates handling packets that are too large to + /// fit in the path MTU (Maximum Transmission Unit) of the network segment. + /// + IPv6_Frag = 44, + + /// + /// Represents the IPv6 No Next Header protocol in the context of firewall rules. + /// This protocol indicates that there is no next header following the current header in the IPv6 packet. + /// It is primarily used in cases where the payload does not require a specific transport protocol header. + /// + IPv6_NoNxt = 59, + + /// + /// Represents the IPv6 Options (IPv6_Opts) protocol in the context of firewall rules. + /// IPv6 Options is a part of the IPv6 suite used for carrying optional internet-layer information + /// and additional headers for specific purposes, providing extensibility in IPv6 communication. + /// + IPv6_Opts = 60, + + /// + /// Represents the Virtual Router Redundancy Protocol (VRRP) in the context of firewall rules. + /// VRRP is a network protocol that provides automatic assignment of available routers to + /// participating hosts, ensuring redundancy and high availability of router services. + /// + VRRP = 112, + + /// + /// Represents the Pragmatic General Multicast (PGM) protocol in the context of firewall rules. + /// PGM is a reliable multicast transport protocol that ensures ordered, duplicate-free, + /// and scalable delivery of data in multicast-enabled networks. + /// + PGM = 113, + + /// + /// Represents the Layer 2 Tunneling Protocol (L2TP) in the context of firewall rules. + /// L2TP is a tunneling protocol used to support virtual private networks (VPNs) or + /// as part of the delivery of services by Internet Service Providers (ISPs). + /// + L2TP = 115, + + /// + /// Represents a wildcard protocol option to match any protocol in the context of firewall rules. + /// The "Any" value can be used to specify that the rule applies to all network protocols + /// without restriction or specificity. + /// + Any = 255 +} \ No newline at end of file diff --git a/Source/NETworkManager.Models/Firewall/FirewallRule.cs b/Source/NETworkManager.Models/Firewall/FirewallRule.cs new file mode 100644 index 0000000000..c9273fba28 --- /dev/null +++ b/Source/NETworkManager.Models/Firewall/FirewallRule.cs @@ -0,0 +1,140 @@ +using System.Collections.Generic; +using System.Text; + +namespace NETworkManager.Models.Firewall; + +/// +/// Represents a security rule used within a firewall to control network traffic based on +/// specific conditions such as IP addresses, ports, and protocols. +/// +public class FirewallRule +{ + #region Variables + + /// + /// Represents the name associated with the object. + /// + /// + /// This property is used to identify the object with a descriptive, human-readable name. + /// The value of this property is typically a string and can be used for display purposes + /// or as an identifier in various contexts. + /// + public string Name { get; set; } + + /// + /// Represents a text-based explanation or information associated with an object. + /// + public string Description { get; set; } + + /// + /// Represents the communication protocol to be used in the network configuration. + /// + public FirewallProtocol Protocol { get; set; } = FirewallProtocol.TCP; + + /// + /// Defines the direction of traffic impacted by the rule or configuration. + /// + public FirewallRuleDirection Direction { get; set; } = FirewallRuleDirection.Inbound; + + /// + /// Represents the entry point and core execution logic for an application. + /// + public FirewallRuleProgram Program { get; set; } + + /// + /// Defines the local ports associated with the firewall rule. + /// + public List LocalPorts + { + get; + set + { + if (value is null) + { + field = []; + return; + } + field = value; + } + } = []; + + /// + /// Defines the range of remote ports associated with the firewall rule. + /// + public List RemotePorts + { + get; + set + { + if (value is null) + { + field = []; + return; + } + field = value; + } + } = []; + + /// + /// Network profiles in order Domain, Private, Public. + /// + public bool[] NetworkProfiles + { + get; + set + { + if (value?.Length is not 3) + return; + field = value; + } + } = new bool[3]; + + public FirewallInterfaceType InterfaceType { get; set; } = FirewallInterfaceType.Any; + + /// + /// Represents the operation to be performed or executed. + /// + public FirewallRuleAction Action { get; set; } = FirewallRuleAction.Block; + + #endregion + + #region Constructors + + /// + /// Represents a rule within the firewall configuration. + /// Used to control network traffic based on specified criteria, such as + /// ports, protocols, the interface type, network profiles, and the used programs. + /// + public FirewallRule() + { + + } + #endregion + + #region Methods + + /// + /// Converts a collection of port numbers to a single, comma-separated string representation. + /// + /// A collection of integers representing port numbers. + /// Separator character to use + /// Separate entries with a space. + /// A separated string containing all the port numbers from the input collection. + public static string PortsToString(List ports, char separator = ';', bool spacing = true) + { + if (ports.Count is 0) + return string.Empty; + + var whitespace = spacing ? " " : string.Empty; + + var builder = new StringBuilder(); + + foreach (var port in ports) + builder.Append($"{port}{separator}{whitespace}"); + + var offset = spacing ? 2 : 1; + + return builder.ToString()[..^offset]; + } + #endregion +} \ No newline at end of file diff --git a/Source/NETworkManager.Models/Firewall/FirewallRuleAction.cs b/Source/NETworkManager.Models/Firewall/FirewallRuleAction.cs new file mode 100644 index 0000000000..eaaa63cfc4 --- /dev/null +++ b/Source/NETworkManager.Models/Firewall/FirewallRuleAction.cs @@ -0,0 +1,20 @@ +namespace NETworkManager.Models.Firewall; + +/// +/// Represents the action, if the rule filter applies. +/// +public enum FirewallRuleAction +{ + /// + /// Represents the action to block network traffic in a firewall rule. + /// + Block, + + /// + /// Represents the action to allow network traffic. + /// + Allow, + + // Unsupported for now + //AllowIPsec +} \ No newline at end of file diff --git a/Source/NETworkManager.Models/Firewall/FirewallRuleDirection.cs b/Source/NETworkManager.Models/Firewall/FirewallRuleDirection.cs new file mode 100644 index 0000000000..ddd72c116f --- /dev/null +++ b/Source/NETworkManager.Models/Firewall/FirewallRuleDirection.cs @@ -0,0 +1,18 @@ +namespace NETworkManager.Models.Firewall; + +/// +/// Represents a firewall rule direction that allows or processes network traffic +/// incoming to the system or network from external sources. +/// +public enum FirewallRuleDirection +{ + /// + /// Inbound packets. + /// + Inbound, + + /// + /// Outbound packets. + /// + Outbound +} \ No newline at end of file diff --git a/Source/NETworkManager.Models/Firewall/FirewallRuleProgram.cs b/Source/NETworkManager.Models/Firewall/FirewallRuleProgram.cs new file mode 100644 index 0000000000..12133b1344 --- /dev/null +++ b/Source/NETworkManager.Models/Firewall/FirewallRuleProgram.cs @@ -0,0 +1,100 @@ +using System; +using System.IO; +using System.Text.Json.Serialization; +using System.Xml.Serialization; + +namespace NETworkManager.Models.Firewall; + +/// +/// Represents a program associated with a firewall rule. +/// +public class FirewallRuleProgram : ICloneable +{ + #region Variables + /// + /// Program to apply rule to. + /// + [JsonIgnore] + [XmlIgnore] + public FileInfo Executable { + private set; + get + { + if (field is null && Name is not null) + field = new FileInfo(Name); + + return field; + } + } + + /// + /// Represents the name associated with the object. + /// + public string Name + { + get; + // Public modifier required for deserialization + // ReSharper disable once MemberCanBePrivate.Global + // ReSharper disable once PropertyCanBeMadeInitOnly.Global + set + { + if (string.IsNullOrWhiteSpace(value)) + return; + + Executable = new FileInfo(value); + field = value; + } + } + #endregion + + #region Constructor + /// + /// Public empty constructor is required for de-/serialization. + /// + // ReSharper disable once MemberCanBePrivate.Global + public FirewallRuleProgram() + { + } + + /// + /// Construct program reference for firewall rule. + /// + /// + public FirewallRuleProgram(string pathToExe) + { + ArgumentNullException.ThrowIfNull(pathToExe); + var exe = new FileInfo(pathToExe); + Executable = exe; + Name = exe.FullName; + } + #endregion + + #region Methods + /// + /// Convert the full file path to string. + /// + /// + public override string ToString() + { + return Executable?.FullName; + } + + /// + /// Clone instance. + /// + /// An instance clone. + public object Clone() + { + try + { + return new FirewallRuleProgram(Executable?.FullName); + } + catch (ArgumentNullException) + { + return new FirewallRuleProgram(); + } + + } + + #endregion +} diff --git a/Source/NETworkManager.Models/Network/NetworkProfiles.cs b/Source/NETworkManager.Models/Network/NetworkProfiles.cs new file mode 100644 index 0000000000..4d7d52d203 --- /dev/null +++ b/Source/NETworkManager.Models/Network/NetworkProfiles.cs @@ -0,0 +1,27 @@ +namespace NETworkManager.Models.Network; + +/// +/// Defines the network profile detected by Windows. +/// +public enum NetworkProfiles +{ + /// + /// Network profile is not configured. + /// + NotConfigured = -1, + + /// + /// Network has an Active Directory (AD) controller and you are authenticated. + /// + Domain, + + /// + /// Network is private. Firewall will allow most connections. + /// + Private, + + /// + /// Network is public. Firewall will block most connections. + /// + Public +} \ No newline at end of file diff --git a/Source/NETworkManager.Utilities.WPF/NETworkManager.Utilities.WPF.csproj b/Source/NETworkManager.Utilities.WPF/NETworkManager.Utilities.WPF.csproj index 06652b0713..98eaffd891 100644 --- a/Source/NETworkManager.Utilities.WPF/NETworkManager.Utilities.WPF.csproj +++ b/Source/NETworkManager.Utilities.WPF/NETworkManager.Utilities.WPF.csproj @@ -18,4 +18,7 @@ + + + diff --git a/Source/NETworkManager.Utilities.WPF/TypedBindingProxies/FirewallViewModelProxy.cs b/Source/NETworkManager.Utilities.WPF/TypedBindingProxies/FirewallViewModelProxy.cs new file mode 100644 index 0000000000..6e99fb16bf --- /dev/null +++ b/Source/NETworkManager.Utilities.WPF/TypedBindingProxies/FirewallViewModelProxy.cs @@ -0,0 +1,36 @@ +using System.Windows; +using NETworkManager.Interfaces.ViewModels; + +namespace NETworkManager.Utilities.WPF.TypedBindingProxies; + +/// +/// Binding proxy for s. +/// +public class FirewallViewModelProxy : Freezable +{ + /// + /// Dependency property used to hold a generic object. + /// This property allows data binding scenarios where a proxy + /// is required to transfer data between binding contexts. + /// + public static readonly DependencyProperty DataProperty = + DependencyProperty.Register(nameof(Data), typeof(IFirewallViewModel), typeof(FirewallViewModelProxy)); + + /// + /// Gets or sets the data object used for binding in WPF applications. + /// + public IFirewallViewModel Data + { + get => (IFirewallViewModel)GetValue(DataProperty); + set => SetValue(DataProperty, value); + } + + /// Creates a new instance of the BindingProxy class. + /// + /// A new instance of the BindingProxy class. + /// + protected override Freezable CreateInstanceCore() + { + return new FirewallViewModelProxy(); + } +} \ No newline at end of file diff --git a/Source/NETworkManager.Utilities.WPF/TypedBindingProxies/FrameworkElementProxy.cs b/Source/NETworkManager.Utilities.WPF/TypedBindingProxies/FrameworkElementProxy.cs new file mode 100644 index 0000000000..0c1d0a1947 --- /dev/null +++ b/Source/NETworkManager.Utilities.WPF/TypedBindingProxies/FrameworkElementProxy.cs @@ -0,0 +1,35 @@ +using System.Windows; + +namespace NETworkManager.Utilities.WPF.TypedBindingProxies; + +/// +/// Binding proxy for s. +/// +public class FrameworkElementProxy : Freezable +{ + /// + /// Dependency property used to hold a generic object. + /// This property allows data binding scenarios where a proxy + /// is required to transfer data between binding contexts. + /// + public static readonly DependencyProperty DataProperty = + DependencyProperty.Register(nameof(Data), typeof(FrameworkElement), typeof(FrameworkElementProxy)); + + /// + /// Gets or sets the data object used for binding in WPF applications. + /// + public FrameworkElement Data + { + get => (FrameworkElement)GetValue(DataProperty); + set => SetValue(DataProperty, value); + } + + /// Creates a new instance of the BindingProxy class. + /// + /// A new instance of the BindingProxy class. + /// + protected override Freezable CreateInstanceCore() + { + return new FrameworkElementProxy(); + } +} \ No newline at end of file diff --git a/Source/NETworkManager.Utilities.WPF/TypedBindingProxies/ProfileViewModelProxy.cs b/Source/NETworkManager.Utilities.WPF/TypedBindingProxies/ProfileViewModelProxy.cs new file mode 100644 index 0000000000..7099c04c0d --- /dev/null +++ b/Source/NETworkManager.Utilities.WPF/TypedBindingProxies/ProfileViewModelProxy.cs @@ -0,0 +1,36 @@ +using System.Windows; +using NETworkManager.Interfaces.ViewModels; + +namespace NETworkManager.Utilities.WPF.TypedBindingProxies; + +/// +/// Binding proxy for s. +/// +public class ProfileViewModelProxy : Freezable +{ + /// + /// Dependency property used to hold a generic object. + /// This property allows data binding scenarios where a proxy + /// is required to transfer data between binding contexts. + /// + public static readonly DependencyProperty DataProperty = + DependencyProperty.Register(nameof(Data), typeof(IProfileViewModel), typeof(ProfileViewModelProxy)); + + /// + /// Gets or sets the data object used for binding in WPF applications. + /// + public IProfileViewModel Data + { + get => (IProfileViewModel)GetValue(DataProperty); + set => SetValue(DataProperty, value); + } + + /// Creates a new instance of the BindingProxy class. + /// + /// A new instance of the BindingProxy class. + /// + protected override Freezable CreateInstanceCore() + { + return new ProfileViewModelProxy(); + } +} \ No newline at end of file diff --git a/Source/NETworkManager.Utilities.WPF/ValidationHelper.cs b/Source/NETworkManager.Utilities.WPF/ValidationHelper.cs new file mode 100644 index 0000000000..4910a4f715 --- /dev/null +++ b/Source/NETworkManager.Utilities.WPF/ValidationHelper.cs @@ -0,0 +1,68 @@ +using System; +using System.ComponentModel; +using System.Windows; +using System.Windows.Controls; + +namespace NETworkManager.Utilities.WPF; + +/// +/// This allows propagating validation errors to a ViewModel allowing style changes bound +/// to the view, e.g., red border on a DataGridRow. +/// +/// +/// Class is AI-generated. See FirewallRuleGrid control for usage. +/// +public static class ValidationHelper +{ + // This property acts as a bridge. We can write to it from a Style Trigger, + // and it can push that value to the ViewModel via OneWayToSource binding. + public static readonly DependencyProperty HasErrorProperty = DependencyProperty.RegisterAttached( + "HasError", + typeof(bool), + typeof(ValidationHelper), + new FrameworkPropertyMetadata(false, FrameworkPropertyMetadataOptions.BindsTwoWayByDefault)); + + public static void SetHasError(DependencyObject element, bool value) => element.SetValue(HasErrorProperty, value); + + public static bool GetHasError(DependencyObject element) => (bool)element.GetValue(HasErrorProperty); + + // Observe validation errors directly. Required unless NotifyOnValidationErrors is set. + public static readonly DependencyProperty ObserveValidationProperty = DependencyProperty.RegisterAttached( + "ObserveValidation", + typeof(bool), + typeof(ValidationHelper), + new PropertyMetadata(false, OnObserveValidationChanged)); + + public static void SetObserveValidation(DependencyObject element, bool value) => element.SetValue(ObserveValidationProperty, value); + + public static bool GetObserveValidation(DependencyObject element) => (bool)element.GetValue(ObserveValidationProperty); + + private static void OnObserveValidationChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d is not FrameworkElement element) + return; + + if ((bool)e.NewValue) + { + // Listen to the Validation.HasError property changes directly + var descriptor = DependencyPropertyDescriptor.FromProperty(Validation.HasErrorProperty, typeof(FrameworkElement)); + descriptor.AddValueChanged(element, OnValidationHasErrorChanged); + + // Initial sync + SetHasError(element, Validation.GetHasError(element)); + } + else + { + var descriptor = DependencyPropertyDescriptor.FromProperty(Validation.HasErrorProperty, typeof(FrameworkElement)); + descriptor.RemoveValueChanged(element, OnValidationHasErrorChanged); + } + } + + private static void OnValidationHasErrorChanged(object sender, EventArgs e) + { + if (sender is DependencyObject d) + { + SetHasError(d, Validation.GetHasError(d)); + } + } +} \ No newline at end of file diff --git a/Source/NETworkManager.Validators/AnyNetworkProfileValidator.cs b/Source/NETworkManager.Validators/AnyNetworkProfileValidator.cs new file mode 100644 index 0000000000..f104a52e26 --- /dev/null +++ b/Source/NETworkManager.Validators/AnyNetworkProfileValidator.cs @@ -0,0 +1,40 @@ +using System.Globalization; +using System.Linq; +using System.Windows.Controls; +using System.Windows.Data; +using NETworkManager.Localization.Resources; +using NETworkManager.Interfaces.ViewModels; + +namespace NETworkManager.Validators; + +/// +/// Checks for any network profile to be selected. +/// +/// +/// Has to be called in ValidationStep.UpdatedValue or later. This means that the value update cannot be prevented, +/// however, we can still show the configuration error. +/// +public class AnyNetworkProfileValidator : ValidationRule +{ + public override ValidationResult Validate(object value, CultureInfo cultureInfo) + { + var bindingExpression = value as BindingExpression; + if (bindingExpression?.DataItem is not IFirewallRuleViewModel viewModel) + { + return new ValidationResult(false, + "No ViewModel could be found. Is the ValidationStep set correctly?"); + } + if (bindingExpression.DataItem is true) + return ValidationResult.ValidResult; + + bool[] currentValues = + [ + viewModel.NetworkProfileDomain, + viewModel.NetworkProfilePrivate, + viewModel.NetworkProfilePublic + ]; + + return currentValues.Any(x => x) ? ValidationResult.ValidResult + : new ValidationResult(false, Strings.AtLeastOneNetworkProfileMustBeSelected); + } +} \ No newline at end of file diff --git a/Source/NETworkManager.Validators/EmptyOrFileExistsValidator.cs b/Source/NETworkManager.Validators/EmptyOrFileExistsValidator.cs index d669e71e4d..e24ce4ffb9 100644 --- a/Source/NETworkManager.Validators/EmptyOrFileExistsValidator.cs +++ b/Source/NETworkManager.Validators/EmptyOrFileExistsValidator.cs @@ -1,19 +1,22 @@ using System.Globalization; -using System.IO; using System.Windows.Controls; -using NETworkManager.Localization.Resources; namespace NETworkManager.Validators; public class EmptyOrFileExistsValidator : ValidationRule { - public override ValidationResult Validate(object value, CultureInfo cultureInfo) + private static FileExistsValidator FileExistsValidator { - if (string.IsNullOrEmpty(value as string)) - return ValidationResult.ValidResult; + get + { + field ??= new FileExistsValidator(); + return field; + } + } - return File.Exists((string)value) - ? ValidationResult.ValidResult - : new ValidationResult(false, Strings.FileDoesNotExist); + public override ValidationResult Validate(object value, CultureInfo cultureInfo) + { + return string.IsNullOrEmpty(value as string) + ? ValidationResult.ValidResult : FileExistsValidator.Validate(value, cultureInfo); } } \ No newline at end of file diff --git a/Source/NETworkManager.Validators/EmptyOrFileIsExeValidator.cs b/Source/NETworkManager.Validators/EmptyOrFileIsExeValidator.cs new file mode 100644 index 0000000000..69e2dd05f1 --- /dev/null +++ b/Source/NETworkManager.Validators/EmptyOrFileIsExeValidator.cs @@ -0,0 +1,20 @@ +using System; +using System.Globalization; +using System.Windows.Controls; +using NETworkManager.Localization.Resources; + +namespace NETworkManager.Validators; + +/// +/// Evaluate whether the file path is a *.exe or *.EXE file. +/// +public class EmptyOrFileIsExeValidator : ValidationRule +{ + public override ValidationResult Validate(object value, CultureInfo cultureInfo) + { + if (value is not string strVal || string.IsNullOrEmpty(strVal) + || strVal.EndsWith(".exe", StringComparison.InvariantCultureIgnoreCase)) + return ValidationResult.ValidResult; + return new ValidationResult(false, Strings.EnterPathToExe); + } +} \ No newline at end of file diff --git a/Source/NETworkManager.Validators/EmptyOrFilePathValidator.cs b/Source/NETworkManager.Validators/EmptyOrFilePathValidator.cs new file mode 100644 index 0000000000..20896606bc --- /dev/null +++ b/Source/NETworkManager.Validators/EmptyOrFilePathValidator.cs @@ -0,0 +1,23 @@ +using System.Globalization; +using System.Windows.Controls; + +namespace NETworkManager.Validators; + +public class EmptyOrFilePathValidator : ValidationRule +{ + private static FilePathValidator FilePathValidator + { + get + { + field ??= new FilePathValidator(); + return field; + } + } + + public override ValidationResult Validate(object value, CultureInfo cultureInfo) + { + if (value is not string strValue || string.IsNullOrEmpty(strValue)) + return ValidationResult.ValidResult; + return FilePathValidator.Validate(value, cultureInfo); + } +} \ No newline at end of file diff --git a/Source/NETworkManager.Validators/EmptyOrFirewallPortRangeValidator.cs b/Source/NETworkManager.Validators/EmptyOrFirewallPortRangeValidator.cs new file mode 100644 index 0000000000..3c23da1d67 --- /dev/null +++ b/Source/NETworkManager.Validators/EmptyOrFirewallPortRangeValidator.cs @@ -0,0 +1,53 @@ +using System.Globalization; +using System.Windows.Controls; +using NETworkManager.Localization.Resources; +using NETworkManager.Settings; + +namespace NETworkManager.Validators; + +public class EmptyOrFirewallPortRangeValidator : ValidationRule +{ + public override ValidationResult Validate(object value, CultureInfo cultureInfo) + { + if (string.IsNullOrEmpty(value as string)) + return ValidationResult.ValidResult; + + var isValid = true; + char portSeparator = ';'; + var portList = ((string)value).Replace(" ", "").Split(portSeparator); + if (portList.Length > 10000) + return new ValidationResult(false, Strings.EnterLessThan10001PortsOrPortRanges); + foreach (var portOrRange in portList) + if (portOrRange.Contains('-')) + { + var portRange = portOrRange.Split('-'); + + if (int.TryParse(portRange[0], out var startPort) && int.TryParse(portRange[1], out var endPort)) + { + if (startPort is < 0 or > 65536 || endPort is < 0 or > 65536 || + startPort > endPort) + isValid = false; + } + else + { + isValid = false; + } + } + else + { + if (int.TryParse(portOrRange, out var portNumber)) + { + if (portNumber is <= 0 or >= 65536) + isValid = false; + } + else + { + isValid = false; + } + } + + return isValid + ? ValidationResult.ValidResult + : new ValidationResult(false, Strings.EnterValidPortOrPortRange); + } +} \ No newline at end of file diff --git a/Source/NETworkManager.Validators/EmptyOrInt32Validator.cs b/Source/NETworkManager.Validators/EmptyOrInt32Validator.cs new file mode 100644 index 0000000000..ffb3c3adef --- /dev/null +++ b/Source/NETworkManager.Validators/EmptyOrInt32Validator.cs @@ -0,0 +1,23 @@ +using System.Globalization; +using System.Windows.Controls; + +namespace NETworkManager.Validators; + +public class EmptyOrInt32Validator : ValidationRule +{ + private static Int32Validator Int32Validator + { + get + { + field ??= new Int32Validator(); + return field; + } + } + + public override ValidationResult Validate(object value, CultureInfo cultureInfo) + { + if (value is string strValue && string.IsNullOrEmpty(strValue)) + return ValidationResult.ValidResult; + return Int32Validator.Validate(value, cultureInfo); + } +} \ No newline at end of file diff --git a/Source/NETworkManager.Validators/FileNameValidator.cs b/Source/NETworkManager.Validators/FileNameValidator.cs index 7defc70b48..7c44c747b3 100644 --- a/Source/NETworkManager.Validators/FileNameValidator.cs +++ b/Source/NETworkManager.Validators/FileNameValidator.cs @@ -10,7 +10,7 @@ namespace NETworkManager.Validators; /// Check if the filename is a valid file name (like "text.txt"). The file name does not have to exist on the local /// system. /// -public class FileNameValidator : ValidationRule +public partial class FileNameValidator : ValidationRule { /// /// Check if the filename is a valid file name (like "text.txt"). The filen ame does not have to exist on the local @@ -24,8 +24,11 @@ public override ValidationResult Validate(object value, CultureInfo cultureInfo) var filename = (string)value; // Check if the filename has valid chars and a dot. - return filename.IndexOfAny(Path.GetInvalidFileNameChars()) < 0 && new Regex(@"^.+\..+$").IsMatch(filename) + return (filename?.IndexOfAny(Path.GetInvalidFileNameChars()) ?? 1) < 0 && FileRegex().IsMatch(filename) ? ValidationResult.ValidResult : new ValidationResult(false, Strings.EnterValidFileName); } + + [GeneratedRegex(@"^.+\..+$")] + private static partial Regex FileRegex(); } \ No newline at end of file diff --git a/Source/NETworkManager.Validators/FirewallRuleNameValidator.cs b/Source/NETworkManager.Validators/FirewallRuleNameValidator.cs new file mode 100644 index 0000000000..217687a6a3 --- /dev/null +++ b/Source/NETworkManager.Validators/FirewallRuleNameValidator.cs @@ -0,0 +1,50 @@ +using System.Globalization; +using System.Windows.Controls; +using System.Windows.Data; +using NETworkManager.Interfaces.ViewModels; +using NETworkManager.Localization.Resources; + +namespace NETworkManager.Validators; + +/// +/// Provides validation logic for user-defined names or descriptions used in firewall rules. +/// Ensures the value meets specific criteria for validity: +/// - The character '|' is not allowed. +/// - The string length does not exceed 9999 characters. +/// +public class FirewallRuleNameValidator : ValidationRule +{ + /// + /// Validates a string based on the following two conditions: + /// - The string must not contain the '|' character. + /// - The string must not exceed a length of 9999 characters. + /// + /// The value to be validated. + /// The culture information used during validation. + /// A ValidationResult indicating whether the string is valid or not. + public override ValidationResult Validate(object value, CultureInfo cultureInfo) + { + var bindingExpression = value as BindingExpression; + if (bindingExpression?.DataItem is not IFirewallRuleViewModel viewModel) + { + return new ValidationResult(false, + "No ViewModel could be found. Is the ValidationStep set correctly?"); + } + + if (string.IsNullOrEmpty(viewModel.UserDefinedName)) + { + viewModel.NameHasError = false; + return ValidationResult.ValidResult; + } + + if (viewModel.UserDefinedName.Length <= viewModel.MaxLengthName) + { + viewModel.NameHasError = false; + return ValidationResult.ValidResult; + } + + viewModel.NameHasError = true; + return new ValidationResult(false, Strings.InputLengthExceeded); + } + +} \ No newline at end of file diff --git a/Source/NETworkManager.Validators/FirewallRuleNoPipeValidator.cs b/Source/NETworkManager.Validators/FirewallRuleNoPipeValidator.cs new file mode 100644 index 0000000000..0f77c07d30 --- /dev/null +++ b/Source/NETworkManager.Validators/FirewallRuleNoPipeValidator.cs @@ -0,0 +1,39 @@ +using System.Globalization; +using System.Windows.Controls; +using System.Windows.Data; +using NETworkManager.Interfaces.ViewModels; +using NETworkManager.Localization.Resources; + +namespace NETworkManager.Validators; + +public class FirewallRuleNoPipeValidator : ValidationRule +{ + public override ValidationResult Validate(object value, CultureInfo cultureInfo) + { + string valueToCheck; + bool isProfileValidation = false; + if (value is not BindingExpression bindingExpression) + { + valueToCheck = value?.ToString(); + } + else if (bindingExpression.DataItem is not IProfileViewModel viewModel) + { + return new ValidationResult(false, + "No ViewModel could be found. Is the ValidationStep set correctly?"); + } + else + { + isProfileValidation = true; + if (!viewModel.Firewall_Enabled) + return ValidationResult.ValidResult; + valueToCheck = viewModel.Name; + } + if (string.IsNullOrWhiteSpace(valueToCheck)) + return ValidationResult.ValidResult; + return valueToCheck.Contains('|') + ? new ValidationResult(false, + isProfileValidation ? Strings.PipeNotAllowedWhenFirewallEnabled : Strings.PipeNotAllowed) + : ValidationResult.ValidResult; + + } +} \ No newline at end of file diff --git a/Source/NETworkManager.Validators/NETworkManager.Validators.csproj b/Source/NETworkManager.Validators/NETworkManager.Validators.csproj index 2a0c1a9f7d..bc989c92c2 100644 --- a/Source/NETworkManager.Validators/NETworkManager.Validators.csproj +++ b/Source/NETworkManager.Validators/NETworkManager.Validators.csproj @@ -21,6 +21,7 @@ + diff --git a/Source/NETworkManager.Validators/ProgramNameLengthValidator.cs b/Source/NETworkManager.Validators/ProgramNameLengthValidator.cs new file mode 100644 index 0000000000..d52e61aeb8 --- /dev/null +++ b/Source/NETworkManager.Validators/ProgramNameLengthValidator.cs @@ -0,0 +1,16 @@ +using System.Globalization; +using System.Windows.Controls; +using NETworkManager.Localization.Resources; + +namespace NETworkManager.Validators; + +public class ProgramNameLengthValidator : ValidationRule +{ + public override ValidationResult Validate(object value, CultureInfo cultureInfo) + { + if (value is not string strVal) + return ValidationResult.ValidResult; + return strVal.Length > 259 ? new ValidationResult(false, Strings.ProgramNameTooLong) + : ValidationResult.ValidResult; + } +} \ No newline at end of file diff --git a/Source/NETworkManager.sln b/Source/NETworkManager.sln index bcfaeb362c..fef9fd0592 100644 --- a/Source/NETworkManager.sln +++ b/Source/NETworkManager.sln @@ -43,6 +43,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Dragablz", "3rdparty\Dragab EndProject Project("{B7DD6F7E-DEF8-4E67-B5B7-07EF123DB6F0}") = "NETworkManager.Setup", "NETworkManager.Setup\NETworkManager.Setup.wixproj", "{B474758D-E657-4DA6-AB2B-63764C5FBAF1}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NETworkManager.Interfaces", "NETworkManager.Interfaces\NETworkManager.Interfaces.csproj", "{F25AA52C-7203-4F3D-B9B2-915DFFD25294}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -338,6 +340,26 @@ Global {B474758D-E657-4DA6-AB2B-63764C5FBAF1}.Release|x64.Build.0 = Release|x64 {B474758D-E657-4DA6-AB2B-63764C5FBAF1}.Release|x86.ActiveCfg = Release|x86 {B474758D-E657-4DA6-AB2B-63764C5FBAF1}.Release|x86.Build.0 = Release|x86 + {F25AA52C-7203-4F3D-B9B2-915DFFD25294}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F25AA52C-7203-4F3D-B9B2-915DFFD25294}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F25AA52C-7203-4F3D-B9B2-915DFFD25294}.Debug|ARM.ActiveCfg = Debug|Any CPU + {F25AA52C-7203-4F3D-B9B2-915DFFD25294}.Debug|ARM.Build.0 = Debug|Any CPU + {F25AA52C-7203-4F3D-B9B2-915DFFD25294}.Debug|ARM64.ActiveCfg = Debug|Any CPU + {F25AA52C-7203-4F3D-B9B2-915DFFD25294}.Debug|ARM64.Build.0 = Debug|Any CPU + {F25AA52C-7203-4F3D-B9B2-915DFFD25294}.Debug|x64.ActiveCfg = Debug|Any CPU + {F25AA52C-7203-4F3D-B9B2-915DFFD25294}.Debug|x64.Build.0 = Debug|Any CPU + {F25AA52C-7203-4F3D-B9B2-915DFFD25294}.Debug|x86.ActiveCfg = Debug|Any CPU + {F25AA52C-7203-4F3D-B9B2-915DFFD25294}.Debug|x86.Build.0 = Debug|Any CPU + {F25AA52C-7203-4F3D-B9B2-915DFFD25294}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F25AA52C-7203-4F3D-B9B2-915DFFD25294}.Release|Any CPU.Build.0 = Release|Any CPU + {F25AA52C-7203-4F3D-B9B2-915DFFD25294}.Release|ARM.ActiveCfg = Release|Any CPU + {F25AA52C-7203-4F3D-B9B2-915DFFD25294}.Release|ARM.Build.0 = Release|Any CPU + {F25AA52C-7203-4F3D-B9B2-915DFFD25294}.Release|ARM64.ActiveCfg = Release|Any CPU + {F25AA52C-7203-4F3D-B9B2-915DFFD25294}.Release|ARM64.Build.0 = Release|Any CPU + {F25AA52C-7203-4F3D-B9B2-915DFFD25294}.Release|x64.ActiveCfg = Release|Any CPU + {F25AA52C-7203-4F3D-B9B2-915DFFD25294}.Release|x64.Build.0 = Release|Any CPU + {F25AA52C-7203-4F3D-B9B2-915DFFD25294}.Release|x86.ActiveCfg = Release|Any CPU + {F25AA52C-7203-4F3D-B9B2-915DFFD25294}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/Source/NETworkManager/Controls/FirewallRuleEnumTranslations.cs b/Source/NETworkManager/Controls/FirewallRuleEnumTranslations.cs new file mode 100644 index 0000000000..ddd0b73f20 --- /dev/null +++ b/Source/NETworkManager/Controls/FirewallRuleEnumTranslations.cs @@ -0,0 +1,35 @@ +using NETworkManager.Models.Firewall; +using NETworkManager.ViewModels; + +namespace NETworkManager.Controls; + +/// +/// Static class to profile strings for Enum translations. +/// +public static class FirewallRuleEnumTranslation +{ + /// + /// Names of the firewall rule actions. + /// + public static string[] ActionNames => + FirewallRuleViewModel.GetEnumTranslation(typeof(FirewallRuleAction)); + + /// + /// Names of the directions. + /// + public static string[] DirectionNames => + FirewallRuleViewModel.GetEnumTranslation(typeof(FirewallRuleDirection)); + + /// + /// Names of the protocols. + /// + public static string[] ProtocolNames => + FirewallRuleViewModel.GetEnumTranslation(typeof(FirewallProtocol)); + + /// + /// Names of the interface types. + /// + public static string[] InterfaceTypeNames => + FirewallRuleViewModel.GetEnumTranslation(typeof(FirewallInterfaceType)); +} + diff --git a/Source/NETworkManager/Controls/FirewallRuleGrid.xaml b/Source/NETworkManager/Controls/FirewallRuleGrid.xaml new file mode 100644 index 0000000000..490ad77058 --- /dev/null +++ b/Source/NETworkManager/Controls/FirewallRuleGrid.xaml @@ -0,0 +1,724 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Source/NETworkManager/Controls/FirewallRuleGrid.xaml.cs b/Source/NETworkManager/Controls/FirewallRuleGrid.xaml.cs new file mode 100644 index 0000000000..692fa2ed1b --- /dev/null +++ b/Source/NETworkManager/Controls/FirewallRuleGrid.xaml.cs @@ -0,0 +1,393 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Input; +using System.Windows.Media; +using System.Windows.Shapes; +using System.Windows.Threading; +using NETworkManager.Converters; +using NETworkManager.Localization.Resources; +using NETworkManager.Models.Firewall; +using NETworkManager.ViewModels; + +namespace NETworkManager.Controls; + +/// +/// Code-behind for the firewall rule grid. +/// +public partial class FirewallRuleGrid +{ + /// + /// Mutex to prevent spamming the history for every rule. + /// + /// + /// Every rule's local and remote port combo boxes are registered to the + /// method. So, if one rule changes its + /// port values, every rule's combo boxes will trigger the event. Preventing this + /// with the following static variables. + /// + private static readonly Mutex LocalPortsAdding = new(); + /// + /// Last local port string, which was added. + /// + private static string _lastLocalPortsString = string.Empty; + /// + /// Mutex for remote ports being added to the history. + /// + private static readonly Mutex RemotePortsAdding = new(); + /// + /// Last remote port string, which was added. + /// + private static string _lastRemotePortsString = string.Empty; + + /// + /// List of TextBlock content to ignore on double clicks. + /// + private static readonly string[] IgnoredTextBlocks = [Strings.Domain, Strings.Private, Strings.Public]; + + public FirewallRuleGrid() + { + InitializeComponent(); + RestoreRuleGridFocus(); + } + + #region Events + /// + /// Handle new valid ports and add them to the history. + /// + /// The combo box. Not necessarily the one with the change. + /// Event arguments. Unused. + private void ComboBoxPorts_OnLostFocus(object sender, RoutedEventArgs e) + { + if (sender is not ComboBox comboBox) + return; + if (string.IsNullOrWhiteSpace(comboBox.Text)) + return; + if (Validation.GetHasError(comboBox)) + return; + var portType = (FirewallPortLocation)comboBox.Tag; + if (comboBox.DataContext is not FirewallRuleViewModel dataContext) + return; + var converter = new PortRangeToPortSpecificationConverter(); + var structuredData = converter.Convert(comboBox.Text, typeof(List), null, null); + if (structuredData is not List list) + return; + string formattedText = converter.ConvertBack(list, typeof(string), null, null) as string; + if (string.IsNullOrWhiteSpace(formattedText)) + return; + + // Direct visual update: Find the internal TextBox and force formatting the text. + // This bypasses ComboBox.Text property synchronization issues during LostFocus. + if (GetVisualChild(comboBox) is { } textBox) + { + // Only update if visually different to avoid cursor/selection side effects + if (textBox.Text != formattedText) + textBox.Text = formattedText; + } + switch (portType) + { + case FirewallPortLocation.LocalPorts: + if (_lastLocalPortsString == formattedText) + return; + + try + { + LocalPortsAdding?.WaitOne(); + _lastLocalPortsString = formattedText; + dataContext.AddPortsToHistory(formattedText, portType); + } + finally + { + LocalPortsAdding?.ReleaseMutex(); + } + break; + case FirewallPortLocation.RemotePorts: + if (_lastRemotePortsString == formattedText) + return; + + try + { + RemotePortsAdding?.WaitOne(); + _lastRemotePortsString = formattedText; + dataContext.AddPortsToHistory(formattedText, portType); + } + finally + { + RemotePortsAdding?.ReleaseMutex(); + } + break; + } + } + + /// + /// Toggle row visibility. + /// + /// + /// + private void ButtonBase_OnClick(object sender, RoutedEventArgs e) + { + if (e.OriginalSource is not Button) + return; + // Get the row from the sender + for (var visual = sender as Visual; visual != null; visual = VisualTreeHelper.GetParent(visual) as Visual) + { + if (visual is not DataGridRow row) + continue; + + + row.DetailsVisibility = + row.DetailsVisibility == Visibility.Visible ? Visibility.Collapsed : Visibility.Visible; + break; + } + } + + /// + /// Collapse and open rule details by double clicking. + /// + /// + /// + private void Row_OnDoubleClick(object sender, RoutedEventArgs e) + { + // Avoid collision with ButtonBase behavior + if (e.OriginalSource is Button or Rectangle) + return; + // Get the row from the sender + for (var visual = sender as Visual; visual != null; visual = VisualTreeHelper.GetParent(visual) as Visual) + { + if (visual is not DataGridRow row) + continue; + + if (row.DetailsVisibility is Visibility.Visible + && e.OriginalSource is not Border and not TextBlock and not CheckBox) + return; + if (e.OriginalSource is TextBlock textBlock && IgnoredTextBlocks.Contains(textBlock.DataContext as string)) + return; + row.DetailsVisibility = + row.DetailsVisibility == Visibility.Visible ? Visibility.Collapsed : Visibility.Visible; + break; + } + } + + + /// + /// Set the data context of the context menu. + /// + /// + /// + private void ContextMenu_Opened(object sender, RoutedEventArgs e) + { + if (sender is ContextMenu menu) + menu.DataContext = DataContext; + } + + /// + /// Keyboard control + /// + /// + /// + private void DataGridRow_PreviewKeyDown(object sender, KeyEventArgs e) + { + // Only handle if the focus is on the cell itself (navigation mode), + // to avoid interfering with editing (e.g., inside a TextBox). + if (e.OriginalSource is TextBox or CheckBox or ComboBox) + return; + + if (sender is not MultiSelectDataGrid grid) + return; + + if (DataContext is not FirewallViewModel dataContext) + return; + + DataGridRow row = null; + if (grid.SelectedIndex > -1 && e.OriginalSource is DependencyObject source) + row = ItemsControl.ContainerFromElement(grid, source) as DataGridRow; + + switch (e.Key) + { + case Key.A when Keyboard.IsKeyDown(Key.LeftCtrl) || Keyboard.IsKeyDown(Key.RightCtrl): + //dataContext.ApplyConfigurationCommand.Execute(null); + e.Handled = true; + break; + case Key.D when Keyboard.IsKeyDown(Key.LeftCtrl) || Keyboard.IsKeyDown(Key.RightCtrl): + case Key.Delete: + int index = RuleGrid.SelectedIndex; + //dataContext.DeleteRulesCommand.Execute(null); + if (grid.HasItems) + { + // Select the same index or the last item if we deleted the end + index = Math.Min(index, grid.Items.Count - 1); + if (index < 0) + index = 0; + grid.SelectedIndex = index; + grid.Focus(); + } + + e.Handled = true; + break; + case Key.C when (Keyboard.IsKeyDown(Key.LeftCtrl) || Keyboard.IsKeyDown(Key.RightCtrl)) + && (Keyboard.IsKeyDown(Key.LeftShift) || Keyboard.IsKeyDown(Key.RightShift)) + && (Keyboard.IsKeyDown(Key.LeftAlt) || Keyboard.IsKeyDown(Key.RightAlt)): + // FirewallViewModel.DeleteWindowsRulesCommand.Execute(null); + e.Handled = true; + break; + case Key.C when (Keyboard.IsKeyDown(Key.LeftCtrl) || Keyboard.IsKeyDown(Key.RightCtrl)) + && (Keyboard.IsKeyDown(Key.LeftShift) || Keyboard.IsKeyDown(Key.RightShift)): + //dataContext.DeleteAllRulesCommand.Execute(null); + e.Handled = true; + break; + case Key.N when Keyboard.IsKeyDown(Key.LeftCtrl) || Keyboard.IsKeyDown(Key.RightCtrl): + //dataContext.AddRuleCommand.Execute(null); + e.Handled = true; + break; + case Key.Right when row?.DetailsVisibility == Visibility.Collapsed: + row.DetailsVisibility = Visibility.Visible; + e.Handled = true; + break; + case Key.Left when row?.DetailsVisibility == Visibility.Visible: + row.DetailsVisibility = Visibility.Collapsed; + e.Handled = true; + break; + // If nothing is selected, start from the bottom or top row. + case Key.Down: + case Key.Up: + if (!grid.IsKeyboardFocused) + return; + if (row is null && grid.HasItems) + { + if (grid.SelectedIndex is -1) + index = e.Key is Key.Down ? 0 : grid.Items.Count - 1; + else + index = e.Key is Key.Down ? grid.SelectedIndex + 1 : grid.SelectedIndex - 1; + if (index < 0) + index = 0; + if (index >= grid.Items.Count) + index = grid.Items.Count - 1; + grid.SelectedIndex = index; + var item = grid.Items[grid.SelectedIndex] as FirewallRuleViewModel; + if (item is not null) + grid.ScrollIntoView(item); + grid.UpdateLayout(); + row = grid.ItemContainerGenerator.ContainerFromIndex(grid.SelectedIndex) as DataGridRow; + var viewModel = grid.DataContext as FirewallViewModel; + //viewModel?.SelectedRule = item; + } + FocusManager.SetFocusedElement(FocusManager.GetFocusScope(grid), null); + Keyboard.ClearFocus(); + // DataGridRow is not focusable. + if (row is not null) + { + var cell = GetCell(grid, row, 1); + cell?.Focus(); + } + e.Handled = true; + break; + case Key.W when Keyboard.IsKeyDown(Key.LeftCtrl) || Keyboard.IsKeyDown(Key.RightCtrl): + //dataContext.OpenWindowsFirewallCommand.Execute(null); + e.Handled = true; + break; + } + } + + /// + /// Gets a DataGridCell from a given DataGrid, given row and column accounting for virtualization. + /// + /// The DataGrid to retrieve the cell from. + /// The row instance to get the index from. + /// The column index. + /// A DataGridCell instance. + private static DataGridCell GetCell(DataGrid grid, DataGridRow row, int column) + { + if (row == null) return null; + + var presenter = GetVisualChild(row); + if (presenter != null) + return presenter.ItemContainerGenerator.ContainerFromIndex(column) as DataGridCell; + grid.ScrollIntoView(row, grid.Columns[column]); + presenter = GetVisualChild(row); + + return presenter?.ItemContainerGenerator.ContainerFromIndex(column) as DataGridCell; + } + + /// + /// Get the first child of a Visual, which is not null. + /// + /// Parent of children. + /// Any Visual type. + /// First non-null child found or null. + private static T GetVisualChild(Visual parent) where T : Visual + { + T child = null; + int numVisuals = VisualTreeHelper.GetChildrenCount(parent); + for (int i = 0; i < numVisuals; i++) + { + var v = (Visual)VisualTreeHelper.GetChild(parent, i); + child = v as T ?? GetVisualChild(v); + if (child != null) + { + break; + } + } + return child; + } + + /// + /// Restore the focus to the RuleGrid, whenever it is lost by column sorting or button clicks. + /// Also called on loading. + /// + public void RestoreRuleGridFocus() + { + if (RuleGrid is null) + return; + + // Post-focus: let WPF finish the click/command that caused focus to move away. + Dispatcher.BeginInvoke(new Action(() => + { + if (!IsVisible || !IsEnabled) + return; + + if (!RuleGrid.HasItems) + { + RuleGrid.Focus(); + return; + } + + if (RuleGrid.SelectedIndex < 0) + RuleGrid.SelectedIndex = 0; + + RuleGrid.ScrollIntoView(RuleGrid.SelectedItem); + RuleGrid.UpdateLayout(); + + // Prefer focusing a real cell (more reliable than focusing the DataGrid itself). + var row = RuleGrid.ItemContainerGenerator.ContainerFromIndex(RuleGrid.SelectedIndex) as DataGridRow; + if (row != null) + { + // Pick a sensible column (0 = expander button column; 1 = "Name" in your grid). + var cell = GetCell(RuleGrid, row, column: 1) ?? GetCell(RuleGrid, row, column: 0); + if (cell != null) + { + cell.Focus(); + Keyboard.Focus(cell); + return; + } + } + + RuleGrid.Focus(); + Keyboard.Focus(RuleGrid); + }), DispatcherPriority.Input); + } + + /// + /// Delegate the refocusing to on sorting, + /// but with another dispatcher context. + /// + /// + /// + private void RuleGrid_OnSorting(object sender, DataGridSortingEventArgs e) + { + Dispatcher.BeginInvoke(new Action(RestoreRuleGridFocus), DispatcherPriority.ContextIdle); + } + #endregion + +} \ No newline at end of file diff --git a/Source/NETworkManager/NETworkManager.csproj b/Source/NETworkManager/NETworkManager.csproj index 865ff0fab4..df322fba84 100644 --- a/Source/NETworkManager/NETworkManager.csproj +++ b/Source/NETworkManager/NETworkManager.csproj @@ -82,6 +82,9 @@ $(TargetDir)\lib\MSTSCLib.dll + + FirewallRuleGrid.xaml + @@ -92,6 +95,7 @@ + @@ -141,6 +145,11 @@ Wpf Designer + + MSBuild:Compile + Wpf + Designer + diff --git a/Source/NETworkManager/Properties/Resources.Designer.cs b/Source/NETworkManager/Properties/Resources.Designer.cs index 79d39ad2e6..694724d834 100644 --- a/Source/NETworkManager/Properties/Resources.Designer.cs +++ b/Source/NETworkManager/Properties/Resources.Designer.cs @@ -1,7 +1,6 @@ //------------------------------------------------------------------------------ // // This code was generated by a tool. -// Runtime Version:4.0.30319.42000 // // Changes to this file may cause incorrect behavior and will be lost if // the code is regenerated. @@ -70,7 +69,7 @@ public static string MicrosoftEdgeWebView2Runtime_DownloadUrl { } /// - /// Looks up a localized string similar to https://github.com/BornToBeRoot/NETworkManager/blob/main/CONTRIBUTORS. + /// Looks up a localized string similar to https://github.com/BornToBeRoot/NETworkManager/blob/main/CONTRIBUTORS.md. /// public static string NETworkManager_Contributors { get { diff --git a/Source/NETworkManager/Resources/Styles/CheckBoxStyles.xaml b/Source/NETworkManager/Resources/Styles/CheckBoxStyles.xaml index 3291de6f49..ef3d375aef 100644 --- a/Source/NETworkManager/Resources/Styles/CheckBoxStyles.xaml +++ b/Source/NETworkManager/Resources/Styles/CheckBoxStyles.xaml @@ -4,7 +4,7 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/Source/NETworkManager/ViewModels/FirewallRuleViewModel.cs b/Source/NETworkManager/ViewModels/FirewallRuleViewModel.cs new file mode 100644 index 0000000000..e791497299 --- /dev/null +++ b/Source/NETworkManager/ViewModels/FirewallRuleViewModel.cs @@ -0,0 +1,1038 @@ +using System.IO; + +namespace NETworkManager.ViewModels; + +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Linq.Expressions; +using System.Text; +using System.Windows.Data; +using System.Windows.Forms; +using System.Windows.Input; +using Localization.Resources; +using Models.Firewall; +using NW = Models.Network; +using NETworkManager.Interfaces.ViewModels; +using Settings; +using Utilities; + +/// +/// ViewModel for a firewall rule +/// +public class FirewallRuleViewModel : ViewModelBase, ICloneable, IFirewallRuleViewModel +{ + #region Variables + + /// + /// Reflected access to converter. + /// + private static IValueConverter PortRangeToPortSpecificationConverter + { + get + { + if (field is not null) return field; + var type = Type.GetType( + "NETworkManager.Converters.PortRangeToPortSpecificationConverter, NETworkManager.Converters"); + + if (type is null) return field; + var ctor = Expression.New(type); + var lambda = Expression.Lambda>(ctor); + field = lambda.Compile().Invoke(); + + return field; + } + } + + /// + /// Represents the underlying firewall rule associated with the configuration. + /// + [NotNull] + private readonly FirewallRule _rule = new(); + + public bool NameHasError + { + get; + set + { + if (value == field) + return; + field = value; + OnPropertyChanged(); + OnPropertyChanged(nameof(HasError)); + } + } + + public bool DescriptionHasError + { + get; + set + { + if (value == field) + return; + field = value; + OnPropertyChanged(); + OnPropertyChanged(nameof(HasError)); + } + } + + public bool ProgramHasError + { + get; + set + { + if (value == field) + return; + field = value; + OnPropertyChanged(); + OnPropertyChanged(nameof(HasError)); + } + } + + private bool ProfilesHaveError + { + get; + set + { + if (value == field) + return; + field = value; + OnPropertyChanged(); + OnPropertyChanged(nameof(HasError)); + } + } + + public bool HasError => NameHasError || DescriptionHasError || ProgramHasError || ProfilesHaveError; + + /// + /// Represents the name or identifier associated with an entity or object. + /// + public string Name + { + get => _rule.Name; + set + { + if (value == _rule.Name) + return; + _rule.Name = value; + ValidateName(); + OnPropertyChanged(); + OnRuleChangedEvent(); + } + } + + /// + /// Optionally describe the firewall rule with this property + /// + public string Description + { + get => _rule.Description; + set + { + if (value == _rule.Description) + return; + _rule.Description = value; + OnRuleChangedEvent(); + OnPropertyChanged(); + } + } + + /// + /// Name of the currently loaded profile + /// + public string ProfileName + { + get; + set + { + if (value == field) + return; + field = value; + UpdateRuleName(); + OnPropertyChanged(); + OnPropertyChanged(nameof(MaxLengthName)); + OnPropertyChanged(nameof(UserDefinedName)); + OnRuleChangedEvent(); + } + } + + private string _userDefineName; + /// + /// Name override for the firewall DisplayName + /// + public string UserDefinedName + { + get; + set + { + if (value == field) + return; + field = value; + if (value?.Length <= MaxLengthName) + _userDefineName = value; + ValidateName(); + OnPropertyChanged(); + UpdateRuleName(); + OnRuleChangedEvent(); + } + } + + /// + /// Max length of the firewall DisplayName + /// + public int MaxLengthName => + 9999 - "NwM__".Length - ProfileName?.Length ?? + 9999 - "NwM__".Length - "Default".Length; + + /// + /// Default name shown in the field watermark and used, if no is provided. + /// + public string DefaultName + { + get; + private set + { + if (field == value) + return; + field = value; + OnPropertyChanged(); + } + } + + /// + /// Firewall protocol to apply the rule to. + /// + public FirewallProtocol Protocol + { + get => _rule.Protocol; + set + { + if (value == _rule.Protocol) + return; + _rule.Protocol = value; + UpdateRuleName(); + OnPropertyChanged(); + OnPropertyChanged(nameof(PortsEnabled)); + OnRuleChangedEvent(); + } + } + + /// + /// Specifies the direction of traffic flow for the rule. + /// + public FirewallRuleDirection Direction + { + get => _rule.Direction; + set + { + if (value == _rule.Direction) + return; + _rule.Direction = value; + UpdateRuleName(); + OnPropertyChanged(); + OnRuleChangedEvent(); + } + } + + /// + /// Program for which the rule applies. + /// + public FirewallRuleProgram Program + { + get => _rule.Program; + set + { + if (value == _rule.Program) + return; + _rule.Program = value; + ValidateProgramPath(); + UpdateRuleName(); + OnPropertyChanged(); + OnRuleChangedEvent(); + } + } + + /// + /// Binding field for the port input fields to be activated. + /// + public bool PortsEnabled => Protocol is FirewallProtocol.TCP or FirewallProtocol.UDP; + + /// + /// Specifies the local ports that the rule applies to. + /// + public List LocalPorts + { + get => _rule.LocalPorts; + set + { + _rule.LocalPorts = value; + UpdateRuleName(); + OnPropertyChanged(); + OnRuleChangedEvent(); + } + } + + /// + /// Index of the history combobox. + /// + public int LocalPortsIndex + { + get; + set + { + if (value == field) + return; + field = value; + OnPropertyChanged(); + } + } = -1; + + /// + /// Specifies the remote ports that the rule applies to. + /// + public List RemotePorts + { + get => _rule.RemotePorts; + set + { + _rule.RemotePorts = value; + UpdateRuleName(); + OnPropertyChanged(); + OnRuleChangedEvent(); + } + } + + /// + /// Index of the history combobox. + /// + public int RemotePortsIndex + { + get; + set + { + if (value == field) + return; + field = value; + OnPropertyChanged(); + } + } = -1; + + /// + /// View for history combobox. + /// + public ICollectionView LocalPortsHistoryView + { + get; + set + { + if (value == field) + return; + field = value; + OnPropertyChanged(); + } + + } + + /// + /// View for history combobox. + /// + public ICollectionView RemotePortsHistoryView + { + get; + set + { + if (value == field) + return; + field = value; + OnPropertyChanged(); + } + } + + /// + /// Local port history. + /// + public static ObservableCollection LocalPortsHistory + { + get; + set; + } = []; + + /// + /// Remote port history. + /// + public static ObservableCollection RemotePortsHistory + { + get; + set; + } = []; + + /// + /// View for the combination of and history. + /// + public static ObservableCollection CombinedPortsHistory + { + get; + } = []; + + private string _lastLocalPortValue = string.Empty; + private string _lastRemotePortValue = string.Empty; + private readonly bool _isInit; + + /// + /// Watermark for the port input fields. + /// + public string PortWatermark + { + get; + set + { + if (value == field) + return; + field = value; + OnPropertyChanged(); + } + } + + /// + /// Checkbox for the domain network profile. + /// + public bool NetworkProfileDomain + { + get; + set + { + if (value == field) + return; + field = value; + if (_isInit) + return; + // Temporarily apply the change to a copy to check validity + var newProfiles = _rule.NetworkProfiles.ToArray(); + newProfiles[(int)NW.NetworkProfiles.Domain] = value; + + if (newProfiles.Any(x => x)) + { + NetworkProfiles[(int)NW.NetworkProfiles.Domain] = value; + NetworkProfiles[(int)NW.NetworkProfiles.Private] = NetworkProfilePrivate; + NetworkProfiles[(int)NW.NetworkProfiles.Public] = NetworkProfilePublic; + ProfilesHaveError = false; + UpdateRuleName(); + OnRuleChangedEvent(); + } + else + { + ProfilesHaveError = true; + } + OnPropertyChanged(); + OnPropertyChanged(nameof(NetworkProfilePrivate)); + OnPropertyChanged(nameof(NetworkProfilePublic)); + OnPropertyChanged(nameof(NetworkProfiles)); + } + } + + /// + /// Checkbox for the private network profile. + /// + public bool NetworkProfilePrivate + { + get; + set + { + if (value == field) + return; + field = value; + if (_isInit) + return; + var newProfiles = _rule.NetworkProfiles.ToArray(); + newProfiles[(int)NW.NetworkProfiles.Private] = value; + + if (newProfiles.Any(x => x)) + { + NetworkProfiles[(int)NW.NetworkProfiles.Domain] = NetworkProfileDomain; + NetworkProfiles[(int)NW.NetworkProfiles.Private] = value; + NetworkProfiles[(int)NW.NetworkProfiles.Public] = NetworkProfilePublic; + ProfilesHaveError = false; + UpdateRuleName(); + OnRuleChangedEvent(); + } + else + { + ProfilesHaveError = true; + } + + OnPropertyChanged(); + OnPropertyChanged(nameof(NetworkProfileDomain)); + OnPropertyChanged(nameof(NetworkProfilePublic)); + OnPropertyChanged(nameof(NetworkProfiles)); + } + } + + /// + /// Checkbox for the public network profile. + /// + public bool NetworkProfilePublic + { + get; + set + { + if (value == field) + return; + field = value; + if (_isInit) + return; + var newProfiles = _rule.NetworkProfiles.ToArray(); + newProfiles[(int)NW.NetworkProfiles.Public] = value; + + if (newProfiles.Any(x => x)) + { + NetworkProfiles[(int)NW.NetworkProfiles.Domain] = NetworkProfileDomain; + NetworkProfiles[(int)NW.NetworkProfiles.Private] = NetworkProfilePrivate; + NetworkProfiles[(int)NW.NetworkProfiles.Public] = value; + ProfilesHaveError = false; + UpdateRuleName(); + OnRuleChangedEvent(); + } + else + { + ProfilesHaveError = true; + } + OnPropertyChanged(); + OnPropertyChanged(nameof(NetworkProfileDomain)); + OnPropertyChanged(nameof(NetworkProfilePrivate)); + OnPropertyChanged(nameof(NetworkProfiles)); + } + } + + /// + /// Combination of all checkboxes for network profiles. + /// + public bool[] NetworkProfiles + { + get => _rule.NetworkProfiles; + init + { + if (value == _rule.NetworkProfiles) + return; + _isInit = true; + _rule.NetworkProfiles = value; + NetworkProfileDomain = value[(int)NW.NetworkProfiles.Domain]; + NetworkProfilePrivate = value[(int)NW.NetworkProfiles.Private]; + NetworkProfilePublic = value[(int)NW.NetworkProfiles.Public]; + _isInit = false; + OnPropertyChanged(); + OnPropertyChanged(nameof(NetworkProfileDomain)); + OnPropertyChanged(nameof(NetworkProfilePrivate)); + OnPropertyChanged(nameof(NetworkProfilePublic)); + OnRuleChangedEvent(); + } + } + + /// + /// Interface type filter for the firewall rule. + /// + public FirewallInterfaceType InterfaceType + { + get => _rule.InterfaceType; + set + { + if (value == _rule.InterfaceType) + return; + _rule.InterfaceType = value; + OnPropertyChanged(); + UpdateRuleName(); + OnRuleChangedEvent(); + } + } + + /// + /// Action to execute when the rule is applied. + /// + public FirewallRuleAction Action + { + get => _rule.Action; + set + { + if (value == _rule.Action) + return; + _rule.Action = value; + UpdateRuleName(); + OnPropertyChanged(); + OnRuleChangedEvent(); + } + } + + public int MaxLengthHistory + { + get; + set + { + if (value == field) + return; + field = value; + OnPropertyChanged(); + } + } + #endregion + + #region Constructor + + /// + /// Represents a view model for a firewall rule that provides details + /// about the rule's configuration and state for user interface bindings. + /// + public FirewallRuleViewModel() + { + NetworkProfiles = [true, true, true]; + ProfileName = "Default"; + PortWatermark = StaticStrings.ExamplePortScanRange; + + // Set the collection views for port histories + LocalPortsHistoryView = CollectionViewSource. + GetDefaultView(LocalPortsHistory); + RemotePortsHistoryView = CollectionViewSource. + GetDefaultView(RemotePortsHistory); + } + + /// + /// Construct a rule from a Firewall rule and a profile name. + /// + /// The rule to get data from. + /// The profile name to use. + public FirewallRuleViewModel(FirewallRule rule, string profileName = null) : this() + { + Direction = rule.Direction; + Protocol = rule.Protocol; + LocalPorts = rule.LocalPorts; + RemotePorts = rule.RemotePorts; + /* + if (SettingsManager.Current.Firewall_CombinePortHistory) + { + char separator = SettingsManager.Current.Firewall_UseWindowsPortSyntax ? ',' : ';'; + LocalPortsIndex = CombinedPortsHistory.IndexOf(FirewallRule.PortsToString(LocalPorts, separator)); + RemotePortsIndex = CombinedPortsHistory.IndexOf(FirewallRule.PortsToString(RemotePorts, separator)); + } + */ + Program = rule.Program; + Description = rule.Description; + Action = rule.Action; + InterfaceType = rule.InterfaceType; + NetworkProfiles = rule.NetworkProfiles; + ProfileName = profileName; + UpdateRuleName(); + string ruleName = rule.Name.Substring("NwM_".Length); + ruleName = ruleName.Substring(0, ruleName.LastIndexOf('_')); + if (DefaultName != ruleName) + UserDefinedName = ruleName; + } + #endregion + + #region Methods + /// + /// Updates the firewall rule's name based on the current configuration. + /// + private void UpdateRuleName() + { + StringBuilder resultBuilder = new(); + if (!string.IsNullOrWhiteSpace(_userDefineName)) + { + resultBuilder.Append(_userDefineName); + } + else + { + string nextToken; + var direction = Direction switch + { + FirewallRuleDirection.Inbound => "in", + FirewallRuleDirection.Outbound => "out", + _ => null + }; + if (direction is not null) + resultBuilder.Append(direction); + resultBuilder.Append($"_{Protocol}"); + if (Protocol is FirewallProtocol.TCP or FirewallProtocol.UDP) + { + if (LocalPorts?.Count is 0 && RemotePorts?.Count is 0) + { + resultBuilder.Append("_any"); + } + else + { + char separator = ';'; + if (LocalPorts?.Count > 0) + { + nextToken = $"_loc:{FirewallRule.PortsToString(LocalPorts, separator, false)}"; + if (nextToken.Length > 20) + nextToken = $"{nextToken[..20]}..."; + resultBuilder.Append(nextToken); + } + + if (RemotePorts?.Count > 0) + { + nextToken = $"_rem:{FirewallRule.PortsToString(RemotePorts, separator, false)}"; + if (nextToken.Length > 20) + nextToken = $"{nextToken[..20]}..."; + resultBuilder.Append(nextToken); + } + } + } + + if (!string.IsNullOrEmpty(Program?.Executable?.Name)) + { + nextToken = $"_{Program.Executable.Name}"; + if (nextToken.Length > 30) + nextToken = $"{nextToken[..30]}..."; + resultBuilder.Append(nextToken); + } + + if (NetworkProfiles.Any(x => x)) + { + resultBuilder.Append('_'); + if (NetworkProfiles.All(x => x)) + { + resultBuilder.Append("all"); + } + else + { + if (NetworkProfiles[(int)NW.NetworkProfiles.Domain]) + resultBuilder.Append("dom,"); + if (NetworkProfiles[(int)NW.NetworkProfiles.Private]) + resultBuilder.Append("prv,"); + if (NetworkProfiles[(int)NW.NetworkProfiles.Public]) + resultBuilder.Append("pub"); + if (resultBuilder[^1] == ',') + resultBuilder.Remove(resultBuilder.Length - 1, 1); + } + } + string type = InterfaceType switch + { + FirewallInterfaceType.RemoteAccess => "vpn", + FirewallInterfaceType.Wired => "wire", + FirewallInterfaceType.Wireless => "wifi", + _ => null + }; + if (type is not null) + resultBuilder.Append($"_if:{type}"); + string action = Action switch + { + FirewallRuleAction.Allow => "acc", + FirewallRuleAction.Block => "blk", + _ => null + }; + if (action is not null) + resultBuilder.Append($"_{action}"); + } + + string defaultName = resultBuilder.ToString(); + if (defaultName.Length > MaxLengthName) + defaultName = $"{defaultName[..(MaxLengthName - 3)]}..."; + Name = $"NwM_{defaultName}_{ProfileName ?? "Default"}"; + DefaultName = defaultName; + } + + /// + /// Sets the error state while the view is not in the VisualTree, which can happen + /// in the ProfileChildWindow until the tab has been opened. + /// + private void ValidateName() + { + if (string.IsNullOrEmpty(UserDefinedName) || UserDefinedName.Length <= MaxLengthName) + { + NameHasError = false; + return; + } + + NameHasError = true; + } + + private void ValidateProgramPath() + { + if (Program?.Executable?.FullName is not { } strValue) + { + ProgramHasError = false; + return; + } + ProgramHasError = !File.Exists(strValue); + } + + /// Converts the current instance of the FirewallRuleViewModel to a FirewallRule object. + /// + /// A object representing the current instance. + /// + public FirewallRule ToRule(bool toLoadOrSave = false) + { + ValidateProgramPath(); + ValidateName(); + return HasError && !toLoadOrSave ? null : _rule; + } + + /// + /// Retrieves the localized translation for a given enumeration value. + /// + /// The enumeration type to translate. + /// The localized string corresponding to the provided enumeration value. + public static string[] GetEnumTranslation(Type enumType) + { + if (!enumType.IsEnum) + return null; + + var enumStrings = Enum.GetNames(enumType); + var transStrings = new string[enumStrings.Length]; + for (int i = 0; i < enumStrings.Length; i++) + transStrings[i] = Strings.ResourceManager.GetString(enumStrings[i], Strings.Culture) ?? enumStrings[i]; + + return transStrings; + } + + /// + /// Store the current port values to spare them from deletion on collection clearing. + /// + public void StorePortValues() + { + // Store original port values + var converter = PortRangeToPortSpecificationConverter; + _lastLocalPortValue = converter.ConvertBack(LocalPorts, typeof(string), null, null) as string; + _lastRemotePortValue = converter.ConvertBack(RemotePorts, typeof(string), null, null) as string; + } + + /// + /// Restore the port values after the history has been modified. + /// + public void RestorePortValues() + { + var converter = PortRangeToPortSpecificationConverter; + // Restore the original field values + if (!string.IsNullOrWhiteSpace(_lastLocalPortValue)) + { + // Find appropriate index + int tmpLocalIndex = LocalPortsHistory.IndexOf(_lastLocalPortValue); + // Restore field value + if (tmpLocalIndex != -1 + && converter.Convert(_lastLocalPortValue, typeof(List), + null, null) is List convertedPorts) + LocalPorts = convertedPorts; + LocalPortsIndex = tmpLocalIndex; + + + } + // Reset stored value + _lastLocalPortValue = string.Empty; + + // Same for remote ports + if (!string.IsNullOrWhiteSpace(_lastRemotePortValue)) + { + int tmpRemoteIndex = RemotePortsHistory.IndexOf(_lastRemotePortValue); + if (tmpRemoteIndex != -1 + && converter.Convert(_lastRemotePortValue, typeof(List), + null, null) is List convertedPorts) + RemotePorts = convertedPorts; + RemotePortsIndex = tmpRemoteIndex; + } + // Reset stored value + _lastRemotePortValue = string.Empty; + } + + /// + /// Add ports to history. + /// + /// Port list to add. + /// Type of port history to add to. + public void AddPortsToHistory(string ports, FirewallPortLocation firewallPortType) + { + OnAddingPortsToHistoryEvent(); + ObservableCollection portHistory; + switch (firewallPortType) + { + case FirewallPortLocation.LocalPorts: + portHistory = LocalPortsHistory; + break; + case FirewallPortLocation.RemotePorts: + portHistory = RemotePortsHistory; + break; + default: + return; + } + + // Create the new list + var list = ListHelper.Modify(portHistory.ToList(), ports, + SettingsManager.Current.General_HistoryListEntries); + + // Clear the old items + portHistory.Clear(); + + // Raise property changed again after the collection has been cleared + switch (firewallPortType) + { + case FirewallPortLocation.LocalPorts: + OnPropertyChanged(nameof(LocalPortsHistoryView)); + break; + case FirewallPortLocation.RemotePorts: + OnPropertyChanged(nameof(RemotePortsHistoryView)); + break; + } + + // Fill with the new items + list.ForEach(x => portHistory.Add(x)); + + // Update history config + /* + switch (firewallPortType) + { + case FirewallPortLocation.LocalPorts: + LocalPortsHistory = portHistory; + SettingsManager.Current.Firewall_LocalPortsHistoryConfig = list; + FirewallSettingsViewModel.Instance.LocalPortsHaveItems = true; + break; + case FirewallPortLocation.RemotePorts: + RemotePortsHistory = portHistory; + SettingsManager.Current.Firewall_RemotePortsHistoryConfig = list; + FirewallSettingsViewModel.Instance.RemotePortsHaveItems = true; + break; + } + */ + + // Update the combined history if configured + + OnAddedPortsToHistoryEvent(); + } + + /// + /// Update or create the combined port history. + /// + public static void UpdateCombinedPortsHistory() + { + // This will as a side effect reset all unchanged combobox fields in all rules, because its source is empty + // StorePorts() and RestorePorts() are required to circumvent this. + CombinedPortsHistory.Clear(); + + // Refill the combined history alternating between local and remote fields when possible + int count = 0; + int indexLocal = 0; + int indexRemote = 0; + bool swap = false; + var localPorts = LocalPortsHistory; + var remotePorts = RemotePortsHistory; + if (localPorts is null | remotePorts is null) + return; + while (count < SettingsManager.Current.General_HistoryListEntries) + { + if (indexLocal >= localPorts.Count + && indexRemote >= remotePorts.Count) + break; + if (indexLocal < localPorts.Count && (!swap || indexRemote >= remotePorts.Count)) + { + // Avoid duplicates + if (CombinedPortsHistory.Contains(localPorts[indexLocal++])) + continue; + CombinedPortsHistory.Add(localPorts[indexLocal - 1]); + swap = true; + count++; + continue; + } + if (indexRemote < remotePorts.Count) + { + if (CombinedPortsHistory.Contains(remotePorts[indexRemote++])) + continue; + CombinedPortsHistory.Add(remotePorts[indexRemote - 1]); + count++; + swap = false; + } + } + } + + /// + /// Command for selecting a program. + /// + public ICommand SelectProgramCommand => new RelayCommand(_ => SelectProgramAction()); + + /// + /// Select the program using a file dialog. + /// + private void SelectProgramAction() + { + var openFileDialog = new OpenFileDialog(); + + var fileExtension = "exe"; + + openFileDialog.Filter = $@"{Strings.Program} | *.{fileExtension}"; + + if (openFileDialog.ShowDialog() == DialogResult.OK) + Program = new FirewallRuleProgram(openFileDialog.FileName); + } + + /// + /// Clone this instance. + /// + /// Cloned instance. + public object Clone() + { + var clone = new FirewallRuleViewModel + { + Name = new string(Name ?? string.Empty), + Program = Program?.Clone() as FirewallRuleProgram, + NetworkProfileDomain = NetworkProfileDomain, + NetworkProfilePrivate = NetworkProfilePrivate, + NetworkProfilePublic = NetworkProfilePublic, + DefaultName = new string(DefaultName ?? string.Empty), + Action = Action, + InterfaceType = InterfaceType, + Protocol = Protocol, + Direction = Direction, + LocalPorts = LocalPorts?.ToList(), + RemotePorts = RemotePorts?.ToList(), + NetworkProfiles = NetworkProfiles.ToArray(), + PortWatermark = new string(PortWatermark ?? string.Empty), + Description = new string(Description ?? string.Empty), + UserDefinedName = new string(UserDefinedName ?? string.Empty), + LocalPortsIndex = LocalPortsIndex, + RemotePortsIndex = RemotePortsIndex, + }; + UpdateCombinedPortsHistory(); + return clone; + } + #endregion + + #region Events + + /// + /// Event when ports are added to history. + /// + public event EventHandler OnAddingPortsToHistory; + + /// + /// Fire event. + /// + private void OnAddingPortsToHistoryEvent() + { + OnAddingPortsToHistory?.Invoke(this, EventArgs.Empty); + } + + /// + /// Event after ports have been added to history. + /// + public event EventHandler OnAddedPortsToHistory; + + /// + /// Fire event. + /// + private void OnAddedPortsToHistoryEvent() + { + OnAddedPortsToHistory?.Invoke(this, EventArgs.Empty); + } + + /// + /// Event when the rule configuration has changed. + /// + public event EventHandler OnRuleChanged; + + /// + /// Fire event. + /// + private void OnRuleChangedEvent() + { + OnRuleChanged?.Invoke(this, EventArgs.Empty); + } + #endregion +} \ No newline at end of file diff --git a/Source/NETworkManager/Views/NetworkInterfaceView.xaml b/Source/NETworkManager/Views/NetworkInterfaceView.xaml index d65bc0e61a..5f274ec649 100644 --- a/Source/NETworkManager/Views/NetworkInterfaceView.xaml +++ b/Source/NETworkManager/Views/NetworkInterfaceView.xaml @@ -103,7 +103,7 @@ + Foreground="{StaticResource MahApps.Brushes.Gray3}" /> @@ -728,7 +728,7 @@ + Foreground="{StaticResource MahApps.Brushes.Gray3}" /> @@ -837,7 +837,7 @@ @@ -967,7 +967,7 @@ + Foreground="{StaticResource MahApps.Brushes.Gray3}" /> @@ -1049,10 +1049,13 @@ + Style="{DynamicResource DefaultTextBlock}" Margin="0,10,0,0"> + + + + - @@ -1495,7 +1498,7 @@ + Visibility="{Binding Path=ProfileFilterTagsView.IsEmpty, Converter={StaticResource BooleanToVisibilityCollapsedConverter}}"> @@ -1514,7 +1517,7 @@ - + diff --git a/Source/NETworkManager/Views/ProfileChildWindow.xaml b/Source/NETworkManager/Views/ProfileChildWindow.xaml index 151444aa27..9cfea4f4f3 100644 --- a/Source/NETworkManager/Views/ProfileChildWindow.xaml +++ b/Source/NETworkManager/Views/ProfileChildWindow.xaml @@ -14,11 +14,13 @@ xmlns:mah="http://metro.mahapps.com/winfx/xaml/controls" xmlns:viewModels="clr-namespace:NETworkManager.ViewModels" xmlns:wpfHelpers="clr-namespace:NETworkManager.Utilities.WPF;assembly=NETworkManager.Utilities.WPF" + xmlns:typedProxies="clr-namespace:NETworkManager.Utilities.WPF.TypedBindingProxies;assembly=NETworkManager.Utilities.WPF" xmlns:interactivity="http://schemas.microsoft.com/xaml/behaviors" xmlns:localization="clr-namespace:NETworkManager.Localization.Resources;assembly=NETworkManager.Localization" xmlns:profiles="clr-namespace:NETworkManager.Profiles;assembly=NETworkManager.Profiles" + xmlns:controls="clr-namespace:NETworkManager.Controls" xmlns:iconPacks="http://metro.mahapps.com/winfx/xaml/iconpacks" - xmlns:simpleChildWindow="clr-namespace:MahApps.Metro.SimpleChildWindow;assembly=MahApps.Metro.SimpleChildWindow" + xmlns:simpleChildWindow="clr-namespace:MahApps.Metro.SimpleChildWindow;assembly=MahApps.Metro.SimpleChildWindow" CloseButtonCommand="{Binding CancelCommand}" Style="{StaticResource DefaultChildWindow}" Loaded="ChildWindow_OnLoaded" @@ -42,6 +44,8 @@ + + @@ -60,6 +64,7 @@ + @@ -771,7 +776,7 @@ - + @@ -1008,6 +1013,53 @@ + + + + + + + + + + + + + + + + + + + + + @@ -1138,7 +1190,7 @@ Value="{x:Static profiles:ProfileName.PuTTY}"> - + + + + - - + + @@ -2218,7 +2277,7 @@ Value="{Binding RemoteDesktop_AuthenticationLevel}" Maximum="3" Minimum="0" Interval="1" IsEnabled="{Binding RemoteDesktop_OverrideAuthenticationLevel}" /> - @@ -2236,7 +2295,7 @@ - @@ -3127,7 +3186,7 @@ - + @@ -3759,6 +3818,90 @@ + + + + + + + + + + + + + + @@ -4341,7 +4484,7 @@ - + @@ -4475,49 +4618,62 @@ - + - + + + + + + + + + + + - - - + - + - - + + diff --git a/Source/NETworkManager/Views/ProfileChildWindow.xaml.cs b/Source/NETworkManager/Views/ProfileChildWindow.xaml.cs index aa19c9d22f..12505fb2de 100644 --- a/Source/NETworkManager/Views/ProfileChildWindow.xaml.cs +++ b/Source/NETworkManager/Views/ProfileChildWindow.xaml.cs @@ -16,8 +16,6 @@ public ProfileChildWindow(Window parentWindow) InitializeComponent(); // Set the width and height of the child window based on the parent window size - ChildWindowMaxWidth = 1050; - ChildWindowMaxHeight = 650; ChildWindowWidth = parentWindow.ActualWidth * 0.85; ChildWindowHeight = parentWindow.ActualHeight * 0.85; @@ -28,7 +26,7 @@ public ProfileChildWindow(Window parentWindow) ChildWindowHeight = parentWindow.ActualHeight * 0.85; }; } - + private void ChildWindow_OnLoaded(object sender, RoutedEventArgs e) { Dispatcher.BeginInvoke(DispatcherPriority.ContextIdle, new Action(delegate @@ -54,4 +52,4 @@ private void ScrollViewer_ManipulationBoundaryFeedback(object sender, Manipulati { e.Handled = true; } -} \ No newline at end of file +} diff --git a/Source/NETworkManager/Views/ProfilesView.xaml b/Source/NETworkManager/Views/ProfilesView.xaml index b6e4283a3a..90f15266cd 100644 --- a/Source/NETworkManager/Views/ProfilesView.xaml +++ b/Source/NETworkManager/Views/ProfilesView.xaml @@ -414,6 +414,10 @@ Header="{x:Static localization:Strings.WebConsole}" Binding="{Binding (profiles:ProfileInfo.WebConsole_Enabled)}" SortMemberPath="WebConsole_Enabled" MinWidth="80" Width="Auto" /> + - + @@ -443,7 +447,7 @@ - + diff --git a/Website/docs/changelog/next-release.md b/Website/docs/changelog/next-release.md index 0687410c26..c00a302a01 100644 --- a/Website/docs/changelog/next-release.md +++ b/Website/docs/changelog/next-release.md @@ -7,7 +7,7 @@ keywords: [NETworkManager, changelog, release notes, next release, upcoming feat # Next Release Version: **Next release**
-Release date: **xx.xx.2025** +Release date: **xx.xx.2026** | File | `SHA256` | | ---- | -------- | @@ -21,6 +21,10 @@ Release date: **xx.xx.2025** ## What's new? +**Firewall** + +- Firewall application has been added for adding NETworkManager controlled Windows firewall rules with profile support. Special thanks to [@labre-rdc](https://github.com/labre-rdc) [#3353](https://github.com/BornToBeRoot/NETworkManager/pull/3353) + **PowerShell** - DPI scaling is now applied correctly when NETworkManager is moved to a monitor with a different DPI scaling factor. The embedded PowerShell (conhost) window now rescales its font automatically using the Windows Console API (`AttachConsole` + `SetCurrentConsoleFontEx`), bypassing the OS limitation that prevents `WM_DPICHANGED` from being forwarded to cross-process child windows. [#3352](https://github.com/BornToBeRoot/NETworkManager/pull/3352) @@ -31,10 +35,20 @@ Release date: **xx.xx.2025** ## Improvements -- Redesign Status Window to make it more compact [#3359](https://github.com/BornToBeRoot/NETworkManager/pull/3359) +- Reuse existing validators and converters where applicable. [#3353](https://github.com/BornToBeRoot/NETworkManager/pull/3353) +- Support commands exceeding the commandline limit in PowershellHelper. [#3353](https://github.com/BornToBeRoot/NETworkManager/pull/3353) +- Fix warnings in NetworkInterfaceView. [#3353](https://github.com/BornToBeRoot/NETworkManager/pull/3353) +- Add various converters and validators [#3353](https://github.com/BornToBeRoot/NETworkManager/pull/3353) +- Allow to click validation errors out of the way. [#3353](https://github.com/BornToBeRoot/NETworkManager/pull/3353) +- Add validation error template on checkboxes. [#3353](https://github.com/BornToBeRoot/NETworkManager/pull/3353) +- Allow style changes when ViewModels recognize configuration errors. [#3353](https://github.com/BornToBeRoot/NETworkManager/pull/3353) ## Bug Fixes +**General** + +- Fix null dereferences in various validators and converters. [#3353](https://github.com/BornToBeRoot/NETworkManager/pull/3353) + **PowerShell** - Fixed incorrect initial embedded window size on high-DPI monitors. The `WindowsFormsHost` panel now sets its initial dimensions in physical pixels using the current DPI scale factor, ensuring the PowerShell window fills the panel correctly at startup. [#3352](https://github.com/BornToBeRoot/NETworkManager/pull/3352) @@ -52,3 +66,5 @@ Release date: **xx.xx.2025** - Code cleanup & refactoring - Language files updated via [#transifex](https://github.com/BornToBeRoot/NETworkManager/pulls?q=author%3Aapp%2Ftransifex-integration) - Dependencies updated via [#dependabot](https://github.com/BornToBeRoot/NETworkManager/pulls?q=author%3Aapp%2Fdependabot) +- Add code documentation in various places. [#3353](https://github.com/BornToBeRoot/NETworkManager/pull/3353) +- Refactor ListHelper.Modify as generic method.