Skip to content

Commit 9bc28b6

Browse files
committed
Featuer: Refactoring firewall + add docs
1 parent a56fa17 commit 9bc28b6

13 files changed

Lines changed: 913 additions & 217 deletions

File tree

Source/NETworkManager.Localization/Resources/Strings.Designer.cs

Lines changed: 453 additions & 181 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Source/NETworkManager.Localization/Resources/Strings.resx

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4148,6 +4148,27 @@ You can copy your profile files from “{0}” to “{1}” to migrate your exis
41484148
<value>Remote addresses</value>
41494149
</data>
41504150
<data name="EnterValidFirewallAddress" xml:space="preserve">
4151-
<value>Enter a valid IP address, subnet (e.g. 10.0.0.0/8) or keyword (e.g. LocalSubnet, Internet)</value>
4151+
<value>Enter a valid IP address, subnet (e.g. 10.0.0.0/8), range (e.g. 10.0.0.1-10.0.0.10), or keyword (Any, LocalSubnet, DNS, DHCP, WINS, DefaultGateway, Internet, Intranet, IntranetRemoteAccess, PlayToDevice, CaptivePortal)</value>
4152+
</data>
4153+
<data name="AddRule" xml:space="preserve">
4154+
<value>Add rule</value>
4155+
</data>
4156+
<data name="AddRuleDots" xml:space="preserve">
4157+
<value>Add rule...</value>
4158+
</data>
4159+
<data name="EditRule" xml:space="preserve">
4160+
<value>Edit rule</value>
4161+
</data>
4162+
<data name="EditRuleDots" xml:space="preserve">
4163+
<value>Edit rule...</value>
4164+
</data>
4165+
<data name="DeleteRule" xml:space="preserve">
4166+
<value>Delete rule</value>
4167+
</data>
4168+
<data name="EnableRule" xml:space="preserve">
4169+
<value>Enable rule</value>
4170+
</data>
4171+
<data name="DisableRule" xml:space="preserve">
4172+
<value>Disable rule</value>
41524173
</data>
41534174
</root>

Source/NETworkManager.Models/Firewall/Firewall.cs

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -296,17 +296,17 @@ private static string BuildAddScript(FirewallRule rule)
296296
if (rule.Protocol is FirewallProtocol.TCP or FirewallProtocol.UDP)
297297
{
298298
if (rule.LocalPorts.Count > 0)
299-
sb.AppendLine($"$params['LocalPort'] = '{FirewallRule.PortsToString(rule.LocalPorts, ',', false)}'");
299+
sb.AppendLine($"$params['LocalPort'] = {ToPsArray(rule.LocalPorts.Select(p => p.ToString()))}");
300300

301301
if (rule.RemotePorts.Count > 0)
302-
sb.AppendLine($"$params['RemotePort'] = '{FirewallRule.PortsToString(rule.RemotePorts, ',', false)}'");
302+
sb.AppendLine($"$params['RemotePort'] = {ToPsArray(rule.RemotePorts.Select(p => p.ToString()))}");
303303
}
304304

305305
if (rule.LocalAddresses.Count > 0)
306-
sb.AppendLine($"$params['LocalAddress'] = '{string.Join(',', rule.LocalAddresses.Select(EscapePs))}'");
306+
sb.AppendLine($"$params['LocalAddress'] = {ToPsArray(rule.LocalAddresses)}");
307307

308308
if (rule.RemoteAddresses.Count > 0)
309-
sb.AppendLine($"$params['RemoteAddress'] = '{string.Join(',', rule.RemoteAddresses.Select(EscapePs))}'");
309+
sb.AppendLine($"$params['RemoteAddress'] = {ToPsArray(rule.RemoteAddresses)}");
310310

311311
if (rule.Program != null && !string.IsNullOrWhiteSpace(rule.Program.Name))
312312
sb.AppendLine($"$params['Program'] = '{EscapePs(rule.Program.Name)}'");
@@ -323,6 +323,16 @@ private static string BuildAddScript(FirewallRule rule)
323323
/// <param name="value">The raw string value to escape.</param>
324324
private static string EscapePs(string value) => value.Replace("'", "''");
325325

