Introduce TCPUDP as the protocol analog of FamilyAny and DirAny: a merged value spanning both transports, distinct from ProtocolAny (which matches every IP protocol and carries no port). Backends whose native syntax holds both transports in one row (nftables, ufw, apf) store and read it as one rule; the rest fan it out with expandProtocols. Removing one transport of a merged row splits it via splitMergedRow, which composes the family and protocol splits so an nftables row merged on both axes leaves a correct, non-overlapping remainder. NAT rejects TCPUDP with ErrUnsupportedNAT. Remove read-side merging. GetRules now reports the firewall's actual rows and never synthesizes a FamilyAny, TCPUDP, or DirAny rule by pairing up separately-stored ones, so mergeFamilies, mergeDirections and their helpers are gone and mergedInsertIndex becomes logicalInsertIndex. Rules are instead compared by coverage: the new exported Rule.Covers / Rule.CoveredBy (and the NATRule pair) expand a rule across family, transport and direction and decide containment cell by cell, which is what lets Sync stay a no-op against its own output whichever representation a backend chose. Extract the systemd/SysV service helpers out of the iptables backend into services.go so every Linux backend shares one implementation, and document the multi-state rule model and the coverage helpers in the README.
375 lines
20 KiB
Markdown
375 lines
20 KiB
Markdown
# go-firewall
|
||
|
||
[](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
|
||
platform‑agnostic `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 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.
|