Manage any host firewall from Go (iptables, nftables, ufw, firewalld, pf, WFP, and more).
Find a file
James Coleman b4e54a66b6 Move the module to git.gec.im/GRMrGecko/go-firewall
A temporary holding path until the library is ready to release.

The cmd module, its imports and the README referred to the library as
github.com/grmrgecko/firewall while the root go.mod declared it as
github.com/grmrgecko/go-firewall, so its require/replace pair never named the
module it was replacing. Point all of them at the new path, which makes them
agree for the first time. The go-firewalld dependency shares the old prefix and
is unrelated; it is left alone.
2026-07-09 21:32:10 -05:00
cmd/go-firewall Move the module to git.gec.im/GRMrGecko/go-firewall 2026-07-09 21:32:10 -05:00
scripts Add address-set support to APF/CSF and ipset reboot persistence to iptables 2026-07-08 17:20:38 -05:00
test/integration first commit 2026-07-08 15:54:48 -05:00
.gitignore first commit 2026-07-08 15:54:48 -05:00
.golangci.yml first commit 2026-07-08 15:54:48 -05:00
apf_linux.go Route inexpressible csf/apf shapes to the hook instead of rejecting them 2026-07-09 21:30:27 -05:00
apf_linux_test.go Route inexpressible csf/apf shapes to the hook instead of rejecting them 2026-07-09 21:30:27 -05:00
backup.go Add TCPUDP protocol, coverage relation, and drop read-side merging 2026-07-09 17:52:19 -05:00
configfile.go first commit 2026-07-08 15:54:48 -05:00
configfile_other.go first commit 2026-07-08 15:54:48 -05:00
configfile_test.go first commit 2026-07-08 15:54:48 -05:00
configfile_unix.go first commit 2026-07-08 15:54:48 -05:00
csf_linux.go Route inexpressible csf/apf shapes to the hook instead of rejecting them 2026-07-09 21:30:27 -05:00
csf_linux_test.go Route inexpressible csf/apf shapes to the hook instead of rejecting them 2026-07-09 21:30:27 -05:00
firewall.go Add TCPUDP protocol, coverage relation, and drop read-side merging 2026-07-09 17:52:19 -05:00
firewall_test.go Add TCPUDP protocol, coverage relation, and drop read-side merging 2026-07-09 17:52:19 -05:00
firewalld_linux.go Add TCPUDP protocol, coverage relation, and drop read-side merging 2026-07-09 17:52:19 -05:00
firewalld_linux_test.go Add TCPUDP protocol, coverage relation, and drop read-side merging 2026-07-09 17:52:19 -05:00
go.mod Move the module to git.gec.im/GRMrGecko/go-firewall 2026-07-09 21:32:10 -05:00
go.sum first commit 2026-07-08 15:54:48 -05:00
hooks_linux.go Route inexpressible csf/apf shapes to the hook instead of rejecting them 2026-07-09 21:30:27 -05:00
hooks_linux_test.go Route inexpressible csf/apf shapes to the hook instead of rejecting them 2026-07-09 21:30:27 -05:00
integration_darwin_test.go first commit 2026-07-08 15:54:48 -05:00
integration_freebsd_test.go first commit 2026-07-08 15:54:48 -05:00
integration_linux_test.go Add TCPUDP protocol, coverage relation, and drop read-side merging 2026-07-09 17:52:19 -05:00
integration_nohook_test.go Add TCPUDP protocol, coverage relation, and drop read-side merging 2026-07-09 17:52:19 -05:00
integration_test.go Route inexpressible csf/apf shapes to the hook instead of rejecting them 2026-07-09 21:30:27 -05:00
integration_windows_test.go first commit 2026-07-08 15:54:48 -05:00
iptables_linux.go Add TCPUDP protocol, coverage relation, and drop read-side merging 2026-07-09 17:52:19 -05:00
iptables_linux_test.go Add TCPUDP protocol, coverage relation, and drop read-side merging 2026-07-09 17:52:19 -05:00
LICENSE first commit 2026-07-08 15:54:48 -05:00
Makefile first commit 2026-07-08 15:54:48 -05:00
manager_darwin.go first commit 2026-07-08 15:54:48 -05:00
manager_freebsd.go first commit 2026-07-08 15:54:48 -05:00
manager_linux.go first commit 2026-07-08 15:54:48 -05:00
manager_windows.go first commit 2026-07-08 15:54:48 -05:00
nft_linux.go Add TCPUDP protocol, coverage relation, and drop read-side merging 2026-07-09 17:52:19 -05:00
nft_linux_test.go Add TCPUDP protocol, coverage relation, and drop read-side merging 2026-07-09 17:52:19 -05:00
pf.go Add TCPUDP protocol, coverage relation, and drop read-side merging 2026-07-09 17:52:19 -05:00
pf_test.go Add TCPUDP protocol, coverage relation, and drop read-side merging 2026-07-09 17:52:19 -05:00
README.md Move the module to git.gec.im/GRMrGecko/go-firewall 2026-07-09 21:32:10 -05:00
services.go Add TCPUDP protocol, coverage relation, and drop read-side merging 2026-07-09 17:52:19 -05:00
ufw_linux.go Add TCPUDP protocol, coverage relation, and drop read-side merging 2026-07-09 17:52:19 -05:00
ufw_linux_test.go Add TCPUDP protocol, coverage relation, and drop read-side merging 2026-07-09 17:52:19 -05:00
utils.go first commit 2026-07-08 15:54:48 -05:00
wf_windows.go Add TCPUDP protocol, coverage relation, and drop read-side merging 2026-07-09 17:52:19 -05:00
wf_windows_test.go Add TCPUDP protocol, coverage relation, and drop read-side merging 2026-07-09 17:52:19 -05:00

