diff --git a/CLAUDE.md b/CLAUDE.md index b357f12..919581d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,37 +1,83 @@ # 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** | +| nftables | family/table/chain + expression digest | the rule's *own* counter | partly | + +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 + 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, + 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 +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 +111,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/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/elevation.rs b/src/elevation.rs index 83cc537..33d5676 100644 --- a/src/elevation.rs +++ b/src/elevation.rs @@ -28,7 +28,29 @@ 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 { + 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)) + .and_then(|euid| euid.parse().ok()) +} + +#[cfg(not(any(windows, target_os = "linux")))] pub fn is_elevated() -> bool { false } @@ -79,6 +101,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/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/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/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 new file mode 100644 index 0000000..4a652b0 --- /dev/null +++ b/src/linux/mod.rs @@ -0,0 +1,505 @@ +//! 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 firewalld; +pub mod nftables; +pub mod proc; +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, + Firewalld, + Nftables, +} + +impl Backend { + pub fn label(self) -> &'static str { + match self { + Backend::Ufw => "ufw", + Backend::Firewalld => "firewalld", + Backend::Nftables => "nftables", + } + } + + /// 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)" + } + Backend::Nftables => "each rule's own nftables counter — exact, nothing reconstructed", + } + } + + /// 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, + // 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, + // 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, + } + } + + /// 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(), + // 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(), + }, + // raw nftables has no zone or profile concept + Backend::Nftables => crate::model::ScopeVocabulary::none(), + } + } +} + +/// 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)); + } + } + 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) +} + +/// Everything one run learned about a rule. +#[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", + /// 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, + /// 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)>, +} + +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), + 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, 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)?; + 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, 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!( + "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 live_listeners = proc::enumerate_listeners(); + 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 { + let info = rule.to_rule_info(); + report.rows.push(RuleUsageRow { + listening: crate::listeners::listeners_for_rule(&info, &live_listeners), + 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 + } + } + }; + let info = rule.to_rule_info(); + report.rows.push(RuleUsageRow { + listening: crate::listeners::listeners_for_rule(&info, &live_listeners), + rule: info, + hits, + }); + } + report.note = Some(firewalld::REBOOT_CAVEAT.to_string()); + Ok((report, next)) +} + +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 { + 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 + } + }; + let info = rule.to_rule_info(); + report.rows.push(RuleUsageRow { + listening: crate::listeners::listeners_for_rule(&info, &live_listeners), + rule: info, + hits, + }); + } + 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::*; + + #[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 { + listening: Vec::new(), + 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))], + note: None, + 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/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/linux/proc.rs b/src/linux/proc.rs new file mode 100644 index 0000000..3ffaf5a --- /dev/null +++ b/src/linux/proc.rs @@ -0,0 +1,305 @@ +//! 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 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()); + } + } +} 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 c3abe85..c19d103 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,24 @@ 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: 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 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\ + 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 +172,70 @@ 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 and \ + firewalld; raw nftables is 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, 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, &args.db_path)?); + return Ok(()); + } + if args.restore_audit { + println!("{}", linux::stop_collection(backend, &args.db_path)?); + 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)?; + print_linux_report(backend, &report); + Ok(()) +} + +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(); @@ -253,39 +334,86 @@ fn dump_filters() -> Result<()> { Ok(()) } -#[cfg(test)] -mod tests { - use super::parse_args_from; +/// 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(), + backend.evidence_summary() + ); + if backend.needs_instrumentation() { + println!("Collection: opt-in (--enable-only), removable (--restore-audit)"); + } - fn parse(argv: &[&str]) -> super::Args { - parse_args_from(argv.iter().map(|s| (*s).to_string())) + 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, nothing listening ({}) — strongest disable candidates ===", + empty.len() + ); + for row in &empty { + println!( + " {} [{} {}]", + row.rule.display_name, row.rule.direction, row.rule.action + ); } - #[test] - fn collect_without_path_defaults() { - let a = parse(&["--collect"]); - assert_eq!(a.collect, Some(None)); + 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(", ") + ); + } } - #[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()))); + 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 { + let behind = if row.listening.is_empty() { + String::new() + } else { + format!(" <- {}", row.listening.join(", ")) + }; + println!( + " {:>12} packets {}{behind}", + row.hits.unwrap_or(0), + row.rule.display_name + ); } - #[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); + if let Some(note) = &report.note { + println!("\nNote: {note}"); } - #[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")); + 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}"); + } } } @@ -385,3 +513,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/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 a10226f..2a6ef94 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, @@ -270,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()?; @@ -428,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) { @@ -443,7 +451,7 @@ fn build_rows( seen_apps, listening, target_enabled, - target_profiles, + target_scopes, reviewed: review, } }) @@ -574,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 87a2b79..1a6bc15 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, @@ -87,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 @@ -119,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), @@ -241,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 } @@ -347,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"]); @@ -358,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", @@ -372,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", @@ -382,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"]); } @@ -396,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"]); } @@ -411,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", @@ -434,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", @@ -453,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 @@ -476,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 6518383..9b502e7 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 { @@ -103,6 +112,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 +225,82 @@ 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, + }) + } + + /// 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 + /// 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/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/syspath.rs b/src/syspath.rs index bcbf30a..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) @@ -23,12 +41,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..69594e2 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(), @@ -622,6 +615,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 +648,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()); @@ -746,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; @@ -854,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 { @@ -916,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()); } } @@ -1008,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()); } } } @@ -1045,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()) @@ -1073,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; } @@ -1284,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; @@ -1321,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, @@ -1332,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 { @@ -1344,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 6510828..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()), @@ -1187,12 +1190,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(); @@ -1240,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); @@ -1262,6 +1277,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; @@ -1432,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 @@ -1580,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); @@ -1588,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()), ); } @@ -1680,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, @@ -1723,7 +1750,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 +1827,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()); } } @@ -2004,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), @@ -2022,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() } }; @@ -2049,13 +2074,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() { @@ -2188,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 => {} @@ -2207,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, @@ -2266,9 +2291,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; + } } }); } @@ -2285,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() @@ -2518,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()), ); } @@ -2613,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() { @@ -2636,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(); @@ -2678,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()); @@ -2686,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, @@ -2819,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); @@ -2829,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]; @@ -2881,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(); @@ -2943,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()), ); } }); @@ -2971,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())); @@ -3027,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()), ); } }); @@ -3059,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); @@ -3320,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 @@ -3366,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| { 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()) }