Beta. The family is days old and still changing. Package names, flags and keys may move without notice until 1.0. Pin versions, and report what breaks.
A terminal UI for the Linux firewall — ufw, firewalld, iptables and nftables — that shows the rules you actually have and previews the exact command line of every change before running it.
None of them has a TUI: you get the CLI, or a desktop GUI. tui-firewall
fills the gap on a server or a tiling desktop, in the
Omarchy visual style. It picks the backend your machine
actually runs, and the screen is built from what that backend can do — one rule
list and three default policies for ufw, one group per zone with its target for
firewalld, the iptables chains of a cloud image whose rules are restored at boot
by iptables-persistent, and for a machine none of them manages, the raw nftables
ruleset read straight from nft: filter chains by hook, a NAT view, and named
sets as aliases.
Status: early, under validation. An independent tool that follows the Omarchy visual style; it is not part of the Omarchy project and not endorsed by its maintainers. Expect rough edges.
Needs the tui-tools repository, which is a one-time setup.
The one-liner detects the distribution and adds the repository and its signing key:
curl -fsSL https://pkgs.tui.tools/install.sh | shPiping a script into a shell is not this family's style, so here is the same
setup by hand — read it, or read the script first with curl -fsSL https://pkgs.tui.tools/install.sh -o install.sh:
curl -fsSL -o /tmp/tui-tools.asc https://pkgs.tui.tools/pubkey.asc
sudo pacman-key --add /tmp/tui-tools.asc
sudo pacman-key --lsign-key \
"$(gpg --show-keys --with-colons /tmp/tui-tools.asc | awk -F: '/^fpr:/{print $10; exit}')"
printf '[tui-tools]\nServer = https://pkgs.tui.tools/arch/$arch\n' \
| sudo tee -a /etc/pacman.conf
sudo pacman -SyThen, and for every other tool in the family:
sudo pacman -S tui-firewallUpgrades then arrive with the rest of your system updates.
Needs the tui-tools repository, which is a one-time setup.
The one-liner detects the distribution and adds the repository and its signing key:
curl -fsSL https://pkgs.tui.tools/install.sh | shPiping a script into a shell is not this family's style, so here is the same
setup by hand — read it, or read the script first with curl -fsSL https://pkgs.tui.tools/install.sh -o install.sh:
sudo install -d -m 0755 /etc/apt/keyrings
curl -fsSL https://pkgs.tui.tools/pubkey.asc \
| sudo gpg --dearmor -o /etc/apt/keyrings/tui-tools.gpg
echo "deb [signed-by=/etc/apt/keyrings/tui-tools.gpg] https://pkgs.tui.tools/deb stable main" \
| sudo tee /etc/apt/sources.list.d/tui-tools.list
sudo apt updateThen, and for every other tool in the family:
sudo apt install tui-firewallUpgrades then arrive with the rest of your system updates.
Needs the tui-tools repository, which is a one-time setup.
The one-liner detects the distribution and adds the repository and its signing key:
curl -fsSL https://pkgs.tui.tools/install.sh | shPiping a script into a shell is not this family's style, so here is the same
setup by hand — read it, or read the script first with curl -fsSL https://pkgs.tui.tools/install.sh -o install.sh:
sudo rpm --import https://pkgs.tui.tools/pubkey.asc
sudo curl -fsSL -o /etc/yum.repos.d/tui-tools.repo https://pkgs.tui.tools/rpm/tui-tools.repo
sudo dnf makecacheThen, and for every other tool in the family:
sudo dnf install tui-firewallUpgrades then arrive with the rest of your system updates.
curl -fsSL https://github.com/tui-tools/tui-firewall/releases/download/v0.6.2/tui-firewall_0.6.2_linux_amd64.tar.gz | tar -xz tui-firewall
sudo install -m0755 tui-firewall /usr/local/bin/tui-firewallOne static binary. Verify it against checksums.txt from the same release.
git clone https://github.com/tui-tools/tui-firewall
cd tui-firewall && make build
sudo install -m0755 bin/tui-firewall /usr/local/bin/tui-firewallNeeds Go 1.26 or newer.
Not packaged for these yet; the static binary works everywhere in the meantime.
paru -S tui-firewall-binThe -bin package installs the released static binary.
Needs the tui-tools repository, which is a one-time setup.
sudo zypper install tui-firewallThe rpm repository is shared with dnf; zypper support is not tested yet.
Every release of tui-firewall ships a checksums.txt. Check an archive
against it before installing:
sha256sum -c checksums.txt --ignore-missingWebsite: https://tui.tools/tools/tui-firewall/
One static binary, no daemon, no state of its own. Nothing keeps running after you quit.
tui-firewall --demo # an in-memory ufw
tui-firewall --demo=firewalld # an in-memory firewalld
tui-firewall --demo=iptables # a cloud image: INPUT ends in a REJECT, docker chains
tui-firewall --demo=nftables # a sample router ruleset--demo runs against an in-memory sample firewall. Every key works, every
command is built and previewed for real, and nothing touches your system. The
four demos are the four real backends over fake output, not four skins: each
one parses backend-shaped output with the same parser and builds its commands
with the same builder. The nftables demo starts from a router ruleset — filter
chains for input, forward and output, the two NAT chains, and the named sets the
rules use as aliases.
y runs the command shown, n does not. There is no other path to a change:
the UI hands the same value to the preview and to the runner, so what you read
is what executes.
tui-firewall # drive the real firewall
tui-firewall --demo # sample ufw data, no privileges needed
tui-firewall --demo=firewalld # sample firewalld data
tui-firewall --demo=iptables # sample cloud-image iptables
tui-firewall --demo=nftables # sample router ruleset
tui-firewall --check # read the firewall, print JSON, exit
tui-firewall --report # print what a bug report needs, exit
tui-firewall --open 19443/tcp,41641/udp --comment 'headscale control (tailnet)'
# open the add form prefilled, per port
tui-firewall --backend ufw # skip autodetection
tui-firewall --backend firewalld
tui-firewall --backend iptables
tui-firewall --backend nftables
tui-firewall --theme ~/mytheme/colors.toml
tui-firewall --sudo "" # run the firewall command directly (as root)
tui-firewall --version--check is the non-interactive read path: it loads the firewall through the
same backend the UI would use, prints the parsed model as JSON and exits 0, or
exits 1 with the reason if the backend cannot be read. No UI, and it never
builds or runs a mutation, so it is safe to run anywhere.
$ sudo -n tui-firewall --check | head -8
{
"tool": "tui-firewall",
"version": "0.1.0",
"backend": "ufw",
"describe": "ufw via /usr/bin/sudo -n",
"enabled": true,
"logging": "low",
"groups": 1,It exists so a test can assert on what the tool parsed rather than on what it
painted — for instance that the rule count matches ufw status numbered, or
that a port added to the runtime configuration alone is reported as
"Note": "runtime only", which is what catches a parser regression.
The report also carries a backends block naming every backend this tool
knows and what the detector saw of it, so "firewalld was chosen" is
distinguishable from "ufw is not installed" without running the detection
again. The version is probed for the selected backend only: probing the other
would run a binary nobody asked this tool to touch.
What it costs is the backend's own reads, so it depends on the backend:
| Backend | What --check runs |
Typical time |
|---|---|---|
| ufw | ufw status verbose, ufw status numbered, ufw app list |
well under half a second |
| nftables | nft -j list ruleset |
well under half a second |
| iptables | iptables-save -c and ip6tables-save -c |
well under half a second |
| firewalld | eight firewall-cmd reads started together, plus firewall-cmd --version |
about 1.7 s on a 2-vCPU Fedora 44 VM (firewalld 2.4.4), down from 8 s |
firewalld is the slow one because every firewall-cmd is a Python
interpreter that imports the firewalld client before it makes its D-Bus
calls: 100 to 300 ms each, most of it start-up rather than firewalld
answering. Since 0.6.2 the tool asks for whole listings and starts them
together, so the cost is roughly that of the slowest one on an idle machine,
where 0.6.1 paid for 10 reads plus 2 per policy object one after another
(about 8 s on the lab's Fedora guest). With firewalld stopped every read
waits about 10 s for the daemon on D-Bus before giving up, so --check
takes that long to say it is not running.
A tool that reads --check on every refresh, as tui-wireguard and
tui-tailscale do, can keep the answer for a few seconds rather than asking
again for each redraw: the firewall rarely changes between two reloads, and
the tool that changes it knows when it did.
tui-lab uses it to test this tool
against real firewalls on Ubuntu, Fedora and Omarchy Server; the assertions live
in test/smoke.sh.
tui-firewall --open 19443/tcp,41641/udp --comment 'headscale control (tailnet)'
tui-firewall --open 19443/tcp --open 41641/udp # the same list, repeated
tui-firewall --demo --open 19443/tcp --comment test # try it on the demo--open starts the UI straight in the add-rule form, filled in to allow the
first port: action ALLOW, the port, the protocol and the --comment. Enter
shows the preview and y applies it, exactly as a rule added by hand with a;
nothing is applied without that confirm. Then the form for the next port opens,
one form and one confirm per port, in the order given. esc skips a port and
n at the confirm declines it; either way the sequence moves on. Every other
field of the form is still there to change before Enter.
It is the entry point for another tool that has just found a port closed, such as tui-tailscale handing over the ports a self-hosted control plane needs, so the user does not have to retype what that tool already knew.
Where the rule goes depends on the backend:
| Backend | Group the rule is added to |
|---|---|
| ufw | the rule list (ufw allow 19443/tcp comment 'headscale control (tailnet)') |
| firewalld | the default zone (--add-port=19443/tcp); firewalld has no rule comments, so --comment is not used and the status line says so |
| nftables | the input chain of the inet tui table, or else the first chain hooked on input; on an empty ruleset the status line says so and the forms open once the chains are created with x |
| iptables | the filter INPUT chain of each family, so a port gets one form for iptables and one for ip6tables |
The value is checked before anything is read: each entry is a single port from
1 to 65535 and tcp or udp, a port may not be given twice, and the comment is
one line of at most 128 characters. A wrong value exits with the reason instead
of opening a half-filled form. --comment without --open, and --open with
--check or --report, are refused the same way. --check itself is
unchanged.
Every preview is shell-quoted, so a comment with a space or a parenthesis reads as one argument, and the line pasted into a shell runs the same argv:
$ /usr/bin/sudo -n ufw allow 19443/tcp comment 'headscale control (tailnet)'--report prints, in one block, everything a maintainer has to ask for
otherwise: the tool and kit versions, the backend and the version probed off
it, what the detector saw of the backend it did not choose, the distribution,
the kernel, the terminal, the theme, the escalation prefix, and whether the
running binary came from a package. It needs no privileges and reads no
firewall, so it works on the machine where the bug is — including one where no
backend can be selected at all, which is itself a thing worth reporting.
$ tui-firewall --report
tui-firewall 0.2.2 (kit v0.2.9)
backend: firewalld 2.3.2
mode: live
distro: fedora 42 (Fedora Linux 42 (Workstation Edition))
kernel: 6.19.14-108.fc42.x86_64
arch: x86_64
locale: en_US.UTF-8
term: xterm-256color
theme: tokyo-night
sudo: sudo -n
root: no
binary: /usr/bin/tui-firewall (packaged)
backends: ufw absent, firewalld activeThe block is written to be published as it is: it carries no hostname, user
name, home path or address, and no environment variable beyond LANG,
LC_ALL, TERM and TERM_PROGRAM. A binary living under your home directory
is reported as being there without naming the path. --report works with
--demo too, where it says so on the mode line.
The bug form asks for this block first — see
.github/ISSUE_TEMPLATE/bug_report.yml.
tui-firewall needs root to change anything, and to read anything on ufw. When
it is not running as root it uses sudo -n, which never prompts: run sudo -v
in another terminal first, or start tui-firewall with sudo. If neither
firewall nor sudo is available, it says so and points at --demo.
| Key | Action |
|---|---|
↑/k, ↓/j |
Move the selection |
g / G |
First / last rule |
pgup / pgdn |
Scroll a page |
/ |
Filter rules (matches any column; esc clears) |
a |
Add a rule |
E |
Edit the selected rule in place — same handle, same position (nftables) |
K / J |
Move the selected rule up / down, as one atomic transaction (nftables) |
D |
Disable or enable the selected rule, keeping its spec and its position (nftables) |
d |
Delete the selected rule |
e |
Enable or disable the firewall (ufw only) |
r |
Reload the firewall |
p |
Change a default policy (ufw), a zone target (firewalld) or a chain policy (iptables, nftables) |
L |
Change the logging level (ufw) or the log-denied value (firewalld) |
l |
Toggle logging on the selected rule (nftables) |
w |
Watch the live firewall log of the logged rules (nftables) |
W |
Save the tool's own table to a file loaded on boot, with a diff preview (nftables) |
W |
Persist the running rules to the files restored at boot, with a diff preview (iptables) |
x |
Actions this backend offers beyond these keys |
[ / ] |
Previous / next group — the firewalld zones and policy objects |
R |
Re-read the firewall |
? |
Help |
q |
Quit |
In the add rule form: tab / shift+tab move between fields, ←/→
cycle a choice, enter opens a picker on a choice field and submits from a
text field, esc cancels.
A firewalld zone holds several species of entry at once, so each row carries its kind — service, port, protocol, source port, forward port, bound source, assigned interface, masquerading, intra-zone forwarding, ICMP block, rich rule. The kind is what decides which flag removes it, which is why it is shown rather than flattened away.
firewalld keeps two configurations: the one enforced right now and the one that
survives a reload or a reboot. They routinely differ — an interface
NetworkManager assigned, a port someone added without --permanent, a change
made permanently and not yet reloaded. tui-firewall reads both
(--list-all-zones and the same with --permanent) and marks the difference in
a WHERE column: nothing when an entry is in both, runtime only or
permanent only when it is not.
That also decides what a delete does. Removing an entry that exists in both issues both commands; removing one that exists in the runtime only issues the runtime command alone, because the permanent form would simply fail.
Every change runs twice: the command as it is, then the same command with
--permanent. Both lines are in the confirm dialog, and nothing else runs.
firewall-cmd --zone=public --add-service=wireguard
firewall-cmd --permanent --zone=public --add-service=wireguardThe alternative — --permanent followed by --reload — was rejected on
purpose: a reload rebuilds the rule set and can drop established connections,
which is a bad thing to do to someone who just wanted to open a port over SSH.
There is exactly one change firewalld has no runtime form for, the zone
target: --set-target is permanent-only, so that change is written
permanently and applied with a reload. The dialog says so before you confirm.
x opens the actions a backend offers that the common key map has no place
for. For firewalld those are: set the default zone, move an interface to
another zone, bind a source address to this zone, turn masquerading on or off,
turn panic mode on or off, and save the running configuration as permanent
(--runtime-to-permanent). Each one previews its commands like any other
change, and panic mode says in as many words that it drops the SSH session you
are reading it in.
The UI does not know what any of these mean: the backend describes them, the UI
collects the answers and previews whatever comes back. That is the same reason
e is absent on firewalld — starting and stopping a system service is
systemctl's job, and the tool says so instead of doing it quietly.
Many cloud images ship no ufw and no firewalld. Their firewall is a plain
iptables ruleset, restored at boot by iptables-persistent (Debian, Ubuntu:
/etc/iptables/rules.v4 and rules.v6) or iptables-services (Fedora, RHEL:
/etc/sysconfig/iptables), and the INPUT chain ends in a catch-all:
-A INPUT -p tcp -m state --state NEW -m tcp --dport 22 -j ACCEPT
-A INPUT -j REJECT --reject-with icmp-host-prohibited
On such a machine tui-firewall drives iptables and ip6tables directly. It
reads the rules with iptables-save -c and ip6tables-save -c (both the
nf_tables and the legacy variants), shows one view per chain and family,
INPUT (iptables) beside INPUT (ip6tables), and writes only to the built-in
INPUT, FORWARD and OUTPUT chains of the filter table. The chains docker,
tailscaled, kube-proxy or fail2ban create are shown and left alone, because
those daemons rebuild them every time they start.
An appended allow after the catch-all never matches. So a new rule is inserted in front of the catch-all REJECT or DROP, and the preview says so:
Insert a rule at position 5 of INPUT (iptables)
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
$ sudo -n iptables -I INPUT 5 -p udp -m udp --dport 51820 -j ACCEPT
A chain with no catch-all gets the rule at its end, where the policy decides
what nothing matched. An explicit position in the form is honoured, unless it
lands after the catch-all, which is refused with the reason. A rule is deleted
by its specification (iptables -D INPUT -p tcp ... -j ACCEPT), not by its
number, so a rule another daemon inserted in the meantime cannot shift the
delete onto a neighbour.
A change applies to the running kernel, and the next boot restores the saved
file. The header says which state you are in (saved: in sync, or
saved: differs: 1 line not saved (W)), the status line repeats it after every
change, and W persists, with the diff between the saved file and the running
rules in the preview:
The command is the persistence layer's own: netfilter-persistent save on
Debian and Ubuntu, /usr/libexec/iptables/iptables.init save and
ip6tables.init save on Fedora and RHEL (what service iptables save runs,
without needing the service wrapper installed).
The comparison covers the filter table and leaves out the chains other daemons
rebuild, so a host where tailscaled added ts-input after the last save still
reads as in sync.
Staging (s, S, k) works here too: the batch applies through
iptables-restore --noflush, all or nothing per address family, and if the
keep window runs out the filter table is restored from a snapshot taken right
before the apply.
auto picks 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 carrying rules of the operator's
own, or when the legacy iptables tables do. Docker's plumbing alone does not
count: a pure-nft host running docker stays on nftables. A running ufw or
firewalld always wins, since both of them drive iptables underneath. The
selection line in the header, --check and --report says which of these it
was, and names any native nft table the iptables backend does not show.
The nftables backend, in turn, refuses to write into a table iptables-nft owns
(ip filter with its upper-case chains and xtables matches): a native rule
there can make iptables-save refuse the table, which breaks
netfilter-persistent save, and is lost when the saved rules are restored at
boot. The refusal points at --backend iptables.
--check carries an iptables block: the variant, the writable chains and
where a new rule would land in each, the ports INPUT accepts ahead of its
catch-all (and any accept that sits after it and never matches), and the
persistence layer with the drift between running and saved rules.
On a machine that runs neither ufw nor firewalld, tui-firewall reads nft -j list ruleset and drives nft itself. It is the third backend, picked
automatically when the other two are absent, and the screen is built from the
ruleset it finds rather than from a fixed layout.
Because nftables is not a policy manager but the packet path itself, the rule
list reads the way a router's rule list reads: the verdict, the interfaces
the packet passes between (iifname / oifname), the connection state it
matches (ct state established,related), the protocol and port, and the
counter that says whether the rule has ever fired. A chain has a view of its
own, one group per hooked chain, stepped through with [ and ] or picked from
v; base chains show their policy even when they hold no rules, because the
policy is a fact worth seeing.
Two chain types get a view shaped for the question they answer:
-
NAT. Address translation is read as what does this become, not what is allowed, so the masquerade, SNAT and DNAT rules of the
natchains are shown in their own view with the translated target as the column the eye lands on. -
Aliases. A named set is an alias a rule refers to by name. Each is listed with what it holds, its members, and how many rules use it — a set nothing refers to is dimmed as dead weight, and one four rules refer to is four rules you change by editing it.
The add-rule form carries the matches a router rule needs that ufw and firewalld never expose: inbound and outbound interface, connection state, ICMP type, address family, and an alias as the source.
nftables has no permanent/runtime split and no manager to answer to, so a write
is a change to the live ruleset. That makes it easy to lock yourself out, and
tui-firewall is deliberately narrow about where it writes: only to a base
chain whose policy nft reported, or to its own table. A chain that some
other manager owns is shown but marked read-only, and the tool says why in the
chain's own description rather than letting you find out at the confirm dialog.
Every rule is deleted by its handle, which nft -j carries in the ruleset,
because a rule's position shifts the moment anything is inserted above it.
Changing many rules on a router one command at a time is how a remote session
dies halfway through, with the box in a state that is neither the old ruleset nor
the new one. nftables can do better, and this backend uses it: press s to turn
staging on, and every change is collected instead of applied. S reviews
the pending set.
Applying the batch is one nft -f transaction: all of the changes, or none,
never a half-applied ruleset. Before it runs, the whole transaction is previewed
— the script that goes to nft's standard input, which no single command line
could show.
The apply is also connectivity-safe. Just before the batch runs, the tool
snapshots the current ruleset; then it applies, and starts a timer. If you press
k to say you still have access, the batch stays. If you do not — because the
change you just made cut off the session you are reading this in — the snapshot
is restored automatically when the timer expires, and you are back on the ruleset
that was working. It is the standard iptables-apply idea, done as one nft
transaction.
The nftables backend does not stop at adding and deleting.
Edit in place. Press E on a rule and the add-rule form opens pre-filled
with the rule as it stands — interfaces, connection state, alias source,
comment, all of it read back from the modelled match. Submitting builds one
nft replace rule … handle H, the same replace-by-handle the log toggle uses,
so the rule keeps its handle and its position. A rule the tool cannot hold in
full — one with a match shown only as text, a NAT translation, a source-port
match, a log statement with a level or an nflog group — is refused before the
form opens, with the reason, rather than silently rewritten with half of
itself missing.
Move. K and J move a rule one position up or down. nftables has no
move command, so a move is a copy written at the new position plus a delete of
the old handle — two statements that only make sense together, which is why
they always run as one nft -f transaction: wrapped as one when staging
is off, or staged into the atomic batch when it is on. The rule is never
duplicated or missing in between; the moved copy gets a fresh handle and a
reset counter, and the preview says so.
Save. Everything above changes the running ruleset, which a reboot
throws away. Press W and the tool serialises its own table — nft list table inet tui, nft's own text, which is a valid nft script — and installs it
with install -m 644 /dev/stdin <path>. The confirm dialog shows a unified
diff against the file as it currently stands, so "what does saving change"
is answered before a byte is written. On an Omarchy router the file is
/etc/omarchy/router/tui-firewall.nft, which the router profile includes on
boot; on any other machine it is /etc/nftables.d/tui-firewall.nft, loaded by
the usual include from /etc/nftables.conf (the dialog spells this out), and
TUI_FIREWALL_SAVE_PATH overrides the path for tests and labs. Saving writes
the running table: staged changes that were not applied yet are not in the
file, and the dialog says so.
Disable. Press D and the selected rule stops being enforced without
being lost. nftables has no per-rule off switch — a rule is in the ruleset or
it is not — so disabling is nft delete rule … handle H plus a record of the
statement the rule was and the position it sat at. The rule stays in the table
as a greyed-out row marked disabled, drawn from that record rather than from
the kernel, and D again puts it back where it was with nft insert rule … index N (or appends it, if the chain has become shorter since). Only rules
the tool holds in full can be disabled: the same refusals E makes, for the
same reason.
That record is written to the same file W saves, as comment lines nft -f
ignores and this tool reads back:
# tui-firewall:spec v2
table inet tui {
…
}
# tui-firewall:disabled {"index":2,"expr":["iifname","\"wan0\"","udp","dport","53","counter","drop"],…}
The live half of the file is still nft's own listing, byte for byte, so the
boot path is exactly as it was — and a file written before this feature
existed still loads, as a ruleset with nothing disabled. A ruleset with
nothing disabled still writes that same v1 file, so nobody who does not use
D sees the format at all. Disabling never joins a staged batch (the ruleset
and the record have to change together), and it offers the save straight
afterwards, because until it is saved the disabled rule lives only in the
running process — which is what the rules: 1 disabled, unsaved (W) fact in
the header is telling you. DESIGN.md has the format in full.
Press l on a rule to mark it for logging. nft rules are immutable, so the rule
is replaced in place — same handle, same position — with a log statement
carrying a stable prefix, tui:<chain> <verdict> . The replacement is previewed
like every other change, and rebuilt from the rule's own modelled match, so a
rule the tool cannot hold in full is refused rather than rewritten from a
rendering. Press l again to remove the log. A rule that logs carries a LOG
marker in its row.
Press w for the live log: a read-only, OPNsense-style view of the kernel
firewall log, filtered to this tool's own prefix. Each logged packet appears with
its time, direction, action, source, destination, port and prefix, newest at the
bottom. The nftables log statement writes to the kernel log, which journald
carries, so the view reads journalctl --kernel --follow; a machine without
journald is told so plainly. The feed pauses with space and the retained lines
are capped, so a firewall under a scan cannot grow it without bound.
Every backend
- Read the live firewall and show status, default policies and logging.
- List rules with action, source, destination, ports, protocol, service, address family and comment; filter across every column.
- Add and delete rules, reload, change a default policy, change the logging level — each previewed and confirmed first.
- Follow the active Omarchy theme, and respect
NO_COLOR.
ufw
- Read
ufw status verbose,ufw status numberedandufw app list. - Default policies for incoming, outgoing and routed traffic;
routerules and IPv6 rules included. - Add a rule with an action (allow/deny/reject/limit), a direction, a port, port list or range, a protocol, an app profile, source and destination CIDR, a comment, and insertion at a given position.
- Enable and disable the firewall.
firewalld
- Read
--list-all-zonesand--list-all-policies(runtime and permanent),--get-services,--get-log-denied,--query-panicand--query-lockdown, all eight started together. The default zone and the active zones come from the(default, active)flags of the zone listing; a firewalld that prints no flags, or has no--list-all-policies, is asked--get-default-zone,--get-active-zonesand--get-policiesinstead, and--stateis asked only when the zone listing fails, to tell a stopped daemon from a refused read. - One group per zone, default zone first, then the other active zones; policy objects follow as further groups.
- Every entry kind, each marked runtime-only or permanent-only where they differ, and each deletable with the right flag.
- Add a service or a port; anything with an address, an address family, a logging flag or a reject/drop verdict is built as a rich rule instead, from the same guided form.
- Zone target, log-denied value, reload, and the actions menu above.
iptables
- Read
iptables-save -candip6tables-save -c(nf_tables or legacy) and show INPUT, FORWARD and OUTPUT of both families, then every other chain that holds a rule, read-only. - Insert a rule right before the chain's catch-all REJECT or DROP, with the position and the reason in the preview; refuse a position after it. Delete by specification.
- Persist with
netfilter-persistent saveor iptables-services'iptables.init save, previewed with the diff against the saved files; report runtime-vs-saved drift in the header, the status line and--check. - Stage changes and apply them through
iptables-restore --noflush, with the same keep-or-roll-back window as nftables.
nftables
- Read
nft -j list rulesetand show every base and used regular chain, its hook and its policy, as one group each. - The router reading of a rule: inbound and outbound interface, connection state, protocol, port, address family, counter and comment.
- A NAT view for masquerade, SNAT and DNAT port forwards, and an aliases view of the named sets with the number of rules using each.
- Add a rule with an action (accept/drop/reject), an interface pair, a connection-state match, an ICMP type, an alias as the source, and a family; add a masquerade, a port forward or a named set from the actions menu.
- Write only to a base chain nft reported a policy for, or to the tool's own table, and say why when it will not. Delete by handle.
- Stage changes and apply them as one atomic
nft -ftransaction, with an automatic connectivity-safe rollback if the apply is not kept in time. - Edit a rule in place with
E: the form opens pre-filled from the rule's own modelled match and the submit is onenft replace rule … handle H, keeping the handle and the position; a rule the model does not hold in full is refused, never half-rewritten. - Move a rule up and down with
KandJ, as one atomicnft -ftransaction (a copy at the new position plus a delete of the old handle), staged into the batch when staging is on. - Disable a rule with
Dand enable it again withD: the rule leaves the ruleset and stays on screen as a greyed-out row, rebuilt from the tool's own persisted spec and put back at the position it had. - Save the tool's own table to disk with
W—nft list table inet tuiinstalled to/etc/omarchy/router/tui-firewall.nfton an Omarchy router,/etc/nftables.d/tui-firewall.nftelsewhere — with a unified diff against the current file in the confirm dialog. The disabled rules ride in the same file as commentsnft -fignores, so it stays loadable on boot as it is. - Mark a rule to be logged with
l: the rule is replaced in place, keeping its handle and position, with a stablelog prefix "tui:<chain> <verdict> "the live view greps for. Toggle it off the same way. - Watch the logged rules fire in real time with
w: a live view of the kernel firewall log — time, direction, action, source, destination, port and the log prefix — read from journald and filtered to the tool's own rules, pausable and bounded so it never grows without limit.
- No rule editing on ufw or firewalld. Change a rule by deleting it and
adding the new one; only the nftables backend edits in place with
E. - No
ufwinterface qualifiers (ufw allow in on eth0 …): they are parsed and displayed, but the form cannot create them. - No
ufwapplication profile management (only using existing profiles). - No firewalld service, ipset or icmptype editing, and no zone creation: zones and services are used, not defined.
- A firewalld policy object is read-only-ish: its entries are listed and can be added and removed, but its target and its ingress/egress zones are not editable here.
- No live log tail on ufw or firewalld — it is an nftables feature, since it
reads the kernel log the nftables
logstatement writes. - No nftables set-element editing: a named set is created or used, and its members are added and removed one at a time from the actions menu, not edited in bulk.
- No per-rule enable/disable on ufw or firewalld: it needs the tool's own persisted spec, which only the nftables backend has. On nftables the spec is not reconciled against a ruleset something else changed behind this tool's back, so a rule enabled after another tool has reshuffled the chain lands at the recorded index rather than beside the neighbour it had.
- On ufw and firewalld, rules follow the backend's own order; the move keys are an nftables feature.
tui-firewall probes its backend once at startup and shows the version in the
header. A version nobody has tested is marked (untested) there rather than
hidden; one below the minimum is marked as such and the tool still runs.
| Binary | ufw |
| Version read with | ufw --version |
| Minimum | 0.36 |
| Tested | 0.36.2 |
| Version-gated features | rule-comments (since 0.35) |
| Versions | What changes |
|---|---|
<0.36 |
ufw status numbered has no app profile column, so a rule added from a profile is shown by its ports and cannot be edited as a profile |
0.36.x |
status numbered indexes IPv4 and IPv6 halves of one rule separately, so deleting by number renumbers the rest and the list is re-read after every delete |
| Binary | firewall-cmd |
| Version read with | firewall-cmd --version |
| Minimum | 0.9 |
| Tested | 2.4.4 |
| Versions | What changes |
|---|---|
==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 |
>=2.2 |
firewalld removed the lockdown feature, so no lockdown state is shown |
| Binary | iptables |
| Version read with | iptables --version |
| Minimum | 1.6 |
| Tested | 1.8.10, 1.8.11 |
| Versions | What changes |
|---|---|
<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 |
>=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 |
| Binary | nft |
| Version read with | nft --version |
| Minimum | 0.9 |
| Tested | none yet |
| Versions | What changes |
|---|---|
<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 |
>=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 |
The tested versions are generated from compat/results.jsonl, which the tool's
own smoke test appends to when it runs against a real machine in
tui-lab.
The nftables backend shows no tested version yet because no smoke run has
appended one to compat/results.jsonl. It has been exercised end to end in the
tui-lab router-topology scenario — a
libvirt router on Ubuntu noble, where the read path, the router matches, the NAT
and alias views and the staged atomic apply were driven against a real nft,
alongside the network-namespace tests in this repo. A tested version will land
here once that run records one through the smoke test.
/etc/tui-firewall/config.toml, then ~/.config/tui-firewall/config.toml (the
user file overrides the machine-wide one), then TUI_FIREWALL_* in the
environment. Flags override everything. See
examples/config.toml.
# Which firewall to drive: "auto", "ufw", "firewalld", "iptables" or "nftables".
backend = "auto"
# Privilege escalation prefix; "" runs the command directly.
sudo = "sudo -n"
# Path to an Omarchy-style colors.toml; empty follows the active theme.
theme = ""With backend = "auto", tui-firewall takes the installed backend whose system
service is running. If neither is running it takes the one systemd would start
at boot (systemctl is-enabled), and if neither is enabled either, the first
one installed — ufw before firewalld. Having both on one machine is a
misconfiguration rather than a supported setup, so the tie-break exists to be
predictable; name the one you mean when both are present. Then comes iptables,
when a loader restores its saved rules or its tables carry rules of their own
(see iptables), and last nftables, for a machine nothing else
manages. With nothing installed it exits with a message naming what to install.
The default palette is Tokyo Night. On Omarchy, the tool reads the active
desktop theme from ~/.config/omarchy/current/theme/colors.toml and follows it.
TUI_THEME or --theme override; NO_COLOR drops color and keeps layout. The
rules live in tui-kit.
The UI never builds a ufw command line. It talks to
internal/firewall.Backend, which returns a backend-neutral model:
Model{Enabled, Logging, Groups []Group}
Group{Name, Default policies, PolicySlots, Rules []Rule}
Rule{Action, Direction, Proto, Ports, From, To, Service, Comment, Family, Raw, Extra}
ufw exposes a single group (rules) carrying the global in/out/routed
policies; the group selector stays hidden when there is only one. firewalld
exposes one group per zone with the zone target as its policy, plus one per
policy object — the full mapping is documented at the top of
internal/firewalld/firewalld.go. nftables exposes one group per hooked or used
chain with its policy, plus a NAT view and an aliases view; the Extra map is
where the router-shaped fields ride — interface, connection state, counter,
translated target, set membership — so the same Rule serves every backend
without the model growing a column per feature.
Mutations are firewall.Change values produced by the backend: an ordered list
of runner.Commands with the description and the danger flag the dialog is
painted from. The list exists because firewalld needs two invocations to apply
one change, and making that a list rather than a hidden second exec keeps the
promise intact — every line the dialog shows is a line that runs, and nothing
else does. On nftables a staged batch is the same idea taken to its limit: the
whole set of changes is one nft -f transaction, previewed as the script it
sends, applied all-or-nothing, and rolled back to a pre-apply snapshot if the
operator does not confirm they still have access in time. On confirmation the UI hands those commands back to the
kit runner,
which resolves the binary and the privilege prefix. That is the whole trust
boundary.
Everything else the UI shows is likewise the backend's answer, not a branch on
its name: which actions the add form offers, whether e exists at all, whether
rows carry a kind, what the logging concept is called, and what the x menu
holds. grep firewalld cmd/ finds a test and nothing more.
make check # gofmt, go vet and the tests: what CI runs
make test
make build
make demo
make screenshots # re-render the frames above from --demoDependencies are deliberately small: Bubble Tea, Bubbles and tui-kit, which carries the palette, the widgets, the config loader and the command runner shared by the whole family.
The-Robin-Hood/ufWall is an earlier
Go + Bubble Tea TUI for ufw (MIT, early-stage, inactive since March 2026);
tui-firewall is an independent implementation with its own parsers and a
backend-agnostic core that covers firewalld as well.
- Enabling the firewall over SSH can lock you out.
tui-firewallwarns before confirming, but add a rule for your SSH port first. ufw enableandufw deleteare run with--force, because ufw's own interactive prompt cannot be answered from inside a TUI. The confirm dialog replaces it.- firewalld panic mode drops every packet, including your own session, and is offered behind an explicit warning. It is not permanent: restarting firewalld clears it.
- A firewalld reload replaces the running configuration with the permanent one, so anything that was runtime-only is lost. That is why ordinary changes here do not reload.
tui-firewallre-reads the firewall after every change, so what you see is what the system reports, not what the tool assumed.
Contributions are welcome as pull requests; the workflow, the commit style and the checks a change has to pass are described in the family's CONTRIBUTING.md. Security vulnerabilities go through the family's SECURITY.md instead, never in a public issue.


















