Skip to content

Commit 644a640

Browse files
edimarlnxclaude
andauthored
nftables backend: router-shaped matches and staged atomic apply (#13)
* Read the nftables ruleset, and write only where it is ours `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 * Test the reader against real nft output, and fuzz all four of them 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 * Give nftables its own screens, and the manifest its version floor 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 * Smoke the nftables read path against the machine's own ruleset 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 * nftables phase 2: router-shaped matches and a staged atomic apply 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 * Document the nftables backend and render its screens 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 --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent ac88f41 commit 644a640

60 files changed

Lines changed: 10527 additions & 123 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.goreleaser.yaml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ nfpms:
7070
vendor: tui-tools
7171
homepage: https://tui.tools/tools/tui-firewall/
7272
maintainer: tui-tools <maintainers@tui.tools>
73-
description: "ufw and firewalld, with every change previewed before it runs"
73+
description: "ufw, firewalld and nftables, with every change previewed before it runs"
7474
license: MIT
7575
# The binary belongs in the distribution's own path, not /usr/local: it
7676
# arrives through the package manager, so the package manager owns it.

‎Makefile‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,8 +62,10 @@ tidy:
6262
$(GO) mod tidy
6363

6464
## screenshots: re-render the README frames from --demo (needs chrome/chromium).
65-
## Both backends are covered: the ufw demo for the shared screens, the
66-
## firewalld one for the zone view and the actions menu that only it has.
65+
## Every backend is covered: the ufw demo for the shared screens, the firewalld
66+
## one for the zone view and the actions menu that only it has, and the
67+
## nftables one for the router screens — the flow rule list, the NAT and alias
68+
## views, the phase-2 add form and the staged atomic apply.
6769
screenshots: build
6870
python3 $(KIT)/tools/render-screenshots.py \
6971
--bin $(BIN)/$(TOOL) --name $(TOOL) --out docs/screenshots \
@@ -73,6 +75,13 @@ screenshots: build
7375
--bin $(BIN)/$(TOOL) --name $(TOOL) --out docs/screenshots \
7476
--args="--demo=firewalld" \
7577
--screen firewalld= --screen firewalld-actions=x
78+
python3 $(KIT)/tools/render-screenshots.py \
79+
--bin $(BIN)/$(TOOL) --name $(TOOL) --out docs/screenshots \
80+
--args="--demo=nftables" \
81+
--screen nftables= --screen nftables-add=a \
82+
--screen 'nftables-nat=[[' --screen 'nftables-aliases=[' \
83+
--screen nftables-staging=sdjdS \
84+
--screen 'nftables-apply=sdjdS\r'
7685

7786
## readme: regenerate the generated README sections from tool.json.
7887
readme:

‎README.md‎

Lines changed: 142 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -7,16 +7,18 @@
77
> and keys may move without notice until 1.0. Pin versions, and report what
88
> breaks.
99
10-
A terminal UI for the Linux firewall — **ufw** and **firewalld** — that shows
11-
the rules you actually have and **previews the exact command line of every
12-
change before running it**.
10+
A terminal UI for the Linux firewall — **ufw**, **firewalld** and **nftables** —
11+
that shows the rules you actually have and **previews the exact command line of
12+
every change before running it**.
1313

14-
Neither firewall has a TUI: you get the CLI, or a desktop GUI. `tui-firewall`
14+
None of them has a TUI: you get the CLI, or a desktop GUI. `tui-firewall`
1515
fills the gap on a server or a tiling desktop, in the
1616
[Omarchy](https://omarchy.org) visual style. It picks the backend your machine
1717
actually runs, and the screen is built from what that backend can do — one rule
1818
list and three default policies for ufw, one group per zone with its target for
19-
firewalld.
19+
firewalld, and for a machine neither of them manages, the raw nftables ruleset
20+
read straight from `nft`: filter chains by hook, a NAT view, and named sets as
21+
aliases.
2022

2123
![Rules table](docs/screenshots/tui-firewall-main.png)
2224

@@ -185,13 +187,16 @@ you quit.
185187
```sh
186188
tui-firewall --demo # an in-memory ufw
187189
tui-firewall --demo=firewalld # an in-memory firewalld
190+
tui-firewall --demo=nftables # a sample router ruleset
188191
```
189192

190193
`--demo` runs against an in-memory sample firewall. Every key works, every
191194
command is built and previewed for real, and nothing touches your system. The
192-
two demos are the two real backends over fake output, not two skins: the
193-
firewalld one parses firewalld-shaped text with the same parser and builds its
194-
commands with the same builder.
195+
three demos are the three real backends over fake output, not three skins: each
196+
one parses backend-shaped output with the same parser and builds its commands
197+
with the same builder. The nftables demo starts from a router ruleset — filter
198+
chains for input, forward and output, the two NAT chains, and the named sets the
199+
rules use as aliases.
195200

196201
## Every change is previewed
197202

@@ -207,10 +212,12 @@ is what executes.
207212
tui-firewall # drive the real firewall
208213
tui-firewall --demo # sample ufw data, no privileges needed
209214
tui-firewall --demo=firewalld # sample firewalld data
215+
tui-firewall --demo=nftables # sample router ruleset
210216
tui-firewall --check # read the firewall, print JSON, exit
211217
tui-firewall --report # print what a bug report needs, exit
212218
tui-firewall --backend ufw # skip autodetection
213219
tui-firewall --backend firewalld
220+
tui-firewall --backend nftables
214221
tui-firewall --theme ~/mytheme/colors.toml
215222
tui-firewall --sudo "" # run the firewall command directly (as root)
216223
tui-firewall --version
@@ -380,9 +387,84 @@ collects the answers and previews whatever comes back. That is the same reason
380387
`e` is absent on firewalld — starting and stopping a system service is
381388
`systemctl`'s job, and the tool says so instead of doing it quietly.
382389

390+
## nftables
391+
392+
![The nftables rule list](docs/screenshots/tui-firewall-nftables.png)
393+
394+
On a machine that runs neither ufw nor firewalld, `tui-firewall` reads `nft -j
395+
list ruleset` and drives `nft` itself. It is the third backend, picked
396+
automatically when the other two are absent, and the screen is built from the
397+
ruleset it finds rather than from a fixed layout.
398+
399+
Because nftables is not a policy manager but the packet path itself, the rule
400+
list reads the way a router's rule list reads: the verdict, the **interfaces**
401+
the packet passes between (`iifname` / `oifname`), the **connection state** it
402+
matches (`ct state established,related`), the protocol and port, and the
403+
**counter** that says whether the rule has ever fired. A chain has a view of its
404+
own, one group per hooked chain, stepped through with `[` and `]` or picked from
405+
`v`; base chains show their **policy** even when they hold no rules, because the
406+
policy is a fact worth seeing.
407+
408+
Two chain types get a view shaped for the question they answer:
409+
410+
- **NAT.** Address translation is read as *what does this become*, not *what is
411+
allowed*, so the masquerade, SNAT and DNAT rules of the `nat` chains are shown
412+
in their own view with the translated target as the column the eye lands on.
413+
414+
![The NAT view](docs/screenshots/tui-firewall-nftables-nat.png)
415+
416+
- **Aliases.** A named set is an alias a rule refers to by name. Each is listed
417+
with what it holds, its members, and how many rules use it — a set nothing
418+
refers to is dimmed as dead weight, and one four rules refer to is four rules
419+
you change by editing it.
420+
421+
![Named sets as aliases](docs/screenshots/tui-firewall-nftables-aliases.png)
422+
423+
The add-rule form carries the matches a router rule needs that ufw and firewalld
424+
never expose: inbound and outbound interface, connection state, ICMP type,
425+
address family, and an alias as the source.
426+
427+
![The add-rule form on nftables](docs/screenshots/tui-firewall-nftables-add.png)
428+
429+
### What it writes, and where it will not
430+
431+
nftables has no permanent/runtime split and no manager to answer to, so a write
432+
is a change to the live ruleset. That makes it easy to lock yourself out, and
433+
`tui-firewall` is deliberately narrow about where it writes: only to a **base
434+
chain whose policy nft reported**, or to **its own table**. A chain that some
435+
other manager owns is shown but marked read-only, and the tool says why in the
436+
chain's own description rather than letting you find out at the confirm dialog.
437+
Every rule is deleted by its **handle**, which `nft -j` carries in the ruleset,
438+
because a rule's position shifts the moment anything is inserted above it.
439+
440+
### Staged atomic apply, with a connectivity-safe rollback
441+
442+
Changing many rules on a router one command at a time is how a remote session
443+
dies halfway through, with the box in a state that is neither the old ruleset nor
444+
the new one. nftables can do better, and this backend uses it: press `s` to turn
445+
**staging** on, and every change is collected instead of applied. `S` reviews
446+
the pending set.
447+
448+
![The staged changes](docs/screenshots/tui-firewall-nftables-staging.png)
449+
450+
Applying the batch is one `nft -f` transaction: **all of the changes, or none**,
451+
never a half-applied ruleset. Before it runs, the whole transaction is previewed
452+
— the script that goes to `nft`'s standard input, which no single command line
453+
could show.
454+
455+
![The atomic apply preview](docs/screenshots/tui-firewall-nftables-apply.png)
456+
457+
The apply is also **connectivity-safe**. Just before the batch runs, the tool
458+
snapshots the current ruleset; then it applies, and starts a timer. If you press
459+
`k` to say you still have access, the batch stays. If you do not — because the
460+
change you just made cut off the session you are reading this in — the snapshot
461+
is restored automatically when the timer expires, and you are back on the ruleset
462+
that was working. It is the standard `iptables-apply` idea, done as one nft
463+
transaction.
464+
383465
## What v0.1 can do
384466

385-
**Both backends**
467+
**Every backend**
386468

387469
- Read the live firewall and show status, default policies and logging.
388470
- List rules with action, source, destination, ports, protocol, service,
@@ -416,6 +498,22 @@ collects the answers and previews whatever comes back. That is the same reason
416498
from the same guided form.
417499
- Zone target, log-denied value, reload, and the actions menu above.
418500

501+
**nftables**
502+
503+
- Read `nft -j list ruleset` and show every base and used regular chain, its
504+
hook and its policy, as one group each.
505+
- The router reading of a rule: inbound and outbound interface, connection
506+
state, protocol, port, address family, counter and comment.
507+
- A NAT view for masquerade, SNAT and DNAT port forwards, and an aliases view of
508+
the named sets with the number of rules using each.
509+
- Add a rule with an action (accept/drop/reject), an interface pair, a
510+
connection-state match, an ICMP type, an alias as the source, and a family;
511+
add a masquerade, a port forward or a named set from the actions menu.
512+
- Write only to a base chain nft reported a policy for, or to the tool's own
513+
table, and say why when it will not. Delete by handle.
514+
- Stage changes and apply them as one atomic `nft -f` transaction, with an
515+
automatic connectivity-safe rollback if the apply is not kept in time.
516+
419517
## What v0.1 cannot do
420518

421519
- **No rule editing.** Change a rule by deleting it and adding the new one.
@@ -427,7 +525,10 @@ collects the answers and previews whatever comes back. That is the same reason
427525
- **A firewalld policy object is read-only-ish**: its entries are listed and can
428526
be added and removed, but its target and its ingress/egress zones are not
429527
editable here.
430-
- **No live log tail**, no packet counters, no nft view.
528+
- **No live log tail.**
529+
- **No nftables rule editing or reordering**, and no set-element editing: a rule
530+
is changed by deleting it and adding the new one, and a named set is created or
531+
used, not edited member by member.
431532
- Rules follow the backend's own order; there is no reordering key.
432533

433534
## Compatibility
@@ -469,11 +570,33 @@ hidden; one below the minimum is marked as such and the tool still runs.
469570
| `==2.0.0` | `--permanent --list-all-zones` prints the same settings for every zone on this release (firewalld#1152), so the permanent half of the runtime/permanent comparison is not trustworthy; 2.0.1 fixed it |
470571
| `>=2.2` | firewalld removed the lockdown feature, so no lockdown state is shown |
471572

573+
### nftables
574+
575+
| | |
576+
| --- | --- |
577+
| Binary | `nft` |
578+
| Version read with | `nft --version` |
579+
| Minimum | 0.9 |
580+
| Tested | none yet |
581+
582+
| Versions | What changes |
583+
| --- | --- |
584+
| `<0.9` | `nft -j` does not exist yet, so there is no ruleset to read in any form this tool will parse. 0.9.0 is where nft grew JSON output and stamped it json_schema_version 1, the schema this backend reads, and that is the whole reason the minimum is 0.9 |
585+
| `>=0.9` | `nft -j list ruleset` carries rule handles whether or not `-a` is given, unlike the human output. A handle is how a rule is deleted, because positions shift the moment anything is inserted above them |
586+
472587
The tested versions are generated from `compat/results.jsonl`, which the tool's
473588
own smoke test appends to when it runs against a real machine in
474589
[tui-lab](https://github.com/tui-tools/tui-lab).
475590
<!-- compat:end -->
476591

592+
The nftables backend shows no tested version yet because no smoke run has
593+
appended one to `compat/results.jsonl`. It has been exercised end to end in the
594+
[tui-lab](https://github.com/tui-tools/tui-lab) `router-topology` scenario — a
595+
libvirt router on Ubuntu noble, where the read path, the router matches, the NAT
596+
and alias views and the staged atomic apply were driven against a real `nft`,
597+
alongside the network-namespace tests in this repo. A tested version will land
598+
here once that run records one through the smoke test.
599+
477600
## Configuration
478601

479602
`/etc/tui-firewall/config.toml`, then `~/.config/tui-firewall/config.toml` (the
@@ -522,14 +645,21 @@ ufw exposes a single group (`rules`) carrying the global in/out/routed
522645
policies; the group selector stays hidden when there is only one. firewalld
523646
exposes one group per zone with the zone target as its policy, plus one per
524647
policy object — the full mapping is documented at the top of
525-
`internal/firewalld/firewalld.go`.
648+
`internal/firewalld/firewalld.go`. nftables exposes one group per hooked or used
649+
chain with its policy, plus a NAT view and an aliases view; the `Extra` map is
650+
where the router-shaped fields ride — interface, connection state, counter,
651+
translated target, set membership — so the same `Rule` serves every backend
652+
without the model growing a column per feature.
526653

527654
Mutations are `firewall.Change` values produced by the backend: an ordered list
528655
of `runner.Command`s with the description and the danger flag the dialog is
529656
painted from. The list exists because firewalld needs two invocations to apply
530657
one change, and making that a list rather than a hidden second exec keeps the
531658
promise intact — every line the dialog shows is a line that runs, and nothing
532-
else does. On confirmation the UI hands those commands back to the
659+
else does. On nftables a staged batch is the same idea taken to its limit: the
660+
whole set of changes is one `nft -f` transaction, previewed as the script it
661+
sends, applied all-or-nothing, and rolled back to a pre-apply snapshot if the
662+
operator does not confirm they still have access in time. On confirmation the UI hands those commands back to the
533663
[kit runner](https://github.com/tui-tools/tui-kit#the-contract-preview-confirm-run),
534664
which resolves the binary and the privilege prefix. That is the whole trust
535665
boundary.

0 commit comments

Comments
 (0)