YAML-based configuration manager for foomuuri nftables firewall
  • Python 98.5%
  • Shell 1.5%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
sam c699058a65 check: warn when a cross-zone dnat has no return path
A dnat into another zone only works if the target can answer the source
address it sees. When that address belongs to a zone the target has no route
to, the reply leaves through its own default gateway and the connection
hangs: the packet was rewritten and forwarded correctly, only the answer
never arrives, and check reported the config as valid throughout. Finding it
meant reading the generated ruleset and reasoning about the return path by
hand.

Two cases are exempt, and they are what keeps the warning worth reading. A
target that is a pod macro needs no SNAT, since conntrack on the firewall
handles its reply -- the same config can hold one dnat to a local pod and
one to a machine on the LAN, and only the second is a problem. Traffic
arriving from dbus_zone is exempt too: that is port forwarding from the
internet, where the target normally routes back through the firewall and a
SNAT would cost the client address in the logs for nothing. Same-zone
targets are left to the hairpin logic, which already covers them.

The message names both fixes rather than picking one, since they differ in
what is being declared: hairpin on the target zone derives the SNAT and
follows the dnats, while an explicit snat states it as routing policy.

Checked against both configurations in use: the one carrying hairpin stays
silent, the broken one is reported.
2026-10-07 13:34:30 +02:00
claude_code check: warn when a cross-zone dnat has no return path 2026-10-07 13:34:30 +02:00
fwctl check: warn when a cross-zone dnat has no return path 2026-10-07 13:34:30 +02:00
install install: anchor the version sed to the assignment line 2026-09-02 18:01:54 +02:00
LICENSE Aggiungi LICENSE 2026-03-13 17:59:59 +00:00
README.MD log: give the terminal action a vocabulary and a default 2026-09-10 10:20:43 +02:00

fwctl

YAML-based configuration manager for foomuuri nftables firewall.

foomuuri reads a single configuration file in /etc/foomuuri/. fwctl lets you manage that configuration through a human-readable fw.yaml file per server and generates the foomuuri-native fw.conf automatically.

Features

  • Single fw.yaml per server, version-control friendly
  • Per-zone inbound/outbound policy with auto-generated zone-specific templates
  • Built-in foomuuri service definitions loaded from default.services.conf
  • DNAT/SNAT with IPv4/IPv6 auto-detection
  • Bidirectional forward rules (↔ syntax)
  • Config validation with errors and warnings
  • CLI: init, show, check, apply, gen, add, del, set, get, unset, edit, log, update, prune
  • Write commands require root (or a user-writable config path)
  • Rootless podman/pasta support via pods section (auto-generates templates, macros, DNAT)

Requirements

  • Python 3.6+
  • PyYAML (pip install pyyaml or apt install python3-yaml)
  • foomuuri

Installation

cp fwctl /usr/local/bin/fwctl
chmod +x /usr/local/bin/fwctl

Quick start

# Generate a minimal config with WAN zone only (writes /etc/foomuuri/fw.yaml)
fwctl init --iface eth0

# Validate
fwctl check

# Preview generated fw.conf
fwctl gen --print

# Write fw.conf
fwctl gen

# Write fw.conf and reload foomuuri
fwctl apply

# Show diff before applying
fwctl apply --dry-run

fw.yaml structure

fwctl:
  output:           /etc/foomuuri/fw.conf
  ulogd:            /etc/ulogd.conf             # used by gen --ulogd
  cmd:              "systemctl restart foomuuri" # empty = print reminder only
  auto_ips:         yes                          # auto-update macros.ip from interface addresses
  log_file:         /var/log/fw.json             # used by fwctl log
  exclude_services: [ping, ping6, dns, ntp, dhcp, igmp, ssdp, mdns, llmnr, netbios-ns]