326+
/// <summary>
327+
/// Builds a PowerShell array literal (e.g. <c>@('80','443','8080-8090')</c>) from the given values.
328+
/// New-NetFirewallRule parameters such as -LocalPort and -LocalAddress are typed as
329+
/// <c>String[]</c>; passing a single comma-joined string would be interpreted as one
330+
/// element and rejected, so we emit a real array.
331+
/// </summary>
332+
/// <param name="values">The values to embed into the array literal.</param>
333+
private static string ToPsArray(IEnumerable<string> values) =>
334+
$"@({string.Join(",", values.Select(v => $"'{EscapePs(v)}'"))})";
335+
326336
/// <summary>
327337
/// Maps a <see cref="FirewallProtocol"/> value to the string accepted by
328338
/// <c>New-NetFirewallRule -Protocol</c>.

Source/NETworkManager.Validators/EmptyOrFirewallAddressValidator.cs

Lines changed: 42 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,17 @@ namespace NETworkManager.Validators;
1111
/// <summary>
1212
/// Validates that the input is empty (meaning "Any") or contains semicolon-separated
1313
/// valid IPv4/IPv6 addresses, IPv4/IPv6 CIDR subnets, IPv4 subnets in subnet-mask
14-
/// notation (e.g. <c>10.0.0.0/255.0.0.0</c>), or recognized Windows Firewall keywords
15-
/// (e.g. LocalSubnet, Internet, Intranet, DNS, DHCP, WINS, DefaultGateway).
14+
/// notation (e.g. <c>10.0.0.0/255.0.0.0</c>), IPv4/IPv6 ranges (e.g. <c>1.2.3.4-1.2.3.7</c>),
15+
/// or recognized Windows Firewall keywords (Any, LocalSubnet, DNS, DHCP, WINS, DefaultGateway,
16+
/// Internet, Intranet, IntranetRemoteAccess, PlayToDevice, CaptivePortal). Keywords may be
17+
/// suffixed with <c>4</c> or <c>6</c> to restrict matching to IPv4 or IPv6 (e.g. <c>LocalSubnet4</c>).
1618
/// </summary>
1719
public class EmptyOrFirewallAddressValidator : ValidationRule
1820
{
1921
private static readonly string[] Keywords =
2022
[
21-
"Any", "LocalSubnet", "Internet", "Intranet", "DNS", "DHCP", "WINS", "DefaultGateway"
23+
"Any", "LocalSubnet", "DNS", "DHCP", "WINS", "DefaultGateway",
24+
"Internet", "Intranet", "IntranetRemoteAccess", "PlayToDevice", "CaptivePortal"
2225
];
2326

2427
/// <inheritdoc />
@@ -34,9 +37,17 @@ public override ValidationResult Validate(object value, CultureInfo cultureInfo)
3437
if (string.IsNullOrEmpty(token))
3538
continue;
3639

37-
if (Array.Exists(Keywords, k => k.Equals(token, StringComparison.OrdinalIgnoreCase)))
40+
if (IsKeyword(token))
3841
continue;
3942

43+
if (!token.Contains('/') && token.Contains('-'))
44+
{
45+
if (IsValidRange(token))
46+
continue;
47+
48+
return new ValidationResult(false, Strings.EnterValidFirewallAddress);
49+
}
50+
4051
var slashIndex = token.IndexOf('/');
4152
var addressPart = slashIndex > 0 ? token[..slashIndex] : token;
4253

@@ -45,7 +56,7 @@ public override ValidationResult Validate(object value, CultureInfo cultureInfo)
4556

4657
if (slashIndex <= 0)
4758
continue;
48-
59+
4960
var suffix = token[(slashIndex + 1)..];
5061

5162
if (ip.AddressFamily == AddressFamily.InterNetwork && RegexHelper.SubnetmaskRegex().IsMatch(suffix))
@@ -59,4 +70,30 @@ public override ValidationResult Validate(object value, CultureInfo cultureInfo)
5970

6071
return ValidationResult.ValidResult;
6172
}
73+
74+
private static bool IsKeyword(string token)
75+
{
76+
if (Array.Exists(Keywords, k => k.Equals(token, StringComparison.OrdinalIgnoreCase)))
77+
return true;
78+
79+
if (token.Length < 2 || (token[^1] != '4' && token[^1] != '6'))
80+
return false;
81+
82+
var bare = token[..^1];
83+
return Array.Exists(Keywords, k => k.Equals(bare, StringComparison.OrdinalIgnoreCase));
84+
}
85+
86+
private static bool IsValidRange(string token)
87+
{
88+
var dashIndex = token.IndexOf('-');
89+
90+
if (dashIndex <= 0 || dashIndex >= token.Length - 1)
91+
return false;
92+
93+
if (!IPAddress.TryParse(token[..dashIndex], out var start) ||
94+
!IPAddress.TryParse(token[(dashIndex + 1)..], out var end))
95+
return false;
96+
97+
return start.AddressFamily == end.AddressFamily;
98+
}
6299
}

