go-firewall/README.md
2026-07-08 15:54:48 -05:00

308 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# go-firewall
[![Go Reference](https://pkg.go.dev/badge/github.com/grmrgecko/firewall.svg)](https://pkg.go.dev/github.com/grmrgecko/firewall)
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/github.com/grmrgecko/firewall>
```go
import "github.com/grmrgecko/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/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 <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 concrete `TCP`/`UDP` proto. |
| `Ports` | Destination port list/ranges (`[]PortRange`). Overrides `Port` when non-empty. |
| `Proto` | `ProtocolAny`, `TCP`, `UDP`, `ICMP`, `ICMPv6`, `SCTP`, `GRE`, `ESP`, or `AH`. |
| `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. |
A `FamilyAny` rule that resolves to an identical IPv4 and IPv6 pair is collapsed
into a single rule when reading rules back. `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`.
### `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. **On read**, an input rule and its swapped output twin are collapsed
back into one `DirAny` rule (the same way a v4/v6 pair collapses to `FamilyAny`).
- **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 `csf.allow`/`allow_hosts` 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.
## 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.