settings:
  localhost_zone: fw
  dbus_zone: wan
  log_level: '"group 0"'      # nflog group for ulogd (foomuuri default: level info flags skuid)
  log_input: yes
  log_output: yes
  log_forward: yes

zones:
  fw:    ~        # no interface (localhost)
  wan:   eth0     # WAN uplink
  lan:   eth1     # internal LAN
  vpn:   wg0      # VPN tunnel

macros:
  proto:                              # custom port/protocol macros (emitted as proto_<name>)
    myapp:   "tcp 8080"
    myapp2:  "tcp 9000; udp 9000"
  ip:                                 # single IP addresses (name+4 or name+6)
    wan4:    "1.2.3.4"
    wan6:    "2001:db8::1"
    srv4:    "192.168.1.10"
  net:                                # subnets per zone (v4/v6 auto-detected)
    lan: ["192.168.1.0/24", "fd00::/64"]
    vpn: "10.0.0.0/24"               # single family
  log:                                # log macros
    login:  'log "login"'
    logout: 'log "logout"'

log:                                  # terminal action per zone: [inbound, outbound]
  default: [drop, drop]               # fallback for anything not listed below
  wan: [login reject, logout reject]  # → "log_login reject" / "log_logout reject"
  lan: [drop, logout]                 # → "drop" / "log_logout drop"
  vpn: [login]                        # → "log_login drop" / default[1]
  # entry: drop | reject | continue | return | <log macro> [action]
  # a bare macro implies drop

policy:
  wan:
    i: [ssh ipv4, http, http2]        # zone → fw (inbound)
    o: [ssh, http, http2, domain, ntp] # fw → zone (outbound); DNS is "domain"
  lan:
    i: accept                         # accept all inbound
    o: [http, domain]
  vpn:
    i: ["ssh ipv4"]
    o: accept                         # accept all outbound

forward:                              # traffic through fw (not destined to it)
  "wan → lan": [http]
  "lan → wan": accept
  "lan ↔ vpn": accept                # bidirectional shorthand

dnat:
  - {from: wan,  service: [http, http2], iif: [zone1, zone2], to: srv}
  - {from: wan4, service: myapp,         iif: zone1,          to: srv4, port: 8080}
  - {service: domain,                    iif: wan,            to: pihole}  # transparent (no daddr filter)
  # from: omit (or ~) for transparent proxy — intercepts ALL matching traffic on iif
  # from/to without suffix → auto-expand to v4+v6
  # iif: zone name (resolved to interface) or interface name directly
  # port: redirect to specific port (number or service name)

snat:
  - {zone: lan, via: wan}        # masquerade via zone (v4+v6)
  - {zone: vpn, via: wan, ip: 4} # IPv4 only

hairpin:                              # zones requiring auto-SNAT on incoming DNAT
  - vpn                               # opt-in: when a DNAT targets a host in `vpn`,
                                      # fwctl derives the matching forward+SNAT at
                                      # gen-time (not persisted in fw.yaml) so
                                      # clients can reach the service through the
                                      # public IP (NAT loopback / hairpin)

pods:                                 # rootless podman/pasta pods
  default_zones: [wan]               # global default: all pods visible from wan only
  pod_db:
    ip:  192.168.1.10
    i:   [mysql, postgresql, myapp]   # inbound: built-ins + a macros.proto entry
    o:   accept                       # outbound: accept all fw → pod
    # uses default_zones: [wan]
  pod_web:
    ip:   192.168.1.20
    i:    [http, http2]
    dnat: {http: 8080}                # redirect http(80) → 8080
    # uses default_zones: [wan]
  pod_app:
    ip:   192.168.1.30
    i:    accept                      # inbound: accept all
    o:    [http, ssh]                 # outbound: specific services
    dnat:                             # multiple DNAT entries
      http:  8080
      myapi: 9000
    zones: [wan, vpn]                 # per-pod override (overrides default_zones)

CLI reference

fwctl init