go-firewall

Go Reference

A Go module that presents a single, uniform interface over the many firewall managers found across operating systems. You describe rules with one platformagnostic Rule struct and the module translates them to whatever backend is actually running on the host.

Reference documentation: https://pkg.go.dev/git.gec.im/GRMrGecko/go-firewall

import "git.gec.im/GRMrGecko/go-firewall"

Supported backends

Platform Backends
Linux firewalld → ufw → CSF → APF → iptables → nftables
macOS pf (Packet Filter)
FreeBSD pf (Packet Filter)
Windows Windows Filtering Platform (WFP)

Usage

package main

import (
	"context"
	"log"

	"git.gec.im/GRMrGecko/go-firewall"
)

func main() {
	ctx := context.Background()

	// Detect and connect to the host's firewall. The rule prefix tags/namespaces
	// rules this module creates.
	mgr, err := firewall.NewManager(ctx, "myapp")
	if err != nil {
		log.Fatal(err)
	}
	defer mgr.Close(ctx)

	// Resolve the zone for an interface (empty for backends without zones).
	zone, err := mgr.GetZone(ctx, "eth0")
	if err != nil {
		log.Fatal(err)
	}

	// Allow inbound TCP 443 from a subnet, logged and rate-limited.
	rule := &firewall.Rule{
		Family:    firewall.IPv4,
		Source:    "192.168.0.0/24",
		Port:      443,
		Proto:     firewall.TCP,
		Action:    firewall.Accept,
		Log:       true,
		LogPrefix: "https",
		RateLimit: &firewall.RateLimit{Rate: 20, Unit: firewall.PerSecond, Burst: 10},
	}

	if err := mgr.AddRule(ctx, zone, rule); err != nil {
		log.Fatal(err)
	}

	// Forward inbound TCP 8080 to an internal host (a NAT rule).
	nat := &firewall.NATRule{
		Kind:      firewall.DNAT,
		Family:    firewall.IPv4,
		Proto:     firewall.TCP,
		Port:      8080,
		ToAddress: "10.0.0.5",
		ToPort:    80,
	}
	if err := mgr.AddNATRule(ctx, zone, nat); err != nil {
		log.Fatal(err)
	}

	// Some backends stage changes; Reload activates them (a no-op where
	// changes apply immediately).
	if err := mgr.Reload(ctx); err != nil {
		log.Fatal(err)
	}
}

CLI

cmd/go-firewall is a unified firewall management CLI and implementation demo for the library. It auto-detects the host's active backend and exposes the same surface across all of them. Build it from the repo root:

make cli                  # builds ./build/go-firewall
make install              # installs into $GOBIN

Managing rules needs appropriate privileges (root/Administrator), and the CLI never modifies the host unless you run a mutating subcommand.

