ferm ("For Easy Rule Making", pronounced "firm") is a frontend for
iptables. It reads firewall rules from a structured, high-level
configuration language — with variables, functions, arrays, blocks and
includes — and installs them into the running kernel by calling
iptables(8) / iptables-restore. It also drives the ip6tables,
arptables and ebtables families.
The goal is to make rules easy to write and easy to read, so the administrator spends time designing good rules rather than transcribing them.
This repository is mid-migration from the original Perl implementation to Python:
src/pyferm/— the Python port. Phase 1 is complete: it parses the full ferm configuration language and emitsiptablesrulesets (ip,ip6,arp,ebfamilies), with both the fast (iptables-restore) and slow (per-rule) execution paths, the--interactiverollback safety net, and theimport-fermsave-file round-trip. Output is validated byte-for-byte against the Perl oracle.reference/— the original Perl implementation, kept verbatim as the semantic oracle. Run its own test suite withmake -C reference check.
Phase 2 (opt-in / experimental): a native nftables backend behind
--nft translates the same configuration into a native nft ruleset and
applies it atomically via nft -f -. The default backend stays
iptables, so existing configurations and output are unchanged unless
--nft is passed. --nft is opt-in and experimental: it carries
documented semantic differences (a policy DROP shift under the own-table
model, @preserve unsupported) and has golden + nft -c coverage but no
Perl-oracle differential test.
Later phases built on this base: packaged distributions (PyPI,
.deb/.rpm/.apk, a standalone binary), operational safety (--plan,
incremental --nft delta-apply, etckeeper-backed rollback), nft-native
sets and verdict maps, and read-only tooling (--lint, --graph,
--list-modules / --describe) — all documented below. The roadmap
lives in docs/ROADMAP.md.
main— the new default branch (release line).develop— active development; branch your work from here.python-port— the porting-process branch.master— frozen; kept for historical reference only.
- Python 3.11–3.14
iptables(includingiptables-save/iptables-restore) and a netfilter-capable kernel, at runtimeuvfor development
There are no required runtime dependencies. @resolve() uses
dnspython when it is installed (full record
vocabulary, including NS/MX); otherwise it falls back to the system stub
resolver (getaddrinfo, honouring /etc/nsswitch.conf), which answers only
A/AAAA records — other types then raise a clear error. Install the dns
extra (pip install ferm[dns]) for the full set of record types. The stub
resolver consults system sources such as /etc/hosts and mDNS that dnspython
bypasses, so the two backends can diverge when those local sources differ from
authoritative DNS.
Several distribution forms are available; all carry the same version derived
from the same git tag. PyPI, the native .deb, and the standalone binary are
documented below. Native .rpm (RPM-based distros) and .apk (Alpine)
packages are also built — install them with your distribution's package
manager; their migration behaviour is covered in the warning above.
Before enabling the ferm systemd service, read this section carefully.
The starter /etc/ferm/ferm.conf installed by the .deb package applies a
DROP policy to the INPUT chain. Only two narrow exceptions are open by
default: SSH on port 22 (identified by the service name ssh) and the
ICMPv6 essentials required by RFC 4890 (neighbour discovery, etc.). Any
other inbound traffic is dropped immediately on service start.
Before running systemctl enable --now ferm:
- Add every service you need to reachable through your firewall as a
drop-in fragment in
/etc/ferm/ferm.d/(for example, HTTP/HTTPS, custom application ports). - If SSH runs on a port other than 22, edit
$SSH_PORTin/etc/ferm/ferm.confbefore enabling. Enabling the service with the wrong SSH port will lock you out of a remote machine. - Fragments in
/etc/ferm/ferm.d/*.confare executed as root. Keep them owned byroot:rootwith permissions0644or stricter. A world-writable fragment is a privilege-escalation vector.
Migrating from the Perl ferm package: the pyferm .deb installs
over the Perl package via Provides/Conflicts/Replaces: ferm, but it does
not automatically enable or start ferm.service. If you relied on the
Perl package having the service enabled, you must re-enable it explicitly
after installing pyferm. This is intentional: the default configuration
above would otherwise lock you out on first boot.
Third-party packages that declare Depends: ferm (automation tooling,
configuration managers) will have their dependency satisfied by pyferm,
but any systemctl enable ferm those tools may run will apply the default
DROP configuration. Audit what your automation does before installing.
Alpine (.apk, OpenRC): the posture-downgrade advisory (a breadcrumb
warning that the firewall will no longer auto-apply after migrating) works
across the usual two-transaction migration (apk del ferm then
apk add pyferm), even though apk has no pre-removal hook. The OpenRC
runlevel symlink is admin state, not a package-owned file, so apk del ferm
leaves it in place (the upstream Alpine ferm aport ships no deinstall
scriptlet that would remove it), and apk add pyferm lays its own service
down before its post-install runs, so the symlink is present at probe time.
Detection uses [ -L ] (lstat), so it holds whether the symlink is live or
dangling. Fail-safe regardless — pyferm ships its service un-added to any
runlevel, so even a missed advisory never causes a lockout. The .deb/.rpm
packages snapshot the prior posture in a pre-install hook (which runs before
the legacy package is removed) across systemd, SysV and systemctl is-enabled.
pip install fermFor full DNS record-type support in @resolve() (including NS/MX),
install the dns extra:
pip install ferm[dns]The ferm and import-ferm console scripts are placed on PATH by pip.
Download pyferm_<version>_all.deb from the
GitHub Releases page and install
it:
sudo apt install ./pyferm_<version>_all.debapt install ./... (with the explicit ./ path) resolves dependencies
automatically. Do not use dpkg -i directly unless you handle dependencies
yourself.
The package name is pyferm but it declares Provides: ferm,
Conflicts: ferm, and Replaces: ferm. Installing it removes any
existing Perl ferm package and satisfies packages that depend on ferm.
When migrating from the Perl ferm, your edited /etc/ferm/ferm.conf is
kept: an interactive apt prompts you to keep it (the default), and for an
unattended upgrade pass -o Dpkg::Options::=--force-confold to keep it
without prompting.
After installation the service is not enabled. Review the warning above,
customise /etc/ferm/ferm.conf and add fragments to /etc/ferm/ferm.d/,
then opt in:
systemctl enable --now fermApplying changes — reload, don't restart: after editing
/etc/ferm/ferm.conf or a fragment, run systemctl reload ferm. reload
re-applies the ruleset atomically (via iptables-restore) with no window in
which the firewall is down. restart first runs the unit's ExecStop, which
flushes the rules (ferm -F) and briefly leaves the host open before they
are re-applied.
Coexisting with other firewall tools: ferm owns every table it manages and
replaces that table wholesale on each apply. Rules another daemon (Docker,
fail2ban, libvirt) writes into a ferm-managed table are therefore dropped on
the next reload unless you carry them across explicitly with the @preserve
keyword. The native --nft backend instead manages a single
table <family> ferm and leaves other tables untouched — but it does not
support @preserve.
A self-contained binary is published for Linux x86_64 (glibc 2.28
or newer). It carries its own Python runtime and a bundled dnspython,
so no Python installation is needed on the target host. It does not
bundle iptables or nft — those must already be present on the system,
because ferm calls them to install the rules.
Download the release tarball ferm-<version>-linux-x86_64.tar.gz and
unpack it, preserving symlinks:
tar xzf ferm-<version>-linux-x86_64.tar.gzThis produces a ferm.dist/ directory containing the ferm binary and,
next to it, an import-ferm symlink.
The ferm binary loads its bundled shared objects from its own directory
(via an $ORIGIN-relative runtime path). Do not move or copy the bare
ferm binary out of ferm.dist/ — a lone copy can no longer find its
libraries and will fail to start. To run it from a directory on PATH,
create a symlink to the binary instead of copying it; $ORIGIN still
resolves through the symlink:
ln -s /opt/ferm/ferm.dist/ferm /usr/local/bin/fermferm runs as root. Because the binary loads shared objects from its own
directory, a world- or group-writable dist directory lets a local
attacker plant a malicious shared object next to the binary that then
runs with root privileges. Unpack into a directory owned by root and
not writable by other users (for example /opt/ferm, mode 0755,
owner root), and verify the permissions before the first run as root:
sudo install -d -o root -g root -m 0755 /opt/ferm
sudo tar xzf ferm-<version>-linux-x86_64.tar.gz -C /opt/ferm
ls -ld /opt/ferm /opt/ferm/ferm.distAs a backstop, the standalone binary checks this itself: when run as root it
refuses to start if its own dist directory is not owned by root or is group-
or world-writable, printing how to fix the permissions. This is a safety net,
not a substitute for a correct install — it cannot detect a malicious shared
object already planted before the check. Set FERM_SKIP_DIST_PERM_CHECK=1 to
override it for a deliberately non-standard layout.
The release ships a SHA256SUMS file. It guards against accidental
corruption in transfer — integrity, not authenticity. A matching
checksum does not prove the file was not maliciously substituted,
because an attacker who can replace the tarball can replace the checksum
file too.
For authenticity, verify the build provenance attestation with the GitHub CLI:
gh attestation verify ferm-<version>-linux-x86_64.tar.gz --repo 6RUN0/fermAttestation only protects those who actually run the check, so verify every download rather than trusting the file blindly.
@resolve() looks names up through the host's /etc/resolv.conf, and
ferm runs as root. If a rule's correctness depends on the resolved
address (for example, restricting access to a named host), a tampered or
spoofed DNS answer can change which addresses the installed ruleset
trusts. For security-significant rules, use a trusted or local resolver,
or write static addresses directly.
# Inspect the generated rules without touching the kernel (the safe way):
uv run ferm --noexec --lines /etc/ferm/ferm.conf
# Install the ruleset into the running kernel (needs root):
uv run ferm /etc/ferm/ferm.conf
# Convert an existing firewall into a ferm config:
uv run import-ferm > /etc/ferm/ferm.confBe careful not to lock yourself out of a remote machine — use the
interactive mode (--interactive, -i) often. It installs the new
ruleset, then rolls back to the previous one unless you confirm in time.
ferm --plan computes the ruleset and reports what would change against
the live kernel without applying anything, for both the default
iptables backend and --nft. --plan-format selects a structured
summary (default) or a unified diff. The run is exit-coded: 0 when
nothing would change, non-zero otherwise. A modified named set is shown
with both its current and desired elements. @preserve is reported as
unsupported under --plan.
ferm --plan /etc/ferm/ferm.conf # what would change?
ferm --plan --plan-format diff /etc/ferm/ferm.conf # as a unified diff
ferm --plan --nft /etc/ferm/ferm.conf # against the nft backendWhen etckeeper manages /etc, every
successful apply records a commit in the /etc history with a semantic
message describing what changed in the kernel ruleset (for example
filter/INPUT: +3 -1), not just the textual diff of the .ferm files. This
is on by default whenever etckeeper is installed; pass --no-etckeeper to
turn it off for a single run.
# Show the ferm config's revision history (git-only):
ferm rollback --list
# Undo the last change: revert /etc/ferm to the previous revision and
# re-apply (shows the diff and asks for confirmation):
ferm rollback
# Roll back to an exact revision:
ferm rollback --to <sha>Notes and boundaries:
- Rollback is git-only. The commit side works with any VCS etckeeper supports; rollback needs git (other VCS report it as unavailable with a clear message).
- Rollback restores the source, then regenerates. It reverts the
/etc/fermconfig and re-applies it — it does not restore the exact bytes that were live before (those may differ after a ferm/nftables upgrade or a@resolve()drift). For a firewall this is usually what you want; a byte-exact restore is not promised. - The commit captures all of
/etc(etckeeper's nature), so unrelated uncommitted/etcchanges are swept into the ferm-attributed commit. - Content and message are separate axes. The commit records the
/etcsource; the message describes the kernel delta. Editing a.fermfile with no effect on the kernel yields an empty message body, and a clean tree (a reboot,systemctl reload, or an idempotent re-run) is committed silently as nothing-to-commit. - Rollback refuses to run if
/etc/fermhas uncommitted changes (the checkout would overwrite them) — commit or stash them first. - If you roll back under
--interactiveand then do not confirm, the kernel reverts to its pre-rollback state while the worktree stays rolled back; re-runfermto resync.
The ferm(1) man page (authored in reference/doc/ferm.pod) is the
extensive reference for the configuration syntax.
ferm's default backend drives iptables/iptables-restore. There are two
ways to have your rules end up in the kernel's nft subsystem instead.
1. Point the system iptables at its nft variant (recommended, stable).
On Debian / Ubuntu the iptables command is itself an alternative between a
legacy and an nft implementation. Select the nft variant and ferm needs no
change — its iptables-restore then writes straight into the nft kernel
tables:
sudo update-alternatives --set iptables /usr/sbin/iptables-nft
sudo update-alternatives --set ip6tables /usr/sbin/ip6tables-nft
# or pick interactively: sudo update-alternatives --config iptables2. ferm's native --nft backend (opt-in, experimental). This translates
the config into a native nft ruleset (see Project status above). Install
nftables (a Recommends of the deb/rpm package; the apk declares no
recommends, so on Alpine install it explicitly with apk add nftables) and
pass --nft:
ferm --noexec --lines --nft /etc/ferm/ferm.conf # inspect
sudo ferm --nft /etc/ferm/ferm.conf # applyAn apply computes an incremental delta against the live ruleset and
commits only the changed sets, chains and rules in one atomic nft -f -
transaction, preserving the counters of untouched rules; pass
--full-reload to force a full flush-and-rebuild instead.
To make the systemd service use it, add a drop-in override with
sudo systemctl edit ferm (the empty assignments clear the unit's values
before resetting them — systemd requires this to override ExecStart):
[Service]
ExecStart=
ExecStart=/usr/bin/ferm --nft /etc/ferm/ferm.conf
ExecReload=
ExecReload=/usr/bin/ferm --nft /etc/ferm/ferm.conf
ExecStop=
ExecStop=/usr/bin/ferm --nft -F /etc/ferm/ferm.confOn Alpine (OpenRC), add --nft to the ferm invocations in start() and
stop() in /etc/init.d/ferm; reload() delegates to start(), so it
inherits the flag automatically.
ferm --lint runs a terminal, read-only static-analysis pass over a single
config file. It is eval-free: the file is only structurally parsed — no
variable substitution, no module loading, @include is not expanded.
Six checks run over the resulting tree, each finding printed to stdout
as one <severity>: <message> line:
| Check | Severity | Example |
|---|---|---|
jump-cycle |
error | error: jump cycle: A -> B -> A |
unused-definition |
warning | warning: unused definition: $foo / &foo |
undefined-jump |
warning | warning: jump to undefined chain: BAR |
unreachable-chain |
warning | warning: unreachable chain: FOO |
duplicate-definition |
warning | warning: duplicate definition: $foo |
deprecated-keyword |
info | info: deprecated keyword: realgoto (use goto) |
ferm --lint /etc/ferm/ferm.conf # print findings, exit 0
ferm --lint --lint-strict /etc/ferm/ferm.conf # exit 2 on any warning+
ferm --lint --lint-fail-level=error ferm.conf # exit 2 only on errors
ferm --lint --lint-fail-level=info ferm.conf # exit 2 on any findingFindings are ordered by severity (errors, then warnings, then info),
then by check, then by message. By default findings never change the
exit code (0); --lint-strict gates at the warning level, and
--lint-fail-level={error,warning,info} sets an explicit threshold
(it wins when both flags are given). Exit 1 covers a file-read
error, an internal bug, or a usage error; note that an invalid
--lint-fail-level literal dies inside argparse with exit 2 before
ferm's own usage-error contract (the same pre-existing pattern as
--plan-format) — distinguish it by the usage message on stderr.
Notes and boundaries:
- No syntax validation. The structural parser is error-tolerant, so a
malformed config (unbalanced braces, truncated input) is analyzed
best-effort on the parsed portion rather than producing a syntax error —
--lintcatches the checks above, not "broken file". - Phantom jump cycles. The cycle graph flattens
(domain, table)and includes BOTH@ifbranches, so opposite edges from two tables or two branches report a cycle no single ruleset contains; a@defnested in a chain block adds its body's edges to that chain even if the function is never called. The highest-severity check is thus the least precise one — mind this before wiring--lint-fail-level=errorinto CI. - Function-call cycles are invisible. A real loop routed through a
function call (
@def &f() = jump A;called from chainA) is NOT reported: a call site is not a jump edge —jump-cycle's one false-negative gap. - A chain reached only via
jump $varor declared behind a braceless@if $c ...;guard is invisible (literal, structural analysis);unreachable-chainchecks in-degree, not reachability, so a deadA<->Bisland or self-loop is not flagged (the island shows up as ajump-cycle); a chain literally namedrealgotofalse-firesdeprecated-keyword; duplicate definitions of one name in two scopes print as one line (messages carry no positions);@include/@hookspans are not scanned. --lintis incompatible with the apply/plan flags (--plan,--nft,--fast,--slow,--shell,--interactive,--flush,--noflush,--full-reload), with a non-default--plan-format, and with--def/--domain(both eval-only, so they would silently do nothing under an eval-free analysis); combining them is a clean error.
ferm --graph config.ferm prints the chain control-flow graph to stdout —
nodes are chains and terminal verdicts, edges are jump/goto/@subchain,
verdict actions, and the default policy. It is read-only and eval-free
(like --lint): nothing touches the kernel. Choose the renderer with
--graph-format {d2,dot} (default d2), then pipe to the tool:
ferm --graph config.ferm | d2 - graph.svg
ferm --graph --graph-format dot config.ferm | dot -Tpng -o graph.pngBlind spots by design: non-literal jump $var targets are invisible,
non-terminal targets (LOG, MARK) are drawn as leaves, and the body of a
bare @subchain "name" { ... } (one written without a leading match rule)
is elided — the subchain edge is drawn, its inner rules are not. Do not
read the graph as a guarantee that a chain has no escape hatch.
ferm --list-modules prints every supported netfilter module (protocol,
match and target, per family) plus the built-in configuration keywords.
ferm --describe NAME shows what a single name means: the option table
of a module, the signature of a built-in keyword or @-function, a
shortcut expansion, or which module provides an option of that name.
Both are read-only terminal modes: they take no input file, touch
neither the kernel nor any configuration, and combine with no other
switch.
$ ferm --list-modules
protocol modules (ip/ip6):
dccp icmp mh sctp tcp udp
...
$ ferm --describe connlimit
match module 'connlimit' (ip/ip6):
connlimit-upto <value> negatable (! before keyword)
connlimit-above <value> negatable (! before keyword)
connlimit-mask <value>
connlimit-saddr (no argument)
connlimit-daddr (no argument)
see iptables-extensions(8) and ferm(1)Exit codes: 0 on success, 1 for an unknown name or a rejected
switch combination.
The project is managed entirely with uv and orchestrated with nox:
uv sync # create .venv from uv.lock
uv run nox -s lint tests typecheck # the everyday inner loop
uv run nox -s preflight # the full binding gateSelected nox sessions:
| Session | Purpose |
|---|---|
lint |
all pre-commit hooks (ruff lint + format, codespell, …) |
tests |
unit + golden-file suite |
typecheck |
mypy + pyright (verifytypes 100%) |
golden_oracle |
golden output diffed against the Perl oracle |
coverage |
coverage with an enforced floor |
matrix |
the suite across Python 3.11–3.14 |
fuzz |
Hypothesis differential fuzzing vs. the oracle |
crashfuzz |
atheris crash fuzzing of both parsers (opt-in) |
mutation |
mutmut mutation testing (opt-in, nightly) |
lockout |
containerised anti-lockout --interactive e2e (opt-in) |
The suite is golden-file ("expected output") based: each fixture pairs a
.ferm input with a checked-in expected output, and ferm's output is
diffed after canonicalisation (tables/chains emit in non-deterministic
order). On top of that, the port is continuously checked differentially
against the Perl oracle — both on a corpus of real-world configs and on
Hypothesis-generated inputs — so divergences are caught automatically.
GPL-2.0-or-later. See COPYING.
Original authors: Auke Kok and Max Kellermann. Python port maintained by Boris Talovikov.