- Python 98.5%
- Shell 1.5%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
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. |
||
| claude_code | ||
| fwctl | ||
| install | ||
| LICENSE | ||
| README.MD | ||
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,get,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]
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--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 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:
- 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 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:
/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, 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