Linux support: ufw, firewalld and raw nftables backends - #16
Merged
Conversation
The gate skipped native clippy because Windows-only code compiled on Linux read as dead. That is now stated as #[cfg(windows)] (or #[cfg(any(windows, test))] where the logic is portable and unit-tested) instead of being suppressed, so the native lint is signal again — and it is the only thing that will lint the Linux backends. Also fixes the two real warnings native clippy was hiding behind the noise: an unnecessary `mut` in syspath::command and items after the test module in main.rs.
ufw needs no instrumentation: iptables-nft counts every rule already, so the first run has a real answer with nothing enabled and no waiting period. Rule identity is ufw's own `### tuple ###` line, joined to live counters by chain position. Three things the counting has to get right, each verified against a real host rather than assumed: - A kernel counter is a gauge, not an event stream. Totals bank the old lifetime on reset (counter going backwards, or a changed boot-id/ruleset generation) instead of re-adding each raw reading. Confirmed live: 100 -> 150 -> reset -> 155 -> 170. - `limit` expands to three iptables rules over the *same* traffic while `proto any` expands to disjoint tcp and udp rules. Summing the first triples the count; maxing the second loses half the evidence. Entries are grouped by match signature: max within a group, sum across groups. - Anything unmeasurable is reported as unmeasurable, never folded into the zero-hit list. "We could not read this" must not read as "safe to delete". Two rule shapes found only by running it on a second distro: Fedora keeps the rule files under /var/lib/ufw, and its default install uses application profiles, whose two extra tuple fields shift the direction token. Both distros' rulesets are now golden fixtures. IPv6 binds to the ufw6-* chains, without which every v6 rule reads as unmeasurable.
ProfileSet was three bools named Domain/Private/Public — the Windows network-profile trichotomy, hardcoded into shared code the Linux backends have to pass through. firewalld has N user-defined zones and ufw has no scope concept at all, so the names had to become data. ScopeVocabulary declares what a backend divides rules into; ScopeSet is a rule's membership in it. The UI's scope chips, filter row and apply path all loop over the vocabulary rather than three fields, so an empty vocabulary (ufw) simply renders nothing and a zone list of any length works. Two behaviours that had to survive the change, and now have tests that say so: a rule whose scope will not parse expands to *every* scope rather than none — it is live somewhere, and treating it as empty would both hide it from the table and let an apply narrow it to nothing; and an empty selection is not "Any", so narrowing a rule to no scopes has no rule-text form and the caller must disable the rule instead. ScopeIndex::build and ScopeSet::from_rule take the vocabulary explicitly rather than reading a process-wide default. The default is per-platform, so a global made the Windows profile-gate tests pass or fail depending on which host ran them — they were passing on Linux for the wrong reason. Also gives Linux a real data directory: /var/lib/firebreak, created 0700 and accepted only when owned by the effective UID and closed to group and other. The non-Windows path was a bare create_dir_all marked "dev builds only", which is no longer true now that it holds the same usage database.
firewalld's nftables table carries `flags owner`, so the kernel refuses to let any process add a counter to it — sudo does not help — and firewalld emits no counters of its own. There is nothing to read, so Firebreak brings its own: a table in the input hook at priority 300, after firewalld's filter at priority 0, so a hit means "allowed via this rule" rather than "would have matched". Policy accept, counters only, no verdicts: it can count traffic but cannot change what happens to it. `ct state new` makes it per-connection, the same granularity as Windows event 5156. Verified on this host: a rule change reinstalls the table and the old readings are banked rather than lost (7 tcp survived the reinstall), and teardown leaves `nft list tables` with firewalld's table alone. Collecting means *writing* to the kernel firewall, which the Windows side never does. So it is opt-in on the same flags that gate audit policy there: --enable-only installs, --restore-audit removes, and a bare run reports that collection is off rather than quietly instrumenting the host. The parser bug the tests caught is the one that mattered: firewalld prints rich rules and forward-ports on tab-indented continuation lines under an otherwise-empty key, so reading only the inline value dropped every rich rule on the host — telling the user their firewall was simpler than it is. Rich rules, ipsets, protocols, icmp-blocks and forward-ports are now all reported as unmeasurable, never as zero-hit. Zones become the scope vocabulary, which is what the previous commit generalised for. Ports are validated as integers before reaching the nft ruleset, since it is handed over as text.
Answers "which processes are sitting behind the ports this rule opens" from the live listener set. That is inference, not the per-connection attribution Windows gets free from event 5156 — Linux has no event carrying rule and process identity together, and joining the two sources on a 5-tuple is a different, lossier job. For judging whether a rule is over-broad, which is what the tool is for, the listener set is often the more useful answer and costs one directory walk. The module doc states its limits so nobody reads more into a row than it says. The rule-matching itself is the shared listeners::listeners_for_rule the Windows path already uses, reached by returning the same Listener shape — the reuse the port was supposed to deliver, actually delivered. Never-matched rules now split by whether anything is listening. A rule with a live process behind it may simply be idle; one with nothing behind it is the stronger disable candidate. On this host the wide-open 1025-65535/tcp rule resolves to 14 processes including clickhouse, postgres and ollama. Two /proc details worth the tests: addresses are hex in host byte order, so 0100007F is 127.0.0.1 and not 1.0.0.127; and UDP has no LISTEN state, so filtering on one would hide every UDP service on the host. CLAUDE.md now describes both targets, the counter-gauge rule, the owner-lock and the four properties the firewalld shadow table has to keep.
The third backend, for hosts running neither ufw nor firewalld. Here the
firewall *is* the ruleset, and the evidence is each rule's own `counter` —
the only exact evidence in the tool. Nothing is reconstructed: where a
counter exists the kernel is counting that precise rule, not Firebreak's
guess at what it matches, which is what the firewalld shadow table has to
settle for.
Detection runs ufw -> firewalld -> nftables, and the order is load-bearing:
the first two are nftables underneath, so checking raw nftables first would
audit their generated rules instead of the vocabulary the user actually
wrote.
Rules that already carry a counter cost nothing to read. The rest are
reported as unmeasurable with an actionable reason — never as zero-hit,
since "nobody ever counted this" and "this is never used" are opposite
conclusions and only one justifies deleting a rule.
This is the only backend that edits the user's own rules, so the safety is
the design:
- The expression is never re-derived from text. It is the kernel's own JSON,
returned with one {"counter": null} inserted before the verdict, so match
semantics cannot drift. Verified live: anonymous sets, prefixes and
multi-match rules all survive the round trip untouched.
- The full ruleset is backed up to the secured data directory first.
- Every touched rule is re-read and checked to be its original expression
plus exactly one counter; anything else rolls the ruleset back. Tests
cover a changed match, a lost counter and a vanished rule.
- --restore-audit restores from the backup, which also preserves counters
the admin wrote themselves — we cannot tell ours from theirs, so we remove
none of them.
Identity is family/table/chain plus an expression digest, not the handle:
handles are renumbered on every reload, so using them would reset every
rule's total at each boot. Counter values are stripped from the digest so a
rule's identity does not move as traffic accrues.
CI runs rustc 1.97.1; this machine had 1.96.0, so the gate passed locally and failed remotely. main is red for the same reason and has been since its last commit — none of this originates in the Linux work, but enabling the native clippy target is what surfaced it, so it gets fixed rather than skirted. Two lints, both applied via the compiler's own machine-applicable suggestions rather than by hand: - rustc's new f32-fallback future-compat lint (50 sites in the UI code): `Stroke::new(1.0, …)` is ambiguous through `impl Into<f32>` and will become a hard error. Type suffixes only; no behaviour changes. - clippy's useless_borrows_in_formatting and for_kv_map in support.rs. Verified green on 1.97.1 for fmt, both clippy targets and the tests, and still green on 1.96.0.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adds Linux as a second real target. Firebreak now audits firewall rule usage
on ufw, firewalld and raw nftables, alongside the existing Windows path.
Windows event 5156 carries rule identity and process identity in one
record. Linux has no such event, so the rule side and the process side are
separate sources with no shared key. That single difference shapes
everything here: Linux reads per-rule packet counters instead of events, and
process attribution is inferred from the live listener set rather than
joined per connection.
docs/spike-linux-port.md(local, gitignored) has the full evidence surveybehind those calls.
Backends
### tuple ###inuser.rulesDetection runs ufw → firewalld → nftables. The order matters: the first two
are nftables underneath, so probing raw nftables first would audit their
generated rules instead of the vocabulary the user wrote.
The three things that had to be right
A kernel counter is a gauge, not an event stream. It resets on reboot,
reload and
iptables -Z. Totals bank the old lifetime instead of re-addingraw readings, detected by both a backwards counter and a changed
boot-id/ruleset generation. Verified live: 100 → 150 → reset → 155 → 170.
Unmeasurable is never folded into unused. Rich rules, ipsets,
protocol-only entries, unparseable tuples and counter-less nft rules get
their own report section. "We could not read this" must not read as "safe to
delete".
firewalld's nft table is
flags owner— the kernel refuses to let anyprocess add a counter to it, sudo included. Hence a shadow table at input
priority 300, after firewalld's verdict, carrying counters and no verdicts of
its own.
Writing to the firewall
Two backends need to write, which the Windows side never does. Both are
opt-in on the flags that already gate audit policy on Windows
(
--enable-only/--restore-audit), and a plain run never instruments thehost.
Raw nftables is the sharper case, since it edits the user's own rules. The
expression is never re-derived from text — it is the kernel's own JSON with
one
{"counter": null}inserted before the verdict — the ruleset is backedup first, and every touched rule is re-read and verified to be its original
self plus exactly one counter, or the whole change rolls back.
Bugs the tests caught
only the inline value dropped every rich rule on the host.
ufw6-*chains for IPv6; without that every v6 rule read asunmeasurable — a silently half-blind audit on any dual-stack host.
tuple fields shift the direction token.
limitexpands to three iptables rules over the same traffic whileproto anyexpands to disjoint tcp/udp rules; summing the first triplesthe count, maxing the second loses half the evidence.
Shared code
ProfileSet's three hardcoded Domain/Private/Public bools became abackend-supplied
ScopeVocabulary, so firewalld's arbitrary zones and ufw'sabsence of scopes both fit. This also fixed a testability bug: the Windows
profile-gate tests were passing on Linux for the wrong reason, because the
vocabulary was a platform-dependent global.
Linux gets
/var/lib/firebreak, created 0700 and refused if anotherprincipal owns it — the non-Windows path was previously a bare
create_dir_allmarked "dev builds only".Process attribution reuses the existing
listeners::listeners_for_ruletheWindows path already uses, rather than reimplementing it.
Gate
The gate now lints both targets on a Linux host. It previously skipped
native clippy because Windows-only code compiled on Linux read as dead; that
is now expressed as
#[cfg(windows)]rather than suppressed, so the nativelint is signal again — and it is the only thing that lints
src/linux/atall.
Tests: 52 → 141, all green.
Not in this PR
linux::Reportdoes not flow intoui::RuleRow, soLinux is CLI-only for now.
nft monitor trace(authoritative per-connection attribution) and eBPFprocess attribution are both unbuilt; the listener-set inference may prove
sufficient.
🤖 Generated with Claude Code
https://claude.ai/code/session_01Rujf8EVWdDJ1cXMjssiv7Q