Skip to content

6RUN0/ferm

 
 

Repository files navigation

ferm (Python port)

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.

Project status

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 emits iptables rulesets (ip, ip6, arp, eb families), with both the fast (iptables-restore) and slow (per-rule) execution paths, the --interactive rollback safety net, and the import-ferm save-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 with make -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.

Branches

  • 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.

Requirements

  • Python 3.11–3.14
  • iptables (including iptables-save / iptables-restore) and a netfilter-capable kernel, at runtime
  • uv for 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.

Installation

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.

WARNING: the default configuration blocks all inbound traffic

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_PORT in /etc/ferm/ferm.conf before enabling. Enabling the service with the wrong SSH port will lock you out of a remote machine.
  • Fragments in /etc/ferm/ferm.d/*.conf are executed as root. Keep them owned by root:root with permissions 0644 or 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.

PyPI (pip)

pip install ferm

For 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.

Native .deb package

Download pyferm_<version>_all.deb from the GitHub Releases page and install it:

sudo apt install ./pyferm_<version>_all.deb

apt 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 ferm

Applying 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.

Standalone binary

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.gz

This produces a ferm.dist/ directory containing the ferm binary and, next to it, an import-ferm symlink.

Keep the binary inside its directory

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/ferm

Unpack into a root-owned directory

ferm 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.dist

As 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.

Verifying the download

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/ferm

Attestation only protects those who actually run the check, so verify every download rather than trusting the file blindly.

@resolve() and the host resolver

@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.

Usage

# 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.conf

Be 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.

Reviewing changes before applying (--plan)

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 backend

Config history and rollback (etckeeper)

When 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/ferm config 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 /etc changes are swept into the ferm-attributed commit.
  • Content and message are separate axes. The commit records the /etc source; the message describes the kernel delta. Editing a .ferm file 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/ferm has uncommitted changes (the checkout would overwrite them) — commit or stash them first.
  • If you roll back under --interactive and then do not confirm, the kernel reverts to its pre-rollback state while the worktree stays rolled back; re-run ferm to resync.

The ferm(1) man page (authored in reference/doc/ferm.pod) is the extensive reference for the configuration syntax.

Using the nftables backend

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 iptables

2. 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               # apply

An 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.conf

On 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.

Static analysis (--lint)

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 finding

Findings 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 — --lint catches the checks above, not "broken file".
  • Phantom jump cycles. The cycle graph flattens (domain, table) and includes BOTH @if branches, so opposite edges from two tables or two branches report a cycle no single ruleset contains; a @def nested 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=error into CI.
  • Function-call cycles are invisible. A real loop routed through a function call (@def &f() = jump A; called from chain A) is NOT reported: a call site is not a jump edge — jump-cycle's one false-negative gap.
  • A chain reached only via jump $var or declared behind a braceless @if $c ...; guard is invisible (literal, structural analysis); unreachable-chain checks in-degree, not reachability, so a dead A<->B island or self-loop is not flagged (the island shows up as a jump-cycle); a chain literally named realgoto false-fires deprecated-keyword; duplicate definitions of one name in two scopes print as one line (messages carry no positions); @include/@hook spans are not scanned.
  • --lint is 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.

Visualising the chain graph

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.png

Blind 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.

Introspection (--list-modules, --describe)

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.

Development

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 gate

Selected 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)

Testing

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.

License

GPL-2.0-or-later. See COPYING.

Original authors: Auke Kok and Max Kellermann. Python port maintained by Boris Talovikov.

About

ferm for iptables/nftables, ported to Python - one config language, four netfilter families

Topics

Resources

License

Stars

0 stars

Watchers

0 watching

Forks

Packages

 
 
 

Contributors

Languages

  • Python 96.6%
  • Shell 2.3%
  • Other 1.1%