Source/NETworkManager/ViewModels/FirewallViewModel.cs

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -439,13 +439,13 @@ private void LoadSettings()
439439
/// Gets the command to open the dialog for adding a new firewall rule.
440440
/// Only enabled when the application is running as administrator.
441441
/// </summary>
442-
public ICommand AddEntryCommand => new RelayCommand(_ => AddEntry().ConfigureAwait(false), _ => ModifyEntry_CanExecute());
442+
public ICommand AddRuleCommand => new RelayCommand(_ => AddRule().ConfigureAwait(false), _ => ModifyRule_CanExecute());
443443

444444
/// <summary>
445445
/// Opens the add-firewall-rule dialog. On confirmation, creates the rule via PowerShell
446446
/// and refreshes the rule list.
447447
/// </summary>
448-
private async Task AddEntry()
448+
private async Task AddRule()
449449
{
450450
var childWindow = new FirewallRuleChildWindow();
451451

@@ -472,7 +472,7 @@ private async Task AddEntry()
472472
ConfigurationManager.Current.IsChildWindowOpen = false;
473473
});
474474

475-
childWindow.Title = Strings.AddEntry;
475+
childWindow.Title = Strings.AddRule;
476476
childWindow.DataContext = childWindowViewModel;
477477

478478
ConfigurationManager.Current.IsChildWindowOpen = true;
@@ -484,13 +484,13 @@ private async Task AddEntry()
484484
/// Gets the command to enable the selected firewall rule.
485485
/// Only executable when the rule is currently disabled and modification is allowed.
486486
/// </summary>
487-
public ICommand EnableEntryCommand => new RelayCommand(_ => SetRuleEnabled(SelectedResult, true).ConfigureAwait(false), _ => ModifyEntry_CanExecute() && SelectedResult is { IsEnabled: false });
487+
public ICommand EnableRuleCommand => new RelayCommand(_ => SetRuleEnabled(SelectedResult, true).ConfigureAwait(false), _ => ModifyRule_CanExecute() && SelectedResult is { IsEnabled: false });
488488

489489
/// <summary>
490490
/// Gets the command to disable the selected firewall rule.
491491
/// Only executable when the rule is currently enabled and modification is allowed.
492492
/// </summary>
493-
public ICommand DisableEntryCommand => new RelayCommand(_ => SetRuleEnabled(SelectedResult, false).ConfigureAwait(false), _ => ModifyEntry_CanExecute() && SelectedResult is { IsEnabled: true });
493+
public ICommand DisableRuleCommand => new RelayCommand(_ => SetRuleEnabled(SelectedResult, false).ConfigureAwait(false), _ => ModifyRule_CanExecute() && SelectedResult is { IsEnabled: true });
494494

495495
/// <summary>
496496
/// Enables or disables the given <paramref name="rule"/> via PowerShell,
@@ -523,14 +523,14 @@ private async Task SetRuleEnabled(FirewallRule rule, bool enabled)
523523
/// Gets the command to open the dialog for editing the selected firewall rule.
524524
/// Only executable when a rule is selected and modification is allowed.
525525
/// </summary>
526-
public ICommand EditEntryCommand => new RelayCommand(_ => EditEntry().ConfigureAwait(false), _ => ModifyEntry_CanExecute() && SelectedResult != null);
526+
public ICommand EditRuleCommand => new RelayCommand(_ => EditRule().ConfigureAwait(false), _ => ModifyRule_CanExecute() && SelectedResult != null);
527527