Generate a fw.yaml populated from the host's network state. Default output: /etc/foomuuri/fw.yaml.

  • Auto-detects the WAN interface (ip route get 1.1.1.1), its IPs and subnets. Use --iface to override.
  • Then scans all other interfaces and asks interactively which ones to add as additional zones ([Y/n], default yes).
  • Discarded automatically: lo, DOWN interfaces, interfaces without a scope global address, bridge slaves (master <x>), veth peers of netns (names containing @).
  • For each accepted interface, populates zones, macros.ip.<name>4/6, macros.net.<name>, log.<name>: [login, logout], and policy.<name>: {i: [], o: []}.
fwctl init
fwctl init --output /path/to/fw.yaml
fwctl init --iface enp0s3
fwctl init --force   # overwrite existing

Example interactive session:

Detected WAN: eth0 (1.2.3.4, 2001:db8::1, 1.2.3.0/24)
  Found interface 'wg0' (10.0.0.1, 10.0.0.0/24)
    Add as zone 'wg0'? [Y/n] y
  Found interface 'lan0' (192.168.1.1, 192.168.1.0/24)
    Add as zone 'lan0'? [Y/n] n
Written: /etc/foomuuri/fw.yaml

fwctl check

Validate fw.yaml. Reports errors and warnings. Exits 1 on error.

fwctl check

fwctl gen

Validate and write fw.conf.

fwctl gen
fwctl gen --print    # print to stdout instead of writing
fwctl gen --ulogd    # also generate /etc/ulogd.conf

fwctl apply

Validate, write fw.conf, and reload foomuuri.

fwctl apply
fwctl apply --dry-run   # show unified diff vs current fw.conf

fwctl show

Show the parsed configuration in a readable format.

fwctl show                  # everything
fwctl show zones
fwctl show macros
fwctl show macros proto|ip|net|log|builtin
fwctl show macros --default     # show only foomuuri built-in services
fwctl show macros ssh       # search by name
fwctl show macros 443       # search by port
fwctl show macros tcp       # search by protocol
fwctl show policy
fwctl show policy zone1     # specific zone
fwctl show policy zone1 -c  # compact: services space-separated (ready to paste into edit policy)
fwctl show policy zone1 -i -c   # inbound only
fwctl show policy zone1 -o -c   # outbound only
fwctl show forward
fwctl show dnat
fwctl show snat
fwctl show hairpin
fwctl show settings             # both `fwctl:` and `foomuuri:` sections (Default + Custom)
fwctl show settings --default   # show all built-in defaults of both sections
fwctl show redirect             # redirect rules (manual + derived)

Example output of fwctl show settings:

=== Settings ===
  fwctl:
    Default:
      output            /etc/foomuuri/fw.conf
      ulogd             /etc/ulogd.conf
      auto_ips          yes
      auto_forward      yes
      log_file          /var/log/fw.json
      exclude_services  ping, ping6, dns, ntp, dhcp, igmp, ssdp, mdns, llmnr, netbios-ns
      hairpin_redirect  []
    Custom:
      cmd               rc-service foomuuri restart
  foomuuri:
    Default:
      log_rate          1/second burst 3
      ...
    Custom:
      localhost_zone    fw
      dbus_zone         wan
      log_input         yes

This makes it easy to verify whether features like hairpin_redirect are active: if it appears under Default: with value [], the feature is disabled; under Custom: it's enabled (with yes or a service list).

fwctl set

Set a value in fw.yaml (fwctl: or settings: section, auto-detected by key name). If the value matches the foomuuri/fwctl default, the key is removed to keep the file clean.

Unknown keys are rejected: fwctl set only accepts keys that exist in FWCTL_DEFAULTS or in foomuuri's built-in settings. An unknown key prints the full list of valid keys on stderr and exits with code 1.

fwctl set cmd systemctl restart foomuuri
fwctl set log_input yes
fwctl set log_rate 1/second burst 5
fwctl set -h   # list all available keys with defaults

