# go-firewall [![Go Reference](https://pkg.go.dev/badge/git.gec.im/GRMrGecko/go-firewall.svg)](https://pkg.go.dev/git.gec.im/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 "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 ```go 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: ```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 `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**: `Source`↔`Destination`, source↔destination ports, and `InInterface`↔`OutInterface`. 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: ```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 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: ```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 (`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`) | ```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 ```sh 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.