Skip to content

Linux support: ufw, firewalld and raw nftables backends - #16

Merged
ghostpsalm merged 8 commits into
mainfrom
linux/port
Aug 9, 2026
Merged

Linux support: ufw, firewalld and raw nftables backends#16
ghostpsalm merged 8 commits into
mainfrom
linux/port

Conversation

@ghostpsalm

Copy link
Copy Markdown
Owner

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 survey
behind those calls.

Backends

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 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-adding
raw 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 any
process 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 the
host.

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 backed
up 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

  • firewalld prints rich rules on tab-indented continuation lines, so reading
    only the inline value dropped every rich rule on the host.
  • ufw uses ufw6-* chains for IPv6; without that every v6 rule read as
    unmeasurable — a silently half-blind audit on any dual-stack host.
  • Fedora's default ufw install uses application profiles, whose two extra
    tuple fields shift the direction token.
  • limit expands to three iptables rules over the same traffic while
    proto any expands to disjoint tcp/udp rules; summing the first triples
    the count, maxing the second loses half the evidence.

Shared code

ProfileSet's three hardcoded Domain/Private/Public bools became a
backend-supplied ScopeVocabulary, so firewalld's arbitrary zones and ufw's
absence 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 another
principal owns it — the non-Windows path was previously a bare
create_dir_all marked "dev builds only".

Process attribution reuses the existing listeners::listeners_for_rule the
Windows 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 native
lint is signal again — and it is the only thing that lints src/linux/ at
all.

Tests: 52 → 141, all green.

Not in this PR

  • No Linux GUI. linux::Report does not flow into ui::RuleRow, so
    Linux is CLI-only for now.
  • nft monitor trace (authoritative per-connection attribution) and eBPF
    process attribution are both unbuilt; the listener-set inference may prove
    sufficient.
  • No version bump — this is a feature branch, not a release.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Rujf8EVWdDJ1cXMjssiv7Q

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.
@ghostpsalm
ghostpsalm merged commit f5342b5 into main Aug 9, 2026
1 check passed
@ghostpsalm
ghostpsalm deleted the linux/port branch August 9, 2026 21:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant