Skip to content

Commit b92a304

Browse files
committed
Feature: Firewall add / edit firewall rule
1 parent a144800 commit b92a304

15 files changed

Lines changed: 1109 additions & 33 deletions

File tree

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

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

Source/NETworkManager.Localization/Resources/StaticStrings.resx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -342,4 +342,10 @@
342342
<data name="ExampleFqdnOrIPAddressPublic" xml:space="preserve">
343343
<value>borntoberoot.net or 1.1.1.1</value>
344344
</data>
345+
<data name="ExampleFirewallRuleName" xml:space="preserve">
346+
<value>MyApp - HTTP</value>
347+
</data>
348+
<data name="ExampleFirewallAddresses" xml:space="preserve">
349+
<value>192.168.1.0/24; LocalSubnet</value>
350+
</data>
345351
</root>

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

Lines changed: 45 additions & 0 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: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4135,4 +4135,19 @@ You can copy your profile files from “{0}” to “{1}” to migrate your exis
41354135
<data name="FirewallRuleAction_Allow" xml:space="preserve">
41364136
<value>Allow</value>
41374137
</data>
4138+
<data name="LocalPorts" xml:space="preserve">
4139+
<value>Local ports</value>
4140+
</data>
4141+
<data name="RemotePorts" xml:space="preserve">
4142+
<value>Remote ports</value>
4143+
</data>
4144+
<data name="LocalAddresses" xml:space="preserve">
4145+
<value>Local addresses</value>
4146+
</data>
4147+
<data name="RemoteAddresses" xml:space="preserve">
4148+
<value>Remote addresses</value>
4149+
</data>
4150+
<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>
4152+
</data>
41384153
</root>

Source/NETworkManager.Models/Firewall/Firewall.cs

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
using System;
22
using System.Collections.Generic;
3+
using System.Linq;
34
using System.Management.Automation.Runspaces;
5+
using System.Text;
46
using System.Threading;
57
using System.Threading.Tasks;
68
using SMA = System.Management.Automation;
@@ -231,6 +233,142 @@ await Task.Run(() =>
231233
}
232234
}
233235

