# go-firewall [![Go Reference](https://pkg.go.dev/badge/github.com/grmrgecko/go-firewall.svg)](https://pkg.go.dev/github.com/grmrgecko/go-firewall) A Go module that presents a single, uniform interface over the many firewall managers found across operating systems. You describe rules with one platform‑agnostic `Rule` struct and the module translates them to whatever backend is actually running on the host. Reference documentation: ```go import "github.com/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 ```go package main import ( "context" "log" "github.com/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: ```sh 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. ```sh 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 --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 multi-state 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 coverage Three values each describe a rule spanning both values of one axis: - `FamilyAny` — both IPv4 and IPv6. - `TCPUDP` — both TCP and UDP; it carries a port just as `TCP` or `UDP` does. It is **not** `ProtocolAny`, which matches *every* IP protocol (ICMP, GRE, ESP, …). - `DirAny` — both the input and output directions (never forward). A `DirAny` rule is authored in the inbound frame, and its outbound half is the **role swap**: `Source`↔`Destination`, source↔destination ports, and `InInterface`↔`OutInterface`. So `DirAny` with `Source: X` matches inbound traffic *from* `X` and outbound traffic *to* `X`. Whether such a rule becomes **one** object in the firewall or **several** depends entirely on the backend's own model: - **On add**, the rule is split only on the axes the backend cannot express. nftables stores a `FamilyAny` + `TCPUDP` rule as a single row; iptables must write a line per family, per transport and per chain, so a rule spanning all three axes becomes eight lines. - **On read**, `GetRules` reports the firewall's actual rows. It reports a multi-state value only for an entry that genuinely carries both values and never fabricates one by pairing up separately-stored rows, so that same rule reads back as one rule from nftables and as eight from iptables. - **On remove**, a target clears every row it covers. Where a stored row covers *more* than the target, the backend deletes it 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. - Where an axis does not exist at all — `Capabilities().Output` is false, as on firewalld — a `DirAny` rule degrades to its input half (`DirInput`, same fields) rather than being rejected. Because the read-back shape is backend-specific, a caller cannot check for a rule with `==`. `Covers` and `CoveredBy` express the relation directly, and are the supported way to test a multi-state rule against what the firewall actually holds: ```go // 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 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. `Sync` compares this way, which is why it stays a no-op against its own output whichever representation the backend chose. ## 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: ```go 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. A false field means the corresponding operation returns an unsupported error, except `RuleCounters` and `Comments`, where it means `GetRules` reports the value empty. Features every backend supports are not advertised as booleans. The matrix below documents each backend's coverage, booleans and unconditional features alike. | Feature | firewalld | ufw | CSF | APF | iptables | nftables | pf | WFP | | ---------------- | --------- | --- | --- | --- | -------- | -------- | -------------------- | ------------------------ | | Forward rules | no | yes | yes | yes | yes | yes | no | no | | IPv6 | yes | yes | yes | yes | yes | yes | yes | yes | | ICMP | yes | yes | yes | yes | yes | yes | yes | yes | | ICMP type | yes | yes | yes | yes | yes | yes | yes | yes | | SCTP/GRE/ESP/AH | yes | yes | yes | yes | yes | yes | yes | partial (no SCTP port) | | Comment | no | yes | yes | yes | yes | yes | yes | yes | | Port range | yes | yes | yes | yes | yes | yes | yes | yes | | Port list | no | yes | yes | yes | yes | yes | yes | yes | | Source port | partial (not with a destination port) | yes | yes | yes | yes | yes | yes | yes | | Connection state | no | yes | yes | yes | yes | yes | no | no | | Interface match | no | yes | yes | yes | yes | yes | yes | no | | Logging | yes | yes | yes | yes | yes | yes | partial (no prefix) | no | | Rate limit | yes | yes | yes | yes | yes | yes | partial (per-source) | no | | Connection limit | no | yes | yes | yes | yes | yes | partial (per-source) | no | | NAT | partial (forward-port and masquerade only) | yes | yes | yes | yes | yes | partial (no redirect) | no | A `yes` means the feature is fully expressible, whatever route the backend takes to get there: firewalld reaches several through a rich rule, ufw through its route ruleset, and CSF and APF write the shapes their own config files cannot hold through their managed pre-hook. A `partial` cell means the backend can only express the narrowed form named beside it. Two of the `no` cells have a backend-specific reason: firewalld binds interfaces to zones rather than matching them per rule, and pf keeps state on a pass rule automatically but exposes no equivalent of the connection-state match this model carries. `Capabilities().DenyActionFromConfig` (true for CSF and APF) flags a backend whose native deny store carries no per-entry action: the tool applies the action its own config names (`csf.conf` `DROP`, `conf.apf` `ALL_STOP`), so a deny added with the config's action is stored natively, one with a differing action is expressed through the backend's pre-hook, and `RemoveRule` clears a native deny entry whatever action the removal target names. The `IPv6` row above is the one capability resolved per host rather than per backend, and it covers every IPv6 rule shape, ICMPv6 included. CSF and APF report it false when their own config disables IPv6 (`csf.conf` `IPV6`, `conf.apf` `USE_IPV6`), since neither tool then keeps an IPv6 ruleset in sync. iptables reports it false on a host whose packaging ships no ip6tables save file — a system built without IPv6, or one that never installed the ip6tables package — and manages IPv4 alone: reads report IPv4 rows only, a `FamilyAny` write narrows to the IPv4 file, a concrete-IPv6 write returns `ErrUnsupported`, and a removal is a no-op (there is nothing IPv6 to remove). ## 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 | ipset commands in the managed pre-hook | | WFP | unsupported (`ErrUnsupportedSet`) | ```go 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 The Makefile drives every test path; run these from the repo root. ```sh make test-general # unit/parser tests — fast, no root, no VM make test-integration-linux # Linux backends end-to-end in a throwaway QEMU VM make test-integration-freebsd # pf end-to-end in a throwaway FreeBSD VM make test-integration-windows # Windows Firewall end-to-end in a throwaway Windows VM make test-integration # all three integration suites make test # test-general plus every integration suite make lint # golangci-lint across every target GOOS ``` `make test-general` is the plain `go test ./...` suite — rule encoding/decoding, capability and helper logic. It never touches a live firewall and needs no root, so it is the one to run while iterating. The `test-integration*` targets boot disposable QEMU VMs and run the capability-driven suite against the real backends inside them, so nothing on the host is modified. Backend detection and rule application need the corresponding firewall installed and running, which is what the VMs provide. Limit the Linux run to particular backends with `BACKENDS`: ```sh BACKENDS="nft firewalld ufw iptables apf csf" make test-integration-linux ``` They require `qemu-system-x86_64`, KVM (`/dev/kvm`), `genisoimage`, and `python3` for the FreeBSD image. VM artifacts are cached under `./.cache`; `make clean` removes that cache and the compiled test binaries. macOS cannot be automated in a VM, so run its pf backend manually on a Mac: ```sh sudo go test -tags integration -run TestIntegration ```