- Python 99.3%
- Shell 0.7%
| build/alpine | ||
| claude_code | ||
| fwctl | ||
| install | ||
| LICENSE | ||
| README.MD | ||
| VERSION | ||
| VERSIONS | ||
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.yamlper 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,unset,edit,log,update,prune - Write commands require root (or a user-writable config path)
- Rootless podman/pasta support via
podssection (auto-generates templates, macros, DNAT)
Requirements
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]
wan: [login, logout] # → "login drop" / "logout drop"
lan: [drop, logout] # → "drop" / "logout drop"
vpn: [login, accept] # → "login drop" / (nothing)
# values: <log macro> | drop | accept
policy:
wan:
i: [ssh ipv4, http, http2] # zone → fw (inbound)
o: [ssh, http, http2, dns, ntp] # fw → zone (outbound)
lan:
i: accept # accept all inbound
o: [http, dns]
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: dns, 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, www] # inbound: specific services
o: accept # outbound: accept all fw → pod
# uses default_zones: [wan]
pod_web:
ip: 192.168.1.20
i: [www]
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--ifaceto override. - Then scans all other interfaces and asks interactively which ones to add as additional zones (
[Y/n], default yes). - Discarded automatically:
lo,DOWNinterfaces, interfaces without ascope globaladdress, 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], andpolicy.<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 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 dns
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 www --dnat http:8080
fwctl add pod pod_db 192.168.1.10 --in mysql postgresql www --out
fwctl add pod pod_app 192.168.1.30 --in www --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 dns -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 dns -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 www
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 shows the local/remote versions, asks for confirmation, then runs the official installer (curl -sL https://sh.snix.me/fwctl | sh) which also keeps dependencies in sync.
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:
- fwctl computes the derived rules from
dnat+hairpin+hairpin_redirect - It merges them with whatever you have in
forward/snat/redirect(manual entries win on conflicts) - 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 pmtuuses 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 → vpnrule (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 → vpnandsnat: {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
frommacro (so the redirect can filter bydaddr ip_<from>). Transparent DNATs (nofrom) are skipped with a warning, since redirecting all local HTTP traffic to an internal host would break outboundcurlto external sites. - The derived
redirectrules are not persisted tofw.yaml— see Auto-derived rules. They are recomputed at everygen/applyfrom the DNATs. - Use
fwctl set hairpin_redirect yes/fwctl set hairpin_redirect http http2/fwctl set hairpin_redirect noto 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 witho: acceptoro: [svc...]- DNAT entries for pods with
dnat:mappings template pod_i/template pod_oreferences in zone blocks (only for zones that can reach the pods)
fwctl add pod pod_web 192.168.1.20 --in www --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 www --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:
/etc/foomuuri/fw.yaml./fw.yaml
How it works
fwctl reads fw.yaml and generates fw.conf in this section order:
foomuuri { }— global settingssnat { }— SNAT rules (manual + derived fromhairpin)macro { }— all macros (proto + ip + log)zone { }— zone definitionstemplate <zone>_i { }/template <zone>_o { }— per-zone templates<zone>-fw { }/fw-<zone> { }— policy rule blocks- Forward blocks
zone1-zone2 { }(manual + derived fromdnat) dnat { }— DNAT rulesoutput nat dstnat { }— local redirects (manual + derived fromhairpin_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, dns, ntp, etc.) are loaded
from /usr/share/foomuuri/default.services.conf and are available without
redefinition. Only define custom services in macros.proto.
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