From 97f2c41af70b895cee0b7d2136c749d87110cb14 Mon Sep 17 00:00:00 2001 From: ghostpsalm Date: Mon, 10 Aug 2026 07:25:34 +1000 Subject: [PATCH 1/8] Linux GUI: render the counter backends in the shared window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Linux was CLI-only. It now boots to the same window Windows does — same table, filters, scope chips, detail drawer, CSV export — with `--no-ui` keeping the text report. The UI worker picks `linux::bridge` or `pipeline` at compile time, so it is still written once. The mapping that needed care is unmeasurable-versus-idle. Windows ingests events, so a rule with no hits was watched and found idle. Linux reads counters, and a rule the backend could not count has no hits for the opposite reason. Both reach the UI as `usage: None`, so RuleRow now carries `hits_known` and the zero-hit filter — the list a user works through deleting things — excludes what was never measured. Four things the screenshots caught, which no test would have: - Scope chips rendered a firewalld zone as "ANY". ANY means *every* scope, so a rule confined to one zone was labelled as the opposite of the truth. Chips now carry the zone's own name. - "Last seen: never" appeared beside a rule with 143 hits. Counters have no timestamps at all, so it now reads "not recorded" when there are hits and "—" when the rule was never measured. "never" is reserved for what it actually means. - The enable screen offered to turn on Windows Filtering Platform auditing and quoted ~40 MB/day of security log. - The header counted "events" for "this run" when Linux counters are cumulative packets, which understated the evidence. Apply is refused on Linux with a reason rather than silently no-oping: changing a ufw or firewalld rule means deleting and recreating it, which is a different operation with a different blast radius and is not built. Verified against live firewalld under Xvfb; host restored afterwards. --- src/firewall_rules.rs | 1 + src/linux/bridge.rs | 236 ++++++++++++++++++++++++++++++++++++++++++ src/linux/mod.rs | 15 +++ src/main.rs | 18 +++- src/pipeline.rs | 8 ++ src/preview.rs | 1 + src/ui.rs | 94 +++++++++++++---- src/ui/paint.rs | 159 +++++++++++++++++++++++----- 8 files changed, 478 insertions(+), 54 deletions(-) create mode 100644 src/linux/bridge.rs diff --git a/src/firewall_rules.rs b/src/firewall_rules.rs index b4d1b24..99ff790 100644 --- a/src/firewall_rules.rs +++ b/src/firewall_rules.rs @@ -135,6 +135,7 @@ pub fn save_rules_cache(rules: &[RuleInfo]) { } } +#[cfg(not(target_os = "linux"))] pub fn load_rules_cache() -> Option> { let json = std::fs::read_to_string(rules_cache_path()).ok()?; serde_json::from_str(&json).ok() diff --git a/src/linux/bridge.rs b/src/linux/bridge.rs new file mode 100644 index 0000000..e5237e8 --- /dev/null +++ b/src/linux/bridge.rs @@ -0,0 +1,236 @@ +//! Adapts a Linux backend's [`super::Report`] to the shape the shared UI +//! renders, so the same table, filters, drawer and CSV export serve both +//! platforms. +//! +//! It mirrors the handful of `pipeline` entry points the UI worker calls, so +//! `ui.rs` picks a module at compile time rather than branching on the OS at +//! every call site. +//! +//! One mapping deserves stating plainly. Windows ingests *events*: a rule +//! with no hits was measured and found idle. Linux reads *counters*, and a +//! rule the backend could not count has no hits for an entirely different +//! reason. Both would arrive at the UI as `usage: None`, so +//! [`crate::ui::RuleRow::hits_known`] carries the distinction through — +//! without it, every unmeasurable rule would appear in the zero-hit filter, +//! which is the list the user works through deleting things. + +use anyhow::Result; +use std::path::Path; + +use crate::pipeline::AnalysisResult; +use crate::ui::{self, AuditContext, RuleRow}; + +/// Is collection already running? For a backend the kernel counts for free +/// this is always true; for one that needs instrumenting it asks whether the +/// instrumentation is actually in place. +pub fn audit_enabled() -> Result { + let Some(backend) = super::detect()? else { + return Ok(false); + }; + Ok(!backend.needs_instrumentation() || super::collection_active(backend)) +} + +/// No cached fast path on Linux: reading counters *is* the fast path. The +/// UI treats `None` as "nothing to paint yet" and waits for `analyze`. +pub fn quick_cached_result(_db_path: &Path) -> Option { + None +} + +/// The rule table with no usage data — the first-run screen, before the +/// user has opted into collection. +pub fn rules_only(progress: &dyn Fn(&str)) -> Result { + let backend = require_backend()?; + progress("Reading firewall rules…"); + let (report, _) = super::analyze(backend, &super::PriorState::default())?; + Ok(to_result(backend, report, false)) +} + +/// A full run: read counters, fold them into the running totals, persist. +pub fn analyze(db_path: &Path, progress: &dyn Fn(&str)) -> Result { + let backend = require_backend()?; + progress(&format!("Reading {} rules…", backend.label())); + let store = crate::store::Store::open(db_path)?; + let prior = store.load_counter_state()?; + progress("Reading rule counters…"); + let (report, next) = super::analyze(backend, &prior)?; + store.save_counter_state(&next)?; + Ok(to_result(backend, report, true)) +} + +/// Start collecting — installs whatever the backend needs. +pub fn enable_collection(db_path: &Path, progress: &dyn Fn(&str)) -> Result<()> { + let backend = require_backend()?; + progress("Enabling collection…"); + let message = super::enable_collection(backend, db_path)?; + progress(&message); + Ok(()) +} + +fn require_backend() -> Result { + super::detect()?.ok_or_else(|| { + anyhow::anyhow!( + "no supported Linux firewall backend is active (Firebreak supports ufw, \ + firewalld and raw nftables)" + ) + }) +} + +/// Fold a backend report into the shared result type. +fn to_result(backend: super::Backend, report: super::Report, collecting: bool) -> AnalysisResult { + let measured: i64 = report.rows.iter().filter_map(|r| r.hits).sum(); + let unmeasurable = report.unmeasurable.len() as u64; + + let mut note = report.note.clone().unwrap_or_default(); + if unmeasurable > 0 { + note = format!( + "{unmeasurable} rule(s) could not be counted and are listed under \ + Not measurable — they are active, not unused. {note}" + ); + } + + let rows = report.rows.into_iter().map(row_from).collect::>(); + let unmatched = report + .unmeasurable + .into_iter() + .map(|(name, why)| crate::pipeline::UnmatchedRow { + filter_name: format!("{name} — {why}"), + usage: crate::model::RuleUsage::default(), + }) + .collect(); + + AnalysisResult { + rows, + ctx: AuditContext { + hostname: format!("{} ({})", crate::pipeline::hostname(), backend.label()), + auditing_active: collecting, + collection_started: None, + last_ingest: Some(crate::pipeline::now_iso()), + // Counters are totals, not a per-run event stream: this is + // everything counted so far, not what this run ingested. + events_processed: measured.max(0) as u64, + unmatched_events: unmeasurable, + note: note.trim().to_string(), + }, + unmatched, + listeners: super::proc::enumerate_listeners(), + } +} + +fn row_from(row: super::RuleUsageRow) -> RuleRow { + let hits_known = row.hits.is_some(); + // A counter counts packets the rule matched; whether that is traffic + // allowed or traffic blocked is the rule's own verdict. + let blocks = row.rule.action.eq_ignore_ascii_case("block"); + let usage = row.hits.map(|hits| crate::model::RuleUsage { + rule_id: row.rule.name.clone(), + allow_count: if blocks { 0 } else { hits }, + block_count: if blocks { hits } else { 0 }, + // Counters carry no timestamps: we know a rule was matched, never + // when. Left empty rather than invented. + first_seen: None, + last_seen: None, + apps: Vec::new(), + distinct_peers: 0, + by_profile: Vec::new(), + }); + let target_scopes = crate::model::ScopeSet::from_rule(&row.rule, crate::model::vocabulary()); + RuleRow { + flags: crate::baseline_checks::flags_for(&row.rule), + target_enabled: row.rule.is_enabled(), + target_scopes, + seen_apps: Vec::new(), + listening: row.listening, + reviewed: ui::ReviewState::No, + rule: row.rule, + usage, + hits_known, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn row(name: &str, action: &str, hits: Option) -> super::super::RuleUsageRow { + super::super::RuleUsageRow { + listening: Vec::new(), + hits, + rule: crate::model::RuleInfo { + name: name.into(), + display_name: name.into(), + description: None, + enabled: "True".into(), + direction: "Inbound".into(), + action: action.into(), + profile: "Any".into(), + group: None, + program: None, + protocol: None, + local_port: None, + remote_port: None, + service: None, + remote_address: None, + }, + } + } + + #[test] + fn an_uncounted_rule_is_not_a_zero_hit_rule() { + // The whole reason hits_known exists. Both of these reach the UI + // with usage: None; only one of them is a disable candidate. + let unknown = row_from(row("a", "Allow", None)); + let idle = row_from(row("b", "Allow", Some(0))); + assert!(!unknown.hits_known); + assert!(idle.hits_known); + assert_eq!(unknown.total_hits(), 0); + assert_eq!(idle.total_hits(), 0); + } + + #[test] + fn a_blocking_rules_counter_is_blocked_traffic() { + let allow = row_from(row("a", "Allow", Some(7))); + let block = row_from(row("b", "Block", Some(7))); + assert_eq!(allow.usage.as_ref().unwrap().allow_count, 7); + assert_eq!(allow.usage.as_ref().unwrap().block_count, 0); + assert_eq!(block.usage.as_ref().unwrap().block_count, 7); + assert_eq!(block.usage.as_ref().unwrap().allow_count, 0); + } + + #[test] + fn counters_carry_no_timestamps_so_none_are_invented() { + let r = row_from(row("a", "Allow", Some(3))); + let u = r.usage.unwrap(); + assert_eq!(u.first_seen, None); + assert_eq!(u.last_seen, None); + } + + #[test] + fn unmeasurable_rules_are_surfaced_in_the_report_context() { + let report = super::super::Report { + rows: vec![row("a", "Allow", Some(1)), row("b", "Allow", None)], + note: None, + unmeasurable: vec![("b".into(), "no counter".into())], + }; + let result = to_result(super::super::Backend::Ufw, report, true); + assert_eq!(result.ctx.unmatched_events, 1); + assert!( + result.ctx.note.contains("not unused"), + "{}", + result.ctx.note + ); + assert_eq!(result.unmatched.len(), 1); + assert!(result.unmatched[0].filter_name.contains("no counter")); + } + + #[test] + fn totals_reflect_everything_counted_so_far() { + let report = super::super::Report { + rows: vec![row("a", "Allow", Some(4)), row("b", "Block", Some(6))], + note: None, + unmeasurable: vec![], + }; + let result = to_result(super::super::Backend::Ufw, report, true); + assert_eq!(result.ctx.events_processed, 10); + assert_eq!(result.rows.len(), 2); + } +} diff --git a/src/linux/mod.rs b/src/linux/mod.rs index 4a652b0..1a9d68a 100644 --- a/src/linux/mod.rs +++ b/src/linux/mod.rs @@ -17,6 +17,7 @@ //! there is no collection clock to start and no waiting period before the //! first useful answer. +pub mod bridge; pub mod counters; pub mod firewalld; pub mod nftables; @@ -174,6 +175,20 @@ pub fn analyze(backend: Backend, prior: &PriorState) -> Result<(Report, PriorSta } } +/// Is this backend's instrumentation currently in place? Meaningless for a +/// backend that needs none, which reports true. +pub fn collection_active(backend: Backend) -> bool { + match backend { + Backend::Ufw => true, + Backend::Firewalld => firewalld::table_exists(), + // Partial by nature: some rules may carry counters the admin wrote. + // "Active" here means Firebreak has something to read at all. + Backend::Nftables => nftables::read_rules() + .map(|rules| rules.iter().any(|r| r.counter.is_some())) + .unwrap_or(false), + } +} + /// Start collecting on a backend that needs instrumentation. No-op where the /// kernel already counts. pub fn enable_collection(backend: Backend, db_path: &std::path::Path) -> Result { diff --git a/src/main.rs b/src/main.rs index c19d103..7307044 100644 --- a/src/main.rs +++ b/src/main.rs @@ -226,11 +226,19 @@ fn run_linux(args: &Args, backend: linux::Backend) -> Result<()> { 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)?; - print_linux_report(backend, &report); - Ok(()) + if args.no_ui { + let prior = store.load_counter_state()?; + let (report, next) = linux::analyze(backend, &prior)?; + store.save_counter_state(&next)?; + print_linux_report(backend, &report); + return Ok(()); + } + + // Default, as on Windows: boot straight to the window. The rule table, + // filters, drawer and CSV export are the same ones — only the evidence + // behind them differs. + drop(store); + ui::run_live(args.db_path.clone()) } fn run_windows(args: Args) -> Result<()> { diff --git a/src/pipeline.rs b/src/pipeline.rs index 2a6ef94..cb125ee 100644 --- a/src/pipeline.rs +++ b/src/pipeline.rs @@ -453,6 +453,9 @@ fn build_rows( target_enabled, target_scopes, reviewed: review, + // Windows ingests events: every rule's traffic is measured, + // even when the answer is none. + hits_known: true, } }) .collect() @@ -484,6 +487,10 @@ fn build_unmatched(store: &Store) -> Result> { /// Instant startup: build a result from the cached rule set + whatever the /// store already holds, without the (slow) live rule enumeration. Returns /// None if there's no cache yet. A full analyze() refresh follows. +/// +/// Windows-only: on Linux the UI reads counters through `linux::bridge`, +/// where reading them *is* the fast path and there is nothing to cache. +#[cfg(not(target_os = "linux"))] pub fn quick_cached_result(db_path: &Path) -> Option { let rules = firewall_rules::load_rules_cache()?; let store = Store::open(db_path).ok()?; @@ -510,6 +517,7 @@ pub fn quick_cached_result(db_path: &Path) -> Option { /// The rule table without any usage data — for the first-run screen before /// auditing is enabled (rules + scope + current listeners are still useful). +#[cfg(not(target_os = "linux"))] pub fn rules_only(progress: &dyn Fn(&str)) -> Result { progress("Enumerating firewall rules…"); let rules = firewall_rules::enumerate_rules().context("enumerating firewall rules")?; diff --git a/src/preview.rs b/src/preview.rs index 9104b96..21d92f7 100644 --- a/src/preview.rs +++ b/src/preview.rs @@ -416,6 +416,7 @@ pub fn run() -> Result<()> { target_enabled, target_scopes, reviewed, + hits_known: true, } }) .collect(); diff --git a/src/ui.rs b/src/ui.rs index 69594e2..0f135e9 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -10,7 +10,17 @@ use std::sync::mpsc::{Receiver, TryRecvError}; use crate::listeners::Listener; use crate::model::{BaselineFlag, RuleInfo, RuleUsage}; -use crate::pipeline::{self, AnalysisResult, UnmatchedRow}; +#[cfg(not(target_os = "linux"))] +use crate::pipeline; +use crate::pipeline::{AnalysisResult, UnmatchedRow}; + +/// Where the UI worker gets its data. Windows ingests audit events through +/// `pipeline`; Linux reads rule counters through `linux::bridge`. Both expose +/// the same four entry points, so the worker below is written once. +#[cfg(target_os = "linux")] +use crate::linux::bridge as backend; +#[cfg(not(target_os = "linux"))] +use crate::pipeline as backend; use crate::theme::{self as t}; use crate::{firewall_rules, time_util}; @@ -28,6 +38,11 @@ pub struct RuleRow { /// intended scope (edited via the scope chips) pub target_scopes: crate::model::ScopeSet, pub reviewed: ReviewState, + /// Whether this rule's traffic was actually measured. False means the + /// backend could not count it — a firewalld rich rule, an nft rule with + /// no counter. It must never render or filter as "zero hits", because + /// zero invites deleting the rule and unknown does not. + pub hits_known: bool, } /// User attestation state for a rule. `Stale` = it was reviewed, but the @@ -59,8 +74,10 @@ impl RuleRow { .map(|u| u.allow_count + u.block_count) .unwrap_or(0) } + /// A genuine disable candidate: measured, and never matched. A rule + /// nobody counted is not zero-hit, it is unknown. fn is_zero_hit(&self) -> bool { - self.total_hits() == 0 + self.hits_known && self.total_hits() == 0 } fn orig_scopes(&self) -> crate::model::ScopeSet { crate::model::ScopeSet::from_rule(&self.rule, crate::model::vocabulary()) @@ -521,8 +538,12 @@ impl App { }; // audit state first — cheap, and lets the header settle before // the slower rule enumeration - progress("Checking Windows audit policy…"); - let enabled = match pipeline::audit_enabled() { + progress(if cfg!(target_os = "linux") { + "Detecting firewall backend…" + } else { + "Checking Windows audit policy…" + }); + let enabled = match backend::audit_enabled() { Ok(b) => b, Err(e) => { let _ = tx.send(WorkerMsg::Failed(format!("{e:#}"))); @@ -535,17 +556,17 @@ impl App { if enabled { // instant paint from cached rules while the live query runs - if let Some(prelim) = pipeline::quick_cached_result(&db_path) { + if let Some(prelim) = backend::quick_cached_result(&db_path) { let _ = tx.send(WorkerMsg::Preliminary(Box::new(prelim))); egui_ctx.request_repaint(); } - let msg = match pipeline::analyze(&db_path, &progress) { + let msg = match backend::analyze(&db_path, &progress) { Ok(r) => WorkerMsg::Ready(Box::new(r)), Err(e) => WorkerMsg::Failed(format!("{e:#}")), }; let _ = tx.send(msg); } else { - let msg = match pipeline::rules_only(&progress) { + let msg = match backend::rules_only(&progress) { Ok(r) => WorkerMsg::NeedsEnable(Box::new(r)), Err(e) => WorkerMsg::Failed(format!("{e:#}")), }; @@ -770,8 +791,8 @@ impl App { ctx.request_repaint(); } }; - let msg = match pipeline::enable_collection(&db_path, &progress) - .and_then(|()| pipeline::analyze(&db_path, &progress)) + let msg = match backend::enable_collection(&db_path, &progress) + .and_then(|()| backend::analyze(&db_path, &progress)) { Ok(r) => WorkerMsg::Ready(Box::new(r)), Err(e) => WorkerMsg::Failed(format!("{e:#}")), @@ -914,6 +935,18 @@ impl App { } fn start_apply(&mut self, egui_ctx: &egui::Context) { + // Applying goes through PowerShell's Set-NetFirewallRule, which has + // no Linux counterpart. Changing a ufw or firewalld rule means + // deleting and recreating it — a different operation with a + // different blast radius, and one that has not been built. Refuse + // loudly rather than run a no-op that looks like it worked. + if cfg!(target_os = "linux") { + self.status = "Applying rule changes isn't supported on Linux yet — Firebreak is \ + read-only here. Change rules with ufw/firewall-cmd/nft directly." + .into(); + self.confirm_open = false; + return; + } let plan = self.planned_changes(); if plan.is_empty() { return; @@ -1252,15 +1285,40 @@ pub fn run_preview( } // small helpers shared with the paint module -pub(crate) fn profile_chip(tag: &str) -> (&'static str, Color32, Color32, Color32) { + +/// Colours and short label for a scope chip. +/// +/// The three Windows profiles have fixed abbreviations and colours. Anything +/// else is a backend-supplied scope — a firewalld zone — and gets the neutral +/// chip with its *own* name. It must not fall back to the "ANY" chip: ANY +/// means "every scope", so labelling a single zone that way tells the reader +/// the opposite of the truth. +pub(crate) fn profile_chip(tag: &str) -> (String, Color32, Color32, Color32) { + let owned = |(l, a, b, c): (&'static str, Color32, Color32, Color32)| (l.to_string(), a, b, c); match tag { - "Domain" => t::CHIP_DOM(), - "Private" => t::CHIP_PRV(), - "Public" => t::CHIP_PUB(), - _ => t::CHIP_ANY(), + "Domain" => owned(t::CHIP_DOM()), + "Private" => owned(t::CHIP_PRV()), + "Public" => owned(t::CHIP_PUB()), + "Any" => owned(t::CHIP_ANY()), + zone => { + let (_, fg, bg, border) = t::CHIP_ANY(); + (abbreviate_scope(zone), fg, bg, border) + } } } +/// Zone names are user-chosen and can be long; the chip is small. Keep it +/// recognisable rather than complete — the full name is in the rule's own +/// display name and in the scope filter row. +fn abbreviate_scope(zone: &str) -> String { + const MAX: usize = 10; + let upper: String = zone.to_uppercase(); + if upper.chars().count() <= MAX { + return upper; + } + upper.chars().take(MAX - 1).collect::() + "\u{2026}" +} + pub(crate) use helpers::*; mod helpers { use super::*; @@ -1303,13 +1361,7 @@ mod helpers { editable: bool, id_src: (usize, u8), ) -> (f32, Option) { - let (label, fg, bg, border) = profile_chip(match tag { - "Domain" => "Domain", - "Private" => "Private", - "Public" => "Public", - _ => "Any", - }); - let short = &label; // DOM/PRV/PUB from profile_chip + let (short, fg, bg, border) = profile_chip(tag); let font = t::semibold(9.5); let (fg, bg, border) = if kept { (fg, bg, border) diff --git a/src/ui/paint.rs b/src/ui/paint.rs index 135c60a..f1bb5db 100644 --- a/src/ui/paint.rs +++ b/src/ui/paint.rs @@ -407,7 +407,7 @@ fn header(app: &mut App, ctx: &egui::Context) { .as_deref() .map(time_util::since_with_age) .unwrap_or_else(|| "just now".into()); - stat(ui, "Auditing active", &format!("Since {since}")); + stat(ui, collecting_label(), &format!("Since {since}")); ui.add_space(12.0); // Stop control — disables auditing (and returns to the // first-run view, handy for testing that state too) @@ -418,8 +418,12 @@ fn header(app: &mut App, ctx: &egui::Context) { } else { stat( ui, - "Auditing is off", - "No connection data has ever been collected", + if cfg!(target_os = "linux") { + "Not counting yet" + } else { + "Auditing is off" + }, + "No usage data has ever been collected", ); } @@ -429,14 +433,15 @@ fn header(app: &mut App, ctx: &egui::Context) { .ctx_info .last_ingest .as_deref() - .map(|s| format!("This run · Last Ingest {}", time_util::relative(s))) - .unwrap_or_else(|| "This run".into()); + .map(|s| format!("{} · Read {}", measured_scope(), time_util::relative(s))) + .unwrap_or_else(|| measured_scope().to_string()); let events = if app.phase == Phase::Loading || app.phase == Phase::Enabling { - format!("Ingesting… {}", app.progress) + format!("Reading… {}", app.progress) } else { format!( - "{} events", - t::fmt_thousands(app.ctx_info.events_processed as i64) + "{} {}", + t::fmt_thousands(app.ctx_info.events_processed as i64), + measured_noun() ) }; stat(ui, &events, &last); @@ -445,7 +450,14 @@ fn header(app: &mut App, ctx: &egui::Context) { let gap = !app.ctx_info.note.is_empty(); let young = app.young_evidence_hours().is_some(); let value = if gap { - "Coverage gap" + // On Windows a note means the event log has a hole in + // it. On Linux it is a caveat about how counting + // works, not a hole in the evidence. + if cfg!(target_os = "linux") { + "Read the caveat" + } else { + "Coverage gap" + } } else { "Coverage complete" }; @@ -1097,18 +1109,41 @@ fn fmt(font: egui::FontId, color: Color32) -> egui::text::TextFormat { fn firstrun_band(app: &mut App, ctx: &egui::Context) { egui::TopBottomPanel::top("firstrun") - .frame(egui::Frame::none().fill(t::ACCENT_TINT()).inner_margin(egui::Margin::symmetric(PAGE, 12.0))) + .frame( + egui::Frame::none() + .fill(t::ACCENT_TINT()) + .inner_margin(egui::Margin::symmetric(PAGE, 12.0)), + ) .show(ctx, |ui| { - super::stroke_bottom(ui.painter(), ui.max_rect().expand2(Vec2::new(PAGE, 12.0)), t::ACCENT_TINT_BORDER()); + super::stroke_bottom( + ui.painter(), + ui.max_rect().expand2(Vec2::new(PAGE, 12.0)), + t::ACCENT_TINT_BORDER(), + ); ui.horizontal(|ui| { let enabling = app.phase == Phase::Enabling; - let label = if enabling { "Enabling…" } else { "Enable connection auditing" }; - let galley = ui.painter().layout_no_wrap(label.to_string(), t::semibold(13.0), Color32::WHITE); + let label = if enabling { + "Enabling…" + } else { + enable_button_label() + }; + let galley = ui.painter().layout_no_wrap( + label.to_string(), + t::semibold(13.0), + Color32::WHITE, + ); let size = Vec2::new(galley.size().x + 40.0, galley.size().y + 16.0); let (rect, resp) = ui.allocate_exact_size(size, Sense::click()); - let fill = if enabling { t::ACCENT().gamma_multiply(0.6) } else if resp.hovered() { t::ACCENT().gamma_multiply(1.1) } else { t::ACCENT() }; + let fill = if enabling { + t::ACCENT().gamma_multiply(0.6) + } else if resp.hovered() { + t::ACCENT().gamma_multiply(1.1) + } else { + t::ACCENT() + }; ui.painter().rect_filled(rect, 0.0, fill); - ui.painter().galley(rect.center() - galley.size() / 2.0, galley, Color32::WHITE); + ui.painter() + .galley(rect.center() - galley.size() / 2.0, galley, Color32::WHITE); if resp.clicked() && !enabling { app.start_enable(ctx); } @@ -1116,23 +1151,85 @@ fn firstrun_band(app: &mut App, ctx: &egui::Context) { // constrain the explainer to the space left of the buttons so // it wraps instead of running off the window let w = (ui.available_width() - 8.0).max(120.0); - ui.allocate_ui_with_layout(Vec2::new(w, 0.0), egui::Layout::top_down(egui::Align::Min), |ui| { - let mut job = egui::text::LayoutJob::default(); - job.wrap.max_width = w; - job.append( - "Turns on Windows Filtering Platform audit events (security log, ~40 MB/day at typical load). ", - 0.0, fmt(t::sans(12.0), t::INK())); - job.append("Nothing is blocked or modified", 0.0, fmt(t::semibold(12.0), t::INK())); - job.append( - " — firebreak only records which rules the traffic matches. Usage columns fill in as evidence \ - accumulates; plan on ~7–14 days before zero-hit values mean anything.", - 0.0, fmt(t::sans(12.0), t::INK())); - ui.label(job); - }); + ui.allocate_ui_with_layout( + Vec2::new(w, 0.0), + egui::Layout::top_down(egui::Align::Min), + |ui| { + let mut job = egui::text::LayoutJob::default(); + job.wrap.max_width = w; + let (before, after) = enable_explainer(); + job.append(before, 0.0, fmt(t::sans(12.0), t::INK())); + job.append( + "Nothing is blocked or modified", + 0.0, + fmt(t::semibold(12.0), t::INK()), + ); + job.append(after, 0.0, fmt(t::sans(12.0), t::INK())); + ui.label(job); + }, + ); }); }); } +/// Header wording for an active collection. +fn collecting_label() -> &'static str { + if cfg!(target_os = "linux") { + "Counting active" + } else { + "Auditing active" + } +} + +/// Windows counts events ingested *this run*; Linux counters are cumulative +/// totals, so calling them "this run" would understate months of evidence. +fn measured_scope() -> &'static str { + if cfg!(target_os = "linux") { + "Total so far" + } else { + "This run" + } +} + +fn measured_noun() -> &'static str { + if cfg!(target_os = "linux") { + "packets" + } else { + "events" + } +} + +/// What "start collecting" is called, per platform. Windows turns on an +/// audit policy; Linux installs counters. +fn enable_button_label() -> &'static str { + if cfg!(target_os = "linux") { + "Start counting rule usage" + } else { + "Enable connection auditing" + } +} + +/// The two halves of the enable explainer, either side of the bolded +/// reassurance. Both platforms make the same promise — Firebreak observes, +/// it does not enforce — but by different means, and the Windows wording +/// (WFP, security log, MB/day) is simply untrue on Linux. +fn enable_explainer() -> (&'static str, &'static str) { + if cfg!(target_os = "linux") { + ( + "Adds packet counters so the kernel records which rules traffic matches. ", + " — no rule's verdict changes, and no packet is dropped that would not have been. \ + Counts fill in as traffic arrives, and reset on reboot or a firewall reload \ + (earlier totals are kept).", + ) + } else { + ( + "Turns on Windows Filtering Platform audit events (security log, ~40 MB/day at typical load). ", + " — firebreak only records which rules the traffic matches. Usage columns fill in as evidence \ + accumulates; plan on ~7–14 days before zero-hit values mean anything.", + ) + } +} + // ---- filter bar ---- const CTRL_H: f32 = 25.0; // shared height for all filter-bar controls @@ -1961,8 +2058,14 @@ fn row(app: &mut App, ui: &mut egui::Ui, ri: usize, rect: Rect, cols: &Cols, res } // last seen + // "never" is only true when we *watched* and saw nothing. A packet + // counter has no timestamps at all, so a rule with 143 hits and no + // last-seen must not be labelled never — it flatly contradicts its + // own hit count. let (last_txt, last_col) = match r.usage.as_ref().and_then(|u| u.last_seen.as_deref()) { Some(ls) => (time_util::relative(ls), t::SECONDARY()), + None if r.total_hits() > 0 => ("not recorded".to_string(), t::DISABLED()), + None if !r.hits_known => ("—".to_string(), t::DISABLED()), None => ("never".to_string(), t::DISABLED()), }; cell_text( From c96f367c75124467cfb80207329b9507136000e2 Mon Sep 17 00:00:00 2001 From: ghostpsalm Date: Mon, 10 Aug 2026 08:06:25 +1000 Subject: [PATCH 2/8] Linux Apply: disable and scope changes across all three backends MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes parity with the Windows Apply flow. The button, the confirm dialog, the per-rule progress and the backup-first discipline are the same; what runs underneath is not. The mismatch worth stating plainly: Windows sets a flag the rule survives. firewalld removes a service or port from a zone and can add it back. ufw and nftables have no per-rule off switch at all, so disabling DELETES the rule. `Reversibility` carries that into the confirm dialog, because a dialog that says "disable" over a deletion is how someone loses a rule they meant to keep. Every Apply writes a full config snapshot first and re-reads the rule set afterwards to confirm the target actually went. Scope edits are firewalld-only, since it is the only backend with zones. Emptying a rule's zones is refused rather than silently performed — that is a deletion wearing an edit's clothing — and zones are added before the old one is removed, so a failure leaves the rule live rather than gone from everywhere. Every command was run against a live backend rather than derived from the manual, which caught a real bug: `ufw delete allow port 8080 proto tcp` is rejected outright ("Need 'to' or 'from' clause"). The unit test had asserted my own wrong reconstruction. The correct form always carries `to any`, and ufw removes a rule's v4 and v6 twins together — both noted in the code. Verified: nftables handle deletion removes exactly the target; firewalld --remove-service/--remove-port confirmed against the live daemon on an inactive zone, restored exactly afterwards; the active zone never touched. --- CLAUDE.md | 10 +- src/firewall_rules.rs | 7 + src/linux/apply.rs | 574 ++++++++++++++++++++++++++++++++++++++++++ src/linux/mod.rs | 1 + src/linux/ufw.rs | 2 +- src/ui.rs | 89 +++++-- src/ui/paint.rs | 41 ++- 7 files changed, 699 insertions(+), 25 deletions(-) create mode 100644 src/linux/apply.rs diff --git a/CLAUDE.md b/CLAUDE.md index 919581d..dd6fa54 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -56,7 +56,15 @@ Four things to know before touching them: unparseable tuples and counter-less nft rules are reported in their own section. Folding them into the zero-hit list would invite deleting a load-bearing rule. -- **Raw nftables is the only backend that edits the user's rules.** Adding a +- **"Disable" does not mean the same thing on every backend.** Windows sets + a flag the rule survives; firewalld removes a service/port from a zone and + can add it back; **ufw and nftables have no off switch at all, so disabling + deletes the rule.** `linux::apply::Reversibility` carries that to the + confirm dialog — a dialog saying "disable" over a deletion is how someone + loses a rule they meant to keep. Apply always writes a full config backup + first and re-reads afterwards to confirm the rule actually went. +- **Raw nftables is the only backend that edits the user's rules** for + *collection*. Adding a counter takes the kernel's own JSON expression and inserts `{"counter": null}` before the verdict — never re-derived from text — after a full ruleset backup, and every touched rule is re-read and verified to be diff --git a/src/firewall_rules.rs b/src/firewall_rules.rs index 99ff790..5f9f676 100644 --- a/src/firewall_rules.rs +++ b/src/firewall_rules.rs @@ -6,6 +6,7 @@ use anyhow::{bail, Context, Result}; use base64::Engine; +#[cfg(not(target_os = "linux"))] use chrono::Utc; use std::path::{Path, PathBuf}; @@ -142,6 +143,7 @@ pub fn load_rules_cache() -> Option> { } /// Directory where backups land: %ProgramData%\firebreak\backups +#[cfg(not(target_os = "linux"))] pub fn backup_dir() -> PathBuf { let base = std::env::var("ProgramData").unwrap_or_else(|_| r"C:\ProgramData".into()); Path::new(&base).join("firebreak").join("backups") @@ -150,6 +152,7 @@ pub fn backup_dir() -> PathBuf { /// Export the full firewall policy before any mutation. Produces a /// restorable .wfw (netsh advfirewall import) plus a JSON rule dump for /// human-readable diffing. Returns the .wfw path. +#[cfg(not(target_os = "linux"))] pub fn backup_policy(rules: &[RuleInfo]) -> Result { let dir = backup_dir(); crate::secure_dir::ensure_secured_dir(&dir)?; @@ -175,10 +178,12 @@ pub fn backup_policy(rules: &[RuleInfo]) -> Result { /// Names per Set-NetFirewallRule invocation: keeps the -EncodedCommand /// well under the 32,767-char Windows command-line limit even with long /// InstanceIDs, so a big batch can't fail wholesale after confirmation. +#[cfg(not(target_os = "linux"))] const RULES_PER_INVOCATION: usize = 100; /// Enable/disable a single rule by unique Name (InstanceID) — the apply /// worker goes rule-by-rule so progress and per-rule failures are exact. +#[cfg(not(target_os = "linux"))] pub fn set_rule_enabled(rule_name: &str, enabled: bool) -> Result<()> { set_rules_enabled(std::slice::from_ref(&rule_name.to_string()), enabled) } @@ -187,6 +192,7 @@ pub fn set_rule_enabled(rule_name: &str, enabled: bool) -> Result<()> { /// to turn it off for Public. The rule is left enabled (you keep it active /// on the remaining profiles). `profile_arg` is a comma-separated set or /// "Any". Backup first — the UI's Apply flow does. +#[cfg(not(target_os = "linux"))] pub fn set_rule_profiles(rule_name: &str, profile_arg: &str) -> Result<()> { let name = rule_name.replace('\'', "''"); // profile_arg is a controlled token set (Any / Domain,Private,Public @@ -214,6 +220,7 @@ Set-NetFirewallRule -Name '{name}' -Profile {prof} -Enabled True /// Enable/disable rules by unique Name (InstanceID). Backup first — this /// module doesn't do it for you; the UI's Apply flow does. On error, /// reports how many rules had already been applied. +#[cfg(not(target_os = "linux"))] pub fn set_rules_enabled(rule_names: &[String], enabled: bool) -> Result<()> { let value = if enabled { "True" } else { "False" }; let mut applied = 0usize; diff --git a/src/linux/apply.rs b/src/linux/apply.rs new file mode 100644 index 0000000..44c9280 --- /dev/null +++ b/src/linux/apply.rs @@ -0,0 +1,574 @@ +//! Changing firewall rules on Linux. +//! +//! Everything else in `linux/` reads. This module writes, and the three +//! backends do not agree on what "disable a rule" even means: +//! +//! | backend | disable is | reversible by re-enabling? | +//! |---|---|---| +//! | firewalld | `--remove-service` / `--remove-port` | yes — re-add it | +//! | ufw | `ufw delete` | **no — the rule is gone** | +//! | nftables | `nft delete rule` | **no — the rule is gone** | +//! +//! Windows' disable is a flag on a rule that survives being switched off. +//! ufw and nftables have no such flag: the only way to stop a rule matching +//! is to remove it. That difference has to reach the user *before* they +//! confirm, which is what [`Reversibility`] is for — a confirm dialog that +//! says "disable" over an operation that deletes is how someone loses a rule +//! they meant to keep. +//! +//! Two rules hold throughout: +//! +//! * **Back up first.** Every backend can produce a restorable snapshot of +//! its whole configuration, and Apply writes one before touching anything. +//! * **Verify, don't trust.** After a change, the rule set is re-read and +//! checked to be exactly what was intended — the target gone, everything +//! else untouched. A reconstruction bug that removed the wrong rule would +//! otherwise be silent. +//! +//! Nothing here goes through a shell. Arguments are passed as argv and every +//! value that reaches one is validated first. + +use anyhow::{bail, Context, Result}; +use std::path::{Path, PathBuf}; + +use super::Backend; + +/// Whether switching a rule off can be undone by switching it back on. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Reversibility { + /// The rule can be put back exactly as it was. + Reversible, + /// The rule is deleted. Restoring it means restoring the backup. + Destructive, +} + +impl Backend { + /// What disabling a rule actually does here. + pub fn disable_semantics(self) -> Reversibility { + match self { + // firewalld config is declarative: removing a service from a + // zone and adding it back gives the same rule. + Backend::Firewalld => Reversibility::Reversible, + // Neither has a per-rule "off" flag; removal is the only way. + Backend::Ufw | Backend::Nftables => Reversibility::Destructive, + } + } + + /// Whether a rule's scope can be edited at all. Only firewalld has + /// zones; for the others the scope chips have nothing to move between. + pub fn scope_is_editable(self) -> bool { + matches!(self, Backend::Firewalld) + } + + /// Sentence shown above the confirm dialog, so the word on the button + /// matches what the host will actually do. + pub fn apply_warning(self) -> &'static str { + match self.disable_semantics() { + Reversibility::Reversible => { + "Disabled rules are removed from their zone and can be added back." + } + Reversibility::Destructive => { + "This backend has no per-rule off switch: disabling DELETES the rule. \ + Restoring it means restoring the backup Firebreak writes first." + } + } + } +} + +// --------------------------------------------------------------------------- +// Backup +// --------------------------------------------------------------------------- + +/// Snapshot the whole firewall configuration so any change can be undone. +/// Returns the file written. +pub fn backup(backend: Backend, db_path: &Path) -> Result { + let dir = db_path + .parent() + .unwrap_or_else(|| Path::new(".")) + .join("backups"); + crate::secure_dir::ensure_secured_dir(&dir)?; + let stamp = chrono::Utc::now().format("%Y%m%dT%H%M%SZ"); + let (name, body) = match backend { + Backend::Ufw => ( + format!("ufw-{stamp}.rules"), + read_ufw_config().context("snapshotting ufw rules")?, + ), + Backend::Firewalld => ( + format!("firewalld-{stamp}.txt"), + run("firewall-cmd", &["--list-all-zones"]).context("snapshotting firewalld zones")?, + ), + Backend::Nftables => ( + format!("nftables-{stamp}.nft"), + run("nft", &["list", "ruleset"]).context("snapshotting the nftables ruleset")?, + ), + }; + let path = dir.join(name); + std::fs::write(&path, body).with_context(|| format!("writing {}", path.display()))?; + Ok(path) +} + +fn read_ufw_config() -> Result { + let mut out = String::new(); + for family in [super::ufw::Family::V4, super::ufw::Family::V6] { + for candidate in family.rules_files() { + if let Ok(text) = std::fs::read_to_string(candidate) { + out.push_str(&format!("# ==== {candidate} ====\n{text}\n")); + break; + } + } + } + if out.is_empty() { + bail!("could not read ufw's rule files"); + } + Ok(out) +} + +// --------------------------------------------------------------------------- +// Rule identity -> command +// --------------------------------------------------------------------------- + +/// A firewalld rule id: `firewalld://