YAML-based configuration manager for foomuuri nftables firewall
  • Python 99.3%
  • Shell 0.7%
Find a file
2026-05-26 06:12:36 +02:00
build/alpine update 2026-04-09 21:18:29 +02:00
claude_code update 2026-05-20 19:54:11 +02:00
fwctl update 2026-05-20 19:54:11 +02:00
install update 2026-05-26 06:12:36 +02:00
LICENSE Aggiungi LICENSE 2026-03-13 17:59:59 +00:00
README.MD update 2026-05-20 19:54:11 +02:00
VERSION update 2026-05-14 12:10:22 +02:00
VERSIONS update 2026-05-14 12:10:22 +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, 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]
  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 --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 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:

  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 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:

  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, 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