Shortcuts for cmd:

fwctl set cmd openrc    # → "rc-service foomuuri restart"
fwctl set cmd systemd   # → "systemctl restart foomuuri"

Any other value (including the literal rc-service foomuuri restart) is stored as-is.

fwctl get

Print the effective value of a single key, with no decoration — meant for scripts.

fwctl get cmd                 # rc-service foomuuri restart
fwctl get mss                 # pmtu
cmd=$(fwctl get cmd)          # usable directly in a shell

When fw.yaml does not override the key, the built-in default is printed — that is the value fwctl actually acts on. --origin (-o) tells the two apart:

$ fwctl get localhost_zone --origin
fw	(custom)
$ fwctl get log_rate --origin
1/second burst 3	(default)

Formatting matches fwctl set, so a value read back can be written straight in:

fwctl set mss $(fwctl get mss)

Unlike fwctl show settings, get answers about one key and prints nothing else — useful because with yq installed show settings only lists what is written in the file, so a key left at its default does not appear there at all. get is read-only and does not need root.

fwctl unset

Remove a custom key from fw.yaml, restoring the default. Only removes keys present in fw.yaml; keys already at default are left untouched.

fwctl unset log_input
fwctl unset cmd

fwctl add

Add resources to an existing fw.yaml. Errors on duplicates.

# Add a policy for a zone
fwctl add policy wan --in ssh http --out http domain
fwctl add policy lan --in accept
fwctl add policy vpn --out accept

# Add a pod
# --in → field i (inbound services), --out → field o: accept (outbound)
fwctl add pod pod_web  192.168.1.20 --in http --dnat http:8080
fwctl add pod pod_db   192.168.1.10 --in mysql postgresql --out
fwctl add pod pod_app  192.168.1.30 --in http --dnat http:8080 myapi:9000
fwctl add pod pod_db   192.168.1.10 --in mysql --zones wan

# Add a zone (also adds default log entry [login, logout])
fwctl add zone nat nat
fwctl add zone dmz eth1 --net 192.168.1.0/24
fwctl add zone vpn wg0  --net 10.10.0.0/24 --net fd00::/48

# Add a macro
fwctl add macro proto livekit tcp 7881 udp 7882 udp 50000-60000
fwctl add macro ip   wan4 1.2.3.4
fwctl add macro net  nat  10.0.0.0/24
fwctl add macro net  vpn  10.10.0.0/24 fd00::/48
fwctl add macro log  login
fwctl add macro log  myevent "my event"

# Add a forward rule
fwctl add forward wan lan http http2
fwctl add forward lan wan accept
fwctl add forward lan vpn - accept         # bidirectional

# Add a DNAT rule
fwctl add dnat host1 -s http http2 -i zone1 zone2 -f ext1
fwctl add dnat host1 -s myapp -i zone1 -f ext14
fwctl add dnat host1 -s myapp -i zone1 -f ext14 -p 8080
fwctl add dnat pihole -s domain -i wan               # transparent (no -f = intercept all)

# Add a SNAT rule
fwctl add snat zone1 zone2
fwctl add snat zone1 zone2 -i 4    # IPv4 only

# Mark a zone as hairpin (auto-SNAT for DNAT targeting this zone)
fwctl add hairpin vpn

fwctl del

Remove resources from an existing fw.yaml.

# Remove a pod
fwctl del pod pod_web

# Remove a zone (also removes from policy, log, macros.net)
fwctl del zone nat

# Remove a macro
fwctl del macro proto livekit
fwctl del macro ip   wan4

# Remove a forward rule
fwctl del forward wan lan
fwctl del forward lan vpn -                # bidirectional

# Remove a DNAT rule
fwctl del dnat --hash a3f2c1b0                       # by hash (see: fwctl show dnat --hash)
fwctl del dnat host1 -s http -i zone1 -f ext1
fwctl del dnat host1 -s myapp -i zone1 -f ext14 -p 8080
fwctl del dnat pihole -s domain -i wan               # transparent rule (no -f)