go-firewall status                                    # backend + capabilities
go-firewall rule list                                 # all filter rules (PREFIX column flags ours)
go-firewall rule add --proto tcp --port 443 --source 192.168.0.0/24 --log
go-firewall rule add --proto tcp --ports 80,443,1000-2000 --comment "web"
go-firewall rule remove --proto tcp --port 443
go-firewall rule insert 1 --proto tcp --port 22       # 1-based position
go-firewall nat add --kind dnat --proto tcp --port 8080 --to-address 10.0.0.5 --to-port 80
go-firewall nat insert 1 --kind dnat --proto tcp --port 8080 --to-address 10.0.0.5 --to-port 80
go-firewall nat add --kind masquerade
go-firewall policy get
go-firewall policy set --input drop --forward drop
go-firewall set create blocklist --family ipv4 --type hash:net
go-firewall set add-entry blocklist 203.0.113.0/24
go-firewall set show blocklist                        # metadata + every entry
go-firewall backup -o snapshot.json                   # portable JSON snapshot (rules, NAT, policy, sets)
go-firewall restore -f snapshot.json                  # replay the snapshot
go-firewall zone eth0
go-firewall reload
go-firewall install-completions                       # bash/zsh/fish completion

Global flags: --prefix (rule namespace; default go_firewall), --no-reload (skip the automatic reload after a mutation), -j/--json (machine-readable output — list, status, and a {"status":...} object on mutations), --version. A rule's flags are identical across add, remove, insert and move, so the flag set that creates a rule is also its match key for removal. Run go-firewall <command> --help for the full flag reference.

The Rule type

Field Meaning
Direction DirInput (default), DirOutput, DirForward, or DirAny — the input, output, forward (routing) chain, or both input and output. See Capabilities().Forward and the DirAny note below.
Priority Rule priority, where the backend supports it (e.g. firewalld rich rules).
Family FamilyAny, IPv4, or IPv6.
Source Source address/CIDR. Prefix with ! to negate, where supported.
Destination Destination address/CIDR. Prefix with ! to negate, where supported.
Port Single destination port. A non-zero port requires a port-carrying proto (TCP, UDP, TCPUDP, SCTP).
Ports Destination port list/ranges ([]PortRange). Overrides Port when non-empty.
Proto ProtocolAny, TCP, UDP, TCPUDP, ICMP, ICMPv6, SCTP, GRE, ESP, or AH. TCPUDP matches both transports; ProtocolAny matches every IP protocol and cannot carry a port.
ICMPType Optional single ICMP type for an ICMP/ICMPv6 rule (*uint8, nil = any type).
State Connection-tracking states to match, OR-combined (e.g. StateEstablished|StateRelated).
InInterface Inbound interface to match. Empty means any interface. A forward rule may match this alongside OutInterface.
OutInterface Outbound interface to match. Empty means any interface. A forward rule may match this alongside InInterface.
Action Accept, Reject, or Drop.
Log Log each matched packet before applying Action.
LogPrefix Optional label on the log line (not all backends carry a prefix; pf ignores it).
RateLimit *RateLimit (Rate/Unit/Burst) — cap the packet rate the rule matches. nil = unlimited.
ConnLimit *ConnLimit (Count/PerSource) — cap concurrent connections. nil = unlimited.
Packets Per-rule packet counter, populated by GetRules on backends that read them (nftables, iptables, pf). Zero elsewhere and ignored when adding a rule.
Bytes Per-rule byte counter, populated alongside Packets. Not part of rule identity.
Comment Optional human-readable label carried where the backend can store one. Informational: not part of rule identity, ignored where unsupported. See Capabilities().Comments.
HasPrefix Informational flag reporting whether the rule carries the configured prefix.

Capabilities().Output reports whether a backend distinguishes input from output (firewalld, for example, does not), and Capabilities().Forward reports whether it can express a forward-chain (routing) rule. A DirForward rule on a backend without forward support is rejected with ErrUnsupportedForward.

Multi-state rules and what GetRules reports

FamilyAny, TCPUDP and DirAny each describe a rule spanning two values of one axis. Whether such a rule becomes one object in the firewall or several depends entirely on the backend's own model:

  • On add, a rule is split only on the axes the backend cannot express. nftables stores a FamilyAny rule as one unpinned inet row and a TCPUDP rule as one meta l4proto { tcp, udp } row; iptables must write a line per family, per transport and per chain, so the same rule becomes eight lines.
  • On read, GetRules reports the firewall's actual rows. It reports FamilyAny, TCPUDP or DirAny only for an entry that genuinely carries both values; it never fabricates one by pairing up separately-stored rows. So the same FamilyAny + TCPUDP + DirAny rule reads back as one rule from nftables and as eight from iptables.
  • On remove, a target clears every row it covers. If a stored row covers more than the target — removing IPv4 from a family-agnostic nftables row — the backend deletes that row and re-adds the remainder in its place, so the untargeted coverage survives. Where its model cannot express the remainder, it returns ErrUnsupported rather than over-removing.

