Skip to content

nftables backend: router-shaped matches and staged atomic apply - #13

Merged
edimarlnx merged 6 commits into
mainfrom
nftables
Aug 31, 2026
Merged

edimarlnx merged 6 commits into
mainfrom
nftables

Conversation

@edimarlnx

Copy link
Copy Markdown
Contributor

Brings the nftables backend (phases 1 and 2) to main, a third backend beside ufw and firewalld.

The third backend

On a machine that runs neither ufw nor firewalld, tui-firewall reads nft -j list ruleset and drives nft itself. It is picked automatically when the other two are absent, and the screen is built from the ruleset it finds:

  • Filter chains by hook, one group per base or used chain, each showing its policy even when it holds no rules.
  • A NAT view for masquerade, SNAT and DNAT port forwards, read as what does this become.
  • Named sets as aliases, each with what it holds and how many rules use it.

It writes only to a base chain whose policy nft reported, or to its own table, and says why when it will not. Deletes go by handle, which nft -j carries.

Phase 2: router-shaped matches

The rule list reads the way a router's does: the verdict, the inbound/outbound interfaces (iifname/oifname), the connection state (ct state established,related), protocol and port, address family, and the per-rule counter. The add-rule form gained the matches ufw and firewalld never expose: interface pair, connection state, ICMP type, address family, and an alias as the source. Delete-table and delete-chain actions round it out.

Staged atomic apply, with connectivity-safe rollback

Changing many rules on a router one command at a time is how a remote session dies halfway through. s turns staging on; changes are collected instead of applied. S reviews the pending set. Applying is one nft -f transaction — all of the changes or none — previewed as the exact script it sends. Just before it runs the tool snapshots the ruleset; after applying it starts a timer. k keeps the batch; if you lose access and cannot, the snapshot is restored automatically. It is the iptables-apply idea done as one nft transaction.

Acceptance

Proven in the lab: the tui-lab router-topology branch ran the full flow against a real nft on a libvirt router (Ubuntu noble) — read path, router matches, NAT and alias views, and the staged atomic apply, 36/36 three times. In this repo the backend is covered by network-namespace tests plus the parser/fuzz/render suites. The compat table still shows no tested version for nftables because no smoke run has appended one to compat/results.jsonl yet; the lab coverage is noted in the README instead.

This PR's own change

Documents the backend in the README (a third-backend section, the staged atomic apply, updated intro/usage/demo/architecture) and renders the six new --demo=nftables screens the README references — the flow rule list, the NAT and alias views, the phase-2 add form, the staging review and the atomic-apply preview — wired into the screenshots target and the manifest. make check is green, exec boundary clean, pre-push scan empty.

🤖 Generated with Claude Code

https://claude.ai/code/session_01MCypLQF5AD8JD831Ea9S9q

edimarlnx and others added 6 commits August 30, 2026 17:37
`nft -j list ruleset` is the source of truth: one JSON read, decoded into
tables, base chains with their hook and policy, rules whose expressions
become the columns a router rule list needs — verdict, interfaces,
protocol, addresses, ports, counter, comment — and named sets with the
number of rules that refer to each. An expression this package does not
model is rendered as text beside the columns rather than dropped, because
a rule shown half of is worse than a rule shown verbatim.

Writing is the narrow half. Every change is one nft invocation with an
argv built here and previewed by the same runner that executes it, and
the guard refuses anything outside a base chain with a policy or outside
the table this tool owns, with the sentence saying which of the two it
was and why it matters.

Selection learns the third backend. nftables is the answer for a machine
nothing else manages, so it is chosen last, and when the loaded ruleset
carries ufw's chains or firewalld's table the detector names that tool
instead and says so in the detail --check and --report now print.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MCypLQF5AD8JD831Ea9S9q
Four fixtures, all of them bytes nft itself wrote. `nft -j list ruleset`
needs root and these tests do not have it, but a user namespace with a
network namespace of its own carries CAP_NET_ADMIN over that namespace's
empty ruleset — so `unshare -rn nft -f x.nft && nft -j -a list ruleset`
produces a real listing while touching nothing on the host. The .nft
source of each sits beside its JSON, so anyone can produce them again.

