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.
375 lines
20 KiB
Markdown
375 lines
20 KiB
Markdown
# go-firewall
|
||
|
||
[](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: <https://pkg.go.dev/git.gec.im/GRMrGecko/go-firewall>
|
||
|
||
```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 <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**:
|
||
`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.
|