Skip to content

iptables backend (#23) - #24

Merged
edimarlnx merged 4 commits into
mainfrom
feat/iptables-backend
Sep 25, 2026
Merged

edimarlnx merged 4 commits into
mainfrom
feat/iptables-backend

Conversation

@edimarlnx

Copy link
Copy Markdown
Contributor

Closes #23.

What

A fourth backend, iptables, for hosts whose filter tables are written through the xtables tools (iptables-nft or legacy) and restored at boot by iptables-persistent / netfilter-persistent (Debian, Ubuntu) or iptables-services (Fedora, RHEL): the stock cloud-image shape where INPUT ends in -j REJECT --reject-with icmp-host-prohibited and docker has added its chains.

  • Selection (internal/backends): a running ufw or firewalld still wins. Then iptables when its loader is active or enabled (netfilter-persistent, or the iptables unit of iptables-services), when the nft ruleset shows iptables-nft filter tables with rules of the operator's own, or when the legacy tables carry rules. Docker plumbing alone does not count, so a pure-nft host running docker stays on nftables. The reason line names the loader and the file it restores, and any native nft table the iptables backend does not show.
  • Reads via iptables-save -c / ip6tables-save -c (nf_tables and legacy). One view per built-in chain and family (INPUT (iptables) beside INPUT (ip6tables)), then every other chain holding a rule, read-only. Chains of docker, tailscaled, kube-proxy, fail2ban... are shown and never written.
  • Writes iptables -I <chain> <pos> with the position right before the first catch-all REJECT/DROP; the preview shows the position and why. An explicit position after the catch-all is refused. Deletes go by specification (iptables -D INPUT <spec>), not by number. Policies via -P.
  • Persistence is its own previewed step on W: netfilter-persistent save (Debian/Ubuntu) or service iptables save + service ip6tables save (RHEL), with a unified diff against the saved files. Header fact saved: in sync / saved: differs: N lines not saved (W), a status-line reminder after a change, and --check / --report report runtime-vs-saved drift. The comparison covers the filter table and ignores the chains other daemons rebuild at start (a host where tailscaled added ts-input after the last save reads as in sync).
  • Staging/rollback: the staging session now takes a dialect. The iptables one applies a batch as iptables-restore --noflush per family (all or nothing per family) and rolls back by restoring a filter-table snapshot taken right before the apply (nat is left alone).
  • nftables refuses xtables tables: any ip/ip6 table with iptables' names and upper-case built-in chains or xtables expressions is read-only, with a message pointing at --backend iptables; when those tables carry operator rules, DetectManagement names iptables as the firewall in charge.
  • --demo=iptables (OCI-like: INPUT ending in REJECT, docker chains, metadata chain), a --check iptables block (variant, writable chains and placement, open/shadowed INPUT ports, what ends INPUT, persistence + drift, staging), a --report line, a compat entry (minimum 1.6, iptables --version), README section, three screenshots, and a smoke branch with record_compat.

Tests

  • Unit tests over scrubbed read-only captures from a real Oracle Cloud Ubuntu 24.04 host (iptables-nft 1.8.10 + iptables-persistent + docker + tailscale): parser, model, placement, builders and refusals, drift, persist, fake, dialect; nftables xtables detection and refusal over the same host's nft -j list ruleset, plus a docker-on-nft fixture captured in a namespace.
  • TestAgainstRealIptables drives real iptables under unshare -rn (insert before REJECT, staged batch, rollback, a rejected batch changes nothing): passes locally with iptables 1.8.11 (nf_tables).
  • make lint clean (gofmt, vet, exec boundary, golangci-lint), go test -race ./... green.

Real case (read-only)

The dev build's --check on the real host (not mutated), excerpt:

backend: iptables
selection: netfilter-persistent is enabled and restores /etc/iptables/rules.v4 at boot, so iptables is the firewall in charge of this machine
openInput.v4: 54203/tcp, 41641/udp, 443/tcp, 22/tcp      (51820/udp: not open)
placement ip filter / INPUT: inserted at position 9 of INPUT, right before rule 9 (-j REJECT --reject-with icmp-host-prohibited)
persistence: netfilter-persistent, enabled, drift "in sync"
--backend nftables: writable [] ; readOnly "the ruleset's table ip filter is written by iptables-nft and carries 10 rules of the operator's own, so iptables is the firewall in charge of this machine"

Lab evidence (tui-lab ubuntu guest, Ubuntu 24.04.5)

The guest was snapshotted, then made to look like the cloud image: ufw disabled (iptables-persistent removes it), iptables-persistent installed, the scrubbed cloud rules.v4 (INPUT ending in the REJECT, docker and metadata chains) loaded and saved. All flows below were driven in the real TUI through tmux; the guest was restored to its snapshot afterwards.

Open 51820/udp: inserted before the REJECT

  ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
  │                                                                                                                  │
  │  Insert a rule at position 5 of INPUT (iptables)                                                                 │
  │                                                                                                                  │
  │  The firewall will be changed as follows.                                                                        │
  │  inserted at position 5 of INPUT, right before rule 5 (-j REJECT --reject-with icmp-host-prohibited): that rule  │
  │  catches everything, so a rule appended after it would never match; runtime only until persisted: W writes the   │
  │  running rules to the file restored at boot.                                                                     │
  │                                                                                                                  │
  │  Command to run:                                                                                                 │
  │  $ iptables -I INPUT 5 -p udp -m udp --dport 51820 -j ACCEPT                                                     │
  │                                                                                                                  │
  │  y confirm    n cancel                                                                                           │
  │                                                                                                                  │
  ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
 tui-firewall  iptables (nf_tables) (root)  ·  Chain: INPUT (iptables)  ·  14 chains, [ ] or v switches
 status: enabled   incoming: allow   backend: iptables 1.8.10 (untested)   saved: differs: 1 line not saved (W)
 #   ACTION  IN     OUT    PROTO IP SOURCE             DESTINATION        PORT      COUNTER      COMMENT
 1   ALLOW   -      -      -     v4 Anywhere           Anywhere           -         584p/5.6M    state RELATED,ESTAB…
 2   ALLOW   -      -      icmp  v4 Anywhere           Anywhere           -         0p/0B
 3   ALLOW   lo     -      -     v4 Anywhere           Anywhere           -         19p/1.2K
 4   ALLOW   -      -      tcp   v4 Anywhere           Anywhere           22        0p/0B        state NEW
 5   ALLOW   -      -      udp   v4 Anywhere           Anywhere           51820     0p/0B
 6   REJECT  -      -      -     v4 Anywhere           Anywhere           -         0p/0B        --reject-with icmp-…
 a add  d delete  v view  p policies  W persist  s stage  / filter  ? help  q quit
 Insert a rule at position 5 of INPUT (iptables): done  ·  runtime only, W persists

Open 443/tcp, then persist (W) with the diff

  │  Insert a rule at position 6 of INPUT (iptables)                                                                 │
  │  $ iptables -I INPUT 6 -p tcp -m tcp --dport 443 -j ACCEPT                                                       │
   ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
   │                                                                                                                │
   │  Persist the running rules (netfilter-persistent)                                                              │
   │                                                                                                                │
   │  The running rules are written to the files restored at boot, replacing what the diff below shows.             │
   │  netfilter-persistent runs its plugins, which write iptables-save into /etc/iptables/rules.v4 and              │
   │  ip6tables-save into /etc/iptables/rules.v6; it restores both at boot; the save also copies the chains docker  │
   │  rebuilds when started, which is how these tools always save.                                                  │
   │                                                                                                                │
   │  Command to run:                                                                                               │
   │  $ netfilter-persistent save                                                                                   │
   │                                                                                                                │
   │  --- /etc/iptables/rules.v4                                                                                    │
   │  +++ running iptables rules                                                                                    │
   │  @@ -13,6 +13,8 @@                                                                                             │
   │   -A INPUT -p icmp -j ACCEPT                                                                                   │
   │   -A INPUT -i lo -j ACCEPT                                                                                     │
   │   -A INPUT -p tcp -m state --state NEW -m tcp --dport 22 -j ACCEPT                                             │
   │  +-A INPUT -p udp -m udp --dport 51820 -j ACCEPT                                                               │
   │  +-A INPUT -p tcp -m tcp --dport 443 -j ACCEPT                                                                 │
   │   -A INPUT -j REJECT --reject-with icmp-host-prohibited                                                        │
   │   -A FORWARD -j DOCKER-USER                                                                                    │
   │   -A FORWARD -j DOCKER-FORWARD                                                                                 │
   │                                                                                                                │
   │  y confirm    n cancel                                                                                         │
   │                                                                                                                │
   ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
 status: enabled   incoming: allow   backend: iptables 1.8.10 (untested)   saved: in sync
-A INPUT -m state --state RELATED,ESTABLISHED -j ACCEPT
-A INPUT -p icmp -j ACCEPT
-A INPUT -i lo -j ACCEPT
-A INPUT -p tcp -m state --state NEW -m tcp --dport 22 -j ACCEPT
-A INPUT -p udp -m udp --dport 51820 -j ACCEPT
-A INPUT -p tcp -m tcp --dport 443 -j ACCEPT
-A INPUT -j REJECT --reject-with icmp-host-prohibited

Reboot 1: rules survive, before the REJECT

On the guest after systemctl reboot: boot time, boot_id, systemctl is-active netfilter-persistent, iptables -S INPUT, then --check.

2026-09-25 03:58:16
8e1b2fb0-eb04-4786-a360-14ae1e12bafc
active
-P INPUT ACCEPT
-A INPUT -m state --state RELATED,ESTABLISHED -j ACCEPT
-A INPUT -p icmp -j ACCEPT
-A INPUT -i lo -j ACCEPT
-A INPUT -p tcp -m state --state NEW -m tcp --dport 22 -j ACCEPT
-A INPUT -p udp -m udp --dport 51820 -j ACCEPT
-A INPUT -p tcp -m tcp --dport 443 -j ACCEPT
-A INPUT -j REJECT --reject-with icmp-host-prohibited
Fri Sep 25 03:58:48 UTC 2026
    "openInput": {
      "v4": [
        "22/tcp",
        "51820/udp",
        "443/tcp"
      ],
      "v6": []

Delete 443/tcp, persist, reboot 2

The second boot has a new boot_id (the previous one is at the end of the block).

  ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
  │                                                                                                                 │
  │  Delete rule 6 of INPUT (iptables)                                                                              │
  │                                                                                                                 │
  │  Rule: ALLOW IN Anywhere from Anywhere                                                                          │
  │  deleted by its specification rather than its number, so a rule another daemon inserted meanwhile cannot shift  │
  │  the delete onto a neighbour; runtime only until persisted: W writes the running rules to the file restored at  │
  │  boot.                                                                                                          │
  │                                                                                                                 │
  │  Command to run:                                                                                                │
  │  $ iptables -D INPUT -p tcp -m tcp --dport 443 -j ACCEPT                                                        │
  │                                                                                                                 │
  │  y confirm    n cancel                                                                                          │
  │                                                                                                                 │
  ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
   │  Command to run:                                                                                               │
   │  $ netfilter-persistent save                                                                                   │
   │                                                                                                                │
   │  --- /etc/iptables/rules.v4                                                                                    │
   │  +++ running iptables rules                                                                                    │
   │  @@ -14,7 +14,6 @@                                                                                             │
   │   -A INPUT -i lo -j ACCEPT                                                                                     │
   │   -A INPUT -p tcp -m state --state NEW -m tcp --dport 22 -j ACCEPT                                             │
   │   -A INPUT -p udp -m udp --dport 51820 -j ACCEPT                                                               │
   │  --A INPUT -p tcp -m tcp --dport 443 -j ACCEPT                                                                 │
   │   -A INPUT -j REJECT --reject-with icmp-host-prohibited                                                        │
   │   -A FORWARD -j DOCKER-USER                                                                                    │
   │   -A FORWARD -j DOCKER-FORWARD                                                                                 │
   │                                                                                                                │
   │  y confirm    n cancel                                                                                         │

boot_id=d22bf20c-4eeb-4386-9053-2fac3b3fd61e
Sep 25 03:59:47 ubuntu kernel: Linux ver
active
-P INPUT ACCEPT
-A INPUT -m state --state RELATED,ESTABLISHED -j ACCEPT
-A INPUT -p icmp -j ACCEPT
-A INPUT -i lo -j ACCEPT
-A INPUT -p tcp -m state --state NEW -m tcp --dport 22 -j ACCEPT
-A INPUT -p udp -m udp --dport 51820 -j ACCEPT
-A INPUT -j REJECT --reject-with icmp-host-prohibited
    "openInput": {
      "v4": [
        "22/tcp",
        "51820/udp"
      ],
      "v6": []
      "inSync": true

Staged apply through iptables-restore, keep window expires, rollback

   ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
   │                                                                                                               │
   │  Apply the staged batch                                                                                       │
   │                                                                                                               │
   │  These 1 change(s) apply through iptables-restore --noflush: all of them or none, once for IPv4 and once for  │
   │  IPv6. After they apply you have 1m0s to press k and keep them; if you lose access and do not, the snapshot   │
   │  below is restored automatically.                                                                             │
   │                                                                                                               │
   │  Command to run:                                                                                              │
   │  $ # iptables-restore --noflush                                                                               │
   │  *filter                                                                                                      │
   │  -I INPUT 6 -p udp -m udp --dport 41641 -j ACCEPT                                                             │
   │  COMMIT                                                                                                       │
   │                                                                                                               │
   │  y confirm    n cancel                                                                                        │
   │                                                                                                               │
   ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
 tui-firewall  iptables (nf_tables) (root)  ·  Chain: INPUT (iptables)  ·  14 chains, [ ] or v switches
 status: enabled   incoming: allow   backend: iptables 1.8.10 (untested)   staging: awaiting keep (k)
 saved: differs: 1 line not saved (W)
 a add  d delete  v view  p policies  W persist  k keep  / filter  ? help  q quit
 applied — press k to keep, or it reverts in 1m0s
-P INPUT ACCEPT
-A INPUT -m state --state RELATED,ESTABLISHED -j ACCEPT
-A INPUT -p icmp -j ACCEPT
-A INPUT -i lo -j ACCEPT
-A INPUT -p tcp -m state --state NEW -m tcp --dport 22 -j ACCEPT
-A INPUT -p udp -m udp --dport 51820 -j ACCEPT
-A INPUT -p udp -m udp --dport 41641 -j ACCEPT
-A INPUT -j REJECT --reject-with icmp-host-prohibited
(no k within 60s)
 tui-firewall  iptables (nf_tables) (root)  ·  Chain: INPUT (iptables)  ·  14 chains, [ ] or v switches
 status: enabled   incoming: allow   backend: iptables 1.8.10 (untested)   saved: in sync
 Restore the filter rules captured before the staged apply: done  ·  runtime only, W persists
-P INPUT ACCEPT
-A INPUT -m state --state RELATED,ESTABLISHED -j ACCEPT
-A INPUT -p icmp -j ACCEPT
-A INPUT -i lo -j ACCEPT
-A INPUT -p tcp -m state --state NEW -m tcp --dport 22 -j ACCEPT
-A INPUT -p udp -m udp --dport 51820 -j ACCEPT
-A INPUT -j REJECT --reject-with icmp-host-prohibited

lab.sh test tui-firewall --bin ... ubuntu

PASS  report names the selected backend
PASS  report says the run was live
PASS  report works in demo mode too
PASS  and says so on the mode line
PASS  report leaks neither a home path nor the host name
PASS  check reads the iptables backend
PASS  and says why it chose it
PASS  the variant matches `iptables --version` (nf_tables)
PASS  INPUT has 6 rules, as `iptables -S INPUT` says
PASS  a new rule goes right before the catch-all (rule 6)
PASS  the persistence layer is reported
PASS  and the saved rules could be compared
PASS  a runtime-only rule is reported as not saved
PASS  and its port as open
PASS  removing it restores the saved state (inSync: true)
PASS  --backend nftables refuses ip filter / INPUT
PASS  and names the iptables backend instead
PASS  the report names every backend it knows
compat-result: {"backend":"iptables","date":"2026-09-25","distro":"ubuntu-24.04","result":"pass","suite":"smoke","tool":"tui-firewall","version":"1.8.10"}
--- tui-firewall: 18 passed, 0 failed

Not covered

  • iptables-services (RHEL) persistence is built and unit-tested (service iptables save / service ip6tables save) but not run on a lab guest: the Fedora guest was busy.
  • The legacy-variant selection path is unit-tested only; the lab guest is iptables-nft.

🤖 Generated with Claude Code

https://claude.ai/code/session_01MbCfX3T8khyd9hsrN5kMo3

edimarlnx and others added 4 commits September 25, 2026 00:54
On a stock cloud image (Ubuntu with iptables-persistent, INPUT ending in a
catch-all REJECT, docker chains) 0.4.1 picked nftables and listed
ip filter / INPUT as writable: an allow would have been appended after the
REJECT as a native nft rule, never matching, possibly breaking
iptables-save, and gone at the next boot.

The new backend reads iptables-save/ip6tables-save (-c), inserts every rule
in front of the chain's catch-all with the position and the reason in the
preview, deletes by specification, and makes persisting a separate,
previewed step (netfilter-persistent save, or service iptables save on
RHEL) with a diff against the saved files. The header, the status line and
--check report runtime-vs-saved drift, ignoring the chains docker and
tailscaled rebuild at start. Staging reuses the session with a dialect:
iptables-restore --noflush per family, rolled back from a filter snapshot.

Selection picks iptables when its loader is active or enabled, when the nft
ruleset shows iptables-nft filter tables with rules of the operator's own,
or when the legacy tables carry rules; docker plumbing alone does not count.
The nftables backend now refuses to write into any xtables table and points
at --backend iptables.

Fixtures are scrubbed captures from a real host; a namespace test drives a
real iptables under unshare -rn.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MbCfX3T8khyd9hsrN5kMo3
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
Naming the fourth backend pushed it to 86; the validate job refuses that.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MbCfX3T8khyd9hsrN5kMo3
Naming the fourth backend pushed the tagline, the description, the W key
and needs_sudo past their maxLength; the package description follows the
tagline, as check-nfpm requires.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MbCfX3T8khyd9hsrN5kMo3
@edimarlnx
edimarlnx merged commit 00e649c into main Sep 25, 2026
8 checks passed
@edimarlnx
edimarlnx deleted the feat/iptables-backend branch September 25, 2026 04: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.

iptables backend: hosts managed by iptables(-nft) + iptables-persistent / iptables-services

1 participant