528528
/// <summary>
529529
/// Opens the edit-firewall-rule dialog pre-filled with the selected rule's properties.
530530
/// On confirmation, deletes the old rule, creates the updated rule via PowerShell,
531531
/// and refreshes the rule list.
532532
/// </summary>
533-
private async Task EditEntry()
533+
private async Task EditRule()
534534
{
535535
var childWindow = new FirewallRuleChildWindow();
536536

@@ -558,7 +558,7 @@ private async Task EditEntry()
558558
ConfigurationManager.Current.IsChildWindowOpen = false;
559559
}, SelectedResult);
560560

561-
childWindow.Title = Strings.EditEntry;
561+
childWindow.Title = Strings.EditRule;
562562
childWindow.DataContext = childWindowViewModel;
563563

564564
ConfigurationManager.Current.IsChildWindowOpen = true;
@@ -570,18 +570,18 @@ private async Task EditEntry()
570570
/// Gets the command to permanently delete the selected firewall rule.
571571
/// Only executable when a rule is selected and modification is allowed.
572572
/// </summary>
573-
public ICommand DeleteEntryCommand => new RelayCommand(_ => DeleteEntry().ConfigureAwait(false), _ => ModifyEntry_CanExecute() && SelectedResult != null);
573+
public ICommand DeleteRuleCommand => new RelayCommand(_ => DeleteRule().ConfigureAwait(false), _ => ModifyRule_CanExecute() && SelectedResult != null);
574574