Since the read-back shape is backend-specific, compare rules by coverage, not equality — see below. Sync does exactly this, which is why it stays a no-op against its own output whichever representation the backend chose.

DirAny — both directions

DirAny is the direction analog of FamilyAny: it describes a rule that applies to both the input and output directions (never forward). A DirAny rule is authored in the inbound frame — its Source/destination ports/InInterface are the input-chain meaning — and its outbound half is the role swap: SourceDestination, source↔destination ports, and InInterfaceOutInterface. So DirAny with Source: X matches inbound traffic from X and outbound traffic to X.

  • On add, a DirAny rule fans out into a concrete input row plus its swapped output row, except on csf/apf, whose bare-host csf.allow/allow_hosts line is inherently bidirectional and stores it as one entry (read back as DirAny).
  • Removing a single direction of a DirAny rule leaves the other in place: the chain backends drop only that direction's row, while csf/apf split their bidirectional plain line and re-express the surviving direction through their raw-iptables hook.
  • On csf/apf, a bare (address-only, no-port) DirAny host allow is the single bidirectional plain line; a one-way (DirInput/DirOutput) bare host allow is written to the hook instead, since a plain line is inherently bidirectional and an advanced rule requires a port.
  • On a backend that does not distinguish an output chain (Capabilities().Output is false, e.g. firewalld), a both-directions rule cannot be expressed, so a DirAny rule degrades to its input half (DirInput, same fields) rather than being rejected.

TCPUDP — both transports

TCPUDP is the protocol analog of FamilyAny and DirAny: it matches TCP and UDP. It is not ProtocolAny, which matches every IP protocol (ICMP, GRE, ESP, …) and therefore cannot carry a port — a ProtocolAny rule with a port is rejected by every backend.

  • On add, a TCPUDP rule fans out into a tcp row plus a udp row on the backends with no both-transports form (iptables, pf, firewalld, wf, csf), each of which then reads back as its own rule.
  • Three backends express it natively in a single rule and need no fan-out: nftables (meta l4proto { tcp, udp } with a th dport match), ufw (its bare-port any-protocol tuple) and apf (a protocol-less advanced trust line, which apf itself applies to tcp and udp). Those read back as one TCPUDP rule.
  • Removing a single transport of a TCPUDP rule leaves the other in place: the fan-out backends drop only that transport's row, while nftables and ufw split their single both-transports row and re-add the surviving transport.

Coverage: Covers and CoveredBy

Because a rule may be stored as one row or several, a caller checking whether a rule is already installed cannot compare with ==. Two helpers express the coverage relation directly:

// Does this one rule contain that one?
want := &fw.Rule{Family: fw.FamilyAny, Proto: fw.TCPUDP, Direction: fw.DirAny, Port: 53, Action: fw.Accept}
want.Covers(&fw.Rule{Family: fw.IPv4, Proto: fw.UDP, Direction: fw.DirInput, Port: 53, Action: fw.Accept}) // true

// Is this rule fully present across a set — even if no single rule contains it?
existing, _ := mgr.GetRules(ctx, "")
if !want.CoveredBy(existing) {
    _ = mgr.AddRule(ctx, "", want)
}

Covers is asymmetric: a TCPUDP rule covers its TCP half, never the reverse. CoveredBy is its set-valued inverse — it expands the receiver across all three axes and requires every resulting cell to be covered by some rule in the set. That is what makes it work against a fan-out backend, where no single stored row covers the rule but the rows together do; and it is why a rule spanning both transports is not reported present when only its TCP half is. NATRule.Covers and NATRule.CoveredBy mirror them over family, the only axis a NAT rule spans.

NAT (port forwarding and masquerade)

NAT rules are managed separately from filter rules through AddNATRule/RemoveNATRule/GetNATRules, using the NATRule type.