# Remove a SNAT rule
fwctl del snat zone1

# Remove a hairpin zone
fwctl del hairpin vpn

fwctl edit

Without arguments: open fw.yaml in $EDITOR with validate-on-save loop. Press Enter to re-edit on errors, Ctrl+C to abort without saving.

With subcommand: modify a specific resource directly.

fwctl edit                                        # open editor
fwctl edit zone wan eth1                          # change interface
fwctl edit policy wan --in ssh http --out http    # replace policy rules
fwctl edit pod pod_web --ip 192.168.1.20 --in http
fwctl edit forward wan lan http https
fwctl edit log wan --in login --out logout
fwctl edit macro ip wan4 1.2.3.5
fwctl edit dnat                                   # interactive mode
fwctl edit dnat --hash                            # show rules with hashes
fwctl edit dnat a3f2c1b0 -t host1                # modify by hash
fwctl edit snat zone1 -v zone2                   # change outbound zone
fwctl edit snat zone1 -i 4                       # restrict to IPv4

fwctl log

Filter and display entries from the firewall JSON log (/var/log/fw.json). Requires jq (apt install jq). Login/logout prefixes are read from macros.log in fw.yaml.

fwctl log                   # all entries (noise filtered out)
fwctl log -a                # all JSON fields
fwctl log -i                # inbound only
fwctl log -o                # outbound only
fwctl log -i -s ssh         # inbound SSH
fwctl log -p tcp            # TCP only
fwctl log -n eth0           # filter by interface
fwctl log --ip 1.2.3.4      # filter by IP address
fwctl log --port 443        # filter by destination port
fwctl log --hash <hash>     # show raw entry for specific hash

Log file and excluded services are configured in fw.yaml under fwctl: (see fw.yaml structure above). If exclude_services is absent, the built-in default list is used. If present (even empty []), it overrides the default.

fwctl update

Update fwctl in place from upstream.

