From 07b3ad7dfafb3128c8bf6b90d3b3a335f1280b0c Mon Sep 17 00:00:00 2001 From: ghostpsalm Date: Sun, 9 Aug 2026 23:56:31 +1000 Subject: [PATCH 1/8] Linux port (1/n): lint the native target in the gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gate skipped native clippy because Windows-only code compiled on Linux read as dead. That is now stated as #[cfg(windows)] (or #[cfg(any(windows, test))] where the logic is portable and unit-tested) instead of being suppressed, so the native lint is signal again — and it is the only thing that will lint the Linux backends. Also fixes the two real warnings native clippy was hiding behind the noise: an unnecessary `mut` in syspath::command and items after the test module in main.rs. --- scripts/gate.sh | 17 ++++++++--- src/audit_control.rs | 2 ++ src/collect.rs | 19 ++++++++++-- src/event_query.rs | 12 +++----- src/filter_map.rs | 1 + src/main.rs | 72 ++++++++++++++++++++++---------------------- src/pipeline.rs | 7 +++++ src/scope.rs | 4 ++- src/syspath.rs | 8 +++-- src/ui.rs | 2 ++ src/update.rs | 1 + 11 files changed, 89 insertions(+), 56 deletions(-) diff --git a/scripts/gate.sh b/scripts/gate.sh index a45dc05..1cf4bd0 100755 --- a/scripts/gate.sh +++ b/scripts/gate.sh @@ -10,12 +10,19 @@ echo "== cargo clippy ==" if [[ "${OS:-}" == "Windows_NT" ]]; then cargo clippy --all-targets -- -D warnings else - # Firebreak is a Windows-only app (heavy #[cfg(windows)] use). Linting the - # native Linux target flags huge swaths of real code as dead, since none - # of it is reachable outside a Windows build. Lint the actual deployment - # target instead — requires the x86_64-pc-windows-gnu rustup target and - # a mingw-w64 gcc (`rustup target add x86_64-pc-windows-gnu`). + # Two real deployment targets, so lint both. The Windows target is the one + # the bulk of the code is written for and can only be checked by + # cross-compiling (needs the x86_64-pc-windows-gnu rustup target and a + # mingw-w64 gcc); the native target is the Linux build and its backends. + # + # Native linting used to be skipped because Windows-only code compiled on + # Linux read as dead. That is now expressed as #[cfg(windows)] instead, so + # the native lint is signal again — and it is the ONLY thing that lints the + # Linux backends at all. Do not drop it. + echo "-- windows target --" cargo clippy --target x86_64-pc-windows-gnu -- -D warnings + echo "-- native (linux) target --" + cargo clippy --all-targets -- -D warnings fi echo "== cargo test ==" diff --git a/src/audit_control.rs b/src/audit_control.rs index 70affe1..6a4117c 100644 --- a/src/audit_control.rs +++ b/src/audit_control.rs @@ -25,7 +25,9 @@ pub const FILTERING_PLATFORM_CONNECTION_GUID: &str = "{0CCE9226-69AE-11D9-BED3-5 #[cfg(windows)] const SUBCATEGORY: GUID = GUID::from_u128(0x0CCE9226_69AE_11D9_BED3_505054503030); +#[cfg(windows)] const POLICY_AUDIT_EVENT_SUCCESS: u32 = 0x1; +#[cfg(windows)] const POLICY_AUDIT_EVENT_FAILURE: u32 = 0x2; #[cfg(windows)] const POLICY_AUDIT_EVENT_NONE: u32 = 0x4; diff --git a/src/collect.rs b/src/collect.rs index fd1c4e4..44768c6 100644 --- a/src/collect.rs +++ b/src/collect.rs @@ -12,17 +12,24 @@ //! rules.json — Vec, exactly the shape enumerate_rules parses //! events.evtx — Security log filtered to 5156/5157 -use anyhow::{anyhow, bail, Context, Result}; +#[cfg(any(windows, test))] +use anyhow::anyhow; +use anyhow::{bail, Context, Result}; use serde::{Deserialize, Serialize}; -use std::io::{Read, Write}; +#[cfg(any(windows, test))] +use std::io::Read; +use std::io::Write; use std::path::{Path, PathBuf}; +#[cfg(any(windows, test))] use crate::model::RuleInfo; pub const SCHEMA: u32 = 1; /// The PowerShell fallback collector, kept embedded so the script a user -/// hands out always matches the parser in their build. +/// hands out always matches the parser in their build. Only the Windows UI +/// hands it out. +#[cfg(windows)] pub const COLLECT_PS1: &str = include_str!("../assets/collect.ps1"); #[derive(Serialize, Deserialize)] @@ -42,6 +49,10 @@ pub struct BundleContext { pub iface_profiles: std::collections::HashMap, } +/// A bundle opened for review. Parsing one is portable and stays +/// unit-tested from any host; only *replaying* its events.evtx needs +/// EvtQuery, so nothing outside Windows calls this in a real run. +#[cfg(any(windows, test))] pub struct Bundle { pub manifest: Manifest, pub rules: Vec, @@ -130,6 +141,7 @@ pub fn collect(out_zip: &Path, progress: &dyn Fn(&str)) -> Result<()> { /// Open a bundle: parse manifest/rules/context, extract events.evtx to a /// temp file for the event API. +#[cfg(any(windows, test))] pub fn read_bundle(zip_path: &Path) -> Result { let file = std::fs::File::open(zip_path).with_context(|| format!("opening {}", zip_path.display()))?; @@ -173,6 +185,7 @@ pub fn read_bundle(zip_path: &Path) -> Result { }) } +#[cfg(any(windows, test))] fn read_entry(z: &mut zip::ZipArchive, name: &str) -> Result { let mut e = z .by_name(name) diff --git a/src/event_query.rs b/src/event_query.rs index 701b58c..75fd8af 100644 --- a/src/event_query.rs +++ b/src/event_query.rs @@ -14,6 +14,9 @@ use anyhow::Result; use crate::model::EventRecord; /// XPath filter for 5156/5157, resuming strictly after `since_record_id`. +/// Only the Windows binary queries the Security channel, but the cursor +/// logic is pure and stays unit-tested from any host. +#[cfg(any(windows, test))] pub fn build_query(since_record_id: Option) -> String { match since_record_id { Some(id) => format!( @@ -172,14 +175,6 @@ pub fn query_events_from_file( drain_query(&result_set, on_event) } -#[cfg(not(windows))] -pub fn query_events_from_file( - _path: &std::path::Path, - _on_event: impl FnMut(EventRecord), -) -> Result { - bail!("event log query is only available on Windows") -} - /// Pull every matched event from an open result set, delivering the parsed /// ones to `on_event` and counting the ones that couldn't be parsed. #[cfg(windows)] @@ -304,6 +299,7 @@ fn decode_direction(raw: &str) -> String { } /// Extract just the EventRecordID from any rendered event XML. +#[cfg(any(windows, test))] pub fn parse_record_id(xml: &str) -> Option { let start = xml.find("")? + "".len(); let end = xml[start..].find("")? + start; diff --git a/src/filter_map.rs b/src/filter_map.rs index 233443c..b5a6111 100644 --- a/src/filter_map.rs +++ b/src/filter_map.rs @@ -104,6 +104,7 @@ pub fn enumerate_filters() -> Result> { /// Decode a providerData blob as UTF-16LE text (lossy, control chars /// stripped) plus a hex dump capped for storage. +#[cfg(windows)] fn decode_provider_data(data: &[u8]) -> (String, String) { let utf16: Vec = data .chunks_exact(2) diff --git a/src/main.rs b/src/main.rs index c3abe85..563acf7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -253,42 +253,6 @@ fn dump_filters() -> Result<()> { Ok(()) } -#[cfg(test)] -mod tests { - use super::parse_args_from; - - fn parse(argv: &[&str]) -> super::Args { - parse_args_from(argv.iter().map(|s| (*s).to_string())) - } - - #[test] - fn collect_without_path_defaults() { - let a = parse(&["--collect"]); - assert_eq!(a.collect, Some(None)); - } - - #[test] - fn collect_with_path_takes_it() { - let a = parse(&["--collect", r"C:\out.zip"]); - assert_eq!(a.collect, Some(Some(r"C:\out.zip".into()))); - } - - #[test] - fn collect_does_not_swallow_following_flag() { - // regression for F2: `--collect --enable-only` must run both, not - // silently drop --enable-only while peeking for a path - let a = parse(&["--collect", "--enable-only"]); - assert_eq!(a.collect, Some(None)); - assert!(a.enable_only); - } - - #[test] - fn db_takes_a_path() { - let a = parse(&["--db", r"D:\fb.db"]); - assert_eq!(a.db_path, std::path::PathBuf::from(r"D:\fb.db")); - } -} - fn print_text_report(result: &pipeline::AnalysisResult) -> Result<()> { let rows = &result.rows; let mut sorted: Vec<&ui::RuleRow> = rows.iter().collect(); @@ -385,3 +349,39 @@ fn print_text_report(result: &pipeline::AnalysisResult) -> Result<()> { } Ok(()) } + +#[cfg(test)] +mod tests { + use super::parse_args_from; + + fn parse(argv: &[&str]) -> super::Args { + parse_args_from(argv.iter().map(|s| (*s).to_string())) + } + + #[test] + fn collect_without_path_defaults() { + let a = parse(&["--collect"]); + assert_eq!(a.collect, Some(None)); + } + + #[test] + fn collect_with_path_takes_it() { + let a = parse(&["--collect", r"C:\out.zip"]); + assert_eq!(a.collect, Some(Some(r"C:\out.zip".into()))); + } + + #[test] + fn collect_does_not_swallow_following_flag() { + // regression for F2: `--collect --enable-only` must run both, not + // silently drop --enable-only while peeking for a path + let a = parse(&["--collect", "--enable-only"]); + assert_eq!(a.collect, Some(None)); + assert!(a.enable_only); + } + + #[test] + fn db_takes_a_path() { + let a = parse(&["--db", r"D:\fb.db"]); + assert_eq!(a.db_path, std::path::PathBuf::from(r"D:\fb.db")); + } +} diff --git a/src/pipeline.rs b/src/pipeline.rs index a10226f..a7b568d 100644 --- a/src/pipeline.rs +++ b/src/pipeline.rs @@ -187,6 +187,10 @@ pub fn export_csv(rows: &[ui::RuleRow], path: &Path) -> Result<()> { /// dedicated import DB, never the live store). `reset_first` clears any prior /// import so a fresh single-file review doesn't concatenate; false appends /// (multi-machine review). +/// +/// Windows-only: reading a .evtx needs the EvtQuery API, so there is no +/// Linux path here. A Linux host reviews Linux evidence, not Windows events. +#[cfg(windows)] pub fn import_evtx( scratch_db: &Path, evtx_path: &Path, @@ -222,6 +226,8 @@ pub fn import_evtx( /// Import a firebreak-export bundle: the target's own rules and interface /// profiles ride along, so attribution reflects THAT device, not this one. +/// Windows-only for the same reason as [`import_evtx`]. +#[cfg(windows)] pub fn import_bundle( scratch_db: &Path, zip_path: &Path, @@ -254,6 +260,7 @@ pub fn import_bundle( result } +#[cfg(windows)] #[allow(clippy::too_many_arguments)] fn import_events( scratch_db: &Path, diff --git a/src/scope.rs b/src/scope.rs index 87a2b79..60f8954 100644 --- a/src/scope.rs +++ b/src/scope.rs @@ -34,7 +34,9 @@ impl Profile { } } - /// Inverse of label(); NetworkCategory spellings also accepted. + /// Inverse of label(); NetworkCategory spellings also accepted. Only the + /// bundle-read path calls it, which no Linux run reaches. + #[cfg(any(windows, test))] pub fn from_label(s: &str) -> Profile { match s.trim() { "Domain" | "DomainAuthenticated" => Profile::Domain, diff --git a/src/syspath.rs b/src/syspath.rs index bcbf30a..dfaac28 100644 --- a/src/syspath.rs +++ b/src/syspath.rs @@ -23,12 +23,14 @@ pub fn powershell() -> PathBuf { /// A `Command` that never flashes a console window — CREATE_NO_WINDOW. /// All subprocess spawns go through this so the GUI stays clean. pub fn command(program: impl AsRef) -> std::process::Command { - let mut c = std::process::Command::new(program); + let c = std::process::Command::new(program); #[cfg(windows)] - { + let c = { use std::os::windows::process::CommandExt; const CREATE_NO_WINDOW: u32 = 0x0800_0000; + let mut c = c; c.creation_flags(CREATE_NO_WINDOW); - } + c + }; c } diff --git a/src/ui.rs b/src/ui.rs index b6f2c03..7b6a3fb 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -622,6 +622,7 @@ impl App { /// Analyze events from an imported .evtx file on a worker thread. /// `append` = add to the current import session; otherwise start fresh. + #[cfg(windows)] fn spawn_import(&mut self, path: PathBuf, append: bool, egui_ctx: egui::Context) { // stable per-process import scratch DB let db = self.import_db.clone().unwrap_or_else(|| { @@ -654,6 +655,7 @@ impl App { /// Open a firebreak-export bundle (another device's rules + events) as a /// fresh read-only review session. + #[cfg(windows)] pub(crate) fn spawn_import_bundle(&mut self, path: PathBuf, egui_ctx: egui::Context) { let db = std::env::temp_dir().join(format!("firebreak-import-{}.db", std::process::id())); self.import_db = Some(db.clone()); diff --git a/src/update.rs b/src/update.rs index 512d471..c25ed89 100644 --- a/src/update.rs +++ b/src/update.rs @@ -23,6 +23,7 @@ pub fn download_url() -> String { } /// Detached minisign signature published next to the asset. +#[cfg(windows)] pub fn signature_url() -> String { format!("{}.minisig", download_url()) } From 380300135b48954a46bcbf84427957cd343f3ad1 Mon Sep 17 00:00:00 2001 From: ghostpsalm Date: Mon, 10 Aug 2026 00:15:49 +1000 Subject: [PATCH 2/8] Linux port (2/n): ufw backend with reset-safe counters ufw needs no instrumentation: iptables-nft counts every rule already, so the first run has a real answer with nothing enabled and no waiting period. Rule identity is ufw's own `### tuple ###` line, joined to live counters by chain position. Three things the counting has to get right, each verified against a real host rather than assumed: - A kernel counter is a gauge, not an event stream. Totals bank the old lifetime on reset (counter going backwards, or a changed boot-id/ruleset generation) instead of re-adding each raw reading. Confirmed live: 100 -> 150 -> reset -> 155 -> 170. - `limit` expands to three iptables rules over the *same* traffic while `proto any` expands to disjoint tcp and udp rules. Summing the first triples the count; maxing the second loses half the evidence. Entries are grouped by match signature: max within a group, sum across groups. - Anything unmeasurable is reported as unmeasurable, never folded into the zero-hit list. "We could not read this" must not read as "safe to delete". Two rule shapes found only by running it on a second distro: Fedora keeps the rule files under /var/lib/ufw, and its default install uses application profiles, whose two extra tuple fields shift the direction token. Both distros' rulesets are now golden fixtures. IPv6 binds to the ufw6-* chains, without which every v6 rule reads as unmeasurable. --- src/elevation.rs | 23 +- src/linux/counters.rs | 168 ++++++++ src/linux/mod.rs | 247 +++++++++++ src/linux/ufw.rs | 941 ++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 104 ++++- src/store.rs | 74 ++++ src/syspath.rs | 20 +- 7 files changed, 1574 insertions(+), 3 deletions(-) create mode 100644 src/linux/counters.rs create mode 100644 src/linux/mod.rs create mode 100644 src/linux/ufw.rs diff --git a/src/elevation.rs b/src/elevation.rs index 83cc537..b99d517 100644 --- a/src/elevation.rs +++ b/src/elevation.rs @@ -28,7 +28,24 @@ pub fn is_elevated() -> bool { } } -#[cfg(not(windows))] +/// Linux: root, checked by effective UID. Every read in the evidence loop +/// needs it — ufw's rule files are root-only, iptables counters come from a +/// privileged netlink socket, and `/proc//exe` only resolves for other +/// users' processes as root. Read from /proc rather than linking libc for +/// one call; the effective UID is the second field of the `Uid:` line. +#[cfg(target_os = "linux")] +pub fn is_elevated() -> bool { + let Ok(status) = std::fs::read_to_string("/proc/self/status") else { + return false; + }; + status + .lines() + .find_map(|l| l.strip_prefix("Uid:")) + .and_then(|rest| rest.split_whitespace().nth(1)) + .is_some_and(|euid| euid == "0") +} + +#[cfg(not(any(windows, target_os = "linux")))] pub fn is_elevated() -> bool { false } @@ -79,6 +96,10 @@ pub fn relaunch_elevated() -> bool { } } +/// No Linux equivalent of the UAC prompt: a GUI process cannot ask the +/// kernel for privilege mid-run, and re-execing under pkexec/sudo from +/// inside the app would be a worse trust story than telling the user to +/// start it as root. #[cfg(not(windows))] pub fn relaunch_elevated() -> bool { false diff --git a/src/linux/counters.rs b/src/linux/counters.rs new file mode 100644 index 0000000..9842d3b --- /dev/null +++ b/src/linux/counters.rs @@ -0,0 +1,168 @@ +//! Turning kernel packet counters into a usage total that survives resets. +//! +//! Windows hands Firebreak *events*: each one is a fact that happened once, +//! and a monotonic EventRecordID makes resume exact. Linux hands it a +//! *gauge*: `iptables -L -v` reports packets since the rule was installed. +//! Gauges reset — on reboot, on `ufw reload`, on `iptables -Z` — and a naive +//! reader silently loses everything counted before the reset, or (worse) +//! double-counts by adding a raw reading to a running total every run. +//! +//! So each rule carries `accumulated` (everything banked from previous +//! counter lifetimes) plus `last_raw` (the reading at the previous run). +//! Total is the sum. A reset banks `last_raw` and starts the new lifetime. +//! +//! Two independent reset signals, because neither alone is sufficient: +//! +//! 1. `raw < last_raw` — the counter went backwards, which only a reset +//! can do. Misses a reset that climbed back past the old value between +//! two runs. +//! 2. A changed *generation* token (boot id + a hash of the rule set). +//! Catches reboots and rule reloads regardless of counter values. +//! +//! What neither catches: `iptables -Z` (zero counters) with no rule-set +//! change and no reboot, where traffic then pushes the counter past its old +//! value before the next run. That undercounts, and it is unfixable by +//! polling — recorded here so nobody later reads a total as exact. + +/// Per-rule counter bookkeeping, persisted between runs. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct CounterState { + /// Packets banked from counter lifetimes that have already ended. + pub accumulated: i64, + /// The raw counter as of the previous observation. + pub last_raw: i64, +} + +impl CounterState { + /// Everything this rule has matched, across resets. + pub fn total(&self) -> i64 { + self.accumulated + self.last_raw + } + + /// Fold a fresh reading in. `generation_changed` is the caller's verdict + /// on whether the counter's lifetime restarted (reboot / rule reload). + pub fn observe(self, raw: i64, generation_changed: bool) -> CounterState { + // A negative reading is not physically meaningful; treat it as zero + // rather than letting it subtract from a real total. + let raw = raw.max(0); + if generation_changed || raw < self.last_raw { + CounterState { + accumulated: self.accumulated + self.last_raw, + last_raw: raw, + } + } else { + CounterState { + accumulated: self.accumulated, + last_raw: raw, + } + } + } +} + +/// A token identifying the current counter lifetime. Changes whenever the +/// counters could have been reset out from under us: a reboot changes the +/// boot id, and any rule-set edit or reload changes the digest. +pub fn generation(rule_identities: &[String]) -> String { + let boot = std::fs::read_to_string("/proc/sys/kernel/random/boot_id") + .map(|s| s.trim().to_string()) + .unwrap_or_else(|_| "no-boot-id".into()); + format!("{boot}:{:016x}", digest(rule_identities)) +} + +/// FNV-1a over the rule identities. Not a security hash — it only needs to +/// change when the rule set does, and to be stable across runs and builds. +fn digest(items: &[String]) -> u64 { + let mut h: u64 = 0xcbf2_9ce4_8422_2325; + for item in items { + for b in item.as_bytes() { + h ^= *b as u64; + h = h.wrapping_mul(0x0000_0100_0000_01b3); + } + // separator, so ["ab","c"] and ["a","bc"] differ + h ^= 0xff; + h = h.wrapping_mul(0x0000_0100_0000_01b3); + } + h +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_rising_counter_is_not_double_counted() { + // The bug this whole module exists to prevent: adding each raw + // reading to a running total. Three runs seeing 10, 25, 40 packets + // is a rule that has matched 40 — not 75. + let s = CounterState::default(); + let s = s.observe(10, false); + let s = s.observe(25, false); + let s = s.observe(40, false); + assert_eq!(s.total(), 40); + } + + #[test] + fn a_counter_going_backwards_banks_the_old_lifetime() { + let s = CounterState::default().observe(500, false); + // reboot: counter restarts, and the 500 must not be lost + let s = s.observe(7, false); + assert_eq!(s.accumulated, 500); + assert_eq!(s.last_raw, 7); + assert_eq!(s.total(), 507); + } + + #[test] + fn a_generation_change_banks_even_when_the_counter_rose() { + // ufw reload between runs: the new counter (600) is larger than the + // old (500), so the backwards check alone would read it as continued + // growth and lose 500 packets of real evidence. + let s = CounterState::default().observe(500, false); + let s = s.observe(600, true); + assert_eq!(s.accumulated, 500); + assert_eq!(s.total(), 1100); + } + + #[test] + fn repeated_resets_keep_banking() { + let mut s = CounterState::default(); + for _ in 0..4 { + s = s.observe(100, false); + s = s.observe(0, true); + } + assert_eq!(s.total(), 400); + } + + #[test] + fn an_unchanged_counter_adds_nothing() { + let s = CounterState::default().observe(42, false); + let s = s.observe(42, false); + assert_eq!(s.total(), 42); + } + + #[test] + fn a_negative_reading_cannot_subtract_from_a_real_total() { + let s = CounterState::default().observe(100, false); + // a parse failure or garbage reading must not eat banked evidence + let s = s.observe(-5, false); + assert_eq!(s.accumulated, 100); + assert_eq!(s.total(), 100); + } + + #[test] + fn generation_digest_tracks_the_rule_set() { + let a = vec!["allow tcp 22".to_string(), "deny tcp 23".to_string()]; + let b = vec!["allow tcp 22".to_string()]; + assert_ne!(digest(&a), digest(&b)); + assert_eq!(digest(&a), digest(&a.clone())); + // order matters: reordering rules changes which counter is which + let c = vec!["deny tcp 23".to_string(), "allow tcp 22".to_string()]; + assert_ne!(digest(&a), digest(&c)); + } + + #[test] + fn digest_is_not_fooled_by_boundary_shifts() { + let a = vec!["ab".to_string(), "c".to_string()]; + let b = vec!["a".to_string(), "bc".to_string()]; + assert_ne!(digest(&a), digest(&b)); + } +} diff --git a/src/linux/mod.rs b/src/linux/mod.rs new file mode 100644 index 0000000..9fa3725 --- /dev/null +++ b/src/linux/mod.rs @@ -0,0 +1,247 @@ +//! Linux firewall backends. +//! +//! Windows has one firewall with one rule vocabulary. Linux has three in +//! common use, and they differ in what a "rule" even is and in how — or +//! whether — the kernel will tell you it was matched: +//! +//! | backend | rule identity | evidence | +//! |-----------|------------------------------|-----------------------------------| +//! | ufw | `### tuple ###` line | iptables counters, always on | +//! | firewalld | zone + service/port entry | shadow counter table (its own | +//! | | | nft table is `flags owner`) | +//! | nftables | table/chain/handle | counters, if the admin added them | +//! +//! The seam below is what the shared pipeline talks to. The one method that +//! is not cosmetic is [`Backend::needs_instrumentation`]: it is false for +//! ufw, and that collapses Firebreak's three run modes into one, because +//! there is no collection clock to start and no waiting period before the +//! first useful answer. + +pub mod counters; +pub mod ufw; + +use anyhow::{Context, Result}; + +/// Which firewall manager owns this host's rules. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Backend { + Ufw, +} + +impl Backend { + pub fn label(self) -> &'static str { + match self { + Backend::Ufw => "ufw", + } + } + + /// Whether Firebreak must turn something on before evidence accrues. + /// False means the kernel is already counting and the first run has a + /// real answer. + pub fn needs_instrumentation(self) -> bool { + match self { + Backend::Ufw => false, + } + } +} + +/// Work out which backend is in charge. +/// +/// Presence is not enough — a host can have ufw installed but inactive +/// while firewalld actually runs the show. Only an *active* manager counts, +/// because an inactive one's rules sit on disk but not in the kernel, and +/// reading its counters would report every rule as unused. +/// +/// Three outcomes, deliberately distinct: `Ok(Some)` means use this backend, +/// `Ok(None)` means nothing supported is running here, and `Err` means a +/// backend looks present but could not be interrogated — which is a problem +/// the user needs told about, not one to silently treat as absence. +pub fn detect() -> Result> { + if crate::syspath::system_tool("ufw").is_some() { + let active = ufw::status().context("checking whether ufw is active")?; + if active { + return Ok(Some(Backend::Ufw)); + } + } + Ok(None) +} + +/// Everything one run learned about a rule. +#[derive(Debug, Clone)] +pub struct RuleUsageRow { + pub rule: crate::model::RuleInfo, + /// Total packets matched across counter resets, or `None` when the + /// rule's counters could not be read. `None` is not zero: zero means + /// "never used, consider removing it" and `None` means "we do not know", + /// and conflating them is how a tool talks someone into deleting a rule + /// that is load-bearing. + pub hits: Option, +} + +/// A backend's report for one run. +#[derive(Debug, Default)] +pub struct Report { + pub rows: Vec, + /// Rules that exist but cannot be measured, with the reason. Kept apart + /// from `rows` so nothing unmeasurable is ever rendered as unused. + pub unmeasurable: Vec<(String, String)>, +} + +impl Report { + /// Rules that are definitely never matched — the disable candidates. + /// Excludes anything whose hits are unknown. + pub fn unused(&self) -> Vec<&RuleUsageRow> { + self.rows.iter().filter(|r| r.hits == Some(0)).collect() + } +} + +/// Counter bookkeeping carried between runs, as read from (and written back +/// to) the store. +#[derive(Debug, Default, Clone)] +pub struct PriorState { + /// The counter lifetime the stored readings belong to. `None` on a first + /// run — which is *not* treated as a reset, or every first run would + /// bank a phantom lifetime. + pub generation: Option, + pub counters: std::collections::BTreeMap, +} + +/// Collect one run's evidence from whichever backend is active. Returns the +/// report plus the state the caller must persist for the next run. +pub fn analyze(backend: Backend, prior: &PriorState) -> Result<(Report, PriorState)> { + match backend { + Backend::Ufw => analyze_ufw(prior), + } +} + +fn analyze_ufw(prior: &PriorState) -> Result<(Report, PriorState)> { + use std::collections::BTreeMap; + + let parsed = ufw::read_rules()?; + let mut report = Report::default(); + + for (tuple, reason) in &parsed.unreadable { + report.unmeasurable.push(( + format!("ufw:{tuple}"), + format!("{reason}. The rule is still active in the firewall."), + )); + } + + // one counter read per (family, chain) rather than per rule + let mut chains: BTreeMap<(ufw::Family, String), BTreeMap> = BTreeMap::new(); + for rule in &parsed.rules { + let key = (rule.family, rule.chain.clone()); + if let std::collections::btree_map::Entry::Vacant(slot) = chains.entry(key) { + slot.insert(ufw::read_counters(rule.family, &rule.chain)?); + } + } + + let ids: Vec = parsed.rules.iter().map(ufw::UfwRule::id).collect(); + let generation = counters::generation(&ids); + // A first run has nothing banked, so nothing can have been lost to a + // reset — only a *changed* generation means the counters restarted. + let generation_changed = prior.generation.as_deref().is_some_and(|g| g != generation); + + let mut next = PriorState { + generation: Some(generation), + counters: BTreeMap::new(), + }; + + for rule in &parsed.rules { + let id = rule.id(); + if parsed.untrustworthy_chains.contains(&rule.chain) { + report.unmeasurable.push(( + id, + format!( + "{} is loaded with inserts as well as appends, so Firebreak cannot tell \ + which live counter belongs to which rule.", + rule.chain + ), + )); + continue; + } + let raw = chains + .get(&(rule.family, rule.chain.clone())) + .and_then(|c| ufw::hits_for(rule, c)); + let hits = match raw { + Some(raw) => { + let state = prior + .counters + .get(&id) + .copied() + .unwrap_or_default() + .observe(raw, generation_changed); + next.counters.insert(id.clone(), state); + Some(state.total()) + } + None => { + report.unmeasurable.push(( + id.clone(), + "The live firewall chain no longer matches ufw's rule file, so this \ + rule's counter could not be identified." + .into(), + )); + None + } + }; + report.rows.push(RuleUsageRow { + rule: rule.to_rule_info(), + hits, + }); + } + Ok((report, next)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ufw_needs_no_instrumentation() { + // The whole reason ufw is the first backend: nothing to enable, no + // waiting period, an answer on the first run. + assert!(!Backend::Ufw.needs_instrumentation()); + } + + #[test] + fn unused_excludes_rules_whose_hits_are_unknown() { + let mk = |name: &str, hits: Option| RuleUsageRow { + rule: crate::model::RuleInfo { + name: name.into(), + display_name: name.into(), + description: None, + enabled: "True".into(), + direction: "Inbound".into(), + action: "Allow".into(), + profile: "Any".into(), + group: None, + program: None, + protocol: None, + local_port: None, + remote_port: None, + service: None, + remote_address: None, + }, + hits, + }; + let report = Report { + rows: vec![mk("a", Some(0)), mk("b", None), mk("c", Some(5))], + unmeasurable: vec![], + }; + let unused: Vec<&str> = report + .unused() + .iter() + .map(|r| r.rule.name.as_str()) + .collect(); + assert_eq!(unused, vec!["a"], "unknown must never read as unused"); + } + + #[test] + fn a_first_run_is_not_mistaken_for_a_counter_reset() { + // With no stored generation there is nothing banked, so treating the + // run as a reset would add a phantom lifetime to every total. + let prior = PriorState::default(); + let changed = prior.generation.as_deref().is_some_and(|g| g != "boot-a:1"); + assert!(!changed); + } +} diff --git a/src/linux/ufw.rs b/src/linux/ufw.rs new file mode 100644 index 0000000..cb41f64 --- /dev/null +++ b/src/linux/ufw.rs @@ -0,0 +1,941 @@ +//! The ufw backend. +//! +//! ufw is the easiest firewall Firebreak has to audit, and by some distance: +//! `iptables-nft` puts a packet counter on every rule automatically, so the +//! "which rules are unused" question is answerable read-only, with nothing +//! enabled and no waiting period. There is no collection clock to start — +//! see [`super::Backend::needs_instrumentation`]. +//! +//! Rule identity comes from the `### tuple ###` lines in +//! `/etc/ufw/user.rules`, which are ufw's own machine-readable record of +//! what the user asked for and map 1:1 onto `ufw status numbered`. Each +//! tuple is followed by the iptables rules ufw generated from it, in the +//! order they are loaded — so the Nth `-A ` line in the file is the +//! Nth rule of that live chain, and that positional join is how a tuple gets +//! its counter. + +use anyhow::{bail, Context, Result}; +use std::collections::BTreeMap; +use std::path::Path; + +use crate::model::RuleInfo; + +/// IP family — ufw keeps v4 and v6 rules in separate files and separate +/// kernel tables, and the same tuple text can appear in both. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum Family { + V4, + V6, +} + +impl Family { + pub fn tag(self) -> &'static str { + match self { + Family::V4 => "v4", + Family::V6 => "v6", + } + } + + /// Candidate rule-file locations, in order. Distros disagree: Debian and + /// Ubuntu keep them under `/etc/ufw`, Fedora under `/var/lib/ufw`. + fn rules_files(self) -> [&'static str; 2] { + match self { + Family::V4 => ["/etc/ufw/user.rules", "/var/lib/ufw/user.rules"], + Family::V6 => ["/etc/ufw/user6.rules", "/var/lib/ufw/user6.rules"], + } + } + + fn tool(self) -> &'static str { + match self { + Family::V4 => "iptables", + Family::V6 => "ip6tables", + } + } + + /// ufw's user chains, which are named per family: `ufw-user-input` for + /// v4 but `ufw6-user-input` for v6. Reading v6 rules against the v4 + /// chain names finds nothing, which looks exactly like a firewall with + /// no IPv6 rules — a silent half-blind audit on any dual-stack host. + fn user_chains(self) -> [&'static str; 3] { + match self { + Family::V4 => ["ufw-user-input", "ufw-user-output", "ufw-user-forward"], + Family::V6 => ["ufw6-user-input", "ufw6-user-output", "ufw6-user-forward"], + } + } + + fn chain_for(self, direction: &str) -> &'static str { + let chains = self.user_chains(); + match direction { + "out" => chains[1], + "fwd" => chains[2], + _ => chains[0], + } + } +} + +/// One generated iptables rule belonging to a tuple. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Entry { + /// 1-based position within its chain, as iptables numbers them. + pub position: usize, + /// The traffic this entry matches, ignoring what it *does* about it. + /// Entries sharing a signature see the same packets; entries with + /// different signatures see disjoint packets. Everything about counting + /// a tuple's hits correctly turns on this distinction. + pub signature: String, +} + +/// One ufw rule, as the user wrote it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct UfwRule { + /// The raw tuple text — ufw's own identity for the rule, and ours. + pub tuple: String, + pub family: Family, + /// allow / deny / reject / limit + pub action: String, + pub proto: Option, + pub dport: Option, + pub sport: Option, + pub src: String, + pub dst: String, + /// Destination application profile (`ufw allow SSH`), when the rule came + /// from one. It names the rule far better than its port does. + pub app: Option, + /// in / out / fwd + pub direction: String, + pub iface: Option, + pub comment: Option, + pub chain: String, + pub entries: Vec, +} + +/// What a parse of one rules file produced. Tuples we could not read are +/// carried out explicitly rather than dropped: a rule that silently vanishes +/// here would look like a rule that does not exist, and the whole point of +/// the tool is to tell the user what their firewall actually allows. +#[derive(Debug, Default)] +pub struct ParsedRules { + pub rules: Vec, + /// Rules that exist but cannot be counted, as (tuple text, reason). + /// Two distinct causes, kept distinguishable because they need + /// different fixes: a tuple shape this parser does not understand, and + /// a tuple that generated no iptables rules at all. + pub unreadable: Vec<(String, &'static str)>, + /// Chains whose positional join is untrustworthy because the file + /// inserts (`-I`) into them rather than only appending. + pub untrustworthy_chains: Vec, +} + +/// Parse a `user.rules` / `user6.rules` file. +pub fn parse_user_rules(text: &str, family: Family) -> ParsedRules { + let mut out = ParsedRules::default(); + // running per-chain rule position, mirroring how iptables-restore loads + let mut next_position: BTreeMap<&str, usize> = BTreeMap::new(); + let mut current: Option = None; + let mut in_rules = false; + + for raw in text.lines() { + let line = raw.trim(); + + if line == "### RULES ###" { + in_rules = true; + continue; + } + if line == "### END RULES ###" { + if let Some(r) = current.take() { + push_rule(&mut out, r); + } + in_rules = false; + continue; + } + + if let Some(spec) = line.strip_prefix("### tuple ### ") { + if let Some(r) = current.take() { + push_rule(&mut out, r); + } + match parse_tuple(spec, family) { + Some(r) => current = Some(r), + None => out.unreadable.push(( + spec.to_string(), + "Firebreak does not understand this rule's format", + )), + } + continue; + } + + // Positional joining assumes append-only load order. An insert into + // a user chain would shift every later position, so refuse to trust + // that chain's counters rather than report shifted numbers. + if let Some(rest) = line.strip_prefix("-I ") { + if let Some(chain) = rest.split_whitespace().next() { + if family.user_chains().contains(&chain) + && !out.untrustworthy_chains.iter().any(|c| c == chain) + { + out.untrustworthy_chains.push(chain.to_string()); + } + } + continue; + } + + let Some(rest) = line.strip_prefix("-A ") else { + continue; + }; + let Some(chain) = rest.split_whitespace().next() else { + continue; + }; + // every -A advances that chain's position, whether or not it belongs + // to a tuple — the live chain numbers them all + let slot = next_position.entry(chain_key(family, chain)).or_insert(1); + let position = *slot; + *slot += 1; + + if !in_rules { + continue; + } + if let Some(rule) = current.as_mut() { + if rule.chain == chain { + rule.entries.push(Entry { + position, + signature: match_signature(rest), + }); + } + } + } + if let Some(r) = current.take() { + push_rule(&mut out, r); + } + out +} + +/// Chain names are interned to `&'static str` where known so the position +/// map can key on them; unknown chains share one bucket, which is harmless +/// because only user chains are ever joined to counters. +fn chain_key(family: Family, chain: &str) -> &'static str { + family + .user_chains() + .iter() + .find(|c| **c == chain) + .copied() + .unwrap_or("other") +} + +fn push_rule(out: &mut ParsedRules, rule: UfwRule) { + // A tuple with no generated entries has nothing to count; surface it + // rather than reporting it as a zero-hit (i.e. unused) rule. + if rule.entries.is_empty() { + out.unreadable.push(( + rule.tuple, + "ufw recorded this rule but generated no firewall entry for it", + )); + return; + } + out.rules.push(rule); +} + +/// `### tuple ###` payload: +/// ` [ ] [] +/// [comment=]` +/// +/// The application-profile fields are the trap here. A rule created from an +/// app profile (`ufw allow SSH`) writes two extra tokens before the +/// direction — `allow tcp 22 0.0.0.0/0 any 0.0.0.0/0 SSH - in` — so the +/// direction is not at a fixed index. It is found by matching from the end +/// instead, which is also what makes the field count self-describing. +/// Fedora's default install ships such rules, so this is the common case, +/// not an exotic one. +/// +/// `action` may also carry a logging suffix (`allow_log`, `deny_log-all`) +/// and `dir` an interface (`in_eth0`). +fn parse_tuple(spec: &str, family: Family) -> Option { + let mut tokens: Vec<&str> = spec.split_whitespace().collect(); + + let mut comment = None; + if let Some(pos) = tokens.iter().position(|t| t.starts_with("comment=")) { + comment = decode_hex_comment(&tokens[pos]["comment=".len()..]); + tokens.remove(pos); + } + + if tokens.len() < 6 { + return None; + } + + // A trailing direction token is optional; without one, inbound is the + // implied default (ufw omitted it on older rules). + let mut direction = "in".to_string(); + let mut iface = None; + if tokens.len() > 6 { + if let Some((d, i)) = split_direction(tokens[tokens.len() - 1]) { + direction = d; + iface = i; + tokens.pop(); + } + } + + // Whatever is left past the six fixed fields must be the app-profile + // pair. Anything else is a shape this parser does not understand, and + // guessing would silently mis-describe a live rule. + let dapp = match tokens.len() { + 6 => None, + 8 => Some(tokens[6]).filter(|t| *t != "-"), + _ => return None, + }; + + let action = tokens[0].split('_').next().unwrap_or(tokens[0]).to_string(); + if !matches!(action.as_str(), "allow" | "deny" | "reject" | "limit") { + return None; + } + + let any = |s: &str| -> Option { + if s.eq_ignore_ascii_case("any") { + None + } else { + Some(s.to_string()) + } + }; + + Some(UfwRule { + tuple: spec.to_string(), + family, + action, + proto: any(tokens[1]), + dport: any(tokens[2]), + dst: tokens[3].to_string(), + sport: any(tokens[4]), + src: tokens[5].to_string(), + app: dapp.map(str::to_string), + chain: family.chain_for(&direction).to_string(), + direction, + iface, + comment, + entries: Vec::new(), + }) +} + +fn split_direction(token: &str) -> Option<(String, Option)> { + let (dir, iface) = match token.split_once('_') { + Some((d, i)) => (d, Some(i.to_string())), + None => (token, None), + }; + match dir { + "in" | "out" | "fwd" => Some((dir.to_string(), iface)), + _ => None, + } +} + +/// ufw hex-encodes rule comments. A comment that will not decode is dropped +/// (it is cosmetic), never allowed to fail the rule. +fn decode_hex_comment(hex: &str) -> Option { + if hex.is_empty() || !hex.len().is_multiple_of(2) { + return None; + } + let bytes: Option> = (0..hex.len()) + .step_by(2) + .map(|i| u8::from_str_radix(&hex[i..i + 2], 16).ok()) + .collect(); + String::from_utf8(bytes?).ok() +} + +/// Which packets an `-A` spec matches, ignoring the verdict. Only the match +/// options count — `-j`, `-m recent …`, `-m conntrack …` and friends +/// describe what happens to the packet, not which packets are seen. +fn match_signature(spec: &str) -> String { + let tokens: Vec<&str> = spec.split_whitespace().collect(); + let mut keys: BTreeMap<&str, &str> = BTreeMap::new(); + let mut i = 0; + while i < tokens.len() { + let key = tokens[i]; + let is_match_key = matches!( + key, + "-p" | "--dport" | "--sport" | "-s" | "-d" | "-i" | "-o" + ); + if is_match_key { + if let Some(value) = tokens.get(i + 1) { + keys.insert(key, value); + i += 2; + continue; + } + } + i += 1; + } + keys.into_iter() + .map(|(k, v)| format!("{k} {v}")) + .collect::>() + .join(" ") +} + +/// Hits for one tuple, given its chain's live counters indexed by position. +/// +/// A tuple can expand into several iptables rules, and they fall into two +/// kinds that must be combined differently: +/// +/// * **Same signature** — one traffic class inspected several times, as +/// `limit` does (`recent --set`, then `recent --update -j ufw-user-limit`, +/// then `-j ufw-user-limit-accept`). Summing would report three hits per +/// connection, so take the maximum: the rule that saw every packet. +/// * **Different signatures** — disjoint traffic, as `proto any` does by +/// expanding to one tcp rule and one udp rule. Neither sees the other's +/// packets, so these must be summed or half the evidence is lost. +/// +/// Returns `None` when any of the tuple's positions has no counter, which +/// means the live chain no longer matches the file. Reporting nothing is +/// correct there; reporting a partial sum would look like a lightly-used +/// rule and invite the user to delete it. +pub fn hits_for(rule: &UfwRule, counters: &BTreeMap) -> Option { + let mut by_signature: BTreeMap<&str, i64> = BTreeMap::new(); + for entry in &rule.entries { + let value = *counters.get(&entry.position)?; + let slot = by_signature.entry(entry.signature.as_str()).or_insert(0); + *slot = (*slot).max(value); + } + Some(by_signature.values().sum()) +} + +/// Parse `iptables -L -v -n -x --line-numbers` into position -> packets. +pub fn parse_chain_counters(text: &str) -> BTreeMap { + let mut out = BTreeMap::new(); + for line in text.lines() { + let mut fields = line.split_whitespace(); + let (Some(num), Some(pkts)) = (fields.next(), fields.next()) else { + continue; + }; + // header lines ("Chain …", "num pkts bytes …") fail these parses + let (Ok(num), Ok(pkts)) = (num.parse::(), pkts.parse::()) else { + continue; + }; + out.insert(num, pkts); + } + out +} + +/// Human-facing rule name, close to what `ufw status` shows. +fn display_name(rule: &UfwRule) -> String { + let port = match (&rule.app, &rule.dport, &rule.proto) { + // an app profile is the name the user chose; prefer it to the port + (Some(app), _, _) => app.clone(), + (None, Some(p), Some(proto)) => format!("{p}/{proto}"), + (None, Some(p), None) => p.clone(), + (None, None, Some(proto)) => proto.clone(), + (None, None, None) => "any".to_string(), + }; + let mut s = format!("{} {}", rule.action.to_uppercase(), port); + if rule.dst != "0.0.0.0/0" && rule.dst != "::/0" { + s.push_str(&format!(" to {}", rule.dst)); + } + if rule.src != "0.0.0.0/0" && rule.src != "::/0" { + s.push_str(&format!(" from {}", rule.src)); + } + if let Some(iface) = &rule.iface { + s.push_str(&format!(" on {iface}")); + } + if rule.family == Family::V6 { + s.push_str(" (v6)"); + } + if let Some(c) = &rule.comment { + s.push_str(&format!(" — {c}")); + } + s +} + +impl UfwRule { + /// Stable identity: family plus ufw's own tuple text. Survives + /// reordering, unlike a chain position. + pub fn id(&self) -> String { + format!("ufw:{}:{}", self.family.tag(), self.tuple) + } + + pub fn to_rule_info(&self) -> RuleInfo { + RuleInfo { + name: self.id(), + display_name: display_name(self), + description: self.comment.clone(), + enabled: "True".into(), + direction: match self.direction.as_str() { + "out" => "Outbound".into(), + "fwd" => "Forward".into(), + _ => "Inbound".into(), + }, + action: match self.action.as_str() { + // `limit` allows, with a rate cap — it is not a block + "allow" | "limit" => "Allow".into(), + _ => "Block".into(), + }, + // ufw has no zones or profiles; every rule is unconditionally in + // scope. The generalised scope label lands with the firewalld + // backend, which is the one that actually has zones. + profile: "Any".into(), + group: Some(format!("ufw {}", self.direction)), + program: None, + protocol: self.proto.clone(), + local_port: self.dport.clone(), + remote_port: self.sport.clone(), + service: None, + remote_address: Some(self.src.clone()), + } + } +} + +// --------------------------------------------------------------------------- +// Live host access +// --------------------------------------------------------------------------- + +/// Is ufw installed and active? An inactive ufw still has rules on disk but +/// nothing loaded in the kernel, so its counters would all read zero — which +/// would report every rule as unused. Refuse rather than mislead. +pub fn status() -> Result { + let ufw = crate::syspath::system_tool("ufw").context("ufw is not installed")?; + let out = crate::syspath::command(ufw) + .arg("status") + .output() + .context("running ufw status")?; + if !out.status.success() { + bail!( + "ufw status failed: {}", + String::from_utf8_lossy(&out.stderr).trim() + ); + } + let text = String::from_utf8_lossy(&out.stdout); + Ok(text + .lines() + .any(|l| l.trim().eq_ignore_ascii_case("Status: active"))) +} + +/// Read and parse both rules files. +pub fn read_rules() -> Result { + let mut all = ParsedRules::default(); + let mut looked_in: Vec<&str> = Vec::new(); + for family in [Family::V4, Family::V6] { + let Some(path) = family + .rules_files() + .into_iter() + .inspect(|p| looked_in.push(p)) + .map(Path::new) + .find(|p| p.exists()) + else { + continue; + }; + let text = std::fs::read_to_string(path).with_context(|| { + format!( + "reading {} (Firebreak needs root to read ufw's rule files)", + path.display() + ) + })?; + let parsed = parse_user_rules(&text, family); + all.rules.extend(parsed.rules); + all.unreadable.extend(parsed.unreadable); + all.untrustworthy_chains.extend(parsed.untrustworthy_chains); + } + if all.rules.is_empty() && all.unreadable.is_empty() { + bail!("no ufw rules found (looked in {})", looked_in.join(", ")); + } + Ok(all) +} + +/// Live counters for one chain. +pub fn read_counters(family: Family, chain: &str) -> Result> { + let tool = crate::syspath::system_tool(family.tool()) + .with_context(|| format!("{} is not installed", family.tool()))?; + let out = crate::syspath::command(tool) + // -x is not optional: without it iptables rounds counts to "1234K" + // and every large number becomes a lie + .args(["-L", chain, "-v", "-n", "-x", "--line-numbers"]) + .output() + .with_context(|| format!("reading {} counters for {chain}", family.tool()))?; + if !out.status.success() { + bail!( + "{} -L {chain} failed: {}", + family.tool(), + String::from_utf8_lossy(&out.stderr).trim() + ); + } + Ok(parse_chain_counters(&String::from_utf8_lossy(&out.stdout))) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Verbatim `/etc/ufw/user.rules` from a real Ubuntu 24.04 host with + /// ufw 0.36.2, after: `ufw limit ssh`, `ufw allow from 10.0.0.0/8 to any + /// port 5432 proto tcp comment "postgres from lan"`, `ufw deny 23`, + /// `ufw allow out 53`, `ufw allow in on eth0 to any port 8080 proto tcp`. + const REAL_USER_RULES: &str = r#"*filter +:ufw-user-input - [0:0] +:ufw-user-output - [0:0] +:ufw-user-forward - [0:0] +:ufw-user-limit - [0:0] +:ufw-user-limit-accept - [0:0] +### RULES ### + +### tuple ### limit tcp 22 0.0.0.0/0 any 0.0.0.0/0 in +-A ufw-user-input -p tcp --dport 22 -m conntrack --ctstate NEW -m recent --set +-A ufw-user-input -p tcp --dport 22 -m conntrack --ctstate NEW -m recent --update --seconds 30 --hitcount 6 -j ufw-user-limit +-A ufw-user-input -p tcp --dport 22 -j ufw-user-limit-accept + +### tuple ### allow tcp 5432 0.0.0.0/0 any 10.0.0.0/8 in comment=706f7374677265732066726f6d206c616e +-A ufw-user-input -p tcp --dport 5432 -s 10.0.0.0/8 -j ACCEPT + +### tuple ### deny any 23 0.0.0.0/0 any 0.0.0.0/0 in +-A ufw-user-input -p tcp --dport 23 -j DROP +-A ufw-user-input -p udp --dport 23 -j DROP + +### tuple ### allow any 53 0.0.0.0/0 any 0.0.0.0/0 out +-A ufw-user-output -p tcp --dport 53 -j ACCEPT +-A ufw-user-output -p udp --dport 53 -j ACCEPT + +### tuple ### allow tcp 8080 0.0.0.0/0 any 0.0.0.0/0 in_eth0 +-A ufw-user-input -i eth0 -p tcp --dport 8080 -j ACCEPT + +### END RULES ### + +### LOGGING ### +-A ufw-after-logging-input -j LOG --log-prefix "[UFW BLOCK] " -m limit --limit 3/min --limit-burst 10 +### END LOGGING ### + +### RATE LIMITING ### +-A ufw-user-limit -m limit --limit 3/minute -j LOG --log-prefix "[UFW LIMIT BLOCK] " +-A ufw-user-limit -j REJECT +-A ufw-user-limit-accept -j ACCEPT +### END RATE LIMITING ### +COMMIT +"#; + + /// Verbatim `iptables -L ufw-user-input -v -n -x --line-numbers` for the + /// same host, with counters edited in to exercise the arithmetic. + const REAL_INPUT_COUNTERS: &str = r#"Chain ufw-user-input (1 references) +num pkts bytes target prot opt in out source destination +1 900 54000 6 -- * * 0.0.0.0/0 0.0.0.0/0 tcp dpt:22 ctstate NEW recent: SET +2 12 720 ufw-user-limit 6 -- * * 0.0.0.0/0 0.0.0.0/0 tcp dpt:22 ctstate NEW recent: UPDATE +3 888 53280 ufw-user-limit-accept 6 -- * * 0.0.0.0/0 0.0.0.0/0 tcp dpt:22 +4 17 1020 ACCEPT 6 -- * * 10.0.0.0/8 0.0.0.0/0 tcp dpt:5432 +5 5 300 DROP 6 -- * * 0.0.0.0/0 0.0.0.0/0 tcp dpt:23 +6 3 180 DROP 17 -- * * 0.0.0.0/0 0.0.0.0/0 udp dpt:23 +7 0 0 ACCEPT 6 -- eth0 * 0.0.0.0/0 0.0.0.0/0 tcp dpt:8080 +"#; + + /// Verbatim `/var/lib/ufw/user.rules` from a real Fedora 44 host — a + /// different distro with a different rule-file location and, critically, + /// application-profile rules in the *default* install. The two extra + /// tuple fields these carry (`SSH -`) shift the direction token. + const FEDORA_USER_RULES: &str = r#"### RULES ### + +### tuple ### allow tcp 22 0.0.0.0/0 any 0.0.0.0/0 SSH - in +-A ufw-user-input -p tcp --dport 22 -j ACCEPT -m comment --comment 'dapp_SSH' + +### tuple ### allow udp 5353 224.0.0.251 any 0.0.0.0/0 mDNS - in +-A ufw-user-input -p udp -d 224.0.0.251 --dport 5353 -j ACCEPT -m comment --comment 'dapp_mDNS' + +### END RULES ### +"#; + + fn parsed() -> ParsedRules { + parse_user_rules(REAL_USER_RULES, Family::V4) + } + + #[test] + fn application_profile_rules_parse_despite_the_shifted_direction() { + let p = parse_user_rules(FEDORA_USER_RULES, Family::V4); + assert!(p.unreadable.is_empty(), "{:?}", p.unreadable); + assert_eq!(p.rules.len(), 2); + + let ssh = &p.rules[0]; + assert_eq!(ssh.app.as_deref(), Some("SSH")); + assert_eq!(ssh.dport.as_deref(), Some("22")); + assert_eq!(ssh.direction, "in", "'SSH' must not be read as a direction"); + assert_eq!(ssh.chain, "ufw-user-input"); + + let mdns = &p.rules[1]; + assert_eq!(mdns.app.as_deref(), Some("mDNS")); + assert_eq!(mdns.dst, "224.0.0.251"); + assert_eq!(mdns.proto.as_deref(), Some("udp")); + } + + #[test] + fn an_app_profile_names_the_rule_better_than_its_port_does() { + let p = parse_user_rules(FEDORA_USER_RULES, Family::V4); + assert_eq!(display_name(&p.rules[0]), "ALLOW SSH"); + assert_eq!(display_name(&p.rules[1]), "ALLOW mDNS to 224.0.0.251"); + } + + #[test] + fn a_source_app_profile_placeholder_is_not_mistaken_for_a_profile() { + let p = parse_user_rules(FEDORA_USER_RULES, Family::V4); + // "-" is ufw's "no source app profile" placeholder + assert_eq!(p.rules[0].app.as_deref(), Some("SSH")); + } + + /// Verbatim `/var/lib/ufw/user6.rules` from the same Fedora host. Note + /// the chain names: ufw uses `ufw6-user-input` for IPv6. + const FEDORA_USER6_RULES: &str = r#"### RULES ### + +### tuple ### allow tcp 22 ::/0 any ::/0 SSH - in +-A ufw6-user-input -p tcp --dport 22 -j ACCEPT -m comment --comment 'dapp_SSH' + +### tuple ### deny any 23 ::/0 any ::/0 in +-A ufw6-user-input -p tcp --dport 23 -j DROP +-A ufw6-user-input -p udp --dport 23 -j DROP + +### END RULES ### +"#; + + #[test] + fn ipv6_rules_bind_to_the_ufw6_chains() { + // Parsing v6 rules against the v4 chain names attaches no entries, + // which renders every IPv6 rule unmeasurable — indistinguishable + // from a host with no IPv6 rules at all. Half-blind, silently. + let p = parse_user_rules(FEDORA_USER6_RULES, Family::V6); + assert!(p.unreadable.is_empty(), "{:?}", p.unreadable); + assert_eq!(p.rules.len(), 2); + assert!(p.rules.iter().all(|r| r.chain == "ufw6-user-input")); + assert!(p.rules.iter().all(|r| !r.entries.is_empty())); + // and v6 positions are counted in their own chain, from 1 + assert_eq!( + p.rules[1] + .entries + .iter() + .map(|e| e.position) + .collect::>(), + vec![2, 3] + ); + } + + #[test] + fn an_unrecognised_field_count_is_reported_rather_than_guessed() { + // seven tokens with no trailing direction is a shape we do not + // understand; inventing a reading would mis-describe a live rule + let text = "### RULES ###\n\ + ### tuple ### allow tcp 22 0.0.0.0/0 any 0.0.0.0/0 mystery\n\ + -A ufw-user-input -p tcp --dport 22 -j ACCEPT\n\ + ### END RULES ###\n"; + let p = parse_user_rules(text, Family::V4); + assert!(p.rules.is_empty()); + assert_eq!(p.unreadable.len(), 1); + } + + fn rule(tuple_starts_with: &str) -> UfwRule { + parsed() + .rules + .into_iter() + .find(|r| r.tuple.starts_with(tuple_starts_with)) + .expect("rule present") + } + + #[test] + fn parses_every_tuple_in_a_real_rules_file() { + let p = parsed(); + assert_eq!(p.rules.len(), 5, "{:?}", p.rules); + assert!(p.unreadable.is_empty(), "{:?}", p.unreadable); + assert!(p.untrustworthy_chains.is_empty()); + } + + #[test] + fn tuple_fields_map_to_the_users_intent() { + let r = rule("allow tcp 5432"); + assert_eq!(r.action, "allow"); + assert_eq!(r.proto.as_deref(), Some("tcp")); + assert_eq!(r.dport.as_deref(), Some("5432")); + assert_eq!(r.src, "10.0.0.0/8"); + assert_eq!(r.sport, None, "'any' sport must not become a constraint"); + assert_eq!(r.direction, "in"); + assert_eq!(r.comment.as_deref(), Some("postgres from lan")); + assert_eq!(r.chain, "ufw-user-input"); + } + + #[test] + fn interface_qualified_direction_is_split_out() { + let r = rule("allow tcp 8080"); + assert_eq!(r.direction, "in"); + assert_eq!(r.iface.as_deref(), Some("eth0")); + assert_eq!(r.chain, "ufw-user-input"); + } + + #[test] + fn outbound_rules_land_in_the_output_chain_with_their_own_positions() { + let r = rule("allow any 53"); + assert_eq!(r.chain, "ufw-user-output"); + // positions restart per chain — an outbound rule is not position 8 + assert_eq!( + r.entries.iter().map(|e| e.position).collect::>(), + vec![1, 2] + ); + } + + #[test] + fn positions_follow_the_live_chain_ordering() { + assert_eq!( + rule("limit tcp 22") + .entries + .iter() + .map(|e| e.position) + .collect::>(), + vec![1, 2, 3] + ); + assert_eq!( + rule("allow tcp 5432") + .entries + .iter() + .map(|e| e.position) + .collect::>(), + vec![4] + ); + assert_eq!( + rule("allow tcp 8080") + .entries + .iter() + .map(|e| e.position) + .collect::>(), + vec![7] + ); + } + + #[test] + fn rate_limit_expansion_is_not_multiplied() { + // `limit` inspects the same connection three times. Summing would + // report 1800 hits for 900 connections. + let counters = parse_chain_counters(REAL_INPUT_COUNTERS); + assert_eq!(hits_for(&rule("limit tcp 22"), &counters), Some(900)); + } + + #[test] + fn protocol_expansion_is_summed_not_maxed() { + // `deny any 23` becomes disjoint tcp and udp rules: 5 + 3. + // Taking the max here would silently discard the udp evidence. + let counters = parse_chain_counters(REAL_INPUT_COUNTERS); + assert_eq!(hits_for(&rule("deny any 23"), &counters), Some(8)); + } + + #[test] + fn a_simple_rule_reports_its_own_counter() { + let counters = parse_chain_counters(REAL_INPUT_COUNTERS); + assert_eq!(hits_for(&rule("allow tcp 5432"), &counters), Some(17)); + assert_eq!(hits_for(&rule("allow tcp 8080"), &counters), Some(0)); + } + + #[test] + fn a_missing_counter_reports_nothing_rather_than_a_partial_sum() { + // live chain shorter than the file: the join is broken, and a + // partial total would read as "barely used — safe to delete" + let mut counters = parse_chain_counters(REAL_INPUT_COUNTERS); + counters.remove(&6); + assert_eq!(hits_for(&rule("deny any 23"), &counters), None); + } + + #[test] + fn counter_parsing_ignores_headers_and_keeps_exact_numbers() { + let counters = parse_chain_counters(REAL_INPUT_COUNTERS); + assert_eq!(counters.len(), 7); + assert_eq!(counters.get(&1), Some(&900)); + assert_eq!(counters.get(&4), Some(&17)); + } + + #[test] + fn match_signature_ignores_verdicts_and_stateful_matches() { + // the three `limit` entries differ only in what they do + let a = match_signature( + "ufw-user-input -p tcp --dport 22 -m conntrack --ctstate NEW -m recent --set", + ); + let b = match_signature("ufw-user-input -p tcp --dport 22 -j ufw-user-limit-accept"); + assert_eq!(a, b); + // but a different protocol is genuinely different traffic + let c = match_signature("ufw-user-input -p udp --dport 22 -j ACCEPT"); + assert_ne!(a, c); + // ...as is a different interface + let d = match_signature("ufw-user-input -i eth0 -p tcp --dport 22 -j ACCEPT"); + assert_ne!(a, d); + } + + #[test] + fn an_unparseable_tuple_is_reported_not_dropped() { + let text = "### RULES ###\n\ + ### tuple ### nonsense\n\ + -A ufw-user-input -j ACCEPT\n\ + ### END RULES ###\n"; + let p = parse_user_rules(text, Family::V4); + assert!(p.rules.is_empty()); + assert_eq!(p.unreadable.len(), 1); + assert_eq!(p.unreadable[0].0, "nonsense"); + assert!(p.unreadable[0].1.contains("format")); + } + + #[test] + fn a_tuple_with_no_generated_rules_is_reported_not_counted_as_unused() { + let text = "### RULES ###\n\ + ### tuple ### allow tcp 99 0.0.0.0/0 any 0.0.0.0/0 in\n\ + ### END RULES ###\n"; + let p = parse_user_rules(text, Family::V4); + assert!(p.rules.is_empty()); + assert_eq!(p.unreadable.len(), 1); + } + + #[test] + fn an_insert_into_a_user_chain_invalidates_that_chains_positions() { + let text = "### RULES ###\n\ + ### tuple ### allow tcp 99 0.0.0.0/0 any 0.0.0.0/0 in\n\ + -A ufw-user-input -p tcp --dport 99 -j ACCEPT\n\ + -I ufw-user-input -p tcp --dport 1 -j ACCEPT\n\ + ### END RULES ###\n"; + let p = parse_user_rules(text, Family::V4); + assert_eq!(p.untrustworthy_chains, vec!["ufw-user-input".to_string()]); + } + + #[test] + fn logging_action_variants_keep_their_base_action() { + let text = "### RULES ###\n\ + ### tuple ### deny_log-all any 25 0.0.0.0/0 any 0.0.0.0/0 in\n\ + -A ufw-user-input -p tcp --dport 25 -j DROP\n\ + ### END RULES ###\n"; + let p = parse_user_rules(text, Family::V4); + assert_eq!(p.rules[0].action, "deny"); + assert_eq!(p.rules[0].to_rule_info().action, "Block"); + } + + #[test] + fn limit_is_an_allow_not_a_block() { + assert_eq!(rule("limit tcp 22").to_rule_info().action, "Allow"); + } + + #[test] + fn rule_identity_is_stable_and_family_qualified() { + let r = rule("allow tcp 5432"); + assert_eq!( + r.id(), + "ufw:v4:allow tcp 5432 0.0.0.0/0 any 10.0.0.0/8 in comment=706f7374677265732066726f6d206c616e" + ); + let mut v6 = r.clone(); + v6.family = Family::V6; + assert_ne!(r.id(), v6.id(), "v4 and v6 rules are distinct rules"); + } + + #[test] + fn display_name_reads_like_ufw_status() { + assert_eq!( + display_name(&rule("allow tcp 5432")), + "ALLOW 5432/tcp from 10.0.0.0/8 — postgres from lan" + ); + assert_eq!( + display_name(&rule("allow tcp 8080")), + "ALLOW 8080/tcp on eth0" + ); + assert_eq!(display_name(&rule("deny any 23")), "DENY 23"); + } + + #[test] + fn comments_round_trip_from_hex() { + assert_eq!( + decode_hex_comment("706f7374677265732066726f6d206c616e").as_deref(), + Some("postgres from lan") + ); + assert_eq!(decode_hex_comment("zz").as_deref(), None); + assert_eq!(decode_hex_comment("7").as_deref(), None); + } + + #[test] + fn a_tuple_without_a_direction_defaults_to_inbound() { + let text = "### RULES ###\n\ + ### tuple ### allow tcp 21 0.0.0.0/0 any 0.0.0.0/0\n\ + -A ufw-user-input -p tcp --dport 21 -j ACCEPT\n\ + ### END RULES ###\n"; + let p = parse_user_rules(text, Family::V4); + assert_eq!(p.rules[0].direction, "in"); + assert_eq!(p.rules[0].chain, "ufw-user-input"); + } +} diff --git a/src/main.rs b/src/main.rs index 563acf7..77ebb9d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -9,6 +9,8 @@ mod elevation; mod event_query; mod filter_map; mod firewall_rules; +#[cfg(target_os = "linux")] +mod linux; mod listeners; mod model; mod pipeline; @@ -91,9 +93,14 @@ fn parse_args_from(args_iter: impl Iterator) -> Args { "--help" | "-h" => { println!( "Firebreak — Observe first. Enforce with confidence.\n\ - Windows Firewall rule-usage auditor.\n\n\ + Firewall rule-usage auditor for Windows and Linux.\n\n\ USAGE:\n\ \x20 firebreak [OPTIONS]\n\n\ + ON LINUX (ufw): runs as root, prints a rule-usage report and exits.\n\ + \x20 The kernel already counts packets per rule, so there is nothing to\n\ + \x20 enable and no waiting period — the first run has a real answer. The\n\ + \x20 collection options below are Windows-only and do not apply.\n\n\ + ON WINDOWS:\n\ Run without arguments for the app: it boots to the rule table, offers an\n\ 'Enable connection auditing' button on first run, and on later runs\n\ ingests new 5156/5157 events and correlates them to firewall rules.\n\ @@ -155,6 +162,46 @@ fn main() -> Result<()> { return preview::run(); } + // On Linux, take the counter-backend path when one of the supported + // firewall managers is actually in charge. Otherwise fall through to the + // shared flow, which still serves --ui-preview and reports honestly that + // the Windows evidence sources are unavailable here. + #[cfg(target_os = "linux")] + { + if !elevation::is_elevated() { + bail!( + "firebreak must run as root on Linux — the firewall's rule files, its packet \ + counters and /proc process attribution are all root-only. Re-run with sudo, \ + or use --ui-preview to look at the interface unprivileged." + ); + } + if let Some(backend) = linux::detect()? { + return run_linux(&args, backend); + } + eprintln!( + "No supported Linux firewall backend is active (Firebreak supports ufw so far; \ + firewalld and raw nftables are not wired up yet)." + ); + } + + run_windows(args) +} + +/// The Linux run. Deliberately not the Windows flow with substitutions: on +/// ufw there is no audit policy to enable, no event log to checkpoint and no +/// collection clock to start, because the kernel is already counting. The +/// first run has a real answer. +#[cfg(target_os = "linux")] +fn run_linux(args: &Args, backend: linux::Backend) -> Result<()> { + let store = Store::open(&args.db_path)?; + let prior = store.load_counter_state()?; + let (report, next) = linux::analyze(backend, &prior)?; + store.save_counter_state(&next)?; + print_linux_report(backend, &report); + Ok(()) +} + +fn run_windows(args: Args) -> Result<()> { // clear a leftover exe image from a prior self-update update::cleanup_old(); @@ -253,6 +300,61 @@ fn dump_filters() -> Result<()> { Ok(()) } +/// Text report for a counter-based backend. Unused, used and unmeasurable +/// are three separate sections on purpose: folding "we could not read this +/// rule's counter" into the zero-hit list would invite the user to delete a +/// rule Firebreak never actually observed. +#[cfg(target_os = "linux")] +fn print_linux_report(backend: linux::Backend, report: &linux::Report) { + println!( + "Backend: {} ({})", + backend.label(), + if backend.needs_instrumentation() { + "collection must be enabled first" + } else { + "counters already running — no collection to enable" + } + ); + + let unused = report.unused(); + println!( + "\n=== Never matched ({}) — disable candidates ===", + unused.len() + ); + for row in &unused { + println!( + " {} [{} {}]", + row.rule.display_name, row.rule.direction, row.rule.action + ); + } + + let mut used: Vec<_> = report + .rows + .iter() + .filter(|r| r.hits.unwrap_or(0) > 0) + .collect(); + used.sort_by_key(|r| std::cmp::Reverse(r.hits.unwrap_or(0))); + println!("\n=== Matched (most first) ==="); + for row in used { + println!( + " {:>12} packets {}", + row.hits.unwrap_or(0), + row.rule.display_name + ); + } + + if !report.unmeasurable.is_empty() { + println!( + "\n=== Not measurable ({}) — active, but with no usable hit count ===", + report.unmeasurable.len() + ); + println!("(these are NOT unused; Firebreak simply cannot count them)"); + for (id, why) in &report.unmeasurable { + println!(" {id}\n {why}"); + } + } +} + fn print_text_report(result: &pipeline::AnalysisResult) -> Result<()> { let rows = &result.rows; let mut sorted: Vec<&ui::RuleRow> = rows.iter().collect(); diff --git a/src/store.rs b/src/store.rs index 6518383..741dac0 100644 --- a/src/store.rs +++ b/src/store.rs @@ -103,6 +103,16 @@ impl Store { fingerprint TEXT NOT NULL, reviewed_at TEXT NOT NULL ); + + -- Linux counter backends only. A kernel packet counter is a + -- gauge that resets (reboot, firewall reload), so a rule's real + -- total is everything banked from previous counter lifetimes + -- plus the current reading. See linux::counters. + CREATE TABLE IF NOT EXISTS rule_counter ( + rule_id TEXT PRIMARY KEY, + accumulated INTEGER NOT NULL DEFAULT 0, + last_raw INTEGER NOT NULL DEFAULT 0 + ); "#, )?; let store = Store { conn }; @@ -206,6 +216,70 @@ impl Store { } /// rule_id -> (fingerprint, reviewed_at) + /// Read back the counter bookkeeping for a Linux counter backend. + #[cfg(target_os = "linux")] + pub fn load_counter_state(&self) -> Result { + let mut stmt = self + .conn + .prepare("SELECT rule_id, accumulated, last_raw FROM rule_counter")?; + let rows = stmt.query_map([], |r| { + Ok(( + r.get::<_, String>(0)?, + crate::linux::counters::CounterState { + accumulated: r.get(1)?, + last_raw: r.get(2)?, + }, + )) + })?; + let mut counters = std::collections::BTreeMap::new(); + for row in rows { + let (k, v) = row?; + counters.insert(k, v); + } + Ok(crate::linux::PriorState { + generation: self.get_meta("counter_generation")?, + counters, + }) + } + + /// Persist counter bookkeeping. Written as one transaction with the + /// generation token: a generation saved without its counters (or the + /// reverse) would make the next run mis-detect a reset and either bank a + /// phantom lifetime or silently drop a real one. + #[cfg(target_os = "linux")] + pub fn save_counter_state(&self, state: &crate::linux::PriorState) -> Result<()> { + self.conn.execute_batch("BEGIN IMMEDIATE")?; + let result = (|| -> Result<()> { + // rules deleted from the firewall must not keep reporting totals + self.conn.execute("DELETE FROM rule_counter", [])?; + for (rule_id, c) in &state.counters { + self.conn.execute( + "INSERT INTO rule_counter (rule_id, accumulated, last_raw) + VALUES (?1, ?2, ?3)", + params![rule_id, c.accumulated, c.last_raw], + )?; + } + if let Some(g) = &state.generation { + self.conn.execute( + "INSERT INTO meta (key, value) VALUES ('counter_generation', ?1) + ON CONFLICT(key) DO UPDATE SET value = excluded.value", + params![g], + )?; + } + Ok(()) + })(); + match result { + Ok(()) => { + self.conn.execute_batch("COMMIT")?; + Ok(()) + } + Err(e) => { + let _ = self.conn.execute_batch("ROLLBACK"); + Err(e) + } + } + } + pub fn load_reviewed(&self) -> Result> { let mut stmt = self .conn diff --git a/src/syspath.rs b/src/syspath.rs index dfaac28..63545b5 100644 --- a/src/syspath.rs +++ b/src/syspath.rs @@ -1,7 +1,8 @@ //! Absolute paths for the system executables we spawn. An elevated process //! must not resolve tool names through the PATH/CreateProcess search order — //! a planted powershell.exe next to the binary or in a user-writable PATH -//! entry would run with admin rights. +//! entry would run with admin rights. The same rule holds for a root-run +//! Linux process and `ufw`/`iptables`/`nft`. use std::path::PathBuf; @@ -10,6 +11,23 @@ fn system_root() -> PathBuf { PathBuf::from(std::env::var("SystemRoot").unwrap_or_else(|_| r"C:\Windows".into())) } +/// System directories a Linux tool may legitimately live in. Fixed list, in +/// preference order — never `$PATH`, which a caller can point anywhere. +#[cfg(unix)] +const SYSTEM_BIN_DIRS: [&str; 4] = ["/usr/sbin", "/sbin", "/usr/bin", "/bin"]; + +/// Resolve a Linux system tool (`ufw`, `iptables`, `nft`, `firewall-cmd`) to +/// an absolute path under [`SYSTEM_BIN_DIRS`]. `None` when it isn't +/// installed — callers treat that as "this backend isn't present", not as an +/// error. +#[cfg(unix)] +pub fn system_tool(name: &str) -> Option { + SYSTEM_BIN_DIRS + .iter() + .map(|d| PathBuf::from(d).join(name)) + .find(|p| p.is_file()) +} + /// Full path to a System32 tool, e.g. netsh.exe / auditpol.exe / wevtutil.exe. pub fn system32_tool(exe_name: &str) -> PathBuf { system_root().join("System32").join(exe_name) From 19c1f646cbfd11d2e343a0380168ad88b2144882 Mon Sep 17 00:00:00 2001 From: ghostpsalm Date: Mon, 10 Aug 2026 00:24:55 +1000 Subject: [PATCH 3/8] Linux port (3/n): make rule scope a backend-supplied vocabulary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ProfileSet was three bools named Domain/Private/Public — the Windows network-profile trichotomy, hardcoded into shared code the Linux backends have to pass through. firewalld has N user-defined zones and ufw has no scope concept at all, so the names had to become data. ScopeVocabulary declares what a backend divides rules into; ScopeSet is a rule's membership in it. The UI's scope chips, filter row and apply path all loop over the vocabulary rather than three fields, so an empty vocabulary (ufw) simply renders nothing and a zone list of any length works. Two behaviours that had to survive the change, and now have tests that say so: a rule whose scope will not parse expands to *every* scope rather than none — it is live somewhere, and treating it as empty would both hide it from the table and let an apply narrow it to nothing; and an empty selection is not "Any", so narrowing a rule to no scopes has no rule-text form and the caller must disable the rule instead. ScopeIndex::build and ScopeSet::from_rule take the vocabulary explicitly rather than reading a process-wide default. The default is per-platform, so a global made the Windows profile-gate tests pass or fail depending on which host ran them — they were passing on Linux for the wrong reason. Also gives Linux a real data directory: /var/lib/firebreak, created 0700 and accepted only when owned by the effective UID and closed to group and other. The non-Windows path was a bare create_dir_all marked "dev builds only", which is no longer true now that it holds the same usage database. --- src/elevation.rs | 15 +- src/linux/mod.rs | 9 ++ src/main.rs | 4 + src/model.rs | 388 ++++++++++++++++++++++++++++++++++------------ src/pipeline.rs | 9 +- src/preview.rs | 5 +- src/scope.rs | 34 ++-- src/secure_dir.rs | 121 ++++++++++++++- src/store.rs | 17 +- src/ui.rs | 85 +++++----- src/ui/paint.rs | 77 +++++---- 11 files changed, 561 insertions(+), 203 deletions(-) diff --git a/src/elevation.rs b/src/elevation.rs index b99d517..33d5676 100644 --- a/src/elevation.rs +++ b/src/elevation.rs @@ -35,14 +35,19 @@ pub fn is_elevated() -> bool { /// one call; the effective UID is the second field of the `Uid:` line. #[cfg(target_os = "linux")] pub fn is_elevated() -> bool { - let Ok(status) = std::fs::read_to_string("/proc/self/status") else { - return false; - }; - status + effective_uid() == Some(0) +} + +/// This process's effective UID. `None` if /proc is unreadable, which +/// callers must treat as "unknown", never as "root". +#[cfg(unix)] +pub fn effective_uid() -> Option { + std::fs::read_to_string("/proc/self/status") + .ok()? .lines() .find_map(|l| l.strip_prefix("Uid:")) .and_then(|rest| rest.split_whitespace().nth(1)) - .is_some_and(|euid| euid == "0") + .and_then(|euid| euid.parse().ok()) } #[cfg(not(any(windows, target_os = "linux")))] diff --git a/src/linux/mod.rs b/src/linux/mod.rs index 9fa3725..65da259 100644 --- a/src/linux/mod.rs +++ b/src/linux/mod.rs @@ -43,6 +43,15 @@ impl Backend { Backend::Ufw => false, } } + + /// How this backend divides rules into scopes. ufw does not: every rule + /// applies unconditionally, so the scope column and filter disappear + /// rather than showing three Windows profiles that mean nothing here. + pub fn scope_vocabulary(self) -> crate::model::ScopeVocabulary { + match self { + Backend::Ufw => crate::model::ScopeVocabulary::none(), + } + } } /// Work out which backend is in charge. diff --git a/src/main.rs b/src/main.rs index 77ebb9d..a2816d9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -193,6 +193,8 @@ fn main() -> Result<()> { /// first run has a real answer. #[cfg(target_os = "linux")] fn run_linux(args: &Args, backend: linux::Backend) -> Result<()> { + // Declare the host's scope vocabulary before anything renders a rule. + model::set_vocabulary(backend.scope_vocabulary()); let store = Store::open(&args.db_path)?; let prior = store.load_counter_state()?; let (report, next) = linux::analyze(backend, &prior)?; @@ -202,6 +204,8 @@ fn run_linux(args: &Args, backend: linux::Backend) -> Result<()> { } fn run_windows(args: Args) -> Result<()> { + model::set_vocabulary(model::ScopeVocabulary::windows_profiles()); + // clear a leftover exe image from a prior self-update update::cleanup_old(); diff --git a/src/model.rs b/src/model.rs index 4e816d4..63bfada 100644 --- a/src/model.rs +++ b/src/model.rs @@ -62,42 +62,189 @@ impl RuleInfo { ) } - /// Profile tags for display: ["Domain"], ["Private", "Public"], … or - /// ["Any"]. Unknown/NotApplicable values render as-is so nothing is - /// silently hidden. - pub fn profile_tags(&self) -> Vec<&'static str> { - let p = self.profile.to_lowercase(); - if p.contains("any") { - return vec!["Any"]; + /// Scope tags for display: ["Domain"], ["Private", "Public"], … or + /// ["Any"]. Names come from the host's vocabulary, so on firewalld these + /// are zones. Unrecognised values (Windows' "NotApplicable", an unknown + /// zone) yield no tags, which callers must treat as "scope unknown" — + /// never as "scope empty". + pub fn scope_tags(&self, vocab: &ScopeVocabulary) -> Vec { + let raw = self.profile.to_lowercase(); + if vocab.is_empty() { + return Vec::new(); } - let mut tags = Vec::new(); - if p.contains("domain") { - tags.push("Domain"); + if raw.contains(&vocab.any_token.to_lowercase()) { + return vec![vocab.any_token.clone()]; } - if p.contains("private") { - tags.push("Private"); - } - if p.contains("public") { - tags.push("Public"); - } - tags + vocab + .names + .iter() + .filter(|n| raw.contains(&n.to_lowercase())) + .cloned() + .collect() } - /// Whether this rule is active in at least one of the selected profiles. - /// "Any" (and unrecognized values like NotApplicable) match whenever at - /// least one profile is selected — filtering must never hide a rule - /// whose scope we couldn't parse. - pub fn applies_to_profile(&self, domain: bool, private: bool, public: bool) -> bool { - if !(domain || private || public) { + /// Whether this rule is active in at least one of the selected scopes. + /// "Any", an unparseable scope, and a backend with no scope concept at + /// all each match whenever *something* is selected: a filter must never + /// hide a rule whose scope could not be read, or the user audits a + /// firewall while a rule they cannot see is letting traffic through. + pub fn applies_to_scopes(&self, vocab: &ScopeVocabulary, selected: &[String]) -> bool { + if vocab.is_empty() { + return true; + } + if selected.is_empty() { return false; } - let tags = self.profile_tags(); - if tags.is_empty() || tags == ["Any"] { + let tags = self.scope_tags(vocab); + if tags.is_empty() || tags == [vocab.any_token.clone()] { return true; } - (domain && tags.contains(&"Domain")) - || (private && tags.contains(&"Private")) - || (public && tags.contains(&"Public")) + tags.iter().any(|t| selected.contains(t)) + } +} + +/// The scopes a firewall backend divides its rules into. +/// +/// Windows has exactly three network profiles. firewalld has a user-defined +/// list of zones, of any length. ufw has no such concept at all — every rule +/// simply applies. Nothing shared can therefore hardcode three names, so the +/// names are data supplied by whichever backend is in charge. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct ScopeVocabulary { + /// Scope names in display order. Empty = this backend has no scopes. + pub names: Vec, + /// The token a rule uses to mean "all scopes". + pub any_token: String, +} + +impl ScopeVocabulary { + /// Windows Firewall's network profiles. + pub fn windows_profiles() -> Self { + ScopeVocabulary { + names: vec!["Domain".into(), "Private".into(), "Public".into()], + any_token: "Any".into(), + } + } + + /// A backend without scopes, e.g. ufw. + pub fn none() -> Self { + ScopeVocabulary::default() + } + + pub fn is_empty(&self) -> bool { + self.names.is_empty() + } +} + +/// The host's scope vocabulary. It is a property of the machine Firebreak is +/// auditing — fixed for the life of the process — so it is set once at +/// startup rather than threaded through every rule-rendering call. +static VOCABULARY: std::sync::OnceLock = std::sync::OnceLock::new(); + +/// Declare the host's vocabulary. First call wins; later calls are ignored, +/// so a backend cannot silently redefine scopes mid-run. +pub fn set_vocabulary(vocab: ScopeVocabulary) { + let _ = VOCABULARY.set(vocab); +} + +pub fn vocabulary() -> &'static ScopeVocabulary { + VOCABULARY.get_or_init(|| { + if cfg!(windows) { + ScopeVocabulary::windows_profiles() + } else { + ScopeVocabulary::none() + } + }) +} + +/// Which of the host vocabulary's scopes a rule is active in — the editable +/// scope behind the clickable chips. Ordered to match the vocabulary so the +/// UI renders scopes in a stable order. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct ScopeSet { + entries: Vec<(String, bool)>, + /// Carried so the set can render itself back to rule text without + /// needing the vocabulary again. + any_token: String, +} + +impl ScopeSet { + pub fn from_rule(r: &RuleInfo, vocab: &ScopeVocabulary) -> ScopeSet { + let tags = r.scope_tags(vocab); + // "Any" and an unreadable scope both expand to every scope: the rule + // is live everywhere until proven otherwise. + let all = tags.is_empty() || tags == [vocab.any_token.clone()]; + ScopeSet { + entries: vocab + .names + .iter() + .map(|n| (n.clone(), all || tags.contains(n))) + .collect(), + any_token: vocab.any_token.clone(), + } + } + + pub fn iter(&self) -> impl Iterator { + self.entries.iter().map(|(n, a)| (n.as_str(), *a)) + } + + pub fn is_active(&self, name: &str) -> bool { + self.entries.iter().any(|(n, a)| n == name && *a) + } + + pub fn set(&mut self, name: &str, active: bool) { + for (n, a) in self.entries.iter_mut() { + if n == name { + *a = active; + } + } + } + + pub fn toggle(&mut self, name: &str) { + let now = self.is_active(name); + self.set(name, !now); + } + + /// No scope selected. For a backend with no scopes this is false — an + /// empty vocabulary means "always applies", not "applies nowhere". + pub fn is_empty(&self) -> bool { + !self.entries.is_empty() && self.entries.iter().all(|(_, a)| !*a) + } + + pub fn is_all(&self) -> bool { + self.entries.iter().all(|(_, a)| *a) + } + + /// The backend's rule-text form, e.g. Windows' `-Profile Domain,Private`. + /// None when nothing is selected — the caller should disable the rule + /// instead of narrowing it to nothing. + pub fn to_arg(&self) -> Option { + if self.entries.is_empty() { + return None; + } + if self.is_empty() { + return None; + } + if self.is_all() { + return Some(self.any_token.clone()); + } + Some( + self.entries + .iter() + .filter(|(_, a)| *a) + .map(|(n, _)| n.as_str()) + .collect::>() + .join(","), + ) + } + + /// Scopes present in `self` but dropped in `other` — what an edit removes. + pub fn removed_since(&self, other: &ScopeSet) -> Vec<&str> { + self.entries + .iter() + .filter(|(n, a)| *a && !other.is_active(n)) + .map(|(n, _)| n.as_str()) + .collect() } } @@ -181,64 +328,6 @@ pub struct BaselineFlag { pub advice: &'static str, } -/// The set of network profiles a rule applies to — the editable scope behind -/// the clickable profile chips. "Any" expands to all three. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct ProfileSet { - pub domain: bool, - pub private: bool, - pub public: bool, -} - -impl ProfileSet { - pub fn from_rule(r: &RuleInfo) -> ProfileSet { - let tags = r.profile_tags(); - if tags == ["Any"] { - ProfileSet { - domain: true, - private: true, - public: true, - } - } else { - ProfileSet { - domain: tags.contains(&"Domain"), - private: tags.contains(&"Private"), - public: tags.contains(&"Public"), - } - } - } - - pub fn is_empty(&self) -> bool { - !self.domain && !self.private && !self.public - } - - pub fn is_all(&self) -> bool { - self.domain && self.private && self.public - } - - /// The `-Profile` argument for Set-NetFirewallRule; None when empty - /// (caller should disable the rule instead). - pub fn to_profile_arg(self) -> Option { - if self.is_empty() { - return None; - } - if self.is_all() { - return Some("Any".into()); - } - let mut v = Vec::new(); - if self.domain { - v.push("Domain"); - } - if self.private { - v.push("Private"); - } - if self.public { - v.push("Public"); - } - Some(v.join(",")) - } -} - #[cfg(test)] mod tests { use super::*; @@ -262,28 +351,131 @@ mod tests { } } + fn sel(names: &[&str]) -> Vec { + names.iter().map(|s| s.to_string()).collect() + } + #[test] - fn profile_tags_parse_combinations() { - assert_eq!(rule_with_profile("Any").profile_tags(), vec!["Any"]); + fn scope_tags_parse_combinations() { + let v = ScopeVocabulary::windows_profiles(); + assert_eq!(rule_with_profile("Any").scope_tags(&v), vec!["Any"]); assert_eq!( - rule_with_profile("Domain, Public").profile_tags(), + rule_with_profile("Domain, Public").scope_tags(&v), vec!["Domain", "Public"] ); - assert_eq!(rule_with_profile("Private").profile_tags(), vec!["Private"]); + assert_eq!(rule_with_profile("Private").scope_tags(&v), vec!["Private"]); } #[test] - fn profile_filter_matches_selected_sets() { + fn scope_filter_matches_selected_sets() { + let v = ScopeVocabulary::windows_profiles(); let dp = rule_with_profile("Domain, Private"); - assert!(dp.applies_to_profile(true, false, false)); - assert!(dp.applies_to_profile(false, true, false)); - assert!(!dp.applies_to_profile(false, false, true)); + assert!(dp.applies_to_scopes(&v, &sel(&["Domain"]))); + assert!(dp.applies_to_scopes(&v, &sel(&["Private"]))); + assert!(!dp.applies_to_scopes(&v, &sel(&["Public"]))); // Any matches whenever something is selected, never when nothing is let any = rule_with_profile("Any"); - assert!(any.applies_to_profile(false, false, true)); - assert!(!any.applies_to_profile(false, false, false)); - // unparseable scope must stay visible rather than silently vanish + assert!(any.applies_to_scopes(&v, &sel(&["Public"]))); + assert!(!any.applies_to_scopes(&v, &sel(&[]))); + } + + #[test] + fn an_unreadable_scope_stays_visible() { + // A rule whose scope Firebreak cannot parse is still a live rule. + // Hiding it would let the user audit a firewall to "clean" while + // something they never saw is admitting traffic. + let v = ScopeVocabulary::windows_profiles(); let odd = rule_with_profile("NotApplicable"); - assert!(odd.applies_to_profile(true, false, false)); + assert!(odd.applies_to_scopes(&v, &sel(&["Domain"]))); + } + + #[test] + fn a_backend_without_scopes_shows_every_rule() { + // ufw has no zones or profiles at all. An empty vocabulary must mean + // "always applies", not "applies nowhere" — the latter would render + // an entire Linux firewall invisible. + let v = ScopeVocabulary::none(); + let r = rule_with_profile("Any"); + assert!(r.applies_to_scopes(&v, &sel(&[]))); + assert!(r.scope_tags(&v).is_empty()); + } + + #[test] + fn an_arbitrary_zone_vocabulary_works() { + // firewalld zones are user-defined and there can be any number. + let v = ScopeVocabulary { + names: vec!["FedoraWorkstation".into(), "public".into(), "dmz".into()], + any_token: "Any".into(), + }; + let r = rule_with_profile("FedoraWorkstation"); + assert_eq!(r.scope_tags(&v), vec!["FedoraWorkstation"]); + assert!(r.applies_to_scopes(&v, &sel(&["FedoraWorkstation"]))); + assert!(!r.applies_to_scopes(&v, &sel(&["dmz"]))); + } + + #[test] + fn scope_set_round_trips_through_the_rule_text_form() { + let v = ScopeVocabulary::windows_profiles(); + let mut s = ScopeSet::from_rule(&rule_with_profile("Any"), &v); + assert!(s.is_all()); + assert_eq!(s.to_arg().as_deref(), Some("Any")); + s.set("Public", false); + assert_eq!(s.to_arg().as_deref(), Some("Domain,Private")); + } + + #[test] + fn narrowing_to_nothing_has_no_rule_text_form() { + // An empty scope is not "Any". Writing it back as Any would widen a + // rule the user was trying to switch off — the caller must disable + // the rule instead. + let v = ScopeVocabulary::windows_profiles(); + let mut s = ScopeSet::from_rule(&rule_with_profile("Any"), &v); + for name in ["Domain", "Private", "Public"] { + s.set(name, false); + } + assert!(s.is_empty()); + assert_eq!(s.to_arg(), None); + } + + #[test] + fn removed_since_reports_what_an_edit_drops() { + let v = ScopeVocabulary::windows_profiles(); + let orig = ScopeSet::from_rule(&rule_with_profile("Any"), &v); + let mut target = orig.clone(); + target.set("Public", false); + assert_eq!(orig.removed_since(&target), vec!["Public"]); + assert!(target.removed_since(&orig).is_empty()); + } + + #[test] + fn a_no_scope_backend_has_an_empty_editable_set() { + // ufw: nothing to render as chips, and nothing to write back. + let v = ScopeVocabulary::none(); + let s = ScopeSet::from_rule(&rule_with_profile("Any"), &v); + assert_eq!(s.iter().count(), 0); + assert!(!s.is_empty(), "no scopes is not the same as none selected"); + assert_eq!(s.to_arg(), None); + } + + #[test] + fn an_unreadable_scope_expands_to_every_scope_not_none() { + // A rule whose scope will not parse is live somewhere. Treating it + // as empty would render it as "applies nowhere" and, worse, let an + // apply narrow it to nothing. + let v = ScopeVocabulary::windows_profiles(); + let s = ScopeSet::from_rule(&rule_with_profile("NotApplicable"), &v); + assert!(s.is_all()); + } + + #[test] + fn zones_of_any_length_round_trip() { + let v = ScopeVocabulary { + names: vec!["a".into(), "b".into(), "c".into(), "d".into()], + any_token: "Any".into(), + }; + let mut s = ScopeSet::from_rule(&rule_with_profile("b, d"), &v); + assert_eq!(s.to_arg().as_deref(), Some("b,d")); + s.toggle("a"); + assert_eq!(s.to_arg().as_deref(), Some("a,b,d")); } } diff --git a/src/pipeline.rs b/src/pipeline.rs index a7b568d..2a6ef94 100644 --- a/src/pipeline.rs +++ b/src/pipeline.rs @@ -277,7 +277,7 @@ fn import_events( store.reset_ingestion()?; } - let scope_index = crate::scope::ScopeIndex::build(&rules); + let scope_index = crate::scope::ScopeIndex::build(&rules, crate::model::vocabulary()); let device_map = app_identity::device_path_map(); store.begin()?; @@ -435,7 +435,8 @@ fn build_rows( .unwrap_or_default(); let listening = listeners::listeners_for_rule(&rule, listener_list); let target_enabled = rule.is_enabled(); - let target_profiles = crate::model::ProfileSet::from_rule(&rule); + let target_scopes = + crate::model::ScopeSet::from_rule(&rule, crate::model::vocabulary()); // a review attests to a specific definition: on fingerprint // mismatch the mark goes stale and the rule resurfaces let review = match reviewed.get(&rule.name) { @@ -450,7 +451,7 @@ fn build_rows( seen_apps, listening, target_enabled, - target_profiles, + target_scopes, reviewed: review, } }) @@ -581,7 +582,7 @@ pub fn analyze(db_path: &Path, progress: &dyn Fn(&str)) -> Result Result<()> { let flags = baseline_checks::flags_for(&rule); let listening = listeners::listeners_for_rule(&rule, &mock_listeners); let target_enabled = pending.unwrap_or_else(|| rule.is_enabled()); - let target_profiles = crate::model::ProfileSet::from_rule(&rule); + let target_scopes = + crate::model::ScopeSet::from_rule(&rule, crate::model::vocabulary()); // demo reviewed states: one verified, one stale (rule changed // since it was checked) let reviewed = match rule.display_name.as_str() { @@ -413,7 +414,7 @@ pub fn run() -> Result<()> { seen_apps: apps.into_iter().map(Into::into).collect(), listening, target_enabled, - target_profiles, + target_scopes, reviewed, } }) diff --git a/src/scope.rs b/src/scope.rs index 60f8954..1a6bc15 100644 --- a/src/scope.rs +++ b/src/scope.rs @@ -89,7 +89,7 @@ struct RuleScope { } impl RuleScope { - fn from_rule(r: &RuleInfo) -> Option { + fn from_rule(r: &RuleInfo, vocab: &crate::model::ScopeVocabulary) -> Option { // A disabled rule is never loaded into WFP, so it cannot decide any // connection — crediting it with a hit would be a false attribution. // Exclude it from the index entirely (matches the module doc: we @@ -121,13 +121,17 @@ impl RuleScope { .as_deref() .filter(|p| !p.is_empty() && !p.eq_ignore_ascii_case("any")) .map(|p| basename(&expand_program(p)).to_lowercase()); - let tags = r.profile_tags(); - let profiles = if tags == ["Any"] { + // Per-profile attribution is a Windows notion: events carry an + // interface index that maps to a network profile. Backends whose + // scope vocabulary is empty (ufw) or unrelated to interfaces + // (firewalld zones) constrain nothing here. + let tags = r.scope_tags(vocab); + let profiles = if tags.is_empty() || tags == [vocab.any_token.clone()] { None } else { let set: HashSet = tags .iter() - .filter_map(|t| match *t { + .filter_map(|t| match t.as_str() { "Domain" => Some(Profile::Domain), "Private" => Some(Profile::Private), "Public" => Some(Profile::Public), @@ -243,12 +247,12 @@ pub struct ScopeIndex { } impl ScopeIndex { - pub fn build(rules: &[RuleInfo]) -> ScopeIndex { + pub fn build(rules: &[RuleInfo], vocab: &crate::model::ScopeVocabulary) -> ScopeIndex { let mut scopes = Vec::new(); let mut by_proto: HashMap<(bool, u32), Vec> = HashMap::new(); let mut any_proto: HashMap> = HashMap::new(); for r in rules { - if let Some(s) = RuleScope::from_rule(r) { + if let Some(s) = RuleScope::from_rule(r, vocab) { if !s.is_attributable() { continue; // Any/Any/Any rule — would match everything } @@ -349,7 +353,7 @@ mod tests { rule("mDNS", "Inbound", Some("UDP"), Some("5353"), None, None), rule("RDP", "Inbound", Some("TCP"), Some("3389"), None, None), ]; - let idx = ScopeIndex::build(&rules); + let idx = ScopeIndex::build(&rules, &crate::model::ScopeVocabulary::windows_profiles()); let e = ev("Inbound", 17, "5353", "5353", r"\device\hd\svchost.exe"); let c = conn(&e, r"C:\windows\system32\svchost.exe"); assert_eq!(idx.matching_rules(&c), vec!["mDNS"]); @@ -360,7 +364,7 @@ mod tests { // Any/Any/Any inbound rule (a Store-app rule) must not match every // connection — regression for the 30-identical-rows bug let rules = vec![rule("Windows Camera", "Inbound", None, None, None, None)]; - let idx = ScopeIndex::build(&rules); + let idx = ScopeIndex::build(&rules, &crate::model::ScopeVocabulary::windows_profiles()); let c = conn( &ev("Inbound", 6, "40000", "443", "chrome.exe"), "chrome.exe", @@ -374,7 +378,7 @@ mod tests { // even when its scope matches the connection exactly (regression: F1) let mut r = rule("Old RDP", "Inbound", Some("TCP"), Some("3389"), None, None); r.enabled = "False".into(); - let idx = ScopeIndex::build(&[r]); + let idx = ScopeIndex::build(&[r], &crate::model::ScopeVocabulary::windows_profiles()); let c = conn( &ev("Inbound", 6, "40000", "3389", "svchost.exe"), "svchost.exe", @@ -384,7 +388,7 @@ mod tests { // not the scope let mut r2 = rule("RDP", "Inbound", Some("TCP"), Some("3389"), None, None); r2.enabled = "True".into(); - let idx2 = ScopeIndex::build(&[r2]); + let idx2 = ScopeIndex::build(&[r2], &crate::model::ScopeVocabulary::windows_profiles()); assert_eq!(idx2.matching_rules(&c), vec!["RDP"]); } @@ -398,7 +402,7 @@ mod tests { None, None, )]; - let idx = ScopeIndex::build(&rules); + let idx = ScopeIndex::build(&rules, &crate::model::ScopeVocabulary::windows_profiles()); let c = conn(&ev("Inbound", 1, "0", "0", "System"), "System"); assert_eq!(idx.matching_rules(&c), vec!["Echo Request v4"]); } @@ -413,7 +417,7 @@ mod tests { None, Some(r"C:\x\svchost.exe"), )]; - let idx = ScopeIndex::build(&rules); + let idx = ScopeIndex::build(&rules, &crate::model::ScopeVocabulary::windows_profiles()); let miss = conn( &ev("Inbound", 6, "40000", "135", "svchost.exe"), r"C:\x\svchost.exe", @@ -436,7 +440,7 @@ mod tests { Some("53"), None, )]; - let idx = ScopeIndex::build(&rules); + let idx = ScopeIndex::build(&rules, &crate::model::ScopeVocabulary::windows_profiles()); let c = conn( &ev("Outbound", 17, "50000", "53", "svchost.exe"), "svchost.exe", @@ -455,7 +459,7 @@ mod tests { None, ); r.profile = "Domain".into(); - let idx = ScopeIndex::build(&[r]); + let idx = ScopeIndex::build(&[r], &crate::model::ScopeVocabulary::windows_profiles()); let mut e = ev("Inbound", 6, "40000", "3389", "svchost.exe"); e.interface_index = 5; // interface 5 is Public → Domain-only rule must not match @@ -478,7 +482,7 @@ mod tests { None, None, )]; - let idx = ScopeIndex::build(&rules); + let idx = ScopeIndex::build(&rules, &crate::model::ScopeVocabulary::windows_profiles()); let c = conn( &ev("Outbound", 17, "5353", "5353", "svchost.exe"), "svchost.exe", diff --git a/src/secure_dir.rs b/src/secure_dir.rs index 9c58022..63d057f 100644 --- a/src/secure_dir.rs +++ b/src/secure_dir.rs @@ -4,6 +4,10 @@ //! on. So: directories are created with an explicit SYSTEM+Administrators //! DACL (no inheritance from the parent), and pre-existing directories are //! only accepted if owned by SYSTEM or Administrators. +//! +//! Linux gets the same guarantee by its own means — created 0700, and an +//! existing directory accepted only when it is owned by the effective UID +//! (root, in any real run) and closed to group and other. use anyhow::Result; use std::path::Path; @@ -102,9 +106,122 @@ pub fn ensure_secured_dir(path: &Path) -> Result<()> { Ok(()) } -#[cfg(not(windows))] +/// Linux: root-owned and 0700. The Windows reasoning carries over intact — +/// this directory holds the usage DB and policy backups that an +/// administrator later acts on, so a directory another user owns (or can +/// write to) must not be trusted just because it has the expected name. +#[cfg(unix)] +pub fn ensure_secured_dir(path: &Path) -> Result<()> { + use anyhow::{bail, Context}; + use std::os::unix::fs::{DirBuilderExt, MetadataExt, PermissionsExt}; + + if path.exists() { + let meta = + std::fs::metadata(path).with_context(|| format!("reading {}", path.display()))?; + if !meta.is_dir() { + bail!("{} exists but is not a directory", path.display()); + } + // Must be owned by us. In production "us" is root, since the tool + // refuses to run otherwise — but stating it as the effective UID + // keeps the check meaningful (and testable) whoever is running. + let me = crate::elevation::effective_uid(); + if me.is_some_and(|uid| meta.uid() != uid) { + bail!( + "{} exists but is owned by uid {} rather than uid {} — refusing to use it \ + (possible tampering; remove it as root, or pass --db with a different \ + location)", + path.display(), + meta.uid(), + me.unwrap_or(0) + ); + } + // group/other must have nothing: 0o077 covers rwx for both + let mode = meta.permissions().mode() & 0o777; + if mode & 0o077 != 0 { + bail!( + "{} is mode {:04o} — it must not be accessible to group or other, since it \ + holds the usage database and policy backups. Fix with: chmod 700 {}", + path.display(), + mode, + path.display() + ); + } + return Ok(()); + } + + if let Some(parent) = path.parent() { + if !parent.exists() { + ensure_secured_dir(parent)?; + } + } + std::fs::DirBuilder::new() + .mode(0o700) + .create(path) + .with_context(|| format!("creating secured directory {}", path.display()))?; + Ok(()) +} + +#[cfg(not(any(windows, unix)))] pub fn ensure_secured_dir(path: &Path) -> Result<()> { - // dev/preview builds only — the Windows path is the enforced one std::fs::create_dir_all(path)?; Ok(()) } + +#[cfg(all(test, unix))] +mod tests { + use super::*; + use std::os::unix::fs::PermissionsExt; + + fn scratch(name: &str) -> std::path::PathBuf { + std::env::temp_dir().join(format!("fb-secdir-{}-{}", std::process::id(), name)) + } + + #[test] + fn a_group_or_world_accessible_directory_is_refused() { + // The whole point: a predictable path another user can write to must + // not be adopted just because the name matches. + let dir = scratch("loose"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o755)).unwrap(); + let err = ensure_secured_dir(&dir).unwrap_err().to_string(); + assert!(err.contains("group or other"), "{err}"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn a_private_directory_is_accepted_and_created_private() { + let dir = scratch("tight"); + let _ = std::fs::remove_dir_all(&dir); + // creation path + ensure_secured_dir(&dir).unwrap(); + let mode = std::fs::metadata(&dir).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o700, "created directory must be private"); + // and re-accepting it is idempotent + ensure_secured_dir(&dir).unwrap(); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn a_directory_owned_by_someone_else_is_refused() { + // /tmp is root-owned and we are not root under `cargo test`, so it + // stands in for the real hazard: a predictable path someone else + // already owns. + if crate::elevation::effective_uid() == Some(0) { + return; // running as root: /tmp *is* ours, nothing to prove + } + let err = ensure_secured_dir(std::path::Path::new("/tmp")) + .unwrap_err() + .to_string(); + assert!(err.contains("owned by uid"), "{err}"); + } + + #[test] + fn a_file_where_the_directory_should_be_is_refused() { + let path = scratch("afile"); + let _ = std::fs::remove_file(&path); + std::fs::write(&path, b"x").unwrap(); + assert!(ensure_secured_dir(&path).is_err()); + let _ = std::fs::remove_file(&path); + } +} diff --git a/src/store.rs b/src/store.rs index 741dac0..1789778 100644 --- a/src/store.rs +++ b/src/store.rs @@ -13,11 +13,20 @@ pub struct Store { /// per-profile counts. v4 = disabled rules excluded from attribution (#1). const MODEL_VERSION: &str = "4"; -/// Default DB location: %ProgramData%\firebreak\firebreak.db (survives per-user -/// profile churn; tool runs elevated anyway). +/// Default DB location. Windows: %ProgramData%\firebreak (survives per-user +/// profile churn; the tool runs elevated anyway). Linux: /var/lib/firebreak, +/// the FHS home for state a system service accumulates. Both are secured by +/// [`crate::secure_dir`] before use. pub fn default_db_path() -> PathBuf { - let base = std::env::var("ProgramData").unwrap_or_else(|_| r"C:\ProgramData".into()); - Path::new(&base).join("firebreak").join("firebreak.db") + #[cfg(target_os = "linux")] + { + Path::new("/var/lib/firebreak").join("firebreak.db") + } + #[cfg(not(target_os = "linux"))] + { + let base = std::env::var("ProgramData").unwrap_or_else(|_| r"C:\ProgramData".into()); + Path::new(&base).join("firebreak").join("firebreak.db") + } } impl Store { diff --git a/src/ui.rs b/src/ui.rs index 7b6a3fb..fa6c61d 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -25,8 +25,8 @@ pub struct RuleRow { pub seen_apps: Vec, pub listening: Vec, pub target_enabled: bool, - /// intended profile scope (edited via the profile chips) - pub target_profiles: crate::model::ProfileSet, + /// intended scope (edited via the scope chips) + pub target_scopes: crate::model::ScopeSet, pub reviewed: ReviewState, } @@ -62,12 +62,11 @@ impl RuleRow { fn is_zero_hit(&self) -> bool { self.total_hits() == 0 } - fn orig_profiles(&self) -> crate::model::ProfileSet { - crate::model::ProfileSet::from_rule(&self.rule) + fn orig_scopes(&self) -> crate::model::ScopeSet { + crate::model::ScopeSet::from_rule(&self.rule, crate::model::vocabulary()) } fn pending(&self) -> bool { - self.target_enabled != self.rule.is_enabled() - || self.target_profiles != self.orig_profiles() + self.target_enabled != self.rule.is_enabled() || self.target_scopes != self.orig_scopes() } } @@ -140,18 +139,8 @@ impl PlannedChange { } } -fn removed_labels(orig: crate::model::ProfileSet, target: crate::model::ProfileSet) -> String { - let mut removed = Vec::new(); - if orig.domain && !target.domain { - removed.push("Domain"); - } - if orig.private && !target.private { - removed.push("Private"); - } - if orig.public && !target.public { - removed.push("Public"); - } - removed.join(", ") +fn removed_labels(orig: &crate::model::ScopeSet, target: &crate::model::ScopeSet) -> String { + orig.removed_since(target).join(", ") } /// Streamed apply progress — one message per step so the footer shows @@ -301,8 +290,9 @@ impl App { match a.effect { ActionEffect::Disable => r.target_enabled, ActionEffect::RemovePublic => { - r.target_profiles.public - && (r.target_profiles.domain || r.target_profiles.private) + r.target_scopes.is_active("Public") + && (r.target_scopes.is_active("Domain") + || r.target_scopes.is_active("Private")) } } }) @@ -315,7 +305,7 @@ impl App { for i in self.action_pending(a) { match a.effect { ActionEffect::Disable => self.rows[i].target_enabled = false, - ActionEffect::RemovePublic => self.rows[i].target_profiles.public = false, + ActionEffect::RemovePublic => self.rows[i].target_scopes.set("Public", false), } } } @@ -423,9 +413,10 @@ pub struct App { only_zero_hit: bool, only_flagged: bool, hide_reviewed: bool, - show_domain: bool, - show_private: bool, - show_public: bool, + /// Scope filter: one entry per scope the host's backend defines, in + /// display order. Empty on a backend without scopes (ufw), where the + /// filter row simply does not render. + scope_filter: Vec<(String, bool)>, sort: Sort, sort_asc: bool, col_w: ColWidths, @@ -470,9 +461,11 @@ impl App { only_zero_hit: false, only_flagged: false, hide_reviewed: true, - show_domain: true, - show_private: true, - show_public: true, + scope_filter: crate::model::vocabulary() + .names + .iter() + .map(|n| (n.clone(), true)) + .collect(), sort: Sort::Hits, sort_asc: false, // hits descending by default (design) col_w: ColWidths::default(), @@ -748,7 +741,7 @@ impl App { // demo: remove Public from a multi-profile rule + a disable for r in app.rows.iter_mut() { if r.rule.display_name.contains("File and Printer") { - r.target_profiles.public = false; + r.target_scopes.set("Public", false); } } app.confirm_open = true; @@ -856,27 +849,24 @@ impl App { fn planned_changes(&self) -> Vec { let mut out = Vec::new(); for r in &self.rows { - let orig = r.orig_profiles(); + let orig = r.orig_scopes(); let was_enabled = r.rule.is_enabled(); // whole-rule off wins over any profile edit - if !r.target_enabled || r.target_profiles.is_empty() { + if !r.target_enabled || r.target_scopes.is_empty() { if was_enabled { out.push(PlannedChange::new(r, ChangeKind::Disable)); } continue; } // enabled target - if r.target_profiles != orig { - let arg = r - .target_profiles - .to_profile_arg() - .unwrap_or_else(|| "Any".into()); + if r.target_scopes != orig { + let arg = r.target_scopes.to_arg().unwrap_or_else(|| "Any".into()); out.push(PlannedChange::new( r, ChangeKind::Profiles { arg, was_enabled, - removed: removed_labels(orig, r.target_profiles), + removed: removed_labels(&orig, &r.target_scopes), }, )); } else if !was_enabled { @@ -918,7 +908,8 @@ impl App { fn revert_all(&mut self) { for r in &mut self.rows { r.target_enabled = r.rule.is_enabled(); - r.target_profiles = crate::model::ProfileSet::from_rule(&r.rule); + r.target_scopes = + crate::model::ScopeSet::from_rule(&r.rule, crate::model::vocabulary()); } } @@ -1010,14 +1001,11 @@ impl App { // the applied reality (enabled state + profile scope) for name in newly_committed { if let Some(r) = self.rows.iter_mut().find(|r| r.rule.name == name) { - let effective_enabled = r.target_enabled && !r.target_profiles.is_empty(); + let effective_enabled = r.target_enabled && !r.target_scopes.is_empty(); r.rule.enabled = if effective_enabled { "True" } else { "False" }.into(); r.target_enabled = effective_enabled; if effective_enabled { - r.rule.profile = r - .target_profiles - .to_profile_arg() - .unwrap_or_else(|| "Any".into()); + r.rule.profile = r.target_scopes.to_arg().unwrap_or_else(|| "Any".into()); } } } @@ -1047,6 +1035,17 @@ impl App { // ---- filtering ---- + /// Scope names currently ticked in the filter row. An empty vocabulary + /// yields an empty list, which `applies_to_scopes` reads as "no scope + /// concept — show everything" rather than "nothing selected". + fn scope_filter_selected(&self) -> Vec { + self.scope_filter + .iter() + .filter(|(_, on)| *on) + .map(|(n, _)| n.clone()) + .collect() + } + fn visible(&self) -> Vec { let needle = self.filter_text.to_lowercase(); let mut idx: Vec = (0..self.rows.len()) @@ -1075,7 +1074,7 @@ impl App { } if !r .rule - .applies_to_profile(self.show_domain, self.show_private, self.show_public) + .applies_to_scopes(crate::model::vocabulary(), &self.scope_filter_selected()) { return false; } diff --git a/src/ui/paint.rs b/src/ui/paint.rs index 6510828..2b18af8 100644 --- a/src/ui/paint.rs +++ b/src/ui/paint.rs @@ -1187,12 +1187,24 @@ fn filter_bar(app: &mut App, ctx: &egui::Context) { ("Flagged", &mut app.only_flagged, true, "Show only rules with a security advisory (e.g. RDP, SMB-inbound, broad allow, mDNS)"), ("Hide reviewed", &mut app.hide_reviewed, true, "Hide rules you've marked as reviewed (the circle in the right-most column) — tick rules off to work the list down to zero"), ]); - ui.add_space(4.0); - segmented_toggles(ui, &mut [ - ("Domain", &mut app.show_domain, true, "Include rules active on the Domain profile (corporate/AD network)"), - ("Private", &mut app.show_private, true, "Include rules active on the Private profile (home/trusted network)"), - ("Public", &mut app.show_public, true, "Include rules active on the Public profile (untrusted/public network)"), - ]); + // Scope filter, driven by whatever the host's backend + // defines. Nothing renders on a backend without scopes. + if !app.scope_filter.is_empty() { + ui.add_space(4.0); + let mut segments: Vec<(&str, &mut bool, bool, String)> = app + .scope_filter + .iter_mut() + .map(|(name, on)| { + let tip = scope_tooltip(name); + (name.as_str(), on, true, tip) + }) + .collect(); + let mut cells: Vec<(&str, &mut bool, bool, &str)> = segments + .iter_mut() + .map(|(n, on, e, tip)| (*n, &mut **on, *e, tip.as_str())) + .collect(); + segmented_toggles(ui, &mut cells); + } ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { let total = app.rows.len(); @@ -1262,6 +1274,18 @@ fn segment_cell( } /// Segmented multi-toggle (each cell independently on/off). +/// Tooltip for a scope filter chip. The Windows profiles get their real +/// meanings; anything else (a firewalld zone) is named but not editorialised +/// about, since only the admin knows what their zone is for. +fn scope_tooltip(name: &str) -> String { + match name { + "Domain" => "Include rules active on the Domain profile (corporate/AD network)".into(), + "Private" => "Include rules active on the Private profile (home/trusted network)".into(), + "Public" => "Include rules active on the Public profile (untrusted/public network)".into(), + other => format!("Include rules active in the {other} zone"), + } +} + fn segmented_toggles(ui: &mut egui::Ui, segs: &mut [(&str, &mut bool, bool, &str)]) { let prev = ui.spacing().item_spacing; ui.spacing_mut().item_spacing.x = 0.0; @@ -1723,7 +1747,7 @@ fn row(app: &mut App, ui: &mut egui::Ui, ri: usize, rect: Rect, cols: &Cols, res // checkbox — indeterminate when the rule stays on but its profile scope // was narrowed let partial = - r.target_enabled && !r.target_profiles.is_empty() && r.target_profiles != r.orig_profiles(); + r.target_enabled && !r.target_scopes.is_empty() && r.target_scopes != r.orig_scopes(); let cb_rect = Rect::from_center_size( Pos2::new(cols.check + 17.0, rect.center().y), Vec2::splat(13.0), @@ -1800,31 +1824,29 @@ fn row(app: &mut App, ui: &mut egui::Ui, ri: usize, rect: Rect, cols: &Cols, res CELL_PAD, ); - // profiles chips — clickable to toggle a profile off/on for this rule - let orig = r.orig_profiles(); - let target = r.target_profiles; - let mut clicked_profile: Option = None; + // scope chips — clickable to toggle a scope off/on for this rule. The + // set is whatever the host's backend defines: three network profiles on + // Windows, N zones on firewalld, none at all on ufw (where this simply + // renders nothing). + let orig = r.orig_scopes(); + let mut clicked_scope: Option = None; let mut cx = cols.profiles.0 + CELL_PAD; let editable = app.apply.is_none() && app.phase == Phase::Ready; - for (bit, present, kept, label) in [ - (0u8, orig.domain, target.domain, "Domain"), - (1, orig.private, target.private, "Private"), - (2, orig.public, target.public, "Public"), - ] { + for (slot, (name, present)) in orig.iter().enumerate() { if !present { continue; } let (w, resp) = interactive_chip( ui, Pos2::new(cx, rect.center().y - 7.5), - label, - kept, + name, + r.target_scopes.is_active(name), editable, - (ri, bit), + (ri, slot as u8), ); cx += w; if resp.is_some_and(|r| r.clicked()) { - clicked_profile = Some(bit); + clicked_scope = Some(name.to_string()); } } @@ -2049,13 +2071,8 @@ fn row(app: &mut App, ui: &mut egui::Ui, ri: usize, rect: Rect, cols: &Cols, res ); // interactions - if let Some(bit) = clicked_profile { - let p = &mut app.rows[ri].target_profiles; - match bit { - 0 => p.domain = !p.domain, - 1 => p.private = !p.private, - _ => p.public = !p.public, - } + if let Some(name) = clicked_scope { + app.rows[ri].target_scopes.toggle(&name); } else if cb_resp.clicked() && app.apply.is_none() { app.rows[ri].target_enabled = !app.rows[ri].target_enabled; } else if rv_resp.clicked() { @@ -2266,9 +2283,9 @@ fn empty_state(app: &mut App, ui: &mut egui::Ui) { app.only_enabled = false; app.only_zero_hit = false; app.only_flagged = false; - app.show_domain = true; - app.show_private = true; - app.show_public = true; + for (_, on) in app.scope_filter.iter_mut() { + *on = true; + } } }); } From 59f616604a253f63d9cbfd707208beedb840b8e1 Mon Sep 17 00:00:00 2001 From: ghostpsalm Date: Mon, 10 Aug 2026 00:33:53 +1000 Subject: [PATCH 4/8] Linux port (4/n): firewalld backend via a shadow counter table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit firewalld's nftables table carries `flags owner`, so the kernel refuses to let any process add a counter to it — sudo does not help — and firewalld emits no counters of its own. There is nothing to read, so Firebreak brings its own: a table in the input hook at priority 300, after firewalld's filter at priority 0, so a hit means "allowed via this rule" rather than "would have matched". Policy accept, counters only, no verdicts: it can count traffic but cannot change what happens to it. `ct state new` makes it per-connection, the same granularity as Windows event 5156. Verified on this host: a rule change reinstalls the table and the old readings are banked rather than lost (7 tcp survived the reinstall), and teardown leaves `nft list tables` with firewalld's table alone. Collecting means *writing* to the kernel firewall, which the Windows side never does. So it is opt-in on the same flags that gate audit policy there: --enable-only installs, --restore-audit removes, and a bare run reports that collection is off rather than quietly instrumenting the host. The parser bug the tests caught is the one that mattered: firewalld prints rich rules and forward-ports on tab-indented continuation lines under an otherwise-empty key, so reading only the inline value dropped every rich rule on the host — telling the user their firewall was simpler than it is. Rich rules, ipsets, protocols, icmp-blocks and forward-ports are now all reported as unmeasurable, never as zero-hit. Zones become the scope vocabulary, which is what the previous commit generalised for. Ports are validated as integers before reaching the nft ruleset, since it is handed over as text. --- src/linux/firewalld.rs | 739 +++++++++++++++++++++++++++++++++++++++++ src/linux/mod.rs | 147 ++++++++ src/main.rs | 43 ++- src/store.rs | 12 + 4 files changed, 931 insertions(+), 10 deletions(-) create mode 100644 src/linux/firewalld.rs diff --git a/src/linux/firewalld.rs b/src/linux/firewalld.rs new file mode 100644 index 0000000..92ca66e --- /dev/null +++ b/src/linux/firewalld.rs @@ -0,0 +1,739 @@ +//! The firewalld backend. +//! +//! firewalld is the hard one. Its nftables table carries `flags owner`, so +//! the kernel refuses to let any other process add a counter to it — not a +//! permissions problem, and `sudo` does not help: +//! +//! ```text +//! $ nft replace rule inet firewalld filter_IN_FedoraWorkstation_allow \ +//! handle 163 udp dport 137 counter accept +//! Error: Could not process rule: Operation not permitted +//! ``` +//! +//! firewalld emits no counters of its own either, so there is nothing to +//! read. Firebreak therefore installs a **shadow table** of its own: same +//! traffic, counters only, no verdicts. +//! +//! Placement is the whole design. The shadow chain sits in the input hook at +//! priority 300 — *after* firewalld's filter at priority 0 — so a packet only +//! reaches it if firewalld already accepted it. A hit therefore means +//! "allowed via this rule", not "would have matched if it got here". The +//! chain's policy is `accept` and its rules carry no verdict, so it cannot +//! change any packet's fate; it can only count. +//! +//! Two consequences the caller must carry honestly: +//! +//! * The shadow rules are a *reconstruction* of firewalld's semantics, not +//! firewalld's own rules. Anything not expressible as a tcp/udp port match +//! — rich rules, ipsets, icmp-blocks, protocol-only entries — is reported +//! as **unmeasurable**, never as zero-hit. Mistaking "cannot count this" +//! for "never used" is how a tool talks someone into deleting a rule that +//! is load-bearing. +//! * nftables tables do not survive a reboot. Collection therefore stops at +//! every reboot until Firebreak next runs — unlike Windows, where the +//! audit policy persists. [`REBOOT_CAVEAT`] is the text shown to the user. + +use anyhow::{bail, Context, Result}; +use std::collections::BTreeMap; + +use crate::model::RuleInfo; + +/// Our own table. Named, versioned and never shared with firewalld's. +pub const SHADOW_TABLE: &str = "firebreak_shadow"; + +/// Input-hook priority. firewalld's filter runs at 0, so 300 is after its +/// verdict: we see accepted traffic only. +const SHADOW_PRIORITY: i32 = 300; + +pub const REBOOT_CAVEAT: &str = "nftables tables do not survive a reboot, so counting stops at \ + every reboot until Firebreak runs again. Totals already collected are kept."; + +/// One firewalld entry, as the user configured it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FwdRule { + pub zone: String, + /// "service" or "port" + pub kind: String, + /// service name, or the port spec for a bare port entry + pub label: String, + pub proto: String, + /// comma-separated ports/ranges + pub ports: String, +} + +impl FwdRule { + pub fn id(&self) -> String { + format!( + "firewalld:{}/{}/{}/{}", + self.zone, self.kind, self.label, self.proto + ) + } + + pub fn to_rule_info(&self) -> RuleInfo { + RuleInfo { + name: self.id(), + display_name: format!("{} ({})", self.label, self.zone), + description: None, + enabled: "True".into(), + // Zone services and ports admit inbound traffic. + direction: "Inbound".into(), + action: "Allow".into(), + // the zone *is* the scope; the vocabulary is the zone list + profile: self.zone.clone(), + group: Some(self.kind.clone()), + program: None, + protocol: Some(self.proto.clone()), + local_port: Some(self.ports.clone()), + remote_port: None, + service: (self.kind == "service").then(|| self.label.clone()), + remote_address: None, + } + } +} + +/// What a zone contains, split into what Firebreak can and cannot count. +#[derive(Debug, Default)] +pub struct Zones { + pub rules: Vec, + /// (id, reason) for configuration that exists but cannot be measured. + pub unmeasurable: Vec<(String, String)>, + /// Active zone names, in the order firewalld reports them — this becomes + /// the scope vocabulary. + pub names: Vec, +} + +fn firewall_cmd(args: &[&str]) -> Result { + let bin = + crate::syspath::system_tool("firewall-cmd").context("firewall-cmd is not installed")?; + let out = crate::syspath::command(bin) + .args(args) + .output() + .context("running firewall-cmd")?; + if !out.status.success() { + bail!( + "firewall-cmd {:?} failed: {}", + args, + String::from_utf8_lossy(&out.stderr).trim() + ); + } + Ok(String::from_utf8_lossy(&out.stdout).into_owned()) +} + +pub fn is_running() -> bool { + firewall_cmd(&["--state"]).is_ok_and(|s| s.trim() == "running") +} + +/// Active zone names. `--get-active-zones` prints the zone name on its own +/// line followed by indented detail, and marks the default with a suffix. +pub fn active_zones(text: &str) -> Vec { + text.lines() + .filter(|l| !l.starts_with(char::is_whitespace) && !l.trim().is_empty()) + // "FedoraWorkstation (default)" -> "FedoraWorkstation" + .filter_map(|l| l.split_whitespace().next()) + .map(str::to_string) + .collect() +} + +/// Parse `firewall-cmd --info-service=` into (proto, port) pairs and +/// the services it includes. +pub fn parse_service(text: &str) -> (Vec<(String, String)>, Vec) { + let mut ports = Vec::new(); + let mut includes = Vec::new(); + for line in text.lines() { + let l = line.trim(); + if let Some(rest) = l.strip_prefix("ports:") { + for tok in rest.split_whitespace() { + if let Some((port, proto)) = tok.split_once('/') { + ports.push((proto.to_string(), port.to_string())); + } + } + } else if let Some(rest) = l.strip_prefix("includes:") { + includes.extend(rest.split_whitespace().map(str::to_string)); + } + } + (ports, includes) +} + +/// Expand a service into every (proto, port) it opens, following `includes`. +/// +/// firewalld services compose: `samba-client` declares only 138/udp but +/// includes `netbios-ns`, which adds 137/udp. Not following includes makes a +/// rule look narrower than it is — the one direction of error this tool must +/// never make. Depth-capped and cycle-guarded, because the include graph is +/// user-editable. +fn service_ports(name: &str) -> Vec<(String, String)> { + fn walk( + name: &str, + seen: &mut std::collections::HashSet, + out: &mut Vec<(String, String)>, + depth: usize, + ) { + if depth > 8 || !seen.insert(name.to_string()) { + return; + } + let Ok(text) = firewall_cmd(&[&format!("--info-service={name}")]) else { + return; + }; + let (ports, includes) = parse_service(&text); + out.extend(ports); + for inc in includes { + walk(&inc, seen, out, depth + 1); + } + } + let mut seen = std::collections::HashSet::new(); + let mut out = Vec::new(); + walk(name, &mut seen, &mut out, 0); + out.sort(); + out.dedup(); + out +} + +/// Turn one zone's `--info-zone` output into rules plus a list of everything +/// in it Firebreak cannot count. +pub fn parse_zone(zone: &str, text: &str, expand: &dyn Fn(&str) -> Vec<(String, String)>) -> Zones { + let mut z = Zones::default(); + let mut unmeasurable = |what: &str, detail: &str, why: &str| { + z.unmeasurable + .push((format!("firewalld:{zone}/{what}/{detail}"), why.to_string())); + }; + + // firewalld prints most fields inline ("services: ssh mdns") but puts + // rich rules and forward-ports on *tab-indented continuation lines* + // under an otherwise-empty key. Treating a blank value as "nothing here" + // therefore drops every rich rule silently — the exact failure this + // backend exists to avoid. + let mut current_key = String::new(); + for line in text.lines() { + let (key, rest) = if line.starts_with('\t') { + (current_key.clone(), line.trim().to_string()) + } else { + let Some((k, v)) = line.trim().split_once(':') else { + continue; + }; + current_key = k.to_string(); + (k.to_string(), v.trim().to_string()) + }; + let (key, rest) = (key.as_str(), rest.as_str()); + if rest.is_empty() { + continue; + } + match key { + "services" => { + for svc in rest.split_whitespace() { + let mut by_proto: BTreeMap> = BTreeMap::new(); + for (proto, port) in expand(svc) { + by_proto.entry(proto).or_default().push(port); + } + if by_proto.is_empty() { + unmeasurable( + "service", + svc, + "This service opens no tcp/udp port that Firebreak can count \ + (it may use a protocol or kernel helper instead).", + ); + continue; + } + for (proto, ports) in by_proto { + z.rules.push(FwdRule { + zone: zone.to_string(), + kind: "service".into(), + label: svc.to_string(), + proto, + ports: ports.join(","), + }); + } + } + } + "ports" => { + for tok in rest.split_whitespace() { + let Some((port, proto)) = tok.split_once('/') else { + continue; + }; + if !matches!(proto, "tcp" | "udp") { + unmeasurable( + "port", + tok, + "Only tcp and udp ports can be counted by the shadow table.", + ); + continue; + } + z.rules.push(FwdRule { + zone: zone.to_string(), + kind: "port".into(), + label: tok.to_string(), + proto: proto.to_string(), + ports: port.to_string(), + }); + } + } + // Everything below is real, active configuration that the shadow + // table cannot express. It is listed so the user sees it exists, + // rather than silently getting a shorter rule list. + "rich rules" => unmeasurable( + "rich-rule", + rest, + "Rich rules can match on source, ipset, logging and rate limits; Firebreak \ + cannot reconstruct them as a counter, so this rule has no hit count.", + ), + "protocols" => unmeasurable( + "protocol", + rest, + "Protocol-level entries (esp, ah, gre …) have no port to count.", + ), + "source-ports" => unmeasurable( + "source-port", + rest, + "Source-port entries are not reconstructed by the shadow table.", + ), + "icmp-blocks" => unmeasurable( + "icmp-block", + rest, + "ICMP block entries have no port to count.", + ), + "forward-ports" => unmeasurable( + "forward-port", + rest, + "Forwarded ports are redirected before the shadow chain sees them.", + ), + _ => {} + } + } + z +} + +/// Read every active zone. +pub fn read_zones() -> Result { + let mut all = Zones { + names: active_zones(&firewall_cmd(&["--get-active-zones"])?), + ..Zones::default() + }; + for zone in &all.names { + let text = firewall_cmd(&[&format!("--info-zone={zone}")])?; + let z = parse_zone(zone, &text, &service_ports); + all.rules.extend(z.rules); + all.unmeasurable.extend(z.unmeasurable); + } + if all.rules.is_empty() && all.unmeasurable.is_empty() { + bail!("no firewalld zone configuration found"); + } + Ok(all) +} + +/// Short, comment-safe id for a rule's counter. nft comments are capped, and +/// the position in `rules` is what the reader joins on. +fn slot(index: usize) -> String { + format!("fb{index}") +} + +/// The nft match expression that recognises a rule's traffic. +pub fn match_expr(rule: &FwdRule) -> Option { + if !matches!(rule.proto.as_str(), "tcp" | "udp") { + return None; + } + let parts: Vec<&str> = rule.ports.split(',').filter(|p| !p.is_empty()).collect(); + if parts.is_empty() { + return None; + } + // reject anything that is not a bare port or a lo-hi range, so nothing + // unexpected is ever spliced into a ruleset we hand to the kernel + for p in &parts { + let ok = match p.split_once('-') { + Some((a, b)) => { + a.parse::().is_ok_and(|a| a > 0) && b.parse::().is_ok_and(|b| b > 0) + } + None => p.parse::().is_ok_and(|p| p > 0), + }; + if !ok { + return None; + } + } + Some(if parts.len() == 1 { + format!("{} dport {}", rule.proto, parts[0]) + } else { + format!("{} dport {{ {} }}", rule.proto, parts.join(", ")) + }) +} + +/// Build the full shadow ruleset. Counters only, policy accept, no verdicts: +/// this table can count traffic but cannot change what happens to it. +pub fn shadow_ruleset(rules: &[FwdRule]) -> String { + let mut s = String::new(); + s.push_str(&format!("table inet {SHADOW_TABLE} {{\n")); + s.push_str(" chain shadow_in {\n"); + s.push_str(&format!( + " type filter hook input priority {SHADOW_PRIORITY}; policy accept;\n" + )); + // ct state new makes this per-connection rather than per-packet, which is + // the same granularity Windows event 5156 reports. + s.push_str(" ct state new jump shadow_match\n"); + s.push_str(" }\n"); + s.push_str(" chain shadow_match {\n"); + for (i, rule) in rules.iter().enumerate() { + if let Some(expr) = match_expr(rule) { + s.push_str(&format!(" {expr} counter comment \"{}\"\n", slot(i))); + } + } + s.push_str(" }\n}\n"); + s +} + +/// Which rules the shadow table cannot express. +pub fn unexpressible(rules: &[FwdRule]) -> Vec<(String, String)> { + rules + .iter() + .filter(|r| match_expr(r).is_none()) + .map(|r| { + ( + r.id(), + "Firebreak could not express this rule as a port counter, so it has no hit \ + count. It is still active in the firewall." + .to_string(), + ) + }) + .collect() +} + +fn nft(args: &[&str]) -> Result { + let bin = crate::syspath::system_tool("nft").context("nft is not installed")?; + let out = crate::syspath::command(bin) + .args(args) + .output() + .context("running nft")?; + if !out.status.success() { + bail!( + "nft {:?} failed: {}", + args, + String::from_utf8_lossy(&out.stderr).trim() + ); + } + Ok(String::from_utf8_lossy(&out.stdout).into_owned()) +} + +/// Load a ruleset via `nft -f -`. +fn nft_load(ruleset: &str) -> Result<()> { + use std::io::Write; + let bin = crate::syspath::system_tool("nft").context("nft is not installed")?; + let mut child = crate::syspath::command(bin) + .args(["-f", "-"]) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .context("spawning nft")?; + child + .stdin + .as_mut() + .context("nft stdin")? + .write_all(ruleset.as_bytes())?; + let out = child.wait_with_output().context("running nft -f -")?; + if !out.status.success() { + bail!( + "installing the shadow counter table failed: {}", + String::from_utf8_lossy(&out.stderr).trim() + ); + } + Ok(()) +} + +pub fn table_exists() -> bool { + nft(&["list", "table", "inet", SHADOW_TABLE]).is_ok() +} + +/// Remove the shadow table. Safe to call when it is not there. +pub fn teardown() -> Result<()> { + if !table_exists() { + return Ok(()); + } + nft(&["delete", "table", "inet", SHADOW_TABLE])?; + Ok(()) +} + +/// Install (or replace) the shadow table so it matches `rules`. +/// +/// Replacing resets the counters, which is why the caller folds a generation +/// token over the rule set: a changed rule set means a new counter lifetime, +/// and the old readings must be banked rather than treated as a decrease. +pub fn install(rules: &[FwdRule]) -> Result<()> { + let _ = teardown(); + nft_load(&shadow_ruleset(rules)) +} + +/// Read the shadow table's counters, keyed by rule index. +pub fn read_counters() -> Result> { + let json = nft(&["-j", "list", "table", "inet", SHADOW_TABLE])?; + let v: serde_json::Value = serde_json::from_str(&json).context("parsing nft JSON output")?; + Ok(parse_counters(&v)) +} + +/// Extract `comment -> packets` from `nft -j list table` output. +pub fn parse_counters(v: &serde_json::Value) -> BTreeMap { + let mut out = BTreeMap::new(); + let Some(items) = v["nftables"].as_array() else { + return out; + }; + for item in items { + let Some(rule) = item.get("rule") else { + continue; + }; + let Some(comment) = rule.get("comment").and_then(|c| c.as_str()) else { + continue; + }; + let Some(index) = comment + .strip_prefix("fb") + .and_then(|n| n.parse::().ok()) + else { + continue; + }; + let packets = rule["expr"] + .as_array() + .map(|exprs| { + exprs + .iter() + .filter_map(|e| e.get("counter")) + .filter_map(|c| c.get("packets")) + .filter_map(serde_json::Value::as_i64) + .sum::() + }) + .unwrap_or(0); + out.insert(index, packets); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Verbatim `firewall-cmd --info-zone=FedoraWorkstation` from the Fedora + /// 44 host this was developed on. Note `ports: 1025-65535/*`, which is + /// what Fedora Workstation ships open by default. + const REAL_ZONE: &str = r#"FedoraWorkstation (active) + target: default + ingress-priority: 0 + icmp-block-inversion: no + interfaces: eno2 wlo1 + sources: + services: dhcpv6-client samba-client ssh + ports: 1025-65535/udp 1025-65535/tcp + protocols: + forward: yes + masquerade: no + forward-ports: + source-ports: + icmp-blocks: + rich rules: +"#; + + fn fake_expand(svc: &str) -> Vec<(String, String)> { + match svc { + "ssh" => vec![("tcp".into(), "22".into())], + "dhcpv6-client" => vec![("udp".into(), "546".into())], + // samba-client declares 138/udp and includes netbios-ns (137/udp) + "samba-client" => vec![("udp".into(), "137".into()), ("udp".into(), "138".into())], + _ => vec![], + } + } + + fn zone() -> Zones { + parse_zone("FedoraWorkstation", REAL_ZONE, &fake_expand) + } + + #[test] + fn a_real_zone_yields_its_services_and_ports() { + let z = zone(); + let ids: Vec = z.rules.iter().map(|r| r.id()).collect(); + assert!(ids.contains(&"firewalld:FedoraWorkstation/service/ssh/tcp".to_string())); + assert!(ids.contains(&"firewalld:FedoraWorkstation/port/1025-65535/tcp/tcp".to_string())); + assert_eq!(z.rules.len(), 5, "{ids:?}"); + } + + #[test] + fn a_composed_service_reports_every_port_it_opens() { + // samba-client looks like one port and is really two. Under-reporting + // here makes a rule look narrower than it is. + let z = zone(); + let samba = z + .rules + .iter() + .find(|r| r.label == "samba-client") + .expect("samba-client present"); + assert_eq!(samba.ports, "137,138"); + } + + #[test] + fn empty_zone_fields_add_nothing() { + // "protocols:" with no value must not become an unmeasurable entry + let z = zone(); + assert!(z.unmeasurable.is_empty(), "{:?}", z.unmeasurable); + } + + /// Verbatim `firewall-cmd --info-zone=public` from a Fedora 44 host + /// configured with rich rules, a protocol, an icmp-block and a + /// forward-port. The tab-indented continuation lines under `rich rules:` + /// and `forward-ports:` are exactly how firewalld prints them. + const REAL_ZONE_WITH_RICH_RULES: &str = "public (default)\n \ + target: default\n \ + icmp-block-inversion: no\n \ + interfaces: \n \ + services: dhcpv6-client mdns ssh\n \ + ports: \n \ + protocols: esp\n \ + forward: yes\n \ + masquerade: no\n \ + forward-ports: \n\ + \tport=80:proto=tcp:toport=8080:toaddr=\n \ + source-ports: \n \ + icmp-blocks: echo-request\n \ + rich rules: \n\ + \trule service name=\"ssh\" log prefix=\"ssh\" level=\"info\" limit value=\"3/m\" accept\n\ + \trule family=\"ipv4\" source address=\"10.0.0.0/8\" port port=\"5432\" protocol=\"tcp\" accept\n"; + + #[test] + fn rich_rules_on_continuation_lines_are_not_silently_dropped() { + // firewalld prints "rich rules:" with an empty value and the rules + // themselves on tab-indented lines below. Reading only the inline + // value loses every rich rule on the host — the user would be told + // their firewall is simpler than it is. + let z = parse_zone("public", REAL_ZONE_WITH_RICH_RULES, &fake_expand); + let rich: Vec<&(String, String)> = z + .unmeasurable + .iter() + .filter(|(id, _)| id.contains("rich-rule")) + .collect(); + assert_eq!( + rich.len(), + 2, + "both rich rules must surface: {:?}", + z.unmeasurable + ); + assert!(rich.iter().any(|(id, _)| id.contains("10.0.0.0/8"))); + assert!(rich.iter().any(|(id, _)| id.contains("ssh"))); + } + + #[test] + fn every_uncountable_zone_feature_is_listed() { + let z = parse_zone("public", REAL_ZONE_WITH_RICH_RULES, &fake_expand); + let ids: Vec<&str> = z.unmeasurable.iter().map(|(i, _)| i.as_str()).collect(); + assert!(ids.iter().any(|i| i.contains("protocol/esp")), "{ids:?}"); + assert!(ids.iter().any(|i| i.contains("icmp-block")), "{ids:?}"); + assert!(ids.iter().any(|i| i.contains("forward-port")), "{ids:?}"); + // and the countable services still come through + assert!(z.rules.iter().any(|r| r.label == "ssh")); + } + + #[test] + fn active_zone_names_drop_the_default_marker() { + let text = + "FedoraWorkstation (default)\n interfaces: eno2 wlo1\npublic\n interfaces: tun0\n"; + assert_eq!(active_zones(text), vec!["FedoraWorkstation", "public"]); + } + + #[test] + fn service_includes_are_parsed() { + let text = "samba-client\n ports: 138/udp\n protocols:\n includes: netbios-ns\n"; + let (ports, includes) = parse_service(text); + assert_eq!(ports, vec![("udp".to_string(), "138".to_string())]); + assert_eq!(includes, vec!["netbios-ns"]); + } + + #[test] + fn match_expressions_cover_single_ports_ranges_and_sets() { + let mk = |proto: &str, ports: &str| FwdRule { + zone: "z".into(), + kind: "port".into(), + label: "l".into(), + proto: proto.into(), + ports: ports.into(), + }; + assert_eq!( + match_expr(&mk("tcp", "22")).as_deref(), + Some("tcp dport 22") + ); + assert_eq!( + match_expr(&mk("tcp", "1025-65535")).as_deref(), + Some("tcp dport 1025-65535") + ); + assert_eq!( + match_expr(&mk("udp", "137,138")).as_deref(), + Some("udp dport { 137, 138 }") + ); + } + + #[test] + fn nothing_unexpected_reaches_the_kernel_ruleset() { + // The ruleset is handed to nft as text, so a port field that is not + // a number must be refused outright rather than interpolated. + let mk = |ports: &str| FwdRule { + zone: "z".into(), + kind: "port".into(), + label: "l".into(), + proto: "tcp".into(), + ports: ports.into(), + }; + assert_eq!(match_expr(&mk("22; drop")), None); + assert_eq!(match_expr(&mk("}")), None); + assert_eq!(match_expr(&mk("")), None); + assert_eq!(match_expr(&mk("0")), None); + assert_eq!(match_expr(&mk("99999")), None); + } + + #[test] + fn the_shadow_table_can_count_but_never_decide() { + let z = zone(); + let rs = shadow_ruleset(&z.rules); + assert!(rs.contains("policy accept")); + assert!(rs.contains("priority 300"), "must sit after firewalld"); + assert!( + rs.contains("ct state new"), + "per-connection, not per-packet" + ); + for verdict in ["drop", "reject", "accept\n"] { + assert!( + !rs.contains(&format!(" {verdict}")), + "shadow rules must carry no verdict: {rs}" + ); + } + // one counter per expressible rule + assert_eq!(rs.matches("counter comment").count(), z.rules.len()); + } + + #[test] + fn unexpressible_rules_are_named_so_they_cannot_read_as_unused() { + let rules = vec![FwdRule { + zone: "z".into(), + kind: "service".into(), + label: "weird".into(), + proto: "esp".into(), + ports: "".into(), + }]; + let out = unexpressible(&rules); + assert_eq!(out.len(), 1); + assert!(out[0].1.contains("still active")); + } + + #[test] + fn counters_are_read_back_by_slot() { + let json = serde_json::json!({ + "nftables": [ + {"metainfo": {"version": "1.1.6"}}, + {"rule": {"comment": "fb0", "expr": [ + {"match": {}}, {"counter": {"packets": 9, "bytes": 540}}]}}, + {"rule": {"comment": "fb2", "expr": [ + {"counter": {"packets": 0, "bytes": 0}}]}}, + {"rule": {"expr": [{"counter": {"packets": 5}}]}} + ] + }); + let c = parse_counters(&json); + assert_eq!(c.get(&0), Some(&9)); + assert_eq!(c.get(&2), Some(&0)); + assert_eq!(c.len(), 2, "a rule with no comment is not ours"); + } + + #[test] + fn zone_scope_is_the_rules_scope() { + let z = zone(); + let info = z.rules[0].to_rule_info(); + assert_eq!(info.profile, "FedoraWorkstation"); + assert_eq!(info.direction, "Inbound"); + } +} diff --git a/src/linux/mod.rs b/src/linux/mod.rs index 65da259..2eaeb44 100644 --- a/src/linux/mod.rs +++ b/src/linux/mod.rs @@ -18,6 +18,7 @@ //! first useful answer. pub mod counters; +pub mod firewalld; pub mod ufw; use anyhow::{Context, Result}; @@ -26,12 +27,24 @@ use anyhow::{Context, Result}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Backend { Ufw, + Firewalld, } impl Backend { pub fn label(self) -> &'static str { match self { Backend::Ufw => "ufw", + Backend::Firewalld => "firewalld", + } + } + + /// How this backend gets its evidence, for the report header. + pub fn evidence_summary(self) -> &'static str { + match self { + Backend::Ufw => "iptables counters, always on — nothing to enable", + Backend::Firewalld => { + "Firebreak's own shadow counter table (firewalld's is owner-locked)" + } } } @@ -41,6 +54,10 @@ impl Backend { pub fn needs_instrumentation(self) -> bool { match self { Backend::Ufw => false, + // firewalld's own table is owner-locked and carries no counters, + // so Firebreak must install a shadow table before anything is + // measured — and then wait for traffic. + Backend::Firewalld => true, } } @@ -50,6 +67,11 @@ impl Backend { pub fn scope_vocabulary(self) -> crate::model::ScopeVocabulary { match self { Backend::Ufw => crate::model::ScopeVocabulary::none(), + // zones are firewalld's scopes, and there can be any number + Backend::Firewalld => crate::model::ScopeVocabulary { + names: firewalld::read_zones().map(|z| z.names).unwrap_or_default(), + any_token: "Any".into(), + }, } } } @@ -72,6 +94,9 @@ pub fn detect() -> Result> { return Ok(Some(Backend::Ufw)); } } + if crate::syspath::system_tool("firewall-cmd").is_some() && firewalld::is_running() { + return Ok(Some(Backend::Firewalld)); + } Ok(None) } @@ -91,6 +116,8 @@ pub struct RuleUsageRow { #[derive(Debug, Default)] pub struct Report { pub rows: Vec, + /// A caveat about how this backend collects, shown with the report. + pub note: Option, /// Rules that exist but cannot be measured, with the reason. Kept apart /// from `rows` so nothing unmeasurable is ever rendered as unused. pub unmeasurable: Vec<(String, String)>, @@ -120,7 +147,126 @@ pub struct PriorState { pub fn analyze(backend: Backend, prior: &PriorState) -> Result<(Report, PriorState)> { match backend { Backend::Ufw => analyze_ufw(prior), + Backend::Firewalld => analyze_firewalld(prior), + } +} + +/// Start collecting on a backend that needs instrumentation. No-op where the +/// kernel already counts. +pub fn enable_collection(backend: Backend) -> Result { + match backend { + Backend::Ufw => Ok("ufw counters are always running — nothing to enable.".into()), + Backend::Firewalld => { + let zones = firewalld::read_zones()?; + firewalld::install(&zones.rules)?; + Ok(format!( + "Installed the shadow counter table for {} rule(s) across {} zone(s). \n{}", + zones.rules.len(), + zones.names.len(), + firewalld::REBOOT_CAVEAT + )) + } + } +} + +/// Undo whatever `enable_collection` installed. Collected totals in the +/// store are kept — this stops counting, it does not discard evidence. +pub fn stop_collection(backend: Backend) -> Result { + match backend { + Backend::Ufw => Ok("ufw needed no instrumentation, so there is nothing to remove.".into()), + Backend::Firewalld => { + firewalld::teardown()?; + Ok(format!( + "Removed the `{}` counter table. Collected totals are kept.", + firewalld::SHADOW_TABLE + )) + } + } +} + +fn analyze_firewalld(prior: &PriorState) -> Result<(Report, PriorState)> { + use std::collections::BTreeMap; + + let zones = firewalld::read_zones()?; + let mut report = Report::default(); + report.unmeasurable.extend(zones.unmeasurable.clone()); + report + .unmeasurable + .extend(firewalld::unexpressible(&zones.rules)); + + let ids: Vec = zones.rules.iter().map(firewalld::FwdRule::id).collect(); + let generation = counters::generation(&ids); + let generation_changed = prior.generation.as_deref().is_some_and(|g| g != generation); + + // Firebreak does not instrument a host that has not asked for it. This + // backend *writes* to the kernel firewall to collect, which Windows + // never does, so installing the shadow table is an explicit decision + // (--enable-only) exactly as enabling audit policy is on Windows. + if !firewalld::table_exists() { + let mut report = report; + report.note = Some(format!( + "Collection is not enabled on this host, so there are no counts yet. Run \ + `firebreak --enable-only` to install the shadow counter table, leave it to \ + gather traffic, then run again. {}", + firewalld::REBOOT_CAVEAT + )); + for rule in &zones.rules { + report.rows.push(RuleUsageRow { + rule: rule.to_rule_info(), + hits: None, + }); + } + return Ok((report, prior.clone())); } + + // The table must describe the rules as they are *now*. A changed rule + // set means a new table and therefore new counters, which the generation + // token turns into a banked lifetime rather than a decrease. + if generation_changed { + firewalld::install(&zones.rules)?; + } + + let live = firewalld::read_counters()?; + let mut next = PriorState { + generation: Some(generation), + counters: BTreeMap::new(), + }; + + for (i, rule) in zones.rules.iter().enumerate() { + let id = rule.id(); + let hits = if firewalld::match_expr(rule).is_none() { + // already reported as unmeasurable above + None + } else { + match live.get(&i) { + Some(raw) => { + let state = prior + .counters + .get(&id) + .copied() + .unwrap_or_default() + .observe(*raw, generation_changed); + next.counters.insert(id.clone(), state); + Some(state.total()) + } + None => { + report.unmeasurable.push(( + id.clone(), + "The shadow counter table has no entry for this rule, so it could \ + not be counted." + .into(), + )); + None + } + } + }; + report.rows.push(RuleUsageRow { + rule: rule.to_rule_info(), + hits, + }); + } + report.note = Some(firewalld::REBOOT_CAVEAT.to_string()); + Ok((report, next)) } fn analyze_ufw(prior: &PriorState) -> Result<(Report, PriorState)> { @@ -235,6 +381,7 @@ mod tests { }; let report = Report { rows: vec![mk("a", Some(0)), mk("b", None), mk("c", Some(5))], + note: None, unmeasurable: vec![], }; let unused: Vec<&str> = report diff --git a/src/main.rs b/src/main.rs index a2816d9..461d85d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -179,8 +179,8 @@ fn main() -> Result<()> { return run_linux(&args, backend); } eprintln!( - "No supported Linux firewall backend is active (Firebreak supports ufw so far; \ - firewalld and raw nftables are not wired up yet)." + "No supported Linux firewall backend is active (Firebreak supports ufw and \ + firewalld; raw nftables is not wired up yet)." ); } @@ -189,13 +189,33 @@ fn main() -> Result<()> { /// The Linux run. Deliberately not the Windows flow with substitutions: on /// ufw there is no audit policy to enable, no event log to checkpoint and no -/// collection clock to start, because the kernel is already counting. The -/// first run has a real answer. +/// collection clock to start, because the kernel is already counting, so the +/// first run has a real answer. firewalld does need instrumenting, and there +/// the existing collection flags carry over exactly: +/// +/// * `--enable-only` installs the shadow counter table and exits, i.e. starts +/// the clock — the same job it does on Windows. +/// * `--restore-audit` removes it again, leaving collected totals intact. #[cfg(target_os = "linux")] fn run_linux(args: &Args, backend: linux::Backend) -> Result<()> { // Declare the host's scope vocabulary before anything renders a rule. model::set_vocabulary(backend.scope_vocabulary()); + + if args.enable_only { + println!("{}", linux::enable_collection(backend)?); + return Ok(()); + } + if args.restore_audit { + println!("{}", linux::stop_collection(backend)?); + return Ok(()); + } + let store = Store::open(&args.db_path)?; + if args.reset { + store.reset_counter_state()?; + println!("Cleared collected rule usage. Counting restarts from the next run."); + return Ok(()); + } let prior = store.load_counter_state()?; let (report, next) = linux::analyze(backend, &prior)?; store.save_counter_state(&next)?; @@ -311,14 +331,13 @@ fn dump_filters() -> Result<()> { #[cfg(target_os = "linux")] fn print_linux_report(backend: linux::Backend, report: &linux::Report) { println!( - "Backend: {} ({})", + "Backend: {} — {}", backend.label(), - if backend.needs_instrumentation() { - "collection must be enabled first" - } else { - "counters already running — no collection to enable" - } + backend.evidence_summary() ); + if backend.needs_instrumentation() { + println!("Collection: opt-in (--enable-only), removable (--restore-audit)"); + } let unused = report.unused(); println!( @@ -347,6 +366,10 @@ fn print_linux_report(backend: linux::Backend, report: &linux::Report) { ); } + if let Some(note) = &report.note { + println!("\nNote: {note}"); + } + if !report.unmeasurable.is_empty() { println!( "\n=== Not measurable ({}) — active, but with no usable hit count ===", diff --git a/src/store.rs b/src/store.rs index 1789778..9b502e7 100644 --- a/src/store.rs +++ b/src/store.rs @@ -251,6 +251,18 @@ impl Store { }) } + /// Drop all counter bookkeeping so totals restart. The generation goes + /// with it — leaving it behind would make the next run compare fresh + /// readings against a lifetime that no longer has any banked packets. + #[cfg(target_os = "linux")] + pub fn reset_counter_state(&self) -> Result<()> { + self.conn.execute_batch( + "DELETE FROM rule_counter; + DELETE FROM meta WHERE key = 'counter_generation';", + )?; + Ok(()) + } + /// Persist counter bookkeeping. Written as one transaction with the /// generation token: a generation saved without its counters (or the /// reverse) would make the next run mis-detect a reset and either bank a From 814ca2504acbddb43d8559235eb3ad60ef8b3c36 Mon Sep 17 00:00:00 2001 From: ghostpsalm Date: Mon, 10 Aug 2026 00:38:14 +1000 Subject: [PATCH 5/8] Linux port (5/n): process attribution from /proc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Answers "which processes are sitting behind the ports this rule opens" from the live listener set. That is inference, not the per-connection attribution Windows gets free from event 5156 — Linux has no event carrying rule and process identity together, and joining the two sources on a 5-tuple is a different, lossier job. For judging whether a rule is over-broad, which is what the tool is for, the listener set is often the more useful answer and costs one directory walk. The module doc states its limits so nobody reads more into a row than it says. The rule-matching itself is the shared listeners::listeners_for_rule the Windows path already uses, reached by returning the same Listener shape — the reuse the port was supposed to deliver, actually delivered. Never-matched rules now split by whether anything is listening. A rule with a live process behind it may simply be idle; one with nothing behind it is the stronger disable candidate. On this host the wide-open 1025-65535/tcp rule resolves to 14 processes including clickhouse, postgres and ollama. Two /proc details worth the tests: addresses are hex in host byte order, so 0100007F is 127.0.0.1 and not 1.0.0.127; and UDP has no LISTEN state, so filtering on one would hide every UDP service on the host. CLAUDE.md now describes both targets, the counter-gauge rule, the owner-lock and the four properties the firewalld shadow table has to keep. --- CLAUDE.md | 80 ++++++++++--- src/linux/mod.rs | 20 +++- src/linux/proc.rs | 300 ++++++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 47 ++++++-- 4 files changed, 417 insertions(+), 30 deletions(-) create mode 100644 src/linux/proc.rs diff --git a/CLAUDE.md b/CLAUDE.md index b357f12..f5c07b9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,37 +1,70 @@ # Firebreak -On-demand Windows Firewall rule-usage auditor: correlates WFP audit events -(Security log 5156/5157) with the live WFP filter table and -`Get-NetFirewallRule` to find unused and over-broad rules. No service, no -driver — a native Rust GUI (`eframe`/`egui`) that runs, reports, and exits. -See `docs/ARCHITECTURE.md` for the design rationale (why the Security log, -not `pfirewall.log` or a packet-capture driver) and `docs/internals.md` for -the ingest pipeline. +On-demand firewall rule-usage auditor for Windows and Linux: finds unused +and over-broad rules. No service, no driver — it runs, reports, and exits. + +- **Windows** correlates WFP audit events (Security log 5156/5157) with the + live WFP filter table and `Get-NetFirewallRule`. Native Rust GUI + (`eframe`/`egui`). +- **Linux** reads per-rule packet counters instead, because there is no + Linux event carrying rule identity and process identity together. See + `docs/spike-linux-port.md` for the full evidence survey behind that call. + +See `docs/ARCHITECTURE.md` for the Windows design rationale (why the +Security log, not `pfirewall.log` or a packet-capture driver) and +`docs/internals.md` for the ingest pipeline. ## Stack - Rust, edition 2021, GUI via `eframe`/`egui` 0.29. -- **Windows-only in practice.** Almost every real code path is - `#[cfg(windows)]` — WFP, the Security Event Log, audit policy, elevation. - The non-Windows fallback paths exist only so the pure logic (parsing, - scoping, aggregation) can be unit-tested from Linux. +- **Two real targets.** The Windows evidence layer (WFP, Security Event Log, + audit policy, PowerShell rule enumeration) is `#[cfg(windows)]`; the Linux + one lives under `src/linux/` and is `#[cfg(target_os = "linux")]`. Logic + that is portable but only *called* from one platform is + `#[cfg(any(windows, test))]` so it stays unit-tested from either host. - Cross-compiled from Linux to `x86_64-pc-windows-gnu` (needs `mingw-w64` + `rustup target add x86_64-pc-windows-gnu`); native build on - Windows works too. `cargo test` runs cross-platform — the tests exercise - the `#[cfg(not(windows))]`-safe logic, not the WinAPI calls themselves. -- `rusqlite` (bundled) for the local `%ProgramData%\firebreak\firebreak.db`. + Windows works too. +- `rusqlite` (bundled) for the local store — + `%ProgramData%\firebreak\firebreak.db` or `/var/lib/firebreak/firebreak.db`, + both created private and refused if another principal owns them + (`src/secure_dir.rs`). - `minisign-verify` for self-update signature checking (fails closed — see `signing/README.md` and `src/update.rs`). +## Linux backends (`src/linux/`) + +| backend | rule identity | evidence | instrument? | +|---|---|---|---| +| ufw | `### tuple ###` in `user.rules` | iptables counters, always on | no | +| firewalld | zone + service/port | Firebreak's own shadow nft table | **yes** | + +Three things to know before touching them: + +- **A kernel counter is a gauge, not an event stream.** It resets on reboot, + reload and `iptables -Z`. `linux/counters.rs` banks the old lifetime + instead of re-adding raw readings. Never add a raw counter to a total. +- **firewalld's nft table is `flags owner`** — the kernel refuses to let any + process add a counter to it, sudo included. Hence the shadow table, at + input priority 300 so a hit means "firewalld allowed this". +- **Unmeasurable is not unused.** Rich rules, ipsets, protocol-only entries + and unparseable tuples are reported in their own section. Folding them + into the zero-hit list would invite deleting a load-bearing rule. + +Rule scope is a backend-supplied vocabulary (`model::ScopeVocabulary`), not +Windows' Domain/Private/Public: firewalld zones are arbitrary and ufw has no +scopes at all. + ## Gate `./scripts/gate.sh` — fmt, clippy, test. Must be green before every commit. -**Clippy lints the `x86_64-pc-windows-gnu` target, not native Linux.** -Because of the `#[cfg(windows)]` gating above, linting the native target -reports huge swaths of real, used code as dead — it's noise, not signal. -The gate detects the host and lints natively only when actually run on -Windows. +**On a Linux host it lints both targets**: `x86_64-pc-windows-gnu` (the +Windows code, only checkable by cross-compiling) and native (the Linux +backends). The native lint used to be skipped because Windows-only code +compiled on Linux read as dead; that is now stated as `#[cfg(windows)]` +rather than suppressed, so the native lint is signal — and it is the only +thing that lints `src/linux/` at all. Don't drop it. CI (`.github/workflows/ci.yml`) runs the same gate on push/PR to `main`, installing `mingw-w64` first. @@ -65,6 +98,15 @@ installing `mingw-w64` first. every read in the evidence loop (Security log, audit policy, WFP filter enum, the ACL-protected local DB) is admin-bound. Don't "fix" this without reading that doc first; it's a considered verdict, not drift. + Linux needs root for the same reason: ufw's rule files, the iptables + counters and `/proc//exe` for other users' processes. Without it + process attribution *silently shrinks* rather than failing, which is why + the Linux path refuses to run unprivileged instead of degrading. +- **On firewalld, collecting means writing to the kernel firewall.** The + Windows side never does this — it only reads. The shadow table is + therefore opt-in (`--enable-only`) and removable (`--restore-audit`), a + plain run never installs it, and the table carries no verdicts so it + cannot change any packet's fate. Keep all four of those properties. - **Self-update is a supply-chain surface**: the in-app updater downloads a release asset and its `.minisig` signature, verifying against a pinned public key (`TRUSTED_PUBLIC_KEY` in `src/update.rs`) before installing. diff --git a/src/linux/mod.rs b/src/linux/mod.rs index 2eaeb44..97b42d3 100644 --- a/src/linux/mod.rs +++ b/src/linux/mod.rs @@ -19,6 +19,7 @@ pub mod counters; pub mod firewalld; +pub mod proc; pub mod ufw; use anyhow::{Context, Result}; @@ -104,6 +105,10 @@ pub fn detect() -> Result> { #[derive(Debug, Clone)] pub struct RuleUsageRow { pub rule: crate::model::RuleInfo, + /// Processes currently listening behind the ports this rule opens, as + /// "name:port". Inference from the live listener set, not per-connection + /// attribution — see [`proc`]. + pub listening: Vec, /// Total packets matched across counter resets, or `None` when the /// rule's counters could not be read. `None` is not zero: zero means /// "never used, consider removing it" and `None` means "we do not know", @@ -188,6 +193,7 @@ fn analyze_firewalld(prior: &PriorState) -> Result<(Report, PriorState)> { use std::collections::BTreeMap; let zones = firewalld::read_zones()?; + let live_listeners = proc::enumerate_listeners(); let mut report = Report::default(); report.unmeasurable.extend(zones.unmeasurable.clone()); report @@ -211,8 +217,10 @@ fn analyze_firewalld(prior: &PriorState) -> Result<(Report, PriorState)> { firewalld::REBOOT_CAVEAT )); for rule in &zones.rules { + let info = rule.to_rule_info(); report.rows.push(RuleUsageRow { - rule: rule.to_rule_info(), + listening: crate::listeners::listeners_for_rule(&info, &live_listeners), + rule: info, hits: None, }); } @@ -260,8 +268,10 @@ fn analyze_firewalld(prior: &PriorState) -> Result<(Report, PriorState)> { } } }; + let info = rule.to_rule_info(); report.rows.push(RuleUsageRow { - rule: rule.to_rule_info(), + listening: crate::listeners::listeners_for_rule(&info, &live_listeners), + rule: info, hits, }); } @@ -273,6 +283,7 @@ fn analyze_ufw(prior: &PriorState) -> Result<(Report, PriorState)> { use std::collections::BTreeMap; let parsed = ufw::read_rules()?; + let live_listeners = proc::enumerate_listeners(); let mut report = Report::default(); for (tuple, reason) in &parsed.unreadable { @@ -339,8 +350,10 @@ fn analyze_ufw(prior: &PriorState) -> Result<(Report, PriorState)> { None } }; + let info = rule.to_rule_info(); report.rows.push(RuleUsageRow { - rule: rule.to_rule_info(), + listening: crate::listeners::listeners_for_rule(&info, &live_listeners), + rule: info, hits, }); } @@ -361,6 +374,7 @@ mod tests { #[test] fn unused_excludes_rules_whose_hits_are_unknown() { let mk = |name: &str, hits: Option| RuleUsageRow { + listening: Vec::new(), rule: crate::model::RuleInfo { name: name.into(), display_name: name.into(), diff --git a/src/linux/proc.rs b/src/linux/proc.rs new file mode 100644 index 0000000..daa8edf --- /dev/null +++ b/src/linux/proc.rs @@ -0,0 +1,300 @@ +//! Process attribution from `/proc`. +//! +//! This is where Linux is genuinely worse off than Windows, and it is worth +//! being precise about why. Windows event 5156 carries the rule that matched +//! *and* the application that triggered it in one record. Linux has no such +//! event: the rule side (nftables counters) and the process side (`/proc`, +//! auditd, eBPF) are separate sources with no shared key, and joining them +//! per connection means correlating a 5-tuple across two streams. +//! +//! Firebreak does not attempt that join. It answers the question the rule +//! table actually asks — *which processes are sitting behind the ports this +//! rule opens?* — from the current listener set. That is inference, not +//! per-connection attribution: it names who **could** be reached through a +//! rule, not who was. For deciding whether a rule is over-broad, which is +//! what the tool is for, it is often the more useful answer, and it costs +//! one directory walk. +//! +//! Its limits, stated so nobody reads more into a row than it says: +//! +//! * A process that was listening yesterday and is not now does not appear. +//! * Short-lived listeners are missed entirely. +//! * Outbound connections have no listener, so outbound rules get nothing. +//! * Resolving another user's process needs root, and without it the answer +//! silently shrinks rather than failing — see [`super::Report`] and the +//! root check in `main`. + +use std::collections::HashMap; + +use crate::listeners::Listener; + +/// TCP state 0A = LISTEN, per include/net/tcp_states.h. +const TCP_LISTEN: &str = "0A"; + +/// One socket as `/proc/net/*` describes it, before we know its owner. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SocketRow { + pub proto: &'static str, + pub local_address: String, + pub local_port: u32, + pub inode: String, +} + +/// Parse one of `/proc/net/{tcp,tcp6,udp,udp6}`. +/// +/// `listening_only` keeps TCP sockets in LISTEN. UDP has no listen state, so +/// every bound UDP socket counts — which is correct, since a bound UDP port +/// is reachable. +pub fn parse_net_table(text: &str, proto: &'static str, v6: bool) -> Vec { + let mut out = Vec::new(); + for line in text.lines().skip(1) { + let f: Vec<&str> = line.split_whitespace().collect(); + // sl local rem st tx rx tr tm retrnsmt uid timeout inode + if f.len() < 10 { + continue; + } + let Some((addr_hex, port_hex)) = f[1].rsplit_once(':') else { + continue; + }; + let Ok(local_port) = u32::from_str_radix(port_hex, 16) else { + continue; + }; + if proto == "TCP" && f[3] != TCP_LISTEN { + continue; + } + let Some(local_address) = decode_address(addr_hex, v6) else { + continue; + }; + out.push(SocketRow { + proto, + local_address, + local_port, + inode: f[9].to_string(), + }); + } + out +} + +/// `/proc/net` writes addresses as hex in host byte order per 32-bit word, +/// so 0100007F is 127.0.0.1 rather than 1.0.0.127. +pub fn decode_address(hex: &str, v6: bool) -> Option { + if v6 { + if hex.len() != 32 { + return None; + } + let mut groups = Vec::with_capacity(8); + // four little-endian 32-bit words + for word in 0..4 { + let raw = u32::from_str_radix(&hex[word * 8..word * 8 + 8], 16).ok()?; + let be = raw.swap_bytes(); + groups.push((be >> 16) as u16); + groups.push((be & 0xffff) as u16); + } + let addr = std::net::Ipv6Addr::new( + groups[0], groups[1], groups[2], groups[3], groups[4], groups[5], groups[6], groups[7], + ); + Some(addr.to_string()) + } else { + if hex.len() != 8 { + return None; + } + let raw = u32::from_str_radix(hex, 16).ok()?; + let o = raw.to_le_bytes(); + Some(format!("{}.{}.{}.{}", o[0], o[1], o[2], o[3])) + } +} + +/// socket inode -> (pid, process name, executable path), by walking /proc. +fn socket_owners() -> HashMap { + let mut out = HashMap::new(); + let Ok(entries) = std::fs::read_dir("/proc") else { + return out; + }; + for entry in entries.flatten() { + let name = entry.file_name(); + let Some(pid_str) = name.to_str() else { + continue; + }; + let Ok(pid) = pid_str.parse::() else { + continue; + }; + let Ok(fds) = std::fs::read_dir(format!("/proc/{pid}/fd")) else { + // another user's process without root, or one that just exited + continue; + }; + let exe = std::fs::read_link(format!("/proc/{pid}/exe")) + .map(|p| p.display().to_string()) + .unwrap_or_default(); + let comm = std::fs::read_to_string(format!("/proc/{pid}/comm")) + .map(|s| s.trim().to_string()) + .unwrap_or_default(); + for fd in fds.flatten() { + let Ok(target) = std::fs::read_link(fd.path()) else { + continue; + }; + let t = target.to_string_lossy(); + if let Some(inode) = t.strip_prefix("socket:[").and_then(|s| s.strip_suffix(']')) { + out.insert(inode.to_string(), (pid, comm.clone(), exe.clone())); + } + } + } + out +} + +/// Every listening socket on the host, in the shape the shared rule-matching +/// in [`crate::listeners`] already understands. +pub fn enumerate_listeners() -> Vec { + let tables: [(&str, &'static str, bool); 4] = [ + ("/proc/net/tcp", "TCP", false), + ("/proc/net/tcp6", "TCP", true), + ("/proc/net/udp", "UDP", false), + ("/proc/net/udp6", "UDP", true), + ]; + let owners = socket_owners(); + let mut out = Vec::new(); + for (path, proto, v6) in tables { + let Ok(text) = std::fs::read_to_string(path) else { + continue; + }; + for row in parse_net_table(&text, proto, v6) { + let (pid, name, path) = owners.get(&row.inode).cloned().unwrap_or_default(); + out.push(Listener { + proto: row.proto.to_string(), + local_address: row.local_address, + local_port: row.local_port, + pid, + process_name: name, + process_path: path, + }); + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Verbatim `/proc/net/tcp` from the Fedora 44 host this was written on, + /// trimmed to a few rows. Row 0 is listening; the third is established + /// and must not be reported as a listener. + const REAL_TCP: &str = " sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode\n \ + 0: 0100007F:4F11 00000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 52281 2 00000000b1f80692 100 0 0 10 0\n \ + 1: 00000000:0016 00000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 22240 1 00000000a73d3201 100 0 0 10 0\n \ + 2: 0801BD0A:B0F6 9A1714D0:01BB 01 00000000:00000000 00:00000000 00000000 1000 0 52879092 1 000000005d28da7a 20 4 30 10 -1\n"; + + const REAL_TCP6: &str = " sl local_address remote_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode\n \ + 0: 00000000000000000000000000000000:0016 00000000000000000000000000000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 22240 1 00000000a73d3201 100 0 0 10 0\n"; + + const REAL_UDP: &str = " sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode ref pointer drops\n \ + 2610: 3600007F:0035 00000000:0000 07 00000000:00000000 00:00000000 00000000 193 0 10382 2 00000000b01acf9d 0\n"; + + #[test] + fn addresses_decode_from_host_byte_order() { + // the classic trap: 0100007F is 127.0.0.1, not 1.0.0.127 + assert_eq!( + decode_address("0100007F", false).as_deref(), + Some("127.0.0.1") + ); + assert_eq!( + decode_address("00000000", false).as_deref(), + Some("0.0.0.0") + ); + // 10.189.1.8 + assert_eq!( + decode_address("0801BD0A", false).as_deref(), + Some("10.189.1.8") + ); + assert_eq!( + decode_address("00000000000000000000000000000000", true).as_deref(), + Some("::") + ); + } + + #[test] + fn malformed_addresses_are_rejected_rather_than_guessed() { + assert_eq!(decode_address("0100", false), None); + assert_eq!(decode_address("zzzzzzzz", false), None); + assert_eq!(decode_address("0100007F", true), None); + } + + #[test] + fn only_listening_tcp_sockets_count() { + let rows = parse_net_table(REAL_TCP, "TCP", false); + assert_eq!(rows.len(), 2, "the established socket must be excluded"); + assert_eq!(rows[0].local_address, "127.0.0.1"); + assert_eq!(rows[0].local_port, 0x4F11); + assert_eq!(rows[1].local_port, 22); + assert_eq!(rows[1].inode, "22240"); + } + + #[test] + fn every_bound_udp_socket_counts() { + // UDP has no LISTEN state, and a bound UDP port is reachable, so + // filtering on state would hide every UDP service on the host. + let rows = parse_net_table(REAL_UDP, "UDP", false); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].local_port, 53); + assert_eq!(rows[0].local_address, "127.0.0.54"); + } + + #[test] + fn ipv6_rows_parse() { + let rows = parse_net_table(REAL_TCP6, "TCP", true); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].local_address, "::"); + assert_eq!(rows[0].local_port, 22); + } + + #[test] + fn a_truncated_table_yields_nothing_rather_than_panicking() { + assert!(parse_net_table("header only\n", "TCP", false).is_empty()); + assert!(parse_net_table("h\n 0: junk\n", "TCP", false).is_empty()); + assert!(parse_net_table("", "TCP", false).is_empty()); + } + + #[test] + fn listeners_bind_to_the_shared_rule_matcher() { + // The point of returning crate::listeners::Listener: the "which + // processes are behind this rule's ports" logic is shared with the + // Windows path rather than reimplemented. + use crate::model::RuleInfo; + let ls = vec![Listener { + proto: "TCP".into(), + local_address: "0.0.0.0".into(), + local_port: 8444, + pid: 42, + process_name: "clickhouse".into(), + process_path: "/usr/bin/clickhouse".into(), + }]; + let rule = RuleInfo { + name: "firewalld:z/port/1025-65535/tcp".into(), + display_name: "1025-65535/tcp".into(), + description: None, + enabled: "True".into(), + direction: "Inbound".into(), + action: "Allow".into(), + profile: "z".into(), + group: None, + program: None, + protocol: Some("tcp".into()), + local_port: Some("1025-65535".into()), + remote_port: None, + service: None, + remote_address: None, + }; + assert_eq!( + crate::listeners::listeners_for_rule(&rule, &ls), + vec!["clickhouse:8444"] + ); + } + + #[test] + fn this_host_has_at_least_one_listening_socket() { + // smoke test against the real /proc — sshd, a resolver, something is + // always bound on a running Linux box + let ls = enumerate_listeners(); + assert!(!ls.is_empty(), "expected some listening socket"); + assert!(ls.iter().all(|l| l.local_port > 0)); + } +} diff --git a/src/main.rs b/src/main.rs index 461d85d..e2d0645 100644 --- a/src/main.rs +++ b/src/main.rs @@ -96,10 +96,16 @@ fn parse_args_from(args_iter: impl Iterator) -> Args { Firewall rule-usage auditor for Windows and Linux.\n\n\ USAGE:\n\ \x20 firebreak [OPTIONS]\n\n\ - ON LINUX (ufw): runs as root, prints a rule-usage report and exits.\n\ - \x20 The kernel already counts packets per rule, so there is nothing to\n\ - \x20 enable and no waiting period — the first run has a real answer. The\n\ - \x20 collection options below are Windows-only and do not apply.\n\n\ + ON LINUX: runs as root, prints a rule-usage report and exits.\n\ + \x20 ufw the kernel already counts every rule, so there is nothing\n\ + \x20 to enable and no waiting period — the first run answers.\n\ + \x20 firewalld its nftables table is owner-locked and carries no counters,\n\ + \x20 so --enable-only installs Firebreak's own shadow counter\n\ + \x20 table and --restore-audit removes it again. A plain run\n\ + \x20 never instruments the host.\n\ + \x20 --reset clear collected totals and start counting over.\n\ + \x20 --db database path (default /var/lib/firebreak/firebreak.db)\n\ + \x20 The remaining options below are Windows-only.\n\n\ ON WINDOWS:\n\ Run without arguments for the app: it boots to the rule table, offers an\n\ 'Enable connection auditing' button on first run, and on later runs\n\ @@ -340,17 +346,37 @@ fn print_linux_report(backend: linux::Backend, report: &linux::Report) { } let unused = report.unused(); + // A never-matched rule that still has something listening behind it is a + // different conversation from one with nothing there: the first may just + // be waiting for its first connection. + let (idle, empty): (Vec<&&linux::RuleUsageRow>, Vec<&&linux::RuleUsageRow>) = + unused.iter().partition(|r| !r.listening.is_empty()); println!( - "\n=== Never matched ({}) — disable candidates ===", - unused.len() + "\n=== Never matched, nothing listening ({}) — strongest disable candidates ===", + empty.len() ); - for row in &unused { + for row in &empty { println!( " {} [{} {}]", row.rule.display_name, row.rule.direction, row.rule.action ); } + if !idle.is_empty() { + println!( + "\n=== Never matched, but something is listening ({}) ===", + idle.len() + ); + println!("(the port is open and a process is behind it — it may simply be idle)"); + for row in &idle { + println!( + " {} <- {}", + row.rule.display_name, + row.listening.join(", ") + ); + } + } + let mut used: Vec<_> = report .rows .iter() @@ -359,8 +385,13 @@ fn print_linux_report(backend: linux::Backend, report: &linux::Report) { used.sort_by_key(|r| std::cmp::Reverse(r.hits.unwrap_or(0))); println!("\n=== Matched (most first) ==="); for row in used { + let behind = if row.listening.is_empty() { + String::new() + } else { + format!(" <- {}", row.listening.join(", ")) + }; println!( - " {:>12} packets {}", + " {:>12} packets {}{behind}", row.hits.unwrap_or(0), row.rule.display_name ); From e2f3245c2d3d3a395b1d045f32901da1ecaed3cc Mon Sep 17 00:00:00 2001 From: ghostpsalm Date: Mon, 10 Aug 2026 06:57:47 +1000 Subject: [PATCH 6/8] Linux port (6/n): raw nftables backend, completing the loop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The third backend, for hosts running neither ufw nor firewalld. Here the firewall *is* the ruleset, and the evidence is each rule's own `counter` — the only exact evidence in the tool. Nothing is reconstructed: where a counter exists the kernel is counting that precise rule, not Firebreak's guess at what it matches, which is what the firewalld shadow table has to settle for. Detection runs ufw -> firewalld -> nftables, and the order is load-bearing: the first two are nftables underneath, so checking raw nftables first would audit their generated rules instead of the vocabulary the user actually wrote. Rules that already carry a counter cost nothing to read. The rest are reported as unmeasurable with an actionable reason — never as zero-hit, since "nobody ever counted this" and "this is never used" are opposite conclusions and only one justifies deleting a rule. This is the only backend that edits the user's own rules, so the safety is the design: - The expression is never re-derived from text. It is the kernel's own JSON, returned with one {"counter": null} inserted before the verdict, so match semantics cannot drift. Verified live: anonymous sets, prefixes and multi-match rules all survive the round trip untouched. - The full ruleset is backed up to the secured data directory first. - Every touched rule is re-read and checked to be its original expression plus exactly one counter; anything else rolls the ruleset back. Tests cover a changed match, a lost counter and a vanished rule. - --restore-audit restores from the backup, which also preserves counters the admin wrote themselves — we cannot tell ours from theirs, so we remove none of them. Identity is family/table/chain plus an expression digest, not the handle: handles are renumbered on every reload, so using them would reset every rule's total at each boot. Counter values are stripped from the digest so a rule's identity does not move as traffic accrues. --- CLAUDE.md | 21 +- src/linux/mod.rs | 92 +++++- src/linux/nftables.rs | 742 ++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 8 +- 4 files changed, 855 insertions(+), 8 deletions(-) create mode 100644 src/linux/nftables.rs diff --git a/CLAUDE.md b/CLAUDE.md index f5c07b9..919581d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -38,8 +38,13 @@ Security log, not `pfirewall.log` or a packet-capture driver) and |---|---|---|---| | ufw | `### tuple ###` in `user.rules` | iptables counters, always on | no | | firewalld | zone + service/port | Firebreak's own shadow nft table | **yes** | +| nftables | family/table/chain + expression digest | the rule's *own* counter | partly | -Three things to know before touching them: +Detection order is ufw → firewalld → raw nftables, and the order matters: +the first two *are* nftables underneath, so checking raw nftables first +would audit their generated rules instead of the vocabulary the user wrote. + +Four things to know before touching them: - **A kernel counter is a gauge, not an event stream.** It resets on reboot, reload and `iptables -Z`. `linux/counters.rs` banks the old lifetime @@ -47,9 +52,17 @@ Three things to know before touching them: - **firewalld's nft table is `flags owner`** — the kernel refuses to let any process add a counter to it, sudo included. Hence the shadow table, at input priority 300 so a hit means "firewalld allowed this". -- **Unmeasurable is not unused.** Rich rules, ipsets, protocol-only entries - and unparseable tuples are reported in their own section. Folding them - into the zero-hit list would invite deleting a load-bearing rule. +- **Unmeasurable is not unused.** Rich rules, ipsets, protocol-only entries, + 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 + 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 + its original self plus exactly one counter, or the whole thing is rolled + back. Keep all three of those. Identity is an expression digest, not the + handle, which is renumbered on every reload. Rule scope is a backend-supplied vocabulary (`model::ScopeVocabulary`), not Windows' Domain/Private/Public: firewalld zones are arbitrary and ufw has no diff --git a/src/linux/mod.rs b/src/linux/mod.rs index 97b42d3..4a652b0 100644 --- a/src/linux/mod.rs +++ b/src/linux/mod.rs @@ -19,6 +19,7 @@ pub mod counters; pub mod firewalld; +pub mod nftables; pub mod proc; pub mod ufw; @@ -29,6 +30,7 @@ use anyhow::{Context, Result}; pub enum Backend { Ufw, Firewalld, + Nftables, } impl Backend { @@ -36,6 +38,7 @@ impl Backend { match self { Backend::Ufw => "ufw", Backend::Firewalld => "firewalld", + Backend::Nftables => "nftables", } } @@ -46,6 +49,7 @@ impl Backend { Backend::Firewalld => { "Firebreak's own shadow counter table (firewalld's is owner-locked)" } + Backend::Nftables => "each rule's own nftables counter — exact, nothing reconstructed", } } @@ -59,6 +63,10 @@ impl Backend { // so Firebreak must install a shadow table before anything is // measured — and then wait for traffic. Backend::Firewalld => true, + // A raw ruleset may already carry counters, in which case the + // first run answers. Rules without one need a counter adding, + // which edits the live firewall and is therefore opt-in. + Backend::Nftables => true, } } @@ -73,6 +81,8 @@ impl Backend { names: firewalld::read_zones().map(|z| z.names).unwrap_or_default(), any_token: "Any".into(), }, + // raw nftables has no zone or profile concept + Backend::Nftables => crate::model::ScopeVocabulary::none(), } } } @@ -98,6 +108,13 @@ pub fn detect() -> Result> { if crate::syspath::system_tool("firewall-cmd").is_some() && firewalld::is_running() { return Ok(Some(Backend::Firewalld)); } + // Last: a hand-written ruleset with no manager in front of it. Checked + // only after the others, since both of them *are* nftables underneath — + // auditing their generated rules directly would report a vocabulary the + // user never wrote. + if crate::syspath::system_tool("nft").is_some() && nftables::has_ruleset() { + return Ok(Some(Backend::Nftables)); + } Ok(None) } @@ -153,14 +170,16 @@ pub fn analyze(backend: Backend, prior: &PriorState) -> Result<(Report, PriorSta match backend { Backend::Ufw => analyze_ufw(prior), Backend::Firewalld => analyze_firewalld(prior), + Backend::Nftables => analyze_nftables(prior), } } /// Start collecting on a backend that needs instrumentation. No-op where the /// kernel already counts. -pub fn enable_collection(backend: Backend) -> Result { +pub fn enable_collection(backend: Backend, db_path: &std::path::Path) -> Result { match backend { Backend::Ufw => Ok("ufw counters are always running — nothing to enable.".into()), + Backend::Nftables => nftables::add_counters(db_path), Backend::Firewalld => { let zones = firewalld::read_zones()?; firewalld::install(&zones.rules)?; @@ -176,9 +195,10 @@ pub fn enable_collection(backend: Backend) -> Result { /// Undo whatever `enable_collection` installed. Collected totals in the /// store are kept — this stops counting, it does not discard evidence. -pub fn stop_collection(backend: Backend) -> Result { +pub fn stop_collection(backend: Backend, db_path: &std::path::Path) -> Result { match backend { Backend::Ufw => Ok("ufw needed no instrumentation, so there is nothing to remove.".into()), + Backend::Nftables => nftables::remove_counters(db_path), Backend::Firewalld => { firewalld::teardown()?; Ok(format!( @@ -360,6 +380,74 @@ fn analyze_ufw(prior: &PriorState) -> Result<(Report, PriorState)> { Ok((report, next)) } +/// Raw nftables: read whatever counters the ruleset already carries. +/// +/// This is the only backend whose evidence is exact rather than inferred — +/// the counter belongs to the rule itself. Rules without one are reported as +/// unmeasurable with an actionable reason, never as zero-hit, because +/// "nobody ever counted this" and "this is never used" are opposite +/// conclusions and only one of them justifies deleting a rule. +fn analyze_nftables(prior: &PriorState) -> Result<(Report, PriorState)> { + use std::collections::BTreeMap; + + let rules = nftables::read_rules()?; + let live_listeners = proc::enumerate_listeners(); + let mut report = Report::default(); + + let ids: Vec = rules.iter().map(nftables::NftRule::id).collect(); + let generation = counters::generation(&ids); + let generation_changed = prior.generation.as_deref().is_some_and(|g| g != generation); + let mut next = PriorState { + generation: Some(generation), + counters: BTreeMap::new(), + }; + + let uncounted = rules.iter().filter(|r| r.counter.is_none()).count(); + for rule in &rules { + let id = rule.id(); + let hits = match rule.counter { + Some(raw) => { + let state = prior + .counters + .get(&id) + .copied() + .unwrap_or_default() + .observe(raw, generation_changed); + next.counters.insert(id.clone(), state); + Some(state.total()) + } + None => { + // Name the rule as the admin wrote it. Its identity is a + // digest, which tells a reader nothing about which rule of + // theirs is going uncounted. + report.unmeasurable.push(( + format!("{} {} — {}", rule.table, rule.chain, rule.text), + "This rule carries no counter, so the kernel is not counting it. Run \ + `firebreak --enable-only` to add one (the ruleset is backed up first \ + and every edit is verified)." + .into(), + )); + None + } + }; + let info = rule.to_rule_info(); + report.rows.push(RuleUsageRow { + listening: crate::listeners::listeners_for_rule(&info, &live_listeners), + rule: info, + hits, + }); + } + + if uncounted > 0 { + report.note = Some(format!( + "{uncounted} of {} rule(s) carry no counter and are listed as not measurable. \ + Counters also reset on reboot or a ruleset reload; Firebreak banks the old total.", + rules.len() + )); + } + Ok((report, next)) +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/linux/nftables.rs b/src/linux/nftables.rs new file mode 100644 index 0000000..4aa4e4a --- /dev/null +++ b/src/linux/nftables.rs @@ -0,0 +1,742 @@ +//! The raw-nftables backend, for hosts running neither ufw nor firewalld. +//! +//! Here the firewall *is* the nftables ruleset, so a "rule" is one nft rule +//! and the evidence is that rule's own `counter` — the most exact evidence +//! any backend in this tool has. Nothing is reconstructed: where a counter +//! exists, the kernel is counting that precise rule, not Firebreak's guess +//! at what it matches. (Contrast `firewalld`, where the table is owner-locked +//! and a shadow table has to approximate.) +//! +//! Many rulesets already carry `counter` because writing it is idiomatic. +//! Those cost nothing to read. For the rest, Firebreak can add counters — +//! but that means **editing the user's live firewall**, so it is opt-in +//! (`--enable-only`), reversible (`--restore-audit`), and built to be safe +//! in a specific way: +//! +//! * The expression is not re-derived from text. It is the kernel's own +//! JSON, read back and returned with a single `{"counter": null}` +//! inserted before the verdict, so the match semantics cannot drift. +//! * The whole ruleset is backed up first, to the secured data directory. +//! * After writing, every touched rule is re-read and checked to be the +//! original expression plus exactly one counter. Anything else and the +//! change is rolled back from the backup. +//! +//! A counter is a pure side effect — it does not alter a packet's fate — so +//! the worst realistic outcome is a rule that fails to replace, which nft +//! rejects atomically and leaves untouched. + +use anyhow::{bail, Context, Result}; +use serde_json::{json, Value}; + +/// Tables Firebreak must not touch: its own shadow table, and firewalld's +/// (owner-locked, and it has a backend of its own). +const SKIP_TABLES: [&str; 2] = [super::firewalld::SHADOW_TABLE, "firewalld"]; + +/// nft verdict statements. A counter goes *before* these so it counts every +/// packet the rule matched, whatever the rule then does with it. +const VERDICTS: [&str; 7] = [ + "accept", "drop", "reject", "return", "jump", "goto", "continue", +]; + +/// One rule of the live ruleset. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NftRule { + pub family: String, + pub table: String, + pub chain: String, + /// Kernel handle. Stable while the ruleset lives, not across reloads — + /// which is why it is not the identity. + pub handle: u64, + /// The rule's expression array, verbatim from the kernel. + pub expr: Value, + /// Packets counted, when the rule carries a counter. + pub counter: Option, + /// Human-readable rule text, from `nft -a list ruleset`. + pub text: String, + /// Distinguishes byte-identical rules in the same chain. + pub occurrence: usize, +} + +impl NftRule { + /// Stable identity across reloads: where the rule lives plus what it + /// matches. Deliberately not the handle, which is reassigned whenever + /// the ruleset is reloaded — totals would reset every boot. + pub fn id(&self) -> String { + format!( + "nft:{}/{}/{}/{}#{}", + self.family, + self.table, + self.chain, + expr_digest(&self.expr), + self.occurrence + ) + } + + pub fn to_rule_info(&self) -> crate::model::RuleInfo { + crate::model::RuleInfo { + name: self.id(), + display_name: if self.text.is_empty() { + format!("{} {} handle {}", self.table, self.chain, self.handle) + } else { + self.text.clone() + }, + description: None, + enabled: "True".into(), + direction: direction_of(&self.chain), + action: action_of(&self.expr), + // raw nftables has no zone or profile concept + profile: "Any".into(), + group: Some(format!("{} {}", self.table, self.chain)), + program: None, + protocol: None, + local_port: None, + remote_port: None, + service: None, + remote_address: None, + } + } +} + +/// Best-effort direction from the chain's name. Raw chains are named by +/// their author, so this is a hint for display, never used for matching. +fn direction_of(chain: &str) -> String { + let c = chain.to_lowercase(); + if c.contains("out") { + "Outbound".into() + } else if c.contains("forward") || c.contains("fwd") { + "Forward".into() + } else { + "Inbound".into() + } +} + +/// What the rule does with a packet it matches. +fn action_of(expr: &Value) -> String { + let Some(items) = expr.as_array() else { + return "Allow".into(); + }; + for item in items { + let Some(obj) = item.as_object() else { + continue; + }; + for key in obj.keys() { + match key.as_str() { + "accept" => return "Allow".into(), + "drop" | "reject" => return "Block".into(), + _ => {} + } + } + } + // no verdict of its own: the rule counts, logs or jumps onward + "Continue".into() +} + +/// Canonical fingerprint of what a rule matches and does, with counter +/// *values* removed so reading a counter never changes a rule's identity. +pub fn expr_digest(expr: &Value) -> String { + let stripped = strip_counter_values(expr); + let text = serde_json::to_string(&stripped).unwrap_or_default(); + let mut h: u64 = 0xcbf2_9ce4_8422_2325; + for b in text.as_bytes() { + h ^= *b as u64; + h = h.wrapping_mul(0x0000_0100_0000_01b3); + } + format!("{h:016x}") +} + +/// Replace every `{"counter": {...}}` with `{"counter": null}` so a rule's +/// identity does not move as its counter climbs. +fn strip_counter_values(expr: &Value) -> Value { + match expr { + Value::Array(items) => Value::Array( + items + .iter() + .map(|item| { + if item.get("counter").is_some() { + json!({ "counter": null }) + } else { + item.clone() + } + }) + .collect(), + ), + other => other.clone(), + } +} + +fn counter_packets(expr: &Value) -> Option { + expr.as_array()? + .iter() + .filter_map(|e| e.get("counter")) + .filter_map(|c| c.get("packets")) + .filter_map(Value::as_i64) + .next() +} + +fn has_counter(expr: &Value) -> bool { + expr.as_array() + .is_some_and(|items| items.iter().any(|e| e.get("counter").is_some())) +} + +/// Map `handle -> rule text` from `nft -a list ruleset`, which appends +/// `# handle N` to every rule line. +pub fn parse_rule_text(text: &str) -> std::collections::HashMap { + let mut out = std::collections::HashMap::new(); + for line in text.lines() { + let line = line.trim(); + let Some((body, handle)) = line.rsplit_once("# handle ") else { + continue; + }; + let Ok(handle) = handle.trim().parse::() else { + continue; + }; + let body = body.trim(); + // table/chain headers also carry handles; they are not rules + if body.is_empty() || body.ends_with('{') { + continue; + } + out.insert(handle, strip_counter_text(body)); + } + out +} + +/// Drop the live counter out of a rule's display text. It is shown in its +/// own column, and leaving it in makes the rule's name change on every run — +/// which reads as a different rule each time. +fn strip_counter_text(body: &str) -> String { + let mut out = String::with_capacity(body.len()); + let mut rest = body; + while let Some(at) = rest.find("counter packets ") { + out.push_str(&rest[..at]); + // skip "counter packets bytes " + let after = &rest[at..]; + let mut fields = after.split_whitespace(); + let consumed: usize = fields + .by_ref() + .take(5) + .map(|f| f.len() + 1) + .sum::() + .min(after.len()); + rest = after[consumed..].trim_start(); + } + out.push_str(rest); + out.trim().to_string() +} + +/// Every rule of the live ruleset that Firebreak may look at. +pub fn parse_ruleset(json: &Value, listing: &str) -> Vec { + let texts = parse_rule_text(listing); + let mut seen: std::collections::HashMap = std::collections::HashMap::new(); + let mut out = Vec::new(); + let Some(items) = json["nftables"].as_array() else { + return out; + }; + for item in items { + let Some(rule) = item.get("rule") else { + continue; + }; + let table = rule["table"].as_str().unwrap_or_default().to_string(); + if SKIP_TABLES.contains(&table.as_str()) { + continue; + } + let Some(handle) = rule["handle"].as_u64() else { + continue; + }; + let expr = rule + .get("expr") + .cloned() + .unwrap_or_else(|| Value::Array(vec![])); + let family = rule["family"].as_str().unwrap_or_default().to_string(); + let chain = rule["chain"].as_str().unwrap_or_default().to_string(); + let key = format!("{family}/{table}/{chain}/{}", expr_digest(&expr)); + let occurrence = { + let slot = seen.entry(key).or_insert(0); + let n = *slot; + *slot += 1; + n + }; + out.push(NftRule { + counter: counter_packets(&expr), + text: texts.get(&handle).cloned().unwrap_or_default(), + family, + table, + chain, + handle, + expr, + occurrence, + }); + } + out +} + +/// The same expression with a counter inserted before the verdict. `None` +/// when it already has one. +pub fn with_counter(expr: &Value) -> Option { + if has_counter(expr) { + return None; + } + let items = expr.as_array()?; + let position = items + .iter() + .position(|e| { + e.as_object() + .is_some_and(|o| o.keys().any(|k| VERDICTS.contains(&k.as_str()))) + }) + .unwrap_or(items.len()); + let mut next = items.clone(); + next.insert(position, json!({ "counter": null })); + Some(Value::Array(next)) +} + +/// The same expression with every counter removed — the undo. +pub fn without_counter(expr: &Value) -> Option { + if !has_counter(expr) { + return None; + } + let items = expr.as_array()?; + Some(Value::Array( + items + .iter() + .filter(|e| e.get("counter").is_none()) + .cloned() + .collect(), + )) +} + +/// An `nft -j -f -` payload replacing each rule in place. +pub fn replace_payload(edits: &[(&NftRule, Value)]) -> Value { + json!({ + "nftables": edits + .iter() + .map(|(rule, expr)| json!({ + "replace": { "rule": { + "family": rule.family, + "table": rule.table, + "chain": rule.chain, + "handle": rule.handle, + "expr": expr, + }} + })) + .collect::>() + }) +} + +// --------------------------------------------------------------------------- +// Live host access +// --------------------------------------------------------------------------- + +fn nft(args: &[&str]) -> Result { + let bin = crate::syspath::system_tool("nft").context("nft is not installed")?; + let out = crate::syspath::command(bin) + .args(args) + .output() + .context("running nft")?; + if !out.status.success() { + bail!( + "nft {:?} failed: {}", + args, + String::from_utf8_lossy(&out.stderr).trim() + ); + } + Ok(String::from_utf8_lossy(&out.stdout).into_owned()) +} + +fn nft_stdin(args: &[&str], payload: &str) -> Result<()> { + use std::io::Write; + let bin = crate::syspath::system_tool("nft").context("nft is not installed")?; + let mut child = crate::syspath::command(bin) + .args(args) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .context("spawning nft")?; + child + .stdin + .as_mut() + .context("nft stdin")? + .write_all(payload.as_bytes())?; + let out = child.wait_with_output().context("running nft")?; + if !out.status.success() { + bail!( + "nft rejected the ruleset: {}", + String::from_utf8_lossy(&out.stderr).trim() + ); + } + Ok(()) +} + +/// Is there a raw nftables ruleset worth auditing? An empty ruleset, or one +/// consisting only of tables another backend owns, is not this backend's job. +pub fn has_ruleset() -> bool { + read_rules().is_ok_and(|r| !r.is_empty()) +} + +/// Read the live ruleset. +pub fn read_rules() -> Result> { + let json: Value = + serde_json::from_str(&nft(&["-j", "list", "ruleset"])?).context("parsing nft JSON")?; + let listing = nft(&["-a", "list", "ruleset"]).unwrap_or_default(); + Ok(parse_ruleset(&json, &listing)) +} + +/// Where the pre-edit ruleset is kept, inside the secured data directory. +fn backup_path(db_path: &std::path::Path) -> std::path::PathBuf { + db_path + .parent() + .unwrap_or_else(|| std::path::Path::new(".")) + .join("nftables-before-firebreak.nft") +} + +/// Add a counter to every rule that lacks one. +/// +/// Writes a full ruleset backup first, then verifies every touched rule came +/// back as the original expression plus exactly one counter — restoring from +/// the backup if it did not. +pub fn add_counters(db_path: &std::path::Path) -> Result { + let rules = read_rules()?; + let edits: Vec<(&NftRule, Value)> = rules + .iter() + .filter_map(|r| with_counter(&r.expr).map(|e| (r, e))) + .collect(); + let already = rules.len() - edits.len(); + if edits.is_empty() { + return Ok(format!( + "All {} rule(s) already carry a counter — nothing to add.", + rules.len() + )); + } + + let backup = nft(&["list", "ruleset"])?; + let path = backup_path(db_path); + if let Some(dir) = path.parent() { + crate::secure_dir::ensure_secured_dir(dir)?; + } + std::fs::write(&path, &backup) + .with_context(|| format!("writing ruleset backup to {}", path.display()))?; + + let payload = serde_json::to_string(&replace_payload(&edits))?; + if let Err(e) = nft_stdin(&["-j", "-f", "-"], &payload) { + return Err(e).with_context(|| { + format!( + "no rule was changed (nft applies a ruleset atomically). Backup at {}", + path.display() + ) + }); + } + + // Verify rather than trust: the expression we sent came from the kernel, + // but a round-trip gap would silently change what a rule matches. + let after = read_rules()?; + if let Some(problem) = verify(&edits, &after) { + let _ = nft_stdin(&["-f", "-"], &format!("flush ruleset\n{backup}")); + bail!( + "{problem} — the ruleset has been restored from {}. No counters were added.", + path.display() + ); + } + + Ok(format!( + "Added a counter to {} rule(s); {already} already had one. Ruleset backed up to {}.\n{}", + edits.len(), + path.display(), + "Counters reset on reboot or a ruleset reload; Firebreak banks the old total." + )) +} + +/// Check that each edited rule is now its original self plus one counter. +/// Returns a description of the first problem, or `None` if all is well. +pub fn verify(edits: &[(&NftRule, Value)], after: &[NftRule]) -> Option { + for (original, _) in edits { + let Some(now) = after + .iter() + .find(|r| r.handle == original.handle && r.table == original.table) + else { + return Some(format!( + "rule handle {} vanished after the edit", + original.handle + )); + }; + if !has_counter(&now.expr) { + return Some(format!("rule handle {} has no counter", original.handle)); + } + // stripping the counter must give back exactly what was there before + let restored = without_counter(&now.expr).unwrap_or_else(|| now.expr.clone()); + if strip_counter_values(&restored) != strip_counter_values(&original.expr) { + return Some(format!( + "rule handle {} no longer matches what it did before", + original.handle + )); + } + } + None +} + +/// Remove the counters Firebreak added. Rules that already had one are left +/// alone — we cannot tell ours from theirs, so we remove none of them and +/// say so, rather than stripping counters the admin wrote. +pub fn remove_counters(db_path: &std::path::Path) -> Result { + let path = backup_path(db_path); + if !path.exists() { + return Ok(format!( + "No ruleset backup at {} — Firebreak has not added any counters on this host.", + path.display() + )); + } + let backup = + std::fs::read_to_string(&path).with_context(|| format!("reading {}", path.display()))?; + nft_stdin(&["-f", "-"], &format!("flush ruleset\n{backup}")) + .context("restoring the ruleset recorded before Firebreak added counters")?; + let _ = std::fs::remove_file(&path); + Ok(format!( + "Restored the ruleset recorded at {}. Collected totals are kept.", + path.display() + )) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Verbatim `nft -j list ruleset` from a Fedora 44 host with a hand-written + /// ruleset: one rule already counted, one anonymous set, one two-match + /// rule, and one counter-only rule with no verdict. + const REAL_JSON: &str = r#"{"nftables": [ + {"metainfo": {"version": "1.1.6", "json_schema_version": 1}}, + {"table": {"family": "inet", "name": "myfw", "handle": 1}}, + {"chain": {"family": "inet", "table": "myfw", "name": "input", "handle": 1, + "type": "filter", "hook": "input", "prio": 0, "policy": "drop"}}, + {"rule": {"family": "inet", "table": "myfw", "chain": "input", "handle": 6, + "expr": [{"match": {"op": "==", "left": {"payload": {"protocol": "tcp", "field": "dport"}}, "right": 22}}, + {"counter": {"packets": 17, "bytes": 1020}}, {"accept": null}]}}, + {"rule": {"family": "inet", "table": "myfw", "chain": "input", "handle": 8, + "expr": [{"match": {"op": "==", "left": {"payload": {"protocol": "tcp", "field": "dport"}}, "right": {"set": [80, 443]}}}, + {"accept": null}]}}, + {"rule": {"family": "inet", "table": "myfw", "chain": "input", "handle": 10, + "expr": [{"match": {"op": "==", "left": {"payload": {"protocol": "ip", "field": "saddr"}}, "right": {"prefix": {"addr": "10.0.0.0", "len": 8}}}}, + {"match": {"op": "==", "left": {"payload": {"protocol": "tcp", "field": "dport"}}, "right": 5432}}, + {"accept": null}]}}, + {"rule": {"family": "inet", "table": "myfw", "chain": "input", "handle": 11, + "expr": [{"counter": {"packets": 3, "bytes": 180}}]}}, + {"rule": {"family": "inet", "table": "firewalld", "chain": "filter_INPUT", "handle": 99, + "expr": [{"accept": null}]}} + ]}"#; + + const REAL_LISTING: &str = r#"table inet myfw { # handle 1 + chain input { # handle 1 + type filter hook input priority filter; policy drop; + tcp dport 22 counter packets 17 bytes 1020 accept # handle 6 + tcp dport { 80, 443 } accept # handle 8 + ip saddr 10.0.0.0/8 tcp dport 5432 accept # handle 10 + counter packets 3 bytes 180 comment "dropped" # handle 11 + } +}"#; + + fn rules() -> Vec { + let json: Value = serde_json::from_str(REAL_JSON).unwrap(); + parse_ruleset(&json, REAL_LISTING) + } + + #[test] + fn firewalld_and_our_own_tables_are_left_alone() { + // firewalld's table is owner-locked and has its own backend; the + // shadow table is ours. Auditing either here would double-count or + // fail outright. + let rs = rules(); + assert!(rs.iter().all(|r| r.table == "myfw"), "{:?}", rs); + assert_eq!(rs.len(), 4); + } + + #[test] + fn existing_counters_are_read_directly() { + let rs = rules(); + let ssh = rs.iter().find(|r| r.handle == 6).unwrap(); + assert_eq!(ssh.counter, Some(17)); + assert_eq!( + ssh.text, "tcp dport 22 accept", + "the live counter belongs in the hits column, not the rule's name" + ); + } + + #[test] + fn a_rule_without_a_counter_reports_none_not_zero() { + // None means "not measured"; Some(0) would mean "never matched" and + // invite deleting a rule nobody ever counted. + let rs = rules(); + assert_eq!(rs.iter().find(|r| r.handle == 8).unwrap().counter, None); + } + + #[test] + fn identity_survives_the_counter_climbing() { + // The id must not move as traffic accrues, or every run would look + // like a brand-new rule and totals would never accumulate. + let rs = rules(); + let ssh = rs.iter().find(|r| r.handle == 6).unwrap(); + let mut later = ssh.clone(); + later.expr = json!([ + {"match": {"op": "==", "left": {"payload": {"protocol": "tcp", "field": "dport"}}, "right": 22}}, + {"counter": {"packets": 9999, "bytes": 600000}}, + {"accept": null} + ]); + assert_eq!(ssh.id(), later.id()); + } + + #[test] + fn identity_survives_a_reload_that_renumbers_handles() { + let rs = rules(); + let ssh = rs.iter().find(|r| r.handle == 6).unwrap(); + let mut reloaded = ssh.clone(); + reloaded.handle = 4242; + assert_eq!(ssh.id(), reloaded.id(), "handles must not be the identity"); + } + + #[test] + fn identical_rules_in_one_chain_get_distinct_identities() { + let json: Value = serde_json::from_str( + r#"{"nftables": [ + {"rule": {"family":"inet","table":"t","chain":"c","handle":1,"expr":[{"accept": null}]}}, + {"rule": {"family":"inet","table":"t","chain":"c","handle":2,"expr":[{"accept": null}]}} + ]}"#, + ) + .unwrap(); + let rs = parse_ruleset(&json, ""); + assert_eq!(rs.len(), 2); + assert_ne!(rs[0].id(), rs[1].id()); + } + + #[test] + fn a_counter_goes_before_the_verdict() { + // after the verdict it would never be reached + let rs = rules(); + let set_rule = rs.iter().find(|r| r.handle == 8).unwrap(); + let next = with_counter(&set_rule.expr).unwrap(); + let items = next.as_array().unwrap(); + assert_eq!(items.len(), 3); + assert!(items[1].get("counter").is_some()); + assert!(items[2].get("accept").is_some()); + // and the match is untouched + assert_eq!(items[0], set_rule.expr.as_array().unwrap()[0]); + } + + #[test] + fn a_rule_with_no_verdict_gets_its_counter_appended() { + let expr = json!([{"log": {"prefix": "x"}}]); + let next = with_counter(&expr).unwrap(); + let items = next.as_array().unwrap(); + assert_eq!(items.len(), 2); + assert!(items[1].get("counter").is_some()); + } + + #[test] + fn an_already_counted_rule_is_not_touched_twice() { + let rs = rules(); + let ssh = rs.iter().find(|r| r.handle == 6).unwrap(); + assert_eq!(with_counter(&ssh.expr), None); + } + + #[test] + fn removing_a_counter_gives_back_the_original_expression() { + let rs = rules(); + let plain = rs.iter().find(|r| r.handle == 8).unwrap(); + let counted = with_counter(&plain.expr).unwrap(); + assert_eq!(without_counter(&counted).unwrap(), plain.expr); + } + + #[test] + fn verification_rejects_a_rule_whose_match_changed() { + // The safety net: if a JSON round-trip ever altered what a rule + // matches, this is what catches it before the user lives with it. + let rs = rules(); + let original = rs.iter().find(|r| r.handle == 8).unwrap(); + let edits: Vec<(&NftRule, Value)> = vec![(original, with_counter(&original.expr).unwrap())]; + + let mut tampered = original.clone(); + tampered.expr = json!([ + {"match": {"op": "==", "left": {"payload": {"protocol": "tcp", "field": "dport"}}, "right": {"set": [80]}}}, + {"counter": {"packets": 0, "bytes": 0}}, + {"accept": null} + ]); + let problem = verify(&edits, &[tampered]).expect("must be rejected"); + assert!(problem.contains("no longer matches"), "{problem}"); + } + + #[test] + fn verification_rejects_a_rule_that_lost_its_counter() { + let rs = rules(); + let original = rs.iter().find(|r| r.handle == 8).unwrap(); + let edits: Vec<(&NftRule, Value)> = vec![(original, with_counter(&original.expr).unwrap())]; + let problem = verify(&edits, std::slice::from_ref(original)).expect("must be rejected"); + assert!(problem.contains("no counter"), "{problem}"); + } + + #[test] + fn verification_rejects_a_rule_that_disappeared() { + let rs = rules(); + let original = rs.iter().find(|r| r.handle == 8).unwrap(); + let edits: Vec<(&NftRule, Value)> = vec![(original, with_counter(&original.expr).unwrap())]; + let problem = verify(&edits, &[]).expect("must be rejected"); + assert!(problem.contains("vanished"), "{problem}"); + } + + #[test] + fn verification_accepts_a_correct_edit() { + let rs = rules(); + let original = rs.iter().find(|r| r.handle == 8).unwrap(); + let counted = with_counter(&original.expr).unwrap(); + let edits: Vec<(&NftRule, Value)> = vec![(original, counted.clone())]; + let mut after = original.clone(); + after.expr = counted; + assert_eq!(verify(&edits, &[after]), None); + } + + #[test] + fn the_replace_payload_targets_rules_by_handle() { + let rs = rules(); + let r = rs.iter().find(|r| r.handle == 8).unwrap(); + let payload = replace_payload(&[(r, with_counter(&r.expr).unwrap())]); + let cmd = &payload["nftables"][0]["replace"]["rule"]; + assert_eq!(cmd["handle"], 8); + assert_eq!(cmd["table"], "myfw"); + assert_eq!(cmd["family"], "inet"); + } + + #[test] + fn rule_text_pairs_by_handle_and_skips_headers() { + let texts = parse_rule_text(REAL_LISTING); + assert_eq!(texts.len(), 4, "table and chain headers are not rules"); + assert_eq!( + texts.get(&8).map(String::as_str), + Some("tcp dport { 80, 443 } accept") + ); + } + + #[test] + fn display_text_drops_the_live_counter() { + // otherwise the rule's name changes every run as traffic accrues + assert_eq!( + strip_counter_text("tcp dport 22 counter packets 17 bytes 1020 accept"), + "tcp dport 22 accept" + ); + assert_eq!( + strip_counter_text("counter packets 3 bytes 180 comment \"dropped\""), + "comment \"dropped\"" + ); + assert_eq!(strip_counter_text("iif \"lo\" accept"), "iif \"lo\" accept"); + } + + #[test] + fn actions_are_read_from_the_verdict() { + assert_eq!(action_of(&json!([{"accept": null}])), "Allow"); + assert_eq!(action_of(&json!([{"drop": null}])), "Block"); + assert_eq!(action_of(&json!([{"reject": {}}])), "Block"); + // a counter-only rule decides nothing + assert_eq!(action_of(&json!([{"counter": null}])), "Continue"); + } + + #[test] + fn chain_names_hint_at_direction_without_being_trusted() { + assert_eq!(direction_of("input"), "Inbound"); + assert_eq!(direction_of("my_output"), "Outbound"); + assert_eq!(direction_of("forward"), "Forward"); + } +} diff --git a/src/main.rs b/src/main.rs index e2d0645..c19d103 100644 --- a/src/main.rs +++ b/src/main.rs @@ -103,6 +103,10 @@ fn parse_args_from(args_iter: impl Iterator) -> Args { \x20 so --enable-only installs Firebreak's own shadow counter\n\ \x20 table and --restore-audit removes it again. A plain run\n\ \x20 never instruments the host.\n\ + \x20 nftables reads each rule's own counter, where the ruleset has one.\n\ + \x20 --enable-only adds counters to the rules that don't (the\n\ + \x20 ruleset is backed up first and every edit verified);\n\ + \x20 --restore-audit puts the ruleset back.\n\ \x20 --reset clear collected totals and start counting over.\n\ \x20 --db database path (default /var/lib/firebreak/firebreak.db)\n\ \x20 The remaining options below are Windows-only.\n\n\ @@ -208,11 +212,11 @@ fn run_linux(args: &Args, backend: linux::Backend) -> Result<()> { model::set_vocabulary(backend.scope_vocabulary()); if args.enable_only { - println!("{}", linux::enable_collection(backend)?); + println!("{}", linux::enable_collection(backend, &args.db_path)?); return Ok(()); } if args.restore_audit { - println!("{}", linux::stop_collection(backend)?); + println!("{}", linux::stop_collection(backend, &args.db_path)?); return Ok(()); } From b2353562ebb84c1cbe26613835c30bc2e0fe7962 Mon Sep 17 00:00:00 2001 From: ghostpsalm Date: Mon, 10 Aug 2026 07:01:28 +1000 Subject: [PATCH 7/8] Linux port (7/n): make the /proc smoke test independent of the host's services --- src/linux/proc.rs | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/src/linux/proc.rs b/src/linux/proc.rs index daa8edf..3ffaf5a 100644 --- a/src/linux/proc.rs +++ b/src/linux/proc.rs @@ -290,11 +290,16 @@ mod tests { } #[test] - fn this_host_has_at_least_one_listening_socket() { - // smoke test against the real /proc — sshd, a resolver, something is - // always bound on a running Linux box - let ls = enumerate_listeners(); - assert!(!ls.is_empty(), "expected some listening socket"); - assert!(ls.iter().all(|l| l.local_port > 0)); + fn reading_the_real_proc_yields_well_formed_listeners() { + // Smoke test against the live /proc. Deliberately does not require a + // non-empty result: a minimal container or CI runner may genuinely + // have nothing bound, and a test that depends on the host's services + // is flaky rather than strict. The golden fixtures above are what + // actually pin the parsing. + for l in enumerate_listeners() { + assert!(l.local_port > 0, "port 0 is not a real listener"); + assert!(matches!(l.proto.as_str(), "TCP" | "UDP"), "{}", l.proto); + assert!(!l.local_address.is_empty()); + } } } From c7dcbcc177310590da69049aaf06a8f0e6d6ada4 Mon Sep 17 00:00:00 2001 From: ghostpsalm Date: Mon, 10 Aug 2026 07:10:17 +1000 Subject: [PATCH 8/8] Fix clippy and rustc drift on the newer stable toolchain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI runs rustc 1.97.1; this machine had 1.96.0, so the gate passed locally and failed remotely. main is red for the same reason and has been since its last commit — none of this originates in the Linux work, but enabling the native clippy target is what surfaced it, so it gets fixed rather than skirted. Two lints, both applied via the compiler's own machine-applicable suggestions rather than by hand: - rustc's new f32-fallback future-compat lint (50 sites in the UI code): `Stroke::new(1.0, …)` is ambiguous through `impl Into` and will become a hard error. Type suffixes only; no behaviour changes. - clippy's useless_borrows_in_formatting and for_kv_map in support.rs. Verified green on 1.97.1 for fmt, both clippy targets and the tests, and still green on 1.96.0. --- src/support.rs | 4 +- src/ui.rs | 12 ++++-- src/ui/paint.rs | 105 ++++++++++++++++++++++++++---------------------- 3 files changed, 68 insertions(+), 53 deletions(-) diff --git a/src/support.rs b/src/support.rs index 06f0c99..1f0dbca 100644 --- a/src/support.rs +++ b/src/support.rs @@ -152,7 +152,7 @@ pub fn export(out_path: &Path) -> Result<()> { f.filter_id, f.name, truncate(&f.provider_data_utf16, 120), - &f.provider_data_hex.chars().take(64).collect::() + f.provider_data_hex.chars().take(64).collect::() ); shown += 1; if shown >= 15 { @@ -175,7 +175,7 @@ pub fn export(out_path: &Path) -> Result<()> { let rule_map = filter_map::build_filter_rule_map(&filters, &rules); let mut via_pd = 0; let mut via_name = 0; - for (_, (_, via)) in rule_map.iter() { + for (_, via) in rule_map.values() { match via { MappedVia::ProviderData => via_pd += 1, MappedVia::DisplayName => via_name += 1, diff --git a/src/ui.rs b/src/ui.rs index fa6c61d..69594e2 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -1285,7 +1285,11 @@ mod helpers { } pub fn stroke_bottom(painter: &egui::Painter, rect: Rect, color: Color32) { - painter.hline(rect.x_range(), rect.bottom() - 0.5, Stroke::new(1.0, color)); + painter.hline( + rect.x_range(), + rect.bottom() - 0.5, + Stroke::new(1.0_f32, color), + ); } /// Clickable profile chip. `kept` = still in the rule's target scope; @@ -1322,7 +1326,7 @@ mod helpers { let w = galley.size().x + 10.0; let h = 15.0; let r = Rect::from_min_size(top_left, Vec2::new(w, h)); - ui.painter().rect(r, 0.0, bg, Stroke::new(1.0, border)); + ui.painter().rect(r, 0.0, bg, Stroke::new(1.0_f32, border)); ui.painter().galley( egui::pos2(r.left() + 5.0, r.center().y - galley.size().y / 2.0), galley, @@ -1333,7 +1337,7 @@ mod helpers { ui.painter().hline( r.left() + 3.0..=r.right() - 3.0, r.center().y, - Stroke::new(1.0, t::DISABLED()), + Stroke::new(1.0_f32, t::DISABLED()), ); } let resp = if editable { @@ -1345,7 +1349,7 @@ mod helpers { if re.hovered() { ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); ui.painter() - .rect_stroke(r, 0.0, Stroke::new(1.0, t::ACCENT())); + .rect_stroke(r, 0.0, Stroke::new(1.0_f32, t::ACCENT())); } Some(re) } else { diff --git a/src/ui/paint.rs b/src/ui/paint.rs index 2b18af8..135c60a 100644 --- a/src/ui/paint.rs +++ b/src/ui/paint.rs @@ -106,13 +106,13 @@ mod glyph { pub fn magnifier(p: &egui::Painter, center: Pos2, color: Color32) { let r = 4.0; let c = Pos2::new(center.x - 1.0, center.y - 1.0); - p.circle_stroke(c, r, Stroke::new(1.3, color)); + p.circle_stroke(c, r, Stroke::new(1.3_f32, color)); p.line_segment( [ Pos2::new(c.x + r * 0.7, c.y + r * 0.7), Pos2::new(c.x + r * 1.6, c.y + r * 1.6), ], - Stroke::new(1.3, color), + Stroke::new(1.3_f32, color), ); } @@ -128,7 +128,7 @@ mod glyph { if filled { p.circle_filled(center, 7.5, circle); } else { - p.circle_stroke(center, 7.0, Stroke::new(1.3, circle)); + p.circle_stroke(center, 7.0, Stroke::new(1.3_f32, circle)); } let s = 7.5; p.line_segment( @@ -136,14 +136,14 @@ mod glyph { Pos2::new(center.x - s * 0.32, center.y + s * 0.02), Pos2::new(center.x - s * 0.08, center.y + s * 0.30), ], - Stroke::new(1.5, mark), + Stroke::new(1.5_f32, mark), ); p.line_segment( [ Pos2::new(center.x - s * 0.08, center.y + s * 0.30), Pos2::new(center.x + s * 0.36, center.y - s * 0.26), ], - Stroke::new(1.5, mark), + Stroke::new(1.5_f32, mark), ); } @@ -159,7 +159,7 @@ mod glyph { Pos2::new(d.x + c * 4.0, d.y + s * 4.0), Pos2::new(d.x + c * 6.0, d.y + s * 6.0), ], - Stroke::new(1.2, color), + Stroke::new(1.2_f32, color), ); } } @@ -176,14 +176,14 @@ mod glyph { Pos2::new(center.x - 5.0, center.y), Pos2::new(center.x + 5.0, center.y), ], - Stroke::new(1.2, color), + Stroke::new(1.2_f32, color), ); } /// Windowed → offer maximize: a single square. pub fn maximize(p: &egui::Painter, center: Pos2, color: Color32) { let r = Rect::from_center_size(center, Vec2::splat(9.0)); - p.rect_stroke(r, 0.0, Stroke::new(1.2, color)); + p.rect_stroke(r, 0.0, Stroke::new(1.2_f32, color)); } /// Maximized → offer restore: two overlapping squares. @@ -194,14 +194,14 @@ mod glyph { Pos2::new(center.x - s / 2.0 + 2.0, center.y - s / 2.0 - 2.0), Vec2::splat(s), ); - p.rect_stroke(back, 0.0, Stroke::new(1.1, color)); + p.rect_stroke(back, 0.0, Stroke::new(1.1_f32, color)); // front square (bottom-left), painted over with the panel fill behind let front = Rect::from_min_size( Pos2::new(center.x - s / 2.0 - 2.0, center.y - s / 2.0 + 2.0), Vec2::splat(s), ); p.rect_filled(front, 0.0, crate::theme::TITLEBAR()); - p.rect_stroke(front, 0.0, Stroke::new(1.1, color)); + p.rect_stroke(front, 0.0, Stroke::new(1.1_f32, color)); } } @@ -239,7 +239,7 @@ pub fn window(app: &mut App, ctx: &egui::Context) { egui::Order::Foreground, egui::Id::new("winborder"), )) - .rect_stroke(screen.shrink(0.5), 0.0, Stroke::new(1.0, t::BORDER())); + .rect_stroke(screen.shrink(0.5), 0.0, Stroke::new(1.0_f32, t::BORDER())); } // ---- title bar ---- @@ -524,7 +524,7 @@ fn divider(ui: &mut egui::Ui) { ui.painter().vline( rect.center().x, rect.y_range(), - Stroke::new(1.0, t::BORDER_LIGHT()), + Stroke::new(1.0_f32, t::BORDER_LIGHT()), ); ui.add_space(24.0); } @@ -547,7 +547,8 @@ fn flat_button(ui: &mut egui::Ui, label: &str) -> egui::Response { if resp.hovered() { ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); } - ui.painter().rect(rect, 0.0, fill, Stroke::new(1.0, border)); + ui.painter() + .rect(rect, 0.0, fill, Stroke::new(1.0_f32, border)); ui.painter() .galley(rect.center() - galley.size() / 2.0, galley, t::INK()); resp @@ -587,7 +588,7 @@ fn settings_menu( let resp = area.show(ctx, |ui| { egui::Frame::none() .fill(t::TABLE_BG()) - .stroke(Stroke::new(1.0, t::CONTROL_BORDER())) + .stroke(Stroke::new(1.0_f32, t::CONTROL_BORDER())) .inner_margin(egui::Margin::same(4.0)) .show(ui, |ui| { ui.set_width(202.0); @@ -816,7 +817,7 @@ fn about_box(app: &mut App, ctx: &egui::Context) { .frame( egui::Frame::none() .fill(t::TABLE_BG()) - .stroke(Stroke::new(1.0, t::CONTROL_BORDER())), + .stroke(Stroke::new(1.0_f32, t::CONTROL_BORDER())), ) .show(ctx, |ui| { ui.add_space(18.0); @@ -967,7 +968,8 @@ fn theme_toggle(ui: &mut egui::Ui, dark: bool) -> egui::Response { if resp.hovered() { ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); } - ui.painter().rect(rect, 0.0, fill, Stroke::new(1.0, border)); + ui.painter() + .rect(rect, 0.0, fill, Stroke::new(1.0_f32, border)); if dark { glyph::sun(ui.painter(), rect.center(), t::SECONDARY()); } else { @@ -995,7 +997,8 @@ fn settings_button(ui: &mut egui::Ui) -> egui::Response { if resp.hovered() { ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); } - ui.painter().rect(rect, 0.0, fill, Stroke::new(1.0, border)); + ui.painter() + .rect(rect, 0.0, fill, Stroke::new(1.0_f32, border)); ui.painter().galley( Pos2::new(rect.left() + 12.0, rect.center().y - galley.size().y / 2.0), galley, @@ -1150,7 +1153,7 @@ fn filter_bar(app: &mut App, ctx: &egui::Context) { // with its text indented past the icon (no overlap, icon // doesn't move with the text) let (field, _) = ui.allocate_exact_size(Vec2::new(224.0, CTRL_H), Sense::hover()); - ui.painter().rect(field, 0.0, t::TABLE_BG(), Stroke::new(1.0, t::CONTROL_BORDER())); + ui.painter().rect(field, 0.0, t::TABLE_BG(), Stroke::new(1.0_f32, t::CONTROL_BORDER())); glyph::magnifier(ui.painter(), Pos2::new(field.left() + 12.0, field.center().y), t::FAINT()); let text_area = Rect::from_min_max( Pos2::new(field.left() + 24.0, field.top()), @@ -1252,7 +1255,7 @@ fn segment_cell( t::TABLE_BG() }; ui.painter().rect_filled(rect, 0.0, fill); - let border = Stroke::new(1.0, t::CONTROL_BORDER()); + let border = Stroke::new(1.0_f32, t::CONTROL_BORDER()); ui.painter().hline(rect.x_range(), rect.top() + 0.5, border); ui.painter() .hline(rect.x_range(), rect.bottom() - 0.5, border); @@ -1456,7 +1459,7 @@ fn resize_handles(app: &mut App, ui: &mut egui::Ui, cols: &Cols, header_rect: Re if resp.hovered() || resp.dragged() { ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeHorizontal); ui.painter() - .vline(x, header_rect.y_range(), Stroke::new(1.0, t::ACCENT())); + .vline(x, header_rect.y_range(), Stroke::new(1.0_f32, t::ACCENT())); } if resp.dragged() { // seed Rule's width the first time it's dragged @@ -1604,7 +1607,7 @@ fn table_header(ui: &mut egui::Ui, app: &mut App, cols: &Cols) { cols.reviewed.0, ] { ui.painter() - .vline(x, rect.y_range(), Stroke::new(1.0, t::BORDER_LIGHT())); + .vline(x, rect.y_range(), Stroke::new(1.0_f32, t::BORDER_LIGHT())); } // resize handles on the fixed-column right edges resize_handles(app, ui, cols, rect); @@ -1612,7 +1615,7 @@ fn table_header(ui: &mut egui::Ui, app: &mut App, cols: &Cols) { ui.painter().hline( rect.x_range(), rect.bottom() - 0.5, - Stroke::new(1.0, t::CONTROL_BORDER()), + Stroke::new(1.0_f32, t::CONTROL_BORDER()), ); } @@ -1704,10 +1707,10 @@ fn row(app: &mut App, ui: &mut egui::Ui, ri: usize, rect: Rect, cols: &Cols, res p.hline( rect.x_range(), rect.bottom() - 0.5, - Stroke::new(1.0, t::ROW_BORDER()), + Stroke::new(1.0_f32, t::ROW_BORDER()), ); // faint column separators, aligned with the header's - let sep = Stroke::new(1.0, t::ROW_BORDER()); + let sep = Stroke::new(1.0_f32, t::ROW_BORDER()); for x in [ cols.name.0, cols.dir.0, @@ -2026,14 +2029,14 @@ fn row(app: &mut App, ui: &mut egui::Ui, ri: usize, rect: Rect, cols: &Cols, res } ReviewState::Stale(at) => { ui.painter() - .circle_stroke(rv_center, 7.0, Stroke::new(1.3, t::ADVISORY())); + .circle_stroke(rv_center, 7.0, Stroke::new(1.3_f32, t::ADVISORY())); // exclamation: stem + dot ui.painter().line_segment( [ Pos2::new(rv_center.x, rv_center.y - 3.6), Pos2::new(rv_center.x, rv_center.y + 0.8), ], - Stroke::new(1.5, t::ADVISORY()), + Stroke::new(1.5_f32, t::ADVISORY()), ); ui.painter().circle_filled( Pos2::new(rv_center.x, rv_center.y + 3.4), @@ -2044,7 +2047,7 @@ fn row(app: &mut App, ui: &mut egui::Ui, ri: usize, rect: Rect, cols: &Cols, res } ReviewState::No => { ui.painter() - .circle_stroke(rv_center, 7.0, Stroke::new(1.3, t::CB_EMPTY_BORDER())); + .circle_stroke(rv_center, 7.0, Stroke::new(1.3_f32, t::CB_EMPTY_BORDER())); "Mark this rule as reviewed/verified (does not change the firewall)".to_string() } }; @@ -2205,14 +2208,14 @@ fn draw_checkbox( } else { (t::CB_EMPTY_BORDER(), t::TABLE_BG(), CbMark::None, t::INK()) }; - p.rect(rect, 0.0, fill, Stroke::new(1.5, border)); + p.rect(rect, 0.0, fill, Stroke::new(1.5_f32, border)); match mark { CbMark::Check => glyph::check(p, rect.center(), 9.0, mark_col), CbMark::Dash => { p.hline( rect.left() + 3.0..=rect.right() - 3.0, rect.center().y, - Stroke::new(2.0, mark_col), + Stroke::new(2.0_f32, mark_col), ); } CbMark::None => {} @@ -2224,7 +2227,12 @@ fn draw_listen_chip(p: &egui::Painter, top_left: Pos2, text: &str) { let galley = p.layout_no_wrap(text.to_string(), font, t::LIVE_TEXT()); let w = galley.size().x + 14.0 + 11.0; let rect = Rect::from_min_size(top_left, Vec2::new(w, 17.0)); - p.rect(rect, 0.0, t::LIVE_BG(), Stroke::new(1.0, t::LIVE_BORDER())); + p.rect( + rect, + 0.0, + t::LIVE_BG(), + Stroke::new(1.0_f32, t::LIVE_BORDER()), + ); p.circle_filled( Pos2::new(rect.left() + 7.0, rect.center().y), 3.0, @@ -2302,7 +2310,7 @@ fn detail_panel(app: &mut App, ctx: &egui::Context) { ui.painter().vline( ui.max_rect().left(), ui.max_rect().y_range(), - Stroke::new(1.0, t::BORDER()), + Stroke::new(1.0_f32, t::BORDER()), ); let r = &app.rows[ri]; egui::ScrollArea::vertical() @@ -2535,7 +2543,7 @@ fn section_sep(ui: &mut egui::Ui) { ui.painter().hline( rect.x_range(), rect.center().y, - Stroke::new(1.0, t::BORDER_LIGHT()), + Stroke::new(1.0_f32, t::BORDER_LIGHT()), ); } @@ -2630,13 +2638,13 @@ fn drawer(app: &mut App, ctx: &egui::Context) { ui.painter().hline( grip.x_range(), grip.center().y, - Stroke::new(2.0, t::ACCENT_TINT_BORDER()), + Stroke::new(2.0_f32, t::ACCENT_TINT_BORDER()), ); } else { ui.painter().hline( grip.x_range(), grip.center().y, - Stroke::new(1.0, t::BORDER_LIGHT()), + Stroke::new(1.0_f32, t::BORDER_LIGHT()), ); } if gresp.dragged() { @@ -2653,7 +2661,7 @@ fn drawer(app: &mut App, ctx: &egui::Context) { ui.painter().hline( bar.x_range(), bar.bottom() - 0.5, - Stroke::new(1.0, t::BORDER_LIGHT()), + Stroke::new(1.0_f32, t::BORDER_LIGHT()), ); let mut x = bar.left(); let n_actions = app.applicable_action_count(); @@ -2695,7 +2703,7 @@ fn drawer(app: &mut App, ctx: &egui::Context) { ui.painter().hline( tab_rect.x_range(), tab_rect.top() + 1.0, - Stroke::new(2.0, t::ACCENT()), + Stroke::new(2.0_f32, t::ACCENT()), ); } else if hovered { ui.painter().rect_filled(tab_rect, 0.0, t::HOVER_WASH()); @@ -2703,7 +2711,7 @@ fn drawer(app: &mut App, ctx: &egui::Context) { ui.painter().vline( tab_rect.right(), tab_rect.y_range(), - Stroke::new(1.0, t::BORDER_LIGHT()), + Stroke::new(1.0_f32, t::BORDER_LIGHT()), ); ui.painter().galley( tab_rect.center() - g.size() / 2.0, @@ -2836,7 +2844,7 @@ fn actions_body(app: &mut App, ui: &mut egui::Ui) { } else { (t::ACCENT_TINT(), t::ACCENT_TINT_BORDER(), t::ACCENT()) }; - ui.painter().rect(brect, 0.0, fill, Stroke::new(1.0, border)); + ui.painter().rect(brect, 0.0, fill, Stroke::new(1.0_f32, border)); ui.painter().galley(brect.center() - g.size() / 2.0, ui.painter().layout_no_wrap(label, t::sans(11.5), txt), txt); if bresp.hovered() { ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); @@ -2846,7 +2854,7 @@ fn actions_body(app: &mut App, ui: &mut egui::Ui) { stage = Some(i); } } - ui.painter().hline(rr.x_range(), rr.bottom() - 0.5, Stroke::new(1.0, t::ROW_BORDER())); + ui.painter().hline(rr.x_range(), rr.bottom() - 0.5, Stroke::new(1.0_f32, t::ROW_BORDER())); } if let Some(i) = stage { let a = &crate::ui::actions_catalog()[i]; @@ -2898,7 +2906,7 @@ fn sockets_body(app: &App, ui: &mut egui::Ui) { p.hline( rect.x_range(), hr.bottom(), - Stroke::new(1.0, t::ROW_BORDER()), + Stroke::new(1.0_f32, t::ROW_BORDER()), ); let mut list: Vec<&Listener> = app.listeners.iter().collect(); @@ -2960,7 +2968,7 @@ fn sockets_body(app: &App, ui: &mut egui::Ui) { p.hline( rect.x_range(), rr.bottom() - 0.5, - Stroke::new(1.0, t::CHROME()), + Stroke::new(1.0_f32, t::CHROME()), ); } }); @@ -2988,8 +2996,11 @@ fn unattributed_body(app: &App, ui: &mut egui::Ui) { // permanent explainer let ex = Rect::from_min_size(rect.min, Vec2::new(rect.width(), 26.0)); ui.painter().rect_filled(ex, 0.0, t::RAISED()); - ui.painter() - .hline(ex.x_range(), ex.bottom(), Stroke::new(1.0, t::ROW_BORDER())); + ui.painter().hline( + ex.x_range(), + ex.bottom(), + Stroke::new(1.0_f32, t::ROW_BORDER()), + ); let mut job = egui::text::LayoutJob::default(); job.wrap.max_width = rect.width() - 2.0 * PAGE; job.append("Traffic that Windows blocked by default policy — it matched no rule at all. Port scans and stray broadcasts land here. This is normal, not an error.", 0.0, fmt(t::italic(11.0), t::SECONDARY())); @@ -3044,7 +3055,7 @@ fn unattributed_body(app: &App, ui: &mut egui::Ui) { p.hline( rect.x_range(), rr.bottom() - 0.5, - Stroke::new(1.0, t::CHROME()), + Stroke::new(1.0_f32, t::CHROME()), ); } }); @@ -3076,7 +3087,7 @@ fn footer(app: &mut App, ctx: &egui::Context) { ui.painter().hline( ui.max_rect().expand2(Vec2::new(PAGE, 0.0)).x_range(), ui.max_rect().top(), - Stroke::new(1.0, border), + Stroke::new(1.0_f32, border), ); if running { footer_running(app, ui); @@ -3337,7 +3348,7 @@ fn confirm_modal(app: &mut App, ctx: &egui::Context) { .frame( egui::Frame::none() .fill(t::TABLE_BG()) - .stroke(Stroke::new(1.0, t::CONTROL_BORDER())), + .stroke(Stroke::new(1.0_f32, t::CONTROL_BORDER())), ) .show(ctx, |ui| { // title @@ -3383,7 +3394,7 @@ fn confirm_modal(app: &mut App, ctx: &egui::Context) { pad20(ui, |ui| { egui::Frame::none() .fill(t::BACKUP_BG()) - .stroke(Stroke::new(1.0, t::BACKUP_BORDER())) + .stroke(Stroke::new(1.0_f32, t::BACKUP_BORDER())) .inner_margin(egui::Margin::symmetric(12.0, 10.0)) .show(ui, |ui| { ui.horizontal_top(|ui| {