The fuzz targets found four things worth having found. A chain with no
hook could arrive carrying a policy, which is a promise the kernel is not
making and the mutation guard reads. A control character in a rule or in
a set element went straight into a table cell, where a newline breaks the
table rather than the cell. An expression with several fields lost all
but one of them. And a set name counted before sanitising was a reference
to something the user could not see. Their inputs are seeds now.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MCypLQF5AD8JD831Ea9S9q
A filter rule, an address translation and a named set are three different
things to read, and one set of columns is honest about none of them. So a
group now names the layout it wants: the rule table gains the interface,
port and counter columns when the backend fills them — which is the
OPNsense reading of a rule, and which ufw and firewalld would only answer
with dashes — while NAT gets the translation target up front and the
aliases get their member count and, more to the point, the number of
rules that would break if one went away.

v opens the group picker, which the update loop could already answer and
nothing had ever asked. Nothing else changed keys. The three keys
nftables cannot honour stop being advertised on it, because a key that
only ever says "not supported here" is worse than no key.

The manifest floor is 0.9 and the reason is in the notes beside it: that
is where nft grew `-j` and stamped the schema this backend reads. Below
it there is no ruleset to read in any form this tool will parse, and
parsing nft's human output is not a thing it will do.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MCypLQF5AD8JD831Ea9S9q
The other two arms of the smoke test count what the firewall itself
reports and compare. This one does the same with `nft list ruleset`: the
base chains, the sets, the version that printed the JSON. A tool that
fetched the output and failed to parse it reports zero, and a tool that
misread a hook reports the wrong number; both fail here.

It also stops claiming the NAT and alias views are writable on a machine
with no table of ours. They are not: everything they create lives in that
table, and the refusal now points at the action that creates it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MCypLQF5AD8JD831Ea9S9q
Close the seven UI gaps the phase-1 lab run surfaced, and add the
connectivity-safe batch apply.

Filter rules gain interface (iifname/oifname), connection-state
(ct state established,related) and ICMP (ip protocol icmp / ip6 nexthdr
ipv6-icmp, with an optional type) matches, each built for the table's
family. Masquerade takes an optional source network. A rule that matches
an alias reads its family off the set's element type instead of demanding
the Family field. The actions menu can delete a chain of the tool's own
table and drop the whole table, guarded so it never removes someone
else's. Pressing a in the NAT and alias views opens the actions those
views add through instead of a refusal.

Staging (internal/nftables/staging) collects mutations into a pending
set, applies them as one `nft -f` transaction — all-or-nothing — after a
single preview and confirm, snapshots the ruleset first, and rolls it
back on its own if the operator does not confirm they still have access
within a keep window (default 60s). Driven from the TUI with s/S/k, and
reported by --check and --report.

Covered by table-driven tests, staging unit tests with a hand-driven
timer, two new fuzz targets plus ct-state/icmp parser seeds (which caught
a whitespace-only ct state), and a real-nft run in a `unshare -rn`
namespace that applies a forward policy drop with its accept atomically
and restores the snapshot on a missed keep.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MCypLQF5AD8JD831Ea9S9q
Add the six nftables --demo frames the README now references — the flow
rule list, the NAT and alias views, the phase-2 add form, and the staged
atomic apply with its transaction preview — and wire them into the
screenshots target and the manifest.

Write the nftables section: the third backend and when it is picked, the
router reading of a rule (interfaces, connection state, counter), the NAT
and alias views, where it will and will not write, and the staged atomic
apply with its connectivity-safe rollback. Note the router-topology lab
coverage where the compat table still shows no tested version.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MCypLQF5AD8JD831Ea9S9q
@edimarlnx
edimarlnx merged commit 644a640 into main Aug 31, 2026
8 checks passed
@edimarlnx
edimarlnx deleted the nftables branch August 31, 2026 01:06
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