Field Meaning
Kind DNAT, Redirect, SNAT, or Masquerade.
Family FamilyAny, IPv4, or IPv6.
Proto Protocol to match (TCP, UDP, etc.).
Port Matched destination port (Ports for a list/range). Requires a tcp/udp protocol.
ToAddress Rewrite target address: new destination for DNAT, new source for SNAT.
ToPort Rewrite target port (DNAT/Redirect). Unused for SNAT/Masquerade.
Interface Inbound interface for DNAT/Redirect; outbound for SNAT/Masquerade.
HasPrefix Informational flag, same semantics as Rule.HasPrefix.

DNAT forwards inbound traffic to ToAddress:ToPort. Redirect sends matching traffic to a local ToPort. SNAT rewrites the source to a fixed address, and Masquerade uses the outgoing interface address. Backends that cannot express NAT return ErrUnsupportedNAT.

Capabilities

mgr.Capabilities() returns a Capabilities struct advertising which features the active backend can express, so a caller can branch before trial-and-error:

caps := mgr.Capabilities()
if !caps.NAT {
    log.Println("this backend cannot do NAT")
}
if caps.RuleCounters {
    // rules read back will carry Packets/Bytes
}

Every boolean corresponds to a Rule/NATRule field or an interface method (NAT, RuleOrdering, DefaultPolicy, RuleCounters, AddressSets, Comments, …). A false RuleCounters/Comments means GetRules simply reports zero counters / an empty comment; every other false field means the corresponding operation returns an unsupported error. Features every backend supports — ICMP and ICMP-type matching, port ranges, source ports, and backup/restore — are not advertised as booleans; a caller can rely on them unconditionally. The matrix below still documents how each backend expresses these features.

Feature firewalld ufw CSF APF iptables nftables pf WFP
Forward rules no yes (route) via hook via hook yes yes no no
ICMP yes yes with address type list yes yes yes yes
ICMPv6 yes yes yes yes yes yes yes yes
ICMP type rich rule yes yes yes yes yes yes yes
SCTP/GRE/ESP/AH rich rule yes yes yes yes yes yes by number (no SCTP port)
Comment no yes yes yes yes yes label description
Port range yes yes yes yes yes yes yes yes
Port list no yes yes ports config yes yes yes yes
Source port yes yes adv rule adv rule yes yes yes yes
Connection state no yes yes yes yes yes no (stateful) no
Interface match no (zone) yes yes yes yes yes yes no
Logging rich rule yes yes yes yes yes yes (no prefix) no
Rate limit rich rule yes yes yes yes yes per-source no
Connection limit no yes per-port per-port yes yes per-source no
NAT fwd-port/masq yes dnat/redirect dnat/snat/masq yes yes rdr/nat (no redirect) no

Default policy

GetDefaultPolicy/SetDefaultPolicy read and set the default action applied to packets that match no rule. A DefaultPolicy carries an Action per direction (Input, Output, Forward); a direction left as ActionInvalid is not exposed (on Get) or left unchanged (on Set). On a backend that supports it, the policy is captured in a Backup and re-asserted by Restore, so a snapshot of a default-drop host reproduces that policy on replay rather than inheriting the restore host's.

Backend Directions supported
iptables input, output, forward
ufw input, output, forward
nftables input, output, forward
firewalld input (the zone target)
others unsupported (ErrUnsupportedPolicy)

Address sets (ipset / nftset / pf tables)

Address sets are named collections of addresses (AddressSet) that rules can match against, managed separately from filter and NAT rules. A Backup captures the managed sets (with their entries) and Restore recreates them before the rules, so a set-referencing rule (@set) resolves when a snapshot is replayed on a host that does not yet have the set. They map onto the backend's native construct:

Backend Construct
iptables ipset (hash:ip, hash:net)
ufw ipset (via the host iptables)
nftables a set in the private inet table
firewalld a firewalld ipset (D-Bus)
pf a pf table
CSF/APF/WFP unsupported (ErrUnsupportedSet)
set := &firewall.AddressSet{Name: "blocklist", Family: firewall.IPv4, Type: firewall.SetHashNet}
_ = mgr.AddAddressSet(ctx, set)
_ = mgr.AddAddressSetEntry(ctx, "blocklist", "203.0.113.0/24")
sets, _ := mgr.GetAddressSets(ctx)

Testing

go test ./...

The marshal/unmarshal (rule encoding/decoding) logic for each backend is unit tested and does not require a live firewall. Backend detection and rule application do require the corresponding firewall to be installed and running.