236+
/// <summary>
237+
/// Creates a new Windows Firewall rule with the properties specified in <paramref name="rule"/>.
238+
/// The rule's <see cref="FirewallRule.Name"/> is prefixed with <see cref="RuleIdentifier"/> so
239+
/// it is picked up by <see cref="GetRulesAsync"/> on the next refresh.
240+
/// </summary>
241+
/// <param name="rule">
242+
/// The firewall rule to create.
243+
/// </param>
244+
/// <exception cref="Exception">
245+
/// Thrown when the PowerShell pipeline reports one or more errors.
246+
/// </exception>
247+
public static async Task AddRuleAsync(FirewallRule rule)
248+
{
249+
await Lock.WaitAsync();
250+
try
251+
{
252+
await Task.Run(() =>
253+
{
254+
using var ps = SMA.PowerShell.Create();
255+
ps.Runspace = SharedRunspace;
256+
257+
ps.AddScript(BuildAddScript(rule));
258+
ps.Invoke();
259+
260+
if (ps.Streams.Error.Count > 0)
261+
throw new Exception(string.Join("; ", ps.Streams.Error));
262+
});
263+
}
264+
finally
265+
{
266+
Lock.Release();
267+
}
268+
}
269+
270+
/// <summary>
271+
/// Builds the PowerShell script that calls <c>New-NetFirewallRule</c> with all
272+
/// properties from <paramref name="rule"/>.
273+
/// </summary>
274+
/// <param name="rule">
275+
/// The firewall rule whose properties are used to build the script.
276+
/// </param>
277+
private static string BuildAddScript(FirewallRule rule)
278+
{
279+
var sb = new StringBuilder();
280+
sb.AppendLine("$params = @{");
281+
sb.AppendLine($" DisplayName = '{RuleIdentifier}{EscapePs(rule.Name)}'");
282+
sb.AppendLine($" Enabled = '{(rule.IsEnabled ? "True" : "False")}'");
283+
sb.AppendLine($" Direction = '{rule.Direction}'");
284+
sb.AppendLine($" Action = '{rule.Action}'");
285+
sb.AppendLine($" Protocol = '{GetProtocolString(rule.Protocol)}'");
286+
sb.AppendLine($" InterfaceType = '{GetInterfaceTypeString(rule.InterfaceType)}'");
287+
sb.AppendLine($" Profile = '{GetProfileString(rule.NetworkProfiles)}'");
288+
sb.AppendLine("}");
289+
290+
if (!string.IsNullOrWhiteSpace(rule.Description))
291+
sb.AppendLine($"$params['Description'] = '{EscapePs(rule.Description)}'");
292+
293+
if (rule.Protocol is FirewallProtocol.TCP or FirewallProtocol.UDP)
294+
{
295+
if (rule.LocalPorts.Count > 0)
296+
sb.AppendLine($"$params['LocalPort'] = '{FirewallRule.PortsToString(rule.LocalPorts, ',', false)}'");
297+
298+
if (rule.RemotePorts.Count > 0)
299+
sb.AppendLine($"$params['RemotePort'] = '{FirewallRule.PortsToString(rule.RemotePorts, ',', false)}'");
300+
}
301+
302+
if (rule.LocalAddresses.Count > 0)
303+
sb.AppendLine($"$params['LocalAddress'] = '{string.Join(',', rule.LocalAddresses.Select(EscapePs))}'");
304+
305+
if (rule.RemoteAddresses.Count > 0)
306+
sb.AppendLine($"$params['RemoteAddress'] = '{string.Join(',', rule.RemoteAddresses.Select(EscapePs))}'");
307+
308+
if (rule.Program != null && !string.IsNullOrWhiteSpace(rule.Program.Name))
309+
sb.AppendLine($"$params['Program'] = '{EscapePs(rule.Program.Name)}'");
310+
311+
sb.AppendLine("New-NetFirewallRule @params");
312+
313+
return sb.ToString();
314+
}
315+
316+
/// <summary>
317+
/// Escapes a string for embedding inside a PowerShell single-quoted string by
318+
/// doubling any single-quote characters.
319+
/// </summary>
320+
/// <param name="value">The raw string value to escape.</param>
321+
private static string EscapePs(string value) => value.Replace("'", "''");
322+
323+
/// <summary>
324+
/// Maps a <see cref="FirewallProtocol"/> value to the string accepted by
325+
/// <c>New-NetFirewallRule -Protocol</c>.
326+
/// </summary>
327+
/// <param name="protocol">The protocol to convert.</param>
328+
private static string GetProtocolString(FirewallProtocol protocol) => protocol switch
329+
{
330+
FirewallProtocol.Any => "Any",
331+
FirewallProtocol.TCP => "TCP",
332+
FirewallProtocol.UDP => "UDP",
333+
FirewallProtocol.ICMPv4 => "ICMPv4",
334+
FirewallProtocol.ICMPv6 => "ICMPv6",
335+
FirewallProtocol.GRE => "GRE",
336+
FirewallProtocol.L2TP => "L2TP",
337+
_ => ((int)protocol).ToString()
338+
};
339+
340+
/// <summary>
341+
/// Maps a <see cref="FirewallInterfaceType"/> value to the string accepted by
342+
/// <c>New-NetFirewallRule -InterfaceType</c>.
343+
/// </summary>
344+
/// <param name="interfaceType">The interface type to convert.</param>
345+
private static string GetInterfaceTypeString(FirewallInterfaceType interfaceType) => interfaceType switch
346+
{
347+
FirewallInterfaceType.Wired => "Wired",
348+
FirewallInterfaceType.Wireless => "Wireless",
349+
FirewallInterfaceType.RemoteAccess => "RemoteAccess",
350+
_ => "Any"
351+
};
352+
353+
/// <summary>
354+
/// Converts the three-element network-profile boolean array (Domain, Private, Public)
355+
/// to the comma-separated profile string accepted by <c>New-NetFirewallRule -Profile</c>.
356+
/// All-false or all-true both map to <c>"Any"</c>.
357+
/// </summary>
358+
/// <param name="profiles">Three-element boolean array (Domain=0, Private=1, Public=2).</param>
359+
private static string GetProfileString(bool[] profiles)
360+
{
361+
if (profiles == null || profiles.Length < 3 || profiles.All(p => p) || profiles.All(p => !p))
362+
return "Any";
363+
364+
var parts = new List<string>(3);
365+
if (profiles[0]) parts.Add("Domain");
366+
if (profiles[1]) parts.Add("Private");
367+
if (profiles[2]) parts.Add("Public");
368+
369+
return parts.Count == 0 ? "Any" : string.Join(",", parts);
370+
}
371+
234372
/// <summary>
235373
/// Parses a PowerShell direction string (e.g. <c>"Outbound"</c>) to a
236374
/// <see cref="FirewallRuleDirection"/> value. Defaults to <see cref="FirewallRuleDirection.Inbound"/>.
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
using System;
2+
using System.Globalization;
3+
using System.Net;
4+
using System.Windows.Controls;
5+
using NETworkManager.Localization.Resources;
6+
7+
namespace NETworkManager.Validators;
8+
9+
/// <summary>
10+
/// Validates that the input is empty (meaning "Any") or contains semicolon-separated
11+
/// valid IPv4/IPv6 addresses, CIDR subnets, or recognized Windows Firewall keywords
12+
/// (e.g. LocalSubnet, Internet, Intranet, DNS, DHCP, WINS, DefaultGateway).
13+
/// </summary>
14+
public class EmptyOrFirewallAddressValidator : ValidationRule
15+
{
16+
private static readonly string[] Keywords =
17+
[
18+
"Any", "LocalSubnet", "Internet", "Intranet", "DNS", "DHCP", "WINS", "DefaultGateway"
19+
];
20+
21+
/// <inheritdoc />
22+
public override ValidationResult Validate(object value, CultureInfo cultureInfo)
23+
{
24+
if (string.IsNullOrEmpty(value as string))
25+
return ValidationResult.ValidResult;
26+
27+
foreach (var entry in ((string)value).Split(';'))
28+
{
29+
var token = entry.Trim();
30+
31+
if (string.IsNullOrEmpty(token))
32+
continue;
33+
34+
if (Array.Exists(Keywords, k => k.Equals(token, StringComparison.OrdinalIgnoreCase)))
35+
continue;
36+
37+
var slashIndex = token.IndexOf('/');
38+
var addressPart = slashIndex > 0 ? token[..slashIndex] : token;
39+
40+
if (!IPAddress.TryParse(addressPart, out _))
41+
return new ValidationResult(false, Strings.EnterValidFirewallAddress);
42+
}
43+
44+
return ValidationResult.ValidResult;
45+
}
46+
}

0 commit comments

Comments
 (0)