diff --git a/CLAUDE.md b/CLAUDE.md index 919581d..dd6fa54 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -56,7 +56,15 @@ Four things to know before touching them: unparseable tuples and counter-less nft rules are reported in their own section. Folding them into the zero-hit list would invite deleting a load-bearing rule. -- **Raw nftables is the only backend that edits the user's rules.** Adding a +- **"Disable" does not mean the same thing on every backend.** Windows sets + a flag the rule survives; firewalld removes a service/port from a zone and + can add it back; **ufw and nftables have no off switch at all, so disabling + deletes the rule.** `linux::apply::Reversibility` carries that to the + confirm dialog — a dialog saying "disable" over a deletion is how someone + loses a rule they meant to keep. Apply always writes a full config backup + first and re-reads afterwards to confirm the rule actually went. +- **Raw nftables is the only backend that edits the user's rules** for + *collection*. Adding a counter takes the kernel's own JSON expression and inserts `{"counter": null}` before the verdict — never re-derived from text — after a full ruleset backup, and every touched rule is re-read and verified to be diff --git a/Cargo.lock b/Cargo.lock index 0fec591..902dfd7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1187,7 +1187,7 @@ checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" [[package]] name = "firebreak" -version = "0.7.0" +version = "0.7.12" dependencies = [ "anyhow", "base64", diff --git a/Cargo.toml b/Cargo.toml index d0799af..d39c1b1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "firebreak" -version = "0.7.0" +version = "0.7.12" edition = "2021" description = "On-demand Windows Firewall rule-usage auditor: correlates WFP audit events (5156/5157) with firewall rules to find unused and over-broad rules" license-file = "LICENSE" diff --git a/assets/collect.ps1 b/assets/collect.ps1 index 2635a0f..28978d7 100644 --- a/assets/collect.ps1 +++ b/assets/collect.ps1 @@ -35,7 +35,7 @@ $ports = @{}; Get-NetFirewallPortFilter -All | ForEach-Object { } $svcs = @{}; Get-NetFirewallServiceFilter -All | ForEach-Object { $svcs[$_.InstanceID] = [string]$_.Service } $addrs = @{}; Get-NetFirewallAddressFilter -All | ForEach-Object { $addrs[$_.InstanceID] = (@($_.RemoteAddress) -join ',') } -$rules = Get-NetFirewallRule | ForEach-Object { +$rules = Get-NetFirewallRule -PolicyStore ActiveStore -TracePolicyStore | ForEach-Object { $p = $ports[$_.InstanceID] [pscustomobject]@{ Name = $_.Name @@ -52,6 +52,8 @@ $rules = Get-NetFirewallRule | ForEach-Object { RemotePort = $p.RemotePort Service = $svcs[$_.InstanceID] RemoteAddress = $addrs[$_.InstanceID] + PolicyStoreSource = [string]$_.PolicyStoreSource + PolicyStoreSourceType = [string]$_.PolicyStoreSourceType } } ConvertTo-Json -InputObject @($rules) -Compress -Depth 3 | Set-Content -Encoding UTF8 (Join-Path $work "rules.json") diff --git a/build.rs b/build.rs index 749e206..8a245ce 100644 --- a/build.rs +++ b/build.rs @@ -1,16 +1,4 @@ fn main() { - // build number = git commit count (monotonic, unique per commit). - let build = std::process::Command::new("git") - .args(["rev-list", "--count", "HEAD"]) - .output() - .ok() - .filter(|o| o.status.success()) - .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()) - .filter(|s| !s.is_empty()) - .unwrap_or_else(|| "0".to_string()); - println!("cargo:rustc-env=FIREBREAK_BUILD={build}"); - println!("cargo:rerun-if-changed=.git/HEAD"); - if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows") { let manifest = r#" diff --git a/src/baseline_checks.rs b/src/baseline_checks.rs index 39bd4cf..2e10b39 100644 --- a/src/baseline_checks.rs +++ b/src/baseline_checks.rs @@ -92,6 +92,19 @@ const CHECKS: &[Check] = &[ pub fn flags_for(rule: &RuleInfo) -> Vec { let mut out = Vec::new(); + + // A rule applied by Group Policy or another management system is not + // this machine's to change: switching it off here lasts until the next + // policy refresh puts it back. Saying so stops someone "fixing" the same + // rule every week and wondering why it returns. + if rule.is_managed() { + out.push(BaselineFlag { + title: "Managed centrally", + advice: "This rule comes from Group Policy or device management, not from this \ + machine. Disabling it here is undone at the next policy refresh — change \ + it where it is defined.", + }); + } let name = rule.display_name.to_lowercase(); let group = rule.group.as_deref().unwrap_or("").to_lowercase(); let inbound = rule.direction.eq_ignore_ascii_case("inbound"); @@ -133,14 +146,95 @@ pub fn flags_for(rule: &RuleInfo) -> Vec { title: "Broad inbound allow", advice: "Inbound allow with no program and no port restriction — vet scope (RemoteAddress, profile) or tighten.", }); + } else if inbound && rule.is_enabled() && rule.action.eq_ignore_ascii_case("allow") { + // A rule *with* a port restriction can still be enormous. Fedora + // Workstation ships 1025-65535/tcp open by default: 64,511 ports, + // which the "no port restriction" test above sails straight past + // while being the broadest rule on the host. + if let Some(spec) = rule.local_port.as_deref() { + let span = port_span(spec); + if span > WIDE_PORT_SPAN { + out.push(BaselineFlag { + title: "Very wide port range", + advice: "This inbound allow covers thousands of ports, so anything that binds \ + one of them is reachable — check the Listening column for what is \ + actually behind it, and narrow the range if you can.", + }); + } + } } out } +/// More ports than any single service needs. Chosen to sit just above the +/// privileged range so "all high ports" trips it and a legitimate multi-port +/// service does not. +const WIDE_PORT_SPAN: u32 = 1024; + +/// Total number of ports a rule's port spec admits. +fn port_span(spec: &str) -> u32 { + crate::listeners::parse_port_ranges(spec) + .iter() + .map(|(a, b)| b.saturating_sub(*a).saturating_add(1)) + .sum() +} + #[cfg(test)] mod tests { use super::*; + fn wide_rule(port: &str) -> RuleInfo { + RuleInfo { + name: "r".into(), + display_name: "r".into(), + description: None, + enabled: "True".into(), + direction: "Inbound".into(), + action: "Allow".into(), + profile: "Any".into(), + group: None, + program: None, + protocol: Some("tcp".into()), + local_port: Some(port.into()), + remote_port: None, + service: None, + remote_address: None, + policy_source: None, + policy_source_type: None, + } + } + + #[test] + fn a_huge_port_range_is_flagged_even_though_it_is_a_restriction() { + // Fedora Workstation's default. The "no port restriction" test does + // not fire here, so without this the broadest rule on the host is + // the one rule nothing flags. + let flags = flags_for(&wide_rule("1025-65535")); + assert!( + flags.iter().any(|f| f.title == "Very wide port range"), + "{flags:?}" + ); + } + + #[test] + fn an_ordinary_multi_port_service_is_not_flagged() { + for spec in ["80,443", "137,138,139", "8000-8080", "22"] { + let flags = flags_for(&wide_rule(spec)); + assert!( + !flags.iter().any(|f| f.title == "Very wide port range"), + "{spec} should not be flagged: {flags:?}" + ); + } + } + + #[test] + fn port_spans_are_counted_across_ranges_and_lists() { + assert_eq!(port_span("22"), 1); + assert_eq!(port_span("80,443"), 2); + assert_eq!(port_span("1025-65535"), 64511); + assert_eq!(port_span("RPC"), 0); + } + fn rule( display: &str, dir: &str, @@ -164,6 +258,8 @@ mod tests { remote_port: None, service: None, remote_address: None, + policy_source: None, + policy_source_type: None, } } diff --git a/src/filter_map.rs b/src/filter_map.rs index b5a6111..b3d45f2 100644 --- a/src/filter_map.rs +++ b/src/filter_map.rs @@ -20,7 +20,9 @@ pub fn enumerate_filters() -> Result> { use windows::Win32::Foundation::HANDLE; use windows::Win32::NetworkManagement::WindowsFilteringPlatform::{ FwpmEngineClose0, FwpmEngineOpen0, FwpmFilterCreateEnumHandle0, - FwpmFilterDestroyEnumHandle0, FwpmFilterEnum0, FwpmFreeMemory0, FWPM_FILTER0, + FwpmFilterDestroyEnumHandle0, FwpmFilterEnum0, FwpmFreeMemory0, + FwpmProviderCreateEnumHandle0, FwpmProviderDestroyEnumHandle0, FwpmProviderEnum0, + FWPM_FILTER0, FWPM_PROVIDER0, }; const RPC_C_AUTHN_WINNT: u32 = 10; @@ -40,6 +42,37 @@ pub fn enumerate_filters() -> Result> { bail!("FwpmEngineOpen0 failed with error {err} (needs elevation)"); } + // provider GUID -> display name, so a filter can say who owns it + // rather than showing a raw GUID. Built once per enumeration. + let mut providers: HashMap = HashMap::new(); + { + let mut ph = HANDLE::default(); + if FwpmProviderCreateEnumHandle0(engine, None, &mut ph) == 0 { + loop { + let mut entries: *mut *mut FWPM_PROVIDER0 = std::ptr::null_mut(); + let mut returned: u32 = 0; + if FwpmProviderEnum0(engine, ph, 512, &mut entries, &mut returned) != 0 { + break; + } + if returned == 0 { + if !entries.is_null() { + FwpmFreeMemory0(&mut entries as *mut _ as *mut *mut core::ffi::c_void); + } + break; + } + for i in 0..returned as usize { + let p = &**entries.add(i); + providers.insert( + format!("{:?}", p.providerKey), + pwstr_to_string(p.displayData.name), + ); + } + FwpmFreeMemory0(&mut entries as *mut _ as *mut *mut core::ffi::c_void); + } + let _ = FwpmProviderDestroyEnumHandle0(engine, ph); + } + } + let result = (|| -> Result> { let mut enum_handle = HANDLE::default(); let err = FwpmFilterCreateEnumHandle0(engine, None, &mut enum_handle); @@ -76,7 +109,17 @@ pub fn enumerate_filters() -> Result> { ) }; let (pd_utf16, pd_hex) = decode_provider_data(provider_data); + // providerKey is optional; a null pointer means the + // filter was created without one (many built-ins). + let provider_key = if f.providerKey.is_null() { + String::new() + } else { + format!("{:?}", *f.providerKey) + }; + let provider_name = providers.get(&provider_key).cloned().unwrap_or_default(); out.push(FilterInfo { + provider_key: provider_key.clone(), + provider_name, filter_id: f.filterId, name: pwstr_to_string(f.displayData.name), description: pwstr_to_string(f.displayData.description), @@ -238,6 +281,8 @@ mod tests { provider_data_hex: String::new(), provider_context_key: String::new(), layer_key: String::new(), + provider_key: String::new(), + provider_name: String::new(), } } @@ -257,6 +302,8 @@ mod tests { remote_port: None, service: None, remote_address: None, + policy_source: None, + policy_source_type: None, } } @@ -314,3 +361,140 @@ mod tests { assert_eq!(map[&21].1, MappedVia::DisplayName); } } + +/// Providers whose filters *are* Windows Firewall rules. Their filters are +/// already represented in the rule table, so surfacing them again as +/// pseudo-rules would double-count. +#[cfg(any(windows, test))] +const FIREWALL_PROVIDER_HINTS: [&str; 3] = ["firewall", "mpssvc", "windows defender firewall"]; + +/// Is this filter one the Windows Firewall itself created? +#[cfg(any(windows, test))] +pub fn is_firewall_provider(provider_name: &str) -> bool { + let lc = provider_name.to_lowercase(); + FIREWALL_PROVIDER_HINTS.iter().any(|h| lc.contains(h)) +} + +/// Turn the live filter table into read-only pseudo-rules for everything +/// filtering traffic that is *not* a Windows Firewall rule. +/// +/// The point is to explain blocks the rule table cannot. Microsoft Defender +/// for Endpoint's network protection, VPN clients and third-party security +/// software all enforce through WFP callouts rather than firewall rules, so +/// traffic they drop matches no rule and lands in the unattributed bucket +/// with nothing to name it. +/// +/// Filters are collapsed by (provider, filter name): a host has thousands of +/// WFP filters but only a handful of distinct things doing the filtering, and +/// a table with one row per filter would bury the firewall rules it exists to +/// show. Filters with no provider are dropped — those are OS plumbing, not +/// somebody's security product. +#[cfg(any(windows, test))] +pub fn pseudo_rules(filters: &[FilterInfo]) -> Vec { + let mut seen: std::collections::BTreeMap<(String, String), usize> = + std::collections::BTreeMap::new(); + for f in filters { + if f.provider_name.is_empty() || is_firewall_provider(&f.provider_name) { + continue; + } + *seen + .entry((f.provider_name.clone(), f.name.clone())) + .or_insert(0) += 1; + } + seen.into_iter() + .map(|((provider, name), count)| { + let display = if name.is_empty() { + provider.clone() + } else { + name + }; + RuleInfo { + name: format!("wfp:{provider}:{display}"), + display_name: display, + description: Some(format!( + "{count} live WFP filter(s) from {provider}. Not a firewall rule — it can \ + allow or block traffic that no firewall rule explains." + )), + enabled: "True".into(), + direction: "Any".into(), + action: "Filter".into(), + profile: "Any".into(), + group: Some(provider.clone()), + program: None, + protocol: None, + local_port: None, + remote_port: None, + service: None, + remote_address: None, + policy_source: Some(provider), + policy_source_type: Some(RuleInfo::SOURCE_TYPE_WFP.into()), + } + }) + .collect() +} + +#[cfg(test)] +mod pseudo_tests { + use super::*; + + fn filter(name: &str, provider: &str) -> FilterInfo { + FilterInfo { + filter_id: 1, + name: name.into(), + description: String::new(), + provider_data_utf16: String::new(), + provider_data_hex: String::new(), + provider_context_key: String::new(), + layer_key: String::new(), + provider_key: format!("{{{provider}}}"), + provider_name: provider.into(), + } + } + + #[test] + fn firewall_filters_are_not_duplicated_as_pseudo_rules() { + // They are already in the rule table; showing them twice would make + // the same rule look like two separate things filtering traffic. + let f = vec![ + filter("Block inbound", "Microsoft Windows Defender Firewall"), + filter("x", "MPSSVC"), + ]; + assert!(pseudo_rules(&f).is_empty()); + } + + #[test] + fn other_security_products_become_read_only_rows() { + let f = vec![filter( + "Network Protection", + "Microsoft Defender for Endpoint", + )]; + let rules = pseudo_rules(&f); + assert_eq!(rules.len(), 1); + let r = &rules[0]; + assert_eq!(r.source(), crate::model::RuleSource::WfpFilter); + assert!(!r.is_editable(), "a WFP filter has no rule to edit"); + assert_eq!(r.source_label(), "Microsoft Defender for Endpoint"); + assert!(r.source_detail().contains("Not a firewall rule")); + } + + #[test] + fn thousands_of_filters_collapse_to_the_things_doing_the_filtering() { + // A real host has thousands of WFP filters. One row each would bury + // the firewall rules the table exists to show. + let mut f = Vec::new(); + for _ in 0..500 { + f.push(filter("Network Protection", "Defender")); + f.push(filter("Tunnel", "SomeVPN")); + } + let rules = pseudo_rules(&f); + assert_eq!(rules.len(), 2); + assert!(rules[0].description.as_ref().unwrap().contains("500")); + } + + #[test] + fn filters_without_a_provider_are_os_plumbing_and_are_dropped() { + let mut f = filter("Boot time default", ""); + f.provider_key = String::new(); + assert!(pseudo_rules(&[f]).is_empty()); + } +} diff --git a/src/firewall_rules.rs b/src/firewall_rules.rs index b4d1b24..66ed823 100644 --- a/src/firewall_rules.rs +++ b/src/firewall_rules.rs @@ -6,6 +6,7 @@ use anyhow::{bail, Context, Result}; use base64::Engine; +#[cfg(not(target_os = "linux"))] use chrono::Utc; use std::path::{Path, PathBuf}; @@ -40,6 +41,19 @@ pub(crate) fn run_powershell(script: &str) -> Result { /// Enumerate all firewall rules with their program/port filters joined in. /// One PowerShell round-trip; the -All filter queries avoid a per-rule /// association lookup, which is unusably slow across ~500 rules. +/// +/// **ActiveStore, not the default.** `Get-NetFirewallRule` with no +/// `-PolicyStore` returns PersistentStore — local rules only. On a +/// domain-joined or Intune-managed machine that silently omits every rule +/// applied by Group Policy and by Windows Service Hardening, so the audit +/// would report an incomplete firewall and push traffic that matched a +/// managed rule into the unattributed bucket. ActiveStore is the resultant +/// set: local + GPO/RSOP + service stores. +/// +/// `-TracePolicyStore` fills in PolicyStoreSource / PolicyStoreSourceType, +/// which is how a managed rule is told apart from one an admin made here. +/// Rules deployed by Intune live in their own MDM store and may still be +/// absent — see the verification note in docs/internals.md. pub fn enumerate_rules() -> Result> { let script = r#" $ErrorActionPreference = 'Stop' @@ -57,7 +71,7 @@ $svcs = @{} Get-NetFirewallServiceFilter -All | ForEach-Object { $svcs[$_.InstanceID] = [string]$_.Service } $addrs = @{} Get-NetFirewallAddressFilter -All | ForEach-Object { $addrs[$_.InstanceID] = (@($_.RemoteAddress) -join ',') } -$out = Get-NetFirewallRule | ForEach-Object { +$out = Get-NetFirewallRule -PolicyStore ActiveStore -TracePolicyStore | ForEach-Object { $p = $ports[$_.InstanceID] [pscustomobject]@{ Name = $_.Name @@ -74,6 +88,8 @@ $out = Get-NetFirewallRule | ForEach-Object { RemotePort = $p.RemotePort Service = $svcs[$_.InstanceID] RemoteAddress = $addrs[$_.InstanceID] + PolicyStoreSource = [string]$_.PolicyStoreSource + PolicyStoreSourceType = [string]$_.PolicyStoreSourceType } } ConvertTo-Json -InputObject @($out) -Compress -Depth 3 @@ -135,12 +151,14 @@ pub fn save_rules_cache(rules: &[RuleInfo]) { } } +#[cfg(not(target_os = "linux"))] pub fn load_rules_cache() -> Option> { let json = std::fs::read_to_string(rules_cache_path()).ok()?; serde_json::from_str(&json).ok() } /// Directory where backups land: %ProgramData%\firebreak\backups +#[cfg(not(target_os = "linux"))] pub fn backup_dir() -> PathBuf { let base = std::env::var("ProgramData").unwrap_or_else(|_| r"C:\ProgramData".into()); Path::new(&base).join("firebreak").join("backups") @@ -149,6 +167,7 @@ pub fn backup_dir() -> PathBuf { /// Export the full firewall policy before any mutation. Produces a /// restorable .wfw (netsh advfirewall import) plus a JSON rule dump for /// human-readable diffing. Returns the .wfw path. +#[cfg(not(target_os = "linux"))] pub fn backup_policy(rules: &[RuleInfo]) -> Result { let dir = backup_dir(); crate::secure_dir::ensure_secured_dir(&dir)?; @@ -174,10 +193,12 @@ pub fn backup_policy(rules: &[RuleInfo]) -> Result { /// Names per Set-NetFirewallRule invocation: keeps the -EncodedCommand /// well under the 32,767-char Windows command-line limit even with long /// InstanceIDs, so a big batch can't fail wholesale after confirmation. +#[cfg(not(target_os = "linux"))] const RULES_PER_INVOCATION: usize = 100; /// Enable/disable a single rule by unique Name (InstanceID) — the apply /// worker goes rule-by-rule so progress and per-rule failures are exact. +#[cfg(not(target_os = "linux"))] pub fn set_rule_enabled(rule_name: &str, enabled: bool) -> Result<()> { set_rules_enabled(std::slice::from_ref(&rule_name.to_string()), enabled) } @@ -186,6 +207,7 @@ pub fn set_rule_enabled(rule_name: &str, enabled: bool) -> Result<()> { /// to turn it off for Public. The rule is left enabled (you keep it active /// on the remaining profiles). `profile_arg` is a comma-separated set or /// "Any". Backup first — the UI's Apply flow does. +#[cfg(not(target_os = "linux"))] pub fn set_rule_profiles(rule_name: &str, profile_arg: &str) -> Result<()> { let name = rule_name.replace('\'', "''"); // profile_arg is a controlled token set (Any / Domain,Private,Public @@ -213,6 +235,7 @@ Set-NetFirewallRule -Name '{name}' -Profile {prof} -Enabled True /// Enable/disable rules by unique Name (InstanceID). Backup first — this /// module doesn't do it for you; the UI's Apply flow does. On error, /// reports how many rules had already been applied. +#[cfg(not(target_os = "linux"))] pub fn set_rules_enabled(rule_names: &[String], enabled: bool) -> Result<()> { let value = if enabled { "True" } else { "False" }; let mut applied = 0usize; diff --git a/src/linux/apply.rs b/src/linux/apply.rs new file mode 100644 index 0000000..44c9280 --- /dev/null +++ b/src/linux/apply.rs @@ -0,0 +1,574 @@ +//! Changing firewall rules on Linux. +//! +//! Everything else in `linux/` reads. This module writes, and the three +//! backends do not agree on what "disable a rule" even means: +//! +//! | backend | disable is | reversible by re-enabling? | +//! |---|---|---| +//! | firewalld | `--remove-service` / `--remove-port` | yes — re-add it | +//! | ufw | `ufw delete` | **no — the rule is gone** | +//! | nftables | `nft delete rule` | **no — the rule is gone** | +//! +//! Windows' disable is a flag on a rule that survives being switched off. +//! ufw and nftables have no such flag: the only way to stop a rule matching +//! is to remove it. That difference has to reach the user *before* they +//! confirm, which is what [`Reversibility`] is for — a confirm dialog that +//! says "disable" over an operation that deletes is how someone loses a rule +//! they meant to keep. +//! +//! Two rules hold throughout: +//! +//! * **Back up first.** Every backend can produce a restorable snapshot of +//! its whole configuration, and Apply writes one before touching anything. +//! * **Verify, don't trust.** After a change, the rule set is re-read and +//! checked to be exactly what was intended — the target gone, everything +//! else untouched. A reconstruction bug that removed the wrong rule would +//! otherwise be silent. +//! +//! Nothing here goes through a shell. Arguments are passed as argv and every +//! value that reaches one is validated first. + +use anyhow::{bail, Context, Result}; +use std::path::{Path, PathBuf}; + +use super::Backend; + +/// Whether switching a rule off can be undone by switching it back on. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Reversibility { + /// The rule can be put back exactly as it was. + Reversible, + /// The rule is deleted. Restoring it means restoring the backup. + Destructive, +} + +impl Backend { + /// What disabling a rule actually does here. + pub fn disable_semantics(self) -> Reversibility { + match self { + // firewalld config is declarative: removing a service from a + // zone and adding it back gives the same rule. + Backend::Firewalld => Reversibility::Reversible, + // Neither has a per-rule "off" flag; removal is the only way. + Backend::Ufw | Backend::Nftables => Reversibility::Destructive, + } + } + + /// Whether a rule's scope can be edited at all. Only firewalld has + /// zones; for the others the scope chips have nothing to move between. + pub fn scope_is_editable(self) -> bool { + matches!(self, Backend::Firewalld) + } + + /// Sentence shown above the confirm dialog, so the word on the button + /// matches what the host will actually do. + pub fn apply_warning(self) -> &'static str { + match self.disable_semantics() { + Reversibility::Reversible => { + "Disabled rules are removed from their zone and can be added back." + } + Reversibility::Destructive => { + "This backend has no per-rule off switch: disabling DELETES the rule. \ + Restoring it means restoring the backup Firebreak writes first." + } + } + } +} + +// --------------------------------------------------------------------------- +// Backup +// --------------------------------------------------------------------------- + +/// Snapshot the whole firewall configuration so any change can be undone. +/// Returns the file written. +pub fn backup(backend: Backend, db_path: &Path) -> Result { + let dir = db_path + .parent() + .unwrap_or_else(|| Path::new(".")) + .join("backups"); + crate::secure_dir::ensure_secured_dir(&dir)?; + let stamp = chrono::Utc::now().format("%Y%m%dT%H%M%SZ"); + let (name, body) = match backend { + Backend::Ufw => ( + format!("ufw-{stamp}.rules"), + read_ufw_config().context("snapshotting ufw rules")?, + ), + Backend::Firewalld => ( + format!("firewalld-{stamp}.txt"), + run("firewall-cmd", &["--list-all-zones"]).context("snapshotting firewalld zones")?, + ), + Backend::Nftables => ( + format!("nftables-{stamp}.nft"), + run("nft", &["list", "ruleset"]).context("snapshotting the nftables ruleset")?, + ), + }; + let path = dir.join(name); + std::fs::write(&path, body).with_context(|| format!("writing {}", path.display()))?; + Ok(path) +} + +fn read_ufw_config() -> Result { + let mut out = String::new(); + for family in [super::ufw::Family::V4, super::ufw::Family::V6] { + for candidate in family.rules_files() { + if let Ok(text) = std::fs::read_to_string(candidate) { + out.push_str(&format!("# ==== {candidate} ====\n{text}\n")); + break; + } + } + } + if out.is_empty() { + bail!("could not read ufw's rule files"); + } + Ok(out) +} + +// --------------------------------------------------------------------------- +// Rule identity -> command +// --------------------------------------------------------------------------- + +/// A firewalld rule id: `firewalld://