575575
/// <summary>
576576
/// Shows a confirmation dialog and, if confirmed, deletes the selected firewall rule
577577
/// via PowerShell and reloads the rule list.
578578
/// Any PowerShell error is written to the log and shown in the status bar.
579579
/// </summary>
580-
private async Task DeleteEntry()
580+
private async Task DeleteRule()
581581
{
582582
var result = await DialogHelper.ShowConfirmationMessageAsync(
583583
Application.Current.MainWindow,
584-
Strings.DeleteEntry,
584+
Strings.DeleteRule,
585585
string.Format(Strings.DeleteFirewallRuleMessage, SelectedResult.Name),
586586
ChildWindowIcon.Info,
587587
Strings.Delete);
@@ -607,7 +607,7 @@ private async Task DeleteEntry()
607607
/// Returns <see langword="true"/> when the application is running as administrator,
608608
/// no dialog is open, and no child window is open — i.e. it is safe to modify a rule.
609609
/// </summary>
610-
private static bool ModifyEntry_CanExecute()
610+
private static bool ModifyRule_CanExecute()
611611
{
612612
return ConfigurationManager.Current.IsAdmin &&
613613
Application.Current.MainWindow != null &&

Source/NETworkManager/Views/FirewallView.xaml

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -102,13 +102,13 @@
102102
SelectedItemsList="{Binding SelectedResults, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}"
103103
RowDetailsVisibilityMode="Collapsed">
104104
<controls:MultiSelectDataGrid.InputBindings>
105-
<KeyBinding Command="{Binding EditEntryCommand}" Key="F2" />
106-
<KeyBinding Command="{Binding DeleteEntryCommand}" Key="Delete" />
105+
<KeyBinding Command="{Binding EditRuleCommand}" Key="F2" />
106+
<KeyBinding Command="{Binding DeleteRuleCommand}" Key="Delete" />
107107
</controls:MultiSelectDataGrid.InputBindings>
108108
<controls:MultiSelectDataGrid.Resources>
109109
<ContextMenu x:Key="RowContextMenu" Opened="ContextMenu_Opened" MinWidth="150">
110-
<MenuItem Header="{x:Static localization:Strings.EnableEntry}"
111-
Command="{Binding Path=EnableEntryCommand}"
110+
<MenuItem Header="{x:Static localization:Strings.EnableRule}"
111+
Command="{Binding Path=EnableRuleCommand}"
112112
Visibility="{Binding Path=SelectedResult.IsEnabled, Converter={StaticResource BooleanReverseToVisibilityCollapsedConverter}}">
113113
<MenuItem.Icon>
114114
<Rectangle Width="16" Height="16" Fill="{DynamicResource MahApps.Brushes.Gray3}">
@@ -118,8 +118,8 @@
118118
</Rectangle>
119119
</MenuItem.Icon>
120120
</MenuItem>
121-
<MenuItem Header="{x:Static localization:Strings.DisableEntry}"
122-
Command="{Binding Path=DisableEntryCommand}"
121+
<MenuItem Header="{x:Static localization:Strings.DisableRule}"
122+
Command="{Binding Path=DisableRuleCommand}"
123123
Visibility="{Binding Path=SelectedResult.IsEnabled, Converter={StaticResource BooleanToVisibilityCollapsedConverter}}">
124124
<MenuItem.Icon>
125125
<Rectangle Width="16" Height="16" Fill="{DynamicResource MahApps.Brushes.Gray3}">
@@ -129,8 +129,8 @@
129129
</Rectangle>
130130
</MenuItem.Icon>
131131
</MenuItem>
132-
<MenuItem Header="{x:Static localization:Strings.EditEntryDots}"
133-
Command="{Binding Path=EditEntryCommand}">
132+
<MenuItem Header="{x:Static localization:Strings.EditRuleDots}"
133+
Command="{Binding Path=EditRuleCommand}">
134134
<MenuItem.Icon>
135135
<Rectangle Width="16" Height="16" Fill="{DynamicResource MahApps.Brushes.Gray3}">
136136
<Rectangle.OpacityMask>
@@ -139,8 +139,8 @@
139139
</Rectangle>
140140
</MenuItem.Icon>
141141
</MenuItem>
142-
<MenuItem Header="{x:Static localization:Strings.DeleteEntry}"
143-
Command="{Binding Path=DeleteEntryCommand}">
142+
<MenuItem Header="{x:Static localization:Strings.DeleteRule}"
143+
Command="{Binding Path=DeleteRuleCommand}">
144144
<MenuItem.Icon>
145145
<Rectangle Width="16" Height="16" Fill="{DynamicResource MahApps.Brushes.Gray3}">
146146
<Rectangle.OpacityMask>
@@ -538,7 +538,7 @@
538538
Orientation="Horizontal"
539539
VerticalAlignment="Center"
540540
HorizontalAlignment="Right">
541-
<Button Command="{Binding Path=AddEntryCommand}" Style="{StaticResource ImageWithTextButton}">
541+
<Button Command="{Binding Path=AddRuleCommand}" Style="{StaticResource ImageWithTextButton}">
542542
<Grid>
543543
<Grid.ColumnDefinitions>
544544
<ColumnDefinition Width="Auto" />
@@ -550,7 +550,7 @@
550550
</Rectangle.OpacityMask>
551551
</Rectangle>
552552
<TextBlock Grid.Column="1"
553-
Text="{x:Static localization:Strings.AddEntryDots}"
553+
Text="{x:Static localization:Strings.AddRuleDots}"
554554
Style="{StaticResource ButtonWithImageTextBlock}" />
555555
</Grid>
556556
</Button>

Website/docs/application/arp-table.md

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,3 +32,50 @@ With `F5` you can refresh the ARP table.
3232
Right-click on the result to delete an entry, or to copy or export the information.
3333

3434
:::
35+
36+
## Add entry
37+
38+
The **Add entry** dialog is opened by clicking the **Add entry...** button below the table. It creates a new static ARP entry that maps an IP address to a MAC address.
39+
40+
![Add entry](../img/arp-table-entry.png)
41+
42+
### IP address
43+
44+
IPv4 address of the device.
45+
46+
**Type:** `String`
47+
48+
**Default:** `Empty`
49+
50+
**Example:** `10.0.0.10`
51+
52+
:::note
53+
54+
Only IPv4 addresses are accepted. The field is required and validated for a correct address format.
55+
56+
:::
57+
58+
### MAC address
59+
60+
MAC address of the device the [IP address](#ip-address) should be mapped to.
61+
62+
**Type:** `String`
63+
64+
**Default:** `Empty`
65+
66+
**Example:**
67+
68+
- `00:1A:2B:3C:4D:5E`
69+
- `00-1A-2B-3C-4D-5E`
70+
71+
:::note
72+
73+
The field is required and validated for a correct MAC address format.
74+
75+
:::
76+
77+
:::note
78+
79+
Adding a static ARP entry requires administrator privileges and runs `arp -s` under the hood.
80+
81+
:::

0 commit comments

Comments
 (0)