Skip to content

Commit b5da473

Browse files
edimarlnxclaude
andcommitted
Document and smoke-test the iptables backend (#23)
The README gets an iptables section (where a rule lands, persisting as its own step, how the backend is chosen, what nftables now refuses), the demo and flag lists name it, and three screenshots from --demo=iptables show the insert before the catch-all and the persist diff. test/smoke.sh gains an iptables branch: selection and its reason, the variant, the INPUT count against iptables -S, the placement before the catch-all, drift reported for a rule added behind the tool's back and cleared when it goes, and --backend nftables refusing ip filter. It passed 18/18 on the lab's Ubuntu 24.04 guest converted to the cloud-image shape, and the compat line for iptables 1.8.10 is harvested from that run. The header's reminder to persist now appears only when the reload shows drift, and the delete dialog names the port on backends whose destination column does not carry it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MbCfX3T8khyd9hsrN5kMo3
1 parent 87e141c commit b5da473

13 files changed

Lines changed: 286 additions & 30 deletions

File tree

‎Makefile‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,9 @@ tidy:
6565
## Every backend is covered: the ufw demo for the shared screens, the firewalld
6666
## one for the zone view and the actions menu that only it has, and the
6767
## 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.
68+
## views, the phase-2 add form and the staged atomic apply — and the iptables
69+
## one for the cloud-image screens: the insert before the catch-all REJECT and
70+
## the persist diff.
6971
screenshots: build
7072
python3 $(KIT)/tools/render-screenshots.py \
7173
--bin $(BIN)/$(TOOL) --name $(TOOL) --out docs/screenshots \
@@ -86,6 +88,12 @@ screenshots: build
8688
--bin $(BIN)/$(TOOL) --name $(TOOL) --out docs/screenshots \
8789
--args="--demo=nftables" --settle 1.0 --budget 7.0 \
8890
--screen nftables-live=w
91+
python3 $(KIT)/tools/render-screenshots.py \
92+
--bin $(BIN)/$(TOOL) --name $(TOOL) --out docs/screenshots \
93+
--args="--demo=iptables" \
94+
--screen iptables= \
95+
--screen 'iptables-add=a\t51820\t\rudp\r\t\r' \
96+
--screen 'iptables-persist=a\t51820\t\rudp\r\t\ryW'
8997

9098
## readme: regenerate the generated README sections from tool.json.
9199
readme:

‎README.md‎

Lines changed: 132 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
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**, **firewalld** and **nftables** —
10+
A terminal UI for the Linux firewall — **ufw**, **firewalld**, **iptables** and **nftables** —
1111
that shows the rules you actually have and **previews the exact command line of
1212
every change before running it**.
1313

@@ -16,9 +16,10 @@ 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, 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.
19+
firewalld, the iptables chains of a cloud image whose rules are restored at boot
20+
by iptables-persistent, and for a machine none of them manages, the raw nftables
21+
ruleset read straight from `nft`: filter chains by hook, a NAT view, and named
22+
sets as aliases.
2223

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

@@ -189,12 +190,13 @@ you quit.
189190
```sh
190191
tui-firewall --demo # an in-memory ufw
191192
tui-firewall --demo=firewalld # an in-memory firewalld
193+
tui-firewall --demo=iptables # a cloud image: INPUT ends in a REJECT, docker chains
192194
tui-firewall --demo=nftables # a sample router ruleset
193195
```
194196

195197
`--demo` runs against an in-memory sample firewall. Every key works, every
196198
command is built and previewed for real, and nothing touches your system. The
197-
three demos are the three real backends over fake output, not three skins: each
199+
four demos are the four real backends over fake output, not four skins: each
198200
one parses backend-shaped output with the same parser and builds its commands
199201
with the same builder. The nftables demo starts from a router ruleset — filter
200202
chains for input, forward and output, the two NAT chains, and the named sets the
@@ -214,11 +216,13 @@ is what executes.
214216
tui-firewall # drive the real firewall
215217
tui-firewall --demo # sample ufw data, no privileges needed
216218
tui-firewall --demo=firewalld # sample firewalld data
219+
tui-firewall --demo=iptables # sample cloud-image iptables
217220
tui-firewall --demo=nftables # sample router ruleset
218221
tui-firewall --check # read the firewall, print JSON, exit
219222
tui-firewall --report # print what a bug report needs, exit
220223
tui-firewall --backend ufw # skip autodetection
221224
tui-firewall --backend firewalld
225+
tui-firewall --backend iptables
222226
tui-firewall --backend nftables
223227
tui-firewall --theme ~/mytheme/colors.toml
224228
tui-firewall --sudo "" # run the firewall command directly (as root)
@@ -315,11 +319,12 @@ firewall nor `sudo` is available, it says so and points at `--demo`.
315319
| `d` | Delete the selected rule |
316320
| `e` | Enable or disable the firewall (ufw only) |
317321
| `r` | Reload the firewall |
318-
| `p` | Change a default policy (ufw) or a zone target (firewalld) |
322+
| `p` | Change a default policy (ufw), a zone target (firewalld) or a chain policy (iptables, nftables) |
319323
| `L` | Change the logging level (ufw) or the log-denied value (firewalld) |
320324
| `l` | Toggle logging on the selected rule (nftables) |
321325
| `w` | Watch the live firewall log of the logged rules (nftables) |
322326
| `W` | Save the tool's own table to a file loaded on boot, with a diff preview (nftables) |
327+
| `W` | Persist the running rules to the files restored at boot, with a diff preview (iptables) |
323328
| `x` | Actions this backend offers beyond these keys |
324329
| `[` / `]` | Previous / next group — the firewalld zones and policy objects |
325330
| `R` | Re-read the firewall |
@@ -395,6 +400,94 @@ collects the answers and previews whatever comes back. That is the same reason
395400
`e` is absent on firewalld — starting and stopping a system service is
396401
`systemctl`'s job, and the tool says so instead of doing it quietly.
397402

403+
## iptables
404+
405+
![The iptables INPUT chain of a cloud image](docs/screenshots/tui-firewall-iptables.png)
406+
407+
Many cloud images ship no ufw and no firewalld. Their firewall is a plain
408+
iptables ruleset, restored at boot by **iptables-persistent** (Debian, Ubuntu:
409+
`/etc/iptables/rules.v4` and `rules.v6`) or **iptables-services** (Fedora, RHEL:
410+
`/etc/sysconfig/iptables`), and the INPUT chain ends in a catch-all:
411+
412+
```
413+
-A INPUT -p tcp -m state --state NEW -m tcp --dport 22 -j ACCEPT
414+
-A INPUT -j REJECT --reject-with icmp-host-prohibited
415+
```
416+
417+
On such a machine `tui-firewall` drives `iptables` and `ip6tables` directly. It
418+
reads the rules with `iptables-save -c` and `ip6tables-save -c` (both the
419+
`nf_tables` and the `legacy` variants), shows one view per chain and family,
420+
`INPUT (iptables)` beside `INPUT (ip6tables)`, and writes only to the built-in
421+
`INPUT`, `FORWARD` and `OUTPUT` chains of the filter table. The chains docker,
422+
tailscaled, kube-proxy or fail2ban create are shown and left alone, because
423+
those daemons rebuild them every time they start.
424+
425+
### Where a new rule goes
426+
427+
An appended allow after the catch-all never matches. So a new rule is inserted
428+
**in front of the catch-all REJECT or DROP**, and the preview says so:
429+
430+
![Adding a rule before the catch-all](docs/screenshots/tui-firewall-iptables-add.png)
431+
432+
```
433+
Insert a rule at position 5 of INPUT (iptables)
434+
inserted at position 5 of INPUT, right before rule 5 (-j REJECT --reject-with
435+
icmp-host-prohibited): that rule catches everything, so a rule appended after
436+
it would never match
437+
438+
$ sudo -n iptables -I INPUT 5 -p udp -m udp --dport 51820 -j ACCEPT
439+
```
440+
441+
A chain with no catch-all gets the rule at its end, where the policy decides
442+
what nothing matched. An explicit position in the form is honoured, unless it
443+
lands after the catch-all, which is refused with the reason. A rule is deleted
444+
by its **specification** (`iptables -D INPUT -p tcp ... -j ACCEPT`), not by its
445+
number, so a rule another daemon inserted in the meantime cannot shift the
446+
delete onto a neighbour.
447+
448+
### Persisting is its own step
449+
450+
A change applies to the running kernel, and the next boot restores the saved
451+
file. The header says which state you are in (`saved: in sync`, or
452+
`saved: differs: 1 line not saved (W)`), the status line repeats it after every
453+
change, and `W` persists, with the diff between the saved file and the running
454+
rules in the preview:
455+
456+
![Persisting with a diff preview](docs/screenshots/tui-firewall-iptables-persist.png)
457+
458+
The command is the persistence layer's own: `netfilter-persistent save` on
459+
Debian and Ubuntu, `service iptables save` and `service ip6tables save` on RHEL.
460+
The comparison covers the filter table and leaves out the chains other daemons
461+
rebuild, so a host where tailscaled added `ts-input` after the last save still
462+
reads as in sync.
463+
464+
Staging (`s`, `S`, `k`) works here too: the batch applies through
465+
`iptables-restore --noflush`, all or nothing per address family, and if the
466+
keep window runs out the filter table is restored from a snapshot taken right
467+
before the apply.
468+
469+
### Which backend is chosen, and why
470+
471+
`auto` picks iptables when its loader is active or enabled
472+
(`netfilter-persistent`, or the `iptables` unit of iptables-services), when the
473+
nft ruleset shows iptables-nft filter tables carrying rules of the operator's
474+
own, or when the legacy iptables tables do. Docker's plumbing alone does not
475+
count: a pure-nft host running docker stays on nftables. A running ufw or
476+
firewalld always wins, since both of them drive iptables underneath. The
477+
selection line in the header, `--check` and `--report` says which of these it
478+
was, and names any native nft table the iptables backend does not show.
479+
480+
The nftables backend, in turn, refuses to write into a table iptables-nft owns
481+
(`ip filter` with its upper-case chains and xtables matches): a native rule
482+
there can make `iptables-save` refuse the table, which breaks
483+
`netfilter-persistent save`, and is lost when the saved rules are restored at
484+
boot. The refusal points at `--backend iptables`.
485+
486+
`--check` carries an `iptables` block: the variant, the writable chains and
487+
where a new rule would land in each, the ports INPUT accepts ahead of its
488+
catch-all (and any accept that sits after it and never matches), and the
489+
persistence layer with the drift between running and saved rules.
490+
398491
## nftables
399492

400493
![The nftables rule list](docs/screenshots/tui-firewall-nftables.png)
@@ -592,6 +685,20 @@ are capped, so a firewall under a scan cannot grow it without bound.
592685
from the same guided form.
593686
- Zone target, log-denied value, reload, and the actions menu above.
594687

688+
**iptables**
689+
690+
- Read `iptables-save -c` and `ip6tables-save -c` (nf_tables or legacy) and show
691+
INPUT, FORWARD and OUTPUT of both families, then every other chain that holds a
692+
rule, read-only.
693+
- Insert a rule right before the chain's catch-all REJECT or DROP, with the
694+
position and the reason in the preview; refuse a position after it. Delete by
695+
specification.
696+
- Persist with `netfilter-persistent save` or `service iptables save`, previewed
697+
with the diff against the saved files; report runtime-vs-saved drift in the
698+
header, the status line and `--check`.
699+
- Stage changes and apply them through `iptables-restore --noflush`, with the
700+
same keep-or-roll-back window as nftables.
701+
595702
**nftables**
596703

597704
- Read `nft -j list ruleset` and show every base and used regular chain, its
@@ -694,6 +801,20 @@ hidden; one below the minimum is marked as such and the tool still runs.
694801
| `==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 |
695802
| `>=2.2` | firewalld removed the lockdown feature, so no lockdown state is shown |
696803

804+
### iptables
805+
806+
| | |
807+
| --- | --- |
808+
| Binary | `iptables` |
809+
| Version read with | `iptables --version` |
810+
| Minimum | 1.6 |
811+
| Tested | `1.8.10` |
812+
813+
| Versions | What changes |
814+
| --- | --- |
815+
| `<1.8` | only the legacy xtables interface exists, so the rules live outside nf_tables and the nft ruleset cannot show them; the backend reads them through iptables-save and the selector asks the legacy tables directly |
816+
| `>=1.8` | iptables-nft writes table ip filter and ip6 filter in nf_tables, and `iptables --version` says which variant is installed; the nftables backend treats those tables as read-only and points here |
817+
697818
### nftables
698819

699820
| | |
@@ -729,7 +850,7 @@ environment. Flags override everything. See
729850
[`examples/config.toml`](examples/config.toml).
730851

731852
```toml
732-
# Which firewall to drive: "auto", "ufw" or "firewalld".
853+
# Which firewall to drive: "auto", "ufw", "firewalld", "iptables" or "nftables".
733854
backend = "auto"
734855

735856
# Privilege escalation prefix; "" runs the command directly.
@@ -744,8 +865,10 @@ service is running. If neither is running it takes the one systemd would start
744865
at boot (`systemctl is-enabled`), and if neither is enabled either, the first
745866
one installed — ufw before firewalld. Having both on one machine is a
746867
misconfiguration rather than a supported setup, so the tie-break exists to be
747-
predictable; name the one you mean when both are present. With nothing
748-
installed it exits with a message naming what to install.
868+
predictable; name the one you mean when both are present. Then comes iptables,
869+
when a loader restores its saved rules or its tables carry rules of their own
870+
(see [iptables](#iptables)), and last nftables, for a machine nothing else
871+
manages. With nothing installed it exits with a message naming what to install.
749872

750873
## Theme
751874

‎cmd/tui-firewall/app.go‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,9 @@ type app struct {
107107
// pendingSave marks the change at the confirm dialog as the save, so a yes
108108
// clears the "not written yet" state of the spec.
109109
pendingSave bool
110+
// persistHintPending asks the next load to add the "W persists" reminder
111+
// to the status line when the running rules now differ from the saved ones.
112+
persistHintPending bool
110113
// saveOfferPending asks the next load to offer the save, so the disabled
111114
// rule is on screen before the dialog that writes it to disk opens.
112115
saveOfferPending bool
@@ -242,6 +245,12 @@ func (a *app) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
242245
}
243246
a.loadFailed = false
244247
a.model = msg.model
248+
if a.persistHintPending {
249+
a.persistHintPending = false
250+
if p, ok := a.backend.(persister); ok && !p.PersistState().Drift.InSync {
251+
a.status += " · runtime only, W persists"
252+
}
253+
}
245254
if _, ok := a.model.Group(a.group); !ok && len(a.model.Groups) > 0 {
246255
a.group = a.model.Groups[0].Name
247256
}
@@ -295,8 +304,10 @@ func (a *app) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
295304
a.setStatusf(ui.StatusOK, "%s: %s", msg.change.Description, firstLine(summary))
296305
// On a backend whose changes are runtime-only until persisted, the
297306
// success line says so: the next boot restores the saved file.
307+
// The reload decides: a change that put the rules back where the saved
308+
// file has them (a rollback, a re-add) needs no reminder.
298309
if _, ok := a.backend.(persister); ok && !wasSave {
299-
a.status += " · runtime only, W persists"
310+
a.persistHintPending = true
300311
}
301312
a.loading = true
302313
if wasSave {

‎cmd/tui-firewall/check.go‎

Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,9 @@ type iptablesFacts struct {
232232
// and no catch-all, every port not refused above is open, which the
233233
// openInput list alone would not say.
234234
InputEnd map[string]string `json:"inputEnd"`
235+
// InputRules counts the rules of INPUT per family, for a smoke test to
236+
// compare with `iptables -S INPUT`.
237+
InputRules map[string]int `json:"inputRules"`
235238
// Persistence is the layer that restores the rules at boot and the drift
236239
// between it and the running rules.
237240
Persistence iptablesPersistence `json:"persistence"`
@@ -273,18 +276,19 @@ func collectIptablesFacts(backend firewall.Backend, model firewall.Model) *iptab
273276
}
274277
state := source.State()
275278
facts := &iptablesFacts{
276-
Version: state.V4.Version,
277-
Variant: state.V4.Variant,
278-
HasV6: state.HasV6,
279-
TablesV4: len(state.V4.Tables),
280-
TablesV6: len(state.V6.Tables),
281-
RulesV4: countDumpRules(state.V4),
282-
RulesV6: countDumpRules(state.V6),
283-
Warnings: append(append([]string(nil), state.V4.Warnings...), state.V6.Warnings...),
284-
Writable: []string{},
285-
Placement: map[string]string{},
286-
OpenInput: map[string][]string{},
287-
InputEnd: map[string]string{},
279+
Version: state.V4.Version,
280+
Variant: state.V4.Variant,
281+
HasV6: state.HasV6,
282+
TablesV4: len(state.V4.Tables),
283+
TablesV6: len(state.V6.Tables),
284+
RulesV4: countDumpRules(state.V4),
285+
RulesV6: countDumpRules(state.V6),
286+
Warnings: append(append([]string(nil), state.V4.Warnings...), state.V6.Warnings...),
287+
Writable: []string{},
288+
Placement: map[string]string{},
289+
OpenInput: map[string][]string{},
290+
InputEnd: map[string]string{},
291+
InputRules: map[string]int{},
288292
}
289293
for _, family := range iptables.Families() {
290294
if family == iptables.V6 && !state.HasV6 {
@@ -294,6 +298,7 @@ func collectIptablesFacts(backend firewall.Backend, model firewall.Model) *iptab
294298
if !ok {
295299
continue
296300
}
301+
facts.InputRules[string(family)] = len(chain.Rules)
297302
if at, rule := chain.CatchAll(); at > 0 {
298303
facts.InputEnd[string(family)] = fmt.Sprintf("rule %d: %s", at,
299304
rule.Match.TargetDetail())

‎cmd/tui-firewall/staging.go‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -198,7 +198,7 @@ func (a *app) openApplyConfirm(change firewall.Change) {
198198
body := fmt.Sprintf(
199199
"These %d change(s) apply %s. "+
200200
"After they apply you have %s to press k and keep them; if you lose "+
201-
"access and do not, the snapshot below is restored automatically.",
201+
"access and do not, the snapshot taken just before is restored automatically.",
202202
a.staging.Len(), a.staging.Atomicity(), a.staging.Timeout().Round(time.Second))
203203
a.pendingApply = true
204204
a.mode = modeConfirm

‎cmd/tui-firewall/view.go‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -408,6 +408,15 @@ func describeRule(r firewall.Rule) string {
408408
parts = append(parts, string(r.Direction))
409409
}
410410
parts = append(parts, r.To, "from", r.From)
411+
// A backend whose destination column does not already carry the port
412+
// (nftables, iptables) gets it spelled out, so the dialog names the rule.
413+
if r.Ports != "" && !strings.Contains(r.To, r.Ports) {
414+
port := "port " + r.Ports
415+
if r.Proto != "" {
416+
port += "/" + r.Proto
417+
}
418+
parts = append(parts, port)
419+
}
411420
if r.Comment != "" {
412421
parts = append(parts, "#", r.Comment)
413422
}

‎compat/results.jsonl‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
{"backend": "ufw", "date": "2026-08-30", "distro": "omarchy-server-4.0.1", "result": "pass", "suite": "smoke", "tool": "tui-firewall", "version": "0.36.2"}
22
{"backend": "ufw", "date": "2026-08-30", "distro": "ubuntu-24.04", "result": "pass", "suite": "smoke", "tool": "tui-firewall", "version": "0.36.2"}
33
{"backend": "firewalld", "date": "2026-08-30", "distro": "fedora-44", "result": "pass", "suite": "smoke", "tool": "tui-firewall", "version": "2.4.4"}
4+
{"backend": "iptables", "date": "2026-09-25", "distro": "ubuntu-24.04", "result": "pass", "suite": "smoke", "tool": "tui-firewall", "version": "1.8.10"}
41.7 KB
Loading
71.3 KB
Loading
55.2 KB
Loading

0 commit comments

Comments
 (0)