first commit

This commit is contained in:
James Coleman 2026-08-10 17:17:03 -05:00
commit 0c7ce9e334
70 changed files with 39054 additions and 0 deletions

15
.gitignore vendored Normal file
View file

@ -0,0 +1,15 @@
# Integration-test VM cache (base cloud image, overlay disk, cloud-init seed,
# serial console log). Created by the test/integration/host-*-vm.sh scripts; removed by `make clean`.
/.cache/
# Build output for the go-firewall CLI (see `make cli`). Removed by `make clean`.
/build/
# Stray CLI binary from a bare `go build` inside the cmd module (the binary name
# matches the directory). The canonical build target is /build/ above.
/cmd/go-firewall/go-firewall
/cmd/go-firewall/go-firewall.exe
# Bytecode cache Python writes when the refactor helpers in scripts/ are run.
/scripts/__pycache__/

36
.golangci.yml Normal file
View file

@ -0,0 +1,36 @@
version: "2"
# go-firewall is a multi-platform library: each backend lives behind a GOOS
# build tag (iptables/nft/ufw/firewalld/csf/apf on linux, pf on darwin+freebsd,
# wf on windows). golangci-lint only analyzes one GOOS per run, so the firewall
# is linted once per platform — see the `lint` target in the Makefile, which is
# the canonical entry point.
#
# The linux run is authoritative for the `unused` linter: linux compiles every
# linux backend plus the shared helpers, so it alone can tell dead code from a
# helper that only a subset of backends use. The cross-compiled runs disable
# `unused` (a linux-only helper unavoidably reads as dead code under another
# GOOS) but keep errcheck/govet/ineffassign/staticcheck.
#
# The gap that leaves: a shared helper called only from pf.go (darwin/freebsd)
# or wf_windows.go reads as dead under the one run that judges dead code. Those
# carry a `//nolint:unused` naming the backend that needs them — check the
# caller before deleting one.
run:
# Analyze test files too, so the integration suites are held to the same bar.
tests: true
linters:
# The conservative standard set: errcheck, govet, ineffassign, staticcheck, unused.
default: standard
issues:
# Report every occurrence; the defaults cap repeats and hide real work.
max-issues-per-linter: 0
max-same-issues: 0
formatters:
# Enforce canonical gofmt formatting as part of the same run.
enable:
- gofmt

19
LICENSE Normal file
View file

@ -0,0 +1,19 @@
Copyright (c) 2026 Mr. Gecko's Media (James Coleman). http://mrgeckosmedia.com/
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

91
Makefile Normal file
View file

@ -0,0 +1,91 @@
# go-firewall test targets.
#
# make lint golangci-lint across every target GOOS
# 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 backend end-to-end in a throwaway FreeBSD VM
# make test-integration-windows Windows Firewall backend in a throwaway Windows VM
# make test-integration every OS's integration (slow; big downloads)
# make test general + every OS's integration
# make build the go-firewall CLI (separate module in ./cmd/go-firewall)
# make install install the go-firewall CLI into $GOBIN
#
# test-general runs the plain `go test` suite (rule parsing/marshalling, capability
# and helper logic) — it never touches a live firewall.
#
# The test-integration* targets boot disposable QEMU VMs and run the capability-
# driven suite against the real backends there, so nothing touches the host:
# * Linux — nft/firewalld/ufw/iptables/apf/csf natively in an Ubuntu VM.
# Limit backends with BACKENDS, e.g. `make test-integration BACKENDS=nft`.
# * FreeBSD — pf in a FreeBSD VM (pf runs natively; also covers macOS's pf backend).
# * Windows — the Windows Firewall backend in a Windows VM (heaviest; large image).
# They need qemu-system-x86_64, KVM (/dev/kvm), genisoimage (and python3 for FreeBSD).
#
# macOS is not automatable in a VM (Apple hardware); run its pf backend manually on a
# Mac: `sudo go test -tags integration -run TestIntegration`.
#
# VM artifacts (cloud images, overlay disks, seeds) are cached under ./.cache
# (git-ignored). `make clean` removes that cache and the compiled test binaries.
BACKENDS ?=
CLI_DIR := ./cmd/go-firewall
BUILD_DIR := ./build
CLI_BIN := $(BUILD_DIR)/go-firewall
# Sources the CLI is built from: its own package plus the library it imports.
CLI_SRC := $(shell find $(CLI_DIR) . -maxdepth 1 -name '*.go' -not -name '*_test.go') \
$(CLI_DIR)/go.mod $(CLI_DIR)/go.sum go.mod go.sum
.PHONY: all lint test test-general test-integration test-integration-linux test-integration-freebsd test-integration-windows cli install clean
# Bare `make` builds the CLI.
.DEFAULT_GOAL := all
all: cli
test: test-general test-integration
# Lint every target GOOS. Each backend is behind a build tag, so a single run
# only sees one platform's code. The linux run is authoritative (it compiles all
# backends and shared helpers, so `unused` is meaningful); the cross-compiled
# runs disable `unused` because a linux-only helper unavoidably reads as dead
# code under another GOOS. Requires golangci-lint v2 (see .golangci.yml).
lint:
golangci-lint run ./...
GOOS=darwin golangci-lint run --disable=unused ./...
GOOS=freebsd golangci-lint run --disable=unused ./...
GOOS=windows golangci-lint run --disable=unused ./...
cd $(CLI_DIR) && golangci-lint run ./...
test-general:
go test ./...
test-integration: test-integration-linux test-integration-freebsd test-integration-windows
test-integration-linux:
./test/integration/host-linux-vm.sh $(BACKENDS)
test-integration-freebsd:
./test/integration/host-freebsd-vm.sh
test-integration-windows:
./test/integration/host-windows-vm.sh
# Build the go-firewall CLI into $(BUILD_DIR) (./build by default, git-ignored).
# The CLI lives in a separate Go module (so the kong dependency is not imposed
# on library users) and is built from $(CLI_DIR). `cli` is a convenience alias
# for the real binary target below, which only relinks when a source changes.
# Override the version with: make cli VERSION=v1.2.3
cli: $(CLI_BIN)
$(CLI_BIN): $(CLI_SRC) | $(BUILD_DIR)
go build -C $(CLI_DIR) -ldflags "-X main.version=$(VERSION)" -o $(abspath $(CLI_BIN)) .
$(BUILD_DIR):
mkdir -p $(BUILD_DIR)
# Install the go-firewall CLI into $GOBIN (or $GOPATH/bin). Requires Go 1.26+.
install:
go install -C $(CLI_DIR) -ldflags "-X main.version=$(VERSION)"
clean:
rm -rf .cache $(BUILD_DIR) test/integration/firewall.test test/integration/firewall.test.*

391
README.md Normal file
View file

@ -0,0 +1,391 @@
# 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
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/go-firewall>
```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 <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 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
```

1951
apf_linux.go Normal file

File diff suppressed because it is too large Load diff

798
apf_linux_test.go Normal file
View file

@ -0,0 +1,798 @@
package firewall
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"github.com/stretchr/testify/require"
)
func TestAPFAdvRules(t *testing.T) {
fw := new(APF)
// Advanced-rule encodings, including underscore ranges and bracketed IPv6.
cases := []struct {
rule *Rule
want string
}{
{&Rule{Proto: TCP, Port: 22, Source: "192.168.2.1", Family: IPv4, Action: Accept}, "tcp:in:d=22:s=192.168.2.1"},
{&Rule{Proto: TCP, Ports: []PortRange{{Start: 6000, End: 7000}}, Source: "192.168.5.0/24", Family: IPv4, Action: Accept}, "tcp:in:d=6000_7000:s=192.168.5.0/24"},
{&Rule{Proto: TCP, Port: 443, Source: "2001:db8::/32", Family: IPv6, Action: Accept}, "tcp:in:d=443:s=[2001:db8::/32]"},
{&Rule{Direction: DirOutput, Proto: UDP, Port: 53, Destination: "192.0.2.1", Family: IPv4, Action: Accept}, "udp:out:d=53:d=192.0.2.1"},
}
for _, c := range cases {
got := fw.MarshalAdvRule(c.rule)
require.Equal(t, c.want, got, "marshal %+v", *c.rule)
parsed := fw.ParseAdvRule(got, c.rule.Action)
require.NotNil(t, parsed, "failed to parse %q", got)
require.True(t, parsed.Equal(c.rule, true),
"round-trip mismatch: input %+v, line %q, output %+v", *c.rule, got, parsed)
}
// A bracketed IPv6 host address parses back without brackets.
r := fw.ParseAdvRule("tcp:in:d=443:s=[2001:db8::1]", Accept)
require.NotNil(t, r)
require.Equal(t, "2001:db8::1", r.Source)
require.Equal(t, IPv6, r.Family)
require.EqualValues(t, 443, r.Port)
}
// A port-only deny whose action is Drop (conf.apf's default ALL_STOP=DROP) must
// still be written to deny_hosts. The placeholder branch keys on "not an accept",
// not on Reject, so a Drop deny is written rather than skipped while AddRule
// reports success and leaves the port open.
func TestAPFPortOnlyDropDenyIsWritten(t *testing.T) {
ctx := context.Background()
fw := new(APF)
dir := t.TempDir()
path := filepath.Join(dir, "deny_hosts.rules")
require.NoError(t, os.WriteFile(path, nil, 0644))
drop := &Rule{Family: IPv4, Proto: TCP, Port: 3306, Action: Drop}
require.NoError(t, fw.EditIPList(ctx, path, Drop, drop, false))
data, err := os.ReadFile(path)
require.NoError(t, err)
require.Contains(t, string(data), "tcp:in:d=3306:s=0.0.0.0/0",
"a port-only Drop deny must be written with the any-network placeholder")
}
func TestAPFSourcePorts(t *testing.T) {
fw := new(APF)
// Source ports round-trip through the s= port-flow field (single port and
// underscore range).
cases := []struct {
rule *Rule
want string
}{
{&Rule{Proto: TCP, SourcePort: 1234, Destination: "192.0.2.1", Family: IPv4, Action: Accept}, "tcp:in:s=1234:d=192.0.2.1"},
{&Rule{Proto: UDP, SourcePorts: []PortRange{{Start: 1024, End: 2048}}, Source: "192.0.2.1", Family: IPv4, Action: Accept}, "udp:in:s=1024_2048:s=192.0.2.1"},
}
for _, c := range cases {
got := fw.MarshalAdvRule(c.rule)
require.Equal(t, c.want, got, "marshal %+v", *c.rule)
parsed := fw.ParseAdvRule(got, c.rule.Action)
require.NotNil(t, parsed, "failed to parse %q", got)
require.True(t, parsed.Equal(c.rule, true),
"round-trip mismatch: input %+v, line %q, output %+v", *c.rule, got, parsed)
}
}
func TestAPFConnLimit(t *testing.T) {
fw := new(APF)
// IG_TCP_CLIMIT parses into per-port reject rules with a per-source cap; an
// underscore port range is preserved.
rules := fw.ParseConnLimit("80:50,8080_8090:25", TCP)
require.Len(t, rules, 2)
require.Equal(t, Reject, rules[0].Action)
require.EqualValues(t, 80, rules[0].Port)
require.NotNil(t, rules[0].ConnLimit)
require.EqualValues(t, 50, rules[0].ConnLimit.Count)
require.True(t, rules[0].ConnLimit.PerSource)
require.Len(t, rules[1].Ports, 1)
require.Equal(t, PortRange{Start: 8080, End: 8090}, rules[1].Ports[0])
// Editing adds a range entry and removes a port entry.
added := fw.editConnLimit("IG_TCP_CLIMIT", "80:50",
&Rule{Proto: TCP, Ports: []PortRange{{Start: 8080, End: 8090}}, Action: Reject, ConnLimit: &ConnLimit{Count: 25, PerSource: true}}, false)
require.Equal(t, `IG_TCP_CLIMIT="80:50,8080_8090:25"`, added)
removed := fw.editConnLimit("IG_TCP_CLIMIT", "80:50,443:100",
&Rule{Proto: TCP, Port: 443, Action: Reject, ConnLimit: &ConnLimit{Count: 100, PerSource: true}}, true)
require.Equal(t, `IG_TCP_CLIMIT="80:50"`, removed)
}
func TestAPFPortAndICMPConfig(t *testing.T) {
fw := new(APF)
// Port lists parse single ports and underscore ranges.
rules := fw.ParsePorts("21,22,6000_7000", TCP, DirInput)
require.Len(t, rules, 3, "expected 3 port rules")
require.Len(t, rules[2].Ports, 1)
require.Equal(t, PortRange{Start: 6000, End: 7000}, rules[2].Ports[0],
"expected a 6000-7000 range rule")
// ICMP type lists become ICMP rules, one per type.
icmp := fw.ParseICMPTypes("3,5,8", ICMP, DirInput)
require.Len(t, icmp, 3, "expected 3 icmp rules")
require.Equal(t, ICMP, icmp[2].Proto)
require.NotNil(t, icmp[2].ICMPType, "expected icmp type 8 rule, got %+v", *icmp[2])
require.EqualValues(t, 8, *icmp[2].ICMPType, "expected icmp type 8 rule")
// EditRulePort adds a range token to the matching port list.
got := fw.EditRulePort(`IG_TCP_CPORTS="22"`, "IG_TCP_CPORTS", "22",
&Rule{Proto: TCP, Ports: []PortRange{{Start: 6000, End: 7000}}, Action: Accept}, false)
require.Equal(t, `IG_TCP_CPORTS="22,6000_7000"`, got, "unexpected port edit")
// EditRulePort adds an ICMP type to the icmp type list.
got = fw.EditRulePort(`IG_ICMP_TYPES="3,5"`, "IG_ICMP_TYPES", "3,5",
&Rule{Proto: ICMP, ICMPType: Ptr[uint8](8), Action: Accept}, false)
require.Equal(t, `IG_ICMP_TYPES="3,5,8"`, got, "unexpected icmp edit")
}
// TestAPFICMPNamedTypeReconcile guards ICMP-type reconciliation against a conf.apf
// list that spells a type by name (e.g. "echo-request") rather than its number.
// The read path resolves names to numbers, so removal/add must compare by resolved
// number or a foreign name-based entry can never be removed (Sync never converges)
// and an add would append a numeric duplicate.
func TestAPFICMPNamedTypeReconcile(t *testing.T) {
// Removing ICMP type 8 must clear a name-based "echo-request" entry.
fw := new(APF)
got := fw.EditRulePort(`IG_ICMP_TYPES="echo-request"`, "IG_ICMP_TYPES", "echo-request",
&Rule{Proto: ICMP, ICMPType: Ptr[uint8](8), Action: Accept}, true)
require.Equal(t, `IG_ICMP_TYPES=""`, got, "a name-based echo-request entry must be removed for type 8")
require.True(t, fw.ConfigChanged, "the config must be marked changed when the entry is removed")
// Adding type 8 when "echo-request" is already present must not duplicate it.
fw = new(APF)
got = fw.EditRulePort(`IG_ICMP_TYPES="echo-request"`, "IG_ICMP_TYPES", "echo-request",
&Rule{Proto: ICMP, ICMPType: Ptr[uint8](8), Action: Accept}, false)
require.Equal(t, `IG_ICMP_TYPES="echo-request"`, got, "adding a type already present by name must not duplicate")
require.False(t, fw.ConfigChanged, "no change when the type is already present by name")
// The same holds for ICMPv6, resolved through the ICMPv6 name table (128).
fw = new(APF)
got = fw.EditRulePort(`IG_ICMPV6_TYPES="echo-request"`, "IG_ICMPV6_TYPES", "echo-request",
&Rule{Proto: ICMPv6, ICMPType: Ptr[uint8](128), Action: Accept}, true)
require.Equal(t, `IG_ICMPV6_TYPES=""`, got, "a name-based ICMPv6 echo-request entry must be removed for type 128")
}
// apf carries ICMPv6 types and the "all" wildcard natively; both must round-trip
// through the conf.apf type lists and stay off the raw-iptables hook.
func TestAPFICMPv6AndAllWildcard(t *testing.T) {
fw := new(APF)
// IG_ICMPV6_TYPES parses to IPv6 ICMPv6 accepts, one per type.
v6 := fw.ParseICMPTypes("1,2,128,129", ICMPv6, DirInput)
require.Len(t, v6, 4)
require.Equal(t, ICMPv6, v6[3].Proto)
require.Equal(t, IPv6, v6[3].Family)
require.EqualValues(t, 129, *v6[3].ICMPType)
// The "all" wildcard parses to a typeless (all-types) accept.
all := fw.ParseICMPTypes("all", ICMP, DirOutput)
require.Len(t, all, 1)
require.Nil(t, all[0].ICMPType, "'all' must be an all-types rule")
require.True(t, all[0].IsOutput())
// Writing an ICMPv6 type into its native list.
got := fw.EditRulePort(`IG_ICMPV6_TYPES="1,2"`, "IG_ICMPV6_TYPES", "1,2",
&Rule{Proto: ICMPv6, ICMPType: Ptr[uint8](128), Action: Accept}, false)
require.Equal(t, `IG_ICMPV6_TYPES="1,2,128"`, got)
// Writing the "all" wildcard for a typeless ICMP accept.
got = fw.EditRulePort(`EG_ICMP_TYPES=""`, "EG_ICMP_TYPES", "",
&Rule{Proto: ICMP, Direction: DirOutput, Action: Accept}, false)
require.Equal(t, `EG_ICMP_TYPES="all"`, got)
// A native ICMPv6 accept is routed to conf.apf, not the hook.
require.True(t, fw.nativeICMPv6(&Rule{Proto: ICMPv6, ICMPType: Ptr[uint8](128), Action: Accept}))
require.True(t, fw.isConfRule(&Rule{Proto: ICMPv6, ICMPType: Ptr[uint8](128), Action: Accept}))
// An ICMPv6 rule that also needs state matching stays on the hook path.
require.False(t, fw.nativeICMPv6(&Rule{Proto: ICMPv6, ICMPType: Ptr[uint8](128), State: StateEstablished, Action: Accept}))
}
func TestAPFIPListComment(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "allow_hosts.rules")
fw := &APF{rulePrefix: "myapp"}
ctx := context.Background()
require.NoError(t, os.WriteFile(path, []byte(
"# myapp trusted office\n"+
"tcp:in:d=22:s=10.0.0.0/24\n"+
"\n"+
"# unrelated note\n"+
"# separated by blank\n"+
"192.0.2.5\n"+
"2001:db8::1 # inline ignored\n",
), 0644))
rules, err := fw.ParseIPList(path, Accept)
require.NoError(t, err)
// Advanced rule keeps the comment immediately above it.
adv := rules[0]
require.Equal(t, "trusted office", adv.Comment)
require.Equal(t, "10.0.0.0/24", adv.Source)
require.EqualValues(t, 22, adv.Port)
// A bare IPv4 line is one bidirectional DirAny rule carrying the accumulated comment.
host := rules[1]
require.Equal(t, DirAny, host.Direction)
require.Equal(t, "192.0.2.5", host.Source)
require.Equal(t, "unrelated note separated by blank", host.Comment)
// Inline comment is ignored, not treated as a rule comment.
v6 := rules[2]
require.Equal(t, DirAny, v6.Direction)
require.Equal(t, "", v6.Comment)
require.Equal(t, "2001:db8::1", v6.Source)
// Add a rule with a comment: a prefixed full-line comment is written above it.
add := &Rule{Proto: TCP, Port: 443, Source: "192.0.2.10", Action: Accept, Comment: "web"}
require.NoError(t, fw.EditIPList(ctx, path, Accept, add, false))
data, err := os.ReadFile(path)
require.NoError(t, err)
require.Contains(t, string(data), "# myapp web\n")
require.Contains(t, string(data), "tcp:in:d=443:s=192.0.2.10")
// Removing the rule drops the comment line above it as well.
require.NoError(t, fw.EditIPList(ctx, path, Accept, add, true))
data, err = os.ReadFile(path)
require.NoError(t, err)
require.NotContains(t, string(data), "# myapp web")
require.NotContains(t, string(data), "192.0.2.10")
// A port-only rule has nowhere to go in an IP-list file; no dangling
// comment line should be written even when a comment is supplied.
portOnly := &Rule{Proto: TCP, Port: 8080, Action: Accept, Comment: "not-stored"}
require.NoError(t, fw.EditIPList(ctx, path, Accept, portOnly, false))
data, err = os.ReadFile(path)
require.NoError(t, err)
require.NotContains(t, string(data), "not-stored")
// A rule appended after instructional header comments must still report
// HasPrefix: the prefix tag starts a fresh comment block so header
// comments are not absorbed into the rule's comment.
headerPath := filepath.Join(dir, "header_allow_hosts.rules")
require.NoError(t, os.WriteFile(headerPath, []byte(
"# This is the apf allow_hosts.rules file.\n"+
"# Add hosts/rules below, one per line.\n"+
"# Format: proto:flow:port:ip\n",
), 0644))
appendRule := &Rule{Proto: TCP, Port: 3456, Source: "192.0.2.10/32", Action: Accept}
require.NoError(t, fw.EditIPList(ctx, headerPath, Accept, appendRule, false))
parsed, err := fw.ParseIPList(headerPath, Accept)
require.NoError(t, err)
require.Len(t, parsed, 1)
require.True(t, parsed[0].HasPrefix, "rule after header comments must be flagged with the prefix")
require.Equal(t, "", parsed[0].Comment)
}
// TestAPFRemovePreservesForeignHeader verifies that removing a managed rule keeps
// a foreign section header sitting directly above its prefix tag. ParseIPList
// treats the tag as starting a fresh comment block, so the header is not part of
// the rule's comment; removal must mirror that and not delete it.
func TestAPFRemovePreservesForeignHeader(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "allow_hosts.rules")
fw := &APF{rulePrefix: "myapp"}
ctx := context.Background()
require.NoError(t, os.WriteFile(path, []byte(
"# Section: web servers\n"+
"# myapp trusted\n"+
"tcp:in:d=22:s=192.0.2.50/32\n",
), 0644))
require.NoError(t, fw.EditIPList(ctx, path, Accept, &Rule{Proto: TCP, Port: 22, Source: "192.0.2.50/32", Action: Accept}, true))
data, err := os.ReadFile(path)
require.NoError(t, err)
got := string(data)
require.NotContains(t, got, "tcp:in:d=22", "the managed rule must be removed")
require.NotContains(t, got, "# myapp trusted", "the rule's own tag comment is removed with it")
require.Contains(t, got, "# Section: web servers", "the foreign section header must be preserved")
}
// conf.apf's ALL_STOP accepts DROP, REJECT and PROHIBIT (upstream apf builds a
// dedicated PROHIBIT chain that rejects with an ICMP prohibited response). This
// model has no third action, so PROHIBIT must map to Reject like REJECT does,
// not silently fall into the DROP default alongside genuinely unrecognized
// values.
func TestAPFParseStopAction(t *testing.T) {
fw := new(APF)
cases := []struct {
val string
want Action
}{
{"DROP", Drop},
{"REJECT", Reject},
{"PROHIBIT", Reject},
{"prohibit", Reject},
{`"PROHIBIT"`, Reject},
{"", Drop},
{"BOGUS", Drop},
}
for _, c := range cases {
require.Equal(t, c.want, fw.parseStopAction(c.val), "ALL_STOP=%q", c.val)
}
}
// conf.apf's ALL_STOP, TCP_STOP and UDP_STOP are independent settings (upstream
// apf_validate.sh validates each separately with no equality constraint, and
// they may be set differently on a real host). readStopAction/stopKey must
// read the setting matching a deny's protocol, not conflate them, so a fixture
// where they diverge is read back correctly.
func TestAPFReadStopActionIndependentSettings(t *testing.T) {
fw := new(APF)
dir := t.TempDir()
conf := filepath.Join(dir, "conf.apf")
require.NoError(t, os.WriteFile(conf, []byte("ALL_STOP=\"DROP\"\nTCP_STOP=\"REJECT\"\nUDP_STOP=\"DROP\"\n"), 0o644))
require.Equal(t, Drop, fw.readStopAction(conf, "ALL_STOP"))
require.Equal(t, Reject, fw.readStopAction(conf, "TCP_STOP"))
require.Equal(t, Drop, fw.readStopAction(conf, "UDP_STOP"))
require.Equal(t, "ALL_STOP", fw.stopKey(ProtocolAny))
require.Equal(t, "TCP_STOP", fw.stopKey(TCP))
require.Equal(t, "UDP_STOP", fw.stopKey(UDP))
}
// APF's IG_*_CPORTS lists are dual-stack (one list applied to both v4 and v6), so
// a port rule read from them must be FamilyAny, not IPv4. Otherwise a FamilyAny
// desired rule (the default) never matches its own read-back and Sync churns.
func TestAPFPortListFamilyIsAny(t *testing.T) {
f := new(APF)
rules := f.ParsePorts("22", TCP, DirInput)
require.Len(t, rules, 1)
require.Equal(t, FamilyAny, rules[0].Family,
"a dual-stack CPORTS entry must read back as FamilyAny")
// End to end: a default (FamilyAny) desired rule equals its read-back.
desired := &Rule{Proto: TCP, Port: 22, Action: Accept}
require.True(t, desired.Equal(rules[0], true),
"FamilyAny tcp/22 must equal the APF read-back or Sync churns")
}
// APF's IG_*_CLIMIT lists are likewise dual-stack, so a connection-limit rule
// read from them must be FamilyAny to reconcile with a FamilyAny desired rule.
func TestAPFConnLimitFamilyIsAny(t *testing.T) {
f := new(APF)
rules := f.ParseConnLimit("80:50", TCP)
require.Len(t, rules, 1)
require.Equal(t, FamilyAny, rules[0].Family,
"a dual-stack CLIMIT entry must read back as FamilyAny")
desired := &Rule{Proto: TCP, Port: 80, Action: Reject, ConnLimit: &ConnLimit{Count: 50, PerSource: true}}
require.True(t, desired.Equal(rules[0], true),
"FamilyAny connlimit must equal the APF read-back or Sync churns")
}
// A port-only deny must not corrupt the rule's family. apf requires an address
// field, so it writes an "any" placeholder matching the rule's family. A
// family-neutral rule writes one line per family: two rows that cover the rule
// between them.
//
// The rule action is Drop, not Reject: a tcp port-carrying deny_hosts entry is an
// apf "advanced" entry, which apf routes through TCP_STOP (not ALL_STOP) — Drop is
// the stock default for both, and is the value denyActionFor actually resolves
// to here since no real conf.apf exists in this test environment. Using an action
// apf would not actually apply to this entry would make EditIPList reject it.
func TestAPFPortOnlyRejectFamily(t *testing.T) {
// IPv6 enabled, so a family-neutral deny fans out to both families (see filterFamiliesIPv6).
fw := &APF{ipv6Enabled: true}
ctx := context.Background()
for _, rule := range []*Rule{
{Action: Drop, Proto: TCP, Port: 80},
{Action: Drop, Proto: TCP, Port: 80, Family: IPv4},
{Action: Drop, Proto: TCP, Port: 8080, Family: IPv6},
} {
deny := filepath.Join(t.TempDir(), "deny_hosts.rules")
require.NoError(t, os.WriteFile(deny, nil, 0o644))
require.NoError(t, fw.EditIPList(ctx, deny, Drop, rule, false))
// A concrete-family rule is one line; a family-neutral one is a line per family.
// Either way the rows read back cover exactly the rule that was written.
wantRows := 1
if rule.impliedFamily() == FamilyAny {
wantRows = 2
}
got, err := fw.ParseIPList(deny, Drop)
require.NoError(t, err)
require.Len(t, got, wantRows, "port-only deny (%s) must round-trip to %d row(s)", rule.Family, wantRows)
require.True(t, rule.CoveredBy(got), "read-back rows must cover the written rule; want family=%s", rule.Family)
for _, g := range got {
require.True(t, rule.Covers(g), "read-back row must not widen the written rule: %+v", g)
}
// It must also be removable (matched back on delete).
require.NoError(t, fw.EditIPList(ctx, deny, Drop, rule, true))
got, err = fw.ParseIPList(deny, Drop)
require.NoError(t, err)
require.Len(t, got, 0, "rule (%s) must be fully removed", rule.Family)
}
}
// A port-only deny fans out across family, but the file may already hold a subset of
// those lines — a prior single-family add, a manual edit, or the same rule added
// twice by a reconcile. The add must note each fan-out line present and write only
// the rest. A single-"exists" gate does not cover a family-neutral target: one line
// present must not count as the whole rule, and a present IPv4 line must not leave
// the IPv6 twin unwritten.
func TestAPFPortOnlyDenyHealsAndDoesNotDuplicate(t *testing.T) {
ctx := context.Background()
// IPv6 enabled, so the deny fans out to an IPv4 and an IPv6 line.
fw := &APF{ipv6Enabled: true}
dir := t.TempDir()
// Adding the same family-neutral deny twice must leave one line per family.
path := filepath.Join(dir, "deny_hosts.rules")
require.NoError(t, os.WriteFile(path, nil, 0o644))
deny := &Rule{Family: FamilyAny, Proto: TCP, Port: 80, Action: Drop}
require.NoError(t, fw.EditIPList(ctx, path, Drop, deny, false))
require.NoError(t, fw.EditIPList(ctx, path, Drop, deny, false))
data, err := os.ReadFile(path)
require.NoError(t, err)
text := string(data)
require.Equal(t, 1, strings.Count(text, "tcp:in:d=80:s=0.0.0.0/0"),
"re-adding the rule must not duplicate the IPv4 line")
require.Equal(t, 1, strings.Count(text, "tcp:in:d=80:s=[::/0]"),
"re-adding the rule must not duplicate the IPv6 line")
// A file holding only the IPv4 line must gain the missing IPv6 one, so IPv6:80 is
// actually blocked rather than reported blocked while open.
path2 := filepath.Join(dir, "deny_hosts2.rules")
require.NoError(t, os.WriteFile(path2, []byte("tcp:in:d=80:s=0.0.0.0/0\n"), 0o644))
require.NoError(t, fw.EditIPList(ctx, path2, Drop, deny, false))
data2, err := os.ReadFile(path2)
require.NoError(t, err)
text2 := string(data2)
require.Equal(t, 1, strings.Count(text2, "tcp:in:d=80:s=0.0.0.0/0"),
"the pre-existing IPv4 line must be preserved, not duplicated")
require.Equal(t, 1, strings.Count(text2, "tcp:in:d=80:s=[::/0]"),
"the missing IPv6 line must be added")
}
// A bare all-protocol host rule (address, no port) is the one portless address
// shape apf's trust files express, written as the plain address line. The
// inexpressible shapes — a concrete-protocol host or a source+destination pair —
// are diverted to the hook by AddRule (shapeNeedsHook) and never reach this
// writer, so only the legitimate write is exercised here.
func TestAPFBareHostWritten(t *testing.T) {
fw := new(APF)
ctx := context.Background()
list := filepath.Join(t.TempDir(), "allow_hosts.rules")
require.NoError(t, os.WriteFile(list, nil, 0o644))
require.NoError(t, fw.EditIPList(ctx, list, Accept, &Rule{Source: "1.2.3.4", Action: Accept}, false))
got, err := os.ReadFile(list)
require.NoError(t, err)
require.Contains(t, string(got), "1.2.3.4", "an any-protocol host rule must be written as a plain address")
}
// TestAPFMultiPortRemovalSweepsCPortsTokens covers removal of a multi-port
// accept against a conf.apf CPORTS list: the rule lives in the hook now, but an
// earlier per-port add (or a manual edit) may hold the same ports as list
// tokens, so removeFamilyAnyPort's EditConf sweep must strip exactly the
// target's tokens and no others.
func TestAPFMultiPortRemovalSweepsCPortsTokens(t *testing.T) {
fw := new(APF)
target := &Rule{Proto: TCP, Ports: []PortRange{{Start: 80, End: 80}, {Start: 443, End: 443}}, Action: Accept}
require.Equal(t, `IG_TCP_CPORTS="22"`,
fw.EditRulePort(`IG_TCP_CPORTS="22,80,443"`, "IG_TCP_CPORTS", "22,80,443", target, true),
"a multi-port removal must strip each of its own port tokens and keep the rest")
}
// APF EditIPList must write the missing IPv6 line when adding the IPv6 twin of an
// existing IPv4 port-only deny; the family-specific EqualBase check must not
// treat the IPv4 line as covering IPv6 and write nothing, leaving IPv6 open.
func TestAPFCrossFamilyDenyAddsMissingFamily(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "deny_hosts.rules")
require.NoError(t, os.WriteFile(path, []byte("tcp:in:d=80:s=0.0.0.0/0\n"), 0o644))
f := &APF{}
r := &Rule{Proto: TCP, Port: 80, Direction: DirInput, Family: IPv6, Action: Drop}
require.NoError(t, f.EditIPList(context.Background(), path, Drop, r, false))
out, err := os.ReadFile(path)
require.NoError(t, err)
require.Contains(t, string(out), "::", "an IPv6 (::/0) deny line must be written so IPv6 port 80 is blocked")
require.Contains(t, string(out), "0.0.0.0/0", "the existing IPv4 deny must be preserved")
}
// APF RemoveRule of an IPv4-pinned port-only deny must not take out the IPv6 twin:
// EqualForRemoval gates the family so removing one family keeps the other.
func TestAPFCrossFamilyRemoveKeepsOppositeFamily(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "deny_hosts.rules")
require.NoError(t, os.WriteFile(path, []byte("tcp:in:d=80:s=0.0.0.0/0\ntcp:in:d=80:s=[::/0]\n"), 0o644))
f := &APF{}
// Remove only the IPv4 port-80 deny.
r := &Rule{Proto: TCP, Port: 80, Direction: DirInput, Family: IPv4, Action: Drop}
require.NoError(t, f.EditIPList(context.Background(), path, Drop, r, true))
out, err := os.ReadFile(path)
require.NoError(t, err)
require.Contains(t, string(out), "[::/0]", "the IPv6 deny must survive removing the IPv4 twin")
require.NotContains(t, string(out), "0.0.0.0/0", "the IPv4 deny must be removed")
}
// Editing an existing connection-limit entry's count must record a config change
// so Reload runs apf --restart and the new limit is applied; an unchanged count
// must not trigger a spurious restart.
func TestAPFConnLimitCountChangeReloads(t *testing.T) {
fw := new(APF)
// Changing the count from 50 to 25 must flag a config change.
fw.ConfigChanged = false
out := fw.editConnLimit("IG_TCP_CLIMIT", "80:50",
&Rule{Proto: TCP, Port: 80, Action: Reject, ConnLimit: &ConnLimit{Count: 25, PerSource: true}}, false)
require.Equal(t, `IG_TCP_CLIMIT="80:25"`, out)
require.True(t, fw.ConfigChanged, "a changed connlimit count must set ConfigChanged")
// Re-applying the same count must not flag a change.
fw.ConfigChanged = false
out = fw.editConnLimit("IG_TCP_CLIMIT", "80:25",
&Rule{Proto: TCP, Port: 80, Action: Reject, ConnLimit: &ConnLimit{Count: 25, PerSource: true}}, false)
require.Equal(t, `IG_TCP_CLIMIT="80:25"`, out)
require.False(t, fw.ConfigChanged, "an unchanged connlimit count must not set ConfigChanged")
}
// TestAPFTCPUDPCPortsFanOut covers the write half of the both-transports port accept:
// conf.apf's CPORTS lists are per-transport, so a TCPUDP port must be added to (and
// removed from) both. EditRulePort must key a TCPUDP rule into both the TCP and UDP
// lists, not match neither and leave the port unopened.
func TestAPFTCPUDPCPortsFanOut(t *testing.T) {
fw := new(APF)
r := &Rule{Proto: TCPUDP, Port: 80, Action: Accept, Direction: DirInput}
require.Equal(t, `IG_TCP_CPORTS="22,80"`,
fw.EditRulePort(`IG_TCP_CPORTS="22"`, "IG_TCP_CPORTS", "22", r, false),
"a tcpudp port must be added to the tcp list")
require.Equal(t, `IG_UDP_CPORTS="53,80"`,
fw.EditRulePort(`IG_UDP_CPORTS="53"`, "IG_UDP_CPORTS", "53", r, false),
"a tcpudp port must be added to the udp list")
// Removal clears it from both lists.
require.Equal(t, `IG_TCP_CPORTS="22"`,
fw.EditRulePort(`IG_TCP_CPORTS="22,80"`, "IG_TCP_CPORTS", "22,80", r, true))
require.Equal(t, `IG_UDP_CPORTS="53"`,
fw.EditRulePort(`IG_UDP_CPORTS="53,80"`, "IG_UDP_CPORTS", "53,80", r, true))
// A concrete transport still touches only its own list.
tcp := &Rule{Proto: TCP, Port: 80, Action: Accept, Direction: DirInput}
require.Equal(t, `IG_UDP_CPORTS="53"`,
fw.EditRulePort(`IG_UDP_CPORTS="53"`, "IG_UDP_CPORTS", "53", tcp, false),
"a tcp rule must not open the udp port")
// An outbound rule touches only the egress lists.
out := &Rule{Proto: TCPUDP, Port: 80, Action: Accept, Direction: DirOutput}
require.Equal(t, `IG_TCP_CPORTS="22"`,
fw.EditRulePort(`IG_TCP_CPORTS="22"`, "IG_TCP_CPORTS", "22", out, false))
require.Equal(t, `EG_TCP_CPORTS="22,80"`,
fw.EditRulePort(`EG_TCP_CPORTS="22"`, "EG_TCP_CPORTS", "22", out, false))
}
// TestAPFTCPUDPCPortsReadBack covers the read half: apf's CPORTS lists are keyed per
// transport, so a TCPUDP port is one entry in each and reads back as one rule per
// list — two dual-stack rules that together cover the TCPUDP rule that was written.
// A port in only one list covers only its own transport.
func TestAPFTCPUDPCPortsReadBack(t *testing.T) {
fw := new(APF)
rules := append(fw.ParsePorts("80", TCP, DirInput), fw.ParsePorts("80", UDP, DirInput)...)
require.Len(t, rules, 2, "the two lists parse independently")
for _, r := range rules {
require.Equal(t, FamilyAny, r.Family, "a CPORTS entry is dual-stack")
}
both := &Rule{Proto: TCPUDP, Port: 80, Action: Accept, Direction: DirInput}
require.True(t, both.CoveredBy(rules), "the tcp+udp CPORTS entries cover the TCPUDP rule")
// A port in only one list leaves the other transport uncovered.
tcpOnly := fw.ParsePorts("80", TCP, DirInput)
require.Len(t, tcpOnly, 1)
require.Equal(t, TCP, tcpOnly[0].Proto)
require.False(t, both.CoveredBy(tcpOnly), "the tcp entry alone must not cover a TCPUDP rule")
require.True(t, (&Rule{Proto: TCP, Port: 80, Action: Accept, Direction: DirInput}).CoveredBy(tcpOnly))
}
// TestAPFTCPUDPAdvRule: apf's advanced rule treats a missing protocol field as both
// transports (its trust parser derives a -p tcp and a -p udp rule from it), so TCPUDP
// is written by omitting the field and must read back as TCPUDP — never ProtocolAny,
// which would claim every IP protocol is matched.
func TestAPFTCPUDPAdvRule(t *testing.T) {
fw := new(APF)
r := &Rule{Proto: TCPUDP, Port: 80, Source: "192.0.2.1", Action: Accept, Direction: DirInput}
line := fw.MarshalAdvRule(r)
require.Equal(t, "in:d=80:s=192.0.2.1", line, "the protocol field is omitted for both transports")
back := fw.ParseAdvRule(line, Accept)
require.NotNil(t, back)
require.Equal(t, TCPUDP, back.Proto, "a protocol-less advanced line is tcp+udp, not every protocol")
require.True(t, back.EqualBase(r, true))
// A concrete transport names itself and round-trips unchanged.
line = fw.MarshalAdvRule(&Rule{Proto: TCP, Port: 80, Source: "192.0.2.1", Action: Accept})
require.Equal(t, "tcp:in:d=80:s=192.0.2.1", line)
require.Equal(t, TCP, fw.ParseAdvRule(line, Accept).Proto)
}
// With conf.apf's USE_IPV6 off, apf installs no IPv6 rule from its config, so a
// family-neutral port-only deny must be written as the IPv4 line alone (see
// filterFamiliesIPv6). Removal still sweeps both families.
func TestAPFPortOnlyDenyIPv6DisabledWritesV4Only(t *testing.T) {
ctx := context.Background()
dir := t.TempDir()
off := new(APF)
path := filepath.Join(dir, "deny_hosts.rules")
require.NoError(t, os.WriteFile(path, nil, 0o644))
deny := &Rule{Family: FamilyAny, Proto: TCP, Port: 80, Action: Drop}
require.NoError(t, off.EditIPList(ctx, path, Drop, deny, false))
data, err := os.ReadFile(path)
require.NoError(t, err)
require.Contains(t, string(data), "tcp:in:d=80:s=0.0.0.0/0", "the IPv4 line must be written")
require.NotContains(t, string(data), "[::/0]",
"no IPv6 line may be written while apf's IPv6 handling is off")
got, err := off.ParseIPList(path, Drop)
require.NoError(t, err)
require.Len(t, got, 1)
require.Equal(t, IPv4, got[0].impliedFamily())
// Switching IPv6 off must not strand the v6 line written while it was on.
on := &APF{ipv6Enabled: true}
bothPath := filepath.Join(dir, "deny_hosts.both.rules")
require.NoError(t, os.WriteFile(bothPath, nil, 0o644))
require.NoError(t, on.EditIPList(ctx, bothPath, Drop, deny, false))
data, err = os.ReadFile(bothPath)
require.NoError(t, err)
require.Contains(t, string(data), "[::/0]")
require.NoError(t, off.EditIPList(ctx, bothPath, Drop, deny, true))
data, err = os.ReadFile(bothPath)
require.NoError(t, err)
require.NotContains(t, string(data), "d=80",
"removal must sweep the stale IPv6 line even with IPv6 off")
}
// With USE_IPV6 off a family-agnostic NAT rule is written for IPv4 only and a
// concrete-IPv6 one is rejected outright; removal still sweeps both families so
// a stale v6 line does not survive an IPv6 switch-off.
func TestAPFNATIPv6Gating(t *testing.T) {
off := new(APF)
masq := &NATRule{Kind: Masquerade, Interface: "eth0"}
rows := filterNATFamiliesIPv6(false, masq)
require.Len(t, rows, 1, "a family-agnostic write must narrow to IPv4 while IPv6 is off")
require.Equal(t, IPv4, rows[0].impliedFamily())
// Removal still sweeps both families: the family-agnostic target covers the
// stale IPv6 line through EqualForRemoval's family check.
v6 := *masq
v6.Family = IPv6
require.True(t, v6.EqualForRemoval(masq), "removal must still cover the IPv6 line")
err := off.AddNATRule(context.Background(), "", &NATRule{Kind: DNAT, Family: IPv6, Proto: TCP, Port: 8080, ToAddress: "2001:db8::5"})
require.ErrorIs(t, err, ErrUnsupportedNAT, "a concrete-IPv6 nat add must be rejected while IPv6 is off")
rows = filterNATFamiliesIPv6(true, masq)
require.Len(t, rows, 2, "with IPv6 on a family-agnostic write fans out to both families")
}
// confKeyApplies mirrors EditRulePort's routing guards; EditConf keys its
// missing-config-line detection on it.
func TestAPFConfKeyApplies(t *testing.T) {
fw := new(APF)
port := &Rule{Proto: TCP, Port: 80, Action: Accept}
require.True(t, fw.confKeyApplies("IG_TCP_CPORTS", port))
require.False(t, fw.confKeyApplies("IG_UDP_CPORTS", port))
require.False(t, fw.confKeyApplies("EG_TCP_CPORTS", port))
both := &Rule{Proto: TCPUDP, Port: 53, Action: Accept}
require.True(t, fw.confKeyApplies("IG_TCP_CPORTS", both))
require.True(t, fw.confKeyApplies("IG_UDP_CPORTS", both))
icmp6 := &Rule{Proto: ICMPv6, ICMPType: Ptr[uint8](128), Action: Accept}
require.True(t, fw.confKeyApplies("IG_ICMPV6_TYPES", icmp6))
require.False(t, fw.confKeyApplies("IG_ICMP_TYPES", icmp6))
climit := &Rule{Proto: TCP, Port: 80, Action: Reject, ConnLimit: &ConnLimit{Count: 5, PerSource: true}}
require.True(t, fw.confKeyApplies("IG_TCP_CLIMIT", climit))
require.False(t, fw.confKeyApplies("IG_TCP_CPORTS", climit),
"a connlimit rule must never touch the accept port lists")
}
// apfLiveSave is a trimmed `iptables-save -c -t filter` capture from a host
// running apf, covering each shape it generates: a conf.apf port-list accept, an
// ICMP-type accept carrying apf's ICMP_LIM framing, a bare trust-list address
// (one entry, one row per direction), and an advanced trust line.
var apfLiveSave = []string{
"*filter",
":INPUT DROP [0:0]",
"[9:540] -A INPUT -j TALLOW",
"[3:180] -A INPUT -p tcp -m tcp --dport 22 -j ACCEPT",
"[5:300] -A INPUT -p udp -m udp --dport 53 -j ACCEPT",
"[4:336] -A INPUT -p icmp -m icmp --icmp-type 8 -m limit --limit 30/sec -j ACCEPT",
"[2:120] -A OUTPUT -p tcp -m tcp --dport 25 -j ACCEPT",
"[1:60] -A TALLOW -s 172.31.5.5/32 -j ACCEPT",
"[1:44] -A TALLOW -d 172.31.5.5/32 -j ACCEPT",
"[7:420] -A TALLOW -s 172.31.7.7/32 -p tcp -m multiport --dports 8443 -j ACCEPT",
"[8:480] -A TDENY -s 172.31.9.9/32 -j DROP",
"COMMIT",
}
// TestAPFParseLiveRules verifies apf's framing is undone on read: the rate it
// attaches to every accepted ICMP type is dropped, and a row in its trust chains
// — which are entered from both INPUT and OUTPUT — takes its direction from the
// address side it matches on.
func TestAPFParseLiveRules(t *testing.T) {
fw := new(APF)
// The capture's ICMP row carries apf's stock ICMP_LIM; state the rate here
// rather than reading conf.apf, which is not present under test.
rules := fw.decodeLiveRules(apfLiveSave, IPv4, &RateLimit{Rate: 30, Unit: PerSecond})
require.Len(t, rules, 8, "the jump into TALLOW models no rule of its own")
require.EqualValues(t, 22, rules[0].Port)
require.EqualValues(t, 3, rules[0].Packets)
icmp := rules[2]
require.Equal(t, ICMP, icmp.Proto)
require.Nil(t, icmp.RateLimit, "apf's ICMP_LIM framing is not part of the rule")
require.EqualValues(t, 4, icmp.Packets)
// The trust chains carry no direction, so the row's address side supplies it.
require.Equal(t, DirInput, rules[4].Direction, "a source match is the inbound half")
require.Equal(t, DirOutput, rules[5].Direction, "a destination match is the outbound half")
require.Equal(t, DirInput, rules[6].Direction)
require.EqualValues(t, 8443, rules[6].Port)
}
// TestAPFApplyCounters verifies an apf entry that spans more than one axis sums
// every row it materializes into: a CPORTS entry is dual-stack (FamilyAny) and a
// bare trust address is bidirectional (DirAny).
func TestAPFApplyCounters(t *testing.T) {
fw := new(APF)
live := fw.decodeLiveRules(apfLiveSave, IPv4, &RateLimit{Rate: 30, Unit: PerSecond})
port := &Rule{Direction: DirInput, Family: FamilyAny, Proto: TCP, Port: 22, Action: Accept}
host := &Rule{Direction: DirAny, Family: IPv4, Source: "172.31.5.5", Action: Accept}
adv := &Rule{Direction: DirInput, Family: IPv4, Proto: TCP, Port: 8443, Source: "172.31.7.7", Action: Accept}
applyLiveCounters([]*Rule{port, host, adv}, live)
require.EqualValues(t, 3, port.Packets, "a dual-stack CPORTS entry counts this family's row")
require.EqualValues(t, 2, host.Packets, "a bare trust address sums both directions")
require.EqualValues(t, 104, host.Bytes)
require.EqualValues(t, 7, adv.Packets, "an advanced trust line matches its single row")
}
// TestAPFCountableRulesSpansFamilies verifies a dual-stack entry is offered to
// both families' rulesets, so its counters accumulate across the two rather than
// reporting only IPv4.
func TestAPFCountableRulesSpansFamilies(t *testing.T) {
dual := &Rule{Direction: DirInput, Family: FamilyAny, Proto: TCP, Port: 22, Action: Accept}
v4only := &Rule{Direction: DirInput, Family: IPv4, Proto: TCP, Port: 80, Action: Accept}
rules := []*Rule{dual, v4only}
require.Len(t, countableRules(rules, IPv4), 2)
require.Equal(t, []*Rule{dual}, countableRules(rules, IPv6),
"only the family-agnostic rule is offered to the IPv6 ruleset")
// Both families' rows add up onto the one reported rule.
v4 := []*Rule{{Direction: DirInput, Family: IPv4, Proto: TCP, Port: 22, Action: Accept, Packets: 3, Bytes: 180}}
v6 := []*Rule{{Direction: DirInput, Family: IPv6, Proto: TCP, Port: 22, Action: Accept, Packets: 2, Bytes: 160}}
applyLiveCounters(countableRules(rules, IPv4), v4)
applyLiveCounters(countableRules(rules, IPv6), v6)
require.EqualValues(t, 5, dual.Packets)
require.EqualValues(t, 340, dual.Bytes)
}

148
atomicfile.go Normal file
View file

@ -0,0 +1,148 @@
package firewall
import (
"bufio"
"errors"
"os"
"path/filepath"
)
// atomicFile stages an atomic replacement of a config file. It creates a temp
// file in the destination's directory and captures the destination's ownership
// and permission mode up front; Commit restores that metadata onto the temp
// file before renaming it into place. A destination that does not yet exist
// falls back to defaultMode with the temp file's default ownership.
//
// The struct embeds *os.File so callers stream into it with the usual Write and
// fmt.Fprintln calls, whether they buffer the whole file or scan the original
// and rewrite line by line. Writes pass through an internal bufio.Writer that
// Commit flushes, so a line-by-line rewrite of a large save file does not issue
// a syscall per line; the embedded fd remains reachable for the metadata calls
// Commit makes.
type atomicFile struct {
*os.File
w *bufio.Writer
dst string
tmp string
mode os.FileMode
uid, gid int
chown bool // True only when the destination already existed.
done bool
}
// newAtomicFile opens a temp file next to dst for streaming. The caller writes
// to the returned handle, then calls Commit to install it or Abort to discard
// it. Capturing the destination's mode and ownership here means a later
// scan-and-rewrite of the original still commits with the original's metadata.
// A symlinked destination is resolved so the rename replaces the link's target
// rather than turning the link itself into a regular file.
func newAtomicFile(dst string, defaultMode os.FileMode) (*atomicFile, error) {
if resolved, err := filepath.EvalSymlinks(dst); err == nil {
dst = resolved
}
mode := defaultMode
var uid, gid int
var chown bool
if fi, err := os.Stat(dst); err == nil {
mode = fi.Mode().Perm()
uid, gid, chown = statOwner(fi)
}
fd, err := os.CreateTemp(filepath.Dir(dst), filepath.Base(dst)+".tmp.*")
if err != nil {
return nil, err
}
return &atomicFile{
File: fd,
w: bufio.NewWriter(fd),
dst: dst,
tmp: fd.Name(),
mode: mode,
uid: uid,
gid: gid,
chown: chown,
}, nil
}
// Write buffers p into the staged file. It shadows the embedded fd's own Write
// so every caller — the line-by-line rewrites and the whole-buffer writers
// alike — shares one bufio.Writer; Commit flushes it before installing the
// file, and Abort discards it with the temp file.
func (a *atomicFile) Write(p []byte) (int, error) {
return a.w.Write(p)
}
// Commit flushes, applies the captured mode and ownership to the temp file, and
// renames it over the destination. Ownership is best-effort: a caller that can
// write the file but cannot chown it keeps the temp file's owner and still gets
// the original mode.
func (a *atomicFile) Commit() error {
if a.done {
return nil
}
// Flush the buffered writes down to the fd before any of the metadata and
// durability steps below act on it.
if err := a.w.Flush(); err != nil {
return a.fail(err)
}
// Apply mode and ownership through the fd so the staged file is never
// briefly installed with the wrong permissions.
if err := a.Chmod(a.mode); err != nil {
return a.fail(err)
}
if a.chown {
if err := a.Chown(a.uid, a.gid); err != nil && !errors.Is(err, os.ErrPermission) {
return a.fail(err)
}
}
// Sync before the rename: these files are reboot-persistence-critical
// (csf.conf, iptables save files), and a crash could otherwise reorder the
// rename ahead of the data reaching disk, installing a truncated config.
if err := a.Sync(); err != nil {
return a.fail(err)
}
if err := a.Close(); err != nil {
return a.fail(err)
}
// Install the staged file.
if err := os.Rename(a.tmp, a.dst); err != nil {
_ = os.Remove(a.tmp)
a.done = true
return err
}
a.done = true
return nil
}
// Abort discards the temp file. It is a no-op after a successful Commit, so a
// caller may defer Abort immediately after opening.
func (a *atomicFile) Abort() {
if a.done {
return
}
_ = a.Close()
_ = os.Remove(a.tmp)
a.done = true
}
// fail closes and removes the temp file, then returns the triggering error.
func (a *atomicFile) fail(err error) error {
_ = a.Close()
_ = os.Remove(a.tmp)
a.done = true
return err
}
// writeConfigFile atomically replaces path with data for callers that already
// hold the whole file in memory, preserving the existing file's mode and
// ownership (falling back to defaultMode for a file that does not yet exist).
func writeConfigFile(path string, data []byte, defaultMode os.FileMode) error {
af, err := newAtomicFile(path, defaultMode)
if err != nil {
return err
}
defer af.Abort()
if _, err := af.Write(data); err != nil {
return err
}
return af.Commit()
}

11
atomicfile_other.go Normal file
View file

@ -0,0 +1,11 @@
//go:build !unix
package firewall
import "os"
// statOwner reports no ownership on platforms without Unix stat data, so Commit
// leaves the temp file's ownership unchanged.
func statOwner(fi os.FileInfo) (uid, gid int, ok bool) {
return 0, 0, false
}

182
atomicfile_test.go Normal file
View file

@ -0,0 +1,182 @@
//go:build unix
package firewall
import (
"os"
"path/filepath"
"strings"
"syscall"
"testing"
"github.com/stretchr/testify/require"
)
// noTempLeftover asserts the staging directory holds no uncommitted temp files.
func noTempLeftover(t *testing.T, dir string) {
t.Helper()
entries, err := os.ReadDir(dir)
require.NoError(t, err)
for _, e := range entries {
require.NotContains(t, e.Name(), ".tmp.", "a staged temp file was left behind")
}
}
// TestAtomicFilePreservesMode confirms a rewrite of an existing file keeps its
// non-default permission mode rather than resetting to the caller's default.
func TestAtomicFilePreservesMode(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "conf")
require.NoError(t, os.WriteFile(path, []byte("old\n"), 0600))
require.NoError(t, os.Chmod(path, 0640))
af, err := newAtomicFile(path, 0644)
require.NoError(t, err)
_, err = af.Write([]byte("new\n"))
require.NoError(t, err)
require.NoError(t, af.Commit())
fi, err := os.Stat(path)
require.NoError(t, err)
require.Equal(t, os.FileMode(0640), fi.Mode().Perm(), "existing mode must be preserved")
got, err := os.ReadFile(path)
require.NoError(t, err)
require.Equal(t, "new\n", string(got))
noTempLeftover(t, dir)
}
// TestAtomicFileNewFileUsesDefaultMode confirms a file that does not yet exist
// is created with the supplied default mode.
func TestAtomicFileNewFileUsesDefaultMode(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "new-conf")
af, err := newAtomicFile(path, 0600)
require.NoError(t, err)
_, err = af.Write([]byte("content\n"))
require.NoError(t, err)
require.NoError(t, af.Commit())
fi, err := os.Stat(path)
require.NoError(t, err)
require.Equal(t, os.FileMode(0600), fi.Mode().Perm())
noTempLeftover(t, dir)
}
// TestAtomicFileAbortLeavesOriginal confirms Abort discards the staged rewrite
// and leaves the original file and its content untouched.
func TestAtomicFileAbortLeavesOriginal(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "conf")
require.NoError(t, os.WriteFile(path, []byte("original\n"), 0644))
af, err := newAtomicFile(path, 0644)
require.NoError(t, err)
_, err = af.Write([]byte("discarded\n"))
require.NoError(t, err)
af.Abort()
got, err := os.ReadFile(path)
require.NoError(t, err)
require.Equal(t, "original\n", string(got), "Abort must not modify the original")
noTempLeftover(t, dir)
}
// TestAtomicFileAbortAfterCommitIsNoOp confirms a deferred Abort after a
// successful Commit does not remove the installed file.
func TestAtomicFileAbortAfterCommitIsNoOp(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "conf")
af, err := newAtomicFile(path, 0644)
require.NoError(t, err)
_, err = af.Write([]byte("kept\n"))
require.NoError(t, err)
require.NoError(t, af.Commit())
af.Abort() // Must be inert.
got, err := os.ReadFile(path)
require.NoError(t, err)
require.Equal(t, "kept\n", string(got))
}
// TestWriteConfigFilePreservesMode confirms the buffer-all convenience wrapper
// preserves an existing file's mode.
func TestWriteConfigFilePreservesMode(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "conf")
require.NoError(t, os.WriteFile(path, []byte("x"), 0600))
require.NoError(t, os.Chmod(path, 0640))
require.NoError(t, writeConfigFile(path, []byte("y\n"), 0644))
fi, err := os.Stat(path)
require.NoError(t, err)
require.Equal(t, os.FileMode(0640), fi.Mode().Perm())
got, err := os.ReadFile(path)
require.NoError(t, err)
require.Equal(t, "y\n", string(got))
noTempLeftover(t, dir)
}
// TestAtomicFilePreservesOwner confirms a rewrite restores the original file's
// ownership. It requires root to assign a foreign owner, so it is skipped for an
// unprivileged run.
func TestAtomicFilePreservesOwner(t *testing.T) {
if os.Geteuid() != 0 {
t.Skip("requires root to chown to a foreign owner")
}
const uid, gid = 65534, 65534 // nobody/nogroup on most systems.
dir := t.TempDir()
path := filepath.Join(dir, "conf")
require.NoError(t, os.WriteFile(path, []byte("old\n"), 0644))
require.NoError(t, os.Chown(path, uid, gid))
require.NoError(t, writeConfigFile(path, []byte("new\n"), 0644))
fi, err := os.Stat(path)
require.NoError(t, err)
st, ok := fi.Sys().(*syscall.Stat_t)
require.True(t, ok)
require.Equal(t, uint32(uid), st.Uid, "owner uid must be preserved")
require.Equal(t, uint32(gid), st.Gid, "owner gid must be preserved")
}
// TestAtomicFileStagesInDestinationDir confirms the temp file is created next to
// the destination so the final rename is atomic within one filesystem.
func TestAtomicFileStagesInDestinationDir(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "conf")
af, err := newAtomicFile(path, 0644)
require.NoError(t, err)
require.Equal(t, dir, filepath.Dir(af.tmp))
require.True(t, strings.HasPrefix(filepath.Base(af.tmp), "conf.tmp."))
af.Abort()
}
// readConfValue must not truncate a quoted value containing '#', and the last
// assignment of a key wins, matching the shell that sources these files.
func TestReadConfValueQuotingAndLastAssignment(t *testing.T) {
path := filepath.Join(t.TempDir(), "conf")
body := `
# A comment line.
PREFIX = "pre#fix" # trailing comment
USE_IPV6 = "0"
USE_IPV6 = "1"
`
require.NoError(t, os.WriteFile(path, []byte(body), 0644))
v, err := readConfValue(path, "PREFIX")
require.NoError(t, err)
require.Equal(t, "pre#fix", v, "a '#' inside quotes is part of the value")
v, err = readConfValue(path, "USE_IPV6")
require.NoError(t, err)
require.Equal(t, "1", v, "the last assignment wins")
v, err = readConfValue(path, "MISSING")
require.NoError(t, err)
require.Equal(t, "", v)
}

18
atomicfile_unix.go Normal file
View file

@ -0,0 +1,18 @@
//go:build unix
package firewall
import (
"os"
"syscall"
)
// statOwner extracts the uid and gid from a Unix FileInfo. The final return is
// false when the underlying stat data is unavailable, leaving ownership
// unchanged on Commit.
func statOwner(fi os.FileInfo) (uid, gid int, ok bool) {
if st, ok := fi.Sys().(*syscall.Stat_t); ok {
return int(st.Uid), int(st.Gid), true
}
return 0, 0, false
}

280
backup.go Normal file
View file

@ -0,0 +1,280 @@
package firewall
import (
"context"
"encoding/json"
"fmt"
"io"
"strings"
)
// Backup JSON serialization so snapshots can be persisted to disk or moved
// between hosts. Enum values are marshaled as their stable string names to keep
// backups readable across library versions. The two generic helpers below keep
// the per-type boilerplate to one line.
// marshalEnum renders an enum value as its canonical string name.
func marshalEnum[T ~uint8](v T, str func(T) string) ([]byte, error) {
return json.Marshal(str(v))
}
// unmarshalEnum parses an enum value from its canonical string name via parse.
func unmarshalEnum[T ~uint8](data []byte, parse func(string) (T, error)) (T, error) {
var zero T
var s string
if err := json.Unmarshal(data, &s); err != nil {
return zero, err
}
return parse(s)
}
// MarshalJSON renders the action as its stable name (e.g. "accept").
func (a Action) MarshalJSON() ([]byte, error) { return marshalEnum(a, Action.String) }
// UnmarshalJSON parses an action from its stable name. The sentinel "invalid"
// (ActionInvalid) is accepted on the wire for round-trip fidelity even though
// ParseAction rejects it as caller input.
func (a *Action) UnmarshalJSON(data []byte) error {
v, err := unmarshalEnum(data, func(s string) (Action, error) {
if strings.EqualFold(strings.TrimSpace(s), "invalid") {
return ActionInvalid, nil
}
return ParseAction(s)
})
if err != nil {
return err
}
*a = v
return nil
}
// MarshalJSON renders the family as its stable name (e.g. "ipv4").
func (f Family) MarshalJSON() ([]byte, error) { return marshalEnum(f, Family.String) }
// UnmarshalJSON parses a family from its stable name.
func (f *Family) UnmarshalJSON(data []byte) error {
v, err := unmarshalEnum(data, ParseFamily)
if err != nil {
return err
}
*f = v
return nil
}
// MarshalJSON renders the protocol as its stable name (e.g. "tcp").
func (p Protocol) MarshalJSON() ([]byte, error) { return marshalEnum(p, Protocol.String) }
// UnmarshalJSON parses a protocol from its stable name. An unrecognized token is
// rejected rather than silently widening the rule to ProtocolAny; only the
// wildcard spellings ("any" or empty) resolve to ProtocolAny.
func (p *Protocol) UnmarshalJSON(data []byte) error {
v, err := unmarshalEnum(data, func(s string) (Protocol, error) {
proto := GetProtocol(s)
t := strings.TrimSpace(s)
if proto == ProtocolAny && t != "" && !strings.EqualFold(t, "any") {
return 0, fmt.Errorf("unknown protocol %q", s)
}
return proto, nil
})
if err != nil {
return err
}
*p = v
return nil
}
// MarshalJSON renders the connection-state set as a comma-joined name list.
func (s ConnState) MarshalJSON() ([]byte, error) { return marshalEnum(s, ConnState.String) }
// UnmarshalJSON parses a connection-state set from a comma-joined name list.
func (s *ConnState) UnmarshalJSON(data []byte) error {
v, err := unmarshalEnum(data, func(tok string) (ConnState, error) { return ParseConnState(tok) })
if err != nil {
return err
}
*s = v
return nil
}
// MarshalJSON renders the rate unit as its stable name (e.g. "minute").
func (u RateUnit) MarshalJSON() ([]byte, error) { return marshalEnum(u, RateUnit.String) }
// UnmarshalJSON parses a rate unit from its stable name.
func (u *RateUnit) UnmarshalJSON(data []byte) error {
v, err := unmarshalEnum(data, ParseRateUnit)
if err != nil {
return err
}
*u = v
return nil
}
// MarshalJSON renders the NAT kind as its stable name (e.g. "dnat").
func (k NATKind) MarshalJSON() ([]byte, error) { return marshalEnum(k, NATKind.String) }
// UnmarshalJSON parses a NAT kind from its stable name. The sentinel "invalid"
// (NATInvalid) is accepted on the wire for round-trip fidelity even though
// ParseNATKind rejects it as caller input, mirroring Action.
func (k *NATKind) UnmarshalJSON(data []byte) error {
v, err := unmarshalEnum(data, func(s string) (NATKind, error) {
if strings.EqualFold(strings.TrimSpace(s), "invalid") {
return NATInvalid, nil
}
return ParseNATKind(s)
})
if err != nil {
return err
}
*k = v
return nil
}
// MarshalJSON renders the direction as its stable name (e.g. "input").
func (d Direction) MarshalJSON() ([]byte, error) { return marshalEnum(d, Direction.String) }
// UnmarshalJSON parses a direction from its stable name.
func (d *Direction) UnmarshalJSON(data []byte) error {
v, err := unmarshalEnum(data, ParseDirection)
if err != nil {
return err
}
*d = v
return nil
}
// MarshalJSON renders the set type as its stable name (e.g. "hash:net").
func (t SetType) MarshalJSON() ([]byte, error) { return marshalEnum(t, SetType.String) }
// UnmarshalJSON parses a set type from its stable name.
func (t *SetType) UnmarshalJSON(data []byte) error {
v, err := unmarshalEnum(data, ParseSetType)
if err != nil {
return err
}
*t = v
return nil
}
// captureBackupState fills a backup's DefaultPolicy and AddressSets from the
// backend, for the backends that advertise those features. It is shared by every
// Backup implementation so a snapshot captures the full managed state — not just
// filter/NAT rules — without each backend re-probing capabilities. A backend that
// advertises neither feature leaves both fields nil, so the snapshot is unchanged.
func captureBackupState(ctx context.Context, mgr Manager, zoneName string, b *Backup) error {
caps := mgr.Capabilities()
if caps.DefaultPolicy {
// Best-effort: a backend that cannot report a single coherent default policy
// (iptables with out-of-band divergent IPv4/IPv6 chain policies) captures none
// rather than failing the whole backup, and Restore then leaves the policy as
// it finds it. A cancelled context is not that case — it would silently
// produce a snapshot missing the policy a default-drop host depends on — so
// it still fails the backup.
policy, err := mgr.GetDefaultPolicy(ctx, zoneName)
if err != nil && ctx.Err() != nil {
return err
}
if err == nil {
b.DefaultPolicy = policy
}
}
if caps.AddressSets {
sets, err := mgr.GetAddressSets(ctx)
if err != nil {
return err
}
b.AddressSets = sets
}
return nil
}
// restoreBackupSets recreates a backup's address sets so a set-referencing rule
// (@set) resolves on Restore. It runs before the filter rules are re-added. Only
// backends that advertise AddressSets act. cleanFirst removes each set before
// recreating it: a caller whose restore has already cleared the rules that could
// reference a set (a container backend that flushes its table/anchor) passes true
// so the set is rebuilt from a clean slate — needed for nftables, whose
// AddAddressSet is a no-op on an existing set and so would not reconcile its
// entries. A caller whose old rules are still loaded when sets are recreated
// (a tag/rewrite backend) passes false and relies on AddAddressSet's own
// idempotent create-or-reconcile (ipset -exist, pfctl -T replace).
func restoreBackupSets(ctx context.Context, mgr Manager, b *Backup, cleanFirst bool) error {
if b == nil || !mgr.Capabilities().AddressSets {
return nil
}
for _, set := range b.AddressSets {
if cleanFirst {
if err := mgr.RemoveAddressSet(ctx, set.Name); err != nil {
return err
}
}
if err := mgr.AddAddressSet(ctx, set); err != nil {
return err
}
}
return nil
}
// applyBackupPolicy re-asserts a backup's default policy, after the rules are
// restored (the policy is independent of rule order). Only backends that
// advertise DefaultPolicy act, and a nil snapshot policy (a backend that captured
// none) is left unchanged.
func applyBackupPolicy(ctx context.Context, mgr Manager, zoneName string, b *Backup) error {
if b == nil || b.DefaultPolicy == nil || !mgr.Capabilities().DefaultPolicy {
return nil
}
return mgr.SetDefaultPolicy(ctx, zoneName, b.DefaultPolicy)
}
// WriteBackup encodes backup as JSON to w so it can be persisted to disk (or
// moved to another host) and later replayed with ReadBackup or RestoreReader.
//
// The encoding is portable: enum fields are written as their stable string
// names, so a backup survives a reordering of the library's iota constants and
// is readable on a host running a different version of the library. Per-rule
// counters (Packets/Bytes) are carried through for the record but are ignored
// when a backup is restored (they are not part of rule identity).
//
// f, _ := os.Create("backup.json")
// _ = firewall.WriteBackup(f, backup)
func WriteBackup(w io.Writer, backup *Backup) error {
if backup == nil {
return fmt.Errorf("backup cannot be nil")
}
if w == nil {
return fmt.Errorf("writer cannot be nil")
}
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
return enc.Encode(backup)
}
// ReadBackup decodes a backup previously written by WriteBackup from r.
//
// f, _ := os.Open("backup.json")
// backup, _ := firewall.ReadBackup(f)
// _ = mgr.Restore(ctx, zone, backup)
func ReadBackup(r io.Reader) (*Backup, error) {
if r == nil {
return nil, fmt.Errorf("reader cannot be nil")
}
var backup Backup
if err := json.NewDecoder(r).Decode(&backup); err != nil {
return nil, err
}
return &backup, nil
}
// RestoreReader reads a backup from r and applies it via mgr.Restore. It is the
// streaming counterpart of Manager.Restore: a caller can replay a backup
// straight from a file or network reader without first buffering it into a
// *Backup. A read error is returned before any rules are touched.
//
// f, _ := os.Open("backup.json")
// _ = firewall.RestoreReader(ctx, mgr, zone, f)
func RestoreReader(ctx context.Context, mgr Manager, zoneName string, r io.Reader) error {
backup, err := ReadBackup(r)
if err != nil {
return err
}
return mgr.Restore(ctx, zoneName, backup)
}

97
cmd/go-firewall/backup.go Normal file
View file

@ -0,0 +1,97 @@
package main
import (
"context"
"fmt"
"os"
fw "github.com/grmrgecko/go-firewall"
)
// This file holds the backup and restore subcommands. A backup captures the
// managed filter and NAT rules as a portable JSON snapshot (via the library's
// WriteBackup/ReadBackup/RestoreReader), so a snapshot taken on one host can
// be replayed on another or persisted for later.
// BackupSubcmd snapshots the managed rules to a file or stdout. The output is
// the portable JSON form the library's WriteBackup emits, so it round-trips
// across hosts and library versions.
type BackupSubcmd struct {
Zone string `name:"zone" short:"z" help:"Zone name or empty for the default."`
Interface string `name:"interface" short:"i" help:"Resolve the zone for this interface."`
File string `name:"file" short:"o" help:"Write to this file (default: stdout)." placeholder:"FILE"`
}
// Run writes the managed-rule snapshot to the file or stdout.
func (c *BackupSubcmd) Run(g *Globals) error {
mgr, cleanup, err := g.manager()
if err != nil {
return err
}
defer cleanup()
zone, err := resolveZone(mgr, c.Zone, c.Interface)
if err != nil {
return err
}
backup, err := mgr.Backup(context.Background(), zone)
if err != nil {
return fmt.Errorf("backup: %w", err)
}
// WriteBackup always emits JSON; --json here would be redundant, so we
// ignore the global flag and just write the snapshot.
w := os.Stdout
if c.File != "" && c.File != "-" {
f, err := os.Create(c.File)
if err != nil {
return fmt.Errorf("open %s: %w", c.File, err)
}
defer func() { _ = f.Close() }()
w = f
}
if err := fw.WriteBackup(w, backup); err != nil {
return fmt.Errorf("write backup: %w", err)
}
return nil
}
// RestoreSubcmd replays a backup from a file or stdin. Existing managed rules
// are removed before the backup rules are applied (the library's Restore
// semantics), so the managed set ends up exactly equal to the backup.
type RestoreSubcmd struct {
Zone string `name:"zone" short:"z" help:"Zone name or empty for the default."`
Interface string `name:"interface" short:"i" help:"Resolve the zone for this interface."`
File string `name:"file" short:"f" help:"Read from this file (default: stdin)." placeholder:"FILE"`
}
// Run replays a backup snapshot read from the file or stdin.
func (c *RestoreSubcmd) Run(g *Globals) error {
mgr, cleanup, err := g.manager()
if err != nil {
return err
}
defer cleanup()
zone, err := resolveZone(mgr, c.Zone, c.Interface)
if err != nil {
return err
}
r := os.Stdin
if c.File != "" && c.File != "-" {
f, err := os.Open(c.File)
if err != nil {
return fmt.Errorf("open %s: %w", c.File, err)
}
defer func() { _ = f.Close() }()
r = f
}
// RestoreReader decodes and applies in one step, failing before any rules
// are touched if the file is malformed.
if err := fw.RestoreReader(context.Background(), mgr, zone, r); err != nil {
return fmt.Errorf("restore: %w", err)
}
if err := g.reload(mgr); err != nil {
return fmt.Errorf("reload: %w", err)
}
return g.emitStatus("restored")
}

317
cmd/go-firewall/cli_test.go Normal file
View file

@ -0,0 +1,317 @@
package main
import (
"strings"
"testing"
fw "github.com/grmrgecko/go-firewall"
)
// These tests cover the CLI's pure logic — flag parsing, rate/ICMP token
// parsing, and the human-readable renderers. They never open a firewall
// manager and never touch the host, so they run anywhere `go test` does.
func TestParseRateToken(t *testing.T) {
cases := []struct {
in string
rate uint
unit fw.RateUnit
wantErr bool
}{
{"10/second", 10, fw.PerSecond, false},
{"20/m", 20, fw.PerMinute, false},
{"5/hour", 5, fw.PerHour, false},
{"1/d", 1, fw.PerDay, false},
{"10", 0, 0, true}, // missing unit
{"abc/minute", 0, 0, true}, // non-numeric rate
{"10/fortnight", 0, 0, true}, // unknown unit
}
for _, c := range cases {
rate, unit, err := parseRateToken(c.in)
if c.wantErr {
if err == nil {
t.Errorf("parseRateToken(%q): want error, got nil", c.in)
}
continue
}
if err != nil {
t.Errorf("parseRateToken(%q): unexpected error: %v", c.in, err)
continue
}
if rate != c.rate || unit != c.unit {
t.Errorf("parseRateToken(%q): got %d/%v, want %d/%v", c.in, rate, unit, c.rate, c.unit)
}
}
}
func TestParseICMPType(t *testing.T) {
cases := []struct {
in string
v6 bool
want uint8
ok bool
}{
{"8", false, 8, true},
{"echo-request", false, 8, true},
{"ping", false, 8, true},
{"ECHO-REQUEST", false, 8, true}, // case-insensitive
{"destination-unreachable", false, 3, true},
{"255", false, 255, true},
{"echo_boop", false, 0, false}, // unknown name
// ICMPv6 resolves the same names to different numbers.
{"echo-request", true, 128, true},
{"ping", true, 128, true},
{"destination-unreachable", true, 1, true},
{"nd-neighbor-solicit", true, 135, true},
{"128", true, 128, true},
// A name that only exists in the v4 table is unknown under v6.
{"source-quench", true, 0, false},
}
for _, c := range cases {
got, ok := fw.ParseICMPType(c.in, c.v6)
if ok != c.ok {
t.Errorf("fw.ParseICMPType(%q, v6=%v): ok=%v want %v", c.in, c.v6, ok, c.ok)
continue
}
if ok && got != c.want {
t.Errorf("fw.ParseICMPType(%q, v6=%v): got %d want %d", c.in, c.v6, got, c.want)
}
}
}
func TestRuleFlagsToRule(t *testing.T) {
// A representative rule exercising most flag-driven fields. The point is
// to confirm the string flags route through the library's Parse* helpers
// into the right struct fields.
flags := ruleFlags{
Action: "drop",
Family: "ipv4",
Proto: "tcp",
Source: "192.168.0.0/24",
Destination: "10.0.0.1",
Port: 443,
Ports: "80,443,1000-2000",
ICMPType: "echo-request",
State: "new,established",
Log: true,
LogPrefix: "https",
RateLimit: "10/minute",
RateBurst: 5,
ConnLimit: 100,
ConnPerSrc: true,
InInterface: "eth0",
Comment: "web",
}
rule, err := flags.toRule()
if err != nil {
t.Fatalf("toRule: unexpected error: %v", err)
}
if rule.Action != fw.Drop {
t.Errorf("Action: got %v want drop", rule.Action)
}
if rule.Family != fw.IPv4 {
t.Errorf("Family: got %v want ipv4", rule.Family)
}
if rule.Proto != fw.TCP {
t.Errorf("Proto: got %v want tcp", rule.Proto)
}
if rule.Source != "192.168.0.0/24" {
t.Errorf("Source: got %q", rule.Source)
}
if len(rule.Ports) != 3 || rule.Ports[2].Start != 1000 || rule.Ports[2].End != 2000 {
t.Errorf("Ports: got %+v", rule.Ports)
}
if rule.ICMPType == nil || *rule.ICMPType != 8 {
t.Errorf("ICMPType: got %+v", rule.ICMPType)
}
if rule.State != (fw.StateNew | fw.StateEstablished) {
t.Errorf("State: got %v", rule.State)
}
if !rule.Log || rule.LogPrefix != "https" {
t.Errorf("Log: got %v/%q", rule.Log, rule.LogPrefix)
}
if rule.RateLimit == nil || rule.RateLimit.Rate != 10 || rule.RateLimit.Unit != fw.PerMinute || rule.RateLimit.Burst != 5 {
t.Errorf("RateLimit: got %+v", rule.RateLimit)
}
if rule.ConnLimit == nil || rule.ConnLimit.Count != 100 || !rule.ConnLimit.PerSource {
t.Errorf("ConnLimit: got %+v", rule.ConnLimit)
}
if rule.InInterface != "eth0" {
t.Errorf("InInterface: got %q", rule.InInterface)
}
if rule.Comment != "web" {
t.Errorf("Comment: got %q", rule.Comment)
}
}
func TestRuleFlagsToRuleErrors(t *testing.T) {
// Each malformed flag should surface as an error rather than a silent
// default — the CLI must never author a broader rule than the user asked.
bad := []ruleFlags{
{Action: "explode"},
{Family: "ipv9"},
{Proto: "tpc"}, // typo must not silently widen to any
{Ports: "80,abc"},
{State: "new,bogus"},
{RateLimit: "10"},
}
for _, f := range bad {
if _, err := f.toRule(); err == nil {
t.Errorf("toRule(%+v): want error, got nil", f)
}
}
// The same unknown-protocol rejection applies to NAT rules via toNATRule.
if _, err := (&natFlags{Kind: "dnat", Family: "any", Proto: "tpc"}).toNATRule(); err == nil {
t.Errorf("toNATRule with bad proto: want error, got nil")
}
}
func TestDescribeRule(t *testing.T) {
r := &fw.Rule{
Direction: fw.DirInput,
Action: fw.Accept,
Family: fw.IPv4,
Proto: fw.TCP,
Source: "192.168.1.0/24",
Port: 443,
Log: true,
LogPrefix: "https",
Comment: "web",
}
got := describeRule(r)
// Spot-check a few segments rather than the whole string, so the test
// does not break on cosmetic wording changes.
for _, want := range []string{"in", "accept", "ipv4", "tcp", "from 192.168.1.0/24", "port 443", "log=https", "# web"} {
if !strings.Contains(got, want) {
t.Errorf("describeRule: missing %q in %q", want, got)
}
}
}
func TestDescribeNATRule(t *testing.T) {
r := &fw.NATRule{
Kind: fw.DNAT,
Family: fw.IPv4,
Proto: fw.TCP,
Port: 8080,
ToAddress: "10.0.0.5",
ToPort: 80,
}
got := describeNATRule(r)
for _, want := range []string{"dnat", "ipv4", "tcp", "port 8080", "-> 10.0.0.5:80"} {
if !strings.Contains(got, want) {
t.Errorf("describeNATRule: missing %q in %q", want, got)
}
}
}
func TestPrintSet(t *testing.T) {
var buf strings.Builder
set := &fw.AddressSet{
Name: "blocklist",
Family: fw.IPv4,
Type: fw.SetHashNet,
Entries: []string{"203.0.113.0/24", "198.51.100.7"},
}
printSet(&buf, set)
got := buf.String()
for _, want := range []string{"blocklist", "family=ipv4", "type=hash:net", "entries=2", "203.0.113.0/24", "198.51.100.7"} {
if !strings.Contains(got, want) {
t.Errorf("printSet: missing %q in %q", want, got)
}
}
// A zero Type defaults to hash:ip, and an empty set is rendered explicitly
// rather than as a blank line.
buf.Reset()
printSet(&buf, &fw.AddressSet{Name: "empty"})
got = buf.String()
for _, want := range []string{"type=hash:ip", "(no entries)"} {
if !strings.Contains(got, want) {
t.Errorf("printSet(empty): missing %q in %q", want, got)
}
}
}
func TestPrintRulesHonorsWriter(t *testing.T) {
// printRules/printNATRules/printSets must write to the writer they are
// given, not an implicit os.Stdout — a bytes buffer here would stay empty
// if the writer were ignored.
var buf strings.Builder
printRules(&buf, []*fw.Rule{{Action: fw.Accept, Proto: fw.TCP, Port: 22}})
if !strings.Contains(buf.String(), "port 22") {
t.Errorf("printRules did not write to the provided writer: %q", buf.String())
}
}
func TestResolveZone(t *testing.T) {
// resolveZone never touches a real manager when zone is explicit: it
// short-circuits before calling GetZone. A nil manager is therefore fine
// for the explicit-zone case (and only that case).
zone, err := resolveZone(nil, "public", "")
if err != nil {
t.Fatalf("resolveZone: unexpected error: %v", err)
}
if zone != "public" {
t.Errorf("resolveZone: got %q want public", zone)
}
// An explicit --zone takes precedence over --interface (the documented
// contract). With a nil manager this passes only because resolveZone returns
// the zone before it would resolve the interface; if the precedence were
// reversed it would dereference the nil manager and panic.
zone, err = resolveZone(nil, "public", "eth0")
if err != nil {
t.Fatalf("resolveZone with both zone and interface: unexpected error: %v", err)
}
if zone != "public" {
t.Errorf("resolveZone: --zone must win over --interface; got %q want public", zone)
}
}
// A redirect NAT rule needs a port-bearing protocol and a destination port; the
// CLI must reject the ambiguous defaults up front rather than deferring to the
// backend.
func TestNATFlagsRedirectValidation(t *testing.T) {
// Redirect with proto any and no --to-port is rejected on the protocol.
if _, err := (&natFlags{Kind: "redirect", Family: "any", Proto: "any"}).toNATRule(); err == nil {
t.Errorf("redirect with proto any / no to-port: want error, got nil")
}
// Redirect with a port-bearing proto but still no --to-port is rejected.
if _, err := (&natFlags{Kind: "redirect", Family: "any", Proto: "tcp"}).toNATRule(); err == nil {
t.Errorf("redirect without to-port: want error, got nil")
}
// A complete redirect is accepted.
r, err := (&natFlags{Kind: "redirect", Family: "any", Proto: "tcp", ToPort: 8080, Port: 80}).toNATRule()
if err != nil {
t.Fatalf("valid redirect: unexpected error %v", err)
}
if r.Kind != fw.Redirect || r.ToPort != 8080 {
t.Errorf("unexpected redirect rule: %+v", r)
}
// A dnat still accepts the default proto (validation is redirect-specific).
if _, err := (&natFlags{Kind: "dnat", Family: "any", Proto: "any", ToAddr: "10.0.0.1"}).toNATRule(); err != nil {
t.Errorf("dnat with default proto: unexpected error %v", err)
}
}
// Interface matches are direction-specific: toRule must reject an outbound
// interface on an input rule and an inbound interface on an output rule.
func TestRuleFlagsInterfaceDirection(t *testing.T) {
// --out-interface on an input (default) rule is rejected.
if _, err := (&ruleFlags{Action: "accept", Family: "any", Proto: "any", OutInterface: "eth0"}).toRule(); err == nil {
t.Errorf("out-interface on input rule: want error, got nil")
}
// --in-interface on an output rule is rejected.
if _, err := (&ruleFlags{Action: "accept", Family: "any", Proto: "any", Output: true, InInterface: "eth0"}).toRule(); err == nil {
t.Errorf("in-interface on output rule: want error, got nil")
}
// The matching combinations are accepted.
if _, err := (&ruleFlags{Action: "accept", Family: "any", Proto: "any", InInterface: "eth0"}).toRule(); err != nil {
t.Errorf("in-interface on input rule: unexpected error %v", err)
}
if _, err := (&ruleFlags{Action: "accept", Family: "any", Proto: "any", Output: true, OutInterface: "eth0"}).toRule(); err != nil {
t.Errorf("out-interface on output rule: unexpected error %v", err)
}
}

37
cmd/go-firewall/go.mod Normal file
View file

@ -0,0 +1,37 @@
module github.com/grmrgecko/go-firewall/cmd/go-firewall
go 1.26.4
require (
github.com/alecthomas/kong v1.15.0
github.com/grmrgecko/go-firewall v0.0.0-00010101000000-000000000000
github.com/willabides/kongplete v0.4.0
)
require (
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect
github.com/coreos/go-systemd/v22 v22.5.0 // indirect
github.com/go-ole/go-ole v1.2.6 // indirect
github.com/godbus/dbus/v5 v5.2.2 // indirect
github.com/google/cabbie v1.0.2 // indirect
github.com/google/glazier v0.0.0-20211029225403-9f766cca891d // indirect
github.com/google/go-cmp v0.6.0 // indirect
github.com/google/nftables v0.3.0 // indirect
github.com/grmrgecko/go-firewalld v0.0.0-20260702144632-5eb6ba8201bb // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/iamacarpet/go-win64api v0.0.0-20240507095429-873e84e85847 // indirect
github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 // indirect
github.com/mdlayher/socket v0.5.0 // indirect
github.com/posener/complete v1.2.3 // indirect
github.com/riywo/loginshell v0.0.0-20200815045211-7d26008be1ab // indirect
github.com/scjalliance/comshim v0.0.0-20190308082608-cf06d2532c4e // indirect
github.com/vishvananda/netlink v1.3.1 // indirect
github.com/vishvananda/netns v0.0.5 // indirect
go4.org/netipx v0.0.0-20220725152314-7e7bdc8411bf // indirect
golang.org/x/net v0.33.0 // indirect
golang.org/x/sync v0.6.0 // indirect
golang.org/x/sys v0.40.0 // indirect
)
replace github.com/grmrgecko/go-firewall => ../..

161
cmd/go-firewall/go.sum Normal file
View file

@ -0,0 +1,161 @@
bitbucket.org/creachadair/stringset v0.0.9/go.mod h1:t+4WcQ4+PXTa8aQdNKe40ZP6iwesoMFWAxPGd3UGjyY=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/StackExchange/wmi v1.2.0/go.mod h1:3eOhrUMpNV+6aFIbp5/iudMxNCF27Vw2OZgy4xEx0Fg=
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
github.com/alecthomas/kong v1.15.0 h1:BVJstKbpO73zKpmIu+m/aLRrNmWwxXPIGTNin9VmLVI=
github.com/alecthomas/kong v1.15.0/go.mod h1:wrlbXem1CWqUV5Vbmss5ISYhsVPkBb1Yo7YKJghju2I=
github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs=
github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
github.com/capnspacehook/taskmaster v0.0.0-20210519235353-1629df7c85e9/go.mod h1:257CYs3Wd/CTlLQ3c72jKv+fFE2MV3WPNnV5jiroYUU=
github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/creachadair/staticfile v0.1.3/go.mod h1:a3qySzCIXEprDGxk6tSxSI+dBBdLzqeBOMhZ+o2d3pM=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
github.com/go-ole/go-ole v1.2.4/go.mod h1:XCwSNxSkXRo4vlyPy93sltvi/qJq0jqQhjqQNIwKuxM=
github.com/go-ole/go-ole v1.2.5/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/godbus/dbus v4.1.0+incompatible/go.mod h1:/YcGZj5zSblfDWMMoOzV4fas9FZnQYTkDnsGvmh2Grw=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
github.com/golang/glog v0.0.0-20210429001901-424d2337a529/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/google/aukera v0.0.0-20201117230544-d145c8357fea/go.mod h1:oXqTZORBzdwQ6L32YjJmaPajqIV/hoGEouwpFMf4cJE=
github.com/google/cabbie v1.0.2 h1:UtB+Nn6fPB43wGg5xs4tgU+P3hTZ6KsulgtaHtqZZfs=
github.com/google/cabbie v1.0.2/go.mod h1:6MmHaUrgfabehCHAIaxdrbmvHSxUVXj3Abs08FMABSo=
github.com/google/glazier v0.0.0-20210617205946-bf91b619f5d4/go.mod h1:g7oyIhindbeebnBh0hbFua5rv6XUt/nweDwIWdvxirg=
github.com/google/glazier v0.0.0-20211029225403-9f766cca891d h1:GBIF4RkD4E9USvSRT4O4tBCT77JExIr+qnruI9nkJQo=
github.com/google/glazier v0.0.0-20211029225403-9f766cca891d/go.mod h1:h2R3DLUecGbLSyi6CcxBs5bdgtJhgK+lIffglvAcGKg=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/logger v1.1.0/go.mod h1:w7O8nrRr0xufejBlQMI83MXqRusvREoJdaAxV+CoAB4=
github.com/google/logger v1.1.1/go.mod h1:BkeJZ+1FhQ+/d087r4dzojEg1u2ZX+ZqG1jTUrLM+zQ=
github.com/google/nftables v0.3.0 h1:bkyZ0cbpVeMHXOrtlFc8ISmfVqq5gPJukoYieyVmITg=
github.com/google/nftables v0.3.0/go.mod h1:BCp9FsrbF1Fn/Yu6CLUc9GGZFw/+hsxfluNXXmxBfRM=
github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/winops v0.0.0-20210803215038-c8511b84de2b/go.mod h1:ShbX8v8clPm/3chw9zHVwtW3QhrFpL8mXOwNxClt4pg=
github.com/grmrgecko/go-firewalld v0.0.0-20260702144632-5eb6ba8201bb h1:2wDo4vmBRWk2n3W5EsEpMQ2t8Sx0diVXdZjJTlLCBzc=
github.com/grmrgecko/go-firewalld v0.0.0-20260702144632-5eb6ba8201bb/go.mod h1:PrxtlI/xoBCOT8ugAoxeuE++VGq/D7jxbz5URoeV7ow=
github.com/groob/plist v0.0.0-20210519001750-9f754062e6d6/go.mod h1:itkABA+w2cw7x5nYUS/pLRef6ludkZKOigbROmCTaFw=
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk=
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/iamacarpet/go-win64api v0.0.0-20210311141720-fe38760bed28/go.mod h1:oGJx9dz0Ny7HC7U55RZ0Smd6N9p3hXP/+hOFtuYrAxM=
github.com/iamacarpet/go-win64api v0.0.0-20240507095429-873e84e85847 h1:cRHZFGwIDgQlr9abL/P93JXR7pYxzvf0xAIt0xzwrh0=
github.com/iamacarpet/go-win64api v0.0.0-20240507095429-873e84e85847/go.mod h1:B7zFQPAznj+ujXel5X+LUoK3LgY6VboCdVYHZNn7gpg=
github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 h1:A1Cq6Ysb0GM0tpKMbdCXCIfBclan4oHk1Jb+Hrejirg=
github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42/go.mod h1:BB4YCPDOzfy7FniQ/lxuYQ3dgmM2cZumHbK8RpTjN2o=
github.com/mdlayher/socket v0.5.0 h1:ilICZmJcQz70vrWVes1MFera4jGiWNocSkykwwoy3XI=
github.com/mdlayher/socket v0.5.0/go.mod h1:WkcBFfvyG8QENs5+hfQPl1X6Jpd2yeLIYgrGFmJiJxI=
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=
github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
github.com/onsi/gomega v1.10.2/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/posener/complete v1.2.3 h1:NP0eAhjcjImqslEwo/1hq7gpajME0fTLTezBKDqfXqo=
github.com/posener/complete v1.2.3/go.mod h1:WZIdtGGp+qx0sLrYKtIRAruyNpv6hFCicSgv7Sy7s/s=
github.com/rickb777/date v1.14.2/go.mod h1:swmf05C+hN+m8/Xh7gEq3uB6QJDNc5pQBWojKdHetOs=
github.com/rickb777/plural v1.2.2/go.mod h1:xyHbelv4YvJE51gjMnHvk+U2e9zIysg6lTnSQK8XUYA=
github.com/riywo/loginshell v0.0.0-20200815045211-7d26008be1ab h1:ZjX6I48eZSFetPb41dHudEyVr5v953N15TsNZXlkcWY=
github.com/riywo/loginshell v0.0.0-20200815045211-7d26008be1ab/go.mod h1:/PfPXh0EntGc3QAAyUaviy4S9tzy4Zp0e2ilq4voC6E=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/scjalliance/comshim v0.0.0-20190308082608-cf06d2532c4e h1:+/AzLkOdIXEPrAQtwAeWOBnPQ0BnYlBW0aCZmSb47u4=
github.com/scjalliance/comshim v0.0.0-20190308082608-cf06d2532c4e/go.mod h1:9Tc1SKnfACJb9N7cw2eyuI6xzy845G7uZONBsi5uPEA=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/vishvananda/netlink v1.3.1 h1:3AEMt62VKqz90r0tmNhog0r/PpWKmrEShJU0wJW6bV0=
github.com/vishvananda/netlink v1.3.1/go.mod h1:ARtKouGSTGchR8aMwmkzC0qiNPrrWO5JS/XMVl45+b4=
github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY=
github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM=
github.com/willabides/kongplete v0.4.0 h1:eivXxkp5ud5+4+NVN9e4goxC5mSh3n1RHov+gsblM2g=
github.com/willabides/kongplete v0.4.0/go.mod h1:0P0jtWD9aTsqPSUAl4de35DLghrr57XcayPyvqSi2X8=
go4.org/intern v0.0.0-20211027215823-ae77deb06f29 h1:UXLjNohABv4S58tHmeuIZDO6e3mHpW2Dx33gaNt03LE=
go4.org/intern v0.0.0-20211027215823-ae77deb06f29/go.mod h1:cS2ma+47FKrLPdXFpr7CuxiTW3eyJbWew4qx0qtQWDA=
go4.org/netipx v0.0.0-20220725152314-7e7bdc8411bf h1:IdwJUzqoIo5lkr2EOyKoe5qipUaEjbOKKY5+fzPBZ3A=
go4.org/netipx v0.0.0-20220725152314-7e7bdc8411bf/go.mod h1:+QXzaoURFd0rGDIjDNpyIkv+F9R7EmeKorvlKRnhqgA=
go4.org/unsafe/assume-no-moving-gc v0.0.0-20220617031537-928513b29760 h1:FyBZqvoA/jbNzuAWLQE2kG820zMAkcilx6BMjGbL/E4=
go4.org/unsafe/assume-no-moving-gc v0.0.0-20220617031537-928513b29760/go.mod h1:FftLjUGFEDu5k8lt0ddY+HcrH/qU/0qk+H8j9/nTl3E=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20201006153459-a7d1128ccaa0/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210614182718-04defd469f4e/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200622182413-4b0db7f3f76b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210426230700-d19ff857e887/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210601080250-7ecdf8ef093b/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211107104306-e0b2ad06fe42/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

138
cmd/go-firewall/info.go Normal file
View file

@ -0,0 +1,138 @@
package main
import (
"context"
"fmt"
"io"
"os"
"reflect"
"sort"
fw "github.com/grmrgecko/go-firewall"
)
// This file holds the read-only/info subcommands: status (backend + caps),
// zone (resolve an interface's zone), and reload (force a backend reload).
// StatusCmd reports the detected backend and its capabilities. It is the
// default thing to run when orienting on an unfamiliar host.
type StatusCmd struct{}
// Run reports the detected backend and its capabilities.
func (c *StatusCmd) Run(g *Globals) error {
mgr, cleanup, err := g.manager()
if err != nil {
return err
}
defer cleanup()
caps := mgr.Capabilities()
type statusOut struct {
Backend string `json:"backend"`
Prefix string `json:"prefix"`
Capabilities fw.Capabilities `json:"capabilities"`
}
out := statusOut{
Backend: mgr.Type(),
Prefix: g.Prefix,
Capabilities: caps,
}
return g.emit(out, func() error {
fmt.Printf("backend: %s\n", out.Backend)
fmt.Printf("prefix: %s\n", out.Prefix)
fmt.Println()
fmt.Println("capabilities:")
printCapabilities(os.Stdout, caps)
return nil
})
}
// printCapabilities lists the capability booleans in a stable, readable order.
// It walks the struct via reflection so a new bool field in fw.Capabilities is
// surfaced automatically without a parallel slice here. Non-bool fields are
// skipped so a future non-bool capability cannot panic the reflection walk.
func printCapabilities(w io.Writer, caps fw.Capabilities) {
v := reflect.ValueOf(caps)
t := v.Type()
type field struct {
name string
ok bool
}
fields := make([]field, 0, t.NumField())
for i := 0; i < t.NumField(); i++ {
if v.Field(i).Kind() != reflect.Bool {
continue
}
fields = append(fields, field{
name: t.Field(i).Name,
ok: v.Field(i).Bool(),
})
}
sort.Slice(fields, func(i, j int) bool { return fields[i].name < fields[j].name })
maxLen := 0
for _, f := range fields {
if len(f.name) > maxLen {
maxLen = len(f.name)
}
}
for _, f := range fields {
mark := "-"
if f.ok {
mark = "yes"
}
_, _ = fmt.Fprintf(w, " %-*s %s\n", maxLen, f.name, mark)
}
}
// ZoneCmd resolves the zone an interface belongs to. On backends without zones
// (iptables, nftables, pf, WFP) the returned zone is empty and that is the
// correct answer — it just means the backend has no zone abstraction.
type ZoneCmd struct {
Interface string `arg:"" name:"interface" help:"Network interface to resolve (e.g. eth0)."`
}
// Run resolves and prints the zone the interface belongs to.
func (c *ZoneCmd) Run(g *Globals) error {
mgr, cleanup, err := g.manager()
if err != nil {
return err
}
defer cleanup()
zone, err := mgr.GetZone(context.Background(), c.Interface)
if err != nil {
return fmt.Errorf("resolving zone for %q: %w", c.Interface, err)
}
type zoneOut struct {
Interface string `json:"interface"`
Zone string `json:"zone"`
}
out := zoneOut{Interface: c.Interface, Zone: zone}
return g.emit(out, func() error {
// A zoneless backend returns an empty zone; show a human placeholder here
// only, so the JSON form keeps the honest empty string.
display := out.Zone
if display == "" {
display = "(none)"
}
fmt.Printf("%s -> %s\n", out.Interface, display)
return nil
})
}
// ReloadCmd forces a backend reload, activating any staged rules. Useful after
// a series of --no-reload mutations, or to pick up changes made out of band.
type ReloadCmd struct{}
// Run forces a backend reload, activating any staged rules.
func (c *ReloadCmd) Run(g *Globals) error {
mgr, cleanup, err := g.manager()
if err != nil {
return err
}
defer cleanup()
if err := mgr.Reload(context.Background()); err != nil {
return fmt.Errorf("reload: %w", err)
}
return g.emitStatus("reloaded")
}

166
cmd/go-firewall/main.go Normal file
View file

@ -0,0 +1,166 @@
// Command go-firewall is a unified firewall management CLI built on the
// github.com/grmrgecko/go-firewall library. It auto-detects the host's active
// firewall backend and exposes a single command set across every backend the
// library supports (firewalld, ufw, CSF, APF, iptables, nftables, pf, WFP).
//
// It doubles as runnable example code for the library: every subcommand maps
// one-to-one onto a Manager method.
package main
import (
"context"
"encoding/json"
"fmt"
"io"
"os"
"text/tabwriter"
"time"
"github.com/alecthomas/kong"
"github.com/willabides/kongplete"
fw "github.com/grmrgecko/go-firewall"
)
// managerDetectTimeout bounds backend detection so a hung probe (pfctl, D-Bus)
// fails fast instead of hanging the CLI process indefinitely.
const managerDetectTimeout = 30 * time.Second
// version is the CLI version, overridden at build time with:
//
// go build -ldflags "-X main.version=1.2.3"
var version = "dev"
// VersionFlag is a kong flag type that prints the version and exits before any
// command runs (mirrors the pattern in kong's own examples).
type VersionFlag bool
// Decode satisfies kong's flag decoder; the value itself carries no data.
func (v VersionFlag) Decode(ctx *kong.DecodeContext) error { return nil }
// IsBool marks the flag as boolean so it takes no argument.
func (v VersionFlag) IsBool() bool { return true }
// BeforeApply prints the version and exits before any command runs.
func (v VersionFlag) BeforeApply(app *kong.Kong, vars kong.Vars) error {
fmt.Println(vars["version"])
app.Exit(0)
return nil
}
// Globals are the top-level flags shared by every subcommand. Kong passes a
// pointer to each command's Run method, so every subcommand opens the firewall
// manager the same way regardless of which one ran.
type Globals struct {
// Prefix namespaces the rules this CLI creates, mirroring
// firewall.NewManager's rulePrefix argument. Backends that need a name
// (WFP rule group, nftables table, pf anchor) fall back to "go_firewall"
// when empty.
Prefix string `name:"prefix" env:"GOFIREWALL_PREFIX" help:"Rule prefix used to namespace managed rules." default:"go_firewall"`
// NoReload disables the automatic Reload after a mutating command on
// backends that stage changes (ufw, CSF, APF). Off by default so changes
// take effect immediately.
NoReload bool `name:"no-reload" env:"GOFIREWALL_NO_RELOAD" help:"Do not reload the backend after a mutation."`
// JSON switches list/status output to machine-readable JSON.
JSON bool `short:"j" name:"json" env:"GOFIREWALL_JSON" help:"Emit list/status output as JSON."`
// Version prints the CLI version and exits.
Version VersionFlag `name:"version" help:"Print version information and quit."`
Rule RuleCmd `cmd:"" help:"Manage filter rules." group:"rules"`
NAT NATCmd `cmd:"" help:"Manage NAT (port-forward/masquerade) rules." group:"rules"`
Policy PolicyCmd `cmd:"" help:"Manage the default policy." group:"rules"`
Set SetCmd `cmd:"" help:"Manage address sets (ipset/nftset/pf tables)." group:"management"`
Backup BackupSubcmd `cmd:"" help:"Snapshot managed rules to a file (or stdout)." group:"management"`
Restore RestoreSubcmd `cmd:"" help:"Replay a backup file (or stdin)." group:"management"`
Status StatusCmd `cmd:"" help:"Show the detected firewall backend and its capabilities." group:"info"`
Zone ZoneCmd `cmd:"" help:"Resolve the zone for an interface." group:"info"`
Reload ReloadCmd `cmd:"" help:"Reload the firewall backend (activate staged rules)." group:"info"`
// InstallCompletions registers shell completion for bash/zsh/fish. Running
// it emits (or installs) the completion script; the actual completion
// requests are served by kongplete.Complete in main before Parse.
InstallCompletions kongplete.InstallCompletions `cmd:"" help:"Install shell completion for go-firewall." group:"info"`
}
func main() {
var cli Globals
parser := kong.Must(&cli,
kong.Name("go-firewall"),
kong.Description("A unified firewall management CLI across firewalld, ufw, CSF, APF, iptables, nftables, pf and WFP."),
kong.UsageOnError(),
kong.ConfigureHelp(kong.HelpOptions{
Compact: true,
Tree: true,
}),
kong.Vars{"version": version},
)
// Serve shell-completion requests (COMP_LINE-driven) before parsing, so a
// completion invocation returns candidates and exits without running a
// command. It is a no-op for a normal invocation.
kongplete.Complete(parser)
ctx, err := parser.Parse(os.Args[1:])
parser.FatalIfErrorf(err)
err = ctx.Run(&cli)
ctx.FatalIfErrorf(err)
}
// manager opens the detected firewall manager, scoped to g.Prefix. The returned
// closer must be invoked by the caller. It is the single entry point every
// subcommand uses, so detection and teardown are defined once.
func (g *Globals) manager() (fw.Manager, func(), error) {
// Bound only detection: the returned closer and each command's operations run
// on their own contexts, so this deadline cannot cancel in-flight work.
ctx, cancel := context.WithTimeout(context.Background(), managerDetectTimeout)
defer cancel()
mgr, err := fw.NewManager(ctx, g.Prefix)
if err != nil {
return nil, nil, fmt.Errorf("detecting firewall backend: %w", err)
}
cleanup := func() { _ = mgr.Close(context.Background()) }
return mgr, cleanup, nil
}
// reload conditionally activates staged rules. NoReload skips it (useful when a
// caller batches several mutations and reloads once at the end).
func (g *Globals) reload(mgr fw.Manager) error {
if g.NoReload {
return nil
}
return mgr.Reload(context.Background())
}
// emit prints either JSON (when g.JSON is set) or the human-readable rendering
// produced by human. It is the single output path for list/status commands so
// every subcommand honors --json the same way.
func (g *Globals) emit(v any, human func() error) error {
if g.JSON {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
return enc.Encode(v)
}
return human()
}
// emitStatus reports the outcome of a mutating command. In text mode it prints
// the bare word (e.g. "added"); under --json it emits a small status object so
// a scripted caller passing -j still gets parseable output instead of empty
// stdout. It is the single success path for every mutation so they stay in sync.
func (g *Globals) emitStatus(status string) error {
if g.JSON {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
return enc.Encode(struct {
Status string `json:"status"`
}{status})
}
fmt.Println(status)
return nil
}
// newTable returns a tabwriter that flushes to w, configured for the
// human-readable tables used by the list commands. All columns are left-aligned
// for readability.
func newTable(w io.Writer) *tabwriter.Writer {
return tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
}

253
cmd/go-firewall/nat.go Normal file
View file

@ -0,0 +1,253 @@
package main
import (
"context"
"fmt"
"os"
fw "github.com/grmrgecko/go-firewall"
)
// This file holds the NAT command group: list, add, insert, move, remove. NAT
// rules are managed separately from filter rules in the library, so they get
// their own subcommand group here too.
// NATCmd is the top-level "nat" command group.
type NATCmd struct {
List NATListCmd `cmd:"" help:"List NAT rules (default)."`
Add NATAddCmd `cmd:"" help:"Add a NAT rule (port-forward/masquerade)."`
Insert NATInsertCmd `cmd:"" help:"Insert a NAT rule at a 1-based position within its nat chain."`
Move NATMoveCmd `cmd:"" help:"Move a NAT rule to a new 1-based position within its nat chain."`
Remove NATRemoveCmd `cmd:"" help:"Remove a NAT rule matching the given fields."`
}
// NATListCmd lists every NAT rule in a zone. The PREFIX column reports which rules
// carry the configured prefix (HasPrefix); filtering is left to the caller rather
// than hidden here.
type NATListCmd struct {
Zone string `name:"zone" short:"z" help:"Zone name or empty for the default."`
Interface string `name:"interface" short:"i" help:"Resolve the zone for this interface."`
}
// Run lists the NAT rules in the resolved zone.
func (c *NATListCmd) Run(g *Globals) error {
mgr, cleanup, err := g.manager()
if err != nil {
return err
}
defer cleanup()
zone, err := resolveZone(mgr, c.Zone, c.Interface)
if err != nil {
return err
}
rules, err := mgr.GetNATRules(context.Background(), zone)
if err != nil {
return fmt.Errorf("listing NAT rules: %w", err)
}
return g.emit(rules, func() error {
if len(rules) == 0 {
fmt.Println("(no NAT rules)")
return nil
}
printNATRules(os.Stdout, rules)
return nil
})
}
// natFlags carries every fw.NATRule field that add/remove accept.
type natFlags struct {
Kind string `name:"kind" short:"k" help:"dnat|redirect|snat|masquerade." default:"dnat"`
Family string `name:"family" short:"f" help:"any|ipv4|ipv6 (default any)." default:"any"`
Proto string `name:"proto" help:"any|tcp|udp|icmp|icmpv6|sctp|gre|esp|ah (default any)." default:"any"`
If string `name:"nat-interface" help:"Inbound interface for dnat/redirect; outbound for snat/masquerade."`
Source string `name:"source" short:"s" help:"Source address/CIDR."`
Dest string `name:"destination" short:"d" help:"Destination address/CIDR."`
Port uint16 `name:"port" short:"p" help:"Matched destination port. Requires tcp/udp."`
Ports string `name:"ports" help:"Matched port list/ranges. Overrides --port."`
ToAddr string `name:"to-address" help:"Translation address (dnat/snat). Empty for redirect/masquerade."`
ToPort uint16 `name:"to-port" help:"Translation port (dnat/redirect). Unused for snat/masquerade."`
}
// toNATRule assembles natFlags into a *fw.NATRule using the library's Parse*
// helpers. Validation (e.g. dnat requires an address) is delegated to the
// library's NATRule.validate, called by each backend before marshaling.
func (f *natFlags) toNATRule() (*fw.NATRule, error) {
kind, err := fw.ParseNATKind(f.Kind)
if err != nil {
return nil, err
}
family, err := fw.ParseFamily(f.Family)
if err != nil {
return nil, err
}
proto, err := parseProto(f.Proto)
if err != nil {
return nil, err
}
r := &fw.NATRule{
Kind: kind,
Family: family,
Proto: proto,
Interface: f.If,
Source: f.Source,
Destination: f.Dest,
Port: f.Port,
ToAddress: f.ToAddr,
ToPort: f.ToPort,
}
if f.Ports != "" {
ranges, err := fw.ParsePortRanges(f.Ports, ",")
if err != nil {
return nil, err
}
r.Ports = ranges
}
// A redirect sends matched traffic to a local port, so it needs a port-bearing
// protocol and a concrete destination port. Reject the ambiguous defaults here
// rather than deferring to the backend.
if kind == fw.Redirect {
if !proto.HasPorts() {
return nil, fmt.Errorf("redirect requires a port-bearing protocol (tcp, udp or sctp)")
}
if r.ToPort == 0 {
return nil, fmt.Errorf("redirect requires --to-port")
}
}
return r, nil
}
// NATAddCmd adds a NAT rule.
type NATAddCmd struct {
Zone string `name:"zone" short:"z" help:"Zone name or empty for the default."`
Interface string `name:"interface" short:"i" help:"Resolve the zone for this interface."`
natFlags
}
// Run validates the flags and adds the NAT rule.
func (c *NATAddCmd) Run(g *Globals) error {
rule, err := c.toNATRule()
if err != nil {
return err
}
mgr, cleanup, err := g.manager()
if err != nil {
return err
}
defer cleanup()
zone, err := resolveZone(mgr, c.Zone, c.Interface)
if err != nil {
return err
}
if err := mgr.AddNATRule(context.Background(), zone, rule); err != nil {
return fmt.Errorf("add NAT rule: %w", err)
}
if err := g.reload(mgr); err != nil {
return fmt.Errorf("reload: %w", err)
}
return g.emitStatus("added")
}
// NATInsertCmd inserts a NAT rule at a 1-based position within its nat chain
// (1 = first; a position past the end appends). Only ordered backends support
// this; others return an unsupported-ordering error.
type NATInsertCmd struct {
Zone string `name:"zone" short:"z" help:"Zone name or empty for the default."`
Interface string `name:"interface" short:"i" help:"Resolve the zone for this interface."`
Position int `arg:"" name:"position" help:"1-based position to insert at (1 = first; past the end appends)."`
natFlags
}
// Run inserts the NAT rule at the requested 1-based position.
func (c *NATInsertCmd) Run(g *Globals) error {
rule, err := c.toNATRule()
if err != nil {
return err
}
mgr, cleanup, err := g.manager()
if err != nil {
return err
}
defer cleanup()
zone, err := resolveZone(mgr, c.Zone, c.Interface)
if err != nil {
return err
}
if err := mgr.InsertNATRule(context.Background(), zone, c.Position, rule); err != nil {
return fmt.Errorf("insert NAT rule: %w", err)
}
if err := g.reload(mgr); err != nil {
return fmt.Errorf("reload: %w", err)
}
return g.emitStatus("inserted")
}
// NATMoveCmd moves an existing NAT rule to a new 1-based position within its nat
// chain (1 = first; a position past the end moves the rule to the end). The
// flags describe the rule to move (matched by identity). Only ordered backends
// support this; others return an unsupported-ordering error.
type NATMoveCmd struct {
Zone string `name:"zone" short:"z" help:"Zone name or empty for the default."`
Interface string `name:"interface" short:"i" help:"Resolve the zone for this interface."`
Position int `arg:"" name:"position" help:"1-based position to move the rule to."`
natFlags
}
// Run moves the matched NAT rule to the requested 1-based position.
func (c *NATMoveCmd) Run(g *Globals) error {
rule, err := c.toNATRule()
if err != nil {
return err
}
mgr, cleanup, err := g.manager()
if err != nil {
return err
}
defer cleanup()
zone, err := resolveZone(mgr, c.Zone, c.Interface)
if err != nil {
return err
}
if err := mgr.MoveNATRule(context.Background(), zone, rule, c.Position); err != nil {
return fmt.Errorf("move NAT rule: %w", err)
}
if err := g.reload(mgr); err != nil {
return fmt.Errorf("reload: %w", err)
}
return g.emitStatus("moved")
}
// NATRemoveCmd removes a NAT rule matched by identity.
type NATRemoveCmd struct {
Zone string `name:"zone" short:"z" help:"Zone name or empty for the default."`
Interface string `name:"interface" short:"i" help:"Resolve the zone for this interface."`
natFlags
}
// Run removes the NAT rule matching the given flags.
func (c *NATRemoveCmd) Run(g *Globals) error {
rule, err := c.toNATRule()
if err != nil {
return err
}
mgr, cleanup, err := g.manager()
if err != nil {
return err
}
defer cleanup()
zone, err := resolveZone(mgr, c.Zone, c.Interface)
if err != nil {
return err
}
if err := mgr.RemoveNATRule(context.Background(), zone, rule); err != nil {
return fmt.Errorf("remove NAT rule: %w", err)
}
if err := g.reload(mgr); err != nil {
return fmt.Errorf("reload: %w", err)
}
return g.emitStatus("removed")
}

239
cmd/go-firewall/output.go Normal file
View file

@ -0,0 +1,239 @@
package main
import (
"fmt"
"io"
"strconv"
"strings"
fw "github.com/grmrgecko/go-firewall"
)
// This file holds the human-readable renderers for the library's data types.
// They have no side effects, which keeps them easy to unit-test and reuse
// across the list subcommands. The --json path serializes the structs directly
// (they already carry stable JSON via backup.go), so only the text path needs
// bespoke formatting.
// describeRule renders a *fw.Rule as a compact, single-line summary suitable
// for a table cell. The order is deliberately familiar: direction, action,
// proto, addresses, ports, then the optional modifiers (state, interface,
// limits, counters, comment).
func describeRule(r *fw.Rule) string {
var b strings.Builder
// Direction.
switch r.Direction {
case fw.DirOutput:
b.WriteString("out")
case fw.DirForward:
b.WriteString("fwd")
case fw.DirAny:
b.WriteString("any")
default:
b.WriteString("in")
}
// Action.
b.WriteByte(' ')
b.WriteString(r.Action.String())
// Family (skip "any" to reduce noise; a concrete family still prints).
if r.Family != fw.FamilyAny {
b.WriteByte(' ')
b.WriteString(r.Family.String())
}
// Protocol.
b.WriteByte(' ')
if r.Proto == fw.ProtocolAny {
b.WriteString("any")
} else {
b.WriteString(r.Proto.String())
}
// Source/destination.
if r.Source != "" {
b.WriteString(" from ")
b.WriteString(r.Source)
}
if r.Destination != "" {
b.WriteString(" to ")
b.WriteString(r.Destination)
}
// Ports.
if ports := r.PortSpecs(); len(ports) > 0 {
b.WriteString(" port ")
b.WriteString(fw.FormatPortRanges(ports, ","))
}
if sports := r.SourcePortSpecs(); len(sports) > 0 {
b.WriteString(" sport ")
b.WriteString(fw.FormatPortRanges(sports, ","))
}
// ICMP type (only meaningful for ICMP/ICMPv6).
if r.ICMPType != nil && r.Proto.IsICMP() {
b.WriteString(" type ")
b.WriteString(strconv.FormatUint(uint64(*r.ICMPType), 10))
}
// Connection-tracking state.
if r.State != 0 {
b.WriteString(" state=")
b.WriteString(r.State.String())
}
// Interface(s): an input rule carries only an in-interface, an output rule only
// an out-interface, a forward rule may carry both — print whichever are set.
if r.InInterface != "" {
b.WriteString(" iif=")
b.WriteString(r.InInterface)
}
if r.OutInterface != "" {
b.WriteString(" oif=")
b.WriteString(r.OutInterface)
}
// Logging.
if r.Log {
b.WriteString(" log")
if r.LogPrefix != "" {
b.WriteByte('=')
b.WriteString(r.LogPrefix)
}
}
// Rate / connection limits.
if r.RateLimit != nil {
b.WriteString(" rate=")
b.WriteString(r.RateLimit.String())
if r.RateLimit.Burst != 0 {
b.WriteString("/burst=")
b.WriteString(strconv.FormatUint(uint64(r.RateLimit.Burst), 10))
}
}
if r.ConnLimit != nil {
b.WriteString(" conn=")
b.WriteString(strconv.FormatUint(uint64(r.ConnLimit.Count), 10))
if r.ConnLimit.PerSource {
b.WriteString("/src")
}
}
// Priority (only when nonzero and thus meaningful).
if r.Priority != 0 {
b.WriteString(" prio=")
b.WriteString(strconv.Itoa(r.Priority))
}
// Comment (informational).
if r.Comment != "" {
b.WriteString(" # ")
b.WriteString(r.Comment)
}
return b.String()
}
// describeNATRule renders a NAT rule as a compact single-line summary.
func describeNATRule(r *fw.NATRule) string {
var b strings.Builder
b.WriteString(r.Kind.String())
if r.Family != fw.FamilyAny {
b.WriteByte(' ')
b.WriteString(r.Family.String())
}
b.WriteByte(' ')
if r.Proto == fw.ProtocolAny {
b.WriteString("any")
} else {
b.WriteString(r.Proto.String())
}
if r.Interface != "" {
b.WriteString(" if=")
b.WriteString(r.Interface)
}
if r.Source != "" {
b.WriteString(" from ")
b.WriteString(r.Source)
}
if r.Destination != "" {
b.WriteString(" to ")
b.WriteString(r.Destination)
}
if ports := r.PortSpecs(); len(ports) > 0 {
b.WriteString(" port ")
b.WriteString(fw.FormatPortRanges(ports, ","))
}
// Translation target.
switch r.Kind {
case fw.DNAT:
b.WriteString(" -> ")
b.WriteString(r.ToAddress)
if r.ToPort != 0 {
b.WriteByte(':')
b.WriteString(strconv.FormatUint(uint64(r.ToPort), 10))
}
case fw.Redirect:
b.WriteString(" -> ")
b.WriteString(strconv.FormatUint(uint64(r.ToPort), 10))
case fw.SNAT:
b.WriteString(" -> ")
b.WriteString(r.ToAddress)
case fw.Masquerade:
b.WriteString(" (dynamic)")
}
return b.String()
}
// printRules writes a rule list to w as a numbered table. The index is the
// 1-based position returned by GetRules, which is what RemoveRule/MoveRule
// refer to (since those methods match by rule identity, not index, the index is
// informational — it just helps the user pick the right line).
func printRules(w io.Writer, rules []*fw.Rule) {
tw := newTable(w)
defer func() { _ = tw.Flush() }()
_, _ = fmt.Fprintln(tw, "#\tPREFIX\tACTION\tDESCRIPTION")
for i, r := range rules {
_, _ = fmt.Fprintf(tw, "%d\t%s\t%s\t%s\n", i+1, hasPrefixFlag(r.HasPrefix), r.Action.String(), describeRule(r))
}
}
// printNATRules writes a NAT rule list to w as a numbered table.
func printNATRules(w io.Writer, rules []*fw.NATRule) {
tw := newTable(w)
defer func() { _ = tw.Flush() }()
_, _ = fmt.Fprintln(tw, "#\tPREFIX\tKIND\tDESCRIPTION")
for i, r := range rules {
_, _ = fmt.Fprintf(tw, "%d\t%s\t%s\t%s\n", i+1, hasPrefixFlag(r.HasPrefix), r.Kind.String(), describeNATRule(r))
}
}
// hasPrefixFlag renders whether a rule carries the configured prefix, for the
// PREFIX column of a rule listing.
func hasPrefixFlag(hasPrefix bool) string {
if hasPrefix {
return "yes"
}
return "no"
}
// setType renders an address set's type, defaulting the zero value to hash:ip
// the way the library does when a set is created without an explicit type.
func setType(s *fw.AddressSet) string {
if s.Type == 0 {
return fw.SetHashIP.String()
}
return s.Type.String()
}
// printSet writes one address set's header line followed by its entries, one per
// line. An empty set prints "(no entries)" so the output is never ambiguous.
func printSet(w io.Writer, s *fw.AddressSet) {
_, _ = fmt.Fprintf(w, "%s family=%s type=%s entries=%d\n", s.Name, s.Family.String(), setType(s), len(s.Entries))
if len(s.Entries) == 0 {
_, _ = fmt.Fprintln(w, " (no entries)")
return
}
for _, e := range s.Entries {
_, _ = fmt.Fprintf(w, " %s\n", e)
}
}
// printSets writes an address-set list to w as a table.
func printSets(w io.Writer, sets []*fw.AddressSet) {
tw := newTable(w)
defer func() { _ = tw.Flush() }()
_, _ = fmt.Fprintln(tw, "NAME\tFAMILY\tTYPE\tENTRIES")
for _, s := range sets {
_, _ = fmt.Fprintf(tw, "%s\t%s\t%s\t%d\n", s.Name, s.Family.String(), setType(s), len(s.Entries))
}
}

48
cmd/go-firewall/parse.go Normal file
View file

@ -0,0 +1,48 @@
package main
import (
"fmt"
"strconv"
"strings"
fw "github.com/grmrgecko/go-firewall"
)
// This file holds a small parser the firewall library keeps private
// (parseRateToken) but that the CLI needs to turn flag strings into library
// types. It intentionally mirrors the library's own logic so a CLI-authored rule
// is accepted identically to one the library writes. ICMP-type resolution is
// delegated to the library's exported fw.ParseICMPType so the CLI cannot drift
// from the backends' own name tables.
// parseRateToken parses a "<rate>/<unit>" token (e.g. "10/minute") into its
// numeric rate and unit. It is the flag-facing counterpart of RateLimit.String.
func parseRateToken(tok string) (uint, fw.RateUnit, error) {
num, unitStr, ok := strings.Cut(strings.TrimSpace(tok), "/")
if !ok {
return 0, 0, fmt.Errorf("invalid rate %q (want <rate>/<unit>, e.g. 10/minute)", tok)
}
n, err := strconv.ParseUint(strings.TrimSpace(num), 10, 32)
if err != nil {
return 0, 0, fmt.Errorf("invalid rate %q: %w", tok, err)
}
unit, err := fw.ParseRateUnit(unitStr)
if err != nil {
return 0, 0, err
}
return uint(n), unit, nil
}
// parseProto resolves a --proto flag to a library Protocol, erroring on an
// unrecognized token instead of silently widening to "any". The library's
// GetProtocol returns ProtocolAny for both an empty value (the legitimate
// "match any protocol" default) and an unknown one, so a typo like "tpc" would
// otherwise pass through as an any-protocol rule — broader than the user asked
// for. An empty value, or the explicit spelling "any", is accepted as ProtocolAny.
func parseProto(tok string) (fw.Protocol, error) {
p := fw.GetProtocol(tok)
if p == fw.ProtocolAny && strings.TrimSpace(tok) != "" && !strings.EqualFold(strings.TrimSpace(tok), "any") {
return 0, fmt.Errorf("unknown protocol %q", tok)
}
return p, nil
}

117
cmd/go-firewall/policy.go Normal file
View file

@ -0,0 +1,117 @@
package main
import (
"context"
"fmt"
fw "github.com/grmrgecko/go-firewall"
)
// This file holds the default-policy command group: get and set. A default
// policy is the action applied to packets that match no rule, per direction
// (input/output/forward). Not every backend exposes every direction, and some
// expose none at all (they return ErrUnsupportedPolicy).
// PolicyCmd is the top-level "policy" command group.
type PolicyCmd struct {
Get PolicyGetCmd `cmd:"" help:"Show the default policy (default)."`
Set PolicySetCmd `cmd:"" help:"Set the default policy for one or more directions."`
}
// PolicyGetCmd prints the default policy. Directions the backend cannot express
// are shown as "-" (the library returns ActionInvalid for those).
type PolicyGetCmd struct {
Zone string `name:"zone" short:"z" help:"Zone name or empty for the default."`
Interface string `name:"interface" short:"i" help:"Resolve the zone for this interface."`
}
// Run prints the default policy for each direction.
func (c *PolicyGetCmd) Run(g *Globals) error {
mgr, cleanup, err := g.manager()
if err != nil {
return err
}
defer cleanup()
zone, err := resolveZone(mgr, c.Zone, c.Interface)
if err != nil {
return err
}
pol, err := mgr.GetDefaultPolicy(context.Background(), zone)
if err != nil {
return fmt.Errorf("get default policy: %w", err)
}
return g.emit(pol, func() error {
printPolicy("input", pol.Input)
printPolicy("output", pol.Output)
printPolicy("forward", pol.Forward)
return nil
})
}
// printPolicy renders one direction's action, collapsing ActionInvalid to "-"
// so a get on a backend that doesn't expose a direction reads cleanly.
func printPolicy(dir string, a fw.Action) {
s := a.String()
if a == fw.ActionInvalid {
s = "-"
}
fmt.Printf("%-8s %s\n", dir, s)
}
// PolicySetCmd sets the default policy. Each direction is optional: an unset
// flag leaves that direction unchanged (the library treats ActionInvalid that
// way on Set), so `policy set --input drop` changes only the input direction.
type PolicySetCmd struct {
Zone string `name:"zone" short:"z" help:"Zone name or empty for the default."`
Interface string `name:"interface" short:"i" help:"Resolve the zone for this interface."`
Input string `name:"input" help:"accept|reject|drop for the input direction. Unset leaves it unchanged."`
Output string `name:"output" help:"accept|reject|drop for the output direction. Unset leaves it unchanged."`
Forward string `name:"forward" help:"accept|reject|drop for the forward direction. Unset leaves it unchanged."`
}
// Run sets the default policy for the directions whose flags were given.
func (c *PolicySetCmd) Run(g *Globals) error {
// Parse each direction up front; an empty flag stays ActionInvalid, which
// the library interprets as "leave unchanged" on Set. Doing this before
// opening the manager means a bad action token fails fast.
pol := &fw.DefaultPolicy{}
if c.Input != "" {
a, err := fw.ParseAction(c.Input)
if err != nil {
return err
}
pol.Input = a
}
if c.Output != "" {
a, err := fw.ParseAction(c.Output)
if err != nil {
return err
}
pol.Output = a
}
if c.Forward != "" {
a, err := fw.ParseAction(c.Forward)
if err != nil {
return err
}
pol.Forward = a
}
mgr, cleanup, err := g.manager()
if err != nil {
return err
}
defer cleanup()
zone, err := resolveZone(mgr, c.Zone, c.Interface)
if err != nil {
return err
}
if err := mgr.SetDefaultPolicy(context.Background(), zone, pol); err != nil {
return fmt.Errorf("set default policy: %w", err)
}
if err := g.reload(mgr); err != nil {
return fmt.Errorf("reload: %w", err)
}
return g.emitStatus("ok")
}

350
cmd/go-firewall/rule.go Normal file
View file

@ -0,0 +1,350 @@
package main
import (
"context"
"fmt"
"os"
fw "github.com/grmrgecko/go-firewall"
)
// This file holds the rule command group: list, add, remove, insert, move.
// ruleFlags is a shared struct embedded by add/remove/insert/move so that the
// Rule field set is described once and stays in sync with the library's Rule.
// RuleCmd is the top-level "rule" command group. It only has subcommands of its
// own; running it bare prints help.
type RuleCmd struct {
List RuleListCmd `cmd:"" help:"List filter rules (default)."`
Add RuleAddCmd `cmd:"" help:"Add a filter rule."`
Remove RuleRemoveCmd `cmd:"" help:"Remove a filter rule matching the given fields."`
Insert RuleInsertCmd `cmd:"" help:"Insert a filter rule at a 1-based position."`
Move RuleMoveCmd `cmd:"" help:"Move an existing filter rule to a 1-based position."`
}
// RuleListCmd lists every filter rule in a zone. The PREFIX column reports which
// rules carry the configured prefix (HasPrefix); filtering is left to the caller
// rather than hidden here.
type RuleListCmd struct {
zoneMixin
}
// Run lists the filter rules in the resolved zone.
func (c *RuleListCmd) Run(g *Globals) error {
mgr, cleanup, err := g.manager()
if err != nil {
return err
}
defer cleanup()
zone, err := resolveZone(mgr, c.Zone, c.Interface)
if err != nil {
return err
}
rules, err := mgr.GetRules(context.Background(), zone)
if err != nil {
return fmt.Errorf("listing rules: %w", err)
}
return g.emit(rules, func() error {
if len(rules) == 0 {
fmt.Println("(no rules)")
return nil
}
printRules(os.Stdout, rules)
return nil
})
}
// ruleFlags carries every fw.Rule field that add/remove/insert/move accept. It
// is embedded by each so the flag surface is identical across them (the match
// key for remove equals the spec for add). Field-for-field mapping of the
// library's Rule struct, minus the read-only counters.
type ruleFlags struct {
// Direction.
Output bool `name:"out" help:"Outbound (output) rule. Default is inbound (input)."`
Forward bool `name:"forward" help:"Forward (routing) rule, for traffic routed through the host. Mutually exclusive with --out."`
Both bool `name:"both" help:"Both directions (input and output). The address/port roles swap on the outbound half. Mutually exclusive with --out/--forward."`
// Match: addresses, protocol, ports.
Action string `name:"action" short:"a" help:"accept|reject|drop (default accept)." default:"accept"`
Family string `name:"family" short:"f" help:"any|ipv4|ipv6 (default any)." default:"any"`
Proto string `name:"proto" help:"any|tcp|udp|icmp|icmpv6|sctp|gre|esp|ah (default any)." default:"any"`
Source string `name:"source" short:"s" help:"Source address/CIDR. Prefix '!' to negate where supported."`
Destination string `name:"destination" short:"d" help:"Destination address/CIDR. Prefix '!' to negate where supported."`
Port uint16 `name:"port" short:"p" help:"Destination port (single). Requires tcp/udp/sctp."`
Ports string `name:"ports" help:"Destination port list/ranges, e.g. \"80,443,1000-2000\". Overrides --port."`
SourcePort uint16 `name:"source-port" help:"Source port (single). Requires tcp/udp/sctp."`
SourcePorts string `name:"source-ports" help:"Source port list/ranges. Overrides --source-port."`
ICMPType string `name:"icmp-type" help:"ICMP type (number or name like echo-request). Requires icmp/icmpv6."`
// Modifiers.
State string `name:"state" help:"Connection states to match, comma-joined: new,established,related,invalid."`
InInterface string `name:"in-interface" help:"Inbound interface to match (input rules)."`
OutInterface string `name:"out-interface" help:"Outbound interface to match (output rules)."`
Log bool `name:"log" help:"Log matched packets before the action."`
LogPrefix string `name:"log-prefix" help:"Label attached to log lines (where supported)."`
RateLimit string `name:"rate-limit" help:"Rate cap as <rate>/<unit> (e.g. 10/minute). Implies a logged drop on overflow."`
RateBurst uint `name:"rate-burst" help:"Burst allowance for --rate-limit."`
ConnLimit uint `name:"conn-limit" help:"Cap concurrent connections."`
ConnPerSrc bool `name:"conn-per-source" help:"Apply --conn-limit per source address."`
Priority int `name:"priority" help:"Rule priority (where the backend supports it, e.g. firewalld rich rules)."`
Comment string `name:"comment" short:"c" help:"Human-readable label carried where supported (see 'status')."`
}
// btoi returns 1 for true and 0 for false, so exclusive boolean flags can be
// counted.
func btoi(b bool) int {
if b {
return 1
}
return 0
}
// toRule assembles the ruleFlags into a *fw.Rule, parsing and validating the
// string-typed flags via the library's own Parse* helpers so the CLI never
// reinvents rule semantics. Returns an error for a malformed flag rather than a
// silent default.
func (f *ruleFlags) toRule() (*fw.Rule, error) {
action, err := fw.ParseAction(f.Action)
if err != nil {
return nil, err
}
family, err := fw.ParseFamily(f.Family)
if err != nil {
return nil, err
}
proto, err := parseProto(f.Proto)
if err != nil {
return nil, err
}
if btoi(f.Output)+btoi(f.Forward)+btoi(f.Both) > 1 {
return nil, fmt.Errorf("--out, --forward and --both are mutually exclusive")
}
direction := fw.DirInput
switch {
case f.Forward:
direction = fw.DirForward
case f.Both:
direction = fw.DirAny
case f.Output:
direction = fw.DirOutput
}
// Interface matches are direction-specific: an inbound interface is invalid on
// an output rule and an outbound interface on an input rule; a forward or
// both-directions rule may match either (a DirAny rule's interfaces swap sides
// on its outbound half).
if direction == fw.DirOutput && f.InInterface != "" {
return nil, fmt.Errorf("--in-interface is only valid on an inbound, forward or both-directions rule (omit --out)")
}
if direction == fw.DirInput && f.OutInterface != "" {
return nil, fmt.Errorf("--out-interface is only valid on an outbound, forward or both-directions rule (--out, --forward or --both)")
}
rule := &fw.Rule{
Direction: direction,
Priority: f.Priority,
Action: action,
Family: family,
Source: f.Source,
Destination: f.Destination,
Port: f.Port,
SourcePort: f.SourcePort,
Proto: proto,
InInterface: f.InInterface,
OutInterface: f.OutInterface,
Log: f.Log,
LogPrefix: f.LogPrefix,
Comment: f.Comment,
}
if f.Ports != "" {
ranges, err := fw.ParsePortRanges(f.Ports, ",")
if err != nil {
return nil, err
}
rule.Ports = ranges
}
if f.SourcePorts != "" {
ranges, err := fw.ParsePortRanges(f.SourcePorts, ",")
if err != nil {
return nil, err
}
rule.SourcePorts = ranges
}
if f.ICMPType != "" {
t, ok := fw.ParseICMPType(f.ICMPType, rule.Proto == fw.ICMPv6)
if !ok {
return nil, fmt.Errorf("unknown icmp type %q", f.ICMPType)
}
rule.ICMPType = fw.Ptr[uint8](t)
}
if f.State != "" {
state, err := fw.ParseConnState(f.State)
if err != nil {
return nil, err
}
rule.State = state
}
if f.RateLimit != "" {
rate, unit, err := parseRateToken(f.RateLimit)
if err != nil {
return nil, err
}
rule.RateLimit = &fw.RateLimit{Rate: rate, Unit: unit, Burst: f.RateBurst}
}
if f.ConnLimit != 0 {
rule.ConnLimit = &fw.ConnLimit{Count: f.ConnLimit, PerSource: f.ConnPerSrc}
}
return rule, nil
}
// zoneMixin is shared by list/add/remove/insert/move: they all optionally target
// a zone, defaulting to the backend's default zone when neither is given. An
// explicit --zone wins; --interface is resolved only when --zone is empty.
type zoneMixin struct {
Zone string `name:"zone" short:"z" help:"Zone name (firewalld) or empty for the default. Takes precedence over --interface."`
Interface string `name:"interface" short:"i" help:"Resolve the zone for this interface. Used only when --zone is empty."`
}
// RuleAddCmd adds a filter rule.
type RuleAddCmd struct {
zoneMixin
ruleFlags
}
// Run validates the flags and adds the filter rule.
func (c *RuleAddCmd) Run(g *Globals) error {
// Validate the rule spec before touching the firewall: a malformed flag
// (bad action, family, port range, ...) should fail fast here rather
// than after a backend connection has been opened.
rule, err := c.toRule()
if err != nil {
return err
}
mgr, cleanup, err := g.manager()
if err != nil {
return err
}
defer cleanup()
zone, err := resolveZone(mgr, c.Zone, c.Interface)
if err != nil {
return err
}
if err := mgr.AddRule(context.Background(), zone, rule); err != nil {
return fmt.Errorf("add rule: %w", err)
}
if err := g.reload(mgr); err != nil {
return fmt.Errorf("reload: %w", err)
}
return g.emitStatus("added")
}
// RuleRemoveCmd removes a filter rule. The flags describe the rule to match
// (identity is rule.Equal, so counters and comment are ignored).
type RuleRemoveCmd struct {
zoneMixin
ruleFlags
}
// Run removes the filter rule matching the given flags.
func (c *RuleRemoveCmd) Run(g *Globals) error {
rule, err := c.toRule()
if err != nil {
return err
}
mgr, cleanup, err := g.manager()
if err != nil {
return err
}
defer cleanup()
zone, err := resolveZone(mgr, c.Zone, c.Interface)
if err != nil {
return err
}
if err := mgr.RemoveRule(context.Background(), zone, rule); err != nil {
return fmt.Errorf("remove rule: %w", err)
}
if err := g.reload(mgr); err != nil {
return fmt.Errorf("reload: %w", err)
}
return g.emitStatus("removed")
}
// RuleInsertCmd inserts a filter rule at a 1-based position (1 = first; a
// position past the end appends). Only ordered backends support this.
type RuleInsertCmd struct {
zoneMixin
Position int `arg:"" name:"position" help:"1-based position to insert at (1 = first; past the end appends)."`
ruleFlags
}
// Run inserts the filter rule at the requested 1-based position.
func (c *RuleInsertCmd) Run(g *Globals) error {
rule, err := c.toRule()
if err != nil {
return err
}
mgr, cleanup, err := g.manager()
if err != nil {
return err
}
defer cleanup()
zone, err := resolveZone(mgr, c.Zone, c.Interface)
if err != nil {
return err
}
if err := mgr.InsertRule(context.Background(), zone, c.Position, rule); err != nil {
return fmt.Errorf("insert rule: %w", err)
}
if err := g.reload(mgr); err != nil {
return fmt.Errorf("reload: %w", err)
}
return g.emitStatus("inserted")
}
// RuleMoveCmd moves an existing rule to a new 1-based position. The flags
// describe the rule to move (matched by identity).
type RuleMoveCmd struct {
zoneMixin
Position int `arg:"" name:"position" help:"1-based position to move the rule to."`
ruleFlags
}
// Run moves the matched filter rule to the requested 1-based position.
func (c *RuleMoveCmd) Run(g *Globals) error {
rule, err := c.toRule()
if err != nil {
return err
}
mgr, cleanup, err := g.manager()
if err != nil {
return err
}
defer cleanup()
zone, err := resolveZone(mgr, c.Zone, c.Interface)
if err != nil {
return err
}
if err := mgr.MoveRule(context.Background(), zone, rule, c.Position); err != nil {
return fmt.Errorf("move rule: %w", err)
}
if err := g.reload(mgr); err != nil {
return fmt.Errorf("reload: %w", err)
}
return g.emitStatus("moved")
}
// resolveZone picks the zone to operate on: an explicit --zone wins, otherwise
// the zone for --interface is resolved (which is empty on zoneless backends,
// which is correct), otherwise the empty default.
func resolveZone(mgr fw.Manager, zone, iface string) (string, error) {
if zone != "" {
return zone, nil
}
if iface != "" {
return mgr.GetZone(context.Background(), iface)
}
return "", nil
}

189
cmd/go-firewall/set.go Normal file
View file

@ -0,0 +1,189 @@
package main
import (
"context"
"fmt"
"os"
fw "github.com/grmrgecko/go-firewall"
)
// This file holds the address-set command group: list, create, remove,
// add-entry, remove-entry. Address sets are named collections of addresses
// (ipset / nftables set / pf table) that rules can match against; they are
// managed separately from filter and NAT rules.
// SetCmd is the top-level "set" command group.
type SetCmd struct {
List SetListCmd `cmd:"" help:"List address sets (default)."`
Show SetShowCmd `cmd:"" help:"Show a single address set and its entries."`
Create SetCreateCmd `cmd:"" help:"Create an address set."`
Remove SetRemoveCmd `cmd:"" help:"Remove an address set."`
AddEntry SetAddEntryCmd `cmd:"" help:"Add an entry to an address set."`
RemoveEntry SetRemoveEntryCmd `cmd:"" help:"Remove an entry from an address set."`
}
// SetShowCmd prints one address set's metadata and every entry it holds. The
// list command shows only an entry count; this is how the actual addresses are
// inspected in text mode (the --json list already carries them). The library
// has no get-by-name method, so it filters GetAddressSets by name.
type SetShowCmd struct {
Name string `arg:"" name:"name" help:"Set name to show."`
}
// Run prints one address set's metadata and every entry it holds.
func (c *SetShowCmd) Run(g *Globals) error {
mgr, cleanup, err := g.manager()
if err != nil {
return err
}
defer cleanup()
sets, err := mgr.GetAddressSets(context.Background())
if err != nil {
return fmt.Errorf("listing address sets: %w", err)
}
var set *fw.AddressSet
for _, s := range sets {
if s.Name == c.Name {
set = s
break
}
}
if set == nil {
return fmt.Errorf("address set %q not found", c.Name)
}
return g.emit(set, func() error {
printSet(os.Stdout, set)
return nil
})
}
// SetListCmd lists every address set the backend manages.
type SetListCmd struct{}
// Run lists every address set the backend manages.
func (c *SetListCmd) Run(g *Globals) error {
mgr, cleanup, err := g.manager()
if err != nil {
return err
}
defer cleanup()
sets, err := mgr.GetAddressSets(context.Background())
if err != nil {
return fmt.Errorf("listing address sets: %w", err)
}
return g.emit(sets, func() error {
if len(sets) == 0 {
fmt.Println("(no address sets)")
return nil
}
printSets(os.Stdout, sets)
return nil
})
}
// SetCreateCmd creates an address set. Adding a set that already exists by name
// is a no-op in the library.
type SetCreateCmd struct {
Name string `arg:"" name:"name" help:"Set name."`
Family string `name:"family" short:"f" help:"any|ipv4|ipv6 (default any; some backends resolve 'any' to ipv4)." default:"any"`
Type string `name:"type" short:"t" help:"hash:ip|hash:net (default hash:ip)." default:"hash:ip"`
}
// Run creates an address set from the parsed flags.
func (c *SetCreateCmd) Run(g *Globals) error {
// Parse the enum flags before opening the manager so a bad token fails
// fast instead of after a backend connection.
family, err := fw.ParseFamily(c.Family)
if err != nil {
return err
}
typ, err := fw.ParseSetType(c.Type)
if err != nil {
return err
}
mgr, cleanup, err := g.manager()
if err != nil {
return err
}
defer cleanup()
set := &fw.AddressSet{Name: c.Name, Family: family, Type: typ}
if err := mgr.AddAddressSet(context.Background(), set); err != nil {
return fmt.Errorf("create address set: %w", err)
}
if err := g.reload(mgr); err != nil {
return fmt.Errorf("reload: %w", err)
}
return g.emitStatus("created")
}
// SetRemoveCmd removes an address set by name.
type SetRemoveCmd struct {
Name string `arg:"" name:"name" help:"Set name to remove."`
}
// Run removes an address set by name.
func (c *SetRemoveCmd) Run(g *Globals) error {
mgr, cleanup, err := g.manager()
if err != nil {
return err
}
defer cleanup()
if err := mgr.RemoveAddressSet(context.Background(), c.Name); err != nil {
return fmt.Errorf("remove address set: %w", err)
}
if err := g.reload(mgr); err != nil {
return fmt.Errorf("reload: %w", err)
}
return g.emitStatus("removed")
}
// SetAddEntryCmd adds an entry (IP or CIDR) to a set.
type SetAddEntryCmd struct {
Name string `arg:"" name:"name" help:"Set name."`
Entry string `arg:"" name:"entry" help:"Address or CIDR to add."`
}
// Run adds an entry (IP or CIDR) to a set.
func (c *SetAddEntryCmd) Run(g *Globals) error {
mgr, cleanup, err := g.manager()
if err != nil {
return err
}
defer cleanup()
if err := mgr.AddAddressSetEntry(context.Background(), c.Name, c.Entry); err != nil {
return fmt.Errorf("add entry: %w", err)
}
if err := g.reload(mgr); err != nil {
return fmt.Errorf("reload: %w", err)
}
return g.emitStatus("added")
}
// SetRemoveEntryCmd removes an entry from a set.
type SetRemoveEntryCmd struct {
Name string `arg:"" name:"name" help:"Set name."`
Entry string `arg:"" name:"entry" help:"Address or CIDR to remove."`
}
// Run removes an entry from a set.
func (c *SetRemoveEntryCmd) Run(g *Globals) error {
mgr, cleanup, err := g.manager()
if err != nil {
return err
}
defer cleanup()
if err := mgr.RemoveAddressSetEntry(context.Background(), c.Name, c.Entry); err != nil {
return fmt.Errorf("remove entry: %w", err)
}
if err := g.reload(mgr); err != nil {
return fmt.Errorf("reload: %w", err)
}
return g.emitStatus("removed")
}

201
container.go Normal file
View file

@ -0,0 +1,201 @@
package firewall
import (
"net"
"regexp"
"strings"
)
// Container runtimes and CNI plugins do not merely add firewall rules, they own
// and continuously reconcile them: dockerd, podman/netavark, kube-proxy and the
// CNI plugins each rewrite their rules on daemon start, network create and
// container start. Rewriting or deleting one of those rules severs live container
// networking for services the consumer never asked this library to touch, and the
// owning daemon reinstates it on its own schedule, so the change does not even
// hold. Every read path therefore treats such a rule as out of scope: it never
// reaches a caller, so it never enters a desired set, and the file-rewrite paths
// preserve its line verbatim.
//
// This is not the ownership filter the package deliberately avoids. The library
// still manages foreign rules — a hand-written system rule is reconciled like any
// other. The distinction is a live, self-reconciling owner, not authorship.
// containerRuntimeIfaces names interfaces a container runtime or CNI plugin
// creates and manages. A rule matching on one of these is that runtime's.
var containerRuntimeIfaces = map[string]bool{
// Docker.
"docker0": true,
"docker_gwbridge": true,
// Podman.
"cni-podman0": true,
// Kubernetes CNI plugins.
"cni0": true,
"flannel.1": true,
"tunl0": true,
"vxlan.calico": true,
"cilium_host": true,
"cilium_net": true,
"cilium_vxlan": true,
"weave": true,
"kube-bridge": true,
"kube-ipvs0": true,
"nodelocaldns": true,
}
// containerRuntimeIfacePrefixes names interface prefixes a container runtime or
// CNI plugin allocates per network or per container, where the suffix is a
// generated index or identifier.
var containerRuntimeIfacePrefixes = []string{
"podman", // podman0, podman1, ... per podman network.
"cali", // Calico's per-workload interfaces; matches its own `cali+` rules.
"lxc", // Cilium's per-endpoint interfaces, and LXC's container interfaces.
}
// dockerBridgeRe matches the bridge Docker creates for a user-defined network:
// "br-" followed by the first 12 hex digits of the network ID. The pattern is
// deliberately exact rather than a "br-" prefix so an operator's own bridge —
// br-lan, br-wan, br-100 — is still managed normally.
var dockerBridgeRe = regexp.MustCompile(`^br-[0-9a-f]{12}$`)
// isDockerBridge reports whether name is a bridge Docker generated for a
// user-defined network. Beyond the name pattern it requires at least one numeral,
// which rules out a hand-named bridge that happens to be spelled in hex letters
// (br-deadbeefcafe) while costing Docker nothing: a network ID is random hex, so
// the odds of its first 12 digits holding no numeral are about 1 in 195,000.
//
// This stays a heuristic. A bridge renamed through the network's
// com.docker.network.bridge.name option is not detectable by name at all, and a
// bridge deliberately named as 12 hex digits including a numeral is a false
// positive. Both are accepted: the pattern covers the default every
// docker compose project produces, which is the case that actually occurs.
func isDockerBridge(name string) bool {
return dockerBridgeRe.MatchString(name) && strings.ContainsAny(name, "0123456789")
}
// containerRuntimeChains names the iptables/nftables chains a container runtime
// or CNI plugin creates and reconciles. A trailing "-" or "_" marks a prefix
// whose remainder is generated (a network name, a service hash, an index).
var containerRuntimeChains = []string{
// Docker: DOCKER itself, plus DOCKER-USER, DOCKER-INGRESS, DOCKER-ISOLATION-*
// and the DOCKER-FORWARD/BRIDGE/CT/INTERNAL set Docker 28 introduced.
"DOCKER", "DOCKER-",
// Podman: netavark, and the CNI stack older releases used.
"NETAVARK", "NETAVARK_", "NETAVARK-", "CNI-",
// Kubernetes kube-proxy and kubelet.
"KUBE-",
// CNI plugins.
"CALI-", "cali-", "CILIUM_", "FLANNEL-", "WEAVE", "WEAVE-",
}
// containerRuntimeTables names the nftables tables a container runtime or CNI
// plugin owns outright. Every rule in one of these is the runtime's, whatever
// chain it sits in — Docker's native nftables backend, for example, uses generic
// chain names (filter-forward-in, nat-postrouting-out) that carry no marker of
// their own.
var containerRuntimeTables = map[string]bool{
"docker-bridges": true, // dockerd with firewall-backend=nftables.
"netavark": true, // podman/netavark.
"kube-proxy": true,
"kube-router": true,
"calico": true,
"cilium": true,
}
// isContainerRuntimeIface reports whether name is an interface a container
// runtime or CNI plugin manages.
func isContainerRuntimeIface(name string) bool {
if name == "" {
return false
}
// An interface match may be negated; the underlying name still identifies the
// runtime, and a negated match is just as much its rule.
name = strings.TrimPrefix(name, "!")
name = strings.TrimSpace(name)
if containerRuntimeIfaces[name] {
return true
}
if isDockerBridge(name) {
return true
}
// An iptables interface match may carry a trailing "+" wildcard (cali+).
bare := strings.TrimSuffix(name, "+")
for _, p := range containerRuntimeIfacePrefixes {
if strings.HasPrefix(bare, p) {
return true
}
}
return false
}
// isContainerRuntimeChain reports whether name is a chain a container runtime or
// CNI plugin creates and reconciles.
func isContainerRuntimeChain(name string) bool {
if name == "" {
return false
}
for _, c := range containerRuntimeChains {
// A generated-suffix entry matches by prefix; an exact entry must match whole
// so a user chain merely starting with one is still managed.
if strings.HasSuffix(c, "-") || strings.HasSuffix(c, "_") {
if strings.HasPrefix(name, c) {
return true
}
continue
}
if name == c {
return true
}
}
return false
}
// isContainerRuntimeTable reports whether an nftables table is one a container
// runtime or CNI plugin owns outright. It accepts either a bare table name or the
// "family name" form the ruleset listing produces (e.g. "ip docker-bridges").
func isContainerRuntimeTable(name string) bool {
if name == "" {
return false
}
if i := strings.LastIndex(name, " "); i >= 0 {
name = name[i+1:]
}
return containerRuntimeTables[name]
}
// isContainerRuntime reports whether the rule belongs to a container runtime or
// CNI plugin, by the interfaces it matches on. Chain and table membership are
// checked by the backends that can see them, before a rule is ever built.
func (r *Rule) isContainerRuntime() bool {
if r == nil {
return false
}
return isContainerRuntimeIface(r.InInterface) || isContainerRuntimeIface(r.OutInterface)
}
// isHairpinMasquerade reports whether the NAT rule is a container runtime's
// hairpin (loopback) masquerade: the row Docker and podman add per published port
// so a container reaching its own published address is NATed back to itself. It
// carries no interface match, so it is the one container NAT rule the interface
// signal cannot see, and it is identified by its shape instead — a masquerade
// whose source and destination are the same single host. Nothing else generates
// that: masquerading a host's traffic to itself is meaningless outside a
// port-publishing loopback.
func (r *NATRule) isHairpinMasquerade() bool {
if r.Kind != Masquerade || r.Source == "" || !addrEqual(r.Source, r.Destination) {
return false
}
// canonAddr collapses a host prefix (/32, /128) onto the bare address, so a
// source that canonicalizes back to a plain IP is a single host, not a network.
// A masquerade of a whole subnet onto itself is not the hairpin shape.
c, ok := canonAddr(r.Source)
return ok && net.ParseIP(strings.TrimPrefix(c, "!")) != nil
}
// isContainerRuntime reports whether the NAT rule belongs to a container runtime
// or CNI plugin. It is the NATRule counterpart of Rule.isContainerRuntime.
func (r *NATRule) isContainerRuntime() bool {
if r == nil {
return false
}
return isContainerRuntimeIface(r.Interface) || r.isHairpinMasquerade()
}

102
container_test.go Normal file
View file

@ -0,0 +1,102 @@
package firewall
import (
"testing"
"github.com/stretchr/testify/require"
)
// The detector must catch every interface a container runtime allocates without
// swallowing an operator's own interfaces, which are managed normally. The
// br-<12 hex> case is the sharp edge: Docker's generated bridges must match while
// a hand-named br-lan must not.
func TestIsContainerRuntimeIface(t *testing.T) {
runtime := []string{
"docker0", "docker_gwbridge", "br-1a2b3c4d5e6f",
"podman0", "podman1", "cni-podman0",
"cni0", "flannel.1", "cilium_host", "weave", "kube-ipvs0",
"cali1234567890a", "lxc00aa",
"!docker0", "cali+", // Negated and wildcard forms still identify the runtime.
}
for _, name := range runtime {
require.True(t, isContainerRuntimeIface(name), "%q must be detected as a container-runtime interface", name)
}
operator := []string{
"", "eth0", "ens3", "wlan0", "bridge0",
"br-1a2b3c4d5e6", // Too short for a Docker network ID.
"br-1A2B3C4D5E6F", // Docker lowercases its hex; an uppercase name is not its.
"dockerish0", // Not an interface Docker creates.
// An operator's bridge that happens to be 12 hex characters. A generated
// network ID practically always carries a numeral, so requiring one keeps
// names spelled in hex letters managed.
"br-deadbeefcafe", "br-cafedecadeff",
"br-1a2b3c4d5e6f7", // One character too long for a network ID.
"br-lan", "br-wan", "br-100", "vmbr0", "virbr0", "lxdbr0",
// A veth pair is not a container-runtime signal on its own: systemd-nspawn,
// libvirt and hand-built netns setups all use them, and a runtime's own rules
// match the bridge rather than the container-side leg.
"veth1a2b3c", "vethwe-bridge",
"tun0", "wg0", "bond0", "lo",
}
for _, name := range operator {
require.False(t, isContainerRuntimeIface(name), "%q is the operator's interface and must stay managed", name)
}
}
func TestIsContainerRuntimeChain(t *testing.T) {
runtime := []string{
"DOCKER", "DOCKER-USER", "DOCKER-ISOLATION-STAGE-1", "DOCKER-INGRESS",
"DOCKER-FORWARD", "DOCKER-BRIDGE", "DOCKER-CT", "DOCKER-INTERNAL",
"NETAVARK_FORWARD", "NETAVARK-ISOLATION-2", "NETAVARK-HOSTPORT-DNAT",
"CNI-FORWARD", "CNI-ADMIN",
"KUBE-SERVICES", "KUBE-NODEPORTS", "KUBE-FIREWALL",
"CALI-INPUT", "cali-fw-cali123", "CILIUM_POST_mangle", "FLANNEL-FWD", "WEAVE",
}
for _, name := range runtime {
require.True(t, isContainerRuntimeChain(name), "%q must be detected as a container-runtime chain", name)
}
operator := []string{"", "INPUT", "OUTPUT", "FORWARD", "ufw-user-input", "f2b-sshd", "DOCKERISH"}
for _, name := range operator {
require.False(t, isContainerRuntimeChain(name), "%q must stay managed", name)
}
}
// Docker's native nftables backend names its chains generically
// (filter-forward-in, nat-postrouting-out), so the table name is the only signal.
// The listing form carries the family, which must be tolerated.
func TestIsContainerRuntimeTable(t *testing.T) {
for _, name := range []string{"docker-bridges", "ip docker-bridges", "ip6 docker-bridges", "inet netavark", "ip kube-proxy"} {
require.True(t, isContainerRuntimeTable(name), "%q must be detected as a container-runtime table", name)
}
for _, name := range []string{"", "inet filter", "ip nat", "inet go-firewall", "filter"} {
require.False(t, isContainerRuntimeTable(name), "%q must stay managed", name)
}
}
// Docker and podman add a hairpin masquerade per published port, and it carries
// no interface match, so shape is the only signal. It must not swallow an
// operator's ordinary masquerade or a whole-subnet self-masquerade.
func TestIsHairpinMasquerade(t *testing.T) {
hairpin := []*NATRule{
{Kind: Masquerade, Source: "172.17.0.2/32", Destination: "172.17.0.2/32", Proto: TCP, Port: 80},
{Kind: Masquerade, Source: "10.89.0.4", Destination: "10.89.0.4/32", Proto: UDP, Port: 53},
{Kind: Masquerade, Source: "fd00::2/128", Destination: "fd00::2/128", Proto: TCP, Port: 443},
}
for _, r := range hairpin {
require.True(t, r.isContainerRuntime(), "hairpin masquerade %s must be out of scope", r.Source)
}
managed := []*NATRule{
{Kind: Masquerade, Interface: "eth0"}, // Ordinary egress NAT.
{Kind: Masquerade, Source: "10.0.0.0/24", Destination: "10.0.0.0/24"}, // A subnet, not a host.
{Kind: Masquerade, Source: "10.0.0.5/32", Destination: "10.0.0.6/32"}, // Different hosts.
{Kind: Masquerade, Source: "10.0.0.5/32"}, // No destination.
{Kind: SNAT, Source: "10.0.0.5/32", Destination: "10.0.0.5/32", ToAddress: "203.0.113.1"},
{Kind: DNAT, Destination: "203.0.113.1/32", ToAddress: "10.0.0.5"},
}
for _, r := range managed {
require.False(t, r.isContainerRuntime(), "%+v is the operator's NAT rule and must stay managed", *r)
}
}

1940
csf_linux.go Normal file

File diff suppressed because it is too large Load diff

863
csf_linux_test.go Normal file
View file

@ -0,0 +1,863 @@
package firewall
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"github.com/stretchr/testify/require"
)
// A TCPUDP port-only reject must be written to csf.deny as explicit tcp and
// udp advanced lines: csf's linefilter defaults a protocol-less line to -p tcp,
// so a single protocol-less line would leave udp open while the library reported
// the port blocked for all protocols.
func TestCSFTCPUDPRejectFansOut(t *testing.T) {
ctx := context.Background()
fw := new(CSF)
dir := t.TempDir()
path := filepath.Join(dir, "csf.deny")
require.NoError(t, os.WriteFile(path, nil, 0644))
reject := &Rule{Family: IPv4, Proto: TCPUDP, Port: 80, Action: Reject}
require.NoError(t, fw.EditIPList(ctx, path, Reject, reject, false))
data, err := os.ReadFile(path)
require.NoError(t, err)
text := string(data)
require.Contains(t, text, "tcp|in|d=80|s=0.0.0.0/0", "tcp line must be present")
require.Contains(t, text, "udp|in|d=80|s=0.0.0.0/0", "udp line must be present so udp is actually blocked")
// No protocol-less line (which csf would silently treat as tcp only).
for _, line := range strings.Split(text, "\n") {
require.False(t, strings.HasPrefix(strings.TrimSpace(line), "in|"),
"a protocol-less advanced line silently means tcp-only in csf: %q", line)
}
}
// A port-only deny whose action is Drop (csf.conf's default DROP for an inbound
// deny) must still be written. The placeholder branch keys on "not an accept",
// not on Reject, so a Drop deny is written rather than skipped while AddRule
// reports success and leaves the port open.
func TestCSFPortOnlyDropDenyIsWritten(t *testing.T) {
ctx := context.Background()
fw := new(CSF)
dir := t.TempDir()
path := filepath.Join(dir, "csf.deny")
require.NoError(t, os.WriteFile(path, nil, 0644))
drop := &Rule{Family: IPv4, Proto: TCP, Port: 3306, Action: Drop}
require.NoError(t, fw.EditIPList(ctx, path, Drop, drop, false))
data, err := os.ReadFile(path)
require.NoError(t, err)
require.Contains(t, string(data), "tcp|in|d=3306|s=0.0.0.0/0",
"a port-only Drop deny must be written with the any-network placeholder")
}
// A TCPUDP port deny is written as a tcp line and a udp line, so it must be
// idempotent on re-add, read back as a single TCPUDP rule, and be fully
// removed by one RemoveRule. The add/remove matcher must treat a TCPUDP rule as
// covering its tcp and udp lines, not compare it exactly and let re-adds
// duplicate the pair while removal is a silent no-op.
func TestCSFTCPUDPPortDenyRoundTrip(t *testing.T) {
ctx := context.Background()
fw := new(CSF)
dir := t.TempDir()
path := filepath.Join(dir, "csf.deny")
require.NoError(t, os.WriteFile(path, nil, 0644))
deny := &Rule{Family: IPv4, Proto: TCPUDP, Port: 80, Action: Drop}
// Add fans the TCPUDP deny out to a tcp and a udp line.
require.NoError(t, fw.EditIPList(ctx, path, Drop, deny, false))
data, err := os.ReadFile(path)
require.NoError(t, err)
require.Equal(t, 1, strings.Count(string(data), "tcp|in|d=80|s=0.0.0.0/0"))
require.Equal(t, 1, strings.Count(string(data), "udp|in|d=80|s=0.0.0.0/0"))
// Re-adding is idempotent: neither line is duplicated.
require.NoError(t, fw.EditIPList(ctx, path, Drop, deny, false))
data, err = os.ReadFile(path)
require.NoError(t, err)
require.Equal(t, 1, strings.Count(string(data), "tcp|in|d=80|s=0.0.0.0/0"),
"re-adding a TCPUDP deny must not duplicate its tcp line")
require.Equal(t, 1, strings.Count(string(data), "udp|in|d=80|s=0.0.0.0/0"),
"re-adding a TCPUDP deny must not duplicate its udp line")
// The fanned lines read back as their own rules and cover the TCPUDP deny.
parsed, err := fw.ParseIPList(path, Drop)
require.NoError(t, err)
require.True(t, deny.CoveredBy(parsed), "the tcp+udp deny lines must cover the TCPUDP rule")
for _, g := range parsed {
require.True(t, deny.Covers(g), "a fanned line must not widen the rule: %+v", g)
}
// A single RemoveRule must drop every fanned line.
require.NoError(t, fw.EditIPList(ctx, path, Drop, deny, true))
data, err = os.ReadFile(path)
require.NoError(t, err)
require.NotContains(t, string(data), "d=80",
"removing a TCPUDP deny must delete all of its fanned lines")
}
// A port-only deny fans out across family (and protocol), but the file may already
// hold a subset of those lines — a prior single-family add, or a manual edit. The
// add must heal the missing lines rather than treat the rule as fully present the
// moment one fan-out line matches: otherwise the other family/protocol stays open
// while the library reports the port blocked. The gate must check every fan-out
// line, not skip the whole fan-out on a single "exists" match.
func TestCSFPortOnlyDenyHealsMissingFamily(t *testing.T) {
ctx := context.Background()
// IPv6 enabled, so the deny fans out across families and the missing v6 line heals.
fw := &CSF{ipv6Enabled: true}
dir := t.TempDir()
path := filepath.Join(dir, "csf.deny")
// The file already has only the IPv4 fan-out line.
require.NoError(t, os.WriteFile(path, []byte("tcp|in|d=80|s=0.0.0.0/0\n"), 0644))
// Adding a FamilyAny port-80 TCP deny must add the missing IPv6 line (and not
// duplicate the existing IPv4 one).
deny := &Rule{Family: FamilyAny, Proto: TCP, Port: 80, Action: Drop}
require.NoError(t, fw.EditIPList(ctx, path, Drop, deny, false))
data, err := os.ReadFile(path)
require.NoError(t, err)
text := string(data)
require.Equal(t, 1, strings.Count(text, "tcp|in|d=80|s=0.0.0.0/0"),
"the pre-existing IPv4 line must be preserved, not duplicated")
require.Equal(t, 1, strings.Count(text, "tcp|in|d=80|s=::/0"),
"the missing IPv6 line must be added so IPv6:80 is actually blocked")
// A TCPUDP deny whose udp line already exists must add the missing tcp line.
path2 := filepath.Join(dir, "csf.deny2")
require.NoError(t, os.WriteFile(path2, []byte("udp|in|d=53|s=0.0.0.0/0\n"), 0644))
anyDeny := &Rule{Family: IPv4, Proto: TCPUDP, Port: 53, Action: Drop}
require.NoError(t, fw.EditIPList(ctx, path2, Drop, anyDeny, false))
data2, err := os.ReadFile(path2)
require.NoError(t, err)
require.Equal(t, 1, strings.Count(string(data2), "udp|in|d=53|s=0.0.0.0/0"),
"the pre-existing udp line must be preserved")
require.Equal(t, 1, strings.Count(string(data2), "tcp|in|d=53|s=0.0.0.0/0"),
"the missing tcp line must be added so tcp:53 is actually blocked")
}
// A csf advanced rule with an address, a port, and TCPUDP cannot be expressed as a
// single line: csf.pl defaults a protocol-less line to tcp, so udp would be silently
// left open. AddRule must therefore fan the rule into a tcp rule and a udp rule
// before it reaches MarshalAdvRule, each of which marshals to its own line.
func TestCSFAdvRuleTCPUDPWithAddressFansOut(t *testing.T) {
fw := new(CSF)
both := &Rule{Family: IPv4, Proto: TCPUDP, Port: 443, Source: "192.0.2.10", Action: Drop}
subs := expandProtocols(both)
require.Len(t, subs, 2, "a TCPUDP rule must fan out before it is marshalled")
var lines []string
for _, sub := range subs {
lines = append(lines, fw.MarshalAdvRule(sub))
}
require.Equal(t, []string{"tcp|in|d=443|s=192.0.2.10", "udp|in|d=443|s=192.0.2.10"}, lines,
"each transport must get its own line so udp is not left open")
}
func TestCSFParseAdvRuleIPv6(t *testing.T) {
fw := new(CSF)
// An IPv6 source with a port must parse (the field separator is '|', so a
// colon in the value is an IPv6 address, not a range/list separator).
r := fw.ParseAdvRule("tcp|in|d=22|s=2001:db8::1", Accept)
require.NotNil(t, r, "expected IPv6 advanced rule to parse")
require.Equal(t, IPv6, r.Family, "expected IPv6 family")
require.Equal(t, "2001:db8::1", r.Source)
require.EqualValues(t, 22, r.Port)
require.Equal(t, TCP, r.Proto)
require.False(t, r.IsOutput())
// An IPv4 destination with a port still parses.
r = fw.ParseAdvRule("tcp|out|d=80|d=192.0.2.1", Accept)
require.NotNil(t, r, "expected IPv4 advanced rule to parse")
require.Equal(t, IPv4, r.Family)
require.Equal(t, "192.0.2.1", r.Destination)
require.EqualValues(t, 80, r.Port)
// A destination port range is neither a valid IP nor a single port, so it
// must still be rejected.
require.Nil(t, fw.ParseAdvRule("tcp|in|d=1000:2000", Accept),
"expected a port range to be rejected")
// A comma-separated address list is still rejected.
require.Nil(t, fw.ParseAdvRule("tcp|in|s=192.0.2.1,192.0.2.2", Accept),
"expected a multi-address rule to be rejected")
}
func TestCSFFeatureRules(t *testing.T) {
fw := new(CSF)
// Advanced-rule encodings.
cases := []struct {
rule *Rule
want string
}{
{&Rule{Proto: TCP, Ports: []PortRange{{Start: 80}, {Start: 443}}, Source: "1.2.3.4", Family: IPv4, Action: Accept}, "tcp|in|d=80,443|s=1.2.3.4"},
{&Rule{Proto: TCP, Ports: []PortRange{{Start: 2000, End: 3000}}, Source: "1.2.3.4", Family: IPv4, Action: Accept}, "tcp|in|d=2000_3000|s=1.2.3.4"},
{&Rule{Proto: ICMP, ICMPType: Ptr[uint8](8), Source: "44.33.22.11", Family: IPv4, Action: Accept}, "icmp|in|d=8|s=44.33.22.11"},
{&Rule{Direction: DirOutput, Proto: UDP, Port: 53, Destination: "192.0.2.1", Family: IPv4, Action: Accept}, "udp|out|d=53|d=192.0.2.1"},
}
for _, c := range cases {
got := fw.MarshalAdvRule(c.rule)
require.Equal(t, c.want, got, "marshal %+v", *c.rule)
parsed := fw.ParseAdvRule(got, c.rule.Action)
require.NotNil(t, parsed, "failed to parse %q", got)
require.True(t, parsed.Equal(c.rule, true),
"round-trip mismatch: input %+v, line %q, output %+v", *c.rule, got, parsed)
}
// An ICMP type given by name resolves to its number.
r := fw.ParseAdvRule("icmp|in|d=ping|s=44.33.22.11", Accept)
require.NotNil(t, r, "expected icmp type 8 from name ping")
require.NotNil(t, r.ICMPType, "expected icmp type 8 from name ping")
require.EqualValues(t, 8, *r.ICMPType, "expected icmp type 8 from name ping")
// csf reuses the port position for the ICMP type in BOTH the s= and d= fields
// (csf.pl maps `s=<n>` to `--icmp-type <n>` for an icmp rule, just like `d=`).
// A foreign rule that carries the type in s= must therefore read back as the
// ICMP type, not as a nonsensical source port.
r = fw.ParseAdvRule("icmp|in|s=8|d=44.33.22.11", Accept)
require.NotNil(t, r, "expected icmp rule with type in s= to parse")
require.NotNil(t, r.ICMPType, "expected s=8 to be read as icmp type 8")
require.EqualValues(t, 8, *r.ICMPType, "expected icmp type 8 from s=8")
require.False(t, r.HasSourcePorts(), "an icmp type must not be read as a source port")
require.Equal(t, "44.33.22.11", r.Destination)
// A colon range in an advanced rule is invalid (csf uses underscores there).
require.Nil(t, fw.ParseAdvRule("tcp|in|d=1000:2000|s=1.2.3.4", Accept),
"expected colon range in advanced rule to be rejected")
// csf.conf port lists parse single ports and colon ranges.
rules := fw.ParsePorts("20,21,30000:35000", IPv4, TCP, DirInput)
require.Len(t, rules, 3, "expected 3 port rules")
require.Len(t, rules[2].Ports, 1)
require.Equal(t, PortRange{Start: 30000, End: 35000}, rules[2].Ports[0],
"expected a 30000-35000 range rule")
// EditRulePort adds a colon range token to the matching csf.conf port list.
require.Equal(t, `TCP_IN = "22,2000:3000"`,
fw.EditRulePort(`TCP_IN = "22"`, "TCP_IN", "22",
&Rule{Proto: TCP, Ports: []PortRange{{Start: 2000, End: 3000}}, Action: Accept}, false),
"unexpected csf.conf port edit")
}
func TestCSFSourcePorts(t *testing.T) {
fw := new(CSF)
// Source ports round-trip through the s= port-flow field, including a
// multiport list and an underscore range.
cases := []struct {
rule *Rule
want string
}{
{&Rule{Proto: TCP, SourcePort: 1234, Destination: "192.0.2.1", Family: IPv4, Action: Accept}, "tcp|in|s=1234|d=192.0.2.1"},
{&Rule{Proto: UDP, SourcePorts: []PortRange{{Start: 80}, {Start: 443}}, Source: "1.2.3.4", Family: IPv4, Action: Accept}, "udp|in|s=80,443|s=1.2.3.4"},
{&Rule{Proto: TCP, SourcePorts: []PortRange{{Start: 2000, End: 3000}}, Source: "1.2.3.4", Family: IPv4, Action: Accept}, "tcp|in|s=2000_3000|s=1.2.3.4"},
}
for _, c := range cases {
got := fw.MarshalAdvRule(c.rule)
require.Equal(t, c.want, got, "marshal %+v", *c.rule)
parsed := fw.ParseAdvRule(got, c.rule.Action)
require.NotNil(t, parsed, "failed to parse %q", got)
require.True(t, parsed.Equal(c.rule, true),
"round-trip mismatch: input %+v, line %q, output %+v", *c.rule, got, parsed)
}
}
func TestCSFConnLimit(t *testing.T) {
fw := new(CSF)
// A csf.conf CONNLIMIT value parses into per-port reject rules carrying a
// per-source connection cap. csf's CONNLIMIT chain rejects the excess with a
// TCP reset (-j REJECT --reject-with tcp-reset), so the action is Reject.
rules := fw.ParseConnLimit("22;5,80;20")
require.Len(t, rules, 2)
require.Equal(t, TCP, rules[1].Proto)
require.EqualValues(t, 80, rules[1].Port)
require.Equal(t, Reject, rules[1].Action)
require.NotNil(t, rules[1].ConnLimit)
require.EqualValues(t, 20, rules[1].ConnLimit.Count)
require.True(t, rules[1].ConnLimit.PerSource)
// Editing the CONNLIMIT list adds, removes, and updates a port's entry.
require.Equal(t, `CONNLIMIT = "22;5,80;20"`, fw.editConnLimit("22;5", 80, 20, false))
require.Equal(t, `CONNLIMIT = "80;20"`, fw.editConnLimit("22;5,80;20", 22, 5, true))
require.Equal(t, `CONNLIMIT = "80;50"`, fw.editConnLimit("80;20", 80, 50, false))
}
// ParseConnLimit's reported Family must follow csf.conf's IPV6 setting: csf.pl
// only installs the ip6tables CONNLIMIT rule when IPV6 is enabled, so on the
// shipped default (IPV6="0") CONNLIMIT protects IPv4 only, not both families.
func TestCSFConnLimitFamily(t *testing.T) {
disabled := &CSF{ipv6Enabled: false}
rules := disabled.ParseConnLimit("22;5")
require.Len(t, rules, 1)
require.Equal(t, IPv4, rules[0].Family,
"CONNLIMIT must report IPv4-only when csf.conf IPV6 is off")
enabled := &CSF{ipv6Enabled: true}
rules = enabled.ParseConnLimit("22;5")
require.Len(t, rules, 1)
require.Equal(t, FamilyAny, rules[0].Family,
"CONNLIMIT must report dual-stack (FamilyAny) when csf.conf IPV6 is on")
}
func TestCSFRedirectNAT(t *testing.T) {
fw := new(CSF)
cases := []struct {
rule *NATRule
want string
}{
// A local port redirect (IPy = "*").
{&NATRule{Kind: Redirect, Proto: TCP, Port: 666, ToPort: 25}, "*|666|*|25|tcp"},
// A forward to another host with a fixed destination address.
{&NATRule{Kind: DNAT, Proto: TCP, Destination: "192.168.254.62", Port: 666, ToAddress: "10.0.0.1", ToPort: 25, Family: IPv4}, "192.168.254.62|666|10.0.0.1|25|tcp"},
// A full-IP forward, all ports (portA/portB unset).
{&NATRule{Kind: DNAT, Proto: TCP, Destination: "192.168.254.62", ToAddress: "10.0.0.1", Family: IPv4}, "192.168.254.62|*|10.0.0.1|*|tcp"},
}
for _, c := range cases {
got := fw.MarshalNATRule(c.rule)
require.Equal(t, c.want, got, "marshal %+v", *c.rule)
parsed := fw.UnmarshalNATRule(got)
require.NotNil(t, parsed, "failed to parse %q", got)
require.True(t, parsed.EqualBase(c.rule), "round-trip mismatch: input %+v, line %q, output %+v", *c.rule, got, parsed)
}
// A malformed csf.redirect line is ignored by the parser.
require.Nil(t, fw.UnmarshalNATRule("nonsense|line"))
}
func TestCSFIPListComment(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "csf.allow")
fw := &CSF{rulePrefix: "myapp"}
ctx := context.Background()
require.NoError(t, os.WriteFile(path, []byte(
"# myapp trusted office\n"+
"tcp|in|d=22|s=10.0.0.0/24\n"+
"\n"+
"# unrelated note\n"+
"# separated by blank\n"+
"192.0.2.5\n"+
"2001:db8::1 # inline ignored\n",
), 0644))
rules, err := fw.ParseIPList(path, Accept)
require.NoError(t, err)
// Advanced rule keeps the comment immediately above it.
adv := rules[0]
require.Equal(t, "trusted office", adv.Comment)
require.Equal(t, "10.0.0.0/24", adv.Source)
require.EqualValues(t, 22, adv.Port)
// A bare IPv4 line is one bidirectional DirAny rule carrying the accumulated comment.
host := rules[1]
require.Equal(t, DirAny, host.Direction)
require.Equal(t, "192.0.2.5", host.Source)
require.Equal(t, "unrelated note separated by blank", host.Comment)
// Inline comment is ignored, not treated as a rule comment.
v6 := rules[2]
require.Equal(t, DirAny, v6.Direction)
require.Equal(t, "", v6.Comment)
require.Equal(t, "2001:db8::1", v6.Source)
// Add a rule with a comment: a prefixed full-line comment is written above it.
add := &Rule{Proto: TCP, Port: 443, Source: "192.0.2.10", Action: Accept, Comment: "web"}
require.NoError(t, fw.EditIPList(ctx, path, Accept, add, false))
data, err := os.ReadFile(path)
require.NoError(t, err)
require.Contains(t, string(data), "# myapp web\n")
require.Contains(t, string(data), "tcp|in|d=443|s=192.0.2.10")
// Removing the rule drops the comment line above it as well.
require.NoError(t, fw.EditIPList(ctx, path, Accept, add, true))
data, err = os.ReadFile(path)
require.NoError(t, err)
require.NotContains(t, string(data), "# myapp web")
require.NotContains(t, string(data), "192.0.2.10")
// A port-only rule has nowhere to go in an IP-list file; no dangling
// comment line should be written even when a comment is supplied.
portOnly := &Rule{Proto: TCP, Port: 8080, Action: Accept, Comment: "not-stored"}
require.NoError(t, fw.EditIPList(ctx, path, Accept, portOnly, false))
data, err = os.ReadFile(path)
require.NoError(t, err)
require.NotContains(t, string(data), "not-stored")
// A rule appended after instructional header comments must still report
// HasPrefix: the prefix tag starts a fresh comment block so header
// comments are not absorbed into the rule's comment.
headerPath := filepath.Join(dir, "header_csf.allow")
require.NoError(t, os.WriteFile(headerPath, []byte(
"# This is the csf.allow file.\n"+
"# Add hosts/rules below, one per line.\n"+
"# Format: proto|flow|port|ip\n",
), 0644))
appendRule := &Rule{Proto: TCP, Port: 3456, Source: "192.0.2.10/32", Action: Accept}
require.NoError(t, fw.EditIPList(ctx, headerPath, Accept, appendRule, false))
parsed, err := fw.ParseIPList(headerPath, Accept)
require.NoError(t, err)
require.Len(t, parsed, 1)
require.True(t, parsed[0].HasPrefix, "rule after header comments must be flagged with the prefix")
require.Equal(t, "", parsed[0].Comment)
}
// TestCSFRemovePreservesForeignHeader verifies that removing a managed rule keeps
// a foreign section header sitting directly above its prefix tag. ParseIPList
// treats the tag as starting a fresh comment block, so the header is not part of
// the rule's comment; removal must mirror that and not delete it.
func TestCSFRemovePreservesForeignHeader(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "csf.allow")
fw := &CSF{rulePrefix: "myapp"}
ctx := context.Background()
require.NoError(t, os.WriteFile(path, []byte(
"# Section: web servers\n"+
"# myapp trusted\n"+
"192.0.2.50\n",
), 0644))
require.NoError(t, fw.EditIPList(ctx, path, Accept, &Rule{Source: "192.0.2.50", Action: Accept}, true))
data, err := os.ReadFile(path)
require.NoError(t, err)
got := string(data)
require.NotContains(t, got, "192.0.2.50", "the managed rule must be removed")
require.NotContains(t, got, "# myapp trusted", "the rule's own tag comment is removed with it")
require.Contains(t, got, "# Section: web servers", "the foreign section header must be preserved")
}
// csf.deny encodes no action of its own, so a rule added with Action Drop must be
// found and removed by the same Drop rule rather than leaking.
func TestCSFDropRuleRemovable(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "csf.deny")
require.NoError(t, os.WriteFile(path, nil, 0644))
fw := new(CSF)
drop := &Rule{Proto: TCP, Port: 3306, Source: "1.2.3.4", Action: Drop}
require.NoError(t, fw.EditIPList(context.Background(), path, Reject, drop, false))
require.NoError(t, fw.EditIPList(context.Background(), path, Reject, drop, false))
require.NoError(t, fw.EditIPList(context.Background(), path, Reject, drop, true))
data, _ := os.ReadFile(path)
require.NotContains(t, string(data), "1.2.3.4", "a Drop rule must be removable by the same Drop rule")
}
// csf.conf's CONNLIMIT is a single dual-stack config key (it caps both v4 and v6
// connections; there is no separate v6 variant), so a connection-limit rule read
// from it must be FamilyAny — not IPv4 — or a FamilyAny desired connlimit rule
// (the natural shape: no address, so no family is implied) never matches its own
// read-back and Sync removes-and-re-adds it every reconcile, firing csf -r each
// time.
func TestCSFConnLimitFamilyIsAny(t *testing.T) {
// csf.pl only installs the ip6tables CONNLIMIT rule when csf.conf's IPV6 is
// enabled; only then does a dual-stack FamilyAny read-back (and the
// FamilyAny-desired-rule match below) hold. See TestCSFConnLimitFamily for
// the IPV6-disabled (stock default) case, where CONNLIMIT is IPv4-only.
f := &CSF{ipv6Enabled: true}
rules := f.ParseConnLimit("80;20")
require.Len(t, rules, 1)
require.Equal(t, FamilyAny, rules[0].Family,
"a dual-stack CONNLIMIT entry must read back as FamilyAny when csf.conf IPV6 is on")
desired := &Rule{Proto: TCP, Port: 80, Action: Reject, ConnLimit: &ConnLimit{Count: 20, PerSource: true}}
require.True(t, desired.Equal(rules[0], true),
"FamilyAny connlimit must equal the CSF read-back or Sync churns")
}
// A port-only reject (no address) must be written so csf actually enforces it.
// csf's advanced-rule handler only emits an iptables rule when the line carries a
// source/destination IP alongside the port, so a bare "d=80" was parsed by csf
// and then silently never applied — the port stayed open while the library
// reported it blocked. The rule must be written with the "any" network as the
// address (so csf enforces it) and must still round-trip and remove: parseAddr
// normalizes the "any" network back to an empty address, and a family-neutral rule
// writes one line per family, which cover it between them.
func TestCSFPortOnlyRejectRoundTrip(t *testing.T) {
// IPv6 enabled, so a family-neutral reject writes a line per family.
fw := &CSF{ipv6Enabled: true}
ctx := context.Background()
for _, rule := range []*Rule{
{Action: Reject, Proto: TCP, Port: 80},
{Action: Reject, Proto: TCP, Port: 80, Family: IPv4},
{Action: Reject, Proto: TCP, Port: 8080, Family: IPv6},
{Action: Reject, Proto: TCP, Port: 443, Direction: DirOutput},
} {
deny := filepath.Join(t.TempDir(), "csf.deny")
require.NoError(t, os.WriteFile(deny, nil, 0o644))
require.NoError(t, fw.EditIPList(ctx, deny, Reject, rule, false))
// The written line must carry an address, or csf never applies it.
raw, err := os.ReadFile(deny)
require.NoError(t, err)
require.True(t, strings.Contains(string(raw), "0.0.0.0/0") || strings.Contains(string(raw), "::/0"),
"port-only reject (%s) must be written with an address so csf enforces it; got:\n%s", rule.Family, raw)
// A concrete-family rule is one line; a family-neutral one is a line per family.
wantRows := 1
if rule.impliedFamily() == FamilyAny {
wantRows = 2
}
got, err := fw.ParseIPList(deny, Reject)
require.NoError(t, err)
require.Len(t, got, wantRows, "port-only reject (%s) must round-trip to %d row(s)", rule.Family, wantRows)
require.True(t, rule.CoveredBy(got), "read-back rows must cover the written rule: %+v", got)
for _, g := range got {
require.True(t, rule.Covers(g), "read-back row must not widen the written rule: %+v", g)
}
// It must also be removable (matched back on delete).
require.NoError(t, fw.EditIPList(ctx, deny, Reject, rule, true))
got, err = fw.ParseIPList(deny, Reject)
require.NoError(t, err)
require.Len(t, got, 0, "rule (%s) must be fully removed", rule.Family)
}
}
// A bare all-protocol host rule (address, no port) is the one portless address
// shape csf.allow/csf.deny express, written as the plain address line. The
// inexpressible shapes — a concrete-protocol host or a source+destination pair —
// are diverted to the hook by AddRule (shapeNeedsHook) and never reach this
// writer, so only the legitimate write is exercised here.
func TestCSFBareHostWritten(t *testing.T) {
fw := new(CSF)
ctx := context.Background()
list := filepath.Join(t.TempDir(), "csf.allow")
require.NoError(t, os.WriteFile(list, nil, 0o644))
require.NoError(t, fw.EditIPList(ctx, list, Accept, &Rule{Source: "1.2.3.4", Action: Accept}, false))
got, err := os.ReadFile(list)
require.NoError(t, err)
require.Contains(t, string(got), "1.2.3.4", "an any-protocol host rule must be written as a plain address")
}
// A port-only "any"-source deny is written to csf.deny as a family-specific
// placeholder line (0.0.0.0/0 for IPv4, ::/0 for IPv6). The two lines cover
// different families, so adding the IPv6 twin while the IPv4 line already exists
// must write it — EditIPList matches an existing line with EqualForDedup, so without
// the family coverage gate the IPv6 add was silently dropped as a false duplicate,
// leaving IPv6 open and making Sync churn forever.
func TestCSFCrossFamilyAdvDenyBothWritten(t *testing.T) {
ctx := context.Background()
fw := new(CSF)
dir := t.TempDir()
path := filepath.Join(dir, "csf.deny")
require.NoError(t, os.WriteFile(path, nil, 0644))
v4 := &Rule{Family: IPv4, Proto: TCP, Port: 80, Action: Drop}
v6 := &Rule{Family: IPv6, Proto: TCP, Port: 80, Action: Drop}
require.NoError(t, fw.EditIPList(ctx, path, Drop, v4, false))
require.NoError(t, fw.EditIPList(ctx, path, Drop, v6, false))
data, err := os.ReadFile(path)
require.NoError(t, err)
text := string(data)
require.Equal(t, 1, strings.Count(text, "tcp|in|d=80|s=0.0.0.0/0"), "IPv4 deny line must be present")
require.Equal(t, 1, strings.Count(text, "tcp|in|d=80|s=::/0"), "IPv6 deny line must be present, not dropped as a false duplicate")
// Removing only the IPv6 twin must leave the IPv4 line intact (family-scoped
// removal must not delete the other family's line).
require.NoError(t, fw.EditIPList(ctx, path, Drop, v6, true))
data, err = os.ReadFile(path)
require.NoError(t, err)
text = string(data)
require.Equal(t, 1, strings.Count(text, "tcp|in|d=80|s=0.0.0.0/0"), "removing IPv6 must not drop the IPv4 line")
require.Equal(t, 0, strings.Count(text, "tcp|in|d=80|s=::/0"), "the IPv6 line must be removed")
}
// A FamilyAny port-only deny writes both placeholder lines and must still be
// idempotent on re-add and fully removable — EditIPList's EqualForDedup/
// EqualForRemoval gate must not disturb the FamilyAny case.
func TestCSFFamilyAnyAdvDenyRoundTrip(t *testing.T) {
ctx := context.Background()
// IPv6 enabled, so a FamilyAny deny fans out to both placeholder lines.
fw := &CSF{ipv6Enabled: true}
dir := t.TempDir()
path := filepath.Join(dir, "csf.deny")
require.NoError(t, os.WriteFile(path, nil, 0644))
deny := &Rule{Family: FamilyAny, Proto: TCP, Port: 22, Action: Drop}
require.NoError(t, fw.EditIPList(ctx, path, Drop, deny, false))
// Re-add is idempotent.
require.NoError(t, fw.EditIPList(ctx, path, Drop, deny, false))
data, err := os.ReadFile(path)
require.NoError(t, err)
require.Equal(t, 1, strings.Count(string(data), "tcp|in|d=22|s=0.0.0.0/0"))
require.Equal(t, 1, strings.Count(string(data), "tcp|in|d=22|s=::/0"))
// One removal clears both family lines.
require.NoError(t, fw.EditIPList(ctx, path, Drop, deny, true))
data, err = os.ReadFile(path)
require.NoError(t, err)
require.NotContains(t, string(data), "d=22", "a FamilyAny removal must clear both placeholder lines")
}
// CSF expresses IPv4 and IPv6 opens through separate config keys (TCP_IN vs
// TCP6_IN), so a `TCP_IN="53"` + `UDP6_IN="53"` config produces a tcp/IPv4 rule and
// a udp/IPv6 rule. Those cover different families, and neither a TCPUDP/IPv4 rule nor
// its IPv6 twin may be reported as present against them — treating the pair as one
// both-transports rule drops a family's coverage and makes Sync churn forever.
func TestCSFCrossFamilyPairCoversNeitherTransportPair(t *testing.T) {
stored := []*Rule{
{Family: IPv4, Proto: TCP, Port: 53, Action: Accept},
{Family: IPv6, Proto: UDP, Port: 53, Action: Accept},
}
require.False(t, (&Rule{Family: IPv4, Proto: TCPUDP, Port: 53, Action: Accept}).CoveredBy(stored),
"udp/IPv6 must not stand in for the missing udp/IPv4 open")
require.False(t, (&Rule{Family: IPv6, Proto: TCPUDP, Port: 53, Action: Accept}).CoveredBy(stored),
"tcp/IPv4 must not stand in for the missing tcp/IPv6 open")
require.False(t, (&Rule{Family: FamilyAny, Proto: TCPUDP, Port: 53, Action: Accept}).CoveredBy(stored))
// Each stored rule still covers exactly its own cell.
require.True(t, (&Rule{Family: IPv4, Proto: TCP, Port: 53, Action: Accept}).CoveredBy(stored))
require.True(t, (&Rule{Family: IPv6, Proto: UDP, Port: 53, Action: Accept}).CoveredBy(stored))
}
// GetRules reports both the library's own rules and foreign ones, each tagged
// with HasPrefix and with the configured prefix stripped from the surfaced comment.
func TestCSFHasPrefixFlag(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "csf.allow")
fw := &CSF{rulePrefix: "myapp"}
require.NoError(t, os.WriteFile(path, []byte(
"# myapp web\n"+
"tcp|in|d=443|s=192.0.2.10\n"+
"\n"+
"# hand-added by an admin\n"+
"tcp|in|d=22|s=10.0.0.0/24\n",
), 0644))
rules, err := fw.ParseIPList(path, Accept)
require.NoError(t, err)
require.Len(t, rules, 2)
// Our rule: prefix stripped from the comment, flagged as carrying the prefix.
require.Equal(t, "web", rules[0].Comment)
require.True(t, rules[0].HasPrefix, "prefixed comment sets HasPrefix")
// The admin's rule: comment surfaces unchanged, no prefix.
require.Equal(t, "hand-added by an admin", rules[1].Comment)
require.False(t, rules[1].HasPrefix, "a comment without the prefix is not flagged")
}
// With csf.conf's IPV6 off, csf installs no IPv6 rule from its config, so a
// family-neutral port-only deny must be written as the IPv4 line alone. An IPv6
// placeholder line would sit inert in csf.deny and read back as an IPv6 rule csf does
// not enforce and AddRule would reject. Removal still matches the target against every
// line, so a v6 line written while IPv6 was on is swept regardless.
func TestCSFPortOnlyDenyIPv6DisabledWritesV4Only(t *testing.T) {
ctx := context.Background()
dir := t.TempDir()
off := new(CSF)
path := filepath.Join(dir, "csf.deny")
require.NoError(t, os.WriteFile(path, nil, 0644))
deny := &Rule{Family: FamilyAny, Proto: TCP, Port: 80, Action: Drop}
require.NoError(t, off.EditIPList(ctx, path, Drop, deny, false))
data, err := os.ReadFile(path)
require.NoError(t, err)
require.Contains(t, string(data), "tcp|in|d=80|s=0.0.0.0/0", "the IPv4 line must be written")
require.NotContains(t, string(data), "::/0",
"no IPv6 line may be written while csf's IPv6 handling is off")
// The rows read back cover the rule, and only for the family csf enforces.
got, err := off.ParseIPList(path, Drop)
require.NoError(t, err)
require.Len(t, got, 1)
require.Equal(t, IPv4, got[0].impliedFamily())
// A rule pinned to IPv6 still writes its line: the AddRule IPv6 gate stops a
// fresh add, and Restore bypasses that gate on purpose to reproduce a snapshot.
v6path := filepath.Join(dir, "csf.deny.v6")
require.NoError(t, os.WriteFile(v6path, nil, 0644))
v6 := &Rule{Family: IPv6, Proto: TCP, Port: 80, Action: Drop}
require.NoError(t, off.EditIPList(ctx, v6path, Drop, v6, false))
data, err = os.ReadFile(v6path)
require.NoError(t, err)
require.Contains(t, string(data), "tcp|in|d=80|s=::/0")
// Switching IPv6 off must not strand the v6 line written while it was on.
on := &CSF{ipv6Enabled: true}
bothPath := filepath.Join(dir, "csf.deny.both")
require.NoError(t, os.WriteFile(bothPath, nil, 0644))
require.NoError(t, on.EditIPList(ctx, bothPath, Drop, deny, false))
data, err = os.ReadFile(bothPath)
require.NoError(t, err)
require.Contains(t, string(data), "::/0")
require.NoError(t, off.EditIPList(ctx, bothPath, Drop, deny, true))
data, err = os.ReadFile(bothPath)
require.NoError(t, err)
require.NotContains(t, string(data), "d=80",
"removal must sweep the stale IPv6 line even with IPv6 off")
}
// A protocol-less advanced line is enforced by csf.pl as `-p tcp` (its protocol
// default), so it must read back as TCP: a ProtocolAny read-back would report an
// all-protocol rule csf does not enforce, and one whose removal the iptables
// validity check rejects.
func TestCSFAdvRuleProtocolDefaultsTCP(t *testing.T) {
fw := new(CSF)
r := fw.ParseAdvRule("in|d=80|s=192.0.2.1", Drop)
require.NotNil(t, r)
require.Equal(t, TCP, r.Proto, "csf enforces a protocol-less advanced line as tcp")
require.EqualValues(t, 80, r.Port)
require.Equal(t, "192.0.2.1", r.Source)
}
// csf.pl accepts a colon-delimited advanced line (converting `:` to `|` when the
// line has no pipe), so the parser must read it; an IPv6 literal keeps parsing as
// a plain address, never as a colon-delimited rule.
func TestCSFColonDelimitedAdvRule(t *testing.T) {
fw := new(CSF)
r := fw.parseListLine("tcp:in:d=22:s=192.0.2.1", Drop)
require.NotNil(t, r, "a colon-delimited advanced line must parse")
require.Equal(t, TCP, r.Proto)
require.EqualValues(t, 22, r.Port)
require.Equal(t, "192.0.2.1", r.Source)
v6 := fw.parseListLine("2001:db8::7", Drop)
require.NotNil(t, v6)
require.Equal(t, "2001:db8::7", v6.Source, "an IPv6 literal is a plain address line")
require.Equal(t, DirAny, v6.Direction)
}
// With csf.conf's IPV6 off, a family-agnostic port add must not touch the *6_*
// lists (their entries would be inert), a removal must still sweep them, and a
// concrete-IPv6 row written by Restore keeps its family.
func TestCSFEditRulePortIPv6Disabled(t *testing.T) {
off := &CSF{}
anyFam := &Rule{Proto: TCP, Port: 8080, Action: Accept}
got := off.EditRulePort(`TCP6_IN = "22"`, "TCP6_IN", "22", anyFam, false)
require.Equal(t, `TCP6_IN = "22"`, got, "a family-agnostic add must not touch TCP6_IN while IPv6 is off")
got = off.EditRulePort(`TCP_IN = "22"`, "TCP_IN", "22", anyFam, false)
require.Contains(t, got, "8080", "the IPv4 list still takes the add")
// Removal sweeps the inert v6 entry so it does not outlive the rule.
got = off.EditRulePort(`TCP6_IN = "22,8080"`, "TCP6_IN", "22,8080", anyFam, true)
require.NotContains(t, got, "8080", "a removal must sweep the v6 list even with IPv6 off")
// A concrete-IPv6 rule (Restore reproducing a snapshot) keeps its family.
v6 := &Rule{Family: IPv6, Proto: TCP, Port: 8080, Action: Accept}
got = off.EditRulePort(`TCP6_IN = "22"`, "TCP6_IN", "22", v6, false)
require.Contains(t, got, "8080", "a concrete-IPv6 write keeps its family")
on := &CSF{ipv6Enabled: true}
got = on.EditRulePort(`TCP6_IN = "22"`, "TCP6_IN", "22", anyFam, false)
require.Contains(t, got, "8080", "with IPv6 on the v6 list takes the add")
}
// With IPV6 off a concrete-IPv6 NAT rule is rejected outright: csf neither
// applies a v6 redirect nor flushes the v6 nat table, so neither store could
// keep the rule in sync (the NAT analog of the AddRule IPv6 gate, mirroring apf).
func TestCSFNATIPv6Gating(t *testing.T) {
off := new(CSF)
err := off.AddNATRule(context.Background(), "", &NATRule{Kind: DNAT, Family: IPv6, Proto: TCP, Port: 8080, ToAddress: "2001:db8::5"})
require.ErrorIs(t, err, ErrUnsupportedNAT, "a concrete-IPv6 nat add must be rejected while IPV6 is off")
}
// csfLiveSave is a trimmed `iptables-save -c -t filter` capture from a host
// running csf, covering each shape it generates: a config port-list accept (its
// interface frame plus a NEW state match), an allow-list address in the
// per-direction ALLOW chains, a deny-list address whose outbound half jumps into
// csf's logging drop chain, and a raw pre-hook rule carrying no frame at all.
var csfLiveSave = []string{
"*filter",
":INPUT DROP [0:0]",
"[9:540] -A INPUT ! -i lo -j LOCALINPUT",
"[3:180] -A INPUT ! -i lo -p tcp -m conntrack --ctstate NEW -m tcp --dport 22 -j ACCEPT",
"[5:300] -A INPUT ! -i lo -p tcp -m conntrack --ctstate NEW -m tcp --dport 80 -j ACCEPT",
"[7:420] -A INPUT -p tcp -m conntrack --ctstate NEW -m tcp --dport 9100 -m comment --comment gofw -j ACCEPT",
"[2:120] -A OUTPUT ! -o lo -p tcp -m conntrack --ctstate NEW -m tcp --dport 25 -j ACCEPT",
"[1:60] -A ALLOWIN -s 203.0.113.5/32 ! -i lo -j ACCEPT",
"[1:40] -A ALLOWOUT -d 203.0.113.5/32 ! -o lo -j ACCEPT",
"[4:240] -A DENYIN -s 203.0.113.9/32 ! -i lo -j DROP",
"[6:360] -A DENYOUT -d 203.0.113.9/32 ! -o lo -j LOGDROPOUT",
"[6:360] -A LOGDROPOUT -j REJECT --reject-with icmp-port-unreachable",
"COMMIT",
}
// TestCSFParseLiveRules verifies csf's own framing is undone on read: the
// interface match and the NEW state it stamps on a config rule are dropped, a
// jump into its logging drop chain is restored to the action that chain applies,
// and a pre-hook rule (which carries no frame) keeps the state it was written
// with. csf's internal chains contribute no rules of their own.
func TestCSFParseLiveRules(t *testing.T) {
fw := new(CSF)
rules := fw.parseLiveRules(csfLiveSave, IPv4)
require.Len(t, rules, 8, "the LOCALINPUT jump and the LOGDROPOUT chain body model no rule")
// A config port-list accept: framing gone, counters kept.
require.EqualValues(t, 22, rules[0].Port)
require.Equal(t, DirInput, rules[0].Direction)
require.Empty(t, rules[0].InInterface, "csf's interface frame is not part of the rule")
require.Zero(t, rules[0].State, "csf's NEW state frame is not part of the rule")
require.EqualValues(t, 3, rules[0].Packets)
// A pre-hook rule carries no frame, so its own state match survives.
require.EqualValues(t, 9100, rules[2].Port)
require.Equal(t, StateNew, rules[2].State, "a rule csf did not frame keeps the state it matches on")
// The deny list's outbound half reads back as the action its chain ends in.
require.Equal(t, Drop, rules[6].Action)
require.Equal(t, Reject, rules[7].Action, "LOGDROPOUT applies csf.conf's DROP_OUT")
require.EqualValues(t, 6, rules[7].Packets)
}
// TestCSFApplyCountersLists verifies the allow and deny lists count both halves
// of a bidirectional entry: the allow entry sums its two rows through ordinary
// coverage, and the deny entry's outbound row — which carries DROP_OUT while the
// entry reads back as DROP — is claimed by claimDenyOutRows.
func TestCSFApplyCountersLists(t *testing.T) {
fw := new(CSF)
live := fw.parseLiveRules(csfLiveSave, IPv4)
allow := &Rule{Direction: DirAny, Family: IPv4, Source: "203.0.113.5", Action: Accept}
deny := &Rule{Direction: DirAny, Family: IPv4, Source: "203.0.113.9", Action: Drop}
port := &Rule{Direction: DirInput, Family: IPv4, Proto: TCP, Port: 22, Action: Accept}
targets := []*Rule{allow, deny, port}
leftover := applyLiveCounters(targets, live)
fw.claimDenyOutRows(targets, leftover)
require.EqualValues(t, 3, port.Packets, "a config port rule counts its framed row")
require.EqualValues(t, 2, allow.Packets, "a bidirectional allow sums its ALLOWIN and ALLOWOUT rows")
require.EqualValues(t, 100, allow.Bytes)
require.EqualValues(t, 10, deny.Packets, "a bidirectional deny sums its DENYIN and DENYOUT rows")
require.EqualValues(t, 600, deny.Bytes)
}
// TestCSFClaimDenyOutRowsIsScoped verifies the outbound-deny claim does not
// absorb a row belonging to a different entry: it must still match every field
// but the action, and it only applies to a bidirectional rule.
func TestCSFClaimDenyOutRowsIsScoped(t *testing.T) {
fw := new(CSF)
other := &Rule{Direction: DirAny, Family: IPv4, Source: "198.51.100.1", Action: Drop}
oneWay := &Rule{Direction: DirOutput, Family: IPv4, Destination: "203.0.113.9", Action: Reject}
leftover := []*Rule{
{Direction: DirOutput, Family: IPv4, Destination: "203.0.113.9", Action: Reject, Packets: 6, Bytes: 360},
}
fw.claimDenyOutRows([]*Rule{other, oneWay}, leftover)
require.Zero(t, other.Packets, "an unrelated deny entry must not absorb the row")
require.Zero(t, oneWay.Packets, "a one-way deny matches on identity and is not claimed here")
}

1991
firewall.go Normal file

File diff suppressed because it is too large Load diff

370
firewall_test.go Normal file
View file

@ -0,0 +1,370 @@
package firewall
import (
"bytes"
"reflect"
"testing"
"github.com/stretchr/testify/require"
)
// Rule identity compares addresses semantically: a bare host, its /32 (or /128)
// form, and a differently spelled but equal IPv6 address are the same address.
// Backends re-spell addresses on read (nft strips a /32, iptables-save adds it),
// so an exact string compare would report the rule as changed on every reconcile.
func TestAddrEqualCanonicalizesHostPrefix(t *testing.T) {
require.True(t, addrEqual("1.2.3.4", "1.2.3.4/32"))
require.True(t, addrEqual("2001:db8::1", "2001:0db8::1/128"))
require.True(t, addrEqual("10.0.0.5/24", "10.0.0.0/24"), "host bits are masked to the network")
require.True(t, addrEqual("!1.2.3.4", "!1.2.3.4/32"), "negation is preserved")
require.False(t, addrEqual("1.2.3.4", "1.2.3.5"))
require.False(t, addrEqual("1.2.3.4", "!1.2.3.4/32"), "a negation must not match its non-negated form")
require.False(t, addrEqual("", "0.0.0.0/0"), "an any-address is not the empty match")
require.False(t, addrEqual("myset", "myotherset"), "non-IP tokens compare verbatim")
a := &Rule{Family: IPv4, Source: "1.2.3.4/32", Proto: TCP, Port: 22, Action: Accept}
b := &Rule{Family: IPv4, Source: "1.2.3.4", Proto: TCP, Port: 22, Action: Accept}
require.True(t, a.EqualBase(b, true), "a /32 host and its bare form are the same rule")
}
// A Backup serializes to portable JSON and decodes back identically, including
// enum fields, pointers, port lists, limits and comments.
func TestBackupJSONRoundTrip(t *testing.T) {
icmpType := uint8(8)
original := &Backup{
Rules: []*Rule{
{
Direction: DirOutput, Priority: 5, Family: IPv4,
Source: "10.0.0.0/8", Destination: "!192.168.1.5",
Port: 443, Ports: []PortRange{{Start: 8000, End: 8100}, {Start: 9000, End: 9000}},
SourcePort: 53,
Proto: TCP, State: StateNew | StateEstablished,
InInterface: "eth0", OutInterface: "eth1",
Action: Accept, Log: true, LogPrefix: "https",
RateLimit: &RateLimit{Rate: 20, Unit: PerSecond, Burst: 10},
ConnLimit: &ConnLimit{Count: 100, PerSource: true},
Comment: "ingress",
},
{Family: IPv6, Proto: ICMPv6, ICMPType: &icmpType, Action: Drop},
{Family: FamilyAny, Proto: GRE, Action: Reject},
},
NATRules: []*NATRule{
{Kind: DNAT, Family: IPv4, Proto: TCP, Port: 8080, ToAddress: "10.0.0.5", ToPort: 80, Interface: "eth0"},
{Kind: Masquerade, Family: IPv4, Interface: "eth1"},
{Kind: Redirect, Family: IPv4, Proto: UDP, Port: 5353, ToPort: 5353},
},
// A direction left ActionInvalid (Forward here) must survive the round-trip
// as "invalid" so SetDefaultPolicy leaves it unchanged on restore.
DefaultPolicy: &DefaultPolicy{Input: Drop, Output: Accept, Forward: ActionInvalid},
AddressSets: []*AddressSet{
{Name: "blocklist", Family: IPv4, Type: SetHashNet, Entries: []string{"192.0.2.0/24", "198.51.100.0/24"}},
{Name: "allow6", Family: IPv6, Type: SetHashIP, Entries: []string{"2001:db8::1"}},
},
}
var buf bytes.Buffer
require.NoError(t, WriteBackup(&buf, original))
// The encoding carries the stable names, not bare numbers.
require.Contains(t, buf.String(), `"accept"`)
require.Contains(t, buf.String(), `"ipv4"`)
require.Contains(t, buf.String(), `"dnat"`)
require.Contains(t, buf.String(), `"hash:net"`) // set type as a stable name
require.Contains(t, buf.String(), `"invalid"`) // ActionInvalid policy direction
require.NotContains(t, buf.String(), `"Family":1`) // no numeric family encoding
got, err := ReadBackup(&buf)
require.NoError(t, err)
require.Len(t, got.Rules, len(original.Rules))
require.Len(t, got.NATRules, len(original.NATRules))
for i := range original.Rules {
require.True(t, reflect.DeepEqual(original.Rules[i], got.Rules[i]),
"rule %d: want %+v got %+v", i, original.Rules[i], got.Rules[i])
}
for i := range original.NATRules {
require.True(t, reflect.DeepEqual(original.NATRules[i], got.NATRules[i]),
"nat rule %d: want %+v got %+v", i, original.NATRules[i], got.NATRules[i])
}
require.True(t, reflect.DeepEqual(original.DefaultPolicy, got.DefaultPolicy),
"default policy: want %+v got %+v", original.DefaultPolicy, got.DefaultPolicy)
require.True(t, reflect.DeepEqual(original.AddressSets, got.AddressSets),
"address sets: want %+v got %+v", original.AddressSets, got.AddressSets)
}
// isSetRef must treat the "any" wildcard as an address, not a named set, so a
// backend never emits set-match syntax for the match-all token.
func TestIsSetRefTreatsAnyAsWildcard(t *testing.T) {
for _, a := range []string{"any", "!any", " any ", ""} {
require.False(t, isSetRef(a), "isSetRef(%q) must be false", a)
}
for _, a := range []string{"1.2.3.4", "10.0.0.0/8", "!192.168.1.1"} {
require.False(t, isSetRef(a), "isSetRef(%q) is an address", a)
}
for _, a := range []string{"myset", "!blocklist"} {
require.True(t, isSetRef(a), "isSetRef(%q) is a set reference", a)
}
}
// A port set whose ranges are contiguous or overlapping must compare equal to
// the coalesced form a backend lists back (nft merges adjacent ranges on read),
// so rule identity is coalescing-invariant and Sync does not churn.
func TestPortRangesEqualCoalesces(t *testing.T) {
cases := []struct {
name string
a, b []PortRange
want bool
}{
{"contiguous singletons and range", []PortRange{{22, 22}, {23, 23}, {24, 30}}, []PortRange{{22, 30}}, true},
{"adjacent ranges merge", []PortRange{{100, 200}, {201, 300}}, []PortRange{{100, 300}}, true},
{"single plus adjacent range", []PortRange{{80, 80}, {81, 90}}, []PortRange{{80, 90}}, true},
{"overlapping ranges", []PortRange{{100, 200}, {150, 250}}, []PortRange{{100, 250}}, true},
{"order independence", []PortRange{{443, 443}, {80, 80}}, []PortRange{{80, 80}, {443, 443}}, true},
{"discrete singletons equal to their span", []PortRange{{80, 80}, {81, 81}}, []PortRange{{80, 81}}, true},
{"non-contiguous stays distinct", []PortRange{{80, 80}, {443, 443}}, []PortRange{{80, 443}}, false},
{"gap of one is not contiguous", []PortRange{{80, 80}, {82, 82}}, []PortRange{{80, 82}}, false},
{"top-of-range does not wrap", []PortRange{{65534, 65535}}, []PortRange{{65534, 65534}, {65535, 65535}}, true},
}
for _, c := range cases {
require.Equalf(t, c.want, portRangesEqual(c.a, c.b), "portRangesEqual(%v,%v)", c.a, c.b)
require.Equalf(t, c.want, portRangesEqual(c.b, c.a), "portRangesEqual is symmetric for %v,%v", c.a, c.b)
}
}
// A rule spanning all three axes has eight cells, so a backend that can store none of
// them reports it as eight physical rows. Those rows cover it exactly, and dropping
// any one of them breaks the coverage — which is what keeps Sync from re-adding a
// rule that is already fully installed, and from calling a half-installed rule done.
func TestAllThreeAxesCoverage(t *testing.T) {
// No address, so nothing pins the family: the rule genuinely spans both.
var rows []*Rule
for _, fam := range []Family{IPv4, IPv6} {
for _, proto := range []Protocol{TCP, UDP} {
rows = append(rows,
&Rule{Family: fam, Proto: proto, Port: 53, Direction: DirInput, Action: Accept},
&Rule{Family: fam, Proto: proto, SourcePort: 53, Direction: DirOutput, Action: Accept},
)
}
}
require.Len(t, rows, 8)
want := &Rule{Family: FamilyAny, Proto: TCPUDP, Port: 53, Direction: DirAny, Action: Accept}
require.Len(t, want.cells(true), 8, "the rule spans eight concrete cells")
require.True(t, want.CoveredBy(rows), "the eight rows cover the rule")
for i := range rows {
missing := append(append([]*Rule{}, rows[:i]...), rows[i+1:]...)
require.False(t, want.CoveredBy(missing), "dropping row %d must break coverage", i)
}
// An address pins the family, so the same rule then spans only four cells: the
// direction swap moves it to the destination, and both halves stay IPv4.
addressed := &Rule{Proto: TCPUDP, Source: "192.0.2.1", Port: 53, Direction: DirAny, Action: Accept}
require.Len(t, addressed.cells(true), 4, "an IPv4 source pins the family axis")
}
// The port axis is a merged axis like family/transport/direction: a list rule
// spans one cell per spec, a stored set of single-port rows covers it, and a
// range covers its interior. This keeps Sync from re-adding a list rule whose
// ports are already installed as single-port rows on a backend that fans lists
// out (firewalld, pf), and from keeping a wider row the desired set narrowed.
func TestPortAxisCoverage(t *testing.T) {
list := &Rule{Family: IPv4, Proto: TCP, Ports: []PortRange{{Start: 80, End: 80}, {Start: 443, End: 443}}, Action: Accept}
rows := []*Rule{
{Family: IPv4, Proto: TCP, Port: 80, Action: Accept},
{Family: IPv4, Proto: TCP, Port: 443, Action: Accept},
}
require.Len(t, list.cells(true), 2, "a two-port list spans two cells")
require.True(t, list.CoveredBy(rows), "single-port rows cover the list rule")
require.True(t, list.Covers(rows[0]), "the list covers each single-port row")
require.False(t, rows[0].Covers(list), "coverage is asymmetric: a single port cannot cover the list")
require.True(t, rows[0].CoveredBy([]*Rule{list}), "a single-port row is covered by the list")
require.False(t, list.CoveredBy(rows[:1]), "dropping one port breaks coverage")
// A range covers the ports inside it; a port never covers the range.
rng := &Rule{Proto: TCP, Ports: []PortRange{{Start: 1000, End: 2000}}, Action: Accept}
require.True(t, rng.Covers(&Rule{Proto: TCP, Port: 1500, Action: Accept}))
require.False(t, (&Rule{Proto: TCP, Port: 1500, Action: Accept}).Covers(rng))
// A discrete set covers only the ports it names, never the span between them.
require.False(t, list.Covers(&Rule{Proto: TCP, Ports: []PortRange{{Start: 80, End: 443}}, Action: Accept}))
// The cross product with the other axes: a TCPUDP two-port list spans four
// cells (two transports times two ports, family pinned), and every one must
// be covered.
both := &Rule{Family: IPv4, Proto: TCPUDP, Ports: []PortRange{{Start: 80, End: 80}, {Start: 443, End: 443}}, Action: Accept}
require.Len(t, both.cells(true), 4)
require.True(t, both.CoveredBy([]*Rule{
{Proto: TCP, Port: 80, Action: Accept},
{Proto: TCP, Port: 443, Action: Accept},
{Proto: UDP, Ports: []PortRange{{Start: 80, End: 80}, {Start: 443, End: 443}}, Action: Accept},
}), "a merged row covers the udp cells while single-port rows cover tcp")
// Source ports expand independently of destination ports.
src := &Rule{Family: IPv4, Proto: TCP, SourcePorts: []PortRange{{Start: 1024, End: 1024}, {Start: 2048, End: 2048}}, Action: Accept}
require.Len(t, src.cells(true), 2)
require.True(t, src.CoveredBy([]*Rule{
{Proto: TCP, SourcePort: 1024, Action: Accept},
{Proto: TCP, SourcePort: 2048, Action: Accept},
}))
}
// TestRuleCovers pins the exported coverage relation: a merged rule contains its
// concrete halves on every axis, never the reverse, and ProtocolAny is not a merged
// value.
func TestRuleCovers(t *testing.T) {
merged := &Rule{Family: FamilyAny, Proto: TCPUDP, Direction: DirAny, Port: 53, Action: Accept}
cell := &Rule{Family: IPv4, Proto: TCP, Direction: DirInput, Port: 53, Action: Accept}
require.True(t, merged.Covers(cell), "a rule merged on every axis covers each of its cells")
require.False(t, cell.Covers(merged), "coverage is asymmetric: a concrete rule cannot cover a merged one")
require.True(t, cell.Covers(cell), "a rule covers itself")
// Each axis independently.
require.True(t, (&Rule{Family: FamilyAny, Proto: TCP, Port: 53, Action: Accept}).
Covers(&Rule{Family: IPv6, Proto: TCP, Port: 53, Action: Accept}))
require.True(t, (&Rule{Proto: TCPUDP, Port: 53, Action: Accept}).
Covers(&Rule{Proto: UDP, Port: 53, Action: Accept}))
require.True(t, (&Rule{Proto: TCP, Direction: DirAny, Port: 53, Action: Accept}).
Covers(&Rule{Proto: TCP, Direction: DirOutput, SourcePort: 53, Action: Accept}),
"a DirAny rule covers its role-swapped output half")
// Siblings never cover each other.
require.False(t, (&Rule{Family: IPv4, Proto: TCP, Port: 53, Action: Accept}).
Covers(&Rule{Family: IPv6, Proto: TCP, Port: 53, Action: Accept}))
require.False(t, (&Rule{Proto: TCP, Port: 53, Action: Accept}).
Covers(&Rule{Proto: UDP, Port: 53, Action: Accept}))
// ProtocolAny matches every IP protocol; it is not the merged tcp/udp value and
// so covers neither transport.
require.False(t, (&Rule{Proto: ProtocolAny, Action: Accept}).Covers(&Rule{Proto: TCP, Action: Accept}))
require.True(t, (&Rule{Proto: ProtocolAny, Action: Accept}).Covers(&Rule{Proto: ProtocolAny, Action: Accept}))
// An ordinary field must still match exactly.
require.False(t, merged.Covers(&Rule{Family: IPv4, Proto: TCP, Direction: DirInput, Port: 53, Action: Drop}),
"a different action is a different rule")
require.False(t, merged.Covers(&Rule{Family: IPv4, Proto: TCP, Direction: DirInput, Port: 54, Action: Accept}))
}
// TestNATCoveredBy mirrors Rule.CoveredBy over the axes NAT merges on: family,
// and the match ports. A Redirect
// carries no translation address, so its family is genuinely FamilyAny — a DNAT's
// ToAddress would pin the family through impliedFamily.
func TestNATCoveredBy(t *testing.T) {
want := &NATRule{Kind: Redirect, Family: FamilyAny, Proto: TCP, Port: 80, ToPort: 8080}
v4 := &NATRule{Kind: Redirect, Family: IPv4, Proto: TCP, Port: 80, ToPort: 8080}
v6 := &NATRule{Kind: Redirect, Family: IPv6, Proto: TCP, Port: 80, ToPort: 8080}
require.True(t, want.Covers(v4))
require.False(t, v4.Covers(want))
require.False(t, v4.Covers(v6))
require.False(t, want.CoveredBy([]*NATRule{v4}))
require.True(t, want.CoveredBy([]*NATRule{v4, v6}))
require.True(t, v6.CoveredBy([]*NATRule{want}))
// A DNAT's translation address pins the family, so a FamilyAny DNAT to an IPv4
// target is already an IPv4 rule and one concrete rule covers it.
dnatAny := &NATRule{Kind: DNAT, Family: FamilyAny, Proto: TCP, Port: 80, ToAddress: "192.0.2.9"}
dnatV4 := &NATRule{Kind: DNAT, Family: IPv4, Proto: TCP, Port: 80, ToAddress: "192.0.2.9"}
require.True(t, dnatAny.CoveredBy([]*NATRule{dnatV4}),
"the translation address already pins this rule to IPv4")
// The match ports are the second axis: a port-list DNAT spans one cell per
// port and is covered by the single-port rows a fanned-out backend stores.
list := &NATRule{Kind: DNAT, Proto: TCP, Ports: []PortRange{{Start: 80, End: 80}, {Start: 443, End: 443}}, ToAddress: "192.0.2.9", ToPort: 8080}
p80 := &NATRule{Kind: DNAT, Proto: TCP, Port: 80, ToAddress: "192.0.2.9", ToPort: 8080}
p443 := &NATRule{Kind: DNAT, Proto: TCP, Port: 443, ToAddress: "192.0.2.9", ToPort: 8080}
require.Len(t, list.cells(), 2, "a two-port list spans two cells")
require.True(t, list.Covers(p80), "the list covers each single-port row")
require.False(t, p80.Covers(list), "a single port cannot cover the list")
require.True(t, list.CoveredBy([]*NATRule{p80, p443}))
require.False(t, list.CoveredBy([]*NATRule{p80}), "dropping one port breaks coverage")
require.True(t, p80.CoveredBy([]*NATRule{list}), "a single-port row is covered by the list")
}
// impliedFamily resolves the family a rule effectively targets from any of its
// sources: the Family field alone (a port-only rule carries no address), an
// address, or a family-pinned ICMP protocol. Backends key family fan-outs and
// the csf/apf IPv6 gates on it, so a concrete family must win over an absent
// one and a rule pinning nothing must stay FamilyAny.
func TestImpliedFamily(t *testing.T) {
v6 := []*Rule{
{Proto: ProtocolAny, Source: "2001:db8::1", Action: Accept},
{Family: IPv6, Proto: TCP, Port: 22, Source: "2001:db8::1", Action: Accept},
{Family: IPv6, Proto: TCP, Port: 8080, Action: Drop},
{Proto: ICMPv6, ICMPType: Ptr[uint8](128), Action: Accept},
{Proto: ICMPv6, ICMPType: Ptr[uint8](128), State: StateEstablished, Action: Accept},
}
for _, r := range v6 {
require.Equal(t, IPv6, r.impliedFamily(), "expected %+v to imply IPv6", *r)
}
v4 := []*Rule{
{Family: IPv4, Proto: TCP, Port: 22, Source: "192.0.2.1", Action: Accept},
{Proto: ProtocolAny, Source: "192.0.2.1", Action: Accept},
{Proto: ICMP, ICMPType: Ptr[uint8](8), Action: Accept},
}
for _, r := range v4 {
require.Equal(t, IPv4, r.impliedFamily(), "expected %+v to imply IPv4", *r)
}
anyFam := []*Rule{
{Proto: TCP, Port: 8080, Action: Drop},
{Proto: TCP, Port: 22, Action: Accept, State: StateNew},
}
for _, r := range anyFam {
require.Equal(t, FamilyAny, r.impliedFamily(), "expected %+v to imply neither family", *r)
}
}
// setRefFamilyFrom is the shared core every backend's set-family resolution
// runs through: it pins a set-referencing rule to its set's single family from
// whatever store the backend's lookup reads, and an unknown set or a
// mixed-family pair cannot produce a loadable rule, so both error.
func TestSetRefFamilyFrom(t *testing.T) {
lookup := func(name string) (Family, bool, error) {
switch name {
case "v4set":
return IPv4, true, nil
case "v6set":
return IPv6, true, nil
case "macset":
// A family-untyped set (hash:mac and friends).
return FamilyAny, true, nil
}
return FamilyAny, false, nil
}
fam, err := setRefFamilyFrom(lookup, "v4set", "")
require.NoError(t, err)
require.Equal(t, IPv4, fam)
fam, err = setRefFamilyFrom(lookup, "", "v6set")
require.NoError(t, err)
require.Equal(t, IPv6, fam)
// The "!" negation and "@" set marker are stripped before lookup.
fam, err = setRefFamilyFrom(lookup, "!@v6set", "")
require.NoError(t, err)
require.Equal(t, IPv6, fam)
// A family-untyped set matches as IPv4.
fam, err = setRefFamilyFrom(lookup, "macset", "")
require.NoError(t, err)
require.Equal(t, IPv4, fam)
// Both fields naming same-family sets agree.
fam, err = setRefFamilyFrom(lookup, "v4set", "v4set")
require.NoError(t, err)
require.Equal(t, IPv4, fam)
// Sets of different families cannot share one rule.
_, err = setRefFamilyFrom(lookup, "v4set", "v6set")
require.ErrorContains(t, err, "different families")
// An unknown set errors rather than guessing a family.
_, err = setRefFamilyFrom(lookup, "ghost", "")
require.ErrorContains(t, err, `"ghost"`)
}

1788
firewalld_linux.go Normal file

File diff suppressed because it is too large Load diff

617
firewalld_linux_test.go Normal file
View file

@ -0,0 +1,617 @@
package firewall
import (
"testing"
firewalld "github.com/grmrgecko/go-firewalld"
"github.com/stretchr/testify/require"
)
// TestFirewallDZonePortRulesSkipsUnmodeledProto verifies a zone port on an
// unmodeled protocol (dccp) is not surfaced. Such a rule would read back as
// ProtocolAny, which RemoveRule and MarshalRichRule reject, so Sync could never
// reconcile it away. Modeled protocols (tcp/udp/sctp) still surface normally.
func TestFirewallDZonePortRulesSkipsUnmodeledProto(t *testing.T) {
fw := new(FirewallD)
ports := []firewalld.Port{
{Port: "443", Protocol: "dccp"},
{Port: "22", Protocol: "tcp"},
{Port: "49152-49215", Protocol: "udp"},
}
rules := fw.zonePortRules(ports, false)
require.Len(t, rules, 2, "the dccp port must be skipped, only tcp/udp surface")
require.Equal(t, TCP, rules[0].Proto)
require.EqualValues(t, 22, rules[0].Port)
require.Equal(t, UDP, rules[1].Proto)
require.Equal(t, []PortRange{{Start: 49152, End: 49215}}, rules[1].Ports)
// Source ports bind to the source-port fields and likewise skip dccp.
src := fw.zonePortRules([]firewalld.Port{{Port: "53", Protocol: "dccp"}, {Port: "53", Protocol: "udp"}}, true)
require.Len(t, src, 1, "the dccp source port must be skipped")
require.EqualValues(t, 53, src[0].SourcePort)
require.EqualValues(t, 0, src[0].Port, "a source-port rule must not set the destination port")
}
func TestFirewallDRichRules(t *testing.T) {
fw := new(FirewallD)
// Parse a rule that is expected to parse right: an action-scoped limit
// (after accept) is the traffic limit RateLimit models.
rule, err := fw.UnmarshalRichRule(`rule family="ipv4" source address="192.168.0.0/24" port port=23 protocol=udp log audit accept limit value="1/m"`)
require.NoError(t, err)
// Re-encode the rule which should result in expected rich rule. The log and
// rate limit round-trip (audit is still not modeled and is dropped).
richRule, err := fw.MarshalRichRule(rule)
require.NoError(t, err)
require.Equal(t, `rule family="ipv4" source address="192.168.0.0/24" port port="23" protocol="udp" log level="info" accept limit value="1/m"`, richRule,
"the rich rule did not encode as expected")
// A log-scoped limit (directly after log) throttles only the logging, which
// the model cannot hold; reading it as a traffic limit would let a rewrite
// change what the rule enforces, so it must stay unmodeled.
_, err = fw.UnmarshalRichRule(`rule family="ipv4" source address="192.168.0.0/24" port port=23 protocol=udp log limit value="1/m" audit accept`)
require.Error(t, err, "a log-scoped limit must be rejected")
// Try encoding a bunch of invalid rules.
invalidRules := []string{
`rule family=ipv4 source address="192.168.0.0/24" service name=ftp reject`,
`family="ipv4" source address="192.168.0.0/24" port port=23 protocol=udp accept`,
`rule family="ipv4" source address="192.168.0.0/24" port port=23 protocol=udp`,
`rule family="ipv6" source address="1:2:3:4:6::" forward-port to-addr="1::2:3:4:7" to-port="4012" protocol="tcp" port="4011"`,
// A port on a protocol this library does not model (dccp) reads back as
// ProtocolAny, which MarshalRichRule cannot re-emit, so parsing must reject
// it rather than surface a rule Restore would choke on. The same applies to a
// port on a modeled but portless protocol (gre) and to a source-port element.
`rule family="ipv4" port port="80" protocol="dccp" accept`,
`rule family="ipv4" port port="80" protocol="gre" accept`,
`rule family="ipv4" source-port port="1024" protocol="dccp" accept`,
}
for _, richRule := range invalidRules {
_, err := fw.UnmarshalRichRule(richRule)
require.Error(t, err, "this rich rule was parsed when it should be invalid: %s", richRule)
}
// Test rules we typically set.
validRules := []string{
`rule priority="10" family="ipv6" port port="4789" protocol="udp" accept`,
`rule priority="10" family="ipv4" source address="203.0.113.10" port port="4789" protocol="tcp" accept`,
`rule priority="10" family="ipv4" destination address="203.0.113.10" port port="4791" protocol="tcp" accept`,
}
for _, richRule := range validRules {
_, err := fw.UnmarshalRichRule(richRule)
require.NoError(t, err, "this rich rule was not parsed when it should be valid: %s", richRule)
}
// A port without a concrete protocol cannot be expressed as a rich rule
// (protocol="any" is invalid), so marshalling must error rather than emit a
// rule firewalld will refuse.
_, err = fw.MarshalRichRule(&Rule{Port: 80, Proto: ProtocolAny, Action: Accept})
require.Error(t, err, "expected error marshalling a port with no protocol")
// firewalld's zone/rich-rule model has no forward chain, so a forward rule is
// rejected with the ErrUnsupportedForward sentinel.
_, err = fw.MarshalRichRule(&Rule{Direction: DirForward, Proto: TCP, Port: 80, Action: Accept})
require.ErrorIs(t, err, ErrUnsupportedForward, "a forward rule must be rejected")
}
func TestFirewallDFeatureRules(t *testing.T) {
fw := new(FirewallD)
// Confirm representative encodings.
cases := []struct {
rule *Rule
want string
}{
// A bare (untyped) ICMP/ICMPv6 protocol match needs no family qualifier:
// firewalld accepts a familyless `protocol value="ipv6-icmp"` rule just
// like any other protocol, and the protocol value string alone tells the
// read path ICMP from ICMPv6. An explicit Family is still honored verbatim.
{&Rule{Proto: ICMP, Action: Accept}, `rule protocol value="icmp" accept`},
{&Rule{Proto: ICMPv6, Action: Accept}, `rule protocol value="ipv6-icmp" accept`},
{&Rule{Family: IPv6, Proto: ICMPv6, Action: Accept}, `rule family="ipv6" protocol value="ipv6-icmp" accept`},
{&Rule{Proto: TCP, Ports: []PortRange{{Start: 1000, End: 2000}}, Action: Accept}, `rule port port="1000-2000" protocol="tcp" accept`},
}
for _, c := range cases {
got, err := fw.MarshalRichRule(c.rule)
require.NoError(t, err, "failed to marshal %+v", *c.rule)
require.Equal(t, c.want, got, "marshal %+v", *c.rule)
}
// Round-trip ICMP and port-range rules.
rules := []*Rule{
{Proto: ICMP, Action: Accept},
{Family: IPv6, Proto: ICMPv6, Action: Drop},
{Proto: TCP, Ports: []PortRange{{Start: 1000, End: 2000}}, Action: Accept},
{Family: IPv4, Source: "192.168.0.0/24", Proto: UDP, Port: 23, Action: Accept},
}
for _, r := range rules {
rich, err := fw.MarshalRichRule(r)
require.NoError(t, err, "failed to marshal %+v", *r)
parsed, err := fw.UnmarshalRichRule(rich)
require.NoError(t, err, "failed to parse %q", rich)
require.True(t, parsed.Equal(r, false),
"round-trip mismatch: input %+v, rich %q, output %+v", *r, rich, parsed)
}
// Features a rich rule cannot express are rejected. A port list is not among
// them: AddRule/RemoveRule fan it into a row per spec with expandPorts before
// the marshaller runs, so a list never reaches it.
unsupported := []*Rule{
{Proto: TCP, Port: 22, State: StateEstablished, Action: Accept},
{InInterface: "eth0", Proto: TCP, Port: 22, Action: Accept},
}
for _, r := range unsupported {
_, err := fw.MarshalRichRule(r)
require.Error(t, err, "expected error marshalling unsupported rule %+v", *r)
}
}
func TestFirewallDICMPType(t *testing.T) {
fw := new(FirewallD)
// A specific ICMP type encodes to an icmp-type element, resolved by family:
// echo-request is type 8 for IPv4 and 128 for IPv6, but the same firewalld
// name is used for both.
cases := []struct {
rule *Rule
want string
}{
{&Rule{Family: IPv4, Proto: ICMP, ICMPType: Ptr[uint8](8), Action: Accept},
`rule family="ipv4" icmp-type name="echo-request" accept`},
{&Rule{Family: IPv6, Proto: ICMPv6, ICMPType: Ptr[uint8](128), Action: Accept},
`rule family="ipv6" icmp-type name="echo-request" accept`},
{&Rule{Family: IPv6, Proto: ICMPv6, ICMPType: Ptr[uint8](136), Action: Drop},
`rule family="ipv6" icmp-type name="neighbour-advertisement" drop`},
// The ICMP protocol pins the family, so an unset Family is derived from it
// (ICMP => ipv4) rather than rejected.
{&Rule{Proto: ICMP, ICMPType: Ptr[uint8](8), Action: Accept},
`rule family="ipv4" icmp-type name="echo-request" accept`},
// ICMPv6 => ipv6.
{&Rule{Proto: ICMPv6, ICMPType: Ptr[uint8](128), Action: Accept},
`rule family="ipv6" icmp-type name="echo-request" accept`},
}
for _, c := range cases {
got, err := fw.MarshalRichRule(c.rule)
require.NoError(t, err, "failed to marshal %+v", *c.rule)
require.Equal(t, c.want, got, "marshal %+v", *c.rule)
parsed, err := fw.UnmarshalRichRule(got)
require.NoError(t, err, "failed to parse %q", got)
require.True(t, parsed.Equal(c.rule, false),
"round-trip mismatch: input %+v, rich %q, output %+v", *c.rule, got, parsed)
}
// Rules a firewalld icmp-type element cannot express are rejected.
unsupported := []*Rule{
// A type with no firewalld name in that family cannot be expressed.
{Family: IPv4, Proto: ICMP, ICMPType: Ptr[uint8](200), Action: Accept},
// echo-request is 128 in IPv6; the IPv4 number 8 has no IPv6 name.
{Family: IPv6, Proto: ICMPv6, ICMPType: Ptr[uint8](8), Action: Accept},
// An ICMP type only applies to an ICMP/ICMPv6 protocol.
{Family: IPv4, Proto: TCP, Port: 80, ICMPType: Ptr[uint8](8), Action: Accept},
}
for _, r := range unsupported {
_, err := fw.MarshalRichRule(r)
require.Error(t, err, "expected error marshalling %+v", *r)
}
}
// firewalld's destination grammar accepts an ipset (like the source), so a rule
// matching a destination ipset must marshal to `destination ipset="..."` and
// round-trip, rather than being rejected or read back as invisible. Unlike a
// source ipset, firewalld rejects a familyless destination ipset with
// MISSING_FAMILY, so the caller must supply a concrete Family.
func TestFirewallDDestinationIPSet(t *testing.T) {
fw := new(FirewallD)
rule := &Rule{Family: IPv4, Destination: "myset", Proto: TCP, Port: 443, Action: Accept}
got, err := fw.MarshalRichRule(rule)
require.NoError(t, err)
require.Contains(t, got, `destination ipset="myset"`, "unexpected marshal: %q", got)
parsed, err := fw.UnmarshalRichRule(got)
require.NoError(t, err)
require.Equal(t, "myset", parsed.Destination)
require.False(t, parsed.IsOutput(),
"a destination element is an inbound-frame match, not a direction signal")
// A negated destination ipset round-trips too.
neg := &Rule{Family: IPv4, Destination: "!badset", Proto: TCP, Port: 443, Action: Drop}
got, err = fw.MarshalRichRule(neg)
require.NoError(t, err)
require.Contains(t, got, `destination NOT ipset="badset"`, "unexpected marshal: %q", got)
parsed, err = fw.UnmarshalRichRule(got)
require.NoError(t, err)
require.Equal(t, "!badset", parsed.Destination)
}
// A destination ipset with no explicit Family is rejected rather than marshaled
// into a rule firewalld refuses at apply time. A source ipset, by contrast,
// needs no family.
func TestFirewallDDestinationIPSetRequiresFamily(t *testing.T) {
fw := new(FirewallD)
_, err := fw.MarshalRichRule(&Rule{Destination: "myset", Proto: TCP, Port: 443, Action: Accept})
require.Error(t, err, "a familyless destination ipset must be rejected")
got, err := fw.MarshalRichRule(&Rule{Source: "myset", Proto: TCP, Port: 443, Action: Accept})
require.NoError(t, err, "a familyless source ipset is valid and must not be rejected")
require.Contains(t, got, `source ipset="myset"`, "unexpected marshal: %q", got)
require.NotContains(t, got, "family=", "a familyless source ipset must not gain a family attribute")
}
func TestFirewallDSourcePort(t *testing.T) {
fw := new(FirewallD)
// Source-port matches encode to the source-port rich-rule element.
cases := []struct {
rule *Rule
want string
}{
{&Rule{Proto: TCP, SourcePort: 1024, Action: Accept},
`rule source-port port="1024" protocol="tcp" accept`},
{&Rule{Family: IPv4, Proto: TCP, SourcePort: 1024, Action: Accept},
`rule family="ipv4" source-port port="1024" protocol="tcp" accept`},
{&Rule{Proto: UDP, SourcePorts: []PortRange{{Start: 1000, End: 2000}}, Action: Accept},
`rule source-port port="1000-2000" protocol="udp" accept`},
}
for _, c := range cases {
got, err := fw.MarshalRichRule(c.rule)
require.NoError(t, err, "failed to marshal %+v", *c.rule)
require.Equal(t, c.want, got, "marshal %+v", *c.rule)
parsed, err := fw.UnmarshalRichRule(got)
require.NoError(t, err, "failed to parse %q", got)
require.True(t, parsed.Equal(c.rule, false),
"round-trip mismatch: input %+v, rich %q, output %+v", *c.rule, got, parsed)
}
// A rich rule carries a single port element, so these cannot be expressed. A
// source-port list is not tested here: AddRule/RemoveRule fan it into a row per
// spec with expandPorts before the marshaller runs, so a list never reaches it.
unsupported := []*Rule{
// A destination port and a source port cannot coexist in one rule.
{Proto: TCP, Port: 80, SourcePort: 1024, Action: Accept},
// A source port needs a concrete tcp/udp protocol.
{SourcePort: 1024, Action: Accept},
}
for _, r := range unsupported {
_, err := fw.MarshalRichRule(r)
require.Error(t, err, "expected error marshalling %+v", *r)
}
// The destination+source port rejection reports the source-port sentinel.
_, err := fw.MarshalRichRule(&Rule{Proto: TCP, Port: 80, SourcePort: 1024, Action: Accept})
require.ErrorIs(t, err, ErrUnsupportedSourcePort)
}
// A concrete-family bare-port accept is stored as a rich rule (zoneEntryEligible
// requires FamilyAny), so a family-agnostic port opened per family becomes two rich
// rules. Removing it with a FamilyAny target must locate both against the stored rich
// rules with EqualForRemoval — the family-strict Equal matches neither and leaves
// the port open. The firewalld family-agnostic remove must not no-op.
func TestFirewallDFamilyAnyRichRuleRemovable(t *testing.T) {
fw := new(FirewallD)
v4, err := fw.UnmarshalRichRule(`rule family="ipv4" port port="3492" protocol="tcp" accept`)
require.NoError(t, err)
v6, err := fw.UnmarshalRichRule(`rule family="ipv6" port port="3492" protocol="tcp" accept`)
require.NoError(t, err)
// The two stored rich rules cover the family-agnostic rule between them.
target := &Rule{Family: FamilyAny, Proto: TCP, Port: 3492, Action: Accept}
require.True(t, target.CoveredBy([]*Rule{v4, v6}))
// The family-strict matcher finds neither stored rich rule.
require.False(t, target.Equal(v4, false))
require.False(t, target.Equal(v6, false))
// EqualForRemoval finds both, so RemoveRule clears every rich rule the target
// covers.
require.True(t, v4.EqualForRemoval(target, false))
require.True(t, v6.EqualForRemoval(target, false))
}
// A merged TCPUDP rule has no single rich-rule form (a rich rule port element
// carries one protocol), so AddRule and RemoveRule fan it into a tcp row and a
// udp row with expandProtocols before the marshaller runs.
func TestFirewallDMarshalRejectsMergedProtocol(t *testing.T) {
fw := new(FirewallD)
// expandProtocols yields a tcp row and a udp row, each of which marshals cleanly.
subs := expandProtocols(&Rule{Port: 80, Proto: TCPUDP, Action: Accept})
require.Len(t, subs, 2, "a TCPUDP rule fans into a tcp row and a udp row")
require.Equal(t, TCP, subs[0].Proto)
require.Equal(t, UDP, subs[1].Proto)
for _, sub := range subs {
rich, merr := fw.MarshalRichRule(sub)
require.NoError(t, merr, "an expanded transport row must marshal: %+v", *sub)
require.Contains(t, rich, `protocol="`+sub.Proto.String()+`"`)
}
}
// A rich rule's port element carries one protocol, so a port opened for both tcp and
// udp in the same zone is two rich rules. Each reads back as its own rule (both
// family-agnostic, since neither names a family), and together they cover the TCPUDP
// rule that was written. A TCPUDP target reaches both on removal.
func TestFirewallDTCPUDPRichRulePair(t *testing.T) {
fw := new(FirewallD)
tcp, err := fw.UnmarshalRichRule(`rule port port="3492" protocol="tcp" accept`)
require.NoError(t, err)
udp, err := fw.UnmarshalRichRule(`rule port port="3492" protocol="udp" accept`)
require.NoError(t, err)
require.Equal(t, FamilyAny, tcp.impliedFamily(), "a familyless rich rule covers both families")
target := &Rule{Proto: TCPUDP, Port: 3492, Action: Accept}
require.True(t, target.CoveredBy([]*Rule{tcp, udp}), "the tcp/udp rich-rule pair covers the TCPUDP rule")
require.False(t, target.CoveredBy([]*Rule{tcp}), "the tcp rich rule alone leaves udp uncovered")
// The TCPUDP target reaches each concrete stored row on removal.
require.True(t, tcp.EqualForRemoval(target, false))
require.True(t, udp.EqualForRemoval(target, false))
}
// A rich rule cannot express a specific rate-limit burst, but the netfilter default
// burst (5) is treated as "unset" everywhere else in the library and reads back as 0
// from nft/iptables. MarshalRichRule must therefore accept a Burst=5 rule (rendering
// the bare rate) instead of rejecting it — otherwise a desired set that is portable
// across nft/iptables/firewalld aborts Sync on firewalld alone. The raw-burst guard
// must honor normBurst.
func TestFirewallDRateLimitDefaultBurstAccepted(t *testing.T) {
fw := new(FirewallD)
// Burst=5 (the netfilter default) must marshal to the bare rate and round-trip.
def := &Rule{Proto: TCP, Port: 22, Action: Accept,
RateLimit: &RateLimit{Rate: 10, Unit: PerMinute, Burst: netfilterDefaultBurst}}
got, err := fw.MarshalRichRule(def)
require.NoError(t, err, "a default-burst rate limit must be accepted")
require.Contains(t, got, `limit value="10/m"`)
// It round-trips: the read-back rule (Burst 0) still equals the desired rule, so
// Sync does not churn.
parsed, err := fw.UnmarshalRichRule(got)
require.NoError(t, err)
require.True(t, parsed.Equal(def, false),
"a default-burst rule must equal its read-back so Sync is stable")
// A genuinely non-default burst is still unexpressible and rejected.
_, err = fw.MarshalRichRule(&Rule{Proto: TCP, Port: 22, Action: Accept,
RateLimit: &RateLimit{Rate: 10, Unit: PerMinute, Burst: 20}})
require.ErrorIs(t, err, ErrUnsupportedRateLimit)
}
// A FamilyAny rule that matches a concrete IP address must be marshaled with a
// family attribute: firewalld requires one whenever a rich rule uses an address
// (it rejects a familyless address rule) and stores the rule under that family.
// The rule must then still reconcile against firewalld's family-normalized
// read-back under the family-sensitive Equal that Sync/RemoveRule use.
func TestFirewallDFamilyAnyAddressGetsFamily(t *testing.T) {
fw := new(FirewallD)
orig := &Rule{Source: "10.0.0.1", Proto: TCP, Port: 22, Action: Accept}
rich, err := fw.MarshalRichRule(orig)
require.NoError(t, err)
require.Contains(t, rich, `family="ipv4"`, "a rich rule with an IP address must declare its family")
// firewalld lists the rule back with the family it stored it under.
canon := `rule family="ipv4" source address="10.0.0.1" port port="22" protocol="tcp" accept`
got, err := fw.UnmarshalRichRule(canon)
require.NoError(t, err)
require.True(t, orig.Equal(got, false),
"a FamilyAny address rule must reconcile with its family-normalized read-back")
// A bare (addressless) rule stays unqualified — firewalld applies it to both
// families and needs no family attribute.
bare, err := fw.MarshalRichRule(&Rule{Proto: TCP, Port: 22, Action: Accept})
require.NoError(t, err)
require.NotContains(t, bare, "family=", "an addressless rule must not be pinned to a family")
}
// A log prefix (or address) containing a space must survive the rich-rule parse,
// which needs a quote-aware tokenizer.
func TestFirewallDLogPrefixWithSpaces(t *testing.T) {
fw := new(FirewallD)
orig := &Rule{Family: IPv4, Proto: TCP, Port: 22, Action: Accept, Log: true, LogPrefix: "drop ssh"}
rich, err := fw.MarshalRichRule(orig)
require.NoError(t, err)
got, err := fw.UnmarshalRichRule(rich)
require.NoError(t, err)
require.True(t, got.Log)
require.Equal(t, "drop ssh", got.LogPrefix)
}
// lone range must take the zone-entry (AddPort/RemovePort) path — only a genuine
// multi-element list forces the rich-rule path. Gating on HasPortSet (true for a
// single range) left a foreign zone-port range unremovable, so Sync could never
// converge on it.
func TestFirewalldZoneEntryEligibleRange(t *testing.T) {
fw := new(FirewallD)
eligible := []struct {
name string
rule *Rule
}{
{"single port", &Rule{Proto: TCP, Port: 22, Action: Accept}},
{"single port in slice", &Rule{Proto: TCP, Ports: []PortRange{{Start: 22, End: 22}}, Action: Accept}},
{"single contiguous range", &Rule{Proto: TCP, Ports: []PortRange{{Start: 1000, End: 2000}}, Action: Accept}},
{"single source-port range", &Rule{Proto: UDP, SourcePorts: []PortRange{{Start: 1000, End: 2000}}, Action: Accept}},
{"bare source", &Rule{Source: "10.0.0.0/24", Action: Accept}},
}
for _, c := range eligible {
require.Truef(t, fw.zoneEntryEligible(c.rule), "%s must use the zone-entry path", c.name)
}
ineligible := []struct {
name string
rule *Rule
}{
{"multi-port list", &Rule{Proto: TCP, Ports: []PortRange{{Start: 80, End: 80}, {Start: 443, End: 443}}, Action: Accept}},
{"port list with a range", &Rule{Proto: TCP, Ports: []PortRange{{Start: 80, End: 80}, {Start: 1000, End: 2000}}, Action: Accept}},
{"multi source-port list", &Rule{Proto: TCP, SourcePorts: []PortRange{{Start: 80, End: 80}, {Start: 90, End: 90}}, Action: Accept}},
{"drop action", &Rule{Proto: TCP, Port: 22, Action: Drop}},
{"logging", &Rule{Proto: TCP, Port: 22, Action: Accept, Log: true}},
{"rate limit", &Rule{Proto: TCP, Port: 22, Action: Accept, RateLimit: &RateLimit{Rate: 1, Unit: PerSecond}}},
{"concrete family", &Rule{Family: IPv4, Proto: TCP, Port: 22, Action: Accept}},
{"icmp", &Rule{Proto: ICMP, Action: Accept}},
}
for _, c := range ineligible {
require.Falsef(t, fw.zoneEntryEligible(c.rule), "%s must use the rich-rule path", c.name)
}
}
// TestFirewallDBareICMPFamilyPinned guards that a bare (untyped) ICMP/ICMPv6 rule
// is left unqualified by family and still round-trips correctly. firewalld only
// requires a family for a source/destination address, never for a bare protocol
// match, and the protocol value string alone ("icmp" vs "ipv6-icmp") tells the
// read path ICMP from ICMPv6.
func TestFirewallDBareICMPFamilyPinned(t *testing.T) {
fw := new(FirewallD)
v6, err := fw.MarshalRichRule(&Rule{Proto: ICMPv6, Action: Accept})
require.NoError(t, err)
require.NotContains(t, v6, `family=`, "a bare icmpv6 rule needs no family qualifier")
require.Contains(t, v6, `value="ipv6-icmp"`)
v4, err := fw.MarshalRichRule(&Rule{Proto: ICMP, Action: Accept})
require.NoError(t, err)
require.NotContains(t, v4, `family=`, "a bare icmp rule needs no family qualifier")
// The rich rule must round-trip back to an equal rule.
parsed, err := fw.UnmarshalRichRule(v6)
require.NoError(t, err)
require.True(t, parsed.Equal(&Rule{Proto: ICMPv6, Action: Accept}, false),
"the familyless icmpv6 rich rule should round-trip to the original rule")
// An explicit Family is still honored verbatim.
explicit, err := fw.MarshalRichRule(&Rule{Family: IPv6, Proto: ICMPv6, Action: Accept})
require.NoError(t, err)
require.Contains(t, explicit, `family="ipv6"`, "an explicit Family must still be emitted")
// A *typed* ICMP match still needs the family qualifier: firewalld resolves an
// icmp-type name without a protocol element, so this library's own read path
// depends on the family to disambiguate an ICMPv4 name from the identically
// named ICMPv6 one.
typed, err := fw.MarshalRichRule(&Rule{Proto: ICMPv6, ICMPType: Ptr[uint8](128), Action: Accept})
require.NoError(t, err)
require.Contains(t, typed, `family="ipv6"`, "a typed icmpv6 rule must still pin family=ipv6")
}
// TestFirewallDSourceProtoNotZoneSource guards that a source combined with a
// concrete protocol is NOT routed to a bare zone source (which would drop the
// protocol and widen the rule to every protocol). Such a rule must take the
// rich-rule path, which preserves the protocol match.
func TestFirewallDSourceProtoNotZoneSource(t *testing.T) {
fw := new(FirewallD)
// A plain source with no protocol is a zone source.
require.True(t, fw.sourceZoneShape(&Rule{Source: "1.2.3.4", Action: Accept}),
"a bare source with no protocol should map to a zone source")
// A source with a concrete protocol must not: it is a rich rule.
require.False(t, fw.sourceZoneShape(&Rule{Source: "1.2.3.4", Proto: TCP, Action: Accept}),
"a source+protocol rule must not be encoded as a bare zone source")
// A negated source is a rich rule too.
require.False(t, fw.sourceZoneShape(&Rule{Source: "!1.2.3.4", Action: Accept}))
// The rich rule that AddRule now falls through to keeps the protocol match.
rich, err := fw.MarshalRichRule(&Rule{Source: "1.2.3.4", Proto: TCP, Action: Accept})
require.NoError(t, err)
require.Contains(t, rich, `source address="1.2.3.4"`)
require.Contains(t, rich, `protocol value="tcp"`, "the protocol match must survive on the rich-rule path")
}
// firewalld expresses the portless protocols as a bare protocol element and
// SCTP as a port protocol; both round-trip through a rich rule.
func TestFirewallDProtocolExtras(t *testing.T) {
fw := new(FirewallD)
cases := []*Rule{
{Proto: GRE, Action: Accept},
{Proto: ESP, Action: Accept},
{Family: IPv4, Source: "192.168.0.0/24", Proto: SCTP, Port: 9000, Action: Accept},
}
for _, orig := range cases {
rich, err := fw.MarshalRichRule(orig)
require.NoError(t, err, "%+v", orig)
got, err := fw.UnmarshalRichRule(rich)
require.NoError(t, err, "rich %q", rich)
require.True(t, got.EqualBase(orig, true), "rich %q: want %+v got %+v", rich, orig, got)
}
}
// forwardPort builds a firewalld ForwardPort from a NAT rule and rejects the
// shapes firewalld's forward-port model cannot express.
func TestFWForwardPort(t *testing.T) {
fw := new(FirewallD)
// DNAT to another host: ToAddr is set.
fp, err := fw.forwardPort(&NATRule{Kind: DNAT, Proto: TCP, Port: 80, ToAddress: "10.0.0.5", ToPort: 8080})
require.NoError(t, err)
require.Equal(t, firewalld.ForwardPort{Port: "80", Protocol: "tcp", ToAddr: "10.0.0.5", ToPort: "8080"}, fp)
// Redirect to a local port: ToAddr is empty.
fp, err = fw.forwardPort(&NATRule{Kind: Redirect, Proto: UDP, Port: 53, ToPort: 5353})
require.NoError(t, err)
require.Equal(t, firewalld.ForwardPort{Port: "53", Protocol: "udp", ToAddr: "", ToPort: "5353"}, fp)
// A single contiguous range is allowed. A port list never reaches forwardPort:
// AddNATRule/RemoveNATRule fan it into one forward-port per spec beforehand.
fp, err = fw.forwardPort(&NATRule{Kind: DNAT, Proto: TCP, Ports: []PortRange{{Start: 1000, End: 2000}}, ToAddress: "10.0.0.5", ToPort: 3000})
require.NoError(t, err)
require.Equal(t, "1000-2000", fp.Port)
bad := []struct {
name string
rule *NATRule
}{
{"non tcp/udp", &NATRule{Kind: DNAT, Proto: ICMP, Port: 80, ToAddress: "10.0.0.5"}},
{"no port", &NATRule{Kind: DNAT, Proto: TCP, ToAddress: "10.0.0.5"}},
{"interface", &NATRule{Kind: DNAT, Proto: TCP, Port: 80, Interface: "eth0", ToAddress: "10.0.0.5", ToPort: 8080}},
{"source match", &NATRule{Kind: DNAT, Proto: TCP, Port: 80, Source: "1.2.3.4", ToAddress: "10.0.0.5", ToPort: 8080}},
}
for _, c := range bad {
_, err := fw.forwardPort(c.rule)
require.Errorf(t, err, "forwardPort should reject %s", c.name)
}
}
// firewalld's ICMPv6 icmp-type table was missing destination-unreachable (type
// 1), which real firewalld defines for both ipv4 and ipv6. A rich rule using that
// name on IPv6 failed to resolve on read and was silently dropped from
// GetRules/Backup (and unmarshalling failed). It must round-trip.
func TestFirewalldICMPv6DestinationUnreachable(t *testing.T) {
fw := new(FirewallD)
n, ok := fw.icmpTypeNumber(true, "destination-unreachable")
require.True(t, ok, "destination-unreachable must be a known ICMPv6 type")
require.Equal(t, uint8(1), n, "ICMPv6 destination-unreachable is type 1")
r := &Rule{Family: IPv6, Proto: ICMPv6, ICMPType: Ptr[uint8](1), Action: Reject}
rr, err := fw.MarshalRichRule(r)
require.NoError(t, err)
require.Contains(t, rr, "destination-unreachable", "rich rule should carry the type name")
back, err := fw.UnmarshalRichRule(rr)
require.NoError(t, err, "an icmpv6 destination-unreachable rich rule must parse")
require.True(t, r.Equal(back, false), "icmpv6 destination-unreachable must round-trip: got %+v", back)
}
// modeledForwardPort must reject the forward-port shapes the write side
// (forwardPort) cannot re-create — otherwise GetNATRules would surface entries
// that can never be removed or restored, and Restore would destroy them.
func TestFirewallDModeledForwardPort(t *testing.T) {
fw := new(FirewallD)
dnat := fw.modeledForwardPort(firewalld.ForwardPort{Port: "8080", Protocol: "tcp", ToPort: "80", ToAddr: "10.0.0.5"})
require.NotNil(t, dnat)
require.Equal(t, DNAT, dnat.Kind)
require.EqualValues(t, 8080, dnat.Port)
require.EqualValues(t, 80, dnat.ToPort)
redirect := fw.modeledForwardPort(firewalld.ForwardPort{Port: "8443", Protocol: "udp", ToPort: "443"})
require.NotNil(t, redirect)
require.Equal(t, Redirect, redirect.Kind)
// Shapes forwardPort rejects stay unmodeled: a non-tcp/udp protocol and a
// target-port range.
require.Nil(t, fw.modeledForwardPort(firewalld.ForwardPort{Port: "9999", Protocol: "sctp", ToPort: "80", ToAddr: "10.0.0.5"}))
require.Nil(t, fw.modeledForwardPort(firewalld.ForwardPort{Port: "9999", Protocol: "dccp"}))
require.Nil(t, fw.modeledForwardPort(firewalld.ForwardPort{Port: "8080", Protocol: "tcp", ToPort: "8080-8090", ToAddr: "10.0.0.5"}))
}

32
go.mod Normal file
View file

@ -0,0 +1,32 @@
module github.com/grmrgecko/go-firewall
go 1.26.4
require (
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be
github.com/coreos/go-systemd/v22 v22.5.0
github.com/google/nftables v0.3.0
github.com/grmrgecko/go-firewalld v0.0.0-20260702144632-5eb6ba8201bb
github.com/iamacarpet/go-win64api v0.0.0-20240507095429-873e84e85847
github.com/stretchr/testify v1.11.1
github.com/vishvananda/netlink v1.3.1
go4.org/netipx v0.0.0-20220725152314-7e7bdc8411bf
golang.org/x/sys v0.40.0
)
require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/go-ole/go-ole v1.2.6 // indirect
github.com/godbus/dbus/v5 v5.2.2 // indirect
github.com/google/cabbie v1.0.2 // indirect
github.com/google/glazier v0.0.0-20211029225403-9f766cca891d // indirect
github.com/google/go-cmp v0.6.0 // indirect
github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 // indirect
github.com/mdlayher/socket v0.5.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/scjalliance/comshim v0.0.0-20190308082608-cf06d2532c4e // indirect
github.com/vishvananda/netns v0.0.5 // indirect
golang.org/x/net v0.33.0 // indirect
golang.org/x/sync v0.6.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

138
go.sum Normal file
View file

@ -0,0 +1,138 @@
bitbucket.org/creachadair/stringset v0.0.9/go.mod h1:t+4WcQ4+PXTa8aQdNKe40ZP6iwesoMFWAxPGd3UGjyY=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/StackExchange/wmi v1.2.0/go.mod h1:3eOhrUMpNV+6aFIbp5/iudMxNCF27Vw2OZgy4xEx0Fg=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
github.com/capnspacehook/taskmaster v0.0.0-20210519235353-1629df7c85e9/go.mod h1:257CYs3Wd/CTlLQ3c72jKv+fFE2MV3WPNnV5jiroYUU=
github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/creachadair/staticfile v0.1.3/go.mod h1:a3qySzCIXEprDGxk6tSxSI+dBBdLzqeBOMhZ+o2d3pM=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
github.com/go-ole/go-ole v1.2.4/go.mod h1:XCwSNxSkXRo4vlyPy93sltvi/qJq0jqQhjqQNIwKuxM=
github.com/go-ole/go-ole v1.2.5/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/godbus/dbus v4.1.0+incompatible/go.mod h1:/YcGZj5zSblfDWMMoOzV4fas9FZnQYTkDnsGvmh2Grw=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
github.com/golang/glog v0.0.0-20210429001901-424d2337a529/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/google/aukera v0.0.0-20201117230544-d145c8357fea/go.mod h1:oXqTZORBzdwQ6L32YjJmaPajqIV/hoGEouwpFMf4cJE=
github.com/google/cabbie v1.0.2 h1:UtB+Nn6fPB43wGg5xs4tgU+P3hTZ6KsulgtaHtqZZfs=
github.com/google/cabbie v1.0.2/go.mod h1:6MmHaUrgfabehCHAIaxdrbmvHSxUVXj3Abs08FMABSo=
github.com/google/glazier v0.0.0-20210617205946-bf91b619f5d4/go.mod h1:g7oyIhindbeebnBh0hbFua5rv6XUt/nweDwIWdvxirg=
github.com/google/glazier v0.0.0-20211029225403-9f766cca891d h1:GBIF4RkD4E9USvSRT4O4tBCT77JExIr+qnruI9nkJQo=
github.com/google/glazier v0.0.0-20211029225403-9f766cca891d/go.mod h1:h2R3DLUecGbLSyi6CcxBs5bdgtJhgK+lIffglvAcGKg=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/logger v1.1.0/go.mod h1:w7O8nrRr0xufejBlQMI83MXqRusvREoJdaAxV+CoAB4=
github.com/google/logger v1.1.1/go.mod h1:BkeJZ+1FhQ+/d087r4dzojEg1u2ZX+ZqG1jTUrLM+zQ=
github.com/google/nftables v0.3.0 h1:bkyZ0cbpVeMHXOrtlFc8ISmfVqq5gPJukoYieyVmITg=
github.com/google/nftables v0.3.0/go.mod h1:BCp9FsrbF1Fn/Yu6CLUc9GGZFw/+hsxfluNXXmxBfRM=
github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/winops v0.0.0-20210803215038-c8511b84de2b/go.mod h1:ShbX8v8clPm/3chw9zHVwtW3QhrFpL8mXOwNxClt4pg=
github.com/grmrgecko/go-firewalld v0.0.0-20260702144632-5eb6ba8201bb h1:2wDo4vmBRWk2n3W5EsEpMQ2t8Sx0diVXdZjJTlLCBzc=
github.com/grmrgecko/go-firewalld v0.0.0-20260702144632-5eb6ba8201bb/go.mod h1:PrxtlI/xoBCOT8ugAoxeuE++VGq/D7jxbz5URoeV7ow=
github.com/groob/plist v0.0.0-20210519001750-9f754062e6d6/go.mod h1:itkABA+w2cw7x5nYUS/pLRef6ludkZKOigbROmCTaFw=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/iamacarpet/go-win64api v0.0.0-20210311141720-fe38760bed28/go.mod h1:oGJx9dz0Ny7HC7U55RZ0Smd6N9p3hXP/+hOFtuYrAxM=
github.com/iamacarpet/go-win64api v0.0.0-20240507095429-873e84e85847 h1:cRHZFGwIDgQlr9abL/P93JXR7pYxzvf0xAIt0xzwrh0=
github.com/iamacarpet/go-win64api v0.0.0-20240507095429-873e84e85847/go.mod h1:B7zFQPAznj+ujXel5X+LUoK3LgY6VboCdVYHZNn7gpg=
github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 h1:A1Cq6Ysb0GM0tpKMbdCXCIfBclan4oHk1Jb+Hrejirg=
github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42/go.mod h1:BB4YCPDOzfy7FniQ/lxuYQ3dgmM2cZumHbK8RpTjN2o=
github.com/mdlayher/socket v0.5.0 h1:ilICZmJcQz70vrWVes1MFera4jGiWNocSkykwwoy3XI=
github.com/mdlayher/socket v0.5.0/go.mod h1:WkcBFfvyG8QENs5+hfQPl1X6Jpd2yeLIYgrGFmJiJxI=
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=
github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
github.com/onsi/gomega v1.10.2/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rickb777/date v1.14.2/go.mod h1:swmf05C+hN+m8/Xh7gEq3uB6QJDNc5pQBWojKdHetOs=
github.com/rickb777/plural v1.2.2/go.mod h1:xyHbelv4YvJE51gjMnHvk+U2e9zIysg6lTnSQK8XUYA=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/scjalliance/comshim v0.0.0-20190308082608-cf06d2532c4e h1:+/AzLkOdIXEPrAQtwAeWOBnPQ0BnYlBW0aCZmSb47u4=
github.com/scjalliance/comshim v0.0.0-20190308082608-cf06d2532c4e/go.mod h1:9Tc1SKnfACJb9N7cw2eyuI6xzy845G7uZONBsi5uPEA=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/vishvananda/netlink v1.3.1 h1:3AEMt62VKqz90r0tmNhog0r/PpWKmrEShJU0wJW6bV0=
github.com/vishvananda/netlink v1.3.1/go.mod h1:ARtKouGSTGchR8aMwmkzC0qiNPrrWO5JS/XMVl45+b4=
github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY=
github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM=
go4.org/intern v0.0.0-20211027215823-ae77deb06f29 h1:UXLjNohABv4S58tHmeuIZDO6e3mHpW2Dx33gaNt03LE=
go4.org/intern v0.0.0-20211027215823-ae77deb06f29/go.mod h1:cS2ma+47FKrLPdXFpr7CuxiTW3eyJbWew4qx0qtQWDA=
go4.org/netipx v0.0.0-20220725152314-7e7bdc8411bf h1:IdwJUzqoIo5lkr2EOyKoe5qipUaEjbOKKY5+fzPBZ3A=
go4.org/netipx v0.0.0-20220725152314-7e7bdc8411bf/go.mod h1:+QXzaoURFd0rGDIjDNpyIkv+F9R7EmeKorvlKRnhqgA=
go4.org/unsafe/assume-no-moving-gc v0.0.0-20220617031537-928513b29760 h1:FyBZqvoA/jbNzuAWLQE2kG820zMAkcilx6BMjGbL/E4=
go4.org/unsafe/assume-no-moving-gc v0.0.0-20220617031537-928513b29760/go.mod h1:FftLjUGFEDu5k8lt0ddY+HcrH/qU/0qk+H8j9/nTl3E=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20201006153459-a7d1128ccaa0/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210614182718-04defd469f4e/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200622182413-4b0db7f3f76b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210426230700-d19ff857e887/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210601080250-7ecdf8ef093b/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211107104306-e0b2ad06fe42/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

1175
hooks_linux.go Normal file

File diff suppressed because it is too large Load diff

1111
hooks_linux_test.go Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,19 @@
//go:build integration
package firewall
import (
"context"
"testing"
)
// TestIntegration runs the capability-driven suite against macOS's pf backend.
// macOS cannot be automated in a general VM (it needs Apple hardware), so this is
// run manually on a Mac: `sudo go test -tags integration -run TestIntegration`.
// The pf backend is shared with FreeBSD, which the VM runner covers automatically.
// See integration_test.go for the shared suite and runIntegration.
func TestIntegration(t *testing.T) {
runIntegration(t, []backendFactory{
{"pf", func(ctx context.Context, p string) (Manager, error) { return NewPF(ctx, p) }},
})
}

View file

@ -0,0 +1,16 @@
//go:build integration
package firewall
import (
"context"
"testing"
)
// TestIntegration runs the capability-driven suite against FreeBSD's pf backend.
// See integration_test.go for the shared suite and runIntegration.
func TestIntegration(t *testing.T) {
runIntegration(t, []backendFactory{
{"pf", func(ctx context.Context, p string) (Manager, error) { return NewPF(ctx, p) }},
})
}

308
integration_linux_test.go Normal file
View file

@ -0,0 +1,308 @@
//go:build integration
package firewall
import (
"context"
"fmt"
"os"
"os/exec"
"strings"
"testing"
)
// linuxBackends lists every Linux backend in the same order NewManager probes them.
func linuxBackends() []backendFactory {
return []backendFactory{
{"firewalld", func(ctx context.Context, p string) (Manager, error) { return NewFirewallD(ctx, p) }},
{"ufw", func(ctx context.Context, p string) (Manager, error) { return NewUFW(ctx, p) }},
{"csf", func(ctx context.Context, p string) (Manager, error) { return NewCSF(ctx, p) }},
{"apf", func(ctx context.Context, p string) (Manager, error) { return NewAPF(ctx, p) }},
{"iptables", func(ctx context.Context, p string) (Manager, error) { return NewIPTables(ctx, p) }},
{"nft", func(ctx context.Context, p string) (Manager, error) { return NewNFT(ctx, p) }},
}
}
// TestIntegration runs the capability-driven suite against the Linux backends.
// See integration_test.go for the shared suite and runIntegration.
func TestIntegration(t *testing.T) {
runIntegration(t, linuxBackends())
}
// rawCopyPlanter returns a function that writes a rule directly into the backend's
// raw-iptables side store — the csf/apf pre-hook, ufw's before.rules — standing in
// for a copy a customer added by hand, or nil when the backend keeps no such store.
// It lives here rather than in the shared suite because it names the Linux-only
// backend types, which do not compile for the pf and Windows targets.
func rawCopyPlanter(mgr Manager) func(*Rule) error {
switch b := mgr.(type) {
case *APF:
return func(r *Rule) error { _, err := b.hook().edit(r, false); return err }
case *CSF:
return func(r *Rule) error { _, err := b.hook().edit(r, false); return err }
case *UFW:
return func(r *Rule) error { return b.editIPTablesRules(r, false) }
}
return nil
}
// foreignSeeder returns a function seeding a foreign rule with the backend's own
// tooling, or nil when the backend has no seeder on this platform. The seeding
// commands/paths are inherently backend-specific; the assertions in the shared
// foreignrule subtest are not.
func foreignSeeder(mgr Manager) func(zone string) (*foreignSeed, error) {
switch mgr.Type() {
case IPTablesType:
// The iptables backend manages the persistent save files (rules.v4 /
// rules.v6), so the operator-style seed is a hand-edited save-file line,
// not a live `iptables -A` (which the file model deliberately never sees).
ipt, ok := mgr.(*IPTables)
if !ok {
return nil
}
return func(string) (*foreignSeed, error) {
undo, err := insertSaveFileRule(ipt.IP4Path, "-A INPUT -p tcp --dport 8123 -j ACCEPT")
if err != nil {
return nil, err
}
return &foreignSeed{
rule: &Rule{Family: IPv4, Proto: TCP, Port: 8123, Action: Accept},
inScope: true,
undo: undo,
}, nil
}
case NFTType:
return func(string) (*foreignSeed, error) {
const table = "foreignseed"
// The seed carries an address match on purpose: an ip table is its own
// family qualifier, so the row states no nfproto and the reader must take
// the family from the table or the network-header offsets are ambiguous
// and the whole row reads back as an opaque slot.
cmds := [][]string{
{"add", "table", "ip", table},
{"add", "chain", "ip", table, "input", "{", "type", "filter", "hook", "input", "priority", "0", ";", "policy", "accept", ";", "}"},
{"add", "rule", "ip", table, "input", "ip", "saddr", "192.0.2.10", "tcp", "dport", "8123", "accept"},
}
for _, c := range cmds {
if out, err := exec.Command("nft", c...).CombinedOutput(); err != nil {
_ = exec.Command("nft", "delete", "table", "ip", table).Run()
return nil, fmt.Errorf("nft %s: %v: %s", strings.Join(c, " "), err, out)
}
}
return &foreignSeed{
rule: &Rule{Family: IPv4, Source: "192.0.2.10", Proto: TCP, Port: 8123, Action: Accept},
inScope: false, // nft reports foreign tables but writes only to its own.
undo: func() { _ = exec.Command("nft", "delete", "table", "ip", table).Run() },
}, nil
}
case UFWType:
return func(string) (*foreignSeed, error) {
if out, err := exec.Command("ufw", "allow", "8123/tcp").CombinedOutput(); err != nil {
return nil, fmt.Errorf("ufw: %v: %s", err, out)
}
return &foreignSeed{
rule: &Rule{Proto: TCP, Port: 8123, Action: Accept},
inScope: true,
undo: func() { _ = exec.Command("ufw", "--force", "delete", "allow", "8123/tcp").Run() },
}, nil
}
case FirewallDType:
return func(zone string) (*foreignSeed, error) {
if out, err := exec.Command("firewall-cmd", "--permanent", "--zone="+zone, "--add-port=8123/tcp").CombinedOutput(); err != nil {
return nil, fmt.Errorf("firewall-cmd: %v: %s", err, out)
}
return &foreignSeed{
rule: &Rule{Proto: TCP, Port: 8123, Action: Accept},
// firewalld's container is the zone itself, so every rule read from
// it — foreign included — carries the informational flag.
hasPrefix: true,
inScope: true,
undo: func() {
_ = exec.Command("firewall-cmd", "--permanent", "--zone="+zone, "--remove-port=8123/tcp").Run()
},
}, nil
}
case CSFType:
return func(string) (*foreignSeed, error) {
undo, err := appendConfigLine(CSFAllow, "198.51.100.99")
if err != nil {
return nil, err
}
return &foreignSeed{
rule: &Rule{Direction: DirAny, Family: IPv4, Source: "198.51.100.99", Action: Accept},
inScope: true,
undo: undo,
}, nil
}
case APFType:
return func(string) (*foreignSeed, error) {
undo, err := appendConfigLine(APFAllow, "198.51.100.99")
if err != nil {
return nil, err
}
return &foreignSeed{
rule: &Rule{Direction: DirAny, Family: IPv4, Source: "198.51.100.99", Action: Accept},
inScope: true,
undo: undo,
}, nil
}
}
return nil
}
// foreignMACSeeder returns a function seeding a foreign MAC zone source with the
// backend's own tooling, or nil when the backend has no MAC-source construct on
// this platform. Only firewalld models one (a zone source) and only firewall-cmd
// can seed it; the assertions in the shared foreignmacsource subtest are not
// backend-specific.
func foreignMACSeeder(mgr Manager) func(zone string) (*foreignSeed, error) {
if mgr.Type() != FirewallDType {
return nil
}
return func(zone string) (*foreignSeed, error) {
const mac = "00:11:22:33:44:55"
if out, err := exec.Command("firewall-cmd", "--permanent", "--zone="+zone, "--add-source="+mac).CombinedOutput(); err != nil {
return nil, fmt.Errorf("firewall-cmd: %v: %s", err, out)
}
return &foreignSeed{
rule: &Rule{Source: mac, Action: Accept},
hasPrefix: true,
inScope: true,
undo: func() {
_ = exec.Command("firewall-cmd", "--permanent", "--zone="+zone, "--remove-source="+mac).Run()
},
}, nil
}
}
// foreignProtocolSeeder returns a function seeding a foreign bare-protocol allow
// with the backend's own tooling, or nil when the backend has no distinct
// protocol-entry construct on this platform. Only firewalld stores one (a zone
// protocol entry, distinct from the rich-rule form the library writes).
func foreignProtocolSeeder(mgr Manager) func(zone string) (*foreignSeed, error) {
if mgr.Type() != FirewallDType {
return nil
}
return func(zone string) (*foreignSeed, error) {
const proto = "gre"
if out, err := exec.Command("firewall-cmd", "--permanent", "--zone="+zone, "--add-protocol="+proto).CombinedOutput(); err != nil {
return nil, fmt.Errorf("firewall-cmd: %v: %s", err, out)
}
return &foreignSeed{
rule: &Rule{Proto: GRE, Action: Accept},
hasPrefix: true,
inScope: true,
undo: func() {
_ = exec.Command("firewall-cmd", "--permanent", "--zone="+zone, "--remove-protocol="+proto).Run()
},
}, nil
}
}
// unmanagedRawRuleSeeder returns a function injecting a parseable rule into a
// raw rules file the backend deliberately does not manage, or nil when the
// backend keeps no such file on this platform. Only ufw has the before/after
// split: the library writes raw rules into before.rules only, so a rule seeded
// into after.rules must stay invisible to GetRules. The returned probe is what
// the seeded line would read back as if it were (wrongly) surfaced.
func unmanagedRawRuleSeeder(mgr Manager) func() (*Rule, func(), error) {
if mgr.Type() != UFWType {
return nil
}
return func() (*Rule, func(), error) {
const afterPath = "/etc/ufw/after.rules"
orig, err := os.ReadFile(afterPath)
if err != nil {
return nil, nil, err
}
// Inject before the real COMMIT directive. after.rules carries a "# don't
// delete the 'COMMIT' line" comment, so match the standalone COMMIT line
// rather than the first literal.
lines := strings.Split(string(orig), "\n")
placed := false
for i, l := range lines {
if strings.TrimSpace(l) == "COMMIT" {
lines = append(lines[:i:i], append([]string{"-A ufw-after-input -p tcp -m tcp --dport 8765 -j ACCEPT"}, lines[i:]...)...)
placed = true
break
}
}
if !placed {
return nil, nil, fmt.Errorf("%s has no COMMIT directive to inject before", afterPath)
}
if err := os.WriteFile(afterPath, []byte(strings.Join(lines, "\n")), 0o640); err != nil {
return nil, nil, err
}
probe := &Rule{Family: IPv4, Proto: TCP, Port: 8765, Action: Accept}
return probe, func() { _ = os.WriteFile(afterPath, orig, 0o640) }, nil
}
}
// zoneInterfaceSeeder returns a function binding an interface to a named zone
// out of band (permanent config only, so nothing filters at runtime), or nil
// when the backend has no interface-to-zone mapping on this platform. Only
// firewalld models one and only firewall-cmd can seed it; the assertions in the
// shared zones subtest are not backend-specific.
func zoneInterfaceSeeder(mgr Manager) func(iface, zoneName string) (func(), error) {
if mgr.Type() != FirewallDType {
return nil
}
return func(iface, zoneName string) (func(), error) {
if out, err := exec.Command("firewall-cmd", "--permanent", "--zone="+zoneName, "--add-interface="+iface).CombinedOutput(); err != nil {
return nil, fmt.Errorf("firewall-cmd: %v: %s", err, out)
}
return func() {
_ = exec.Command("firewall-cmd", "--permanent", "--zone="+zoneName, "--remove-interface="+iface).Run()
}, nil
}
}
// insertSaveFileRule inserts one iptables-save rule line into path's *filter
// section, before its COMMIT, returning an undo that restores the original
// content byte for byte.
func insertSaveFileRule(path, line string) (func(), error) {
orig, err := os.ReadFile(path)
if err != nil {
return nil, err
}
lines := strings.Split(string(orig), "\n")
inFilter, placed := false, false
for i, l := range lines {
trimmed := strings.TrimSpace(l)
if strings.HasPrefix(trimmed, "*") {
inFilter = trimmed == "*filter"
continue
}
if inFilter && trimmed == "COMMIT" {
lines = append(lines[:i:i], append([]string{line}, lines[i:]...)...)
placed = true
break
}
}
if !placed {
return nil, fmt.Errorf("%s has no *filter COMMIT to insert before", path)
}
if err := os.WriteFile(path, []byte(strings.Join(lines, "\n")), 0o600); err != nil {
return nil, err
}
return func() { _ = os.WriteFile(path, orig, 0o600) }, nil
}
// appendConfigLine appends one line to a config file, returning an undo that
// restores the original content byte for byte.
func appendConfigLine(path, line string) (func(), error) {
orig, err := os.ReadFile(path)
if err != nil {
return nil, err
}
content := string(orig)
if content != "" && !strings.HasSuffix(content, "\n") {
content += "\n"
}
// The file exists, so WriteFile keeps its mode; the permission argument only
// applies on create.
if err := os.WriteFile(path, []byte(content+line+"\n"), 0o600); err != nil {
return nil, err
}
return func() { _ = os.WriteFile(path, orig, 0o600) }, nil
}

View file

@ -0,0 +1,43 @@
//go:build integration && !linux
package firewall
// rawCopyPlanter reports that no backend on this platform keeps a raw-iptables side
// store: the csf/apf pre-hook and ufw's before.rules are Linux-only constructs. The
// shared suite skips its raw-copy probe on a nil result. See the Linux implementation
// in integration_linux_test.go.
func rawCopyPlanter(mgr Manager) func(*Rule) error {
return nil
}
// foreignSeeder reports no out-of-band foreign-rule seeder on this platform; the
// Linux implementation in integration_linux_test.go covers the Linux backends.
// The shared suite skips its foreignrule probe on a nil result. Seeding pf (pfctl
// anchors) and wf (netsh advfirewall) the same way is still open.
func foreignSeeder(mgr Manager) func(zone string) (*foreignSeed, error) {
return nil
}
// foreignMACSeeder reports no MAC zone-source seeder on this platform: the MAC
// source is a firewalld construct and firewalld is Linux-only.
func foreignMACSeeder(mgr Manager) func(zone string) (*foreignSeed, error) {
return nil
}
// foreignProtocolSeeder reports no zone protocol-entry seeder on this platform,
// for the same reason as foreignMACSeeder.
func foreignProtocolSeeder(mgr Manager) func(zone string) (*foreignSeed, error) {
return nil
}
// unmanagedRawRuleSeeder reports no unmanaged raw rules file on this platform:
// the before/after split is a ufw construct and ufw is Linux-only.
func unmanagedRawRuleSeeder(mgr Manager) func() (*Rule, func(), error) {
return nil
}
// zoneInterfaceSeeder reports no interface-to-zone seeder on this platform: the
// interface binding is a firewalld construct and firewalld is Linux-only.
func zoneInterfaceSeeder(mgr Manager) func(iface, zoneName string) (func(), error) {
return nil
}

2473
integration_test.go Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,19 @@
//go:build integration
package firewall
import (
"context"
"testing"
)
// TestIntegration runs the capability-driven suite against the Windows Firewall
// (WFP) backend. It needs Administrator privileges and the Windows API, so it runs
// inside a Windows VM (see test/integration/host-windows-vm.sh) or manually on a
// Windows host: `go test -tags integration -run TestIntegration` from an elevated
// prompt. See integration_test.go for the shared suite and runIntegration.
func TestIntegration(t *testing.T) {
runIntegration(t, []backendFactory{
{"wf", func(ctx context.Context, p string) (Manager, error) { return NewWF(ctx, p) }},
})
}

314
ipset_linux.go Normal file
View file

@ -0,0 +1,314 @@
package firewall
import (
"context"
"errors"
"fmt"
"net"
"path/filepath"
"strconv"
"strings"
"syscall"
"github.com/vishvananda/netlink"
"github.com/vishvananda/netlink/nl"
"golang.org/x/sys/unix"
)
// ipsetLayoutInstalled reports the ipset staging file and restore service a
// packaging uses, or empty strings when its persistence mechanism is not
// installed. The Debian layout restores sets through a netfilter-persistent
// plugin (proven by ipsetPlugin's presence); the RHEL and Arch layouts use a
// dedicated ipset service (proven by its unit or init.d script existing).
func ipsetLayoutInstalled(ctx context.Context, layout iptLayout) (path, service string) {
if layout.ipsetPath == "" {
return "", ""
}
if layout.ipsetPlugin != "" {
if matches, _ := filepath.Glob(layout.ipsetPlugin); len(matches) == 0 {
return "", ""
}
return layout.ipsetPath, layout.ipsetService
}
if !serviceInstalled(ctx, layout.ipsetService) {
return "", ""
}
return layout.ipsetPath, layout.ipsetService
}
// detectIPSetPersistence reports the first installed ipset persistence mechanism
// among the known packagings, independent of which one manages the rules save
// files. It serves backends that keep their rules elsewhere but stage their sets
// as plain ipsets — ufw, whose host commonly carries the ipset package without
// iptables-persistent, so probing by rules layout would find nothing.
func detectIPSetPersistence(ctx context.Context) (path, service string) {
for _, l := range iptLayouts {
if p, s := ipsetLayoutInstalled(ctx, l); p != "" {
return p, s
}
}
return "", ""
}
// ipsetLiveFamily reports the family of a live kernel ipset by name: the shared
// set-family source for backends whose address sets are plain ipsets created
// live (iptables, and ufw through its iptables set helper). It is a variable so
// tests can substitute a fake kernel.
var ipsetLiveFamily = netlinkIPSetFamily
// ipsetRefFamily resolves the single family of the set(s) a rule references for
// every backend whose address sets are kernel ipsets: declared — the backend's
// own staged sets, its config file or hook — is consulted first, because those
// backends stage set changes and activate them on Reload, so a set staged but
// not yet loaded is the family the rule will actually match once both are
// applied. An unrelated live ipset sharing the name must not shadow it. The live
// kernel is the fallback, covering a set that exists only in the kernel (a host
// with no persistence mechanism, where sets are created live). declared runs
// lazily, at most once per resolve. A live query failure (netlink blocked, no
// privileges) just means not found live. Backends whose sets are not kernel
// ipsets (nftables named sets, firewalld's D-Bus ipsets) must not resolve
// through here.
func ipsetRefFamily(source, destination string, declared func() ([]*AddressSet, error)) (Family, error) {
var sets []*AddressSet
loaded := false
return setRefFamilyFrom(func(name string) (Family, bool, error) {
if !loaded {
loaded = true
var err error
if sets, err = declared(); err != nil {
return FamilyAny, false, err
}
}
for _, s := range sets {
if s.Name == name {
return s.Family, true, nil
}
}
if fam, found, err := ipsetLiveFamily(name); err == nil && found {
return fam, true, nil
}
return FamilyAny, false, nil
}, source, destination)
}
// --- live kernel ipsets -----------------------------------------------------
//
// Every kernel-side set operation but the staged bulk load goes through netlink
// here. The `ipset` binary is kept only for that load (ipset restore), where the
// tool owns the file format, parses the entries and negotiates each type's
// revision; doing that over netlink would mean reimplementing all three.
//
// Reading these back is deliberately kept in step with the save-format decode in
// iptables_linux.go: the same address set must look the same whether it was read
// from a staging file or from the kernel.
// ipsetCall runs a netlink ipset operation, turning the library's panic on a
// kernel error it cannot type-assert to syscall.Errno into an ordinary error.
// Every call into the library must be wrapped: the assertion is unchecked, so
// any non-Errno failure (a closed socket, a short read) would otherwise take the
// caller's process down.
func ipsetCall(op string, fn func() error) (err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("ipset %s failed: %v", op, r)
}
}()
return fn()
}
// ipsetNoSuchSet reports whether err is the kernel's answer for an operation on
// a set that does not exist, which every removal treats as already done.
func ipsetNoSuchSet(err error) bool {
return errors.Is(err, syscall.ENOENT)
}
// ipsetInUse reports whether err is the kernel refusing to destroy a set that a
// loaded rule still matches on. The staged model reloads the rules before the
// destroys it owes, so this means a rule outside the staging file holds the set.
func ipsetInUse(err error) bool {
var e nl.IPSetError
return errors.As(err, &e) && (int(e) == nl.IPSET_ERR_BUSY || int(e) == nl.IPSET_ERR_REFERENCED)
}
// ipsetUnavailable reports whether err means the host has no ipset support at
// all — the kernel module is absent and nfnetlink has no subsystem to hand the
// request to. Read as no sets rather than an error, matching a host where the
// feature was never installed.
func ipsetUnavailable(err error) bool {
return errors.Is(err, syscall.EOPNOTSUPP) || errors.Is(err, syscall.EPROTONOSUPPORT)
}
// ipsetNLFamily maps a family to the value the kernel's ipset API expects.
func ipsetNLFamily(f Family) uint8 {
if f == IPv6 {
return unix.AF_INET6
}
return unix.AF_INET
}
// ipsetEncodeEntry converts a set entry — a bare address or a CIDR — into the
// netlink form. Replace is set so add and delete carry the kernel's exist flag,
// the same idempotence `ipset -exist` gives.
func ipsetEncodeEntry(entry string) (*netlink.IPSetEntry, error) {
if strings.Contains(entry, "/") {
_, n, err := net.ParseCIDR(entry)
if err != nil {
return nil, fmt.Errorf("address set entry %q is not a valid cidr: %w", entry, err)
}
ones, _ := n.Mask.Size()
return &netlink.IPSetEntry{IP: n.IP, CIDR: uint8(ones), Replace: true}, nil
}
ip := net.ParseIP(entry)
if ip == nil {
return nil, fmt.Errorf("address set entry %q is not an ip address or cidr", entry)
}
return &netlink.IPSetEntry{IP: ip, Replace: true}, nil
}
// ipsetDecodeEntry renders a kernel entry the way the save format writes it, so
// a set read live and the same set read from a staging file compare equal. A
// prefix covering the whole address is dropped, which is how `ipset save` emits
// a single host stored in a hash:net set.
func ipsetDecodeEntry(e netlink.IPSetEntry) string {
if e.IP == nil {
return ""
}
bits := 128
if e.IP.To4() != nil {
bits = 32
}
if e.CIDR == 0 || int(e.CIDR) == bits {
return e.IP.String()
}
return e.IP.String() + "/" + strconv.Itoa(int(e.CIDR))
}
// ipsetCreate creates a kernel set, tolerating one that already exists with the
// same definition. The type revision is left to the netlink library, which maps
// hash:ip to its revision 1 and falls through to the base revision 0 for
// hash:net: every kernel carrying hash:net registers 0, and the revisions above
// it only add features (ranges, nomatch, counters, comments, forceadd, skbinfo)
// this library never asks for.
func ipsetCreate(name string, family Family, t SetType) error {
return ipsetCall("create "+name, func() error {
return netlink.IpsetCreate(name, t.String(), netlink.IpsetCreateOptions{
Replace: true,
Family: ipsetNLFamily(family),
})
})
}
// ipsetDestroy removes a kernel set. A set that is already gone is success.
func ipsetDestroy(name string) error {
err := ipsetCall("destroy "+name, func() error { return netlink.IpsetDestroy(name) })
if err == nil || ipsetNoSuchSet(err) {
return nil
}
if ipsetInUse(err) {
return fmt.Errorf("address set %q is still referenced by a loaded rule: %w", name, err)
}
return err
}
// ipsetFlush empties a kernel set. A set that is already gone is success.
func ipsetFlush(name string) error {
err := ipsetCall("flush "+name, func() error { return netlink.IpsetFlush(name) })
if err != nil && !ipsetNoSuchSet(err) {
return err
}
return nil
}
// ipsetAddEntry adds an entry to a kernel set. An entry already present is
// success; a set that does not exist is reported, since the caller asked to add
// to something that is not there.
func ipsetAddEntry(name, entry string) error {
e, err := ipsetEncodeEntry(entry)
if err != nil {
return err
}
if err := ipsetCall("add "+name, func() error { return netlink.IpsetAdd(name, e) }); err != nil {
if ipsetNoSuchSet(err) {
return fmt.Errorf("address set %q does not exist", name)
}
return err
}
return nil
}
// ipsetDelEntry removes an entry from a kernel set. A missing entry, or a
// missing set, is success.
func ipsetDelEntry(name, entry string) error {
e, err := ipsetEncodeEntry(entry)
if err != nil {
return err
}
err = ipsetCall("del "+name, func() error { return netlink.IpsetDel(name, e) })
if err != nil && !ipsetNoSuchSet(err) {
return err
}
return nil
}
// ipsetLiveSets reports every set the kernel holds, decoded into address sets.
// A host with no ipset support has no sets to report rather than an error, which
// keeps a Backup on such a host from failing over a feature it never had.
func ipsetLiveSets() ([]*AddressSet, error) {
var res []netlink.IPSetResult
err := ipsetCall("list", func() error {
var e error
res, e = netlink.IpsetListAll()
return e
})
if err != nil {
if ipsetUnavailable(err) {
return nil, nil
}
return nil, err
}
sets := make([]*AddressSet, 0, len(res))
for _, r := range res {
if r.SetName == "" {
continue
}
set := &AddressSet{Name: r.SetName, Family: IPv4, Type: SetHashIP}
if r.Family == unix.NFPROTO_IPV6 {
set.Family = IPv6
}
// An unmodeled type reads as hash:ip, matching how the save-format decode
// treats a create line it does not recognize.
if r.TypeName == SetHashNet.String() {
set.Type = SetHashNet
}
for _, e := range r.Entries {
if s := ipsetDecodeEntry(e); s != "" {
set.Entries = append(set.Entries, s)
}
}
sets = append(sets, set)
}
return sets, nil
}
// netlinkIPSetFamily queries the kernel's ipset subsystem over netlink for a
// set's family. A set that does not exist reports found=false; any other
// failure (netlink unavailable, insufficient privileges) is an error the caller
// can fall back from.
func netlinkIPSetFamily(name string) (fam Family, found bool, err error) {
var res *netlink.IPSetResult
err = ipsetCall("list "+name, func() error {
var e error
res, e = netlink.IpsetList(name)
return e
})
if err != nil {
if ipsetNoSuchSet(err) {
return FamilyAny, false, nil
}
return FamilyAny, false, err
}
if res.Family == unix.NFPROTO_IPV6 {
return IPv6, true, nil
}
return IPv4, true, nil
}

3449
iptables_linux.go Normal file

File diff suppressed because it is too large Load diff

1534
iptables_linux_test.go Normal file

File diff suppressed because it is too large Load diff

192
livecounters_linux.go Normal file
View file

@ -0,0 +1,192 @@
package firewall
import (
"context"
"strings"
)
// Live packet/byte counters for the backends that store their rules in files but
// enforce them through iptables — iptables itself, ufw, csf and apf. The files
// carry no counts, so the counts have to come from the running ruleset, and the
// rows there are whatever the product generates for a rule rather than the rule
// itself. Each backend supplies the decode step that turns one of its rows back
// into the rule it stands for — for iptables that is the identity, for the others
// it undoes the product's own framing; the reading, the run handling and the
// matching below are the same for all four.
//
// Counters are informational (Capabilities().RuleCounters) and never part of
// rule identity, so everything here is best-effort: a missing save binary, a row
// this model cannot hold, or a rule the running ruleset does not carry simply
// leaves that rule's counters zero.
// liveRow is one counter-annotated `iptables-save -c` rule line, split into the
// parts a backend needs to decide what rule it stands for.
type liveRow struct {
// chain is the chain the line appends to.
chain string
// fields is the whole line split on whitespace, counter token first. It is
// for inspection only: a quoted log prefix does not survive the split, so
// rewrites operate on line.
fields []string
// line is the line verbatim, counter token included.
line string
// newChain reports whether this row opens a chain, so a decoder holding
// state across adjacent rows knows to drop it.
newChain bool
}
// liveSaveLines reads the live filter table for a family with counters attached.
// A missing or failing save binary yields no lines rather than an error, since a
// backend that cannot read counters still reports its rules.
func liveSaveLines(ctx context.Context, fam Family) []string {
cmd := "iptables-save"
if fam == IPv6 {
cmd = "ip6tables-save"
}
out, err := runCommand(ctx, cmd, "-c", "-t", "filter")
if err != nil {
return nil
}
return out
}
// jumpTarget returns the target of a rulespec's `-j`/`--jump` option, or the
// empty string when the line carries none.
func jumpTarget(fields []string) string {
for i, tok := range fields {
if tok == "-j" || tok == "--jump" {
if i+1 < len(fields) {
return fields[i+1]
}
return ""
}
}
return ""
}
// parseLiveRow reparses a live row as a rule in the direction its chain stands
// for. It rewrites the product's chain to the INPUT/OUTPUT/FORWARD equivalent
// and reuses the iptables rulespec parser, which also lifts the [pkts:bytes]
// prefix onto the rule. A row the model cannot hold is rejected.
func parseLiveRow(row liveRow, dir Direction, fam Family) (*Rule, bool) {
spec := strings.Replace(row.line, row.fields[1]+" "+row.chain,
row.fields[1]+" "+iptChainForDirection(dir), 1)
r, err := unmarshalIPTablesRule(spec, fam)
if err != nil {
return nil, false
}
return r, true
}
// decodeLiveRows decodes counter-annotated `iptables-save -c` output into the
// rules a backend's chains hold. decode turns one row into the rule it stands
// for and reports false for a row that stands for no rule of its own — a chain
// the backend does not surface, or one line of a multi-line expansion.
//
// A LOG line and the action line beneath it are one logical rule, but only while
// they stay physically adjacent, so a row decode rejects ends the current run
// and that run is coalesced on its own.
func decodeLiveRows(out []string, decode func(row liveRow) (*Rule, bool)) []*Rule {
var rules, run []*Rule
flush := func() {
if len(run) > 0 {
rules = append(rules, coalesceLoggedRules(run)...)
run = nil
}
}
chain := ""
for _, line := range out {
line = strings.TrimSpace(line)
// Only the counter-annotated rule lines carry a rule; the table and chain
// headers do not.
if !strings.HasPrefix(line, "[") {
continue
}
fields := strings.Fields(line)
if len(fields) < 4 || (fields[1] != "-A" && fields[1] != "--append") {
continue
}
row := liveRow{chain: fields[2], fields: fields, line: line}
if row.chain != chain {
flush()
chain, row.newChain = row.chain, true
}
r, ok := decode(row)
if !ok {
flush()
continue
}
run = append(run, r)
}
flush()
return rules
}
// applyLiveCounters attaches each live row's counters to the rule it belongs to
// and returns the rows no rule claimed. Every live row is consumed at most once,
// so duplicate rules keep distinct counts, and a rule with no live counterpart —
// an edit not yet activated by Reload, or one the backend stores but has not
// loaded — keeps zero counters. The leftovers are for a backend that has to
// claim a row on something other than rule identity (see CSF.claimDenyOutRows).
func applyLiveCounters(targets, live []*Rule) []*Rule {
if len(targets) == 0 || len(live) == 0 {
return live
}
// Match on identity first, so a rule that has a row of its own never absorbs a
// wider neighbour's count.
used := make([]bool, len(live))
matched := make([]bool, len(targets))
for ri, r := range targets {
for i, l := range live {
if used[i] || !l.Equal(r, true) {
continue
}
r.Packets, r.Bytes = l.Packets, l.Bytes
used[i], matched[ri] = true, true
break
}
}
// Then sum the rows a wider rule spans: a rule that covers more than one
// family, transport or direction is written as one row per cell, so its count
// is their total.
for ri, r := range targets {
if matched[ri] {
continue
}
for i, l := range live {
if used[i] || !r.Covers(l) {
continue
}
r.Packets += l.Packets
r.Bytes += l.Bytes
used[i] = true
}
}
var leftover []*Rule
for i, l := range live {
if !used[i] {
leftover = append(leftover, l)
}
}
return leftover
}
// countableRules returns the rules a family's live ruleset can account for: the
// ones pinned to that family, plus the family-agnostic ones. A backend that
// stores a rule dual-stack (apf's port lists, a family-agnostic hook line)
// reports it as one FamilyAny rule that both families' rulesets hold a row for,
// so it is offered to each and its counters accumulate across the two.
func countableRules(rules []*Rule, fam Family) []*Rule {
var out []*Rule
for _, r := range rules {
if r != nil && (r.Family == fam || r.Family == FamilyAny) {
out = append(out, r)
}
}
return out
}

17
manager_darwin.go Normal file
View file

@ -0,0 +1,17 @@
package firewall
import (
"context"
"fmt"
)
// NewManager gets a firewall manager for this macOS host.
func NewManager(ctx context.Context, rulePrefix string) (Manager, error) {
// macOS uses pf (Packet Filter) for its network firewall.
pf, err := NewPF(ctx, rulePrefix)
if err == nil {
return pf, nil
}
return nil, fmt.Errorf("no firewall manager found")
}

17
manager_freebsd.go Normal file
View file

@ -0,0 +1,17 @@
package firewall
import (
"context"
"fmt"
)
// NewManager gets a firewall manager for this FreeBSD host.
func NewManager(ctx context.Context, rulePrefix string) (Manager, error) {
// FreeBSD uses pf (Packet Filter) for its network firewall.
pf, err := NewPF(ctx, rulePrefix)
if err == nil {
return pf, nil
}
return nil, fmt.Errorf("no firewall manager found")
}

183
manager_linux.go Normal file
View file

@ -0,0 +1,183 @@
package firewall
import (
"bufio"
"context"
"errors"
"fmt"
"os"
"strings"
)
// NewManager gets a firewall manager for this server, probing the higher-level
// managers first: firewalld, then ufw/csf/apf, then plain iptables, and
// nftables last, so a manager that is itself backed by iptables or nftables is
// preferred over managing its tables behind its back. The context bounds the
// detection probes (each shells out or opens a D-Bus/systemd connection).
func NewManager(ctx context.Context, rulePrefix string) (Manager, error) {
// A probe error usually means "not installed", but it can also be a
// transient failure on a host whose firewall IS that manager (a D-Bus
// hiccup with firewalld running); every probe's reason is carried in the
// final error so a mis-detection is diagnosable.
var errs []error
probes := []struct {
name string
try func() (Manager, error)
}{
{"firewalld", func() (Manager, error) { return NewFirewallD(ctx, rulePrefix) }},
{"ufw", func() (Manager, error) { return NewUFW(ctx, rulePrefix) }},
{"csf", func() (Manager, error) { return NewCSF(ctx, rulePrefix) }},
{"apf", func() (Manager, error) { return NewAPF(ctx, rulePrefix) }},
{"iptables", func() (Manager, error) { return NewIPTables(ctx, rulePrefix) }},
{"nftables", func() (Manager, error) { return NewNFT(ctx, rulePrefix) }},
}
for _, p := range probes {
mgr, err := p.try()
if err == nil {
return mgr, nil
}
errs = append(errs, fmt.Errorf("%s: %w", p.name, err))
}
return nil, fmt.Errorf("no firewall manager found: %w", errors.Join(errs...))
}
// combineComment joins the configured prefix and an optional user comment into the
// single comment string stored on a rule. The prefix is always carried so rules
// this library creates stay identifiable: when both are present the prefix is
// followed by a space and the user text; when only one is present it is used
// alone; when neither is present the result is empty. Shared by the tag-based
// Linux backends (iptables, ufw, csf, apf, and the csf/apf hook).
func combineComment(prefix, comment string) string {
if prefix == "" {
return comment
}
if comment == "" {
return prefix
}
return prefix + " " + comment
}
// commentGroup is one span of a scanned list file: a content line together with
// the full-line comment lines attached directly above it, or a single
// passthrough line — a blank, a detached comment, or a line the file's
// convention does not attach comments to — on its own.
type commentGroup struct {
// raw preserves the original lines, attached comments first and the content
// line last, so a rewrite copies user formatting through verbatim and a
// removal drops a rule's comment together with its line.
raw []string
// line is the trimmed content line, or "" for a passthrough group.
line string
// comment is the space-joined text of the attached comment lines.
comment string
}
// scanCommentGroups streams a list file to fn as comment-attached groups, the
// convention shared by the csf.allow/csf.deny lists, the apf trust files, and
// the raw-iptables hook: consecutive full-line `#` comments attach to the
// content line directly below them as its comment. A blank line detaches a
// pending comment block into passthrough groups, and a rulePrefix tag starts a
// fresh block, so header comments above a tagged rule survive the rule's
// removal. A `#!` line is never a comment: a shebang must not attach to a rule
// and be dropped with it. attach reports whether a content line takes the
// pending comments (nil attaches to every non-blank, non-comment line); a line
// it declines — user shell or ipset lines in the hook — detaches them and
// passes through on its own. A nil fd scans as an empty file. An error from fn
// stops the scan.
func scanCommentGroups(fd *os.File, rulePrefix string, attach func(trimmed string) bool, fn func(g commentGroup) error) error {
if fd == nil {
return nil
}
scanner := bufio.NewScanner(fd)
// pending holds the raw held-back comment lines (a rewrite needs them
// verbatim); pendingText accumulates their space-joined comment text (the
// parse needs it).
var pending []string
var pendingText string
flush := func() error {
for _, c := range pending {
if err := fn(commentGroup{raw: []string{c}}); err != nil {
return err
}
}
pending, pendingText = nil, ""
return nil
}
for scanner.Scan() {
raw := scanner.Text()
trimmed := strings.TrimSpace(raw)
// A full-line comment is held as a candidate rule comment.
if strings.HasPrefix(trimmed, "#") && !strings.HasPrefix(trimmed, "#!") {
text := strings.TrimSpace(strings.TrimPrefix(trimmed, "#"))
if rulePrefix != "" && (text == rulePrefix || strings.HasPrefix(text, rulePrefix+" ")) {
if err := flush(); err != nil {
return err
}
}
if text != "" {
if pendingText != "" {
pendingText += " " + text
} else {
pendingText = text
}
}
pending = append(pending, raw)
continue
}
// A blank line or a line the file's convention does not attach comments
// to detaches the pending block and passes through untouched.
if trimmed == "" || (attach != nil && !attach(trimmed)) {
if err := flush(); err != nil {
return err
}
if err := fn(commentGroup{raw: []string{raw}}); err != nil {
return err
}
continue
}
g := commentGroup{raw: append(pending, raw), line: trimmed, comment: pendingText}
pending, pendingText = nil, ""
if err := fn(g); err != nil {
return err
}
}
if err := flush(); err != nil {
return err
}
return scanner.Err()
}
// trimInlineComment strips a trailing inline `#` comment from a list line —
// a note on the line itself, not a rule comment — returning the trimmed rule
// text that remains.
func trimInlineComment(line string) string {
if ci := strings.IndexByte(line, '#'); ci >= 0 {
line = line[:ci]
}
return strings.TrimSpace(line)
}
// prefixedComment splits a stored comment into its user-facing text and whether
// the comment carried the configured prefix (marking a rule tagged with this
// library's namespace). A comment equal to the prefix (a prefix-only tag) has the
// prefix with empty text; a comment carrying the prefix followed by a space has
// the prefix with the remainder as text; any other comment lacks the prefix and
// is returned unchanged. An empty prefix gives the library no namespace of its
// own, so the prefix cannot be derived from the comment — hasPrefix is reported
// false and the caller decides (backends treat an empty prefix as covering
// everything, see GetRules). It is the read-side inverse of combineComment.
func prefixedComment(prefix, comment string) (text string, hasPrefix bool) {
if prefix == "" {
return comment, false
}
if comment == prefix {
return "", true
}
if rest, ok := strings.CutPrefix(comment, prefix+" "); ok {
return rest, true
}
return comment, false
}

31
manager_linux_test.go Normal file
View file

@ -0,0 +1,31 @@
package firewall
import (
"testing"
"github.com/stretchr/testify/require"
)
// prefixedComment splits a stored comment into user text and a prefix signal: a
// prefix-only tag has the prefix with empty text, a "prefix text" comment has the
// prefix with the remainder, anything else lacks the prefix and is unchanged, and
// an empty prefix yields no prefix signal (callers treat that as covering
// everything).
func TestPrefixedComment(t *testing.T) {
cases := []struct {
prefix, stored string
wantText string
wantHasPrefix bool
}{
{"myapp", "myapp", "", true},
{"myapp", "myapp https", "https", true},
{"myapp", "someone else's note", "someone else's note", false},
{"myapp", "", "", false},
{"", "anything at all", "anything at all", false},
}
for _, c := range cases {
text, hasPrefix := prefixedComment(c.prefix, c.stored)
require.Equal(t, c.wantText, text, "text for (%q,%q)", c.prefix, c.stored)
require.Equal(t, c.wantHasPrefix, hasPrefix, "hasPrefix for (%q,%q)", c.prefix, c.stored)
}
}

16
manager_windows.go Normal file
View file

@ -0,0 +1,16 @@
package firewall
import (
"context"
"fmt"
)
// NewManager gets a firewall manager for this server; Windows has a single
// backend, the Windows Filtering Platform. The context bounds the probe.
func NewManager(ctx context.Context, rulePrefix string) (Manager, error) {
wf, err := NewWF(ctx, rulePrefix)
if err == nil {
return wf, nil
}
return nil, fmt.Errorf("no firewall manager found: %w", err)
}

3264
nft_linux.go Normal file

File diff suppressed because it is too large Load diff

666
nft_linux_test.go Normal file
View file

@ -0,0 +1,666 @@
package firewall
import (
"net"
"testing"
"github.com/google/nftables"
"github.com/google/nftables/binaryutil"
"github.com/google/nftables/expr"
"github.com/stretchr/testify/require"
)
// nftEncodeRule marshals a rule, failing the test if it cannot be expressed.
func nftEncodeRule(t *testing.T, f *NFT, r *Rule) *nftEncoded {
t.Helper()
enc, err := f.MarshalRule(r)
require.NoError(t, err, "failed to marshal %+v", *r)
return enc
}
// nftDecodeRule decodes an encoding back into a rule, resolving whatever anonymous
// sets the encoding staged rather than reaching for a live ruleset.
func nftDecodeRule(t *testing.T, f *NFT, enc *nftEncoded) *Rule {
t.Helper()
got, err := f.UnmarshalRule(
&nftables.Rule{Exprs: enc.exprs, UserData: enc.userData},
enc.chain, newStagedSetReader(enc), f.tableRef())
require.NoError(t, err, "failed to decode encoding")
return got
}
// nftRoundTrip encodes a rule and decodes the result, which is the shape every
// read-after-write path depends on: a rule that does not survive this makes Sync
// re-add it on every pass.
func nftRoundTrip(t *testing.T, f *NFT, r *Rule) *Rule {
t.Helper()
return nftDecodeRule(t, f, nftEncodeRule(t, f, r))
}
// nftExprKinds names the expressions an encoding produced, in order, so a test can
// assert the shape of an encoding without pinning every field.
func nftExprKinds(exprs []expr.Any) []string {
var out []string
for _, e := range exprs {
switch e.(type) {
case *expr.Meta:
out = append(out, "meta")
case *expr.Cmp:
out = append(out, "cmp")
case *expr.Payload:
out = append(out, "payload")
case *expr.Bitwise:
out = append(out, "bitwise")
case *expr.Lookup:
out = append(out, "lookup")
case *expr.Range:
out = append(out, "range")
case *expr.Ct:
out = append(out, "ct")
case *expr.Connlimit:
out = append(out, "connlimit")
case *expr.Dynset:
out = append(out, "dynset")
case *expr.Limit:
out = append(out, "limit")
case *expr.Log:
out = append(out, "log")
case *expr.Counter:
out = append(out, "counter")
case *expr.Verdict:
out = append(out, "verdict")
case *expr.Reject:
out = append(out, "reject")
case *expr.Immediate:
out = append(out, "immediate")
case *expr.NAT:
out = append(out, "nat")
case *expr.Masq:
out = append(out, "masq")
case *expr.Redir:
out = append(out, "redir")
default:
out = append(out, "unknown")
}
}
return out
}
// Every rule shape the backend can express must survive an encode/decode round
// trip, across both directions, both families and each match axis.
func TestNFTRuleRoundTrip(t *testing.T) {
f := &NFT{table: "go_firewall"}
rules := []*Rule{
// Addresses and families.
{Family: IPv4, Source: "192.168.0.0/24", Port: 23, Proto: UDP, Action: Accept},
{Family: IPv4, Source: "1.2.3.4", Proto: TCP, Port: 22, Action: Accept},
{Family: IPv4, Source: "1.2.3.4/32", Proto: TCP, Port: 22, Action: Accept},
{Family: IPv4, Source: "10.0.0.0/12", Action: Drop},
{Family: IPv4, Destination: "203.0.113.10", Port: 4791, Proto: TCP, Action: Reject},
{Family: IPv6, Source: "2001:db8::1", Action: Drop},
{Family: IPv6, Source: "2001:db8::/32", Action: Drop},
{Family: IPv6, Destination: "2001:db8::/48", Proto: TCP, Port: 80, Action: Accept},
// Negation.
{Family: IPv6, Source: "!2001:db8::1", Action: Drop},
{Family: IPv4, Destination: "!10.0.0.0/8", Action: Drop},
// Named set references.
{Family: IPv4, Source: "blocklist", Port: 22, Proto: TCP, Action: Drop},
{Direction: DirOutput, Family: IPv6, Destination: "!allowlist", Port: 80, Proto: TCP, Action: Accept},
// Family pinned with no address at all.
{Family: IPv4, Port: 4789, Proto: UDP, Action: Accept},
{Direction: DirOutput, Family: IPv6, Port: 4789, Proto: UDP, Action: Accept},
// Ports: single, span, list, mixed list, source ports.
{Proto: TCP, Port: 22, Action: Accept},
{Proto: UDP, Ports: []PortRange{{Start: 1000, End: 2000}}, Action: Accept},
{Proto: TCP, Ports: []PortRange{{Start: 80}, {Start: 443}}, Action: Accept},
{Proto: TCP, Ports: []PortRange{{Start: 80}, {Start: 443}, {Start: 8000, End: 8100}}, Action: Accept},
{Proto: TCP, SourcePort: 1024, Action: Accept},
{Proto: TCP, SourcePorts: []PortRange{{Start: 1024, End: 65535}}, Action: Accept},
{Proto: TCP, Port: 22, SourcePort: 1024, Action: Accept},
// Protocols with no ports.
{Proto: SCTP, Action: Accept},
{Proto: GRE, Action: Accept},
{Proto: ESP, Action: Accept},
{Proto: AH, Action: Accept},
// ICMP.
{Proto: ICMP, Action: Accept},
{Proto: ICMPv6, Action: Accept},
{Proto: ICMP, ICMPType: Ptr[uint8](8), Action: Accept},
{Family: IPv6, Proto: ICMPv6, ICMPType: Ptr[uint8](128), Action: Accept},
// Both transports in a single row.
{Proto: TCPUDP, Port: 53, Action: Accept},
{Proto: TCPUDP, Ports: []PortRange{{Start: 80}, {Start: 443}}, Action: Accept},
// Connection state.
{Proto: TCP, Port: 22, State: StateEstablished, Action: Accept},
{Proto: TCP, Port: 22, State: StateNew | StateEstablished, Action: Accept},
{State: StateEstablished | StateRelated, Action: Accept},
{State: StateInvalid, Action: Drop},
// Interfaces, including a wildcard and a forward rule matching both.
{InInterface: "eth0", Proto: TCP, Port: 22, Action: Accept},
{InInterface: "eth*", Action: Accept},
{Direction: DirOutput, OutInterface: "eth1", Proto: UDP, Port: 53, Action: Accept},
{Direction: DirForward, InInterface: "eth0", OutInterface: "eth1", Proto: TCP, Port: 22, Action: Accept},
// Rate and connection limits.
{Proto: TCP, Port: 22, RateLimit: &RateLimit{Rate: 5, Unit: PerMinute}, Action: Accept},
{Proto: TCP, Port: 22, RateLimit: &RateLimit{Rate: 100, Unit: PerSecond, Burst: 20}, Action: Accept},
{Proto: TCP, Port: 22, ConnLimit: &ConnLimit{Count: 10}, Action: Drop},
{Family: IPv4, Proto: TCP, Port: 22, ConnLimit: &ConnLimit{Count: 10, PerSource: true}, Action: Drop},
{Family: IPv6, Proto: TCP, Port: 22, ConnLimit: &ConnLimit{Count: 3, PerSource: true}, Action: Drop},
// Logging and comments.
{Proto: TCP, Port: 22, Log: true, Action: Accept},
{Proto: TCP, Port: 22, Log: true, LogPrefix: "ssh drop ", Action: Drop},
{Proto: TCP, Port: 22, Comment: "managed by go-firewall", Action: Accept},
}
for _, r := range rules {
got := nftRoundTrip(t, f, r)
require.True(t, got.Equal(r, true),
"round-trip mismatch: input %+v, output %+v", *r, *got)
require.Equal(t, r.Comment, got.Comment, "comment must round-trip for %+v", *r)
}
}
// In an inet table a network-header offset names a different field in each
// family, so every rule that resolves to a concrete family must carry the
// nfproto guard — without it an IPv4 source-address match also matches inside an
// IPv6 source address.
func TestNFTFamilyGuardAlwaysEmitted(t *testing.T) {
f := &NFT{table: "go_firewall"}
for _, r := range []*Rule{
{Family: IPv4, Source: "1.2.3.4", Action: Drop},
{Family: IPv6, Destination: "2001:db8::1", Action: Drop},
{Family: IPv4, Proto: TCP, Port: 22, Action: Accept},
{Family: IPv4, Proto: TCP, Port: 22, ConnLimit: &ConnLimit{Count: 2, PerSource: true}, Action: Drop},
} {
enc := nftEncodeRule(t, f, r)
require.Equal(t, "meta", nftExprKinds(enc.exprs)[0], "expected a leading family guard for %+v", *r)
meta := enc.exprs[0].(*expr.Meta)
require.Equal(t, expr.MetaKeyNFPROTO, meta.Key, "expected an nfproto guard for %+v", *r)
cmp := enc.exprs[1].(*expr.Cmp)
require.Equal(t, []byte{f.nfprotoByte(r.impliedFamily())}, cmp.Data)
}
// A rule with no family to pin carries no guard.
enc := nftEncodeRule(t, f, &Rule{Proto: TCP, Port: 22, Action: Accept})
meta := enc.exprs[0].(*expr.Meta)
require.Equal(t, expr.MetaKeyL4PROTO, meta.Key, "an unpinned rule must not claim a family")
}
// A per-source connection limit counts in a named dynamic set keyed on the
// source address. The set is family-typed, so its key must name the family's own
// address field and the set must be created alongside the rule.
func TestNFTPerSourceConnLimit(t *testing.T) {
f := &NFT{table: "go_firewall"}
r := &Rule{Family: IPv4, Proto: TCP, Port: 22, ConnLimit: &ConnLimit{Count: 10, PerSource: true}, Action: Drop}
enc := nftEncodeRule(t, f, r)
require.NotNil(t, enc.meterSet, "a per-source limit must create its counting set")
require.True(t, enc.meterSet.Dynamic, "the counting set must be dynamic")
require.Equal(t, nftables.TypeIPAddr, enc.meterSet.KeyType)
var ds *expr.Dynset
for _, e := range enc.exprs {
if v, ok := e.(*expr.Dynset); ok {
ds = v
}
}
require.NotNil(t, ds, "expected a dynset statement")
require.Equal(t, enc.meterSet.Name, ds.SetName)
require.Len(t, ds.Exprs, 1)
cl, ok := ds.Exprs[0].(*expr.Connlimit)
require.True(t, ok, "the dynset must carry a connlimit")
require.Equal(t, uint32(10), cl.Count)
require.Equal(t, uint32(expr.NFT_CONNLIMIT_F_INV), cl.Flags, "the count must be an over-limit test")
// The IPv6 form keys on the v6 source address instead.
enc6 := nftEncodeRule(t, f, &Rule{Family: IPv6, Proto: TCP, Port: 22, ConnLimit: &ConnLimit{Count: 10, PerSource: true}, Action: Drop})
require.Equal(t, nftables.TypeIP6Addr, enc6.meterSet.KeyType)
require.NotEqual(t, enc.meterSet.Name, enc6.meterSet.Name, "each family counts in its own set")
// The set name is derived from rule identity, so re-adding the same rule
// reuses its counting state while a different rule gets its own.
require.Equal(t, enc.meterSet.Name, nftEncodeRule(t, f, r).meterSet.Name)
other := &Rule{Family: IPv4, Proto: TCP, Port: 443, ConnLimit: &ConnLimit{Count: 10, PerSource: true}, Action: Drop}
require.NotEqual(t, enc.meterSet.Name, nftEncodeRule(t, f, other).meterSet.Name)
// A family-agnostic per-source limit has no single row: the caller must fan
// it out first, so validateRule rejects it.
require.Error(t, f.validateRule(&Rule{Proto: TCP, Port: 22, ConnLimit: &ConnLimit{Count: 10, PerSource: true}, Action: Drop}),
"a FamilyAny per-source limit must be expanded first")
require.True(t, f.perSourceFamilySplit(&Rule{Proto: TCP, ConnLimit: &ConnLimit{Count: 1, PerSource: true}, Action: Drop}))
}
// A both-transports rule stays a single row: the protocol is an anonymous set of
// the two transport numbers, and the ports match through the shared offsets.
func TestNFTTCPUDPSingleRow(t *testing.T) {
f := &NFT{table: "go_firewall"}
enc := nftEncodeRule(t, f, &Rule{Proto: TCPUDP, Port: 53, Action: Accept})
require.Len(t, enc.anonSets, 1, "the protocol pair rides one anonymous set")
set := enc.anonSets[0]
require.Equal(t, nftables.TypeInetProto, set.set.KeyType)
require.True(t, set.set.Anonymous && set.set.Constant)
require.ElementsMatch(t,
[][]byte{{6}, {17}},
[][]byte{set.elements[0].Key, set.elements[1].Key},
"the set must hold tcp and udp")
require.Equal(t, []string{"meta", "lookup", "payload", "cmp", "counter", "verdict"}, nftExprKinds(enc.exprs))
}
// The library's connection-state bits and the kernel's do not share an ordering,
// so the mapping between them is explicit and must stay symmetric.
func TestNFTConnStateMask(t *testing.T) {
f := new(NFT)
for _, c := range []struct {
state ConnState
mask uint32
}{
{StateNew, 0x08},
{StateEstablished, 0x02},
{StateRelated, 0x04},
{StateInvalid, 0x01},
{StateEstablished | StateRelated, 0x06},
{StateNew | StateEstablished | StateRelated | StateInvalid, 0x0f},
} {
require.Equal(t, c.mask, f.ctStateMask(c.state), "encoding %v", c.state.Strings())
got, ok := f.connStateForMask(c.mask)
require.True(t, ok, "decoding mask %#x", c.mask)
require.Equal(t, c.state, got, "decoding mask %#x", c.mask)
}
// A mask carrying a state the model cannot hold (untracked) is rejected
// rather than narrowed to the states that did map, so the row stays opaque.
_, ok := f.connStateForMask(0x40)
require.False(t, ok, "an unmodelled ct state must not decode")
_, ok = f.connStateForMask(0x02 | 0x40)
require.False(t, ok, "a partly unmodelled ct state mask must not decode")
}
// nft shortens a byte-aligned prefix to a narrower payload load rather than
// masking, so the decoder must accept that form as well as the masked one this
// backend writes.
func TestNFTShortenedPrefixDecodes(t *testing.T) {
f := &NFT{table: "go_firewall"}
for _, c := range []struct {
fam Family
offset uint32
length uint32
data []byte
want string
}{
{IPv4, 12, 1, []byte{10}, "10.0.0.0/8"},
{IPv4, 12, 2, []byte{192, 168}, "192.168.0.0/16"},
{IPv6, 8, 4, []byte{0x20, 0x01, 0x0d, 0xb8}, "2001:db8::/32"},
} {
nr := &nftables.Rule{Exprs: []expr.Any{
&expr.Meta{Key: expr.MetaKeyNFPROTO, Register: 1},
&expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: []byte{f.nfprotoByte(c.fam)}},
&expr.Payload{DestRegister: 1, Base: expr.PayloadBaseNetworkHeader, Offset: c.offset, Len: c.length},
&expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: c.data},
&expr.Verdict{Kind: expr.VerdictDrop},
}}
got, err := f.UnmarshalRule(nr, "input", newStagedSetReader(), f.tableRef())
require.NoError(t, err)
require.Equal(t, c.want, got.Source)
}
}
// A row carrying a construct the Rule model cannot hold must fail to decode, so
// the caller keeps it as an opaque slot instead of misrepresenting it.
func TestNFTUnmodelledRowsRejected(t *testing.T) {
f := &NFT{table: "go_firewall"}
sets := newStagedSetReader()
cases := map[string][]expr.Any{
"unknown l4proto": {
&expr.Meta{Key: expr.MetaKeyL4PROTO, Register: 1},
&expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: []byte{2}},
&expr.Verdict{Kind: expr.VerdictAccept},
},
"jump verdict": {
&expr.Verdict{Kind: expr.VerdictJump, Chain: "other"},
},
"unmodelled expression": {
&expr.Quota{Bytes: 100},
&expr.Verdict{Kind: expr.VerdictAccept},
},
"address without a family guard": {
&expr.Payload{DestRegister: 1, Base: expr.PayloadBaseNetworkHeader, Offset: 12, Len: 4},
&expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: []byte{1, 2, 3, 4}},
&expr.Verdict{Kind: expr.VerdictAccept},
},
"under-limit connection count": {
&expr.Connlimit{Count: 5},
&expr.Verdict{Kind: expr.VerdictAccept},
},
"no verdict at all": {
&expr.Counter{},
},
}
for name, exprs := range cases {
_, err := f.UnmarshalRule(&nftables.Rule{Exprs: exprs}, "input", sets, f.tableRef())
require.Error(t, err, "expected %s to stay opaque", name)
}
}
// An ip or ip6 table is itself the family qualifier, so an operator's rules in
// one carry no nfproto match. Reading those rows against the table's family is
// what keeps a foreign address match — a literal or an address set — out of the
// opaque bucket, and what reports the one family the row can ever match.
func TestNFTForeignTableFamilyFromTable(t *testing.T) {
f := &NFT{table: "go_firewall"}
sets := newStagedSetReader()
for _, c := range []struct {
name string
tbl *nftables.Table
offset uint32
length uint32
data []byte
want *Rule
}{
{
name: "ip table address match", offset: 12, length: 4, data: []byte{192, 0, 2, 10},
tbl: &nftables.Table{Family: nftables.TableFamilyIPv4, Name: "filter"},
want: &Rule{Family: IPv4, Source: "192.0.2.10", Action: Accept},
},
{
name: "ip6 table address match", offset: 8, length: 16,
data: net.ParseIP("2001:db8::1").To16(),
tbl: &nftables.Table{Family: nftables.TableFamilyIPv6, Name: "filter"},
want: &Rule{Family: IPv6, Source: "2001:db8::1", Action: Accept},
},
} {
nr := &nftables.Rule{Exprs: []expr.Any{
&expr.Payload{DestRegister: 1, Base: expr.PayloadBaseNetworkHeader, Offset: c.offset, Len: c.length},
&expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: c.data},
&expr.Verdict{Kind: expr.VerdictAccept},
}}
got, err := f.UnmarshalRule(nr, "input", sets, c.tbl)
require.NoError(t, err, c.name)
require.Equal(t, c.want.Family, got.Family, c.name)
require.Equal(t, c.want.Source, got.Source, c.name)
}
// A set reference is the same case: the set's own family is not needed to read
// the row, because the table already pinned it.
nr := &nftables.Rule{Exprs: []expr.Any{
&expr.Payload{DestRegister: 1, Base: expr.PayloadBaseNetworkHeader, Offset: 12, Len: 4},
&expr.Lookup{SourceRegister: 1, SetName: "allowlist"},
&expr.Verdict{Kind: expr.VerdictAccept},
}}
got, err := f.UnmarshalRule(nr, "input", sets, &nftables.Table{Family: nftables.TableFamilyIPv4, Name: "filter"})
require.NoError(t, err)
require.Equal(t, IPv4, got.Family)
require.Equal(t, "allowlist", got.Source)
// A row with no family evidence at all still takes the table's family: an ip
// table can only ever match IPv4.
nr = &nftables.Rule{Exprs: []expr.Any{
&expr.Meta{Key: expr.MetaKeyL4PROTO, Register: 1},
&expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: []byte{6}},
&expr.Payload{DestRegister: 1, Base: expr.PayloadBaseTransportHeader, Offset: 2, Len: 2},
&expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: binaryutil.BigEndian.PutUint16(8123)},
&expr.Verdict{Kind: expr.VerdictAccept},
}}
got, err = f.UnmarshalRule(nr, "input", sets, &nftables.Table{Family: nftables.TableFamilyIPv4, Name: "filter"})
require.NoError(t, err)
require.Equal(t, IPv4, got.Family)
// The backend's own inet table settles nothing, so an unguarded address match
// there stays opaque (see TestNFTUnmodelledRowsRejected).
fam, ok := f.familyForTable(f.tableRef())
require.False(t, ok)
require.Equal(t, FamilyAny, fam)
}
// nftables reports the default burst of 5 on every limit even when none was
// asked for, so the default must read back as unset or a rule never matches its
// own read-back and Sync re-adds it forever.
func TestNFTRateBurstDefaultNormalized(t *testing.T) {
f := &NFT{table: "go_firewall"}
got := nftRoundTrip(t, f, &Rule{Proto: TCP, Port: 22, RateLimit: &RateLimit{Rate: 5, Unit: PerMinute}, Action: Accept})
require.NotNil(t, got.RateLimit)
require.Zero(t, got.RateLimit.Burst, "the netfilter default burst must read as unset")
// An explicit burst of something other than the default survives intact.
got = nftRoundTrip(t, f, &Rule{Proto: TCP, Port: 22, RateLimit: &RateLimit{Rate: 5, Unit: PerHour, Burst: 20}, Action: Accept})
require.Equal(t, uint(20), got.RateLimit.Burst)
require.Equal(t, PerHour, got.RateLimit.Unit)
}
// The counters a listed rule carries are reported onto the rule but are not part
// of its identity.
func TestNFTCounters(t *testing.T) {
f := &NFT{table: "go_firewall"}
nr := &nftables.Rule{Exprs: []expr.Any{
&expr.Meta{Key: expr.MetaKeyL4PROTO, Register: 1},
&expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: []byte{6}},
&expr.Payload{DestRegister: 1, Base: expr.PayloadBaseTransportHeader, Offset: 2, Len: 2},
&expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: binaryutil.BigEndian.PutUint16(22)},
&expr.Counter{Packets: 42, Bytes: 336},
&expr.Verdict{Kind: expr.VerdictAccept},
}}
r, err := f.UnmarshalRule(nr, "input", newStagedSetReader(), f.tableRef())
require.NoError(t, err)
require.Equal(t, uint64(42), r.Packets)
require.Equal(t, uint64(336), r.Bytes)
require.True(t, r.EqualBase(&Rule{Proto: TCP, Port: 22, Action: Accept}, true),
"counters must not be part of rule identity: %+v", r)
}
// A comment and a log prefix ride in user data and a log expression rather than
// a quoted string, so characters the textual interface could never carry now
// round-trip verbatim.
func TestNFTCommentAndPrefixVerbatim(t *testing.T) {
f := &NFT{table: "go_firewall"}
for _, s := range []string{`has "quotes"`, "has # hash", " leading and trailing ", `back\slash`} {
got := nftRoundTrip(t, f, &Rule{Proto: TCP, Port: 22, Comment: s, Action: Accept})
require.Equal(t, s, got.Comment, "comment must survive verbatim")
got = nftRoundTrip(t, f, &Rule{Proto: TCP, Port: 22, Log: true, LogPrefix: s, Action: Accept})
require.Equal(t, s, got.LogPrefix, "log prefix must survive verbatim")
}
// Both are length-capped by nftables, so an over-long value is rejected up
// front rather than being silently truncated by the kernel.
require.Error(t, f.validateRule(&Rule{Action: Accept, Comment: string(make([]byte, nftCommentMax+1))}))
require.Error(t, f.validateRule(&Rule{Action: Accept, Log: true, LogPrefix: string(make([]byte, nftLogPrefixMax+1))}))
}
// Shapes nftables cannot express must be rejected by validateRule, which the
// encoding entry points run, rather than producing a rule that would not read
// back as written.
func TestNFTMarshalRejections(t *testing.T) {
f := &NFT{table: "go_firewall"}
cases := map[string]*Rule{
"port without a protocol": {Port: 80, Proto: ProtocolAny, Action: Accept},
"input interface on an output rule": {Direction: DirOutput, InInterface: "eth0", Action: Accept},
"output interface on an input rule": {OutInterface: "eth0", Action: Accept},
"no action": {Proto: TCP, Port: 22},
}
for name, r := range cases {
require.Error(t, f.validateRule(r), "expected %s to be rejected", name)
}
}
// Every NAT kind must survive an encode/decode round trip.
func TestNFTNATRoundTrip(t *testing.T) {
f := &NFT{table: "go_firewall"}
rules := []*NATRule{
{Kind: DNAT, Proto: TCP, Port: 80, ToAddress: "192.168.1.2"},
{Kind: DNAT, Proto: TCP, Port: 80, ToAddress: "192.168.1.2", ToPort: 8080},
{Kind: DNAT, Proto: UDP, Ports: []PortRange{{Start: 5000, End: 5100}}, ToAddress: "192.168.1.2"},
{Kind: DNAT, Proto: TCP, Port: 80, ToAddress: "2001:db8::1", ToPort: 8080},
{Kind: SNAT, Proto: TCP, Interface: "eth0", ToAddress: "1.2.3.4"},
{Kind: SNAT, Interface: "eth0", ToAddress: "2001:db8::1"},
{Kind: Redirect, Proto: TCP, Port: 80, ToPort: 8080},
{Kind: Masquerade, Interface: "eth0"},
{Kind: Masquerade},
{Kind: DNAT, Family: IPv4, Source: "10.0.0.0/8", Proto: TCP, Port: 80, ToAddress: "192.168.1.2"},
{Kind: SNAT, Proto: SCTP, Interface: "eth0", ToAddress: "1.2.3.4"},
}
for _, r := range rules {
enc, err := f.MarshalNATRule(r)
require.NoError(t, err, "failed to marshal %+v", *r)
got, err := f.UnmarshalNATRule(
&nftables.Rule{Exprs: enc.exprs}, newStagedSetReader(enc), f.tableRef())
require.NoError(t, err, "failed to decode %+v", *r)
require.True(t, got.EqualBase(r), "round-trip mismatch: input %+v, output %+v", *r, *got)
}
// DNAT lands in prerouting, SNAT in postrouting.
enc, err := f.MarshalNATRule(&NATRule{Kind: DNAT, Proto: TCP, Port: 80, ToAddress: "1.2.3.4"})
require.NoError(t, err)
require.Equal(t, "prerouting", enc.chain)
enc, err = f.MarshalNATRule(&NATRule{Kind: SNAT, ToAddress: "1.2.3.4"})
require.NoError(t, err)
require.Equal(t, "postrouting", enc.chain)
}
// A family-agnostic NAT rule is one unpinned row covering both families, so it
// must not acquire a family guard on the way out.
func TestNFTFamilyAnyNATIsDualStack(t *testing.T) {
f := &NFT{table: "go_firewall"}
enc, err := f.MarshalNATRule(&NATRule{Kind: Masquerade, Interface: "eth0"})
require.NoError(t, err)
for _, e := range enc.exprs {
if m, ok := e.(*expr.Meta); ok {
require.NotEqual(t, expr.MetaKeyNFPROTO, m.Key,
"a family-agnostic masquerade must stay unpinned")
}
}
}
// nftables' snat expression carries no port, so a source-port translation is a
// shape this backend genuinely cannot express and must report as unsupported
// rather than silently dropping the port.
func TestNFTNATRejections(t *testing.T) {
f := &NFT{table: "go_firewall"}
err := f.validateNAT(&NATRule{Kind: SNAT, Proto: TCP, ToAddress: "1.2.3.4", ToPort: 8080})
require.ErrorIs(t, err, ErrUnsupportedNAT, "snat cannot translate the source port")
// A CIDR translation target has no single address to rewrite to; the encoder
// rejects it while building the nat expression.
_, err = f.MarshalNATRule(&NATRule{Kind: DNAT, Proto: TCP, Port: 80, ToAddress: "10.0.0.0/8"})
require.Error(t, err, "a translation target must be a single address")
}
// An address set's entries are stored as element keys — discrete addresses in a
// plain set, boundary markers in an interval set — and must be rendered back as
// the CIDR or range they came from.
func TestNFTAddressSetElements(t *testing.T) {
f := new(NFT)
// A discrete set holds one element per address.
elems, err := f.setElements("1.2.3.4", false)
require.NoError(t, err)
require.Len(t, elems, 1)
require.Equal(t, []byte{1, 2, 3, 4}, elems[0].Key)
// A range cannot be stored in a discrete set.
_, err = f.setElements("10.0.0.0/8", false)
require.Error(t, err)
// An interval set stores the inclusive start and an exclusive end marker.
elems, err = f.setElements("10.0.0.0/8", true)
require.NoError(t, err)
require.Len(t, elems, 2)
require.Equal(t, []byte{10, 0, 0, 0}, elems[0].Key)
require.Equal(t, []byte{11, 0, 0, 0}, elems[1].Key)
require.True(t, elems[1].IntervalEnd)
// Round-trip each entry form through the element encoding and back.
for _, c := range []struct {
entry string
interval bool
}{
{"1.2.3.4", false},
{"2001:db8::1", false},
{"10.0.0.0/8", true},
{"192.168.1.0/24", true},
{"2001:db8::/32", true},
{"10.0.0.1-10.0.0.9", true},
} {
els, eerr := f.setElements(c.entry, c.interval)
require.NoError(t, eerr, "encoding %q", c.entry)
got := f.addressSetEntries(&nftSetContents{
set: &nftables.Set{Interval: c.interval},
elements: els,
})
require.Equal(t, []string{c.entry}, got, "round-trip of %q", c.entry)
}
}
// A dynamic set is connection-limit counting state and an anonymous set is a
// rule's own inline literal; neither is a caller-managed address set.
func TestNFTAddressSetKeyTypes(t *testing.T) {
f := new(NFT)
kt, err := f.setKeyType(IPv4)
require.NoError(t, err)
require.Equal(t, nftables.TypeIPAddr, kt)
require.Equal(t, IPv4, f.familyForKeyType(kt))
kt, err = f.setKeyType(IPv6)
require.NoError(t, err)
require.Equal(t, nftables.TypeIP6Addr, kt)
require.Equal(t, IPv6, f.familyForKeyType(kt))
// An nftables set carries a single address type, so an unspecified family
// resolves to IPv4 rather than failing.
kt, err = f.setKeyType(FamilyAny)
require.NoError(t, err)
require.Equal(t, nftables.TypeIPAddr, kt)
}
// An interface name is compared against a fixed-width NUL-padded buffer, while a
// trailing '*' makes it a prefix match against just the leading characters.
func TestNFTInterfaceEncoding(t *testing.T) {
f := new(NFT)
exact := f.ifnameBytes("eth0")
require.Len(t, exact, 16, "an exact interface match is fixed width")
require.Equal(t, "eth0", f.ifnameString(exact))
wild := f.ifnameBytes("eth*")
require.Equal(t, []byte("eth"), wild, "a wildcard compares only the prefix")
require.Equal(t, "eth*", f.ifnameString(wild))
}
// The table name is derived from the rule prefix and must be a valid nftables
// identifier, which cannot begin with a digit.
func TestNFTSanitizeName(t *testing.T) {
require.Equal(t, "fw_1fw", sanitizeNFTName("1fw"))
require.Equal(t, "go_firewall", sanitizeNFTName(""))
require.Equal(t, "go_firewall", sanitizeNFTName("!!!"))
require.Equal(t, "my_fw", sanitizeNFTName("my-fw"))
require.Equal(t, "my_fw", sanitizeNFTName("my.fw"))
}
// A concrete-family removal of a merged row must keep the coverage the caller
// never named, across both the family and the transport axis.
func TestNFTSplitMergedRowTwoAxes(t *testing.T) {
f := &NFT{table: "go_firewall"}
// A single row covering both families and both transports.
merged := &Rule{Proto: TCPUDP, Port: 53, Action: Accept}
// Removing only the IPv4 TCP half leaves three cells behind.
target := &Rule{Family: IPv4, Proto: TCP, Port: 53, Action: Accept}
remainder := splitMergedRow(merged, target)
require.NotEmpty(t, remainder, "removing one cell must leave the rest in place")
// Every remainder must still be expressible, or the removal would fail
// halfway through and drop coverage it meant to keep.
for _, r := range remainder {
_, err := f.MarshalRule(r)
require.NoError(t, err, "remainder %+v must be expressible", *r)
}
}

2300
pf.go Normal file

File diff suppressed because it is too large Load diff

831
pf_test.go Normal file
View file

@ -0,0 +1,831 @@
//go:build darwin || freebsd
package firewall
import (
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"github.com/stretchr/testify/require"
)
// TestPFICMP6TypeNameParse verifies that an icmp6-type printed by name resolves
// through the ICMPv6 table: pfctl reuses ICMPv4 spellings (e.g. echoreq) for
// different ICMPv6 numbers, so the ICMPv4 table would decode it wrongly.
func TestPFICMP6TypeNameParse(t *testing.T) {
f := &PF{anchor: "go_firewall"}
// echoreq is ICMPv6 type 128 (it is 8 under ICMPv4).
line, err := f.MarshalRule(&Rule{Family: IPv6, Proto: ICMPv6, ICMPType: Ptr[uint8](128), Action: Accept})
require.NoError(t, err)
named := strings.Replace(line, "icmp6-type 128", "icmp6-type echoreq", 1)
require.NotEqual(t, line, named, "marshaled rule should contain the numeric icmp6 type")
got, err := f.UnmarshalRule(named)
require.NoError(t, err)
require.NotNil(t, got.ICMPType)
require.Equal(t, uint8(128), *got.ICMPType, "echoreq must resolve to ICMPv6 type 128, not the ICMPv4 8")
// An ICMPv4 rule must still resolve echoreq to 8.
line4, err := f.MarshalRule(&Rule{Family: IPv4, Proto: ICMP, ICMPType: Ptr[uint8](8), Action: Accept})
require.NoError(t, err)
named4 := strings.Replace(line4, "icmp-type 8", "icmp-type echoreq", 1)
got4, err := f.UnmarshalRule(named4)
require.NoError(t, err)
require.NotNil(t, got4.ICMPType)
require.Equal(t, uint8(8), *got4.ICMPType)
}
// TestPFTranslationBoundary verifies the nat/rdr anchors are inserted before the
// first filtering statement and AFTER any queueing (altq/queue) section — pf.conf
// sections are strictly ordered options → normalization → queueing → translation
// → filtering, so treating a queueing keyword as the boundary would splice the
// anchors ahead of it and produce a ruleset pfctl -f rejects.
func TestPFTranslationBoundary(t *testing.T) {
fw := new(PF)
// altq/queue precede the first pass/block; the boundary must be the pass line.
conf := []string{
"set skip on lo",
"scrub in all",
`altq on em0 bandwidth 100Mb hfsc queue { q_def }`,
`queue q_def bandwidth 100% hfsc(default)`,
"pass out all",
"block in all",
}
require.Equal(t, 4, fw.translationBoundary(conf),
"anchors must go after the queueing section, at the first pass/block")
// No filtering statements: append at the end.
require.Equal(t, 2, fw.translationBoundary([]string{"set skip on lo", "scrub in all"}))
// antispoof and anchor also open the filtering section.
require.Equal(t, 0, fw.translationBoundary([]string{"antispoof for em0"}))
require.Equal(t, 1, fw.translationBoundary([]string{"scrub in all", `anchor "foo"`}))
}
// TestPFHighICMPTypeNames verifies the high ICMPv4 type names pfctl prints (31-40)
// round-trip: MarshalRule emits the numeric type, pfctl re-spells it by name on
// -sr, and UnmarshalRule must resolve that name back to the number.
func TestPFHighICMPTypeNames(t *testing.T) {
f := &PF{anchor: "go_firewall"}
for name, num := range map[string]uint8{"photuris": 40, "skip": 39, "mobregreq": 35, "ipv6-where": 33} {
line, err := f.MarshalRule(&Rule{Family: IPv4, Proto: ICMP, ICMPType: Ptr(num), Action: Accept})
require.NoError(t, err)
named := strings.Replace(line, "icmp-type "+strconv.Itoa(int(num)), "icmp-type "+name, 1)
require.NotEqual(t, line, named, "expected numeric icmp-type in %q", line)
got, err := f.UnmarshalRule(named)
require.NoError(t, err, "pfctl name %q (type %d) must parse", name, num)
require.NotNil(t, got.ICMPType)
require.Equal(t, num, *got.ICMPType, "%s must resolve to %d", name, num)
}
}
// TestPFProtocolAndComment round-trips the added protocols and a rule comment
// (a pf label) through the pf rule encoder.
func TestPFProtocolAndComment(t *testing.T) {
f := &PF{anchor: "go_firewall"}
cases := []*Rule{
{Family: IPv4, Proto: SCTP, Port: 9000, Action: Accept},
{Family: IPv4, Proto: GRE, Action: Accept},
{Family: IPv4, Proto: ESP, Action: Accept},
{Family: IPv4, Proto: TCP, Port: 22, Action: Accept, Comment: "ssh access"},
}
for _, orig := range cases {
line, err := f.MarshalRule(orig)
require.NoError(t, err)
got, err := f.UnmarshalRule(line)
require.NoError(t, err, "line %q", line)
require.True(t, got.EqualBase(orig, true), "line %q: want %+v got %+v", line, orig, got)
require.Equal(t, orig.Comment, got.Comment, "line %q comment", line)
}
}
// TestPFAnchorPreservesUnmodeled verifies parseAnchorRules keeps a rule line it
// cannot model as an opaque row (nil rule, raw text preserved) instead of dropping
// it, so a read-modify-write rewrite of our anchor does not silently delete a
// foreign rule loaded into it. The rules slice stays 1:1 with raw so the physical
// row edits (insert/move/remove) never misalign.
func TestPFAnchorPreservesUnmodeled(t *testing.T) {
fw := &PF{anchor: "go_firewall"}
// The middle line uses a pf port operator (port > 1023) this backend does not
// model, so it cannot become a Rule; the two surrounding lines are modeled.
out := []string{
"pass in quick inet proto tcp from any to any port = 22 keep state",
"pass in quick inet proto tcp from any to any port > 1023 keep state",
"pass out quick inet proto udp from any to any port = 53 keep state",
}
rules, raw := fw.parseAnchorRules(out)
require.Len(t, rules, 3, "every physical row needs a slot, the opaque one included")
require.Len(t, raw, 3, "raw must stay 1:1 with rules")
require.NotNil(t, rules[0])
require.Nil(t, rules[1], "the unmodeled line must be an opaque (nil) row")
require.NotNil(t, rules[2])
require.Equal(t, out[1], raw[1], "the unmodeled line's text must be preserved verbatim")
}
// TestPFReorderRowsKeepsOpaque verifies the move/remove row rebuild keeps an opaque
// (nil) row in place and maps the target position to the correct physical index past
// it, so relocating a modeled rule never drops or displaces a foreign line sharing
// our anchor.
func TestPFReorderRowsKeepsOpaque(t *testing.T) {
fw := new(PF)
ruleA := &Rule{Family: IPv4, Port: 22, Proto: TCP, Action: Accept}
ruleB := &Rule{Family: IPv4, Port: 53, Proto: UDP, Action: Accept}
rules := []*Rule{ruleA, nil, ruleB}
raw := []string{"lineA", "opaque", "lineB"}
// Move ruleB (a distinct rule) to the front.
out, moved := fw.reorderRows(rules, raw, ruleB, 1)
require.True(t, moved)
require.Equal(t, []string{"lineB", "lineA", "opaque"}, out,
"the opaque line must be kept; only the modeled rule relocates")
}
func TestPFRules(t *testing.T) {
fw := &PF{anchor: "go_firewall"}
// Marshal a representative rule and confirm the pf rule line.
line, err := fw.MarshalRule(&Rule{
Family: IPv4,
Source: "192.168.0.0/24",
Port: 23,
Proto: UDP,
Action: Accept,
})
require.NoError(t, err)
require.Equal(t, "pass in quick inet proto udp from 192.168.0.0/24 to any port 23", line,
"unexpected rule line")
// The normalized form emitted by `pfctl -sr` must parse back to an
// equivalent rule.
rule, err := fw.UnmarshalRule("pass in quick inet proto udp from 192.168.0.0/24 to any port = 23 keep state")
require.NoError(t, err)
want := &Rule{Family: IPv4, Source: "192.168.0.0/24", Port: 23, Proto: UDP, Action: Accept}
require.True(t, rule.Equal(want, true), "parsed rule does not match: got %+v", rule)
// Round-trip the rules we typically set across directions, families and
// actions.
rules := []*Rule{
{Family: IPv4, Port: 4789, Proto: UDP, Action: Accept},
{Direction: DirOutput, Family: IPv6, Port: 4789, Proto: UDP, Action: Accept},
{Family: IPv4, Source: "203.0.113.10", Port: 4789, Proto: TCP, Action: Accept},
{Direction: DirOutput, Family: IPv4, Destination: "203.0.113.10", Port: 4791, Proto: TCP, Action: Reject},
{Family: IPv6, Source: "!2001:db8::1", Action: Drop},
// A non-address Source/Destination names a pf table, referenced as <name>.
{Family: IPv4, Source: "blocklist", Port: 22, Proto: TCP, Action: Drop},
{Direction: DirOutput, Family: IPv4, Destination: "!allowlist", Port: 80, Proto: TCP, Action: Accept},
}
for _, r := range rules {
line, err := fw.MarshalRule(r)
require.NoError(t, err, "failed to marshal %+v", *r)
parsed, err := fw.UnmarshalRule(line)
require.NoError(t, err, "failed to parse %q", line)
require.True(t, parsed.Equal(r, true),
"round-trip mismatch: input %+v, line %q, output %+v", *r, line, parsed)
}
// A non-address Source is emitted as a pf table reference in angle brackets.
setLine, err := fw.MarshalRule(&Rule{Family: IPv4, Source: "blocklist", Port: 22, Proto: TCP, Action: Drop})
require.NoError(t, err)
require.Contains(t, setLine, "from <blocklist>")
// Invalid lines must be rejected.
invalidRules := []string{
"pass in quick inet proto foo from any to any",
"frobnicate in quick from any to any",
"pass sideways quick from any to any",
}
for _, line := range invalidRules {
_, err := fw.UnmarshalRule(line)
require.Error(t, err, "line parsed when it should be invalid: %s", line)
}
// A port without a concrete protocol cannot be expressed in pf.
require.Error(t, fw.validateRule(&Rule{Port: 80, Proto: ProtocolAny, Action: Accept}),
"expected a port with no protocol to be rejected")
// A single source port and a contiguous source-port range round-trip and are
// accepted; a discrete source-port list does not round-trip (pfctl expands it),
// so it is rejected rather than emitted.
require.NoError(t, fw.validateRule(&Rule{Proto: TCP, SourcePort: 1024, Action: Accept}),
"a single source port is valid")
require.NoError(t, fw.validateRule(&Rule{Proto: TCP, SourcePorts: []PortRange{{Start: 1024, End: 2048}}, Action: Accept}),
"a source-port range is valid")
require.Error(t, fw.validateRule(&Rule{Proto: TCP, SourcePorts: []PortRange{{Start: 80, End: 80}, {Start: 443, End: 443}}, Action: Accept}),
"a discrete source-port list must be rejected")
}
// pf exposes per-rule counters through `pfctl -vsr`, which prints a
// `[ Evaluations: N Packets: N Bytes: N States: N ]` continuation line under
// each rule (and may prefix rules with a @N number in a verbose listing).
// parseAnchorRules attaches those counters to the preceding rule.
func TestPFRuleCounters(t *testing.T) {
fw := &PF{anchor: "go_firewall"}
out := []string{
"@0 pass in quick proto tcp from any to any port = 22",
" [ Evaluations: 100 Packets: 40 Bytes: 2400 States: 2 ]",
" [ Inserted: uid 0 pid 1 State Creations: 2 ]",
"pass in quick proto udp from any to any port = 53",
" [ Evaluations: 5 Packets: 5 Bytes: 300 States: 0 ]",
}
rules, raw := fw.parseAnchorRules(out)
require.Len(t, rules, 2, "expected two rules parsed")
require.Len(t, raw, 2, "raw must exclude the continuation lines")
// The @N prefix is stripped so the raw text stays loadable by pfctl -f.
require.NotContains(t, raw[0], "@0", "the rule-number prefix must be stripped: %q", raw[0])
require.EqualValues(t, 40, rules[0].Packets)
require.EqualValues(t, 2400, rules[0].Bytes)
require.EqualValues(t, 22, rules[0].Port)
require.EqualValues(t, 5, rules[1].Packets)
require.EqualValues(t, 300, rules[1].Bytes)
// The counter parser only fires on a line that carries both counters.
p, b, ok := fw.parseRuleCounters("[ Evaluations: 1 Packets: 7 Bytes: 500 States: 0 ]")
require.True(t, ok)
require.EqualValues(t, 7, p)
require.EqualValues(t, 500, b)
_, _, ok = fw.parseRuleCounters("[ Inserted: uid 0 pid 1 State Creations: 2 ]")
require.False(t, ok, "a non-counter continuation line must not report counters")
}
func TestPFFeatureRules(t *testing.T) {
fw := &PF{anchor: "go_firewall"}
// Confirm representative encodings.
cases := []struct {
rule *Rule
want string
}{
{&Rule{Proto: ICMP, Action: Accept}, "pass in quick inet proto icmp from any to any"},
{&Rule{Proto: ICMPv6, Action: Accept}, "pass in quick inet6 proto icmp6 from any to any"},
{&Rule{Proto: ICMP, ICMPType: Ptr[uint8](8), Action: Accept}, "pass in quick inet proto icmp from any to any icmp-type 8"},
{&Rule{Proto: ICMPv6, ICMPType: Ptr[uint8](135), Action: Accept}, "pass in quick inet6 proto icmp6 from any to any icmp6-type 135"},
{&Rule{Proto: UDP, Ports: []PortRange{{Start: 1000, End: 2000}}, Action: Accept}, "pass in quick proto udp from any to any port 1000:2000"},
{&Rule{InInterface: "em0", Proto: TCP, Port: 22, Action: Accept}, "pass in quick on em0 proto tcp from any to any port 22"},
{&Rule{Direction: DirOutput, OutInterface: "em1", Action: Drop}, "block drop out quick on em1 from any to any"},
}
for _, c := range cases {
got, err := fw.MarshalRule(c.rule)
require.NoError(t, err, "failed to marshal %+v", *c.rule)
require.Equal(t, c.want, got, "marshal %+v", *c.rule)
}
// Round-trip every new-feature rule shape.
rules := []*Rule{
{Proto: ICMP, Action: Accept},
{Proto: ICMPv6, Action: Drop},
{Family: IPv6, Proto: ICMPv6, Action: Accept},
{Proto: ICMP, ICMPType: Ptr[uint8](8), Action: Accept},
{Family: IPv6, Proto: ICMPv6, ICMPType: Ptr[uint8](135), Action: Accept},
{Proto: UDP, Ports: []PortRange{{Start: 1000, End: 2000}}, Action: Accept},
{InInterface: "em0", Proto: TCP, Port: 22, Action: Accept},
{Direction: DirOutput, OutInterface: "em1", Proto: UDP, Port: 53, Action: Accept},
}
for _, r := range rules {
line, err := fw.MarshalRule(r)
require.NoError(t, err, "failed to marshal %+v", *r)
parsed, err := fw.UnmarshalRule(line)
require.NoError(t, err, "failed to parse %q", line)
require.True(t, parsed.Equal(r, true),
"round-trip mismatch: input %+v, line %q, output %+v", *r, line, parsed)
}
// A discrete destination-port list has no single-row pf form: pfctl expands
// `port { 80 443 }` into one rule per port on load, so validateRule rejects it
// and AddRule fans the list into one row per port with expandPorts instead.
require.ErrorIs(t, fw.validateRule(&Rule{Proto: TCP, Ports: []PortRange{{Start: 80}, {Start: 443}}, Action: Accept}),
ErrUnsupported, "a destination-port list must be rejected under pf")
// pf cannot express a connection-state match in this model.
require.Error(t, fw.validateRule(&Rule{Proto: TCP, Port: 22, State: StateEstablished, Action: Accept}),
"expected a state match to be rejected under pf")
// Interface/direction mismatches must be rejected.
require.Error(t, fw.validateRule(&Rule{Direction: DirOutput, InInterface: "em0", Action: Accept}),
"expected an input interface on an output rule to be rejected")
require.Error(t, fw.validateRule(&Rule{OutInterface: "em0", Action: Accept}),
"expected an output interface on an input rule to be rejected")
// pf has no distinct forward chain, so a forward rule is rejected with the
// ErrUnsupportedForward sentinel.
require.ErrorIs(t, fw.validateRule(&Rule{Direction: DirForward, Proto: TCP, Port: 22, Action: Accept}),
ErrUnsupportedForward, "a forward rule must be rejected")
}
func TestPFLogLimitRoundTrip(t *testing.T) {
fw := &PF{anchor: "go_firewall"}
cases := []*Rule{
{Family: IPv4, Port: 22, Proto: TCP, Action: Accept, Log: true},
{Family: IPv4, Port: 22, Proto: TCP, Action: Accept,
ConnLimit: &ConnLimit{Count: 100, PerSource: true},
RateLimit: &RateLimit{Rate: 15, Unit: PerSecond}},
{Family: IPv4, Port: 22, Proto: TCP, Action: Accept,
RateLimit: &RateLimit{Rate: 10, Unit: PerMinute}},
}
for _, orig := range cases {
line, err := fw.MarshalRule(orig)
require.NoError(t, err)
got, err := fw.UnmarshalRule(line)
require.NoError(t, err, "line %q", line)
require.True(t, got.EqualBase(orig, true), "line %q: want %+v got %+v", line, orig, got)
}
// pf has no log prefix, and limits require an accept rule.
require.Error(t, fw.validateRule(&Rule{Family: IPv4, Proto: TCP, Port: 22, Action: Accept, Log: true, LogPrefix: "x"}),
"expected pf to reject a log prefix")
require.Error(t, fw.validateRule(&Rule{Family: IPv4, Proto: TCP, Port: 22, Action: Drop, RateLimit: &RateLimit{Rate: 1, Unit: PerSecond}}),
"expected pf to reject a limit on a non-accept rule")
require.Error(t, fw.validateRule(&Rule{Family: IPv4, Proto: TCP, Port: 22, Action: Accept, ConnLimit: &ConnLimit{Count: 5, PerSource: false}}),
"expected pf to reject a non-per-source connection limit")
}
func TestPFNATRoundTrip(t *testing.T) {
fw := &PF{anchor: "go_firewall"}
cases := []*NATRule{
{Kind: DNAT, Family: IPv4, Proto: TCP, Port: 80, ToAddress: "10.0.0.5", ToPort: 8080, Interface: "em0"},
{Kind: SNAT, Family: IPv4, Source: "10.0.0.0/24", ToAddress: "1.2.3.4", Interface: "em0"},
{Kind: Masquerade, Family: IPv4, Interface: "em0"},
}
for _, orig := range cases {
line, err := fw.MarshalNATRule(orig)
require.NoError(t, err)
got, err := fw.UnmarshalNATRule(line)
require.NoError(t, err, "line %q", line)
require.True(t, got.EqualBase(orig), "line %q: want %+v got %+v", line, orig, got)
}
// pfctl prints a well-known translation port by its /etc/services name, e.g. a
// DNAT to port 80 reads back as `-> 10.0.0.5 port http`. The target port must
// resolve through the service-name lookup like a match port; a number-only
// parse fails and anchorNATRules silently drops the rule from the snapshot.
named, err := fw.UnmarshalNATRule("rdr on em0 inet proto tcp from any to any port www -> 10.0.0.5 port http")
require.NoError(t, err, "a named nat target port must parse")
require.Equal(t, uint16(80), named.ToPort, "named target port http must resolve to 80")
// pf has no portless redirect and masquerade needs an interface.
require.Error(t, fw.validateNAT(&NATRule{Kind: Redirect, Family: IPv4, Proto: TCP, Port: 80, ToPort: 8080}),
"expected pf to reject a redirect")
require.Error(t, fw.validateNAT(&NATRule{Kind: Masquerade, Family: IPv4}),
"expected pf masquerade to require an interface")
}
// pf's max-src-conn-rate has no burst term, so a rate limit carrying a non-zero
// Burst cannot be honored and must be rejected rather than marshaled into a rule
// that reads back with Burst 0 and fails rule-identity comparison.
func TestPFRateLimitBurstRejected(t *testing.T) {
fw := &PF{anchor: "go_firewall"}
require.ErrorIs(t, fw.validateRule(&Rule{Family: IPv4, Proto: TCP, Port: 22, Action: Accept,
RateLimit: &RateLimit{Rate: 10, Unit: PerMinute, Burst: 5}}),
ErrUnsupported, "a rate-limit burst must be rejected, not silently dropped")
// A burst-less rate limit still round-trips.
orig := &Rule{Family: IPv4, Proto: TCP, Port: 22, Action: Accept,
RateLimit: &RateLimit{Rate: 10, Unit: PerMinute}}
line, err := fw.MarshalRule(orig)
require.NoError(t, err)
got, err := fw.UnmarshalRule(line)
require.NoError(t, err)
require.True(t, got.EqualBase(orig, true), "line %q", line)
}
// A pf label (user comment) containing consecutive spaces must round-trip: the
// line tokenizer collapses whitespace, so the label is recovered from the raw
// line rather than the split tokens.
func TestPFLabelConsecutiveSpaces(t *testing.T) {
fw := &PF{anchor: "go_firewall"}
for _, comment := range []string{"web server", "a b c", `has "quote" and spaces`} {
orig := &Rule{Family: IPv4, Proto: TCP, Port: 22, Action: Accept, Comment: comment}
line, err := fw.MarshalRule(orig)
require.NoError(t, err)
got, err := fw.UnmarshalRule(line)
require.NoError(t, err, "line %q", line)
require.Equal(t, comment, got.Comment, "label whitespace must survive; line %q", line)
}
}
// A pf rule written per family lives in two anchor rows. RemoveRule/MoveRule must
// locate both from a FamilyAny target with EqualForRemoval, not the family-strict
// Equal — which matches neither, so the port stays open. The pf remove must not
// no-op on a family-agnostic target.
func TestPFFamilyAnyTargetMatchesBothTwins(t *testing.T) {
f := &PF{anchor: "go_firewall"}
v4, err := f.UnmarshalRule("pass in quick inet proto tcp from any to any port = 22")
require.NoError(t, err)
v6, err := f.UnmarshalRule("pass in quick inet6 proto tcp from any to any port = 22")
require.NoError(t, err)
// The two rows cover the family-agnostic rule between them.
target := &Rule{Family: FamilyAny, Proto: TCP, Port: 22, Action: Accept, Direction: DirInput}
require.True(t, target.CoveredBy([]*Rule{v4, v6}))
// The family-strict matcher finds neither physical row.
require.False(t, target.Equal(v4, true))
require.False(t, target.Equal(v6, true))
// EqualForRemoval finds both, so RemoveRule clears both anchor rows and MoveRule
// can locate the rule.
require.True(t, v4.EqualForRemoval(target, true))
require.True(t, v6.EqualForRemoval(target, true))
}
// MoveRule must relocate every physical row a target covers, not just the first.
// Moving only the first row of a v4/v6 pair leaves the twin at the earlier index —
// which then wins on the next read, making a move to a LATER position a silent no-op.
// reorderRows moves the covered rows as a block.
func TestPFReorderRowsFamilyPair(t *testing.T) {
fw := new(PF)
mk := func(fam Family, port uint16) *Rule {
return &Rule{Family: fam, Proto: TCP, Port: port, Action: Accept}
}
// Physical anchor rows: A is a v4/v6 pair (rows 0,1); B is a v4/v6 pair (rows 2,3).
// GetRules reports four rules, numbered 1..4.
rules := []*Rule{mk(IPv4, 22), mk(IPv6, 22), mk(IPv4, 80), mk(IPv6, 80)}
raw := []string{"A_v4", "A_v6", "B_v4", "B_v6"}
// Move both A rows past B. Once A is pulled out, two rows remain, so position 3 is
// past the end and appends.
out, moved := fw.reorderRows(rules, raw, mk(FamilyAny, 22), 3)
require.True(t, moved)
require.Equal(t, []string{"B_v4", "B_v6", "A_v4", "A_v6"}, out,
"both rows the target covers must move together, landing after B")
// Move both B rows up to the front.
out, moved = fw.reorderRows(rules, raw, mk(FamilyAny, 80), 1)
require.True(t, moved)
require.Equal(t, []string{"B_v4", "B_v6", "A_v4", "A_v6"}, out)
// A concrete-family target relocates only its own family row, never the twin.
// Three rows remain after A_v4 is pulled out, so position 4 appends.
out, moved = fw.reorderRows(rules, raw, mk(IPv4, 22), 4)
require.True(t, moved)
require.Equal(t, []string{"A_v6", "B_v4", "B_v6", "A_v4"}, out)
// A rule that matches nothing reports no move (MoveRule then skips the reload).
_, moved = fw.reorderRows(rules, raw, mk(FamilyAny, 443), 1)
require.False(t, moved)
}
// pf's nat translation maps to an address only; it has no source-port form, so a
// SNAT rule carrying a ToPort is rejected here rather than dropped silently. The
// shared validate accepts the shape, since iptables emits it as
// --to-source addr:port, so the rejection lives in pf's own validateNAT.
func TestPFMarshalRejectsSNATPort(t *testing.T) {
f := &PF{anchor: "go_firewall"}
require.ErrorIs(t, f.validateNAT(&NATRule{Kind: SNAT, Proto: TCP, ToAddress: "192.0.2.1", ToPort: 80}),
ErrUnsupportedNAT, "a source-port SNAT must be rejected")
// A portless SNAT still marshals.
require.NoError(t, f.validateNAT(&NATRule{Kind: SNAT, ToAddress: "192.0.2.1"}))
_, err := f.MarshalNATRule(&NATRule{Kind: SNAT, ToAddress: "192.0.2.1"})
require.NoError(t, err)
}
// expandProtocols fans a TCPUDP rule into a tcp rule and a udp rule, each of which
// marshals to a valid concrete-protocol pf line and round-trips. pf has no both-
// transports form, so the write path fans out before the row-level marshaller.
func TestPFExpandProtocolsMarshal(t *testing.T) {
f := &PF{anchor: "go_firewall"}
subs := expandProtocols(&Rule{Family: IPv4, Proto: TCPUDP, Port: 22, Action: Accept})
require.Len(t, subs, 2, "TCPUDP must fan into two concrete-transport rules")
require.Equal(t, TCP, subs[0].Proto)
require.Equal(t, UDP, subs[1].Proto)
for _, sub := range subs {
line, err := f.MarshalRule(sub)
require.NoError(t, err, "each fanned transport must marshal")
parsed, err := f.UnmarshalRule(line)
require.NoError(t, err, "line %q", line)
require.True(t, parsed.Equal(sub, true), "round-trip mismatch for %q", line)
}
}
// Every modeled anchor row is its own rule, so filterAnchors is the identity over
// them. An opaque (nil) row — an anchor line pf keeps but this backend cannot model —
// occupies a physical slot without consuming a logical position, so the rules after
// it must still map to their own physical rows.
func TestPFFilterAnchorsSkipOpaqueRows(t *testing.T) {
fw := new(PF)
mk := func(proto Protocol, port uint16) *Rule {
return &Rule{Family: IPv4, Proto: proto, Port: port, Action: Accept}
}
rules := []*Rule{mk(TCP, 22), mk(UDP, 22), mk(TCP, 80), mk(UDP, 80)}
require.Equal(t, []int{0, 1, 2, 3}, fw.filterAnchors(rules),
"every modeled row is its own anchor")
require.Equal(t, 1, fw.logicalInsertIndex(fw.filterAnchors(rules), len(rules), 2))
require.Equal(t, 4, fw.logicalInsertIndex(fw.filterAnchors(rules), len(rules), 5),
"a position past the last logical rule appends")
// An unmodeled line sits at physical row 1, shifting the rows after it.
withOpaque := []*Rule{mk(TCP, 22), nil, mk(UDP, 22), mk(TCP, 80)}
anchors := fw.filterAnchors(withOpaque)
require.Equal(t, []int{0, 2, 3}, anchors, "the opaque row consumes no logical position")
require.Equal(t, 2, fw.logicalInsertIndex(anchors, len(withOpaque), 2),
"logical rule 2 lives at physical row 2, past the opaque line")
}
// reorderRows must relocate every physical row a target covers together: pfctl stores
// tcp and udp as separate rows, so a caller moving a TCPUDP rule (matched via
// EqualForRemoval's protocol coverage) moves both. A concrete-transport target moves
// only its own transport row, never the twin's.
func TestPFReorderRowsTransportPair(t *testing.T) {
fw := new(PF)
mk := func(proto Protocol, port uint16) *Rule {
return &Rule{Family: IPv4, Proto: proto, Port: port, Action: Accept}
}
rules := []*Rule{mk(TCP, 22), mk(UDP, 22), mk(TCP, 80), mk(UDP, 80)}
raw := []string{"A_tcp", "A_udp", "B_tcp", "B_udp"}
// Move both A rows past B. Two rows remain once A is pulled out, so position 3
// appends.
out, moved := fw.reorderRows(rules, raw, mk(TCPUDP, 22), 3)
require.True(t, moved)
require.Equal(t, []string{"B_tcp", "B_udp", "A_tcp", "A_udp"}, out,
"both transport rows the target covers must move together, landing after B")
// A concrete-transport target relocates only its own transport row. Three rows
// remain, so position 4 appends.
out, moved = fw.reorderRows(rules, raw, mk(TCP, 22), 4)
require.True(t, moved)
require.Equal(t, []string{"A_udp", "B_tcp", "B_udp", "A_tcp"}, out)
}
// writeFileLines must preserve the original file's mode (not loosen it to 0644)
// and must not leave a fixed-name temp file behind.
func TestWriteFileLinesPreservesMode(t *testing.T) {
fw := new(PF)
dir := t.TempDir()
path := filepath.Join(dir, "pf.conf")
require.NoError(t, os.WriteFile(path, []byte("old\n"), 0600))
require.NoError(t, fw.writeFileLines(path, []string{"line1", "line2"}))
// Content replaced.
data, err := os.ReadFile(path)
require.NoError(t, err)
require.Equal(t, "line1\nline2\n", string(data))
// Mode preserved, not widened to 0644.
fi, err := os.Stat(path)
require.NoError(t, err)
require.Equal(t, os.FileMode(0600), fi.Mode().Perm(), "mode must be preserved")
// No stale fixed-name temp file (the old fixed "<path>.tmp" scheme) remains.
_, err = os.Stat(path + ".tmp")
require.True(t, os.IsNotExist(err), "fixed-name temp file must not linger")
// A brand-new file defaults to 0600 rather than 0644.
newPath := filepath.Join(dir, "new.conf")
require.NoError(t, fw.writeFileLines(newPath, []string{"x"}))
fi, err = os.Stat(newPath)
require.NoError(t, err)
require.Equal(t, os.FileMode(0600), fi.Mode().Perm())
}
// readFileLines must handle a pf.conf line longer than bufio.Scanner's default
// 64 KB token cap rather than failing with a "token too long" error.
func TestReadFileLinesLongLine(t *testing.T) {
fw := new(PF)
dir := t.TempDir()
path := filepath.Join(dir, "pf.conf")
long := strings.Repeat("a", 300*1024) // 300 KB, well past the 64 KB default
require.NoError(t, os.WriteFile(path, []byte(long+"\nshort\n"), 0600))
lines, err := fw.readFileLines(path)
require.NoError(t, err, "a long line must not overflow the scanner")
require.Len(t, lines, 2)
require.Equal(t, long, lines[0])
require.Equal(t, "short", lines[1])
}
// MarshalRule must reject a destination-port list (as it already does for a
// source-port list): pfctl expands a discrete port list (`port { 80 443 }`)
// into one rule per port on load, so a list has no single-row form. AddRule
// fans a list into one row per port with expandPorts, so the row-level
// marshaller only ever sees a single spec. A single contiguous range stays one
// token and must still be allowed.
func TestPFMarshalRejectsDestPortList(t *testing.T) {
f := &PF{anchor: "go_firewall"}
// A destination-port list must be rejected as unsupported.
err := f.validateRule(&Rule{
Proto: TCP,
Ports: []PortRange{{Start: 80, End: 80}, {Start: 443, End: 443}},
Action: Accept,
})
require.Error(t, err, "a destination-port list must be rejected")
require.ErrorIs(t, err, ErrUnsupported)
// A single contiguous range still round-trips as one token, so it is allowed.
line, err := f.MarshalRule(&Rule{
Proto: TCP,
Ports: []PortRange{{Start: 1000, End: 2000}},
Action: Accept,
})
require.NoError(t, err, "a single contiguous range must remain expressible")
require.Contains(t, line, "1000:2000")
}
// parseAddr must not mutate the caller's token slice when stripping a leading
// "!" negation.
func TestParsePFAddrDoesNotMutateTokens(t *testing.T) {
fw := new(PF)
tokens := []string{"!1.2.3.4", "port", "22"}
val, neg, next, err := fw.parseAddr(tokens, 0)
require.NoError(t, err)
require.Equal(t, "1.2.3.4", val)
require.Equal(t, "!", neg)
require.Equal(t, 0, next)
require.Equal(t, "!1.2.3.4", tokens[0], "the caller's slice must be left unchanged")
// The separate-"!" token form advances the index and leaves tokens intact.
tokens = []string{"!", "1.2.3.4"}
val, neg, next, err = fw.parseAddr(tokens, 0)
require.NoError(t, err)
require.Equal(t, "1.2.3.4", val)
require.Equal(t, "!", neg)
require.Equal(t, 1, next)
require.Equal(t, "!", tokens[0])
}
// A verbose listing prints a counters continuation under every rule, including an
// unmodeled one held as an opaque nil row; attaching those counters must not
// dereference the nil slot.
func TestPFVerboseCountersAfterUnmodeledRow(t *testing.T) {
fw := &PF{anchor: "go_firewall"}
out := []string{
"pass in quick inet proto tcp from any to any port > 1023 keep state",
" [ Evaluations: 100 Packets: 40 Bytes: 2400 States: 2 ]",
"pass in quick inet proto udp from any to any port = 53 keep state",
" [ Evaluations: 5 Packets: 5 Bytes: 300 States: 0 ]",
}
rules, raw := fw.parseAnchorRules(out)
require.Len(t, rules, 2)
require.Len(t, raw, 2)
require.Nil(t, rules[0], "the unmodeled line stays an opaque row")
require.NotNil(t, rules[1])
require.EqualValues(t, 5, rules[1].Packets, "counters still attach to the modeled rule")
}
// A portable backup may carry DirAny or TCPUDP rules captured from a backend that
// stores them as one row; marshalExpanded (behind Restore) must fan them out
// exactly as the add paths do rather than fail or drop a half.
func TestPFMarshalExpandedMergedRules(t *testing.T) {
fw := &PF{anchor: "go_firewall"}
merged := &Rule{Direction: DirAny, Family: FamilyAny, Proto: TCPUDP, Port: 53, Action: Accept}
lines, err := fw.marshalExpanded([]*Rule{merged})
require.NoError(t, err)
require.Len(t, lines, 4, "DirAny x TCPUDP must expand to four rows")
var in, out, tcp, udp int
for _, l := range lines {
if strings.Contains(l, "pass in ") {
in++
}
if strings.Contains(l, "pass out ") {
out++
}
if strings.Contains(l, "proto tcp") {
tcp++
}
if strings.Contains(l, "proto udp") {
udp++
}
}
require.Equal(t, 2, in)
require.Equal(t, 2, out)
require.Equal(t, 2, tcp)
require.Equal(t, 2, udp)
// A concrete rule passes through unchanged.
lines, err = fw.marshalExpanded([]*Rule{{Family: IPv4, Proto: TCP, Port: 22, Action: Accept}})
require.NoError(t, err)
require.Len(t, lines, 1)
}
// A nat source-port match and a dynamic-target port pool have no faithful model;
// both must error so the line is preserved as an opaque row rather than mis-read.
func TestPFNATParserKeepsUnmodeledShapesOpaque(t *testing.T) {
fw := &PF{anchor: "go_firewall"}
opaque := []string{
"nat on em0 inet from 10.0.0.0/8 port = 500 to any -> 192.0.2.1",
"nat on em0 inet from 10.0.0.0/8 to any -> (em0) port 1024:65535",
}
for _, line := range opaque {
_, err := fw.UnmarshalNATRule(line)
require.Error(t, err, "line must stay opaque: %s", line)
}
// The plain forms still parse.
r, err := fw.UnmarshalNATRule("nat on em0 inet from 10.0.0.0/8 to any -> (em0)")
require.NoError(t, err)
require.Equal(t, Masquerade, r.Kind)
r, err = fw.UnmarshalNATRule("rdr on em0 inet proto tcp from any to any port = 8080 -> 10.0.0.5 port 80")
require.NoError(t, err)
require.Equal(t, DNAT, r.Kind)
require.EqualValues(t, 8080, r.Port)
require.EqualValues(t, 80, r.ToPort)
}
// insertRows must place every cell of a merged rule in one pass over the anchor's
// rows, so a rule spanning directions and transports is written by a single anchor
// load rather than one read-rewrite cycle per cell. The cells go in at the
// requested index in the order ruleCells fans them out, and the surrounding rows
// keep their positions.
func TestPFInsertRowsPlacesEveryCell(t *testing.T) {
fw := &PF{anchor: "go_firewall"}
merged := &Rule{Direction: DirAny, Family: IPv4, Proto: TCPUDP, Port: 53, Action: Accept}
cells := fw.ruleCells(merged)
require.Len(t, cells, 4, "DirAny x TCPUDP occupies four anchor rows")
// An empty anchor: with no placement point every cell appends.
out, changed, err := fw.insertRows(nil, nil, cells, func(int, *Rule) bool { return false })
require.NoError(t, err)
require.True(t, changed)
require.Len(t, out, 4, "all four rows must land in the one pass")
// A populated anchor with the insert predicate selecting physical row 1: the
// block goes in ahead of that row, leaving the existing rows in order.
existing := []*Rule{{Family: IPv4, Proto: TCP, Port: 22, Action: Accept}, {Family: IPv4, Proto: TCP, Port: 80, Action: Accept}}
raw := []string{"first", "second"}
out, changed, err = fw.insertRows(existing, raw, cells, func(i int, _ *Rule) bool { return i == 1 })
require.NoError(t, err)
require.True(t, changed)
require.Len(t, out, 6)
require.Equal(t, "first", out[0])
require.Equal(t, "second", out[5], "the rows either side of the insert keep their order")
want := make([]string, len(cells))
for i, cell := range cells {
line, mErr := fw.MarshalRule(cell)
require.NoError(t, mErr)
want[i] = line
}
require.Equal(t, want, out[1:5], "the cells go in together, in ruleCells order")
}
// The add-time dedup must ask whether an existing row *covers* the cell, not
// whether it is exactly equal: an anchor row written without an af matches both
// families, so re-adding its IPv4 half would leave a redundant row Sync then
// reports forever. The coverage is one-way, so a concrete-family row must not
// swallow its opposite-family twin — that would leave the twin's family un-firewalled.
func TestPFInsertRowsDedupUsesCoverage(t *testing.T) {
fw := &PF{anchor: "go_firewall"}
never := func(int, *Rule) bool { return false }
// An af-less row covers the concrete-family cell it spans.
dual, err := fw.UnmarshalRule("pass in quick proto tcp from any to any port = 22")
require.NoError(t, err)
v4Cell := &Rule{Family: IPv4, Direction: DirInput, Proto: TCP, Port: 22, Action: Accept}
_, changed, err := fw.insertRows([]*Rule{dual}, []string{"dual"}, []*Rule{v4Cell}, never)
require.NoError(t, err)
require.False(t, changed, "a row covering the cell must not be duplicated")
// A concrete IPv4 row does not cover the IPv6 cell, which must still be written.
v4Row, err := fw.UnmarshalRule("pass in quick inet proto tcp from any to any port = 22")
require.NoError(t, err)
v6Cell := &Rule{Family: IPv6, Direction: DirInput, Proto: TCP, Port: 22, Action: Accept}
out, changed, err := fw.insertRows([]*Rule{v4Row}, []string{"v4"}, []*Rule{v6Cell}, never)
require.NoError(t, err)
require.True(t, changed, "an opposite-family twin is not a duplicate")
require.Len(t, out, 2)
require.Contains(t, out[1], "inet6")
// A partially-present set fills in only the missing cell rather than re-adding
// the rule whole, so an edit interrupted part-way converges on a retry.
missing := &Rule{Family: IPv4, Direction: DirInput, Proto: TCP, Port: 80, Action: Accept}
out, changed, err = fw.insertRows([]*Rule{v4Row}, []string{"v4"}, []*Rule{v4Cell, missing}, never)
require.NoError(t, err)
require.True(t, changed)
require.Len(t, out, 2, "only the absent cell is added")
require.Contains(t, out[1], "port 80")
}
// A concrete-family removal that matches a genuine dual-family row (an anchor rule
// with no af, covering both) must not drop both families: the untargeted family is
// re-marshalled into the removed row's own slot, so it keeps its coverage and its
// place in the anchor. Opaque rows are never removal targets and stay put.
func TestPFRemoveRowsSplitsDualRowInPlace(t *testing.T) {
fw := &PF{anchor: "go_firewall"}
dual, err := fw.UnmarshalRule("pass in quick proto tcp from any to any port = 22")
require.NoError(t, err)
tail := &Rule{Family: IPv4, Direction: DirInput, Proto: TCP, Port: 80, Action: Accept}
rules := []*Rule{nil, dual, tail}
raw := []string{"opaque", "dual", "tail"}
cell := &Rule{Family: IPv4, Direction: DirInput, Proto: TCP, Port: 22, Action: Accept}
out, changed, err := fw.removeRows(rules, raw, []*Rule{cell})
require.NoError(t, err)
require.True(t, changed)
require.Len(t, out, 3, "the dual row is replaced, not dropped")
require.Equal(t, "opaque", out[0], "an opaque row is never a removal target")
require.Contains(t, out[1], "inet6", "the untargeted family survives in the dual row's slot")
require.Equal(t, "tail", out[2])
// A target matching nothing leaves the rows untouched, so no anchor load runs.
_, changed, err = fw.removeRows(rules, raw, []*Rule{{Family: IPv4, Direction: DirInput, Proto: UDP, Port: 9999, Action: Accept}})
require.NoError(t, err)
require.False(t, changed)
}

441
scripts/refactor_backend.py Normal file
View file

@ -0,0 +1,441 @@
#!/usr/bin/env python3
"""refactor_backend.py — normalize function placement/naming in a go-firewall backend.
The library's convention, modeled on the APF refactor:
* A function used by more than one backend stays a package-level global.
* A function used only by this backend lives under the backend struct as a method.
* A method's name never repeats the backend token (the receiver already namespaces
it): apfNeedsHook -> (*APF).needsHook, isAPFConfRule -> (*APF).isConfRule.
* Visibility is preserved: an exported helper stays exported, an unexported one
stays unexported. Exported methods are the surface "people working with the
backend directly out of the manager interface" call.
* The New<Type> constructor stays a global.
The script parses the backend file, classifies every function, and (with --apply)
rewrites the backend file, its _test.go, and bare-name mentions in sibling files.
Default is a dry run that prints the plan. Naming is heuristic review the plan,
and pass --rename a=b,c=d to override any proposed name.
Usage:
scripts/refactor_backend.py csf_linux.go # dry-run plan
scripts/refactor_backend.py csf_linux.go --prefix csf # override token(s)
scripts/refactor_backend.py csf_linux.go --apply
scripts/refactor_backend.py csf_linux.go --apply --rename fooBar=baz
"""
import argparse
import os
import re
import subprocess
import sys
# Go initialisms handled when lowercasing a leading word so an acronym stays intact
# (NATFamilies -> natFamilies, IPv6Unavailable -> ipv6Unavailable, not nATFamilies).
INITIALISMS = [
"ICMPV6", "ICMPV4", "HTTPS", "IPV4", "IPV6", "ICMP", "HTTP", "SCTP",
"NAT", "TCP", "UDP", "DNS", "URL", "URI", "API", "TLS", "SSH", "ACL",
"ID", "IP", "WFP", "UFW", "CSF", "APF", "NFT", "PF",
]
# Group 1: receiver variable (optional — a method may use an anonymous receiver
# like `func (*IPTables) IgnoreLine(...)`). Group 2: receiver type. Group 3: name.
# Group 4: type-parameter list of a generic function, which Go forbids on methods.
FUNC_RE = re.compile(
r"^func\s+(?:\((?:(\w+)\s+)?\*?(\w+)\)\s+)?([A-Za-z_]\w*)\s*(\[[^\]]*\])?\s*\(",
re.MULTILINE,
)
def die(msg):
print("error: " + msg, file=sys.stderr)
sys.exit(1)
def split_blocks(text):
"""Split gofmt'd Go source into (preamble, [(header_line_idx, [lines])...]).
A top-level declaration starts with `func ` in column 0 and ends at the next
column-0 `func ` (gofmt guarantees the layout). Only func blocks are returned;
everything before the first func is the preamble.
"""
lines = text.split("\n")
starts = [i for i, l in enumerate(lines) if l.startswith("func ")]
if not starts:
return lines, []
preamble = lines[: starts[0]]
blocks = []
for n, s in enumerate(starts):
e = starts[n + 1] if n + 1 < len(starts) else len(lines)
blocks.append((s, lines[s:e]))
return preamble, blocks
def results(header):
"""Return the result section of a declaration header, empty when it has none.
The parameter list is skipped by balancing parentheses, since a parameter may
itself be a func type whose own parens would fool a plain regex.
"""
start = header.find("(")
if start < 0:
return ""
depth = 0
for i in range(start, len(header)):
if header[i] == "(":
depth += 1
elif header[i] == ")":
depth -= 1
if depth == 0:
return header[i + 1:].rstrip().rstrip("{").strip()
return ""
def to_unexported(name):
"""Lowercase the leading word of an identifier, keeping acronyms whole."""
if not name or name[0].islower():
return name
up = name.upper()
for init in sorted(INITIALISMS, key=len, reverse=True):
if up.startswith(init) and len(name) >= len(init):
return name[: len(init)].lower() + name[len(init):]
i = 0
while i < len(name) and name[i].isupper():
i += 1
if i <= 1:
return name[0].lower() + name[1:]
if i < len(name): # uppercase run followed by lowercase: last upper starts a word
return name[: i - 1].lower() + name[i - 1:]
return name.lower()
def strip_tokens(name, tokens):
"""Remove each backend token from an identifier as a camelCase/acronym segment."""
for tok in tokens:
low, up, cap = tok.lower(), tok.upper(), tok.capitalize()
if name.startswith(low) and (len(name) == len(low) or name[len(low)].isupper()):
name = name[len(low):]
name = name.replace(up, "")
name = re.sub(cap + r"(?=[A-Z]|$)", "", name)
return name
def target_name(old, exported, tokens):
"""Proposed identifier: token stripped, original visibility preserved."""
stripped = strip_tokens(old, tokens) or old
if exported:
return stripped[0].upper() + stripped[1:]
return to_unexported(stripped)
def main():
ap = argparse.ArgumentParser(description="Normalize a go-firewall backend's function placement/naming.")
ap.add_argument("file", help="backend source file (e.g. csf_linux.go)")
ap.add_argument("--type", help="receiver struct type (auto-detected by default)")
ap.add_argument("--recv", help="receiver variable name for converted globals (auto-detected)")
ap.add_argument("--prefix", help="comma-separated backend token(s) to strip (default: lowercased type)")
ap.add_argument("--rename", default="", help="comma-separated old=new overrides for proposed names")
ap.add_argument("--via", default="",
help="comma-separated Type=expr paths from a non-backend receiver to the backend (e.g. nftDecoder=d.f)")
ap.add_argument("--apply", action="store_true", help="write changes (default: dry-run plan only)")
ap.add_argument("--no-tests", action="store_true", help="do not touch the _test.go file")
ap.add_argument("--no-comments", action="store_true", help="do not update bare-name mentions in sibling files")
args = ap.parse_args()
path = args.file
if not os.path.isfile(path):
die("no such file: " + path)
pkgdir = os.path.dirname(os.path.abspath(path)) or "."
text = open(path).read()
# Detect the receiver type and its dominant receiver-variable name.
recv_types, recv_vars = {}, {}
for m in FUNC_RE.finditer(text):
rv, rt = m.group(1), m.group(2)
if rt:
recv_types[rt] = recv_types.get(rt, 0) + 1
recv_vars.setdefault(rt, {})
recv_vars[rt][rv] = recv_vars[rt].get(rv, 0) + 1
typ = args.type or (max(recv_types, key=recv_types.get) if recv_types else None)
if not typ:
die("could not detect a receiver type; pass --type")
recv = args.recv or (max(recv_vars.get(typ, {"f": 1}), key=recv_vars.get(typ, {"f": 1}).get))
tokens = [t for t in (args.prefix.split(",") if args.prefix else [typ.lower()]) if t]
overrides = dict(p.split("=", 1) for p in args.rename.split(",") if "=" in p)
# Enumerate this file's top-level declarations.
preamble, blocks = split_blocks(text)
decls = [] # (name, is_method, recv_var, header_idx)
for hidx, blk in blocks:
m = FUNC_RE.match(blk[0])
if not m:
continue
decls.append({"name": m.group(3), "method": bool(m.group(2)),
"recv": m.group(1), "rtype": m.group(2),
"generic": bool(m.group(4)), "hidx": hidx})
names = {d["name"] for d in decls}
# A constructor for any type declared in this file stays global, matching
# newHookScript/newAtomicFile: the type it builds, not the backend, owns it.
# A constructor is a new-prefixed global returning a locally declared type,
# which also catches a name that drops the backend token (newSetReader for
# *nftSetReader).
local_types = set(re.findall(r"(?m)^type\s+(\w+)\b", text))
ctors = set()
for hidx, blk in blocks:
m = FUNC_RE.match(blk[0])
if not m or m.group(2) or not m.group(3).startswith(("new", "New")):
continue
if any(re.search(r"\b" + re.escape(t) + r"\b", results(blk[0])) for t in local_types):
ctors.add(m.group(3))
# Methods on a type other than the backend reach a converted global only
# through an explicit path (--via nftDecoder=d.f); without one they cannot
# call it at all, so anything they use is held back as a global.
via = dict(p.split("=", 1) for p in args.via.split(",") if "=" in p)
stranded = {d["name"] for d in decls
if d["method"] and d["rtype"] != typ and d["rtype"] not in via}
# Which globals are shared? A global referenced by any sibling .go file other than
# this file and its own _test.go must stay global.
base = os.path.basename(path)
testbase = base[:-3] + "_test.go"
shared = set()
siblings = [f for f in os.listdir(pkgdir)
if f.endswith(".go") and f not in (base, testbase)]
for f in siblings:
raw = open(os.path.join(pkgdir, f)).read()
# Strip line comments so a bare mention in another file's comment does not
# masquerade as real cross-backend usage.
body = "\n".join(l.split("//", 1)[0] for l in raw.split("\n"))
for d in decls:
if not d["method"] and re.search(r"\b" + re.escape(d["name"]) + r"\b", body):
shared.add(d["name"])
# Reference graph within this file: which declared names each block's body uses.
body_refs = {}
for hidx, blk in blocks:
m = FUNC_RE.match(blk[0])
who = m.group(3)
used = set()
for line in blk[1:]:
code = line.split("//", 1)[0]
for n in names:
if re.search(r"\b" + re.escape(n) + r"\(", code):
used.add(n)
body_refs[who] = used
method_names = {d["name"] for d in decls if d["method"]}
# Decide each function's fate.
# keep-global : constructor / shared / generic / (guarded) called by a staying global
# to-method : backend-only global -> method
# rename : method whose name carries the token
convert = set() # globals that will become methods
for d in decls:
if d["method"]:
continue
# Go has no type-parameterized methods, so a generic helper stays global.
if d["name"] == "New" + typ or d["name"] in shared or d["generic"] or d["name"] in ctors:
continue
convert.add(d["name"])
# A converted global cannot be called from a context with no receiver in scope:
# a function that stays a plain global, a method with an anonymous receiver, or
# a method on another type with no --via path. Demote such names back to global
# until the set is stable.
anon_methods = {d["name"] for d in decls if d["method"] and not d["recv"]}
changed = True
while changed:
changed = False
no_recv = (shared | {"New" + typ} | ctors | anon_methods | stranded
| {d["name"] for d in decls if not d["method"] and d["name"] not in convert})
for g in list(convert):
callers = [w for w, u in body_refs.items() if g in u]
if any(c in no_recv for c in callers):
convert.discard(g)
changed = True
plan = [] # (old, new, action)
for d in decls:
old = d["name"]
if not d["method"]:
if old in convert:
new = overrides.get(old) or target_name(old, old[0].isupper(), tokens)
plan.append((old, new, "global->method"))
else:
if old == "New" + typ or old in ctors:
why = "constructor"
elif old in shared:
why = "shared"
elif d["generic"]:
why = "generic"
else:
why = "kept-global"
plan.append((old, old, why))
continue
# Existing method: strip the token if present, preserve visibility.
stripped = strip_tokens(old, tokens)
if stripped and stripped != old:
new = overrides.get(old) or target_name(old, old[0].isupper(), tokens)
plan.append((old, new, "method-rename"))
else:
plan.append((old, overrides.get(old, old), "method-ok"))
# Report.
rename_map = {o: n for o, n, a in plan if n != o}
print(f"# {base}: type=*{typ} recv={recv} tokens={tokens}")
edits = [p for p in plan if p[2] in ("global->method", "method-rename")]
print(f"# {len(edits)} change(s), {len(rename_map)} of them renames; "
f"{len(shared)} shared global(s) left in place\n")
width = max((len(o) for o, _, _ in plan), default=1)
for old, new, action in plan:
arrow = f"-> {new}" if new != old else ""
note = ""
if action == "global->method":
note = f" (now {recv}.{new})"
print(f" [{action:14}] {old:<{width}} {arrow}{note}")
# Collisions.
seen = {}
for old, new, action in plan:
if action in ("global->method", "method-rename"):
seen.setdefault(new, []).append(old)
for new, olds in seen.items():
if len(olds) > 1 or new in (method_names - set(rename_map)):
print(f" ! WARNING: name collision on {new}: {olds}")
if not args.apply:
print("\n(dry run — re-run with --apply to write changes)")
return
apply_source(path, text, plan, typ, recv, blocks, convert, rename_map, via)
if not args.no_tests:
tpath = os.path.join(pkgdir, testbase)
if os.path.isfile(tpath):
apply_test(tpath, plan, typ, convert, rename_map, recv)
if not args.no_comments:
for f in siblings:
apply_comments(os.path.join(pkgdir, f), rename_map)
changed_files = [path]
if not args.no_tests and os.path.isfile(os.path.join(pkgdir, testbase)):
changed_files.append(os.path.join(pkgdir, testbase))
subprocess.run(["gofmt", "-w", *changed_files])
print(f"\napplied. gofmt'd {len(changed_files)} file(s). Now run: go vet ./... && go test ./...")
def rewrite_body(lines, block_recv, convert_new, method_new):
"""Rewrite one block's body lines: global-call sites gain a receiver, method
renames swap the identifier, and bare comment mentions are renamed."""
out = []
for line in lines:
# Longest-first so no old name is a prefix of another.
for old in sorted(convert_new, key=len, reverse=True):
new = convert_new[old]
if block_recv:
line = re.sub(r"\b" + re.escape(old) + r"\(", f"{block_recv}.{new}(", line)
line = re.sub(r"\b" + re.escape(old) + r"\b(?!\()", new, line)
for old in sorted(method_new, key=len, reverse=True):
line = re.sub(r"\b" + re.escape(old) + r"\b", method_new[old], line)
out.append(line)
return out
def apply_source(path, text, plan, typ, recv, blocks, convert, rename_map, via=None):
convert_new = {o: n for o, n, a in plan if a == "global->method"}
method_new = {o: n for o, n, a in plan if a == "method-rename"}
preamble, blocks = split_blocks(text)
out = list(preamble)
for hidx, blk in blocks:
m = FUNC_RE.match(blk[0])
name = m.group(3)
header, body = blk[0], blk[1:]
if name in convert_new:
# Global declaration becomes a method; body callers get the receiver.
new = convert_new[name]
header = re.sub(r"^func\s+" + re.escape(name) + r"\(",
f"func ({recv} *{typ}) {new}(", header)
block_recv = recv
elif m.group(2): # existing method
# A method on another type reaches the backend only through its --via
# path, so converted calls are qualified with that instead.
block_recv = m.group(1) if m.group(2) == typ else (via or {}).get(m.group(2))
if name in method_new:
header = re.sub(r"\b" + re.escape(name) + r"\b", method_new[name], header, count=1)
else: # staying global function
block_recv = None
out.append(header)
out.extend(rewrite_body(body, block_recv, convert_new, method_new))
open(path, "w").write("\n".join(out))
def apply_test(path, plan, typ, convert, rename_map, recv):
"""Rewrite the backend's _test.go. Method renames are plain swaps; a global that
became a method needs an instance at each call site reuse a local one or inject
`fw := new(Type)` at the top of the test function."""
convert_new = {o: n for o, n, a in plan if a == "global->method"}
method_new = {o: n for o, n, a in plan if a == "method-rename"}
text = open(path).read()
preamble, blocks = split_blocks(text)
# Preferred instance-variable name: the one tests already use most, else "fw".
inst_counts = {}
for pat in (r"(\w+)\s*:=\s*new\(" + typ + r"\)", r"(\w+)\s*:=\s*&" + typ + r"\{"):
for mm in re.finditer(pat, text):
inst_counts[mm.group(1)] = inst_counts.get(mm.group(1), 0) + 1
default_inst = max(inst_counts, key=inst_counts.get) if inst_counts else "fw"
out = list(preamble)
for hidx, blk in blocks:
header, body = blk[0], list(blk[1:])
text_body = "\n".join(body)
needs = any(re.search(r"\b" + re.escape(o) + r"\(", text_body) for o in convert_new)
# An instance already local to this test function?
inst = None
for pat in (r"(\w+)\s*:=\s*new\(" + typ + r"\)", r"(\w+)\s*:=\s*&" + typ + r"\{",
r"var\s+(\w+)\s+\*?" + typ + r"\b"):
mm = re.search(pat, text_body)
if mm:
inst = mm.group(1)
break
inject = needs and inst is None
if inject:
inst = default_inst
recv_for_calls = inst # in tests, "receiver" is the instance var
new_body = []
for line in body:
for old in sorted(convert_new, key=len, reverse=True):
new = convert_new[old]
if recv_for_calls:
line = re.sub(r"\b" + re.escape(old) + r"\(", f"{recv_for_calls}.{new}(", line)
line = re.sub(r"\b" + re.escape(old) + r"\b(?!\()", new, line)
for old in sorted(method_new, key=len, reverse=True):
line = re.sub(r"\b" + re.escape(old) + r"\b", method_new[old], line)
new_body.append(line)
out.append(header)
if inject:
out.append(f"\t{inst} := new({typ})")
out.extend(new_body)
open(path, "w").write("\n".join(out))
def apply_comments(path, rename_map):
"""Update bare-name mentions of renamed identifiers in a sibling file's comments.
Only touches lines that are comments and only replaces the exact old identifier."""
if not rename_map:
return
lines = open(path).read().split("\n")
changed = False
for i, line in enumerate(lines):
if "//" not in line:
continue
code, _, comment = line.partition("//")
new_comment = comment
for old in sorted(rename_map, key=len, reverse=True):
new_comment = re.sub(r"\b" + re.escape(old) + r"\b", rename_map[old], new_comment)
if new_comment != comment:
lines[i] = code + "//" + new_comment
changed = True
if changed:
open(path, "w").write("\n".join(lines))
if __name__ == "__main__":
main()

304
scripts/refactor_order.py Normal file
View file

@ -0,0 +1,304 @@
#!/usr/bin/env python3
"""refactor_order.py — reorder a go-firewall backend so it matches the Manager interface.
Ordering rules:
1. Types and constants stay at the top (the preamble).
2. The New<Type> constructor is placed immediately after the preamble.
3. Remaining functions are ordered to match the `type Manager interface` list.
4. Any helper must appear before the first function that calls it; dependencies are
therefore hoisted in front of the exported method that needs them.
Doc comments attached to a function are moved together with the function.
Usage:
scripts/refactor_order.py apf_linux.go # dry-run to stdout
scripts/refactor_order.py apf_linux.go --apply # rewrite in place
scripts/refactor_order.py apf_linux.go --apply --gofmt # rewrite and format
"""
import argparse
import os
import re
import subprocess
import sys
from collections import Counter
# Regex used to parse a column-0 `func` declaration line. Groups:
# 1: receiver variable (None for a plain function)
# 2: receiver type (None for a plain function)
# 3: function name
# A generic function's type-parameter list sits between the name and the
# signature, so it is skipped without capturing.
DECL_RE = re.compile(r"^func\s+(?:\((?:(\w+)\s+)?\*?(\w+)\)\s+)?([A-Za-z_]\w*)\s*(?:\[[^\]]*\])?\s*\(")
def split_blocks(text):
"""Split source into (preamble, [block_dict, ...]).
A block starts at the function's doc comment and runs to the start of the
next function's doc comment, so every line of the file lands in exactly one
block and nothing can be dropped by the rewrite.
A doc comment is the run of column-0 `//` lines immediately above the `func`
line, which is what Go itself treats as the declaration's doc. Matching the
comment's leading word against function names is deliberately avoided: a
sentence that merely *starts* with another function's name (a wrapped line
such as "// applyRuleFiles fanned out the axes before calling.") would
otherwise be misread as the start of that function's doc comment.
"""
lines = text.split("\n")
func_lines = [i for i, line in enumerate(lines) if line.startswith("func ")]
if not func_lines:
return text, []
# Parse every declaration and claim the doc comment directly above it.
funcs = []
for fl in func_lines:
m = DECL_RE.match(lines[fl])
if not m:
die(f"could not parse declaration: {lines[fl]}")
assert m
doc_line = fl
while doc_line > 0 and lines[doc_line - 1].startswith("//"):
doc_line -= 1
funcs.append(
{
"recv_var": m.group(1),
"recv_type": m.group(2),
"name": m.group(3),
"doc_line": doc_line,
"func_line": fl,
}
)
# Everything above the first function's doc comment is the preamble.
preamble_lines = lines[: funcs[0]["doc_line"]]
preamble = "\n".join(preamble_lines)
if preamble_lines:
preamble += "\n"
# Each block owns its doc comment, its body, and the blank separator that
# follows, so blocks concatenate back into a well-formed file in any order.
# Trailing blanks are normalized to a single separator line; the last block
# keeps whatever follows it (there is no next doc comment to stop at).
blocks = []
for idx, f in enumerate(funcs):
end = funcs[idx + 1]["doc_line"] if idx + 1 < len(funcs) else len(lines)
block_lines = lines[f["doc_line"] : end]
while block_lines and block_lines[-1] == "":
block_lines.pop()
blocks.append(
{
"recv_var": f["recv_var"],
"recv_type": f["recv_type"],
"name": f["name"],
"text": "\n".join(block_lines) + "\n\n",
}
)
return preamble, blocks
def parse_manager_interface(interface_path):
"""Return the method names of `type Manager interface` in order."""
if not os.path.isfile(interface_path):
die(f"interface file not found: {interface_path}")
text = open(interface_path).read()
m = re.search(r"type Manager interface \{(.*?)\n\}", text, re.DOTALL)
if not m:
die(f"type Manager interface not found in {interface_path}")
assert m
body = m.group(1)
# Strip interface comments so they do not interfere with method detection.
body = re.sub(r"(?m)^\s*//.*$", "", body)
methods = re.findall(r"^\s+([A-Z]\w+)\s*\(", body, re.MULTILINE)
return methods
def detect_backend_type_and_receiver(blocks):
"""Detect the dominant backend receiver type and variable name."""
recv_counts = {}
type_counts = {}
for block in blocks:
rv = block["recv_var"]
rt = block["recv_type"]
if rt:
recv_counts[rv] = recv_counts.get(rv, 0) + 1
type_counts[rt] = type_counts.get(rt, 0) + 1
if not type_counts:
die("could not detect a backend receiver type")
backend_type = max(type_counts, key=lambda k: type_counts[k])
receiver = max(recv_counts, key=lambda k: recv_counts[k]) if recv_counts else None
return backend_type, receiver
def build_call_graph(blocks, receiver, backend_type):
"""Return a map from function name to the set of in-file functions it references.
Both call shapes and bare method values count as references, since a
dependency is anything that must already be declared for the reader to
follow the code. The receiver-qualified regex therefore does not require a
trailing "(": `f.removeRuleGroups` handed to a higher-order helper depends on
it just as much as `f.removeRuleGroups(...)` would. Bare names still require
the paren, because an unqualified name with no call syntax is far more often
a prose mention inside a doc comment than a function value.
"""
names = {block["name"] for block in blocks}
graph = {name: set() for name in names}
# Any variable holding the backend can reach its methods, not just the
# dominant receiver: a constructor names its local something else (`ipt`),
# and its calls would otherwise look like calls on an unrelated value.
bt = re.escape(backend_type)
alias_re = re.compile(rf"\b(\w+)\s*:?=\s*(?:&?{bt}\{{|new\(\s*{bt}\s*\))")
for block in blocks:
name = block["name"]
text = block["text"]
deps = graph[name]
recvs = {receiver} if receiver else set()
recvs.update(mm.group(1) for mm in alias_re.finditer(text))
recvs.discard("")
if recvs:
method_re = re.compile(
r"\b(?:" + "|".join(re.escape(r) for r in sorted(recvs)) + r")\.(\w+)\b"
)
for mm in method_re.finditer(text):
called = mm.group(1)
if called != name and called in names:
deps.add(called)
# Plain function calls: name(...)
# Exclude calls preceded by a dot, since those are method calls on some
# other value and are already captured above for the backend receiver.
for mm in re.finditer(r"(?<!\.)\b([A-Za-z_]\w*)\(", text):
called = mm.group(1)
if called != name and called in names:
deps.add(called)
return graph
def reorder(blocks, manager_order, constructor):
"""Return blocks in the required order."""
backend_type, receiver = detect_backend_type_and_receiver(blocks)
graph = build_call_graph(blocks, receiver, backend_type)
name_to_block = {block["name"]: block for block in blocks}
names = set(name_to_block.keys())
if constructor not in names:
die(f"constructor {constructor} not found in file")
missing = [m for m in manager_order if m not in names]
if missing:
die(f"Manager interface methods missing from file: {missing}")
order = []
placed = set()
active = set()
def place(name):
if name in placed or name in active:
return
if name not in name_to_block:
return
# active breaks a dependency cycle (mutual recursion, or a false edge
# from a field that shares a function's name). One member of the cycle
# necessarily ends up after a caller; Go does not mind, and the
# alternative is unbounded recursion.
active.add(name)
for dep in sorted(graph.get(name, set())):
place(dep)
active.discard(name)
order.append(name)
placed.add(name)
# 1. Constructor after preamble, before other functions.
place(constructor)
# 2. Manager interface methods in interface order.
for method in manager_order:
place(method)
# 3. Any leftover functions (e.g. helper-only internals not reachable from
# the public surface) are appended in dependency order.
for name in sorted(names):
if name not in placed:
place(name)
return [name_to_block[name] for name in order]
def die(msg):
print("error: " + msg, file=sys.stderr)
sys.exit(1)
def check_lossless(old_text, new_text):
"""Abort unless the rewrite is a pure permutation of the source.
A reorder may only move lines, never add or drop them, so the multiset of
non-blank lines must be identical on both sides. This is the backstop that
catches a mis-attributed comment or a mis-sliced block before it reaches the
file, since either shows up as a missing line rather than a Go syntax error.
"""
before = Counter(l for l in old_text.split("\n") if l.strip())
after = Counter(l for l in new_text.split("\n") if l.strip())
lost = before - after
gained = after - before
if not lost and not gained:
return
for line in list(lost)[:20]:
print(f"error: line dropped by reorder: {line}", file=sys.stderr)
for line in list(gained)[:20]:
print(f"error: line invented by reorder: {line}", file=sys.stderr)
die("reorder is not lossless; the file was left unchanged")
def main():
ap = argparse.ArgumentParser(
description="Reorder a go-firewall backend to match the Manager interface and dependency order."
)
ap.add_argument("file", help="backend source file (e.g. apf_linux.go)")
ap.add_argument(
"--interface",
default="firewall.go",
help="file containing the Manager interface (default: firewall.go)",
)
ap.add_argument(
"--constructor",
help="constructor name override (default: New<Type> auto-detected)",
)
ap.add_argument("--apply", action="store_true", help="rewrite the file in place")
ap.add_argument("--gofmt", action="store_true", help="run gofmt after rewriting")
args = ap.parse_args()
if not os.path.isfile(args.file):
die(f"no such file: {args.file}")
text = open(args.file).read()
preamble, blocks = split_blocks(text)
if not blocks:
die("no functions found in file")
backend_type, _ = detect_backend_type_and_receiver(blocks)
constructor = args.constructor or ("New" + backend_type)
manager_order = parse_manager_interface(args.interface)
ordered = reorder(blocks, manager_order, constructor)
new_text = preamble + "".join(block["text"] for block in ordered)
new_text = new_text.rstrip("\n") + "\n"
check_lossless(text, new_text)
if not args.apply:
print(new_text, end="")
return
open(args.file, "w").write(new_text)
if args.gofmt:
subprocess.run(["gofmt", "-w", args.file])
print(f"reordered {args.file}")
if __name__ == "__main__":
main()

288
services.go Normal file
View file

@ -0,0 +1,288 @@
//go:build linux
package firewall
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
dbus "github.com/coreos/go-systemd/v22/dbus"
)
// systemdActive reports whether systemd is the running init (/run/systemd/system
// exists), the same marker systemctl uses to decide it can reach the manager.
func systemdActive() bool {
_, err := os.Stat("/run/systemd/system")
return err == nil
}
// commandExists reports whether name resolves on PATH or in the standard install
// directories, matching how runCommand resolves the tool it runs.
func commandExists(name string) bool {
_, ok := resolveBinary(name)
return ok
}
// systemdUnitEnabled reports whether systemd reports name's unit as set to
// start at boot. Only "enabled" and "enabled-runtime" count: a "generated"
// unit wraps a SysV init.d script and exists for every script regardless of
// its rc registration, so boot enablement for those is resolved through the
// SysV checks instead. Returns false when systemd is not running or the unit
// is absent.
func systemdUnitEnabled(ctx context.Context, name string) bool {
conn, err := dbus.NewWithContext(ctx)
if err != nil {
return false
}
defer conn.Close()
prop, err := conn.GetUnitPropertyContext(ctx, name+".service", "UnitFileState")
if err != nil {
return false
}
switch prop.Value.Value() {
case "enabled", "enabled-runtime":
return true
}
return false
}
// systemdUnitState returns name's unit-file state (e.g. "enabled", "disabled",
// "static") and whether its unit file is installed. It lists all unit files and
// filters by name rather than calling ListUnitFilesByPatterns, which older
// systemd (CentOS 7's v219) does not export over D-Bus. Used to tell
// installed-but-disabled units (which can be enabled) from absent ones (which
// cannot be). Returns "", false when systemd is not running.
func systemdUnitState(ctx context.Context, name string) (state string, present bool) {
conn, err := dbus.NewWithContext(ctx)
if err != nil {
return "", false
}
defer conn.Close()
files, err := conn.ListUnitFilesContext(ctx)
if err != nil {
return "", false
}
target := name + ".service"
for _, uf := range files {
if filepath.Base(uf.Path) == target {
return uf.Type, true
}
}
return "", false
}
// chkconfigOn reports whether `chkconfig --list name` shows any runlevel on,
// the RHEL-family enablement signal. Returns false when chkconfig is absent.
func chkconfigOn(ctx context.Context, name string) bool {
results, err := runCommand(ctx, "chkconfig", "--list", name)
if err != nil {
return false
}
for _, line := range results {
fields := strings.Fields(line)
if len(fields) == 0 || fields[0] != name {
continue
}
for _, f := range fields[1:] {
if _, status, found := strings.Cut(f, ":"); found && status == "on" {
return true
}
}
}
return false
}
// rcSymlinksOn reports whether an S*name start symlink exists in any rcN.d
// tree, the enablement signal for both update-rc.d (Debian/Ubuntu, /etc/rcN.d)
// and Slackware (/etc/rc.d/rcN.d).
func rcSymlinksOn(name string) bool {
for _, rl := range []string{"2", "3", "4", "5"} {
for _, dir := range []string{"/etc/rc" + rl + ".d", "/etc/rc.d/rc" + rl + ".d"} {
if matches, _ := filepath.Glob(filepath.Join(dir, "S*"+name)); len(matches) > 0 {
return true
}
}
}
return false
}
// openrcOn reports whether name is linked into an OpenRC runlevel directory
// (default or boot), the enablement signal created by `rc-update add` on Gentoo.
func openrcOn(name string) bool {
for _, rl := range []string{"default", "boot"} {
if _, err := os.Lstat(filepath.Join("/etc/runlevels", rl, name)); err == nil {
return true
}
}
return false
}
// rcLocalOn reports whether name is invoked from an rc.local file, apf's
// last-resort enablement when neither systemd nor an init.d registration
// applies. Commented lines are ignored, and name must appear as its own token
// (bare or path-qualified) so an unrelated mention such as a log-file path
// does not count.
func rcLocalOn(name string) bool {
for _, p := range []string{"/etc/rc.local", "/etc/rc.d/rc.local"} {
data, err := os.ReadFile(p)
if err != nil {
continue
}
for _, line := range strings.Split(string(data), "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
for _, tok := range strings.Fields(line) {
if tok == name || strings.HasSuffix(tok, "/"+name) {
return true
}
}
}
}
return false
}
// sysvServiceEnabled reports whether name is enabled under any SysV-family init
// system, the fallback when systemd is absent or does not report the service
// enabled. It checks chkconfig, Debian/Slackware rcN.d start symlinks, OpenRC
// runlevels, and rc.local in turn; the first to report it on wins.
func sysvServiceEnabled(ctx context.Context, name string) bool {
if chkconfigOn(ctx, name) {
return true
}
if rcSymlinksOn(name) {
return true
}
if openrcOn(name) {
return true
}
return rcLocalOn(name)
}
// serviceInstalled reports whether name's unit or init.d script is installed,
// regardless of whether it is enabled. Used to tell a merely-disabled service
// (which can be enabled) from an absent one (which cannot be).
func serviceInstalled(ctx context.Context, name string) bool {
if systemdActive() {
_, present := systemdUnitState(ctx, name)
return present
}
for _, dir := range []string{"/etc/init.d", "/etc/rc.d/init.d"} {
if _, err := os.Stat(filepath.Join(dir, name)); err == nil {
return true
}
}
return false
}
// serviceEnabled reports whether name is enabled to start, checking systemd
// first and then every SysV-family init mechanism the supported firewalls
// install under: chkconfig (RHEL), update-rc.d (Debian/Ubuntu, via /etc/rcN.d
// symlinks), rc-update (Gentoo/OpenRC, via /etc/runlevels), Slackware rc.d
// symlinks, and rc.local (apf's last resort). name is the service base name
// without a ".service" suffix (e.g. "csf", "apf", "netfilter-persistent"); any
// one mechanism reporting it on counts as enabled.
func serviceEnabled(ctx context.Context, name string) bool {
if systemdUnitEnabled(ctx, name) {
return true
}
return sysvServiceEnabled(ctx, name)
}
// enableService enables name to start at boot under whatever init system is
// active, mirroring how the firewalls' installers register themselves. It is a
// no-op (returns nil) when the service is not installed or already enabled. On
// systemd it runs `systemctl enable` (preceded by daemon-reload so a freshly
// installed unit is picked up); otherwise it uses chkconfig, update-rc.d, or
// rc-update, whichever is present.
func enableService(ctx context.Context, name string) error {
if systemdActive() {
state, present := systemdUnitState(ctx, name)
if !present {
return nil
}
switch state {
case "enabled", "enabled-runtime", "static":
// Static units have no install info and are pulled in by other
// units, so there is nothing to enable.
return nil
case "generated":
// A generated unit wraps a SysV init.d script whose enablement
// lives in its rc registration, not the unit file. `systemctl
// enable` reaches that registration through systemd-sysv-install.
if sysvServiceEnabled(ctx, name) {
return nil
}
}
if _, err := runCommand(ctx, "systemctl", "daemon-reload"); err != nil {
return fmt.Errorf("failed to reload systemd for %s: %s", name, err)
}
if _, err := runCommand(ctx, "systemctl", "enable", name+".service"); err != nil {
return fmt.Errorf("failed to enable %s: %s", name, err)
}
return nil
}
if !serviceInstalled(ctx, name) {
return nil
}
switch {
case commandExists("chkconfig"):
if _, err := runCommand(ctx, "chkconfig", "--add", name); err != nil {
return fmt.Errorf("failed to enable %s: %s", name, err)
}
if _, err := runCommand(ctx, "chkconfig", name, "on"); err != nil {
return fmt.Errorf("failed to enable %s: %s", name, err)
}
case commandExists("update-rc.d"):
if _, err := runCommand(ctx, "update-rc.d", name, "defaults"); err != nil {
return fmt.Errorf("failed to enable %s: %s", name, err)
}
case commandExists("rc-update"):
if _, err := runCommand(ctx, "rc-update", "add", name, "default"); err != nil {
return fmt.Errorf("failed to enable %s: %s", name, err)
}
default:
return fmt.Errorf("no supported init system found to enable %s", name)
}
return nil
}
// restartService restarts name under whatever init system is active. On systemd
// it runs `systemctl restart`; otherwise it prefers the `service` wrapper, then
// OpenRC's `rc-service`, then the init.d script directly.
func restartService(ctx context.Context, name string) error {
if systemdActive() {
if _, err := runCommand(ctx, "systemctl", "restart", name+".service"); err != nil {
// A burst of restarts (a caller reconciling several times in quick
// succession) trips systemd's start rate limit and the unit lands in
// a failed start-limit-hit state; clear it and retry once rather
// than fail a reload the unit itself is healthy for.
if _, rerr := runCommand(ctx, "systemctl", "reset-failed", name+".service"); rerr == nil {
if _, err2 := runCommand(ctx, "systemctl", "restart", name+".service"); err2 == nil {
return nil
}
}
return fmt.Errorf("failed to restart %s: %s", name, err)
}
return nil
}
switch {
case commandExists("service"):
if _, err := runCommand(ctx, "service", name, "restart"); err != nil {
return fmt.Errorf("failed to restart %s: %s", name, err)
}
case commandExists("rc-service"):
if _, err := runCommand(ctx, "rc-service", name, "restart"); err != nil {
return fmt.Errorf("failed to restart %s: %s", name, err)
}
default:
if _, err := runCommand(ctx, filepath.Join("/etc/init.d", name), "restart"); err != nil {
return fmt.Errorf("failed to restart %s: %s", name, err)
}
}
return nil
}

2
test/integration/.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
# Test binary compiled by host-linux-vm.sh and run natively inside the VM.
firewall.test

View file

@ -0,0 +1,453 @@
#!/usr/bin/env bash
# IN-VM script — runs inside the disposable VM, launched by cloud-init from
# host-linux-vm.sh. Do NOT run it on a workstation: it installs packages and
# enables/disables real backends, rewriting /etc/csf, /etc/apf, /etc/ufw, and
# iptables config, which would reconfigure a real host. A GOFW_ALLOW_RUN /
# disposable-VM-marker guard below refuses to run otherwise.
#
# Runs the go-firewall integration suite against real backends natively inside the
# disposable VM. This script loops sequentially over the requested backends (never
# in parallel — they all share the same kernel netfilter/ipset state) and for each
# one lazily provisions it on first use, then runs its test:
#
# ensure_provisioned — if the backend has no clean-config snapshot yet, install
# its packages, seed a minimal/clean config, and snapshot
# it under /root/gofw-base/<backend>/ (mirroring its real
# absolute path). Skipped once a snapshot exists, so a
# reused VM overlay provisions nothing.
# flush_kernel_state — clear leftover kernel netfilter/ipset state.
# restore_config — rsync the clean snapshot back over the live config.
# enable_backend — start the backend.
# run the test binary — one backend at a time.
# disable_backend — stop it again, pass or fail, before the next backend.
#
# Provisioning per backend (rather than all backends up front) means a run limited
# to a subset only pays for the packages it needs — csf and apf in particular pull
# from third-party network sources (see fetch_cached), which is slow and flaky, so
# a run that never touches them never downloads them.
#
# To run, use the Makefile:
#
# make test-integration-linux
set -u
export DEBIAN_FRONTEND=noninteractive
BASE=/root/gofw-base
CACHE_DIR=/mnt/gofw-cache
# Refuse to run unless the disposable-VM marker is present. Installing packages and
# enabling/disabling firewalld/ufw/csf/apf/iptables-persistent operates directly on
# the real host's firewall and systemd state, which is dangerous to do by accident
# on a workstation. host-linux-vm.sh writes the marker before invoking this script
# and exports GOFW_ALLOW_RUN=1 for the guest payload; either is accepted here.
MARKER=/etc/gofw-disposable-vm
if [[ ! -f "$MARKER" ]] && [[ "${GOFW_ALLOW_RUN:-0}" != "1" ]]; then
echo "!! guest-linux-run.sh must be run from inside the disposable test VM, not on your machine." >&2
echo " It installs backend packages and enables/disables real backends (firewalld, ufw," >&2
echo " csf, apf, iptables-persistent), which would reconfigure a real host's firewall." >&2
echo " Use: make test-integration-linux" >&2
exit 1
fi
backends=("$@")
if [[ ${#backends[@]} -eq 0 ]]; then
backends=(nft iptables firewalld ufw csf apf)
fi
# The host compiles the test binary into its .cache, shared here as the writable
# gofwcache mount (CACHE_DIR) — the repo share is read-only and holds no artifacts.
BIN="$CACHE_DIR/firewall.test"
# flush_kernel_state clears the shared netfilter/ipset state every backend reads
# and writes, so nothing one backend's test leaves behind can leak into the next
# backend's run. Run before AND after every backend: before, because a prior run
# in this same VM boot may have left rules behind if its test crashed; after,
# because none of these tools' own disable is guaranteed to fully clear it.
flush_kernel_state() {
nft flush ruleset 2>/dev/null || true
iptables -F 2>/dev/null || true
iptables -t nat -F 2>/dev/null || true
iptables -X 2>/dev/null || true
ip6tables -F 2>/dev/null || true
ip6tables -t nat -F 2>/dev/null || true
ip6tables -X 2>/dev/null || true
ipset destroy 2>/dev/null || true
}
# backend_config_dir prints the single config directory a backend snapshots, or
# nothing for nft (which has no persisted config).
backend_config_dir() {
case "$1" in
iptables) echo /etc/iptables ;;
firewalld) echo /etc/firewalld ;;
ufw) echo /etc/ufw ;;
csf) echo /etc/csf ;;
apf) echo /etc/apf ;;
esac
}
# restore_config rsync-restores a backend's clean config snapshot onto its own
# live config directory — never onto / itself, which would make --delete treat
# every other file on the filesystem as "extraneous" and try to remove it.
# --delete removes anything a previous test iteration left behind that
# provisioning did not seed (e.g. a rule file rewritten by AddRule).
restore_config() {
local b="$1"
local dir
dir="$(backend_config_dir "$b")"
[[ -n "$dir" ]] || return 0
if [[ -d "$BASE/$b$dir" ]]; then
rsync -a --delete "$BASE/$b$dir/" "$dir/"
fi
}
# snapshot copies each given absolute path into $BASE/<backend>/, preserving the
# absolute path underneath so restore_config can restore it with a single `rsync -a`
# scoped to that backend's own config directory (never onto / itself). Creating
# $BASE/<backend>/ also serves as the backend's "provisioned" marker.
snapshot() {
local backend="$1"
shift
mkdir -p "$BASE/$backend"
local p
for p in "$@"; do
mkdir -p "$BASE/$backend/$(dirname "$p")"
cp -a "$p" "$BASE/$backend/$p"
done
}
# pkg_installed reports whether a package is actually installed (dpkg status
# "install ok installed"), not merely known to dpkg — `dpkg -s` exits 0 even for
# a removed package whose config files remain (status "deinstall ok
# config-files"), which is exactly the state ufw and iptables-persistent leave
# each other in when their mutual apt Conflict removes one of them.
pkg_installed() {
dpkg-query -W -f='${Status}' "$1" 2>/dev/null | grep -q "^install ok installed"
}
# wait_active polls `systemctl is-active` for up to 15s so the test does not race
# a daemon that is still settling after `systemctl start` returns.
wait_active() {
local unit="$1"
for _ in $(seq 1 15); do
systemctl is-active --quiet "$unit" && return 0
sleep 1
done
return 1
}
# ---------------------------------------------------------------------------
# Provisioning (lazy, per backend)
#
# Two independent, idempotent concerns, each checked per backend before its test:
# - packages: gated on backend_installed (dpkg/binary presence), so the
# ufw/iptables-persistent package a conflicting backend's install removed is
# reinstalled on demand — the package state, not the config snapshot, answers
# "is it installed?".
# - config: gated on the $BASE/<backend>/ snapshot dir; seeded and snapshotted
# once, then replayed by restore_config before every test.
# A reused VM overlay keeps both, so it provisions nothing; a fresh overlay does
# only the backends this run actually touches.
# ---------------------------------------------------------------------------
apt_updated=0
# apt_update_once refreshes the package index a single time per boot, before the
# first install. Guarded so provisioning several backends in one run does not
# re-run `apt-get update` for each.
apt_update_once() {
[[ "$apt_updated" = 1 ]] && return 0
apt-get update
apt_updated=1
}
# fetch_cached downloads url to dest, retrying a few times with a backoff since
# csf's download host in particular is known to fail intermittently. A
# successful download is copied into CACHE_DIR (a writable 9p share backed by
# the host's .cache, vs. the read-only repo share) so a later re-provision — or
# a fresh VM overlay — reuses it instead of hitting the network again.
fetch_cached() {
local url="$1" dest="$2" name="$3"
local cached="$CACHE_DIR/$name"
if [[ -f "$cached" ]]; then
echo ">> using cached $name"
cp "$cached" "$dest"
return 0
fi
local attempt
for attempt in $(seq 1 5); do
if wget --tries=3 --timeout=30 -qO "$dest" "$url"; then
mkdir -p "$CACHE_DIR"
cp "$dest" "$cached"
return 0
fi
echo "!! download of $name failed (attempt $attempt/5); retrying" >&2
sleep $((attempt * 5))
done
echo "!! failed to download $name after 5 attempts" >&2
return 1
}
# backend_installed reports whether a backend's packages are already present. It
# gates provision_backend on package state rather than on the config snapshot, so
# the ufw/iptables-persistent pair — which apt's mutual Conflict makes
# install-one-remove-the-other — is reinstalled by whichever test needs it, even
# though its config snapshot already exists. csf and apf are third-party and not
# dpkg packages, so they are probed by their installed binary instead.
backend_installed() {
case "$1" in
nft) pkg_installed nftables ;;
iptables) pkg_installed netfilter-persistent ;;
firewalld) pkg_installed firewalld ;;
ufw) pkg_installed ufw ;;
csf) command -v csf >/dev/null 2>&1 ;;
apf) command -v apf >/dev/null 2>&1 ;;
*) return 1 ;;
esac
}
# provision_backend installs one backend's packages. Called only when
# backend_installed reports them missing — on first use, or to reinstall the
# ufw/iptables-persistent package a conflicting backend's install removed. It
# touches neither daemon state (enable_backend/disable_backend own that) nor config
# (ensure_config_snapshot owns that). Runs under `set -e` in a subshell (see
# ensure_provisioned) so any failing step fails the backend cleanly.
provision_backend() {
case "$1" in
nft)
# nft: kernel-native, no persisted config.
apt_update_once
apt-get install -y --no-install-recommends nftables
;;
iptables)
# iptables: installed via iptables-persistent. Preseed its debconf autosave
# prompts so the noninteractive install does not block waiting for a "save
# current rules?" answer. Install netfilter-persistent by name for clarity —
# it ships the .service unit the constructor requires, and iptables-persistent
# depends on it.
#
# ufw and iptables-persistent/netfilter-persistent mutually Conflict at the
# apt level on this distro (Ubuntu ships ufw preinstalled by default, and
# installing either package here auto-removes the other), so only one of the
# pair can be installed at a time — hence backend_installed gates this per
# test so the one a given backend needs is reinstalled on demand.
apt_update_once
echo "iptables-persistent iptables-persistent/autosave_v4 boolean false" | debconf-set-selections
echo "iptables-persistent iptables-persistent/autosave_v6 boolean false" | debconf-set-selections
# ipset-persistent ships the netfilter-persistent plugin the backend probes
# for to decide it has somewhere to stage address sets. Without it the
# backend falls back to creating sets live and the staged path — the one
# Reload loads through `ipset restore` — never runs. ufw cannot have it (it
# pulls netfilter-persistent, which conflicts with ufw as noted above), so
# ufw is what covers the live-only fallback.
apt-get install -y --no-install-recommends iptables iptables-persistent netfilter-persistent ipset ipset-persistent
;;
firewalld)
# firewalld: available in Ubuntu's universe repo.
apt_update_once
apt-get install -y --no-install-recommends firewalld
;;
ufw)
# ufw: Ubuntu's default firewall tool.
apt_update_once
apt-get install -y --no-install-recommends ufw ipset
;;
csf)
# csf (ConfigServer Security & Firewall): third-party Perl package installed
# from upstream.
apt_update_once
apt-get install -y --no-install-recommends perl libwww-perl libio-socket-ssl-perl iptables ipset ca-certificates wget
fetch_cached https://download.configserver.dev/csf.tgz /tmp/csf.tgz csf.tgz
tar -xzf /tmp/csf.tgz -C /tmp
(cd /tmp/csf && sh install.sh)
rm -rf /tmp/csf /tmp/csf.tgz
;;
apf)
# apf (Advanced Policy Firewall): third-party package from rfxn upstream.
apt_update_once
apt-get install -y --no-install-recommends iproute2 kmod ca-certificates wget
fetch_cached https://github.com/rfxn/advanced-policy-firewall/archive/refs/heads/master.tar.gz /tmp/apf.tar.gz apf.tar.gz
tar -xzf /tmp/apf.tar.gz -C /tmp
(cd /tmp/advanced-policy-firewall-master && bash install.sh)
rm -rf /tmp/apf.tar.gz /tmp/advanced-policy-firewall-master
;;
*)
echo "!! unknown backend '$1'" >&2
return 1
;;
esac
}
# ensure_config_snapshot seeds a backend's minimal/clean config and snapshots it
# under $BASE/<backend>/ the first time it is needed, then is a no-op (the snapshot
# dir is the marker). Must run after provision_backend, since it edits config files
# the package install creates. restore_config replays this snapshot before every
# test, so a package reinstalled after a conflict always has its default config
# overwritten by the clean seed. Leaves every backend disabled — the loop's
# enable/disable owns daemon state, so nothing here starts or stops a service.
ensure_config_snapshot() {
local b="$1"
[[ -d "$BASE/$b" ]] && return 0
echo ">> [$b] seeding and snapshotting clean config"
case "$b" in
nft)
# nft has no persisted config; the snapshot dir is just a uniformity marker.
mkdir -p "$BASE/nft"
;;
iptables)
mkdir -p /etc/iptables
printf '*filter\n:INPUT ACCEPT [0:0]\n:FORWARD ACCEPT [0:0]\n:OUTPUT ACCEPT [0:0]\nCOMMIT\n*nat\n:PREROUTING ACCEPT [0:0]\n:INPUT ACCEPT [0:0]\n:OUTPUT ACCEPT [0:0]\n:POSTROUTING ACCEPT [0:0]\nCOMMIT\n' \
| tee /etc/iptables/rules.v4 >/etc/iptables/rules.v6
snapshot iptables /etc/iptables/rules.v4 /etc/iptables/rules.v6
;;
firewalld)
snapshot firewalld /etc/firewalld
;;
ufw)
sed -i 's/^ENABLED=.*/ENABLED=no/' /etc/ufw/ufw.conf
snapshot ufw /etc/ufw
;;
csf)
sed -i 's/^TESTING = .*/TESTING = "0"/' /etc/csf/csf.conf
rm -f /etc/csf/csf.disable
snapshot csf /etc/csf
;;
apf)
sed -i 's/^DEVEL_MODE=.*/DEVEL_MODE="0"/' /etc/apf/conf.apf
snapshot apf /etc/apf
;;
esac
}
# ensure_provisioned makes a backend ready to test: installs its packages if
# backend_installed reports them missing, then seeds+snapshots its clean config if
# that has not been done yet. Each step runs in a `set -e` subshell so a mid-step
# failure aborts that step as a unit under this script's loop-friendly `set -u`,
# while staying contained to the one backend rather than aborting the whole run.
ensure_provisioned() {
local b="$1"
if ! backend_installed "$b"; then
echo ">> [$b] installing packages"
( set -eo pipefail; provision_backend "$b" ) || return 1
fi
( set -eo pipefail; ensure_config_snapshot "$b" ) || return 1
}
# enable_backend starts a backend's daemon against the clean config just restored.
# It restarts (rather than starts) the daemon backends to ensure the restored
# config is re-read if the service was already in a running state.
enable_backend() {
case "$1" in
nft) : ;; # kernel-native; nothing to enable
iptables)
# The unit's constructor requires UnitFileState=="enabled", so enable before
# restarting; daemon-reload picks up the generated unit for this SysV-only service.
systemctl daemon-reload
systemctl enable netfilter-persistent.service
systemctl restart netfilter-persistent.service
;;
firewalld)
# Debian/Ubuntu ships firewalld.service masked by default (it conflicts
# with the distro's default iptables/ufw setup), so it must be unmasked
# before it can start at all.
systemctl unmask firewalld.service 2>/dev/null || true
systemctl enable firewalld.service
systemctl restart firewalld.service && wait_active firewalld.service
;;
ufw)
# restore_config seeded ENABLED=no; flip it on for the test. No restart
# needed: `ufw --force enable` runs a full flush-and-re-apply even when ufw
# is already up, so it is already a restart.
sed -i 's/^ENABLED=.*/ENABLED=yes/' /etc/ufw/ufw.conf
systemctl enable ufw.service
ufw --force enable
;;
csf)
systemctl enable csf.service
systemctl restart csf.service && wait_active csf.service
;;
apf)
# Two stock settings keep apf from enforcing what the tests write, both set
# here rather than in the config snapshot (which is seeded once and reused
# across runs). EGF gates egress filtering as a whole: with it off apf stores
# an EG_*_CPORTS entry but never puts it in the kernel, so an outbound rule
# is never actually applied. BLK_RESNET drops traffic to the reserved
# networks ahead of apf's own port rules, and the rule-counter subtest drives
# packets at a TEST-NET address to make a rule's counter move.
sed -i 's/^EGF=.*/EGF="1"/' /etc/apf/conf.apf
sed -i 's/^BLK_RESNET=.*/BLK_RESNET="0"/' /etc/apf/conf.apf
systemctl enable apf.service
systemctl restart apf.service && wait_active apf.service
;;
esac
}
# disable_backend stops and disables a backend's daemon so it cannot interfere with
# the next backend's test. Called after every run, pass or fail, and errors are
# ignored since the goal is only to leave the daemon down.
disable_backend() {
case "$1" in
nft) : ;;
iptables) systemctl disable --now netfilter-persistent.service 2>/dev/null || true ;;
firewalld) systemctl disable --now firewalld.service 2>/dev/null || true ;;
ufw)
ufw disable 2>/dev/null || true
systemctl disable ufw.service 2>/dev/null || true
;;
csf) systemctl disable --now csf.service lfd.service 2>/dev/null || true ;;
apf) systemctl disable --now apf.service 2>/dev/null || true ;;
esac
}
declare -A result
overall=0
# Loop through requested backends and test.
for b in "${backends[@]}"; do
# Provision on first use; a provisioning failure is contained to this backend.
if ! ensure_provisioned "$b"; then
echo "!! [$b] provisioning failed"
result[$b]="PROVISION-FAIL"
overall=1
continue
fi
echo ">> [$b] resetting kernel state and restoring clean config"
flush_kernel_state
restore_config "$b"
echo ">> [$b] enabling backend"
if ! enable_backend "$b"; then
echo "!! [$b] enable failed"
result[$b]="ENABLE-FAIL"
overall=1
disable_backend "$b"
flush_kernel_state
continue
fi
echo ">> [$b] running integration test"
FIREWALL_BACKEND="$b" "$BIN" -test.v -test.run TestIntegration
rc=$?
# Always disable, even on test failure, so one backend's failure never leaves
# it running to interfere with the next backend's test.
echo ">> [$b] disabling backend"
disable_backend "$b"
flush_kernel_state
if [[ $rc -eq 0 ]]; then
result[$b]="PASS"
else
result[$b]="FAIL($rc)"
overall=1
fi
done
echo
echo "==== integration results ===="
for b in "${backends[@]}"; do
printf " %-10s %s\n" "$b" "${result[$b]:-SKIPPED}"
done
exit $overall

View file

@ -0,0 +1,196 @@
#!/usr/bin/env bash
# HOST script — run this on your workstation. It boots a throwaway FreeBSD QEMU VM
# and runs the pf integration backend inside it.
#
# FreeBSD's pf runs natively, so the VM *is* the test environment: it
# enables pf with a minimal ruleset, mounts the freebsd-cross-compiled test binary
# from the host over a virtio-9p share, and runs it with FIREWALL_BACKEND=pf. The
# same pf backend serves macOS, which cannot be automated in a VM.
#
# Usage:
# ./host-freebsd-vm.sh
#
# The overlay disk is kept between runs by default. pf state lives in the kernel and
# is cleared on every reboot (reloaded from /etc/pf.conf), so reuse just needs a
# fresh cloud-init seed with a unique instance-id each run so cloud-init executes
# the test payload on every boot. Set GOFW_VM_REUSE=0 to force a fresh overlay each
# run; deleting .cache/freebsd-overlay.qcow2 also resets it.
set -euo pipefail
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
repo="$(cd "$here/../.." && pwd)"
IMAGE_URL="${GOFW_FBSD_IMAGE_URL:-https://download.freebsd.org/releases/VM-IMAGES/15.1-RELEASE/amd64/Latest/FreeBSD-15.1-RELEASE-amd64-BASIC-CLOUDINIT-ufs.qcow2.xz}"
MEM="${GOFW_VM_MEM:-2048}"
CPUS="${GOFW_VM_CPUS:-2}"
CACHE="${GOFW_VM_CACHE:-$repo/.cache}"
REUSE="${GOFW_VM_REUSE:-1}" # 1 = keep the overlay disk between runs (default)
BOOT_TIMEOUT="${GOFW_VM_TIMEOUT:-1200}"
SHUTDOWN_GRACE="${GOFW_VM_SHUTDOWN_GRACE:-60}" # seconds to wait for a clean poweroff before killing
mkdir -p "$CACHE"
base="$CACHE/freebsd.qcow2"
overlay="$CACHE/freebsd-overlay.qcow2"
seed="$CACHE/freebsd-seed.iso"
console="$CACHE/freebsd-console.log"
bin="$CACHE/firewall.test.freebsd"
command -v qemu-system-x86_64 >/dev/null || { echo "!! qemu-system-x86_64 not found"; exit 1; }
command -v genisoimage >/dev/null || { echo "!! genisoimage not found"; exit 1; }
echo ">> cross-compiling freebsd test binary on host"
( cd "$repo" && GOOS=freebsd GOARCH=amd64 CGO_ENABLED=0 go test -c -tags integration -o "$bin" . )
if [[ ! -f "$base" ]]; then
echo ">> downloading FreeBSD cloud image -> $base"
curl -L --fail -o "$base.xz" "$IMAGE_URL"
xz -dc "$base.xz" >"$base"
rm -f "$base.xz"
fi
# Reuse the overlay only when asked and one already exists; otherwise start fresh.
if [[ "$REUSE" = 1 ]] && [[ -f "$overlay" ]]; then
echo ">> reusing existing overlay disk (GOFW_VM_REUSE=1): $overlay"
else
echo ">> creating a fresh overlay disk (base stays pristine)"
rm -f "$overlay"
qemu-img create -f qcow2 -b "$base" -F qcow2 "$overlay" 20G >/dev/null
fi
# A fresh cloud-init seed is generated for every run. The unique instance-id makes
# each boot look like a new instance to cloud-init, so per-instance modules
# (write_files and runcmd) execute on both the first and every reused boot.
run_id="$(date +%s)-${RANDOM}"
instance_id="gofw-it-fbsd-${run_id}"
work="$(mktemp -d "$CACHE/fbsd-seed.XXXXXX")"
trap 'rm -rf "$work"' EXIT
cat >"$work/meta-data" <<EOF
instance-id: ${instance_id}
local-hostname: gofw-it-fbsd
EOF
cat >"$work/user-data" <<EOF
#cloud-config
bootcmd:
# Skip the FreeBSD cloud image's first-boot freebsd-update/pkg fetch — it adds
# minutes of patch downloads and is irrelevant to the test. Runs before the
# firstboot rc scripts (a fresh overlay re-triggers them every run otherwise).
- [ rm, -f, /firstboot ]
write_files:
- path: /etc/pf.conf
content: |
set skip on lo0
runcmd:
- sysrc pf_enable=YES
- service pf start
- sh -c 'kldload virtio_p9fs 2>/dev/null || true'
- mkdir -p /mnt/gofw-cache
- mount -t p9fs gofwcache /mnt/gofw-cache
- cp /mnt/gofw-cache/firewall.test.freebsd /root/firewall.test
- chmod +x /root/firewall.test
- sh -c 'echo GOFW_VM_BEGIN; env FIREWALL_BACKEND=pf /root/firewall.test -test.v -test.run TestIntegration; echo "GOFW_VM_DONE rc=\$?"'
- poweroff
EOF
genisoimage -quiet -output "$seed" -volid CIDATA -joliet -rock \
"$work/user-data" "$work/meta-data"
echo ">> booting FreeBSD VM (serial console below; it powers off when finished)"
echo " Ctrl-C requests a clean ACPI shutdown (force-killed after ${SHUTDOWN_GRACE}s)."
echo " ------------------------------------------------------------------"
: >"$console"
# HMP monitor on a localhost-only, transient TCP port so the interrupt/timeout
# handlers can ask the guest to power off cleanly, poked via bash /dev/tcp.
mon_port=$(( 20000 + RANDOM % 20000 ))
# Stream serial through a FIFO so we hold qemu's real PID and tee flushes the log
# deterministically before we read it.
fifo="$work/qemu.out"
mkfifo "$fifo"
tee "$console" <"$fifo" &
tee_pid=$!
qemu_pid=""
interrupted=0
timed_out=0
# stop_vm asks the guest for a clean ACPI poweroff, waits SHUTDOWN_GRACE seconds,
# then hard-kills if it overruns. Idempotent; safe to call more than once.
stop_vm() {
kill -0 "$qemu_pid" 2>/dev/null || return 0
if exec 3<>"/dev/tcp/127.0.0.1/$mon_port" 2>/dev/null; then
printf 'system_powerdown\n' >&3
exec 3<&- 3>&-
fi
for _ in $(seq 1 "$SHUTDOWN_GRACE"); do
kill -0 "$qemu_pid" 2>/dev/null || return 0
sleep 1
done
echo "!! VM did not power off within ${SHUTDOWN_GRACE}s; killing it" >&2
kill -9 "$qemu_pid" 2>/dev/null || true
}
# on_interrupt drives a clean shutdown on Ctrl-C; further signals are ignored so a
# second Ctrl-C cannot abort mid-shutdown and orphan qemu. Invoked via the trap below.
# shellcheck disable=SC2329 # invoked indirectly by `trap on_interrupt INT TERM`.
on_interrupt() {
trap '' INT TERM
interrupted=1
echo >&2
echo ">> interrupt received; asking the VM to power off cleanly…" >&2
stop_vm
}
trap on_interrupt INT TERM
set +e
qemu-system-x86_64 \
-enable-kvm -cpu host -m "$MEM" -smp "$CPUS" \
-drive file="$overlay",if=virtio,format=qcow2 \
-drive file="$seed",if=virtio,format=raw \
-netdev user,id=n0 -device virtio-net-pci,netdev=n0 \
-fsdev local,id=fs0,path="$CACHE",security_model=none,readonly=on \
-device virtio-9p-pci,fsdev=fs0,mount_tag=gofwcache \
-monitor tcp:127.0.0.1:"$mon_port",server,nowait \
-serial stdio -display none -no-reboot </dev/null >"$fifo" 2>&1 &
qemu_pid=$!
# Wait for the VM, enforcing the boot timeout, while staying interruptible.
deadline=$(( SECONDS + BOOT_TIMEOUT ))
while kill -0 "$qemu_pid" 2>/dev/null; do
if [[ "$SECONDS" -ge "$deadline" ]]; then
timed_out=1
echo >&2
echo "!! VM exceeded ${BOOT_TIMEOUT}s; shutting it down" >&2
stop_vm
break
fi
sleep 2 & wait $! 2>/dev/null
done
wait "$qemu_pid" 2>/dev/null
trap - INT TERM
wait "$tee_pid" 2>/dev/null # let tee drain the FIFO and flush the console log
set -e
echo " ------------------------------------------------------------------"
if [[ "$interrupted" = 1 ]]; then
echo "!! run interrupted; VM stopped. Partial serial log: $console"
exit 130
fi
if [[ "$timed_out" = 1 ]]; then
echo "!! VM timed out after ${BOOT_TIMEOUT}s (see $console)"
exit 124
fi
echo
echo "==== FreeBSD pf run summary ===="
if grep -q "GOFW_VM_DONE" "$console"; then
grep -E -- '--- (PASS|FAIL|SKIP): |^(PASS|FAIL|ok)\b|GOFW_VM_DONE' "$console" | sed 's/\r$//'
rc="$(grep -o 'GOFW_VM_DONE rc=[0-9]*' "$console" | tail -1 | grep -o '[0-9]*$')"
echo "(full serial log: $console)"
exit "${rc:-1}"
fi
echo "!! the in-VM run did not complete (no GOFW_VM_DONE marker). See $console"
exit 1

230
test/integration/host-linux-vm.sh Executable file
View file

@ -0,0 +1,230 @@
#!/usr/bin/env bash
# HOST script — run this on your workstation. It boots a throwaway QEMU VM and
# runs the Linux integration backends inside it.
#
# Why a VM: the systemd backends (firewalld/ufw/iptables/csf/apf) need a booted
# systemd and, for real isolation between six backends that all fight over the same
# kernel netfilter/ipset state, a disposable environment we can freely reconfigure.
# We run a minimal Ubuntu server cloud-init image in qemu for that disposability,
# same as we would need even if the backends ran natively on bare metal.
#
# Usage:
# ./host-linux-vm.sh # all backends: nft firewalld ufw iptables apf csf
# ./host-linux-vm.sh firewalld # a subset
#
# The overlay disk is kept between runs by default: the first run creates it and
# provisions each requested backend on first use (package installs, third-party
# csf/apf downloads); later runs boot the same disk with those backends already
# provisioned and go straight to testing. Because provisioning is per-backend and
# keyed on each backend's clean-config snapshot, a fresh overlay only pays for the
# backends a given run actually touches, and a later run adds any not-yet-seen
# backend on demand. A fresh cloud-init seed is generated every run with a unique
# instance-id, so cloud-init executes the test payload on every boot. Backend
# firewall state does not persist across backends within a run — guest-linux-run.sh
# flushes and rsync-restores clean config between every backend. Set GOFW_VM_REUSE=0
# to force a fresh overlay each run; deleting .cache/overlay.qcow2 also resets it.
set -euo pipefail
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
repo="$(cd "$here/../.." && pwd)"
backends=("$@")
if [[ ${#backends[@]} -eq 0 ]]; then
backends=(nft firewalld ufw iptables apf csf)
fi
IMAGE_URL="${GOFW_VM_IMAGE_URL:-https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-amd64.img}"
MEM="${GOFW_VM_MEM:-4096}"
CPUS="${GOFW_VM_CPUS:-2}"
CACHE="${GOFW_VM_CACHE:-$repo/.cache}"
REUSE="${GOFW_VM_REUSE:-1}" # 1 = keep the overlay disk between runs (default)
BOOT_TIMEOUT="${GOFW_VM_TIMEOUT:-2400}" # seconds; covers apt installs, csf/apf downloads, and tests
SHUTDOWN_GRACE="${GOFW_VM_SHUTDOWN_GRACE:-60}" # seconds to wait for a clean poweroff before killing
mkdir -p "$CACHE"
base="$CACHE/base.img"
overlay="$CACHE/overlay.qcow2"
seed="$CACHE/seed.iso"
console="$CACHE/console.log"
bin="$CACHE/firewall.test"
command -v qemu-system-x86_64 >/dev/null || { echo "!! qemu-system-x86_64 not found"; exit 1; }
command -v genisoimage >/dev/null || { echo "!! genisoimage not found"; exit 1; }
echo ">> compiling test binary on host (Go toolchain lives here, not in the VM)"
# Write into $CACHE (shared into the guest as the writable gofwcache mount), not the
# repo tree — the repo is shared read-only and should stay free of build artifacts.
( cd "$repo" && CGO_ENABLED=0 go test -c -tags integration -o "$bin" . )
if [[ ! -f "$base" ]]; then
echo ">> downloading base cloud image -> $base"
curl -L --fail -o "$base.tmp" "$IMAGE_URL"
mv "$base.tmp" "$base"
fi
# Reuse the overlay only when asked and one already exists; otherwise start fresh.
# guest-linux-run.sh provisions each backend lazily and keys the "already
# provisioned" check on its per-backend snapshot, so a reused overlay's snapshots
# simply cause it to skip re-provisioning — the host does not need to track or
# signal fresh-vs-reused at all.
if [[ "$REUSE" = 1 ]] && [[ -f "$overlay" ]]; then
echo ">> reusing existing overlay disk (GOFW_VM_REUSE=1): $overlay"
else
echo ">> creating a fresh overlay disk (base stays pristine)"
rm -f "$overlay"
qemu-img create -f qcow2 -b "$base" -F qcow2 "$overlay" 20G >/dev/null
fi
# A fresh cloud-init seed is generated for every run. The unique instance-id makes
# each boot look like a new instance to cloud-init, so per-instance modules
# (write_files and runcmd) execute on both the first and every reused boot.
run_id="$(date +%s)-${RANDOM}"
instance_id="gofw-it-${run_id}"
# --- cloud-init seed --------------------------------------------------------
work="$(mktemp -d "$CACHE/seed.XXXXXX")"
trap 'rm -rf "$work"' EXIT
cat >"$work/meta-data" <<EOF
instance-id: ${instance_id}
local-hostname: gofw-it
EOF
cat >"$work/user-data" <<EOF
#cloud-config
write_files:
- path: /usr/local/bin/gofw-guest-payload.sh
permissions: '0755'
content: |
#!/bin/bash
set -x
# Authorize the in-VM guest script to run its destructive backend setup.
# guest-linux-run.sh refuses to run without this (or the disposable-VM
# marker below), so a workstation cannot execute it by accident.
export GOFW_ALLOW_RUN=1
mkdir -p /mnt/gofw /mnt/gofw-cache
mount -t 9p -o trans=virtio,version=9p2000.L,ro gofwrepo /mnt/gofw
mount -t 9p -o trans=virtio,version=9p2000.L,rw gofwcache /mnt/gofw-cache
cd /mnt/gofw
touch /etc/gofw-disposable-vm
echo "GOFW_VM_BEGIN"
./test/integration/guest-linux-run.sh ${backends[*]}
echo "GOFW_VM_DONE rc=\$?"
sync
poweroff
runcmd:
- [ bash, /usr/local/bin/gofw-guest-payload.sh ]
EOF
genisoimage -quiet -output "$seed" -volid CIDATA -joliet -rock \
"$work/user-data" "$work/meta-data"
# --- boot -------------------------------------------------------------------
echo ">> booting VM (serial console below; it powers off when finished)"
echo " backends: ${backends[*]}"
echo " Ctrl-C requests a clean ACPI shutdown (force-killed after ${SHUTDOWN_GRACE}s)."
echo " ------------------------------------------------------------------"
: >"$console"
# HMP monitor on a localhost-only, transient TCP port so the interrupt/timeout
# handlers can ask the guest to power off cleanly, poked via bash /dev/tcp (no
# extra tools). A clean ACPI shutdown matters now that the overlay is persisted.
mon_port=$(( 20000 + RANDOM % 20000 ))
# Stream serial through a FIFO instead of a pipeline so we hold qemu's real PID
# (to signal it) and tee flushes the log deterministically before we read it.
fifo="$work/qemu.out"
mkfifo "$fifo"
tee "$console" <"$fifo" &
tee_pid=$!
qemu_pid=""
interrupted=0
timed_out=0
# stop_vm asks the guest for a clean ACPI poweroff, waits SHUTDOWN_GRACE seconds,
# then hard-kills if it overruns. Idempotent; safe to call more than once.
stop_vm() {
kill -0 "$qemu_pid" 2>/dev/null || return 0
if exec 3<>"/dev/tcp/127.0.0.1/$mon_port" 2>/dev/null; then
printf 'system_powerdown\n' >&3
exec 3<&- 3>&-
fi
for _ in $(seq 1 "$SHUTDOWN_GRACE"); do
kill -0 "$qemu_pid" 2>/dev/null || return 0
sleep 1
done
echo "!! VM did not power off within ${SHUTDOWN_GRACE}s; killing it" >&2
kill -9 "$qemu_pid" 2>/dev/null || true
}
# on_interrupt drives a clean shutdown on Ctrl-C; further signals are ignored so a
# second Ctrl-C cannot abort mid-shutdown and orphan qemu. Invoked via the trap below.
# shellcheck disable=SC2329 # invoked indirectly by `trap on_interrupt INT TERM`.
on_interrupt() {
trap '' INT TERM
interrupted=1
echo >&2
echo ">> interrupt received; asking the VM to power off cleanly…" >&2
stop_vm
}
trap on_interrupt INT TERM
# fs1/gofwcache is writable (unlike fs0/gofwrepo) and backed by the same host
# directory used for the VM images, so guest-linux-run.sh can cache downloaded
# csf/apf packages there during provisioning and reuse them across VM overlay
# rebuilds instead of re-fetching from the network every time.
set +e
qemu-system-x86_64 \
-enable-kvm -cpu host -m "$MEM" -smp "$CPUS" \
-drive file="$overlay",if=virtio,format=qcow2 \
-drive file="$seed",if=virtio,format=raw \
-netdev user,id=n0 -device virtio-net-pci,netdev=n0 \
-fsdev local,id=fs0,path="$repo",security_model=none,readonly=on \
-device virtio-9p-pci,fsdev=fs0,mount_tag=gofwrepo \
-fsdev local,id=fs1,path="$CACHE",security_model=none \
-device virtio-9p-pci,fsdev=fs1,mount_tag=gofwcache \
-monitor tcp:127.0.0.1:"$mon_port",server,nowait \
-serial stdio -display none -no-reboot </dev/null >"$fifo" 2>&1 &
qemu_pid=$!
# Wait for the VM, enforcing the boot timeout. Sleeping in a backgrounded child we
# then wait on keeps the loop interruptible so the Ctrl-C trap fires promptly.
deadline=$(( SECONDS + BOOT_TIMEOUT ))
while kill -0 "$qemu_pid" 2>/dev/null; do
if [[ "$SECONDS" -ge "$deadline" ]]; then
timed_out=1
echo >&2
echo "!! VM exceeded ${BOOT_TIMEOUT}s; shutting it down" >&2
stop_vm
break
fi
sleep 2 & wait $! 2>/dev/null
done
wait "$qemu_pid" 2>/dev/null
trap - INT TERM
wait "$tee_pid" 2>/dev/null # let tee drain the FIFO and flush the console log
set -e
echo " ------------------------------------------------------------------"
if [[ "$interrupted" = 1 ]]; then
echo "!! run interrupted; VM stopped. Partial serial log: $console"
exit 130
fi
if [[ "$timed_out" = 1 ]]; then
echo "!! VM timed out after ${BOOT_TIMEOUT}s (see $console)"
exit 124
fi
# --- results ----------------------------------------------------------------
echo
echo "==== VM run summary ===="
if grep -q "GOFW_VM_DONE" "$console"; then
sed -n '/==== integration results ====/,/GOFW_VM_DONE/p' "$console" | sed 's/\r$//'
rc="$(grep -o 'GOFW_VM_DONE rc=[0-9]*' "$console" | tail -1 | grep -o '[0-9]*$')"
echo "(full serial log: $console)"
exit "${rc:-1}"
fi
echo "!! the in-VM run did not complete (no GOFW_VM_DONE marker). See $console"
exit 1

View file

@ -0,0 +1,205 @@
#!/usr/bin/env bash
# HOST script — run this on your workstation. It boots a throwaway QEMU Windows VM
# and runs the Windows Firewall (wf) integration backend inside it.
#
# On first use it performs a fully unattended install of Windows Server 2022
# Evaluation into .cache/windows.qcow2 (downloads the ~5 GB eval ISO if absent),
# provisions OpenSSH + disables UAC via an Autounattend.xml answer file, then boots
# a fresh overlay of that image, copies in the windows-cross-compiled test binary,
# and runs it over SSH. Everything is disposable; the base install is reused.
#
# Requirements: qemu-system-x86_64, KVM, genisoimage, socat, ssh (key auth).
#
# A Windows host with Administrator rights can also run the suite directly:
# go test -tags integration -run TestIntegration (from an elevated prompt)
set -euo pipefail
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
repo="$(cd "$here/../.." && pwd)"
MEM="${GOFW_VM_MEM:-4096}"
CPUS="${GOFW_VM_CPUS:-2}"
CACHE="${GOFW_VM_CACHE:-$repo/.cache}"
BOOT_TIMEOUT="${GOFW_VM_TIMEOUT:-3600}"
SSH_PORT="${GOFW_WIN_SSH_PORT:-2222}"
ISO_URL="${GOFW_WIN_ISO_URL:-https://software-static.download.prss.microsoft.com/sg/download/888969d5-f34g-4e03-ac9d-1f9786c66749/SERVER_EVAL_x64FRE_en-us.iso}"
PASS='Gofw!Test2024'
USER='Administrator'
mkdir -p "$CACHE"
iso="$CACHE/winserver.iso"
base="$CACHE/windows.qcow2"
overlay="$CACHE/windows-overlay.qcow2"
uaiso="$CACHE/autounattend.iso"
bin="$CACHE/firewall.test.exe"
mon="$CACHE/win-mon.sock"
for t in qemu-system-x86_64 genisoimage socat ssh ssh-keygen; do
command -v "$t" >/dev/null || { echo "!! $t not found"; exit 1; }
done
# Key-based SSH (no sshpass dependency); the public key is injected into the guest
# via the Autounattend firstlogon commands.
key="$CACHE/win_key"
[[ -f "$key" ]] || ssh-keygen -q -t ed25519 -N '' -C gofw-win -f "$key"
pubkey="$(cat "$key.pub")"
ssh_opts=(-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ConnectTimeout=10 -o IdentitiesOnly=yes -i "$key")
run_ssh() { ssh "${ssh_opts[@]}" -p "$SSH_PORT" "$USER@127.0.0.1" "$@"; }
copy_in() { scp "${ssh_opts[@]}" -P "$SSH_PORT" "$1" "$USER@127.0.0.1:$2"; }
# wait_ssh <qemu_pid> — returns 0 once the guest answers SSH, 1 on timeout/death.
wait_ssh() {
local pid="$1" deadline=$((SECONDS + BOOT_TIMEOUT))
until run_ssh "echo ok" >/dev/null 2>&1; do
kill -0 "$pid" 2>/dev/null || { echo "!! qemu exited before SSH came up"; return 1; }
[[ $SECONDS -ge $deadline ]] && { echo "!! timed out waiting for SSH"; return 1; }
sleep 10
done
}
# --- one-time unattended install -------------------------------------------
install_windows() {
if [[ ! -f "$iso" ]]; then
echo ">> downloading Windows Server 2022 eval ISO (~5 GB) -> $iso"
curl -L --fail -o "$iso.tmp" "$ISO_URL"
mv "$iso.tmp" "$iso"
fi
echo ">> building Autounattend.xml seed"
local work; work="$(mktemp -d "$CACHE/ua.XXXXXX")"
cat >"$work/autounattend.xml" <<XML
<?xml version="1.0" encoding="utf-8"?>
<unattend xmlns="urn:schemas-microsoft-com:unattend">
<settings pass="windowsPE">
<component name="Microsoft-Windows-International-Core-WinPE" processorArchitecture="amd64" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
<SetupUILanguage><UILanguage>en-US</UILanguage></SetupUILanguage>
<InputLocale>en-US</InputLocale><SystemLocale>en-US</SystemLocale>
<UILanguage>en-US</UILanguage><UserLocale>en-US</UserLocale>
</component>
<component name="Microsoft-Windows-Setup" processorArchitecture="amd64" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
<DiskConfiguration>
<Disk wcm:action="add" xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State">
<DiskID>0</DiskID><WillWipeDisk>true</WillWipeDisk>
<CreatePartitions>
<CreatePartition wcm:action="add"><Order>1</Order><Type>Primary</Type><Extend>true</Extend></CreatePartition>
</CreatePartitions>
<ModifyPartitions>
<ModifyPartition wcm:action="add"><Order>1</Order><PartitionID>1</PartitionID><Format>NTFS</Format><Active>true</Active><Label>Windows</Label></ModifyPartition>
</ModifyPartitions>
</Disk>
</DiskConfiguration>
<ImageInstall>
<OSImage>
<InstallTo><DiskID>0</DiskID><PartitionID>1</PartitionID></InstallTo>
<InstallFrom><MetaData wcm:action="add" xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State"><Key>/IMAGE/INDEX</Key><Value>3</Value></MetaData></InstallFrom>
</OSImage>
</ImageInstall>
<UserData><AcceptEula>true</AcceptEula></UserData>
</component>
</settings>
<settings pass="specialize">
<component name="Microsoft-Windows-Shell-Setup" processorArchitecture="amd64" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
<ComputerName>WINTEST</ComputerName>
</component>
</settings>
<settings pass="oobeSystem">
<component name="Microsoft-Windows-Shell-Setup" processorArchitecture="amd64" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
<UserAccounts><AdministratorPassword><Value>$PASS</Value><PlainText>true</PlainText></AdministratorPassword></UserAccounts>
<AutoLogon><Password><Value>$PASS</Value><PlainText>true</PlainText></Password><Enabled>true</Enabled><Username>$USER</Username><LogonCount>999</LogonCount></AutoLogon>
<OOBE>
<HideEULAPage>true</HideEULAPage><HideLocalAccountScreen>true</HideLocalAccountScreen>
<HideOnlineAccountScreens>true</HideOnlineAccountScreens><HideWirelessSetupInOOBE>true</HideWirelessSetupInOOBE>
<NetworkLocation>Work</NetworkLocation><ProtectYourPC>3</ProtectYourPC>
<SkipUserOOBE>true</SkipUserOOBE><SkipMachineOOBE>true</SkipMachineOOBE>
</OOBE>
<FirstLogonCommands>
<SynchronousCommand wcm:action="add" xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State"><Order>1</Order><CommandLine>reg add HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System /v EnableLUA /t REG_DWORD /d 0 /f</CommandLine></SynchronousCommand>
<SynchronousCommand wcm:action="add" xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State"><Order>2</Order><CommandLine>reg add "HKLM\SOFTWARE\OpenSSH" /v DefaultShell /t REG_SZ /d "C:\Windows\System32\cmd.exe" /f</CommandLine></SynchronousCommand>
<SynchronousCommand wcm:action="add" xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State"><Order>3</Order><CommandLine>powershell -NoProfile -Command "Add-WindowsCapability -Online -Name OpenSSH.Server~~~~0.0.1.0"</CommandLine></SynchronousCommand>
<SynchronousCommand wcm:action="add" xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State"><Order>4</Order><CommandLine>powershell -NoProfile -Command "New-Item -Force -ItemType Directory C:\ProgramData\ssh | Out-Null; Set-Content -Encoding ascii -Path C:\ProgramData\ssh\administrators_authorized_keys -Value '$pubkey'; icacls C:\ProgramData\ssh\administrators_authorized_keys /inheritance:r /grant SYSTEM:F /grant BUILTIN\Administrators:F"</CommandLine></SynchronousCommand>
<SynchronousCommand wcm:action="add" xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State"><Order>5</Order><CommandLine>powershell -NoProfile -Command "Set-Service sshd -StartupType Automatic; Start-Service sshd"</CommandLine></SynchronousCommand>
<SynchronousCommand wcm:action="add" xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State"><Order>6</Order><CommandLine>netsh advfirewall firewall add rule name=OpenSSH dir=in action=allow protocol=TCP localport=22</CommandLine></SynchronousCommand>
</FirstLogonCommands>
</component>
</settings>
</unattend>
XML
genisoimage -quiet -o "$uaiso" -V UNATTEND -J -r "$work/autounattend.xml"
rm -rf "$work"
echo ">> creating install disk"
rm -f "$base.installing"
qemu-img create -f qcow2 "$base.installing" 40G >/dev/null
rm -f "$mon"
echo ">> booting installer (unattended; this takes ~30-40 min)"
qemu-system-x86_64 \
-enable-kvm -cpu host -m "$MEM" -smp "$CPUS" \
-drive file="$base.installing",if=none,id=disk,format=qcow2 \
-device ahci,id=ahci -device ide-hd,drive=disk,bus=ahci.0 \
-drive file="$iso",media=cdrom,if=none,id=inst -device ide-cd,drive=inst,bus=ahci.1 \
-drive file="$uaiso",media=cdrom,if=none,id=ua -device ide-cd,drive=ua,bus=ahci.2 \
-netdev "user,id=n0,hostfwd=tcp::${SSH_PORT}-:22" -device e1000,netdev=n0 \
-boot once=d,menu=off -monitor "unix:$mon,server,nowait" -display none -serial null &
local qpid=$!
trap 'kill "$qpid" 2>/dev/null || true' RETURN
# Bypass the "Press any key to boot from CD" prompt: tap Enter for the first
# ~20 s via the QEMU monitor.
( for _ in $(seq 1 20); do echo "sendkey ret"; sleep 1; done | socat - "unix-connect:$mon" >/dev/null 2>&1 || true ) &
echo ">> waiting for the installed guest to answer SSH (up to ${BOOT_TIMEOUT}s)"
if ! wait_ssh "$qpid"; then
echo "!! install did not finish; see the console. Leaving $base.installing for inspection."
return 1
fi
echo ">> install complete; shutting the guest down"
run_ssh "shutdown /s /t 0" >/dev/null 2>&1 || true
for _ in $(seq 1 30); do kill -0 "$qpid" 2>/dev/null || break; sleep 2; done
kill "$qpid" 2>/dev/null || true
trap - RETURN
mv "$base.installing" "$base"
echo ">> prepared image ready: $base"
}
[[ -f "$base" ]] || install_windows
# --- run the suite ----------------------------------------------------------
echo ">> cross-compiling windows test binary on host"
( cd "$repo" && GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go test -c -tags integration -o "$bin" . )
echo ">> creating a fresh overlay disk (base stays pristine)"
rm -f "$overlay"
qemu-img create -f qcow2 -b "$base" -F qcow2 "$overlay" 40G >/dev/null
echo ">> booting Windows VM (headless; SSH on localhost:$SSH_PORT)"
qemu-system-x86_64 \
-enable-kvm -cpu host -m "$MEM" -smp "$CPUS" \
-drive file="$overlay",if=none,id=disk,format=qcow2 \
-device ahci,id=ahci -device ide-hd,drive=disk,bus=ahci.0 \
-netdev "user,id=n0,hostfwd=tcp::${SSH_PORT}-:22" -device e1000,netdev=n0 \
-monitor "unix:$mon,server,nowait" -display none -serial null &
qpid=$!
trap 'kill "$qpid" 2>/dev/null || true' EXIT
wait_ssh "$qpid" || exit 124
echo ">> copying test binary and running the suite (Administrator, UAC disabled)"
copy_in "$bin" "firewall.test.exe"
set +e
# Keep the SSH control channel alive independent of the port-22 rules the suite
# adds and removes. The guest sshd listens on :22 (host :2222 forwards to it), so a
# subtest that manages a bare TCP/22 rule would otherwise remove the OpenSSH allow
# and WFP would drop this session. This rule permits inbound TCP from the QEMU SLIRP
# gateway (10.0.2.2, the source the forwarded SSH appears from) on any local port;
# its shape (a source-only match, no local port) matches no test rule, so the suite
# never removes it, and it is not the bare TCP/22 rule the suite round-trips.
run_ssh 'netsh advfirewall firewall add rule name=gofw-keepalive dir=in action=allow protocol=TCP remoteip=10.0.2.0/24'
run_ssh 'set "FIREWALL_BACKEND=wf"&& firewall.test.exe -test.v -test.run TestIntegration'
rc=$?
set -e
run_ssh "shutdown /s /t 0" >/dev/null 2>&1 || true
echo "==== Windows wf run finished (rc=$rc) ===="
exit "$rc"

733
types.go Normal file
View file

@ -0,0 +1,733 @@
package firewall
import (
"fmt"
"strconv"
"strings"
)
// Action is the firewall action taken on a rule's matching packets.
type Action uint8
const (
// ActionInvalid is the zero value of Action, meaning no action; it is
// rejected when authoring a rule or policy.
ActionInvalid Action = iota
// Accept permits matching packets through.
Accept
// Reject refuses matching packets with an error response to the sender.
Reject
// Drop silently discards matching packets.
Drop
)
// String returns the canonical lower-case name of the action.
func (t Action) String() string {
switch t {
case Accept:
return "accept"
case Reject:
return "reject"
case Drop:
return "drop"
}
return "invalid"
}
// ParseAction parses a caller-supplied action token (case-insensitive),
// accepting only the concrete actions Accept, Reject and Drop. The sentinel
// "invalid" (ActionInvalid) is rejected here so callers cannot author a rule or
// policy with no real action; backup decoding round-trips it separately in
// Action.UnmarshalJSON.
func ParseAction(s string) (Action, error) {
switch strings.ToLower(strings.TrimSpace(s)) {
case "accept":
return Accept, nil
case "reject":
return Reject, nil
case "drop":
return Drop, nil
}
return 0, fmt.Errorf("unknown action %q", s)
}
// Family is the IP family a rule targets.
type Family uint8
const (
// FamilyAny targets both IPv4 and IPv6.
FamilyAny Family = iota
// IPv4 targets IPv4 traffic only.
IPv4
// IPv6 targets IPv6 traffic only.
IPv6
)
// String returns the canonical lower-case name of the family.
func (t Family) String() string {
switch t {
case IPv4:
return "ipv4"
case IPv6:
return "ipv6"
}
return "any"
}
// ParseFamily parses a family token (case-insensitive), accepting the canonical
// name emitted by Family.String plus the common aliases (v4/inet4, v6/inet6).
// An unknown value is an error.
func ParseFamily(s string) (Family, error) {
switch strings.ToLower(strings.TrimSpace(s)) {
case "any":
return FamilyAny, nil
case "ipv4", "v4", "inet4":
return IPv4, nil
case "ipv6", "v6", "inet6":
return IPv6, nil
}
return 0, fmt.Errorf("unknown family %q", s)
}
// Protocol is the network protocol a rule matches.
type Protocol uint8
const (
// ProtocolAny matches every IP protocol and is the zero value.
ProtocolAny Protocol = iota
// UDP is the connectionless transport protocol.
UDP
// TCP is the connection-oriented transport protocol.
TCP
// ICMP and ICMPv6 are the control-message protocols. ICMP implies IPv4 and
// ICMPv6 implies IPv6.
ICMP
ICMPv6
// SCTP is a transport protocol that, like TCP and UDP, carries ports.
SCTP
// GRE, ESP and AH are portless IP protocols (tunneling and IPsec). A rule
// carrying one of these cannot also match a port.
GRE
ESP
AH
// TCPUDP matches TCP and UDP together.
TCPUDP
)
// String returns the canonical lower-case name of the protocol.
func (t Protocol) String() string {
switch t {
case UDP:
return "udp"
case TCP:
return "tcp"
case TCPUDP:
return "tcpudp"
case ICMP:
return "icmp"
case ICMPv6:
return "icmpv6"
case SCTP:
return "sctp"
case GRE:
return "gre"
case ESP:
return "esp"
case AH:
return "ah"
}
return "any"
}
// IsICMP reports whether the protocol is ICMP or ICMPv6.
func (t Protocol) IsICMP() bool {
return t == ICMP || t == ICMPv6
}
// HasPorts reports whether the protocol carries layer-4 ports (TCP, UDP, SCTP or
// the merged TCPUDP). A port match is only meaningful and only valid for these
// protocols.
func (t Protocol) HasPorts() bool {
return t == TCP || t == UDP || t == SCTP || t == TCPUDP
}
// oppositeProtocol returns the other transport of the TCP/UDP pair a TCPUDP rule
// fans out to: UDP for TCP and vice versa. Every other protocol has no twin and
// returns ProtocolAny (the sentinel meaning "no pair"). It is the protocol analog of
// oppositeFamily, and supports the dual-row split on removal.
func oppositeProtocol(p Protocol) Protocol {
switch p {
case TCP:
return UDP
case UDP:
return TCP
default:
return ProtocolAny
}
}
// Ptr returns a pointer to v. It is a convenience for setting optional rule
// fields such as ICMPType, e.g. firewall.Ptr[uint8](8).
func Ptr[T any](v T) *T {
return &v
}
// icmpNameToNum maps the ICMP type names various tools accept (and their common
// aliases) to their numeric type. It is used when reading a rule whose ICMP type
// is written by name; rules this library writes always emit the number, which
// every backend accepts.
var icmpNameToNum = map[string]uint8{
"echo-reply": 0,
"pong": 0,
"destination-unreachable": 3,
"source-quench": 4,
"redirect": 5,
"echo-request": 8,
"ping": 8,
"router-advertisement": 9,
"router-solicitation": 10,
"time-exceeded": 11,
"ttl-exceeded": 11,
"parameter-problem": 12,
"timestamp-request": 13,
"timestamp-reply": 14,
"info-request": 15,
"info-reply": 16,
"address-mask-request": 17,
"address-mask-reply": 18,
"traceroute": 30,
}
// icmpv6NameToNum maps the ICMPv6 type names nftables, ip6tables and ufw print
// to their numeric type (both the nftables nd-* spellings and the ip6tables
// long forms such as router-solicitation). ICMPv6 reuses several names from
// ICMPv4 (echo-request, destination-unreachable, ...) for *different* numbers,
// so a name read from an ICMPv6 rule must be resolved through this table rather
// than icmpNameToNum.
var icmpv6NameToNum = map[string]uint8{
"destination-unreachable": 1,
"packet-too-big": 2,
"time-exceeded": 3,
"ttl-exceeded": 3,
"parameter-problem": 4,
"echo-request": 128,
"ping": 128,
"echo-reply": 129,
"pong": 129,
"mld-listener-query": 130,
"mld-listener-report": 131,
"mld-listener-done": 132,
"mld-listener-reduction": 132,
"nd-router-solicit": 133,
"router-solicitation": 133,
"nd-router-advert": 134,
"router-advertisement": 134,
"nd-neighbor-solicit": 135,
"neighbor-solicitation": 135,
"neighbour-solicitation": 135,
"nd-neighbor-advert": 136,
"neighbor-advertisement": 136,
"neighbour-advertisement": 136,
"nd-redirect": 137,
"redirect": 137,
"router-renumbering": 138,
"ind-neighbor-solicit": 141,
"ind-neighbor-advert": 142,
"mld2-listener-report": 143,
}
// parseICMPType parses an ICMP type token as either a number (0-255) or one of
// the well-known IPv4 names in icmpNameToNum.
func parseICMPType(tok string) (uint8, bool) {
return parseICMPTypeFamily(tok, false)
}
// parseICMPTypeFamily parses an ICMP type token like parseICMPType, but resolves
// names through the ICMPv6 table when v6 is true. Numbers parse identically in
// either family (and rules this library writes always emit the number), so only
// the name path is family-dependent.
func parseICMPTypeFamily(tok string, v6 bool) (uint8, bool) {
tok = strings.TrimSpace(tok)
if n, err := strconv.ParseUint(tok, 10, 8); err == nil {
return uint8(n), true
}
if v6 {
if n, ok := icmpv6NameToNum[strings.ToLower(tok)]; ok {
return n, true
}
return 0, false
}
if n, ok := icmpNameToNum[strings.ToLower(tok)]; ok {
return n, true
}
return 0, false
}
// ParseICMPType parses an ICMP type token as either a number (0-255) or a
// well-known type name, resolving names through the ICMPv6 table when v6 is true
// (the same name maps to a different number under ICMPv6 — e.g. echo-request is 8
// for ICMPv4 but 128 for ICMPv6). It is the exported form of the resolution the
// backends use internally, so a caller or CLI authoring a rule by name accepts
// exactly the spellings the library itself emits and reads back.
func ParseICMPType(tok string, v6 bool) (uint8, bool) {
return parseICMPTypeFamily(tok, v6)
}
// GetProtocol converts a string to the network protocol. The common spellings
// each backend emits for ICMPv6 (icmpv6, ipv6-icmp, icmp6) are all recognized.
// An unknown token resolves to ProtocolAny (the widest match), so a caller that
// must distinguish an unknown protocol from a genuine "any" checks the token
// itself, as the save-file parsers do.
func GetProtocol(proto string) Protocol {
switch {
case strings.EqualFold("udp", proto):
return UDP
case strings.EqualFold("tcp", proto):
return TCP
case strings.EqualFold("tcpudp", proto):
return TCPUDP
case strings.EqualFold("icmp", proto):
return ICMP
case strings.EqualFold("icmpv6", proto),
strings.EqualFold("ipv6-icmp", proto),
strings.EqualFold("icmp6", proto):
return ICMPv6
case strings.EqualFold("sctp", proto):
return SCTP
case strings.EqualFold("gre", proto):
return GRE
case strings.EqualFold("esp", proto),
strings.EqualFold("ipsec-esp", proto):
return ESP
case strings.EqualFold("ah", proto),
strings.EqualFold("ipsec-ah", proto):
return AH
}
return ProtocolAny
}
// PortRange is an inclusive range of ports. A single port is represented with
// End equal to Start (or End left zero, which normalizes to Start).
type PortRange struct {
// Start is the first port in the inclusive range.
Start uint16
// End is the last port in the inclusive range.
End uint16
}
// normalized returns the range with a zero or inverted End collapsed to a single
// port at Start.
func (pr PortRange) normalized() PortRange {
if pr.End == 0 || pr.End < pr.Start {
pr.End = pr.Start
}
return pr
}
// String renders the range as "80" for a single port or "80-90" for a span.
func (pr PortRange) String() string {
pr = pr.normalized()
if pr.Start == pr.End {
return strconv.FormatUint(uint64(pr.Start), 10)
}
return fmt.Sprintf("%d-%d", pr.Start, pr.End)
}
// ParsePortRange parses a single "80" or "80-90"/"80:90" token into a PortRange.
func ParsePortRange(s string) (PortRange, error) {
s = strings.TrimSpace(s)
sep := "-"
if strings.Contains(s, ":") {
sep = ":"
}
lo, hi, isRange := strings.Cut(s, sep)
start, err := strconv.ParseUint(strings.TrimSpace(lo), 10, 16)
if err != nil {
return PortRange{}, fmt.Errorf("invalid port %q", lo)
}
pr := PortRange{Start: uint16(start), End: uint16(start)}
if isRange {
end, err := strconv.ParseUint(strings.TrimSpace(hi), 10, 16)
if err != nil {
return PortRange{}, fmt.Errorf("invalid port %q", hi)
}
pr.End = uint16(end)
if pr.End < pr.Start {
return PortRange{}, fmt.Errorf("port range end %d is below start %d", pr.End, pr.Start)
}
}
return pr, nil
}
// ParsePortRanges parses a separated list such as "80,443,1000-2000" into a slice
// of PortRange values. sep is the separator between entries (typically ",").
func ParsePortRanges(s, sep string) ([]PortRange, error) {
var out []PortRange
for _, tok := range strings.Split(s, sep) {
tok = strings.TrimSpace(tok)
if tok == "" {
continue
}
pr, err := ParsePortRange(tok)
if err != nil {
return nil, err
}
out = append(out, pr)
}
return out, nil
}
// FormatPortRanges renders a slice of ranges as a separated list.
func FormatPortRanges(prs []PortRange, sep string) string {
parts := make([]string, len(prs))
for i, pr := range prs {
parts[i] = pr.String()
}
return strings.Join(parts, sep)
}
// ConnState is a set of connection-tracking states to match, combined as a
// bitmask (e.g. StateEstablished|StateRelated). The zero value matches no
// particular state (i.e. the rule is stateless).
type ConnState uint8
const (
// StateNew matches packets starting a new connection.
StateNew ConnState = 1 << iota
// StateEstablished matches packets belonging to an existing connection.
StateEstablished
// StateRelated matches packets starting a connection related to an existing
// one.
StateRelated
// StateInvalid matches packets the tracker cannot associate with a connection.
StateInvalid
)
// connStateNames lists the states in canonical rendering order.
var connStateNames = []struct {
bit ConnState
name string
}{
{StateNew, "new"},
{StateEstablished, "established"},
{StateRelated, "related"},
{StateInvalid, "invalid"},
}
// Strings returns the set states as lower-case names in canonical order.
func (s ConnState) Strings() []string {
var out []string
for _, cs := range connStateNames {
if s&cs.bit != 0 {
out = append(out, cs.name)
}
}
return out
}
// String renders the state set as a comma-separated list (e.g.
// "established,related"), or the empty string when no state is set.
func (s ConnState) String() string {
return strings.Join(s.Strings(), ",")
}
// ParseConnState parses state names (case-insensitive) into a ConnState bitmask.
// Each token may itself be a comma-separated list. An unknown name is an error.
func ParseConnState(tokens ...string) (ConnState, error) {
var state ConnState
for _, tok := range tokens {
for _, name := range strings.Split(tok, ",") {
name = strings.TrimSpace(name)
if name == "" {
continue
}
matched := false
for _, cs := range connStateNames {
if strings.EqualFold(name, cs.name) {
state |= cs.bit
matched = true
break
}
}
if !matched {
return 0, fmt.Errorf("unknown connection state %q", name)
}
}
}
return state, nil
}
// RateUnit is the time unit a RateLimit is expressed over.
type RateUnit uint8
const (
// PerSecond expresses a rate per second.
PerSecond RateUnit = iota
// PerMinute expresses a rate per minute.
PerMinute
// PerHour expresses a rate per hour.
PerHour
// PerDay expresses a rate per day.
PerDay
)
// String returns the canonical (nftables-style) unit name.
func (u RateUnit) String() string {
switch u {
case PerMinute:
return "minute"
case PerHour:
return "hour"
case PerDay:
return "day"
}
return "second"
}
// ParseRateUnit parses a rate-unit token, accepting the long, short and
// single-letter spellings the various backends emit (e.g. second/sec/s).
func ParseRateUnit(s string) (RateUnit, error) {
switch strings.ToLower(strings.TrimSpace(s)) {
case "s", "sec", "second", "seconds":
return PerSecond, nil
case "m", "min", "minute", "minutes":
return PerMinute, nil
case "h", "hour", "hours":
return PerHour, nil
case "d", "day", "days":
return PerDay, nil
}
return 0, fmt.Errorf("unknown rate unit %q", s)
}
// RateLimit caps the rate at which a rule matches packets: up to Rate packets
// per Unit, with an optional Burst allowance. A nil *RateLimit on a Rule means
// no rate limiting. Backends that cannot express a rate limit reject a rule
// carrying one rather than applying it unlimited.
type RateLimit struct {
// Rate is the maximum number of matching packets allowed per Unit.
Rate uint
// Unit is the time window Rate is counted over.
Unit RateUnit
// Burst is an optional allowance for bursts above Rate. 0 leaves the burst
// at the backend default.
Burst uint
}
// String renders the limit as "<rate>/<unit>" (e.g. "10/minute").
func (rl RateLimit) String() string {
return fmt.Sprintf("%d/%s", rl.Rate, rl.Unit)
}
// parseRateToken parses a "<rate>/<unit>" token (e.g. "10/minute") into its
// numeric rate and unit. Backends use it when decoding a rule.
func parseRateToken(tok string) (uint, RateUnit, error) {
num, unitStr, ok := strings.Cut(strings.TrimSpace(tok), "/")
if !ok {
return 0, 0, fmt.Errorf("invalid rate %q", tok)
}
n, err := strconv.ParseUint(strings.TrimSpace(num), 10, 32)
if err != nil {
return 0, 0, fmt.Errorf("invalid rate %q", tok)
}
unit, err := ParseRateUnit(unitStr)
if err != nil {
return 0, 0, err
}
return uint(n), unit, nil
}
// ConnLimit caps the number of concurrent connections a rule matches. When
// PerSource is set the cap is applied per source address; otherwise it is a
// single global cap. A nil *ConnLimit means no connection limiting.
type ConnLimit struct {
// Count is the maximum number of concurrent connections the rule matches.
Count uint
// PerSource, when set, applies Count per source address rather than as a
// single global cap.
PerSource bool
}
// netfilterDefaultBurst is the burst the kernel's xt_limit applies when a rule
// names none (5). nft and iptables always print it back, and their read paths
// collapse it to 0 (unset), so a caller that sets Burst=5 is asking for exactly
// that default; normBurst folds the two spellings together.
const netfilterDefaultBurst = 5
// normBurst folds an explicit burst of the netfilter default (5) to 0 (unset)
// so a rule that names Burst=5 matches its own read-back, which reports the
// default as 0.
func normBurst(b uint) uint {
if b == netfilterDefaultBurst {
return 0
}
return b
}
// eqRateLimit reports whether two optional rate limits are equal, treating nil
// as a distinct "unset" value. The burst is compared through normBurst so an
// explicit default burst (5) and an unset burst (0) count as the same limit.
func eqRateLimit(a, b *RateLimit) bool {
if a == nil || b == nil {
return a == b
}
return a.Rate == b.Rate && a.Unit == b.Unit && normBurst(a.Burst) == normBurst(b.Burst)
}
// eqConnLimit reports whether two optional connection limits are equal, treating
// nil as a distinct "unset" value.
func eqConnLimit(a, b *ConnLimit) bool {
if a == nil || b == nil {
return a == b
}
return *a == *b
}
// Direction names the traffic direction a default policy or rule applies to.
type Direction uint8
const (
// DirInput is the inbound (input) direction. It must remain the zero value so
// a rule with no explicit direction is an input rule.
DirInput Direction = iota
// DirOutput is the outbound (output) direction.
DirOutput
// DirForward is the routing (forward) direction, where a backend models it.
DirForward
// DirAny applies to both the input and output directions. It is the direction
// analog of FamilyAny: a backend that can store a bidirectional rule as one
// object reads it back as DirAny, while one that cannot fans it into a concrete
// input row plus a role-swapped output row on write (expandDirections). It never
// covers DirForward (a routed rule has no input/output twin) and must be declared
// last so DirInput stays the zero value.
DirAny
)
// String returns the canonical lower-case name of the direction.
func (d Direction) String() string {
switch d {
case DirOutput:
return "output"
case DirForward:
return "forward"
case DirAny:
return "any"
}
return "input"
}
// ParseDirection parses a direction token (case-insensitive), accepting the
// canonical name emitted by Direction.String. An unknown value is an error.
func ParseDirection(s string) (Direction, error) {
switch strings.ToLower(strings.TrimSpace(s)) {
case "input", "in":
return DirInput, nil
case "output", "out":
return DirOutput, nil
case "forward", "fwd":
return DirForward, nil
case "any", "both":
return DirAny, nil
}
return 0, fmt.Errorf("unknown direction %q", s)
}
// DefaultPolicy describes the default action a firewall applies to packets that
// match no rule, per direction. A field left as ActionInvalid has backend-
// defined meaning: on Get it means the backend does not expose that direction,
// and on Set it means the direction should be left unchanged.
type DefaultPolicy struct {
// Input is the default action for inbound packets.
Input Action
// Output is the default action for outbound packets.
Output Action
// Forward is the default action for routed packets.
Forward Action
}
// get returns the action for a direction on a DefaultPolicy.
func (p *DefaultPolicy) get(d Direction) Action {
switch d {
case DirOutput:
return p.Output
case DirForward:
return p.Forward
}
return p.Input
}
// set assigns the action for a direction on a DefaultPolicy.
func (p *DefaultPolicy) set(d Direction, a Action) {
switch d {
case DirOutput:
p.Output = a
case DirForward:
p.Forward = a
default:
p.Input = a
}
}
// SetType names the kind of entries an AddressSet holds.
type SetType uint8
const (
// SetHashIP is a set of individual IP addresses.
SetHashIP SetType = iota
// SetHashNet is a set of CIDR network ranges.
SetHashNet
)
// String returns the ipset-style name of the set type.
func (t SetType) String() string {
switch t {
case SetHashNet:
return "hash:net"
}
return "hash:ip"
}
// ParseSetType parses a set-type token (case-insensitive), accepting the
// canonical name emitted by SetType.String ("hash:ip"/"hash:net") plus the short
// aliases "ip"/"net". An unknown value is an error.
func ParseSetType(s string) (SetType, error) {
switch strings.ToLower(strings.TrimSpace(s)) {
case "hash:ip", "ip":
return SetHashIP, nil
case "hash:net", "net":
return SetHashNet, nil
}
return 0, fmt.Errorf("unknown set type %q", s)
}
// AddressSet is a named collection of addresses (an ipset, an nftables set or a
// pf table) that rules can match against. It is managed separately from filter
// and NAT rules through the Manager's address-set methods.
type AddressSet struct {
// Name of the set. Backends that namespace sets (nftables table, pf anchor)
// keep it within their own container.
Name string
// Family restricts the set to an IP family. Some backends require a concrete
// family (nftables inet sets carry a single address type); FamilyAny is
// resolved to IPv4 by those backends.
Family Family
// Type is the entry kind, defaulting to SetHashIP when zero.
Type SetType
// Entries are the addresses or CIDRs in the set.
Entries []string
}
// ruleLine is one line a rule materializes into in a csf.allow/csf.deny or apf
// allow_hosts/deny_hosts file, paired with the rule that line reads back as. A rule
// spanning a family or transport axis the native line cannot carry has no single
// form, so it fans out into one line per cell. EditIPList marks the lines the file
// already holds as it scans and writes only the rest, so a partially present fan-out
// — one family written by an earlier single-family add, or a line lost to a manual
// edit — is completed rather than left half open or duplicated wholesale.
type ruleLine struct {
// line is the exact text written to the list file.
line string
// read is the rule that line parses back to, which is what an existing line in
// the file is compared against to decide whether the line is already present.
read *Rule
}

110
types_test.go Normal file
View file

@ -0,0 +1,110 @@
package firewall
import (
"encoding/json"
"testing"
"github.com/stretchr/testify/require"
)
// ParseICMPType resolves a numeric or named ICMP type, selecting the ICMPv6 name
// table when v6 is set so a name shared with ICMPv4 maps to its v6 number. It is
// the exported resolver the CLI relies on, so its name coverage must match the
// tables the backends emit.
func TestParseICMPType(t *testing.T) {
cases := []struct {
tok string
v6 bool
want uint8
ok bool
}{
{"8", false, 8, true}, // numeric parses in either family
{"255", true, 255, true}, // numbers are family-independent
{"echo-request", false, 8, true},
{"ECHO-REQUEST", false, 8, true}, // case-insensitive
{"echo-request", true, 128, true}, // same name, different v6 number
{"destination-unreachable", false, 3, true},
{"destination-unreachable", true, 1, true},
{"nd-neighbor-solicit", true, 135, true},
// info-request/info-reply live only in the v4 table; route them through
// here to prove they resolve.
{"info-request", false, 15, true},
{"info-reply", false, 16, true},
// A v4-only name is unknown under v6, and vice versa.
{"source-quench", true, 0, false},
{"packet-too-big", false, 0, false},
{"not-a-type", false, 0, false},
}
for _, c := range cases {
got, ok := ParseICMPType(c.tok, c.v6)
require.Equalf(t, c.ok, ok, "ParseICMPType(%q, v6=%v) ok", c.tok, c.v6)
if c.ok {
require.Equalf(t, c.want, got, "ParseICMPType(%q, v6=%v)", c.tok, c.v6)
}
}
}
// Enums render as their stable string name (not a bare number) and round-trip
// through encoding/json. A backup must stay readable and meaningful even if an
// iota constant is later reordered.
func TestEnumJSON(t *testing.T) {
cases := []struct {
name string
in any
want string // the quoted JSON string expected
}{
{"action-accept", Accept, `"accept"`},
{"action-drop", Drop, `"drop"`},
{"action-invalid", ActionInvalid, `"invalid"`},
{"family-v4", IPv4, `"ipv4"`},
{"family-v6", IPv6, `"ipv6"`},
{"family-any", FamilyAny, `"any"`},
{"proto-tcp", TCP, `"tcp"`},
{"proto-sctp", SCTP, `"sctp"`},
{"proto-any", ProtocolAny, `"any"`},
{"natkind", DNAT, `"dnat"`},
{"rateunit", PerMinute, `"minute"`},
{"direction", DirForward, `"forward"`},
{"settype", SetHashNet, `"hash:net"`},
{"connstate", ConnState(StateEstablished | StateRelated), `"established,related"`},
{"connstate-zero", ConnState(0), `""`},
}
for _, c := range cases {
out, err := json.Marshal(c.in)
require.NoError(t, err, c.name)
require.Equal(t, c.want, string(out), "%s: marshal", c.name)
}
// Round-trip each value through marshal -> unmarshal.
roundTrips := []struct {
name string
mk func() any // fresh addressable value to unmarshal into
eq func(any) bool // reports whether it equals the marshal source
}{
{"action", func() any { var v Action; return &v }, func(g any) bool { return *g.(*Action) == Accept }},
{"family", func() any { var v Family; return &v }, func(g any) bool { return *g.(*Family) == IPv4 }},
{"proto", func() any { var v Protocol; return &v }, func(g any) bool { return *g.(*Protocol) == TCP }},
{"connstate", func() any { var v ConnState; return &v }, func(g any) bool { return *g.(*ConnState) == (StateNew | StateEstablished) }},
{"rateunit", func() any { var v RateUnit; return &v }, func(g any) bool { return *g.(*RateUnit) == PerHour }},
{"natkind", func() any { var v NATKind; return &v }, func(g any) bool { return *g.(*NATKind) == Masquerade }},
{"direction", func() any { var v Direction; return &v }, func(g any) bool { return *g.(*Direction) == DirOutput }},
{"settype", func() any { var v SetType; return &v }, func(g any) bool { return *g.(*SetType) == SetHashIP }},
}
marshalVals := map[string]any{
"action": Accept,
"family": IPv4,
"proto": TCP,
"connstate": ConnState(StateNew | StateEstablished),
"rateunit": PerHour,
"natkind": Masquerade,
"direction": DirOutput,
"settype": SetHashIP,
}
for _, rt := range roundTrips {
data, err := json.Marshal(marshalVals[rt.name])
require.NoError(t, err, rt.name)
dst := rt.mk()
require.NoError(t, json.Unmarshal(data, dst), "%s: unmarshal %s", rt.name, data)
require.True(t, rt.eq(dst), "%s: round-trip mismatch (got %+v)", rt.name, dst)
}
}

1897
ufw_linux.go Normal file

File diff suppressed because it is too large Load diff

805
ufw_linux_test.go Normal file
View file

@ -0,0 +1,805 @@
package firewall
import (
"encoding/hex"
"os"
"path/filepath"
"strings"
"testing"
"github.com/stretchr/testify/require"
)
// TestUFWParseTupleRowsModelsRouteRows verifies a route/forward tuple is now
// decoded as a forward-direction rule (occupying its physical row), and ParseRules
// returns it alongside the ordinary rules.
func TestUFWParseTupleRowsModelsRouteRows(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "user.rules")
content := "### tuple ### route:allow tcp 23 0.0.0.0/0 any 0.0.0.0/0 in\n" +
"### tuple ### allow tcp 22 0.0.0.0/0 any 0.0.0.0/0 in\n" +
"### tuple ### allow tcp 80 0.0.0.0/0 any 0.0.0.0/0 in\n"
require.NoError(t, os.WriteFile(p, []byte(content), 0644))
fw := new(UFW)
rows, err := fw.parseTupleRows(p, IPv4)
require.NoError(t, err)
require.Len(t, rows, 3, "every tuple line occupies a physical row, including the route rule")
require.NotNil(t, rows[0], "the route (forward) tuple is now modeled as a forward rule")
require.Equal(t, DirForward, rows[0].Direction)
require.EqualValues(t, 23, rows[0].Port)
require.NotNil(t, rows[1])
require.EqualValues(t, 22, rows[1].Port)
require.NotNil(t, rows[2])
require.EqualValues(t, 80, rows[2].Port)
reps, err := fw.ParseRules(p, IPv4)
require.NoError(t, err)
require.Len(t, reps, 3, "ParseRules returns every rule, the forward one included")
}
// TestUFWNativeInsertPositionCountsRouteRows verifies a preceding route rule shifts
// the native `ufw insert` position, since ufw counts route rules in its numbered
// list. Ignoring them would insert the rule one slot too early.
func TestUFWNativeInsertPositionCountsRouteRows(t *testing.T) {
fw := new(UFW)
a := &Rule{Proto: TCP, Port: 22, Action: Accept}
b := &Rule{Proto: TCP, Port: 80, Action: Accept}
// Physical order: route(nil) is #1, A is #2, B is #3. GetRules reports A=1, B=2.
rows := []*Rule{nil, a, b}
require.Equal(t, 2, fw.nativeInsertPositionFromRows(rows, 1), "insert before A lands at A's physical slot 2")
require.Equal(t, 3, fw.nativeInsertPositionFromRows(rows, 2), "insert before B lands at B's physical slot 3, not 2")
require.Equal(t, 4, fw.nativeInsertPositionFromRows(rows, 3), "past the end appends past the last physical tuple")
// With no un-representable rows the mapping is the plain physical index.
plain := []*Rule{a, b}
require.Equal(t, 1, fw.nativeInsertPositionFromRows(plain, 1))
require.Equal(t, 2, fw.nativeInsertPositionFromRows(plain, 2))
require.Equal(t, 3, fw.nativeInsertPositionFromRows(plain, 3))
}
func TestUFWRules(t *testing.T) {
fw := new(UFW)
// Parse a rule that is expected to parse right.
rule, err := fw.UnmarshalRule(`allow udp 23 0.0.0.0/0 any 192.168.0.0/24 in`, IPv4)
require.NoError(t, err)
// Re-encode the rule which should result in expected rich rule.
args := fw.MarshalRule(rule)
require.Equal(t, `allow in proto udp from 192.168.0.0/24 to 0.0.0.0/0 port 23`, args,
"the rule did not encode as expected")
// Try encoding a bunch of invalid rules.
invalidRules := []string{
`log udp 23 0.0.0.0/0 any 192.168.0.0/24 in`, // unsupported action
`allow udp 23 0.0.0.0/0 any`, // too few fields
}
for _, richRule := range invalidRules {
_, err := fw.UnmarshalRule(richRule, IPv4)
require.Error(t, err, "this rule was parsed when it should be invalid: %s", richRule)
}
// A `route:` (forward) tuple decodes to a forward-direction rule. The `route:`
// prefix is stripped from the action, the direction is forward, and the trailing
// interface field(s) populate the in/out interfaces (a bare direction leaves
// both empty).
routeCases := []struct {
tuple string
action Action
port uint16
in string
out string
}{
{`route:allow tcp 23 0.0.0.0/0 any 0.0.0.0/0 in`, Accept, 23, "", ""},
{`route:deny tcp 25 192.168.0.1 any 10.0.0.0/8 in_eth0`, Drop, 25, "eth0", ""},
// ufw stores a dual-interface route rule as one "!"-joined trailing token.
{`route:allow tcp 80 0.0.0.0/0 any 0.0.0.0/0 in_eth0!out_eth1`, Accept, 80, "eth0", "eth1"},
}
for _, rc := range routeCases {
got, err := fw.UnmarshalRule(rc.tuple, IPv4)
require.NoError(t, err, "a route (forward) rule must decode: %s", rc.tuple)
require.Equal(t, DirForward, got.Direction, "route rule is forward: %s", rc.tuple)
require.Equal(t, rc.action, got.Action, rc.tuple)
require.EqualValues(t, rc.port, got.Port, rc.tuple)
require.Equal(t, rc.in, got.InInterface, rc.tuple)
require.Equal(t, rc.out, got.OutInterface, rc.tuple)
}
// An application-profile tuple (9 fields, with a dapp/sapp name before the
// trailing direction field) decodes like an ordinary tuple: ufw's real tuples
// carry the app's concrete protocol/port in the six core fields — e.g. ufw's own
// recorded output for "ufw allow Apache" is
// `allow tcp 80 0.0.0.0/0 any 0.0.0.0/0 Apache - in` — so the dapp/sapp name
// tokens are simply skipped to reach the direction field; they carry no
// independent match information the model needs to represent.
appRule, err := fw.UnmarshalRule(`allow tcp 80 0.0.0.0/0 any 0.0.0.0/0 Apache - in`, IPv4)
require.NoError(t, err, "a real application-profile tuple must decode")
require.Equal(t, Accept, appRule.Action)
require.Equal(t, TCP, appRule.Proto)
require.EqualValues(t, 80, appRule.Port)
require.False(t, appRule.IsOutput())
// A multi-port app profile (Samba's "137,138") and a sapp-only form (source app,
// dapp placeholder "-") both decode the same way.
sambaRule, err := fw.UnmarshalRule(`allow udp 137,138 0.0.0.0/0 any 0.0.0.0/0 Samba - in`, IPv4)
require.NoError(t, err)
require.Equal(t, UDP, sambaRule.Proto)
require.Len(t, sambaRule.Ports, 2)
require.Equal(t, PortRange{Start: 137, End: 137}, sambaRule.Ports[0])
require.Equal(t, PortRange{Start: 138, End: 138}, sambaRule.Ports[1])
sappRule, err := fw.UnmarshalRule(`allow udp any 10.0.0.1 137,138 0.0.0.0/0 - Samba in`, IPv4)
require.NoError(t, err)
require.Equal(t, UDP, sappRule.Proto)
require.Len(t, sappRule.SourcePorts, 2)
require.Equal(t, PortRange{Start: 137, End: 137}, sappRule.SourcePorts[0])
require.Equal(t, PortRange{Start: 138, End: 138}, sappRule.SourcePorts[1])
require.Equal(t, "10.0.0.1", sappRule.Destination)
// An 8-field tuple never occurs in a real ufw file (ufw always writes both dapp
// and sapp, using "-" for whichever is absent), so it is rejected as malformed.
_, err = fw.UnmarshalRule(`allow any any 0.0.0.0/0 any 0.0.0.0/0 Apache -`, IPv4)
require.Error(t, err, "an 8-field tuple is not a real ufw shape and must be rejected")
// Source ports round-trip through the tuple's sport field.
srcTuple, err := fw.UnmarshalRule(`allow tcp any 0.0.0.0/0 1024:65535 192.168.0.0/24 in`, IPv4)
require.NoError(t, err)
require.Len(t, srcTuple.SourcePorts, 1)
require.Equal(t, PortRange{Start: 1024, End: 65535}, srcTuple.SourcePorts[0])
require.Equal(t, "192.168.0.0/24", srcTuple.Source)
srcMarshal := fw.MarshalRule(&Rule{Family: IPv4, Proto: TCP, Source: "192.168.0.0/24", SourcePort: 1234, Port: 22, Action: Accept})
require.Equal(t, "allow in proto tcp from 192.168.0.0/24 port 1234 to 0.0.0.0/0 port 22", srcMarshal,
"unexpected source-port marshal")
// A single source port needs a port-carrying protocol. TCPUDP (ufw's ported `any`,
// tcp+udp) marshals to the native form with no proto clause; ProtocolAny (every
// protocol) is rejected by Rule.validate, since ufw cannot match a port across every
// protocol. A source-port range still needs a concrete tcp/udp, so a TCPUDP range
// fans out into concrete tcp+udp rules instead of taking the tuple path.
anySrc := fw.MarshalRule(&Rule{Family: IPv4, Proto: TCPUDP, SourcePort: 1234, Action: Accept})
require.Contains(t, anySrc, "port 1234")
require.NotContains(t, anySrc, "proto", "a tcpudp rule omits the proto clause")
require.Error(t, (&Rule{Family: IPv4, Proto: ProtocolAny, SourcePort: 1234, Action: Accept}).validate(),
"a source port across every protocol has no ufw form")
require.True(t, fw.tcpudpNeedsExpand(&Rule{Family: IPv4, Proto: TCPUDP, SourcePorts: []PortRange{{Start: 1000, End: 2000}}, Action: Accept}),
"a source-port range without a concrete tcp/udp must fan out")
// Test rules we typically set.
validRules := []string{
`allow udp 4789 ::/0 any ::/0 in`,
`allow udp 4789 ::/0 any ::/0 out`,
`allow tcp 4789 0.0.0.0/0 any 203.0.113.10 in`,
`allow tcp 4791 203.0.113.10 any 0.0.0.0/0 out`,
}
for _, richRule := range validRules {
_, err := fw.UnmarshalRule(richRule, IPv4)
require.NoError(t, err, "this rich rule was not parsed when it should be valid: %s", richRule)
}
// MarshalRule must not mutate the caller's rule while inferring the
// family and normalizing the destination.
orig := &Rule{Direction: DirInput, Family: IPv4, Port: 4789, Proto: TCP, Action: Accept}
before := *orig
fw.MarshalRule(orig)
require.Equal(t, before, *orig, "MarshalRule mutated the caller's rule")
// An interface-bound tuple parses the interface back out of the direction.
ifRule, err := fw.UnmarshalRule(`allow tcp 22 0.0.0.0/0 any 0.0.0.0/0 in_eth0`, IPv4)
require.NoError(t, err)
require.Equal(t, "eth0", ifRule.InInterface, "unexpected interface parse: %+v", *ifRule)
require.False(t, ifRule.IsOutput(), "unexpected interface parse: %+v", *ifRule)
// Marshalling an interface-bound rule emits `in on <iface>`.
spec := fw.MarshalRule(&Rule{InInterface: "eth0", Family: IPv4, Proto: TCP, Port: 22, Action: Accept})
require.Equal(t, "allow in on eth0 proto tcp to 0.0.0.0/0 port 22", spec, "unexpected interface marshal")
// Shapes the tuple form cannot hold never reach MarshalRule: ICMP and a state
// match are routed to the before.rules files, and a TCPUDP port list — a bare
// multiport, which ufw itself rejects — fans out into concrete tcp+udp rules.
// A multiport ProtocolAny match ("every protocol on this port") has no
// port-carrying transport at all, so Rule.validate rejects it outright.
require.True(t, fw.needsIPTablesRules(&Rule{Proto: ICMP, Action: Accept}))
require.True(t, fw.needsIPTablesRules(&Rule{Proto: TCP, Port: 22, State: StateEstablished, Action: Accept}))
require.True(t, fw.tcpudpNeedsExpand(&Rule{Proto: TCPUDP, Ports: []PortRange{{Start: 80}, {Start: 443}}, Action: Accept}))
require.Error(t, (&Rule{Proto: ProtocolAny, Ports: []PortRange{{Start: 80}, {Start: 443}}, Action: Accept}).validate())
// ufw supports port lists and colon ranges on tcp/udp.
portCases := []struct {
rule *Rule
want string
}{
{&Rule{Proto: TCP, Ports: []PortRange{{Start: 80}, {Start: 443}}, Family: IPv4, Action: Accept}, "allow in proto tcp to 0.0.0.0/0 port 80,443"},
{&Rule{Proto: TCP, Ports: []PortRange{{Start: 80}, {Start: 443}}, Action: Accept}, "allow in 80,443/tcp"},
{&Rule{Proto: UDP, Ports: []PortRange{{Start: 1000, End: 2000}}, Action: Accept}, "allow in 1000:2000/udp"},
}
for _, c := range portCases {
require.Equal(t, c.want, fw.MarshalRule(c.rule), "marshal %+v", *c.rule)
}
// A tuple with a multiport list and a colon range parses into port specs.
multi, err := fw.UnmarshalRule("allow tcp 80,443 0.0.0.0/0 any 0.0.0.0/0 in", IPv4)
require.NoError(t, err)
require.Len(t, multi.Ports, 2, "unexpected multiport parse: %+v", *multi)
require.Equal(t, PortRange{Start: 80, End: 80}, multi.Ports[0], "unexpected multiport parse: %+v", *multi)
require.Equal(t, PortRange{Start: 443, End: 443}, multi.Ports[1], "unexpected multiport parse: %+v", *multi)
ran, err := fw.UnmarshalRule("allow tcp 1000:2000 0.0.0.0/0 any 0.0.0.0/0 in", IPv4)
require.NoError(t, err)
require.Len(t, ran.Ports, 1, "unexpected range parse: %+v", *ran)
require.Equal(t, PortRange{Start: 1000, End: 2000}, ran.Ports[0], "unexpected range parse: %+v", *ran)
}
// ufw expresses per-rule logging natively with its `log` keyword (placed after
// the direction and any interface clause), rather than diverting a logged rule to
// before.rules where it could not be removed. Only a custom log prefix — which
// ufw cannot set — still needs the raw path.
func TestUFWNativeLogging(t *testing.T) {
fw := new(UFW)
// A plain logged rule stays on the CLI/tuple path and emits `log`.
logged := &Rule{Family: IPv4, Proto: TCP, Port: 22, Log: true, Action: Accept}
require.False(t, fw.needsIPTablesRules(logged), "a plain logged rule must not be routed to before.rules")
require.Equal(t, "allow in log proto tcp to 0.0.0.0/0 port 22", fw.MarshalRule(logged))
// The keyword follows the interface clause.
ifLogged := &Rule{Family: IPv4, Proto: TCP, Port: 22, InInterface: "eth0", Log: true, Action: Accept}
require.Equal(t, "allow in on eth0 log proto tcp to 0.0.0.0/0 port 22", fw.MarshalRule(ifLogged))
// A custom log prefix cannot be set in a tuple, so such a rule is routed raw.
prefixed := &Rule{Family: IPv4, Proto: TCP, Port: 22, Log: true, LogPrefix: "DROP22", Action: Drop}
require.True(t, fw.needsIPTablesRules(prefixed), "a custom-prefix log must go to before.rules")
}
// A forward rule marshals into a ufw route rule: the spec carries `in on`/`out on`
// interface clauses (no bare direction, which ufw rejects on a route rule) and the
// command args place the `route` keyword before the verb.
func TestUFWForwardRouteMarshal(t *testing.T) {
fw := new(UFW)
r := &Rule{Direction: DirForward, InInterface: "eth0", OutInterface: "eth1", Proto: TCP, Port: 80, Action: Accept}
spec := fw.MarshalRule(r)
require.Contains(t, spec, "allow in on eth0 out on eth1", "route spec must carry both interface clauses: %q", spec)
require.Contains(t, spec, "80", "the destination port must be present: %q", spec)
require.NotContains(t, spec, "route", "MarshalRule leaves the route keyword to the command builder: %q", spec)
// The command builder prepends `route` before the verb.
args := fw.ruleArgs(r, []string{"prepend"}, spec)
require.Equal(t, "route", args[0], "forward command starts with route")
require.Equal(t, "prepend", args[1], "the verb follows route")
// An ordinary rule carries neither the route keyword nor an out-interface clause.
ordinary := fw.MarshalRule(&Rule{Proto: TCP, Port: 22, Action: Accept})
inArgs := fw.ruleArgs(&Rule{Proto: TCP, Port: 22, Action: Accept}, []string{"prepend"}, ordinary)
require.Equal(t, "prepend", inArgs[0], "a non-forward rule has no leading route keyword")
}
func TestUFWParseIPTablesRules(t *testing.T) {
fw := new(UFW)
content := `# rules.before
*filter
:ufw-before-input - [0:0]
:ufw-before-output - [0:0]
# allow all on loopback
-A ufw-before-input -i lo -j ACCEPT
# quickly process packets for which we already have a connection
-A ufw-before-input -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
# ok icmp codes for INPUT
-A ufw-before-input -p icmp --icmp-type echo-request -j ACCEPT
-A ufw-before-input -p icmp --icmp-type destination-unreachable -j ACCEPT
# a jump to an internal chain must be skipped
-A ufw-before-input -j ufw-not-local
-A ufw-not-local -m addrtype --dst-type LOCAL -j RETURN
# the forward chain is now a modeled direction
-A ufw-before-forward -p icmp --icmp-type echo-request -j ACCEPT
COMMIT
`
dir := t.TempDir()
path := filepath.Join(dir, "before.rules")
require.NoError(t, os.WriteFile(path, []byte(content), 0644))
rules, err := fw.ParseIPTablesRules(path, IPv4)
require.NoError(t, err)
// Expect: loopback interface, conntrack established/related, the two input ICMP
// rules, and the forward ICMP rule. The chain jump and internal chain are
// skipped.
require.Len(t, rules, 5, "expected 5 parsed iptables rules, got %+v", rules)
var foundEcho, foundForward bool
for _, r := range rules {
if r.Proto == ICMP && r.ICMPType != nil && *r.ICMPType == 8 && r.Action == Accept {
foundEcho = true
if r.IsForward() {
foundForward = true
}
}
}
require.True(t, foundEcho, "expected an icmp echo-request accept rule, got %+v", rules)
require.True(t, foundForward, "expected the forward-chain icmp rule to be modeled, got %+v", rules)
// A missing iptables rules file contributes no rules and no error.
missing, err := fw.ParseIPTablesRules(filepath.Join(dir, "nope.rules"), IPv4)
require.NoError(t, err, "expected no error for a missing file")
require.Nil(t, missing, "expected no rules for a missing file")
}
func TestUFWIPTablesRulesWrite(t *testing.T) {
fw := new(UFW)
// needsIPTablesRules routes ICMP and state rules to the iptables rules files.
require.True(t, fw.needsIPTablesRules(&Rule{Proto: ICMP, Action: Accept}),
"expected an icmp rule to need the iptables rules files")
require.True(t, fw.needsIPTablesRules(&Rule{State: StateEstablished, Action: Accept}),
"expected a state rule to need the iptables rules files")
require.False(t, fw.needsIPTablesRules(&Rule{Proto: TCP, Port: 22, Action: Accept}),
"a plain tcp rule should use the ufw cli")
// marshalIPTablesLines rewrites the iptables chain to ufw's own chain. IPv4
// rules use the `ufw-*` chains; IPv6 rules must use the `ufw6-*` chains, since
// that is what before6.rules declares.
specs, err := fw.marshalIPTablesLines(&Rule{Proto: ICMP, ICMPType: Ptr[uint8](8), Action: Accept}, IPv4)
require.NoError(t, err)
require.Equal(t, []string{"-A ufw-before-input -p icmp -m icmp --icmp-type 8 -j ACCEPT"}, specs,
"unexpected iptables rules spec")
v6specs, err := fw.marshalIPTablesLines(&Rule{Proto: ICMPv6, ICMPType: Ptr[uint8](128), Action: Accept}, IPv6)
require.NoError(t, err)
require.Equal(t, []string{"-A ufw6-before-input -p icmpv6 -m icmp6 --icmpv6-type 128 -j ACCEPT"}, v6specs,
"an IPv6 rule must target the ufw6- chain so ip6tables-restore accepts before6.rules")
scaffold := "*filter\n:ufw-before-input - [0:0]\n:ufw-before-output - [0:0]\nCOMMIT\n"
dir := t.TempDir()
path := filepath.Join(dir, "before.rules")
require.NoError(t, os.WriteFile(path, []byte(scaffold), 0644))
icmp := &Rule{Proto: ICMP, ICMPType: Ptr[uint8](8), Action: Accept}
// Adding inserts the rule before COMMIT and it parses back equal.
changed, err := fw.editIPTablesRulesFile(path, icmp, IPv4, false)
require.NoError(t, err)
require.True(t, changed, "expected add to change the file")
rules, err := fw.ParseIPTablesRules(path, IPv4)
require.NoError(t, err)
// The parsed rule is family-tagged from the file (IPv4), so compare with
// EqualBase, which ignores family.
require.Len(t, rules, 1)
require.True(t, rules[0].EqualBase(icmp, true), "expected the icmp rule to be present, got %+v", rules[0])
// Adding again is idempotent.
changed, err = fw.editIPTablesRulesFile(path, icmp, IPv4, false)
require.NoError(t, err)
require.False(t, changed, "expected a duplicate add to be a no-op")
// A different (state) rule adds alongside it.
state := &Rule{State: StateEstablished | StateRelated, Action: Accept}
_, err = fw.editIPTablesRulesFile(path, state, IPv4, false)
require.NoError(t, err)
rules, _ = fw.ParseIPTablesRules(path, IPv4)
require.Len(t, rules, 2, "expected 2 rules after adding a state rule, got %+v", rules)
// Removing the icmp rule leaves only the state rule.
changed, err = fw.editIPTablesRulesFile(path, icmp, IPv4, true)
require.NoError(t, err)
require.True(t, changed, "expected remove to change the file")
rules, _ = fw.ParseIPTablesRules(path, IPv4)
require.Len(t, rules, 1, "expected only the state rule to remain, got %+v", rules)
require.Equal(t, StateEstablished|StateRelated, rules[0].State,
"expected only the state rule to remain, got %+v", rules)
// Removing a rule that is not present is a no-op.
changed, err = fw.editIPTablesRulesFile(path, icmp, IPv4, true)
require.NoError(t, err)
require.False(t, changed, "expected removing an absent rule to be a no-op")
}
// A family-agnostic source-port rule must emit the literal "any" address so ufw
// installs both the IPv4 and IPv6 rule, not a v4-only zero network.
func TestUFWFamilyAnySourcePortUsesAny(t *testing.T) {
fw := new(UFW)
spec := fw.MarshalRule(&Rule{Proto: TCP, SourcePort: 1024, Action: Accept})
require.Contains(t, spec, "from any port 1024")
require.NotContains(t, spec, "0.0.0.0/0", "a FamilyAny rule must not be pinned to an IPv4 zero network")
}
// GetRules must read tuples whose action carries a `_log` suffix or is `limit`
// rather than dropping them.
func TestUFWLimitAndLogTupleParse(t *testing.T) {
fw := new(UFW)
limit, err := fw.UnmarshalRule("limit tcp 22 0.0.0.0/0 any 0.0.0.0/0 in", IPv4)
require.NoError(t, err)
require.Equal(t, Accept, limit.Action)
require.NotNil(t, limit.RateLimit, "a limit tuple must carry a rate limit")
logged, err := fw.UnmarshalRule("allow_log tcp 80 0.0.0.0/0 any 0.0.0.0/0 in", IPv4)
require.NoError(t, err)
require.Equal(t, Accept, logged.Action)
require.True(t, logged.Log, "an allow_log tuple must be read as a logged rule")
}
// ufw's native `limit` action can carry logging (`ufw limit log ...` -> a
// `limit_log` tuple in user.rules). Such a rule must stay on the tuple path, not
// be routed to before.rules, or Restore removes it from the wrong file and
// duplicates it.
func TestUFWLimitLogStaysNative(t *testing.T) {
f := new(UFW)
r, err := f.UnmarshalRule("limit_log tcp 22 0.0.0.0/0 any 0.0.0.0/0 in", IPv4)
require.NoError(t, err)
require.True(t, r.Log, "limit_log carries logging")
require.NotNil(t, r.RateLimit)
require.Equal(t, RateLimit{Rate: 12, Unit: PerMinute, Burst: 6}, *r.RateLimit)
require.True(t, f.isNativeLimit(r), "a logged native limit is still native")
require.False(t, f.needsIPTablesRules(r),
"a logged native limit must stay on the tuple path, not go to before.rules")
// It marshals to a tuple carrying both the limit verb and the log keyword.
out := f.MarshalRule(r)
require.Contains(t, out, "limit")
require.Contains(t, out, "log")
// A native limit carrying a *custom* prefix cannot be a tuple, so it must still
// route to before.rules.
prefixed := &Rule{Action: Accept, Proto: TCP, Port: 22, Log: true, LogPrefix: "MINE",
RateLimit: &RateLimit{Rate: 12, Unit: PerMinute, Burst: 6}}
require.False(t, f.isNativeLimit(prefixed), "a custom-prefix limit is not tuple-expressible")
require.True(t, f.needsIPTablesRules(prefixed), "a custom-prefix limit goes to before.rules")
}
// UFW editIPTablesRulesFile remove must preserve an adjacent foreign LOG line that
// is not the removal target's own LOG half, rather than discarding it unflushed.
func TestUFWRemovePreservesForeignLogLine(t *testing.T) {
fw := new(UFW)
dir := t.TempDir()
path := filepath.Join(dir, "before.rules")
content := "*filter\n:ufw-before-input - [0:0]\n" +
"-A ufw-before-input -p tcp --dport 4000 -j LOG --log-prefix \"[FOREIGN] \"\n" +
"-A ufw-before-input -p icmp --icmp-type 8 -j DROP\n" +
"COMMIT\n"
require.NoError(t, os.WriteFile(path, []byte(content), 0o644))
icmp := &Rule{Proto: ICMP, ICMPType: Ptr[uint8](8), Action: Drop}
_, err := fw.editIPTablesRulesFile(path, icmp, IPv4, true)
require.NoError(t, err)
out, err := os.ReadFile(path)
require.NoError(t, err)
require.Contains(t, string(out), "[FOREIGN]", "the unrelated foreign LOG line must survive removal")
require.NotContains(t, string(out), "icmp", "the icmp rule must be removed")
}
// The before.rules scan is table-scoped, as the iptables backend's is: a line on
// a ufw chain sitting in the nat table is not a filter rule, so it is neither
// reported by GetRules nor a removal target — a removal that dropped it would
// silently rewrite the host's NAT block. Every untouched line, that one
// included, streams back through the rewrite verbatim.
func TestUFWBeforeRulesTableScoping(t *testing.T) {
fw := new(UFW)
dir := t.TempDir()
path := filepath.Join(dir, "before.rules")
content := "*nat\n:POSTROUTING ACCEPT [0:0]\n" +
"-A ufw-before-input -p icmp --icmp-type 8 -j DROP\n" +
"COMMIT\n" +
"# the filter section\n" +
"*filter\n:ufw-before-input - [0:0]\n" +
"-A ufw-before-input -p icmp --icmp-type 8 -j DROP\n" +
"COMMIT\n"
require.NoError(t, os.WriteFile(path, []byte(content), 0o600))
// Only the filter table's copy is a rule.
rules, err := fw.ParseIPTablesRules(path, IPv4)
require.NoError(t, err)
require.Len(t, rules, 1, "only the *filter copy is a filter rule, got %+v", rules)
icmp := &Rule{Proto: ICMP, ICMPType: Ptr[uint8](8), Action: Drop}
changed, err := fw.editIPTablesRulesFile(path, icmp, IPv4, true)
require.NoError(t, err)
require.True(t, changed)
out, err := os.ReadFile(path)
require.NoError(t, err)
body := string(out)
filterAt := strings.Index(body, "*filter")
require.Contains(t, body[:filterAt], "--icmp-type 8", "the nat table's line must survive the removal")
require.NotContains(t, body[filterAt:], "--icmp-type 8", "the filter rule must be removed")
require.Contains(t, body, "# the filter section", "untouched lines stream through verbatim")
// The staged rewrite keeps the file's own mode.
fi, err := os.Stat(path)
require.NoError(t, err)
require.Equal(t, os.FileMode(0o600), fi.Mode().Perm())
}
// ParseRules reads a ufw user.rules file, decoding the `### tuple ###` lines and
// the hex-encoded trailing comment (with the configured prefix split off).
func TestUFWParseRulesFile(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "user.rules")
// A plain allow, and a deny carrying a prefixed comment "myapp dns".
comment := hex.EncodeToString([]byte("myapp dns"))
content := "*filter\n" +
"### tuple ### allow tcp 22 0.0.0.0/0 any 0.0.0.0/0 in\n" +
"### tuple ### deny udp 53 0.0.0.0/0 any 0.0.0.0/0 in comment=" + comment + "\n" +
"### RULES ###\n" +
"-A ufw-user-input -p tcp --dport 22 -j ACCEPT\n" +
"COMMIT\n"
require.NoError(t, os.WriteFile(path, []byte(content), 0644))
f := &UFW{rulePrefix: "myapp"}
rules, err := f.ParseRules(path, IPv4)
require.NoError(t, err)
require.Len(t, rules, 2, "only the two ### tuple ### lines are parsed")
require.Equal(t, Accept, rules[0].Action)
require.Equal(t, TCP, rules[0].Proto)
require.EqualValues(t, 22, rules[0].Port)
require.Empty(t, rules[0].Comment)
require.False(t, rules[0].HasPrefix, "an un-commented rule is not flagged as ours")
require.Equal(t, Drop, rules[1].Action)
require.Equal(t, UDP, rules[1].Proto)
require.EqualValues(t, 53, rules[1].Port)
require.Equal(t, "dns", rules[1].Comment, "the configured prefix is split off the comment")
require.True(t, rules[1].HasPrefix, "the prefixed comment marks the rule as ours")
}
// ufw's `any` protocol means two different things depending on whether the rule
// carries a port: on a ported rule it is tcp+udp together (TCPUDP, ufw's native
// both-transports form), and on a portless rule it is every IP protocol
// (ProtocolAny). TestUFWTCPUDPMarshal exercises the write side of that split.
func TestUFWTCPUDPMarshal(t *testing.T) {
fw := new(UFW)
// A single-port TCPUDP rule emits ufw's bare-port short form (proto rides along as
// ufw's `any`, so no `/proto` suffix and no proto clause).
spec := fw.MarshalRule(&Rule{Port: 80, Proto: TCPUDP, Action: Accept})
require.Equal(t, "allow in 80", spec, "a single-port TCPUDP rule uses ufw's bare-port short form")
// The same port with ProtocolAny is rejected at the entry points: a port across
// every protocol has no ufw form.
require.Error(t, (&Rule{Port: 80, Proto: ProtocolAny, Action: Accept}).validate(),
"a ProtocolAny rule carrying a port has no ufw form")
// A portless ProtocolAny rule is ufw's real `any` and still marshals fine.
spec = fw.MarshalRule(&Rule{Proto: ProtocolAny, Source: "1.2.3.4", Action: Accept})
require.Contains(t, spec, "from 1.2.3.4")
require.NotContains(t, spec, "proto", "ufw's `any` protocol carries no proto clause")
// A family-specific single-port TCPUDP rule uses the full grammar with no proto
// clause (ufw's `any` on a ported rule), covering both tcp and udp.
spec = fw.MarshalRule(&Rule{Family: IPv4, Port: 80, Proto: TCPUDP, Action: Accept})
require.Equal(t, "allow in to 0.0.0.0/0 port 80", spec)
}
// TestUFWTCPUDPTupleRoundTrip verifies the read side of ufw's `any`-protocol split:
// an `any` tuple with a port decodes to TCPUDP, one without a port to ProtocolAny.
func TestUFWTCPUDPTupleRoundTrip(t *testing.T) {
fw := new(UFW)
// A ported `any` tuple is tcp+udp together.
ported, err := fw.UnmarshalRule("allow any 80 0.0.0.0/0 any 0.0.0.0/0 in", IPv4)
require.NoError(t, err)
require.Equal(t, TCPUDP, ported.Proto, "a ported `any` tuple decodes to TCPUDP")
require.EqualValues(t, 80, ported.Port)
// A source-port-only `any` tuple is likewise tcp+udp.
sported, err := fw.UnmarshalRule("allow any any 0.0.0.0/0 1024 0.0.0.0/0 in", IPv4)
require.NoError(t, err)
require.Equal(t, TCPUDP, sported.Proto, "an `any` tuple with a source port decodes to TCPUDP")
require.True(t, sported.HasSourcePorts(), "the source port must be parsed")
require.EqualValues(t, 1024, sported.SourcePort)
// A portless `any` tuple is every IP protocol.
portless, err := fw.UnmarshalRule("allow any any 0.0.0.0/0 any 1.2.3.4 in", IPv4)
require.NoError(t, err)
require.Equal(t, ProtocolAny, portless.Proto, "a portless `any` tuple decodes to ProtocolAny")
require.Equal(t, "1.2.3.4", portless.Source)
// A native single-port TCPUDP rule round-trips through marshal + unmarshal.
require.Equal(t, "allow in to 0.0.0.0/0 port 443",
fw.MarshalRule(&Rule{Family: IPv4, Port: 443, Proto: TCPUDP, Action: Accept}))
}
// Every modeled tuple is its own rule, so a logical position maps straight to ufw's
// native slot. An unmodeled tuple (a route rule, kept as a nil row) still occupies a
// physical slot and shifts the ones after it.
func TestUFWNativeInsertPositionSkipsUnmodeledRows(t *testing.T) {
fw := new(UFW)
tcp80 := &Rule{Family: IPv4, Proto: TCP, Port: 80, Action: Accept}
udp80 := &Rule{Family: IPv4, Proto: UDP, Port: 80, Action: Accept}
tcp22 := &Rule{Family: IPv4, Proto: TCP, Port: 22, Action: Accept}
// With every tuple modeled, position N is native slot N.
rows := []*Rule{tcp80, udp80, tcp22}
require.Equal(t, 1, fw.nativeInsertPositionFromRows(rows, 1))
require.Equal(t, 2, fw.nativeInsertPositionFromRows(rows, 2))
require.Equal(t, 3, fw.nativeInsertPositionFromRows(rows, 3))
require.Equal(t, 4, fw.nativeInsertPositionFromRows(rows, 4),
"past the end appends past the last physical tuple")
// A route rule ufw counts but this backend cannot model sits at physical slot 2,
// so the second reported rule lives at slot 3.
withRoute := []*Rule{tcp80, nil, udp80, tcp22}
require.Equal(t, 1, fw.nativeInsertPositionFromRows(withRoute, 1))
require.Equal(t, 3, fw.nativeInsertPositionFromRows(withRoute, 2),
"the unmodeled route tuple shifts the native slot")
require.Equal(t, 4, fw.nativeInsertPositionFromRows(withRoute, 3))
require.Equal(t, 5, fw.nativeInsertPositionFromRows(withRoute, 4))
}
// TestUFWEditIPTablesRulesAnchorsFilterCommit pins that the raw-path add splices
// its rule into the *filter section's COMMIT, not the file's first COMMIT: the
// canonical ufw NAT setup puts a *nat block above *filter, and a filter rule in
// that block references an undeclared chain, failing the reload.
func TestUFWEditIPTablesRulesAnchorsFilterCommit(t *testing.T) {
fw := new(UFW)
natFirst := "*nat\n:POSTROUTING ACCEPT [0:0]\n-A POSTROUTING -s 10.0.0.0/8 -o eth0 -j MASQUERADE\nCOMMIT\n" +
"*filter\n:ufw-before-input - [0:0]\nCOMMIT\n"
dir := t.TempDir()
path := filepath.Join(dir, "before.rules")
require.NoError(t, os.WriteFile(path, []byte(natFirst), 0644))
icmp := &Rule{Proto: ICMP, ICMPType: Ptr[uint8](8), Action: Accept}
changed, err := fw.editIPTablesRulesFile(path, icmp, IPv4, false)
require.NoError(t, err)
require.True(t, changed)
data, err := os.ReadFile(path)
require.NoError(t, err)
body := string(data)
natEnd := strings.Index(body, "*filter")
require.NotContains(t, body[:natEnd], "--icmp-type",
"the rule must not land inside the *nat block")
require.Contains(t, body[natEnd:], "--icmp-type",
"the rule must land inside the *filter block")
// The rule sits before *filter's COMMIT.
filterPart := body[natEnd:]
require.Less(t, strings.Index(filterPart, "--icmp-type"), strings.Index(filterPart, "COMMIT"))
}
// TestUFWOldFormatTupleStaysOpaque pins that ufw's old 8-field storage format
// (six core fields plus dapp/sapp, no direction field) is rejected by the tuple
// parser, so parseTupleRows holds it as a nil row rather than mis-reading it.
func TestUFWOldFormatTupleStaysOpaque(t *testing.T) {
fw := new(UFW)
_, err := fw.UnmarshalRule("allow tcp 80 0.0.0.0/0 any 0.0.0.0/0 Apache -", IPv4)
require.Error(t, err, "the old 8-field format must stay opaque")
// A non-route tuple must not carry a "!"-joined interface pair.
_, err = fw.UnmarshalRule("allow tcp 80 0.0.0.0/0 any 0.0.0.0/0 in_eth0!out_eth1", IPv4)
require.Error(t, err, "a dual-interface token is only valid on a route rule")
}
// ufwLiveSave is a trimmed `iptables-save -c -t filter` capture from a host
// running ufw, covering each shape ufw expands a rule into: a plain action line,
// the tcp+udp pair a ported `any` tuple becomes, the logging-chain jump that
// precedes a logged rule's action line, the three-line `limit` sequence, and a
// raw before.rules rule written as a LOG line plus its action line.
var ufwLiveSave = []string{
"# Generated by iptables-save",
"*filter",
":INPUT DROP [0:0]",
":ufw-user-input - [0:0]",
"[9:540] -A INPUT -j ufw-before-input",
"[3:180] -A ufw-before-output -p tcp -m tcp --dport 39323 -m comment --comment gofw -j LOG --log-prefix \"probe: \"",
"[3:180] -A ufw-before-output -p tcp -m tcp --dport 39323 -m comment --comment gofw -j ACCEPT",
"[7:420] -A ufw-user-input -p tcp -m tcp --dport 22 -m conntrack --ctstate NEW -m recent --set --name DEFAULT --mask 255.255.255.255 --rsource",
"[1:60] -A ufw-user-input -p tcp -m tcp --dport 22 -m conntrack --ctstate NEW -m recent --update --seconds 30 --hitcount 6 --name DEFAULT --mask 255.255.255.255 --rsource -j ufw-user-limit",
"[6:360] -A ufw-user-input -p tcp -m tcp --dport 22 -j ufw-user-limit-accept",
"[2:120] -A ufw-user-input -p tcp -m tcp --dport 53 -j ACCEPT",
"[5:405] -A ufw-user-input -p udp -m udp --dport 53 -j ACCEPT",
"[4:240] -A ufw-user-input -p tcp -m tcp --dport 8080 -j ufw-user-logging-input",
"[4:240] -A ufw-user-input -p tcp -m tcp --dport 8080 -j ACCEPT",
"[4:240] -A ufw-user-logging-input -p tcp -m tcp --dport 8080 -m conntrack --ctstate NEW -m limit --limit 3/min --limit-burst 10 -j LOG --log-prefix \"[UFW ALLOW] \"",
"[4:240] -A ufw-user-logging-input -p tcp -m tcp --dport 8080 -j RETURN",
"[6:360] -A ufw-user-limit-accept -j ACCEPT",
"COMMIT",
}
// TestUFWParseLiveRules verifies the live ruleset is read back as the rules that
// produced it: ufw's logging-chain jump and its three-line `limit` sequence are
// folded back into the single rule each stands for, its internal chains and
// accounting rows contribute nothing, and every row keeps the kernel's counters.
func TestUFWParseLiveRules(t *testing.T) {
fw := new(UFW)
rules := fw.parseLiveRules(ufwLiveSave, IPv4)
require.Len(t, rules, 5, "the internal-chain rows and the limit accounting rows model no rule of their own")
// The raw before.rules rule: its LOG line and action line are one logged rule
// carrying the action line's counters.
require.True(t, rules[0].Log)
require.Equal(t, "probe: ", rules[0].LogPrefix)
require.EqualValues(t, 39323, rules[0].Port)
require.EqualValues(t, 3, rules[0].Packets)
require.EqualValues(t, 180, rules[0].Bytes)
// The `limit` tuple: read back as an accept carrying ufw's built-in rate, with
// the counters of the row that accepts.
require.EqualValues(t, 22, rules[1].Port)
require.Equal(t, Accept, rules[1].Action)
require.NotNil(t, rules[1].RateLimit)
require.Equal(t, fw.nativeLimit(), *rules[1].RateLimit)
require.EqualValues(t, 6, rules[1].Packets)
// The ported `any` tuple stays two rows here; only the merge sums them.
require.Equal(t, TCP, rules[2].Proto)
require.EqualValues(t, 2, rules[2].Packets)
require.Equal(t, UDP, rules[3].Proto)
require.EqualValues(t, 5, rules[3].Packets)
// The logged tuple: the jump into ufw's logging chain sets Log on the action
// row beneath it, and ufw's own log prefix is not a rule field.
require.EqualValues(t, 8080, rules[4].Port)
require.True(t, rules[4].Log)
require.Empty(t, rules[4].LogPrefix)
require.EqualValues(t, 4, rules[4].Packets)
}
// TestUFWApplyCounters verifies counters land on the rule that owns them: an
// exact match wins over a wider rule, a ported `any` tuple sums the tcp and udp
// rows ufw writes for it, and a rule with no live row keeps zero.
func TestUFWApplyCounters(t *testing.T) {
fw := new(UFW)
live := fw.parseLiveRules(ufwLiveSave, IPv4)
limit := fw.nativeLimit()
targets := []*Rule{
{Direction: DirInput, Family: IPv4, Proto: TCP, Port: 22, Action: Accept, RateLimit: &limit},
{Direction: DirInput, Family: IPv4, Proto: TCPUDP, Port: 53, Action: Accept},
{Direction: DirInput, Family: IPv4, Proto: TCP, Port: 8080, Action: Accept, Log: true},
{Direction: DirInput, Family: IPv4, Proto: TCP, Port: 9999, Action: Accept},
}
applyLiveCounters(targets, live)
require.EqualValues(t, 6, targets[0].Packets, "a limit rule counts the row that accepts")
require.EqualValues(t, 7, targets[1].Packets, "a ported any tuple sums its tcp and udp rows")
require.EqualValues(t, 525, targets[1].Bytes)
require.EqualValues(t, 4, targets[2].Packets, "a logged rule counts its action row")
require.Zero(t, targets[3].Packets, "a rule the live ruleset does not hold keeps zero counters")
}
// TestUFWApplyCountersExactMatchWins verifies a rule that has a row of its own is
// matched to it before a wider rule can absorb that row, so the narrow rule is
// not left at zero.
func TestUFWApplyCountersExactMatchWins(t *testing.T) {
fw := new(UFW)
live := fw.parseLiveRules(ufwLiveSave, IPv4)
wide := &Rule{Direction: DirInput, Family: IPv4, Proto: TCPUDP, Port: 53, Action: Accept}
narrow := &Rule{Direction: DirInput, Family: IPv4, Proto: TCP, Port: 53, Action: Accept}
applyLiveCounters([]*Rule{wide, narrow}, live)
require.EqualValues(t, 2, narrow.Packets, "the tcp rule keeps the tcp row it matches exactly")
require.EqualValues(t, 5, wide.Packets, "the wider rule sums only the rows left over")
}
// TestUFWParseLiveRulesLimitLog verifies a `limit_log` tuple, whose live rows put
// ufw's logging jump above the three-line rate-limit sequence, is read back as
// one logged and rate-limited rule: the accounting row between them carries no
// jump and must not consume the pending logging jump.
func TestUFWParseLiveRulesLimitLog(t *testing.T) {
fw := new(UFW)
out := []string{
"[4:240] -A ufw-user-input -p tcp -m tcp --dport 22 -j ufw-user-logging-input",
"[4:240] -A ufw-user-input -p tcp -m tcp --dport 22 -m conntrack --ctstate NEW -m recent --set --name DEFAULT --mask 255.255.255.255 --rsource",
"[1:60] -A ufw-user-input -p tcp -m tcp --dport 22 -m conntrack --ctstate NEW -m recent --update --seconds 30 --hitcount 6 --name DEFAULT --mask 255.255.255.255 --rsource -j ufw-user-limit",
"[3:180] -A ufw-user-input -p tcp -m tcp --dport 22 -j ufw-user-limit-accept",
}
rules := fw.parseLiveRules(out, IPv4)
require.Len(t, rules, 1)
require.True(t, rules[0].Log, "the logging jump above the rate-limit sequence still marks the rule as logged")
require.NotNil(t, rules[0].RateLimit)
require.Equal(t, fw.nativeLimit(), *rules[0].RateLimit)
require.EqualValues(t, 3, rules[0].Packets, "a limit rule counts the row that accepts")
// The tuple ufw stores for that rule must match the row it was read back as.
tuple, err := fw.UnmarshalRule("limit_log tcp 22 0.0.0.0/0 any 0.0.0.0/0 in", IPv4)
require.NoError(t, err)
applyLiveCounters([]*Rule{tuple}, rules)
require.EqualValues(t, 3, tuple.Packets)
require.EqualValues(t, 180, tuple.Bytes)
}

238
utils.go Normal file
View file

@ -0,0 +1,238 @@
package firewall
import (
"bufio"
"context"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
)
func trimQuotes(s string) string {
return strings.Trim(s, "\"'")
}
// stripUnquotedComment removes a trailing '#' comment from a config line,
// ignoring a '#' inside a quoted value so `KEY = "pre#fix"` is not truncated.
func stripUnquotedComment(line string) string {
var inQuote byte
for i := 0; i < len(line); i++ {
switch c := line[i]; {
case inQuote != 0:
if c == inQuote {
inQuote = 0
}
case c == '"' || c == '\'':
inQuote = c
case c == '#':
return line[:i]
}
}
return line
}
// readConfValue scans a shell-style "KEY = \"VALUE\"" config file (conf.apf,
// csf.conf) for key and returns its value, or "" if key is not set. The last
// assignment wins, matching how the shell sources these files. Used for
// one-shot flags read at construction time, not the per-rule list edits (which
// each backend's own EditConf/EditRulePort scan handles in place).
func readConfValue(path, key string) (string, error) {
fd, err := os.Open(path)
if err != nil {
return "", err
}
defer func() { _ = fd.Close() }()
value := ""
scanner := bufio.NewScanner(fd)
for scanner.Scan() {
line := strings.TrimSpace(stripUnquotedComment(scanner.Text()))
if line == "" {
continue
}
k, v, found := strings.Cut(line, "=")
if !found {
continue
}
if strings.TrimSpace(k) == key {
value = trimQuotes(strings.TrimSpace(v))
}
}
return value, scanner.Err()
}
// binSearchDirs are the directories searched for a firewall tool when PATH does
// not resolve it, covering the standard locations iptables, ip6tables, ipset, nft
// and the backend front-ends install into. The tools live in the sbin
// directories, and a process started from a service unit, a cron job or a login
// shell for an unprivileged user routinely gets a PATH without them.
var binSearchDirs = []string{"/usr/sbin", "/sbin", "/usr/local/sbin", "/usr/bin", "/bin", "/usr/local/bin"}
// resolvedBins memoizes successful lookups: every command run resolves its tool
// and an installed tool does not move while the process runs. A failed lookup is
// deliberately not cached, so a tool installed under a long-lived process is
// picked up on its next use.
var resolvedBins sync.Map
// resolveBinary returns the absolute path of a firewall tool and reports whether
// it was found. PATH wins; failing that the standard install directories are
// searched (binSearchDirs). A name that already carries a path separator is taken
// as given. An unresolved name is returned unchanged, leaving the caller to hand
// it to exec (for its own error) or to a shell.
func resolveBinary(name string) (string, bool) {
if name == "" {
return name, false
}
if strings.ContainsRune(name, os.PathSeparator) {
return name, true
}
if cached, ok := resolvedBins.Load(name); ok {
return cached.(string), true
}
if p, err := exec.LookPath(name); err == nil {
if abs, err := filepath.Abs(p); err == nil {
p = abs
}
resolvedBins.Store(name, p)
return p, true
}
// PATH missed it — the tool may still be installed where the tools live.
for _, dir := range binSearchDirs {
cand := filepath.Join(dir, name)
if fi, err := os.Stat(cand); err == nil && fi.Mode().IsRegular() && fi.Mode()&0111 != 0 {
resolvedBins.Store(name, cand)
return cand, true
}
}
return name, false
}
// runCommand runs command and returns its stdout and any error. The context
// bounds the command's lifetime: cancelling it kills the process.
func runCommand(ctx context.Context, command string, args ...string) (out []string, err error) {
return runCommandStdin(ctx, "", command, args...)
}
// runCommandStdin runs command with the provided string fed to stdin, returning its stdout and any error.
func runCommandStdin(ctx context.Context, stdin string, command string, args ...string) (out []string, err error) {
// Resolve the tool up front so a PATH without the sbin directories does not
// turn every backend call into an executable-not-found error.
bin, _ := resolveBinary(command)
cmd := exec.CommandContext(ctx, bin, args...)
// Force the C locale so the backend tools emit their canonical, English output.
// Several backends match tool output to drive control flow — ufw's "Invalid
// position"/"Could not delete non-existent rule" fallbacks, CSF/APF restart
// messages — and those strings are gettext-translated. Without a pinned locale a
// non-English host would break the idempotent-remove and insert-append fallbacks
// (leaving a rule removed-and-not-re-added, or a no-op remove turned into an
// error). LC_ALL wins over LANG/LC_* so appending it last is sufficient.
cmd.Env = append(os.Environ(), "LC_ALL=C")
// Feed stdin when provided.
if stdin != "" {
cmd.Stdin = strings.NewReader(stdin)
}
// Get output pipes.
var stdout, stderr io.ReadCloser
stdout, err = cmd.StdoutPipe()
if err != nil {
return
}
stderr, err = cmd.StderrPipe()
if err != nil {
_ = stdout.Close()
return
}
// Start the command. Close the pipes on failure so their file descriptors do
// not leak; a started command's pipes are closed by Wait below.
err = cmd.Start()
if err != nil {
_ = stdout.Close()
_ = stderr.Close()
return
}
// Setup wait group to wait for buffers to fully read.
var wg sync.WaitGroup
wg.Add(2)
// The default bufio.Scanner token cap is 64 KB, but some backends emit a
// single very long line — notably `nft -j list sets`, whose entire JSON
// result is one line and can far exceed 64 KB for a large blocklist. Give
// each scanner a generous max so such a line is not silently truncated, and
// surface scanner.Err() so a line that still overflows fails loudly rather
// than returning partial output as success.
const maxLine = 64 * 1024 * 1024
var scanErr error
var scanMu sync.Mutex
recordScanErr := func(e error) {
if e == nil {
return
}
scanMu.Lock()
if scanErr == nil {
scanErr = e
}
scanMu.Unlock()
}
// Read stdout.
stdoutScanner := bufio.NewScanner(stdout)
stdoutScanner.Buffer(make([]byte, 0, 64*1024), maxLine)
go func() {
for stdoutScanner.Scan() {
out = append(out, stdoutScanner.Text())
}
recordScanErr(stdoutScanner.Err())
wg.Done()
}()
// Read stderr.
var stderrData strings.Builder
stderrScanner := bufio.NewScanner(stderr)
stderrScanner.Buffer(make([]byte, 0, 64*1024), maxLine)
go func() {
for stderrScanner.Scan() {
line := stderrScanner.Text()
stderrData.WriteString(line)
stderrData.WriteByte('\n')
}
recordScanErr(stderrScanner.Err())
wg.Done()
}()
// Wait for the stdout and stderr reader goroutines to drain before calling cmd.Wait.
wg.Wait()
// Wait for the command to finish.
err = cmd.Wait()
if err != nil {
// Keep the underlying error wrapped so its exit code stays reachable
// through errors.As, and only mention stdout when there is any.
stderrText := strings.TrimSpace(stderrData.String())
switch {
case stderrText != "" && len(out) > 0:
err = fmt.Errorf("%s (stdout %s): %w", stderrText, strings.Join(out, "\n"), err)
case stderrText != "":
err = fmt.Errorf("%s: %w", stderrText, err)
}
// A command can fail and also produce truncated output; surface both so a
// read error is never hidden behind the command's own failure.
if scanErr != nil {
err = fmt.Errorf("%v; scanner: %w", err, scanErr)
}
return
}
// The process succeeded; report a read/truncation error if one occurred so a
// caller never mistakes truncated output for a complete result.
if scanErr != nil {
err = scanErr
}
return
}

1004
wf_windows.go Normal file

File diff suppressed because it is too large Load diff

76
wf_windows_test.go Normal file
View file

@ -0,0 +1,76 @@
package firewall
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestWFFeatureRules(t *testing.T) {
fw := &WF{rulePrefix: "test"}
// Round-trip the rule shapes the WFP backend supports: ICMP/ICMPv6
// protocols and single/list/range ports.
rules := []*Rule{
{Proto: ICMP, Action: Accept},
{Proto: ICMPv6, Action: Drop},
{Proto: ICMP, ICMPType: Ptr[uint8](8), Action: Accept},
{Proto: ICMPv6, ICMPType: Ptr[uint8](135), Action: Drop},
{Proto: TCP, Port: 22, Action: Accept},
{Proto: TCP, Ports: []PortRange{{Start: 80}, {Start: 443}}, Action: Accept},
{Direction: DirOutput, Proto: UDP, Ports: []PortRange{{Start: 1000, End: 2000}}, Action: Accept},
}
for _, r := range rules {
fr, err := fw.MarshallFWRule("", r)
require.NoError(t, err, "failed to marshal %+v", *r)
parsed := fw.UnmarshallFWRule(*fr)
require.NotNil(t, parsed, "failed to parse marshalled rule for %+v", *r)
require.True(t, parsed.Equal(r, true),
"round-trip mismatch: input %+v, output %+v", *r, parsed)
}
}
// TestWFProtocolAndComment round-trips the added portless IP protocols (mapped
// to raw protocol numbers) and a rule comment (carried in the filter
// description). A port on a non-tcp/udp protocol is rejected.
func TestWFProtocolAndComment(t *testing.T) {
fw := &WF{rulePrefix: "test"}
rules := []*Rule{
{Proto: GRE, Action: Accept},
{Proto: ESP, Action: Accept},
{Proto: AH, Action: Drop},
{Proto: TCP, Port: 22, Action: Accept, Comment: "ssh access"},
}
for _, r := range rules {
fr, err := fw.MarshallFWRule("", r)
require.NoError(t, err, "failed to marshal %+v", *r)
parsed := fw.UnmarshallFWRule(*fr)
require.NotNil(t, parsed, "failed to parse %+v", *r)
require.True(t, parsed.Equal(r, true), "round-trip mismatch: %+v vs %+v", *r, parsed)
require.Equal(t, r.Comment, parsed.Comment, "comment round-trip for %+v", *r)
}
}
// decodeAddress must parse an IPv6 address in netmask notation, not only IPv4,
// and must reject an address/netmask family mismatch.
func TestWFDecodeAddressNetmask(t *testing.T) {
fw := &WF{rulePrefix: "test"}
cases := []struct{ in, want string }{
{"192.168.1.5/255.255.255.0", "192.168.1.0/24"}, // IPv4 netmask
{"192.168.1.5/24", "192.168.1.0/24"}, // IPv4 CIDR
{"2001:db8::/ffff:ffff::", "2001:db8::/32"}, // IPv6 netmask
{"2001:db8::/32", "2001:db8::/32"}, // IPv6 CIDR
}
for _, c := range cases {
got, err := fw.decodeAddress(c.in)
require.NoError(t, err, "decodeAddress(%q)", c.in)
require.Equal(t, c.want, got, "decodeAddress(%q)", c.in)
}
// An address/netmask family mismatch is rejected.
_, err := fw.decodeAddress("192.168.1.0/ffff::")
require.Error(t, err, "a v4 address with a v6 netmask must be rejected")
}