The check compares the local script against the upstream version on git.snix.me, ignoring the VERSION = "..." line (which is injected by the installer). If the content is identical, fwctl prints already up to date and exits. Otherwise it prints Update available (current: <VERSION>), asks for confirmation, then runs the official installer (curl -sL https://sh.snix.me/fwctl | sh) which also keeps dependencies in sync.

The upstream version is not displayed: the upstream VERSION file holds only pkgver (e.g. 4.0), while the installed version carries pkgver-rN, so showing them side by side looked like a downgrade. The update check is based on file content, not on comparing version strings.

Before the confirmation prompt, fwctl prints the source URL and the file it will replace, so you can see what you are approving.

The download is checked against the checksum published by the forge (the file's git blob id, so there is no separate checksum file to keep in sync) before anything else happens. On a mismatch the update is refused and the expected/actual hashes are printed.

The check is fail-closed: if the checksum cannot be fetched at all, the update is refused rather than proceeding unverified — someone able to tamper with the download can also make that request fail, so skipping the check when it is unavailable would hand them the weaker path on demand. The cost is that a temporarily unreachable API blocks updates; wait for it, or re-run the full installer.

The file that gets installed is the same one that was just compared — fwctl writes those bytes rather than handing off to the installer, which would download the script again, from a different host, at a later moment. The version (pkgver-rN) is read from the repository tags and stamped in; if it cannot be determined, or if the downloaded file has no VERSION = "dev" line, the update is refused and the existing binary is left untouched. The replacement itself is atomic, so a running fwctl never sees a half-written file.

fwctl update does not install dependencies — on an update they are already present by definition. If a new version needs a package you do not have, install it yourself or re-run the full installer:

curl -sL https://sh.snix.me/fwctl | sh

Note that the checksum is an integrity check, not an authenticity one: it is published by the same host that serves the file, so it catches truncated downloads, stale caches and proxies, but not a compromise of the server itself. fwctl update replaces an executable that normally runs as root with a file downloaded from git.snix.me, so you are trusting that host and its TLS certificate, as with any curl | sh style installer.

fwctl update              # check, prompt, update
fwctl update -y           # skip confirmation
fwctl update -f           # reinstall even if content is identical

Requires write permission on the fwctl binary path (typically sudo fwctl update).

After a successful update, fwctl update also (re)installs the shell completions if fwctl.bash and/or fwctl.zsh are configured — see fwctl completion below.

fwctl completion

Generate the bash/zsh completion script.

By default, the output is written to the path configured in fwctl.bash / fwctl.zsh (in the fwctl: section of fw.yaml). If the path is a directory (or ends with /), the filename is hardcoded:

  • bash → <dir>/fwctl
  • zsh → <dir>/_fwctl

Otherwise the value is treated as a file path.

If the key is empty / not set, the completion is printed to stdout (legacy behavior). --print forces stdout regardless.

# One-time configuration (typical paths):
sudo fwctl set bash /etc/bash_completion.d         # → /etc/bash_completion.d/fwctl
sudo fwctl set zsh  /etc/zsh/zcompletion           # → /etc/zsh/zcompletion/_fwctl

# Manual install:
sudo fwctl completion bash       # writes /etc/bash_completion.d/fwctl
sudo fwctl completion zsh        # writes /etc/zsh/zcompletion/_fwctl

# Print to stdout (always works, even with keys set):
fwctl completion bash --print > my-custom-fwctl

# Legacy redirect still works:
fwctl completion bash > /etc/bash_completion.d/fwctl

fwctl update automatically re-installs the completions after a successful update, so you only need to set fwctl.bash / fwctl.zsh once.

fwctl prune

Remove forward/snat/redirect entries from fw.yaml that are already covered by the auto-derived rules.

fwctl computes forward/snat/redirect rules on the fly from dnat + hairpin + hairpin_redirect. These derived rules are merged into the generated fw.conf at gen/apply time but are not written to fw.yaml. This keeps fw.yaml minimal: deleting a DNAT also removes all the rules it implicitly created.

fwctl prune cleans up older fw.yaml files that contain persisted entries duplicating the derived rules (e.g. legacy configs, or rules accidentally added by hand).

fwctl prune          # show redundant entries + [Y/n] prompt
fwctl prune -y       # skip the prompt

Matching is exact: a manual entry is removed only if it has the same key/services as a derived rule. Anything that differs (a manual forward "nat → wan": accept not derived from anything, a snat with custom ip: 4 filter, a redirect with a port mapping) is preserved.

Auto-derived rules (Design C)

Under the hood, fwctl uses a "hybrid" design for the rules it can compute automatically:

YAML section Manual? Auto-derived from
forward yes (kept in YAML) dnat (required forward path), snat (zone → via: accept)
snat yes (kept in YAML) hairpin zones
redirect yes (kept in YAML) hairpin + hairpin_redirect

When you run fwctl gen / fwctl apply:

  1. fwctl computes the derived rules from dnat + hairpin + hairpin_redirect
  2. It merges them with whatever you have in forward/snat/redirect (manual entries win on conflicts)
  3. It emits the union to fw.conf

When you run fwctl check, derived rules are printed under Auto-derived at gen time (not persisted in fw.yaml) so you see what will end up in fw.conf.

fwctl show forward / show snat / show redirect displays both manual and derived entries; derived ones are tagged with (derived).

Adding/removing DNATs no longer touches forward/snat/redirect in fw.yaml — derived rules appear and disappear automatically.

MSS clamping (fwctl.mss)

For routers/VPSes that forward TCP traffic through an interface with reduced MTU (wireguard ~1420, PPPoE 1492, IPsec ~1400, GRE ~1476), MSS clamping rewrites the MSS option in TCP SYN packets so the peer doesn't send oversized segments. Without it, large TCP flows can stall on fragmentation/black-hole MTU issues.

sudo fwctl set mss yes          # adaptive via PMTUD (recommended)
sudo fwctl set mss 1380         # fixed value (e.g. wg-style)
sudo fwctl set mss no           # disable (removes the key)

In fw.yaml:

fwctl:
  mss: yes        # or a number like 1380

In fw.conf (only when set):

forward {
  mss pmtu
}

Notes:

  • mss pmtu uses the route's MTU at runtime, so it adapts per-destination (1420 via wg, 1500 via eth0, etc.)
  • Only forwarded traffic is clamped — hosts that terminate connections don't need this
  • Transparent to clients: nothing to configure on the LAN side

Hairpin NAT (NAT loopback)

The hairpin section opts-in zones that need automatic SNAT for incoming DNAT — solves the classic "internal client cannot reach its own service via the public IP" problem.

Typical scenario. A VPS DNATs traffic from wan to an internal webserver sitting behind a vpn (wireguard) zone. From a host on the internal LAN — or from the webserver itself — curl https://mysite.example resolves to the VPS public IP and fails: without SNAT, the reply bypasses the VPS and arrives at the client with the wrong source address, breaking the connection.

With hairpin: [vpn], fwctl auto-derives at gen/apply time, for every DNAT targeting a host in that zone:

  • A forward iif_zone → vpn rule (so the firewall actually allows the cross-zone traffic).
  • A matching snat: {zone: iif_zone, via: vpn} so the reply path goes back through the firewall.
  • The same also when the client and the target are in the same zone (e.g. the webserver itself, which routes its own egress through the VPS via the wireguard tunnel) — fwctl derives both forward vpn → vpn and snat: {zone: vpn, via: vpn}.

These derived rules are not persisted to fw.yaml (see Auto-derived rules).

fwctl add hairpin vpn
fwctl del hairpin vpn
fwctl show hairpin

Without the hairpin section, no auto-SNAT is generated and DNAT only works for the "external client" case. This keeps behavior predictable and avoids surprising SNAT rules.

Hairpin redirect (calling your own domains from the firewall host itself)

hairpin handles forwarded traffic (clients on internal zones → DNAT → internal target). It does not help when the firewall host itself calls its own public IP: locally-generated traffic skips prerouting nat, so the DNAT never fires.

fwctl.hairpin_redirect solves this by auto-populating the redirect section (which becomes output nat dstnat rules) for each DNAT targeting a zone in hairpin. Three modes:

fwctl:
  hairpin_redirect: yes              # all services of all DNATs with explicit `from`
  # hairpin_redirect: [http, http2]  # only these services (whitelist)
  # hairpin_redirect: []             # default: disabled

Example: with hairpin: [vpn] and the DNAT {from: wan, service: [http, http2], to: web}, fwctl derives at gen-time:

output nat dstnat {
  daddr ip_wan4 http  dnat to ip_web4
  daddr ip_wan6 http  dnat to ip_web6
  daddr ip_wan4 http2 dnat to ip_web4
  daddr ip_wan6 http2 dnat to ip_web6
}

Now curl https://mysite.example from the firewall itself transparently goes to the internal webserver.

Notes:

  • The DNAT must have an explicit from macro (so the redirect can filter by daddr ip_<from>). Transparent DNATs (no from) are skipped with a warning, since redirecting all local HTTP traffic to an internal host would break outbound curl to external sites.
  • The derived redirect rules are not persisted to fw.yaml — see Auto-derived rules. They are recomputed at every gen/apply from the DNATs.
  • Use fwctl set hairpin_redirect yes / fwctl set hairpin_redirect http http2 / fwctl set hairpin_redirect no to manage the value.

Pods (rootless podman/pasta)

The pods section manages containers running on the host with pasta networking. Each pod gets a dedicated IP on the host interface.

The optional default_zones key at the section level sets the default zone visibility for all pods. Per-pod zones: overrides it. If neither is set, the pod is visible from all zones.

fwctl auto-generates from pods:

  • IP macros in macro {} for each pod
  • template pod_i {} — inbound rules (<svc> daddr <pod> accept)
  • template pod_o {} — outbound rules for pods with o: accept or o: [svc...]
  • DNAT entries for pods with dnat: mappings
  • template pod_i / template pod_o references in zone blocks (only for zones that can reach the pods)
fwctl add pod pod_web 192.168.1.20 --in http --dnat http:8080
fwctl add pod pod_db  192.168.1.10 --in mysql postgresql --out   # --out → o: accept
fwctl add pod pod_web 192.168.1.20 --in http --zones wan vpn     # restrict to zones
fwctl del pod pod_web

Global option

fwctl --config /path/to/fw.yaml <command>   # use a specific config file

Default config search order:

  1. /etc/foomuuri/fw.yaml
  2. ./fw.yaml

How it works

fwctl reads fw.yaml and generates fw.conf in this section order:

  1. foomuuri { } — global settings
  2. snat { } — SNAT rules (manual + derived from hairpin)
  3. macro { } — all macros (proto + ip + log)
  4. zone { } — zone definitions
  5. template <zone>_i { } / template <zone>_o { } — per-zone templates
  6. <zone>-fw { } / fw-<zone> { } — policy rule blocks
  7. Forward blocks zone1-zone2 { } (manual + derived from dnat)
  8. dnat { } — DNAT rules
  9. output nat dstnat { } — local redirects (manual + derived from hairpin_redirect)

Rules in sections 2, 7, and 9 are a union of what you write in fw.yaml (manual entries) and what fwctl computes from dnat + hairpin + hairpin_redirect (derived entries). Derived rules are recomputed at every gen/apply — they're never written to fw.yaml. See Auto-derived rules (Design C).

Built-in foomuuri services (ssh, http, domain, ntp, etc.) are loaded from /usr/share/foomuuri/default.services.conf and are available without redefinition. Only define custom services in macros.proto.

Service names follow IANA /etc/services conventions, not colloquial aliases. The one that catches people out is DNS: the service is called domain (tcp 53; udp 53) — there is no dns, and using it gives ERROR: unknown service 'dns'. Other names worth checking before you guess:

You might write Actual built-in
dns domain (also domain-s, domain-tls, domain-quic)
dhcp dhcp-client / dhcp-server (and the dhcpv6-* pair)
ping6 ping covers both families
https exists, but is tcp+udp 443; http2 is tcp 443 only

List or search what your foomuuri install actually provides:

fwctl show macros builtin    # all built-in services
fwctl show macros domain     # search by name
fwctl show macros 53         # search by port

Note that fwctl.exclude_services is unrelated: it filters the log viewer, and those names (dns, llmnr, netbios-ns, ...) are resolved from /etc/services, where dns is a valid entry.

Macro name prefixing. User-defined macros are emitted with a type prefix in fw.conf:

YAML section Name in fw.conf
macros.proto.www proto_www
macros.ip.wan4 ip_wan4
macros.log.login log_login
macros.pod.db pod_db

Built-in foomuuri services keep their plain name (http, ssh, ...). In fw.yaml you always use the short name (www, wan4, login); fwctl applies the prefix automatically wherever the macro is referenced (policy templates, forward, dnat, pod rules, redirect), preserving ipv4/ipv6 qualifiers.

If a proto macro shadows a built-in (e.g. proto.http: "tcp 8080"), the user's macro wins and http in rules is rewritten to proto_http; fwctl check emits a WARN: proto macro 'X' shadows a foomuuri built-in service.

Built with

Claude Code — Anthropic's AI coding assistant.

License

GPL-3.0 — see LICENSE