From 596c32c5d4d07ae6940e1c755ef0150344db9081 Mon Sep 17 00:00:00 2001 From: "Steve Lee (POWERSHELL HE/HIM) (from Dev Box)" Date: Fri, 27 Mar 2026 15:47:31 -0700 Subject: [PATCH 1/2] Add `Microsoft.Windows/FirewallRuleList` resource --- Cargo.lock | 10 + Cargo.toml | 4 + lib/dsc-lib-jsonschema/.versions.json | 3 +- resources/windows_firewall/.project.data.json | 14 + resources/windows_firewall/Cargo.toml | 17 + resources/windows_firewall/locales/en-us.toml | 37 ++ resources/windows_firewall/src/firewall.rs | 434 ++++++++++++++++++ resources/windows_firewall/src/main.rs | 141 ++++++ resources/windows_firewall/src/types.rs | 121 +++++ resources/windows_firewall/src/util.rs | 112 +++++ .../tests/windows_firewall_export.tests.ps1 | 98 ++++ .../tests/windows_firewall_get.tests.ps1 | 82 ++++ .../tests/windows_firewall_set.tests.ps1 | 147 ++++++ .../windows_firewall.dsc.resource.json | 186 ++++++++ 14 files changed, 1405 insertions(+), 1 deletion(-) create mode 100644 resources/windows_firewall/.project.data.json create mode 100644 resources/windows_firewall/Cargo.toml create mode 100644 resources/windows_firewall/locales/en-us.toml create mode 100644 resources/windows_firewall/src/firewall.rs create mode 100644 resources/windows_firewall/src/main.rs create mode 100644 resources/windows_firewall/src/types.rs create mode 100644 resources/windows_firewall/src/util.rs create mode 100644 resources/windows_firewall/tests/windows_firewall_export.tests.ps1 create mode 100644 resources/windows_firewall/tests/windows_firewall_get.tests.ps1 create mode 100644 resources/windows_firewall/tests/windows_firewall_set.tests.ps1 create mode 100644 resources/windows_firewall/windows_firewall.dsc.resource.json diff --git a/Cargo.lock b/Cargo.lock index 356f1822c..cac351fc4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4293,6 +4293,16 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" +[[package]] +name = "windows_firewall" +version = "0.1.0" +dependencies = [ + "rust-i18n", + "serde", + "serde_json", + "windows 0.62.2", +] + [[package]] name = "windows_i686_gnu" version = "0.52.6" diff --git a/Cargo.toml b/Cargo.toml index 268ac4343..afa53497b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,6 +21,7 @@ members = [ "resources/sshdconfig", "resources/WindowsUpdate", "resources/windows_service", + "resources/windows_firewall", "tools/dsctest", "tools/test_group_resource", "grammars/tree-sitter-dscexpression", @@ -51,6 +52,7 @@ default-members = [ "resources/sshdconfig", "resources/WindowsUpdate", "resources/windows_service", + "resources/windows_firewall", "tools/dsctest", "tools/test_group_resource", "grammars/tree-sitter-dscexpression", @@ -81,6 +83,7 @@ Windows = [ "resources/sshdconfig", "resources/WindowsUpdate", "resources/windows_service", + "resources/windows_firewall", "tools/dsctest", "tools/test_group_resource", "grammars/tree-sitter-dscexpression", @@ -258,6 +261,7 @@ ipnetwork = { version = "0.21" } # WindowsUpdate, windows_service windows = { version = "0.62", features = [ "Win32_Foundation", + "Win32_NetworkManagement_WindowsFirewall", "Win32_System_Com", "Win32_System_Ole", "Win32_System_Services", diff --git a/lib/dsc-lib-jsonschema/.versions.json b/lib/dsc-lib-jsonschema/.versions.json index 1d0158058..b3c3d0a1a 100644 --- a/lib/dsc-lib-jsonschema/.versions.json +++ b/lib/dsc-lib-jsonschema/.versions.json @@ -1,10 +1,11 @@ { "latestMajor": "V3", "latestMinor": "V3_1", - "latestPatch": "V3_1_2", + "latestPatch": "V3_1_3", "all": [ "V3", "V3_1", + "V3_1_3", "V3_1_2", "V3_1_1", "V3_1_0", diff --git a/resources/windows_firewall/.project.data.json b/resources/windows_firewall/.project.data.json new file mode 100644 index 000000000..c4bf939b0 --- /dev/null +++ b/resources/windows_firewall/.project.data.json @@ -0,0 +1,14 @@ +{ + "Name": "windows_firewall", + "Kind": "Resource", + "IsRust": true, + "SupportedPlatformOS": "Windows", + "Binaries": [ + "windows_firewall" + ], + "CopyFiles": { + "Windows": [ + "windows_firewall.dsc.resource.json" + ] + } +} diff --git a/resources/windows_firewall/Cargo.toml b/resources/windows_firewall/Cargo.toml new file mode 100644 index 000000000..66ee2c64e --- /dev/null +++ b/resources/windows_firewall/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "windows_firewall" +version = "0.1.0" +edition = "2024" + +[package.metadata.i18n] +available-locales = ["en-us"] +default-locale = "en-us" +load-path = "locales" + +[dependencies] +rust-i18n = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } + +[target.'cfg(windows)'.dependencies] +windows = { workspace = true } diff --git a/resources/windows_firewall/locales/en-us.toml b/resources/windows_firewall/locales/en-us.toml new file mode 100644 index 000000000..8c6288f40 --- /dev/null +++ b/resources/windows_firewall/locales/en-us.toml @@ -0,0 +1,37 @@ +_version = 1 + +[main] +missingOperation = "Missing operation. Usage: windows_firewall get --input | set --input | export [--input ]" +unknownOperation = "Unknown operation: '%{operation}'. Expected: get, set, or export" +missingInput = "Missing --input argument" +missingInputValue = "Missing value for --input argument" +invalidJson = "Invalid JSON input: %{error}" +windowsOnly = "This resource is only supported on Windows" + +[get] +rulesArrayEmpty = "The rules array cannot be empty for get operations" +selectorRequired = "Each firewall rule in a get request must include a name" +failedSerializeOutput = "Failed to serialize get output: %{error}" + +[set] +rulesArrayEmpty = "The rules array cannot be empty for set operations" +selectorRequired = "Each firewall rule in a set request must include a name" +failedSerializeOutput = "Failed to serialize set output: %{error}" + +[export] +failedSerializeOutput = "Failed to serialize export output: %{error}" + +[firewall] +comInitFailed = "Failed to initialize COM for Windows Firewall access: %{error}" +policyOpenFailed = "Failed to open the Windows Firewall policy: %{error}" +ruleEnumerationFailed = "Failed to enumerate Windows Firewall rules: %{error}" +ruleLookupFailed = "Failed to look up firewall rule '%{name}': %{error}" +ruleCreateFailed = "Failed to create a firewall rule object: %{error}" +ruleAddFailed = "Failed to add firewall rule '%{name}': %{error}" +ruleRemoveFailed = "Failed to remove firewall rule '%{name}': %{error}" +ruleUpdateFailed = "Failed to update firewall rule '%{name}': %{error}" +ruleReadFailed = "Failed to read firewall rule '%{name}': %{error}" +portsNotAllowed = "Ports cannot be specified for firewall rule '%{name}' because protocol %{protocol} does not support ports" +invalidProfiles = "Invalid profiles value '%{value}'. Valid values are Domain, Private, Public, or All" +invalidInterfaceType = "Invalid interface type '%{value}'. Valid values are RemoteAccess, Wireless, Lan, or All" +invalidProtocol = "Invalid protocol number '%{value}'. Must be between 0 and 256" diff --git a/resources/windows_firewall/src/firewall.rs b/resources/windows_firewall/src/firewall.rs new file mode 100644 index 000000000..c384f114a --- /dev/null +++ b/resources/windows_firewall/src/firewall.rs @@ -0,0 +1,434 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use rust_i18n::t; +use windows::core::{BSTR, Interface}; +use windows::core::HRESULT; +use windows::Win32::Foundation::{S_FALSE, VARIANT_BOOL}; +use windows::Win32::NetworkManagement::WindowsFirewall::*; +use windows::Win32::System::Com::{CLSCTX_INPROC_SERVER, CoCreateInstance, CoInitializeEx, CoUninitialize, IDispatch, COINIT_APARTMENTTHREADED}; +use windows::Win32::System::Ole::IEnumVARIANT; +use windows::Win32::System::Variant::VARIANT; + +use crate::types::{FirewallError, FirewallRule, FirewallRuleList, RuleAction, RuleDirection}; +use crate::util::matches_any_filter; + +struct ComGuard; + +impl ComGuard { + fn new() -> Result { + unsafe { CoInitializeEx(None, COINIT_APARTMENTTHREADED) } + .ok() + .map_err(|error| t!("firewall.comInitFailed", error = error.to_string()).to_string())?; + Ok(Self) + } +} + +impl Drop for ComGuard { + fn drop(&mut self) { + unsafe { CoUninitialize() }; + } +} + +struct FirewallStore { + rules: INetFwRules, + _com: ComGuard, +} + +impl FirewallStore { + fn open() -> Result { + let com = ComGuard::new()?; + let policy: INetFwPolicy2 = unsafe { CoCreateInstance(&NetFwPolicy2, None, CLSCTX_INPROC_SERVER) } + .map_err(|error| t!("firewall.policyOpenFailed", error = error.to_string()).to_string())?; + let rules = unsafe { policy.Rules() } + .map_err(|error| t!("firewall.policyOpenFailed", error = error.to_string()).to_string())?; + Ok(Self { rules, _com: com }) + } + + fn enumerate_rules(&self) -> Result, FirewallError> { + let enumerator = unsafe { self.rules._NewEnum() } + .map_err(|error| t!("firewall.ruleEnumerationFailed", error = error.to_string()).to_string())?; + let enum_variant: IEnumVARIANT = enumerator + .cast() + .map_err(|error| t!("firewall.ruleEnumerationFailed", error = error.to_string()).to_string())?; + + let mut results = Vec::new(); + loop { + let mut fetched = 0u32; + let mut variant = [VARIANT::default()]; + let hr = unsafe { enum_variant.Next(&mut variant, &mut fetched) }; + if hr == S_FALSE || fetched == 0 { + break; + } + hr.ok() + .map_err(|error| t!("firewall.ruleEnumerationFailed", error = error.to_string()).to_string())?; + + let dispatch = IDispatch::try_from(&variant[0]) + .map_err(|error: windows::core::Error| t!("firewall.ruleEnumerationFailed", error = error.to_string()).to_string())?; + let rule: INetFwRule = dispatch + .cast() + .map_err(|error| t!("firewall.ruleEnumerationFailed", error = error.to_string()).to_string())?; + results.push(rule); + } + + Ok(results) + } + + fn find_by_selector(&self, selector: &FirewallRule) -> Result, FirewallError> { + // HRESULT 0x80070002 is HRESULT_FROM_WIN32(ERROR_FILE_NOT_FOUND), returned when the + // rule name does not match any existing rule. + const HRESULT_FILE_NOT_FOUND: HRESULT = HRESULT(0x80070002_u32 as i32); + + let Some(lookup_name) = selector.selector_name() else { + return Ok(None); + }; + + match unsafe { self.rules.Item(&BSTR::from(lookup_name)) } { + Ok(rule) => Ok(Some(rule)), + Err(e) if e.code() == HRESULT_FILE_NOT_FOUND => Ok(None), + Err(e) => Err(t!("firewall.ruleLookupFailed", name = lookup_name, error = e.to_string()).to_string().into()), + } + } + + fn remove_rule(&self, rule_name: &str) -> Result<(), FirewallError> { + unsafe { self.rules.Remove(&BSTR::from(rule_name)) } + .map_err(|error| t!("firewall.ruleRemoveFailed", name = rule_name, error = error.to_string()).to_string())?; + Ok(()) + } + + fn create_rule_object(&self) -> Result { + unsafe { CoCreateInstance(&NetFwRule, None, CLSCTX_INPROC_SERVER) } + .map_err(|error| t!("firewall.ruleCreateFailed", error = error.to_string()).to_string().into()) + } +} + +fn bstr_to_option(value: BSTR) -> Result, FirewallError> { + let text = value.to_string(); + if text.is_empty() { + Ok(None) + } else { + Ok(Some(text)) + } +} + +fn native_direction_to_model(direction: NET_FW_RULE_DIRECTION) -> Option { + if direction == NET_FW_RULE_DIR_IN { + Some(RuleDirection::Inbound) + } else if direction == NET_FW_RULE_DIR_OUT { + Some(RuleDirection::Outbound) + } else { + None + } +} + +fn model_direction_to_native(direction: &RuleDirection) -> NET_FW_RULE_DIRECTION { + match direction { + RuleDirection::Inbound => NET_FW_RULE_DIR_IN, + RuleDirection::Outbound => NET_FW_RULE_DIR_OUT, + } +} + +fn native_action_to_model(action: NET_FW_ACTION) -> Option { + if action == NET_FW_ACTION_ALLOW { + Some(RuleAction::Allow) + } else if action == NET_FW_ACTION_BLOCK { + Some(RuleAction::Block) + } else { + None + } +} + +fn model_action_to_native(action: &RuleAction) -> NET_FW_ACTION { + match action { + RuleAction::Allow => NET_FW_ACTION_ALLOW, + RuleAction::Block => NET_FW_ACTION_BLOCK, + } +} + +/// Converts a bitmask of firewall profile flags to a list of profile name strings. +/// +/// `NET_FW_PROFILE2_ALL` is `0x7FFFFFFF` (a sentinel meaning all profiles), while the +/// combination of the three individual profile bits is `0x7`. Both are normalized to `["All"]`. +fn profiles_from_mask(mask: i32) -> Vec { + let all_bits = NET_FW_PROFILE2_DOMAIN.0 | NET_FW_PROFILE2_PRIVATE.0 | NET_FW_PROFILE2_PUBLIC.0; + if mask == NET_FW_PROFILE2_ALL.0 || mask == all_bits { + return vec!["All".to_string()]; + } + + let mut profiles = Vec::new(); + if mask & NET_FW_PROFILE2_DOMAIN.0 != 0 { + profiles.push("Domain".to_string()); + } + if mask & NET_FW_PROFILE2_PRIVATE.0 != 0 { + profiles.push("Private".to_string()); + } + if mask & NET_FW_PROFILE2_PUBLIC.0 != 0 { + profiles.push("Public".to_string()); + } + profiles +} + +fn profiles_to_mask(values: &[String]) -> Result { + let mut mask = 0; + for value in values { + match value.to_ascii_lowercase().as_str() { + "all" => return Ok(NET_FW_PROFILE2_ALL.0), + "domain" => mask |= NET_FW_PROFILE2_DOMAIN.0, + "private" => mask |= NET_FW_PROFILE2_PRIVATE.0, + "public" => mask |= NET_FW_PROFILE2_PUBLIC.0, + _ => return Err(t!("firewall.invalidProfiles", value = value).to_string().into()), + } + } + Ok(mask) +} + +fn split_csv(value: Option) -> Option> { + value.map(|raw| { + raw.split(',') + .map(str::trim) + .filter(|entry| !entry.is_empty()) + .map(ToOwned::to_owned) + .collect::>() + }).filter(|items| !items.is_empty()) +} + +fn join_csv(value: &[String]) -> String { + value.join(",") +} + +fn interface_types_to_string(values: &[String]) -> Result { + let mut normalized = Vec::new(); + for value in values { + match value.to_ascii_lowercase().as_str() { + "all" => return Ok("All".to_string()), + "remoteaccess" => normalized.push("RemoteAccess".to_string()), + "wireless" => normalized.push("Wireless".to_string()), + "lan" => normalized.push("Lan".to_string()), + _ => return Err(t!("firewall.invalidInterfaceType", value = value).to_string().into()), + } + } + Ok(join_csv(&normalized)) +} + +fn protocol_supports_ports(protocol: i32) -> bool { + protocol == NET_FW_IP_PROTOCOL_TCP.0 || protocol == NET_FW_IP_PROTOCOL_UDP.0 +} + +fn validate_protocol(protocol: i32) -> Result<(), FirewallError> { + // IANA protocol numbers 0-255 plus the Windows-specific 256 (Any) + if !(0..=256).contains(&protocol) { + return Err(t!("firewall.invalidProtocol", value = protocol).to_string().into()); + } + Ok(()) +} + +fn map_update_err(name: &str) -> impl Fn(windows::core::Error) -> FirewallError + '_ { + move |error| t!("firewall.ruleUpdateFailed", name = name, error = error.to_string()).to_string().into() +} + +fn map_read_err(name: &str) -> impl Fn(windows::core::Error) -> FirewallError + '_ { + move |error| t!("firewall.ruleReadFailed", name = name, error = error.to_string()).to_string().into() +} + +fn rule_to_model(rule: &INetFwRule) -> Result { + let name = unsafe { rule.Name() }.map_err(|error| t!("firewall.ruleReadFailed", name = "", error = error.to_string()).to_string())?; + let name = name.to_string(); + let err = map_read_err(&name); + let profiles = profiles_from_mask(unsafe { rule.Profiles() }.map_err(&err)?); + + Ok(FirewallRule { + name: Some(name.clone()), + exist: None, + description: bstr_to_option(unsafe { rule.Description() }.map_err(&err)?)?, + application_name: bstr_to_option(unsafe { rule.ApplicationName() }.map_err(&err)?)?, + service_name: bstr_to_option(unsafe { rule.ServiceName() }.map_err(&err)?)?, + protocol: Some(unsafe { rule.Protocol() }.map_err(&err)?), + local_ports: bstr_to_option(unsafe { rule.LocalPorts() }.map_err(&err)?)?, + remote_ports: bstr_to_option(unsafe { rule.RemotePorts() }.map_err(&err)?)?, + local_addresses: bstr_to_option(unsafe { rule.LocalAddresses() }.map_err(&err)?)?, + remote_addresses: bstr_to_option(unsafe { rule.RemoteAddresses() }.map_err(&err)?)?, + direction: native_direction_to_model(unsafe { rule.Direction() }.map_err(&err)?), + action: native_action_to_model(unsafe { rule.Action() }.map_err(&err)?), + enabled: Some(unsafe { rule.Enabled() }.map_err(&err)?.as_bool()), + profiles: Some(profiles), + grouping: bstr_to_option(unsafe { rule.Grouping() }.map_err(&err)?)?, + interface_types: split_csv(bstr_to_option(unsafe { rule.InterfaceTypes() }.map_err(&err)?)?), + edge_traversal: Some(unsafe { rule.EdgeTraversal() }.map_err(&err)?.as_bool()), + }) +} + +fn apply_rule_properties(rule: &INetFwRule, desired: &FirewallRule, existing_protocol: Option) -> Result<(), FirewallError> { + let name = desired.selector_name().unwrap_or(""); + let err = map_update_err(name); + + if let Some(protocol) = desired.protocol { + validate_protocol(protocol)?; + + // Reject port specifications for protocols that don't support them (e.g. ICMP). + // On existing rules the ports are cleared automatically, but on new rules + // (existing_protocol == None) the conflicting SetLocalPorts/SetRemotePorts call + // would fail with a confusing COM error. + if !protocol_supports_ports(protocol) && existing_protocol.is_none() + && (desired.local_ports.is_some() || desired.remote_ports.is_some()) + { + return Err(t!("firewall.portsNotAllowed", name = name, protocol = protocol).to_string().into()); + } + + if let Some(current_protocol) = existing_protocol + && current_protocol != protocol && !protocol_supports_ports(protocol) { + if desired.local_ports.is_none() { + unsafe { rule.SetLocalPorts(&BSTR::from("")) }.map_err(&err)?; + } + if desired.remote_ports.is_none() { + unsafe { rule.SetRemotePorts(&BSTR::from("")) }.map_err(&err)?; + } + } + unsafe { rule.SetProtocol(protocol) }.map_err(&err)?; + } + + if let Some(description) = desired.description.as_ref() { + unsafe { rule.SetDescription(&BSTR::from(description.as_str())) }.map_err(&err)?; + } + if let Some(application_name) = desired.application_name.as_ref() { + unsafe { rule.SetApplicationName(&BSTR::from(application_name.as_str())) }.map_err(&err)?; + } + if let Some(service_name) = desired.service_name.as_ref() { + unsafe { rule.SetServiceName(&BSTR::from(service_name.as_str())) }.map_err(&err)?; + } + if let Some(local_ports) = desired.local_ports.as_ref() { + unsafe { rule.SetLocalPorts(&BSTR::from(local_ports.as_str())) }.map_err(&err)?; + } + if let Some(remote_ports) = desired.remote_ports.as_ref() { + unsafe { rule.SetRemotePorts(&BSTR::from(remote_ports.as_str())) }.map_err(&err)?; + } + if let Some(local_addresses) = desired.local_addresses.as_ref() { + unsafe { rule.SetLocalAddresses(&BSTR::from(local_addresses.as_str())) }.map_err(&err)?; + } + if let Some(remote_addresses) = desired.remote_addresses.as_ref() { + unsafe { rule.SetRemoteAddresses(&BSTR::from(remote_addresses.as_str())) }.map_err(&err)?; + } + if let Some(direction) = desired.direction.as_ref() { + unsafe { rule.SetDirection(model_direction_to_native(direction)) }.map_err(&err)?; + } + if let Some(action) = desired.action.as_ref() { + unsafe { rule.SetAction(model_action_to_native(action)) }.map_err(&err)?; + } + if let Some(enabled) = desired.enabled { + unsafe { rule.SetEnabled(VARIANT_BOOL::from(enabled)) }.map_err(&err)?; + } + if let Some(profiles) = desired.profiles.as_ref() { + let mask = profiles_to_mask(profiles)?; + unsafe { rule.SetProfiles(mask) }.map_err(&err)?; + } + if let Some(grouping) = desired.grouping.as_ref() { + unsafe { rule.SetGrouping(&BSTR::from(grouping.as_str())) }.map_err(&err)?; + } + if let Some(interface_types) = desired.interface_types.as_ref() { + let value = interface_types_to_string(interface_types)?; + unsafe { rule.SetInterfaceTypes(&BSTR::from(value.as_str())) }.map_err(&err)?; + } + if let Some(edge_traversal) = desired.edge_traversal { + unsafe { rule.SetEdgeTraversal(VARIANT_BOOL::from(edge_traversal)) }.map_err(&err)?; + } + + Ok(()) +} + +pub fn get_rules(input: &FirewallRuleList) -> Result { + if input.rules.is_empty() { + return Err(t!("get.rulesArrayEmpty").to_string().into()); + } + + let store = FirewallStore::open()?; + let mut results = Vec::new(); + + for desired in &input.rules { + if desired.selector_name().is_none() { + return Err(t!("get.selectorRequired").to_string().into()); + } + + match store.find_by_selector(desired)? { + Some(rule) => results.push(rule_to_model(&rule)?), + None => results.push(desired.missing_from_input()), + } + } + + Ok(FirewallRuleList { rules: results }) +} + +pub fn set_rules(input: &FirewallRuleList) -> Result { + if input.rules.is_empty() { + return Err(t!("set.rulesArrayEmpty").to_string().into()); + } + + let store = FirewallStore::open()?; + let mut results = Vec::new(); + + for desired in &input.rules { + if desired.selector_name().is_none() { + return Err(t!("set.selectorRequired").to_string().into()); + } + + match store.find_by_selector(desired)? { + Some(rule) => { + let current = rule_to_model(&rule)?; + let rule_name = current.name.clone().unwrap_or_else(|| desired.selector_name().unwrap_or_default().to_string()); + + if desired.exist == Some(false) { + store.remove_rule(&rule_name)?; + results.push(desired.missing_from_input()); + continue; + } + + apply_rule_properties(&rule, desired, current.protocol)?; + results.push(rule_to_model(&rule)?); + } + None => { + if desired.exist == Some(false) { + results.push(desired.missing_from_input()); + continue; + } + + let rule_name = desired.name.clone() + .ok_or_else(|| t!("set.selectorRequired").to_string())?; + let rule = store.create_rule_object()?; + unsafe { rule.SetName(&BSTR::from(rule_name.as_str())) } + .map_err(|error| t!("firewall.ruleAddFailed", name = rule_name.as_str(), error = error.to_string()).to_string())?; + + apply_rule_properties(&rule, desired, None)?; + unsafe { store.rules.Add(&rule) } + .map_err(|error| t!("firewall.ruleAddFailed", name = rule_name.as_str(), error = error.to_string()).to_string())?; + + let created = store + .find_by_selector(&FirewallRule { + name: Some(rule_name), + ..FirewallRule::default() + })? + .ok_or_else(|| t!("firewall.ruleLookupFailed", name = desired.selector_name().unwrap_or(""), error = "created rule not found").to_string())?; + results.push(rule_to_model(&created)?); + } + } + } + + Ok(FirewallRuleList { rules: results }) +} + +pub fn export_rules(filters: Option<&FirewallRuleList>) -> Result { + let store = FirewallStore::open()?; + let all_rules = store.enumerate_rules()?; + let default_filter; + let filter_rules: &[FirewallRule] = match filters { + Some(input) if !input.rules.is_empty() => &input.rules, + _ => { default_filter = [FirewallRule::default()]; &default_filter } + }; + + let mut results = Vec::new(); + for rule in all_rules { + let model = rule_to_model(&rule)?; + if matches_any_filter(&model, filter_rules) { + results.push(model); + } + } + + Ok(FirewallRuleList { rules: results }) +} diff --git a/resources/windows_firewall/src/main.rs b/resources/windows_firewall/src/main.rs new file mode 100644 index 000000000..c18093a95 --- /dev/null +++ b/resources/windows_firewall/src/main.rs @@ -0,0 +1,141 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +mod types; +mod util; + +#[cfg(windows)] +mod firewall; + +use rust_i18n::t; +use std::process::exit; + +use types::FirewallRuleList; + +rust_i18n::i18n!("locales", fallback = "en-us"); + +const EXIT_SUCCESS: i32 = 0; +const EXIT_INVALID_ARGS: i32 = 1; +const EXIT_INVALID_INPUT: i32 = 2; +const EXIT_FIREWALL_ERROR: i32 = 3; + +fn write_error(message: &str) { + eprintln!("{}", serde_json::json!({ "error": message })); +} + +fn print_json(value: &impl serde::Serialize) { + match serde_json::to_string(value) { + Ok(json) => println!("{json}"), + Err(error) => { + write_error(&t!("main.invalidJson", error = error.to_string())); + exit(EXIT_FIREWALL_ERROR); + } + } +} + +fn require_input(input_json: Option) -> FirewallRuleList { + let json = match input_json { + Some(json) => json, + None => { + write_error(&t!("main.missingInput")); + exit(EXIT_INVALID_ARGS); + } + }; + + match serde_json::from_str(&json) { + Ok(value) => value, + Err(error) => { + write_error(&t!("main.invalidJson", error = error.to_string())); + exit(EXIT_INVALID_INPUT); + } + } +} + +#[cfg(not(windows))] +fn main() { + write_error(&t!("main.windowsOnly")); + exit(EXIT_FIREWALL_ERROR); +} + +#[cfg(windows)] +fn main() { + let args: Vec = std::env::args().collect(); + if args.len() < 2 { + write_error(&t!("main.missingOperation")); + exit(EXIT_INVALID_ARGS); + } + + let operation = args[1].as_str(); + let input_json = parse_input_arg(&args); + + match operation { + "get" => { + let input = require_input(input_json); + match firewall::get_rules(&input) { + Ok(result) => { + print_json(&result); + exit(EXIT_SUCCESS); + } + Err(error) => { + write_error(&error.to_string()); + exit(EXIT_FIREWALL_ERROR); + } + } + } + "set" => { + let input = require_input(input_json); + match firewall::set_rules(&input) { + Ok(result) => { + print_json(&result); + exit(EXIT_SUCCESS); + } + Err(error) => { + write_error(&error.to_string()); + exit(EXIT_FIREWALL_ERROR); + } + } + } + "export" => { + let filters: Option = match input_json { + Some(json) => match serde_json::from_str(&json) { + Ok(value) => Some(value), + Err(error) => { + write_error(&t!("main.invalidJson", error = error.to_string())); + exit(EXIT_INVALID_INPUT); + } + }, + None => None, + }; + + match firewall::export_rules(filters.as_ref()) { + Ok(result) => { + print_json(&result); + exit(EXIT_SUCCESS); + } + Err(error) => { + write_error(&error.to_string()); + exit(EXIT_FIREWALL_ERROR); + } + } + } + _ => { + write_error(&t!("main.unknownOperation", operation = operation)); + exit(EXIT_INVALID_ARGS); + } + } +} + +fn parse_input_arg(args: &[String]) -> Option { + let mut index = 2; + while index < args.len() { + if args[index] == "--input" || args[index] == "-i" { + if index + 1 < args.len() { + return Some(args[index + 1].clone()); + } + write_error(&t!("main.missingInputValue")); + exit(EXIT_INVALID_ARGS); + } + index += 1; + } + None +} diff --git a/resources/windows_firewall/src/types.rs b/resources/windows_firewall/src/types.rs new file mode 100644 index 000000000..54ac6962a --- /dev/null +++ b/resources/windows_firewall/src/types.rs @@ -0,0 +1,121 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)] +pub enum RuleDirection { + Inbound, + Outbound, +} + +#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)] +pub enum RuleAction { + Allow, + Block, +} + +#[derive(Debug, Default, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct FirewallRuleList { + pub rules: Vec, +} + +#[derive(Debug, Default, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct FirewallRule { + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, + + /// When `_exist` is `None` (omitted from JSON), the rule exists in the firewall store. + /// When `_exist` is `Some(false)`, the rule was not found (get) or should be removed (set). + /// This asymmetry is intentional: absence means "present" so existing rules produce clean + /// output without a redundant `_exist: true` field. + #[serde(rename = "_exist", skip_serializing_if = "Option::is_none")] + pub exist: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub application_name: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub service_name: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub protocol: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub local_ports: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub remote_ports: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub local_addresses: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub remote_addresses: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub direction: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub action: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub enabled: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub profiles: Option>, + + #[serde(skip_serializing_if = "Option::is_none")] + pub grouping: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub interface_types: Option>, + + #[serde(skip_serializing_if = "Option::is_none")] + pub edge_traversal: Option, +} + +impl FirewallRule { + #[must_use] + pub fn missing_from_input(&self) -> Self { + let mut result = self.clone(); + result.exist = Some(false); + result + } + + #[must_use] + pub fn selector_name(&self) -> Option<&str> { + self.name.as_deref() + } +} + +#[derive(Debug)] +pub struct FirewallError { + pub message: String, +} + +impl std::fmt::Display for FirewallError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.message) + } +} + +impl std::error::Error for FirewallError {} + +impl From for FirewallError { + fn from(message: String) -> Self { + Self { message } + } +} + +#[cfg(windows)] +impl From for FirewallError { + fn from(error: windows::core::Error) -> Self { + Self { message: error.to_string() } + } +} diff --git a/resources/windows_firewall/src/util.rs b/resources/windows_firewall/src/util.rs new file mode 100644 index 000000000..213073166 --- /dev/null +++ b/resources/windows_firewall/src/util.rs @@ -0,0 +1,112 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use crate::types::FirewallRule; + +pub fn matches_wildcard(text: &str, pattern: &str) -> bool { + let text_lower = text.to_lowercase(); + let pattern_lower = pattern.to_lowercase(); + + if !pattern_lower.contains('*') { + return text_lower == pattern_lower; + } + + let parts: Vec<&str> = pattern_lower.split('*').collect(); + if !parts[0].is_empty() && !text_lower.starts_with(parts[0]) { + return false; + } + + let mut position = parts[0].len(); + let suffix = *parts.last().unwrap_or(&""); + let end = if suffix.is_empty() { + text_lower.len() + } else { + if !text_lower.ends_with(suffix) { + return false; + } + text_lower.len() - suffix.len() + }; + + for part in &parts[1..parts.len().saturating_sub(1)] { + if part.is_empty() { + continue; + } + match text_lower.get(position..end).and_then(|s| s.find(part)) { + Some(index) => position += index + part.len(), + None => return false, + } + } + + position <= end +} + +fn matches_optional_wildcard(actual: &Option, filter: &Option) -> bool { + match filter { + Some(pattern) => match actual { + Some(value) => matches_wildcard(value, pattern), + None => false, + }, + None => true, + } +} + +fn matches_optional_exact(actual: &Option, filter: &Option) -> bool { + match filter { + Some(expected) => match actual { + Some(value) => value == expected, + None => false, + }, + None => true, + } +} + +fn normalize_string_vec(values: &[String]) -> Vec { + let mut normalized: Vec = values.iter().map(|value| value.to_lowercase()).collect(); + normalized.sort_unstable(); + normalized +} + +fn matches_optional_vec(actual: &Option>, filter: &Option>) -> bool { + match filter { + Some(expected) => match actual { + Some(value) => normalize_string_vec(value) == normalize_string_vec(expected), + None => false, + }, + None => true, + } +} + +pub fn rule_matches_filter(rule: &FirewallRule, filter: &FirewallRule) -> bool { + matches_optional_wildcard(&rule.name, &filter.name) + && matches_optional_wildcard(&rule.description, &filter.description) + && matches_optional_wildcard(&rule.application_name, &filter.application_name) + && matches_optional_wildcard(&rule.service_name, &filter.service_name) + && matches_optional_exact(&rule.protocol, &filter.protocol) + && matches_optional_wildcard(&rule.local_ports, &filter.local_ports) + && matches_optional_wildcard(&rule.remote_ports, &filter.remote_ports) + && matches_optional_wildcard(&rule.local_addresses, &filter.local_addresses) + && matches_optional_wildcard(&rule.remote_addresses, &filter.remote_addresses) + && matches_optional_exact(&rule.direction, &filter.direction) + && matches_optional_exact(&rule.action, &filter.action) + && matches_optional_exact(&rule.enabled, &filter.enabled) + && matches_optional_vec(&rule.profiles, &filter.profiles) + && matches_optional_wildcard(&rule.grouping, &filter.grouping) + && matches_optional_vec(&rule.interface_types, &filter.interface_types) + && matches_optional_exact(&rule.edge_traversal, &filter.edge_traversal) +} + +pub fn matches_any_filter(rule: &FirewallRule, filters: &[FirewallRule]) -> bool { + filters.iter().any(|filter| rule_matches_filter(rule, filter)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn wildcard_matching_is_case_insensitive() { + assert!(matches_wildcard("Firewall-Rule", "firewall-*")); + assert!(matches_wildcard("AllowTCP", "*tcp")); + assert!(!matches_wildcard("AllowUDP", "*tcp")); + } +} diff --git a/resources/windows_firewall/tests/windows_firewall_export.tests.ps1 b/resources/windows_firewall/tests/windows_firewall_export.tests.ps1 new file mode 100644 index 000000000..69072fae0 --- /dev/null +++ b/resources/windows_firewall/tests/windows_firewall_export.tests.ps1 @@ -0,0 +1,98 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +Describe 'Microsoft.Windows/FirewallRuleList - export operation' -Skip:(!$IsWindows) { + BeforeDiscovery { + $isElevated = if ($IsWindows) { + ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole( + [Security.Principal.WindowsBuiltInRole]::Administrator) + } else { + $false + } + } + + BeforeAll { + $resourceType = 'Microsoft.Windows/FirewallRuleList' + + function Invoke-DscExport { + param( + [string]$InputJson + ) + + if ($InputJson) { + $raw = dsc resource export -r $resourceType -i $InputJson 2>$testdrive/error.log + } + else { + $raw = dsc resource export -r $resourceType 2>$testdrive/error.log + } + + return $raw | ConvertFrom-Json + } + + $initialExport = Invoke-DscExport + if ($LASTEXITCODE -ne 0) { + throw "Failed to export firewall rules: $(Get-Content -Raw $testdrive/error.log)" + } + + $sampleRules = $initialExport.resources[0].properties.rules | Select-Object -First 2 name, direction + if ($sampleRules.Count -lt 2) { + throw 'At least two exported firewall rules are required for export tests.' + } + $firstRule = $sampleRules[0] + $secondRule = $sampleRules[1] + } + + It 'exports all rules with no input' -Skip:(!$isElevated) { + $output = Invoke-DscExport + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) + + $rules = $output.resources[0].properties.rules + $rules | Should -Not -BeNullOrEmpty + $rules.Count | Should -BeGreaterThan 0 + $rules[0].name | Should -Not -BeNullOrEmpty + } + + It 'applies AND logic within a single filter object' -Skip:(!$isElevated) { + $json = @{ rules = @(@{ name = $firstRule.name; direction = $firstRule.direction }) } | ConvertTo-Json -Compress -Depth 5 + $output = Invoke-DscExport -InputJson $json + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) + + $rules = $output.resources[0].properties.rules + $rules.Count | Should -Be 1 + $rules[0].name | Should -BeExactly $firstRule.name + } + + It 'applies OR logic across filter objects' -Skip:(!$isElevated) { + $json = @{ rules = @(@{ name = $firstRule.name }, @{ name = $secondRule.name }) } | ConvertTo-Json -Compress -Depth 5 + $output = Invoke-DscExport -InputJson $json + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) + + $rules = $output.resources[0].properties.rules + $names = $rules | ForEach-Object { $_.name } + $names | Should -Contain $firstRule.name + $names | Should -Contain $secondRule.name + } + + It 'supports wildcard name filtering' -Skip:(!$isElevated) { + # Build a wildcard pattern from the first rule name: take the first word and append '*' + $prefix = ($firstRule.name -split '[-_ ]')[0] + $wildcardPattern = "${prefix}*" + + $json = @{ rules = @(@{ name = $wildcardPattern }) } | ConvertTo-Json -Compress -Depth 5 + $output = Invoke-DscExport -InputJson $json + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) + + $rules = $output.resources[0].properties.rules + $rules | Should -Not -BeNullOrEmpty + $rules | ForEach-Object { $_.name | Should -BeLike $wildcardPattern } + } + + It 'returns no rules when filter matches nothing' -Skip:(!$isElevated) { + $json = @{ rules = @(@{ name = 'DSC-NonExistent-Rule-Filter-12345' }) } | ConvertTo-Json -Compress -Depth 5 + $output = Invoke-DscExport -InputJson $json + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) + + $rules = $output.resources[0].properties.rules + $rules.Count | Should -Be 0 + } +} diff --git a/resources/windows_firewall/tests/windows_firewall_get.tests.ps1 b/resources/windows_firewall/tests/windows_firewall_get.tests.ps1 new file mode 100644 index 000000000..8d019b91e --- /dev/null +++ b/resources/windows_firewall/tests/windows_firewall_get.tests.ps1 @@ -0,0 +1,82 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +Describe 'Microsoft.Windows/FirewallRuleList - get operation' -Skip:(!$IsWindows) { + BeforeDiscovery { + $isElevated = if ($IsWindows) { + ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole( + [Security.Principal.WindowsBuiltInRole]::Administrator) + } else { + $false + } + } + + BeforeAll { + $resourceType = 'Microsoft.Windows/FirewallRuleList' + + $exportRaw = dsc resource export -r $resourceType 2>$testdrive/error.log + if ($LASTEXITCODE -ne 0) { + throw "Failed to export firewall rules: $(Get-Content -Raw $testdrive/error.log)" + } + $exportedRules = ($exportRaw | ConvertFrom-Json).resources[0].properties.rules + if (-not $exportedRules -or $exportedRules.Count -eq 0) { + throw 'No firewall rules were found on the machine.' + } + $knownRuleName = $exportedRules[0].name + if (-not $knownRuleName) { + throw 'The first exported firewall rule has a null or empty name.' + } + } + + It 'returns an existing rule by name' -Skip:(!$isElevated) { + $json = @{ rules = @(@{ name = $knownRuleName }) } | ConvertTo-Json -Compress -Depth 5 + $out = $json | dsc resource get -r $resourceType -f - 2>$testdrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) + + $result = ($out | ConvertFrom-Json).actualState.rules[0] + $result.name | Should -BeExactly $knownRuleName + $result.PSObject.Properties.Name | Should -Not -Contain '_exist' + $result.direction | Should -BeIn @('Inbound', 'Outbound') + $result.action | Should -BeIn @('Allow', 'Block') + } + + It 'returns an existing rule when name matches' -Skip:(!$isElevated) { + $json = @{ rules = @(@{ name = $knownRuleName }) } | ConvertTo-Json -Compress -Depth 5 + $out = $json | dsc resource get -r $resourceType -f - 2>$testdrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) + + $result = ($out | ConvertFrom-Json).actualState.rules[0] + $result.PSObject.Properties.Name | Should -Not -Contain '_exist' + $result.name | Should -BeExactly $knownRuleName + } + + It 'returns _exist false with only input properties when the rule is not found' -Skip:(!$isElevated) { + $json = @{ rules = @(@{ name = 'DSC-Missing-FirewallRule'; description = 'input only' }) } | ConvertTo-Json -Compress -Depth 5 + $out = $json | dsc resource get -r $resourceType -f - 2>$testdrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) + + $result = ($out | ConvertFrom-Json).actualState.rules[0] + $result.name | Should -BeExactly 'DSC-Missing-FirewallRule' + $result.description | Should -BeExactly 'input only' + $result._exist | Should -BeFalse + $result.PSObject.Properties.Name | Should -Not -Contain 'direction' + } + + It 'fails when rules array is empty' -Skip:(!$isElevated) { + $json = '{"rules":[]}' + $out = $json | dsc resource get -r $resourceType -f - 2>&1 + $LASTEXITCODE | Should -Not -Be 0 + } + + It 'handles multiple rules in a single request' -Skip:(!$isElevated) { + $json = @{ rules = @(@{ name = $knownRuleName }, @{ name = 'DSC-Missing-FirewallRule' }) } | ConvertTo-Json -Compress -Depth 5 + $out = $json | dsc resource get -r $resourceType -f - 2>$testdrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) + + $results = ($out | ConvertFrom-Json).actualState.rules + $results.Count | Should -Be 2 + $results[0].name | Should -BeExactly $knownRuleName + $results[1].name | Should -BeExactly 'DSC-Missing-FirewallRule' + $results[1]._exist | Should -BeFalse + } +} diff --git a/resources/windows_firewall/tests/windows_firewall_set.tests.ps1 b/resources/windows_firewall/tests/windows_firewall_set.tests.ps1 new file mode 100644 index 000000000..5ef4fcb7b --- /dev/null +++ b/resources/windows_firewall/tests/windows_firewall_set.tests.ps1 @@ -0,0 +1,147 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +Describe 'Microsoft.Windows/FirewallRuleList - set operation' -Skip:(!$IsWindows) { + BeforeDiscovery { + $isElevated = if ($IsWindows) { + ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole( + [Security.Principal.WindowsBuiltInRole]::Administrator) + } else { + $false + } + } + + BeforeAll { + $resourceType = 'Microsoft.Windows/FirewallRuleList' + $testRuleName = 'DSC-WindowsFirewall-Set-Test' + + function Initialize-TestFirewallRule { + $existing = Get-NetFirewallRule -Name $testRuleName -ErrorAction SilentlyContinue + if (-not $existing) { + New-NetFirewallRule -Name $testRuleName -DisplayName $testRuleName -Direction Inbound -Action Allow -Protocol TCP -LocalPort 32123 | Out-Null + } + } + + function Get-RuleState { + param( + [string]$Name = $testRuleName + ) + $json = @{ rules = @(@{ name = $Name }) } | ConvertTo-Json -Compress -Depth 5 + $out = $json | dsc resource get -r $resourceType -f - 2>$testdrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) + return ($out | ConvertFrom-Json).actualState.rules[0] + } + + Initialize-TestFirewallRule + } + + AfterAll { + Remove-NetFirewallRule -Name $testRuleName -ErrorAction SilentlyContinue + Remove-NetFirewallRule -Name 'DSC-WindowsFirewall-Create-Test' -ErrorAction SilentlyContinue + } + + It 'fails when name is not provided' -Skip:(!$isElevated) { + $json = @{ rules = @(@{ enabled = $true }) } | ConvertTo-Json -Compress -Depth 5 + $out = $json | dsc resource set -r $resourceType -f - 2>&1 + $LASTEXITCODE | Should -Not -Be 0 + } + + It 'fails when rules array is empty' -Skip:(!$isElevated) { + $json = '{"rules":[]}' + $out = $json | dsc resource set -r $resourceType -f - 2>&1 + $LASTEXITCODE | Should -Not -Be 0 + } + + It 'updates an existing rule' -Skip:(!$isElevated) { + Initialize-TestFirewallRule + $json = @{ rules = @(@{ name = $testRuleName; description = 'Updated by DSC test'; enabled = $false }) } | ConvertTo-Json -Compress -Depth 5 + $out = $json | dsc resource set -r $resourceType -f - 2>$testdrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) + + $result = ($out | ConvertFrom-Json).afterState.rules[0] + $result.name | Should -BeExactly $testRuleName + $result.description | Should -BeExactly 'Updated by DSC test' + $result.enabled | Should -BeFalse + } + + It 'creates a new rule when it does not exist' -Skip:(!$isElevated) { + $createRuleName = 'DSC-WindowsFirewall-Create-Test' + Remove-NetFirewallRule -Name $createRuleName -ErrorAction SilentlyContinue + + $json = @{ + rules = @(@{ + name = $createRuleName + direction = 'Inbound' + action = 'Block' + protocol = 6 + enabled = $true + }) + } | ConvertTo-Json -Compress -Depth 5 + $out = $json | dsc resource set -r $resourceType -f - 2>$testdrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) + + $result = ($out | ConvertFrom-Json).afterState.rules[0] + $result.name | Should -BeExactly $createRuleName + $result.direction | Should -BeExactly 'Inbound' + $result.action | Should -BeExactly 'Block' + $result.enabled | Should -BeTrue + + Remove-NetFirewallRule -Name $createRuleName -ErrorAction SilentlyContinue + } + + It 'clears ports when switching protocol from TCP to ICMP' -Skip:(!$isElevated) { + Initialize-TestFirewallRule + # The test rule is TCP with LocalPort 32123. Switch to ICMPv4 (protocol 1). + $json = @{ rules = @(@{ name = $testRuleName; protocol = 1 }) } | ConvertTo-Json -Compress -Depth 5 + $out = $json | dsc resource set -r $resourceType -f - 2>$testdrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) + + $result = ($out | ConvertFrom-Json).afterState.rules[0] + $result.protocol | Should -Be 1 + $result.PSObject.Properties.Name | Should -Not -Contain 'localPorts' + + # Restore original state for subsequent tests + $json = @{ rules = @(@{ name = $testRuleName; protocol = 6; localPorts = '32123' }) } | ConvertTo-Json -Compress -Depth 5 + $json | dsc resource set -r $resourceType -f - 2>$null | Out-Null + } + + It 'removes an existing rule when _exist is false' -Skip:(!$isElevated) { + Initialize-TestFirewallRule + $json = @{ rules = @(@{ name = $testRuleName; _exist = $false }) } | ConvertTo-Json -Compress -Depth 5 + $out = $json | dsc resource set -r $resourceType -f - 2>$testdrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) + + $result = ($out | ConvertFrom-Json).afterState.rules[0] + $result.name | Should -BeExactly $testRuleName + $result._exist | Should -BeFalse + + $actualRule = Get-NetFirewallRule -Name $testRuleName -ErrorAction SilentlyContinue + $actualRule | Should -BeNullOrEmpty + + Initialize-TestFirewallRule + $state = Get-RuleState + $state.PSObject.Properties.Name | Should -Not -Contain '_exist' + } + + It 'handles multiple rules in a single request' -Skip:(!$isElevated) { + Initialize-TestFirewallRule + $secondRuleName = 'DSC-WindowsFirewall-Create-Test' + Remove-NetFirewallRule -Name $secondRuleName -ErrorAction SilentlyContinue + + $json = @{ + rules = @( + @{ name = $testRuleName; description = 'Multi rule test' } + @{ name = $secondRuleName; direction = 'Outbound'; action = 'Allow'; protocol = 6; enabled = $true } + ) + } | ConvertTo-Json -Compress -Depth 5 + $out = $json | dsc resource set -r $resourceType -f - 2>$testdrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) + + $results = ($out | ConvertFrom-Json).afterState.rules + $results.Count | Should -Be 2 + $results[0].description | Should -BeExactly 'Multi rule test' + $results[1].name | Should -BeExactly $secondRuleName + + Remove-NetFirewallRule -Name $secondRuleName -ErrorAction SilentlyContinue + } +} diff --git a/resources/windows_firewall/windows_firewall.dsc.resource.json b/resources/windows_firewall/windows_firewall.dsc.resource.json new file mode 100644 index 000000000..fcc828dd1 --- /dev/null +++ b/resources/windows_firewall/windows_firewall.dsc.resource.json @@ -0,0 +1,186 @@ +{ + "$schema": "https://aka.ms/dsc/schemas/v3/bundled/resource/manifest.json", + "type": "Microsoft.Windows/FirewallRuleList", + "description": "Manage Windows Firewall rules using the netfw.h APIs.", + "tags": [ + "Windows", + "Firewall" + ], + "version": "0.1.0", + "get": { + "executable": "windows_firewall", + "args": [ + "get", + { + "jsonInputArg": "--input", + "mandatory": true + } + ] + }, + "set": { + "executable": "windows_firewall", + "args": [ + "set", + { + "jsonInputArg": "--input", + "mandatory": true + } + ], + "implementsPretest": false, + "return": "state", + "requireSecurityContext": "elevated" + }, + "export": { + "executable": "windows_firewall", + "args": [ + "export", + { + "jsonInputArg": "--input", + "mandatory": false + } + ], + "requireSecurityContext": "elevated" + }, + "exitCodes": { + "0": "Success", + "1": "Invalid arguments", + "2": "Invalid input", + "3": "Firewall error" + }, + "schema": { + "embedded": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Windows Firewall Rule List", + "description": "Manage Windows Firewall rules using the netfw.h APIs. The input is a single object that contains a rules array. For export, each object in the array is a filter where properties are ANDed together and the array entries are ORed together.", + "type": "object", + "additionalProperties": false, + "required": [ + "rules" + ], + "properties": { + "rules": { + "type": "array", + "title": "Rules", + "description": "An array of firewall rule objects.", + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "title": "Rule name", + "description": "The Windows Firewall rule name. Required for get and set." + }, + "_exist": { + "type": "boolean", + "title": "Exists", + "description": "Indicates whether the firewall rule exists. Set this to false in a set operation to remove an existing rule." + }, + "description": { + "type": "string", + "title": "Description", + "description": "A description for the firewall rule." + }, + "applicationName": { + "type": "string", + "title": "Application name", + "description": "The application path associated with the firewall rule." + }, + "serviceName": { + "type": "string", + "title": "Service name", + "description": "The Windows service name associated with the firewall rule." + }, + "protocol": { + "type": "integer", + "title": "Protocol", + "description": "The IP protocol number for the rule. Common values are 256 (Any), 6 (TCP), 17 (UDP), 1 (ICMPv4), and 58 (ICMPv6)." + }, + "localPorts": { + "type": "string", + "title": "Local ports", + "description": "A comma-separated local port list for the rule." + }, + "remotePorts": { + "type": "string", + "title": "Remote ports", + "description": "A comma-separated remote port list for the rule." + }, + "localAddresses": { + "type": "string", + "title": "Local addresses", + "description": "A comma-separated local address list for the rule." + }, + "remoteAddresses": { + "type": "string", + "title": "Remote addresses", + "description": "A comma-separated remote address list for the rule." + }, + "direction": { + "type": "string", + "title": "Direction", + "description": "The traffic direction for the rule.", + "enum": [ + "Inbound", + "Outbound" + ] + }, + "action": { + "type": "string", + "title": "Action", + "description": "The action taken by the rule.", + "enum": [ + "Allow", + "Block" + ] + }, + "enabled": { + "type": "boolean", + "title": "Enabled", + "description": "Indicates whether the rule is enabled." + }, + "profiles": { + "type": "array", + "title": "Profiles", + "description": "The firewall profiles for which the rule applies.", + "items": { + "type": "string", + "enum": [ + "Domain", + "Private", + "Public", + "All" + ] + } + }, + "grouping": { + "type": "string", + "title": "Grouping", + "description": "The grouping string associated with the rule." + }, + "interfaceTypes": { + "type": "array", + "title": "Interface types", + "description": "The interface types associated with the rule.", + "items": { + "type": "string", + "enum": [ + "RemoteAccess", + "Wireless", + "Lan", + "All" + ] + } + }, + "edgeTraversal": { + "type": "boolean", + "title": "Edge traversal", + "description": "Indicates whether edge traversal is enabled for the rule." + } + } + } + } + } + } + } +} From f55e7adcec905da574bd7c34cfd983097ee86b0d Mon Sep 17 00:00:00 2001 From: "Steve Lee (POWERSHELL HE/HIM) (from Dev Box)" Date: Fri, 27 Mar 2026 17:52:17 -0700 Subject: [PATCH 2/2] remove used strings --- resources/windows_firewall/locales/en-us.toml | 5 ----- 1 file changed, 5 deletions(-) diff --git a/resources/windows_firewall/locales/en-us.toml b/resources/windows_firewall/locales/en-us.toml index 8c6288f40..aade66190 100644 --- a/resources/windows_firewall/locales/en-us.toml +++ b/resources/windows_firewall/locales/en-us.toml @@ -11,15 +11,10 @@ windowsOnly = "This resource is only supported on Windows" [get] rulesArrayEmpty = "The rules array cannot be empty for get operations" selectorRequired = "Each firewall rule in a get request must include a name" -failedSerializeOutput = "Failed to serialize get output: %{error}" [set] rulesArrayEmpty = "The rules array cannot be empty for set operations" selectorRequired = "Each firewall rule in a set request must include a name" -failedSerializeOutput = "Failed to serialize set output: %{error}" - -[export] -failedSerializeOutput = "Failed to serialize export output: %{error}" [firewall] comInitFailed = "Failed to initialize COM for Windows Firewall access: %{error}"